Skip to content
Closed
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
9 changes: 9 additions & 0 deletions embedx/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
139 changes: 139 additions & 0 deletions selfservice/strategy/oidc/id_token_provider_alias_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
}
6 changes: 4 additions & 2 deletions selfservice/strategy/oidc/provider_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 !!!
Expand Down
9 changes: 9 additions & 0 deletions selfservice/strategy/oidc/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
}
17 changes: 12 additions & 5 deletions selfservice/strategy/oidc/strategy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion selfservice/strategy/oidc/strategy_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion selfservice/strategy/oidc/strategy_registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading