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, 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..02b36ba2d08b 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" @@ -50,7 +55,16 @@ 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 { + 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 { return nil, errors.WithStack(herodot.ErrMisconfiguration.WithReasonf("Unable to initialize OpenID Connect Provider: %s", err)) } @@ -59,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 func() { _ = 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/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) + }) +} 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) {