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
+21 -10
View File
@@ -120,26 +120,37 @@ func (d *DB) GetUserByOIDC(ctx context.Context, issuer, subject string) (*domain
}
// LinkOIDC stamps an OIDC identity onto an existing user (first OIDC login for a
// pre-existing local account) and returns the updated row. A collision with
// another user's identity pair trips the UNIQUE index and maps to ErrEmailTaken
// as a generic conflict (it shouldn't happen — the caller looks up by identity
// first — but the index is the backstop).
// pre-existing local account) and returns the updated row.
//
// The UPDATE only matches when the account carries no identity yet, or already
// carries this exact one (idempotent) — it will NOT overwrite a different stored
// identity, which would let anyone asserting the same email at a second IdP
// hijack or lock out the account. A no-match (different identity, or the row is
// gone) and a UNIQUE (issuer, subject) collision with another account both
// surface as domain.ErrOIDCIdentityConflict. RETURNING folds the read-back into
// the same statement.
func (d *DB) LinkOIDC(ctx context.Context, userID int64, issuer, subject string) (*domain.User, error) {
_, err := d.sql.ExecContext(ctx,
u, err := scanUser(d.sql.QueryRowContext(ctx,
`UPDATE users
SET oidc_issuer = ?, oidc_subject = ?,
version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ?`,
issuer, subject, userID,
)
WHERE id = ?
AND (oidc_subject IS NULL OR (oidc_issuer = ? AND oidc_subject = ?))
RETURNING `+userColumns,
issuer, subject, userID, issuer, subject,
))
if errors.Is(err, sql.ErrNoRows) {
// The account already has a different identity (or no longer exists).
return nil, domain.ErrOIDCIdentityConflict
}
if err != nil {
if isUniqueViolation(err) {
return nil, domain.ErrEmailTaken
return nil, domain.ErrOIDCIdentityConflict
}
return nil, fmt.Errorf("store: link oidc: %w", err)
}
return d.GetUserByID(ctx, userID)
return u, nil
}
// CountUsers returns the number of user rows. Used to decide first-user-is-admin