diff --git a/driver/config/config.go b/driver/config/config.go index 3a0c3f6c1b41..3cada4fb6d56 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" + 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" @@ -962,6 +963,10 @@ func (p *Config) OAuth2ProviderOverrideReturnTo(ctx context.Context) bool { return p.GetProvider(ctx).Bool(ViperKeyOAuth2ProviderOverrideReturnTo) } +func (p *Config) OAuth2ProviderSubjectSource(ctx context.Context) string { + return p.GetProvider(ctx).String(ViperKeyOAuth2ProviderSubjectSource) +} + 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..7d104f25a1aa 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.Equal(t, "external_id", conf.OAuth2ProviderSubjectSource(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.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 9c009e294d98..9eb5dca649b1 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 + subject_source: external_id diff --git a/embedx/config.schema.json b/embedx/config.schema.json index 3ecb047a28d6..ddd6d51c1ce4 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -2285,6 +2285,13 @@ "type": "boolean", "default": false, "description": "Override the return_to query parameter with the OAuth2 provider request URL when perfoming an OAuth2 login flow." + }, + "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 2aa3b77e55e1..ae6e91cdb9f6 100644 --- a/hydra/fake.go +++ b/hydra/fake.go @@ -20,8 +20,14 @@ const ( var ErrFakeAcceptLoginRequestFailed = errors.New("failed to accept login request") type FakeHydra struct { - Skip bool - RequestURL string + Skip bool + RequestURL string + SubjectSource string + params []AcceptLoginRequestParams +} + +func (h *FakeHydra) Params() []AcceptLoginRequestParams { + return h.params } var _ Hydra = &FakeHydra{} @@ -33,9 +39,23 @@ 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") } + + // 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 423adc35601e..23dcb927e154 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,20 @@ 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 + 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) 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..982198b3b42a --- /dev/null +++ b/selfservice/flow/login/hook_external_id_test.go @@ -0,0 +1,113 @@ +// 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=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 + + 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=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 + + 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) + }) + + 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) + }) +}