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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user