diff --git a/embedx/config.schema.json b/embedx/config.schema.json index cff95817190d..6901e80446ac 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -1946,6 +1946,15 @@ "type": "object", "additionalProperties": false, "properties": { + "id_token_provider_aliases": { + "type": "object", + "description": "Maps incoming provider IDs to configured provider IDs only for login or registration submissions with an id_token. Browser authorization, callbacks and settings keep exact provider-ID lookup.", + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "examples": [{"google": "google-goodsystems"}] + }, "base_redirect_uri": { "type": "string", "title": "Base URL for OAuth2 Redirect URIs", diff --git a/selfservice/strategy/oidc/id_token_provider_alias_test.go b/selfservice/strategy/oidc/id_token_provider_alias_test.go new file mode 100644 index 000000000000..0f24e48a4681 --- /dev/null +++ b/selfservice/strategy/oidc/id_token_provider_alias_test.go @@ -0,0 +1,139 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/internal" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/kratos/selfservice/strategy/oidc" +) + +func TestIDTokenProviderAliasesNativeRequests(t *testing.T) { + for _, entry := range []string{"login", "registration"} { + t.Run(entry, func(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + jwks := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(publicJWKS) + })) + t.Cleanup(jwks.Close) + oidc.RegisterTestGoogleProvider(t, "test-google", jwks.URL) + provider := oidc.Configuration{ + ID: "google-goodsystems", Provider: "test-google", + ClientID: "goodmeet-client", Mapper: "file://./stub/oidc.hydra.jsonnet", PKCE: "never", + } + viperSetProviderConfig(t, conf, provider) + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".oidc.config", &oidc.ConfigurationCollection{ + Providers: []oidc.Configuration{provider}, + IDTokenProviderAliases: map[string]string{"google": "google-goodsystems"}, + }) + conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationEnabled, true) + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/stub.schema.json") + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypeOIDC.String()), []config.SelfServiceHook{{Name: "session"}}) + server, _ := testhelpers.NewKratosServer(t, reg) + + subject := "goodmeet-" + entry + "@example.test" + classic := identity.NewIdentity("default") + classic.Traits = identity.Traits(`{"subject":"` + subject + `"}`) + classicCredentials, err := identity.NewCredentialsOIDC(new(identity.CredentialsOIDCEncryptedTokens), "google", subject, "") + require.NoError(t, err) + classic.SetCredentials(identity.CredentialsTypeOIDC, *classicCredentials) + require.NoError(t, reg.IdentityManager().Create(ctx, classic)) + + token := createIdToken(t, jwt.RegisteredClaims{ + Issuer: "https://accounts.google.com", Subject: subject, + Audience: jwt.ClaimStrings{"goodmeet-client"}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }) + client := &http.Client{CheckRedirect: func(r *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }} + submit := func(providerID, idToken string) (int, []byte) { + t.Helper() + response, err := client.Get(server.URL + "/self-service/" + entry + "/api") + require.NoError(t, err) + flowBody, err := io.ReadAll(response.Body) + require.NoError(t, response.Body.Close()) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode, "%s", flowBody) + action := gjson.GetBytes(flowBody, "ui.action").String() + require.NotEmpty(t, action) + payload, err := json.Marshal(map[string]string{"method": "OIDC", "provider": providerID, "id_token": idToken}) + require.NoError(t, err) + request, err := http.NewRequest(http.MethodPost, action, strings.NewReader(string(payload))) + require.NoError(t, err) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + response, err = client.Do(request) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + require.NoError(t, response.Body.Close()) + require.NoError(t, err) + return response.StatusCode, body + } + var productID string + for _, providerID := range []string{"google", "google-goodsystems", "google"} { + status, body := submit(providerID, token) + require.Equal(t, http.StatusOK, status, "%s", body) + require.NotEmpty(t, gjson.GetBytes(body, "session_token").String()) + actualID := gjson.GetBytes(body, "session.identity.id").String() + require.NotEmpty(t, actualID) + assert.NotEqual(t, classic.ID.String(), actualID) + assert.Equal(t, "google-goodsystems", gjson.GetBytes(body, "session.authentication_methods.0.provider").String()) + if productID == "" { + productID = actualID + } else { + assert.Equal(t, productID, actualID) + } + + product, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, identity.CredentialsTypeOIDC, "google-goodsystems:"+subject) + require.NoError(t, err) + assert.Equal(t, productID, product.ID.String()) + original, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, identity.CredentialsTypeOIDC, "google:"+subject) + require.NoError(t, err) + assert.Equal(t, classic.ID, original.ID) + } + + // A browser-style submission has no ID token, so its provider stays exact. + status, body := submit("google", "") + require.Equal(t, http.StatusNotFound, status, "%s", body) + assert.Contains(t, string(body), "unknown or has not been configured") + + status, body = submit("google", "invalid-id-token") + require.GreaterOrEqual(t, status, http.StatusBadRequest, "%s", body) + assert.Contains(t, string(body), "Could not verify id_token") + assert.Empty(t, gjson.GetBytes(body, "session_token").String()) + + // A product deployment without the opt-in does not accept legacy Google. + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".oidc.config", &oidc.ConfigurationCollection{Providers: []oidc.Configuration{provider}}) + status, body = submit("google", token) + require.Equal(t, http.StatusNotFound, status, "%s", body) + status, body = submit("google-goodsystems", token) + require.Equal(t, http.StatusOK, status, "%s", body) + assert.Equal(t, productID, gjson.GetBytes(body, "session.identity.id").String()) + + // Classic still authenticates its original identity with the same subject. + provider.ID = "google" + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".oidc.config", &oidc.ConfigurationCollection{Providers: []oidc.Configuration{provider}}) + status, body = submit("google", token) + require.Equal(t, http.StatusOK, status, "%s", body) + assert.Equal(t, classic.ID.String(), gjson.GetBytes(body, "session.identity.id").String()) + assert.Equal(t, "google", gjson.GetBytes(body, "session.authentication_methods.0.provider").String()) + }) + } +} diff --git a/selfservice/strategy/oidc/provider_config.go b/selfservice/strategy/oidc/provider_config.go index b98c24972ae4..880c3914f5b2 100644 --- a/selfservice/strategy/oidc/provider_config.go +++ b/selfservice/strategy/oidc/provider_config.go @@ -154,8 +154,10 @@ func (p Configuration) Redir(public *url.URL) string { } type ConfigurationCollection struct { - BaseRedirectURI string `json:"base_redirect_uri"` - Providers []Configuration `json:"providers"` + // IDTokenProviderAliases maps legacy request IDs only for ID-token authentication. + IDTokenProviderAliases map[string]string `json:"id_token_provider_aliases,omitempty"` + BaseRedirectURI string `json:"base_redirect_uri"` + Providers []Configuration `json:"providers"` } // !!! WARNING !!! diff --git a/selfservice/strategy/oidc/provider_test.go b/selfservice/strategy/oidc/provider_test.go index 4e45f42c72f4..61e998fa8da1 100644 --- a/selfservice/strategy/oidc/provider_test.go +++ b/selfservice/strategy/oidc/provider_test.go @@ -135,3 +135,12 @@ func TestLocale(t *testing.T) { }) } } + +func RegisterTestGoogleProvider(t *testing.T, id, jwksURL string) { + supportedProviders[id] = func(c *Configuration, reg Dependencies) Provider { + provider := NewProviderGoogle(c, reg).(*ProviderGoogle) + provider.JWKSUrl = jwksURL + return provider + } + t.Cleanup(func() { delete(supportedProviders, id) }) +} diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index 901579aa4c00..c228b256ab22 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -544,13 +544,20 @@ func (s *Strategy) Config(ctx context.Context) (*ConfigurationCollection, error) } func (s *Strategy) provider(ctx context.Context, id string) (Provider, error) { - if c, err := s.Config(ctx); err != nil { - return nil, err - } else if provider, err := c.Provider(id, s.d); err != nil { + return s.providerForAuthentication(ctx, id, false) +} + +func (s *Strategy) providerForAuthentication(ctx context.Context, id string, hasIDToken bool) (Provider, error) { + c, err := s.Config(ctx) + if err != nil { return nil, err - } else { - return provider, nil } + if hasIDToken { + if canonicalID, ok := c.IDTokenProviderAliases[id]; ok { + id = canonicalID + } + } + return c.Provider(id, s.d) } func (s *Strategy) forwardError(ctx context.Context, w http.ResponseWriter, r *http.Request, f flow.Flow, err error) { diff --git a/selfservice/strategy/oidc/strategy_login.go b/selfservice/strategy/oidc/strategy_login.go index a701315bf289..59c55f78ccbd 100644 --- a/selfservice/strategy/oidc/strategy_login.go +++ b/selfservice/strategy/oidc/strategy_login.go @@ -232,7 +232,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, s.handleError(ctx, w, r, f, pid, nil, err) } - provider, err := s.provider(ctx, pid) + provider, err := s.providerForAuthentication(ctx, pid, p.IDToken != "") if err != nil { return nil, s.handleError(ctx, w, r, f, pid, nil, err) } diff --git a/selfservice/strategy/oidc/strategy_registration.go b/selfservice/strategy/oidc/strategy_registration.go index 4edab83a351e..1698d6a1fce1 100644 --- a/selfservice/strategy/oidc/strategy_registration.go +++ b/selfservice/strategy/oidc/strategy_registration.go @@ -184,7 +184,7 @@ func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registrat return s.handleError(ctx, w, r, f, pid, nil, err) } - provider, err := s.provider(ctx, pid) + provider, err := s.providerForAuthentication(ctx, pid, p.IDToken != "") if err != nil { return s.handleError(ctx, w, r, f, pid, nil, err) } diff --git a/test/schema/fixtures/config.schema.test.failure/root.idTokenProviderAliasEmptyTarget.yaml b/test/schema/fixtures/config.schema.test.failure/root.idTokenProviderAliasEmptyTarget.yaml new file mode 100644 index 000000000000..6f91a209cd22 --- /dev/null +++ b/test/schema/fixtures/config.schema.test.failure/root.idTokenProviderAliasEmptyTarget.yaml @@ -0,0 +1,22 @@ +serve: + public: + base_url: "#/definitions/baseUrl" + +selfservice: + methods: + oidc: + config: + id_token_provider_aliases: + google: "" + default_browser_return_url: "#/definitions/defaultReturnTo" + +dsn: foo + +identity: + schemas: + - id: default + url: https://example.com + +session: + cookie: + domain: foobar diff --git a/test/schema/fixtures/config.schema.test.failure/root.idTokenProviderAliasInvalidTarget.yaml b/test/schema/fixtures/config.schema.test.failure/root.idTokenProviderAliasInvalidTarget.yaml new file mode 100644 index 000000000000..fe07f3a2c5c3 --- /dev/null +++ b/test/schema/fixtures/config.schema.test.failure/root.idTokenProviderAliasInvalidTarget.yaml @@ -0,0 +1,22 @@ +serve: + public: + base_url: "#/definitions/baseUrl" + +selfservice: + methods: + oidc: + config: + id_token_provider_aliases: + google: 123 + default_browser_return_url: "#/definitions/defaultReturnTo" + +dsn: foo + +identity: + schemas: + - id: default + url: https://example.com + +session: + cookie: + domain: foobar diff --git a/test/schema/fixtures/config.schema.test.success/root.idTokenProviderAliases.yaml b/test/schema/fixtures/config.schema.test.success/root.idTokenProviderAliases.yaml new file mode 100644 index 000000000000..258b0ff2ae8c --- /dev/null +++ b/test/schema/fixtures/config.schema.test.success/root.idTokenProviderAliases.yaml @@ -0,0 +1,22 @@ +serve: + public: + base_url: "#/definitions/baseUrl" + +selfservice: + methods: + oidc: + config: + id_token_provider_aliases: + google: google-goodsystems + default_browser_return_url: "#/definitions/defaultReturnTo" + +dsn: foo + +identity: + schemas: + - id: default + url: https://example.com + +session: + cookie: + domain: foobar