Skip to content

refactor(auth): canonical go-oidc + oauth2 OIDC flow + CTF hardening - #39

Merged
AYDEV-FR merged 14 commits into
mainfrom
fix/auth-ctf-hardening
Jun 20, 2026
Merged

refactor(auth): canonical go-oidc + oauth2 OIDC flow + CTF hardening#39
AYDEV-FR merged 14 commits into
mainfrom
fix/auth-ctf-hardening

Conversation

@AYDEV-FR

@AYDEV-FR AYDEV-FR commented Jun 5, 2026

Copy link
Copy Markdown
Owner

OIDC login rewritten onto the canonical Go RP stack — coreos/go-oidc/v3 for discovery + ID-token verification, golang.org/x/oauth2 for the Authorization Code + PKCE flow — following the official go-oidc README example. No framework, no hand-rolled crypto, no cookie-key management. The flow state (state, PKCE verifier, nonce, returnUrl) rides in four short-lived HttpOnly cookies (Path=/auth, SameSite=Lax, Secure on HTTPS, 10 min, cleared one-shot at callback).

Hardening, layered on the rewrite

1. Open-redirect via protocol-relative returnUrl (HIGH)

The previous check strings.HasPrefix(returnURL, "/") accepted //evil.com/x, which c.Redirect then turns into a cross-origin redirect through the browser's protocol-relative URL rules.

sanitizeRelativePath: leading / plus url.Parse-confirmed empty scheme + empty host + nil userinfo, fragment stripped. Covers //, /\\, http://, https://, javascript:, data:, userinfo tricks. Applied in Login (before the cookie is set) and re-applied in Callback (defense-in-depth). 15-case test matrix in oidc_test.go locks the surface.

2. CSRF state + OIDC nonce (MEDIUM)

Random state (24 B) checked cookie-vs-query with subtle.ConstantTimeCompare. Random nonce (16 B) sent via oidc.Nonce(...) and checked against idToken.Nonce on return, so an id_token minted for someone else's login attempt can't be spliced into ours.

3. Server-side id_token verification (MEDIUM)

We used to hand the raw id_token straight to the browser, trusting the request-time JWT validator to catch a bad signature on the next call. Now Verify() runs at callback time — signature, iss, aud, exp, nbf. Forged / unsigned tokens never reach the browser. (We don't verify at_hash: the access token is discarded, only the id_token is used, so there's nothing for the binding to protect.)

4. PKCE S256 (LOW)

OAuth 2.1-recommended PKCE even though we're a confidential client: oauth2.GenerateVerifier() in a cookie, S256 challenge on AuthCodeURL, verifier passed to Exchange. An attacker who snags the auth code on the redirect leg can no longer redeem it.

5. Clean failure paths

IdP errors (?error= per OAuth 2.0 §4.1.2.1) and a missing code get explicit 400s instead of falling through to misleading errors. Exchange/verification failure details are logged server-side only — the browser gets a stable, generic message (no internal IdP endpoints leaked).

What we still rely on

  • HTTPS in production. For the CTF cluster, plain HTTP is a deliberate dev shortcut.
  • Short id_token lifetime at the IdP. Logout doesn't revoke at Dex; a stolen token survives logout until exp.
  • No CSRF guard on the API itself — /api/run/... requires Authorization: Bearer and CORS doesn't include credentials, so no browser-based CSRF surface today.

Also in this PR

  • golangci-lint config migrated to the v2 schema (CI lint had been red repo-wide since the action's latest moved to v2) + repo-wide lint fixes.

Four targeted fixes layered on top of the phase-A/B/C refactor. Every
one uses an existing library helper — nothing hand-rolled.

1. Open-redirect via protocol-relative returnUrl (HIGH)

   The previous check `strings.HasPrefix(returnURL, "/")` accepted
   `//evil.com/x`, which c.Redirect then turns into a cross-origin
   redirect through the browser's protocol-relative URL rules.

   Add safeRelativePath: leading "/" plus url.Parse-confirmed empty
   scheme + empty host + nil userinfo. Covers //, /\\, http://, https://,
   javascript:, data:, userinfo tricks. Applied in both Login (before
   we sign the state) and Callback (before we honour the unwrapped
   blob). Test matrix in oidc_test.go locks the cases.

2. OIDC nonce (MEDIUM)

   The flow had no nonce, so a stolen id_token issued for our
   client_id could be replayed through /auth/callback. Add a 32-byte
   crypto/rand nonce baked into the (signed) state blob, passed to
   the IdP via oidc.Nonce(n) on AuthCodeURL, and checked against
   idToken.Nonce on return.

3. Server-side id_token verification (MEDIUM)

   We used to hand the raw id_token straight to the browser, trusting
   that the request-time JWT validator would catch a bad signature on
   the *next* call. Now Verify() runs at callback time — signature,
   iss, aud, exp, nbf, plus at_hash binding the id_token to the
   access_token it came with. Forged / unsigned tokens never reach
   the browser.

4. PKCE S256 (LOW)

   Adds the OAuth 2.1-recommended PKCE leg even though we're a
   confidential client. The code verifier is baked into the signed
   state, the S256 challenge is sent in AuthCodeURL via
   oauth2.S256ChallengeOption, and the verifier is passed to Exchange
   via oauth2.VerifierOption. An attacker who snags the auth code on
   the redirect leg can no longer redeem it.

No new dependencies — all four leverage the go-oidc / oauth2 we already
pulled in phases A/B. Public surface unchanged.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens the OAuth2/OIDC Authorization Code flow in internal/auth to better withstand common redirect/state probing and token replay attempts, while keeping the flow stateless via a signed state blob.

Changes:

  • Adds safeRelativePath validation for returnUrl and applies it in both /auth/login and /auth/callback.
  • Introduces OIDC nonce + PKCE S256, storing nonce/verifier in the signed state and enforcing them during callback.
  • Verifies id_token server-side during callback before redirecting back to the SPA; adds tests for return URL validation.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
internal/auth/oidc.go Adds nonce + PKCE + callback-time ID token verification, and hardens returnUrl handling.
internal/auth/oidc_test.go Adds a test matrix for safeRelativePath to prevent open-redirect regressions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/auth/oidc.go Outdated
Comment on lines +273 to +275
// Verify the id_token server-side (signature, iss, aud, exp, nbf, at_hash)
// before handing it to the browser. Catches forged / unsigned id_tokens
// here instead of trusting them as far as the next API call.
Comment thread internal/auth/oidc.go
AYDEV-FR added 3 commits June 7, 2026 18:24
Two follow-ups from the inline review:

1. at_hash binding was claimed but never executed

   The Verify() comment said it checks at_hash, but go-oidc/v3's
   Verify only does signature + iss + aud + exp + nbf. at_hash needs
   an explicit idToken.VerifyAccessToken(accessToken) call. Add it:
   when the IdP set the claim (Dex does), confirm the id_token was
   issued together with the access_token we received; skip with a
   warn when the claim is absent (the spec allows that).

2. Smuggled fragment in returnUrl broke the SPA hand-off

   safeRelativePath accepts "/foo#section" (the path is fine, the
   fragment is just metadata). On callback we then build
   "/foo#section#token=..." — two hashes, which the SPA's
   consumeHashToken() doesn't parse (it expects location.hash to
   start with "#token=").

   Strip the user-supplied fragment before appending the token hash.
   stripFragment helper + test cases pinning the behaviour.
Follow-up to the Copilot review. The previous fix had two helpers parsing
the same URL twice:

  - safeRelativePath: url.Parse(s), reject if scheme/host/user set
  - stripFragment: strings.Cut(s, "#"), throw away the tail

stripFragment was the embarrassing one — net/url already exposes
url.URL.Fragment = "" + url.URL.String(), no string fiddling required.
Collapse both helpers into a single sanitizeRelativePath(s) (string, bool)
that returns the canonical fragment-stripped form alongside the safety
verdict. One Parse, one set of checks, one rebuild.

Test suite merges the two matrices into a single table — including
fragment-stripping cases that the previous split missed (e.g. "/?x=1#frag"
must come back as "/?x=1", not "" + stray strings.Cut output).
Phase D — the last chunk of hand-rolled auth code goes away. zitadel/oidc's
RelyingParty owns the whole OAuth2/OIDC flow: discovery, state cookie
(signed + encrypted), PKCE S256, nonce, code exchange, id_token
verification (signature, iss, aud, exp, nbf, at_hash). Everything we
implemented + hardened across phases A–C and #39 is now provided by the
library, with a single import.

What's left in oidc.go is the dploy-specific glue we still own:
- split-horizon AuthURL substitution (in-cluster discovery, public-host
  browser redirect)
- sanitizeRelativePath for returnUrl (open-redirect guard + #fragment strip)
- retry-on-discovery at boot
- Fiber adapter via fiber/middleware/adaptor
- the "#token=..." hand-off the SPA expects

What's gone:
- Manual state map / cookie / encoding
- Manual PKCE generation + Exchange-time VerifierOption wiring
- Manual nonce generation + idToken.Nonce check
- Manual idToken.VerifyAccessToken for at_hash
- Manual id_token Verify call + error mapping
- stateBlob struct, securecookie.SecureCookie setup, consumeState helper

Stats:
- oidc.go: 305 -> 184 lines (-121, -40%)
- Whole auth package: 655 -> 269 lines (-386, -59% of pre-refactor)
- Direct deps net: +zitadel/oidc/v3
- Direct deps gone: jwt.go's golang-jwt was already dropped in phase A
@AYDEV-FR AYDEV-FR changed the title fix(auth): harden OIDC flow against CTF-grade probing refactor(auth): delegate OIDC flow to zitadel/oidc + CTF hardening Jun 7, 2026
@AYDEV-FR
AYDEV-FR requested a review from Copilot June 7, 2026 17:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 4 changed files in this pull request and generated 4 comments.

Comment thread internal/auth/oidc.go Outdated
Comment on lines +157 to +158
stateFn := func() string { return returnURL }
return adaptor.HTTPHandler(rp.AuthURLHandler(stateFn, h.rp))(c)
Comment thread internal/auth/oidc.go Outdated
Comment on lines 171 to 174
returnURL := "/"
if stateData != nil && strings.HasPrefix(stateData.ReturnURL, "/") {
returnURL = stateData.ReturnURL
if clean, ok := sanitizeRelativePath(state); ok {
returnURL = clean
}
Comment thread internal/auth/oidc.go Outdated
Comment on lines +62 to +67
relyingParty, err := newRelyingPartyWithRetry(context.Background(),
cfg.OIDCIssuer, cfg.OIDCClientID, cfg.OIDCClientSecret, cfg.OIDCRedirectURL,
[]string{oidc.ScopeOpenID, "email", "profile"},
rp.WithCookieHandler(cookieHandler),
rp.WithPKCE(cookieHandler),
)
Comment thread internal/auth/oidc.go Outdated
AYDEV-FR added 3 commits June 7, 2026 19:35
Three follow-ups from the new inline comments.

1. CRITICAL — split-horizon broke after the zitadel/oidc switch

   zitadel/oidc validates that the discovery doc's `issuer` matches the
   one we passed to NewRelyingPartyOIDC. Dex always advertises its
   configured (public) issuer regardless of which URL the request came
   through, so the previous call — passing the *internal* issuer —
   would have failed at boot with ErrIssuerInvalid.

   Fix: pass the public issuer as `issuer` and use
   rp.WithCustomDiscoveryUrl(internalURL) to fetch discovery via the
   in-cluster endpoint. The AuthURL rebasing on OAuthConfig() still
   applies (endpoints are request-host-derived by Dex), but TokenURL +
   JWKS stay internal as intended.

2. HIGH — state = sanitized returnUrl was predictable

   For the vast majority of callers returnUrl is just "/", which means
   `state` was guessable. An attacker triggering /auth/login on a
   victim's browser then driving them to /auth/callback with the
   attacker's own code+state=/ would have bypassed CSRF / session-fixation
   protection: the cookie-state vs query-state check trivially matches
   when both are "/".

   Fix: state is now "<16-byte nonce>:<returnUrl>". The nonce makes the
   state unguessable per login attempt; returnUrl piggybacks for free.
   The callback splits on the first ":" to recover the returnUrl (and
   re-sanitises it, defense in depth).

3. MEDIUM — process-random cookie keys broke multi-replica + rolling updates

   The previous code minted per-process hash/block keys, so a callback
   landing on a different pod than the one that started the login would
   fail to decrypt the state cookie. Fine for the current single-replica
   cluster, broken for any rolling update or replicaCount > 1.

   Fix: add OIDC_COOKIE_HASH_KEY / OIDC_COOKIE_BLOCK_KEY env vars
   (loaded via config), with a warn-and-random fallback when unset.
   Chart pipes them through the existing Secret, so values become
   `auth.oidcCookieHashKey` / `auth.oidcCookieBlockKey`. Documented in
   the values comment.
The split-horizon issuer support (in-cluster discovery URL + public
AuthURL rebase) was already gated on OIDCPublicIssuer being set and
distinct from OIDCIssuer, but the code structure read like the
default path. Reword the two comment blocks to say "this is optional,
no-op when you use a single URL" and update the chart values.yaml so
operators know oidcPublicIssuer is only for split-horizon and can be
left empty for the common single-URL setup.

No behaviour change.
Three things I caught re-reading the diff one more time:

1. AuthURL rebase was silently no-op when Dex's discovery returned an
   AuthURL whose base didn't match OIDCIssuer (config drift, port
   mismatch, etc.). strings.Replace just returned the input unchanged
   and we logged "rebased to public" anyway, so the browser would
   redirect to the in-cluster URL it can't reach. Now we Contains-check
   first and fail boot with a clear error.

2. Cookie key warn was inconsistent — only HASH absence triggered the
   "process-random, breaks across replicas" warning, BLOCK absence
   was silent. If you set HASH but forgot BLOCK, you'd ship to prod
   with one stable key and one random one. Symmetrical warn now.

3. The "<nonce>:<returnUrl>" state wire format had no test. Extracted
   decodeStateReturnURL (consumed by Callback, mirrors Login's
   encoding) and pinned it with 11 cases: empty / malformed /
   path-with-colon / fragment-strip / the three classic
   open-redirect tricks injected via the returnUrl half, plus a
   round-trip check that nonce never leaks into the redirect.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 4 comments.

Comment thread internal/auth/oidc.go Outdated
Comment on lines +59 to +69
hashKey := []byte(cfg.OIDCCookieHashKey)
blockKey := []byte(cfg.OIDCCookieBlockKey)
if len(hashKey) == 0 {
hashKey = securecookie.GenerateRandomKey(64)
logger.Warn("OIDC_COOKIE_HASH_KEY not set; using a process-random key — logins will break across pod restarts or replicas")
}
if len(blockKey) == 0 {
blockKey = securecookie.GenerateRandomKey(32)
logger.Warn("OIDC_COOKIE_BLOCK_KEY not set; using a process-random key — logins will break across pod restarts or replicas")
}
cookieHandler := httphelper.NewCookieHandler(hashKey, blockKey, cookieOpts...)
Comment thread charts/dploy/values.yaml Outdated
Comment on lines +87 to +93
# Cookie keys for the zitadel/oidc state + PKCE cookies. Leave empty for
# single-replica dev (API will mint per-process random keys + log a warn).
# MUST be set to stable secrets when running with replicaCount > 1 or
# tolerating rolling updates without breaking in-flight logins.
# Generate with: head -c 64 /dev/urandom | base64
oidcCookieHashKey: ""
oidcCookieBlockKey: ""
Comment thread charts/dploy/templates/secret.yaml Outdated
Comment on lines +21 to +24
{{/* Cookie keys for the zitadel/oidc state + PKCE cookies. When unset the
API generates per-process random keys (logs a warning) — fine for a
single replica; mandatory for replicaCount > 1 or rolling-update
resilience. Generate with `head -c 64 /dev/urandom | base64`. */}}
Comment thread internal/config/config.go Outdated
Comment on lines +23 to +29
// Cookie keys for the OIDC state / PKCE cookies (zitadel/oidc CookieHandler).
// Set both to stable random secrets (k8s Secret, env, etc.) when running
// with replicaCount > 1 or you'll get login failures on rolling updates,
// since requests can land on a pod that can't decrypt cookies set by
// another. Empty values fall back to process-random keys with a warn.
OIDCCookieHashKey string // 64 bytes recommended (HMAC key)
OIDCCookieBlockKey string // 32 bytes (AES key)
AYDEV-FR added 2 commits June 10, 2026 15:47
Drop zitadel/oidc, gorilla/securecookie and the derived-cookie-keys
plumbing. The OIDC RP is now built from the two libraries every other
Go project reaches for (coreos/go-oidc + golang.org/x/oauth2) and
follows the official go-oidc README example: three short-lived
HttpOnly, SameSite=Lax cookies carrying state / PKCE verifier /
returnUrl across the login bounce, plus a state-mismatch check on the
callback.

What this removes:
- the zitadel/oidc RelyingParty + CookieHandler dance and its custom
  discovery URL plumbing
- the OIDC_COOKIE_HASH_KEY / OIDC_COOKIE_BLOCK_KEY env vars (and the
  matching Helm values, Secret entries, config fields, base64-decode
  helper, length validation, and tests)
- the "<nonce>:<returnUrl>" state encoding (returnUrl now lives in its
  own cookie, no encoding gymnastics)
- the fiber/middleware/adaptor bridge — handlers are native Fiber

What this keeps:
- split-horizon issuer support via oidc.InsecureIssuerURLContext +
  AuthURL rebase (gated on OIDCPublicIssuer, no-op when single URL)
- the discovery retry-on-boot for Cilium-style network-identity races
- sanitizeRelativePath + open-redirect tests
- ID token verification (signature, iss, aud, exp, nbf) via the
  Provider's IDTokenVerifier

CSRF protection comes from the state match (cookie vs query); replay
protection from PKCE S256; cookie tampering surface is bounded by
HttpOnly + Secure + SameSite=Lax + a /auth path scope. No shared key
between replicas needed because the flow cookies travel with the user
end-to-end.

Net diff vs the previous PR head: ~170 fewer lines of auth code, two
fewer direct deps, zero new operator config knobs.
State + PKCE cover CSRF and code-replay, but the OIDC spec recommends
nonce as the third leg: it binds the *ID token* (not just the code) to
this specific login attempt. The attack it closes is the one where
both the auth code and the PKCE verifier leak together — the IdP
would issue a token carrying the attacker's nonce, not ours.

Mint a 16-byte random nonce per Login, ship it to the IdP via
oidc.Nonce(...) on AuthCodeURL, store it alongside state/verifier in
an HttpOnly cookie, then compare against idToken.Nonce in Callback
with subtle.ConstantTimeCompare.

Strict mode: a missing nonce cookie fails the flow with "missing or
expired login session" same as missing state/verifier.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.

Comment thread internal/auth/oidc.go
Comment on lines 232 to +236
func (h *OIDCHandler) Callback(c *fiber.Ctx) error {
if errorParam := c.Query("error"); errorParam != "" {
errorDesc := c.Query("error_description", errorParam)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": errorDesc})
}
code := c.Query("code")
state := c.Query("state")
if code == "" || state == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing code or state parameter"})
state := c.Cookies(cookieState)
verifier := c.Cookies(cookieVerifier)
nonce := c.Cookies(cookieNonce)
returnURL := c.Cookies(cookieReturn)
Comment thread internal/auth/oidc.go Outdated
Comment on lines 251 to 254
token, err := h.oauth2Config.Exchange(c.Context(), c.Query("code"), oauth2.VerifierOption(verifier))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": fmt.Sprintf("failed to exchange code: %v", err),
})
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": "token exchange failed: " + err.Error()})
}
Copilot round #4 caught two diagnostic gaps in the new go-oidc-style
callback:

1. The IdP signals failure (user cancelled, consent denied, scope
   rejected, …) by redirecting back with ?error=<code>&
   error_description=<text> per OAuth 2.0 §4.1.2.1, NOT with a code.
   Previous code fell through to state validation and returned a
   "state mismatch" or "token exchange failed" error that hid the
   actual IdP reason. Now we short-circuit on ?error= with a 400 that
   carries through the IdP's error code and description.

2. An empty ?code= (IdP misbehaving, or someone hitting /auth/callback
   directly) reached oauth2.Exchange and surfaced as a 502 from the
   token endpoint. Now it's caught up front with a clean 400 "missing
   authorization code".

Both checks happen after the one-shot cookie clear, so a failed flow
can't be replayed by re-hitting the callback URL.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.

Comment thread internal/auth/oidc.go
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(nonce)) != 1 {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": "nonce mismatch"})
}

Comment thread internal/auth/oidc.go
Comment on lines +268 to 271
token, err := h.oauth2Config.Exchange(c.Context(), code, oauth2.VerifierOption(verifier))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": fmt.Sprintf("failed to exchange code: %v", err),
})
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": "token exchange failed: " + err.Error()})
}
Comment thread internal/auth/oidc.go
Comment on lines +276 to +279
idToken, err := h.verifier.Verify(c.Context(), rawIDToken)
if err != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": "id_token verification failed: " + err.Error()})
}
@AYDEV-FR AYDEV-FR changed the title refactor(auth): delegate OIDC flow to zitadel/oidc + CTF hardening refactor(auth): canonical go-oidc + oauth2 OIDC flow + CTF hardening Jun 11, 2026
AYDEV-FR added 3 commits June 11, 2026 12:47
oauth2 and go-oidc error strings embed the token endpoint and issuer
URLs — internal cluster topology a CTF player shouldn't get for free.
Log the detail, return a stable generic message to the browser.

Addresses Copilot review round 5 (1/3 and 2/3); 3/3 (at_hash) is
resolved in the PR description instead: the access token is discarded,
so the at_hash binding has nothing to protect.
CI lint has been red on every branch since the action's 'latest'
moved to golangci-lint v2, which rejects the v1 config schema (and
the last v1 release can't even run against Go 1.26). Migrated with
'golangci-lint migrate', then tuned:

- exhaustive: default-signifies-exhaustive (all flagged switches
  already had an explicit default)
- drop goconst (pure noise: "true", "error", k8s condition strings)
- govet: disable shadow ('if err :=' inside an err scope is idiomatic)
- gocritic: disable hugeParam (controller-runtime passes specs by value)
- gochecknoinits excluded for api/ and cmd/ (kubebuilder scheme
  registration pattern), gocyclo excluded for Reconcile loops
- errcheck back to defaults (check-blank flagged deliberate discards)

Code fixes: gofmt x4, misspell x4, QF1008 embedded selector, named
results on JWTValidator.Validate, nolint on the scaffolded
scheme.Builder. golangci-lint v2.12.2 now reports 0 issues.
Found by deploying the PR build to the CTF cluster: the split-horizon
AuthURL rebase required the discovery doc's AuthURL to be based on the
internal issuer URL, and returned an error otherwise — crashlooping
the pod. Dex doesn't derive endpoints from the request Host (the old
comment claimed it does): it bakes its configured issuer into every
endpoint, so fetching discovery via the internal URL still yields
public endpoints, and there is nothing to rebase.

AuthURL already on the public base -> leave it (the common Dex case);
on the internal base -> rebase; neither -> warn and keep it, instead
of refusing to boot a config the IdP considers valid.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 14 changed files in this pull request and generated 4 comments.

Comment thread internal/auth/oidc.go
Comment thread internal/auth/oidc.go
Comment on lines +295 to 297
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(nonce)) != 1 {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": "nonce mismatch"})
}
Comment thread internal/auth/oidc.go
Comment on lines +157 to 167
func sanitizeRelativePath(s string) (string, bool) {
if !strings.HasPrefix(s, "/") || strings.HasPrefix(s, "//") || strings.HasPrefix(s, "/\\") {
return "", false
}
u, err := url.Parse(s)
if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil {
return "", false
}
u.Fragment = ""
return u.String(), true
}
Comment on lines +24 to +28
{"//evil.com/x", "", false}, // protocol-relative URL
{"/\\evil.com/x", "", false}, // backslash-prefixed (some browsers)
{"http://evil.com/x", "", false}, // absolute URL
{"https://evil.com/x", "", false}, // absolute URL https
{"javascript:alert(1)", "", false},
…ings

Address the latest Copilot review on PR #39:

- sanitizeRelativePath: reject a backslash in the decoded path. A
  percent-encoded backslash ("/%5cevil.com") survived the literal "/\\"
  prefix check, and user agents that normalize "\" to "/" would read the
  decoded "//evil.com" as a protocol-relative redirect.
- Callback: return 400 (not 502) on nonce mismatch — a client/session
  issue or attack, not an upstream failure, mirroring the state path.
- Fix the package comment: the flow now carries four flow cookies
  (state/verifier/nonce/returnUrl), not three.
- Pin the bypass with percent-encoded backslash test cases.

Claude-Session: https://claude.ai/code/session_01M4Dd5oASBGHwr6ts6wTnYn
@AYDEV-FR
AYDEV-FR requested a review from Copilot June 20, 2026 16:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@AYDEV-FR
AYDEV-FR requested a review from Copilot June 20, 2026 17:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@AYDEV-FR
AYDEV-FR merged commit 18fe87e into main Jun 20, 2026
7 checks passed
AYDEV-FR added a commit that referenced this pull request Jun 20, 2026
Two follow-ups from the inline review:

1. at_hash binding was claimed but never executed

   The Verify() comment said it checks at_hash, but go-oidc/v3's
   Verify only does signature + iss + aud + exp + nbf. at_hash needs
   an explicit idToken.VerifyAccessToken(accessToken) call. Add it:
   when the IdP set the claim (Dex does), confirm the id_token was
   issued together with the access_token we received; skip with a
   warn when the claim is absent (the spec allows that).

2. Smuggled fragment in returnUrl broke the SPA hand-off

   safeRelativePath accepts "/foo#section" (the path is fine, the
   fragment is just metadata). On callback we then build
   "/foo#section#token=..." — two hashes, which the SPA's
   consumeHashToken() doesn't parse (it expects location.hash to
   start with "#token=").

   Strip the user-supplied fragment before appending the token hash.
   stripFragment helper + test cases pinning the behaviour.
AYDEV-FR added a commit that referenced this pull request Jun 20, 2026
Phase D — the last chunk of hand-rolled auth code goes away. zitadel/oidc's
RelyingParty owns the whole OAuth2/OIDC flow: discovery, state cookie
(signed + encrypted), PKCE S256, nonce, code exchange, id_token
verification (signature, iss, aud, exp, nbf, at_hash). Everything we
implemented + hardened across phases A–C and #39 is now provided by the
library, with a single import.

What's left in oidc.go is the dploy-specific glue we still own:
- split-horizon AuthURL substitution (in-cluster discovery, public-host
  browser redirect)
- sanitizeRelativePath for returnUrl (open-redirect guard + #fragment strip)
- retry-on-discovery at boot
- Fiber adapter via fiber/middleware/adaptor
- the "#token=..." hand-off the SPA expects

What's gone:
- Manual state map / cookie / encoding
- Manual PKCE generation + Exchange-time VerifierOption wiring
- Manual nonce generation + idToken.Nonce check
- Manual idToken.VerifyAccessToken for at_hash
- Manual id_token Verify call + error mapping
- stateBlob struct, securecookie.SecureCookie setup, consumeState helper

Stats:
- oidc.go: 305 -> 184 lines (-121, -40%)
- Whole auth package: 655 -> 269 lines (-386, -59% of pre-refactor)
- Direct deps net: +zitadel/oidc/v3
- Direct deps gone: jwt.go's golang-jwt was already dropped in phase A
AYDEV-FR added a commit that referenced this pull request Jun 20, 2026
Three follow-ups from the new inline comments.

1. CRITICAL — split-horizon broke after the zitadel/oidc switch

   zitadel/oidc validates that the discovery doc's `issuer` matches the
   one we passed to NewRelyingPartyOIDC. Dex always advertises its
   configured (public) issuer regardless of which URL the request came
   through, so the previous call — passing the *internal* issuer —
   would have failed at boot with ErrIssuerInvalid.

   Fix: pass the public issuer as `issuer` and use
   rp.WithCustomDiscoveryUrl(internalURL) to fetch discovery via the
   in-cluster endpoint. The AuthURL rebasing on OAuthConfig() still
   applies (endpoints are request-host-derived by Dex), but TokenURL +
   JWKS stay internal as intended.

2. HIGH — state = sanitized returnUrl was predictable

   For the vast majority of callers returnUrl is just "/", which means
   `state` was guessable. An attacker triggering /auth/login on a
   victim's browser then driving them to /auth/callback with the
   attacker's own code+state=/ would have bypassed CSRF / session-fixation
   protection: the cookie-state vs query-state check trivially matches
   when both are "/".

   Fix: state is now "<16-byte nonce>:<returnUrl>". The nonce makes the
   state unguessable per login attempt; returnUrl piggybacks for free.
   The callback splits on the first ":" to recover the returnUrl (and
   re-sanitises it, defense in depth).

3. MEDIUM — process-random cookie keys broke multi-replica + rolling updates

   The previous code minted per-process hash/block keys, so a callback
   landing on a different pod than the one that started the login would
   fail to decrypt the state cookie. Fine for the current single-replica
   cluster, broken for any rolling update or replicaCount > 1.

   Fix: add OIDC_COOKIE_HASH_KEY / OIDC_COOKIE_BLOCK_KEY env vars
   (loaded via config), with a warn-and-random fallback when unset.
   Chart pipes them through the existing Secret, so values become
   `auth.oidcCookieHashKey` / `auth.oidcCookieBlockKey`. Documented in
   the values comment.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants