Address Gadfly review on #5: OIDC identity guard, verified-email, timeouts
Build image / build-and-push (push) Successful in 9s

Fixes from the PR #24 adversarial review (graded 23 real / 1 false positive):

Security / correctness
- LinkOIDC no longer overwrites a different stored identity: the UPDATE
  matches only when the row has no identity yet or already carries this
  exact one, so a second IdP asserting the same verified email can't
  hijack or lock out an account (returns ErrOIDCIdentityConflict). Uses
  a single UPDATE...RETURNING (also fixes the ignored-RowsAffected /
  misleading-ErrNotFound path and the round-trip).
- Provisioning now requires a verified email for BOTH linking and JIT
  creation (was: linking only), so an unverified-email identity can't
  create an account — nor become the first admin on a fresh instance,
  nor squat an email a real user later owns.
- OIDC-identity collisions surface as the dedicated ErrOIDCIdentityConflict
  instead of the email-specific ErrEmailTaken.

Robustness
- readOIDCTxCookie requires a non-empty nonce (an empty one would make the
  callback's nonce check pass vacuously).
- Callback token exchange + verify run under a 15s context timeout so a
  slow IdP can't outlast the server write timeout.
- ensure() performs discovery outside the mutex, so concurrent cold-start
  requests don't serialize behind one another's full timeout.
- setOIDCTxCookie returns its marshal error; oidcLogin aborts rather than
  redirecting to the IdP with no tx cookie.

Maintainability
- redirectAuthError helper dedups the ~dozen callback redirects (and the
  empty-code path now logs like the rest).
- Distinct login error codes (no_email / email_unverified / oidc_conflict)
  for the UI; writeServiceError maps the OIDC sentinels; shared test issuer
  const.

Tests: unverified email refused for both link and JIT; identity-overwrite
refused while the original identity keeps working.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-18 17:33:25 -04:00
co-authored by Claude Opus 4.8
parent 84edf3e42a
commit 8ef092713f
6 changed files with 165 additions and 53 deletions
+8 -3
View File
@@ -39,10 +39,15 @@ var (
// ErrOIDCNoEmail means the IdP returned no email claim, so no account can be
// provisioned (email is the account's unique key). The email scope is required.
ErrOIDCNoEmail = errors.New("oidc identity has no email")
// ErrOIDCEmailUnverified means the IdP's email is unverified and it collides
// with an existing account; auto-linking it would enable account takeover, so
// it is refused.
// ErrOIDCEmailUnverified means the IdP asserted an email it hasn't verified;
// pansy won't provision or link on an unverified email (it would enable
// account takeover / squatting).
ErrOIDCEmailUnverified = errors.New("oidc email not verified")
// ErrOIDCIdentityConflict means the OIDC identity can't be attached: either
// the target account already carries a different identity (refusing to
// overwrite it prevents lockout/takeover) or the (issuer, subject) pair is
// already bound to another account. Mapped to 409.
ErrOIDCIdentityConflict = errors.New("oidc identity conflict")
)
// Enumerated string values mirrored from the schema CHECK constraints.