README:
- Rewrite Status block: drop the stale 'federated identity not yet
shipped' line; flag v2.1.0 OIDC + sessions + back-channel logout
+ break-glass as early-access; encourage GitHub issues for IdP
rough edges. (A1 framing — keep early-access umbrella, no
SAML/WebAuthn/JIT roadmap teaser.)
- Add OIDC SSO bullet to 'What it does' covering per-IdP runbooks,
group-claim → role mapping, AES-256-GCM client_secret encryption,
JWKS auto-refresh, PKCE-S256, RFC 9700 §4.7.1 pre-login binding,
RFC 9207 iss check, __Host- cookies, CSRF rotation, idle+absolute
expiry, BCL, break-glass admin.
- Update Security paragraph: three auth paths (API keys / OIDC /
break-glass), HMAC-signed sessions, CSRF rotation, RFC OIDC BCL.
- Correct CI coverage thresholds against
.github/coverage-thresholds.yml (service 70%, handler 75%,
crypto 88%, auth packages 85-95%); 'static analysis' replaces
the inflated '11 linters' claim (actual count is 4 active).
Docs B3 sweep — strip operator-facing 'Bundle N' / 'Phase N' tags:
- docs/operator/auth-threat-model.md — rewrite intro; rename 5 H2
sections (API-key + RBAC defenses / OIDC + sessions + break-glass
defenses / OIDC + sessions threat catalogue / Closed federated-
identity threats / Future-work threats); clean ~12 H3/prose hits.
- docs/operator/rbac.md — strip Bundle 1 framing from intro,
scope_id deferral note, MCP tools section, day-0 bootstrap, and
'Where to look next'.
- docs/operator/auth-benchmarks.md — drop 'Phase 14' framing from
title intro, hardware floor caption, result table caption,
methodology, and pre-merge audit section.
- docs/operator/security.md — already cleaned earlier this session
(RBAC / day-0 / approval-bypass / OIDC federation / sessions /
OIDC first-admin / break-glass H3s).
- docs/operator/oidc-runbooks/{index,keycloak,authentik,okta,
azure-ad}.md — strip Auth Bundle 2 framing + Phase 10/3/4
references; replace with feature-name prose.
- docs/operator/legacy-clients-tls-1.2.md — drop Bundle F / M-023
audit-reference framing; keep CWE-326.
- docs/operator/database-tls.md — drop Bundle B / M-018 framing
from intro + Helm section.
- docs/operator/runbooks/disaster-recovery.md — drop 'Production
hardening II Phase 10' status callout.
- docs/migration/oidc-enable.md — retitle 'Enable OIDC SSO';
strip Bundle 1/2 framing from prereqs, troubleshooting, related
docs; update __Host- cookie callout from 'audit MED-14' to
v2.1.0-BREAKING.
- docs/migration/api-keys-to-rbac.md — strip Bundle 1 framing from
intro, migration table, IsAdmin section, and cross-references.
- docs/migration/acme-from-cert-manager.md — strip residual
'Phase 5' tags from cert-manager integration test references.
- docs/reference/configuration.md — retitle Auth section.
- docs/reference/profiles.md — strip Bundle 1 Phase 9 framing
from RequiresApproval section + Related list.
- docs/reference/auth-standards-implemented.md — rewrite intro
(API-key + RBAC + OIDC + sessions + back-channel logout +
break-glass); rename 'Bundle 1 (RBAC) standards covered
separately' H2; clean per-row Phase references.
- docs/README.md — rewrite nav-table entries to drop Bundle 1/2
parentheticals; retitle 'Enable OIDC SSO' migration entry.
No code or test changes; pure operator-facing prose polish for
the v2.1.0 tag.
8.9 KiB
Okta OIDC runbook
Last reviewed: 2026-05-10
This runbook wires certctl's OIDC SSO surface against Okta, a commercial cloud IdP. Okta offers a free developer tier (https://dev-NNNNN.okta.com) suitable for evaluation; production runs on a paid Workforce Identity tenant.
For the canonical reference + mental model, read keycloak.md first; this runbook only documents the Okta-specific deltas.
Prerequisites
On the Okta side:
- A Workforce Identity tenant (or free Developer Edition account at https://developer.okta.com/signup/).
- Super Admin or Application Admin role in your Okta tenant.
- Network reachability from certctl-server to
https://<your-org>.okta.com/.well-known/openid-configurationOR to a custom authorization server endpoint if you're using one (https://<your-org>.okta.com/oauth2/<auth-server-id>/.well-known/openid-configuration).
On the certctl side: same as Keycloak.
IdP-side configuration
1. Create the OIDC application
In the Okta admin console:
Applications → Applications → Create App Integration:
- Sign-in method: OIDC - OpenID Connect.
- Application type: Web Application.
- Click Next.
App config:
- App integration name:
certctl. - Logo: optional.
- Grant types: Authorization Code (CHECK). Leave Refresh Token unchecked unless you have a specific reason — certctl doesn't currently use refresh tokens.
- Sign-in redirect URIs:
https://<your-certctl-host>:8443/auth/oidc/callback. - Sign-out redirect URIs: optional; leave empty unless you also configure RP-initiated logout.
- Trusted Origins: leave default.
- Assignments → Controlled access: Limit access to selected groups (recommended; pick the
certctl-*groups from step 3 below). - Click Save.
On the saved app's General tab, copy the Client ID and Client secret (under Client Credentials). The secret is shown once on creation — copy it immediately or rotate via "Generate new secret".
2. Pick or create an authorization server
Okta has TWO authorization-server tiers:
- The Org Authorization Server at
https://<your-org>.okta.com— emits ID tokens with limited claims; cannot host custom claims directly. Use for the simplest setup. - A Custom Authorization Server at
https://<your-org>.okta.com/oauth2/<auth-server-id>— fully configurable scopes + claims + access policies. The free developer tier ships with a default custom server at/oauth2/default. Recommended for production.
For this runbook we use the default custom server: https://<your-org>.okta.com/oauth2/default.
3. Create the groups + assign users
Directory → Groups → Add Group:
- Repeat for
certctl-engineers,certctl-viewers, optionallycertctl-admins.
Directory → People → → Groups: assign each user to the appropriate certctl-* group(s).
Then go back to the App from step 1 and on the Assignments tab, assign the certctl-* groups to the application. Without this assignment Okta will reject the user's login attempt at the IdP layer with "User is not assigned to the client application".
4. Configure the groups claim
This is the load-bearing Okta-specific step. The default authorization server does NOT emit a groups claim out of the box — you have to define it.
Security → API → Authorization Servers → default → Claims → Add Claim:
- Name:
groups. - Include in token type: ID Token, Always (also tick Access Token if you want the userinfo-fallback path to work).
- Value type: Groups.
- Filter: pick Matches regex with the value
certctl-.*so only thecertctl-*groups are emitted (saves on token size; users in dozens of unrelated groups get a bloated token otherwise). - Disable claim: off.
- Include in: Any scope (or pin to
openidif you want the claim only on the certctl-flow). - Click Create.
5. (Optional) Add email and profile claims
The default custom server already emits email and name under the profile and email scopes — no action needed unless you've stripped them from a custom config.
certctl-side configuration
curl -X POST https://<your-certctl-host>:8443/api/v1/auth/oidc/providers \
-H "Authorization: Bearer ${CERTCTL_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "Okta",
"issuer_url": "https://your-org.okta.com/oauth2/default",
"client_id": "<paste-from-step-1>",
"client_secret": "<paste-from-step-1>",
"redirect_uri": "https://certctl.example.com:8443/auth/oidc/callback",
"groups_claim_path": "groups",
"groups_claim_format": "string-array",
"fetch_userinfo": false,
"scopes": ["openid", "profile", "email"],
"iat_window_seconds": 300,
"jwks_cache_ttl_seconds": 3600
}'
Notes:
issuer_urlMUST match exactly what Okta emits as theissclaim. For the default custom server it'shttps://<your-org>.okta.com/oauth2/default(no trailing slash). The org server's issuer is justhttps://<your-org>.okta.com(no/oauth2/...path). Mismatching either side trips certctl'sErrIssuerMismatchsentinel.- The
groupsscope is NOT required in the scopes list — Okta emits the claim based on the claim definition's "Include in: any scope" setting. Addinggroupsto the scopes list is harmless if your custom server has the scope defined.
Add the group→role mappings: certctl-engineers → r-operator, certctl-viewers → r-viewer, certctl-admins → r-admin.
Verification
End-to-end login + audit + Sessions checks are identical to Keycloak.
Okta-specific: the audit row's details.subject will be Okta's user UID (a 20-char alphanumeric string starting with 00u), stable across email changes. The certctl users table's oidc_subject column will hold this UID.
Optional Okta smoke test in CI: certctl ships an opt-in smoke test at internal/auth/oidc/integration_okta_smoke_test.go (build tags integration && okta_smoke). Set OKTA_ISSUER + OKTA_CLIENT_ID + OKTA_CLIENT_SECRET env vars and run make okta-smoke-test to drive a discovery + RefreshKeys round-trip against your live tenant. Pre-reqs: enable the Resource Owner Password (ROPC) grant on the application (Sign-On tab → Grant types → Resource Owner Password) for the smoke test only; production certctl uses auth-code-with-PKCE.
JWKS-rotation drill: Okta auto-rotates signing keys every ~3 months and publishes the new key alongside the old in the JWKS doc for ~1 month overlap. Manual rotation: Security → API → Authorization Servers → default → Keys → "Generate new key". After rotation, click "Refresh discovery cache" in certctl's GUI; new tokens validate immediately.
Troubleshooting
"User is not assigned to the client application" at the Okta login screen.
You created the app + the user but didn't assign the user to the app via a group. Either assign the user directly (App → Assignments → Assign to People) or assign the certctl-* groups to the app (App → Assignments → Assign to Groups).
Login completes but groups claim is empty in the ID token.
Most common Okta gotcha — the default custom server doesn't emit groups until you define the claim (step 4 above). Decode the ID token at jwt.io to confirm. If the claim is defined but empty, check the regex filter in step 4 — certctl-.* matches names like certctl-engineers but NOT engineers.
ErrIssuerMismatch after correctly configuring the discovery URL.
The issuer claim Okta puts in the ID token MUST match OIDCProvider.IssuerURL byte-for-byte, including trailing slash. The default custom server emits https://<your-org>.okta.com/oauth2/default (no trailing slash); the org server emits https://<your-org>.okta.com. Don't append a trailing slash to either.
Login succeeds but the certctl User.Email is empty.
The email scope wasn't requested OR the user's email isn't verified at Okta. Add email to the certctl scopes config and ensure Okta's user has a verified primary email.
Okta returns "PKCE code verifier required". The certctl service hard-codes PKCE-S256 on every login (RFC 9700 mandate). If Okta is rejecting the verifier, the most likely cause is a misconfigured app type — confirm the Okta application is "Web Application" (which supports auth-code + PKCE), not "Single-Page Application" (which has different token-binding rules) or "Native App".
Custom-server access policies blocking the login.
By default the default custom authorization server has an "Access Policy" with one rule allowing all clients + all users. If you've tightened this (production hygiene), add a rule that allows the certctl client + the certctl-* groups: Security → API → Authorization Servers → default → Access Policies → → Add Rule.
Validation checklist
Same as keycloak.md, with Okta-specific values + the access-policy check above.
Sign-off: _______________ (operator) on _______________ (date).