Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .schemastore/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base-url>/self-service/methods/oidc/callback/<provider>, you must use <base-url>/self-service/methods/oidc/callback",
Expand Down
27 changes: 27 additions & 0 deletions contrib/quickstart/kratos/email-password/kratos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: <serve.public.base_url>/self-service/methods/oidc/callback/<provider_id>
# 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:
Expand Down Expand Up @@ -68,6 +92,9 @@ selfservice:
hooks:
- hook: session
- hook: show_verification_ui
oidc:
hooks:
- hook: session

log:
level: debug
Expand Down
7 changes: 7 additions & 0 deletions embedx/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base-url>/self-service/methods/oidc/callback/<provider>, you must use <base-url>/self-service/methods/oidc/callback",
Expand Down
Binary file added kratos-image
Binary file not shown.
9 changes: 7 additions & 2 deletions quickstart.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions selfservice/strategy/oidc/provider_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
134 changes: 132 additions & 2 deletions selfservice/strategy/oidc/provider_generic_oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"slices"
"time"
Expand All @@ -14,6 +17,7 @@
"golang.org/x/oauth2"

"github.com/ory/herodot"
"github.com/ory/kratos/x"
"github.com/ory/x/reqlog"
)

Expand Down Expand Up @@ -117,18 +121,144 @@
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()

Check failure on line 203 in selfservice/strategy/oidc/provider_generic_oidc.go

View workflow job for this annotation

GitHub Actions / Run tests and lints

Error return value of `resp.Body.Close` is not checked (errcheck)

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 {
Expand Down
34 changes: 32 additions & 2 deletions selfservice/strategy/oidc/strategy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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))
Expand All @@ -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))
Expand Down Expand Up @@ -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...)
}

Expand Down
Loading
Loading