template-monorepo/internal/library/zitadel/oauth.go

153 lines
4.6 KiB
Go
Raw Normal View History

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)
if provider == "google" && c.conf.GoogleIdPID != "" {
q.Set("idp_id", c.conf.GoogleIdPID)
}
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
}