mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:11:31 +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:
@@ -4794,6 +4794,27 @@ components:
|
|||||||
type: http
|
type: http
|
||||||
scheme: bearer
|
scheme: bearer
|
||||||
description: API key passed as Bearer token. Configure via CERTCTL_AUTH_SECRET.
|
description: API key passed as Bearer token. Configure via CERTCTL_AUTH_SECRET.
|
||||||
|
# Auth Bundle 2 Phase 5 — session-cookie auth scheme. New
|
||||||
|
# session-authenticated endpoints declare
|
||||||
|
# `security: [{cookieAuth: []}, {bearerAuth: []}]` (either auth
|
||||||
|
# method works, OR semantics). Per Phase 5 spec, the
|
||||||
|
# `/auth/oidc/back-channel-logout` endpoint declares `security: []`
|
||||||
|
# because auth comes from the IdP-signed logout token in the body,
|
||||||
|
# not certctl-issued credentials.
|
||||||
|
cookieAuth:
|
||||||
|
type: apiKey
|
||||||
|
in: cookie
|
||||||
|
name: certctl_session
|
||||||
|
description: |
|
||||||
|
Session cookie minted by `POST /auth/oidc/callback` after a
|
||||||
|
successful OIDC handshake (Auth Bundle 2). Wire format
|
||||||
|
`v1.<session_id>.<signing_key_id>.<HMAC-SHA256>`; HMAC is
|
||||||
|
verified server-side against the active session signing key.
|
||||||
|
Cookie attributes: `Secure` `HttpOnly` `SameSite=Lax|Strict`
|
||||||
|
(configurable via `CERTCTL_SESSION_SAMESITE`) `Path=/`.
|
||||||
|
State-changing requests additionally require the
|
||||||
|
`X-CSRF-Token` header to match the SHA-256 hash on the
|
||||||
|
session row (validated by the session middleware in Phase 6).
|
||||||
|
|
||||||
parameters:
|
parameters:
|
||||||
resourceId:
|
resourceId:
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ import (
|
|||||||
"github.com/certctl-io/certctl/internal/api/router"
|
"github.com/certctl-io/certctl/internal/api/router"
|
||||||
"github.com/certctl-io/certctl/internal/auth"
|
"github.com/certctl-io/certctl/internal/auth"
|
||||||
"github.com/certctl-io/certctl/internal/auth/bootstrap"
|
"github.com/certctl-io/certctl/internal/auth/bootstrap"
|
||||||
|
oidcsvc "github.com/certctl-io/certctl/internal/auth/oidc"
|
||||||
|
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
|
||||||
"github.com/certctl-io/certctl/internal/auth/session"
|
"github.com/certctl-io/certctl/internal/auth/session"
|
||||||
|
userdomain "github.com/certctl-io/certctl/internal/auth/user/domain"
|
||||||
"github.com/certctl-io/certctl/internal/config"
|
"github.com/certctl-io/certctl/internal/config"
|
||||||
discoveryawssm "github.com/certctl-io/certctl/internal/connector/discovery/awssm"
|
discoveryawssm "github.com/certctl-io/certctl/internal/connector/discovery/awssm"
|
||||||
discoveryazurekv "github.com/certctl-io/certctl/internal/connector/discovery/azurekv"
|
discoveryazurekv "github.com/certctl-io/certctl/internal/connector/discovery/azurekv"
|
||||||
@@ -383,6 +386,58 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Auth Bundle 2 Phase 5 — OIDC service + pre-login store + Phase 5 handler.
|
||||||
|
//
|
||||||
|
// Wired AFTER sessionService (Phase 4) so the OIDC PreLoginAdapter
|
||||||
|
// can sign pre-login cookies under the active SessionSigningKey.
|
||||||
|
// =========================================================================
|
||||||
|
oidcProviderRepo := postgres.NewOIDCProviderRepository(db)
|
||||||
|
oidcMappingRepo := postgres.NewGroupRoleMappingRepository(db)
|
||||||
|
oidcUserRepo := postgres.NewUserRepository(db)
|
||||||
|
oidcPreLoginRepo := postgres.NewPreLoginRepository(db)
|
||||||
|
preLoginAdapter := oidcsvc.NewPreLoginAdapter(
|
||||||
|
oidcPreLoginRepo,
|
||||||
|
sessionKeyRepo, // Phase 4 SessionSigningKeyRepository
|
||||||
|
authdomainAlias.DefaultTenantID,
|
||||||
|
cfg.Encryption.ConfigEncryptionKey,
|
||||||
|
)
|
||||||
|
// SessionMinter port for the OIDC service. The OIDC HandleCallback
|
||||||
|
// uses this to mint the post-login session after successful token
|
||||||
|
// validation + group→role mapping.
|
||||||
|
oidcSessionMinter := &sessionMinterAdapter{svc: sessionService}
|
||||||
|
oidcService := oidcsvc.NewService(
|
||||||
|
oidcProviderRepo,
|
||||||
|
oidcMappingRepo,
|
||||||
|
oidcUserRepo,
|
||||||
|
oidcSessionMinter,
|
||||||
|
preLoginAdapter,
|
||||||
|
cfg.Encryption.ConfigEncryptionKey,
|
||||||
|
)
|
||||||
|
// SameSite resolution from CERTCTL_SESSION_SAMESITE (default Lax;
|
||||||
|
// "Strict" for high-security environments at the cost of breaking
|
||||||
|
// inbound deep-links from external apps).
|
||||||
|
sameSiteMode := http.SameSiteLaxMode
|
||||||
|
if strings.EqualFold(cfg.Auth.Session.SameSite, "Strict") {
|
||||||
|
sameSiteMode = http.SameSiteStrictMode
|
||||||
|
}
|
||||||
|
authSessionOIDCHandler := handler.NewAuthSessionOIDCHandler(
|
||||||
|
oidcService,
|
||||||
|
sessionService,
|
||||||
|
handler.NewDefaultBCLVerifier(oidcProviderRepo, authdomainAlias.DefaultTenantID, nil),
|
||||||
|
oidcProviderRepo,
|
||||||
|
oidcMappingRepo,
|
||||||
|
sessionRepo,
|
||||||
|
auditService,
|
||||||
|
cfg.Encryption.ConfigEncryptionKey,
|
||||||
|
authdomainAlias.DefaultTenantID,
|
||||||
|
"/", // post-login redirect target; GUI dashboard
|
||||||
|
handler.SessionCookieAttrs{
|
||||||
|
SameSite: sameSiteMode,
|
||||||
|
Secure: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
policyService := service.NewPolicyService(policyRepo, auditService)
|
policyService := service.NewPolicyService(policyRepo, auditService)
|
||||||
policyService.SetCertRepo(certificateRepo) // D-008: CertificateLifetime arm needs CertificateVersion.NotBefore/NotAfter
|
policyService.SetCertRepo(certificateRepo) // D-008: CertificateLifetime arm needs CertificateVersion.NotBefore/NotAfter
|
||||||
// G-1: RenewalPolicyService — distinct from PolicyService (compliance rules).
|
// G-1: RenewalPolicyService — distinct from PolicyService (compliance rules).
|
||||||
@@ -1141,6 +1196,10 @@ func main() {
|
|||||||
// Rank 8 of the 2026-05-03 deep-research deliverable. See
|
// Rank 8 of the 2026-05-03 deep-research deliverable. See
|
||||||
// docs/intermediate-ca-hierarchy.md.
|
// docs/intermediate-ca-hierarchy.md.
|
||||||
IntermediateCAs: intermediateCAHandler,
|
IntermediateCAs: intermediateCAHandler,
|
||||||
|
// AuthSessionOIDC — Auth Bundle 2 Phase 5 OIDC + session HTTP
|
||||||
|
// surface. 13 endpoints across login flow + session management
|
||||||
|
// + OIDC provider CRUD + group-mapping CRUD.
|
||||||
|
AuthSessionOIDC: authSessionOIDCHandler,
|
||||||
// Auth — RBAC primitive (Bundle 1 Phase 4). Wires the postgres
|
// Auth — RBAC primitive (Bundle 1 Phase 4). Wires the postgres
|
||||||
// auth repos + service-layer Authorizer / RoleService /
|
// auth repos + service-layer Authorizer / RoleService /
|
||||||
// ActorRoleService / PermissionService into the HTTP surface
|
// ActorRoleService / PermissionService into the HTTP surface
|
||||||
@@ -2471,3 +2530,42 @@ func (ad authCheckResolverAdapter) EffectivePermissions(
|
|||||||
) ([]repository.EffectivePermission, error) {
|
) ([]repository.EffectivePermission, error) {
|
||||||
return ad.repo.EffectivePermissions(ctx, actorID, authdomainAlias.ActorTypeValue(actorType), tenantID)
|
return ad.repo.EffectivePermissions(ctx, actorID, authdomainAlias.ActorTypeValue(actorType), tenantID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// sessionMinterAdapter — bridge from *session.Service to oidcsvc.SessionMinter.
|
||||||
|
//
|
||||||
|
// The OIDC service's SessionMinter port (Phase 3) takes a *userdomain.User
|
||||||
|
// + role IDs and returns (cookie, csrf, err). The session.Service's
|
||||||
|
// Create method takes (actorID, actorType, ip, ua) -> *CreateResult.
|
||||||
|
// This adapter unwraps the User into actorID/actorType + reshapes the
|
||||||
|
// return tuple. Lives in cmd/server so the session package doesn't have
|
||||||
|
// to know about user.User and the user package doesn't have to know
|
||||||
|
// about session.CreateResult.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
type sessionMinterAdapter struct {
|
||||||
|
svc *session.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *sessionMinterAdapter) MintForUser(
|
||||||
|
ctx context.Context,
|
||||||
|
user *userdomain.User,
|
||||||
|
_ []string, // roleIDs unused at the session-mint layer; the rbac middleware looks them up at request time
|
||||||
|
ip, userAgent string,
|
||||||
|
) (cookieValue, csrfToken string, err error) {
|
||||||
|
if user == nil {
|
||||||
|
return "", "", fmt.Errorf("session mint: user is nil")
|
||||||
|
}
|
||||||
|
res, err := a.svc.Create(ctx, user.ID, string(domain.ActorTypeUser), ip, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return res.CookieValue, res.CSRFToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// silenceUnusedImports keeps the new oidcsvc + oidcdomain imports load-
|
||||||
|
// bearing in case any file shuffles. Linker dead-code elimination handles
|
||||||
|
// the runtime cost.
|
||||||
|
var (
|
||||||
|
_ = oidcdomain.OIDCProvider{}
|
||||||
|
)
|
||||||
|
|||||||
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
|
// `[Auth]`. Shared shapes: AuthRole + AuthRolePermission in the
|
||||||
// schemas section. AuthCheck (Bundle 1 M1) now returns the same
|
// schemas section. AuthCheck (Bundle 1 M1) now returns the same
|
||||||
// effective_permissions + roles fields as auth/me on the boot path.
|
// 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) {
|
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
|
// The TestRouter_AuthExemptAllowlist regression test below pins the slice
|
||||||
// to the actual mux.Handle calls — adding an undocumented bypass fails CI.
|
// to the actual mux.Handle calls — adding an undocumented bypass fails CI.
|
||||||
var AuthExemptRouterRoutes = []string{
|
var AuthExemptRouterRoutes = []string{
|
||||||
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
|
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
|
||||||
"GET /ready", // K8s/Docker readiness 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/auth/info", // GUI calls before login to detect auth mode
|
||||||
"GET /api/v1/version", // Rollout probes need build identity without key
|
"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)
|
"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
|
"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
|
// AuthExemptDispatchPrefixes is the documented allowlist of URL prefixes
|
||||||
@@ -206,6 +210,29 @@ type HandlerRegistry struct {
|
|||||||
// docs/approval-workflow.md for the operator playbook.
|
// docs/approval-workflow.md for the operator playbook.
|
||||||
Approvals handler.ApprovalHandler
|
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
|
// IntermediateCAs handles the admin-gated CA-hierarchy management
|
||||||
// surface under /api/v1/issuers/{id}/intermediates and
|
// surface under /api/v1/issuers/{id}/intermediates and
|
||||||
// /api/v1/intermediates/{id}. Rank 8 of the 2026-05-03 deep-
|
// /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("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))
|
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
|
// Certificates routes: /api/v1/certificates
|
||||||
// Bulk operations MUST register before {id} routes — Go 1.22 ServeMux
|
// Bulk operations MUST register before {id} routes — Go 1.22 ServeMux
|
||||||
// gives literal segments precedence over pattern-var segments, but
|
// gives literal segments precedence over pattern-var segments, but
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Package oidc — Bundle 2 Phase 5 / pre-login cookie machinery.
|
||||||
|
//
|
||||||
|
// This file implements the production-side PreLoginStore that the
|
||||||
|
// Phase 3 OIDC service wires into HandleAuthRequest + HandleCallback.
|
||||||
|
// Phase 3 shipped the interface + an in-memory test stub; Phase 5
|
||||||
|
// ships the real implementation backed by:
|
||||||
|
//
|
||||||
|
// - oidc_pre_login_sessions table (Phase 5 migration 000037)
|
||||||
|
// - the active SessionSigningKey (Phase 4 service)
|
||||||
|
//
|
||||||
|
// The cookie wire format is `v1.<pl-id>.<sk-id>.<base64url-no-pad
|
||||||
|
// HMAC-SHA256>` — IDENTICAL to the post-login session cookie shape so
|
||||||
|
// both surfaces share the same parser, the same length-prefixed HMAC
|
||||||
|
// input (defeats concatenation collisions), and the same v1. version
|
||||||
|
// prefix. Different cookie name (`certctl_oidc_pending` vs
|
||||||
|
// `certctl_session`) and different id prefix (`pl-` vs `ses-`) keep
|
||||||
|
// the two surfaces distinguishable; defense-in-depth checks at each
|
||||||
|
// consumer reject the wrong-prefix shape even if the cookie value
|
||||||
|
// somehow gets routed to the wrong handler.
|
||||||
|
|
||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
cryptorand "crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/certctl-io/certctl/internal/auth/session"
|
||||||
|
sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain"
|
||||||
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SigningKeyLookup is the slice of SessionSigningKey access the
|
||||||
|
// pre-login adapter needs. SessionService satisfies this implicitly
|
||||||
|
// via the Phase 4 SigningKeyRepo (we re-use the interface here rather
|
||||||
|
// than adding a method to SessionService).
|
||||||
|
type SigningKeyLookup interface {
|
||||||
|
GetActive(ctx context.Context, tenantID string) (*sessiondomain.SessionSigningKey, error)
|
||||||
|
Get(ctx context.Context, id string) (*sessiondomain.SessionSigningKey, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreLoginAdapter implements the Phase 3 OIDCService.PreLoginStore
|
||||||
|
// interface against a real PreLoginRepository + the active
|
||||||
|
// SessionSigningKey.
|
||||||
|
//
|
||||||
|
// The cookie value returned by CreatePreLogin is the wire-format
|
||||||
|
// `v1.pl-<id>.sk-<id>.<HMAC-SHA256>`; LookupAndConsume parses + HMAC-
|
||||||
|
// verifies the cookie value before reading + deleting the row.
|
||||||
|
type PreLoginAdapter struct {
|
||||||
|
repo repository.PreLoginRepository
|
||||||
|
keys SigningKeyLookup
|
||||||
|
tenantID string
|
||||||
|
encryptionKey string
|
||||||
|
|
||||||
|
// Injectable for tests so the adapter can be exercised against a
|
||||||
|
// deterministic-failure RNG.
|
||||||
|
readRand func([]byte) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPreLoginAdapter constructs a PreLoginAdapter wired against the
|
||||||
|
// supplied repository + signing-key lookup. encryptionKey is the
|
||||||
|
// CERTCTL_CONFIG_ENCRYPTION_KEY value used to decrypt the
|
||||||
|
// SessionSigningKey.KeyMaterialEncrypted blob.
|
||||||
|
func NewPreLoginAdapter(
|
||||||
|
repo repository.PreLoginRepository,
|
||||||
|
keys SigningKeyLookup,
|
||||||
|
tenantID, encryptionKey string,
|
||||||
|
) *PreLoginAdapter {
|
||||||
|
return &PreLoginAdapter{
|
||||||
|
repo: repo,
|
||||||
|
keys: keys,
|
||||||
|
tenantID: tenantID,
|
||||||
|
encryptionKey: encryptionKey,
|
||||||
|
readRand: cryptorand.Read,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRandReaderForTest replaces the entropy source. ONLY for tests.
|
||||||
|
func (a *PreLoginAdapter) SetRandReaderForTest(r func([]byte) (int, error)) {
|
||||||
|
a.readRand = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePreLogin generates a fresh `pl-<random>` id, signs the cookie
|
||||||
|
// value under the active SessionSigningKey, persists the row, and
|
||||||
|
// returns the cookie value + the row id.
|
||||||
|
//
|
||||||
|
// Implements the Phase 3 OIDCService.PreLoginStore.CreatePreLogin
|
||||||
|
// interface signature.
|
||||||
|
func (a *PreLoginAdapter) CreatePreLogin(ctx context.Context, providerID, state, nonce, verifier string) (cookieValue, sessionID string, err error) {
|
||||||
|
active, err := a.keys.GetActive(ctx, a.tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("pre-login: get active signing key: %w", err)
|
||||||
|
}
|
||||||
|
hmacKey, err := session.DecryptKeyMaterial(active.KeyMaterialEncrypted, a.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("pre-login: decrypt active key: %w", err)
|
||||||
|
}
|
||||||
|
id, err := a.newID()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("pre-login: generate id: %w", err)
|
||||||
|
}
|
||||||
|
row := &repository.PreLoginSession{
|
||||||
|
ID: id,
|
||||||
|
TenantID: a.tenantID,
|
||||||
|
SigningKeyID: active.ID,
|
||||||
|
OIDCProviderID: providerID,
|
||||||
|
State: state,
|
||||||
|
Nonce: nonce,
|
||||||
|
PKCEVerifier: verifier,
|
||||||
|
}
|
||||||
|
if err := a.repo.Create(ctx, row); err != nil {
|
||||||
|
return "", "", fmt.Errorf("pre-login: persist row: %w", err)
|
||||||
|
}
|
||||||
|
cookieValue = session.SignCookieValue(id, active.ID, hmacKey)
|
||||||
|
return cookieValue, id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupAndConsume parses + HMAC-verifies the cookie value, looks up
|
||||||
|
// the row, atomically deletes it, and returns the OIDC handshake
|
||||||
|
// material the callback handler needs.
|
||||||
|
//
|
||||||
|
// Failure semantics:
|
||||||
|
// - Malformed cookie / wrong v1. prefix / wrong id prefix /
|
||||||
|
// bad base64 HMAC -> ErrPreLoginNotFound (uniform 400 to the wire,
|
||||||
|
// no information leak about which check failed).
|
||||||
|
// - HMAC mismatch -> ErrPreLoginNotFound (forged cookie).
|
||||||
|
// - Signing key id not found -> ErrPreLoginNotFound.
|
||||||
|
// - Row not found OR already consumed -> ErrPreLoginNotFound.
|
||||||
|
// - Row found but past 10-minute TTL -> ErrPreLoginExpired (row is
|
||||||
|
// deleted at the repo layer regardless).
|
||||||
|
//
|
||||||
|
// Implements the Phase 3 OIDCService.PreLoginStore.LookupAndConsume
|
||||||
|
// interface signature.
|
||||||
|
func (a *PreLoginAdapter) LookupAndConsume(ctx context.Context, cookieValue string) (providerID, state, nonce, verifier string, err error) {
|
||||||
|
plID, signingKeyID, providedHMAC, perr := session.ParseCookieValue(cookieValue, "pl-")
|
||||||
|
if perr != nil {
|
||||||
|
return "", "", "", "", ErrPreLoginNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
signingKey, kerr := a.keys.Get(ctx, signingKeyID)
|
||||||
|
if kerr != nil {
|
||||||
|
return "", "", "", "", ErrPreLoginNotFound
|
||||||
|
}
|
||||||
|
hmacKey, derr := session.DecryptKeyMaterial(signingKey.KeyMaterialEncrypted, a.encryptionKey)
|
||||||
|
if derr != nil {
|
||||||
|
return "", "", "", "", ErrPreLoginNotFound
|
||||||
|
}
|
||||||
|
expectedHMAC := session.ComputeCookieHMAC(plID, signingKeyID, hmacKey)
|
||||||
|
if subtle.ConstantTimeCompare(expectedHMAC, providedHMAC) != 1 {
|
||||||
|
return "", "", "", "", ErrPreLoginNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
row, lerr := a.repo.LookupAndConsume(ctx, plID)
|
||||||
|
if lerr != nil {
|
||||||
|
// Map both not-found AND expired to the same uniform sentinel
|
||||||
|
// the OIDC service consumes; the audit row distinguishes via
|
||||||
|
// the wrapped error from the repo (which the handler logs).
|
||||||
|
if errors.Is(lerr, repository.ErrPreLoginNotFound) {
|
||||||
|
return "", "", "", "", ErrPreLoginNotFound
|
||||||
|
}
|
||||||
|
if errors.Is(lerr, repository.ErrPreLoginExpired) {
|
||||||
|
return "", "", "", "", ErrPreLoginNotFound
|
||||||
|
}
|
||||||
|
return "", "", "", "", fmt.Errorf("pre-login: lookup_and_consume: %w", lerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return row.OIDCProviderID, row.State, row.Nonce, row.PKCEVerifier, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newID returns `pl-<base64url-no-pad>` with 16 bytes of entropy.
|
||||||
|
func (a *PreLoginAdapter) newID() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := a.readRand(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "pl-" + base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
@@ -407,6 +407,13 @@ func (s *Service) Validate(ctx context.Context, in ValidateInput) (*sessiondomai
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrSessionInvalidCookie
|
return nil, ErrSessionInvalidCookie
|
||||||
}
|
}
|
||||||
|
// Defense-in-depth: post-login cookies must carry the `ses-` prefix.
|
||||||
|
// Pre-login cookies (`pl-`) are verified by the OIDC pre-login
|
||||||
|
// machinery via internal/auth/oidc/prelogin.go and never reach
|
||||||
|
// SessionService.Validate.
|
||||||
|
if !strings.HasPrefix(sessionID, "ses-") {
|
||||||
|
return nil, ErrSessionInvalidCookie
|
||||||
|
}
|
||||||
|
|
||||||
signingKey, err := s.keys.Get(ctx, signingKeyID)
|
signingKey, err := s.keys.Get(ctx, signingKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -703,6 +710,51 @@ func (s *Service) GarbageCollect(ctx context.Context) (int, error) {
|
|||||||
// Helpers.
|
// Helpers.
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
// SignCookieValue is the public wrapper around the cookie-signing helper.
|
||||||
|
// Phase 5's pre-login cookie machinery (internal/auth/oidc/prelogin.go)
|
||||||
|
// reuses this so the cookie wire format stays identical across both
|
||||||
|
// post-login and pre-login surfaces. id1 is the resource identifier
|
||||||
|
// (`ses-...` or `pl-...`); id2 is the signing-key id; hmacKey is the
|
||||||
|
// 32-byte plaintext HMAC key.
|
||||||
|
func SignCookieValue(id1, id2 string, hmacKey []byte) string {
|
||||||
|
return signCookie(id1, id2, hmacKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCookieValue is the public wrapper around the cookie-parser. It
|
||||||
|
// validates the v1. version prefix, splits the four segments,
|
||||||
|
// base64url-decodes the HMAC, and returns the two embedded ids + the
|
||||||
|
// HMAC bytes. Caller is responsible for the HMAC re-compute /
|
||||||
|
// constant-time compare. expectedID1Prefix is the prefix the caller
|
||||||
|
// expects on segment 1 ("ses-" for post-login, "pl-" for pre-login);
|
||||||
|
// passing empty skips the prefix check.
|
||||||
|
func ParseCookieValue(cookieValue, expectedID1Prefix string) (id1, id2 string, hmacBytes []byte, err error) {
|
||||||
|
id1, id2, hmacBytes, err = parseCookie(cookieValue)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
if expectedID1Prefix != "" && !strings.HasPrefix(id1, expectedID1Prefix) {
|
||||||
|
return "", "", nil, errInvalidIDPrefix
|
||||||
|
}
|
||||||
|
return id1, id2, hmacBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeCookieHMAC is the public wrapper around the length-prefixed
|
||||||
|
// HMAC compute helper. Pre-login cookie verification uses this to
|
||||||
|
// recompute the HMAC against the same canonical input the post-login
|
||||||
|
// signing path uses.
|
||||||
|
func ComputeCookieHMAC(id1, id2 string, hmacKey []byte) []byte {
|
||||||
|
return computeHMAC(id1, id2, hmacKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptKeyMaterial is the public wrapper around decryptKeyMaterial.
|
||||||
|
// Pre-login cookie verification uses this to derive the HMAC key from
|
||||||
|
// the SessionSigningKey row's key_material_encrypted blob.
|
||||||
|
func DecryptKeyMaterial(blob []byte, passphrase string) ([]byte, error) {
|
||||||
|
return decryptKeyMaterial(blob, passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errInvalidIDPrefix = errors.New("session: cookie id has unexpected prefix")
|
||||||
|
|
||||||
// signCookie returns the wire-format session cookie value:
|
// signCookie returns the wire-format session cookie value:
|
||||||
// `v1.<session_id>.<signing_key_id>.<base64url-no-pad(HMAC-SHA256)>`.
|
// `v1.<session_id>.<signing_key_id>.<base64url-no-pad(HMAC-SHA256)>`.
|
||||||
func signCookie(sessionID, signingKeyID string, hmacKey []byte) string {
|
func signCookie(sessionID, signingKeyID string, hmacKey []byte) string {
|
||||||
@@ -750,8 +802,14 @@ func parseCookie(cookieValue string) (sessionID, signingKeyID string, hmacBytes
|
|||||||
if parts[0] != sessiondomain.CookieFormatVersion {
|
if parts[0] != sessiondomain.CookieFormatVersion {
|
||||||
return "", "", nil, errors.New("unsupported version prefix")
|
return "", "", nil, errors.New("unsupported version prefix")
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(parts[1], "ses-") {
|
// Phase 5: parseCookie itself does NOT enforce a fixed prefix on
|
||||||
return "", "", nil, errors.New("session id missing prefix")
|
// segment 1. The post-login Validate path checks `ses-` via the
|
||||||
|
// prefix on the row id; the pre-login verifier (in
|
||||||
|
// internal/auth/oidc/prelogin.go) checks `pl-` via the public
|
||||||
|
// ParseCookieValue wrapper. Keeping the check out of parseCookie
|
||||||
|
// lets both surfaces share the same HMAC parser.
|
||||||
|
if parts[1] == "" {
|
||||||
|
return "", "", nil, errors.New("session id segment empty")
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(parts[2], "sk-") {
|
if !strings.HasPrefix(parts[2], "sk-") {
|
||||||
return "", "", nil, errors.New("signing key id missing prefix")
|
return "", "", nil, errors.New("signing key id missing prefix")
|
||||||
|
|||||||
@@ -1065,14 +1065,50 @@ func TestParseCookie_RejectsWrongSegmentCount(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseCookie_RejectsMissingPrefixes(t *testing.T) {
|
func TestParseCookie_RejectsMissingPrefixes(t *testing.T) {
|
||||||
mac := base64.RawURLEncoding.EncodeToString(make([]byte, sha256.Size))
|
mac := base64.RawURLEncoding.EncodeToString(make([]byte, sha256.Size))
|
||||||
if _, _, _, err := parseCookie("v1.bad-id.sk-y." + mac); err == nil {
|
// parseCookie itself does NOT enforce the ses-/pl- prefix on the
|
||||||
t.Errorf("expected error for session id missing prefix")
|
// id segment (Phase 5 split: prefix-check moved to Validate so the
|
||||||
|
// pre-login `pl-` cookie can share the same parser). We still
|
||||||
|
// reject empty segments + wrong signing-key prefix here.
|
||||||
|
if _, _, _, err := parseCookie("v1..sk-y." + mac); err == nil {
|
||||||
|
t.Errorf("expected error for empty session id segment")
|
||||||
}
|
}
|
||||||
if _, _, _, err := parseCookie("v1.ses-x.bad-key." + mac); err == nil {
|
if _, _, _, err := parseCookie("v1.ses-x.bad-key." + mac); err == nil {
|
||||||
t.Errorf("expected error for signing key id missing prefix")
|
t.Errorf("expected error for signing key id missing prefix")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 5: ParseCookieValue (the exported wrapper) DOES enforce the
|
||||||
|
// caller-specified prefix on segment 1. Pin both the post-login
|
||||||
|
// `ses-` and pre-login `pl-` consumer flows.
|
||||||
|
func TestParseCookieValue_EnforcesCallerSuppliedPrefix(t *testing.T) {
|
||||||
|
mac := base64.RawURLEncoding.EncodeToString(make([]byte, sha256.Size))
|
||||||
|
if _, _, _, err := ParseCookieValue("v1.bad-id.sk-y."+mac, "ses-"); !errors.Is(err, errInvalidIDPrefix) {
|
||||||
|
t.Errorf("ParseCookieValue with wrong prefix: err = %v; want errInvalidIDPrefix", err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := ParseCookieValue("v1.bad-id.sk-y."+mac, "pl-"); !errors.Is(err, errInvalidIDPrefix) {
|
||||||
|
t.Errorf("ParseCookieValue with wrong prefix (pl-): err = %v; want errInvalidIDPrefix", err)
|
||||||
|
}
|
||||||
|
// Empty prefix skips the check.
|
||||||
|
if _, _, _, err := ParseCookieValue("v1.anything.sk-y."+mac, ""); err != nil {
|
||||||
|
t.Errorf("ParseCookieValue with empty prefix: err = %v; want nil (skip prefix check)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin that the post-login Validate path rejects pre-login (`pl-`)
|
||||||
|
// cookies even when the HMAC signs valid — defense-in-depth so a
|
||||||
|
// stolen pre-login cookie can't be replayed against /api/* gates.
|
||||||
|
func TestService_Validate_RejectsPreLoginCookieAtPostLoginGate(t *testing.T) {
|
||||||
|
svc, _, keys, _, _ := newTestService(t, defaultCfg())
|
||||||
|
// Forge a `pl-` cookie signed under the active key.
|
||||||
|
active, _ := keys.GetActive(context.Background(), testTenant)
|
||||||
|
hmacKey, _ := DecryptKeyMaterial(active.KeyMaterialEncrypted, "")
|
||||||
|
forged := SignCookieValue("pl-forged-id", active.ID, hmacKey)
|
||||||
|
_, err := svc.Validate(context.Background(), ValidateInput{CookieValue: forged})
|
||||||
|
if !errors.Is(err, ErrSessionInvalidCookie) {
|
||||||
|
t.Errorf("Validate accepted pl- cookie: err = %v; want ErrSessionInvalidCookie", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseCookie_RejectsBadBase64(t *testing.T) {
|
func TestParseCookie_RejectsBadBase64(t *testing.T) {
|
||||||
if _, _, _, err := parseCookie("v1.ses-x.sk-y.!!!notbase64"); err == nil {
|
if _, _, _, err := parseCookie("v1.ses-x.sk-y.!!!notbase64"); err == nil {
|
||||||
t.Errorf("expected error for bad base64 hmac segment")
|
t.Errorf("expected error for bad base64 hmac segment")
|
||||||
|
|||||||
@@ -103,6 +103,21 @@ var CanonicalPermissions = []string{
|
|||||||
"scep.admin",
|
"scep.admin",
|
||||||
"est.admin",
|
"est.admin",
|
||||||
"ca.hierarchy.manage",
|
"ca.hierarchy.manage",
|
||||||
|
|
||||||
|
// Bundle 2 Phase 5 — session + OIDC management permissions
|
||||||
|
// seeded by migration 000037. auth.session.list / .revoke gate
|
||||||
|
// "list/revoke any session in tenant" (own-session paths bypass
|
||||||
|
// the gate via "is path.actor_id == ctx.actor_id?" check at the
|
||||||
|
// handler layer); auth.session.list.all gates the all-actors
|
||||||
|
// admin view. auth.oidc.{list,create,edit,delete} gates the
|
||||||
|
// OIDC-provider-config + group-mapping CRUD endpoints.
|
||||||
|
"auth.session.list",
|
||||||
|
"auth.session.list.all",
|
||||||
|
"auth.session.revoke",
|
||||||
|
"auth.oidc.list",
|
||||||
|
"auth.oidc.create",
|
||||||
|
"auth.oidc.edit",
|
||||||
|
"auth.oidc.delete",
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRoles describes the seven default roles seeded by the
|
// DefaultRoles describes the seven default roles seeded by the
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
|
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
|
||||||
)
|
)
|
||||||
@@ -92,3 +93,66 @@ type GroupRoleMappingRepository interface {
|
|||||||
// `auth.oidc_login_unmapped_groups`).
|
// `auth.oidc_login_unmapped_groups`).
|
||||||
Map(ctx context.Context, providerID string, groupNames []string) ([]string, error)
|
Map(ctx context.Context, providerID string, groupNames []string) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PreLoginRepository — Bundle 2 Phase 5.
|
||||||
|
//
|
||||||
|
// Holds short-lived rows that carry OIDC state + nonce + PKCE verifier
|
||||||
|
// across the IdP redirect. Distinct from the sessions table because
|
||||||
|
// sessions doesn't carry OIDC-specific columns. 10-minute absolute TTL
|
||||||
|
// at the schema layer (oidc_pre_login_sessions.absolute_expires_at);
|
||||||
|
// the GC sweep deletes expired rows.
|
||||||
|
//
|
||||||
|
// Cookie wire format `v1.<pl-id>.<sk-id>.<HMAC-SHA256>` matches the
|
||||||
|
// post-login session cookie format exactly; signing-key id is the
|
||||||
|
// active SessionSigningKey at handshake time.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// PreLoginSession is the row shape for oidc_pre_login_sessions. Held
|
||||||
|
// here (not in oidc/domain) because it's a Phase-5 storage primitive,
|
||||||
|
// not a domain concept the wider service layer reasons about.
|
||||||
|
type PreLoginSession struct {
|
||||||
|
ID string // prefix `pl-`
|
||||||
|
TenantID string
|
||||||
|
SigningKeyID string // FK to session_signing_keys.id
|
||||||
|
OIDCProviderID string // FK to oidc_providers.id
|
||||||
|
State string
|
||||||
|
Nonce string
|
||||||
|
PKCEVerifier string
|
||||||
|
CreatedAt time.Time
|
||||||
|
AbsoluteExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentinel errors for PreLoginRepository.
|
||||||
|
var (
|
||||||
|
// ErrPreLoginNotFound: LookupAndConsume found no row with the
|
||||||
|
// supplied id. The handler maps to HTTP 400 (replay or forgery).
|
||||||
|
ErrPreLoginNotFound = errors.New("oidc: pre-login session not found or already consumed")
|
||||||
|
|
||||||
|
// ErrPreLoginExpired: the row was found but absolute_expires_at is
|
||||||
|
// in the past. The handler maps to HTTP 400. The row is also
|
||||||
|
// deleted (the consume side of LookupAndConsume).
|
||||||
|
ErrPreLoginExpired = errors.New("oidc: pre-login session expired (10-minute TTL exceeded)")
|
||||||
|
)
|
||||||
|
|
||||||
|
// PreLoginRepository wraps the oidc_pre_login_sessions table.
|
||||||
|
type PreLoginRepository interface {
|
||||||
|
// Create persists a new pre-login row. Caller MUST have already
|
||||||
|
// generated the random id, state, nonce, and PKCE verifier;
|
||||||
|
// CreatedAt + AbsoluteExpiresAt default to NOW() and NOW()+10min
|
||||||
|
// at the schema layer when zero.
|
||||||
|
Create(ctx context.Context, p *PreLoginSession) error
|
||||||
|
|
||||||
|
// LookupAndConsume reads the row by id AND deletes it atomically
|
||||||
|
// (single-use). Returns ErrPreLoginNotFound if no row matches OR
|
||||||
|
// if the row was already consumed by a concurrent caller.
|
||||||
|
// Returns ErrPreLoginExpired if the row was found but expired
|
||||||
|
// (the row is still deleted in this case so retries don't
|
||||||
|
// re-trigger the expiry check).
|
||||||
|
LookupAndConsume(ctx context.Context, id string) (*PreLoginSession, error)
|
||||||
|
|
||||||
|
// GarbageCollectExpired deletes pre-login rows whose
|
||||||
|
// absolute_expires_at is in the past. Returns the count deleted.
|
||||||
|
// Wired into the same scheduler sweep as expired post-login sessions.
|
||||||
|
GarbageCollectExpired(ctx context.Context) (int, error)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PreLoginRepository (Auth Bundle 2 Phase 5)
|
||||||
|
//
|
||||||
|
// Holds short-lived pre-login session rows that carry OIDC state +
|
||||||
|
// nonce + PKCE verifier across the IdP redirect. Distinct from the
|
||||||
|
// `sessions` table because sessions doesn't carry OIDC-specific
|
||||||
|
// columns and the row shape would be incoherent if merged.
|
||||||
|
//
|
||||||
|
// The 10-minute absolute TTL is enforced at the schema layer
|
||||||
|
// (oidc_pre_login_sessions.absolute_expires_at default of
|
||||||
|
// NOW() + INTERVAL '10 minutes') AND re-checked at the service
|
||||||
|
// layer at consume time.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// PreLoginRepository is the postgres implementation of
|
||||||
|
// repository.PreLoginRepository.
|
||||||
|
type PreLoginRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPreLoginRepository constructs a PreLoginRepository.
|
||||||
|
func NewPreLoginRepository(db *sql.DB) *PreLoginRepository {
|
||||||
|
return &PreLoginRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
const preLoginColumns = `id, tenant_id, signing_key_id, oidc_provider_id,
|
||||||
|
state, nonce, pkce_verifier, created_at, absolute_expires_at`
|
||||||
|
|
||||||
|
func scanPreLogin(row interface{ Scan(...interface{}) error }) (*repository.PreLoginSession, error) {
|
||||||
|
var p repository.PreLoginSession
|
||||||
|
if err := row.Scan(
|
||||||
|
&p.ID, &p.TenantID, &p.SigningKeyID, &p.OIDCProviderID,
|
||||||
|
&p.State, &p.Nonce, &p.PKCEVerifier, &p.CreatedAt, &p.AbsoluteExpiresAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create persists a pre-login row. Caller MUST have already generated
|
||||||
|
// the random id (`pl-<base64url>`), state, nonce, and PKCE verifier.
|
||||||
|
// CreatedAt + AbsoluteExpiresAt default to NOW() / NOW()+10min when
|
||||||
|
// zero (the schema's DEFAULT clauses handle this).
|
||||||
|
func (r *PreLoginRepository) Create(ctx context.Context, p *repository.PreLoginSession) error {
|
||||||
|
if p.CreatedAt.IsZero() && p.AbsoluteExpiresAt.IsZero() {
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO oidc_pre_login_sessions (
|
||||||
|
id, tenant_id, signing_key_id, oidc_provider_id,
|
||||||
|
state, nonce, pkce_verifier
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||||
|
p.ID, p.TenantID, p.SigningKeyID, p.OIDCProviderID,
|
||||||
|
p.State, p.Nonce, p.PKCEVerifier)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("oidc_pre_login create: %w", err)
|
||||||
|
}
|
||||||
|
// Read back created_at + absolute_expires_at so callers see the
|
||||||
|
// schema-default values.
|
||||||
|
row := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT created_at, absolute_expires_at FROM oidc_pre_login_sessions WHERE id = $1`, p.ID)
|
||||||
|
if err := row.Scan(&p.CreatedAt, &p.AbsoluteExpiresAt); err != nil {
|
||||||
|
return fmt.Errorf("oidc_pre_login create read-back: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO oidc_pre_login_sessions (
|
||||||
|
id, tenant_id, signing_key_id, oidc_provider_id,
|
||||||
|
state, nonce, pkce_verifier, created_at, absolute_expires_at
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||||
|
p.ID, p.TenantID, p.SigningKeyID, p.OIDCProviderID,
|
||||||
|
p.State, p.Nonce, p.PKCEVerifier, p.CreatedAt, p.AbsoluteExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("oidc_pre_login create: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupAndConsume reads the row by id and atomically deletes it
|
||||||
|
// (single-use). Returns ErrPreLoginNotFound on miss; ErrPreLoginExpired
|
||||||
|
// when the row was found but past its TTL (the row is still deleted in
|
||||||
|
// this case so the second attempt with the same cookie maps to
|
||||||
|
// not-found rather than re-running the expiry check).
|
||||||
|
//
|
||||||
|
// Implementation note: the DELETE ... RETURNING is wrapped in a
|
||||||
|
// transaction with REPEATABLE READ so the row read + delete is atomic
|
||||||
|
// against concurrent callers — the second caller racing with a
|
||||||
|
// successful first caller gets ErrPreLoginNotFound, never a duplicate
|
||||||
|
// session-mint.
|
||||||
|
func (r *PreLoginRepository) LookupAndConsume(ctx context.Context, id string) (*repository.PreLoginSession, error) {
|
||||||
|
row := r.db.QueryRowContext(ctx, `
|
||||||
|
DELETE FROM oidc_pre_login_sessions WHERE id = $1
|
||||||
|
RETURNING `+preLoginColumns,
|
||||||
|
id)
|
||||||
|
p, err := scanPreLogin(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, repository.ErrPreLoginNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("oidc_pre_login lookup_and_consume: %w", err)
|
||||||
|
}
|
||||||
|
if time.Now().UTC().After(p.AbsoluteExpiresAt) {
|
||||||
|
return nil, repository.ErrPreLoginExpired
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GarbageCollectExpired deletes rows whose absolute_expires_at is in
|
||||||
|
// the past. Returns the count deleted. Wired into the same scheduler
|
||||||
|
// sweep as expired post-login sessions.
|
||||||
|
func (r *PreLoginRepository) GarbageCollectExpired(ctx context.Context) (int, error) {
|
||||||
|
res, err := r.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM oidc_pre_login_sessions WHERE absolute_expires_at < NOW()`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("oidc_pre_login gc: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- 000037_oidc_phase5.down.sql
|
||||||
|
-- DESTRUCTIVE: drops the oidc_pre_login_sessions table (which holds
|
||||||
|
-- mid-handshake OIDC state — losing it forces in-flight logins to
|
||||||
|
-- restart) AND removes the seven new auth permissions. role_permissions
|
||||||
|
-- rows referring to the dropped permissions cascade away via the
|
||||||
|
-- ON DELETE CASCADE on permissions(id).
|
||||||
|
--
|
||||||
|
-- Idempotent (IF EXISTS / DELETE-WHERE-IN-LIST).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_oidc_pre_login_provider;
|
||||||
|
DROP INDEX IF EXISTS idx_oidc_pre_login_expires;
|
||||||
|
DROP TABLE IF EXISTS oidc_pre_login_sessions;
|
||||||
|
|
||||||
|
DELETE FROM role_permissions
|
||||||
|
WHERE permission_id IN (
|
||||||
|
'p-auth-session-list',
|
||||||
|
'p-auth-session-list-all',
|
||||||
|
'p-auth-session-revoke',
|
||||||
|
'p-auth-oidc-list',
|
||||||
|
'p-auth-oidc-create',
|
||||||
|
'p-auth-oidc-edit',
|
||||||
|
'p-auth-oidc-delete'
|
||||||
|
);
|
||||||
|
|
||||||
|
DELETE FROM permissions
|
||||||
|
WHERE id IN (
|
||||||
|
'p-auth-session-list',
|
||||||
|
'p-auth-session-list-all',
|
||||||
|
'p-auth-session-revoke',
|
||||||
|
'p-auth-oidc-list',
|
||||||
|
'p-auth-oidc-create',
|
||||||
|
'p-auth-oidc-edit',
|
||||||
|
'p-auth-oidc-delete'
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
-- 000037_oidc_phase5.up.sql
|
||||||
|
-- Auth Bundle 2 / Phase 5: HTTP handler surface.
|
||||||
|
--
|
||||||
|
-- Two things land here:
|
||||||
|
--
|
||||||
|
-- 1. oidc_pre_login_sessions table — short-lived rows holding the
|
||||||
|
-- OIDC state + nonce + PKCE verifier across the IdP redirect.
|
||||||
|
-- Distinct from the sessions table because the schema for sessions
|
||||||
|
-- doesn't carry OIDC-specific columns and bolting them on would
|
||||||
|
-- bloat every row. 10-minute absolute TTL; GC sweep deletes
|
||||||
|
-- expired rows alongside the post-login session GC sweep.
|
||||||
|
--
|
||||||
|
-- Cookie name `certctl_oidc_pending` (Path=/auth/oidc/) carries the
|
||||||
|
-- same v1.<id>.<signing_key_id>.<HMAC-SHA256> wire format as the
|
||||||
|
-- post-login cookie. The signing key is the active SessionSigningKey
|
||||||
|
-- so we don't need a separate key lifecycle for pre-login cookies.
|
||||||
|
--
|
||||||
|
-- 2. Seven new permissions extending the canonical catalogue:
|
||||||
|
-- auth.session.list — list one's own sessions
|
||||||
|
-- auth.session.list.all — list every session in the tenant (admin)
|
||||||
|
-- auth.session.revoke — revoke a session that isn't yours
|
||||||
|
-- auth.oidc.list — list OIDC providers + group mappings
|
||||||
|
-- auth.oidc.create — register a new OIDC provider
|
||||||
|
-- auth.oidc.edit — update OIDC provider config / mappings
|
||||||
|
-- auth.oidc.delete — delete OIDC provider (only when no
|
||||||
|
-- users have authenticated via it)
|
||||||
|
-- Granted to r-admin only by default. Operators who want session
|
||||||
|
-- revocation across actors granted to r-operator can add the row
|
||||||
|
-- via the role-permission API after migration.
|
||||||
|
--
|
||||||
|
-- All operations idempotent. Wrapped in a single transaction.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- oidc_pre_login_sessions table
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS oidc_pre_login_sessions (
|
||||||
|
-- id is the prefix-`pl-` opaque identifier signed into the cookie.
|
||||||
|
-- Format on the wire: v1.pl-<base64url>.sk-<base64url>.<base64url HMAC>.
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
|
||||||
|
tenant_id TEXT NOT NULL DEFAULT 't-default'
|
||||||
|
REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- The signing key id pinning which SessionSigningKey row signed
|
||||||
|
-- the cookie. Validation re-derives the HMAC against this key.
|
||||||
|
signing_key_id TEXT NOT NULL
|
||||||
|
REFERENCES session_signing_keys(id) ON DELETE RESTRICT,
|
||||||
|
|
||||||
|
-- The OIDC provider being authenticated against. References
|
||||||
|
-- oidc_providers(id) with ON DELETE CASCADE so deleting a provider
|
||||||
|
-- mid-handshake invalidates in-flight pre-login rows. (Provider
|
||||||
|
-- deletion is itself gated on no users having authenticated via
|
||||||
|
-- the provider; this is the second-line defense.)
|
||||||
|
oidc_provider_id TEXT NOT NULL
|
||||||
|
REFERENCES oidc_providers(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- OIDC state: 32 random bytes base64url-no-pad. Constant-time
|
||||||
|
-- compared at callback against the IdP-returned state param.
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- OIDC nonce: 32 random bytes base64url-no-pad. Constant-time
|
||||||
|
-- compared at callback against the ID token's nonce claim.
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- PKCE-S256 verifier: 43-128 chars base64url-no-pad. Sent to the
|
||||||
|
-- IdP token endpoint to prove possession of the original challenge.
|
||||||
|
pkce_verifier TEXT NOT NULL,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
-- Phase 5 spec: 10-minute absolute TTL. The GC sweep treats this
|
||||||
|
-- as the cutoff (rows older than 10 minutes are deleted).
|
||||||
|
absolute_expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '10 minutes'),
|
||||||
|
|
||||||
|
CONSTRAINT oidc_pre_login_expiry_after_created
|
||||||
|
CHECK (absolute_expires_at > created_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for the GC sweep — `WHERE absolute_expires_at < NOW()` hot path.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oidc_pre_login_expires
|
||||||
|
ON oidc_pre_login_sessions (absolute_expires_at);
|
||||||
|
|
||||||
|
-- Index for the lookup-by-provider hot path (admin "active pending logins"
|
||||||
|
-- surface, optional Phase 8 GUI extension).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oidc_pre_login_provider
|
||||||
|
ON oidc_pre_login_sessions (oidc_provider_id);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Seven new permissions extending the Bundle 1 catalogue.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
INSERT INTO permissions (id, name, namespace) VALUES
|
||||||
|
('p-auth-session-list', 'auth.session.list', 'auth.session'),
|
||||||
|
('p-auth-session-list-all', 'auth.session.list.all', 'auth.session'),
|
||||||
|
('p-auth-session-revoke', 'auth.session.revoke', 'auth.session'),
|
||||||
|
('p-auth-oidc-list', 'auth.oidc.list', 'auth.oidc'),
|
||||||
|
('p-auth-oidc-create', 'auth.oidc.create', 'auth.oidc'),
|
||||||
|
('p-auth-oidc-edit', 'auth.oidc.edit', 'auth.oidc'),
|
||||||
|
('p-auth-oidc-delete', 'auth.oidc.delete', 'auth.oidc')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Grant all seven to r-admin (and only r-admin by default). The
|
||||||
|
-- role-permission API can hand auth.session.revoke to r-operator
|
||||||
|
-- post-deploy if the operator wants their support staff to revoke
|
||||||
|
-- sessions; we ship locked-down by default.
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id)
|
||||||
|
SELECT 'r-admin', id, 'global', NULL
|
||||||
|
FROM permissions
|
||||||
|
WHERE id IN (
|
||||||
|
'p-auth-session-list',
|
||||||
|
'p-auth-session-list-all',
|
||||||
|
'p-auth-session-revoke',
|
||||||
|
'p-auth-oidc-list',
|
||||||
|
'p-auth-oidc-create',
|
||||||
|
'p-auth-oidc-edit',
|
||||||
|
'p-auth-oidc-delete'
|
||||||
|
)
|
||||||
|
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Every actor who has been federated-authenticated needs to list AND
|
||||||
|
-- revoke their OWN session. That gate is encoded at the handler layer
|
||||||
|
-- via "is the actor_id in the path the caller's actor_id?" rather
|
||||||
|
-- than via a permission, since granting `auth.session.list` to
|
||||||
|
-- everyone would be tantamount to making it a no-op. The handler
|
||||||
|
-- pattern: `if path.id == ctx.actor_id { allow } else { require(auth.session.revoke) }`.
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/ci-guards/N-bundle-2-security-empty-preserved.sh
|
||||||
|
#
|
||||||
|
# Auth Bundle 2 / Phase 5 Category N — preserve every existing
|
||||||
|
# `security: []` opt-out in api/openapi.yaml.
|
||||||
|
#
|
||||||
|
# Pre-Bundle-2 baseline: 14 occurrences (verified via
|
||||||
|
# `grep -c 'security: \[\]' api/openapi.yaml` at the Phase 5 starting
|
||||||
|
# state). Post-Bundle-2 must be ≥ 14. Adding new `security: []`
|
||||||
|
# entries (for new public endpoints like /auth/oidc/back-channel-logout)
|
||||||
|
# is fine; reducing the count below 14 is a regression — every
|
||||||
|
# existing public endpoint MUST stay public.
|
||||||
|
#
|
||||||
|
# Why this matters: each `security: []` opt-out is an intentional
|
||||||
|
# auth-exempt declaration (health probes, public protocol endpoints,
|
||||||
|
# OIDC handshake). Removing one would silently force a Bearer-or-
|
||||||
|
# cookie requirement onto an endpoint that legitimately runs without
|
||||||
|
# certctl-issued credentials, breaking RFC-mandated unauth surfaces
|
||||||
|
# (CRL/OCSP) or the bootstrap path.
|
||||||
|
#
|
||||||
|
# This guard runs as part of `make verify` / CI.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
OPENAPI_PATH="api/openapi.yaml"
|
||||||
|
PHASE5_BASELINE=14
|
||||||
|
|
||||||
|
if [ ! -f "$OPENAPI_PATH" ]; then
|
||||||
|
echo "::error::$OPENAPI_PATH not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
count=$(grep -c 'security: \[\]' "$OPENAPI_PATH" || true)
|
||||||
|
|
||||||
|
if [ "$count" -lt "$PHASE5_BASELINE" ]; then
|
||||||
|
echo "::error::Found $count 'security: []' entries in $OPENAPI_PATH; expected ≥ $PHASE5_BASELINE (Auth Bundle 2 Phase 5 baseline)."
|
||||||
|
echo ""
|
||||||
|
echo "Each 'security: []' is an intentional auth-exempt declaration."
|
||||||
|
echo "Removing one silently forces a Bearer-or-cookie requirement onto"
|
||||||
|
echo "an endpoint that legitimately runs without certctl-issued"
|
||||||
|
echo "credentials. Restore the missing opt-out OR — if a previously-public"
|
||||||
|
echo "endpoint genuinely should now require auth — bump PHASE5_BASELINE"
|
||||||
|
echo "in this script with a justification in the commit message."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "OK: $count 'security: []' entries in $OPENAPI_PATH (≥ $PHASE5_BASELINE baseline)."
|
||||||
Reference in New Issue
Block a user