Files
certctl/docs/operator/oidc-runbooks/okta.md
T
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

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-configuration OR 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, optionally certctl-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 the certctl-* 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 openid if 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_url MUST match exactly what Okta emits as the iss claim. For the default custom server it's https://<your-org>.okta.com/oauth2/default (no trailing slash). The org server's issuer is just https://<your-org>.okta.com (no /oauth2/... path). Mismatching either side trips certctl's ErrIssuerMismatch sentinel.
  • The groups scope is NOT required in the scopes list — Okta emits the claim based on the claim definition's "Include in: any scope" setting. Adding groups to the scopes list is harmless if your custom server has the scope defined.

Add the group→role mappings: certctl-engineersr-operator, certctl-viewersr-viewer, certctl-adminsr-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: Phase 10 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).