Docs
API

Authentication API

Sign in, accept the terms, refresh a session and sign out through the API.

All authenticated endpoints require either:

  • Authorization: Bearer <access_token> header
  • clusterz_token httpOnly cookie (set by OAuth callback)

Sign-in Providers

GET /api/v1/auth/providers

Response 200: the providers this API has fully configured, in the order the sign-in page shows them:

{ "providers": ["google", "apple", "github", "gitlab", "bitbucket"] }

Google and Apple are listed only when every one of their settings is present.

Accept the terms

POST /api/v1/user/terms

Body {"version": "2026-11-08.e3827815"}: the version GET /api/v1/user/me gives as terms_version, whose terms_accepted says whether this person has accepted it, and terms_changed whether they accepted an earlier version. The dashboard sends the version of the words it shows. Response 200, or 409 terms_changed when that is not the version in force, as while the dashboard and the API are on different releases. Until they accept, every change under /api/v1/teams/{teamSlug} answers 403 terms_required; reads stay open. The row records how = dialog, or how = cli when the body adds "how": "cli", as cluz terms accept does.

OAuth Login

GET /api/v1/auth/oauth/{provider}

Redirects to provider's authorization page. Provider: google, apple, github, gitlab, bitbucket.

?terms=<version> is a sign-up's ticked box. When it is the version in force and the Referer host is exactly the dashboard's or the domain it sits under, the callback records an acceptance with how = sign_up_box; a failed write logs, and the sign-in goes on. Anything else leaves no row, and the dashboard asks in its dialog.

Sets a CSRF state token in Redis (10 min TTL) before redirect. Google and Apple also get a nonce derived from the state, which their ID token must carry.

A browser navigates here, so a refusal, such as a provider that is not configured, is a redirect to the dashboard's /auth/error?error=<code>, never JSON. So is the GitHub App's GET /api/v1/auth/github-app/install, with session_expired when the session has lapsed.

OAuth Callback

GET /api/v1/auth/oauth/{provider}/callback?code=<code>&state=<state>
POST /api/v1/auth/oauth/apple/callback

Sign in with Apple posts code, state and, on a person's first sign-in only, user (their name) as a form; every other provider redirects with the code in the query. Google's and Apple's ID tokens are checked against the keys each publishes, with their issuer, audience, expiry and nonce. A cancel at the provider lands on /auth/error?error=sign_in_cancelled.

Exchanges authorization code for user info, creates or links account, issues JWT tokens. A known identity signs in to its own account. A first sign-in through a provider joins an existing account only when the provider verified the email, it matches the account's own verified email (ignoring case), and it is not an Apple private relay address; any other match is refused with /auth/error?error=email_in_use, and an email that matches no account makes a new one.

Response: HTTP redirect to dashboard (a 303 after Apple's form POST, a 307 otherwise) with httpOnly cookies set:

  • clusterz_token (access token, 15 min)
  • clusterz_refresh (refresh token, 7 days, path-scoped to /api/v1/auth/refresh)

A first sign-in creates the user and a team they own. It lands on /onboarding when it came through GitHub, GitLab or Bitbucket. Through Google or Apple it lands on /invite/<token> when an invitation sent to its verified email can still be accepted, and otherwise on /welcome, which asks what the team is for. A returning user lands on /home.

CLI Login

The cluz CLI signs in through the same OAuth redirect with two extra query parameters:

GET /api/v1/auth/oauth/{provider}?cli_port=<port>&cli_challenge=<challenge>
  • cli_port: the CLI's listener on 127.0.0.1, 1024 to 65535.
  • cli_challenge: base64url (no padding) SHA-256 of a random verifier the CLI keeps.

After the provider callback, the browser is redirected to http://127.0.0.1:<port>/callback?code=<one-time-code> instead of the dashboard, and no cookies are set. The code lives 2 minutes and is single-use.

POST /api/v1/auth/cli/token

Request:

{ "code": "<one-time-code>", "code_verifier": "<verifier>" }

Response 200: the same token pair as Refresh Token below. 400 when the code is unknown, expired, already used, or the verifier does not match; the code is spent either way.

Refresh Token

POST /api/v1/auth/refresh

Uses the clusterz_refresh cookie to issue a new access token. Non-browser clients such as the CLI send the refresh token in a Cookie: clusterz_refresh=... header.

Response 200:

{
  "access_token": "eyJ...",
  "refresh_token": "eyJ...",
  "expires_at": "2025-01-15T10:30:00Z",
  "token_type": "Bearer"
}

Logout

POST /api/v1/auth/logout

Clears authentication cookies.

Response 200:

{ "message": "logged out" }