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
6 changes: 6 additions & 0 deletions embedx/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions selfservice/strategy/oidc/provider_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 50 additions & 1 deletion selfservice/strategy/oidc/provider_generic_oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
}
Expand All @@ -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) {
Expand Down
71 changes: 71 additions & 0 deletions selfservice/strategy/oidc/provider_generic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ package oidc_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"

Expand All @@ -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 {
Expand Down Expand Up @@ -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)
})
}
22 changes: 22 additions & 0 deletions selfservice/strategy/oidc/strategy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Loading