harden(auth/session+oidc): 503/401 split + go-oidc string pin (LOW-6 + Nit-2)

Audit 2026-05-10 — close LOW-6 + Nit-2 from the HANDOFF.md backend
batch (items 8 + 9).

LOW-6: introduce ErrSessionTransient sentinel in session.Service.
session.Validate now distinguishes:
  - errors.Is(err, repository.ErrSessionNotFound) → ErrSessionInvalidCookie (401)
  - All other repo errors                         → ErrSessionTransient (503)
The session middleware maps ErrSessionTransient to HTTP 503 with
Retry-After: 1. Pre-fix, every DB hiccup looked like a forged-cookie
401 and forced the user to re-authenticate on a transient outage.
Two new regression tests pin the wire shape:
  - TestService_Validate_TransientSessionGetError (service layer)
  - TestService_Validate_SessionNotFoundMapsToInvalidCookie (negative
    leg: not-found stays 401)
  - TestSessionMiddleware_TransientErrorMappedTo503 (middleware-level
    503 + Retry-After header)

Nit-2: isJWKSFetchError documentation now pins go-oidc/v3 v3.18.0 as
the source-of-truth string set. v3.18.0 exposes only
*oidc.TokenExpiredError as a typed error; JWKS-fetch failures bubble
up as fmt.Errorf-wrapped strings. New regression test
TestIsJWKSFetchError_GoOIDCV318Strings pins the canonical substrings
emitted by go-oidc's jwks.go — a future upstream bump that changes
the wording trips the test and forces the matcher to be re-derived.
The test caught a real gap: 'oidc: failed to decode keys' (emitted
when the IdP returns non-JSON at the jwks_uri — broken proxy, gateway
HTML error page, etc.) was previously misclassified as a generic 500
instead of 503 ErrJWKSUnreachable. Added 'decode keys' substring to
the matcher.

Status: LOW-6 + Nit-2 marked CLOSED in audit-doc table.

Refs: cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md items 8, 9
      cowork/auth-bundles-audit-2026-05-10.md LOW-6, Nit-2
This commit is contained in:
shankar0123
2026-05-10 22:41:19 +00:00
parent 9cce2ab043
commit e7c4654b16
6 changed files with 138 additions and 3 deletions
+27 -1
View File
@@ -921,14 +921,40 @@ func TestService_RotateSigningKey_RetireError(t *testing.T) {
}
}
func TestService_Validate_SessionGetErrorMappedToInvalidCookie(t *testing.T) {
// TestService_Validate_TransientSessionGetError pins the LOW-6
// closure (audit 2026-05-10): a non-deterministic DB error from
// session.Get bubbles up as ErrSessionTransient (→ 503), NOT
// ErrSessionInvalidCookie (→ 401). The middleware test pins the
// 503-with-Retry-After wire shape; this one pins the service-layer
// sentinel.
func TestService_Validate_TransientSessionGetError(t *testing.T) {
svc, sessions, _, _, _ := newTestService(t, defaultCfg())
res, _ := svc.Create(context.Background(), "u-y", "User", "", "")
sessions.getErr = fmt.Errorf("simulated session.Get failure")
_, err := svc.Validate(context.Background(), ValidateInput{CookieValue: res.CookieValue})
if !errors.Is(err, ErrSessionTransient) {
t.Errorf("err = %v; want ErrSessionTransient", err)
}
if errors.Is(err, ErrSessionInvalidCookie) {
t.Errorf("err also matched ErrSessionInvalidCookie; want only ErrSessionTransient")
}
}
// TestService_Validate_SessionNotFoundMapsToInvalidCookie pins the
// other half of the LOW-6 split: repository.ErrSessionNotFound (a
// real, deterministic "the row doesn't exist" answer from the DB)
// stays mapped to ErrSessionInvalidCookie (→ 401), NOT 503.
func TestService_Validate_SessionNotFoundMapsToInvalidCookie(t *testing.T) {
svc, sessions, _, _, _ := newTestService(t, defaultCfg())
res, _ := svc.Create(context.Background(), "u-y2", "User", "", "")
sessions.getErr = repository.ErrSessionNotFound
_, err := svc.Validate(context.Background(), ValidateInput{CookieValue: res.CookieValue})
if !errors.Is(err, ErrSessionInvalidCookie) {
t.Errorf("err = %v; want ErrSessionInvalidCookie", err)
}
if errors.Is(err, ErrSessionTransient) {
t.Errorf("err also matched ErrSessionTransient; want only ErrSessionInvalidCookie")
}
}
func TestService_UpdateLastSeen_RepoErrorWraps(t *testing.T) {