diff --git a/.schemastore/config.schema.json b/.schemastore/config.schema.json index 02f17e880f3a..b153ebb16949 100644 --- a/.schemastore/config.schema.json +++ b/.schemastore/config.schema.json @@ -575,6 +575,13 @@ "default": "id_token", "examples": ["id_token", "userinfo"] }, + "userinfo_url": { + "title": "UserInfo URL override", + "description": "Optional override for the UserInfo endpoint URL. When set, the generic provider uses this URL when fetching claims via the UserInfo endpoint (e.g. when id_token is missing). Some providers (e.g. PayPal Sandbox) require a specific userinfo URL or query (e.g. ?schema=openid).", + "type": "string", + "format": "uri", + "examples": ["https://api.sandbox.paypal.com/v1/identity/openidconnect/userinfo?schema=openid"] + }, "pkce": { "title": "Proof Key for Code Exchange", "description": "PKCE controls if the OpenID Connect OAuth2 flow should use PKCE (Proof Key for Code Exchange). IMPORTANT: If you set this to `force`, you must whitelist a different return URL for your OAuth2 client in the provider's configuration. Instead of /self-service/methods/oidc/callback/, you must use /self-service/methods/oidc/callback", diff --git a/contrib/quickstart/kratos/email-password/kratos.yml b/contrib/quickstart/kratos/email-password/kratos.yml index 5597a1adcdb0..bf6d99393650 100644 --- a/contrib/quickstart/kratos/email-password/kratos.yml +++ b/contrib/quickstart/kratos/email-password/kratos.yml @@ -30,6 +30,30 @@ selfservice: enabled: true code: enabled: true + # OIDC: Replace client_id/client_secret with your app credentials from Google Cloud Console and PayPal Developer Dashboard. + # Add this callback URL in each IdP: /self-service/methods/oidc/callback/ + # e.g. http://127.0.0.1:4433/self-service/methods/oidc/callback/google and .../callback/paypal + oidc: + enabled: true + config: + providers: + - id: paypal + provider: generic + label: "PayPal" + client_id: AX2p8_qt8U8OUbKmO2BN2hEI13COGHJakoVX-Kb-vDkKq5fsDv7zib436NZ1vfPheRm6Jh3SsNGDRvjN + client_secret: EMBIZ3Cdbrz2PIn9lJbVNaEj_9KqLs5P3hX5urEKUMSpDyT471sGi9TA-QeXJ31aXZ6s-HzRDMVnQ_2c + scope: + - openid + - email + requested_claims: + id_token: + email: + essential: true + email_verified: + essential: true + issuer_url: https://b5755142-d32e-4e05-898f-cf0bf7632d20.mock.pstmn.io + userinfo_url: https://api.sandbox.paypal.com/v1/identity/openidconnect/userinfo?schema=openid + mapper_url: "base64://bG9jYWwgY2xhaW1zID0gewogIGVtYWlsX3ZlcmlmaWVkOiBmYWxzZSwKfSArIHN0ZC5leHRWYXIoJ2NsYWltcycpOwoKewogIGlkZW50aXR5OiB7CiAgICB0cmFpdHM6IHsKICAgICAgW2lmICdlbWFpbCcgaW4gY2xhaW1zICYmIGNsYWltcy5lbWFpbF92ZXJpZmllZCB0aGVuICdlbWFpbCcgZWxzZSBudWxsXTogY2xhaW1zLmVtYWlsLAogICAgfSwKCiAgICB2ZXJpZmllZF9hZGRyZXNzZXM6IHN0ZC5wcnVuZShbCiAgICAgIGlmICdlbWFpbCcgaW4gY2xhaW1zICYmIGNsYWltcy5lbWFpbF92ZXJpZmllZCB0aGVuIHsgdmlhOiAnZW1haWwnLCB2YWx1ZTogY2xhaW1zLmVtYWlsIH0sCiAgICBdKSwKICB9LAp9" flows: error: @@ -68,6 +92,9 @@ selfservice: hooks: - hook: session - hook: show_verification_ui + oidc: + hooks: + - hook: session log: level: debug diff --git a/embedx/config.schema.json b/embedx/config.schema.json index 3ecb047a28d6..96b2baaddfe3 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -575,6 +575,13 @@ "default": "id_token", "examples": ["id_token", "userinfo"] }, + "userinfo_url": { + "title": "UserInfo URL override", + "description": "Optional override for the UserInfo endpoint URL. When set, the generic provider uses this URL when fetching claims via the UserInfo endpoint (e.g. when id_token is missing). Some providers (e.g. PayPal Sandbox) require a specific userinfo URL or query (e.g. ?schema=openid).", + "type": "string", + "format": "uri", + "examples": ["https://api.sandbox.paypal.com/v1/identity/openidconnect/userinfo?schema=openid"] + }, "pkce": { "title": "Proof Key for Code Exchange", "description": "PKCE controls if the OpenID Connect OAuth2 flow should use PKCE (Proof Key for Code Exchange). IMPORTANT: If you set this to `force`, you must whitelist a different return URL for your OAuth2 client in the provider's configuration. Instead of /self-service/methods/oidc/callback/, you must use /self-service/methods/oidc/callback", diff --git a/kratos-image b/kratos-image new file mode 100755 index 000000000000..d1ed3fa124cd Binary files /dev/null and b/kratos-image differ diff --git a/quickstart.yml b/quickstart.yml index 834b885648d7..7d74f9a9c4fd 100644 --- a/quickstart.yml +++ b/quickstart.yml @@ -1,7 +1,8 @@ version: '3.7' services: kratos-migrate: - image: oryd/kratos:v25.4.0 + image: kratos-image:latest + user: "0:0" environment: - DSN=sqlite:///var/lib/sqlite/db.sqlite?_fk=true&mode=rwc volumes: @@ -18,7 +19,10 @@ services: - intranet kratos-selfservice-ui-node: image: oryd/kratos-selfservice-ui-node:v25.4.0 + ports: + - "4455:4455" environment: + - PORT=4455 - KRATOS_PUBLIC_URL=http://kratos:4433/ - KRATOS_BROWSER_URL=http://127.0.0.1:4433/ - COOKIE_SECRET=changeme @@ -30,7 +34,8 @@ services: kratos: depends_on: - kratos-migrate - image: oryd/kratos:v25.4.0 + image: kratos-image:latest + user: "0:0" ports: - '4433:4433' # public - '4434:4434' # admin diff --git a/selfservice/strategy/oidc/provider_config.go b/selfservice/strategy/oidc/provider_config.go index 316635a2a5d7..9db264f9cfe9 100644 --- a/selfservice/strategy/oidc/provider_config.go +++ b/selfservice/strategy/oidc/provider_config.go @@ -121,6 +121,12 @@ type Configuration struct { // token). It defaults to `id_token`. ClaimsSource string `json:"claims_source"` + // UserinfoURL is an optional override for the UserInfo endpoint URL. When set, the generic + // provider uses this URL when fetching claims via the UserInfo endpoint (e.g. when id_token + // is missing and claims_source is id_token, for providers like PayPal that support UserInfo-only). + // Some providers (e.g. PayPal Sandbox) require a specific userinfo URL or query (e.g. ?schema=openid). + UserinfoURL string `json:"userinfo_url"` + // PKCE controls if the OpenID Connect OAuth2 flow should use PKCE (Proof Key for Code Exchange). // Possible values are: `auto` (default), `never`, `force`. // - `auto`: PKCE is used if the provider supports it. Requires setting `issuer_url`. diff --git a/selfservice/strategy/oidc/provider_generic_oidc.go b/selfservice/strategy/oidc/provider_generic_oidc.go index f6a465a394f5..367a3033114d 100644 --- a/selfservice/strategy/oidc/provider_generic_oidc.go +++ b/selfservice/strategy/oidc/provider_generic_oidc.go @@ -5,6 +5,9 @@ package oidc import ( "context" + "encoding/json" + "io" + "net/http" "net/url" "slices" "time" @@ -14,6 +17,7 @@ import ( "golang.org/x/oauth2" "github.com/ory/herodot" + "github.com/ory/kratos/x" "github.com/ory/x/reqlog" ) @@ -117,18 +121,144 @@ func (g *ProviderGenericOIDC) verifyAndDecodeClaimsWithProvider(ctx context.Cont return &claims, nil } +// providerIDPayPal is the provider id for which we allow skipping id_token when missing (UserInfo only). +// Only this provider may ignore id_token; all others still require id_token. +const providerIDPayPal = "paypal" + +func (g *ProviderGenericOIDC) isPayPalProvider() bool { + return g.config.ID == providerIDPayPal +} + func (g *ProviderGenericOIDC) Claims(ctx context.Context, exchange *oauth2.Token, _ url.Values) (*Claims, error) { switch g.config.ClaimsSource { case ClaimsSourceIDToken, "": - return g.claimsFromIDToken(ctx, exchange) + claims, err := g.claimsFromIDToken(ctx, exchange) + if err != nil && errors.Is(err, ErrIDTokenMissing) && g.isPayPalProvider() { + return g.claimsFromUserInfoOnly(ctx, exchange) + } + return claims, err case ClaimsSourceUserInfo: - return g.claimsFromUserInfo(ctx, exchange) + claims, err := g.claimsFromUserInfo(ctx, exchange) + if err != nil && errors.Is(err, ErrIDTokenMissing) && g.isPayPalProvider() { + return g.claimsFromUserInfoOnly(ctx, exchange) + } + return claims, err } return nil, errors.WithStack(herodot.ErrMisconfiguration. WithReasonf("Unknown claims source: %q", g.config.ClaimsSource)) } +// claimsFromUserInfoOnly returns claims from the UserInfo endpoint only, without id_token verification. +// Used for PayPal when the token endpoint does not return an id_token. +// If config.UserinfoURL is set, that URL is used (e.g. for PayPal Sandbox which requires ?schema=openid). +func (g *ProviderGenericOIDC) claimsFromUserInfoOnly(ctx context.Context, exchange *oauth2.Token) (*Claims, error) { + if g.config.UserinfoURL != "" { + return g.claimsFromCustomUserinfoURL(ctx, exchange) + } + + p, err := g.provider(ctx) + if err != nil { + return nil, err + } + + t0 := time.Now() + userInfo, err := p.UserInfo(g.withHTTPClientContext(ctx), oauth2.StaticTokenSource(exchange)) + reqlog.AccumulateExternalLatency(ctx, time.Since(t0)) + if err != nil { + return nil, err + } + + var claims Claims + if err = userInfo.Claims(&claims); err != nil { + return nil, err + } + var rawClaims map[string]interface{} + if err := userInfo.Claims(&rawClaims); err != nil { + return nil, errors.WithStack(herodot.ErrBadRequest.WithReasonf("%s", err)) + } + claims.RawClaims = rawClaims + + if claims.Issuer == "" { + claims.Issuer = g.config.IssuerURL + } + + return &claims, nil +} + +// claimsFromCustomUserinfoURL fetches claims from the configured UserinfoURL with the access token. +func (g *ProviderGenericOIDC) claimsFromCustomUserinfoURL(ctx context.Context, exchange *oauth2.Token) (*Claims, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.config.UserinfoURL, nil) + if err != nil { + return nil, errors.WithStack(herodot.ErrInternalServerError.WithWrap(err).WithReasonf("failed to build userinfo request: %s", err)) + } + req.Header.Set("Authorization", "Bearer "+exchange.AccessToken) + + t0 := time.Now() + resp, err := g.reg.HTTPClient(ctx).HTTPClient.Do(req) + reqlog.AccumulateExternalLatency(ctx, time.Since(t0)) + if err != nil { + return nil, errors.WithStack(herodot.ErrUpstreamError.WithWrap(err).WithReasonf("userinfo request failed: %s", err)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + g.reg.Logger(). + WithField("response_code", resp.StatusCode). + WithField("response_body", string(body)). + WithField("userinfo_url", g.config.UserinfoURL). + Error("The upstream OIDC provider userinfo endpoint returned a non-200 status code.") + return nil, errors.WithStack(herodot.ErrUpstreamError. + WithReasonf("%d %s", resp.StatusCode, resp.Status). + WithDetail("response_body", string(body))) + } + + rawBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.WithStack(herodot.ErrUpstreamError.WithWrap(err).WithReasonf("failed to read userinfo response: %s", err)) + } + + var rawClaims map[string]interface{} + if err := json.Unmarshal(rawBody, &rawClaims); err != nil { + return nil, errors.WithStack(herodot.ErrUpstreamError.WithWrap(err).WithReasonf("failed to decode userinfo JSON: %s", err)) + } + + claims := &Claims{RawClaims: rawClaims} + if v, ok := rawClaims["sub"].(string); ok { + claims.Subject = v + } + if v, ok := rawClaims["iss"].(string); ok { + claims.Issuer = v + } + if v, ok := rawClaims["email"].(string); ok { + claims.Email = v + } + if v, ok := rawClaims["name"].(string); ok { + claims.Name = v + } + if v, ok := rawClaims["given_name"].(string); ok { + claims.GivenName = v + } + if v, ok := rawClaims["family_name"].(string); ok { + claims.FamilyName = v + } + if v, ok := rawClaims["picture"].(string); ok { + claims.Picture = v + } + if v, ok := rawClaims["email_verified"]; ok { + if b, ok := v.(bool); ok { + claims.EmailVerified = x.ConvertibleBoolean(b) + } + } + + if claims.Issuer == "" { + claims.Issuer = g.config.IssuerURL + } + + return claims, nil +} + func (g *ProviderGenericOIDC) claimsFromUserInfo(ctx context.Context, exchange *oauth2.Token) (*Claims, error) { p, err := g.provider(ctx) if err != nil { diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index 84f1ec5e3268..d96c02a30f37 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -56,6 +56,9 @@ import ( "github.com/ory/x/urlx" ) +// oidcExchangeIDContextKey is the context key for the OIDC token exchange correlation ID (for logging). +type oidcExchangeIDContextKey struct{} + const ( RouteBase = "/self-service/methods/oidc" @@ -488,6 +491,10 @@ func (s *Strategy) HandleCallback(w http.ResponseWriter, r *http.Request) { var et *identity.CredentialsOIDCEncryptedTokens switch p := provider.(type) { case OAuth2Provider: + // Set exchange ID before token exchange so request/response logging can use it. + oidcExchangeID := uuid.Must(uuid.NewV4()).String() + ctx = context.WithValue(ctx, oidcExchangeIDContextKey{}, oidcExchangeID) + t0 := time.Now() token, err := s.exchangeCode(ctx, p, code, PKCEVerifier(state)) reqlog.AccumulateExternalLatency(ctx, time.Since(t0)) @@ -496,6 +503,17 @@ func (s *Strategy) HandleCallback(w http.ResponseWriter, r *http.Request) { return } + // Log token response summary (request/response bodies logged by tokenExchangeLoggingTransport). + hasIDToken := token != nil && token.Extra("id_token") != nil + s.d.Logger(). + WithField("oidc_exchange_id", oidcExchangeID). + WithField("provider_id", state.ProviderId). + WithField("has_access_token", token != nil && token.AccessToken != ""). + WithField("has_refresh_token", token != nil && token.RefreshToken != ""). + WithField("has_id_token", hasIDToken). + WithField("token_type", cmp.Or(token.TokenType, "")). + Infof("OIDC token response (oidc_exchange_id=%s): correlate with OIDC token endpoint request/response logs", oidcExchangeID) + et, err = s.encryptOAuth2Tokens(ctx, token) if err != nil { s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, err)) @@ -598,8 +616,20 @@ func (s *Strategy) exchangeCode(ctx context.Context, provider OAuth2Provider, co } } - client := s.d.HTTPClient(ctx) - ctx = context.WithValue(ctx, oauth2.HTTPClient, client.HTTPClient) + baseClient := s.d.HTTPClient(ctx).HTTPClient + baseTransport := baseClient.Transport + if baseTransport == nil { + baseTransport = http.DefaultTransport + } + exchangeID, _ := ctx.Value(oidcExchangeIDContextKey{}).(string) + loggingTransport := &tokenExchangeLoggingTransport{ + base: baseTransport, + log: s.d.Logger(), + exchangeID: exchangeID, + providerID: provider.Config().ID, + } + client := &http.Client{Transport: loggingTransport} + ctx = context.WithValue(ctx, oauth2.HTTPClient, client) return te.Exchange(ctx, code, opts...) } diff --git a/selfservice/strategy/oidc/token_exchange_logger.go b/selfservice/strategy/oidc/token_exchange_logger.go new file mode 100644 index 000000000000..3af28eae9558 --- /dev/null +++ b/selfservice/strategy/oidc/token_exchange_logger.go @@ -0,0 +1,95 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "github.com/ory/x/logrusx" +) + +// tokenExchangeLoggingTransport logs OIDC token endpoint request and response for debugging (e.g. PayPal). +// Secrets are redacted: client_secret, code, code_verifier in request; access_token, refresh_token, id_token in response. +type tokenExchangeLoggingTransport struct { + base http.RoundTripper + log *logrusx.Logger + exchangeID string + providerID string +} + +func (t *tokenExchangeLoggingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Only log OAuth2 token endpoint calls (e.g. PayPal .../v1/oauth2/token) + if req.URL == nil || !strings.Contains(req.URL.Path, "oauth2/token") { + return t.base.RoundTrip(req) + } + + // Log request (redact secrets) + var reqBody string + if req.Body != nil { + body, _ := io.ReadAll(req.Body) + _ = req.Body.Close() + req.Body = io.NopCloser(bytes.NewReader(body)) + reqBody = redactTokenRequestForm(string(body)) + } + t.log.WithField("oidc_exchange_id", t.exchangeID). + WithField("provider_id", t.providerID). + WithField("request_url", req.URL.String()). + WithField("request_method", req.Method). + WithField("request_body_redacted", reqBody). + Info("OIDC token endpoint request (secrets redacted)") + + resp, err := t.base.RoundTrip(req) + if err != nil { + return nil, err + } + + // Log what provider (e.g. PayPal) returned, straight away (tokens redacted). + if resp.Body != nil { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(body)) + redacted := redactTokenResponseJSON(body) + t.log.WithField("oidc_exchange_id", t.exchangeID). + WithField("provider_id", t.providerID). + WithField("response_status", resp.StatusCode). + Infof("PayPal/token endpoint response: %s", redacted) + } + + return resp, nil +} + +// redactTokenRequestForm redacts client_secret, code, code_verifier in application/x-www-form-urlencoded body. +func redactTokenRequestForm(body string) string { + vals, err := url.ParseQuery(body) + if err != nil { + return "[parse error]" + } + redact := []string{"client_secret", "code", "code_verifier"} + for _, k := range redact { + if vals.Has(k) { + vals.Set(k, "[REDACTED]") + } + } + return vals.Encode() +} + +// redactTokenResponseJSON redacts access_token, refresh_token, id_token in JSON; shows keys and presence. +func redactTokenResponseJSON(body []byte) string { + var m map[string]interface{} + if err := json.Unmarshal(body, &m); err != nil { + return string(body) // fallback: return as-is if not JSON + } + for _, k := range []string{"access_token", "refresh_token", "id_token"} { + if _, ok := m[k]; ok { + m[k] = "[REDACTED]" + } + } + out, _ := json.Marshal(m) + return string(out) +}