From c55ff69836611c2f92c264d185d31b94ebde0641 Mon Sep 17 00:00:00 2001 From: getlarge Date: Tue, 10 Feb 2026 19:46:25 +0100 Subject: [PATCH 1/4] feat: add use_oidc_discovery_issuer option for generic OIDC provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows the issuer returned by the OpenID Connect Discovery document to differ from the issuer_url used to fetch it, using go-oidc's InsecureIssuerURLContext. This is required for providers like Azure AD B2C where the discovery URL contains the policy name but the issuer in the discovery document and tokens does not. ID Token issuer validation still occurs — tokens are verified against the issuer value from the discovery document. Only the OIDC Discovery §4.3 requirement that the discovery URL must equal the issuer is relaxed. Refs: ory/kratos#2404, ory/kratos#4005 Co-Authored-By: Claude Opus 4.6 --- selfservice/strategy/oidc/provider_config.go | 15 ++++ .../strategy/oidc/provider_generic_oidc.go | 6 +- .../strategy/oidc/provider_generic_test.go | 71 +++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/selfservice/strategy/oidc/provider_config.go b/selfservice/strategy/oidc/provider_config.go index 316635a2a5d7..70fe77b5a987 100644 --- a/selfservice/strategy/oidc/provider_config.go +++ b/selfservice/strategy/oidc/provider_config.go @@ -138,6 +138,21 @@ type Configuration struct { // NetIDTokenOriginHeader contains the orgin header to be used when exchanging a // NetID FedCM token for an ID token. NetIDTokenOriginHeader string `json:"net_id_token_origin_header"` + + // UseOIDCDiscoveryIssuer allows the issuer returned by the OpenID Connect Discovery + // document to differ from the issuer_url used to fetch it. When set to true, the + // issuer from the discovery response is used for token validation instead of + // requiring it to exactly match the issuer_url. + // + // This is required for providers like Azure AD B2C where the discovery URL contains + // the policy name but the issuer in the discovery document and tokens does not. + // + // ID Token issuer validation still occurs — tokens are verified against the issuer + // value from the discovery document. Only the spec requirement that the discovery + // URL must equal the issuer (OIDC Discovery §4.3) is relaxed. + // + // Defaults to false. + UseOIDCDiscoveryIssuer bool `json:"use_oidc_discovery_issuer"` } func (p Configuration) Redir(public *url.URL) string { diff --git a/selfservice/strategy/oidc/provider_generic_oidc.go b/selfservice/strategy/oidc/provider_generic_oidc.go index f6a465a394f5..50feb5559701 100644 --- a/selfservice/strategy/oidc/provider_generic_oidc.go +++ b/selfservice/strategy/oidc/provider_generic_oidc.go @@ -50,7 +50,11 @@ func (g *ProviderGenericOIDC) withHTTPClientContext(ctx context.Context) context func (g *ProviderGenericOIDC) provider(ctx context.Context) (*gooidc.Provider, error) { if g.p == nil { - p, err := gooidc.NewProvider(g.withHTTPClientContext(ctx), g.config.IssuerURL) + ctx = g.withHTTPClientContext(ctx) + if g.config.UseOIDCDiscoveryIssuer { + ctx = gooidc.InsecureIssuerURLContext(ctx, g.config.IssuerURL) + } + p, err := gooidc.NewProvider(ctx, g.config.IssuerURL) if err != nil { return nil, errors.WithStack(herodot.ErrMisconfiguration.WithReasonf("Unable to initialize OpenID Connect Provider: %s", err)) } diff --git a/selfservice/strategy/oidc/provider_generic_test.go b/selfservice/strategy/oidc/provider_generic_test.go index 7c90da7e3ec5..f5321fc331b6 100644 --- a/selfservice/strategy/oidc/provider_generic_test.go +++ b/selfservice/strategy/oidc/provider_generic_test.go @@ -6,6 +6,9 @@ package oidc_test import ( "context" "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "net/url" "testing" @@ -19,6 +22,7 @@ import ( "github.com/ory/kratos/selfservice/flow/login" "github.com/ory/kratos/x" + "github.com/ory/x/contextx" ) func makeOIDCClaims() json.RawMessage { @@ -94,3 +98,70 @@ func TestProviderGenericOIDC_AddAuthCodeURLOptions(t *testing.T) { assert.Contains(t, makeAuthCodeURL(t, r, reg), "claims="+url.QueryEscape(string(makeOIDCClaims()))) }) } + +func TestProviderGenericOIDC_UseOIDCDiscoveryIssuer(t *testing.T) { + // Simulate an OIDC provider (like Azure AD B2C) where the issuer in the + // discovery document does not match the discovery URL. + mismatchedIssuer := "http://different-issuer.example.com" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, `{ + "issuer": %q, + "authorization_endpoint": "http://%s/authorize", + "token_endpoint": "http://%s/token", + "jwks_uri": "http://%s/keys", + "id_token_signing_alg_values_supported": ["RS256"] + }`, mismatchedIssuer, r.Host, r.Host, r.Host) + })) + t.Cleanup(server.Close) + + _, reg := internal.NewFastRegistryWithMocks(t) + ctx := contextx.WithConfigValue(context.Background(), config.ViperKeyPublicBaseURL, "https://ory.sh") + + t.Run("case=fails when issuer does not match discovery URL", func(t *testing.T) { + p := oidc.NewProviderGenericOIDC(&oidc.Configuration{ + Provider: "generic", + ID: "test", + ClientID: "client", + ClientSecret: "secret", + IssuerURL: server.URL, + UseOIDCDiscoveryIssuer: false, + }, reg) + + _, err := p.(oidc.OAuth2Provider).OAuth2(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "Invalid configuration") + }) + + t.Run("case=succeeds when use_oidc_discovery_issuer is true", func(t *testing.T) { + p := oidc.NewProviderGenericOIDC(&oidc.Configuration{ + Provider: "generic", + ID: "test", + ClientID: "client", + ClientSecret: "secret", + IssuerURL: server.URL, + UseOIDCDiscoveryIssuer: true, + }, reg) + + c, err := p.(oidc.OAuth2Provider).OAuth2(ctx) + require.NoError(t, err) + assert.Contains(t, c.Endpoint.AuthURL, server.URL) + }) + + t.Run("case=uses discovered endpoints not config auth_url/token_url", func(t *testing.T) { + p := oidc.NewProviderGenericOIDC(&oidc.Configuration{ + Provider: "generic", + ID: "test", + ClientID: "client", + ClientSecret: "secret", + IssuerURL: server.URL, + AuthURL: "https://should-be-ignored.example.com/authorize", + TokenURL: "https://should-be-ignored.example.com/token", + UseOIDCDiscoveryIssuer: true, + }, reg) + + c, err := p.(oidc.OAuth2Provider).OAuth2(ctx) + require.NoError(t, err) + assert.NotContains(t, c.Endpoint.AuthURL, "should-be-ignored") + assert.Contains(t, c.Endpoint.AuthURL, server.URL) + }) +} From b9b47cd0260d5ebd1a0864aa39958cb1ae1664b4 Mon Sep 17 00:00:00 2001 From: getlarge Date: Wed, 11 Feb 2026 09:08:05 +0100 Subject: [PATCH 2/4] feat: add use_oidc_discovery_issuer to config schema Without this, Kratos rejects the field at config validation time since the schema has additionalProperties: false. Co-Authored-By: Claude Opus 4.6 --- embedx/config.schema.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/embedx/config.schema.json b/embedx/config.schema.json index 3ecb047a28d6..03d983b7f8ed 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -594,6 +594,12 @@ "description": "Contains the orgin header to be used when exchanging a NetID FedCM token for an ID token", "type": "string", "examples": ["https://example.com"] + }, + "use_oidc_discovery_issuer": { + "title": "Use OIDC Discovery Issuer", + "description": "If true, allows the OpenID Connect provider's issuer URL to differ from the discovery document URL. This is required for providers like Azure AD B2C where the issuer claim does not match the discovery endpoint. ID token issuer validation still occurs against the discovered issuer value.", + "type": "boolean", + "default": false } }, "additionalProperties": false, From 2d7ec67f54b33a808b3b1955d95ffedafeeea2c7 Mon Sep 17 00:00:00 2001 From: getlarge Date: Wed, 11 Feb 2026 12:02:36 +0100 Subject: [PATCH 3/4] fix: use discovered issuer for ID token validation InsecureIssuerURLContext stores the passed value as the provider's issuer for token verification. Previously we passed the discovery URL, which caused ID token validation to fail when the token's iss claim differed from the discovery URL. Now we pre-fetch the discovery document to extract the real issuer and pass that to InsecureIssuerURLContext, so token validation uses the correct issuer value. The second fetch by go-oidc's NewProvider is a cache hit (ristretto cache in Ory's go-oidc fork). Also add an e2e test using Hydra that exercises the full registration flow with a mismatched issuer. Co-Authored-By: Claude Opus 4.6 --- .../strategy/oidc/provider_generic_oidc.go | 47 ++++++++++++++++++- selfservice/strategy/oidc/strategy_test.go | 22 +++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/selfservice/strategy/oidc/provider_generic_oidc.go b/selfservice/strategy/oidc/provider_generic_oidc.go index 50feb5559701..5577f58f2510 100644 --- a/selfservice/strategy/oidc/provider_generic_oidc.go +++ b/selfservice/strategy/oidc/provider_generic_oidc.go @@ -5,8 +5,13 @@ package oidc import ( "context" + "encoding/json" + "fmt" + "io" + "net/http" "net/url" "slices" + "strings" "time" gooidc "github.com/coreos/go-oidc/v3/oidc" @@ -52,7 +57,12 @@ func (g *ProviderGenericOIDC) provider(ctx context.Context) (*gooidc.Provider, e if g.p == nil { ctx = g.withHTTPClientContext(ctx) if g.config.UseOIDCDiscoveryIssuer { - ctx = gooidc.InsecureIssuerURLContext(ctx, g.config.IssuerURL) + discoveredIssuer, err := discoverIssuer(ctx, g.config.IssuerURL) + if err != nil { + return nil, errors.WithStack(herodot.ErrMisconfiguration.WithReasonf( + "Unable to fetch OpenID Connect discovery document: %s", err)) + } + ctx = gooidc.InsecureIssuerURLContext(ctx, discoveredIssuer) } p, err := gooidc.NewProvider(ctx, g.config.IssuerURL) if err != nil { @@ -63,6 +73,41 @@ func (g *ProviderGenericOIDC) provider(ctx context.Context) (*gooidc.Provider, e return g.p, nil } +// discoverIssuer fetches the OIDC discovery document and returns the issuer value. +func discoverIssuer(ctx context.Context, issuerURL string) (string, error) { + wellKnown := strings.TrimSuffix(issuerURL, "/") + "/.well-known/openid-configuration" + req, err := http.NewRequestWithContext(ctx, "GET", wellKnown, nil) + if err != nil { + return "", err + } + client := http.DefaultClient + if c, ok := ctx.Value(oauth2.HTTPClient).(*http.Client); ok && c != nil { + client = c + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("%s: %s", resp.Status, body) + } + var doc struct { + Issuer string `json:"issuer"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return "", fmt.Errorf("failed to decode discovery document: %v", err) + } + if doc.Issuer == "" { + return "", fmt.Errorf("discovery document missing issuer field") + } + return doc.Issuer, nil +} + func (g *ProviderGenericOIDC) oauth2ConfigFromEndpoint(ctx context.Context, endpoint oauth2.Endpoint) *oauth2.Config { scope := g.config.Scope if !slices.Contains(scope, gooidc.ScopeOpenID) { diff --git a/selfservice/strategy/oidc/strategy_test.go b/selfservice/strategy/oidc/strategy_test.go index 5a8b4bdd5194..ee95b0ab09d5 100644 --- a/selfservice/strategy/oidc/strategy_test.go +++ b/selfservice/strategy/oidc/strategy_test.go @@ -85,6 +85,7 @@ func TestStrategy(t *testing.T) { routerP, routerA := httprouterx.NewTestRouterPublic(t), httprouterx.NewTestRouterAdminWithPrefix(t) ts, _ := testhelpers.NewKratosServerWithRouters(t, reg, routerP, routerA) invalid := newOIDCProvider(t, ts, remotePublic, remoteAdmin, "invalid-issuer") + discoveryIssuer := newOIDCProvider(t, ts, remotePublic, remoteAdmin, "discovery-issuer") orgID := uuidx.NewV4() viperSetProviderConfig( @@ -114,6 +115,17 @@ func TestStrategy(t *testing.T) { IssuerURL: strings.Replace(remotePublic, "localhost", "127.0.0.1", 1) + "/", Mapper: "file://./stub/oidc.hydra.jsonnet", }, + oidc.Configuration{ + Provider: "generic", + ID: "discovery-issuer", + ClientID: discoveryIssuer.ClientID, + ClientSecret: discoveryIssuer.ClientSecret, + // Same issuer mismatch as invalid-issuer, but UseOIDCDiscoveryIssuer + // allows the provider to accept the discovered issuer. + IssuerURL: strings.Replace(remotePublic, "localhost", "127.0.0.1", 1) + "/", + UseOIDCDiscoveryIssuer: true, + Mapper: "file://./stub/oidc.hydra.jsonnet", + }, ) t.Logf("Kratos Public URL: %s", ts.URL) @@ -341,6 +353,16 @@ func TestStrategy(t *testing.T) { } }) + t.Run("case=should pass with mismatched issuer when use_oidc_discovery_issuer is true", func(t *testing.T) { + subject = "discovery-issuer@ory.sh" + scope = []string{"openid"} + + r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, r.ID, "discovery-issuer") + res, body := makeRequest(t, "discovery-issuer", action, url.Values{}) + assertIdentity(t, res, body) + }) + t.Run("case=should fail because flow does not exist", func(t *testing.T) { for k, v := range []string{loginAction(x.NewUUID()), registerAction(x.NewUUID())} { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { From 041024754963778dd0f4a5b13ca8449db4d9042c Mon Sep 17 00:00:00 2001 From: getlarge Date: Wed, 11 Feb 2026 14:00:30 +0100 Subject: [PATCH 4/4] fix: check resp.Body.Close error (errcheck lint) Co-Authored-By: Claude Opus 4.6 --- selfservice/strategy/oidc/provider_generic_oidc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfservice/strategy/oidc/provider_generic_oidc.go b/selfservice/strategy/oidc/provider_generic_oidc.go index 5577f58f2510..02b36ba2d08b 100644 --- a/selfservice/strategy/oidc/provider_generic_oidc.go +++ b/selfservice/strategy/oidc/provider_generic_oidc.go @@ -88,7 +88,7 @@ func discoverIssuer(ctx context.Context, issuerURL string) (string, error) { if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", err