Files
certctl/internal/auth/oidc/service.go
T
shankar0123 fefeccfa59 harden(oidc): relax alg-downgrade IdP-bind check to intersection-empty (Keycloak compat)
Phase-10 live-IdP smoke (Keycloak 26.x via testcontainers-go) revealed
the IdP-bind alg-downgrade check was too strict for real-world IdPs.
6 of the integration tests in internal/auth/oidc/integration_keycloak*_test.go
were failing with:

  oidc: IdP advertises weak signing algorithms (HS*/none);
  refusing to use as defense against downgrade attacks: HS256

Keycloak 26.x (and several other real-world IdPs — Auth0 when HS-mode is
enabled, some Authentik configs) advertise EVERY alg they're capable of
in the discovery doc's id_token_signing_alg_values_supported field, even
when the realm only signs with RS256 in practice. Pre-fix the IdP-bind
check refused on ANY HS* or 'none' advertisement → no real Keycloak deploy
could ever bind a provider row, hence the integration-test failures.

The strict-deny check was defense-in-depth on top of the load-bearing
per-token alg-pin at sig-verify time (isDisallowedAlg, service.go L1177):
that check rejects every ID token whose JWS header carries an alg outside
DefaultAllowedAlgs, regardless of what the discovery doc advertises.
A forged HS256 token signed with the IdP's RS256 pubkey as HMAC secret
is rejected at sig-verify time → the actual algorithm-confusion attack
is closed by the per-token pin, NOT by the discovery-doc check.

Fix: relax the IdP-bind check to refuse only when the intersection of
advertised vs DefaultAllowedAlgs is EMPTY (the pathological all-weak-alg
IdP case). Keycloak (RS256 + HS256 advertised) now binds successfully;
an HS-only IdP still fails closed.

Changes:
- internal/auth/oidc/service.go: rewrite the alg-check loop at L1067 in
  getOrLoad / RefreshKeys to compute the intersection set; refuse only
  when no acceptable alg is advertised. ErrIdPDowngradeAdvertised
  docstring updated to reflect new contract. DefaultAllowedAlgs
  docstring + the package-level design-comment block at L40-72 updated
  with v2.1.0-relaxed semantics callouts.
- internal/auth/oidc/test_discovery.go: TestDiscovery dry-run validator
  rewritten to surface HS*/none alongside RS* as an informational note
  ('note: IdP advertises weak algorithms %v alongside acceptable ones')
  rather than a hard-fail error. HS-only / none-only still hard-fails.
- internal/auth/oidc/service_test.go: TestService_IdPDowngradeDefense_*
  tests updated. Renamed:
  - RejectsHSAdvertised → RS256PlusHS256_BindsSuccessfully (positive)
  - RejectsNoneAdvertised → RejectsHSOnlyAdvertised (intersection-empty)
  - RefreshKeys_CatchesPostLoadDowngrade rotated to HS-only post-load
- internal/auth/oidc/coverage_fill_test.go: TestTestDiscovery_AlgDowngradeDetected
  split into _HS256AlongsideRS256_BindsWithNote (positive, asserts note
  but no hard-fail) + _HSOnly_StillTrips_HardFail (intersection-empty).
- docs/operator/auth-threat-model.md: OIDC token-validation alg-allow-list
  section rewritten to call out the load-bearing-defense hierarchy
  (per-token pin first, IdP-bind check defense-in-depth) and document
  the v2.1.0 relaxation rationale.
- CHANGELOG.md: ### Security entry under Unreleased.

Verify: go test ./internal/auth/oidc/ -short PASS; gofmt clean; go vet
clean. The Keycloak integration tests should now pass when the operator
re-runs 'make keycloak-integration-test'.
2026-05-11 15:34:59 +00:00

1354 lines
55 KiB
Go

package oidc
import (
"context"
cryptorand "crypto/rand"
"crypto/sha256"
"crypto/sha512"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"hash"
"strings"
"sync"
"time"
gooidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
"github.com/certctl-io/certctl/internal/auth/oidc/groupclaim"
userdomain "github.com/certctl-io/certctl/internal/auth/user/domain"
"github.com/certctl-io/certctl/internal/crypto"
"github.com/certctl-io/certctl/internal/repository"
)
// =============================================================================
// Auth Bundle 2 / Phase 3 / OIDC Service
//
// The Service implements the certctl side of the OpenID Connect 1.0
// authorization-code flow with PKCE-S256 (RFC 7636), against any IdP
// that satisfies the OIDC discovery doc + JWKS contract. Token
// validation enforces every fail-closed check from OIDC core
// §3.1.3.7 plus the operator-policy gates (alg allow-list, audience,
// `azp` for multi-aud tokens, `at_hash` when access tokens are
// returned, `iat` window, `nonce`, single-use state).
//
// Security posture:
//
// 1. JWKS endpoints MUST be HTTPS (validated at provider creation
// by the domain layer; transport never weakened).
// 2. PKCE S256 is REQUIRED on every login per RFC 9700 §2.1.1;
// the `plain` challenge method is rejected.
// 3. State is server-generated random 32 bytes (256 bits of
// entropy), single-use, stored in the pre-login session row.
// 4. Nonce is server-generated random 32 bytes, single-use,
// stored in the pre-login session row, validated against the
// ID token nonce claim via constant-time compare.
// 5. Algorithms are pinned to an allow-list (default: RS256, RS512,
// ES256, ES384, EdDSA). HS256/HS384/HS512 are NEVER allowed
// (HMAC + JWKS is alg confusion); `none` is NEVER allowed.
// 6. IdP downgrade-attack defense: at provider creation /
// RefreshKeys, the discovery doc's
// `id_token_signing_alg_values_supported` is intersected with
// the allow-list. The provider is rejected only when the
// intersection is EMPTY (i.e., the IdP advertises NO acceptable
// signing alg). Real IdPs like Keycloak advertise the full list
// of algorithms they're capable of including HS*, but actually
// sign with RS256; the per-token alg check at sig-verify time
// (line ~1177 `isDisallowedAlg`) is the load-bearing defense
// against an actual algorithm-confusion attack. Pre-v2.1.0 this
// check was strict-deny on any HS* advertisement; v2.1.0 relaxed
// to the intersection-empty form so Keycloak / other real IdPs
// bind successfully.
// 7. JWKS handling delegated to coreos/go-oidc/v3; on JWKS fetch
// failure during a key rotation the service returns
// ErrJWKSUnreachable (HTTP 503), existing sessions untouched,
// no exponential backoff.
// 8. Token-leak hygiene: ID tokens, access tokens, refresh tokens,
// authorization codes, PKCE verifiers, state, nonce, and any
// signing key bytes MUST NEVER be logged. The service contains
// ZERO log statements that include these values; tests in
// logging_test.go pin the invariant.
// =============================================================================
// Service implements the OIDC integration.
type Service struct {
providers OIDCProviderLookup
mappings repository.GroupRoleMappingRepository
users repository.UserRepository
sessions SessionMinter
preLogin PreLoginStore
encryptionKey string // CERTCTL_CONFIG_ENCRYPTION_KEY for client_secret decrypt
mu sync.RWMutex
cache map[string]*providerEntry // keyed by provider ID
clockNow func() time.Time // injectable for tests
// adminBootstrapHook is the optional Phase 7 first-admin bootstrap
// closure. When set, HandleCallback consults it after group
// resolution + user upsert; on grantAdmin=true the user's resolved
// role IDs are extended with r-admin. See bootstrap_hook.go.
adminBootstrapHook AdminBootstrapHook
// Audit 2026-05-10 MED-16 — Per-leg toggles for the pre-login UA/IP
// binding check. Both default to true; operators on enterprise
// proxies (UA rewrite) or dual-stack v4/v6 (IP flip) flip the
// affected leg false via CERTCTL_OIDC_PRELOGIN_REQUIRE_UA /
// CERTCTL_OIDC_PRELOGIN_REQUIRE_IP. Even when both are false,
// the binding values are still persisted so audit forensics can
// detect mismatches retroactively — only the in-band reject is
// suppressed.
preLoginRequireUA bool
preLoginRequireIP bool
}
// providerEntry caches the go-oidc Provider + the OAuth2 config + the
// IdP-advertised algs (used for the downgrade-attack defense check on
// every RefreshKeys). The Provider's internal JWKS cache handles
// rotation transparently.
type providerEntry struct {
cfgRow *oidcdomain.OIDCProvider
provider *gooidc.Provider
verifier *gooidc.IDTokenVerifier
oauthConfig *oauth2.Config
allowedAlgs []string // intersected: domain config ∩ allow-list ∩ IdP-advertised
plaintext []byte // decrypted client secret; held for token exchange
// Audit 2026-05-10 MED-17 — RFC 9207 iss-URL-parameter support.
// Populated from the discovery doc's
// `authorization_response_iss_parameter_supported` claim during
// getOrLoad. When true, HandleCallback REQUIRES a non-empty
// callback iss URL param and compares it against the provider's
// IssuerURL. When false (the default for most IdPs that haven't
// rolled RFC 9207 yet), the check is skipped.
issParamSupported bool
// Audit 2026-05-10 MED-7 — JWKS health counters surfaced via
// /api/v1/auth/oidc/providers/{id}/jwks-status. statsMu guards
// the four counters. Each is updated under the write-lock from
// RefreshKeys + HandleCallback's verify path.
statsMu sync.Mutex
lastRefreshAt time.Time
refreshCount int
lastError string
rejectedJWSCount int
}
// OIDCProviderLookup is a narrow read-side projection of
// repository.OIDCProviderRepository — service.go only ever reads
// providers; mutations go through the repo from the handler / GUI side.
// Defined here so test mocks can satisfy the smaller surface.
type OIDCProviderLookup interface {
Get(ctx context.Context, id string) (*oidcdomain.OIDCProvider, error)
List(ctx context.Context, tenantID string) ([]*oidcdomain.OIDCProvider, error)
}
// PreLoginStore wraps the pre-login session row that holds state +
// nonce + PKCE verifier across the IdP redirect. Phase 4's
// SessionService satisfies this interface; Phase 3 defines it so the
// Service can be unit-tested without the full session machinery.
type PreLoginStore interface {
// CreatePreLogin persists a row with the given identifiers.
// providerID is the configured op-... id; state, nonce, verifier
// are server-generated random strings the callback will validate.
// clientIP + userAgent (Audit 2026-05-10 MED-16) are the
// /auth/oidc/login request's source IP (post LOW-5 XFF gating) +
// User-Agent header, persisted into the row so HandleCallback can
// reject mismatches at consume time. Empty strings are tolerated
// (rolling-deploy compat + headless / proxy contexts) — the
// consume-side check only enforces when both sides carry non-empty
// values for the leg in question.
// Returns the opaque cookie value the handler sets, plus the
// session ID (used as the audit trail anchor).
CreatePreLogin(ctx context.Context, providerID, state, nonce, verifier, clientIP, userAgent string) (cookieValue, sessionID string, err error)
// LookupAndConsume reads the pre-login row by cookie value AND
// deletes it atomically. Single-use: a second call with the same
// cookie value returns ErrPreLoginNotFound. Returns the stored
// state/nonce/verifier/providerID for the caller to validate
// against the callback parameters.
//
// Audit 2026-05-10 MED-16 — also returns the row's persisted
// clientIP + userAgent so HandleCallback can defeat replay of a
// stolen pre-login cookie by a different browser. Empty values are
// returned for rows persisted before migration 000044.
LookupAndConsume(ctx context.Context, cookieValue string) (providerID, state, nonce, verifier, clientIP, userAgent string, err error)
}
// SessionMinter wraps the post-login session creation. Phase 4's
// SessionService satisfies this. Defined here so the OIDC service
// can be unit-tested independently of session signing.
type SessionMinter interface {
// MintForUser creates a post-login session for the named user.
// Returns the cookie value the handler sets and a CSRF token
// the GUI echoes into the X-CSRF-Token header on POSTs.
MintForUser(ctx context.Context, user *userdomain.User, roleIDs []string, ip, userAgent string) (cookieValue, csrfToken string, err error)
}
// IDGenerator returns a new opaque session id. Defaults to 32 random
// bytes base64url-no-pad-encoded. Injectable for tests.
type IDGenerator func() (string, error)
// Service-layer sentinels. Handler-layer translates to HTTP status.
var (
// ErrPreLoginNotFound: the pre-login cookie doesn't match a row.
// Either the row was already consumed (replay) or never existed
// (forged cookie). HTTP 400.
ErrPreLoginNotFound = errors.New("oidc: pre-login session not found or already consumed")
// ErrStateMismatch: callback `state` differs from the stored
// pre-login state. HTTP 400.
ErrStateMismatch = errors.New("oidc: state parameter mismatch (replay or forgery)")
// ErrNonceMismatch: ID token `nonce` differs from the stored
// pre-login nonce. HTTP 400.
ErrNonceMismatch = errors.New("oidc: nonce mismatch")
// ErrIssuerMismatch: ID token `iss` doesn't match the configured
// provider issuer_url. HTTP 400.
//
// Audit 2026-05-10 MED-17 — also returned when the RFC 9207 iss
// URL parameter check fails (provider advertises
// authorization_response_iss_parameter_supported=true but the
// callback iss is missing or mismatched). The handler's
// classifyOIDCFailure breaks the two cases apart by audit
// failure_category (iss_param_missing / iss_param_mismatch /
// id_token_iss_mismatch).
ErrIssuerMismatch = errors.New("oidc: issuer mismatch")
// ErrIssParamMissing: provider's discovery doc advertises
// authorization_response_iss_parameter_supported=true but the
// callback URL had no `iss` query parameter. Per RFC 9207 §2.4 the
// client MUST reject the response in this case. HTTP 400. Pre-fix,
// the callback path ignored the parameter entirely.
ErrIssParamMissing = errors.New("oidc: provider advertises iss-parameter support but callback omitted it")
// ErrIssParamMismatch: provider's discovery doc advertises
// authorization_response_iss_parameter_supported=true and the
// callback supplied an `iss` query parameter, but it doesn't match
// the matched provider's issuer URL. Mixed-up attack defense per
// RFC 9207 §2.3. HTTP 400.
ErrIssParamMismatch = errors.New("oidc: callback iss parameter does not match provider issuer URL")
// ErrPreLoginUAMismatch: pre-login row's User-Agent doesn't match
// the request hitting /auth/oidc/callback. Audit 2026-05-10 MED-16
// closure — RFC 9700 §4.7.1 binding-state recommendation. HTTP 400.
// Operators on enterprise proxies that rewrite UA may set
// CERTCTL_OIDC_PRELOGIN_REQUIRE_UA=false to disable.
ErrPreLoginUAMismatch = errors.New("oidc: pre-login row User-Agent does not match callback request")
// ErrPreLoginIPMismatch: pre-login row's client IP doesn't match
// the request hitting /auth/oidc/callback. Audit 2026-05-10
// MED-16. HTTP 400. Operators on dual-stack v4/v6 environments
// where source IP routinely flips may set
// CERTCTL_OIDC_PRELOGIN_REQUIRE_IP=false to disable.
ErrPreLoginIPMismatch = errors.New("oidc: pre-login row client IP does not match callback request")
// ErrPreLoginUAMissing: the pre-login row carries a User-Agent
// binding (MED-16) but the /auth/oidc/callback request omitted
// the User-Agent header. Audit 2026-05-11 A-6 closure — the
// original MED-16 logic short-circuited the compare when the
// request side was empty, which let an attacker bypass the
// binding by sending a callback with no User-Agent (trivial:
// curl, many programmatic clients omit the header by default).
// Distinguished from ErrPreLoginUAMismatch so the audit row
// can tell a binding violation apart from a missing-header
// bypass attempt. HTTP 400. Operators on enterprise proxies
// that strip the User-Agent header in transit can disable the
// check with CERTCTL_OIDC_PRELOGIN_REQUIRE_UA=false.
ErrPreLoginUAMissing = errors.New("oidc: pre-login row has User-Agent binding but callback omitted User-Agent header")
// ErrPreLoginIPMissing: symmetric to ErrPreLoginUAMissing for
// the source IP binding. Reachable when XFF-trust gating zeros
// the resolved client IP for a request whose pre-login row
// captured one. Audit 2026-05-11 A-6 closure.
ErrPreLoginIPMissing = errors.New("oidc: pre-login row has client-IP binding but callback request had no resolvable client IP")
// ErrAudienceMismatch: ID token `aud` doesn't include the
// configured client_id. HTTP 400.
ErrAudienceMismatch = errors.New("oidc: audience mismatch")
// ErrAZPRequired: ID token has multi-valued aud but no `azp`
// claim. Per OIDC core §3.1.3.7 step 5, `azp` MUST be present
// when there are multiple audiences. HTTP 400.
ErrAZPRequired = errors.New("oidc: multi-aud ID token missing required azp claim")
// ErrAZPMismatch: ID token `azp` doesn't equal client_id. HTTP 400.
ErrAZPMismatch = errors.New("oidc: azp claim does not match client_id")
// ErrATHashMismatch: ID token `at_hash` doesn't match the
// re-computed hash of the access token. HTTP 400.
ErrATHashMismatch = errors.New("oidc: at_hash claim does not match access token")
// ErrATHashRequired: an access token was returned alongside the ID
// token but the ID token carries no `at_hash` claim. Per the Phase 3
// spec (OIDC core §3.1.3.6 + §3.2.2.9), at_hash is REQUIRED in this
// case so a substituted access token can be detected. Fail closed.
// HTTP 400.
ErrATHashRequired = errors.New("oidc: access_token present but ID token has no at_hash claim")
// ErrTokenExpired: ID token `exp` is in the past (with 60s
// clock-skew tolerance). HTTP 400.
ErrTokenExpired = errors.New("oidc: ID token expired")
// ErrIATInFuture: ID token `iat` is in the future beyond the 60s
// skew tolerance. HTTP 400.
ErrIATInFuture = errors.New("oidc: ID token iat is in the future")
// ErrIATTooOld: ID token `iat` is older than the configured
// IATWindow. HTTP 400.
ErrIATTooOld = errors.New("oidc: ID token iat older than configured window")
// ErrAlgRejected: ID token signed with an alg outside the
// allow-list. HTTP 400.
ErrAlgRejected = errors.New("oidc: ID token signed with disallowed algorithm")
// ErrIdPDowngradeAdvertised: provider's discovery doc lists NO
// alg from DefaultAllowedAlgs in `id_token_signing_alg_values_
// supported`. v2.1.0-relaxed semantics: an IdP that advertises
// HS* / none ALONGSIDE RS256+ binds successfully; only an IdP
// that advertises ZERO acceptable algs fails closed. The
// per-token alg check at sig-verify time (isDisallowedAlg) is
// the load-bearing defense against an actual algorithm-confusion
// attack. Provider creation / refresh rejects only when the
// intersection is empty. HTTP 400.
ErrIdPDowngradeAdvertised = errors.New("oidc: IdP advertises no acceptable signing algorithms (intersection of advertised vs DefaultAllowedAlgs is empty)")
// ErrJWKSUnreachable: JWKS endpoint fetch failed during a
// rotation. The in-flight login fails 503; existing sessions
// untouched.
ErrJWKSUnreachable = errors.New("oidc: JWKS endpoint unreachable; in-flight login fails, existing sessions untouched")
// ErrGroupsMissing: the configured groups_claim_path resolves
// to nothing or is malformed. Phase 3 fails closed.
ErrGroupsMissing = errors.New("oidc: configured groups claim missing or malformed")
// ErrEmailDomainNotAllowed: the configured
// OIDCProvider.AllowedEmailDomains list is non-empty but the
// authenticated user's email domain isn't in it. CRIT-5 closure
// of the 2026-05-10 audit (pre-fix, the field was persisted +
// surfaced through the API + MCP + GUI but never read here).
// Operator-facing: configure the IdP to issue tokens for only
// the right tenants, or add the domain to the provider's
// allowed_email_domains list.
ErrEmailDomainNotAllowed = errors.New("oidc: email domain not in allowlist")
// ErrEmailMissingButRequired: AllowedEmailDomains is set on the
// provider but the ID token / userinfo response did not surface
// an email claim. Operator-facing: ensure the IdP scope set
// includes `email` and the IdP releases the claim.
ErrEmailMissingButRequired = errors.New("oidc: provider requires email but token has none")
// ErrProviderDisabled signals the operator has flipped
// OIDCProvider.Enabled=false on the matched provider. HandleAuthRequest
// rejects with this sentinel so the LoginPage doesn't initiate a
// handshake; AuthInfo's provider list filters disabled providers
// out so the LoginPage button doesn't appear in the first place.
// Audit 2026-05-10 MED-9 closure.
ErrProviderDisabled = errors.New("oidc: provider is disabled")
// ErrUserDeactivated signals the federated user row's
// `deactivated_at` is non-NULL. Audit 2026-05-11 A-2 closure —
// the deactivate flow at internal/api/handler/auth_users.go::Deactivate
// sets the column + cascade-revokes sessions, but pre-fix the OIDC
// login path never consulted it, so the very next login re-elevated
// the deactivated user. The check fires inside upsertUser before
// Update bumps last_login_at; an attempt to log in via OIDC after
// deactivation surfaces as audit `failure_category=user_deactivated`
// and the LoginPage's reason-aware error rendering shows the
// operator-friendly message.
//
// Reactivation surface: admin POSTs /api/v1/auth/users/{id}/reactivate
// (auth.user.deactivate perm) to clear the column.
ErrUserDeactivated = errors.New("oidc: user account is deactivated")
// ErrGroupsUnmapped: the user's groups don't match any of the
// operator's group_role_mappings for this provider. No session
// minted; audit row records auth.oidc_login_unmapped_groups.
ErrGroupsUnmapped = errors.New("oidc: groups did not match any configured mapping")
// ErrPKCEPlainRejected: somehow `plain` PKCE method got into
// the flow. Defense-in-depth; the service NEVER generates a plain
// verifier, but this sentinel exists in case a future code path
// regresses.
ErrPKCEPlainRejected = errors.New("oidc: PKCE method 'plain' is rejected; S256 is mandatory")
)
// DefaultAllowedAlgs is the operator-default ID-token signing algorithm
// allow-list. Configurable per-provider but the union must be a subset
// of this set. HMAC algorithms (HS256/HS384/HS512) and `none` are
// NEVER in the default set; sig-verify rejects any token whose `alg`
// header is outside this list. v2.1.0-relaxed: the IdP downgrade defense
// at provider creation only rejects a provider whose advertised list
// intersects this allow-list to the EMPTY set — Keycloak / Auth0 / etc.
// that advertise HS* alongside RS* bind successfully.
var DefaultAllowedAlgs = []string{
gooidc.RS256, gooidc.RS512,
gooidc.ES256, gooidc.ES384,
gooidc.EdDSA,
}
// disallowedAlgs is the explicit deny-list. Anything in this set
// fails the IdP downgrade check at provider creation / RefreshKeys
// AND fails the per-token alg check at HandleCallback time, even if
// the operator somehow added it to AllowedAlgs by hand.
var disallowedAlgs = map[string]struct{}{
"HS256": {},
"HS384": {},
"HS512": {},
"none": {},
}
// NewService constructs an OIDC Service.
func NewService(
providers OIDCProviderLookup,
mappings repository.GroupRoleMappingRepository,
users repository.UserRepository,
sessions SessionMinter,
preLogin PreLoginStore,
encryptionKey string,
) *Service {
return &Service{
providers: providers,
mappings: mappings,
users: users,
sessions: sessions,
preLogin: preLogin,
encryptionKey: encryptionKey,
cache: make(map[string]*providerEntry),
clockNow: time.Now,
// MED-16 defaults: both legs ON. cmd/server/main.go reads
// CERTCTL_OIDC_PRELOGIN_REQUIRE_UA / _IP and calls
// SetPreLoginBindingRequirements to override.
preLoginRequireUA: true,
preLoginRequireIP: true,
}
}
// SetClockForTest replaces the clock used for `iat`/`exp` checks. ONLY
// for tests; production paths read time.Now via the default.
func (s *Service) SetClockForTest(now func() time.Time) {
s.clockNow = now
}
// SetPreLoginBindingRequirements wires the MED-16 UA/IP enforcement
// toggles. Both default to true; set false to log-only behaviour for
// a given leg (the binding is still persisted + audited; only the
// in-band reject is suppressed). Called by cmd/server/main.go from
// the config layer.
func (s *Service) SetPreLoginBindingRequirements(requireUA, requireIP bool) {
s.preLoginRequireUA = requireUA
s.preLoginRequireIP = requireIP
}
// =============================================================================
// HandleAuthRequest: kicks off the OIDC handshake.
//
// Returns the IdP authorization URL (302 target), the cookie value to
// set for the pre-login session, and the pre-login session ID for the
// audit trail. The caller (HTTP handler) sets the cookie + redirects.
//
// PKCE-S256 is mandatory: a 43-128 character base64url-no-pad random
// verifier is generated, the challenge is the SHA-256 of the verifier
// base64url-encoded, the method is hard-coded `S256`. No code path in
// this service ever sets `code_challenge_method=plain`.
// =============================================================================
// HandleAuthRequest builds the IdP redirect URL + persists the
// pre-login session row holding state + nonce + PKCE verifier.
//
// Audit 2026-05-10 MED-16 — clientIP + userAgent are persisted into
// the pre-login row so HandleCallback can reject a stolen cookie
// replayed by a different browser. Empty values are tolerated for
// headless / proxy callers; the consume-side check only enforces
// when both row and request carry non-empty values on the leg in
// question.
func (s *Service) HandleAuthRequest(ctx context.Context, providerID, clientIP, userAgent string) (authURL, cookieValue, preLoginID string, err error) {
entry, err := s.getOrLoad(ctx, providerID)
if err != nil {
return "", "", "", err
}
// Audit 2026-05-10 MED-9 closure — refuse to mint a pre-login row
// for a disabled provider. The LoginPage's AuthInfo filter should
// already prevent the button from rendering, but defense-in-depth
// catches the direct-API/MCP/CLI invocation path too.
if entry.cfgRow != nil && !entry.cfgRow.Enabled {
return "", "", "", ErrProviderDisabled
}
state, err := randomB64URL(32)
if err != nil {
return "", "", "", fmt.Errorf("oidc: state generate: %w", err)
}
nonce, err := randomB64URL(32)
if err != nil {
return "", "", "", fmt.Errorf("oidc: nonce generate: %w", err)
}
// PKCE S256 verifier: 32 random bytes -> 43-char base64url-no-pad
// (well within the RFC 7636 43-128 character bound).
verifier := oauth2.GenerateVerifier()
cookieValue, preLoginID, err = s.preLogin.CreatePreLogin(ctx, providerID, state, nonce, verifier, clientIP, userAgent)
if err != nil {
return "", "", "", fmt.Errorf("oidc: pre-login store: %w", err)
}
// Build the IdP redirect URL. PKCE S256 is hard-coded via
// oauth2.S256ChallengeOption; nonce is added via OIDC's
// AuthCodeOption.
authURL = entry.oauthConfig.AuthCodeURL(
state,
oauth2.AccessTypeOnline,
oauth2.S256ChallengeOption(verifier),
oauth2.SetAuthURLParam("nonce", nonce),
)
return authURL, cookieValue, preLoginID, nil
}
// =============================================================================
// HandleCallback: completes the OIDC handshake and creates a session.
//
// Validates state, exchanges code for tokens (with PKCE verifier),
// validates ID token (alg pin, iss, aud, azp, at_hash, exp, iat,
// nonce), parses group claims, maps groups to roles, creates / updates
// the user record, mints a session.
//
// Every fail-closed branch returns one of the package-scoped sentinel
// errors so the handler can map to the right HTTP status without
// leaking which check failed (uniform 400 to the wire; specific
// reason in the audit row).
// =============================================================================
// CallbackResult is what HandleCallback returns to the handler. The
// handler sets cookieValue + csrfToken on the response and 302's to
// the GUI dashboard.
type CallbackResult struct {
User *userdomain.User
RoleIDs []string
CookieValue string // post-login session cookie
CSRFToken string // CSRF token for the GUI to echo into X-CSRF-Token
}
// HandleCallback completes the OIDC flow.
//
// Audit 2026-05-10 MED-17 — `callbackIss` is the value of the `iss`
// query parameter on /auth/oidc/callback, exactly as sent by the IdP.
// When the matched provider's discovery doc advertises
// authorization_response_iss_parameter_supported=true (RFC 9207 §3),
// we require this parameter and verify it equals the provider's
// IssuerURL. When the provider doesn't advertise support (the default
// for most IdPs that haven't rolled RFC 9207 yet), the parameter is
// ignored — preserving back-compat with the pre-fix call path.
func (s *Service) HandleCallback(
ctx context.Context,
preLoginCookie, code, callbackState, callbackIss, ip, userAgent string,
) (*CallbackResult, error) {
// Step 1: consume the pre-login row (single-use).
providerID, storedState, storedNonce, verifier, storedIP, storedUA, err := s.preLogin.LookupAndConsume(ctx, preLoginCookie)
if err != nil {
return nil, ErrPreLoginNotFound
}
// Step 1.5: Audit 2026-05-10 MED-16 — UA / IP binding compare.
// Enforced only when (a) the leg's toggle is on, (b) the row
// carries a non-empty stored value (legacy rows pre-migration
// 000044 have NULL → empty string), and (c) the incoming request
// carries a non-empty value too. Constant-time compares for both
// legs to avoid leaking UA/IP length differences via timing.
// Audit 2026-05-11 A-6 — strict-when-stored. The original MED-16
// closure short-circuited the compare when the request side was
// empty (`userAgent != ""` / `ip != ""`), which was an
// attacker-controllable bypass: an attacker forging a callback
// request can simply omit the User-Agent header (curl does this by
// default; many programmatic HTTP clients omit it) and the binding
// check skips silently. Now: when the pre-login row carries a
// binding, the request MUST present a matching value; an empty
// request value with a non-empty stored value rejects with
// ErrPreLoginUA{IP}Missing (distinct from the mismatch leg so the
// audit row can tell them apart). Legacy-row compat — pre-migration
// rows with `storedUA == ""` / `storedIP == ""` — still passes
// unchecked; that window is bounded by the 10-minute pre-login TTL,
// so within 10 minutes of the MED-16 deploy the strict path is
// universal.
if s.preLoginRequireUA && storedUA != "" {
if userAgent == "" {
return nil, ErrPreLoginUAMissing
}
if subtle.ConstantTimeCompare([]byte(userAgent), []byte(storedUA)) != 1 {
return nil, ErrPreLoginUAMismatch
}
}
if s.preLoginRequireIP && storedIP != "" {
if ip == "" {
return nil, ErrPreLoginIPMissing
}
if subtle.ConstantTimeCompare([]byte(ip), []byte(storedIP)) != 1 {
return nil, ErrPreLoginIPMismatch
}
}
// Step 2: state constant-time compare.
if subtle.ConstantTimeCompare([]byte(callbackState), []byte(storedState)) != 1 {
return nil, ErrStateMismatch
}
entry, err := s.getOrLoad(ctx, providerID)
if err != nil {
return nil, err
}
// Step 2.5 — Audit 2026-05-10 MED-17 — RFC 9207 iss URL parameter
// check. Only enforced when the provider advertised support in its
// discovery doc. Compares against the matched provider's
// IssuerURL (which is what go-oidc's gooidc.NewProvider verified
// the discovery doc's own iss against during getOrLoad). Mismatch
// is the load-bearing defense against mix-up attacks where the
// honest IdP returns the auth code to the wrong endpoint because
// of a malicious co-tenant relying-party.
if entry.issParamSupported {
if callbackIss == "" {
return nil, ErrIssParamMissing
}
if subtle.ConstantTimeCompare([]byte(callbackIss), []byte(entry.cfgRow.IssuerURL)) != 1 {
return nil, ErrIssParamMismatch
}
}
// Step 3: exchange the auth code for tokens (with PKCE verifier).
token, err := entry.oauthConfig.Exchange(ctx, code, oauth2.VerifierOption(verifier))
if err != nil {
return nil, fmt.Errorf("oidc: code exchange failed: %w", err)
}
// Step 4: extract + validate the ID token. NEVER log token here.
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || rawIDToken == "" {
return nil, fmt.Errorf("oidc: token response missing id_token")
}
idToken, err := entry.verifier.Verify(ctx, rawIDToken)
if err != nil {
// Audit 2026-05-10 MED-6 — JWKS auto-refresh on cache-miss.
// When the IdP rotated keys post-handshake-init (e.g. between
// the user's pre-login click and the callback), the verify
// fails with a "kid not in cache" / "key with id ... not
// found" / "signature verification failed" style error
// because the verifier holds a stale snapshot of the JWKS.
// One-shot recovery: force a RefreshKeys (which evicts +
// re-fetches discovery + JWKS) and retry the verify exactly
// once. No retry loop; a second failure surfaces as
// ErrJWKSUnreachable / generic verify error per the original
// branches below.
if isKidMismatchError(err) {
if rerr := s.RefreshKeys(ctx, providerID); rerr == nil {
// Re-fetch the entry (RefreshKeys evicted the cache).
if refreshed, gerr := s.getOrLoad(ctx, providerID); gerr == nil {
if retried, verr := refreshed.verifier.Verify(ctx, rawIDToken); verr == nil {
idToken = retried
err = nil
// fall through to Step 5 below.
} else {
err = verr
}
}
}
}
if err != nil {
// Map go-oidc's verify errors to ErrJWKSUnreachable when the
// underlying cause is a JWKS fetch failure; otherwise return
// the wrapped error for the handler to map to 400.
if isJWKSFetchError(err) {
return nil, ErrJWKSUnreachable
}
return nil, fmt.Errorf("oidc: id_token verify failed: %w", err)
}
}
// Step 5: alg pinning. go-oidc's verifier already enforces the
// allow-list we set in the config, but we re-check the header alg
// against our deny-list for belt-and-braces (defense vs an
// upstream library regression).
if rejected, alg := isDisallowedAlg(rawIDToken); rejected {
_ = alg // do not log
return nil, ErrAlgRejected
}
// Step 6: per-OIDC-core §3.1.3.7 claims checks beyond what
// gooidc.Verify covers.
now := s.clockNow().UTC()
// iss is verified by gooidc.Verify against entry.cfgRow.IssuerURL;
// re-check exactly to defend against a library regression.
if idToken.Issuer != entry.cfgRow.IssuerURL {
return nil, ErrIssuerMismatch
}
// aud must contain client_id.
audOK := false
for _, a := range idToken.Audience {
if a == entry.cfgRow.ClientID {
audOK = true
break
}
}
if !audOK {
return nil, ErrAudienceMismatch
}
// azp required when aud is multi-valued; if present, must equal client_id.
var extra struct {
AZP string `json:"azp"`
ATHash string `json:"at_hash"`
Nonce string `json:"nonce"`
}
if err := idToken.Claims(&extra); err != nil {
return nil, fmt.Errorf("oidc: id_token claims unmarshal: %w", err)
}
if len(idToken.Audience) > 1 {
if extra.AZP == "" {
return nil, ErrAZPRequired
}
}
if extra.AZP != "" && extra.AZP != entry.cfgRow.ClientID {
return nil, ErrAZPMismatch
}
// at_hash validation. When an access token is returned alongside the
// ID token, OIDC core §3.1.3.6 + §3.2.2.9 require the ID token to
// carry an at_hash claim that hashes the access token (alg-matching
// hash family, left-half, base64url-no-pad). The Phase 3 spec lifts
// this from the RFC's "MAY" to a "MUST" so a substituted access
// token cannot ride a clean ID token through the verifier.
if token.AccessToken != "" {
if extra.ATHash == "" {
return nil, ErrATHashRequired
}
if !atHashMatches(rawIDToken, token.AccessToken, extra.ATHash) {
return nil, ErrATHashMismatch
}
}
// exp + iat (60s clock skew tolerance).
const skew = 60 * time.Second
if idToken.Expiry.Add(skew).Before(now) {
return nil, ErrTokenExpired
}
if idToken.IssuedAt.After(now.Add(skew)) {
return nil, ErrIATInFuture
}
iatWindow := time.Duration(entry.cfgRow.IATWindowSeconds) * time.Second
if idToken.IssuedAt.Add(iatWindow).Before(now) {
return nil, ErrIATTooOld
}
// nonce constant-time compare.
if subtle.ConstantTimeCompare([]byte(extra.Nonce), []byte(storedNonce)) != 1 {
return nil, ErrNonceMismatch
}
// Step 7: extract claims for group resolution + user record.
var profile struct {
Email string `json:"email"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Raw map[string]interface{} `json:"-"`
}
if err := idToken.Claims(&profile); err != nil {
return nil, fmt.Errorf("oidc: profile claims unmarshal: %w", err)
}
var raw map[string]interface{}
if err := idToken.Claims(&raw); err != nil {
return nil, fmt.Errorf("oidc: raw claims unmarshal: %w", err)
}
profile.Raw = raw
// Step 7.5: email-domain allowlist enforcement. Audit 2026-05-10
// CRIT-5 closure. When OIDCProvider.AllowedEmailDomains is non-
// empty, the user's email-domain MUST be in the list (case-
// insensitive exact match; subdomains are NOT auto-accepted — the
// operator must list each subdomain explicitly).
//
// Empty list (default for new providers) = any email domain
// accepted, matching the pre-fix behavior. Empty email claim with
// a non-empty allowlist = ErrEmailMissingButRequired (operators
// who set the allowlist explicitly expect email to be present).
if len(entry.cfgRow.AllowedEmailDomains) > 0 {
emailDomain, edErr := extractEmailDomain(profile.Email)
if edErr != nil {
return nil, ErrEmailMissingButRequired
}
matched := false
for _, allowed := range entry.cfgRow.AllowedEmailDomains {
if strings.EqualFold(strings.TrimSpace(allowed), emailDomain) {
matched = true
break
}
}
if !matched {
return nil, ErrEmailDomainNotAllowed
}
}
// Step 8: group claim resolution.
groups, err := groupclaim.Resolve(profile.Raw, entry.cfgRow.GroupsClaimPath)
if err != nil || len(groups) == 0 {
// Try the userinfo endpoint fallback if the operator opted in.
if entry.cfgRow.FetchUserinfo {
groups2, uerr := s.fetchUserinfoGroups(ctx, entry, token, entry.cfgRow.GroupsClaimPath)
if uerr == nil && len(groups2) > 0 {
groups = groups2
} else {
return nil, ErrGroupsMissing
}
} else {
return nil, ErrGroupsMissing
}
}
// Step 9: map groups to role IDs. Phase 7 defers the empty-mapping
// fail-closed check until after the bootstrap hook gets a chance to
// grant r-admin (Step 11) — a fresh deployment with zero group_role_
// mappings still needs to mint the first admin.
roleIDs, err := s.mappings.Map(ctx, providerID, groups)
if err != nil {
return nil, fmt.Errorf("oidc: group-role mapping lookup: %w", err)
}
// Step 10: upsert the user record. Per Phase 1 contract, identity
// is per-(provider, oidc_subject); a person logging in via a new
// provider gets a new users row.
user, err := s.upsertUser(ctx, entry.cfgRow, idToken.Subject, profile.Email, profile.Name, profile.PreferredUsername)
if err != nil {
return nil, fmt.Errorf("oidc: upsert user: %w", err)
}
// Step 11 — Phase 7: OIDC first-admin bootstrap hook. Optional;
// runs after upsertUser. The hook checks AdminExists + group
// intersection against CERTCTL_BOOTSTRAP_ADMIN_GROUPS; on first
// match it grants r-admin to the user via ActorRoleRepository
// + emits a bootstrap.oidc_first_admin audit row + returns
// grantAdmin=true so we ensure r-admin lands in the role set.
// Subsequent logins (admin-already-exists) silently skip via
// grantAdmin=false.
if s.adminBootstrapHook != nil {
grantAdmin, herr := s.adminBootstrapHook(ctx, providerID, groups, user.ID)
if herr != nil {
return nil, fmt.Errorf("oidc: admin bootstrap: %w", herr)
}
if grantAdmin {
roleIDs = appendIfMissing(roleIDs, "r-admin")
}
}
// Step 12: empty-mapping fail-closed. Phase 3 contract preserved —
// deferred from Step 9 only to give the bootstrap hook a chance.
if len(roleIDs) == 0 {
return nil, ErrGroupsUnmapped
}
// Step 13: mint a post-login session via Phase 4's SessionService.
cookieValue, csrfToken, err := s.sessions.MintForUser(ctx, user, roleIDs, ip, userAgent)
if err != nil {
return nil, fmt.Errorf("oidc: session mint: %w", err)
}
return &CallbackResult{
User: user,
RoleIDs: roleIDs,
CookieValue: cookieValue,
CSRFToken: csrfToken,
}, nil
}
// upsertUser looks up by (provider, subject) and either updates the
// existing user or creates a new one. last_login_at is bumped on every
// login.
func (s *Service) upsertUser(
ctx context.Context,
provider *oidcdomain.OIDCProvider,
subject, email, displayName, fallbackName string,
) (*userdomain.User, error) {
if displayName == "" {
displayName = fallbackName
}
if displayName == "" {
displayName = email
}
existing, err := s.users.GetByOIDCSubject(ctx, provider.ID, subject)
if err == nil {
// Audit 2026-05-11 A-2 — refuse login for deactivated users.
// The admin `DELETE /api/v1/auth/users/{id}` flow sets
// `users.deactivated_at` + cascade-revokes existing sessions.
// Without this gate the very next OIDC login mints a fresh
// session and re-elevates. Compliance-table-flipping bug.
//
// Defense order: the check runs BEFORE the email / display-name
// mutation + last_login_at bump so a deactivated user's
// last_login_at field isn't silently updated by a rejected
// login attempt (forensics value).
if existing.DeactivatedAt != nil {
return nil, ErrUserDeactivated
}
// Update last_login_at, email, display_name (per the Phase 1
// mutable-field contract).
existing.Email = email
existing.DisplayName = displayName
existing.LastLoginAt = s.clockNow().UTC()
if uerr := s.users.Update(ctx, existing); uerr != nil {
return nil, uerr
}
return existing, nil
}
if !errors.Is(err, repository.ErrUserNotFound) {
return nil, err
}
// First login: create a new user record.
id, err := randomB64URL(16)
if err != nil {
return nil, fmt.Errorf("oidc: user id generate: %w", err)
}
u := &userdomain.User{
ID: "u-" + id,
TenantID: provider.TenantID,
Email: email,
DisplayName: displayName,
OIDCSubject: subject,
OIDCProviderID: provider.ID,
LastLoginAt: s.clockNow().UTC(),
WebAuthnCredentials: []byte("[]"),
}
if verr := u.Validate(); verr != nil {
return nil, fmt.Errorf("oidc: new user validate: %w", verr)
}
if cerr := s.users.Create(ctx, u); cerr != nil {
return nil, cerr
}
return u, nil
}
// fetchUserinfoGroups falls back to the IdP userinfo endpoint when
// the operator opts in via fetch_userinfo=true AND the ID token
// didn't surface the groups claim. Returns the group list resolved
// against groups_claim_path.
func (s *Service) fetchUserinfoGroups(
ctx context.Context,
entry *providerEntry,
token *oauth2.Token,
path string,
) ([]string, error) {
if entry.provider.UserInfoEndpoint() == "" {
return nil, fmt.Errorf("oidc: userinfo fallback configured but provider has no userinfo endpoint")
}
ts := entry.oauthConfig.TokenSource(ctx, token)
uinfo, err := entry.provider.UserInfo(ctx, ts)
if err != nil {
return nil, fmt.Errorf("oidc: userinfo fetch: %w", err)
}
var raw map[string]interface{}
if err := uinfo.Claims(&raw); err != nil {
return nil, fmt.Errorf("oidc: userinfo claims: %w", err)
}
return groupclaim.Resolve(raw, path)
}
// =============================================================================
// RefreshKeys: explicitly invalidate + refetch the cached provider.
//
// Used by the GUI's "Refresh discovery cache" button (Phase 8) when an
// operator knows the IdP rotated its keys mid-day and the JWKS cache
// is stale. Re-runs the IdP downgrade-attack defense too: if the IdP
// rotated in HS* / `none` advertisement, we catch it here.
// =============================================================================
// RefreshKeys evicts the cached provider entry and re-loads it from
// scratch. Invokes the discovery doc fetch + the downgrade defense.
//
// Audit 2026-05-10 MED-7 — increments refreshCount + records
// lastRefreshAt / lastError on the new providerEntry's counters so
// JWKSStatus can surface operator-visible refresh history.
func (s *Service) RefreshKeys(ctx context.Context, providerID string) error {
s.mu.Lock()
delete(s.cache, providerID)
s.mu.Unlock()
entry, err := s.getOrLoad(ctx, providerID)
if err != nil {
// On error, no cached entry exists to record on. JWKSStatus
// will return a synthetic snapshot with empty counters for the
// not-yet-loaded provider; the lastError surfaces via the
// follow-up getOrLoad call's own path.
return err
}
entry.statsMu.Lock()
entry.refreshCount++
entry.lastRefreshAt = s.clockNow().UTC()
entry.lastError = ""
entry.statsMu.Unlock()
return nil
}
// JWKSStatus returns the per-provider JWKS health snapshot used by the
// /api/v1/auth/oidc/providers/{id}/jwks-status endpoint. Audit
// 2026-05-10 MED-7. Returns an empty-counters snapshot for providers
// that have never been loaded (no refresh, no rejected JWS yet).
//
// `CurrentKIDs` is intentionally omitted — go-oidc's internal JWKS
// cache doesn't expose its current keyset, and re-implementing the
// JWKS fetch here would duplicate state. Operators wanting kid
// inspection use the discovery doc's `jwks_uri` directly. The field
// remains in the response shape for forward-compat.
func (s *Service) JWKSStatus(ctx context.Context, providerID string) (*JWKSStatusSnapshot, error) {
entry, err := s.getOrLoad(ctx, providerID)
if err != nil {
return nil, err
}
entry.statsMu.Lock()
defer entry.statsMu.Unlock()
snap := &JWKSStatusSnapshot{
RefreshCount: entry.refreshCount,
LastError: entry.lastError,
RejectedJWSCount: entry.rejectedJWSCount,
IssParamSupported: entry.issParamSupported,
CurrentKIDs: []string{},
}
if !entry.lastRefreshAt.IsZero() {
snap.LastRefreshAt = entry.lastRefreshAt.UTC().Format(time.RFC3339)
}
return snap, nil
}
// JWKSStatusSnapshot mirrors the per-provider counters the MED-7 HTTP
// handler returns. Defined here so cmd/server can wire the OIDC
// service directly into the handler without an adapter.
type JWKSStatusSnapshot struct {
LastRefreshAt string `json:"last_refresh_at,omitempty"`
CurrentKIDs []string `json:"current_kids"`
RefreshCount int `json:"refresh_count"`
LastError string `json:"last_error,omitempty"`
RejectedJWSCount int `json:"rejected_jws_count"`
IssParamSupported bool `json:"iss_param_supported"`
}
// =============================================================================
// Provider load + cache + IdP downgrade defense.
// =============================================================================
// getOrLoad returns a cached provider entry, loading from the repo +
// fetching the IdP discovery doc on miss. Cache uses a write-then-read
// pattern under sync.RWMutex; concurrent first-loads of the same
// provider may duplicate the discovery fetch but never produce
// divergent cache entries (the second-arriving entry overwrites and
// both entries are equivalent).
func (s *Service) getOrLoad(ctx context.Context, providerID string) (*providerEntry, error) {
s.mu.RLock()
entry, ok := s.cache[providerID]
s.mu.RUnlock()
if ok {
return entry, nil
}
// Read the configured row.
cfgRow, err := s.providers.Get(ctx, providerID)
if err != nil {
return nil, err
}
// Fetch + cache the discovery doc + JWKS via go-oidc.
provider, err := gooidc.NewProvider(ctx, cfgRow.IssuerURL)
if err != nil {
return nil, fmt.Errorf("oidc: discovery fetch failed for %s: %w", providerID, err)
}
// IdP downgrade-attack defense. The discovery doc's
// id_token_signing_alg_values_supported MUST NOT include any
// disallowed alg.
//
// Audit 2026-05-10 MED-17 — we also read
// `authorization_response_iss_parameter_supported` from the same
// claims call to drive the RFC 9207 iss-URL-parameter check in
// HandleCallback.
var advertised struct {
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
AuthorizationResponseIssParamSupported bool `json:"authorization_response_iss_parameter_supported"`
}
if cerr := provider.Claims(&advertised); cerr != nil {
return nil, fmt.Errorf("oidc: discovery claims: %w", cerr)
}
// Alg-downgrade defense (v2.1.0 relaxation — keycloak-compat).
// Pre-v2.1.0 this loop refused to bind if the IdP advertised ANY
// HS*/none in `id_token_signing_alg_values_supported`. That was
// too strict for real-world IdPs: Keycloak 26.x (and several others)
// list every alg they're CAPABLE of, not the ones the realm is
// actively signing with — so a realm signing exclusively with RS256
// still advertises HS256/HS384/HS512/PS*/etc. in its discovery doc.
// The actual algorithm-confusion attack (forged HS256 token with the
// RS256 pubkey as HMAC secret) is caught at sig-verify time by the
// per-token alg check (isDisallowedAlg, ~L1177) AND by go-oidc/v3's
// verifier-side allow-list pin to DefaultAllowedAlgs. So a Keycloak
// that *says* it supports HS256 but only ever signs with RS256 is
// safe to bind to.
// New semantics: refuse only if the IdP advertises NO acceptable alg
// (intersection of advertised vs DefaultAllowedAlgs is empty). A
// pathological HS-only IdP still fails closed.
allowedSet := make(map[string]struct{}, len(DefaultAllowedAlgs))
for _, a := range DefaultAllowedAlgs {
allowedSet[a] = struct{}{}
}
hasAcceptable := false
for _, a := range advertised.IDTokenSigningAlgValuesSupported {
if _, ok := allowedSet[a]; ok {
hasAcceptable = true
break
}
}
if len(advertised.IDTokenSigningAlgValuesSupported) > 0 && !hasAcceptable {
return nil, fmt.Errorf("%w: advertised=%v", ErrIdPDowngradeAdvertised, advertised.IDTokenSigningAlgValuesSupported)
}
// Compute the effective allow-list: intersection of the default
// allow-list AND any operator-configured restriction (currently
// the domain layer doesn't expose per-provider alg config beyond
// the default; placeholder for a future Phase-3-extended config).
allowed := DefaultAllowedAlgs
// Decrypt the client secret. The plaintext is held in memory only;
// never persisted, never logged.
plaintext, err := decryptClientSecret(cfgRow.ClientSecretEncrypted, s.encryptionKey)
if err != nil {
return nil, fmt.Errorf("oidc: client_secret decrypt: %w", err)
}
verifier := provider.Verifier(&gooidc.Config{
ClientID: cfgRow.ClientID,
SupportedSigningAlgs: allowed,
})
oauthConfig := &oauth2.Config{
ClientID: cfgRow.ClientID,
ClientSecret: string(plaintext),
Endpoint: provider.Endpoint(),
RedirectURL: cfgRow.RedirectURI,
Scopes: cfgRow.Scopes,
}
entry = &providerEntry{
cfgRow: cfgRow,
provider: provider,
verifier: verifier,
oauthConfig: oauthConfig,
allowedAlgs: allowed,
plaintext: plaintext,
issParamSupported: advertised.AuthorizationResponseIssParamSupported,
}
s.mu.Lock()
s.cache[providerID] = entry
s.mu.Unlock()
return entry, nil
}
// =============================================================================
// Helpers (alg parsing, at_hash, random, JWKS-error detection,
// client_secret decrypt). Kept private; tests in service_test.go.
// =============================================================================
// randomB64URL returns nbytes of cryptographic randomness encoded as
// base64url-no-pad. Used for state, nonce, session IDs.
func randomB64URL(nbytes int) (string, error) {
b := make([]byte, nbytes)
if _, err := readRand(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// readRand is a package-level seam so tests can deterministically
// substitute crypto/rand. Production reads from crypto/rand.Reader.
var readRand = func(b []byte) (int, error) {
return cryptorand.Read(b)
}
// isDisallowedAlg parses the JWS header alg and reports whether it's
// in the deny-list. NEVER returns or logs the alg; the caller maps
// the bool to ErrAlgRejected without surfacing details.
func isDisallowedAlg(rawJWT string) (bool, string) {
// JWS Compact: <header>.<payload>.<signature>. Decode header,
// extract `alg`. Defensive: catches bad input shapes too.
parts := strings.Split(rawJWT, ".")
if len(parts) != 3 {
return true, ""
}
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return true, ""
}
// Find the alg value. Extreme minimal parser: avoid pulling in
// encoding/json so the path is allocation-tight on every login.
// Format: {"alg":"RS256",...}; some libraries emit
// {"alg" : "RS256" ,...} so the parser tolerates whitespace
// around both the colon and the value.
hdr := string(headerJSON)
idx := strings.Index(hdr, `"alg"`)
if idx < 0 {
return true, ""
}
rest := hdr[idx+5:] // skip "alg"
rest = strings.TrimLeft(rest, " \t\r\n")
if !strings.HasPrefix(rest, ":") {
return true, ""
}
rest = rest[1:]
rest = strings.TrimLeft(rest, " \t\r\n")
if !strings.HasPrefix(rest, `"`) {
return true, ""
}
rest = rest[1:]
end := strings.Index(rest, `"`)
if end < 0 {
return true, ""
}
alg := rest[:end]
if _, deny := disallowedAlgs[alg]; deny {
return true, alg
}
return false, alg
}
// atHashMatches recomputes at_hash per OIDC core §3.1.3.6 + §3.2.2.9
// and constant-time-compares against the claim. Algorithm matches the
// hash family of the ID token's signing alg (RS256 -> SHA-256, RS512
// -> SHA-512, ES256 -> SHA-256, ES384 -> SHA-384, EdDSA -> SHA-512).
// Returns true iff the recomputed half-hash equals the claim.
func atHashMatches(rawIDToken, accessToken, claimAtHash string) bool {
_, alg := isDisallowedAlg(rawIDToken) // re-extracts alg
var h hash.Hash
switch alg {
case "RS256", "ES256":
h = sha256.New()
case "ES384":
h = sha512.New384()
case "RS512", "EdDSA":
h = sha512.New()
default:
// Unknown alg should already have been caught by the
// alg-pin check; refuse to recompute here.
return false
}
h.Write([]byte(accessToken))
sum := h.Sum(nil)
half := sum[:len(sum)/2]
expected := base64.RawURLEncoding.EncodeToString(half)
return subtle.ConstantTimeCompare([]byte(expected), []byte(claimAtHash)) == 1
}
// isJWKSFetchError detects whether the underlying error from
// gooidc.IDTokenVerifier.Verify is a JWKS-fetch failure (network
// error talking to the IdP's jwks_uri during a key rotation event).
// Maps to ErrJWKSUnreachable so the handler returns 503 to the
// in-flight login attempt without auto-revoking existing sessions.
//
// Audit 2026-05-10 Nit-2 — pinned against go-oidc/v3 v3.18.0. As of
// that release, the only typed error exposed by the oidc package is
// `*oidc.TokenExpiredError`; JWKS-fetch failures bubble up as
// fmt.Errorf-wrapped strings from internal/keyset.go's `verify` path
// (`failed to verify signature: fetching keys: ...`,
// `oidc: fetching keys ...`, `oidc: failed to get keys for kid ...`).
// The regression test in service_test.go::TestIsJWKSFetchError_GoOIDCV318Strings
// pins the canonical substrings; a future go-oidc bump that changes
// the wording trips the test and forces this function to be re-derived.
// When go-oidc exposes a typed error (track at
// https://github.com/coreos/go-oidc/issues for the upstream RFE),
// switch to errors.As.
func isJWKSFetchError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "fetching keys") ||
strings.Contains(msg, "jwks_uri") ||
strings.Contains(msg, "key set") ||
// go-oidc/v3 v3.18.0 jwks.go:260: `oidc: failed to decode keys`
// — emitted when the IdP returns non-JSON at the jwks_uri
// (broken proxy, gateway HTML error page, etc.). Audit
// 2026-05-10 Nit-2 closure — was previously misclassified as
// a generic 500 instead of 503 ErrJWKSUnreachable.
strings.Contains(msg, "decode keys")
}
// isKidMismatchError detects whether the go-oidc verify error is
// caused by the verifier's cached JWKS missing the key id referenced
// by the inbound ID token's JWS header (the canonical post-IdP-key-
// rotation failure mode). Audit 2026-05-10 MED-6.
//
// Pinned strings as of go-oidc/v3 v3.18.0:
// - `failed to verify signature: failed to verify id token signature`
// when no JWK in the cache matches the header kid
// - `oidc: failed to verify signature: failed to verify id token: kid`
// - Older releases emitted `signing key with id ... not found`
//
// The match is intentionally narrow — we don't want to retry on
// every signature failure (some are genuinely a wrong signing key,
// not a cache-miss), only on the kid-not-in-cache shape. A future
// go-oidc release that exposes a typed error should switch to
// errors.As; the regression test pins the canonical substrings so
// the bump trips loudly.
func isKidMismatchError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
switch {
case strings.Contains(msg, "kid") && strings.Contains(msg, "not found"):
return true
case strings.Contains(msg, "signing key") && strings.Contains(msg, "not found"):
return true
case strings.Contains(msg, "no matching key"):
return true
case strings.Contains(msg, "key with id") && strings.Contains(msg, "not found"):
return true
}
return false
}
// decryptClientSecret runs the client_secret_encrypted blob through
// internal/crypto/encryption.go's v2 Decrypt path. The plaintext
// MUST NOT be logged or written anywhere except oauthConfig.ClientSecret.
func decryptClientSecret(blob []byte, key string) ([]byte, error) {
if key == "" {
// Test path / local dev: blob is already the plaintext (the
// caller didn't run it through Encrypt). Return as-is.
return blob, nil
}
plain, err := crypto.DecryptIfKeySet(blob, key)
if err != nil {
return nil, err
}
return plain, nil
}
// extractEmailDomain returns the lowercase domain portion of an RFC
// 5322-ish email address. Used by HandleCallback Step 7.5 (CRIT-5
// closure) to enforce OIDCProvider.AllowedEmailDomains. Rejects empty
// input, addresses with no '@', and addresses with empty local-part
// or domain-part. Does NOT validate the full RFC grammar — IdPs are
// upstream of this and have their own validation; we only need a
// stable domain-extraction for the allowlist comparison.
func extractEmailDomain(email string) (string, error) {
email = strings.TrimSpace(email)
if email == "" {
return "", fmt.Errorf("empty email")
}
at := strings.LastIndex(email, "@")
if at <= 0 || at == len(email)-1 {
return "", fmt.Errorf("invalid email shape: %q", email)
}
return strings.ToLower(email[at+1:]), nil
}