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

9.8 KiB

Auth0 OIDC runbook

Last reviewed: 2026-05-10

This runbook wires certctl's OIDC SSO surface against Auth0, a commercial cloud IdP (now part of Okta but operationally distinct). Auth0 has a free developer tier suitable for evaluation; production runs on a paid B2B / B2C plan.

For the canonical reference + mental model, read keycloak.md first; this runbook only documents the Auth0-specific deltas.

The big Auth0 quirk: namespaced custom claims

Auth0 imposes a hard rule: any custom claim emitted from an Action MUST use a namespaced URL-shape key (e.g. https://your-namespace/groups). Auth0 silently strips claims that look like standard OIDC claims (groups, roles, permissions, etc.) when emitted from an Action — this is a security feature to prevent claim-spoofing.

certctl handles this via the groups_claim_path config. If your Action emits https://your-namespace/groups, set OIDCProvider.groups_claim_path to that exact URL. The hand-rolled groupclaim resolver at internal/auth/oidc/groupclaim/resolver.go recognizes URL-shape paths (anything starting with http:// or https://) and treats the entire string as a single literal key — it does NOT split on /.

Set groups_claim_format to string-array; the underlying claim shape is still a JSON array of group-name strings, just stored under a URL-shape key.

Prerequisites

On the Auth0 side:

  • An Auth0 tenant (free dev tier at https://auth0.com/signup works). Tenant URL looks like https://<tenant-name>.<region>.auth0.com.
  • Owner or Auth0 Administrator role.
  • Network reachability from certctl-server to https://<tenant>.auth0.com/.well-known/openid-configuration.

On the certctl side: same as Keycloak.

IdP-side configuration

1. Pick a namespace string

Decide on a unique URL-shape namespace for certctl's custom claims. It does NOT have to resolve to a real domain; Auth0 just requires it to be URL-shape and unique within your tenant. A reasonable choice:

https://certctl.example.com/auth/

Use that prefix for every custom claim; for groups specifically:

https://certctl.example.com/auth/groups

We'll refer to this as <NS>/groups in the rest of this runbook.

2. Create the Application

In the Auth0 dashboard:

Applications → Applications → Create Application:

  • Name: certctl.
  • Application Type: Regular Web Applications.
  • Click Create.

On the saved app's Settings tab:

  • Application Login URI: blank (Auth0 doesn't need it for the auth-code flow).
  • Allowed Callback URLs: https://<your-certctl-host>:8443/auth/oidc/callback (one entry, exact match).
  • Allowed Logout URLs: optional.
  • Allowed Web Origins: https://<your-certctl-host>:8443.
  • Token Endpoint Authentication Method: Post (default; matches the certctl service's expectation of client_secret_post).
  • Save Changes.

Copy the Domain (this is the issuer base — https://<tenant>.auth0.com), Client ID, and Client Secret from the same Settings page.

3. Configure the connection (where users live)

If you're using Auth0's Database connection (default username + password), the existing Username-Password-Authentication connection works. For SSO to Google / Microsoft / SAML, configure those connections under Authentication → Enterprise or Authentication → Social and ensure the connection is enabled on the certctl Application (App → Connections tab).

4. Define the groups

Auth0 doesn't have a first-class "Groups" concept like Okta or Keycloak — you have THREE options to model groups, each with tradeoffs:

Option A: User app_metadata (simplest, recommended for dev tier).

Each user has a app_metadata JSON blob you can set via the Management API, the dashboard, or a post-registration script. Stick the groups in there:

{
  "groups": ["certctl-engineers"]
}

In the Auth0 dashboard, User Management → Users → → app_metadata: paste the JSON above and Save.

Option B: Auth0 Authorization Extension (paid plans, recommended for production).

Install the Authorization Extension from Marketplace → Extensions → Authorization. It adds a first-class "Groups" concept with UI for assignment + nested groups. Read the extension's docs; it emits groups under <NS>/groups automatically once enabled.

Option C: Roles + Permissions (Auth0's RBAC primitive).

Use User Management → Roles to define roles like certctl-engineer + certctl-viewer. Assign roles to users. Have your Action emit role names as a groups claim. This is what Auth0 documents as the canonical pattern; it's slightly heavier than Option A but more discoverable in the dashboard.

This runbook uses Option A for clarity; the Action below reads from app_metadata.groups.

5. Write the Action that emits the groups claim

Actions → Library → Create Action → Build from scratch:

  • Name: certctl-emit-groups.
  • Trigger: Login / Post Login.
  • Runtime: Node 18.
  • Click Create.

Paste this code:

exports.onExecutePostLogin = async (event, api) => {
  const namespace = "https://certctl.example.com/auth/";
  const groups = (event.user.app_metadata && event.user.app_metadata.groups) || [];
  if (groups.length > 0) {
    api.idToken.setCustomClaim(namespace + "groups", groups);
    api.accessToken.setCustomClaim(namespace + "groups", groups);
  }
};

Replace https://certctl.example.com/auth/ with your namespace from step 1. Click Deploy.

Then bind the Action to the Login flow:

Actions → Flows → Login: drag certctl-emit-groups from the Custom tab into the flow, between Start and Complete. Click Apply.

6. Verify the claim in a test login

Auth0's Authentication → Authentication Profile → Try It button or the Logs → Real-time Logs page can show you the issued ID token in real time. Decode at jwt.io to confirm <NS>/groups is present + populated.

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": "Auth0",
    "issuer_url": "https://<tenant>.auth0.com/",
    "client_id": "<paste-from-step-2>",
    "client_secret": "<paste-from-step-2>",
    "redirect_uri": "https://certctl.example.com:8443/auth/oidc/callback",
    "groups_claim_path": "https://certctl.example.com/auth/groups",
    "groups_claim_format": "string-array",
    "fetch_userinfo": false,
    "scopes": ["openid", "profile", "email"],
    "iat_window_seconds": 300,
    "jwks_cache_ttl_seconds": 3600
  }'

Critical:

  • issuer_url includes the trailing slash for Auth0 (https://<tenant>.auth0.com/). Auth0's iss claim emits with the trailing slash; mismatching trips ErrIssuerMismatch.
  • groups_claim_path is the full namespaced URL, not the bare groups key. The certctl resolver treats this as a single literal lookup key against the ID token claims map (no path-walking through /).

Add the group→role mappings: certctl-engineersr-operator, etc. The mapping table maps the group VALUES (the strings inside the claim's array), not the claim path.

Verification

End-to-end login + audit + Sessions checks are identical to Keycloak. The audit row's details.subject will be Auth0's user_id (e.g. auth0|abc123… for database users, google-oauth2|... for federated), stable across email changes.

Troubleshooting

ErrGroupsUnmapped even though I see groups in the ID token at jwt.io.

Check groups_claim_path exactly matches the namespaced key in the token. A common mistake: setting groups_claim_path to groups (the bare key) when the actual claim key is https://certctl.example.com/auth/groups (the namespaced version). The resolver's URL-shape detection is what makes the namespaced path work; if the claim path doesn't start with http:// or https://, the resolver tries to walk it as a dot-separated path and fails.

The <NS>/groups claim is missing from the ID token.

  • Action not bound to the Login flow: revisit step 5's "Apply" step.
  • Action returns early because event.user.app_metadata.groups is undefined: confirm the user has the metadata set.
  • Trying to set the claim under a non-namespaced key (e.g. api.idToken.setCustomClaim("groups", groups)): Auth0 silently drops it. Always use the namespace prefix.

Auth0 returns "Service not found" or "Invalid audience".

This usually means the certctl client wasn't authorized to access the userinfo endpoint or the application's audience setting conflicts with the OIDC discovery doc. The certctl service uses the Application's client_id as the audience claim — confirm Auth0 is emitting tokens with aud = <client_id> (decode at jwt.io).

Login redirects loop between Auth0 and certctl.

Most often a callback-URL mismatch — Auth0's "Allowed Callback URLs" must contain the EXACT certctl callback URL including port + scheme. Wildcards aren't allowed in production.

email_verified is false and certctl rejects the user.

certctl doesn't currently gate on email_verified — the User row stores email regardless. If your operator policy requires verified-only, add an Action that throws on event.user.email_verified === false:

if (!event.user.email_verified) {
  api.access.deny("email-not-verified");
}

Validation checklist

Same as keycloak.md with Auth0-specific values, plus:

  • The <NS>/groups claim is present in the ID token (verify via jwt.io decode).
  • Removing a user's group from app_metadata.groups causes the next login to land on "no roles assigned".
  • The Auth0 dashboard's Logs → Real-time Logs shows the certctl callback completing with HTTP 302 to the dashboard.

Sign-off: _______________ (operator) on _______________ (date).