From 08908a6f7b7ce88a61f301620b1ebd55f9e15b05 Mon Sep 17 00:00:00 2001 From: Simon Eisenmann Date: Tue, 24 Mar 2026 15:29:25 +0100 Subject: [PATCH 1/2] Add per-client external authorize redirect URIs Allow registered clients to configure external_authorize_redirect_uris in the identifier registration YAML. When set, the OIDC authorize flow redirects to the configured external URL for login instead of the built-in sign-in form. Entries support optional scope prefixes (Scope:URL format) where scope-specific matches take precedence over the default URI. --- identifier-registration.yaml.in | 14 ++++++++ identity/clients/models.go | 62 ++++++++++++++++++++++++++++++++- identity/clients/registry.go | 5 +-- identity/managers/identifier.go | 14 +++++++- 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/identifier-registration.yaml.in b/identifier-registration.yaml.in index 31796a7a..30eab192 100644 --- a/identifier-registration.yaml.in +++ b/identifier-registration.yaml.in @@ -21,6 +21,20 @@ clients: # origins: # - https://my-host:8509 +# - id: playground-trusted.js +# name: Trusted OIDC Playground with External Login +# trusted: yes +# application_type: web +# redirect_uris: +# - https://my-host:8509/ +# origins: +# - https://my-host:8509 +# external_authorize_redirect_uris: +# # Default external login URI used for any scope. +# - https://my-external-login:8443/authorize +# # Scope-specific URI used when the given scope is requested. +# - MyApp.Special:https://my-external-login:8443/authorize-special + # - id: playground-trusted.js # name: Trusted Insecure OIDC Playground # trusted: yes diff --git a/identity/clients/models.go b/identity/clients/models.go index 5a167e5a..663c8142 100644 --- a/identity/clients/models.go +++ b/identity/clients/models.go @@ -23,6 +23,8 @@ import ( "crypto/subtle" "encoding/base64" "fmt" + "net/url" + "strings" "time" "github.com/golang-jwt/jwt/v5" @@ -52,7 +54,8 @@ type ClientRegistration struct { TrustedScopes []string `yaml:"trusted_scopes" json:"-"` Insecure bool `yaml:"insecure" json:"-"` - ImplicitScopes []string `yaml:"implicit_scopes" json:"-"` + ImplicitScopes []string `yaml:"implicit_scopes" json:"-"` + ExternalAuthorizeRedirectURIs []string `yaml:"external_authorize_redirect_uris,flow" json:"-"` Dynamic bool `yaml:"-" json:"-"` IDIssuedAt time.Time `yaml:"-" json:"-"` @@ -81,6 +84,23 @@ type ClientRegistration struct { // Validate validates the associated client registration data and returns error // if the data is not valid. func (cr *ClientRegistration) Validate() error { + for _, entry := range cr.ExternalAuthorizeRedirectURIs { + uri := entry + // Strip scope prefix if present using the colon heuristic. + if idx := strings.Index(entry, ":"); idx > 0 { + rest := entry[idx+1:] + if !strings.HasPrefix(rest, "//") { + uri = rest + } + } + parsed, err := url.Parse(uri) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("invalid external_authorize_redirect_uri: %v", entry) + } + if parsed.Scheme != "https" { + return fmt.Errorf("external_authorize_redirect_uri must use https: %v", entry) + } + } return nil } @@ -200,6 +220,46 @@ func (cr *ClientRegistration) ApplyImplicitScopes(scopes map[string]bool) error return nil } +// GetExternalAuthorizeRedirectURI returns the external authorize redirect URI +// for the given scopes. A scope-specific match takes precedence over the +// default. Returns empty string if none is configured. +func (cr *ClientRegistration) GetExternalAuthorizeRedirectURI(scopes map[string]bool) string { + if len(cr.ExternalAuthorizeRedirectURIs) == 0 { + return "" + } + + var defaultURI string + scopedURIs := make(map[string]string) + for _, entry := range cr.ExternalAuthorizeRedirectURIs { + if idx := strings.Index(entry, ":"); idx > 0 { + rest := entry[idx+1:] + // If the remainder starts with "//", this is a plain URL with + // no scope prefix (e.g. https://...). + if !strings.HasPrefix(rest, "//") { + scopedURIs[entry[:idx]] = rest + continue + } + } + if defaultURI == "" { + defaultURI = entry + } + } + + // Try to find a scope-specific match. + if scopes != nil { + for scope, ok := range scopes { + if !ok { + continue + } + if u, found := scopedURIs[scope]; found { + return u + } + } + } + + return defaultURI +} + func (cr *ClientRegistration) makeSecret(secret []byte) (string, string, error) { // Create random secret. HMAC the client name with it to get the subject. if secret == nil { diff --git a/identity/clients/registry.go b/identity/clients/registry.go index c30a0830..d91aeb80 100644 --- a/identity/clients/registry.go +++ b/identity/clients/registry.go @@ -94,8 +94,9 @@ func NewRegistry(ctx context.Context, trustedURI *url.URL, registrationConfFilep "trusted": client.Trusted, "insecure": client.Insecure, "application_type": client.ApplicationType, - "redirect_uris": client.RedirectURIs, - "origins": client.Origins, + "redirect_uris": client.RedirectURIs, + "origins": client.Origins, + "external_authorize_redirect_uris": client.ExternalAuthorizeRedirectURIs, } if validateErr != nil { diff --git a/identity/managers/identifier.go b/identity/managers/identifier.go index 156d2743..0fefbae4 100644 --- a/identity/managers/identifier.go +++ b/identity/managers/identifier.go @@ -122,6 +122,18 @@ func (im *IdentifierIdentityManager) RegisterManagers(mgrs *managers.Managers) e return im.identifier.RegisterManagers(mgrs) } +// getSignInFormURI returns the sign-in form URI for the given client and +// scopes. If the client has a configured external authorize redirect URI, it +// is returned. Otherwise the default sign-in form URI is used. +func (im *IdentifierIdentityManager) getSignInFormURI(clientID string, scopes map[string]bool) string { + if registration, ok := im.clients.Get(context.Background(), clientID); ok && registration != nil { + if uri := registration.GetExternalAuthorizeRedirectURI(scopes); uri != "" { + return uri + } + } + return im.signInFormURI +} + // Authenticate implements the identity.Manager interface. func (im *IdentifierIdentityManager) Authenticate(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, next identity.Manager) (identity.AuthRecord, error) { var user *identifierUser @@ -254,7 +266,7 @@ func (im *IdentifierIdentityManager) Authenticate(ctx context.Context, rw http.R query.Set("claims_scope", strings.Join(claimsScopes, " ")) } } - u, _ := url.Parse(im.signInFormURI) + u, _ := url.Parse(im.getSignInFormURI(ar.ClientID, ar.Scopes)) u.RawQuery = query.Encode() utils.WriteRedirect(rw, http.StatusFound, u, nil, false) From a3836ada6652cc040178c97e6551dfc2d9f4b98f Mon Sep 17 00:00:00 2001 From: Simon Eisenmann Date: Fri, 27 Mar 2026 16:43:11 +0100 Subject: [PATCH 2/2] Fix Go formatting treewide --- identifier/models.go | 2 +- identity/clients/registry.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/identifier/models.go b/identifier/models.go index 34d02abf..6cfb310f 100644 --- a/identifier/models.go +++ b/identifier/models.go @@ -158,7 +158,7 @@ type Consent struct { } // Scopes returns the associated consents approved scopes filtered by the -//provided requested scopes and the full unfiltered approved scopes table. +// provided requested scopes and the full unfiltered approved scopes table. func (c *Consent) Scopes(requestedScopes map[string]bool) (map[string]bool, map[string]bool) { scopes := make(map[string]bool) if c.RawScope != "" { diff --git a/identity/clients/registry.go b/identity/clients/registry.go index d91aeb80..b380e598 100644 --- a/identity/clients/registry.go +++ b/identity/clients/registry.go @@ -89,11 +89,11 @@ func NewRegistry(ctx context.Context, trustedURI *url.URL, registrationConfFilep validateErr := client.Validate() registerErr := r.Register(client) fields := logrus.Fields{ - "client_id": client.ID, - "with_client_secret": client.Secret != "", - "trusted": client.Trusted, - "insecure": client.Insecure, - "application_type": client.ApplicationType, + "client_id": client.ID, + "with_client_secret": client.Secret != "", + "trusted": client.Trusted, + "insecure": client.Insecure, + "application_type": client.ApplicationType, "redirect_uris": client.RedirectURIs, "origins": client.Origins, "external_authorize_redirect_uris": client.ExternalAuthorizeRedirectURIs,