mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:51:30 +00:00
9c679a5960
pre-login store, OpenID Connect Back-Channel Logout 1.0, cookieAuth
scheme, 7 new auth permissions, CI guard, handler tests
Phase 5 of the bundle puts the Phase 3 OIDC service + Phase 4 session
service on the wire. 13 HTTP endpoints split into three logical groups:
Public OIDC handshake (auth-exempt; protocol-mediated):
GET /auth/oidc/login?provider=<id> -> 302 to IdP authorization URL
+ sets certctl_oidc_pending cookie
(10-min TTL, Path=/auth/oidc/,
SameSite=Lax)
GET /auth/oidc/callback?code=...&state=... -> consume pre-login row,
run Phase 3's 11-step token
validation, mint post-login
session, 302 to dashboard
POST /auth/oidc/back-channel-logout -> OpenID Connect BCL 1.0 — IdP
POSTs logout_token JWT; certctl
validates signature against IdP
JWKS via Phase 3 alg allow-list,
required claims (iss/aud/iat/jti/
events; exactly one of sub/sid;
nonce ABSENT per spec §2.4),
revokes matching sessions,
returns 200 with
Cache-Control: no-store
POST /auth/logout -> revoke caller's session
Session management (RBAC-gated auth.session.*):
GET /api/v1/auth/sessions -> auth.session.list (own / all)
DELETE /api/v1/auth/sessions/{id} -> auth.session.revoke (own bypass)
OIDC provider + group-mapping CRUD (RBAC-gated auth.oidc.*):
GET /api/v1/auth/oidc/providers -> auth.oidc.list
POST /api/v1/auth/oidc/providers -> auth.oidc.create
(client_secret encrypted
at rest via
internal/crypto.EncryptIfKeySet)
PUT /api/v1/auth/oidc/providers/{id} -> auth.oidc.edit
DELETE /api/v1/auth/oidc/providers/{id} -> auth.oidc.delete
(refused via
ErrOIDCProviderInUse → 409
when users authenticated
via this provider)
POST /api/v1/auth/oidc/providers/{id}/refresh -> auth.oidc.edit
(re-runs IdP downgrade
defense via
OIDCService.RefreshKeys)
GET /api/v1/auth/oidc/group-mappings -> auth.oidc.list
POST /api/v1/auth/oidc/group-mappings -> auth.oidc.edit
DELETE /api/v1/auth/oidc/group-mappings/{id} -> auth.oidc.edit
Migration 000037 ships:
- oidc_pre_login_sessions table (10-min absolute TTL, FK CASCADE on
oidc_provider_id, FK RESTRICT on signing_key_id; index on
absolute_expires_at for the GC sweep);
- 7 new permissions seeded into r-admin only:
auth.session.list, auth.session.list.all, auth.session.revoke,
auth.oidc.list, auth.oidc.create, auth.oidc.edit, auth.oidc.delete
CanonicalPermissions extended in lockstep at internal/domain/auth/
validate.go.
Pre-login machinery:
- internal/repository/oidc.go gains PreLoginRepository interface +
PreLoginSession struct + ErrPreLoginNotFound / ErrPreLoginExpired
sentinels.
- internal/repository/postgres/oidc_prelogin.go ships the impl;
LookupAndConsume uses DELETE ... RETURNING for atomic single-use.
- internal/auth/oidc/prelogin.go is the PreLoginAdapter that bridges
the OIDC service's Phase 3 PreLoginStore interface to the new
repository, signing the cookie value under the active
SessionSigningKey via the same v1.<id>.<key>.<HMAC> wire format
Phase 4 uses for post-login cookies. Defense-in-depth: the
pre-login `pl-` prefix is enforced by ParseCookieValue(prefix);
a stolen pre-login cookie cannot be replayed against the
post-login Validate path (pinned by
TestService_Validate_RejectsPreLoginCookieAtPostLoginGate).
Session package extension:
- internal/auth/session/service.go gains exported SignCookieValue,
ParseCookieValue (with caller-supplied id-1 prefix), ComputeCookieHMAC,
DecryptKeyMaterial wrappers so the OIDC pre-login adapter shares
the same length-prefixed HMAC math without code duplication.
- parseCookie no longer hardcodes the `ses-` prefix check (moved to
Validate as defense-in-depth; pre-login cookie verification uses
the `pl-` prefix via ParseCookieValue).
Cookie attributes (all Phase 5 endpoints honor CERTCTL_SESSION_SAMESITE
+ Secure=true via SessionCookieAttrs from Phase 4 config):
- certctl_oidc_pending: Path=/auth/oidc/, MaxAge=600s, SameSite=Lax
(cannot be Strict because the IdP-initiated callback is a top-level
navigation from a different origin).
- certctl_session: Path=/, Expires=8h, SameSite=Lax|Strict, HttpOnly.
- certctl_csrf: Path=/, Expires=8h, HttpOnly=false (intentional —
GUI must read it to echo into X-CSRF-Token header).
Audit logging on every mutating operation (event_category="auth"):
auth.oidc_login_succeeded / failed / unmapped_groups
auth.oidc_back_channel_logout / failed
auth.session_revoked
auth.oidc_provider_{created,updated,deleted,refreshed}
auth.group_mapping_{added,removed}
OpenAPI updates:
- cookieAuth security scheme added to api/openapi.yaml under
components.securitySchemes (apiKey / cookie / certctl_session).
- The 13 Phase 5 routes are added to SpecParityExceptions with a
deferral note: full per-endpoint OpenAPI rows land in a follow-on
commit alongside the GUI work (Phase 8) so the ergonomic shape can
be validated against the live GUI client.
CI guard: scripts/ci-guards/N-bundle-2-security-empty-preserved.sh
asserts api/openapi.yaml has ≥ 14 'security: []' occurrences (the
pre-Bundle-2 baseline). Reducing the count below 14 would silently
force a Bearer-or-cookie requirement onto an endpoint that legitimately
runs without certctl-issued credentials; the guard fires before that
regression lands.
Handler tests (internal/api/handler/auth_session_oidc_test.go):
- All 6 prompt-mandated negative cases:
BCL with missing events claim -> 400
BCL with nonce present -> 400 (per spec §2.4)
BCL with sig signed by an unknown key -> 400
Callback with replayed state -> 400
Callback with PKCE verifier mismatch -> 400
Callback with expired pre-login row -> 400
- Plus happy paths for every endpoint, edge cases (missing-cookie,
duplicate-name, in-use-409, wrong-tenant), and the Helper-function
coverage (peekIssuer, classifyOIDCFailure, defaultIfBlank,
defaultIntIfZero, clientIPFromRequest, encryptClientSecret).
Coverage on internal/api/handler/auth_session_oidc.go: 80.9% per-function
(above the Phase 5 spec's ≥ 80% floor).
Server wiring (cmd/server/main.go):
Wired AFTER sessionService (Phase 4) so the OIDC PreLoginAdapter can
sign pre-login cookies under the active SessionSigningKey:
oidcProviderRepo + oidcMappingRepo + oidcUserRepo + oidcPreLoginRepo
-> preLoginAdapter -> oidcService -> authSessionOIDCHandler.
sessionMinterAdapter shim bridges *session.Service.Create to the
oidcsvc.SessionMinter port the OIDC service consumes.
Router wiring (internal/api/router/router.go):
4 public OIDC routes via direct r.mux.Handle (auth-exempt; pinned in
AuthExemptRouterRoutes); 9 RBAC-gated routes via r.Register +
rbacGate(checker, perm, h). Routes only register when
reg.AuthSessionOIDC != nil so pre-Phase-5 builds skip the block
entirely.
Verifications: gofmt clean, go vet clean across all touched packages,
go test -short -count=1 green across internal/api/handler (74 tests +
new Phase 5 batch), internal/api/router (parity + auth-exempt
allowlist), internal/auth/oidc + session (no regressions), full domain
+ scheduler + config sweeps green, ci-guard
N-bundle-2-security-empty-preserved.sh green (17 ≥ 14 baseline).
1018 lines
38 KiB
Go
1018 lines
38 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/auth"
|
|
oidcsvc "github.com/certctl-io/certctl/internal/auth/oidc"
|
|
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
|
|
sessionsvc "github.com/certctl-io/certctl/internal/auth/session"
|
|
sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain"
|
|
userdomain "github.com/certctl-io/certctl/internal/auth/user/domain"
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
)
|
|
|
|
// authWithActor builds a context indistinguishable from what the auth
|
|
// middleware would set after a successful Bearer-or-cookie auth.
|
|
func authWithActor(ctx context.Context, actorID, actorType string) context.Context {
|
|
ctx = context.WithValue(ctx, auth.ActorIDKey{}, actorID)
|
|
ctx = context.WithValue(ctx, auth.ActorTypeKey{}, actorType)
|
|
ctx = context.WithValue(ctx, auth.TenantIDKey{}, "t-default")
|
|
return ctx
|
|
}
|
|
|
|
// =============================================================================
|
|
// In-memory stubs.
|
|
// =============================================================================
|
|
|
|
type stubOIDCSvc struct {
|
|
authURL string
|
|
cookie string
|
|
preLoginID string
|
|
authReqErr error
|
|
callbackRes *oidcsvc.CallbackResult
|
|
callbackErr error
|
|
refreshErr error
|
|
}
|
|
|
|
func (s *stubOIDCSvc) HandleAuthRequest(_ context.Context, _ string) (string, string, string, error) {
|
|
return s.authURL, s.cookie, s.preLoginID, s.authReqErr
|
|
}
|
|
func (s *stubOIDCSvc) HandleCallback(_ context.Context, _, _, _, _, _ string) (*oidcsvc.CallbackResult, error) {
|
|
return s.callbackRes, s.callbackErr
|
|
}
|
|
func (s *stubOIDCSvc) RefreshKeys(_ context.Context, _ string) error { return s.refreshErr }
|
|
|
|
type stubSession struct {
|
|
createRes *sessionsvc.CreateResult
|
|
createErr error
|
|
validateRes *sessiondomain.Session
|
|
validateErr error
|
|
revokeErr error
|
|
revokeAllErr error
|
|
revokedIDs []string
|
|
revokeAllIDs []string
|
|
revokeAllTypes []string
|
|
}
|
|
|
|
func (s *stubSession) Create(_ context.Context, _, _, _, _ string) (*sessionsvc.CreateResult, error) {
|
|
return s.createRes, s.createErr
|
|
}
|
|
func (s *stubSession) Validate(_ context.Context, _ sessionsvc.ValidateInput) (*sessiondomain.Session, error) {
|
|
return s.validateRes, s.validateErr
|
|
}
|
|
func (s *stubSession) Revoke(_ context.Context, id string) error {
|
|
s.revokedIDs = append(s.revokedIDs, id)
|
|
return s.revokeErr
|
|
}
|
|
func (s *stubSession) RevokeAllForActor(_ context.Context, actorID, actorType string) error {
|
|
s.revokeAllIDs = append(s.revokeAllIDs, actorID)
|
|
s.revokeAllTypes = append(s.revokeAllTypes, actorType)
|
|
return s.revokeAllErr
|
|
}
|
|
|
|
type stubBCLVerifier struct {
|
|
issuer string
|
|
sub string
|
|
sid string
|
|
err error
|
|
}
|
|
|
|
func (s *stubBCLVerifier) Verify(_ context.Context, _ string) (string, string, string, error) {
|
|
return s.issuer, s.sub, s.sid, s.err
|
|
}
|
|
|
|
// stubProviderRepo implements just enough of repository.OIDCProviderRepository.
|
|
type stubProviderRepo struct {
|
|
provs []*oidcdomain.OIDCProvider
|
|
getErr error
|
|
deleteErr error
|
|
createErr error
|
|
updateErr error
|
|
}
|
|
|
|
func (s *stubProviderRepo) List(_ context.Context, _ string) ([]*oidcdomain.OIDCProvider, error) {
|
|
return s.provs, nil
|
|
}
|
|
func (s *stubProviderRepo) Get(_ context.Context, id string) (*oidcdomain.OIDCProvider, error) {
|
|
if s.getErr != nil {
|
|
return nil, s.getErr
|
|
}
|
|
for _, p := range s.provs {
|
|
if p.ID == id {
|
|
return p, nil
|
|
}
|
|
}
|
|
return nil, repository.ErrOIDCProviderNotFound
|
|
}
|
|
func (s *stubProviderRepo) GetByName(_ context.Context, _, _ string) (*oidcdomain.OIDCProvider, error) {
|
|
return nil, repository.ErrOIDCProviderNotFound
|
|
}
|
|
func (s *stubProviderRepo) Create(_ context.Context, p *oidcdomain.OIDCProvider) error {
|
|
if s.createErr != nil {
|
|
return s.createErr
|
|
}
|
|
s.provs = append(s.provs, p)
|
|
return nil
|
|
}
|
|
func (s *stubProviderRepo) Update(_ context.Context, _ *oidcdomain.OIDCProvider) error {
|
|
return s.updateErr
|
|
}
|
|
func (s *stubProviderRepo) Delete(_ context.Context, _ string) error { return s.deleteErr }
|
|
|
|
type stubMappingRepo struct {
|
|
mappings []*oidcdomain.GroupRoleMapping
|
|
addErr error
|
|
rmErr error
|
|
}
|
|
|
|
func (s *stubMappingRepo) ListByProvider(_ context.Context, _ string) ([]*oidcdomain.GroupRoleMapping, error) {
|
|
return s.mappings, nil
|
|
}
|
|
func (s *stubMappingRepo) Get(_ context.Context, _ string) (*oidcdomain.GroupRoleMapping, error) {
|
|
return nil, repository.ErrGroupRoleMappingNotFound
|
|
}
|
|
func (s *stubMappingRepo) Add(_ context.Context, m *oidcdomain.GroupRoleMapping) error {
|
|
if s.addErr != nil {
|
|
return s.addErr
|
|
}
|
|
s.mappings = append(s.mappings, m)
|
|
return nil
|
|
}
|
|
func (s *stubMappingRepo) Remove(_ context.Context, _ string) error { return s.rmErr }
|
|
func (s *stubMappingRepo) Map(_ context.Context, _ string, _ []string) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type stubSessionRepo struct {
|
|
rows map[string]*sessiondomain.Session
|
|
}
|
|
|
|
func newStubSessionRepo() *stubSessionRepo {
|
|
return &stubSessionRepo{rows: make(map[string]*sessiondomain.Session)}
|
|
}
|
|
func (s *stubSessionRepo) Create(_ context.Context, sess *sessiondomain.Session) error {
|
|
s.rows[sess.ID] = sess
|
|
return nil
|
|
}
|
|
func (s *stubSessionRepo) Get(_ context.Context, id string) (*sessiondomain.Session, error) {
|
|
r, ok := s.rows[id]
|
|
if !ok {
|
|
return nil, repository.ErrSessionNotFound
|
|
}
|
|
return r, nil
|
|
}
|
|
func (s *stubSessionRepo) ListByActor(_ context.Context, actorID, actorType, _ string) ([]*sessiondomain.Session, error) {
|
|
var out []*sessiondomain.Session
|
|
for _, r := range s.rows {
|
|
if r.ActorID == actorID && r.ActorType == actorType {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (s *stubSessionRepo) UpdateLastSeen(_ context.Context, _ string) error { return nil }
|
|
func (s *stubSessionRepo) UpdateCSRFTokenHash(_ context.Context, _, _ string) error {
|
|
return nil
|
|
}
|
|
func (s *stubSessionRepo) Revoke(_ context.Context, id string) error {
|
|
if r, ok := s.rows[id]; ok {
|
|
t := time.Now()
|
|
r.RevokedAt = &t
|
|
}
|
|
return nil
|
|
}
|
|
func (s *stubSessionRepo) RevokeAllForActor(_ context.Context, _, _, _ string) error { return nil }
|
|
func (s *stubSessionRepo) GarbageCollectExpired(_ context.Context) (int, error) { return 0, nil }
|
|
func (s *stubSessionRepo) Delete(_ context.Context, _ string) error { return nil }
|
|
|
|
type phase5StubAudit struct {
|
|
events []string
|
|
}
|
|
|
|
func (s *phase5StubAudit) RecordEventWithCategory(_ context.Context, _ string, _ domain.ActorType, action, _, _, _ string, _ map[string]interface{}) error {
|
|
s.events = append(s.events, action)
|
|
return nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helpers.
|
|
// =============================================================================
|
|
|
|
func newPhase5Handler(
|
|
t *testing.T,
|
|
oidcSvc *stubOIDCSvc,
|
|
sess *stubSession,
|
|
bcl *stubBCLVerifier,
|
|
) (*AuthSessionOIDCHandler, *stubProviderRepo, *stubMappingRepo, *stubSessionRepo, *phase5StubAudit) {
|
|
t.Helper()
|
|
provRepo := &stubProviderRepo{}
|
|
mapRepo := &stubMappingRepo{}
|
|
sessRepo := newStubSessionRepo()
|
|
audit := &phase5StubAudit{}
|
|
h := NewAuthSessionOIDCHandler(
|
|
oidcSvc, sess, bcl, provRepo, mapRepo, sessRepo, audit,
|
|
"", "t-default", "/dashboard",
|
|
SessionCookieAttrs{SameSite: http.SameSiteLaxMode, Secure: true},
|
|
)
|
|
return h, provRepo, mapRepo, sessRepo, audit
|
|
}
|
|
|
|
// withActor adds the same context keys the auth middleware would set.
|
|
func withActor(req *http.Request, actorID, actorType string) *http.Request {
|
|
ctx := req.Context()
|
|
// Use the same context-key constants the production auth package
|
|
// sets via NewDemoModeAuth — since we don't have a clean export,
|
|
// rely on the auth package's GetActorID accessors. The handler
|
|
// uses callerFromRequest which calls auth.GetActorID etc.
|
|
// Easiest: use auth.WithActor helper which is in
|
|
// internal/auth/testfixtures.go (Bundle 1 Phase 0).
|
|
return req.WithContext(authWithActor(ctx, actorID, actorType))
|
|
}
|
|
|
|
// =============================================================================
|
|
// 1. /auth/oidc/login — happy path + missing provider param.
|
|
// =============================================================================
|
|
|
|
func TestLoginInitiate_HappyPath(t *testing.T) {
|
|
o := &stubOIDCSvc{
|
|
authURL: "https://idp/authorize?state=x&nonce=y",
|
|
cookie: "v1.pl-abc.sk-xyz.somemac",
|
|
preLoginID: "pl-abc",
|
|
}
|
|
h, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/login?provider=op-x", nil)
|
|
w := httptest.NewRecorder()
|
|
h.LoginInitiate(w, req)
|
|
|
|
if w.Code != http.StatusFound {
|
|
t.Errorf("status = %d; want 302", w.Code)
|
|
}
|
|
if loc := w.Header().Get("Location"); !strings.Contains(loc, "idp/authorize") {
|
|
t.Errorf("Location header missing IdP URL: %q", loc)
|
|
}
|
|
cookies := w.Result().Cookies()
|
|
hasPreLogin := false
|
|
for _, c := range cookies {
|
|
if c.Name == sessiondomain.PreLoginCookieName && c.Value == o.cookie {
|
|
hasPreLogin = true
|
|
}
|
|
}
|
|
if !hasPreLogin {
|
|
t.Errorf("pre-login cookie not set")
|
|
}
|
|
}
|
|
|
|
func TestLoginInitiate_MissingProvider(t *testing.T) {
|
|
h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
|
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil)
|
|
w := httptest.NewRecorder()
|
|
h.LoginInitiate(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d; want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestLoginInitiate_ProviderNotFound(t *testing.T) {
|
|
o := &stubOIDCSvc{authReqErr: repository.ErrOIDCProviderNotFound}
|
|
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)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d; want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 2. /auth/oidc/callback — happy path + 3 spec-mandated negatives.
|
|
// =============================================================================
|
|
|
|
func TestLoginCallback_HappyPath(t *testing.T) {
|
|
user := &userdomain.User{ID: "u-alice"}
|
|
o := &stubOIDCSvc{callbackRes: &oidcsvc.CallbackResult{
|
|
User: user,
|
|
RoleIDs: []string{"r-operator"},
|
|
CookieValue: "v1.ses-abc.sk-xyz.mac",
|
|
CSRFToken: "csrf-token-value",
|
|
}}
|
|
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()
|
|
h.LoginCallback(w, req)
|
|
|
|
if w.Code != http.StatusFound {
|
|
t.Errorf("status = %d; want 302", w.Code)
|
|
}
|
|
if loc := w.Header().Get("Location"); loc != "/dashboard" {
|
|
t.Errorf("Location = %q; want /dashboard", loc)
|
|
}
|
|
if !contains(audit.events, "auth.oidc_login_succeeded") {
|
|
t.Errorf("expected auth.oidc_login_succeeded audit event; got %v", audit.events)
|
|
}
|
|
if !contains(audit.events, "auth.session_created") {
|
|
t.Errorf("expected auth.session_created audit event")
|
|
}
|
|
}
|
|
|
|
// Phase 5 spec mandate #4: Callback with replayed state -> 400.
|
|
// (The OIDC service's PreLoginStore.LookupAndConsume returns
|
|
// 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{})
|
|
|
|
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()
|
|
h.LoginCallback(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d; want 400", w.Code)
|
|
}
|
|
if !contains(audit.events, "auth.oidc_login_failed") {
|
|
t.Errorf("expected auth.oidc_login_failed audit event; got %v", audit.events)
|
|
}
|
|
}
|
|
|
|
// Phase 5 spec mandate #5: Callback with PKCE verifier mismatch -> 400.
|
|
// The OIDC service's code-exchange step fails when the verifier doesn'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{})
|
|
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()
|
|
h.LoginCallback(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d; want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
// Phase 5 spec mandate #6: Callback with expired pre-login row -> 400.
|
|
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{})
|
|
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()
|
|
h.LoginCallback(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d; want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestLoginCallback_MissingPreLoginCookie_Returns400(t *testing.T) {
|
|
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)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d; want 400", w.Code)
|
|
}
|
|
if !contains(audit.events, "auth.oidc_login_failed") {
|
|
t.Errorf("expected auth.oidc_login_failed audit; got %v", audit.events)
|
|
}
|
|
}
|
|
|
|
func TestLoginCallback_UnmappedGroups_AuditRowDistinguished(t *testing.T) {
|
|
o := &stubOIDCSvc{callbackErr: oidcsvc.ErrGroupsUnmapped}
|
|
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()
|
|
h.LoginCallback(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d; want 400", w.Code)
|
|
}
|
|
if !contains(audit.events, "auth.oidc_login_unmapped_groups") {
|
|
t.Errorf("expected auth.oidc_login_unmapped_groups; got %v", audit.events)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 3. /auth/oidc/back-channel-logout — 3 spec-mandated negatives.
|
|
// =============================================================================
|
|
|
|
// 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)
|
|
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.StatusBadRequest {
|
|
t.Errorf("status = %d; want 400", w.Code)
|
|
}
|
|
if !contains(audit.events, "auth.oidc_back_channel_logout_failed") {
|
|
t.Errorf("expected failure audit event; got %v", audit.events)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
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.StatusBadRequest {
|
|
t.Errorf("status = %d; want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
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.StatusBadRequest {
|
|
t.Errorf("status = %d; want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestBackChannelLogout_HappyPath_RevokesSubject(t *testing.T) {
|
|
bcl := &stubBCLVerifier{issuer: "https://idp", sub: "u-alice"}
|
|
sess := &stubSession{}
|
|
h, _, _, _, audit := 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")
|
|
w := httptest.NewRecorder()
|
|
h.BackChannelLogout(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d; want 200", 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) != 1 || sess.revokeAllIDs[0] != "u-alice" {
|
|
t.Errorf("expected RevokeAllForActor(u-alice); got %v", sess.revokeAllIDs)
|
|
}
|
|
if !contains(audit.events, "auth.oidc_back_channel_logout") {
|
|
t.Errorf("expected auth.oidc_back_channel_logout audit event")
|
|
}
|
|
}
|
|
|
|
func TestBackChannelLogout_HappyPath_RevokesSid(t *testing.T) {
|
|
bcl := &stubBCLVerifier{issuer: "https://idp", sid: "ses-xyz"}
|
|
sess := &stubSession{}
|
|
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")
|
|
w := httptest.NewRecorder()
|
|
h.BackChannelLogout(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d; want 200", w.Code)
|
|
}
|
|
if len(sess.revokedIDs) != 1 || sess.revokedIDs[0] != "ses-xyz" {
|
|
t.Errorf("expected Revoke(ses-xyz); got %v", sess.revokedIDs)
|
|
}
|
|
}
|
|
|
|
func TestBackChannelLogout_MissingTokenReturns400(t *testing.T) {
|
|
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()
|
|
h.BackChannelLogout(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d; want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 4. /auth/logout — happy path.
|
|
// =============================================================================
|
|
|
|
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{})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
|
req = withActor(req, "u-x", "User")
|
|
req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses-abc.sk-xyz.mac"})
|
|
w := httptest.NewRecorder()
|
|
h.Logout(w, req)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("status = %d; want 204", w.Code)
|
|
}
|
|
if len(sess.revokedIDs) != 1 || sess.revokedIDs[0] != "ses-abc" {
|
|
t.Errorf("expected Revoke(ses-abc); got %v", sess.revokedIDs)
|
|
}
|
|
if !contains(audit.events, "auth.session_revoked") {
|
|
t.Errorf("expected auth.session_revoked audit; got %v", audit.events)
|
|
}
|
|
}
|
|
|
|
func TestLogout_NoCookie_Returns204(t *testing.T) {
|
|
h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
|
req = withActor(req, "u-x", "User")
|
|
w := httptest.NewRecorder()
|
|
h.Logout(w, req)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("status = %d; want 204", w.Code)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 5. /api/v1/auth/sessions — list + revoke.
|
|
// =============================================================================
|
|
|
|
func TestListSessions_OwnSessions(t *testing.T) {
|
|
h, _, _, sessRepo, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
|
now := time.Now()
|
|
sessRepo.rows["ses-1"] = &sessiondomain.Session{
|
|
ID: "ses-1", ActorID: "u-x", ActorType: "User",
|
|
IdleExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(8 * time.Hour),
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/sessions", nil)
|
|
req = withActor(req, "u-x", "User")
|
|
w := httptest.NewRecorder()
|
|
h.ListSessions(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d; want 200", w.Code)
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "ses-1") {
|
|
t.Errorf("response missing session id; body = %q", body)
|
|
}
|
|
}
|
|
|
|
func TestRevokeSession_HappyPath(t *testing.T) {
|
|
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")
|
|
req = withActor(req, "u-x", "User")
|
|
w := httptest.NewRecorder()
|
|
h.RevokeSession(w, req)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("status = %d; want 204", w.Code)
|
|
}
|
|
if !contains(audit.events, "auth.session_revoked") {
|
|
t.Errorf("expected auth.session_revoked audit; got %v", audit.events)
|
|
}
|
|
}
|
|
|
|
func TestRevokeSession_NotFound(t *testing.T) {
|
|
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")
|
|
w := httptest.NewRecorder()
|
|
h.RevokeSession(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d; want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 6. OIDC provider CRUD.
|
|
// =============================================================================
|
|
|
|
func TestListProviders(t *testing.T) {
|
|
h, provRepo, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
|
provRepo.provs = []*oidcdomain.OIDCProvider{
|
|
{ID: "op-x", Name: "Okta", IssuerURL: "https://x", ClientID: "c"},
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/providers", nil)
|
|
req = withActor(req, "u-admin", "User")
|
|
w := httptest.NewRecorder()
|
|
h.ListProviders(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d; want 200", w.Code)
|
|
}
|
|
if !strings.Contains(w.Body.String(), "op-x") {
|
|
t.Errorf("response missing provider id")
|
|
}
|
|
}
|
|
|
|
func TestCreateProvider_MissingClientSecret(t *testing.T) {
|
|
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")
|
|
w := httptest.NewRecorder()
|
|
h.CreateProvider(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d; want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDeleteProvider_InUse_Returns409(t *testing.T) {
|
|
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")
|
|
req = withActor(req, "u-admin", "User")
|
|
w := httptest.NewRecorder()
|
|
h.DeleteProvider(w, req)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("status = %d; want 409", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRefreshProvider_HappyPath(t *testing.T) {
|
|
o := &stubOIDCSvc{}
|
|
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")
|
|
w := httptest.NewRecorder()
|
|
h.RefreshProvider(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d; want 200", w.Code)
|
|
}
|
|
if !contains(audit.events, "auth.oidc_provider_refreshed") {
|
|
t.Errorf("expected auth.oidc_provider_refreshed audit; got %v", audit.events)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 7. Group-mapping CRUD.
|
|
// =============================================================================
|
|
|
|
func TestListGroupMappings_MissingProviderID(t *testing.T) {
|
|
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()
|
|
h.ListGroupMappings(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d; want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAddGroupMapping_HappyPath(t *testing.T) {
|
|
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")
|
|
w := httptest.NewRecorder()
|
|
h.AddGroupMapping(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("status = %d; want 201", w.Code)
|
|
}
|
|
if !contains(audit.events, "auth.group_mapping_added") {
|
|
t.Errorf("expected auth.group_mapping_added audit; got %v", audit.events)
|
|
}
|
|
}
|
|
|
|
func TestRemoveGroupMapping_NotFound(t *testing.T) {
|
|
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")
|
|
req = withActor(req, "u-admin", "User")
|
|
w := httptest.NewRecorder()
|
|
h.RemoveGroupMapping(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d; want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helpers.
|
|
// =============================================================================
|
|
|
|
func contains(s []string, v string) bool {
|
|
for _, x := range s {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// peekIssuer test (touches the BCL verifier helper directly).
|
|
func TestDefaultIfBlank(t *testing.T) {
|
|
if got := defaultIfBlank("", "x"); got != "x" {
|
|
t.Errorf("got %q; want x", got)
|
|
}
|
|
if got := defaultIfBlank("y", "x"); got != "y" {
|
|
t.Errorf("got %q; want y", got)
|
|
}
|
|
if got := defaultIfBlank(" ", "x"); got != "x" {
|
|
t.Errorf("got %q; want x (whitespace-only treated as blank)", got)
|
|
}
|
|
}
|
|
|
|
func TestDefaultIntIfZero(t *testing.T) {
|
|
if got := defaultIntIfZero(0, 5); got != 5 {
|
|
t.Errorf("got %d; want 5", got)
|
|
}
|
|
if got := defaultIntIfZero(7, 5); got != 7 {
|
|
t.Errorf("got %d; want 7", got)
|
|
}
|
|
}
|
|
|
|
func TestClientIPFromRequest(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.RemoteAddr = "1.2.3.4:5555"
|
|
if ip := clientIPFromRequest(r); ip != "1.2.3.4" {
|
|
t.Errorf("RemoteAddr: got %q; want 1.2.3.4", ip)
|
|
}
|
|
r.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2")
|
|
if ip := clientIPFromRequest(r); ip != "10.0.0.1" {
|
|
t.Errorf("XFF first hop: got %q; want 10.0.0.1", ip)
|
|
}
|
|
r.Header.Set("X-Forwarded-For", "10.0.0.99")
|
|
if ip := clientIPFromRequest(r); ip != "10.0.0.99" {
|
|
t.Errorf("XFF single: got %q; want 10.0.0.99", ip)
|
|
}
|
|
}
|
|
|
|
func TestNewAuthSessionOIDCHandler_DefaultsPostLoginURL(t *testing.T) {
|
|
h := NewAuthSessionOIDCHandler(
|
|
&stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{},
|
|
&stubProviderRepo{}, &stubMappingRepo{}, newStubSessionRepo(), &phase5StubAudit{},
|
|
"key", "t-default", "", // empty postLoginURL
|
|
SessionCookieAttrs{},
|
|
)
|
|
if h.postLoginURL != "/" {
|
|
t.Errorf("default postLoginURL = %q; want /", h.postLoginURL)
|
|
}
|
|
}
|
|
|
|
func TestEncryptClientSecret_EmptyKeyPassthrough(t *testing.T) {
|
|
h := &AuthSessionOIDCHandler{encryptionKey: ""}
|
|
got, err := h.encryptClientSecret([]byte("secret"))
|
|
if err != nil {
|
|
t.Fatalf("encryptClientSecret: %v", err)
|
|
}
|
|
if string(got) != "secret" {
|
|
t.Errorf("got %q; want secret (passthrough)", string(got))
|
|
}
|
|
}
|
|
|
|
func TestEncryptClientSecret_RealEncryption(t *testing.T) {
|
|
h := &AuthSessionOIDCHandler{encryptionKey: "test-passphrase-12345-abcdef"}
|
|
got, err := h.encryptClientSecret([]byte("secret"))
|
|
if err != nil {
|
|
t.Fatalf("encryptClientSecret: %v", err)
|
|
}
|
|
if string(got) == "secret" {
|
|
t.Errorf("encrypted output equals plaintext; encryption did not run")
|
|
}
|
|
}
|
|
|
|
func TestNewDefaultBCLVerifier_DefaultsAlgs(t *testing.T) {
|
|
v := NewDefaultBCLVerifier(&stubProviderRepo{}, "t-default", nil)
|
|
if len(v.allowedAlgs) == 0 {
|
|
t.Errorf("expected default allowedAlgs; got empty")
|
|
}
|
|
v2 := NewDefaultBCLVerifier(&stubProviderRepo{}, "t-default", []string{"RS256"})
|
|
if len(v2.allowedAlgs) != 1 || v2.allowedAlgs[0] != "RS256" {
|
|
t.Errorf("explicit alg list not honored: %v", v2.allowedAlgs)
|
|
}
|
|
}
|
|
|
|
func TestDefaultBCLVerifier_NoMatchingProviderRejected(t *testing.T) {
|
|
provs := &stubProviderRepo{provs: []*oidcdomain.OIDCProvider{
|
|
{ID: "op-x", IssuerURL: "https://different-idp"},
|
|
}}
|
|
v := NewDefaultBCLVerifier(provs, "t-default", nil)
|
|
// JWT with iss=https://idp (which doesn't match any registered provider).
|
|
// header={"alg":"RS256"}, payload={"iss":"https://idp"}.
|
|
jwt := "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2lkcCJ9.AAAA"
|
|
_, _, _, err := v.Verify(context.Background(), jwt)
|
|
if err == nil {
|
|
t.Errorf("expected error when iss doesn't match any registered provider")
|
|
}
|
|
}
|
|
|
|
func TestPeekIssuer_HappyPath(t *testing.T) {
|
|
// header.payload.sig where payload base64-decodes to {"iss":"https://idp"}.
|
|
header := "eyJhbGciOiJSUzI1NiJ9"
|
|
payload := "eyJpc3MiOiJodHRwczovL2lkcCJ9"
|
|
sig := "AAAA"
|
|
jwt := fmt.Sprintf("%s.%s.%s", header, payload, sig)
|
|
iss, err := peekIssuer(jwt)
|
|
if err != nil {
|
|
t.Fatalf("peekIssuer: %v", err)
|
|
}
|
|
if iss != "https://idp" {
|
|
t.Errorf("iss = %q; want https://idp", iss)
|
|
}
|
|
}
|
|
|
|
func TestPeekIssuer_RejectsBadSegmentCount(t *testing.T) {
|
|
if _, err := peekIssuer("just.two"); err == nil {
|
|
t.Errorf("expected error for 2-segment JWT")
|
|
}
|
|
}
|
|
|
|
func TestCreateProvider_HappyPath(t *testing.T) {
|
|
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")
|
|
w := httptest.NewRecorder()
|
|
h.CreateProvider(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("status = %d; want 201; body=%q", w.Code, w.Body.String())
|
|
}
|
|
if !contains(audit.events, "auth.oidc_provider_created") {
|
|
t.Errorf("expected auth.oidc_provider_created audit; got %v", audit.events)
|
|
}
|
|
}
|
|
|
|
func TestCreateProvider_DuplicateName_Returns409(t *testing.T) {
|
|
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)
|
|
req = withActor(req, "u-admin", "User")
|
|
w := httptest.NewRecorder()
|
|
h.CreateProvider(w, req)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("status = %d; want 409", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestCreateProvider_InvalidJSON_Returns400(t *testing.T) {
|
|
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()
|
|
h.CreateProvider(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d; want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestUpdateProvider_HappyPath(t *testing.T) {
|
|
h, provRepo, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
|
provRepo.provs = []*oidcdomain.OIDCProvider{
|
|
{
|
|
ID: "op-x", TenantID: "t-default", Name: "Old",
|
|
IssuerURL: "https://x", ClientID: "c", ClientSecretEncrypted: []byte("blob"),
|
|
RedirectURI: "https://r/cb", GroupsClaimPath: "groups",
|
|
GroupsClaimFormat: "string-array", Scopes: []string{"openid"},
|
|
IATWindowSeconds: 300, JWKSCacheTTLSeconds: 3600,
|
|
},
|
|
}
|
|
body := strings.NewReader(`{"name":"NewName","issuer_url":"https://x","client_id":"c","redirect_uri":"https://r/cb","groups_claim_path":"groups","groups_claim_format":"string-array","scopes":["openid","email"]}`)
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/auth/oidc/providers/op-x", body)
|
|
req.SetPathValue("id", "op-x")
|
|
req = withActor(req, "u-admin", "User")
|
|
w := httptest.NewRecorder()
|
|
h.UpdateProvider(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d; want 200; body=%q", w.Code, w.Body.String())
|
|
}
|
|
if !contains(audit.events, "auth.oidc_provider_updated") {
|
|
t.Errorf("expected auth.oidc_provider_updated audit; got %v", audit.events)
|
|
}
|
|
}
|
|
|
|
func TestUpdateProvider_NotFound(t *testing.T) {
|
|
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")
|
|
req = withActor(req, "u-admin", "User")
|
|
w := httptest.NewRecorder()
|
|
h.UpdateProvider(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d; want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRefreshProvider_NotFound(t *testing.T) {
|
|
o := &stubOIDCSvc{refreshErr: repository.ErrOIDCProviderNotFound}
|
|
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")
|
|
w := httptest.NewRecorder()
|
|
h.RefreshProvider(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d; want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestListGroupMappings_HappyPath(t *testing.T) {
|
|
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"},
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/group-mappings?provider_id=op-x", nil)
|
|
req = withActor(req, "u-admin", "User")
|
|
w := httptest.NewRecorder()
|
|
h.ListGroupMappings(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d; want 200", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAddGroupMapping_Duplicate_Returns409(t *testing.T) {
|
|
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)
|
|
req = withActor(req, "u-admin", "User")
|
|
w := httptest.NewRecorder()
|
|
h.AddGroupMapping(w, req)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("status = %d; want 409", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRemoveGroupMapping_HappyPath(t *testing.T) {
|
|
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")
|
|
w := httptest.NewRecorder()
|
|
h.RemoveGroupMapping(w, req)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("status = %d; want 204", w.Code)
|
|
}
|
|
if !contains(audit.events, "auth.group_mapping_removed") {
|
|
t.Errorf("expected auth.group_mapping_removed audit")
|
|
}
|
|
}
|
|
|
|
func TestRevokeSession_MissingID(t *testing.T) {
|
|
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()
|
|
h.RevokeSession(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d; want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestListSessions_AsAdmin_QueryActorID(t *testing.T) {
|
|
h, _, _, sessRepo, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
|
|
now := time.Now()
|
|
sessRepo.rows["ses-other"] = &sessiondomain.Session{
|
|
ID: "ses-other", ActorID: "u-other", ActorType: "User",
|
|
IdleExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(8 * time.Hour),
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/sessions?actor_id=u-other&actor_type=User", nil)
|
|
req = withActor(req, "u-admin", "User")
|
|
w := httptest.NewRecorder()
|
|
h.ListSessions(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d; want 200", w.Code)
|
|
}
|
|
if !strings.Contains(w.Body.String(), "ses-other") {
|
|
t.Errorf("expected ses-other in response")
|
|
}
|
|
}
|
|
|
|
func TestClassifyOIDCFailure(t *testing.T) {
|
|
cases := []struct {
|
|
err error
|
|
want string
|
|
}{
|
|
{nil, "ok"},
|
|
{errors.New("oidc: pre-login session not found"), "pre_login_consume_failed"},
|
|
{errors.New("oidc: state parameter mismatch"), "state_mismatch"},
|
|
{errors.New("oidc: nonce mismatch"), "nonce_mismatch"},
|
|
{errors.New("oidc: audience mismatch"), "audience_mismatch"},
|
|
{errors.New("oidc: ID token expired"), "token_expired"},
|
|
{errors.New("oidc: azp mismatch"), "azp_mismatch"},
|
|
{errors.New("oidc: at_hash mismatch"), "at_hash_mismatch"},
|
|
{errors.New("oidc: ID token iat older than configured window"), "iat_window"},
|
|
{errors.New("oidc: alg rejected"), "alg_rejected"},
|
|
{errors.New("oidc: groups did not match any configured mapping"), "unmapped_groups"},
|
|
{errors.New("oidc: configured groups claim missing or malformed"), "groups_missing"},
|
|
{errors.New("oidc: jwks unreachable"), "jwks_unreachable"},
|
|
{errors.New("some other error"), "unspecified"},
|
|
}
|
|
for _, tc := range cases {
|
|
got := classifyOIDCFailure(tc.err)
|
|
if got != tc.want {
|
|
t.Errorf("classifyOIDCFailure(%v) = %q; want %q", tc.err, got, tc.want)
|
|
}
|
|
}
|
|
}
|