> ## Documentation Index
> Fetch the complete documentation index at: https://radarhq.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> OIDC + the always-available break-glass admin. WorkOS is not used in self-hosted - your IdP is the source of truth.

Self-hosted has two coexisting auth paths:

1. **OIDC** - federated login against your own IdP (Okta, Auth0, Google Workspace, Azure AD, etc.). The primary path for real users.
2. **Break-glass admin** - a single email + password configured via env vars, always available regardless of OIDC state. Permanent escape hatch, not a stopgap.

WorkOS is never in the loop. Customer auth events stay inside your VPC.

## Break-glass admin

### Why it's permanent

The break-glass admin exists for two scenarios:

* **Initial bootstrap** - you need to log in BEFORE OIDC has been configured, to mint your IdP credentials and run a setup wizard.
* **OIDC misconfiguration** - your IdP's discovery endpoint went down, your IdP's clock skewed past JWT expiry, somebody rotated client secrets. The break-glass account lets the on-call engineer log in without rolling back the deployment.

We recommend leaving it configured permanently. One account, well-protected by a strong password (or pre-hashed bcrypt) in your secret store.

### Configuring it

Two ways to set the password:

```bash theme={null}
# Plaintext - hashed at boot, bcrypt cost 12, original discarded.
helm install ... \
  --set auth.breakGlass.email=admin@acme.example \
  --set auth.breakGlass.password='supersecret123'
```

```bash theme={null}
# Pre-hashed - preferred for production. Generate the hash anywhere with bcrypt.
htpasswd -bnBC 12 "" 'supersecret123' | tr -d ':\n'
# → $2y$12$XYZ...

helm install ... \
  --set auth.breakGlass.email=admin@acme.example \
  --set 'auth.breakGlass.passwordHash=$2y$12$XYZ...'
```

Or via an existing Secret (see [Configuration → Skipping the chart's Secret](/docs/cloud/self-hosted/configuration#skipping-the-charts-secret)).

### Signing in

POST to `/api/auth/break-glass/login`:

```bash theme={null}
curl -i https://radar.acme.example/api/auth/break-glass/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@acme.example","password":"supersecret123"}'
```

Or use the web app's login page - it renders a "Sign in with email + password" panel when break-glass is configured.

### Rotating

Bump the Secret + bounce the Pod:

```bash theme={null}
helm upgrade radar-hub skyhook/radar-hub \
  --namespace radar-hub \
  --reset-then-reuse-values \
  --set 'auth.breakGlass.passwordHash=<new bcrypt hash>'

kubectl -n radar-hub rollout restart deploy/radar-hub-hub
```

Sessions issued under the old password remain valid until cookie expiry - rotate cookies + bcrypt together if you suspect the old credential leaked.

## OIDC

### Endpoints

The control plane mounts:

* `GET /api/auth/oidc/login` - top-level navigation kicks off the IdP redirect.
* `GET /api/auth/oidc/callback` - your IdP redirects here on success.

You register the callback URL with your IdP. It's the only URL that needs to match exactly - case-sensitive, no path normalization.

### IdP-specific recipes

<Tabs>
  <Tab title="Okta">
    1. In Okta admin, create a new **OIDC - Web Application**.
    2. Sign-in redirect URI: `https://radar.acme.example/api/auth/oidc/callback`.
    3. Sign-out redirect URI: `https://radar.acme.example` (optional; only used if your IdP advertises `end_session_endpoint`).
    4. Grant types: Authorization Code + Refresh Token.
    5. (Optional) Add a Groups claim to the ID token; default name is `groups`.

    ```yaml theme={null}
    auth:
      oidc:
        issuer: https://acme.okta.com
        clientID: 0oa<...>
        clientSecret: <from Okta>
        callbackURL: https://radar.acme.example/api/auth/oidc/callback
        groupClaim: groups
        adminGroups: [radar-admins]
        defaultRole: member
    ```
  </Tab>

  <Tab title="Auth0">
    1. Auth0 dashboard → **Applications** → Create → **Regular Web Application**.
    2. Allowed Callback URLs: `https://radar.acme.example/api/auth/oidc/callback`.
    3. Allowed Logout URLs: `https://radar.acme.example`.
    4. (Optional) Configure a Rule or Action that adds a `groups` claim to the ID token.

    ```yaml theme={null}
    auth:
      oidc:
        issuer: https://acme.eu.auth0.com
        clientID: <from Auth0>
        clientSecret: <from Auth0>
        callbackURL: https://radar.acme.example/api/auth/oidc/callback
        groupClaim: https://acme.example/groups
        adminGroups: [radar-admins]
    ```

    Auth0 uses namespaced custom claims (full URL); set `groupClaim` to whatever Action emits.
  </Tab>

  <Tab title="Google Workspace">
    1. Google Cloud Console → **APIs & Services** → **Credentials** → **Create Credentials** → OAuth client ID → Web application.
    2. Authorized redirect URIs: `https://radar.acme.example/api/auth/oidc/callback`.
    3. Google's discovery doc lives at `https://accounts.google.com/.well-known/openid-configuration`.

    ```yaml theme={null}
    auth:
      oidc:
        issuer: https://accounts.google.com
        clientID: <client>.apps.googleusercontent.com
        clientSecret: <secret>
        callbackURL: https://radar.acme.example/api/auth/oidc/callback
        # Google does not emit groups by default. Use the OAuth `hd` claim
        # for domain restriction at the IdP layer; per-group admin
        # promotion isn't directly possible without Workspace Cloud Identity.
        groupClaim: ""
        defaultRole: member
    ```
  </Tab>

  <Tab title="Azure AD">
    1. Azure Portal → **Microsoft Entra ID** → **App registrations** → New.
    2. Redirect URI: `https://radar.acme.example/api/auth/oidc/callback` (type: Web).
    3. Issue a client secret under **Certificates & secrets**.
    4. Configure a **Token configuration** → optional groups claim → "Groups assigned to the user" (or filter to security groups).

    ```yaml theme={null}
    auth:
      oidc:
        issuer: https://login.microsoftonline.com/<tenant-id>/v2.0
        clientID: <app-registration-id>
        clientSecret: <client-secret>
        callbackURL: https://radar.acme.example/api/auth/oidc/callback
        groupClaim: groups
        adminGroups: [<radar-admins-object-id>]
    ```

    Note: Azure emits group **object IDs**, not display names. Match accordingly.
  </Tab>
</Tabs>

### Role mapping

The control plane maps an OIDC ID-token's group claim to one of three roles:

* `owner` - full admin (mint cluster tokens, manage members, view audit log).
* `member` - read + cluster operations (the default).
* `viewer` - read-only.

Logic in plain English:

1. Read the configured `groupClaim` value from the ID token. Leave it empty to disable group-based promotion.
2. If any of those values is in `adminGroups`, the user is `owner`.
3. Otherwise, the user is `defaultRole` (default `member`).

Role is computed only at first sign-in - subsequent logins keep whatever role the user currently has. This avoids accidentally yanking owner status from someone whose group membership got reorganized; promote / demote explicitly via the web app.

### Session lifetime

The control plane mints a sealed session cookie scoped to its own lifetime - it does NOT silently refresh against the IdP. When the cookie expires (default 7 days), the web app shows the login page and the user re-auths through the IdP.

### IdP offboarding behavior

Because the cookie is the freshness signal (not a per-request IdP probe), there is a window between an IdP-side change and the control plane catching up:

* **User removed from the IdP** keeps a working session until the cookie expires (≤ 7 days) or they sign out. To force-revoke immediately: have an owner remove them from the org via Settings → Members. That cuts org-scoped access right away; the user's stale cookie still authenticates them as a person but resolves to no org membership.
* **Group changes do NOT auto-propagate to existing users.** First sign-in resolves a Radar role from `adminGroups` membership and writes it to `org_members`. Subsequent sign-ins do not re-resolve - `ON CONFLICT DO NOTHING` on the join row. Both directions are affected:

  * A new admin added to `adminGroups` after their first login stays at their original role until an owner promotes them via Settings → Members.
  * A demoted user removed from `adminGroups` retains their `owner` role in Radar until an owner adjusts it.

  For shops that need automatic group-driven role sync, write a small reconciler that calls the hub's `PATCH /api/orgs/{id}/members/{user_id}` from your IdP webhook. Built-in two-way sync is on the GA roadmap.

### Sign-out

The web app's sign-out button calls `POST /api/auth/signout`, which clears the session cookie and (when the IdP advertises `end_session_endpoint` in discovery) returns a redirect URL to the IdP's logout page. Most IdPs advertise this; Google does not.

## Personal Access Tokens

PATs (`rhp_*`) work identically to Cloud:

* Mint via Settings → Access tokens (logged-in user only mints their own).
* Pin to a single org at mint time.
* Used as `Authorization: Bearer rhp_...` for MCP, `skyhook-cli`, and any script that can't carry a browser cookie.

The PAT path is decoupled from OIDC and break-glass - tokens validate against a SHA-256 hash in Postgres, no upstream IdP roundtrip per request.
