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:
+16
-10
@@ -57,19 +57,23 @@ func (s *Service) Providers() Providers {
|
||||
// issuing no session itself (the caller does). The IdP has already gated access,
|
||||
// so PANSY_REGISTRATION does not apply. Resolution order:
|
||||
// 1. an existing user with the same (issuer, subject) — a returning OIDC user;
|
||||
// 2. else an existing user with the same *verified* email — linked to this
|
||||
// identity (so one person isn't split across a local and an OIDC account);
|
||||
// 2. else an existing user with the same email — linked to this identity (so
|
||||
// one person isn't split across a local and an OIDC account);
|
||||
// 3. else a new just-in-time account stamped with the identity.
|
||||
//
|
||||
// An unverified email that collides with an existing account is refused (it
|
||||
// would let anyone who can assert that email at the IdP take over the account).
|
||||
// An identity with no email can't be provisioned (email is the account key).
|
||||
// Steps 2 and 3 require a verified email: linking on an unverified email would
|
||||
// let anyone who can assert that email at an IdP take over an account, and
|
||||
// JIT-creating on one would let them squat an email a real user later owns
|
||||
// (then the real user's different identity would be refused by LinkOIDC). An
|
||||
// identity with no email can't be provisioned at all (email is the account key).
|
||||
// Returning users (step 1) skip the email check — their identity is already
|
||||
// proven.
|
||||
func (s *Service) LoginOIDC(ctx context.Context, id OIDCIdentity) (*domain.User, error) {
|
||||
if id.Issuer == "" || id.Subject == "" {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
// 1. Returning OIDC user.
|
||||
// 1. Returning OIDC user (identity already proven; email state irrelevant).
|
||||
u, err := s.store.GetUserByOIDC(ctx, id.Issuer, id.Subject)
|
||||
if err == nil {
|
||||
return u, nil
|
||||
@@ -78,18 +82,20 @@ func (s *Service) LoginOIDC(ctx context.Context, id OIDCIdentity) (*domain.User,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Provisioning (link or create) requires a verified email.
|
||||
email := normalizeEmail(id.Email)
|
||||
if email == "" {
|
||||
return nil, domain.ErrOIDCNoEmail
|
||||
}
|
||||
if !id.EmailVerified {
|
||||
return nil, domain.ErrOIDCEmailUnverified
|
||||
}
|
||||
|
||||
// 2. Link to an existing account by email — but only a verified one.
|
||||
// 2. Link to an existing account by email. LinkOIDC refuses to overwrite a
|
||||
// different stored identity (returns ErrOIDCIdentityConflict).
|
||||
existing, err := s.store.GetUserByEmail(ctx, email)
|
||||
switch {
|
||||
case err == nil:
|
||||
if !id.EmailVerified {
|
||||
return nil, domain.ErrOIDCEmailUnverified
|
||||
}
|
||||
return s.store.LinkOIDC(ctx, existing.ID, id.Issuer, id.Subject)
|
||||
case !errors.Is(err, domain.ErrNotFound):
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user