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>headerclusterz_tokenhttpOnly cookie (set by OAuth callback)
Sign-in Providers
GET /api/v1/auth/providersResponse 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/termsBody {"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/callbackSign 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 on127.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/tokenRequest:
{ "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/refreshUses 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/logoutClears authentication cookies.
Response 200:
{ "message": "logged out" }