# Okta OIDC runbook > Last reviewed: 2026-05-10 This runbook wires certctl's OIDC SSO surface against [Okta](https://www.okta.com/), 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](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 ). - Super Admin or Application Admin role in your Okta tenant. - Network reachability from certctl-server to `https://.okta.com/.well-known/openid-configuration` OR to a custom authorization server endpoint if you're using one (`https://.okta.com/oauth2//.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://: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://.okta.com` — emits ID tokens with limited claims; cannot host custom claims directly. Use for the simplest setup. - **A Custom Authorization Server** at `https://.okta.com/oauth2/` — 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://.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 ```bash curl -X POST https://: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": "", "client_secret": "", "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://.okta.com/oauth2/default` (no trailing slash). The org server's issuer is just `https://.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-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://.okta.com/oauth2/default` (no trailing slash); the org server emits `https://.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](keycloak.md#validation-checklist), with Okta-specific values + the access-policy check above. Sign-off: _______________ (operator) on _______________ (date).