2026-05-21 06:45:35 +00:00
|
|
|
package zitadel
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// IDTokenClaims holds selected OIDC id_token claims used by registration/login flows.
|
|
|
|
|
type IDTokenClaims struct {
|
|
|
|
|
Sub string
|
|
|
|
|
Email string
|
|
|
|
|
EmailVerified bool
|
|
|
|
|
Name string
|
|
|
|
|
Locale string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AuthorizeURL builds the ZITADEL OIDC authorization URL for social registration/login.
|
|
|
|
|
func (c *Client) AuthorizeURL(redirectURI, state, provider string) (string, error) {
|
|
|
|
|
if c == nil {
|
|
|
|
|
return "", ErrNotConfigured
|
|
|
|
|
}
|
|
|
|
|
if c.conf.OAuthClientID == "" {
|
|
|
|
|
return "", fmt.Errorf("zitadel: oauth client id is required for authorize url")
|
|
|
|
|
}
|
|
|
|
|
if redirectURI == "" || state == "" {
|
|
|
|
|
return "", fmt.Errorf("zitadel: redirect_uri and state are required")
|
|
|
|
|
}
|
|
|
|
|
q := url.Values{}
|
|
|
|
|
q.Set("client_id", c.conf.OAuthClientID)
|
|
|
|
|
q.Set("redirect_uri", redirectURI)
|
|
|
|
|
q.Set("response_type", "code")
|
|
|
|
|
q.Set("scope", "openid profile email")
|
|
|
|
|
q.Set("state", state)
|
2026-05-27 09:28:13 +00:00
|
|
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
|
|
|
|
case "google":
|
|
|
|
|
if c.conf.GoogleIdPID != "" {
|
|
|
|
|
q.Set("idp_id", c.conf.GoogleIdPID)
|
|
|
|
|
}
|
|
|
|
|
case "ldap":
|
|
|
|
|
if c.conf.LdapIdPID != "" {
|
|
|
|
|
q.Set("idp_id", c.conf.LdapIdPID)
|
|
|
|
|
}
|
2026-05-21 06:45:35 +00:00
|
|
|
}
|
|
|
|
|
return c.issuer + "/oauth/v2/authorize?" + q.Encode(), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ExchangeAuthorizationCode exchanges an OAuth authorization code for tokens.
|
|
|
|
|
func (c *Client) ExchangeAuthorizationCode(ctx context.Context, code, redirectURI string) (*TokenResult, error) {
|
|
|
|
|
if c == nil {
|
|
|
|
|
return nil, ErrNotConfigured
|
|
|
|
|
}
|
|
|
|
|
if code == "" || redirectURI == "" {
|
|
|
|
|
return nil, fmt.Errorf("zitadel: code and redirect_uri are required")
|
|
|
|
|
}
|
|
|
|
|
if c.conf.OAuthClientID == "" || c.conf.OAuthClientSecret == "" {
|
|
|
|
|
return nil, fmt.Errorf("zitadel: oauth client credentials are required for code exchange")
|
|
|
|
|
}
|
|
|
|
|
form := url.Values{}
|
|
|
|
|
form.Set("grant_type", "authorization_code")
|
|
|
|
|
form.Set("client_id", c.conf.OAuthClientID)
|
|
|
|
|
form.Set("client_secret", c.conf.OAuthClientSecret)
|
|
|
|
|
form.Set("code", code)
|
|
|
|
|
form.Set("redirect_uri", redirectURI)
|
|
|
|
|
return c.postToken(ctx, form)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FetchUserInfo loads OIDC userinfo using an access token.
|
|
|
|
|
func (c *Client) FetchUserInfo(ctx context.Context, accessToken string) (*IDTokenClaims, error) {
|
|
|
|
|
if c == nil {
|
|
|
|
|
return nil, ErrNotConfigured
|
|
|
|
|
}
|
|
|
|
|
if accessToken == "" {
|
|
|
|
|
return nil, fmt.Errorf("zitadel: access token is required")
|
|
|
|
|
}
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.issuer+"/oidc/v1/userinfo", http.NoBody)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("zitadel: userinfo request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
|
|
|
|
|
|
resp, err := c.http.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("zitadel: userinfo request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
raw, err := io.ReadAll(resp.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("zitadel: read userinfo response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
|
|
|
return nil, ErrInvalidCredentials
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
return nil, fmt.Errorf("zitadel: userinfo request: status %d: %s", resp.StatusCode, truncateBody(raw))
|
|
|
|
|
}
|
|
|
|
|
var info struct {
|
|
|
|
|
Sub string `json:"sub"`
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
EmailVerified bool `json:"email_verified"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Locale string `json:"locale"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(raw, &info); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("zitadel: decode userinfo response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if info.Sub == "" {
|
|
|
|
|
return nil, errors.New("zitadel: userinfo missing sub")
|
|
|
|
|
}
|
|
|
|
|
return &IDTokenClaims{
|
|
|
|
|
Sub: info.Sub,
|
|
|
|
|
Email: info.Email,
|
|
|
|
|
EmailVerified: info.EmailVerified,
|
|
|
|
|
Name: info.Name,
|
|
|
|
|
Locale: info.Locale,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ParseIDTokenClaims(idToken string) (*IDTokenClaims, error) {
|
|
|
|
|
if idToken == "" {
|
|
|
|
|
return nil, errors.New("zitadel: id_token is empty")
|
|
|
|
|
}
|
|
|
|
|
parts := strings.Split(idToken, ".")
|
|
|
|
|
if len(parts) < 2 {
|
|
|
|
|
return nil, errors.New("zitadel: malformed id_token")
|
|
|
|
|
}
|
|
|
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("zitadel: decode id_token payload: %w", err)
|
|
|
|
|
}
|
|
|
|
|
var raw struct {
|
|
|
|
|
Sub string `json:"sub"`
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
EmailVerified bool `json:"email_verified"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Locale string `json:"locale"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(payload, &raw); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("zitadel: unmarshal id_token payload: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if raw.Sub == "" {
|
|
|
|
|
return nil, errors.New("zitadel: id_token missing sub")
|
|
|
|
|
}
|
|
|
|
|
return &IDTokenClaims{
|
|
|
|
|
Sub: raw.Sub,
|
|
|
|
|
Email: raw.Email,
|
|
|
|
|
EmailVerified: raw.EmailVerified,
|
|
|
|
|
Name: raw.Name,
|
|
|
|
|
Locale: raw.Locale,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|