From b11723811b2e83380b29534f06c5fab6c729e1e9 Mon Sep 17 00:00:00 2001 From: Christian Marbach Date: Fri, 23 Jan 2026 08:07:34 +0100 Subject: [PATCH 1/2] feat: allow using identity external_id as oauth2 subject This change adds a configuration option `oauth2_provider.use_external_id`. When enabled, Kratos will pass the identity's `external_id` as the subject (`sub`) to Ory Hydra during the OAuth2 login flow. If the toggle is enabled but no `external_id` is present on the identity, it falls back to the internal Identity ID (UUID) to ensure continuity. Part of the effort to better integrate external identity mappings. Closes #4528 --- driver/config/config.go | 5 ++ driver/config/config_test.go | 2 + .../config/stub/.kratos.oauth2_provider.yaml | 1 + embedx/config.schema.json | 6 ++ hydra/fake.go | 6 ++ hydra/hydra.go | 8 +- selfservice/flow/login/hook.go | 2 + .../flow/login/hook_external_id_test.go | 88 +++++++++++++++++++ 8 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 selfservice/flow/login/hook_external_id_test.go diff --git a/driver/config/config.go b/driver/config/config.go index 3a0c3f6c1b41..469921f02c5c 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -194,6 +194,7 @@ const ( ViperKeyOAuth2ProviderURL = "oauth2_provider.url" ViperKeyOAuth2ProviderHeader = "oauth2_provider.headers" ViperKeyOAuth2ProviderOverrideReturnTo = "oauth2_provider.override_return_to" + ViperKeyOAuth2ProviderUseExternalID = "oauth2_provider.use_external_id" ViperKeyClientHTTPNoPrivateIPRanges = "clients.http.disallow_private_ip_ranges" ViperKeyClientHTTPPrivateIPExceptionURLs = "clients.http.private_ip_exception_urls" ViperKeyWebhookHeaderAllowlist = "clients.web_hook.header_allowlist" @@ -962,6 +963,10 @@ func (p *Config) OAuth2ProviderOverrideReturnTo(ctx context.Context) bool { return p.GetProvider(ctx).Bool(ViperKeyOAuth2ProviderOverrideReturnTo) } +func (p *Config) OAuth2ProviderUseExternalID(ctx context.Context) bool { + return p.GetProvider(ctx).Bool(ViperKeyOAuth2ProviderUseExternalID) +} + func (p *Config) OAuth2ProviderURL(ctx context.Context) *url.URL { k := ViperKeyOAuth2ProviderURL v := p.GetProvider(ctx).String(k) diff --git a/driver/config/config_test.go b/driver/config/config_test.go index ea241c60668f..54289c9c3583 100644 --- a/driver/config/config_test.go +++ b/driver/config/config_test.go @@ -1299,6 +1299,7 @@ func TestOAuth2Provider(t *testing.T) { assert.Equal(t, "https://oauth2_provider/", conf.OAuth2ProviderURL(ctx).String()) assert.Equal(t, http.Header{"Authorization": {"Basic"}}, conf.OAuth2ProviderHeader(ctx)) assert.True(t, conf.OAuth2ProviderOverrideReturnTo(ctx)) + assert.True(t, conf.OAuth2ProviderUseExternalID(ctx)) }) t.Run("case=defaults", func(t *testing.T) { @@ -1306,6 +1307,7 @@ func TestOAuth2Provider(t *testing.T) { assert.Empty(t, conf.OAuth2ProviderURL(ctx)) assert.Empty(t, conf.OAuth2ProviderHeader(ctx)) assert.False(t, conf.OAuth2ProviderOverrideReturnTo(ctx)) + assert.False(t, conf.OAuth2ProviderUseExternalID(ctx)) }) } diff --git a/driver/config/stub/.kratos.oauth2_provider.yaml b/driver/config/stub/.kratos.oauth2_provider.yaml index 9c009e294d98..bcf64f887364 100644 --- a/driver/config/stub/.kratos.oauth2_provider.yaml +++ b/driver/config/stub/.kratos.oauth2_provider.yaml @@ -3,3 +3,4 @@ oauth2_provider: headers: Authorization: Basic override_return_to: true + use_external_id: true diff --git a/embedx/config.schema.json b/embedx/config.schema.json index 3ecb047a28d6..1d57447b6063 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -2285,6 +2285,12 @@ "type": "boolean", "default": false, "description": "Override the return_to query parameter with the OAuth2 provider request URL when perfoming an OAuth2 login flow." + }, + "use_external_id": { + "title": "Use external_id as subject", + "type": "boolean", + "default": false, + "description": "If set, the external_id of the identity will be used as the subject in the OAuth2 login request. If no external_id is set, the identity ID will be used." } }, "additionalProperties": false diff --git a/hydra/fake.go b/hydra/fake.go index 2aa3b77e55e1..386a71890a8c 100644 --- a/hydra/fake.go +++ b/hydra/fake.go @@ -22,6 +22,11 @@ var ErrFakeAcceptLoginRequestFailed = errors.New("failed to accept login request type FakeHydra struct { Skip bool RequestURL string + params []AcceptLoginRequestParams +} + +func (h *FakeHydra) Params() []AcceptLoginRequestParams { + return h.params } var _ Hydra = &FakeHydra{} @@ -33,6 +38,7 @@ func NewFake() *FakeHydra { } func (h *FakeHydra) AcceptLoginRequest(_ context.Context, params AcceptLoginRequestParams) (string, error) { + h.params = append(h.params, params) if params.SessionID == "" { return "", errors.New("session id must not be empty") } diff --git a/hydra/hydra.go b/hydra/hydra.go index 423adc35601e..3612a6dc6692 100644 --- a/hydra/hydra.go +++ b/hydra/hydra.go @@ -30,6 +30,7 @@ type ( AcceptLoginRequestParams struct { LoginChallenge string IdentityID string + ExternalID string SessionID string AuthenticationMethods session.AuthenticationMethods } @@ -93,7 +94,12 @@ func (h *DefaultHydra) AcceptLoginRequest(ctx context.Context, params AcceptLogi remember := h.d.Config().SessionPersistentCookie(ctx) rememberFor := int64(h.d.Config().SessionLifespan(ctx) / time.Second) - alr := hydraclientgo.NewAcceptOAuth2LoginRequest(params.IdentityID) + subject := params.IdentityID + if h.d.Config().OAuth2ProviderUseExternalID(ctx) && params.ExternalID != "" { + subject = params.ExternalID + } + + alr := hydraclientgo.NewAcceptOAuth2LoginRequest(subject) alr.IdentityProviderSessionId = ¶ms.SessionID alr.Remember = &remember alr.RememberFor = &rememberFor diff --git a/selfservice/flow/login/hook.go b/selfservice/flow/login/hook.go index 0adaba114763..ae43c5b85918 100644 --- a/selfservice/flow/login/hook.go +++ b/selfservice/flow/login/hook.go @@ -319,6 +319,7 @@ func (e *HookExecutor) PostLoginHook( hydra.AcceptLoginRequestParams{ LoginChallenge: string(f.OAuth2LoginChallenge), IdentityID: i.ID.String(), + ExternalID: string(i.ExternalID), SessionID: s.ID.String(), AuthenticationMethods: s.AMR, }) @@ -373,6 +374,7 @@ func (e *HookExecutor) PostLoginHook( hydra.AcceptLoginRequestParams{ LoginChallenge: string(f.OAuth2LoginChallenge), IdentityID: i.ID.String(), + ExternalID: string(i.ExternalID), SessionID: s.ID.String(), AuthenticationMethods: s.AMR, }) diff --git a/selfservice/flow/login/hook_external_id_test.go b/selfservice/flow/login/hook_external_id_test.go new file mode 100644 index 000000000000..f196e1cbc422 --- /dev/null +++ b/selfservice/flow/login/hook_external_id_test.go @@ -0,0 +1,88 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package login_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/hydra" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/internal" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/login" + "github.com/ory/kratos/session" + "github.com/ory/x/sqlxx" +) + +func TestLoginExecutorWithExternalID(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + fakeHydra := hydra.NewFake() + reg.SetHydra(fakeHydra) + + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/login.schema.json") + conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, "https://www.ory.sh/kratos/return_to") + conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, "https://hydra.example.com") + + i := &identity.Identity{ + ID: uuid.Must(uuid.NewV4()), + ExternalID: sqlxx.NullString("external-id"), + SchemaID: config.DefaultIdentityTraitsSchemaID, + State: identity.StateActive, + } + require.NoError(t, reg.Persister().CreateIdentity(ctx, i)) + + t.Run("case=use_external_id=false", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeyOAuth2ProviderUseExternalID, false) + loginFlow, err := login.NewFlow(conf, time.Minute, hydra.FakeValidLoginChallenge, &http.Request{URL: &url.URL{Path: "/", RawQuery: "login_challenge=" + hydra.FakeValidLoginChallenge}}, flow.TypeBrowser) + require.NoError(t, err) + loginFlow.OAuth2LoginChallenge = hydra.FakeValidLoginChallenge + + w := httptest.NewRecorder() + r := &http.Request{URL: &url.URL{Path: "/login/post"}} + sess := session.NewInactiveSession() + sess.CompletedLoginFor(identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) + + err = reg.LoginHookExecutor().PostLoginHook(w, r, identity.CredentialsTypePassword.ToUiNodeGroup(), loginFlow, i, sess, "") + require.NoError(t, err) + + require.Len(t, fakeHydra.Params(), 1) + assert.Equal(t, i.ID.String(), fakeHydra.Params()[0].IdentityID) + assert.Equal(t, "external-id", fakeHydra.Params()[0].ExternalID) + }) + + t.Run("case=use_external_id=true", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeyOAuth2ProviderUseExternalID, true) + loginFlow, err := login.NewFlow(conf, time.Minute, hydra.FakeValidLoginChallenge, &http.Request{URL: &url.URL{Path: "/", RawQuery: "login_challenge=" + hydra.FakeValidLoginChallenge}}, flow.TypeBrowser) + require.NoError(t, err) + loginFlow.OAuth2LoginChallenge = hydra.FakeValidLoginChallenge + + w := httptest.NewRecorder() + r := &http.Request{URL: &url.URL{Path: "/login/post"}} + sess := session.NewInactiveSession() + sess.CompletedLoginFor(identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) + + fakeHydra.Params() + + err = reg.LoginHookExecutor().PostLoginHook(w, r, identity.CredentialsTypePassword.ToUiNodeGroup(), loginFlow, i, sess, "") + require.NoError(t, err) + + params := fakeHydra.Params() + require.NotEmpty(t, params) + lastParams := params[len(params)-1] + assert.Equal(t, i.ID.String(), lastParams.IdentityID) + assert.Equal(t, "external-id", lastParams.ExternalID) + }) +} From 8f5cfccec1443efaf71b0aa9c3e89ec1726a725a Mon Sep 17 00:00:00 2001 From: Christian Marbach Date: Fri, 20 Feb 2026 00:22:20 +0100 Subject: [PATCH 2/2] feat: use subject_source pattern for OAuth2 provider subject Replace `use_external_id` boolean config with `subject_source` enum to match the existing tokenizer pattern. The new config accepts: - "id" (default): Use identity ID as OAuth2 subject - "external_id": Use identity's external_id as OAuth2 subject Returns an error when `subject_source` is set to "external_id" but the identity's external_id is unset, ensuring predictable behavior and making it easier to identify which ID was used. This aligns the OAuth2 provider configuration with the session tokenizer implementation for consistency across the codebase. Closes #4528 --- driver/config/config.go | 6 ++-- driver/config/config_test.go | 4 +-- .../config/stub/.kratos.oauth2_provider.yaml | 2 +- embedx/config.schema.json | 11 ++++--- hydra/fake.go | 20 +++++++++-- hydra/hydra.go | 10 +++++- .../flow/login/hook_external_id_test.go | 33 ++++++++++++++++--- 7 files changed, 67 insertions(+), 19 deletions(-) diff --git a/driver/config/config.go b/driver/config/config.go index 469921f02c5c..3cada4fb6d56 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -194,7 +194,7 @@ const ( ViperKeyOAuth2ProviderURL = "oauth2_provider.url" ViperKeyOAuth2ProviderHeader = "oauth2_provider.headers" ViperKeyOAuth2ProviderOverrideReturnTo = "oauth2_provider.override_return_to" - ViperKeyOAuth2ProviderUseExternalID = "oauth2_provider.use_external_id" + ViperKeyOAuth2ProviderSubjectSource = "oauth2_provider.subject_source" ViperKeyClientHTTPNoPrivateIPRanges = "clients.http.disallow_private_ip_ranges" ViperKeyClientHTTPPrivateIPExceptionURLs = "clients.http.private_ip_exception_urls" ViperKeyWebhookHeaderAllowlist = "clients.web_hook.header_allowlist" @@ -963,8 +963,8 @@ func (p *Config) OAuth2ProviderOverrideReturnTo(ctx context.Context) bool { return p.GetProvider(ctx).Bool(ViperKeyOAuth2ProviderOverrideReturnTo) } -func (p *Config) OAuth2ProviderUseExternalID(ctx context.Context) bool { - return p.GetProvider(ctx).Bool(ViperKeyOAuth2ProviderUseExternalID) +func (p *Config) OAuth2ProviderSubjectSource(ctx context.Context) string { + return p.GetProvider(ctx).String(ViperKeyOAuth2ProviderSubjectSource) } func (p *Config) OAuth2ProviderURL(ctx context.Context) *url.URL { diff --git a/driver/config/config_test.go b/driver/config/config_test.go index 54289c9c3583..7d104f25a1aa 100644 --- a/driver/config/config_test.go +++ b/driver/config/config_test.go @@ -1299,7 +1299,7 @@ func TestOAuth2Provider(t *testing.T) { assert.Equal(t, "https://oauth2_provider/", conf.OAuth2ProviderURL(ctx).String()) assert.Equal(t, http.Header{"Authorization": {"Basic"}}, conf.OAuth2ProviderHeader(ctx)) assert.True(t, conf.OAuth2ProviderOverrideReturnTo(ctx)) - assert.True(t, conf.OAuth2ProviderUseExternalID(ctx)) + assert.Equal(t, "external_id", conf.OAuth2ProviderSubjectSource(ctx)) }) t.Run("case=defaults", func(t *testing.T) { @@ -1307,7 +1307,7 @@ func TestOAuth2Provider(t *testing.T) { assert.Empty(t, conf.OAuth2ProviderURL(ctx)) assert.Empty(t, conf.OAuth2ProviderHeader(ctx)) assert.False(t, conf.OAuth2ProviderOverrideReturnTo(ctx)) - assert.False(t, conf.OAuth2ProviderUseExternalID(ctx)) + assert.Equal(t, "id", conf.OAuth2ProviderSubjectSource(ctx)) }) } diff --git a/driver/config/stub/.kratos.oauth2_provider.yaml b/driver/config/stub/.kratos.oauth2_provider.yaml index bcf64f887364..9eb5dca649b1 100644 --- a/driver/config/stub/.kratos.oauth2_provider.yaml +++ b/driver/config/stub/.kratos.oauth2_provider.yaml @@ -3,4 +3,4 @@ oauth2_provider: headers: Authorization: Basic override_return_to: true - use_external_id: true + subject_source: external_id diff --git a/embedx/config.schema.json b/embedx/config.schema.json index 1d57447b6063..ddd6d51c1ce4 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -2286,11 +2286,12 @@ "default": false, "description": "Override the return_to query parameter with the OAuth2 provider request URL when perfoming an OAuth2 login flow." }, - "use_external_id": { - "title": "Use external_id as subject", - "type": "boolean", - "default": false, - "description": "If set, the external_id of the identity will be used as the subject in the OAuth2 login request. If no external_id is set, the identity ID will be used." + "subject_source": { + "title": "Subject source for OAuth2 login", + "type": "string", + "enum": ["id", "external_id"], + "default": "id", + "description": "Determines which identifier to use as the subject in OAuth2 login requests. Can be either 'id' (identity ID, default) or 'external_id' (identity's external ID). If 'external_id' is selected but not set on the identity, an error will be returned." } }, "additionalProperties": false diff --git a/hydra/fake.go b/hydra/fake.go index 386a71890a8c..ae6e91cdb9f6 100644 --- a/hydra/fake.go +++ b/hydra/fake.go @@ -20,9 +20,10 @@ const ( var ErrFakeAcceptLoginRequestFailed = errors.New("failed to accept login request") type FakeHydra struct { - Skip bool - RequestURL string - params []AcceptLoginRequestParams + Skip bool + RequestURL string + SubjectSource string + params []AcceptLoginRequestParams } func (h *FakeHydra) Params() []AcceptLoginRequestParams { @@ -42,6 +43,19 @@ func (h *FakeHydra) AcceptLoginRequest(_ context.Context, params AcceptLoginRequ if params.SessionID == "" { return "", errors.New("session id must not be empty") } + + // Validate subject source just like DefaultHydra does + switch h.SubjectSource { + case "", "id": + // Use identity ID - no validation needed + case "external_id": + if params.ExternalID == "" { + return "", herodot.ErrBadRequest.WithReasonf("The identity does not have an external ID set, but it is required for the OAuth2 provider subject.") + } + default: + return "", herodot.ErrBadRequest.WithReasonf("Unknown OAuth2 provider subject source %q", h.SubjectSource) + } + switch params.LoginChallenge { case FakeInvalidLoginChallenge: return "", ErrFakeAcceptLoginRequestFailed diff --git a/hydra/hydra.go b/hydra/hydra.go index 3612a6dc6692..23dcb927e154 100644 --- a/hydra/hydra.go +++ b/hydra/hydra.go @@ -95,8 +95,16 @@ func (h *DefaultHydra) AcceptLoginRequest(ctx context.Context, params AcceptLogi rememberFor := int64(h.d.Config().SessionLifespan(ctx) / time.Second) subject := params.IdentityID - if h.d.Config().OAuth2ProviderUseExternalID(ctx) && params.ExternalID != "" { + switch h.d.Config().OAuth2ProviderSubjectSource(ctx) { + case "", "id": + subject = params.IdentityID + case "external_id": + if params.ExternalID == "" { + return "", errors.WithStack(herodot.ErrBadRequest.WithReasonf("The identity does not have an external ID set, but it is required for the OAuth2 provider subject.")) + } subject = params.ExternalID + default: + return "", errors.WithStack(herodot.ErrBadRequest.WithReasonf("Unknown OAuth2 provider subject source %q", h.d.Config().OAuth2ProviderSubjectSource(ctx))) } alr := hydraclientgo.NewAcceptOAuth2LoginRequest(subject) diff --git a/selfservice/flow/login/hook_external_id_test.go b/selfservice/flow/login/hook_external_id_test.go index f196e1cbc422..982198b3b42a 100644 --- a/selfservice/flow/login/hook_external_id_test.go +++ b/selfservice/flow/login/hook_external_id_test.go @@ -44,8 +44,9 @@ func TestLoginExecutorWithExternalID(t *testing.T) { } require.NoError(t, reg.Persister().CreateIdentity(ctx, i)) - t.Run("case=use_external_id=false", func(t *testing.T) { - conf.MustSet(ctx, config.ViperKeyOAuth2ProviderUseExternalID, false) + t.Run("case=subject_source=id", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeyOAuth2ProviderSubjectSource, "id") + fakeHydra.SubjectSource = "id" loginFlow, err := login.NewFlow(conf, time.Minute, hydra.FakeValidLoginChallenge, &http.Request{URL: &url.URL{Path: "/", RawQuery: "login_challenge=" + hydra.FakeValidLoginChallenge}}, flow.TypeBrowser) require.NoError(t, err) loginFlow.OAuth2LoginChallenge = hydra.FakeValidLoginChallenge @@ -63,8 +64,9 @@ func TestLoginExecutorWithExternalID(t *testing.T) { assert.Equal(t, "external-id", fakeHydra.Params()[0].ExternalID) }) - t.Run("case=use_external_id=true", func(t *testing.T) { - conf.MustSet(ctx, config.ViperKeyOAuth2ProviderUseExternalID, true) + t.Run("case=subject_source=external_id", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeyOAuth2ProviderSubjectSource, "external_id") + fakeHydra.SubjectSource = "external_id" loginFlow, err := login.NewFlow(conf, time.Minute, hydra.FakeValidLoginChallenge, &http.Request{URL: &url.URL{Path: "/", RawQuery: "login_challenge=" + hydra.FakeValidLoginChallenge}}, flow.TypeBrowser) require.NoError(t, err) loginFlow.OAuth2LoginChallenge = hydra.FakeValidLoginChallenge @@ -85,4 +87,27 @@ func TestLoginExecutorWithExternalID(t *testing.T) { assert.Equal(t, i.ID.String(), lastParams.IdentityID) assert.Equal(t, "external-id", lastParams.ExternalID) }) + + t.Run("case=subject_source=external_id without external_id set", func(t *testing.T) { + iWithoutExtID := &identity.Identity{ + ID: uuid.Must(uuid.NewV4()), + SchemaID: config.DefaultIdentityTraitsSchemaID, + State: identity.StateActive, + } + require.NoError(t, reg.Persister().CreateIdentity(ctx, iWithoutExtID)) + + conf.MustSet(ctx, config.ViperKeyOAuth2ProviderSubjectSource, "external_id") + fakeHydra.SubjectSource = "external_id" + loginFlow, err := login.NewFlow(conf, time.Minute, hydra.FakeValidLoginChallenge, &http.Request{URL: &url.URL{Path: "/", RawQuery: "login_challenge=" + hydra.FakeValidLoginChallenge}}, flow.TypeBrowser) + require.NoError(t, err) + loginFlow.OAuth2LoginChallenge = hydra.FakeValidLoginChallenge + + w := httptest.NewRecorder() + r := &http.Request{URL: &url.URL{Path: "/login/post"}} + sess := session.NewInactiveSession() + sess.CompletedLoginFor(identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) + + err = reg.LoginHookExecutor().PostLoginHook(w, r, identity.CredentialsTypePassword.ToUiNodeGroup(), loginFlow, iWithoutExtID, sess, "") + require.Error(t, err) + }) }