Address Gadfly review on #5: OIDC identity guard, verified-email, timeouts
Build image / build-and-push (push) Successful in 9s
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:
+21
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user