diff --git a/README.md b/README.md index 8921311..61182b3 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md). | `logoutURL` | `callbackURL + "/logout"` | RP-initiated logout path. | | `postLogoutRedirectURI` | `/` | Where to send users after logout. | | `scopes` | appended to `openid profile email` | Extra OAuth scopes. Set `overrideScopes: true` to replace defaults. | +| `extraAuthParams` | none | Map of extra query parameters appended to the authorization request (e.g. `screen_hint: signup`, `login_hint`, `ui_locales`, `prompt`). Plugin-managed params (`client_id`, `state`, `nonce`, `redirect_uri`, `code_challenge`, `scope`, `response_type`, …) cannot be overridden. | | `excludedURLs` | none | Prefix-matched paths that bypass auth. | | `allowedUserDomains` | none | Restrict to email domains. | | `allowedUsers` | none | Restrict to specific addresses (or claim values when `userIdentifierClaim != email`). | diff --git a/main.go b/main.go index 5b8fc31..55ba97d 100644 --- a/main.go +++ b/main.go @@ -202,6 +202,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name }(), forceHTTPS: config.ForceHTTPS, enablePKCE: config.EnablePKCE, + extraAuthParams: config.ExtraAuthParams, overrideScopes: config.OverrideScopes, strictAudienceValidation: config.StrictAudienceValidation, allowOpaqueTokens: config.AllowOpaqueTokens, diff --git a/settings.go b/settings.go index c4f34b7..4f0d70f 100644 --- a/settings.go +++ b/settings.go @@ -54,6 +54,7 @@ type Config struct { AllowedUserDomains []string `json:"allowedUserDomains"` AllowedUsers []string `json:"allowedUsers"` Headers []TemplatedHeader `json:"headers"` + ExtraAuthParams map[string]string `json:"extraAuthParams,omitempty"` RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"` // MaxRefreshTokenAgeSeconds is a heuristic upper bound on the lifetime of // a stored refresh token. Once the token has been in the session longer diff --git a/types.go b/types.go index e884568..96aca8c 100644 --- a/types.go +++ b/types.go @@ -165,6 +165,7 @@ type TraefikOidc struct { frontchannelLogoutPath string scopesSupported []string scopes []string + extraAuthParams map[string]string refreshGracePeriod time.Duration maxRefreshTokenAge time.Duration metadataMu sync.RWMutex diff --git a/url_helpers.go b/url_helpers.go index d2d1772..6b077c8 100644 --- a/url_helpers.go +++ b/url_helpers.go @@ -146,6 +146,21 @@ func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge stri t.logger.Debugf("TraefikOidc.buildAuthURL: Final scope string being sent to OIDC provider: %s", finalScopeString) } + // Apply operator-configured extra authorization parameters (e.g. + // screen_hint, login_hint, ui_locales, prompt). These are added last but + // can never override parameters the plugin itself manages (client_id, + // state, nonce, redirect_uri, code_challenge, scope, response_type, ...): + // a key already present in params is left untouched, so this cannot + // weaken security-critical parameters. + for key, value := range t.extraAuthParams { + if params.Get(key) == "" { + params.Set(key, value) + t.logger.Debugf("TraefikOidc.buildAuthURL: Added extra auth param %s", key) + } else { + t.logger.Debugf("TraefikOidc.buildAuthURL: Skipped extra auth param %s (already set by plugin)", key) + } + } + // Read authURL with RLock t.metadataMu.RLock() authURL := t.authURL diff --git a/url_helpers_ultra_test.go b/url_helpers_ultra_test.go index 29a52a8..5bcd231 100644 --- a/url_helpers_ultra_test.go +++ b/url_helpers_ultra_test.go @@ -554,3 +554,54 @@ func TestForceHTTPSIntegration(t *testing.T) { "should use https from X-Forwarded-Proto when forceHTTPS is false") }) } + +// TestBuildAuthURLExtraAuthParams verifies operator-configured extra +// authorization parameters are appended to the authorization URL, and that +// they can never override parameters the plugin itself manages. +func TestBuildAuthURLExtraAuthParams(t *testing.T) { + t.Run("extra params are added (e.g. screen_hint=signup)", func(t *testing.T) { + middleware := createMinimalMiddleware() + middleware.extraAuthParams = map[string]string{ + "screen_hint": "signup", + "ui_locales": "en", + } + + authURL := middleware.buildAuthURL( + "https://app.com/callback", "state123", "nonce456", "", + ) + + assert.Contains(t, authURL, "screen_hint=signup") + assert.Contains(t, authURL, "ui_locales=en") + }) + + t.Run("nil/empty extraAuthParams is a no-op", func(t *testing.T) { + middleware := createMinimalMiddleware() + // extraAuthParams left nil + authURL := middleware.buildAuthURL( + "https://app.com/callback", "state123", "nonce456", "", + ) + + assert.Contains(t, authURL, "client_id=test-client") + assert.NotContains(t, authURL, "screen_hint") + }) + + t.Run("extra params CANNOT override plugin-managed params", func(t *testing.T) { + middleware := createMinimalMiddleware() + middleware.extraAuthParams = map[string]string{ + "client_id": "ATTACKER", + "state": "ATTACKER", + "redirect_uri": "https://evil.example.com", + "response_type": "token", + } + + authURL := middleware.buildAuthURL( + "https://app.com/callback", "state123", "nonce456", "", + ) + + // Plugin-managed values must win; injected values must be absent. + assert.Contains(t, authURL, "client_id=test-client") + assert.NotContains(t, authURL, "ATTACKER") + assert.NotContains(t, authURL, "evil.example.com") + assert.Contains(t, authURL, "response_type=code") + }) +}