mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21: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:
@@ -24,7 +24,10 @@ import (
|
||||
"github.com/certctl-io/certctl/internal/api/router"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"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"
|
||||
userdomain "github.com/certctl-io/certctl/internal/auth/user/domain"
|
||||
"github.com/certctl-io/certctl/internal/config"
|
||||
discoveryawssm "github.com/certctl-io/certctl/internal/connector/discovery/awssm"
|
||||
discoveryazurekv "github.com/certctl-io/certctl/internal/connector/discovery/azurekv"
|
||||
@@ -383,6 +386,58 @@ func main() {
|
||||
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.SetCertRepo(certificateRepo) // D-008: CertificateLifetime arm needs CertificateVersion.NotBefore/NotAfter
|
||||
// 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
|
||||
// docs/intermediate-ca-hierarchy.md.
|
||||
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 repos + service-layer Authorizer / RoleService /
|
||||
// ActorRoleService / PermissionService into the HTTP surface
|
||||
@@ -2471,3 +2530,42 @@ func (ad authCheckResolverAdapter) EffectivePermissions(
|
||||
) ([]repository.EffectivePermission, error) {
|
||||
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{}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user