From 02de02065bec37ab116bf2f5e6abffb44e13074e Mon Sep 17 00:00:00 2001 From: gn-kim-k <171027203+gn-kim-k@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:06:40 +0100 Subject: [PATCH 1/2] fix: preserve raw claims for native Microsoft sign-in --- selfservice/strategy/oidc/provider_apple.go | 2 +- .../strategy/oidc/provider_apple_test.go | 1 + selfservice/strategy/oidc/provider_google.go | 2 +- .../strategy/oidc/provider_google_test.go | 1 + .../strategy/oidc/provider_microsoft.go | 3 +- .../strategy/oidc/provider_microsoft_test.go | 85 +++++++++++++++++++ .../strategy/oidc/strategy_helper_test.go | 7 +- selfservice/strategy/oidc/token_verifier.go | 11 ++- 8 files changed, 105 insertions(+), 7 deletions(-) diff --git a/selfservice/strategy/oidc/provider_apple.go b/selfservice/strategy/oidc/provider_apple.go index 706a7150c5e4..99c999f38192 100644 --- a/selfservice/strategy/oidc/provider_apple.go +++ b/selfservice/strategy/oidc/provider_apple.go @@ -160,7 +160,7 @@ func (a *ProviderApple) Verify(ctx context.Context, rawIDToken string) (*Claims, keySet := oidc.NewRemoteKeySet(ctx, a.JWKSUrl) ctx = oidc.ClientContext(ctx, a.reg.HTTPClient(ctx).HTTPClient) - return verifyToken(ctx, keySet, a.config, rawIDToken, issuerUrlApple) + return verifyToken(ctx, keySet, a.config, rawIDToken, issuerUrlApple, false) } var _ NonceValidationSkipper = new(ProviderApple) diff --git a/selfservice/strategy/oidc/provider_apple_test.go b/selfservice/strategy/oidc/provider_apple_test.go index 422ae643708a..8e10894e03b5 100644 --- a/selfservice/strategy/oidc/provider_apple_test.go +++ b/selfservice/strategy/oidc/provider_apple_test.go @@ -81,6 +81,7 @@ func TestAppleVerify(t *testing.T) { assert.Equal(t, "acme@ory.sh", c.Email) assert.Equal(t, "acme@ory.sh", c.Subject) assert.Equal(t, "https://appleid.apple.com", c.Issuer) + assert.Nil(t, c.RawClaims) }) t.Run("case=fails due to client_id mismatch", func(t *testing.T) { diff --git a/selfservice/strategy/oidc/provider_google.go b/selfservice/strategy/oidc/provider_google.go index e27832692faa..10083ae8cad4 100644 --- a/selfservice/strategy/oidc/provider_google.go +++ b/selfservice/strategy/oidc/provider_google.go @@ -76,7 +76,7 @@ const issuerUrlGoogle = "https://accounts.google.com" func (p *ProviderGoogle) Verify(ctx context.Context, rawIDToken string) (*Claims, error) { keySet := gooidc.NewRemoteKeySet(ctx, p.JWKSUrl) ctx = gooidc.ClientContext(ctx, p.reg.HTTPClient(ctx).HTTPClient) - return verifyToken(ctx, keySet, p.config, rawIDToken, issuerUrlGoogle) + return verifyToken(ctx, keySet, p.config, rawIDToken, issuerUrlGoogle, false) } var _ NonceValidationSkipper = new(ProviderGoogle) diff --git a/selfservice/strategy/oidc/provider_google_test.go b/selfservice/strategy/oidc/provider_google_test.go index c1a1f65b9348..7f9b91e7bee9 100644 --- a/selfservice/strategy/oidc/provider_google_test.go +++ b/selfservice/strategy/oidc/provider_google_test.go @@ -101,6 +101,7 @@ func TestGoogleVerify(t *testing.T) { assert.Equal(t, "acme@ory.sh", c.Email) assert.Equal(t, "acme@ory.sh", c.Subject) assert.Equal(t, "https://accounts.google.com", c.Issuer) + assert.Nil(t, c.RawClaims) }) t.Run("case=fails due to client_id mismatch", func(t *testing.T) { diff --git a/selfservice/strategy/oidc/provider_microsoft.go b/selfservice/strategy/oidc/provider_microsoft.go index 3ac4ccb4a8b6..fa7954ac87b6 100644 --- a/selfservice/strategy/oidc/provider_microsoft.go +++ b/selfservice/strategy/oidc/provider_microsoft.go @@ -162,7 +162,8 @@ func (p *ProviderMicrosoft) Verify(ctx context.Context, rawIDToken string) (*Cla ctx = gooidc.ClientContext(ctx, p.reg.HTTPClient(ctx).HTTPClient) keySet := gooidc.NewRemoteKeySet(ctx, p.JWKSUrl) - claims, err := verifyToken(ctx, keySet, p.config, rawIDToken, "https://login.microsoftonline.com/"+tenantID.String()+"/v2.0") + // Microsoft mappers need provider-specific claims such as xms_edov. + claims, err := verifyToken(ctx, keySet, p.config, rawIDToken, "https://login.microsoftonline.com/"+tenantID.String()+"/v2.0", true) if err != nil { return nil, err } diff --git a/selfservice/strategy/oidc/provider_microsoft_test.go b/selfservice/strategy/oidc/provider_microsoft_test.go index 207ffc27b4b0..5e80319b7b62 100644 --- a/selfservice/strategy/oidc/provider_microsoft_test.go +++ b/selfservice/strategy/oidc/provider_microsoft_test.go @@ -5,6 +5,7 @@ package oidc_test import ( "context" + "encoding/json" "net/http" "net/http/httptest" "net/url" @@ -76,6 +77,58 @@ func TestMicrosoftVerify(t *testing.T) { assert.Equal(t, issuerFor(tenantA), c.Issuer) }) + t.Run("case=preserves verified raw claims for the mapper", func(t *testing.T) { + for _, tc := range []struct { + name string + value interface{} + }{ + {name: "boolean true", value: true}, + {name: "boolean false", value: false}, + {name: "string one", value: "1"}, + {name: "string zero", value: "0"}, + {name: "string true", value: "true"}, + {name: "string false", value: "false"}, + {name: "number one", value: float64(1)}, + {name: "number zero", value: float64(0)}, + {name: "absent"}, + } { + t.Run(tc.name, func(t *testing.T) { + cl := makeClaimsWithRawClaims(issuerFor(tenantA), tenantA, tc.value) + c, err := newProvider(t, &oidc.Configuration{ + Tenant: tenantA, SubjectSource: "oid", + }).Verify(context.Background(), signIdToken(t, cl)) + require.NoError(t, err) + require.NotEmpty(t, c.RawClaims) + + // The mapper receives the JSON-encoded Claims object, not the JWT. + encoded, err := json.Marshal(c) + require.NoError(t, err) + var mapperClaims map[string]interface{} + require.NoError(t, json.Unmarshal(encoded, &mapperClaims)) + raw := mapperClaims["raw_claims"].(map[string]interface{}) + if tc.value == nil { + assert.NotContains(t, raw, "xms_edov") + } else { + assert.Equal(t, tc.value, raw["xms_edov"]) + } + assert.Equal(t, cl.Object, c.Subject) + assert.Equal(t, cl.Subject, raw["sub"]) + assert.Equal(t, cl.Object, raw["oid"]) + assert.Equal(t, cl.Email, c.Email) + assert.Equal(t, cl.TenantID, raw["tid"]) + }) + } + }) + + t.Run("case=rejects expired tokens even with xms_edov", func(t *testing.T) { + cl := makeClaimsWithRawClaims(issuerFor(tenantA), tenantA, "1") + cl.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Hour)) + c, err := newProvider(t, &oidc.Configuration{Tenant: tenantA}).Verify(context.Background(), signIdToken(t, cl)) + require.Error(t, err) + assert.Contains(t, err.Error(), "token is expired") + assert.Nil(t, c) + }) + t.Run("case=fails due to client_id mismatch", func(t *testing.T) { _, err := newProvider(t, &oidc.Configuration{Tenant: tenantA}).Verify(context.Background(), signIdToken(t, makeClaims("com.different-example.app", tenantA))) require.Error(t, err) @@ -221,4 +274,36 @@ func TestMicrosoftClaims(t *testing.T) { assert.Equal(t, http.StatusBadRequest, herr.StatusCode()) assert.Contains(t, herr.Reason(), "`oid` claim") }) + + t.Run("case=browser and native expose the same verified claims", func(t *testing.T) { + for _, value := range []interface{}{true, false, "1", "0", float64(1), nil} { + cl := makeClaimsWithRawClaims(issuer, tenant, value) + cl.Audience = jwt.ClaimStrings{"foo"} + raw := signIdToken(t, cl) + exchange := (&oauth2.Token{AccessToken: "foo"}).WithExtra(map[string]interface{}{"id_token": raw}) + browserClaims, err := provider.Claims(context.Background(), exchange, url.Values{}) + require.NoError(t, err) + + native := provider.(*oidc.ProviderMicrosoft) + native.JWKSUrl = issuer + "/keys" + nativeClaims, err := native.Verify(context.Background(), raw) + require.NoError(t, err) + assert.Equal(t, browserClaims, nativeClaims) + } + }) +} + +func makeClaimsWithRawClaims(issuer, tenant string, xmsEdov interface{}) *claims { + return &claims{ + RegisteredClaims: &jwt.RegisteredClaims{ + Issuer: issuer, + Subject: "pairwise-sub", + Audience: jwt.ClaimStrings{"com.example.app"}, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + Email: "acme@ory.sh", + Object: "00000000-0000-0000-0000-00000000c0de", + TenantID: tenant, + XmsEdov: xmsEdov, + } } diff --git a/selfservice/strategy/oidc/strategy_helper_test.go b/selfservice/strategy/oidc/strategy_helper_test.go index 384bc2076d79..910c25f31ca0 100644 --- a/selfservice/strategy/oidc/strategy_helper_test.go +++ b/selfservice/strategy/oidc/strategy_helper_test.go @@ -390,9 +390,10 @@ var publicJWKS2 []byte type claims struct { *jwt.RegisteredClaims - Email string `json:"email"` - Object string `json:"oid,omitempty"` - TenantID string `json:"tid,omitempty"` + Email string `json:"email"` + Object string `json:"oid,omitempty"` + TenantID string `json:"tid,omitempty"` + XmsEdov interface{} `json:"xms_edov,omitempty"` } func createIdToken(t *testing.T, cl jwt.RegisteredClaims) string { diff --git a/selfservice/strategy/oidc/token_verifier.go b/selfservice/strategy/oidc/token_verifier.go index ce9cb8b3d3ee..0d134771bd7c 100644 --- a/selfservice/strategy/oidc/token_verifier.go +++ b/selfservice/strategy/oidc/token_verifier.go @@ -11,7 +11,7 @@ import ( "github.com/coreos/go-oidc/v3/oidc" ) -func verifyToken(ctx context.Context, keySet oidc.KeySet, config *Configuration, rawIDToken, issuerURL string) (*Claims, error) { +func verifyToken(ctx context.Context, keySet oidc.KeySet, config *Configuration, rawIDToken, issuerURL string, preserveRawClaims bool) (*Claims, error) { tokenAudiences := append([]string{config.ClientID}, config.AdditionalIDTokenAudiences...) var token *oidc.IDToken err := fmt.Errorf("no audience matched the token's audience") @@ -38,5 +38,14 @@ func verifyToken(ctx context.Context, keySet oidc.KeySet, config *Configuration, if err := token.Claims(claims); err != nil { return nil, err } + if preserveRawClaims { + // Decode the verified token, matching the browser flow's mapper input. + // Providers opt in so existing callers keep their claims shape. + var rawClaims map[string]interface{} + if err := token.Claims(&rawClaims); err != nil { + return nil, err + } + claims.RawClaims = rawClaims + } return claims, nil } From 75113ece4abf9dc16bb831a98d136ae29c4c4c94 Mon Sep 17 00:00:00 2001 From: Dmitry Makarenko Date: Tue, 20 Oct 2026 17:32:13 +0100 Subject: [PATCH 2/2] refactor: always preserve raw claims in verifyToken, as upstream does Replace the preserveRawClaims flag with the hunk from upstream ory/kratos 373a2e655 (feat: more extension points (#4272)), so that token_verifier.go matches upstream and a later rebase applies cleanly. Native Apple and Google verification now also return raw claims. Claude-Session: https://claude.ai/code/session_0172mHvWYKYpy7qvrugERAjV --- selfservice/strategy/oidc/provider_apple.go | 2 +- .../strategy/oidc/provider_apple_test.go | 2 +- selfservice/strategy/oidc/provider_google.go | 2 +- .../strategy/oidc/provider_google_test.go | 2 +- .../strategy/oidc/provider_microsoft.go | 3 +-- selfservice/strategy/oidc/token_verifier.go | 20 ++++++++++--------- 6 files changed, 16 insertions(+), 15 deletions(-) diff --git a/selfservice/strategy/oidc/provider_apple.go b/selfservice/strategy/oidc/provider_apple.go index 99c999f38192..706a7150c5e4 100644 --- a/selfservice/strategy/oidc/provider_apple.go +++ b/selfservice/strategy/oidc/provider_apple.go @@ -160,7 +160,7 @@ func (a *ProviderApple) Verify(ctx context.Context, rawIDToken string) (*Claims, keySet := oidc.NewRemoteKeySet(ctx, a.JWKSUrl) ctx = oidc.ClientContext(ctx, a.reg.HTTPClient(ctx).HTTPClient) - return verifyToken(ctx, keySet, a.config, rawIDToken, issuerUrlApple, false) + return verifyToken(ctx, keySet, a.config, rawIDToken, issuerUrlApple) } var _ NonceValidationSkipper = new(ProviderApple) diff --git a/selfservice/strategy/oidc/provider_apple_test.go b/selfservice/strategy/oidc/provider_apple_test.go index 8e10894e03b5..a466f417cc90 100644 --- a/selfservice/strategy/oidc/provider_apple_test.go +++ b/selfservice/strategy/oidc/provider_apple_test.go @@ -81,7 +81,7 @@ func TestAppleVerify(t *testing.T) { assert.Equal(t, "acme@ory.sh", c.Email) assert.Equal(t, "acme@ory.sh", c.Subject) assert.Equal(t, "https://appleid.apple.com", c.Issuer) - assert.Nil(t, c.RawClaims) + assert.Equal(t, "acme@ory.sh", c.RawClaims["sub"]) }) t.Run("case=fails due to client_id mismatch", func(t *testing.T) { diff --git a/selfservice/strategy/oidc/provider_google.go b/selfservice/strategy/oidc/provider_google.go index 10083ae8cad4..e27832692faa 100644 --- a/selfservice/strategy/oidc/provider_google.go +++ b/selfservice/strategy/oidc/provider_google.go @@ -76,7 +76,7 @@ const issuerUrlGoogle = "https://accounts.google.com" func (p *ProviderGoogle) Verify(ctx context.Context, rawIDToken string) (*Claims, error) { keySet := gooidc.NewRemoteKeySet(ctx, p.JWKSUrl) ctx = gooidc.ClientContext(ctx, p.reg.HTTPClient(ctx).HTTPClient) - return verifyToken(ctx, keySet, p.config, rawIDToken, issuerUrlGoogle, false) + return verifyToken(ctx, keySet, p.config, rawIDToken, issuerUrlGoogle) } var _ NonceValidationSkipper = new(ProviderGoogle) diff --git a/selfservice/strategy/oidc/provider_google_test.go b/selfservice/strategy/oidc/provider_google_test.go index 7f9b91e7bee9..76abd6f9023d 100644 --- a/selfservice/strategy/oidc/provider_google_test.go +++ b/selfservice/strategy/oidc/provider_google_test.go @@ -101,7 +101,7 @@ func TestGoogleVerify(t *testing.T) { assert.Equal(t, "acme@ory.sh", c.Email) assert.Equal(t, "acme@ory.sh", c.Subject) assert.Equal(t, "https://accounts.google.com", c.Issuer) - assert.Nil(t, c.RawClaims) + assert.Equal(t, "acme@ory.sh", c.RawClaims["sub"]) }) t.Run("case=fails due to client_id mismatch", func(t *testing.T) { diff --git a/selfservice/strategy/oidc/provider_microsoft.go b/selfservice/strategy/oidc/provider_microsoft.go index fa7954ac87b6..3ac4ccb4a8b6 100644 --- a/selfservice/strategy/oidc/provider_microsoft.go +++ b/selfservice/strategy/oidc/provider_microsoft.go @@ -162,8 +162,7 @@ func (p *ProviderMicrosoft) Verify(ctx context.Context, rawIDToken string) (*Cla ctx = gooidc.ClientContext(ctx, p.reg.HTTPClient(ctx).HTTPClient) keySet := gooidc.NewRemoteKeySet(ctx, p.JWKSUrl) - // Microsoft mappers need provider-specific claims such as xms_edov. - claims, err := verifyToken(ctx, keySet, p.config, rawIDToken, "https://login.microsoftonline.com/"+tenantID.String()+"/v2.0", true) + claims, err := verifyToken(ctx, keySet, p.config, rawIDToken, "https://login.microsoftonline.com/"+tenantID.String()+"/v2.0") if err != nil { return nil, err } diff --git a/selfservice/strategy/oidc/token_verifier.go b/selfservice/strategy/oidc/token_verifier.go index 0d134771bd7c..42b16767a041 100644 --- a/selfservice/strategy/oidc/token_verifier.go +++ b/selfservice/strategy/oidc/token_verifier.go @@ -11,7 +11,7 @@ import ( "github.com/coreos/go-oidc/v3/oidc" ) -func verifyToken(ctx context.Context, keySet oidc.KeySet, config *Configuration, rawIDToken, issuerURL string, preserveRawClaims bool) (*Claims, error) { +func verifyToken(ctx context.Context, keySet oidc.KeySet, config *Configuration, rawIDToken, issuerURL string) (*Claims, error) { tokenAudiences := append([]string{config.ClientID}, config.AdditionalIDTokenAudiences...) var token *oidc.IDToken err := fmt.Errorf("no audience matched the token's audience") @@ -35,17 +35,19 @@ func verifyToken(ctx context.Context, keySet oidc.KeySet, config *Configuration, return nil, fmt.Errorf("token audience didn't match allowed audiences: %+v %w", tokenAudiences, err) } claims := &Claims{} + var rawClaims map[string]any + + if token == nil { + return nil, fmt.Errorf("token is nil") + } + if err := token.Claims(claims); err != nil { return nil, err } - if preserveRawClaims { - // Decode the verified token, matching the browser flow's mapper input. - // Providers opt in so existing callers keep their claims shape. - var rawClaims map[string]interface{} - if err := token.Claims(&rawClaims); err != nil { - return nil, err - } - claims.RawClaims = rawClaims + if err = token.Claims(&rawClaims); err != nil { + return nil, err } + claims.RawClaims = rawClaims + return claims, nil }