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
5 changes: 5 additions & 0 deletions driver/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions driver/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1299,13 +1299,15 @@ 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) {
conf, _ := config.New(ctx, logrusx.New("", ""), os.Stderr, &contextx.Default{}, configx.SkipValidation())
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))
})
}

Expand Down
1 change: 1 addition & 0 deletions driver/config/stub/.kratos.oauth2_provider.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ oauth2_provider:
headers:
Authorization: Basic
override_return_to: true
subject_source: external_id
7 changes: 7 additions & 0 deletions embedx/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions hydra/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand All @@ -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
Expand Down
16 changes: 15 additions & 1 deletion hydra/hydra.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
AcceptLoginRequestParams struct {
LoginChallenge string
IdentityID string
ExternalID string
SessionID string
AuthenticationMethods session.AuthenticationMethods
}
Expand Down Expand Up @@ -93,7 +94,20 @@
remember := h.d.Config().SessionPersistentCookie(ctx)
rememberFor := int64(h.d.Config().SessionLifespan(ctx) / time.Second)

alr := hydraclientgo.NewAcceptOAuth2LoginRequest(params.IdentityID)
subject := params.IdentityID

Check failure on line 97 in hydra/hydra.go

View workflow job for this annotation

GitHub Actions / Run tests and lints

ineffectual assignment to subject (ineffassign)

Check warning

Code scanning / CodeQL

Useless assignment to local variable Warning

This definition of subject is never used.
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 = &params.SessionID
alr.Remember = &remember
alr.RememberFor = &rememberFor
Expand Down
2 changes: 2 additions & 0 deletions selfservice/flow/login/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -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,
})
Expand Down
113 changes: 113 additions & 0 deletions selfservice/flow/login/hook_external_id_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading