mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 18:58:59 +00:00
auth-bundle-2 Phase 5: OIDC + session HTTP surface (13 endpoints),
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).
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -100,6 +100,36 @@ var SpecParityExceptions = map[string]string{
|
||||
// `[Auth]`. Shared shapes: AuthRole + AuthRolePermission in the
|
||||
// schemas section. AuthCheck (Bundle 1 M1) now returns the same
|
||||
// effective_permissions + roles fields as auth/me on the boot path.
|
||||
|
||||
// Auth Bundle 2 Phase 5 — OIDC + session HTTP surface (13 routes).
|
||||
// The `cookieAuth` security scheme is documented in api/openapi.yaml
|
||||
// under components.securitySchemes (load-bearing — the post-Phase-6
|
||||
// session middleware consumes it). Full per-endpoint OpenAPI rows
|
||||
// for the 13 Phase 5 routes are deferred to a follow-on commit
|
||||
// alongside the GUI work (Phase 8) so the ergonomic shape can be
|
||||
// validated against the live GUI client. Operator-facing reference
|
||||
// is the handler doc-block at the top of
|
||||
// internal/api/handler/auth_session_oidc.go and the Phase 5 spec at
|
||||
// cowork/auth-bundle-2-prompt.md.
|
||||
//
|
||||
// Public OIDC handshake (auth-exempt; protocol-mediated):
|
||||
"GET /auth/oidc/login": "Auth Bundle 2 Phase 5 — OIDC start; auth-exempt by definition.",
|
||||
"GET /auth/oidc/callback": "Auth Bundle 2 Phase 5 — OIDC callback; pre-login cookie + state validated inside.",
|
||||
"POST /auth/oidc/back-channel-logout": "Auth Bundle 2 Phase 5 — OpenID Connect Back-Channel Logout 1.0; auth via IdP-signed logout_token JWT in body. security: [] when documented.",
|
||||
"POST /auth/logout": "Auth Bundle 2 Phase 5 — caller's session cookie is checked inside; no Bearer requirement.",
|
||||
// Session management (RBAC-gated auth.session.*):
|
||||
"GET /api/v1/auth/sessions": "Auth Bundle 2 Phase 5 — list sessions; gated auth.session.list; cookieAuth+bearerAuth.",
|
||||
"DELETE /api/v1/auth/sessions/{id}": "Auth Bundle 2 Phase 5 — revoke session; gated auth.session.revoke (own-session bypass at handler).",
|
||||
// OIDC provider CRUD + refresh (RBAC-gated auth.oidc.*):
|
||||
"GET /api/v1/auth/oidc/providers": "Auth Bundle 2 Phase 5 — list providers; gated auth.oidc.list.",
|
||||
"POST /api/v1/auth/oidc/providers": "Auth Bundle 2 Phase 5 — register provider; gated auth.oidc.create; client_secret encrypted at rest.",
|
||||
"PUT /api/v1/auth/oidc/providers/{id}": "Auth Bundle 2 Phase 5 — update provider; gated auth.oidc.edit.",
|
||||
"DELETE /api/v1/auth/oidc/providers/{id}": "Auth Bundle 2 Phase 5 — delete provider; gated auth.oidc.delete; refused when users authenticated.",
|
||||
"POST /api/v1/auth/oidc/providers/{id}/refresh": "Auth Bundle 2 Phase 5 — force discovery + JWKS refresh; gated auth.oidc.edit; re-runs IdP downgrade defense.",
|
||||
// Group-mapping CRUD:
|
||||
"GET /api/v1/auth/oidc/group-mappings": "Auth Bundle 2 Phase 5 — list group→role mappings; gated auth.oidc.list.",
|
||||
"POST /api/v1/auth/oidc/group-mappings": "Auth Bundle 2 Phase 5 — add group→role mapping; gated auth.oidc.edit.",
|
||||
"DELETE /api/v1/auth/oidc/group-mappings/{id}": "Auth Bundle 2 Phase 5 — remove group→role mapping; gated auth.oidc.edit.",
|
||||
}
|
||||
|
||||
func TestRouter_OpenAPIParity(t *testing.T) {
|
||||
|
||||
@@ -78,12 +78,16 @@ func (r *Router) RegisterFunc(pattern string, handler func(http.ResponseWriter,
|
||||
// The TestRouter_AuthExemptAllowlist regression test below pins the slice
|
||||
// to the actual mux.Handle calls — adding an undocumented bypass fails CI.
|
||||
var AuthExemptRouterRoutes = []string{
|
||||
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
|
||||
"GET /ready", // K8s/Docker readiness probe; cannot carry Bearer
|
||||
"GET /api/v1/auth/info", // GUI calls before login to detect auth mode
|
||||
"GET /api/v1/version", // Rollout probes need build identity without key
|
||||
"GET /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — GUI / install one-liner probes "is bootstrap available?" pre-admin; safe (no token, no admin probe leakage)
|
||||
"POST /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — operator POSTs CERTCTL_BOOTSTRAP_TOKEN to mint the first admin; the endpoint is gated by the bootstrap.Strategy and the admin-existence probe
|
||||
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
|
||||
"GET /ready", // K8s/Docker readiness probe; cannot carry Bearer
|
||||
"GET /api/v1/auth/info", // GUI calls before login to detect auth mode
|
||||
"GET /api/v1/version", // Rollout probes need build identity without key
|
||||
"GET /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — GUI / install one-liner probes "is bootstrap available?" pre-admin; safe (no token, no admin probe leakage)
|
||||
"POST /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — operator POSTs CERTCTL_BOOTSTRAP_TOKEN to mint the first admin; the endpoint is gated by the bootstrap.Strategy and the admin-existence probe
|
||||
"GET /auth/oidc/login", // Auth Bundle 2 Phase 5 — kicks off OIDC flow; pre-auth by definition
|
||||
"GET /auth/oidc/callback", // Auth Bundle 2 Phase 5 — IdP redirects here pre-auth; cookie + state validated inside
|
||||
"POST /auth/oidc/back-channel-logout", // Auth Bundle 2 Phase 5 — IdP-initiated; auth via the IdP-signed logout_token JWT in body
|
||||
"POST /auth/logout", // Auth Bundle 2 Phase 5 — caller's session-cookie is checked inside the handler; no Bearer requirement
|
||||
}
|
||||
|
||||
// AuthExemptDispatchPrefixes is the documented allowlist of URL prefixes
|
||||
@@ -206,6 +210,29 @@ type HandlerRegistry struct {
|
||||
// docs/approval-workflow.md for the operator playbook.
|
||||
Approvals handler.ApprovalHandler
|
||||
|
||||
// AuthSessionOIDC handles the Auth Bundle 2 Phase 5 OIDC + session
|
||||
// HTTP surface. 13 endpoints across three groups:
|
||||
// 1. Public OIDC handshake (auth-exempt):
|
||||
// GET /auth/oidc/login
|
||||
// GET /auth/oidc/callback
|
||||
// POST /auth/oidc/back-channel-logout
|
||||
// POST /auth/logout
|
||||
// 2. Session management (RBAC-gated auth.session.*):
|
||||
// GET /api/v1/auth/sessions
|
||||
// DELETE /api/v1/auth/sessions/{id}
|
||||
// 3. OIDC provider + group-mapping CRUD (RBAC-gated auth.oidc.*):
|
||||
// GET /api/v1/auth/oidc/providers
|
||||
// POST /api/v1/auth/oidc/providers
|
||||
// PUT /api/v1/auth/oidc/providers/{id}
|
||||
// DELETE /api/v1/auth/oidc/providers/{id}
|
||||
// POST /api/v1/auth/oidc/providers/{id}/refresh
|
||||
// GET /api/v1/auth/oidc/group-mappings
|
||||
// POST /api/v1/auth/oidc/group-mappings
|
||||
// DELETE /api/v1/auth/oidc/group-mappings/{id}
|
||||
// Optional — when nil the routes are not registered (pre-Bundle-2
|
||||
// deployments still build + run).
|
||||
AuthSessionOIDC *handler.AuthSessionOIDCHandler
|
||||
|
||||
// IntermediateCAs handles the admin-gated CA-hierarchy management
|
||||
// surface under /api/v1/issuers/{id}/intermediates and
|
||||
// /api/v1/intermediates/{id}. Rank 8 of the 2026-05-03 deep-
|
||||
@@ -287,6 +314,80 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
r.Register("POST /api/v1/auth/keys/{id}/roles", http.HandlerFunc(reg.Auth.AssignRoleToKey))
|
||||
r.Register("DELETE /api/v1/auth/keys/{id}/roles/{role_id}", http.HandlerFunc(reg.Auth.RevokeRoleFromKey))
|
||||
|
||||
// =========================================================================
|
||||
// Auth Bundle 2 Phase 5 — OIDC + session HTTP surface.
|
||||
//
|
||||
// Public OIDC handshake routes (auth-exempt — the endpoints
|
||||
// authenticate via the IdP-signed token / pre-login cookie):
|
||||
// GET /auth/oidc/login
|
||||
// GET /auth/oidc/callback
|
||||
// POST /auth/oidc/back-channel-logout
|
||||
// POST /auth/logout
|
||||
//
|
||||
// Session management (RBAC-gated auth.session.* — see migration 000037):
|
||||
// GET /api/v1/auth/sessions -> auth.session.list
|
||||
// DELETE /api/v1/auth/sessions/{id} -> auth.session.revoke
|
||||
//
|
||||
// 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
|
||||
// PUT /api/v1/auth/oidc/providers/{id} -> auth.oidc.edit
|
||||
// DELETE /api/v1/auth/oidc/providers/{id} -> auth.oidc.delete
|
||||
// POST /api/v1/auth/oidc/providers/{id}/refresh -> auth.oidc.edit
|
||||
// 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
|
||||
//
|
||||
// Routes are only registered when reg.AuthSessionOIDC is non-nil
|
||||
// (Phase 5 wiring — production main.go always passes it; pre-Phase-5
|
||||
// builds skip this block entirely).
|
||||
if reg.AuthSessionOIDC != nil {
|
||||
// Public OIDC handshake — auth-exempt. Pinned in
|
||||
// AuthExemptRouterRoutes above + bypasses the auth middleware
|
||||
// chain via direct r.mux.Handle calls. Each endpoint
|
||||
// authenticates via its own protocol primitive:
|
||||
// /auth/oidc/login -> no auth (start of handshake)
|
||||
// /auth/oidc/callback -> pre-login cookie + state validation
|
||||
// /auth/oidc/back-channel-logout -> IdP-signed logout_token JWT
|
||||
// /auth/logout -> caller's own session cookie
|
||||
r.mux.Handle("GET /auth/oidc/login", middleware.Chain(
|
||||
http.HandlerFunc(reg.AuthSessionOIDC.LoginInitiate),
|
||||
middleware.CORS, middleware.ContentType,
|
||||
))
|
||||
r.mux.Handle("GET /auth/oidc/callback", middleware.Chain(
|
||||
http.HandlerFunc(reg.AuthSessionOIDC.LoginCallback),
|
||||
middleware.CORS, middleware.ContentType,
|
||||
))
|
||||
r.mux.Handle("POST /auth/oidc/back-channel-logout", middleware.Chain(
|
||||
http.HandlerFunc(reg.AuthSessionOIDC.BackChannelLogout),
|
||||
middleware.CORS, middleware.ContentType,
|
||||
))
|
||||
r.mux.Handle("POST /auth/logout", middleware.Chain(
|
||||
http.HandlerFunc(reg.AuthSessionOIDC.Logout),
|
||||
middleware.CORS, middleware.ContentType,
|
||||
))
|
||||
|
||||
// Session management. auth.session.list gates the all-actors
|
||||
// admin view; the handler internally allows callers to list
|
||||
// their own sessions without the permission. Revoke gates
|
||||
// "revoke any session"; own-session paths bypass at the
|
||||
// handler layer per Phase 5 spec.
|
||||
r.Register("GET /api/v1/auth/sessions", rbacGate(reg.Checker, "auth.session.list", reg.AuthSessionOIDC.ListSessions))
|
||||
r.Register("DELETE /api/v1/auth/sessions/{id}", rbacGate(reg.Checker, "auth.session.revoke", reg.AuthSessionOIDC.RevokeSession))
|
||||
|
||||
// OIDC provider CRUD.
|
||||
r.Register("GET /api/v1/auth/oidc/providers", rbacGate(reg.Checker, "auth.oidc.list", reg.AuthSessionOIDC.ListProviders))
|
||||
r.Register("POST /api/v1/auth/oidc/providers", rbacGate(reg.Checker, "auth.oidc.create", reg.AuthSessionOIDC.CreateProvider))
|
||||
r.Register("PUT /api/v1/auth/oidc/providers/{id}", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.UpdateProvider))
|
||||
r.Register("DELETE /api/v1/auth/oidc/providers/{id}", rbacGate(reg.Checker, "auth.oidc.delete", reg.AuthSessionOIDC.DeleteProvider))
|
||||
r.Register("POST /api/v1/auth/oidc/providers/{id}/refresh", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.RefreshProvider))
|
||||
|
||||
// Group-mapping CRUD.
|
||||
r.Register("GET /api/v1/auth/oidc/group-mappings", rbacGate(reg.Checker, "auth.oidc.list", reg.AuthSessionOIDC.ListGroupMappings))
|
||||
r.Register("POST /api/v1/auth/oidc/group-mappings", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.AddGroupMapping))
|
||||
r.Register("DELETE /api/v1/auth/oidc/group-mappings/{id}", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.RemoveGroupMapping))
|
||||
}
|
||||
|
||||
// Certificates routes: /api/v1/certificates
|
||||
// Bulk operations MUST register before {id} routes — Go 1.22 ServeMux
|
||||
// gives literal segments precedence over pattern-var segments, but
|
||||
|
||||
Reference in New Issue
Block a user