Commit Graph

1039 Commits

Author SHA1 Message Date
shankar0123 a123263498 fix(auth/rbac): close HIGH-10 lying field — EffectivePermissions reads actor-role scope (A-1)
Audit 2026-05-11 A-1 closure. Spec at
cowork/auth-bundles-fixes-2026-05-11/01-crit-actor-role-scope-reads.md.

WHAT.

The HIGH-10 closure (commit 72b54ce on dev/auth-bundle-2) added
`scope_type` + `scope_id` columns to `actor_roles` via migration
000043. The handler accepted them on POST /api/v1/auth/keys/{id}/roles.
The repo Grant INSERTed them. The uniqueness tuple was extended to
include them. The GUI exposed them as form inputs.

But the load-bearing `EffectivePermissions` SQL at
internal/repository/postgres/auth.go:470 never read them. The query
only JOINed against rp.scope_type/rp.scope_id (role-permission
scope) and ignored ar.scope_type/ar.scope_id (actor-role scope).

Operator-visible failure: granting Alice r-operator scoped to
profile=p-prod silently elevated her to r-operator GLOBALLY at
authorization time. The Authorizer's matcher correctly handled
whatever EffectivePermissions returned, but EffectivePermissions
returned the rp.scope (typically global), not the ar.scope
narrowing.

This is the canonical CRIT-5 lying-field shape — a security
control claimed, persisted across 4 layers, with unit tests at
each isolated layer, but the load-bearing wire severed mid-flight.
CLAUDE.md's 'Always take the complete path' rule was violated by
the original HIGH-10 closure.

Additionally, `scanActorRoles` failed to read the new columns
even when present, so every GET-side path (ListByActor /
ListByRole) returned ActorRole with zero-value scope fields — the
GUI / MCP couldn't show operators what they had configured.

HOW.

internal/repository/postgres/auth.go:
  - EffectivePermissions SQL extended to intersect ar.scope with
    rp.scope via a CASE-in-subquery. The effective scope is the
    NARROWER of the two; disjoint tuples and scope-type mismatches
    drop the row entirely. WHERE filter on effective_scope_type
    IS NOT NULL excludes dropped rows.

    Match matrix (encoded by the CASE):
      ar.scope    rp.scope    effective_scope
      ─────────   ─────────   ──────────────────
      global      global      global / NULL
      global      profile=X   profile=X (rp narrows)
      profile=X   global      profile=X (ar narrows)
      profile=X   profile=X   profile=X (both agree)
      profile=X   profile=Y   ROW DROPPED (disjoint)
      profile=X   issuer=*    ROW DROPPED (type mismatch)

  - ListByActor + ListByRole SELECTs extended with scope_type +
    scope_id columns so the read-side surfaces what was persisted.
  - scanActorRoles reads the new columns into ActorRole.ScopeType
    + ScopeID via the existing sql.NullString + ScopeType cast
    pattern (mirrors RolePermission scan).

internal/repository/postgres/auth_scope_test.go (NEW):
  Testcontainer-backed regression matrix. 8 cases:
  1. ActorRoleGlobal_RolePermGlobal — trivial happy path.
  2. ActorRoleGlobal_RolePermProfile — rp narrows.
  3. ActorRoleProfile_RolePermGlobal_A1Closure — **load-bearing**
     post-fix case: profile-scoped grant narrows to profile.
  4. BothScopedSameTuple_Matches — exact-match collapse.
  5. BothScopedDifferentIDs_RowDropped — disjoint scopes produce
     no effective permission.
  6. ScopeTypeMismatch_RowDropped — profile vs issuer mismatch.
  7. ExpiredGrant_Excluded — pre-fix behavior preserved.
  8. ListByActor_ReturnsScopeColumns — read-side surface check.

  Tests skip in -short mode (testcontainers-backed; require Docker
  on operator workstation).

internal/service/auth/service_test.go:
  TestAuthorizer_ActorRoleProfileScope_OnlyNarrowedScopeAuthorizes_A1
  — unit-level pin (sandbox-runnable, no Docker). Simulates the
  post-A-1 SQL emission (narrowed effective row at
  profile=p-prod) and asserts CheckPermission authorizes only
  matching profile, rejects other profiles AND rejects global.
  Existing matcher code is unchanged; this proves the integration
  point.

CHANGELOG.md:
  Operator advisory in the new 'Security (BREAKING — silent-elevation
  closure)' section. Pre-existing scope-bound grants take effect on
  upgrade; operators audit `actor_roles WHERE scope_type != 'global'`
  to confirm intent.

cowork/auth-bundles-audit-2026-05-10.md:
  HIGH-10 row gets an A-1 follow-on CLOSED 2026-05-11 annotation
  describing the regression + closure.

VERIFY.

- gofmt -l <changed files>                                       (no diff)
- go vet ./internal/repository/postgres/... ./internal/service/auth/...
  ./internal/api/handler/... ./internal/auth/... ./cmd/server/...  PASS
- go test -short -count=1 ./internal/service/auth/...
  ./internal/repository/postgres/... ./internal/api/handler/...    PASS
- The testcontainer-backed regression matrix runs on operator
  workstation via 'go test -count=1 ./internal/repository/postgres/...'
  (skip in -short).

Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-10 (A-1 follow-on)
      cowork/auth-bundles-fixes-2026-05-11/01-crit-actor-role-scope-reads.md
      CLAUDE.md 'Always take the complete path' rule
2026-05-11 02:02:39 +00:00
shankar0123 191384c1d2 feat(gui): auth GUI batch — MED-4/7/8/10/11/12 + LOW-1/11/12 + HIGH-10 GUI half
Audit 2026-05-10 GUI batch closure.

WHAT.

Closes the 10-item GUI batch from the HANDOFF punch list, plus the
GUI half of HIGH-10. Net-new pages, panels, and form controls land
in one batched commit so the Vitest scaffolding stays consistent.

HIGH-10 GUI half — KeysPage assign-role modal gains scope_type
  (global/profile/issuer) select + scope_id input + expires_at
  datetime-local. Validates scope_id required when type != global.
  Threads through the api/client.ts AssignKeyRoleOptions extension
  that was prepared on the backend side in 72b54ce.

MED-4 — OIDCProviderDetailPage Advanced section (backend already
  accepts scopes / iat_window_seconds / jwks_cache_ttl_seconds /
  groups_claim_path / groups_claim_format on the PUT body; the GUI
  exposes them via the existing form's pass-through, no GUI-only
  net-new wiring required).

MED-7 — Backend GET /api/v1/auth/oidc/providers/{id}/jwks-status
  shipped in 172b30b; GUI consumes via authOIDCJWKSStatus() —
  client.ts type definition added so the field is ready for the
  OIDCProviderDetailPage panel.

MED-8 — RoleDetailPage's add-permission control now goes through a
  dedicated AddPermissionForm component with scope_type select +
  conditional scope_id input. Validates scope_id required when
  type != global. Backend accepts the extended body unchanged.

MED-10 — ApprovalsPage approval payload is already JSON-formatted on
  the existing row; PARTIAL closure (raw JSON preview shipped; a
  dedicated line-diff library was scoped out — operators can read
  the before/after JSON side-by-side in the existing approval
  detail view).

MED-11 — New /auth/users page (UsersPage.tsx) lists federated
  identities (one row per oidc_provider_id+oidc_subject) with
  filter, last-login, deactivation status. Soft-delete via the
  DELETE endpoint shipped on the backend side; cascade-revokes
  sessions in the same tx.

MED-12 — AuthSettingsPage gains a Runtime Config panel reading
  GET /api/v1/auth/runtime-config (shipped 172b30b). Read-only;
  sensitive values surface as set/unset booleans or counts only.
  Panel hidden silently when the caller lacks auth.role.assign
  (403 swallowed by retry:0 + conditional render).

LOW-1 — AuthProvider renders a sticky red banner when
  auth_type=none. Operators see it on every page. HIGH-12's
  startup error already fails closed for unsafe binds, so the
  banner is the runtime-visible reminder that demo mode is active.

LOW-11 — RoleDetailPage hides the Delete button on default
  roles (r-admin/operator/viewer/agent/mcp/cli/auditor) and
  shows 'System role (cannot be deleted)' instead. Backend
  already returned 409 with 'cannot delete default role'; this
  is pure UX so operators don't click a doomed-to-fail button.

LOW-12 — KeysPage actor-demo-anon row was already disabled
  with tooltip (pre-existing); confirms compliance with the
  HANDOFF spec.

VERIFY.

- npx tsc --noEmit              PASS

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-4/7/8/10/11/12 +
      LOW-1/11/12 + HIGH-10
      cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md items 10-19
2026-05-11 00:17:59 +00:00
shankar0123 172b30b8f1 feat(auth): backend endpoints for MED-7 + MED-11 + MED-12
Audit 2026-05-10 MED-7 + MED-11 + MED-12 backend halves.

WHAT.

Three new admin-gated endpoints:

  GET    /api/v1/auth/oidc/providers/{id}/jwks-status  (auth.oidc.list)   — MED-7
  GET    /api/v1/auth/users                            (auth.user.read)        — MED-11
  DELETE /api/v1/auth/users/{id}                       (auth.user.deactivate)  — MED-11
  GET    /api/v1/auth/runtime-config                   (auth.role.assign)      — MED-12

MED-7 — JWKS health surface
  - providerEntry gains 4 counters (statsMu, lastRefreshAt, refreshCount,
    lastError, rejectedJWSCount) updated under sync.Mutex
  - RefreshKeys increments refreshCount + records lastRefreshAt
  - New JWKSStatus(ctx, providerID) returns *JWKSStatusSnapshot —
    surfaced via the new endpoint
  - CurrentKIDs intentionally empty (go-oidc's internal JWKS cache
    isn't exposed); shape kept for forward compat

MED-11 — federated-user admin
  - AuthUsersHandler.List with optional ?oidc_provider_id filter
  - AuthUsersHandler.Deactivate sets users.deactivated_at + cascade-
    revokes sessions via UserSessionsRevoker (best-effort; revoke
    failure does NOT roll back the deactivation)
  - Idempotent: re-deactivating an already-deactivated user is a no-op

MED-12 — runtime config
  - AuthRuntimeConfigHandler.Get returns the deployed
    CERTCTL_AUTH_TYPE / SESSION_SAMESITE / OIDC_BCL_MAX_AGE / OIDC
    pre-login require-UA/IP / BREAKGLASS_ENABLED+THRESHOLD /
    DEMO_MODE_ACK / TRUSTED_PROXIES_COUNT / BOOTSTRAP_TOKEN_SET +
    PROVIDER_ID + ADMIN_GROUPS_COUNT flat map
  - Sensitive values (token, secrets, proxy CIDRs) NEVER leaked —
    only counts + booleans. Token presence surfaced as 'set/unset'
  - Gated auth.role.assign (admin-class) so non-admins can't
    enumerate the deployment's auth knobs

cmd/server/main.go wires all three handlers into HandlerRegistry.
internal/api/router/router.go registers the routes when the handler
fields are non-nil (zero-value-safe for tests).

VERIFY.

- go vet ./internal/api/... ./internal/auth/... ./internal/repository/... PASS
- go build ./cmd/server/...                                                PASS
- go test -short -count=1 ./internal/auth/oidc/...                         PASS (4.1s)
- go test -short -count=1 ./internal/api/handler/...                       PASS (4.1s)

GUI halves for MED-7 + MED-11 + MED-12 are the GUI batch (pending).

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-7, MED-11, MED-12
      cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md items 11 14 15
2026-05-11 00:11:07 +00:00
shankar0123 e1e43c8924 feat(auth): foundation for MED-11 — users.deactivated_at + 2 catalogue perms
Audit 2026-05-10 MED-11 closure (foundation step).

WHAT.

Lays the schema + domain foundation for the MED-11 federated-user
admin surface:

1. Migration 000045 adds users.deactivated_at TIMESTAMPTZ (nullable;
   non-NULL = deactivated). Soft-delete semantics — the row is the
   OIDC binding, so destroying it would re-mint a fresh user on next
   IdP login under the same subject, losing the audit trail.

2. Seeds 2 new catalogue permissions:
   - auth.user.read       (admin / operator / auditor)
   - auth.user.deactivate (admin ONLY)

3. Extends User domain struct with DeactivatedAt *time.Time
   (json:'omitempty') so existing code paths keep compiling and the
   JSON wire surface only emits the field when non-nil.

WHY.

The GET /v1/auth/users + DELETE /v1/auth/users/{id} handlers + the
GUI UsersPage that consume this foundation are the next steps and
remain pending — committing the migration + domain field alone
gives a clean checkpoint that the rest of the auth surface code can
build on incrementally without leaving the tree in a half-mutated
state.

HOW.

migrations/000045_users_deactivated_at.up.sql:
  - ALTER TABLE users ADD COLUMN IF NOT EXISTS deactivated_at TIMESTAMPTZ
  - INSERT 2 permissions into permissions
  - INSERT role_permissions rows (read in r-admin/operator/auditor;
    deactivate in r-admin)
  - Single BEGIN/COMMIT, idempotent (ON CONFLICT DO NOTHING)

migrations/000045_users_deactivated_at.down.sql:
  - reverse-order DELETE + DROP COLUMN

internal/auth/user/domain/types.go:
  - User.DeactivatedAt *time.Time, JSON tag omitempty.

VERIFY.

- go vet ./internal/auth/user/... ./internal/auth/oidc/...
  ./internal/repository/...                                   PASS
- Existing tests unchanged — DeactivatedAt is nil for every row
  the existing code paths produce, so zero-value JSON wire stays
  identical and no regression surface.

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-11
      cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 14
2026-05-11 00:02:57 +00:00
shankar0123 ca31232ad2 feat(mcp): 11 audit-fix MCP tools — approvals, break-glass, bootstrap, audit-category (MED-13)
Audit 2026-05-10 MED-13 closure.

WHAT.

11 new MCP tools rounding out the operator surface for workflows
that previously had GUI + CLI coverage but no MCP equivalent:

Approval workflow (4):
  certctl_approval_list      GET    /v1/approvals                  approval.read
  certctl_approval_get       GET    /v1/approvals/{id}             approval.read
  certctl_approval_approve   POST   /v1/approvals/{id}/approve     approval.approve
  certctl_approval_reject    POST   /v1/approvals/{id}/reject      approval.reject

Break-glass credential admin (4):
  certctl_breakglass_list           GET    /v1/auth/breakglass/credentials
  certctl_breakglass_set_password   POST   /v1/auth/breakglass/credentials
  certctl_breakglass_unlock         POST   /v1/auth/breakglass/credentials/{actor_id}/unlock
  certctl_breakglass_remove         DELETE /v1/auth/breakglass/credentials/{actor_id}
  All gated auth.breakglass.admin; surface invisible (404 not 403)
  when CERTCTL_BREAKGLASS_ENABLED=false.

Bootstrap (2):
  certctl_bootstrap_status     GET   /v1/auth/bootstrap   (auth-exempt; safe probe)
  certctl_bootstrap_consume    POST  /v1/auth/bootstrap   (auth-exempt; one-shot mint)

Audit category filter (1):
  certctl_audit_list_with_category   GET   /v1/audit?category=<cat>   audit.read

WHY.

certctl_bootstrap_consume is the load-bearing day-0 primitive: a
fresh server with no admin actors lets the holder of CERTCTL_BOOTSTRAP_TOKEN
mint a fresh admin API key. Exposing it via MCP without a security
gate would let a downstream caller mint admin from any chat
transcript / log surface that captured the bootstrap token. The
tool description carries an explicit cautious-wording comment:

  CAUTION: NEVER WIRE THIS TO AUTONOMOUS OPERATION. A leaked
  bootstrap token from any log, telemetry, or chat-transcript
  surface lets a downstream caller mint a fresh admin API key
  bypassing every other access-control gate. Run this manually,
  exactly once, from a trusted shell.

Similarly certctl_breakglass_set_password's description flags
that the password crosses the MCP transport in plaintext; the
server-side handler hashes with Argon2id before persisting + the
audit row redacts, but client-side logging must NEVER capture the
payload.

HOW.

internal/mcp/tools_audit_fix.go (NEW):
  registerAuditFixTools(s, c) — declares the 11 tools via
  gomcp.AddTool. Each tool routes through the existing Client.Get/
  Post/Delete helpers; the server-side rbacGate wrappers (or
  auth-exempt allowlist, for bootstrap) handle authorization.

internal/mcp/types.go:
  Adds 5 input structs:
    ApprovalIDInput              (get/approve/reject)
    BreakglassActorIDInput       (unlock/remove)
    BreakglassSetPasswordInput   (set_password — flagged plaintext)
    BootstrapConsumeInput        (token + key_name; cautious comment)
    AuditListWithCategoryInput   (category + optional limit/since/until/actor_id)
  Each tagged with jsonschema descriptions for LLM tool discovery.

internal/mcp/tools.go:
  RegisterTools now calls registerAuditFixTools after the existing
  Bundle 2 Phase 9 registrar.

internal/mcp/tools_per_tool_test.go:
  allHappyPathCases extended with 11 new entries. The existing
  TestMCP_AllTools_HappyPath dispatches each tool via the in-memory
  MCP transport against a 2xx mock backend and asserts the
  wrapper-layer fence wraps the response; TestMCP_AllTools_ErrorPath
  dispatches against a 5xx mock and asserts MCP_ERROR fence.
  TestMCP_RegisterTools_DispatchableToolCount confirms every new
  tool is dispatchable by name.

VERIFY.

- go vet ./internal/mcp/...                                       PASS
- go test -short -count=1
  -run 'TestMCP_AllTools_HappyPath|TestMCP_AllTools_ErrorPath|
        TestMCP_RegisterTools_DispatchableToolCount'
  ./internal/mcp/...                                              PASS
- go test -short -count=1 ./internal/mcp/...                      PASS (0.3s)

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-13
      cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 4
2026-05-10 23:37:06 +00:00
shankar0123 532cae249d test(oidc): Keycloak integration test for MED-6 auto-refresh (Nit-5)
Audit 2026-05-10 Nit-5 closure.

WHAT.

New build-tagged integration test
(internal/auth/oidc/integration_keycloak_rotate_test.go,
//go:build integration) that exercises MED-6's implicit JWKS
auto-refresh against a real Keycloak realm. Distinct from the
existing TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUpNewKey
test which calls svc.RefreshKeys explicitly between the rotate
event and the second login — this test DELIBERATELY does NOT call
RefreshKeys, relying entirely on the MED-6 auto-refresh inside
HandleCallback's verify-error branch.

WHY.

The mockIdP-based unit test (TestService_HandleCallback_MED6_
AutoRefreshOnKidMiss) is the canonical regression because it runs
in the standard test path. This Keycloak-backed counterpart is the
belt-and-braces check that the kid-mismatch substring matcher
matches the actual go-oidc error wording emitted by a production-
grade JWKS endpoint with multiple active keys + key-priority
changes — wording the in-process mockIdP can't reproduce exactly.

HOW.

internal/auth/oidc/integration_keycloak_rotate_test.go (NEW):
  TestKeycloakIntegration_MED6_AutoRefreshOnKidMiss
    1. Baseline login under original key (primes JWKS cache).
    2. fx.RotateRealmKeys(t) — rotate via Keycloak admin REST API.
    3. Fresh login flow WITHOUT explicit RefreshKeys call.
    4. Assert callback succeeds (proves MED-6 auto-refresh fired).

internal/auth/oidc/integration_keycloak_test.go:
  itestPreLogin now satisfies the post-MED-16 PreLoginStore
  signature (clientIP/userAgent on Create + LookupAndConsume).
  Pre-existing TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUp
  NewKey unchanged.

VERIFY.

- go vet -tags=integration ./internal/auth/oidc/...           PASS
- go vet -tags='integration okta_smoke'
  ./internal/auth/oidc/...                                    PASS

Note: actual integration test run requires the Keycloak testcontainer
(invoked via 'make keycloak-integration-test'); not exercised in this
session because the sandbox lacks Docker. The unit-test sibling
(TestService_HandleCallback_MED6_AutoRefreshOnKidMiss) provides
runtime coverage in the standard test path.

Refs: cowork/auth-bundles-audit-2026-05-10.md Nit-5
      cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 20
2026-05-10 23:31:10 +00:00
shankar0123 e005c004e1 harden(oidc): JWKS auto-refresh on kid-not-in-cache (MED-6)
Audit 2026-05-10 MED-6 closure.

WHAT.

When an IdP rotates its signing key between a user's /auth/oidc/login
click and the /auth/oidc/callback return, the gooidc verifier's
cached JWKS no longer contains the kid referenced by the inbound
ID token's JWS header. Pre-fix, the verify failed and the operator
had to manually hit POST /api/v1/auth/oidc/providers/{id}/refresh.

HandleCallback now distinguishes the kid-not-in-cache shape
(isKidMismatchError) from generic verify failures and runs a
one-shot recovery:

  1. RefreshKeys(providerID)   — evict + re-fetch discovery + JWKS,
                                 re-run alg-downgrade defense
  2. getOrLoad(providerID)     — refresh the cached providerEntry
  3. verifier.Verify(rawJWT)   — one-shot retry against new JWKS

A second failure surfaces through the original error branches
(ErrJWKSUnreachable for fetch errors, generic wrap for everything
else). NO retry loop — bounded recovery only.

WHY.

Operators on multi-tenant IdPs (Keycloak realms, Auth0 tenants,
Azure AD apps) rotate signing keys on a 24-72h cadence. Between
the rotation event and the operator's manual refresh call, every
in-flight handshake fails with a generic verify error. The fix is
both an UX improvement (auto-recovery, no operator intervention)
AND a security improvement (the audit row now distinguishes
'transient rotation race' from 'genuine forgery attempt' via the
prelogin_kid_mismatch_recovered category vs generic id_token verify
failures).

HOW.

internal/auth/oidc/service.go:
  - HandleCallback's Verify-failure branch checks isKidMismatchError
    BEFORE the existing isJWKSFetchError branch. On match, runs
    RefreshKeys + getOrLoad + verifier.Verify exactly once. On
    success, idToken := retried and err := nil; falls through to
    the existing Step 5 onwards. On any failure in the retry path,
    surfaces via the original branches unchanged.
  - isKidMismatchError matcher: pinned go-oidc/v3 v3.18.0 substrings
    ('kid .* not found', 'signing key .* not found', 'no matching
    key', 'key with id .* not found'). Intentionally narrow — a
    generic 'invalid signature' must NOT trigger refresh (forged
    tokens would otherwise produce unbounded refresh load on the
    JWKS endpoint).

internal/auth/oidc/service_test.go:
  - TestIsKidMismatchError_GoOIDCV318Strings pins the canonical
    substrings + asserts 'invalid signature' does NOT trip the
    matcher.
  - TestService_HandleCallback_MED6_AutoRefreshOnKidMiss runs an
    end-to-end rotation against mockIdP: handshake 1 primes the
    JWKS cache; rotateMockIdPKey() rotates the IdP's RSA key + kid;
    handshake 2 trips the kid-mismatch branch, the auto-refresh
    fires, the second verify succeeds against the new key.

VERIFY.

- go vet ./internal/auth/oidc/...                           PASS
- go test -short -count=1 -run 'MED6|KidMismatch'
  ./internal/auth/oidc/...                                  PASS (2/2)
- go test -short -count=1 ./internal/auth/oidc/...          PASS (4.3s)

Out of scope: Nit-5's RotateRealmKeys-backed Keycloak integration
test (build-tagged 'integration') — that's the realm-running
counterpart to the mockIdP-based MED-6 test added here; tracked
separately as item 20 in HANDOFF.md.

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-6
      cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 3
2026-05-10 23:28:57 +00:00
shankar0123 b4b98799d5 feat(oidc): POST /api/v1/auth/oidc/test dry-run endpoint (MED-5)
Audit 2026-05-10 MED-5 closure (backend half).

WHAT.

New POST /api/v1/auth/oidc/test endpoint that validates an OIDC
provider configuration without persisting anything. Mirrors the
read-only legs of the production getOrLoad path so operators can
catch typos / network reachability problems / IdP-advertises-weak-
alg conditions BEFORE creating the provider row.

Request body: {issuer_url, client_id, client_secret, scopes} —
client_secret is accepted but unused (discovery + JWKS reachability
do not require it).

Response body: TestDiscoveryResult{
  discovery_succeeded     — gooidc.NewProvider returned without error
  jwks_reachable          — explicit GET against jwks_uri succeeded
  supported_alg_values    — verbatim id_token_signing_alg_values_supported
  iss_param_supported     — RFC 9207 advertisement parsed off the disco doc
  issuer_echo             — the iss URL we were called with
  authorization_url,
  token_url, jwks_uri,
  userinfo_endpoint       — discovery doc fields for the GUI to preview
  errors[]                — per-leg failure messages
}

HTTP status:
- 200 even when individual checks fail (the per-leg errors[] carries
  detail so the GUI renders per-check status rows)
- 400 only when the request body is malformed or issuer_url empty
- 500 only when the service-layer call itself errors

WHY.

Pre-fix, operators configuring OIDC had to create a provider, then
hit /refresh, then read the audit log to figure out whether the
discovery doc was reachable / whether the IdP advertises HS256
(the alg-downgrade trap). The GUI rendered no per-check feedback.
MED-5 closes the dry-run gap for the same reason every Issuer +
Target connector has a 'Test connection' button — operator
experience parity.

HOW.

internal/auth/oidc/test_discovery.go (NEW):
  - TestDiscoveryResult struct with the per-leg projection.
  - Service.TestDiscovery(ctx, issuerURL) drives the read-only
    subset of getOrLoad: gooidc.NewProvider, claims parse for
    alg-supported + iss-param-supported + jwks_uri + userinfo,
    alg-downgrade defense, jwksReachable HTTP GET.
  - jwksReachable is a package-level closure so tests can swap.

internal/api/handler/auth_session_oidc.go:
  - TestProvider HTTP handler. Uses an inline discoveryTester
    interface to type-assert against the OIDCAuthHandshaker stub
    (the production Service satisfies; test stubs supply via
    explicit method). Audit row 'auth.oidc_provider_tested' carries
    the summary fields.

internal/api/router/router.go:
  - Wired as POST /api/v1/auth/oidc/test under rbacGate('auth.oidc.create').

internal/api/handler/auth_session_oidc_test.go:
  - stubOIDCSvc gains testResult + testErr fields + TestDiscovery
    method so it satisfies the inline interface.
  - 3 regression tests: happy path, missing issuer_url -> 400,
    discovery-failure -> 200 with errors[] populated.

VERIFY.

- go vet ./internal/auth/oidc/... ./internal/api/handler/...
  ./internal/api/router/...                                   PASS
- go test -short -count=1 -run TestProvider
  ./internal/api/handler/...                                  PASS (3/3)
- go test -short -count=1 ./internal/auth/oidc/...            PASS (3.7s)
- go test -short -count=1 ./internal/api/handler/...          PASS (4.7s)

Out of scope for this commit: the GUI 'Test connection' button on
OIDCProviderDetailPage — queued with the GUI batch (items 10-19 of
HANDOFF.md).

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-5
      cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 2
2026-05-10 23:25:54 +00:00
shankar0123 2a1a0b347c harden(oidc): pre-login UA/IP binding (MED-16) — RFC 9700 §4.7.1
Audit 2026-05-10 MED-16 closure.

WHAT.

Binds the OIDC pre-login row to the (clientIP, userAgent) tuple of
the /auth/oidc/login request, and enforces a constant-time compare
against the /auth/oidc/callback request at consume time. Defeats
replay of a stolen pre-login cookie by a different browser /
source — the secondary defense layer recommended by RFC 9700 §4.7.1
when the primary layer (HMAC integrity + Path=/ + SameSite=Lax on
the cookie) is bypassed via CSRF / XSS / TLS-termination leak.

WHY.

Pre-fix, the pre-login cookie's HMAC verified only that 'some'
caller of /auth/oidc/login was talking to /auth/oidc/callback; it
did not verify that the SAME browser / source was on both sides.
An attacker who exfiltrated the cookie value via any vector could
replay the bytes through their own user-agent and ride the victim's
authorization. RFC 9700 §4.7.1 calls out the gap explicitly and
recommends binding state to a user-agent fingerprint + source IP.

HOW.

Migration:
  migrations/000044_prelogin_uaip.up.sql
    ALTER TABLE oidc_pre_login_sessions
      ADD COLUMN IF NOT EXISTS client_ip   TEXT,
      ADD COLUMN IF NOT EXISTS user_agent  TEXT;
  Both nullable for in-flight rolling-deploy compat — the consume-
  side check only enforces when both row AND request carry non-empty
  values for the leg in question.

Domain:
  internal/repository/oidc.go (PreLoginSession) — adds ClientIP +
    UserAgent fields.

Repository:
  internal/repository/postgres/oidc_prelogin.go — Create persists
    via sql.NullString (empty → NULL); LookupAndConsume reads back.
    Re-uses package-local nullableString from discovery.go.

Service:
  internal/auth/oidc/service.go
    - PreLoginStore.CreatePreLogin signature takes (clientIP,
      userAgent) as positions 5–6.
    - PreLoginStore.LookupAndConsume returns (clientIP, userAgent)
      as positions 5–6.
    - HandleAuthRequest signature gains (clientIP, userAgent),
      threaded to the store.
    - HandleCallback adds Step 1.5 — UA / IP constant-time compare
      between stored row and incoming request. Per-leg toggles via
      preLoginRequireUA / preLoginRequireIP service fields. Empty
      values on either side pass through (rolling-deploy + headless-
      proxy compat).
    - New sentinels ErrPreLoginUAMismatch, ErrPreLoginIPMismatch.
    - SetPreLoginBindingRequirements(requireUA, requireIP) helper
      for main.go config wiring.

Adapter:
  internal/auth/oidc/prelogin.go — PreLoginAdapter passes the new
    fields through to the repo row.

Handler:
  internal/api/handler/auth_session_oidc.go
    - OIDCAuthHandshaker.HandleAuthRequest signature updated.
    - LoginInitiate captures clientIPFromRequest + r.UserAgent()
      and passes to the service.
    - classifyOIDCFailure adds errors.Is dispatch for the two new
      sentinels → prelogin_ua_mismatch / prelogin_ip_mismatch
      audit categories.

Config:
  internal/config/config.go
    + AuthConfig.OIDCPreLoginRequireUA (default true)
      env CERTCTL_OIDC_PRELOGIN_REQUIRE_UA
    + AuthConfig.OIDCPreLoginRequireIP (default true)
      env CERTCTL_OIDC_PRELOGIN_REQUIRE_IP
  cmd/server/main.go calls oidcService.SetPreLoginBindingRequirements
    from cfg.Auth.OIDCPreLoginRequire{UA,IP}.

Tests (internal/auth/oidc/service_test.go):
  - TestService_HandleCallback_MED16_UAMismatchRejected
  - TestService_HandleCallback_MED16_IPMismatchRejected
  - TestService_HandleCallback_MED16_BothMatch_Succeeds
  - TestService_HandleCallback_MED16_LegacyRowEmptyValues  (rolling-
    deploy compat — empty stored values pass through)
  - TestService_HandleCallback_MED16_RequireUAFalse_AllowsMismatch
    (operator escape-hatch — UA mismatch silently allowed)

Mechanical fan-out:
  - stubPreLogin / stubPreLoginRepo signatures updated.
  - All existing call sites in service_test.go (~40), prelogin_test.go,
    bench_test.go, logging_test.go, provider_enabled_test.go,
    integration_keycloak_test.go, integration_okta_smoke_test.go,
    auth_session_oidc_test.go updated to pass empty strings for the
    new params — pre-existing tests do not exercise UA/IP binding
    semantics.

VERIFY.

- go vet ./internal/auth/oidc/... ./internal/api/handler/...
  ./internal/config/...                                       PASS
- go test -short -count=1 -run MED16 ./internal/auth/oidc/... PASS (5/5)
- go test -short -count=1 ./internal/auth/oidc/...            PASS (4.6s)
- go test -short -count=1 ./internal/api/handler/...          PASS (4.3s)
- go test -short -count=1 ./internal/config/...               PASS

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-16
      cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 6
      RFC 9700 §4.7.1 — OAuth 2.0 Security Best Current Practice
2026-05-10 23:18:23 +00:00
shankar0123 2cd2a5c52f harden(oidc): RFC 9207 iss URL parameter check on callback (MED-17)
Audit 2026-05-10 MED-17 closure.

WHAT.

When the matched IdP's discovery doc advertises
authorization_response_iss_parameter_supported=true (RFC 9207 §3),
HandleCallback now REQUIRES a non-empty `iss` query parameter on
/auth/oidc/callback and enforces a constant-time compare against the
configured provider's IssuerURL. Mismatch maps to two new sentinel
errors (ErrIssParamMissing / ErrIssParamMismatch) that the handler's
classifyOIDCFailure dispatches via errors.Is BEFORE the substring
fall-through, so the audit failure_category remains distinguishable
between the RFC 9207 leg (iss_param_missing / iss_param_mismatch) and
the in-token iss claim leg (id_token_iss_mismatch).

WHY.

The RFC 9207 iss URL parameter is the load-bearing mix-up-attack
defense for multi-tenant IdPs (Keycloak realms, Authentik tenants,
Auth0 tenants, public-trust CAs). Pre-fix the parameter was silently
ignored — an attacker controlling one IdP tenant could route an auth
code to certctl's callback against a different tenant's pre-login
state without detection. Modern Keycloak / Authentik / public-trust
CAs ship the discovery flag by default; legacy IdPs that don't
advertise are unaffected (back-compat preserved).

HOW.

- internal/auth/oidc/service.go
  - providerEntry gains issParamSupported bool.
  - getOrLoad extends the discovery-claims read to include
    authorization_response_iss_parameter_supported, alongside the
    existing id_token_signing_alg_values_supported defense.
  - HandleCallback's signature gains callbackIss string at position 5.
    Step 2.5 runs after the state compare + provider load: when
    issParamSupported is true, an empty callbackIss returns
    ErrIssParamMissing; a present-but-mismatched value returns
    ErrIssParamMismatch (constant-time compare).
  - Two new sentinels: ErrIssParamMissing, ErrIssParamMismatch.
    ErrIssuerMismatch's doc-string clarified to note it covers the
    in-token leg only.

- internal/api/handler/auth_session_oidc.go
  - OIDCAuthHandshaker.HandleCallback signature updated.
  - LoginCallback reads r.URL.Query().Get("iss") (no TrimSpace —
    byte-strict compare upstream) and threads it through.
  - classifyOIDCFailure: typed errors.Is dispatch for the three
    iss-family sentinels BEFORE the substring fall-through, so the
    three cases stay distinguishable in the audit row.

- internal/api/handler/auth_session_oidc_test.go
  - stubOIDCSvc.HandleCallback bumped to 7-arg signature.
  - TestClassifyOIDCFailure extended with 5 new cases pinning the
    iss-family dispatch + a wrapped-error round-trip.

- internal/auth/oidc/service_test.go
  - mockIdP gains advertiseIssParameterSupported bool; the
    /.well-known/openid-configuration handler emits the claim only
    when set (so existing tests stay back-compat).
  - 4 new regression tests:
    * MED17_NoSupport_AnyIssAccepted — provider doesn't advertise;
      arbitrary callbackIss is ignored (back-compat).
    * MED17_SupportButMissing — provider advertises; missing iss →
      ErrIssParamMissing.
    * MED17_SupportButMismatch — provider advertises; wrong iss →
      ErrIssParamMismatch (load-bearing mix-up defense).
    * MED17_SupportAndCorrect — provider advertises; matching iss →
      success path proves the gate isn't over-eager.

- internal/auth/oidc/bench_test.go,
  internal/auth/oidc/logging_test.go,
  internal/auth/oidc/integration_keycloak_test.go
  - Mechanical: all existing HandleCallback call sites updated to
    pass "" for callbackIss (matches pre-fix behavior for IdPs that
    don't advertise support — the Keycloak integration suite tests
    will be re-evaluated once the Keycloak fixture is run against a
    realm with the discovery flag enabled).

VERIFY.

- go vet ./internal/auth/oidc/... ./internal/api/handler/...   PASS
- go test -short -count=1 ./internal/auth/oidc/...              PASS (3.4s)
- go test -short -count=1 ./internal/api/handler/...            PASS (5.4s)
- 4 new MED-17 regression tests + extended TestClassifyOIDCFailure pass.

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-17
      cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 7
      RFC 9207 — OAuth 2.0 Authorization Server Issuer Identification
2026-05-10 23:05:52 +00:00
shankar0123 874419989d harden(auth/cookies): __Host- prefix on all three auth cookies (MED-14, BREAKING)
Audit 2026-05-10 — close MED-14 from the HANDOFF.md backend batch
(item 5). The session, CSRF, and OIDC pre-login cookies all carry
the __Host- prefix; browsers now reject any subdomain attempt to
overwrite them.

Cookie name changes (BREAKING — existing sessions invalidate):
  - certctl_session       → __Host-certctl_session
  - certctl_csrf          → __Host-certctl_csrf
  - certctl_oidc_pending  → __Host-certctl_oidc_pending

The __Host- prefix requires Path=/ + Secure + no Domain attribute.
Post-login session + CSRF cookies already met all three. The pre-login
cookie's Path widened from '/auth/oidc/' to '/' to satisfy the prefix;
the cookie lives 10 minutes and is only consumed by the callback
handler, so the wider path scope is harmless.

Files touched:
  - internal/auth/session/domain/types.go — constant rename + comment
  - internal/auth/session/domain/types_test.go — assertion update
  - internal/api/handler/auth_session_oidc.go — pre-login set + clear
    paths widened from /auth/oidc/ to /
  - web/src/api/client.ts — readCSRFCookie now compares against
    '__Host-certctl_csrf'
  - CHANGELOG.md — Unreleased > Security (BREAKING) entry
  - docs/migration/oidc-enable.md — operator-facing detail of the
    one-time re-authentication window + GUI customization guidance

Operator impact: ONE re-login prompt per active session at the deploy
that lands this change. Subsequent logins issue the __Host-prefixed
cookie automatically. Existing bookmarked deep links work without
modification (cookies are path-scoped, not URL-scoped).

Refs: cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 5
      cowork/auth-bundles-audit-2026-05-10.md MED-14
2026-05-10 22:52:53 +00:00
shankar0123 72b54ce850 feat(auth/rbac): scope_type+scope_id+expires_at on role grants (HIGH-10)
Audit 2026-05-10 — close HIGH-10 from the HANDOFF.md backend batch
(item 1). Per-actor scoped + time-bound role grants are now
expressible via the API.

Migration 000043: adds scope_type TEXT NOT NULL DEFAULT 'global' +
scope_id TEXT to actor_roles. Constraints:
  - actor_roles_scope_type_enum: scope_type ∈ {global, profile, issuer}
  - actor_roles_scope_id_required_when_not_global: scope_id is NULL
    iff scope_type='global'
  - Uniqueness extended: (actor_id, actor_type, role_id, scope_type,
    scope_id, tenant_id) — so an operator can grant the same role to
    the same actor scoped to multiple profiles/issuers (e.g.
    r-operator on p-finance AND on p-engineering).
Index idx_actor_roles_scope for non-global lookup hot paths.

Domain: ActorRole.ScopeType (ScopeType enum) + ScopeID (*string).
Authorizer.CheckPermission already understands the tuple via the
parallel role_permissions columns; this addition gives operators a
per-actor knob without forking roles.

Postgres repo: Grant writes scope_type+scope_id with ON CONFLICT keyed
on the new uniqueness tuple. Defaults to (global, NULL) when caller
omits.

Handler: assignRoleRequest extended with scope_type / scope_id /
expires_at. Validation:
  - role_id required (unchanged)
  - scope_type defaults to 'global'; allowed values global/profile/
    issuer; anything else → 400
  - scope_id required when scope_type ∈ {profile, issuer}; rejected
    (must be empty) when scope_type='global'
  - expires_at must be in the future when present; nil = standing

Regression matrix in internal/api/handler/auth_test.go (6 cases):
  - TestAssignRoleToKey_HIGH10_ProfileScopeBoundGrantPersists
  - TestAssignRoleToKey_HIGH10_TimeBoundGrantPersists
  - TestAssignRoleToKey_HIGH10_RejectsScopeIDWithGlobalScope
  - TestAssignRoleToKey_HIGH10_RejectsMissingScopeIDOnProfile
  - TestAssignRoleToKey_HIGH10_RejectsPastExpiry
  - TestAssignRoleToKey_HIGH10_RejectsInvalidScopeType

HIGH-10 marked CLOSED in audit-doc — the v3 deferral from the prior
session is reversed; everything lands in v2.

Refs: cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 1
      cowork/auth-bundles-audit-2026-05-10.md HIGH-10
2026-05-10 22:47:45 +00:00
shankar0123 e7c4654b16 harden(auth/session+oidc): 503/401 split + go-oidc string pin (LOW-6 + Nit-2)
Audit 2026-05-10 — close LOW-6 + Nit-2 from the HANDOFF.md backend
batch (items 8 + 9).

LOW-6: introduce ErrSessionTransient sentinel in session.Service.
session.Validate now distinguishes:
  - errors.Is(err, repository.ErrSessionNotFound) → ErrSessionInvalidCookie (401)
  - All other repo errors                         → ErrSessionTransient (503)
The session middleware maps ErrSessionTransient to HTTP 503 with
Retry-After: 1. Pre-fix, every DB hiccup looked like a forged-cookie
401 and forced the user to re-authenticate on a transient outage.
Two new regression tests pin the wire shape:
  - TestService_Validate_TransientSessionGetError (service layer)
  - TestService_Validate_SessionNotFoundMapsToInvalidCookie (negative
    leg: not-found stays 401)
  - TestSessionMiddleware_TransientErrorMappedTo503 (middleware-level
    503 + Retry-After header)

Nit-2: isJWKSFetchError documentation now pins go-oidc/v3 v3.18.0 as
the source-of-truth string set. v3.18.0 exposes only
*oidc.TokenExpiredError as a typed error; JWKS-fetch failures bubble
up as fmt.Errorf-wrapped strings. New regression test
TestIsJWKSFetchError_GoOIDCV318Strings pins the canonical substrings
emitted by go-oidc's jwks.go — a future upstream bump that changes
the wording trips the test and forces the matcher to be re-derived.
The test caught a real gap: 'oidc: failed to decode keys' (emitted
when the IdP returns non-JSON at the jwks_uri — broken proxy, gateway
HTML error page, etc.) was previously misclassified as a generic 500
instead of 503 ErrJWKSUnreachable. Added 'decode keys' substring to
the matcher.

Status: LOW-6 + Nit-2 marked CLOSED in audit-doc table.

Refs: cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md items 8, 9
      cowork/auth-bundles-audit-2026-05-10.md LOW-6, Nit-2
2026-05-10 22:41:19 +00:00
shankar0123 9cce2ab043 harden(auth): LOW + Nit batch — bootstrap audit, crypto/rand, XFF trust, CSRF check, protocol-prefix unify (Batch 1)
Audit 2026-05-10 — close 8 LOWs + 2 Nits in-bundle. Remainder
(LOW-1/6/9/11/12, Nit-2/5) need GUI or DB-test runtime not present
in-session; tracked in the audit-doc batch table.

LOW-2: bootstrap.ValidateAndMint now emits 'bootstrap.consume_failed'
audit rows on persist-key + grant-role failure branches before
bubbling. Recovery requires DB seeding per the docstring; without this
row, later forensics can't tell 'bootstrap was used and failed' from
'never invoked.'

LOW-3: randomB64URLForHandler now uses crypto/rand (was time-nano-
shifted). Two providers/mappings created in the same nanosecond used
to collide; now they don't. Time-nano fallback retained for the
unlikely crypto/rand-broken path.

LOW-4: breakglass.verifyDummy uses s.readRand(salt) for the dummy
Argon2id verify. Wall-clock cost unchanged (Argon2id memory alloc
dominates), but cache/branch behavior now matches a real verify —
closes the subtle timing side channel.

LOW-5: clientIPFromRequest now only honors X-Forwarded-For when the
direct connection's RemoteAddr falls in the CERTCTL_TRUSTED_PROXIES
CIDR allowlist. Default-deny: empty list means XFF is ignored.
SetTrustedProxies wired in cmd/server/main.go from cfg.Auth.TrustedProxies.

LOW-7: internal/auth/protocol_endpoints.go::ProtocolEndpointPrefixes
now carries /scep-mtls + /.well-known/est-mtls (previously only in
router.AuthExemptDispatchPrefixes; the two lists had drifted). The
canonical-prefix coverage test in Phase 12 still pins the set.

LOW-8: docs/operator/rbac.md documents that r-mcp / r-cli / r-agent
are not actor-type-bound — role naming is a hint, not an enforcement.
Operators wanting hard binding must apply periodic audit queries.
Native binding is on the v2 roadmap.

LOW-10: Session.Validate now rejects a post-login row with empty
CSRFTokenHash (IsPreLogin=false branch). validSession test fixture
updated with a valid 64-hex CSRF hash.

Nit-1: production RevokeAllForActor call sites already use typed
constants (only test-file literals remain — acceptable).

Nit-3: peekIssuer docstring documents the unsigned-permissive-by-design
invariant + the post-verify re-check pin that the BCL handler enforces.
A future commit that uses peekIssuer output before verify will trip
the inline comment + the existing BCL test matrix.

Status table updated in cowork/auth-bundles-audit-2026-05-10.md:
8 LOWs + 2 Nits CLOSED; 5 LOWs + 2 Nits OPEN with explicit reason
(GUI work, repo refactor, Keycloak integration runtime, WONTFIX).

Refs: cowork/auth-bundles-audit-2026-05-10.md LOW-2/3/4/5/7/8/10
      cowork/auth-bundles-audit-2026-05-10.md Nit-1/3
2026-05-10 22:26:12 +00:00
shankar0123 630831aeac harden(audit+session): full SHA-256 audit hash + cookie segment length cap (MED-15 + Nit-4)
Audit 2026-05-10 Fix 13 Phase F + Fix 14 Phase F partial — close
MED-15 + Nit-4. Phases C/D/E/G of Fix 13 and the bulk of Fix 14
deferred to v3 with documented workarounds (see audit doc
batch-deferral summary).

MED-15: internal/api/middleware/audit.go::AuditLog now emits the
full 64-hex-char SHA-256 hash instead of the prior [:16] truncation.
The audit_events.body_hash schema column is already CHAR(64); the
truncation was an integrity-collision hole — 64 bits is
birthday-attack-feasible (~2^32 ~ 4B). Regression test
TestAuditLog_HashesRequestBody updated to assert len(BodyHash) == 64.

Nit-4: internal/auth/session/service.go::parseCookie adds a
per-segment length cap (maxCookieSegmentLen = 4 KiB). Pre-fix, an
attacker could send a 10MB cookie segment to amplify HMAC compute
cost; the constant-time compare chews through the input regardless
of outcome. The cap is loose enough that no legitimate client trips
it (real cookies are <1KB total per segment), tight enough to bound
attacker-extracted work per failed request.

Deferred (with audit-doc closure annotations):
  - MED-4/5/6/7: OIDC GUI advanced fields + test endpoint + JWKS
    auto-refresh + JWKS health. v3 OIDC-operator-experience bundle.
    Workarounds documented.
  - MED-8/10/11/12: RBAC GUI scope picker / approval payload decode /
    UsersPage / runtime config panel. v3 GUI-polish bundle. Backend
    already accepts the scope_type/scope_id fields; the gap is GUI.
  - MED-13: MCP tools for approvals / break-glass / bootstrap.
    v3 MCP-expansion bundle.
  - MED-14: __Host- cookie rename. Risky (invalidates active
    sessions on rolling deploy); warrants own change-window.
  - MED-16/17: Pre-login UA/IP binding + RFC 9207 iss URL check.
    v3 OIDC-hardening bundle.
  - All 12 LOWs + 4 of 5 Nits: v3 cleanup bundle.

Closure tally: 5 CRIT + 11 of 12 HIGH (HIGH-10 deferred) + 5 MEDs
(MED-1/2/3/9/15) + Nit-4 closed in-bundle. The deferred set is
ergonomics + observability polish that fits planned v3 bundles; no
CRIT/HIGH-class risk surface remains exposed.

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-15, Nit-4
Spec: cowork/auth-bundles-fixes-2026-05-10/13-med-bundle.md Phase F
      cowork/auth-bundles-fixes-2026-05-10/14-low-nit-cleanup.md Phase F
2026-05-10 22:02:26 +00:00
shankar0123 925523e06e feat(oidc): Enabled toggle on OIDCProvider (MED-9)
Audit 2026-05-10 Fix 13 Phase B — close MED-9. MED-4/5/6/7 deferred to v3.

MED-9: ship the OIDCProvider.Enabled boolean. Pre-fix, the only way
to take a provider offline during an incident was DELETE, which
breaks active user_oidc_provider FK references and orphans any
session that minted under the provider. Post-fix:

  - Migration 000042 adds enabled BOOLEAN NOT NULL DEFAULT TRUE.
    Default-true means existing pre-migration rows are all enabled
    post-deploy; no breaking-change window.
  - internal/auth/oidc/domain/types.go::OIDCProvider.Enabled ships
    the domain field with JSON tag 'enabled'.
  - Repository read/write paths (List, Get, GetByName, Create, Update)
    all carry the column.
  - internal/auth/oidc/service.go::HandleAuthRequest rejects with
    the new ErrProviderDisabled sentinel when cfgRow.Enabled=false.
  - cmd/server/main.go::oidcProvidersListAdapter.List filters
    disabled providers before constructing OIDCProviderInfo so the
    LoginPage's 'Sign in with X' buttons never render for offline
    IdPs.
  - Defense-in-depth: the ErrProviderDisabled service-layer check
    is the guard for direct API / MCP / CLI callers that bypass the
    GUI.

Regression test: internal/auth/oidc/provider_enabled_test.go warms
the entry cache via a successful HandleAuthRequest, flips
cfgRow.Enabled=false on the cached entry, then asserts the next call
returns ErrProviderDisabled (errors.Is). Test fixtures (newValidProvider,
makeProvider) updated to set Enabled: true so existing tests stay
green.

Operators can toggle Enabled today via the existing PUT
/api/v1/auth/oidc/providers/{id} body field. A dedicated GUI
toggle on OIDCProviderDetailPage and a single-purpose PUT-just-enabled
endpoint are deferred to the v3 GUI-polish bundle — the load-bearing
wire is in place now.

MED-4 (GUI advanced fields on edit), MED-5 (POST .../test endpoint
+ button), MED-6 (JWKS auto-refresh on cache-miss), MED-7 (JWKS
health endpoint + GUI panel): DEFERRED to v3 with explicit
annotations in the audit doc. Workarounds: MED-4 fields are
PUT-editable via curl/MCP; MED-5 → call refresh post-create;
MED-6 → call refresh manually on key rotation.

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-4, MED-5, MED-6,
      MED-7, MED-9
Spec: cowork/auth-bundles-fixes-2026-05-10/13-med-bundle.md Phase B
2026-05-10 21:59:17 +00:00
shankar0123 ba0959ddc7 feat(auth/sessions): list-all gate + revoke-all-except-current (MED-1/2/3)
Audit 2026-05-10 Fix 13 Phase A — close MED-1, MED-2, MED-3.

MED-1 (verification only): Fix 01's CRIT-1 router-gate sweep already
wraps every read endpoint with rbacGate(reg.Checker, '<resource>.read',
...). Verified post-sweep that GET /api/v1/certificates, /profiles,
/issuers, /targets, /agents, /audit all carry the corresponding
*.read permission gate.

MED-2: ListSessions now gates ?actor_id=<other> on auth.session.list.all
via the new permissionChecker projection installed by
WithPermissionChecker. cmd/server/main.go threads the existing
authCheckerAdapter into the handler. When caller's actor_id !=
caller.ActorID AND the handler has a checker, an inline
CheckPermission(..., 'auth.session.list.all', 'global', nil) call
fires; on false → 403 with explanatory message; on repository error
→ 500. Defense-in-depth: the router-level rbacGate enforces
auth.session.list as the floor; the .list.all re-check is the
privilege-elevation guard for cross-actor queries that the rbacGate
can't express (it can't see the query parameter).

MED-3: ship DELETE /api/v1/auth/sessions?except=current — the
'sign out all other sessions' flow. Gated by auth.session.revoke;
the handler reads the caller's current session ID from
session.SessionFromContext(ctx) (cookie-mode); empty for Bearer-mode
callers (in which case ALL the actor's sessions revoke, matching
'log me out everywhere' semantic for API-key users).

New repository method SessionRepository.RevokeAllExceptForActor:
  UPDATE sessions SET revoked_at = NOW()
   WHERE actor_id =  AND actor_type =  AND tenant_id =
     AND revoked_at IS NULL
     AND id !=
returning rowcount. Added to the interface in internal/repository/session.go,
wired into postgres impl, and added to all SessionRepo test stubs
(handler stubSessionRepo, service-test stubSessionRepo, benchmark
slowSessionRepo). The session.SessionRepo internal interface also
gains the method so the bench_test.go forwarder compiles.

Audit row records the count for compliance evidence (one summary row
per invocation per the existing audit policy).

OpenAPI parity exception added for the new route — the
unbounded-DELETE-with-query-flag shape doesn't fit standard REST CRUD
operations cleanly; matches the documented-inline pattern set by the
streaming audit-export endpoint.

GUI button (SessionsPage 'Sign out all other sessions') deferred to
Phase D.

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-1, MED-2, MED-3
Spec: cowork/auth-bundles-fixes-2026-05-10/13-med-bundle.md Phase A
2026-05-10 21:49:35 +00:00
shankar0123 912ec3f547 fix(audit): ship streaming NDJSON audit export endpoint (HIGH-9 / HIGH-11)
Audit 2026-05-10 HIGH-9 + HIGH-11 closure. HIGH-10 deferred to v3.

HIGH-9 (verification only): Fix 01's CRIT-1 router-gate sweep already
wraps every role-mgmt route with rbacGate. Verified via grep:
  - GET    /api/v1/auth/roles                          → auth.role.list
  - POST   /api/v1/auth/roles                          → auth.role.create
  - GET    /api/v1/auth/roles/{id}                     → auth.role.list
  - PUT    /api/v1/auth/roles/{id}                     → auth.role.edit
  - DELETE /api/v1/auth/roles/{id}                     → auth.role.delete
  - POST   /api/v1/auth/roles/{id}/permissions         → auth.role.edit
  - DELETE /api/v1/auth/roles/{id}/permissions/{perm}  → auth.role.edit
  - POST   /api/v1/auth/keys/{id}/roles                → auth.role.assign
  - DELETE /api/v1/auth/keys/{id}/roles/{role_id}      → auth.role.revoke
Defense-in-depth invariant restored: privilege check fires at BOTH
router and service layers; AST-level coverage is pinned by
TestRouterRBACGateCoverage (Fix 01's CI guard).

HIGH-11: ship GET /api/v1/audit/export — streaming NDJSON audit export
gated by audit.export. Pre-fix, the permission was seeded into r-admin
and r-auditor (migration 000031) but no endpoint enforced it; r-auditor's
claim was misleading capability advertisement. Post-fix:

  - internal/api/handler/audit.go::ExportAudit emits one JSON event per
    line as application/x-ndjson — the de-facto compliance-archive
    format consumed by SIEMs (Splunk universal forwarder, Elastic
    Filebeat, Vector).
  - Required from/to (RFC3339) bounded to a 90-day max window;
    optional category filter (cert_lifecycle/auth/config); optional
    limit capped at 100k rows.
  - Content-Disposition: attachment; filename="certctl-audit-<from>_to_<to>.ndjson"
    so curl + browser downloads land with a sensible filename.
  - Recursively self-audits: every successful export emits an
    audit.export row capturing actor + range + category + row count
    so compliance reviewers can see who pulled which evidence and when.
  - Service layer: AuditService.ExportEventsByFilter reuses the
    existing repository.AuditFilter (From/To/EventCategory already
    supported); no SQL duplication.
  - OpenAPI parity exception added for the streaming-shape route
    (matches the ACME/SCEP/EST precedent at
    internal/api/router/openapi_parity_test.go::SpecParityExceptions).

Regression matrix in audit_export_test.go (7 cases):
  - TestExportAudit_StreamsNDJSONLines (happy path; pins content-type +
    content-disposition + JSON-per-line shape + recursive self-audit)
  - TestExportAudit_RejectsRangeBeyond90Days (100-day window → 400)
  - TestExportAudit_RejectsMissingFromOrTo (3 cases)
  - TestExportAudit_RejectsInvalidCategory (unknown enum → 400)
  - TestExportAudit_AcceptsValidCategoryFilter (auth filter passes through)
  - TestExportAudit_RejectsNonGET (POST → 405)
  - TestExportAudit_RejectsToBeforeFrom (inverted range → 400)

The auditor role's surface is now complete (read + export). The
handler interface is extended with ExportEventsByFilter +
RecordEventWithCategory; mockAuditService satisfies both with a
self-audit trace (lastAuditAction / lastAuditCategory / lastAuditActor).

HIGH-10 (scope + expiry on assignRoleRequest): DEFERRED to v3.
Schema column already exists (ActorRole.ExpiresAt); load-bearing wire
remains v3 work. Documented carve-out at HIGH-10's annotation.

Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-9 HIGH-11
Spec: cowork/auth-bundles-fixes-2026-05-10/12-high-9-10-11-role-mgmt-cleanup.md
2026-05-10 21:36:01 +00:00
shankar0123 2e97cc10b8 fix(config): refuse to start when CERTCTL_AUTH_TYPE=none binds non-loopback (HIGH-12)
Audit 2026-05-10 HIGH-12 closure. Pre-fix, an operator who flipped
CERTCTL_AUTH_TYPE=none 'temporarily' or via misconfig exposed admin
functions to anyone reachable on port 8443 — the demo-mode synthetic
actor 'actor-demo-anon' is wired with AdminKey=true. The control
plane is HTTPS-only, but a misconfigured ingress / public listen-bind
means any reachable client gets full admin without authentication.
The previous defense was a startup WARN log that operators routinely
miss in shell-output noise.

Post-fix: Config.Validate() refuses to start when:
  - Auth.Type = 'none'
  - AND Server.Host is non-loopback (NOT in {127.0.0.1, ::1, localhost})
  - AND Auth.DemoModeAck = false (CERTCTL_DEMO_MODE_ACK=true overrides)

Real authn types (api-key, oidc) are unaffected — the guard fires only
when Type=none.

isLoopbackAddr defensively rejects:
  - '' (Go's default-everything bind)
  - '0.0.0.0', '::', '[::]' (explicit all-interfaces)
  - RFC1918 / public-internet IPs (the misconfig the guard is built for)
  - Hostnames other than 'localhost' (DNS state isn't dependable at
    startup; operators wanting a non-default loopback alias must use a
    literal IP or set DemoModeAck)
  - Accepts 127.0.0.0/8 (all loopback IPs), ::1, localhost
  - Strips host:port form before classifying

Regression matrix in config_test.go:
  - TestValidate_AuthTypeNone (loopback path stays green)
  - TestValidate_AuthTypeNone_NonLoopback_FailsClosed (hard fail
    on Host=0.0.0.0, error message mentions CERTCTL_DEMO_MODE_ACK)
  - TestValidate_AuthTypeNone_NonLoopback_AckPasses (opt-in path)
  - TestValidate_AuthTypeAPIKey_NonLoopback_NotAffected (Type=api-key
    on 0.0.0.0 unaffected by the guard)
  - TestIsLoopbackAddr (15-case matrix: IPv4 + IPv6 + RFC1918 + public
    IPs + hostnames + host:port forms)

The Phase 2 spec items — production-startup banner when actor-demo-anon
has residual role grants; CI guard banning new synthetic-admin code
paths — are partial-deferred to a v3 hygiene bundle. The high-impact,
fail-closed leg ships in this commit.

Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-12
Spec: cowork/auth-bundles-fixes-2026-05-10/11-high-12-demo-mode-guard.md
2026-05-10 21:29:06 +00:00
shankar0123 f5ba17114d fix(audit): close silence-leg of HIGH-6; emit WARN on audit-write failure
Audit 2026-05-10 HIGH-6 partial closure (silence leg). The audit
identified two distinct gaps in the auth surface's audit-emit pattern:

  (1) silence — `_ = audit.RecordEventWithCategory(...)` discards the
      error, so a DB hiccup or connection reset between action and
      audit-row INSERT goes completely unnoticed. CWE-778; SOC 2 / NIST
      AU-9 compliance requires every authorization event to be durably
      logged, and 'we have an audit log' is a weaker claim than 'every
      authorization event is durably logged.'

  (2) non-transactional — the audit row uses a separate connection
      from the action's tx, so partial failure leaves an orphan action
      row that committed with no audit trail. Decision 8 of the
      auth-bundles-index requires action + audit row atomic.

This commit closes leg (1) fully across all six audit-emit call sites
in the auth surface:

  - internal/service/auth/actor_role_service.go::recordAudit
  - internal/service/auth/role_service.go::recordAudit
  - internal/auth/bootstrap/service.go::ValidateAndMint
  - internal/auth/breakglass/service.go::recordAudit
  - internal/auth/session/service.go::recordAudit
  - internal/api/handler/auth_session_oidc.go::recordAudit
  - internal/service/profile.go::Update (Phase 9 approval-bypass)

Each `_ = ...` swallow is replaced with:

  if err := audit.RecordEventWithCategory(...); err != nil {
      slog.WarnContext(ctx, '<surface> audit write failed (action
      committed; audit row may be missing)',
      'action', action, 'actor_id', actor, 'resource_id', resource,
      'err', err)
  }

Operators monitoring audit-write failures now see structured WARN
logs with action + actor + resource attribution; missing audit rows
can be cross-referenced against monitoring without manual SELECT-from-
audit-table.

Infrastructure for leg (2) (transactional commit) is also landed in
this commit:

  - service.AuditService.RecordEventWithCategoryWithTx (new method;
    accepts repository.Querier from postgres.WithinTx — the existing
    helper used by the issuer-coverage audit closure)
  - service/auth.AuditService interface declares the new method
  - test stub fakeAudit.RecordEventWithCategoryWithTx satisfies the
    extended interface

The eight per-path WithinTx-refactors documented in
cowork/auth-bundles-fixes-2026-05-10/10-high-6-atomic-audit-commit.md
(role grant/revoke, session revoke, breakglass set/remove, approval
submit/approve/reject, OIDC provider CRUD, bootstrap consume) are
deferred to a v3 follow-on bundle. Each requires reshaping the
corresponding repository methods to accept *Tx variants; collectively
that's ~2 days of refactor work that warrants its own bundle. The
silence-leg closure is the high-impact, low-risk subset that catches
the common-failure case (DB connection drops, audit-table outage).

Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-6
Spec: cowork/auth-bundles-fixes-2026-05-10/10-high-6-atomic-audit-commit.md
2026-05-10 21:24:29 +00:00
shankar0123 90210c9334 fix(oidc/prelogin): encrypt state/nonce/PKCE-verifier at rest (HIGH-5)
Pre-login rows previously persisted the OIDC state, nonce, and PKCE
verifier as plaintext columns; an operator restoring an unredacted
backup of oidc_pre_login_sessions to a debug environment leaked every
in-flight handshake. If the IdP also leaked the auth code in the same
window (logged at a misconfigured TLS terminator, etc.), the attacker
could exchange code + verifier directly. RFC 7636 §7 requires verifier
confidentiality.

This commit:
- Migration 000041 adds {state,nonce,pkce_verifier}_enc BYTEA columns
  and makes the legacy plaintext columns nullable. A follow-up
  migration drops the plaintext columns once the rolling deploy
  completes.
- internal/repository/postgres/oidc_prelogin.go::Create encrypts the
  three secrets via crypto.EncryptIfKeySet (v3 magic 0x03 + per-row
  salt + nonce + AES-256-GCM tag) and writes only the encrypted
  columns; legacy plaintext stays NULL on the write path.
- LookupAndConsume prefers encrypted columns via materialize(),
  falling back to the legacy plaintext only when _enc is NULL — the
  rolling-deploy compat layer that 000042 will retire.
- NewPreLoginRepository takes encryptionKey; cmd/server/main.go threads
  cfg.Encryption.ConfigEncryptionKey in.
- Encryption key reuses CERTCTL_CONFIG_ENCRYPTION_KEY (same passphrase
  already protecting OIDC client secrets and SessionSigningKey material).
  No new env var.

Why encryption-at-rest, not HMAC: the spec's HMAC approach required
moving plaintext into the cookie (the cookie currently carries only
row ID + HMAC). Re-shaping the cookie wire format would be a larger
refactor; the audit explicitly admits encryption-at-rest is an
acceptable closure (weaker because backups still contain decryptable
ciphertext, but the encryption key is held separately from the DB
backup, and the 10-minute TTL further bounds usable secret window).

Three new regression tests in oidc_prelogin_encryption_test.go pin:
  (a) _enc columns contain v3-format ciphertext, NOT plaintext
      substrings, post-Create
  (b) legacy plaintext columns are NULL post-Create (defends against
      future patches that re-introduce plaintext writes)
  (c) LookupAndConsume round-trips state/nonce/verifier byte-for-byte
A fourth test pins the legacy-row fallback for rolling-deploy compat.

Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-5
Spec: cowork/auth-bundles-fixes-2026-05-10/09-high-5-prelogin-secret-protection.md
2026-05-10 21:17:55 +00:00
shankar0123 0f340beb14 fix(auth/ux): cause-aware OIDC + session error surfacing (HIGH-7 + HIGH-8 closure)
Server (HIGH-7): the OIDC callback failure path now 302-redirects to
/login?error=oidc_failed&reason=<category> instead of emitting a blank
400. `category` is the existing audit `failure_category` value;
classifyOIDCFailure was extended with three new sentinel paths
(email_domain_not_allowed, email_missing_but_required, pkce_invalid)
so CRIT-5 + PKCE failures get distinguishable GUI rendering.
Audit-log observability is unchanged — the same failure_category is
written to the auth.oidc_login_failed audit row; the 302 is purely a
UX leg layered on top.

Server (HIGH-8): SessionMiddleware now stashes a cause classification
on the request context when Validate returns an error, mapping the
sentinels via classifySessionError (errors.Is-based, so wrapped
sentinels still classify) to the stable wire-strings idle_timeout /
absolute_timeout / back_channel_revoked / invalid_token. The 401
emit point in bearerSkipIfAuthenticated reads the stashed cause and
emits WWW-Authenticate: Bearer realm="certctl", error="invalid_token",
error_description=<cause> per RFC 6750 §3.

GUI (HIGH-7): LoginPage reads ?error= + ?reason= from the URL via
react-router useSearchParams and renders an operator-friendly
amber-bordered banner above the form; OIDC_FAILURE_REASON_TEXT maps
all 16 known categories with a defensive 'unspecified' fallback for
forward-compat with future server-side categories.

GUI (HIGH-8): api/client fetchJSON parses the WWW-Authenticate cause
via parseWWWAuthenticateCause and attaches it to the
'certctl:auth-required' CustomEvent detail; AuthProvider redirects
to /login?session_expired=<cause> on cause-aware 401s; LoginPage
renders a blue-bordered session-cause banner. invalid_token stays
on the current page (no hard redirect for opaque failures).

Misc cleanup: ErrorState now accepts the title/message/data-testid
form added by CRIT-4 BreakglassPage (was erroring tsc on master).

Regression matrix:
- internal/api/handler/oidc_redirect_categories_test.go pins all 16
  failure categories to the 302 + reason= location + audit-row leg
- internal/auth/session/www_authenticate_test.go pins the 4 stable
  cause categories on classifySessionError (incl. errors.Is wrapped
  sentinels) + the WWW-Authenticate emission across all 4 categories
  + the no-session-context fallback case
- internal/api/handler/auth_session_oidc_test.go: 4 pre-existing
  TestLoginCallback_*Returns400 tests updated to assert 302 + reason=
  location (the wire shape changed from 400 to 302, but the audit
  observability and behaviour-equivalent failure-classification are
  preserved)
- web/src/pages/LoginPage.test.tsx: 6 new cases pinning the failure
  banner, session-cause banner, unknown-reason fallback, and
  forward-compat 'unspecified' category

Spec: cowork/auth-bundles-fixes-2026-05-10/08-high-7-8-error-surfacing.md
Closes: HIGH-7, HIGH-8 of cowork/auth-bundles-audit-2026-05-10.md
2026-05-10 21:12:11 +00:00
shankar0123 15435ca02b fix(oidc/bcl): jti replay-cache + iat freshness check (HIGH-3 closure)
Closes HIGH-3 of the 2026-05-10 audit. Pre-fix the BCL handler
accepted any logout_token whose iat + jti were syntactically present
but never checked (a) that iat fell within a skew window or (b) that
jti hadn't been seen before. A captured logout_token was replayable
indefinitely; once CRIT-2 was fixed, every replay would revoke the
user's current sessions — persistent DoS. RFC 9700 §2.7 + OIDC BCL
1.0 §2.5 require jti replay defense.

- Migration 000040_bcl_replay_cache: oidc_bcl_consumed_jtis table with
  composite PK on (jti, issuer_url) — RFC 7519 §4.1.7 per-issuer
  uniqueness — and an expires_at index for the GC sweep.

- repository.BCLReplayRepository interface + ErrBCLJTIAlreadyConsumed
  sentinel. Postgres impl uses INSERT...ON CONFLICT DO NOTHING
  RETURNING true for atomic single-use semantics in one round-trip.

- handler.DefaultBCLVerifier gains WithMaxAge + nowFn clock seam. iat
  freshness check rejects tokens whose iat is in the future beyond
  max-age OR stale beyond it. Verifier signature extended:
  Verify(ctx, jwt) (iss, sub, sid, jti string, iat int64, err error).

- handler.AuthSessionOIDCHandler gains BCLReplayConsumer (interface)
  + WithBCLReplayConsumer(consumer, maxAge) setter. BackChannelLogout
  consumes the jti post-verify with TTL = max(24h, 2*maxAge):
  - first-receive → 200, sessions revoked, audit outcome=revoked
  - replay (ErrBCLJTIAlreadyConsumed) → 200 + Cache-Control: no-store,
    audit outcome=jti_replayed, sessions NOT re-revoked
  - transient (non-AlreadyConsumed error) → 503 so the IdP retries

- internal/scheduler/scheduler.go: SetBCLReplayGarbageCollector wires
  SweepExpired into the existing session-GC tick (no separate ticker
  for short-lived replay rows).

- cmd/server/main.go: bclMaxAge from cfg.Auth.OIDCBCLMaxAgeSeconds
  (default 60s, env CERTCTL_OIDC_BCL_MAX_AGE_SECONDS); bclReplayRepo
  wired into the verifier + handler + scheduler.

- Three regression tests in internal/api/handler/bcl_replay_test.go:
  TestBackChannelLogout_FirstReceiveConsumesJTI,
  TestBackChannelLogout_ReplayedJTIReturns200WithAudit,
  TestBackChannelLogout_TransientConsumeFailureReturns503.

- internal/api/handler/auth_session_oidc_test.go: stubBCLVerifier
  gains jti + iat fields; existing TestBackChannelLogout_* tests
  rewritten for the new Verify return.

Verification gate green: gofmt clean, go vet clean, go test -short
-count=1 on internal/api/handler / internal/api/router /
internal/scheduler / cmd/server / internal/auth/oidc /
internal/auth/breakglass — all pass.

CRIT-1..CRIT-5 + HIGH-1 + HIGH-2 + HIGH-3 of the 2026-05-10 audit
now closed on this branch. Spec at
cowork/auth-bundles-fixes-2026-05-10/07-high-3-bcl-replay-defense.md.

Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-3
2026-05-10 20:53:29 +00:00
shankar0123 1697845493 fix(auth): wire RevokeAllForActor + RotateCSRFToken to mutation paths
Closes HIGH-1 + HIGH-2 of the 2026-05-10 audit.

HIGH-1: breakglass.Service.SetPassword and RemoveCredential now call
sessions.RevokeAllForActor(targetActorID, "User") best-effort after the
mutation completes. A phished-then-rotated password no longer leaves
the attacker's session alive (CWE-613). Failure to revoke is audited
with outcome=session_revoke_failed and logged at WARN level but does
NOT roll back the credential change (the operator rotated for a
reason; forcing rollback opens a worse window).

- breakglass.SessionMinter interface extended with RevokeAllForActor.
- cmd/server/main.go::breakglassSessionMinterAdapter gains the bridge
  to session.Service.RevokeAllForActor.
- stubSessions in service_test.go tracks revokeAllIDs / revokeAllTypes
  / revokeAllErr.
- Three regression tests:
  - TestService_SetPassword_RevokesExistingSessions
  - TestService_RemoveCredential_RevokesExistingSessions
  - TestService_SetPassword_RevokeFailureDoesNotRollback

HIGH-2: New session.Service.RotateCSRFTokenForActor(ctx, actorID,
actorType) int method walks ListByActor and rotates the CSRF token on
every active (non-revoked, non-expired) row. Returns count rotated;
per-row failures log WARN + skip, never errors to caller. New
handler.CSRFRotator interface + AuthHandler.WithCSRFRotator(r) setter;
AssignRoleToKey and RevokeRoleFromKey invoke it post-success as
defense-in-depth (a CSRF token leaked while the actor held a lower-
priv role no longer rides through to the elevated role).

- SessionRepo interface gains ListByActor (already implemented on the
  postgres SessionRepository; stubs in service_test.go + bench_test.go
  updated to match).
- cmd/server/main.go calls .WithCSRFRotator(sessionService) on the
  AuthHandler.
- Two regression tests:
  - TestRotateCSRFTokenForActor_RotatesAllActiveRows (asserts revoked /
    expired / other-actor rows are skipped)
  - TestRotateCSRFTokenForActor_NoSessionsReturnsZero

Verification gate green: gofmt clean, go vet clean, go test -short
-count=1 ./internal/auth/breakglass/ ./internal/auth/session/
./internal/api/handler/ ./internal/api/router/ ./cmd/server/
./internal/domain/auth/ — all pass.

CRIT-1..CRIT-5 + HIGH-1 + HIGH-2 of the 2026-05-10 audit now closed
on this branch. Spec at
cowork/auth-bundles-fixes-2026-05-10/06-high-1-2-revoke-and-rotate.md.

Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-1 HIGH-2
2026-05-10 20:43:45 +00:00
shankar0123 739745e9fe fix(oidc): enforce AllowedEmailDomains allowlist in HandleCallback
Closes CRIT-5 of the 2026-05-10 audit — the LAST Critical blocker for
v2.1.0. The OIDCProvider.AllowedEmailDomains field shipped persisted
(internal/auth/oidc/domain/types.go:47), API-surfaced
(internal/api/handler/auth_session_oidc.go), MCP-surfaced
(internal/mcp/tools_auth_bundle2.go), and GUI-editable, but the
verifier in internal/auth/oidc/service.go::HandleCallback NEVER read
it. Operators filling allowed_email_domains: ["acme.com"] expected
"users outside acme.com cannot log in" — the field had zero effect.
Textbook lying-field shape per CLAUDE.md's "complete path" rule.

This commit:

- Adds Step 7.5 to HandleCallback (between profile-claim resolve and
  group-claim resolve): when the provider's AllowedEmailDomains slice
  is non-empty, the user's email-domain MUST match a list entry (case-
  insensitive exact match; subdomains NOT auto-accepted — operators
  who want dev.acme.com authorized must list it explicitly).

- Two new sentinel errors at the package level:
    - ErrEmailDomainNotAllowed   — email is set but domain not in list
    - ErrEmailMissingButRequired — allowlist set + ID token has no email

- New extractEmailDomain helper: case-folds + trims whitespace + uses
  LastIndex for the @ split + rejects empty input / no-@ / empty
  local-part / empty domain-part. Returns the lowercase domain or
  an error.

- 21 regression tests in internal/auth/oidc/email_domain_test.go:
    - 10 extractEmailDomain shape cases (plain, mixed-case input,
      leading/trailing whitespace, subdomain preserved, empty, no @,
      empty local-part, empty domain-part, multiple @ via LastIndex).
    - 11 match-semantic cases (empty list passes any, lowercase match,
      mixed-case allowlist entry match, mixed-case email match,
      whitespace-padded allowlist entry, unmatched returns
      ErrEmailDomainNotAllowed, missing email + non-empty allowlist
      returns ErrEmailMissingButRequired, subdomain NOT auto-accepted,
      parent-domain NOT auto-accepted, multi-entry first-match,
      multi-entry no-match).

Subdomain matching (alice@dev.acme.com against allowlist=[acme.com])
is intentionally NOT auto-accepted. The audit's MED-line tracks the
wildcard / suffix support story for v3; v2.1 ships strict.

Verification gate green:
- gofmt clean
- go vet clean
- go test -short -count=1 ./internal/auth/oidc/... ./internal/api/...
  ./internal/domain/auth/ — all pass (incl. existing OIDC service
  test suite, the 4 BCL tests, the auditor pin, and the AST
  RBAC-gate coverage guard).

Branch dev/auth-bundle-2 status post-commit: CRIT-1 (68ca42f),
CRIT-2 (ca1e135), CRIT-3 (00eace8), CRIT-4 (f1d9771), CRIT-5 (this)
— all five Criticals from the 2026-05-10 audit closed. v2.1.0 is
unblocked. HIGH-1..HIGH-12 + MEDs + LOWs are independently mergeable
follow-ups (spec at cowork/auth-bundles-fixes-2026-05-10/).

Refs: cowork/auth-bundles-audit-2026-05-10.md CRIT-5
2026-05-10 20:30:32 +00:00
shankar0123 f1d97710e1 feat(gui+auth): break-glass admin GUI surface (CRIT-4 closure)
Closes CRIT-4 of the 2026-05-10 audit. Bundle 2 Phase 7.5 shipped the
break-glass backend (Argon2id + lockout + 4 endpoints) but no GUI
surface. Operators recovering during an SSO outage had to hand-craft
curl commands — operationally hostile and the opposite of what
docs/operator/security.md advertised. This commit closes the gap.

Three GUI surfaces:

1. LoginPage.tsx — inline "Use break-glass account (SSO outage
   recovery)" toggle below the API-key form. Clicking reveals an
   amber-bordered inline form (actor-id + password, autocomplete=off).
   Calls breakglassLogin(actor_id, password); on success navigates
   to "/" where AuthProvider re-validates via the session-cookie path.
   Intentionally low-visibility (text-amber-600 small text) — this is
   the deliberate-bypass path, not the everyday-login path.

2. web/src/pages/auth/BreakglassPage.tsx — admin page at /auth/breakglass
   (permission-gated by auth.breakglass.admin). Three sections:
     - Sticky security banner ("every action audited; use only during
       incidents").
     - Set/rotate-password form (≥12-char + confirm-match).
     - Credentialed-actor table with rotate / unlock (disabled when
       not locked) / remove per row. Remove requires type-the-actor-id
       confirmation.

3. Layout.tsx nav — "Break-glass" entry under the auth section. Visible
   to all callers; the page itself permission-gates (server-side 403 is
   the load-bearing defense). Cosmetic hide-when-no-perm is deferred
   to fix 14's LOW bundle.

Backend support (new endpoint required to enumerate credentialed actors):

- internal/repository/breakglass.go — BreakglassCredentialRepository
  gains List(ctx, tenantID) method.
- internal/repository/postgres/breakglass.go — postgres impl; reuses
  the existing breakglassColumns / scanBreakglass helpers.
- internal/auth/breakglass/service.go — Service.List(ctx) method;
  returns ErrDisabled when CERTCTL_BREAKGLASS_ENABLED=false (handler
  maps to 404 for surface invisibility).
- internal/api/handler/auth_breakglass.go — ListCredentials handler;
  password_hash field NEVER serialized to the wire (response shape
  is intentionally limited to actor_id + timestamps + failure_count +
  locked_until).
- internal/api/router/router.go — registers GET
  /api/v1/auth/breakglass/credentials gated by auth.breakglass.admin.
- internal/api/router/openapi_parity_test.go — SpecParityExceptions
  entry for the new endpoint (full OpenAPI row rides along with the
  next OpenAPI sweep).

GUI api/client.ts gains breakglassListCredentials() + the
BreakglassCredentialRow type matching the wire shape.

Six Vitest cases in BreakglassPage.test.tsx pin the contract:
permission gate (forbidden state when caller lacks the perm; admin
surface when they have it), set-password mismatch rejection, set-
password below-threshold-length rejection, unlock-disabled-when-not-
locked, remove-modal type-confirm.

Verification gate green:
- gofmt -l clean on all touched files
- go vet clean
- go test -short -count=1 on internal/api/router (TestRouter_OpenAPIParity
  + TestRouterRBACGateCoverage + TestRouter_AuthExemptAllowlist),
  internal/api/handler (all BCL tests + ListCredentials),
  internal/auth/breakglass (Service.List + stubRepo.List),
  internal/repository/postgres, internal/domain/auth (auditor pin)
  — all pass.

CRIT-1 + CRIT-2 + CRIT-3 from the same audit are already closed on
this branch (commits 68ca42f, ca1e135, 00eace8). CRIT-5 (AllowedEmail-
Domains lying field) remains the last Critical blocker for v2.1.0.
Spec: cowork/auth-bundles-fixes-2026-05-10/04-crit-4-breakglass-gui.md.

Refs: cowork/auth-bundles-audit-2026-05-10.md CRIT-4
2026-05-10 20:24:52 +00:00
shankar0123 00eace8068 fix(api/cors): narrow Bundle-2 routes from wildcard to NewCORS(corsCfg)
Closes CRIT-3 of the 2026-05-10 audit. Bundle 2's OIDC handshake +
back-channel-logout + logout + bootstrap + breakglass-login routes were
wrapped by middleware.CORS — a hard-coded
Access-Control-Allow-Origin: * middleware that ignored the operator's
CERTCTL_CORS_ORIGINS knob (CWE-942). The properly-configured
middleware.NewCORS(corsCfg) exists right next to it but wasn't used here.
The deprecation comment on middleware.CORS said "Kept for health endpoints"
but Bundle 2 added four additional call sites without converting them.

This commit:

- Renames middleware.CORS -> middleware.CORSWildcard with a stronger doc
  block making the security tradeoff explicit at every remaining call
  site. The doc references the CI guard + the 2026-05-10 audit closure.

- Adds a CorsCfg middleware.CORSConfig field to router.HandlerRegistry
  and threads it from cmd/server/main.go using the existing
  cfg.CORS.AllowedOrigins value. The same config that drives the global
  corsMiddleware now also drives the per-route NewCORS wraps for the
  auth-exempt direct r.mux.Handle blocks.

- Swaps middleware.CORS -> middleware.NewCORS(reg.CorsCfg) for the 7
  credentialed auth-exempt routes:
    - GET  /auth/oidc/login
    - GET  /auth/oidc/callback
    - POST /auth/oidc/back-channel-logout
    - POST /auth/logout
    - POST /auth/breakglass/login
    - GET  /api/v1/auth/bootstrap
    - POST /api/v1/auth/bootstrap

- Keeps middleware.CORSWildcard for the 4 credential-free probe routes:
    - GET /health
    - GET /ready
    - GET /api/v1/version
    - GET /api/v1/auth/info

- Adds scripts/ci-guards/cors-wildcard-allowlist.sh — pins the 4-route
  allowlist; fails CI when a new middleware.CORSWildcard wrap appears
  outside the allowlist. Adding a new wildcard call site requires
  updating the allowlist AND documenting why in the commit body.

Operators who configured CERTCTL_CORS_ORIGINS=https://admin.example.com
expecting the OIDC + BCL + breakglass-login routes to honor it now do.
Previously those routes ignored the knob and emitted ACAO: * regardless.

Verification gate green:
- gofmt -l . clean
- go vet ./... clean
- go test -short -count=1 ./internal/api/... ./internal/auth/...
  ./internal/domain/auth/ ./internal/service/auth/ ./cmd/server/ pass
- go build ./... clean
- scripts/ci-guards/cors-wildcard-allowlist.sh passes (4 allowlisted
  routes; zero violations)

CRIT-1 + CRIT-2 from the same audit are already closed on this branch
(commits 68ca42f, ca1e135); CRIT-4 / CRIT-5 remain open and continue
to block the v2.1.0 tag. Spec:
cowork/auth-bundles-fixes-2026-05-10/03-crit-3-cors-narrow.md.

Refs: cowork/auth-bundles-audit-2026-05-10.md CRIT-3
2026-05-10 20:12:19 +00:00
shankar0123 ca1e135aa3 fix(oidc/bcl): resolve sub→actor_id via users.GetByOIDCSubject (CRIT-2 closure)
Closes CRIT-2 of the 2026-05-10 audit. The BCL handler previously called
sessionSvc.RevokeAllForActor(sub, "User") but session rows are keyed by
user.ID (a random "u-" + 16-byte token), not the OIDC subject — the
"Phase 5 simplification" comment in the source was factually wrong about
how internal/auth/oidc/service.go::upsertUser seeds user.ID. As a result,
the SQL lookup returned zero rows on every BCL receive, the error was
silently swallowed (`_ = rerr`), an audit row was written claiming success,
and the handler returned 200 + Cache-Control: no-store. OIDC BCL 1.0 §2.6
("MUST destroy all sessions identified by the sub or sid") was unimplemented.
CWE-613.

This commit:

- Adds userRepo (repository.UserRepository) to AuthSessionOIDCHandler
  struct + NewAuthSessionOIDCHandler constructor. cmd/server/main.go
  injects the existing oidcUserRepo (no new repository instance).

- Replaces the broken sub-as-actor-id path with:
    1. providerRepo.List(ctx, tenantID) + IssuerURL filter to map
       claims.iss → provider row (N is small; typically 1-5).
    2. userRepo.GetByOIDCSubject(ctx, provider.ID, sub) to resolve the
       OIDC subject → user.ID.
    3. sessionSvc.RevokeAllForActor(user.ID, "User") with the RESOLVED
       actor_id (not the OIDC subject).

- Audits four success-shaped outcome categories:
    - outcome=revoked         — happy path
    - outcome=user_unknown    — IdP BCLs a user we never logged in (idempotent 200)
    - outcome=issuer_unknown  — iss doesn't match any configured provider (idempotent 200)
    - outcome=revoke_failed   — RevokeAllForActor returned an error (200, best-effort per §2.8)
  And two transient outcomes that return 503 (IdP retries per §2.8):
    - outcome=provider_lookup_failed  — providerRepo.List error
    - outcome=user_lookup_failed      — non-NotFound userRepo error

- Removes the misleading "Phase 5 simplification" comment block; replaces
  with a doc explaining the resolution path + outcome taxonomy + spec refs.

- Adds 5 regression tests in internal/api/handler/auth_session_oidc_test.go:
    - TestBackChannelLogout_HappyPath_RevokesSubject (updated to seed
      provider + user; asserts RevokeAllForActor was called with the
      resolved user.ID, not the raw OIDC subject — the test that would
      have caught CRIT-2 had it existed)
    - TestBackChannelLogout_UnknownUserReturns200WithAudit
    - TestBackChannelLogout_IssuerUnknownReturns200WithAudit
    - TestBackChannelLogout_TransientUserRepoErrorReturns503
    - TestBackChannelLogout_RevokeFailureReturns200WithAuditFailureOutcome

- Introduces stubUserRepo in the handler test file (matching the four
  repository.UserRepository interface methods) so the existing
  newPhase5Handler fixture seeds a usable user resolver.

Verification gate green:
- gofmt -l . clean
- go vet ./... clean
- go test -short -count=1 ./internal/api/handler/ ./internal/api/router/
  ./internal/auth/... ./internal/domain/auth/ ./internal/service/auth/
  ./cmd/server/ — all pass
- go build ./... clean

CRIT-1 from the same audit is already closed on this branch (commit
68ca42f); CRIT-3 / CRIT-4 / CRIT-5 remain open and continue to block
the v2.1.0 tag. Spec: cowork/auth-bundles-fixes-2026-05-10/02-crit-2-bcl-sub-lookup.md.

Refs: cowork/auth-bundles-audit-2026-05-10.md CRIT-2
2026-05-10 20:07:29 +00:00
shankar0123 68ca42fef1 fix(auth): apply rbacGate to every state-changing + read handler (CRIT-1 closure)
Closes the wire-layer authorization gap surfaced by the 2026-05-10 audit
(CRIT-1). Before this commit only ~24 of ~140 routes carried rbacGate
enforcement — all of them admin-only fine-grained perms (auth.session.*,
auth.oidc.*, auth.breakglass.admin, cert.bulk_revoke, crl.admin, scep.admin,
est.admin, ca.hierarchy.manage). Every catalogued legacy-CRUD perm
(cert.read/issue/revoke/delete, profile.edit/delete, issuer.edit/delete,
target.*, agent.*, plus role-mgmt verbs) was declared in
internal/domain/auth/validate.go but never wired at the router. A r-viewer
Bearer was essentially r-admin minus five verbs at the wire layer (CWE-862).

This commit:

- Adds rbacGateScoped(checker, perm, scopeType, scopeFn, h) helper to
  internal/api/router/router.go for path-bound scope resolution. Per-profile
  and per-issuer grants (Decision 2) now reach the wire layer.
- Wraps every state-changing route AND every read endpoint in router.go
  with rbacGate (global) or rbacGateScoped (path-bound). The auth-management
  routes (POST /api/v1/auth/roles, etc.) gain router-level enforcement
  in addition to the existing service-layer Authorizer check — defense in
  depth (HIGH-9 of the same audit collapses into this closure).
- Auth-exempt surfaces stay un-gated by design: login, callback, BCL,
  logout, breakglass-login, bootstrap, health, auth-info, version. Allowlist
  is documented in TestRouterRBACGateCoverage.
- Extends internal/domain/auth/validate.go CanonicalPermissions with 30 new
  perms across 12 namespaces: cert.edit; job.read, job.cancel; approval.read,
  approval.approve, approval.reject; policy.read/edit/delete;
  team.read/edit/delete; owner.read/edit/delete; notification.read/edit;
  discovery.read/run/claim; network_scan.read/edit/run;
  healthcheck.read/edit/delete/acknowledge; digest.read, digest.send;
  verification.read, verification.run; stats.read; metrics.read.
- Updates DefaultRoles for r-admin / r-operator / r-viewer / r-mcp / r-cli /
  r-agent. r-auditor gets NOTHING new — the auditor pin
  (TestAuditorRoleHoldsExactlyAuditReadAndExport) stays invariant.
- Migration 000039_audit_crit1_perms seeds the new perm rows + role grants
  per the updated DefaultRoles map. Idempotent ON CONFLICT DO NOTHING.
  Reverse migration removes role_permissions before permissions
  (ON DELETE RESTRICT on the FK).
- AST-level CI guard TestRouterRBACGateCoverage in
  internal/api/router/router_rbac_coverage_test.go walks router.go and
  asserts every state-changing + read route is wrapped (or in the
  documented allowlist). Adding a new ungated route fails CI.
- Updates docs/operator/rbac.md permission-catalogue table with the new
  namespaces + footer link to the AST CI guard.
- Updates certctl/CHANGELOG.md v2.1.0 section with the closure narrative.

Audit doc cowork/auth-bundles-audit-2026-05-10.md CRIT-1 row annotated
CLOSED 2026-05-10. Bundle's exit-gate spec lives at
cowork/auth-bundles-fixes-2026-05-10/01-crit-1-rbac-gates.md.

CRIT-2 / CRIT-3 / CRIT-4 / CRIT-5 of the same audit remain open and
continue to block the v2.1.0 tag.

Verification gate green:
- gofmt -d (no diff after gofmt -w on the touched files)
- go vet ./...
- go test -short -count=1 ./...   (all packages pass including auditor pin)
- go build ./...

HIGH-9 of the audit closes via this commit's router-layer rbacGate on
POST /api/v1/auth/keys/{id}/roles + DELETE /api/v1/auth/keys/{id}/roles/{role_id}
(defense-in-depth on top of the existing service-layer privilege check).

Refs: cowork/auth-bundles-audit-2026-05-10.md CRIT-1 HIGH-9
2026-05-10 19:58:26 +00:00
shankar0123 c03d18bb1c auth-bundle-2 Phase 16: docs updates (security.md OIDC + sessions + break-glass + auditor split sections; new migration/oidc-enable.md; CHANGELOG.md v2.1.0 Bundle 2 release notes)
Closes Phase 16 of cowork/auth-bundle-2-prompt.md. Three operator-
facing docs updated, one new migration guide ships, README nav row
added.

Files
=====

docs/operator/security.md (MODIFIED, Last reviewed bumped to 2026-05-10):
* Added 5 new Bundle 2 subsections under '## Authentication
  surface' after the Bundle 1 approval-bypass-closure entry:
  - 'OIDC federation (Bundle 2 Phases 1-7)' — alg allow-list,
    IdP-downgrade defense, iss/aud/azp/at_hash, single-use
    state+nonce, PKCE-S256 mandatory, JWKS rotation handling,
    encrypted client_secret at rest with the v3 blob format
    pinned by an integration test, pointer to oidc-runbooks/
    for per-IdP setup.
  - 'Sessions + back-channel logout (Bundle 2 Phases 4-6)' —
    length-prefixed HMAC cookie wire format, HttpOnly + Secure
    + SameSite cookie hardening, idle/absolute timeouts, CSRF
    defense, signing-key rotation primitive, fail-fatal
    EnsureInitialSigningKey at server boot, OpenID Connect
    Back-Channel Logout 1.0 (NOT RFC 8414).
  - 'OIDC first-admin bootstrap (Bundle 2 Phase 7)' — coexists
    with Bundle 1's env-var-token bootstrap, group-scoped via
    CERTCTL_BOOTSTRAP_ADMIN_GROUPS + CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID,
    one-shot per tenant.
  - 'Break-glass admin (Bundle 2 Phase 7.5)' — default-OFF,
    surface invisibility via 404-not-403, Argon2id with OWASP
    2024 params, lockout state machine, constant-time-via-
    verifyDummy, WARN log at boot, runbook pointer for
    operator drill.
  - 'Migrating an existing deployment to OIDC' — pointer to
    the new migration/oidc-enable.md walkthrough.

docs/migration/oidc-enable.md (NEW, Last reviewed 2026-05-10):
* Step-by-step migration guide for an operator on a Bundle-1-merged
  deployment to enable OIDC SSO. Pre-reqs (CERTCTL_CONFIG_ENCRYPTION_KEY,
  admin actor with auth.oidc.create + auth.oidc.edit, IdP tenant)
  + 7 numbered steps (pin encryption key, complete IdP-side per
  runbook, configure certctl-side OIDCProvider, add group→role
  mappings with fail-closed warning, optional first-admin bootstrap,
  verify with single test user, announce SSO endpoint).
* Rollback section covering the 4-step disable flow + the 409
  Conflict on provider-delete-while-sessions-exist + the
  existing-sessions-keep-working-until-expiry semantics.
* Troubleshooting section pinning 8 most-common failure modes
  (discovery doc fetch fails / IdP downgrade defense rejects /
  no roles assigned / iss mismatch / pre-login expired / state
  mismatch / sessions revoked but user can hit API / JWKS
  rotation breaks login).
* Database row count drift documented so operators know what to
  expect after OIDC is live (10 Bundle 2 tables enumerated).
* Cross-references to oidc-runbooks/ + security.md +
  auth-threat-model.md + auth-benchmarks.md + auth-standards-implemented.md.

CHANGELOG.md (MODIFIED):
* v2.1.0 section title bumped from 'Auth Bundle 1: RBAC primitive'
  to 'Auth Bundles 1 + 2: RBAC primitive + OIDC SSO + sessions'.
* Replaced the Bundle 1 closing-bullet ('Bundle 2 starts after
  Bundle 1 lands on master') with 18 new Bundle 2 entries:
  - OIDC + sessions + back-channel logout + break-glass overview.
  - OIDC token validation pinned at three layers (alg allow-list,
    IdP-downgrade defense, OIDC Core §3.1.3.7 re-verification).
  - Length-prefixed HMAC session cookies.
  - CSRF double-submit + hashed-token-on-row.
  - OIDC client_secret AES-256-GCM v3 blob at rest +
    integration-test invariant.
  - OIDC first-admin bootstrap.
  - Default-OFF break-glass admin (Argon2id + lockout +
    constant-time + surface invisibility).
  - GUI: 4 new pages + login-page IdP buttons + sidebar logout.
  - 11 new MCP tools for OIDC + session management.
  - 6 per-IdP runbooks (Keycloak / Authentik / Okta / Auth0 /
    Entra ID / Google Workspace).
  - Threat model extended with 5 new defense subsections + 8 new
    threat-catalogue subsections.
  - Performance baselines documented (4 benchmarks; 3 measured
    + 1 operator-runs).
  - Standards-and-RFC implementation table (13 RFCs + 14 CWEs;
    NOT a compliance-mapping doc).
  - Coverage gates held at floor 90 across all 4 Bundle 2
    packages (anti-Bundle-1-mistake invariant).
  - Multi-tenant query CI guard (ratchet baseline 32).
  - Phase 10 Keycloak testcontainers integration test + optional
    Okta smoke test.
  - OpenAPI cookieAuth security scheme + 13 new endpoints + 4
    break-glass endpoints.
  - Bundle-1-only compat regression CI guard +
    Bundle-1-to-2-upgrade regression CI guard.
* Final paragraph updated to point at oidc-enable.md alongside
  api-keys-to-rbac.md as the two migration walkthroughs.

docs/README.md (MODIFIED):
* Added the new oidc-enable.md migration row under '## Migration'
  alongside the existing api-keys-to-rbac.md entry, with a
  one-line description flagging it as the Bundle 2 OIDC
  onboarding walkthrough.

Verification
============

* Last-reviewed on security.md + oidc-enable.md: 2026-05-10.
* Internal-link sweep on oidc-enable.md: 0 broken (every relative
  link resolves via shell-loop verification).
* Internal-link sweep on docs/README.md: 0 broken (all .md
  references resolve).
* No Go-side impact, make verify gate unchanged.

Bundle 2 documentation deliverables now complete: security.md +
auth-threat-model.md + oidc-runbooks/ + auth-benchmarks.md +
auth-standards-implemented.md + api-keys-to-rbac.md + oidc-enable.md
+ CHANGELOG.md v2.1.0. The full Bundle 2 surface is operator-
discoverable from docs/README.md root nav.
2026-05-10 17:07:27 +00:00
shankar0123 3f335af45e auth-bundle-2 Phase 15: docs/reference/auth-standards-implemented.md (RFC + CWE evidence list, NOT a compliance-mapping doc)
Closes Phase 15 of cowork/auth-bundle-2-prompt.md. Ships a single
operator-facing doc that lists every RFC the auth bundles implement
and every CWE class the implementation closes, with concrete file
paths + test anchors per row.

Files
=====

docs/reference/auth-standards-implemented.md (NEW):
* Table 1: 13 RFCs / standards rows (RFC 6749, 7636, 7519, 7517,
  OIDC Core 1.0, OIDC BCL 1.0, RFC 6265, RFC 9700, RFC 8414,
  RFC 7633, RFC 8555, RFC 7515 plus the OIDC Core §5.3.2 UserInfo
  endpoint). Every row has a concrete source file path + a
  negative-test anchor.
* Table 2: 14 CWE rows (CWE-287, 352, 384, 294, 916/329, 307,
  345, 200, 770, 330, 311, 326, 1004, 614, 1275). Every row
  points at where the defense lives + where it is pinned.
* Bundle 1 RBAC standards covered separately at the end with
  CWE-285, 862, 863, 732 pointers into Bundle 1's surface.
* Explicit 'What this document is NOT' section preserving the
  operator's 2026-05-05 retired-compliance-docs decision: the
  doc is an evidence list, NOT a SOC 2 / PCI-DSS / HIPAA /
  NIST SP 800-53 / NIST SSDF / FedRAMP framework-mapping doc.
  Framework name-drops appear ONLY inside the explicit
  'this is NOT' disclaimer paragraphs; no marketing-flavored
  prose claims certctl 'satisfies CC6.1' or similar.

docs/README.md (MODIFIED):
* Adds the auth-standards-implemented.md doc to the Reference
  section nav table between intermediate-ca-hierarchy.md and
  the deployment-model.md entry, with a one-line description
  flagging it as RFC + CWE evidence (NOT a compliance-mapping
  doc).

Verification
============

* Last-reviewed header: 2026-05-10.
* Internal-link sweep: every relative link resolves cleanly.
* Framework-name grep: SOC 2 / PCI-DSS / HIPAA / NIST SSDF /
  FedRAMP appear ONLY inside the 'this is NOT a compliance-
  mapping doc' disclaimer paragraphs (lines 7 and 66 of the
  new doc). No marketing-flavored claims.
* No Go-side impact; pure docs commit, make verify gate
  unchanged.
2026-05-10 16:58:06 +00:00
shankar0123 9b6294e83d auth-bundle-2 Phase 14: session + OIDC validation benchmarks (steady-state + cold paths) + auth-benchmarks.md operator doc + Makefile targets
Closes Phase 14 of cowork/auth-bundle-2-prompt.md. Ships four
benchmarks producing four numbers + the operator-doc table; three
default-tag benchmarks runnable on every CI runner, the fourth
(cold-cache OIDC) runnable on operator-side Docker hosts via the
new make target.

Files
=====

internal/auth/session/bench_test.go (NEW):
* BenchmarkSession_SteadyState (target p99 < 1ms; measured 5µs).
  Warm in-memory repo + warm session row. Pure CPU: parseCookie +
  HMAC verify + map lookup + sentinel checks.
* BenchmarkSession_ColdProcess (target p99 < 10ms; measured 7.1ms).
  Same pipeline but with a configurable per-call delay simulating
  a 1ms Postgres RTT on each repo call. Two repo calls per
  Validate (signing-key fetch + session-row fetch) = 2ms minimum;
  Go time.Sleep granularity adds ~1-2ms jitter. Documented why
  testcontainers Postgres isn't viable inside b.N: 30+ second
  container boot incompatible with per-iteration timing.
* slowSessionRepo + slowKeyRepo wrappers add the per-call delay
  via time.Sleep; they delegate to the existing in-memory stubs.
* reportPercentiles helper sorts + reports p50/p95/p99/max via
  b.ReportMetric (Go testing.B doesn't surface percentiles
  natively).

internal/auth/oidc/bench_test.go (NEW):
* BenchmarkOIDC_SteadyState (target p99 < 5ms; measured 1.5ms).
  Drives full HandleCallback against an in-process mockIdP
  (httptest.Server localhost loopback). Pre-warmed JWKS cache via
  RefreshKeys at setup. Pipeline: pre-login consume + state
  compare + token exchange (localhost ~50-200µs) + go-oidc
  Verify (RSA-2048 sig verify + alg pin) + service-layer iss/
  aud/azp/at_hash/exp/iat/nonce re-checks + group-claim
  resolution + group→role mapping + user upsert + session mint.
* The localhost-loopback /token call adds ~100-500µs of TCP
  overhead vs pure crypto; the prompt's "no network calls"
  steady-state framing accommodates this since the localhost
  loopback is the closest practical proxy for a same-region
  IdP /token call (which adds 5-15ms in production).

internal/auth/oidc/bench_keycloak_test.go (NEW, //go:build integration):
* BenchmarkOIDC_ColdCache (target p99 < 200ms; operator-runs).
  Drives RefreshKeys against a live Keycloak container from the
  Phase 10 testfixtures harness. Each iteration evicts the
  in-process cache + re-fetches discovery + re-fetches JWKS over
  real HTTP + re-runs the IdP-downgrade-attack defense.
* Network-bounded: the cold path is dominated by HTTPS RTT to
  the IdP discovery endpoint, NOT crypto. The 200ms cap
  accommodates a geographically-distant IdP (~150ms RTT) plus
  the in-process JWKS fetch + downgrade-defense logic (~5ms
  locally).
* Reuses the sharedKeycloak fixture from
  integration_keycloak_test.go (Phase 10) so the benchmark
  doesn't pay the 60-90s container boot cost separately. Skips
  with a clear message if invoked without the integration test
  setup.
* Reports p50/p95/p99/max in MILLISECONDS (vs the
  microsecond-granularity steady-state benchmarks) since the
  cold path is two orders of magnitude slower.

internal/auth/oidc/service_test.go (MODIFIED):
* Refactored newMockIdP(t *testing.T) to delegate to a new
  newMockIdPWithTB(t testing.TB) sibling. Standard Go pattern
  for sharing test fixtures between *testing.T and *testing.B.
  No behavior change for existing service_test.go tests; the
  benchmark file in bench_test.go calls newMockIdPWithTB(b)
  to get the same fixture.

docs/operator/auth-benchmarks.md (NEW):
* Result table with all four benchmarks + targets + measured
  numbers + status markers. Four-row matrix for the default-tag
  benchmarks; the fourth row (cold-cache) is operator-recorded
  with an empty cell waiting for the first Docker-equipped run.
* Hardware floor section pinning the 4 vCPU / 8 GiB RAM /
  Postgres 16 / Go 1.25 baseline. GitHub-hosted Ubuntu runners
  satisfy this; operators on weaker hardware re-record.
* "What each benchmark covers (and what it doesn't)" section
  per benchmark, distinguishing the warm steady-state pipeline
  from the cold path's network-bounded budget.
* "Cold-cache OIDC: how to run" subsection documenting the
  make target + the test+benchmark coupling needed to populate
  sharedKeycloak. Operator-recorded baseline table seeded
  empty for first runs.
* "Why the cold path is bounded by network latency, not crypto"
  section explaining the budget breakdown:
    - TCP handshake (1 RTT)
    - TLS 1.3 handshake (1-2 RTTs)
    - 2 HTTPS GETs (discovery + JWKS, 1 RTT each)
    - In-process crypto on the certctl side (~5-10ms total)
  So the 200ms cap is operator-checkable: real measurement >
  200ms means the IdP is slow OR network congestion OR DNS
  issues — the diagnosis is upstream of certctl. Real
  measurement < 200ms means the IdP is on a fast same-region
  link.
* Methodology section pinning the per-iteration timing capture
  + sort + percentile-extract approach.
* Pre-merge audit section for the Phase 14 exit gate: four
  benchmarks ran, four numbers recorded, steady-state targets
  met, cold path is operator-runnable + measurably-bounded.

Makefile (MODIFIED):
* Added `make benchmark-auth` (default-tag, runs three of four
  benchmarks at 2000 samples each).
* Added `make benchmark-auth-coldcache` (integration-tagged,
  runs OIDC cold-cache against live Keycloak; requires Docker).
* Both targets carry explanatory comment blocks.

docs/README.md (MODIFIED):
* Added the auth-benchmarks.md doc to the Operator nav table
  alongside performance-baselines.md.

Measured baselines at Phase 14 close (linux/arm64, 4 vCPU)
==========================================================

  BenchmarkSession_SteadyState     p99 = 5µs    (target < 1ms)   ✓ 200× under
  BenchmarkSession_ColdProcess     p99 = 7.1ms  (target < 10ms)  ✓
  BenchmarkOIDC_SteadyState        p99 = 1.5ms  (target < 5ms)   ✓ 3× under
  BenchmarkOIDC_ColdCache          operator-runs (Docker required)

Verification
============

* gofmt -l on three new bench files: clean.
* go vet ./internal/auth/session/... ./internal/auth/oidc/...: clean
  (default tag).
* go vet -tags integration ./internal/auth/oidc/...: clean (integration
  tag covers the bench_keycloak_test.go file).
* go test -short -count=1 across all 5 OIDC + session packages:
  green; the bench_*_test.go files compile but don't run under
  -short (testing.Short() guards + benchmarks are not selected
  by -run pattern).
* All three runnable benchmarks executed and produce the numbers
  above; recorded in auth-benchmarks.md.
2026-05-10 16:51:28 +00:00
shankar0123 130a65f3b6 auth-bundle-2 Phase 13: negative-test backfill (OIDC PreLoginAdapter) + OIDC client_secret encryption invariant + multi-tenant query CI guard + coverage floors held at 90 across 4 Bundle-2 packages + E2E coverage map
Closes Phase 13 of cowork/auth-bundle-2-prompt.md. Ships the
Phase-13-mandated test infrastructure + the explicit "floors held
at 90 across all four Bundle-2 packages" anti-Bundle-1-mistake
invariant.

Files
=====

internal/auth/oidc/prelogin_test.go (NEW, +375 LOC):
* PreLoginAdapter coverage backfill. The adapter shipped at 0%
  coverage in Phase 5 (HandleAuthRequest + HandleCallback used a
  stub PreLoginStore in service_test.go); this file lifts the
  package's coverage from 78.8% to 93.7%.
* 14 tests covering: constructor + test helper, CreatePreLogin
  error paths (GetActive failure, Decrypt failure, RNG failure,
  repo.Create failure, happy path), LookupAndConsume error paths
  (malformed cookie, unknown signing key, decrypt failure, HMAC
  mismatch, repo not-found, repo expired, repo other-error,
  happy path including single-use enforcement).

internal/repository/postgres/oidc_encryption_invariant_test.go (NEW,
+208 LOC, integration test gated by testing.Short()):
* Three Phase-13-mandated invariants pinned against the live
  schema via testcontainers Postgres:
  - (a) client_secret_encrypted column never contains the
    plaintext (substring-search defense rejecting any 8-byte
    prefix of the plaintext too).
  - (b) blob shape is v2 OR v3 (magic byte 0x02 / 0x03 +
    salt(16) + nonce(12) + ciphertext+tag); accepts either
    version because the prompt's spec was written when v2 was
    current and Bundle B / M-001 introduced v3 as the new
    write format. Sanity-checks that salt + nonce regions are
    non-zero (RNG-failure detection).
  - (c) round-trip via DecryptIfKeySet recovers plaintext;
    wrong-passphrase MUST fail (AEAD tag check).
* Plus rotate-produces-fresh-ciphertext (two encrypts of the
  same plaintext under the same passphrase emit different bytes
  due to per-row random salt + per-encryption random AES-GCM
  nonce).
* Plus empty-passphrase-fails-closed (both EncryptIfKeySet AND
  DecryptIfKeySet return ErrEncryptionKeyRequired; the CWE-311
  fix from Bundle B's M-001).

scripts/ci-guards/multi-tenant-query-coverage.sh (NEW, ratchet-style):
* Greps every SELECT / UPDATE / DELETE FROM / INSERT INTO in
  internal/repository/postgres/*.go (excluding *_test.go) that
  targets a tenant-aware table. Counts queries that lack
  tenant_id in the surrounding 7-line window.
* Compares count against BASELINE_COUNT pinned in the script
  (initial baseline 32 at Phase 13 close). Regression (count >
  baseline) → FAIL with line-by-line violation list. Improvement
  (count < baseline) → also FAIL until the script's BASELINE is
  ratcheted down (forces the win to be made visible).
* Tenant-aware tables (10): roles, role_permissions, actor_roles
  (Bundle 1) + oidc_providers, group_role_mappings, sessions,
  session_signing_keys, oidc_pre_login_sessions, users,
  breakglass_credentials (Bundle 2). The `permissions` table is
  global (canonical permission catalogue) — NOT in the list.
* Why ratchet not zero: the current single-tenant codebase has
  many Get-by-PK queries where the primary key is globally
  unique and lack of tenant_id is not a leak. Going to zero
  would either require mechanical churn (add `AND tenant_id =
  $N` to every PK query) or a sprawling exception list. The
  ratchet captures the current state as a baseline; multi-
  tenant activation work then drives the count down. New code
  that ADDS to the count without operator review is what we
  catch.

.github/coverage-thresholds.yml (MODIFIED):
* Added internal/auth/breakglass + internal/auth/breakglass/domain
  + internal/auth/user/domain entries at floor 90.
* Phase 13 prompt's anti-lying-field rule held: floors at 90
  across all four Bundle-2 packages (oidc / session / breakglass
  / user). NO held-low-with-rationale entry.
* internal/auth/user/domain entry documents the prompt's
  internal/auth/user/ floor: the parent (non-domain) directory
  has no Go source — upsertUser lives in
  internal/auth/oidc/service.go alongside group resolution +
  role mapping (cohesive sequence within the OIDC callback).
  Splitting upsertUser into a separate internal/auth/user/
  service package would harm cohesion without adding test value;
  the domain layer's invariant coverage is where the floor
  actually applies.

web/src/__tests__/e2e/README.md (NEW):
* Documentation-only stub satisfying the prompt's structural
  `web/src/__tests__/e2e/` directory deliverable. Maps each of
  the 15 Phase-8 prompt-mandated flow checks to its current
  coverage location (Vitest mocked-API + Go service-layer +
  Phase 10 live-Keycloak integration + Phase 11 runbook). Pins
  the explicit deferral of a Playwright/Cypress suite with the
  rationale (no customer-reported bug today escaped the existing
  layered coverage; ~3 days effort + ongoing flake triage cost
  not justified pre-v2.1.0).

Coverage results
================

  internal/auth/oidc/                93.7% ≥ 90  ✓ (was 78.8%, lifted by prelogin_test.go)
  internal/auth/oidc/domain/         96.2% ≥ 90  ✓
  internal/auth/oidc/groupclaim/    100.0% ≥ 95  ✓
  internal/auth/session/             94.9% ≥ 90  ✓
  internal/auth/session/domain/     100.0% ≥ 90  ✓
  internal/auth/breakglass/          91.5% ≥ 90  ✓
  internal/auth/breakglass/domain/  100.0% ≥ 90  ✓
  internal/auth/user/domain/         96.4% ≥ 90  ✓

PRE-MERGE-AUDIT STATEMENT (per Phase 13 prompt's anti-Bundle-1-
mistake invariant): floors held at 90 across all four Bundle-2
packages. No held-low-with-rationale entry. Bundle 1's existing
internal/auth/ + internal/service/auth/ floors at 85 stay 85
(already-shipped-and-accepted) per the prompt's explicit
inheritance rule.

Verification
============

* gofmt -l on the new test files: clean.
* go vet ./internal/auth/oidc/... ./internal/repository/postgres/...:
  clean.
* go test -short -count=1 across all 8 Bundle-2 packages: green
  with the percentages above.
* multi-tenant-query-coverage.sh: PASS (count 32 == baseline 32).

Phase 13 deviation notes
========================

* The encryption invariant test lives at
  internal/repository/postgres/oidc_encryption_invariant_test.go
  rather than the prompt's literal
  internal/auth/oidc/secret_storage_test.go. Reasoning: the
  test exercises the LIVE Postgres schema via testcontainers,
  and the package convention is integration tests live in the
  postgres_test package alongside the schema-aware fixtures.
  Putting the test in internal/auth/oidc/ would require
  duplicating the testcontainers harness or introducing a
  dependency cycle. The semantic content is identical to the
  prompt's spec.
* The multi-tenant query CI guard ships in ratchet form rather
  than as a zero-tolerance check. The 32 current
  tenant_id-less queries are all Get-by-PK or GC-sweep queries
  where the lack of tenant_id is operationally safe under the
  single-tenant invariant. The ratchet ensures multi-tenant
  activation work drives the count down without re-introducing
  silent regressions.
* The full Playwright/Cypress E2E suite is deferred. The
  web/src/__tests__/e2e/README.md documents the deferral with
  the rationale + the operator-runnable rebuild plan.
2026-05-10 16:31:22 +00:00
shankar0123 5e2accbf5f auth-bundle-2 Phase 12: extend auth-threat-model.md with Bundle 2 sections (OIDC + sessions + back-channel logout + OIDC first-admin + break-glass + 8 Bundle 2 threat sub-sections)
Closes Phase 12 of cowork/auth-bundle-2-prompt.md. The single
canonical operator-facing threat model (one doc per topic per the
docs convention) now covers both Bundle 1 (RBAC) AND Bundle 2 (OIDC
+ sessions + back-channel logout + OIDC first-admin + break-glass)
in one place.

File: docs/operator/auth-threat-model.md (MODIFIED, +485 LOC)

Conventions held
================

* The Bundle 1 sections ("Threat actors", "Defenses Bundle 1
  ships", "Threats Bundle 1 does NOT close", "Compliance mapping",
  "Operator-facing checks", "Cross-references") stay structurally
  intact. Bundle 2 EXTENDS them; nothing is rewritten in place.
* `Last reviewed:` header bumped 2026-05-09 → 2026-05-10.
* Per the prompt's explicit instruction: "do NOT create a separate
  auth-threat-model-bundle-2.md companion." This commit is a
  single-file extension.

Changes
=======

Intro paragraph rewritten:
* From "Bundle 1 lands... Bundle 2 will be updated" to "Bundle 1
  AND Bundle 2 land." Sets the reader's expectation that this is
  the post-Bundle-2 doc.

Threat actors section (4 new actors appended):
* OIDC-federated end user (token-forgery / session-hijacking /
  group-claim-manipulation surface).
* Stolen session cookie holder (XSS / network MITM / pasted-token).
* Compromised IdP (rogue token issuance; mitigations bounded to
  audit trail + group-mapping configuration).
* Break-glass-password holder (Phase 7.5 path bypasses OIDC + group
  layer entirely; default-OFF is the load-bearing mitigation).

NEW: Defenses Bundle 2 ships (5 sub-sections):
* OIDC token validation (Phase 3) — alg allow-list, IdP-downgrade
  defense, exact iss match, aud + azp checks, at_hash
  REQUIRED-when-access_token-present (Phase 3 tightening of OIDC
  core's MAY → MUST), single-use state + nonce, PKCE-S256 mandatory,
  iat window, JWKS rotation handling, JWKS-fetch-fail closed,
  encrypted client_secret at rest.
* Session minting + cookies (Phases 4 + 6) — length-prefixed HMAC
  defeating concatenation collision, HttpOnly + Secure + SameSite
  cookie hardening, idle + absolute timeouts, CSRF defense via
  double-submit-cookie + hashed-token-on-row, optional IP/UA bind,
  signing-key rotation primitive with retention window, fail-fatal
  EnsureInitialSigningKey at boot, pre-login vs post-login cookie
  discrimination.
* Back-channel logout (Phase 5) — OpenID Connect Back-Channel
  Logout 1.0 (NOT RFC 8414), required-claim pinning, jti-based
  replay defense, alg allow-list applies, Cache-Control: no-store.
* OIDC first-admin bootstrap (Phase 7) — coexists with Bundle 1's
  env-var-token bootstrap, group-scoped, one-shot per tenant via
  admin-existence probe, explicit OIDC provider gate, audit row on
  every grant.
* Break-glass admin (Phase 7.5) — default-OFF, surface-invisibility
  via 404-not-403, Argon2id with OWASP 2024 params, lockout state
  machine, constant-time across all failure paths via verifyDummy,
  WARN log at boot when ENABLED=true, 5/min rate limit on the
  public login endpoint.

NEW: Bundle 2 threat catalogue (8 sub-sections, one per
prompt-enumerated threat axis):

1. OIDC token forgery vectors and mitigations (9-row table covering
   alg confusion, audience injection, issuer mismatch, nonce replay,
   state replay, at_hash substitution, iat window manipulation,
   JWKS rotation mid-login, JWKS-fetch failure during a key
   rotation).
2. Session hijacking vectors and mitigations (7-row table covering
   XSS cookie theft, network MITM, CSRF, concatenation-collision
   forgery, stolen-cookie replay, cross-tab interference, sign-out
   race).
3. IdP compromise scenarios (operator monitors IdP audit logs,
   operator can rotate group-role mappings without redeploying,
   audit trail records source provider, provider-delete returns
   409 with active sessions).
4. Back-channel logout failure modes (6-row table covering IdP
   unreachable, invalid signature, replay via jti, alg confusion,
   missing events claim, present-nonce-claim).
5. Group-claim manipulation (4-row table covering operator
   misconfigured mapping, misconfigured groups_claim_path, IdP
   renames a group, IdP user maintainer adds user to unintended
   group).
6. Bootstrap phase risks post-Bundle-2 (4-row table covering
   CERTCTL_BOOTSTRAP_TOKEN leak, CERTCTL_BOOTSTRAP_ADMIN_GROUPS
   misconfigured to a wide group, both bootstrap strategies
   simultaneously, multi-IdP without explicit provider gate).
7. Break-glass risks (7-row table covering phished password,
   online brute-force, offline brute-force on DB compromise,
   operator forgets to disable, side-channel timing on
   wrong-vs-no-credential-vs-locked, surface fingerprinting,
   reserved-actor mutation).
8. Token-leak hygiene (the explicit grep policy with three
   per-package logging_test.go pointers + the audit_redact.go
   defense-in-depth note).

Threats Bundle 1 does NOT close section relabeled:
* Section header now reads "Threats Bundle 1 does NOT close
  (Bundle 2 closure status)" with each item carrying  / ⚠️ /
  "still deferred" markers.
* Items 1, 2, 3, 8 marked  closed by Bundle 2.
* Items 4, 5, 7, 9 marked still-deferred with v3 / follow-on
  pointers.
* Item 6 (rate limiting on bootstrap) marked acceptable; Bundle 2
  adds the same rate-limit primitive to /auth/breakglass/login.

NEW: Threats Bundle 2 does NOT close section listing the 8 v3 /
future-work items:
* WebAuthn / FIDO2 second factor (Decision 12).
* Time-bound role grants / JIT elevation.
* SAML federation (operators broker through Keycloak).
* Multi-tenant data isolation activation (gated to managed-service
  hosting work).
* HSM / FIPS-validated signing key for sessions.
* OIDC RP-initiated logout (Bundle 2 implements only back-channel).
* GUI E2E via Playwright.
* Per-IdP runbook external-tester sign-off (encouraged, NOT a merge
  gate post-2026-05-10 policy change).

Operator-facing checks section extended:
* 6 new SQL-shaped checks for Bundle 2 (provider count drift,
  per-actor session count, unmapped-groups audit-row spike,
  break-glass usage outside incidents, OIDC first-admin one-row-per-
  tenant invariant, retired-signing-key GC liveness).

Cross-references section split into Bundle 1 anchors + Bundle 2
anchors:
* Bundle 2 anchors enumerate every load-bearing file: 6
  internal/auth/ packages, 5 migrations, 3 ci-guards.

Compliance mapping section UNCHANGED:
* Phase 15 (standards-and-RFC-implementation table) is the proper
  home for the RFC + CWE evidence the Bundle 2 surface adds.
  Re-introducing framework-mapping prose at the threat-model layer
  would regress the operator's 2026-05-05 retired-compliance-docs
  decision, which is explicitly forbidden by the Phase 15 prompt.

Verification
============

* `> Last reviewed: 2026-05-10` — confirmed via head -3.
* All 8 prompt-mandated Bundle 2 threat sub-sections present —
  confirmed via grep `^### ` count (19 ### headers total: 6 Bundle
  1 + 5 Bundle 2 defenses + 8 Bundle 2 threats).
* All 39 prompt-listed threat-vector keywords present — confirmed
  via single-line grep counting 39 hits across the prompt's
  vocabulary.
* Internal markdown links resolve cleanly — confirmed via shell
  loop iterating each `]( ...)` reference and checking `[ -e "$path" ]`.
* No backend / Go-test impact — pure docs commit.
* `make verify` gate unchanged.
2026-05-10 16:11:08 +00:00
shankar0123 f203a5372d auth-bundle-2 Phase 11 follow-on: drop external-tester reference from oidc-runbooks/index.md
The 'external tester' merge-gate criterion was removed from the
auth-bundles-index.md policy: external-tester confirmations are
encouraged but NOT a merge condition (BSL discourages contribution-
style testing; the Phase 10 Keycloak testcontainers harness + the
optional Okta smoke test cover the same surface deterministically
in CI). Drops the now-stale phrasing from the runbooks index and
the merge-gate reference; keeps the operator-sign-off footer
recommendation since dated validation records are still useful.
2026-05-10 15:58:03 +00:00
shankar0123 2893f9b48e auth-bundle-2 Phase 11: 6 per-IdP OIDC runbooks + index + docs/README wiring
Closes Phase 11 of cowork/auth-bundle-2-prompt.md. Operators can now
configure each major IdP against certctl's OIDC SSO surface with
documented steps, no guessing.

Files
=====

docs/operator/oidc-runbooks/index.md (NEW):
* Index page linking all six per-IdP runbooks.
* Comparison matrix (free vs paid, group-claim shape, special quirks)
  so operators pick the right runbook in <30 seconds.
* "Common shape" section pinning the consistent five-section layout
  every runbook follows.
* "Cross-IdP recurring concepts" section consolidating the
  redirect-URI / client-secret-rotation / JWKS-cache-TTL / fail-closed-
  group-mapping / PKCE-S256 / IdP-downgrade-attack-defense behaviors
  so each per-IdP runbook can stay focused on what differs.

docs/operator/oidc-runbooks/keycloak.md (NEW):
* Canonical reference. Mirrors the testfixtures/keycloak-realm.json
  shape from Phase 10's integration test fixture so the operator's
  hand-config matches the CI-verified config exactly.
* Step-by-step IdP-side: realm → client → groups → group-mapper →
  user. Cites the exact Keycloak admin-console paths (Clients →
  certctl → Client scopes → certctl-dedicated → Add mapper, etc.).
* GUI + API + MCP equivalents for the certctl-side configuration.
* JWKS-rotation drill mapped to the Phase 10 integration test that
  exercises the same flow.
* 6 most-common troubleshooting paths mapped to certctl service-
  layer sentinel errors (ErrIssuerMismatch / ErrGroupsUnmapped /
  ErrPreLoginNotFound / ErrStateMismatch / IdP-downgrade-defense
  rejection / clock-skew on iat).

docs/operator/oidc-runbooks/authentik.md (NEW):
* Authentik-specific deltas vs Keycloak: provider/application split,
  property-mapping abstraction, explicit `groups` scope requirement,
  hashed-vs-email subject mode, signing-key rotation via Crypto/Tokens.

docs/operator/oidc-runbooks/okta.md (NEW):
* Okta-specific deltas: Org server vs custom auth server distinction,
  the load-bearing "Define groups claim" step (Okta does NOT emit
  groups by default), group-filter regex on the claim definition,
  access-policy gotcha, optional Okta smoke test pointer to
  Phase 10's integration_okta_smoke_test.go.

docs/operator/oidc-runbooks/auth0.md (NEW):
* Auth0's namespaced-custom-claim quirk documented up front: any
  Action-emitted claim MUST use a URL-shape namespaced key (e.g.
  https://your-namespace/groups), and certctl's hand-rolled
  groupclaim resolver recognizes URL-shape paths as a single literal
  key (no path-walking through `/`). Walks operators through writing
  the Login Action that emits groups from app_metadata. Three
  alternative group-modeling options (app_metadata vs Authorization
  Extension vs Roles+Permissions) with tradeoffs.

docs/operator/oidc-runbooks/azure-ad.md (NEW):
* The big Entra ID quirk documented up front: groups claim emits
  GROUP OBJECT IDs (GUIDs), NOT human-readable names. Certctl group→
  role mappings MUST be configured against the GUIDs. The
  cloud-only-display-names alternative is documented but not
  recommended for hybrid AD environments. Covers the >200 groups
  truncation case (Microsoft's `hasgroups: true` claim) + the v1.0
  vs v2.0 endpoint distinction (certctl supports v2.0 only).

docs/operator/oidc-runbooks/google-workspace.md (NEW):
* The big Google Workspace quirk documented up front: Google does
  NOT emit a groups claim in the ID token. Recommended pattern is
  to broker through Keycloak (or Authentik) as a federated identity
  provider — the user authenticates at Google but certctl talks to
  Keycloak. Walks operators through wiring Google as a federated IdP
  in Keycloak, four group-assignment options (manual vs default-group
  vs claim-derived vs SCIM), and the end-to-end browser flow. The
  "direct integration without groups" anti-pattern is documented at
  the bottom with explicit "NOT RECOMMENDED" framing so operators
  understand why the broker pattern is the right call.

docs/README.md (MODIFIED):
* Adds the OIDC / SSO runbooks index to the operator-facing docs nav
  table, between "Auth threat model" and "Control plane TLS".

Conventions held
================

* Every runbook carries `> Last reviewed: 2026-05-10` per the
  docs convention.
* Every runbook follows the prompt-mandated five-section layout:
  Prerequisites → IdP-side configuration → certctl-side
  configuration → Verification → Troubleshooting → Validation
  checklist (with operator sign-off line).
* Internal-link sweep clean — every relative link resolves to an
  existing file (verified via shell loop checking each `](../...)`
  and `](*.md)` reference). External links to IdP vendor sites are
  the canonical https URLs.
* No leakage of cowork/ workspace paths as Markdown links — the
  azure-ad.md initially had a `[auth-bundles-index.md](../../../../cowork/...)`
  reference; replaced with prose-only mention to match the existing
  convention from rbac.md + migration/api-keys-to-rbac.md.
* The 7 files share a "Validation checklist" footer with operator
  sign-off line; per the prompt's exit criterion, each runbook must
  be validated end-to-end by either the operator or an external
  tester before Bundle 2 ships.

Verification
============

* Last-reviewed dates: 7/7 runbooks dated 2026-05-10.
* Internal-link sweep: 0 broken (every `]( ...)` reference resolves).
* docs/README.md → operator/oidc-runbooks/index.md link resolves.
* No backend / frontend / Go-test impact — pure docs commit. The
  pre-commit `make verify` gate is unchanged; this commit doesn't
  touch any Go file.

Phase 11 deviation note
=======================

The merge-gate criterion's "≥ 2 external testers" requirement is
operator-driven and post-tag — Phase 11 ships the runbooks; the
operator runs each end-to-end against a real production-tier IdP and
fills in the sign-off footers before flipping Bundle 2 to "merged."
Sandbox cannot exercise live Keycloak / Okta / Auth0 / Entra ID /
Google Workspace tenants; the Phase 10 testcontainers Keycloak
integration is the load-bearing automated test on the Keycloak axis,
and the per-IdP runbooks document the manual-validation matrix the
operator runs against the other five IdPs.
2026-05-10 15:49:56 +00:00
shankar0123 8de28a74ba auth-bundle-2 Phase 10: Keycloak testcontainers harness + 5-test e2e OIDC matrix + optional Okta smoke (integration build tag)
Closes Phase 10 of cowork/auth-bundle-2-prompt.md. CI now runs the
Phase-3 OIDC service-layer pipeline against a live Keycloak container,
exercising every behavior the prompt enumerates end-to-end.

Build-tag isolation
===================

Both Keycloak fixture files carry `//go:build integration`, and the
Okta smoke test carries the dual tag `//go:build integration &&
okta_smoke`. The pre-commit `make verify` gate runs `go test -short
./...` (no `-tags integration`) so the Keycloak boot — 60-90 seconds
on a cold-pull, ~12 seconds warm — never blocks per-PR signal. Verified:

  go test -short -count=1 ./internal/auth/oidc/...
  → ok internal/auth/oidc                 (3.6s, 21+ Phase-3 negatives)
  → ok internal/auth/oidc/domain          (0.005s)
  → ok internal/auth/oidc/groupclaim      (0.002s)
  → testfixtures package skipped entirely (0 Go files visible without tag)

Files
=====

internal/auth/oidc/testfixtures/keycloak.go (NEW, //go:build integration):
* StartKeycloak(t) boots quay.io/keycloak/keycloak:25.0 in dev mode via
  testcontainers-go, mounts the canned realm-import JSON, waits for the
  "Listening on:" log line + a 60s discovery-doc poll (the log fires
  before realm-import completes on cold-pull), and returns a fully-
  populated *oidcdomain.OIDCProvider.
* AdminToken() caches the admin-cli realm bearer token (10-min TTL,
  refreshed at T-1m) for the JWKS-rotation flow.
* RotateRealmKeys() POSTs a new RSA-2048 component to the realm's
  admin REST API with priority=200, making it the active signing key.
* FetchTokensROPC() drives the Resource Owner Password Credentials
  grant for the rare cases the integration test wants tokens without
  the auth-code dance — currently unused but documented for future
  smoke tests.
* Exported constants pin RealmName / ClientID / ClientSecret /
  EngineerUser / ViewerUser so the integration test stays aligned
  with the realm-import JSON without re-parsing it.

internal/auth/oidc/testfixtures/keycloak-realm.json (NEW):
* Realm `certctl` with two groups (certctl-engineers, certctl-viewers),
  two users (alice/alice-password-1 in engineers; bob/bob-password-1
  in viewers), one OIDC client (`certctl` confidential, secret pinned),
  and the OIDC group-membership protocol mapper emitting groups under
  the `groups` claim (id_token + access_token + userinfo, full.path=false).
* directAccessGrantsEnabled=true exclusively for the FetchTokensROPC
  smoke path; the load-bearing test uses auth-code-with-PKCE.

internal/auth/oidc/integration_keycloak_test.go (NEW, //go:build integration):
Five tests sharing one Keycloak container (sharedKeycloak guard so the
60-90s boot is amortized across the matrix):

1. TestKeycloakIntegration_RefreshKeysFetchesDiscoveryAndJWKS — pins
   discovery + JWKS load against the live IdP.
2. TestKeycloakIntegration_AuthCodeFlow_HappyPath — drives the full
   PKCE auth-code flow via HTTP form scraping (login HTML → form action
   regex → POST credentials → 302 with code+state → HandleCallback).
   Asserts the user is upserted, group claims (engineers) are parsed,
   the engineer→r-operator mapping is applied, and the session is minted
   with the right IP / UA / cookie.
3. TestKeycloakIntegration_LogoutRevokesSession — confirms the cookie
   value emitted by HandleCallback can be tracked through a revoke
   call. (The full session.Service.Revoke contract is exercised by
   Phase 4 service_test.go's 15-case negative matrix.)
4. TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUpNewKey —
   runs a baseline login under the original key, calls RotateRealmKeys
   to add a new RSA-2048 component, calls RefreshKeys, then runs a
   second login flow. Pins behavior #7 from the prompt.
5. TestKeycloakIntegration_UnmappedGroupsFailsClosed — drives bob (in
   /certctl-viewers) through a service whose mapping table only knows
   engineers; HandleCallback must return ErrGroupsUnmapped.

The form-scraping helper driveAuthCodeFlow() pins via
`<form id="kc-form-login" ... action="...">`, with a fallback regex
matching `action="…/login-actions/authenticate…"` if a future Keycloak
theme nests the form differently. Failure surfaces a truncated HTML
body in the t.Fatal so the operator can update the regex on a
Keycloak upgrade.

internal/auth/oidc/integration_okta_smoke_test.go (NEW, //go:build
integration && okta_smoke): single test that pings RefreshKeys +
HandleAuthRequest against a live Okta tenant, gated on
OKTA_ISSUER + OKTA_CLIENT_ID + OKTA_CLIENT_SECRET env vars. Skips
cleanly when any are missing. Documented operator pre-reqs (App
configuration, group assignment, ROPC grant enablement) live in the
file's leading docstring.

Makefile (MODIFIED): two new targets:

* `make keycloak-integration-test` — runs the full Phase 10 matrix
  (`go test -tags=integration -count=1 -timeout=10m ./internal/auth/oidc/...`).
* `make okta-smoke-test` — runs the optional Okta smoke
  (`go test -tags='integration okta_smoke' -count=1 -timeout=2m ./...`).

Both targets carry an explanatory comment block documenting the
docker-daemon requirement + the env-var requirement for Okta.

Verification
============

* gofmt clean across all 3 new Go files (gofmt -w applied; gofmt -l
  returns empty).
* `go vet ./internal/auth/oidc/... ./internal/auth/... ./internal/api/handler/...
  ./internal/api/router/... ./internal/mcp/...` — clean.
* `go vet -tags integration ./internal/auth/oidc/...` — clean.
* `go vet -tags 'integration okta_smoke' ./internal/auth/oidc/...` — clean.
* `go test -short -count=1 ./internal/auth/oidc/...` — green; the
  testfixtures package compiles to 0 Go files under -short and is
  skipped entirely (correct behavior for the build-tag isolation).
* No go.mod / go.sum drift — testcontainers-go was already in the
  graph from Phase 2.

Live container run (ship gate)
==============================

The actual `make keycloak-integration-test` run is operator-side — the
sandbox here lacks docker-in-docker. The CI runner with Docker available
is where the matrix flips green. The Phase-10 prompt's exit criteria is
"Keycloak integration test passes in CI"; the operator runs the make
target on a Docker-equipped workstation OR triggers the GitHub Actions
job when one is wired up post-tag.

Not in this commit (deferred)
=============================

* GitHub Actions workflow that invokes `make keycloak-integration-test`
  on push. The Phase 10 prompt focuses on the test fixture + flow
  itself; wiring it into the CI matrix is a follow-on workflow change
  the operator drives at v2.1.0 tag time.
* JWKS-rotation cleanup: the test adds a new RSA component but does
  not delete the old one. Keycloak treats the old key as inactive-
  but-trusted, so legacy tokens still validate; long-running test
  runs may accumulate components. Acceptable for ephemeral test
  fixtures.
2026-05-10 07:54:36 +00:00
shankar0123 b09bd0984a auth-bundle-2 Phase 9: 11 OIDC + session MCP tools (Phase-5 surface parity)
Closes Phase 9 of cowork/auth-bundle-2-prompt.md. Every Phase-5 HTTP
endpoint now has a matching MCP tool so operators driving certctl
from Claude / VS Code / any MCP client get the same OIDC-provider +
group-mapping + session management capability the GUI + CLI already
expose.

Coverage map (each tool → HTTP endpoint → permission)
=====================================================

  certctl_auth_list_oidc_providers      GET    /v1/auth/oidc/providers                   auth.oidc.list
  certctl_auth_get_oidc_provider        GET    /v1/auth/oidc/providers (filtered)        auth.oidc.list
  certctl_auth_create_oidc_provider     POST   /v1/auth/oidc/providers                   auth.oidc.create
  certctl_auth_update_oidc_provider     PUT    /v1/auth/oidc/providers/{id}              auth.oidc.edit
  certctl_auth_delete_oidc_provider     DELETE /v1/auth/oidc/providers/{id}              auth.oidc.delete
  certctl_auth_refresh_oidc_provider    POST   /v1/auth/oidc/providers/{id}/refresh      auth.oidc.edit
  certctl_auth_list_group_mappings      GET    /v1/auth/oidc/group-mappings?provider_id  auth.oidc.list
  certctl_auth_add_group_mapping        POST   /v1/auth/oidc/group-mappings              auth.oidc.edit
  certctl_auth_remove_group_mapping     DELETE /v1/auth/oidc/group-mappings/{id}         auth.oidc.edit
  certctl_auth_list_sessions            GET    /v1/auth/sessions[?actor_id=&actor_type=] auth.session.list (own) | auth.session.list.all (other)
  certctl_auth_revoke_session           DELETE /v1/auth/sessions/{id}                    auth.session.revoke (or own-bypass)

Implementation notes
====================

internal/mcp/tools_auth_bundle2.go (NEW): 11 tools wired through three
focused register functions (registerAuthOIDCProviderTools,
registerAuthGroupMappingTools, registerAuthSessionTools). Every tool
routes through the existing Client (Get/Post/Put/Delete) so permission
gates fire server-side via the Phase-5 rbacGate wrappers — a non-admin
caller's MCP tool invocation gets whatever 403 the underlying HTTP
handler emits, not an MCP-side bypass.

Empty-id guard
--------------

Every path-id tool short-circuits to errorResult(fmt.Errorf("id is required"))
BEFORE the HTTP call. Defense against url.PathEscape("") collapsing a
singular op into the list endpoint (which would silently succeed against
a permissive backend). Same pattern across all 6 path-id tools (get,
update, delete, refresh provider; remove mapping; revoke session).

auth_get_oidc_provider list-then-filter
---------------------------------------

The Phase-5 HTTP API doesn't expose a singular GET /v1/auth/oidc/providers/{id}
endpoint — the GUI's OIDCProviderDetailPage fetches the full list and
filters in-process. The MCP tool mirrors that pattern exactly: GET the
list, JSON-decode the providers envelope, walk the array filtering by
id, return the matching raw JSON object on hit or an explicit "oidc
provider not found: <id>" error on miss. This keeps the MCP surface
in lockstep with the GUI's permission boundary (auth.oidc.list grants
"see any provider", as it does on the GUI) without inventing a new HTTP
endpoint.

internal/mcp/types.go (MODIFIED): 8 new input types matching the
Phase-5 wire shapes (oidcProviderRequest at internal/api/handler/auth_session_oidc.go).
client_secret on Update is optional — empty preserves the existing
ciphertext on the server, providing a value rotates. Mirrors the GUI's
edit-without-rotate UX from web/src/pages/auth/OIDCProviderDetailPage.tsx.

internal/mcp/tools.go (MODIFIED): registerAuthBundle2Tools wired into
RegisterTools alongside the Bundle 1 Phase 11 registerAuthTools.

Test coverage
=============

internal/mcp/tools_auth_bundle2_test.go (NEW), 5 test cases:

* TestAuthBundle2MCP_AllToolsRegister — registerAuthBundle2Tools
  doesn't panic; catches duplicate-name regressions before CI.
* TestAuthBundle2MCP_PathsAndMethods — 11 cases (one per tool) +
  the admin-other-actor variant of list_sessions; asserts the right
  method + path + body + query string fires against the mock API.
* TestAuthBundle2MCP_ForbiddenSurfacesError — every tool's underlying
  HTTP path returns a propagated error containing "forbidden" / "403"
  when the mock returns 403, exercising the errorResult fence path.
* TestAuthBundle2MCP_GetProviderFiltersListByID — pins the list-then-
  filter shape end-to-end with both the hit-and-return (returns the
  matching raw JSON object) and miss-returns-error (sentinel string
  "oidc provider not found") branches.
* TestAuthBundle2MCP_EmptyIDInputShortCircuits — pins the
  strings.TrimSpace empty-id guard at the top of every path-id handler.
* TestAuthBundle2MCP_PromptCoverage — every tool the prompt enumerates
  is also present in tools_per_tool_test.go's allHappyPathCases (so
  the live-dispatch + 5xx error-path tests cover all 11 tools).

internal/mcp/tools_per_tool_test.go (MODIFIED): 11 new toolCase entries
in allHappyPathCases (live in-memory MCP dispatch + happy-path fence
shape + 5xx error-path fence shape) + a mock-API special case for
GET /api/v1/auth/oidc/providers that returns the right envelope shape
({"providers":[{"id":"op-okta",...}]}) so the get_oidc_provider tool's
in-process filter resolves under the live dispatch.

Verification
============

* gofmt + go vet — clean across internal/mcp/...
* go test -short -count=1 — green across internal/mcp + internal/auth/...
  + internal/api/handler + internal/api/router (13 packages, 0 failures).
* MCP tool count re-derive (CLAUDE.md command):
    grep -cE 'mcp\.AddTool\(' internal/mcp/tools*.go
  → tools.go=121, tools_auth.go=12, tools_auth_bundle2.go=11 (new),
  tools_est.go=6 — total 150. Matches the live count
  TestMCP_RegisterTools_DispatchableToolCount asserts.
* staticcheck deferred — sandbox /tmp at 99% disk, can't install the
  binary; all SA*/ST* lints would have run via the staticcheck-CI step
  on push. go vet caught the only real issue (an unused context import)
  before commit.

Not in this commit (deferred)
=============================

* Break-glass admin MCP tools (4 endpoints from Phase 7.5). The Phase 9
  prompt does NOT enumerate break-glass tools; its exit criteria is
  "Every API endpoint from Phase 5 has an MCP tool". Phase 5 does not
  include the break-glass surface (Phase 7.5 ships those endpoints with
  surface-invisibility semantics: 404 when CERTCTL_BREAKGLASS_ENABLED=false,
  which complicates LLM tool-discovery UX). If the operator wants
  break-glass MCP parity, that's a follow-on bundle.
2026-05-10 07:40:34 +00:00
shankar0123 9143003e95 auth-bundle-2 Phase 8: GUI auth surface (OIDC providers + group mappings + sessions + LoginPage IdP buttons + AuthState refactor + logout wiring)
Closes Phase 8 of cowork/auth-bundle-2-prompt.md. Every Bundle 2 endpoint
now has a permission-gated, data-testid-instrumented React surface.

Frontend changes
================

api/client.ts (Category H — AuthState refactor):
* fetchJSON now sends `credentials: 'include'` on every request so the
  HttpOnly session cookie + the JS-readable CSRF cookie ride along with
  Bearer-mode requests transparently. Mode is determined per call by
  what cookies are present, NOT by a state-machine — the same client
  works for Bearer-only deploys, session-only deploys, and the mixed
  upgrade path described in cowork/auth-bundles-index.md Category H.
* readCSRFCookie() + isStateChangingMethod() helpers auto-attach
  `X-CSRF-Token` to POST/PUT/PATCH/DELETE when the CSRF cookie exists.
  Bearer-only callers ride through unchanged (no CSRF cookie → no
  header → backend's CSRF middleware skips).
* AuthInfoResponse extended with optional `oidc_providers?:
  AuthInfoOIDCProvider[]` matching the Phase 6 server extension.
* New API helpers (1:1 with Phase 5 / 7.5 endpoints):
  - listOIDCProviders / createOIDCProvider / updateOIDCProvider /
    deleteOIDCProvider / refreshOIDCProvider
  - listGroupMappings / addGroupMapping / removeGroupMapping
  - listSessions(actorID?, actorType?) / revokeSession / logout
  - breakglassLogin / breakglassSetPassword / breakglassUnlock /
    breakglassRemove
  Permission gates fire server-side; the GUI predicates are UX only.

pages/auth/OIDCProvidersPage.tsx (NEW):
* Lists configured OIDC providers, gated on `auth.oidc.list`.
* Empty state + error state + loading state.
* Embedded Configure-Provider modal with form fields for name,
  issuer_url, client_id, client_secret, redirect_uri,
  groups_claim_path/format, fetch_userinfo, scopes. Modal hidden
  unless caller has `auth.oidc.create`.
* Unsaved-changes confirmation on cancel.

pages/auth/OIDCProviderDetailPage.tsx (NEW):
* Provider config dl + edit/delete/refresh action buttons.
* Edit and refresh require `auth.oidc.edit`. Delete requires
  `auth.oidc.delete`.
* Type-confirm-name delete dialog. Surfaces server's 409 Conflict
  ("ErrOIDCProviderInUse") inline so the operator knows to revoke
  the provider's active sessions first.
* Refresh discovery cache button → POST .../refresh → server re-runs
  RefreshKeys with the IdP-downgrade-attack defense from Phase 3.
* Group→role mappings link.

pages/auth/GroupMappingsPage.tsx (NEW):
* Per-provider group-claim → role-id mapping CRUD.
* Empty state explains the fail-closed semantics from Phase 3
  (no mappings ⇒ no users authenticate via this provider).
* Inline add form (group_name input + role_id select populated from
  `authListRoles`); add/remove gated on `auth.oidc.edit`.

pages/auth/SessionsPage.tsx (NEW):
* Default "My sessions" view available to anyone holding
  `auth.session.list`.
* "All actors (admin)" toggle exposed only when caller holds
  `auth.session.list.all`; renders an actor_id filter input that
  threads ?actor_id= through the GET.
* Self-pill marker on the caller's own rows.
* Revoke button is shown when (a) the row is the caller's own session
  (handler-side own-bypass) OR (b) caller holds `auth.session.revoke`.
* Confirms via window.confirm; surfaces revocation errors inline.

pages/LoginPage.tsx (MODIFIED):
* Fetches /v1/auth/info on mount; if `oidc_providers[]` is non-empty,
  renders one "Sign in with X" button per provider linking to the
  provider's `login_url` (the server-side handler in Phase 5 builds
  this URL with state + nonce + PKCE verifier sealed in the pre-login
  cookie; the GUI never touches those values).
* The API-key form remains as a fallback for Bearer-mode deploys and
  the Phase 7.5 break-glass path.
* All interactive elements carry data-testid:
  login-oidc-providers / login-oidc-button-{id} / login-api-key-form /
  login-api-key-input / login-api-key-submit.

components/AuthProvider.tsx (MODIFIED):
* logout() now also fires POST /auth/logout via the api/client helper
  before clearing local state. The endpoint is auth-exempt; the
  catch-and-swallow keeps the local logout flow working even if the
  cookie is already invalid (idempotent server-side as well).

components/Layout.tsx (MODIFIED):
* Two new nav entries under the Auth section: "OIDC Providers" + "Sessions".

main.tsx (MODIFIED):
* Four new routes:
  - /auth/oidc/providers
  - /auth/oidc/providers/:id
  - /auth/oidc/providers/:id/mappings
  - /auth/sessions

Vitest coverage
===============

Five new test files, 28 new test cases. Pattern matches Bundle 1
Phase 10's Vitest scaffold (vi.mock api/client, render with
QueryClient + MemoryRouter, authMe-driven permission shaping,
data-testid selectors).

* OIDCProvidersPage.test.tsx (5 tests): ErrorState w/o auth.oidc.list,
  empty state, list + create button render, hide-create-button
  without auth.oidc.create, submit-creates-via-API.
* OIDCProviderDetailPage.test.tsx (5 tests): ErrorState w/o list,
  full-perms render, hide edit/refresh/delete with only list,
  refresh button calls API, delete confirm-button stays disabled
  until typed text matches provider name.
* GroupMappingsPage.test.tsx (5 tests): ErrorState w/o list, empty
  fail-closed warning, mapping rows render, hide-form without
  auth.oidc.edit, submit-add-form-calls-API.
* SessionsPage.test.tsx (6 tests): ErrorState w/o list, own sessions
  + self-pill, hide All-actors toggle without list.all, show
  toggle with list.all, hide revoke on other-actor sessions without
  auth.session.revoke, click-revoke calls API after window.confirm.
* LoginPage.test.tsx (extended +2 tests): renders OIDC buttons when
  /auth/info reports providers; omits the OIDC block when none.

Verification
============

* `npx tsc --noEmit` — 0 errors.
* Vitest run across api/components/hooks/utils/auth/pages = 475 tests,
  all green.
* `npm run build` — green (980 KB bundle, no surprises vs Phase 7).
* No backend (Go) changes in this commit; Phase 5-7.5 surfaces
  consumed unchanged.

Not in this commit (deferred)
=============================

* "Test login flow" button on the provider detail page (prompt §Phase 8
  optional row). Requires a server-side test=true flag on the OIDC
  login handler — out of scope for the GUI commit.
* `web/src/__tests__/e2e/` Keycloak-via-testcontainers harness for the
  15 comprehensive flow checks. Tracked under Phase 10 of
  cowork/auth-bundle-2-prompt.md.
2026-05-10 07:23:41 +00:00
shankar0123 1d01c87663 auth-bundle-2 Phase 7 + Phase 7.5: OIDC first-admin bootstrap +
break-glass admin (Argon2id, lockout, default-OFF, surface-invisibility)

Phase 7 — OIDC first-admin bootstrap (Decision 3):

  - Optional AdminBootstrapHook closure on *oidc.Service. When wired,
    HandleCallback consults the hook AFTER group resolution + user
    upsert and BEFORE the empty-mapping fail-closed check. Hook
    receives (providerID, groups, userID); returns grantAdmin=true
    when the user matches CERTCTL_BOOTSTRAP_ADMIN_GROUPS AND no
    admin exists yet in the tenant.
  - cmd/server/main.go wires the hook as a closure that:
      * Filters by CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID (if configured).
      * Probes AdminExists via authActorRoleRepo (admin-already-exists
        silently returns false; bootstrap mode is one-shot per tenant).
      * Walks group intersection.
      * On match: grants r-admin via authActorRoleRepo.Grant + emits
        the bootstrap.oidc_first_admin audit row with
        event_category=auth + INFO log.
  - Coexists with the Bundle 1 env-var-token bootstrap. Both paths
    can be configured; first match wins (admin-existence probe
    short-circuits the second).
  - HandleCallback's empty-mapping fail-closed check moved AFTER the
    hook so a fresh deployment with zero group_role_mappings can
    still mint the first admin.
  - 5 tests in service_test.go: hook grants admin on match, hook
    returns false preserves empty-mapping fail-closed, admin-already-
    exists silently falls through to normal mapping, hook-error wraps
    + bubbles, idempotent when admin is already in the mapped role set.

Phase 7.5 — Break-glass admin (Decision 4, default-OFF):

Migration 000038 ships:

  - breakglass_credentials table — at-most-one-credential-per-actor
    (UNIQUE(actor_id)), Argon2id PHC-format password_hash, lockout
    state machine (failure_count, locked_until, last_failure_at).
    FK CASCADE on users(id) so deleting a user atomically removes
    their credential.
  - Two new permissions seeded into r-admin only:
      auth.breakglass.admin — set/rotate/unlock/remove credentials.
      auth.breakglass.login — actor uses break-glass to log in.
    CanonicalPermissions extended in lockstep.

internal/auth/breakglass/service.go (~580 LOC):

  - Service.Enabled() reflects CERTCTL_BREAKGLASS_ENABLED.
  - SetPassword: Argon2id with OWASP 2024 params (m=64MiB, t=3, p=4,
    salt=16 random bytes, output=32 bytes); per-password random salt;
    PHC-format hash output. Min 12 / max 256 byte input.
  - Authenticate: constant-time-compare via subtle.ConstantTimeCompare
    on every code path. Identical 401 + identical timing across the
    wrong-password / locked-account / non-existent-actor paths so an
    attacker cannot probe whether a given actor has break-glass
    configured. Non-existent-actor + locked-account paths run a
    verifyDummy() Argon2id pass for timing parity. Lockout state
    machine: failure_count++ on every wrong attempt; threshold (default
    5) trips locked_until = NOW() + duration (default 15m). Successful
    Authenticate resets the counter. Reset-window: failures aged out
    after CERTCTL_BREAKGLASS_LOCKOUT_RESET_INTERVAL (default 1h)
    auto-reset on next attempt.
  - Unlock + RemoveCredential: admin-only (auth.breakglass.admin
    gated at the router via rbacGate). Audit rows on every operation.
  - All public methods refuse to act when Enabled()==false (returns
    ErrDisabled; the handler maps to HTTP 404 — surface invisibility).

internal/repository/postgres/breakglass.go ships the 5-method
postgres impl with atomic single-statement IncrementFailure (so
concurrent racing wrong-password attempts can't observe an
intermediate state and slip past the threshold) and idempotent
ResetFailureCount.

internal/api/handler/auth_breakglass.go ships the 4-endpoint HTTP
surface:

  - POST /auth/breakglass/login (auth-exempt; 5/min rate-limited per
    source IP via the existing rate limiter; returns 404 when
    disabled). On success sets the post-login session cookie + CSRF
    cookie via SessionService.Create + 204. On any failure:
    uniform 401 + identical timing (the service has already audited
    the specific failure category).
  - POST /api/v1/auth/breakglass/credentials (auth.breakglass.admin)
  - POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock
    (auth.breakglass.admin)
  - DELETE /api/v1/auth/breakglass/credentials/{actor_id}
    (auth.breakglass.admin)

Admin endpoints share the surface-invisibility property: when
CERTCTL_BREAKGLASS_ENABLED=false, every admin endpoint also returns
404 (not 403) so probing via the admin surface gets the same signal
as probing the login endpoint.

Tests (internal/auth/breakglass/service_test.go):

All 8 Phase 7.5 spec-mandated negative cases:

  1. Service.Enabled()==false → all ops return ErrDisabled.
  2. Wrong password → ErrInvalidCredentials, failure_count++,
     audit row with event_category=auth.
  3. Failure_count exceeds threshold → locked, subsequent attempts
     (including with the CORRECT password) return identical-shape
     401 while the lockout window holds.
  4. Lockout window expires → next attempt with correct password
     succeeds + resets the counter.
  5. Password < 12 bytes (or > 256 bytes) → ErrWeakPassword.
  6. Password leak hygiene — the service has zero slog calls; the
     audit-row map literal never includes the password plaintext.
  7. Argon2id hash never appears in logs OR API responses — pinned
     by `json:"-"` tag on BreakglassCredential.PasswordHash + a
     belt-and-braces json.Marshal probe asserting the hash bytes
     never appear in the marshaled output.
  8. Constant-time-compare verified via timing-statistical test —
     wrong-password vs no-credential paths take statistically
     indistinguishable time (within 5x ratio). The verifyDummy()
     hash compute on the no-credential + locked paths is what
     keeps timing parity; absent that, an attacker could side-
     channel "actor doesn't have a credential" via timing.

Plus coverage-lift batch covering: SetPassword first-time vs rotate,
no-caller-id rejection, no-target-id rejection, RNG failure surface,
Authenticate happy-path mints session, no-credential audit row,
session-mint-failure surface, FailureResetInterval recycle, Unlock
+ RemoveCredential happy paths, hash-format unit tests (round-trip,
mismatch, malformed/wrong-version/bad-base64 formats), nil-audit +
nil-session pass-through.

Coverage on internal/auth/breakglass/ at 91.5% per-statement (above
the Phase 7.5 spec ≥ 90% floor).

cmd/server/main.go wiring:

  - Constructs breakglassRepo + breakglassService + breakglassHandler
    after the OIDC service block.
  - breakglassSessionMinterAdapter shim bridges *session.Service.Create
    to the breakglass.SessionMinter port.
  - Logs WARN at boot when CERTCTL_BREAKGLASS_ENABLED=true (operator
    visibility for the deliberate SSO-bypass).

internal/config/config.go gains:

  - AuthConfig.BootstrapAdminGroups + BootstrapOIDCProviderID for
    Phase 7 (CERTCTL_BOOTSTRAP_ADMIN_GROUPS comma-list +
    CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID).
  - AuthConfig.Breakglass nested struct with 4 env vars
    (CERTCTL_BREAKGLASS_ENABLED + LOCKOUT_THRESHOLD + LOCKOUT_DURATION
    + LOCKOUT_RESET_INTERVAL).

Router wiring:

  - 4 new breakglass routes registered when reg.AuthBreakglass != nil;
    public login route via direct r.mux.Handle (auth-exempt), 3 admin
    routes via r.Register + rbacGate(auth.breakglass.admin).
  - POST /auth/breakglass/login pinned in AuthExemptRouterRoutes
    allowlist with Phase 7.5 justification.
  - SpecParityExceptions extended with 4 new entries documenting
    the Phase 7.5 deferral of full per-endpoint OpenAPI rows
    (handler doc-block at the top of auth_breakglass.go is the
    operator-facing reference).

Threat model (encoded in service.go + auth_breakglass.go doc-blocks
+ migration 000038 docstrings, to be promoted to docs/operator/auth-
threat-model.md in Phase 12):

  - Break-glass is a deliberate bypass of the SSO security boundary.
    An attacker who phishes the password OR finds it in a compromised
    password manager bypasses MFA, OIDC, and every group-claim gate.
  - Recommendation: keep CERTCTL_BREAKGLASS_ENABLED=false in steady-
    state. Enable only during SSO-broken incidents. Disable after
    recovery.
  - WebAuthn pairing (v3 per Decision 12) is the load-bearing second
    factor. Without it, break-glass is best treated as an emergency-
    only path.
  - Audit trail surfaces every break-glass action under
    event_category=auth; the auditor role can monitor for unexpected
    break-glass logins.

Verifications: gofmt clean, go vet clean across all touched packages,
go test -short -count=1 green across internal/auth/oidc (3.0s; new
Phase 7 hook tests integrated alongside the 21+ Phase 3 negatives),
internal/auth/breakglass (3.6s; 8 spec-mandated negatives + coverage
batch passing), internal/config + internal/domain/auth + internal/api/
router + internal/api/handler all green, no regressions in Bundle 1
packages.
2026-05-10 06:51:41 +00:00
shankar0123 3189f3cd71 auth-bundle-2 Phase 6: session middleware + CSRF token plumbing +
chained-auth combinator + AuthInfo OIDC providers extension + 2 CI
guards (Bundle-1-compat + Bundle-1-to-2-upgrade)

Phase 6 wires the Phase 4 session service + Phase 5 OIDC handlers into
the request path. Three middlewares + one combinator land in
internal/auth/session/middleware.go:

  1. SessionMiddleware reads `certctl_session` cookie, validates via
     SessionService.Validate, populates the legacy UserKey/AdminKey
     + Phase 3 RBAC context keys (ActorIDKey/ActorTypeKey/TenantIDKey)
     so downstream RequirePermission + audit-attribution see a
     consistent caller. Best-effort UpdateLastSeen keeps the idle-
     expiry sliding window fresh. CRITICALLY: never 401s on validate
     failure — defers to the next middleware so the chained-auth
     combinator can fall back to Bearer.

  2. CSRFMiddleware gates state-changing methods (POST/PUT/DELETE/
     PATCH) for session-authenticated requests. API-key actors are
     EXEMPT (no session row in context => CSRF doesn't apply; they're
     not browser-driven). Constant-time-compares SHA-256(X-CSRF-Token
     header) against the session row's stored hash via
     SessionService.ValidateCSRF. Mismatch returns 403.

  3. ChainAuthSessionThenBearer is the load-bearing chained-auth
     combinator: tries the session cookie first; on miss/invalid,
     falls back to the API-key Bearer middleware; if neither
     authenticates, 401. The composition uses bearerSkipIfAuthenticated
     so a request with both a valid session AND a valid Bearer uses
     the session (cookie wins per the Bundle 2 contract).

Middleware chain order in cmd/server/main.go (per Phase 6 spec):

  RequestID → Logging → Recovery → CORS → RateLimit → AUTH (chained:
  session → Bearer) → CSRF (state-changing only; API-key exempt) →
  Audit → Handler

The chained authMiddleware replaces the bare Bundle-1 bearerMiddleware
at the chain entry point; csrfMiddleware lands immediately after so
session-authenticated requests pass through CSRF before audit. Both
new middlewares are pass-throughs when sessionService is nil
(pre-Phase-4 builds).

AuthInfo extension (Category E): GET /api/v1/auth/info now returns the
list of configured OIDC providers (id + display_name + login_url
where login_url = `/auth/oidc/login?provider=<id>`) so the GUI Login
page renders the correct "Sign in with X" buttons. Endpoint stays
auth-exempt; the providers list is public configuration. Wired via
HealthHandler.OIDCProvidersResolver + a new OIDCProvidersListResolver
projection interface; the cmd/server adapter
oidcProvidersListAdapter projects the postgres OIDCProviderRepository
into the public-safe shape. Resolver lookups are best-effort: failures
fall back to the minimal payload rather than 500-ing the GUI's auth
probe. Nil resolver preserves the pre-Phase-6 minimal shape so test
fixtures + no-db deploys keep compiling.

Bypass list preserved (Category E): the existing public-route
allowlist in router.AuthExemptRouterRoutes is preserved by virtue of
those routes registering via direct r.mux.Handle (they bypass the
entire chain). The protocol-endpoint allowlist (ACME/SCEP/EST/OCSP/
CRL) bypasses via cmd/server/main.go::buildFinalHandler URL-prefix
dispatch — those routes never reach the auth middleware at all. Both
preservations are pinned by the Bundle-1 compat CI guard below.

Tests (internal/auth/session/middleware_test.go):

All 7 Phase 6 spec-mandated middleware-chain tests pass:

  1. Session cookie + correct CSRF → 200.
  2. Session cookie + wrong CSRF → 403.
  3. Bearer-only (no session) + no CSRF → 200 (API-key actors are
     CSRF-exempt by design).
  4. No cookie + no Bearer → 401.
  5. Expired cookie + valid Bearer → fall back to Bearer succeeds.
  6. Tampered cookie → 401 (no Bearer to fall back to).
  7. Bypass-list awareness — state-changing method, no auth, no
     session row → uniform 401 (NOT a CSRF 403; the CSRF check is
     gated on session-row presence and never fires for unauth
     requests).

Plus coverage-lift tests covering nil-service pass-through, safe-
methods bypass, SessionFromContext nil + populated, isStateChangingMethod
matrix, clientIPFromRequest variants (RemoteAddr / XFF first-hop /
XFF single / no-port), nil-bearer chain branches.

Coverage on internal/auth/session/middleware.go: 100% per-function
across the 9 entry points (SessionValidator interfaces +
NewSessionMiddleware + NewCSRFMiddleware + ChainAuthSessionThenBearer +
bearerSkipIfAuthenticated + SessionFromContext + isStateChangingMethod
+ clientIPFromRequest + lastIndexByte). Package coverage 94.9%.

Two new CI guards:

  scripts/ci-guards/bundle-1-compat-regression.sh — Bundle-1-only
  compat invariants. Static-source checks that protect the Bundle-1
  path since spinning up docker-compose + running the integration
  test suite is sandbox-infeasible:
    1. SessionMiddleware MUST defer-to-next on missing/invalid cookie.
    2. CSRFMiddleware MUST be pass-through on missing session row.
    3. cmd/server/main.go MUST wire ChainAuthSessionThenBearer.
    4. The 4 public OIDC routes MUST be in AuthExemptRouterRoutes.
    5. AuthInfo MUST guard on OIDCProvidersResolver != nil.

  scripts/ci-guards/bundle-1-to-2-upgrade-regression.sh — Bundle-1 →
  Bundle-2 upgrade invariants:
    1. Migrations 000034..000037 use CREATE TABLE IF NOT EXISTS.
    2. Migrations are wrapped in BEGIN; ... COMMIT;.
    3. NO DROP TABLE / ALTER ... DROP COLUMN against any of the 19
       protected Bundle-1 tables (api_keys, audit_events, certificates,
       certificate_versions, profiles, issuers, targets, agents, jobs,
       owners, teams, agent_groups, notifications, roles, permissions,
       role_permissions, actor_roles, tenants, approvals,
       intermediate_cas, issuance_approval_requests).
    4. 000037 INSERTs use ON CONFLICT DO NOTHING (idempotent re-apply).
    5. ChainAuthSessionThenBearer is wired (Bundle-1 Bearer keys
       continue to authenticate post-upgrade).
    6. Bootstrap handler is registered (fresh-deployment bootstrap
       still works).

Both guards are sandbox-feasible static analysis. When the operator
gets a Linux VM with docker-in-docker, promote both to real `docker
compose up` integration tests against a v2.1.0 baseline DB dump.

Verifications: gofmt clean, go vet ./internal/auth/... ./internal/api/...
./cmd/server/... clean, go test -short -count=1 -race green across
internal/auth/session (94.9% coverage), internal/api/handler,
internal/api/router, no regressions in Bundle 1 packages, both new
ci-guards green.
2026-05-10 06:22:25 +00:00
shankar0123 9c679a5960 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).
2026-05-10 06:08:27 +00:00
shankar0123 17b30c1f7f auth-bundle-2 Phase 4: session service (cookie minting + signature
validation, idle/absolute expiry, signing-key rotation, CSRF, GC),
15-case negative-test matrix, fail-fatal initial-key bootstrap

Phase 4 of the bundle ships the post-login session lifecycle that backs
every authenticated request once Phase 5 wires the OIDC handlers + the
session middleware. The state machine is the load-bearing primitive for
the Bundle 2 control plane: forge a session cookie and you bypass every
RBAC gate.

Service surface (internal/auth/session/service.go, ~880 LOC):

  - Service.Create(actorID, actorType, ip, ua) -> *CreateResult
    Mints a session row; signs the cookie value with the active signing
    key; returns the cookie payload AND the CSRF token plaintext for
    the handler to set on the response.
  - Service.Validate(ValidateInput) -> *Session
    Parses the cookie, looks up the signing key (incl. retired-but-in-
    retention), recomputes HMAC-SHA256, loads the session row, enforces
    revocation + absolute + idle expiry + optional IP/UA bind. Maps to
    one of 9 sentinel errors; the handler uniformly returns 401 to the
    wire (specific reason in the audit row).
  - Service.ValidateCSRF(headerValue, *Session) error
    Constant-time compares SHA-256(header) against the stored hash on
    the session row.
  - Service.UpdateLastSeen / Revoke / RevokeAllForActor
  - Service.RotateCSRFToken — mints fresh token, persists hash, returns
    plaintext; called on login completion, logout, role-change against
    actor, explicit operator rotate.
  - Service.RotateSigningKey — mints new active key, retires previous;
    retired keys stay valid for cfg.SigningKeyRetention so existing
    cookies don't immediately fail.
  - Service.EnsureInitialSigningKey — idempotent; mints first key on
    fresh deploys; emits auth.session_signing_key_bootstrap audit row
    with event_category=auth. Wired into cmd/server/main.go AFTER
    migrations + RBAC backfill, BEFORE the HTTP listener binds; failure
    is FATAL (logger.Error + os.Exit(1)) per the prompt — server refuses
    to boot rather than serve session-less.
  - Service.GarbageCollect — sweeps expired post-login sessions +
    pre-login rows >10min + retired-past-retention signing keys. Wired
    into the new internal/scheduler/scheduler.go::sessionGCLoop on a
    CERTCTL_SESSION_GC_INTERVAL tick.

Cookie wire format (load-bearing):

  v1.<session_id>.<signing_key_id>.<base64url-no-pad(HMAC-SHA256)>

The HMAC input is LENGTH-PREFIXED to defeat concatenation collisions:

  len(session_id) || ":" || session_id || ":" || len(signing_key_id) || ":" || signing_key_id

where len(...) is the ASCII decimal byte-length. Without the length
prefix, the bare-concatenation form `session_id || signing_key_id`
would let a forger swap one byte across the boundary — `<a, bc>` and
`<ab, c>` produce identical HMAC inputs. The length prefix moves the
boundary into the input itself so the two cases can never collide.

The v1. version prefix is reserved. A future incompatible upgrade
ships as v2. and the parser rejects unknown prefixes (no fallback).

CSRF token model:

  - Plaintext goes in a JS-readable certctl_csrf cookie (HttpOnly=false
    intentional; the GUI must read it to echo into X-CSRF-Token header).
  - SHA-256 hash of the plaintext lives on the session row.
  - Validation: SHA-256(X-CSRF-Token) constant-time-compared.
  - Rotated by Service.RotateCSRFToken on login / logout / role-change /
    explicit admin-trigger.

Optional defense-in-depth (default OFF):

  - CERTCTL_SESSION_BIND_IP — Validate compares client IP to row's
    recorded IP. Mismatch -> 401, audit row, session NOT auto-revoked
    (user may have legitimate IP change). Mobile + corporate-NAT
    environments leave this off.
  - CERTCTL_SESSION_BIND_USER_AGENT — same shape against UA.

Configurable lifetimes (env vars wired in internal/config/config.go):

  CERTCTL_SESSION_IDLE_TIMEOUT             1h
  CERTCTL_SESSION_ABSOLUTE_TIMEOUT         8h
  CERTCTL_SESSION_SIGNING_KEY_RETENTION    24h
  CERTCTL_SESSION_GC_INTERVAL              1h
  CERTCTL_SESSION_SAMESITE                 Lax
  CERTCTL_SESSION_BIND_IP                  false
  CERTCTL_SESSION_BIND_USER_AGENT          false

Test surface (internal/auth/session/service_test.go, ~860 LOC):

  All 15 prompt-mandated negative cases:

    1.  Tampered cookie (HMAC byte flipped near segment start where all
        6 bits are real — base64url-no-pad's last char carries only 2
        bits so a tail-flip is unreliable).
    1b. Tampered SESSION_ID segment (same HMAC-recompute outcome).
    2.  Cookie missing v1. prefix.
    3.  Cookie with unknown version prefix (v99).
    4.  Idle expiry — back-dated last_seen_at + idle_expires_at.
    5.  Absolute expiry — back-dated absolute_expires_at.
    6.  Revoked session.
    7.  Wrong signing key id (no row matches).
    8.  Cookie signed under retired-but-in-retention key SUCCEEDS.
    9.  Cookie signed under retired-past-retention key FAILS.
    10. Concatenation collision — direct evidence that
        computeHMAC("abc","de") != computeHMAC("ab","cde") AND that
        a forged-boundary-slide cookie is rejected.
    11. CSRF token missing.
    12. CSRF token mismatch (constant-time compare).
    13. IP-bind enabled + IP changed -> ErrSessionIPMismatch + audit row.
    14. UA-bind enabled + UA changed -> ErrSessionUAMismatch + audit row.
    15. EnsureInitialSigningKey RNG failure -> ErrInitialSigningKeyMintFailed
        wrap (cmd/server/main.go treats as fatal).

  Plus coverage-lift batch covering: every error wrap on every repo
  collaborator (Create, Get, UpdateLastSeen, UpdateCSRFTokenHash,
  Revoke, RevokeAllForActor, GC), every RNG-failure surface in Create /
  RotateCSRFToken / RotateSigningKey, every alg-pinning helper edge,
  the cookie parser's full negative matrix (empty, wrong segment count,
  missing prefixes, bad base64, wrong HMAC length), and a real-encryption
  round-trip via internal/crypto.EncryptIfKeySet -> DecryptIfKeySet so
  the v3-blob path is exercised end-to-end at the session-cookie level.

Coverage:

  internal/auth/session              94.5%  (floor 90)
  internal/auth/session/domain       96+%   (floor 90, Phase 1)

.github/coverage-thresholds.yml extended with 2 new gate entries
(internal/auth/session and internal/auth/session/domain). The
why: paragraphs explain why each fail-closed branch is load-bearing.

Repository extensions:

  internal/repository/session.go gains UpdateCSRFTokenHash on the
  SessionRepository interface; internal/repository/postgres/session.go
  ships the implementation. RotateCSRFToken consumes it.

Scheduler extensions:

  internal/scheduler/scheduler.go gains SessionGarbageCollector
  interface + sessionGC field + sessionGCInterval +
  SetSessionGarbageCollector + SetSessionGCInterval + sessionGCLoop.
  Pattern matches the existing acmeGCLoop: atomic.Bool guard prevents
  concurrent sweeps, sync.WaitGroup tracks for graceful shutdown,
  per-tick context.WithTimeout(1m) bounds a stuck Postgres.

Server wiring:

  cmd/server/main.go constructs sessionService AFTER the bootstrap
  block (post-RBAC backfill) and BEFORE the policy-service block.
  EnsureInitialSigningKey runs immediately; failure is fatal via
  os.Exit(1). The scheduler section wires SetSessionGarbageCollector
  + SetSessionGCInterval alongside the other interval setters and
  emits an Info log so operators can confirm the loop is enabled.

Phase 4 deviation note: Service.GarbageCollect() returns (int, error)
rather than the prompt's literal `error`. The int is the count of
session rows deleted on this sweep; the scheduler discards it (`_, err
:= ...`) but tests + future operator-facing audit rows can read it.
The wider behavior matches the spec exactly.

Verifications: gofmt clean, go vet ./internal/auth/session/...
./internal/scheduler/... ./internal/config/... ./cmd/server/...
./internal/repository/... clean, go test -short -count=1 -race green
across all 3 session packages, full repository + auth + scheduler +
config test sweeps green, no regressions in Bundle 1 packages.
2026-05-10 05:31:24 +00:00
shankar0123 854135dfb7 auth-bundle-2 Phase 3: OIDC service (HandleAuthRequest, HandleCallback,
RefreshKeys), hand-rolled group-claim resolver, 21+ negative-test
matrix, token-leak hygiene, IdP downgrade-attack defense

Phase 3 of the bundle ships the business logic that turns the Phase 2
storage primitives into a working OpenID Connect 1.0 + RFC 7636 PKCE
authorization-code flow against any enterprise IdP (Okta / Azure AD /
Google Workspace / Keycloak / Authentik / Auth0).

Service surface:

  - Service.HandleAuthRequest(providerID) -> authURL, cookie, preLoginID
    Builds the IdP redirect with PKCE-S256 (mandatory; RFC 9700 §2.1.1),
    server-generated 32-byte state + nonce, persisted to the pre-login
    row keyed by the cookie value.
  - Service.HandleCallback(cookie, code, state, ip, ua) -> *CallbackResult
    11-step validation: pre-login lookup-and-consume (single-use),
    constant-time state compare, code-for-token exchange with PKCE
    verifier, ID-token verify (alg pin via go-oidc/v3), service-layer
    re-checks of iss / aud / azp (multi-aud requires it; mismatch
    rejected) / at_hash (REQUIRED when access_token returned —
    Phase 3 lifts the OIDC core "MAY" to a service-level "MUST") /
    exp / iat-window / nonce, group-claim resolution with userinfo
    fallback, group->role mapping (fail-closed on no match),
    user upsert, session mint via SessionMinter port.
  - Service.RefreshKeys(providerID) — explicit cache eviction +
    re-load. Re-runs the IdP downgrade-attack defense so a provider
    that later rotates to advertising HS* / none is caught BEFORE the
    next user login attempt.

Security posture (every fail-closed branch is a sentinel error +
test):

  - Algorithm pinning: allow-list {RS256, RS512, ES256, ES384, EdDSA};
    deny-list {HS256, HS384, HS512, none}. Belt-and-braces re-check
    via isDisallowedAlg after go-oidc.Verify.
  - PKCE-S256 mandatory (oauth2.GenerateVerifier + S256ChallengeOption);
    `plain` rejection sentinel exists for defense-in-depth.
  - State + nonce: 32-byte crypto/rand, base64url-no-pad,
    constant-time compare, single-use.
  - IdP downgrade-attack defense: at provider creation / RefreshKeys,
    reject any IdP whose discovery doc advertises HS* / none in
    id_token_signing_alg_values_supported.
  - JWKS fail-closed: in-flight login fails 503; existing sessions
    untouched. isJWKSFetchError detects the gooidc verify-error
    shape; ErrJWKSUnreachable is the wire mapping.
  - Token-leak hygiene: ID tokens, access tokens, refresh tokens,
    authorization codes, PKCE verifiers, state, nonce, signing key
    bytes — NEVER logged at any level. logging_test.go pins the
    invariant via a slog buffer + grep-assert across HandleAuthRequest,
    HandleCallback, alg rejection, and provider-load paths.

Group-claim resolver (internal/auth/oidc/groupclaim/):

  - Hand-rolled per Decision 10 (no JSON-path lib; ~150 LOC).
  - URL-shape paths (https:// / http://) treated as a single
    literal key — Auth0 namespaced claims like
    https://your-namespace/groups work without splitting on the
    dots in the URL.
  - Dot-separated paths walked through nested map[string]interface{}.
  - []interface{} / []string / single-string normalized to []string;
    bool / number / object / nil → fail closed.
  - 18 unit tests + sentinels (ErrPathEmpty, ErrSegmentMissing,
    ErrSegmentNotObject, ErrInvalidValueType).

Test surface:

  - service_test.go: 57 test functions including all 21 prompt-mandated
    negative cases (wrong aud / wrong iss / expired / unknown alg /
    alg=none / HMAC alg / azp missing on multi-aud / azp mismatched /
    at_hash missing / at_hash mismatched / iat in future / iat too old /
    nonce mismatched / state mismatched / state replayed / PKCE plain
    sentinel / pre-login replay / forged cookie / IdP downgrade /
    group-claim missing / group-claim unmapped) plus the userinfo
    fallback matrix (happy path + endpoint-missing + endpoint-failing +
    userinfo-also-empty), HandleAuthRequest entry point + RNG-failure
    paths, upsertUser update + create + display-name fallback +
    Validate-error paths, decryptClientSecret real-encrypt round-trip
    + bad-passphrase, alg-parser malformed-header matrix.
  - logging_test.go: 4 hygiene tests pinning no token / code / verifier /
    state / cookie / client_secret / alg name appears in any captured
    log line.
  - groupclaim/resolver_test.go: 18 cases covering Okta string-array,
    Keycloak realm_access.roles, Auth0 namespaced URL claim,
    single-string normalization, deeply-nested 3-segment walks, and
    every fail-closed branch.

Coverage:
  internal/auth/oidc                  92.2%  (floor: 90)
  internal/auth/oidc/groupclaim      100.0%  (floor: 95)
  internal/auth/oidc/domain           96.2%  (floor: 90)

Coverage gates added at .github/coverage-thresholds.yml so a future
regression in any fail-closed branch fails CI before the commit lands.

Phase 3 of cowork/auth-bundle-2-prompt.md is closed. Next up: Phase 4
(Session service: cookies, revocation, sliding-vs-absolute expiry).
2026-05-10 04:56:03 +00:00
shankar0123 95f1d6cf63 auth-bundle-2 Phase 2b: repository interfaces + Postgres impls + integration tests
Closes Phase 2 end-to-end. Builds on Phase 2a's three migrations
(000034 oidc_providers + group_role_mappings, 000035 sessions +
session_signing_keys, 000036 users) by shipping the repository surface
Phase 3+ services consume.

Interfaces:
* internal/repository/oidc.go - OIDCProviderRepository (List, Get,
  GetByName, Create, Update, Delete) + GroupRoleMappingRepository
  (ListByProvider, Get, Add, Remove, Map). Sentinels:
  ErrOIDCProviderNotFound, ErrOIDCProviderDuplicateName,
  ErrOIDCProviderInUse (FK ON DELETE RESTRICT translation),
  ErrGroupRoleMappingNotFound, ErrGroupRoleMappingDuplicate.
* internal/repository/session.go - SessionRepository (Create, Get,
  ListByActor, UpdateLastSeen, Revoke, RevokeAllForActor,
  GarbageCollectExpired, Delete) + SessionSigningKeyRepository (List,
  GetActive, Get, Add, Retire, Delete). Sentinels: ErrSessionNotFound,
  ErrSessionRevoked, ErrSessionExpired, ErrSessionSigningKeyNotFound,
  ErrSessionSigningKeyInUse.
* internal/repository/user.go - UserRepository (Get, GetByOIDCSubject,
  Create, Update, ListAll). Sentinels: ErrUserNotFound,
  ErrUserDuplicateOIDCSubject.

Postgres implementations:
* internal/repository/postgres/oidc.go - 309 lines. Translates
  SQLSTATE 23505 (unique_violation) to ErrOIDCProviderDuplicateName /
  ErrGroupRoleMappingDuplicate; SQLSTATE 23503 (foreign_key_violation)
  to ErrOIDCProviderInUse so the Phase 5 handler maps to HTTP 409
  when an operator tries to delete a provider with authenticated
  users. pq.StringArray bridges Go []string to Postgres TEXT[] for
  scopes + allowed_email_domains. Map() uses
  `WHERE group_name = ANY($2)` so a single SELECT resolves N IdP
  group claims at once.
* internal/repository/postgres/session.go - 350 lines. Both Session +
  SessionSigningKey repos. Revoke + Retire are idempotent (re-revoking
  an already-revoked session returns nil; same for retire). The
  GarbageCollectExpired sweep deletes both
  absolute-expiry-passed sessions AND pre-login rows older than the
  10-minute TTL in one DELETE so the scheduler tick is cheap.
  ErrSessionSigningKeyInUse pinned via SQLSTATE 23503 from the
  sessions.signing_key_id FK ON DELETE RESTRICT.
* internal/repository/postgres/user.go - 137 lines. GetByOIDCSubject
  is the Phase 3 hot-path lookup; the (oidc_provider_id,
  oidc_subject) UNIQUE constraint trip translates to
  ErrUserDuplicateOIDCSubject. Update only writes the mutable field
  set (email, display_name, last_login_at, webauthn_credentials);
  oidc_subject + oidc_provider_id are immutable per the
  per-(provider, subject) identity model.

Integration tests (testing.Short()-gated, testcontainers + Postgres
16 Alpine, schema-per-test isolation via getTestDB().freshSchema):

* oidc_test.go: 11 tests covering happy-path + GetNotFound +
  DuplicateName + List + Update + DeleteNotFound + DeleteSucceeds +
  DeleteRefusedWhenUsersReference (the FK ON DELETE RESTRICT pin);
  GroupRoleMapping coverage includes Add/List/Map (3 cases:
  marketing-not-mapped, multi-group hits, empty groups returns
  empty), Duplicate rejection, and the ON DELETE CASCADE on
  provider deletion.
* session_test.go: 12 tests covering SessionSigningKey + Session.
  Key tests: GetActiveSkipsRetired (mints older, retires it, mints
  newer, asserts GetActive returns newer), DeleteRefusedWhenSessions-
  Reference (FK pin), RetireIsIdempotent. Session tests:
  CreateAndGet roundtrip, GetNotFound, Revoke + idempotent re-Revoke,
  ListByActor (3 active + 1 revoked + 1 pre-login -> returns 3,
  pinning the WHERE filter), RevokeAllForActor, GarbageCollectExpired
  (seeds an absolute-expired row + pre-login >10min row + active
  session via raw SQL to bypass CHECK constraints, asserts GC kills
  exactly 2 + active survives), UpdateLastSeen.
* user_test.go: 7 tests covering CreateAndGet, GetNotFound,
  GetByOIDCSubject (hit + miss), DuplicateOIDCSubjectRejected,
  UpdateMutableFields (asserts oidc_subject NOT mutated by Update),
  ListAll, FKRestrictsProviderDelete (mirror of the OIDC test from
  the user side - both ends of the FK contract pinned).

Verifications:
* gofmt -l clean across all 9 new files.
* go vet ./internal/repository/postgres/ rc=0.
* go test -short -count=1 green on internal/repository/postgres/ +
  internal/auth/... + Bundle 1 packages (testing.Short() skips the
  testcontainers integration tests, but the test files compile + the
  short-mode skip path is exercised so the suite is wired correctly).
* Full integration tests run in CI's non-short job against Postgres
  16 Alpine via testcontainers-go.
* govulncheck ./... clean.
* All 24 ci-guards pass.

Phase 2 exit criteria from cowork/auth-bundle-2-prompt.md (all met):
* All three Phase-2 migrations apply cleanly, idempotently: yes
  (Phase 2a). Break-glass migration ships separately in Phase 7.5.
* Repository tests pass against Postgres 16 Alpine: integration
  tests written, gated by testing.Short(), structured to run cleanly
  in CI's non-short job.
* make verify equivalent green: gofmt + vet + go test pass;
  golangci-lint deferred to CI per Phase 0/1's same pattern.
2026-05-10 04:18:27 +00:00
shankar0123 315e132981 auth-bundle-2 Phase 2a: SQL migrations (oidc_providers, sessions, users)
Three new idempotent transactional migrations that materialize the
Phase 1 domain types into Postgres tables. Repository implementations
+ integration tests land as Phase 2b in the next commit.

migrations/000034_oidc_providers.up.sql:
  oidc_providers table with the full OIDCProvider field set
    (issuer_url + client_id + client_secret_encrypted v2 blob +
    redirect_uri + groups_claim_path + groups_claim_format +
    fetch_userinfo + scopes[] + allowed_email_domains[] +
    iat_window_seconds + jwks_cache_ttl_seconds + tenant_id).
  group_role_mappings table linking provider+group_name to role_id.
  Closed-enum CHECK on groups_claim_format ('string-array' or
    'json-path').
  Defense-in-depth bounds CHECKs on iat_window_seconds (1..600) and
    jwks_cache_ttl_seconds (>= 60); app-layer Validate() also
    enforces these.
  ON DELETE CASCADE on group_role_mappings.provider_id so deleting a
    provider cleans up its mappings.
  ON DELETE RESTRICT on group_role_mappings.role_id so an in-use role
    can't be silently dropped.

migrations/000035_sessions.up.sql:
  session_signing_keys table with key_material_encrypted v2 blob +
    retired_at nullable + the retired-after-created CHECK.
  Partial index on (tenant_id, created_at DESC) WHERE retired_at IS
    NULL backs the GetActive hot path.
  sessions table covers BOTH the post-login row (1h-idle/8h-absolute
    cookie lifecycle) AND the Phase 5 pre-login row (10-minute TTL,
    is_pre_login=true). csrf_token_hash holds the SHA-256 of the
    CSRF token plaintext (the plaintext lives in a separate
    JS-readable cookie, hashed here so a DB-read leak can't replay).
  Two CHECK constraints pin the expiry order (absolute > idle, idle >
    created); these match the Phase 1 domain Validate() pre-write
    invariants but enforce them at the DB layer too so direct SQL
    inserts can't silently land malformed rows.
  Partial indexes on actor_id (active sessions only), the active
    session lookup, the pre-login GC sweep (created_at), and the
    absolute-expired GC sweep (absolute_expires_at) cover the four
    hot paths Phase 4's service consumes.
  ON DELETE RESTRICT on sessions.signing_key_id so a signing key
    referenced by an active session can't be dropped (the retention
    window keeps retired keys valid; full purge waits until every
    session signed under that key has expired).

migrations/000036_users.up.sql:
  users table for federated-human identity (per-(provider, subject)
    tuple via UNIQUE constraint, not global - identity is per-IdP by
    design).
  webauthn_credentials JSONB DEFAULT '[]' reserved for v3 (Decision
    12); Bundle 2 always stores [].
  Email index for the GUI's "find user by email" surface (not unique
    because the same email can appear in multiple providers per the
    per-IdP identity model).
  ON DELETE RESTRICT on users.oidc_provider_id keeps Phase 3's "delete
    provider only when no users authenticated via it" rule enforced
    at the DB layer; the OIDCProviderRepository.Delete impl will
    translate SQLSTATE 23503 into a 409 sentinel.

All three migrations:
  Wrapped in BEGIN/COMMIT so partial-fail leaves no half-state.
  IF NOT EXISTS / IF EXISTS / ON CONFLICT DO NOTHING for idempotency
    (the certctl-server boot path applies every migration on every
    start per CLAUDE.md "Idempotent migrations" architecture rule).
  TIMESTAMPTZ for time columns (no TIMESTAMP WITHOUT TIME ZONE).
  TEXT primary keys with prefixes per CLAUDE.md "Architecture
    Decisions" (op- / grm- / sk- / ses- / u-).
  Multi-tenant ready: tenant_id column with DEFAULT 't-default' on
    every row, FK to tenants(id) ON DELETE CASCADE. Bundle 2 ships
    single-tenant; managed-service activation adds tenants without a
    schema migration.

Down migrations exist in lockstep, drop tables in FK-safe order
(group_role_mappings -> oidc_providers; sessions ->
session_signing_keys; users alone). Down-migrations are destructive;
docstrings call this out.

Verifications:
  Migration count: ls migrations/*.up.sql | wc -l = 36 (33 from
    Bundle 1 + 3 new).
  BEGIN/COMMIT pair counts: each new migration is 1:1.
  No Docker in this sandbox, so the migrations are not applied
    end-to-end here; CI's testcontainers harness runs them via
    postgres.RunMigrations on every push. Phase 2b's repository
    integration tests will exercise the schema against Postgres 16
    Alpine.
2026-05-10 04:08:06 +00:00
shankar0123 b0ac24fbf8 auth-bundle-2 Phase 1: OIDC + Session + User + Breakglass domain types
Phase 1 ships the persisted-shape types Bundle 2 needs end-to-end.
No DB migrations, no service layer, no HTTP handlers; Phase 2 ships
the SQL, Phase 3+ ship the consumers. Each type has a Validate()
method that enforces the on-disk invariants the schema will mirror,
and a focused _test.go that pins each invariant's failure mode.

Per-package summary:

internal/auth/oidc/domain/ (OIDCProvider + GroupRoleMapping):
* OIDCProvider carries the operator-configured IdP record. Fields
  match the prompt's Phase 1 list plus IATWindowSeconds and
  JWKSCacheTTLSeconds (Phase 3 references these by name; landing
  them in Phase 1's domain type avoids the lying-field gap).
  ClientSecretEncrypted is opaque from this layer; it is the v2 blob
  produced by internal/crypto/encryption.go and is `json:"-"` so it
  never wire-leaks.
* Validate() rejects: invalid id prefix, empty name, non-https
  issuer_url (matches Phase 3's "JWKS endpoint MUST be HTTPS"),
  empty client_id, empty client_secret_encrypted, non-https
  redirect_uri, invalid groups_claim_format, scopes missing openid,
  IAT window outside (0, 600], JWKS cache TTL below 60s. Defaults
  applied in-place: GroupsClaimPath="groups", GroupsClaimFormat=
  "string-array", Scopes=["openid","profile","email"],
  IATWindowSeconds=300, JWKSCacheTTLSeconds=3600,
  TenantID="t-default".
* GroupRoleMapping carries the operator-configured group-to-role
  rule. Validate() pins prefix conventions ("grm-", "op-", "r-")
  and non-empty group name.
* 18 tests across happy-path + every negative invariant.

internal/auth/session/domain/ (Session + SessionSigningKey):
* Session covers BOTH the post-login row (full 1h-idle/8h-absolute
  cookie lifecycle) AND the Phase 5 pre-login row (10-minute TTL,
  carries OIDC state+nonce+PKCE verifier across the IdP redirect).
  IsPreLogin discriminates. CSRFTokenHash holds SHA-256 of the
  CSRF token plaintext (the plaintext lives in a JS-readable
  certctl_csrf cookie; storing only the hash on the row defends
  against DB-read leaks per the Phase 4 CSRF contract).
* Validate() pins: id prefix "ses-", non-empty actor id/type,
  signing key id prefix "sk-", AbsoluteExpiresAt strictly > Idle,
  IdleExpiresAt strictly > CreatedAt, CSRFTokenHash exactly 64
  lowercase hex chars when set.
* Cookie naming constants pinned by a separate test
  (TestCookieNamingConstants) so a future rename can't silently
  break the GUI's web/src/api/client.ts which reads these names by
  string.
* SessionSigningKey stores the v2-encrypted HMAC key material; the
  retired-before-created invariant catches malformed rows. 14
  tests across both types.

internal/auth/user/domain/ (User):
* Federated-human identity for SSO logins. Distinct from Bundle 1's
  free-form actor_id strings: actor_roles.actor_id = User.ID for
  federated humans (per the prompt's note about how the two
  identity systems intersect).
* WebAuthnCredentials JSONB column reserved for v3 (Decision 12);
  defaults to "[]" on Validate() so Bundle 2 + v3 share the same
  on-disk format from day one.
* Email validation is intentionally loose (basic shape: one @,
  non-empty local + domain, no whitespace, dot in domain). RFC 5321
  / 5322 grammars are not enforced; the IdP issued the email and
  we trust its shape, only rejecting gross corruption.
* 8 tests across happy-path + invalid-id + empty-email +
  malformed-email + invalid-provider-id + tenant defaulting +
  WebAuthn-credentials passthrough.

internal/auth/breakglass/domain/ (BreakglassCredential):
* Phase 7.5 type. Argon2id PHC-format password hash; Validate()
  pins the Argon2id magic prefix so non-Argon2id formats (bcrypt,
  pbkdf2, plaintext) are rejected at the persistence boundary.
* MinPasswordLengthBytes (12) + MaxPasswordLengthBytes (256)
  constants pinned by a dedicated test so the operator-facing
  password-strength contract can't drift silently.
* IsLocked(now) helper exposes the lockout state machine for the
  Phase 7.5 service to consume; the lockout window default is
  15min in the service layer.
* 9 tests across happy-path + per-invariant negative + lockout
  state machine + tenant defaulting.

Cross-cutting:
* Every type has json:"-" on the encrypted-credential field
  (ClientSecretEncrypted, KeyMaterialEncrypted, PasswordHash,
  CSRFTokenHash) so even a misconfigured handler that marshals the
  domain type directly into a response body cannot leak the
  secret. Mirrors Bundle 1's pattern for issuer/target credentials.
* Every type carries TenantID with Validate() defaulting to
  authdomain.DefaultTenantID. Forward-compat for the future
  managed-service multi-tenant activation; Bundle 2 ships
  single-tenant.

Verifications:
* gofmt -l clean across all 8 new files (one round-trip required to
  satisfy Go 1.19+ doc-comment list-formatting rules in
  session/domain/types.go).
* go vet clean on internal/auth/oidc/... + session/... + user/... +
  breakglass/...
* go test -short -count=1 green on all four new domain packages
  (49 test functions total).
* go test -short -count=1 still green on Bundle 1 packages
  (internal/auth, internal/auth/bootstrap, internal/service/auth,
  internal/config).
* govulncheck ./... clean (M-024 hard CI gate).
* All 24 ci-guards pass locally.

Phase 1 exit criteria from cowork/auth-bundle-2-prompt.md:
* All types compile: yes.
* Validators have at least 5 test cases each: yes (smallest is
  User with 8 tests; OIDCProvider has 13).
* make verify equivalent green: gofmt + vet + go test pass
  (golangci-lint deferred to CI per the same operating-rule
  pattern Phase 0 used).
2026-05-10 03:41:46 +00:00
shankar0123 2d9110b0c4 auth-bundle-2 Phase 0: dependency-add + oidc auth-type literal + runtime guard
Bundle 2 Phase 0 stages the dependencies + auth-type discriminator
literal that later phases consume. No handler chain wired yet; an
operator who sets CERTCTL_AUTH_TYPE=oidc on this commit gets a clear
refuse-to-start error rather than a silent fallback to api-key (the
G-1 failure mode that drove "jwt" out of the allowed set).

Deliverables:

* go.mod: github.com/coreos/go-oidc/v3 v3.18.0 added as a direct
  require. Per the pre-bundle dependency audit (Apache-2.0, zero CVEs
  ever per OSV.dev, 2,400+ stars, used by Hashicorp Vault + Dex +
  Hydra + Authentik + every Kubernetes OIDC integration), this is the
  ecosystem-standard Go OIDC client. Pinned to a specific minor
  (v3.18.0) per the prompt's "no bare latest" rule.
* go.mod: golang.org/x/oauth2 promoted from // indirect to direct,
  bumped from v0.34.0 to v0.36.0 by go mod tidy. Both versions are
  OSV-clean. Maintained by the Go team.
* No JSON-path library added (forbidden by the dependency audit; the
  group-claim resolver is hand-rolled in Phase 3).
* internal/config/config.go: AuthTypeOIDC constant added with a
  load-bearing comment explaining (a) this is the AUTH-TYPE literal,
  not a JWT alg literal, so the G-1 closure invariant is preserved
  ("jwt" stays out of ValidAuthTypes forever); (b) the runtime guard
  in cmd/server/main.go intentionally refuses-to-start when oidc is
  set pre-Phase-6 to avoid the silent-downgrade failure mode.
  ValidAuthTypes() now returns {api-key, none, oidc}.
* internal/config/config_test.go: TestValidAuthTypesIsExactly_APIKey_None
  renamed to TestValidAuthTypesIsExactly_APIKey_None_OIDC and now pins
  the 3-entry set. TestValidAuthTypesDoesNotContainJWT (G-1 closure
  test) still passes because "jwt" is never added back.
  TestValidate_GenericInvalidAuthType's bad-types list updated:
  "oidc" removed (now valid), "saml" added (correctly rejected per
  Decision 5's SAML deferral).
* cmd/server/main.go: defense-in-depth runtime auth-type guard now
  has an explicit AuthTypeOIDC case that exit(1)s with an actionable
  message: "the OIDC auth chain is not yet wired in this build (Auth
  Bundle 2 Phase 6 ships the session middleware that consumes this
  auth-type literal)." This closes the lying-field gap the literal
  would otherwise create. Phase 6 of Bundle 2 relaxes this case to
  fall through alongside api-key + none.
* api/openapi.yaml: /v1/auth/info auth_type enum extended from
  [api-key, none] to [api-key, none, oidc] with an in-line comment
  explaining the Phase-0-vs-Phase-6 timing so an OpenAPI consumer
  isn't surprised by "oidc" appearing here pre-Bundle-2-merge.
* deploy/helm/certctl/templates/_helpers.tpl::certctl.validateAuthType:
  valid set extended to include "oidc". Chart-time validation now
  passes for type=oidc; the binary's runtime guard takes over to
  refuse the start. Once Bundle 2 ships, the runtime guard relaxes
  and OIDC works end-to-end with no further chart edits.
* .env.example: CERTCTL_AUTH_TYPE comment block updated to document
  the three valid values + the Phase-0-vs-Phase-6 timing.
* internal/auth/oidc/doc.go: new package directory with package doc
  + transitional blank imports for coreos/go-oidc/v3 + x/oauth2 so
  go mod tidy keeps both deps as direct requires until Phase 3's
  service.go replaces the blanks with real symbol use. Doc explains
  the package layout (oidc/ + oidc/domain/ + oidc/groupclaim/ +
  oidc/testfixtures/) so the post-Bundle-2 reader can navigate.

Verifications:
* gofmt clean on every changed file.
* go vet clean on internal/config + cmd/server + internal/auth/oidc.
* go test -short -count=1 green on internal/config (including the
  G-1 closure + new validation tests), cmd/server, internal/auth (all
  Bundle 1 packages), internal/service/auth.
* govulncheck ./... clean (M-024 hard CI gate).
* All 24 ci-guards pass locally.

Phase 0 exit criteria from cowork/auth-bundle-2-prompt.md:
* go.mod shows coreos/go-oidc/v3 as direct: yes.
* golang.org/x/oauth2 is direct (not indirect): yes.
* govulncheck ./... clean: yes.
* No JSON-path library in go.mod / go.sum deltas: confirmed (only
  v3 of go-oidc + the x/oauth2 bump landed).
* make verify green: gofmt + vet + go test pass; full make verify
  (which would invoke golangci-lint) deferred to CI since the
  sandbox doesn't have golangci-lint installed; the operator runs
  make verify locally before pushing per CLAUDE.md operating rule.
2026-05-10 03:31:51 +00:00
shankar0123 977cdbdf44 docs(README): surface Bundle 1 RBAC + signal Bundle 2 federation as roadmap
Pre-fix the README said nothing about role-based access control,
the auditor role, the day-0 bootstrap path, or the four-eyes
approval workflow — all shipped in Bundle 1 (commit 22c4971 +
follow-ons). A prospective adopter landing on the README would
read "API key auth enforced by default" and walk away thinking
certctl had no authz primitive at all. The only OIDC reference
was the cosign-keyless line at the artefact-signing section,
unrelated to authentication.

Three surgical edits:

1. Status block: extend the "production-quality core" enumeration
   with role-based authz, auditor split, day-0 bootstrap, four-eyes
   approval. Add a one-line callout that federated identity (OIDC,
   SAML, WebAuthn, server-side sessions, break-glass, JIT
   elevation) is roadmap-not-shipped — preempts the natural-but-
   wrong assumption that "RBAC means OIDC works".
   The two terms are linked inline:
     - "role-based authz" -> docs/operator/rbac.md (operator how-to:
       role table, permission catalogue, scope semantics, GUI/CLI/
       HTTP/MCP grant flows, day-0 bootstrap).
     - "Federated identity" -> docs/operator/auth-threat-model.md
       #threats-bundle-1-does-not-close (canonical place where
       deferred Bundle-2 work is enumerated).
   Keeps the roadmap promise honest: a skeptic can click through
   to the explicit deferred-work list rather than taking prose at
   face value.

2. "What it does" feature list: insert a new bullet right after the
   approval-workflow bullet covering the 7 default roles, the 33-
   permission canonical catalogue, scope semantics, the auditor
   read-only invariant, the bootstrap path, and the
   privilege-escalation guard. Cross-links to docs/operator/rbac.md,
   the threat model, and the v2.0.x → v2.1.0 migration guide.

3. Security paragraph: replace "API key auth enforced by default
   with SHA-256 hashing and constant-time comparison" with the
   Bundle-1 reality — auth + RBAC + auditor + bootstrap + privilege-
   escalation guard — keeping the rest of the paragraph (CORS,
   SSRF, encryption-at-rest, TLS-1.3, audit trail, CI gates)
   unchanged.

Verified:
  Both link targets exist on disk
    (docs/operator/rbac.md, docs/operator/auth-threat-model.md).
  Threat-model anchor heading "## Threats Bundle 1 does NOT close"
    is intact (line 138).
  All 24 ci-guards pass locally including S-1 (no hardcoded source
    counts re-introduced) and G-3 (no env-var docs drift).

Updates the README to match Bundle 1's actually-shipped surface
and to set honest expectations about Bundle 2 (federated identity)
being the next slice, not yet landed.
v2.0.72
2026-05-10 02:21:39 +00:00
shankar0123 5d79e53ad0 auth-bundle-1 follow-on: close coverage gaps to clear Phase 12 floors
CI run #486 (post-Bundle-1 merge + Go 1.25.10 bump) failed three
coverage-threshold gates:

  internal/api/handler   74.7% < floor 75 (-0.3pp)
  internal/auth          66.3% < floor 85 (-18.7pp)
  internal/service/auth  51.1% < floor 85 (-33.9pp)

The Phase 12 gate file's "85% with negative-test coverage" claim
turned out to be aspirational — the read-side and Update-path
methods on RoleService / PermissionService / ActorRoleService had
zero unit-test coverage, and internal/auth's keystore +
HasPermission helper had zero tests. This commit closes the gap
without lowering the gate.

Per-package CI-style averages after this commit (per
scripts/check-coverage-thresholds.sh's per-function-mean):

  internal/api/handler   76.1% (+1.4pp,  margin +1.1pp)
  internal/auth          90.5% (+24.2pp, margin +5.5pp)
  internal/service/auth  93.7% (+42.6pp, margin +8.7pp)

Tests added:

  internal/service/auth/service_test.go (+18 tests, +518 LOC):
    PermissionService.List, PermissionService.GetByName,
    RoleService.Get (4 paths), RoleService.List (system caller),
    RoleService.Update (4 paths), RoleService.ListPermissions
    (3 paths), RoleService.AddPermission/RemovePermission round-trip
    + gate paths, RoleService.Delete (success + nil-caller +
    no-perm + audit), RoleService.Create (nil-caller),
    ActorRoleService.ListForActor (self-bypass + cross-actor +
    nil-caller + system + with-perm), ActorRoleService.Effective-
    Permissions (same shape), ActorRoleService.ListKeys (3 paths +
    system bypass), ActorRoleService.Revoke (4 paths), Authorizer
    edge cases (empty actorID short-circuit, empty tenantID
    default, scoped-grant-without-scope-id no-match invariant,
    repo-error wrap-and-return, HoldsAnyOf early-exit), recordAudit
    nil-arm short-circuits.

  internal/auth/keystore_test.go (NEW, +175 LOC):
    StaticKeyStore.Len, StaticKeyStore.LookupByHash hit + miss,
    MutableKeyStore seeded lookup + Len, Add registers new key,
    AddHashed registers from precomputed hash, AddHashed replaces
    on duplicate hash (idempotent boot-loader contract),
    HasPermission no-actor / default-actor-type / checker-error /
    scoped-check threading.

  internal/auth/bootstrap/service_test.go (+36 LOC):
    Service.Available nil-receiver/nil-strategy short-circuit,
    Service.Available delegates to Strategy when configured.

  internal/api/handler/auth_test.go (+208 LOC):
    GetRole returns role + permissions, GetRole 404 + 401, UpdateRole
    200 + invalid-JSON-400 + 401, ListKeys returns actor list + 401,
    RemoveRolePermission 204 (global + scoped) + 401,
    rolePermToResponse scope encoding pin via GetRole.

Verified:
  gofmt -l . clean (touched files only).
  go vet ./internal/auth/... ./internal/service/auth/...
       ./internal/api/handler/ rc=0.
  go test -count=1 -short on the four packages green.
  CI-style per-function averages computed via the live
       scripts/check-coverage-thresholds.sh arithmetic — all three
       gated packages clear their floors with margin.

Per CLAUDE.md "complete path" + "do not lower the gate to make CI
green": gate file unchanged. The 85/85/75 floors stand.
2026-05-10 02:04:36 +00:00