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
+42 -6
View File
@@ -290,7 +290,7 @@ func TestProvidersReflectsConfig(t *testing.T) {
// OIDC configured with a base URL → reported ready.
ready := openConfig()
ready.BaseURL = "https://pansy.example.com"
ready.OIDC = config.OIDCConfig{Issuer: "https://idp.example", ClientID: "cid", ButtonLabel: "Sign in with Authentik"}
ready.OIDC = config.OIDCConfig{Issuer: testOIDCIssuer, ClientID: "cid", ButtonLabel: "Sign in with Authentik"}
if got := newTestService(t, ready).Providers(); !got.OIDC || got.OIDCLabel != "Sign in with Authentik" {
t.Errorf("providers = %+v, want oidc=true with Authentik label", got)
}
@@ -303,8 +303,11 @@ func TestProvidersReflectsConfig(t *testing.T) {
}
}
// testOIDCIssuer is the issuer URL used across the OIDC service tests.
const testOIDCIssuer = "https://idp.example"
func oidcIdentity(sub, email, name string, verified bool) OIDCIdentity {
return OIDCIdentity{Issuer: "https://idp.example", Subject: sub, Email: email, EmailVerified: verified, Name: name}
return OIDCIdentity{Issuer: testOIDCIssuer, Subject: sub, Email: email, EmailVerified: verified, Name: name}
}
func TestLoginOIDCJITProvisionsThenReturnsSameUser(t *testing.T) {
@@ -351,14 +354,47 @@ func TestLoginOIDCLinksExistingLocalAccount(t *testing.T) {
}
}
func TestLoginOIDCRefusesUnverifiedEmailCollision(t *testing.T) {
func TestLoginOIDCRefusesUnverifiedEmail(t *testing.T) {
s := newTestService(t, openConfig())
mustRegister(t, s, "[email protected]", "Carol", "password123")
_, err := s.LoginOIDC(context.Background(), oidcIdentity("sub-3", "[email protected]", "Carol", false))
if !errors.Is(err, domain.ErrOIDCEmailUnverified) {
// Unverified email colliding with an existing account is refused (takeover).
mustRegister(t, s, "[email protected]", "Carol", "password123")
if _, err := s.LoginOIDC(context.Background(), oidcIdentity("sub-3", "[email protected]", "Carol", false)); !errors.Is(err, domain.ErrOIDCEmailUnverified) {
t.Errorf("unverified collision err = %v, want ErrOIDCEmailUnverified", err)
}
// Unverified email with NO collision is also refused (can't JIT-provision on
// an unverified email — it could squat an address a real user later owns, and
// could make an unverified identity the first admin).
if _, err := s.LoginOIDC(context.Background(), oidcIdentity("sub-3b", "[email protected]", "Fresh", false)); !errors.Is(err, domain.ErrOIDCEmailUnverified) {
t.Errorf("unverified JIT err = %v, want ErrOIDCEmailUnverified", err)
}
}
func TestLoginOIDCRefusesOverwritingDifferentIdentity(t *testing.T) {
s := newTestService(t, openConfig())
// A user links identity A.
first, err := s.LoginOIDC(context.Background(), oidcIdentity("sub-A", "[email protected]", "Dana", true))
if err != nil {
t.Fatalf("initial link: %v", err)
}
// A different identity asserting the same verified email must NOT overwrite
// the stored identity (that would hijack/lock out the account).
_, err = s.LoginOIDC(context.Background(), oidcIdentity("sub-B", "[email protected]", "Dana", true))
if !errors.Is(err, domain.ErrOIDCIdentityConflict) {
t.Fatalf("overwrite attempt err = %v, want ErrOIDCIdentityConflict", err)
}
// The original identity still works and still points at the same account.
again, err := s.LoginOIDC(context.Background(), oidcIdentity("sub-A", "[email protected]", "Dana", true))
if err != nil || again.ID != first.ID {
t.Errorf("original identity broken: user=%v err=%v", again, err)
}
if again.OIDCSubject == nil || *again.OIDCSubject != "sub-A" {
t.Errorf("stored identity was overwritten: %v", again.OIDCSubject)
}
}
func TestLoginOIDCRequiresEmail(t *testing.T) {