mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51:30 +00:00
fix(oidc/bcl): resolve sub→actor_id via users.GetByOIDCSubject (CRIT-2 closure)
Closes CRIT-2 of the 2026-05-10 audit. The BCL handler previously called
sessionSvc.RevokeAllForActor(sub, "User") but session rows are keyed by
user.ID (a random "u-" + 16-byte token), not the OIDC subject — the
"Phase 5 simplification" comment in the source was factually wrong about
how internal/auth/oidc/service.go::upsertUser seeds user.ID. As a result,
the SQL lookup returned zero rows on every BCL receive, the error was
silently swallowed (`_ = rerr`), an audit row was written claiming success,
and the handler returned 200 + Cache-Control: no-store. OIDC BCL 1.0 §2.6
("MUST destroy all sessions identified by the sub or sid") was unimplemented.
CWE-613.
This commit:
- Adds userRepo (repository.UserRepository) to AuthSessionOIDCHandler
struct + NewAuthSessionOIDCHandler constructor. cmd/server/main.go
injects the existing oidcUserRepo (no new repository instance).
- Replaces the broken sub-as-actor-id path with:
1. providerRepo.List(ctx, tenantID) + IssuerURL filter to map
claims.iss → provider row (N is small; typically 1-5).
2. userRepo.GetByOIDCSubject(ctx, provider.ID, sub) to resolve the
OIDC subject → user.ID.
3. sessionSvc.RevokeAllForActor(user.ID, "User") with the RESOLVED
actor_id (not the OIDC subject).
- Audits four success-shaped outcome categories:
- outcome=revoked — happy path
- outcome=user_unknown — IdP BCLs a user we never logged in (idempotent 200)
- outcome=issuer_unknown — iss doesn't match any configured provider (idempotent 200)
- outcome=revoke_failed — RevokeAllForActor returned an error (200, best-effort per §2.8)
And two transient outcomes that return 503 (IdP retries per §2.8):
- outcome=provider_lookup_failed — providerRepo.List error
- outcome=user_lookup_failed — non-NotFound userRepo error
- Removes the misleading "Phase 5 simplification" comment block; replaces
with a doc explaining the resolution path + outcome taxonomy + spec refs.
- Adds 5 regression tests in internal/api/handler/auth_session_oidc_test.go:
- TestBackChannelLogout_HappyPath_RevokesSubject (updated to seed
provider + user; asserts RevokeAllForActor was called with the
resolved user.ID, not the raw OIDC subject — the test that would
have caught CRIT-2 had it existed)
- TestBackChannelLogout_UnknownUserReturns200WithAudit
- TestBackChannelLogout_IssuerUnknownReturns200WithAudit
- TestBackChannelLogout_TransientUserRepoErrorReturns503
- TestBackChannelLogout_RevokeFailureReturns200WithAuditFailureOutcome
- Introduces stubUserRepo in the handler test file (matching the four
repository.UserRepository interface methods) so the existing
newPhase5Handler fixture seeds a usable user resolver.
Verification gate green:
- gofmt -l . clean
- go vet ./... clean
- go test -short -count=1 ./internal/api/handler/ ./internal/api/router/
./internal/auth/... ./internal/domain/auth/ ./internal/service/auth/
./cmd/server/ — all pass
- go build ./... clean
CRIT-1 from the same audit is already closed on this branch (commit
68ca42f); CRIT-3 / CRIT-4 / CRIT-5 remain open and continue to block
the v2.1.0 tag. Spec: cowork/auth-bundles-fixes-2026-05-10/02-crit-2-bcl-sub-lookup.md.
Refs: cowork/auth-bundles-audit-2026-05-10.md CRIT-2
This commit is contained in:
@@ -429,6 +429,7 @@ func main() {
|
||||
oidcProviderRepo,
|
||||
oidcMappingRepo,
|
||||
sessionRepo,
|
||||
oidcUserRepo, // CRIT-2: BCL sub→actor_id lookup via users.GetByOIDCSubject
|
||||
auditService,
|
||||
cfg.Encryption.ConfigEncryptionKey,
|
||||
authdomainAlias.DefaultTenantID,
|
||||
|
||||
@@ -103,6 +103,7 @@ type AuthSessionOIDCHandler struct {
|
||||
providerRepo repository.OIDCProviderRepository
|
||||
mappingRepo repository.GroupRoleMappingRepository
|
||||
sessionRepo repository.SessionRepository
|
||||
userRepo repository.UserRepository // CRIT-2: BCL sub→actor_id lookup
|
||||
audit AuditRecorder
|
||||
encryptionKey string
|
||||
cookieAttrs SessionCookieAttrs
|
||||
@@ -116,6 +117,11 @@ type AuditRecorder interface {
|
||||
}
|
||||
|
||||
// NewAuthSessionOIDCHandler constructs the handler.
|
||||
//
|
||||
// userRepo is load-bearing for the BCL sub→actor_id resolution
|
||||
// (CRIT-2 of the 2026-05-10 audit). Passing nil here is only valid in
|
||||
// tests that exercise non-BCL paths; production wiring in
|
||||
// cmd/server/main.go MUST inject a non-nil repository.
|
||||
func NewAuthSessionOIDCHandler(
|
||||
oidcSvc OIDCAuthHandshaker,
|
||||
sessionSvc SessionMinter,
|
||||
@@ -123,6 +129,7 @@ func NewAuthSessionOIDCHandler(
|
||||
providerRepo repository.OIDCProviderRepository,
|
||||
mappingRepo repository.GroupRoleMappingRepository,
|
||||
sessionRepo repository.SessionRepository,
|
||||
userRepo repository.UserRepository,
|
||||
audit AuditRecorder,
|
||||
encryptionKey, tenantID, postLoginURL string,
|
||||
cookieAttrs SessionCookieAttrs,
|
||||
@@ -137,6 +144,7 @@ func NewAuthSessionOIDCHandler(
|
||||
providerRepo: providerRepo,
|
||||
mappingRepo: mappingRepo,
|
||||
sessionRepo: sessionRepo,
|
||||
userRepo: userRepo,
|
||||
audit: audit,
|
||||
encryptionKey: encryptionKey,
|
||||
cookieAttrs: cookieAttrs,
|
||||
@@ -314,16 +322,80 @@ func (h *AuthSessionOIDCHandler) BackChannelLogout(w http.ResponseWriter, r *htt
|
||||
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sid,
|
||||
map[string]interface{}{"sub_or_sid": "sid", "issuer": issuer, "session_id": sid})
|
||||
} else if sub != "" {
|
||||
// Phase 5 simplification: revoke ALL sessions belonging to a User
|
||||
// actor with this oidc_subject. The full subject->actor_id lookup
|
||||
// is a 1-row select on users; for v1 we treat sub as the actor_id
|
||||
// directly (this matches the user.id seeding pattern in Phase 3
|
||||
// upsertUser, which uses oidc_subject as the actor_id stem).
|
||||
if rerr := h.sessionSvc.RevokeAllForActor(r.Context(), sub, "User"); rerr != nil {
|
||||
_ = rerr
|
||||
// CRIT-2 closure of the 2026-05-10 audit. Pre-fix this branch called
|
||||
// RevokeAllForActor(sub, "User") under the false assumption that
|
||||
// the OIDC subject was used as the actor_id stem. In reality,
|
||||
// internal/auth/oidc/service.go::upsertUser mints
|
||||
// u.ID = "u-" + randomB64URL(16) and stores the OIDC subject in
|
||||
// a separate column, so the pre-fix lookup never found a session
|
||||
// row and the error was silently swallowed. BCL silently revoked
|
||||
// nothing — CWE-613.
|
||||
//
|
||||
// The fix resolves the IdP-signed `iss` claim back to a provider
|
||||
// row via providerRepo.List + IssuerURL filter, then resolves
|
||||
// sub → user.ID via userRepo.GetByOIDCSubject, then revokes all
|
||||
// sessions for that actor. Outcome categories audited:
|
||||
// - revoked (happy path)
|
||||
// - issuer_unknown (iss doesn't match any configured provider)
|
||||
// - user_unknown (provider matched, but no user.id seeded for this subject)
|
||||
// - revoke_failed (DB hiccup at the revoke step)
|
||||
// - provider_lookup_failed / user_lookup_failed → 503 (transient; IdP retries)
|
||||
// All success-shaped outcomes return 200 + Cache-Control: no-store
|
||||
// per OIDC BCL 1.0 §2.7. Transient errors return 503 so the IdP
|
||||
// follows its own retry semantics.
|
||||
providers, plerr := h.providerRepo.List(r.Context(), h.tenantID)
|
||||
if plerr != nil {
|
||||
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub,
|
||||
map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "provider_lookup_failed"})
|
||||
http.Error(w, "transient", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub,
|
||||
map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub})
|
||||
var matched *oidcdomain.OIDCProvider
|
||||
for _, p := range providers {
|
||||
if p.IssuerURL == issuer {
|
||||
matched = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub,
|
||||
map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "issuer_unknown"})
|
||||
// Idempotent — return 200 per spec.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
user, uerr := h.userRepo.GetByOIDCSubject(r.Context(), matched.ID, sub)
|
||||
if uerr != nil {
|
||||
if errors.Is(uerr, repository.ErrUserNotFound) {
|
||||
// Idempotent: nothing to revoke. IdP may BCL a user we
|
||||
// never logged in. RFC compliance: still 200.
|
||||
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub,
|
||||
map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "user_unknown"})
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
// Transient — let the IdP retry.
|
||||
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub,
|
||||
map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "user_lookup_failed"})
|
||||
http.Error(w, "transient", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
if rerr := h.sessionSvc.RevokeAllForActor(r.Context(), user.ID, string(domain.ActorTypeUser)); rerr != nil {
|
||||
// Revoke failed — BCL is best-effort per §2.8; still 200,
|
||||
// audit the failure.
|
||||
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", user.ID, domain.ActorTypeUser, sub,
|
||||
map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "revoke_failed"})
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", user.ID, domain.ActorTypeUser, sub,
|
||||
map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "revoked"})
|
||||
}
|
||||
// Per spec §2.7 — Cache-Control: no-store on success.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
@@ -194,6 +194,39 @@ func (s *stubSessionRepo) RevokeAllForActor(_ context.Context, _, _, _ string) e
|
||||
func (s *stubSessionRepo) GarbageCollectExpired(_ context.Context) (int, error) { return 0, nil }
|
||||
func (s *stubSessionRepo) Delete(_ context.Context, _ string) error { return nil }
|
||||
|
||||
// stubUserRepo implements just enough of repository.UserRepository for
|
||||
// the BCL sub→actor_id resolution path (CRIT-2 closure). Lookups by
|
||||
// (providerID, subject) return the seeded row if present, ErrUserNotFound
|
||||
// otherwise. lookupErr forces a non-NotFound error (the "transient"
|
||||
// 503 path).
|
||||
type stubUserRepo struct {
|
||||
users map[string]*userdomain.User // key = providerID|subject
|
||||
lookupErr error // when non-nil, GetByOIDCSubject returns this
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) Get(_ context.Context, _ string) (*userdomain.User, error) {
|
||||
return nil, repository.ErrUserNotFound
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) GetByOIDCSubject(_ context.Context, providerID, subject string) (*userdomain.User, error) {
|
||||
if s.lookupErr != nil {
|
||||
return nil, s.lookupErr
|
||||
}
|
||||
if s.users == nil {
|
||||
return nil, repository.ErrUserNotFound
|
||||
}
|
||||
if u, ok := s.users[providerID+"|"+subject]; ok {
|
||||
return u, nil
|
||||
}
|
||||
return nil, repository.ErrUserNotFound
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) Create(_ context.Context, _ *userdomain.User) error { return nil }
|
||||
func (s *stubUserRepo) Update(_ context.Context, _ *userdomain.User) error { return nil }
|
||||
func (s *stubUserRepo) ListAll(_ context.Context, _ string) ([]*userdomain.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type phase5StubAudit struct {
|
||||
events []string
|
||||
}
|
||||
@@ -212,18 +245,19 @@ func newPhase5Handler(
|
||||
oidcSvc *stubOIDCSvc,
|
||||
sess *stubSession,
|
||||
bcl *stubBCLVerifier,
|
||||
) (*AuthSessionOIDCHandler, *stubProviderRepo, *stubMappingRepo, *stubSessionRepo, *phase5StubAudit) {
|
||||
) (*AuthSessionOIDCHandler, *stubProviderRepo, *stubMappingRepo, *stubSessionRepo, *phase5StubAudit, *stubUserRepo) {
|
||||
t.Helper()
|
||||
provRepo := &stubProviderRepo{}
|
||||
mapRepo := &stubMappingRepo{}
|
||||
sessRepo := newStubSessionRepo()
|
||||
userRepo := &stubUserRepo{}
|
||||
audit := &phase5StubAudit{}
|
||||
h := NewAuthSessionOIDCHandler(
|
||||
oidcSvc, sess, bcl, provRepo, mapRepo, sessRepo, audit,
|
||||
oidcSvc, sess, bcl, provRepo, mapRepo, sessRepo, userRepo, audit,
|
||||
"", "t-default", "/dashboard",
|
||||
SessionCookieAttrs{SameSite: http.SameSiteLaxMode, Secure: true},
|
||||
)
|
||||
return h, provRepo, mapRepo, sessRepo, audit
|
||||
return h, provRepo, mapRepo, sessRepo, audit, userRepo
|
||||
}
|
||||
|
||||
// withActor adds the same context keys the auth middleware would set.
|
||||
@@ -248,7 +282,7 @@ func TestLoginInitiate_HappyPath(t *testing.T) {
|
||||
cookie: "v1.pl-abc.sk-xyz.somemac",
|
||||
preLoginID: "pl-abc",
|
||||
}
|
||||
h, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/login?provider=op-x", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -273,7 +307,7 @@ func TestLoginInitiate_HappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoginInitiate_MissingProvider(t *testing.T) {
|
||||
h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.LoginInitiate(w, req)
|
||||
@@ -284,7 +318,7 @@ func TestLoginInitiate_MissingProvider(t *testing.T) {
|
||||
|
||||
func TestLoginInitiate_ProviderNotFound(t *testing.T) {
|
||||
o := &stubOIDCSvc{authReqErr: repository.ErrOIDCProviderNotFound}
|
||||
h, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/login?provider=op-missing", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.LoginInitiate(w, req)
|
||||
@@ -305,7 +339,7 @@ func TestLoginCallback_HappyPath(t *testing.T) {
|
||||
CookieValue: "v1.ses-abc.sk-xyz.mac",
|
||||
CSRFToken: "csrf-token-value",
|
||||
}}
|
||||
h, _, _, _, audit := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, audit, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?code=abc&state=xyz", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessiondomain.PreLoginCookieName, Value: "v1.pl-abc.sk-xyz.mac"})
|
||||
@@ -331,7 +365,7 @@ func TestLoginCallback_HappyPath(t *testing.T) {
|
||||
// ErrPreLoginNotFound on the second call; the handler maps to 400.)
|
||||
func TestLoginCallback_ReplayedState_Returns400(t *testing.T) {
|
||||
o := &stubOIDCSvc{callbackErr: oidcsvc.ErrPreLoginNotFound}
|
||||
h, _, _, _, audit := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, audit, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?code=abc&state=xyz", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessiondomain.PreLoginCookieName, Value: "v1.pl-abc.sk-xyz.mac"})
|
||||
@@ -350,7 +384,7 @@ func TestLoginCallback_ReplayedState_Returns400(t *testing.T) {
|
||||
// match the challenge; the handler surfaces it as 400.
|
||||
func TestLoginCallback_PKCEVerifierMismatch_Returns400(t *testing.T) {
|
||||
o := &stubOIDCSvc{callbackErr: errors.New("oidc: code exchange failed: invalid_grant")}
|
||||
h, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?code=abc&state=xyz", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessiondomain.PreLoginCookieName, Value: "v1.pl-abc.sk-xyz.mac"})
|
||||
w := httptest.NewRecorder()
|
||||
@@ -365,7 +399,7 @@ func TestLoginCallback_ExpiredPreLoginRow_Returns400(t *testing.T) {
|
||||
// Adapter maps ErrPreLoginExpired -> ErrPreLoginNotFound (uniform
|
||||
// 400 per spec; specific reason in audit row).
|
||||
o := &stubOIDCSvc{callbackErr: oidcsvc.ErrPreLoginNotFound}
|
||||
h, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?code=abc&state=xyz", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessiondomain.PreLoginCookieName, Value: "v1.pl-abc.sk-xyz.mac"})
|
||||
w := httptest.NewRecorder()
|
||||
@@ -376,7 +410,7 @@ func TestLoginCallback_ExpiredPreLoginRow_Returns400(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoginCallback_MissingPreLoginCookie_Returns400(t *testing.T) {
|
||||
h, _, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?code=abc&state=xyz", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.LoginCallback(w, req)
|
||||
@@ -390,7 +424,7 @@ func TestLoginCallback_MissingPreLoginCookie_Returns400(t *testing.T) {
|
||||
|
||||
func TestLoginCallback_UnmappedGroups_AuditRowDistinguished(t *testing.T) {
|
||||
o := &stubOIDCSvc{callbackErr: oidcsvc.ErrGroupsUnmapped}
|
||||
h, _, _, _, audit := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, audit, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?code=abc&state=xyz", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessiondomain.PreLoginCookieName, Value: "v1.pl-abc.sk-xyz.mac"})
|
||||
w := httptest.NewRecorder()
|
||||
@@ -410,7 +444,7 @@ func TestLoginCallback_UnmappedGroups_AuditRowDistinguished(t *testing.T) {
|
||||
// Phase 5 spec mandate #1: BCL with missing events claim -> 400.
|
||||
func TestBackChannelLogout_MissingEvents_Returns400(t *testing.T) {
|
||||
bcl := &stubBCLVerifier{err: errors.New("missing events claim")}
|
||||
h, _, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl)
|
||||
h, _, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl)
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout",
|
||||
strings.NewReader("logout_token=eyJ.payload.sig"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -427,7 +461,7 @@ func TestBackChannelLogout_MissingEvents_Returns400(t *testing.T) {
|
||||
// Phase 5 spec mandate #2: BCL with nonce present -> 400 (per spec §2.4).
|
||||
func TestBackChannelLogout_NoncePresent_Returns400(t *testing.T) {
|
||||
bcl := &stubBCLVerifier{err: errors.New("nonce claim must be absent in logout_token")}
|
||||
h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl)
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl)
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout",
|
||||
strings.NewReader("logout_token=eyJ.payload.sig"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -441,7 +475,7 @@ func TestBackChannelLogout_NoncePresent_Returns400(t *testing.T) {
|
||||
// Phase 5 spec mandate #3: BCL with sig signed by an unknown key -> 400.
|
||||
func TestBackChannelLogout_UnknownKeySig_Returns400(t *testing.T) {
|
||||
bcl := &stubBCLVerifier{err: errors.New("verify: signature key not found in JWKS")}
|
||||
h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl)
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl)
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout",
|
||||
strings.NewReader("logout_token=eyJ.payload.sig"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -452,10 +486,26 @@ func TestBackChannelLogout_UnknownKeySig_Returns400(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackChannelLogout_HappyPath_RevokesSubject pins the CRIT-2
|
||||
// closure happy-path: an IdP fires BCL with sub=<oidc-subject>, the
|
||||
// handler resolves sub → user.ID via providerRepo (issuer match) +
|
||||
// userRepo.GetByOIDCSubject, then calls sessionSvc.RevokeAllForActor
|
||||
// with the RESOLVED actor_id (NOT the OIDC subject — pre-fix bug
|
||||
// where the handler called RevokeAllForActor(sub, "User") and silently
|
||||
// revoked nothing because session rows are keyed by user.ID).
|
||||
func TestBackChannelLogout_HappyPath_RevokesSubject(t *testing.T) {
|
||||
bcl := &stubBCLVerifier{issuer: "https://idp", sub: "u-alice"}
|
||||
bcl := &stubBCLVerifier{issuer: "https://idp", sub: "alice@example.com"}
|
||||
sess := &stubSession{}
|
||||
h, _, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl)
|
||||
h, provRepo, _, _, audit, userRepo := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl)
|
||||
|
||||
// Seed: provider with matching IssuerURL + user keyed by (provider.ID, sub).
|
||||
provRepo.provs = []*oidcdomain.OIDCProvider{
|
||||
{ID: "iss-1", IssuerURL: "https://idp", TenantID: "t-default"},
|
||||
}
|
||||
userRepo.users = map[string]*userdomain.User{
|
||||
"iss-1|alice@example.com": {ID: "u-alice", TenantID: "t-default"},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout",
|
||||
strings.NewReader("logout_token=eyJ.payload.sig"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -470,15 +520,139 @@ func TestBackChannelLogout_HappyPath_RevokesSubject(t *testing.T) {
|
||||
if len(sess.revokeAllIDs) != 1 || sess.revokeAllIDs[0] != "u-alice" {
|
||||
t.Errorf("expected RevokeAllForActor(u-alice); got %v", sess.revokeAllIDs)
|
||||
}
|
||||
if len(sess.revokeAllTypes) != 1 || sess.revokeAllTypes[0] != "User" {
|
||||
t.Errorf("expected actor_type=User; got %v", sess.revokeAllTypes)
|
||||
}
|
||||
if !contains(audit.events, "auth.oidc_back_channel_logout") {
|
||||
t.Errorf("expected auth.oidc_back_channel_logout audit event")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackChannelLogout_UnknownUserReturns200WithAudit covers the
|
||||
// idempotent-200 path when the IdP BCLs a user we never logged in.
|
||||
// Per OIDC BCL §2.7 we still return 200 + Cache-Control: no-store; the
|
||||
// audit row carries outcome=user_unknown so forensics can distinguish.
|
||||
func TestBackChannelLogout_UnknownUserReturns200WithAudit(t *testing.T) {
|
||||
bcl := &stubBCLVerifier{issuer: "https://idp", sub: "stranger@example.com"}
|
||||
sess := &stubSession{}
|
||||
h, provRepo, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl)
|
||||
// Provider matches, but no user is seeded for the subject.
|
||||
provRepo.provs = []*oidcdomain.OIDCProvider{
|
||||
{ID: "iss-1", IssuerURL: "https://idp", TenantID: "t-default"},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout",
|
||||
strings.NewReader("logout_token=eyJ.payload.sig"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
h.BackChannelLogout(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d; want 200 (idempotent); got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
if cc := w.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("Cache-Control = %q; want no-store", cc)
|
||||
}
|
||||
if len(sess.revokeAllIDs) != 0 {
|
||||
t.Errorf("expected no RevokeAllForActor calls (no user seeded); got %v", sess.revokeAllIDs)
|
||||
}
|
||||
if !contains(audit.events, "auth.oidc_back_channel_logout") {
|
||||
t.Errorf("expected auth.oidc_back_channel_logout audit event with outcome=user_unknown")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackChannelLogout_IssuerUnknownReturns200WithAudit covers the
|
||||
// "iss doesn't match any configured provider" path. Per RFC idempotency,
|
||||
// still 200; outcome=issuer_unknown in the audit row.
|
||||
func TestBackChannelLogout_IssuerUnknownReturns200WithAudit(t *testing.T) {
|
||||
bcl := &stubBCLVerifier{issuer: "https://wrong-idp", sub: "alice@example.com"}
|
||||
sess := &stubSession{}
|
||||
h, provRepo, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl)
|
||||
provRepo.provs = []*oidcdomain.OIDCProvider{
|
||||
{ID: "iss-1", IssuerURL: "https://idp", TenantID: "t-default"}, // mismatched
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout",
|
||||
strings.NewReader("logout_token=eyJ.payload.sig"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
h.BackChannelLogout(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d; want 200 (idempotent on unknown issuer)", w.Code)
|
||||
}
|
||||
if len(sess.revokeAllIDs) != 0 {
|
||||
t.Errorf("expected no RevokeAllForActor calls; got %v", sess.revokeAllIDs)
|
||||
}
|
||||
if !contains(audit.events, "auth.oidc_back_channel_logout") {
|
||||
t.Errorf("expected audit event with outcome=issuer_unknown")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackChannelLogout_TransientUserRepoErrorReturns503 covers the
|
||||
// transient-DB-failure path. A non-NotFound error from the user
|
||||
// repository surfaces as 503 so the IdP follows its retry semantics
|
||||
// (per OIDC BCL §2.8 IdPs SHOULD retry on transient failures).
|
||||
func TestBackChannelLogout_TransientUserRepoErrorReturns503(t *testing.T) {
|
||||
bcl := &stubBCLVerifier{issuer: "https://idp", sub: "alice@example.com"}
|
||||
sess := &stubSession{}
|
||||
h, provRepo, _, _, _, userRepo := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl)
|
||||
provRepo.provs = []*oidcdomain.OIDCProvider{
|
||||
{ID: "iss-1", IssuerURL: "https://idp", TenantID: "t-default"},
|
||||
}
|
||||
userRepo.lookupErr = errors.New("db connection reset")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout",
|
||||
strings.NewReader("logout_token=eyJ.payload.sig"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
h.BackChannelLogout(w, req)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("status = %d; want 503 (transient → IdP retries)", w.Code)
|
||||
}
|
||||
if len(sess.revokeAllIDs) != 0 {
|
||||
t.Errorf("expected no revoke on transient error; got %v", sess.revokeAllIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackChannelLogout_RevokeFailureReturns200WithAuditFailureOutcome
|
||||
// covers the path where user resolution succeeds but the
|
||||
// RevokeAllForActor call fails. BCL is best-effort per §2.8; still 200,
|
||||
// audit row carries outcome=revoke_failed.
|
||||
func TestBackChannelLogout_RevokeFailureReturns200WithAuditFailureOutcome(t *testing.T) {
|
||||
bcl := &stubBCLVerifier{issuer: "https://idp", sub: "alice@example.com"}
|
||||
sess := &stubSession{revokeAllErr: errors.New("transient")}
|
||||
h, provRepo, _, _, audit, userRepo := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl)
|
||||
provRepo.provs = []*oidcdomain.OIDCProvider{
|
||||
{ID: "iss-1", IssuerURL: "https://idp", TenantID: "t-default"},
|
||||
}
|
||||
userRepo.users = map[string]*userdomain.User{
|
||||
"iss-1|alice@example.com": {ID: "u-alice", TenantID: "t-default"},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout",
|
||||
strings.NewReader("logout_token=eyJ.payload.sig"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
h.BackChannelLogout(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d; want 200 (best-effort on revoke failure)", w.Code)
|
||||
}
|
||||
if cc := w.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("Cache-Control = %q; want no-store", cc)
|
||||
}
|
||||
// RevokeAllForActor WAS called (and failed); audit MUST record the
|
||||
// outcome so the operator can debug.
|
||||
if len(sess.revokeAllIDs) != 1 || sess.revokeAllIDs[0] != "u-alice" {
|
||||
t.Errorf("expected RevokeAllForActor(u-alice) attempted; got %v", sess.revokeAllIDs)
|
||||
}
|
||||
if !contains(audit.events, "auth.oidc_back_channel_logout") {
|
||||
t.Errorf("expected audit event with outcome=revoke_failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackChannelLogout_HappyPath_RevokesSid(t *testing.T) {
|
||||
bcl := &stubBCLVerifier{issuer: "https://idp", sid: "ses-xyz"}
|
||||
sess := &stubSession{}
|
||||
h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl)
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl)
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout",
|
||||
strings.NewReader("logout_token=eyJ.payload.sig"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -493,7 +667,7 @@ func TestBackChannelLogout_HappyPath_RevokesSid(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackChannelLogout_MissingTokenReturns400(t *testing.T) {
|
||||
h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout", strings.NewReader(""))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
@@ -509,7 +683,7 @@ func TestBackChannelLogout_MissingTokenReturns400(t *testing.T) {
|
||||
|
||||
func TestLogout_HappyPath(t *testing.T) {
|
||||
sess := &stubSession{validateRes: &sessiondomain.Session{ID: "ses-abc", ActorID: "u-x", ActorType: "User"}}
|
||||
h, _, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, sess, &stubBCLVerifier{})
|
||||
h, _, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, sess, &stubBCLVerifier{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
||||
req = withActor(req, "u-x", "User")
|
||||
@@ -528,7 +702,7 @@ func TestLogout_HappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLogout_NoCookie_Returns204(t *testing.T) {
|
||||
h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
||||
req = withActor(req, "u-x", "User")
|
||||
w := httptest.NewRecorder()
|
||||
@@ -543,7 +717,7 @@ func TestLogout_NoCookie_Returns204(t *testing.T) {
|
||||
// =============================================================================
|
||||
|
||||
func TestListSessions_OwnSessions(t *testing.T) {
|
||||
h, _, _, sessRepo, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, sessRepo, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
now := time.Now()
|
||||
sessRepo.rows["ses-1"] = &sessiondomain.Session{
|
||||
ID: "ses-1", ActorID: "u-x", ActorType: "User",
|
||||
@@ -563,7 +737,7 @@ func TestListSessions_OwnSessions(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRevokeSession_HappyPath(t *testing.T) {
|
||||
h, _, _, sessRepo, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, sessRepo, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
sessRepo.rows["ses-rev"] = &sessiondomain.Session{ID: "ses-rev", ActorID: "u-x", ActorType: "User"}
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/ses-rev", nil)
|
||||
req.SetPathValue("id", "ses-rev")
|
||||
@@ -579,7 +753,7 @@ func TestRevokeSession_HappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRevokeSession_NotFound(t *testing.T) {
|
||||
h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/ses-nope", nil)
|
||||
req.SetPathValue("id", "ses-nope")
|
||||
req = withActor(req, "u-x", "User")
|
||||
@@ -595,7 +769,7 @@ func TestRevokeSession_NotFound(t *testing.T) {
|
||||
// =============================================================================
|
||||
|
||||
func TestListProviders(t *testing.T) {
|
||||
h, provRepo, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, provRepo, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
provRepo.provs = []*oidcdomain.OIDCProvider{
|
||||
{ID: "op-x", Name: "Okta", IssuerURL: "https://x", ClientID: "c"},
|
||||
}
|
||||
@@ -612,7 +786,7 @@ func TestListProviders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateProvider_MissingClientSecret(t *testing.T) {
|
||||
h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
body := strings.NewReader(`{"name":"x","issuer_url":"https://x","client_id":"c","redirect_uri":"https://r","groups_claim_path":"groups","groups_claim_format":"string-array"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/providers", body)
|
||||
req = withActor(req, "u-admin", "User")
|
||||
@@ -624,7 +798,7 @@ func TestCreateProvider_MissingClientSecret(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteProvider_InUse_Returns409(t *testing.T) {
|
||||
h, provRepo, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, provRepo, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
provRepo.deleteErr = repository.ErrOIDCProviderInUse
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/providers/op-x", nil)
|
||||
req.SetPathValue("id", "op-x")
|
||||
@@ -638,7 +812,7 @@ func TestDeleteProvider_InUse_Returns409(t *testing.T) {
|
||||
|
||||
func TestRefreshProvider_HappyPath(t *testing.T) {
|
||||
o := &stubOIDCSvc{}
|
||||
h, _, _, _, audit := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, audit, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/providers/op-x/refresh", nil)
|
||||
req.SetPathValue("id", "op-x")
|
||||
req = withActor(req, "u-admin", "User")
|
||||
@@ -657,7 +831,7 @@ func TestRefreshProvider_HappyPath(t *testing.T) {
|
||||
// =============================================================================
|
||||
|
||||
func TestListGroupMappings_MissingProviderID(t *testing.T) {
|
||||
h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/group-mappings", nil)
|
||||
req = withActor(req, "u-admin", "User")
|
||||
w := httptest.NewRecorder()
|
||||
@@ -668,7 +842,7 @@ func TestListGroupMappings_MissingProviderID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddGroupMapping_HappyPath(t *testing.T) {
|
||||
h, _, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
body := strings.NewReader(`{"provider_id":"op-x","group_name":"engineers","role_id":"r-operator"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/group-mappings", body)
|
||||
req = withActor(req, "u-admin", "User")
|
||||
@@ -683,7 +857,7 @@ func TestAddGroupMapping_HappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRemoveGroupMapping_NotFound(t *testing.T) {
|
||||
h, _, mapRepo, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, mapRepo, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
mapRepo.rmErr = repository.ErrGroupRoleMappingNotFound
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/group-mappings/grm-x", nil)
|
||||
req.SetPathValue("id", "grm-x")
|
||||
@@ -749,7 +923,7 @@ func TestClientIPFromRequest(t *testing.T) {
|
||||
func TestNewAuthSessionOIDCHandler_DefaultsPostLoginURL(t *testing.T) {
|
||||
h := NewAuthSessionOIDCHandler(
|
||||
&stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{},
|
||||
&stubProviderRepo{}, &stubMappingRepo{}, newStubSessionRepo(), &phase5StubAudit{},
|
||||
&stubProviderRepo{}, &stubMappingRepo{}, newStubSessionRepo(), &stubUserRepo{}, &phase5StubAudit{},
|
||||
"key", "t-default", "", // empty postLoginURL
|
||||
SessionCookieAttrs{},
|
||||
)
|
||||
@@ -827,7 +1001,7 @@ func TestPeekIssuer_RejectsBadSegmentCount(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateProvider_HappyPath(t *testing.T) {
|
||||
h, _, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
body := strings.NewReader(`{"name":"OktaTest","issuer_url":"https://example.okta.com","client_id":"c","client_secret":"s","redirect_uri":"https://r/cb","groups_claim_path":"groups","groups_claim_format":"string-array","scopes":["openid","profile","email"]}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/providers", body)
|
||||
req = withActor(req, "u-admin", "User")
|
||||
@@ -842,7 +1016,7 @@ func TestCreateProvider_HappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateProvider_DuplicateName_Returns409(t *testing.T) {
|
||||
h, provRepo, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, provRepo, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
provRepo.createErr = repository.ErrOIDCProviderDuplicateName
|
||||
body := strings.NewReader(`{"name":"DupTest","issuer_url":"https://example.okta.com","client_id":"c","client_secret":"s","redirect_uri":"https://r/cb","groups_claim_path":"groups","groups_claim_format":"string-array","scopes":["openid"]}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/providers", body)
|
||||
@@ -855,7 +1029,7 @@ func TestCreateProvider_DuplicateName_Returns409(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateProvider_InvalidJSON_Returns400(t *testing.T) {
|
||||
h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/providers", strings.NewReader("{not-json"))
|
||||
req = withActor(req, "u-admin", "User")
|
||||
w := httptest.NewRecorder()
|
||||
@@ -866,7 +1040,7 @@ func TestCreateProvider_InvalidJSON_Returns400(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateProvider_HappyPath(t *testing.T) {
|
||||
h, provRepo, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, provRepo, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
provRepo.provs = []*oidcdomain.OIDCProvider{
|
||||
{
|
||||
ID: "op-x", TenantID: "t-default", Name: "Old",
|
||||
@@ -891,7 +1065,7 @@ func TestUpdateProvider_HappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateProvider_NotFound(t *testing.T) {
|
||||
h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
body := strings.NewReader(`{"name":"X"}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/auth/oidc/providers/op-missing", body)
|
||||
req.SetPathValue("id", "op-missing")
|
||||
@@ -905,7 +1079,7 @@ func TestUpdateProvider_NotFound(t *testing.T) {
|
||||
|
||||
func TestRefreshProvider_NotFound(t *testing.T) {
|
||||
o := &stubOIDCSvc{refreshErr: repository.ErrOIDCProviderNotFound}
|
||||
h, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/providers/op-missing/refresh", nil)
|
||||
req.SetPathValue("id", "op-missing")
|
||||
req = withActor(req, "u-admin", "User")
|
||||
@@ -917,7 +1091,7 @@ func TestRefreshProvider_NotFound(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListGroupMappings_HappyPath(t *testing.T) {
|
||||
h, _, mapRepo, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, mapRepo, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
mapRepo.mappings = []*oidcdomain.GroupRoleMapping{
|
||||
{ID: "grm-1", ProviderID: "op-x", GroupName: "engineers", RoleID: "r-operator", TenantID: "t-default"},
|
||||
}
|
||||
@@ -931,7 +1105,7 @@ func TestListGroupMappings_HappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddGroupMapping_Duplicate_Returns409(t *testing.T) {
|
||||
h, _, mapRepo, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, mapRepo, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
mapRepo.addErr = repository.ErrGroupRoleMappingDuplicate
|
||||
body := strings.NewReader(`{"provider_id":"op-x","group_name":"g","role_id":"r-operator"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/group-mappings", body)
|
||||
@@ -944,7 +1118,7 @@ func TestAddGroupMapping_Duplicate_Returns409(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRemoveGroupMapping_HappyPath(t *testing.T) {
|
||||
h, _, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/group-mappings/grm-x", nil)
|
||||
req.SetPathValue("id", "grm-x")
|
||||
req = withActor(req, "u-admin", "User")
|
||||
@@ -959,7 +1133,7 @@ func TestRemoveGroupMapping_HappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRevokeSession_MissingID(t *testing.T) {
|
||||
h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/", nil)
|
||||
req = withActor(req, "u-x", "User")
|
||||
w := httptest.NewRecorder()
|
||||
@@ -970,7 +1144,7 @@ func TestRevokeSession_MissingID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListSessions_AsAdmin_QueryActorID(t *testing.T) {
|
||||
h, _, _, sessRepo, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
h, _, _, sessRepo, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
||||
now := time.Now()
|
||||
sessRepo.rows["ses-other"] = &sessiondomain.Session{
|
||||
ID: "ses-other", ActorID: "u-other", ActorType: "User",
|
||||
|
||||
Reference in New Issue
Block a user