# Sign in with Cluster



Add "Sign in with Cluster" to your app via standard OpenID Connect — end users authenticate with their Cluster account and your app receives an `id_token` containing their identifier and profile claims. The CLI registers and manages the OAuth 2.0 clients; the identity provider itself runs at `https://accounts.clusterbase.ai`.

OIDC clients are top-level objects scoped to an organization: a registered client belongs to the org and is visible to every member. Each client carries its own redirect URIs, scopes, grant types, and a `client_secret` (issued once at create time — never recoverable later).

## The Flow [#the-flow]

```bash
# 1. Register the client (returns the one-time client_secret)
ccp oidc create --name myapp --redirect-uri https://myapp.com/auth/callback

# 2. Use the issuer + client_id + client_secret in your app's OIDC library
#    (issuer = https://accounts.clusterbase.ai; client_id = the hydra_client_id from step 1)

# 3. Users hit /auth/login in your app → get redirected to accounts.clusterbase.ai
#    → sign in → redirected back with an authorization code → exchange for tokens
```

The `client_secret` prints **once** at create time. If you lose it, rotate it with `ccp oidc rotate-secret`.

## Register a Client [#register-a-client]

```bash
ccp oidc create --name myapp --redirect-uri https://myapp.com/auth/callback
# ✓ Created OIDC client myapp
#    id: oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
#
#    Issuer:         https://accounts.clusterbase.ai
#    Client ID:      b6e3f2a1-90b5-4532-9139-13baa05ad0b3
#    Client Secret:  zKv3rT9pQ2mN8wL4xY7bF1jA5sH6gE0d
#
#    ⚠ Save the secret now — it cannot be recovered later.
#      Rotate it any time with `ccp oidc rotate-secret <id>`.
```

`--name` is unique per organization (1–128 chars). Two clients in the same org cannot share a name; two different orgs can each have a client named `myapp`.

`--redirect-uri` is repeatable — pass once per allowed callback URL:

```bash
ccp oidc create --name myapp \
  --redirect-uri https://myapp.com/auth/callback \
  --redirect-uri http://localhost:3000/auth/callback
```

Redirect URIs must be `https://*` or `http://localhost(:port)` / `http://127.0.0.1(:port)`. Fragments, wildcards, and userinfo are rejected. Matching is exact: `https://myapp.com/cb` does not match `https://myapp.com/cb/` or `https://myapp.com/cb?foo=bar`.

The org is resolved from `cluster.toml`, the `--org-id` flag, the `CCP_ORG_ID` env var, or — when ambiguous — an interactive picker:

```bash
ccp oidc create --name myapp --redirect-uri https://myapp.com/cb --org-id org_abc123
```

### Two IDs to Track [#two-ids-to-track]

`create` prints two UUIDs, and they're for different audiences. Don't paste the wrong one — the labels in the output are the disambiguator:

| Output label     | Shape                     | Goes where                                                                                                            |
| ---------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| **`id:`**        | `oc-<uuid>`               | The **CLI id**. Use with `ccp oidc info`, `rotate-secret`, `destroy`. Your app never sees this.                       |
| **`Client ID:`** | bare `<uuid>` (no prefix) | The &#x2A;*OAuth `client_id`** your OIDC library expects. This is what goes in `CLUSTER_CLIENT_ID` in your app's env. |

Both are real UUIDs. The `oc-` prefix on the CLI id keeps the two distinguishable when you're copy-pasting — paste whichever label matched what you're filling in.

### Optional Flags [#optional-flags]

| Flag                               | Default                               | When to set it                                                                                                                               |
| ---------------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `--subject-type {public,pairwise}` | `public`                              | Pass `pairwise` only if you need per-RP pseudonymous `sub` claims. `public` is correct for most apps.                                        |
| `--first-party`                    | off                                   | Marks the client as first-party; the consent screen auto-accepts. Third-party (default) shows users a "myapp wants permission to..." prompt. |
| `--scope <scope>` (repeatable)     | `openid email profile offline_access` | Override the default scope list.                                                                                                             |
| `--grant-type <type>` (repeatable) | `authorization_code refresh_token`    | Override the default grant list.                                                                                                             |

The defaults are right for a standard authorization-code app with refresh tokens. Don't change them unless you have a specific reason — empty `--scope` / `--grant-type` lists let the API fill in the defaults; passing an empty list explicitly is not the same as omitting the flag.

When you run `ccp oidc create` from inside a project directory (one with `.ccp/config.json`), the CLI also:

* **Writes `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, and `OIDC_CLIENT_SECRET` to `./.env`** — the secret is a one-time value that Hydra never re-reveals, so the CLI saves it to disk before you can close the terminal. The write is atomic and the file is created at mode `0600`.
* **Generates a `SESSION_SECRET`** into `./.env` (32 random bytes, standard base64 — the same shape as `openssl rand -base64 32`) when the project doesn't already have a non-empty one, so apps that seal sessions get a signing key without running that command by hand. An existing non-empty `SESSION_SECRET` is left untouched, so re-running `create` (after a rotation or retry) never invalidates live sealed sessions; clear the value (`SESSION_SECRET=`) to have the next `create` mint a fresh one.
* **Auto-links** the new client to this project — see [Link the Client to a Project](#link-the-client-to-a-project) for what that unlocks.

Pass `--no-link` to opt out of all of this: the client is created and the secret is printed, but nothing is written to `.env` (no trio, no `SESSION_SECRET`) and no project link is performed.

## Link the Client to a Project [#link-the-client-to-a-project]

OIDC clients enforce strict redirect-URI matching (no wildcards, no patterns). That means every deploy URL your users sign in from has to be pre-registered — manually tracking that list breaks the moment your project gets a new prod URL, a custom domain, or a preview environment.

Linking solves it. When a project is linked to an OIDC client, `ccp deploy`, `ccp promote`, and `ccp domain link` automatically sync the client's redirect URIs with whatever hosts the project actually serves. No env vars; no manual list management.

```bash
# From inside a project directory:
ccp oidc link oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
# ✓ Linked project to OIDC client myapp
#    id: oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
#   ✓ Registered https://myapp.clusterbase.dev/auth/callback with OIDC client
```

`link` validates that the client belongs to the same organization as the project (cross-org linking is rejected before any state change is written). It then writes `oidc_client_id` to `.ccp/config.json` and **backfills** by default: queries the project's production deployment URL plus every custom-linked domain and registers `https://<host>/auth/callback` for each in one atomic PATCH. Pass `--no-backfill` to defer registration to the next `ccp deploy`.

`ccp oidc create` does the same backfill-less link automatically when run inside a project dir (so the typical "create + use here" flow is one command). The `--redirect-uri` you pass to `create` is whatever you already needed for local dev — the prod URL gets registered on the first `ccp deploy --prod`.

### Auto-register on Deploy [#auto-register-on-deploy]

Once the project is linked, `ccp deploy --prod` and `ccp promote` register the production deploy URL, and `ccp domain link` registers any newly-attached custom domain:

```bash
ccp deploy --prod
# ◼ Function deployed!
# › https://myapp.clusterbase.dev
#  ✓ Registered https://myapp.clusterbase.dev/auth/callback with OIDC client

ccp domain link app.example.com
#   ✓ Domain app.example.com linked
#   ✓ TLS certificate will be issued automatically
#   ✓ Registered https://app.example.com/auth/callback with OIDC client
```

Behavior at a glance:

* **Idempotent.** If the URI is already in `redirect_uris`, nothing happens — no print, no PATCH.
* **Production auto, preview opt-in.** Preview deploys (without `--prod`) are NOT auto-registered by default. Each preview produces a unique `<deployment-id>.clusterbase.dev` host that would accumulate forever in the OIDC client; the practical client-config cap would be hit within weeks of active CI use. To test sign-in on a one-off preview link, opt in per-deploy with `ccp deploy --register-redirect-uri`, which registers just that deploy's host. It's a per-deploy toggle for a human testing a preview, **not CI** — each run adds one URI with no auto-prune, so clean up afterward with `ccp oidc update --remove-redirect-uri https://<preview-host>/auth/callback`. If the project isn't linked to a client, the flag prints a one-line warning instead of silently doing nothing. (`ccp oidc update --add-redirect-uri` still works too.)
* **Non-fatal.** If the sync fails (network blip, lost session, client deleted), the deploy itself still succeeds. You get a `⚠ Could not auto-register OIDC redirect URI` line with a copy-pasteable retry invocation. No redeploy needed to recover.
* **Project-scoped on domain link.** `ccp domain link foo.com --function fn-OTHER` only auto-registers if `fn-OTHER` matches the cwd project's function. Registering on the wrong client across projects is silently skipped.
* **Manual unlink.** `ccp domain unlink` does NOT auto-deregister — the CLI can't tell which project the domain was previously attached to without an extra round-trip. Remove the URI manually via `ccp oidc update --remove-redirect-uri` if needed.

### Custom Callback Path [#custom-callback-path]

The default callback path is `/auth/callback`. If your app uses a different path (e.g. `/api/auth/callback`, `/oauth/callback`), edit `oidc_callback_path` in `.ccp/config.json`:

```json
{
  "app_id": "abc-123",
  "organization_id": "org_...",
  "oidc_client_id": "oc-...",
  "oidc_callback_path": "/api/auth/callback"
}
```

The setting applies to every subsequent auto-register (deploy, promote, domain link). Existing URIs on the client are not migrated — use `ccp oidc update` to fix them up.

### Unlinking [#unlinking]

Edit `.ccp/config.json` and set `oidc_client_id` to an empty string (or delete the field). Subsequent deploys won't auto-register. Already-registered URIs stay on the client; remove them with `ccp oidc update --remove-redirect-uri` if you're decommissioning the project.

## Use the Client in Your App [#use-the-client-in-your-app]

The `create` output gives you everything your OIDC library needs:

```ts
// Example with `openid-client` (Node):
import { Issuer } from "openid-client";

const issuer = await Issuer.discover("https://accounts.clusterbase.ai");
const client = new issuer.Client({
  client_id:     process.env.CLUSTER_CLIENT_ID,      // the "cluster-..." value
  client_secret: process.env.CLUSTER_CLIENT_SECRET,  // the one-time secret
  redirect_uris: ["https://myapp.com/auth/callback"],
  response_types: ["code"],
});
```

Same shape for any OIDC-compliant library — `golang.org/x/oauth2`, Python `authlib`, Rust `openidconnect`, browser-side `oidc-client-ts`. Point them all at `https://accounts.clusterbase.ai` as the issuer and use the client\_id / client\_secret you got from `ccp oidc create`.

`id_token` claims that ship out of the box:

| Claim               | Source                                        |
| ------------------- | --------------------------------------------- |
| `iss`               | `https://accounts.clusterbase.ai`             |
| `sub`               | Stable user identifier (Kratos identity UUID) |
| `aud`               | Your `client_id`                              |
| `email`             | User's email                                  |
| `name`              | User's display name                           |
| `exp`, `iat`, `nbf` | Standard JWT timestamps                       |

Verify the signature against `https://accounts.clusterbase.ai/.well-known/jwks.json` — every modern OIDC library does this for you when given the issuer URL.

## List Clients [#list-clients]

```bash
ccp oidc ls
# NAME           KIND         ID                                       CLIENT ID                             REDIRECT URIS
# myapp          third-party  oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b  b6e3f2a1-90b5-4532-9139-13baa05ad0b3  https://myapp.com/auth/callback, http://localhost:3000/auth/callback
# internal-tool  first-party  oc-7c89df42-8a31-4d67-b023-1c4e5f6a78b9  f48c1023-5d72-4a98-b6e1-9028fa37c145  https://tool.internal.cluster.app/cb
```

`list` is an alias for `ls`. The org is resolved the same way as `create`.

Rows print one per line, sized to fit the terminal — except the `id` column, which is never elided even at the cost of squeezing other columns, since it's the value every other `ccp oidc` command takes as its argument. Pass `--json` for full ids, exact timestamps, and complete redirect URI lists with no styling.

## Show Client Details [#show-client-details]

```bash
ccp oidc info oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
#  • myapp
#    id:                          oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
#    client_id:                   b6e3f2a1-90b5-4532-9139-13baa05ad0b3
#    organization_id:             org_abc123
#    first_party:                 false
#    token_endpoint_auth_method:  client_secret_basic
#    redirect_uris:
#      - https://myapp.com/auth/callback
#      - http://localhost:3000/auth/callback
#    scopes:
#      - openid
#      - email
#      - profile
#      - offline_access
#    grant_types:
#      - authorization_code
#      - refresh_token
#    created_at:  2026-05-24T17:08:21Z
#    updated_at:  2026-05-24T17:08:21Z
```

`info` never includes the `client_secret`. If you've lost it, see [Rotate the Secret](#rotate-the-secret) below.

## Update a Client [#update-a-client]

Adjust the client's redirect URIs, name, or first-party flag without rotating the secret. Every invocation is one atomic PATCH; the client\_secret is never touched.

```bash
# Add or remove redirect URIs (both flags repeatable, applied in one PATCH)
ccp oidc update oc-5d9f2c3b-... --add-redirect-uri https://staging.myapp.com/auth/callback
ccp oidc update oc-5d9f2c3b-... --remove-redirect-uri http://localhost:9999/auth/callback

# Combined add + remove for a URL rotation
ccp oidc update oc-5d9f2c3b-... \
  --add-redirect-uri https://new.myapp.com/auth/callback \
  --remove-redirect-uri https://old.myapp.com/auth/callback

# Rename the client (still unique per org)
ccp oidc update oc-5d9f2c3b-... --name myapp-v2

# Toggle the first-party flag
ccp oidc update oc-5d9f2c3b-... --first-party
ccp oidc update oc-5d9f2c3b-... --no-first-party
```

Most users won't run `update` directly — linked projects keep redirect URIs in sync automatically (see [Auto-register on Deploy](#auto-register-on-deploy)). Reach for `update` when:

* You need to register a preview URL by hand (preview auto-register is opt-in per-deploy via `ccp deploy --register-redirect-uri`).
* You're cleaning up stale URIs from old deploys or custom domains.
* The project isn't linked and you're managing URIs by hand.
* You're scripting a redirect-URI rotation across deploys.

Guardrails:

* **Self-canceling input is rejected.** `--add-redirect-uri X --remove-redirect-uri X` errors before any network call, because the local merge would produce a zero-diff PATCH that prints success without changing anything.
* **Empty result is rejected.** A removal that would leave the client with zero redirect URIs is rejected — the server enforces `cardinality >= 1`. Add a replacement first, then remove.
* **At least one flag required.** Calling `update` without any of `--add-redirect-uri`, `--remove-redirect-uri`, `--name`, `--first-party`, or `--no-first-party` errors before any network call.

## Rotate the Secret [#rotate-the-secret]

```bash
ccp oidc rotate-secret oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
# Rotate secret for oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b? The current secret is invalidated immediately. (y/N): y
# ✓ Rotated secret for myapp
#
#    Issuer:         https://accounts.clusterbase.ai
#    Client ID:      b6e3f2a1-90b5-4532-9139-13baa05ad0b3
#    Client Secret:  newSecretHere...
#
#    ⚠ Save the secret now — it cannot be recovered later.
```

Rotation invalidates the old secret **immediately** — there is no grace window. Any deployed instance of your app still using the old secret starts getting `401 invalid_client` on its next `/oauth2/token` call. Plan a coordinated deploy: rotate, update the env var in your hosting platform, re-deploy.

`-y` skips the confirmation prompt.

## Destroy a Client [#destroy-a-client]

```bash
ccp oidc destroy oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b
# Destroy OIDC client oc-5d9f2c3b-1e8a-4f7c-9b1a-2d6e3a8f0c4b? Every relying-party request using its credentials will fail. (y/N): y
# ✓ OIDC client destroyed
```

`rm` is an alias for `destroy`. Every active relying-party session that still has an unexpired `id_token` keeps working until the token expires, but no new tokens can be minted: `/oauth2/auth` returns `400 invalid_client` immediately and `/oauth2/token` rejects authorization codes.

`-y` skips the confirmation prompt.

## Capturing the Secret [#capturing-the-secret]

`create` and `rotate-secret` print the secret to stdout; the "save it now" warning prints to stderr. So redirecting stdout captures the secret cleanly:

```bash
ccp oidc create --name myapp --redirect-uri https://myapp.com/cb > secret.txt
# warning lands on terminal (stderr)
# secret + Client ID + Issuer in secret.txt
```

In headless mode (`CCP_HEADLESS=1`) the same redirect works, and the `rotate-secret` / `destroy` confirmation prompts auto-accept — see the [Headless mode](/docs/ccp/headless) reference for the broader contract.

## Redirect URI Requirements [#redirect-uri-requirements]

* Must be `https://*` or `http://localhost(:port)` / `http://127.0.0.1(:port)`.
* Must not include a URL fragment (`#anything`).
* Must not include a `*` substring (no wildcards).
* Must not include userinfo (`https://user:pass@host/cb`).
* Matching is **exact**. Re-register if you change the host, path, port, or query string.

For local development, use `http://localhost:<port>/auth/callback` — the validator accepts loopback over plain HTTP. For everything else, HTTPS is required.

## Subcommand Reference [#subcommand-reference]

| Subcommand                  | Aliases | Description                                                                    |
| --------------------------- | ------- | ------------------------------------------------------------------------------ |
| `create`                    | —       | Register a new client (prints one-time `client_secret`)                        |
| `ls`                        | `list`  | List clients in the resolved org                                               |
| `info <client_id>`          | —       | Show details for one client (no secret)                                        |
| `link <client_id>`          | —       | Link the current project to an OIDC client; backfills redirect URIs by default |
| `update <client_id>`        | —       | Change name, first-party flag, or add/remove redirect URIs (one atomic PATCH)  |
| `rotate-secret <client_id>` | —       | Generate a new secret; old secret invalidated immediately                      |
| `destroy <client_id>`       | `rm`    | Tear down the client                                                           |

## How It Works [#how-it-works]

* `create` POSTs to `/api/v1/oidc/clients` against the org-scoped infra-api, which then registers the client with the identity provider (Ory Hydra) on the cluster's admin port. The plaintext `client_secret` is returned in the response — Hydra stores only a bcrypt hash, so subsequent reads will not include the secret.
* `update` PATCHes the same endpoint with pointer-typed fields: omitted = preserve, present = replace. `--add-redirect-uri` / `--remove-redirect-uri` are merged locally against the current list and sent as one full-replace `redirect_uris` array; the rest of the update (name, first\_party) ships in the same body.
* `rotate-secret` is a composition: the API fetches the current client from Hydra, generates a new 32-byte random secret, and PUT-replaces the client with the new secret. There is no atomic rotate endpoint and no grace window.
* `link` writes `oidc_client_id` to `.ccp/config.json`, then (by default) GETs `/api/v1/serverless/functions/{id}` to enumerate the project's production deploy URL + custom-linked domains, and registers them with the OIDC client in one batched PATCH. The deploy/promote/domain-link auto-register hooks read the same `oidc_client_id` field to know which client to update.
* `destroy` deletes from both Hydra and the infra-api mirror table. The relying-party app starts getting `400 invalid_client` from `/oauth2/auth` on the next request.
* Org membership is enforced server-side on every write — a leaked `oc-…` id from another org cannot be probed.

## Limits [#limits]

| Limit                         | Value                                                          |
| ----------------------------- | -------------------------------------------------------------- |
| OIDC clients per organization | 25                                                             |
| Redirect URIs per client      | Capped by request body size (1MB); practical limit \~thousands |
| Client name length            | 128 characters                                                 |

The per-org cap is configurable on the server side; reach out if you have a legitimate need to register more than 25 clients in a single org.
