Skip to Content
API ReferenceAuthentication

Authentication

The Quazzar Space API authenticates every request with a JWT access token. Send it either as a Bearer token in the Authorization header or as the httpOnly access_token cookie that POST /auth/login sets — the API accepts both, and checks the header first.

Base URL

https://app.quazzar.space

Most authentication endpoints are prefixed with /auth.

Login

POST /auth/login

curl -X POST https://app.quazzar.space/auth/login \ -H "Content-Type: application/json" \ -d '{ "email": "[email protected]", "password": "your-password" }'

Response (200 OK):

{ "access_token": "eyJhbGciOiJIUzI1NiIs...", "refresh_token": "8Kc1r...", "token_type": "bearer" }

The response carries only tokens — there is no user object. Read the account with GET /auth/me after signing in. The same two tokens are also set as httpOnly cookies (access_token, refresh_token), which is how the browser app authenticates.

If the account has an authenticator enrolled, or policy requires one, send the six-digit code as totp_code in the same body. A single-use recovery code is accepted in that field as well.

Refusals:

StatusDetailCause
401Invalid credentialsUnknown email, wrong password, or a blocked account
401TOTP code requiredCorrect password, second factor not yet supplied
401TOTP code required or invalidA code was supplied and did not verify
401TOTP required. Please configure an authenticator app...Policy requires MFA and the account has none enrolled
401Please accept your invitation via the email link before logging inA pending, unexpired invitation exists for this address
423Account temporarily locked due to failed login attempts...Lockout is in force; the body also carries locked_until
422—Missing or malformed fields

A blocked account is refused like an unknown one

An account with is_active cleared — blocked by an administrator — is refused 401 Invalid credentials, the same status and the same body an unknown address gets, and no tokens are issued. Login used to answer 200 and hand back a real token pair that every subsequent request then rejected, which reads as “login works, the app is broken” rather than “this account is suspended”.

Saying “your account is disabled” is deliberately avoided: it would turn the login form into an account-state oracle. The password is verified before the check either way, so the timing matches too.

An access token issued before a block stops working immediately. The account row is re-read on every request, and the paths that decode a token themselves — GET /mcp/status, which takes the token as a query parameter, and the scorecards and gitops progress WebSockets, which cannot use a dependency — go through the same resolver as everything else, so they refuse it as well. A refresh token cannot mint a replacement: POST /auth/token/refresh answers 401 for an inactive account.

Using the token

curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ https://app.quazzar.space/auth/me

Token refusals all arrive as 401 with one of a small set of details: Invalid or expired token, Invalid token claims, Invalid subject in token, Invalid token version (the session was revoked, e.g. by a password reset), Token revoked, or User not found or inactive. That last one covers “no such account” and “that account is blocked” with one message on purpose, so a caller supplying account ids cannot learn which of them exist.

Refresh a session

POST /auth/token/refresh

curl -X POST https://app.quazzar.space/auth/token/refresh \ -H "Content-Type: application/json" \ -d '{"refresh_token": "8Kc1r..."}'

The refresh token may come from the JSON body or from the refresh_token cookie; an empty body is fine when the cookie is present. Returns a new access_token / refresh_token pair in the same shape as login.

Refreshing does not update the account’s last-login timestamp — a refresh token is only ever issued by a login, so refreshing cannot change whether an account has ever signed in.

Registration

POST /auth/register

curl -X POST https://app.quazzar.space/auth/register \ -H "Content-Type: application/json" \ -d '{ "email": "[email protected]", "password": "secure-password-123", "name": "Jane Developer", "phone": "+1-555-0100" }'

The body is email, password (minimum 8 characters), and optional name and phone. There is no full_name or company_name field.

Response (201 Created):

{ "id": "550e8400-e29b-41d4-a716-446655440000", "email": "[email protected]", "name": "Jane Developer", "phone": "+1-555-0100", "is_superuser": false, "is_staff": false, "admin_role": null, "preferred_language": null, "navigation_mode": null, "job_title": null, "timezone": null, "table_density": null }

A new account is created as a customer (see Account classes) and is put on the Free plan.

StatusDescription
400Email already registered, or the password fails the configured policy
403Self-service registration is disabled, registration is invite-only, or the email domain is not on the allowlist
422Validation error (invalid email, password shorter than 8 characters)

Logout

POST /auth/logout

curl -X POST https://app.quazzar.space/auth/logout \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response: 204 No Content. Revokes the refresh token (from the body or the cookie) and clears both auth cookies. Requires a valid access token.

Sessions

EndpointDescription
GET /auth/sessionsList active sessions — device, IP, created_at, last_seen_at, and current for the calling session
DELETE /auth/sessions/{session_id}Revoke one session (204)
DELETE /auth/sessionsRevoke every other session; returns {"revoked": n}

Two-Factor Authentication (TOTP)

Enrolment is two calls: provision mints a secret and returns it to you without storing anything, then verify proves you can generate codes from it and stores it as a named device. Up to three authenticators per account.

POST /auth/totp/provision

curl -X POST https://app.quazzar.space/auth/totp/provision \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response (200 OK):

{ "secret": "JBSWY3DPEHPK3PXP", "otpauth_url": "otpauth://totp/[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Quazzar%20Space", "issuer": "Quazzar Space", "account_name": "[email protected]", "qrcode_data_url": "data:image/png;base64,iVBOR..." }

Nothing is persisted by this call. Keep the secret — the next call needs it back.

POST /auth/totp/verify

curl -X POST https://app.quazzar.space/auth/totp/verify \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"secret": "JBSWY3DPEHPK3PXP", "code": "123456", "name": "Phone"}'

Response: 204 No Content. All three fields are required. 400 for an invalid code, a missing name, or a fourth authenticator.

Recovery codes are not returned here — they are a separate endpoint.

Recovery codes

EndpointDescription
POST /auth/totp/recovery/regenerateIssue a fresh set of 10 single-use codes, replacing any prior set. Returns {"codes": [...]} — shown once
GET /auth/totp/recovery{"total": n, "unused": n}

A recovery code is accepted in the totp_code field of POST /auth/login and is consumed on use.

Managing authenticators

EndpointDescription
GET /auth/totp/devicesList enrolled authenticators
DELETE /auth/totp/devices/{device_id}Remove one authenticator (204)
POST /auth/totp/deleteClear the account’s legacy single-secret TOTP. Body {"password": "..."}; 204 on success, 401 for a wrong password

POST /auth/totp/delete does not remove totp/devices rows — use the per-device endpoint for those. There is no DELETE /auth/totp.

Password Reset

POST /auth/password/reset

curl -X POST https://app.quazzar.space/auth/password/reset \ -H "Content-Type: application/json" \ -d '{"email": "[email protected]"}'

Always answers 200 {"status": "ok"}, whether or not the address exists, and pads every response to a constant minimum duration so timing does not reveal it either.

If the account has an authenticator enrolled, a valid totp_code must be included in this body. Without it the request still answers 200 and simply sends nothing — second-factor accounts cannot be reset from mailbox control alone. The reset link is valid for 60 minutes.

POST /auth/password/reset/confirm

curl -X POST https://app.quazzar.space/auth/password/reset/confirm \ -H "Content-Type: application/json" \ -d '{ "token": "reset-token-from-email", "new_password": "new-secure-password-456" }'

Answers 200 {"status": "ok"}. Confirming a reset revokes every session: all refresh tokens are marked revoked and the account’s token version is bumped, so access tokens issued before the reset are refused.

StatusDescription
400Empty token, token expired or already used, or the new password fails the policy
404No such reset token

The paths are /auth/password/reset and /auth/password/reset/confirm. There is no /auth/password-reset/request.

Current User

GET /auth/me

curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ https://app.quazzar.space/auth/me

Returns the same object shape as registration.

PATCH /auth/me

curl -X PATCH https://app.quazzar.space/auth/me \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "Jane M. Developer", "job_title": "SRE", "timezone": "Europe/Kyiv"}'

Writable fields: email, name, phone, preferred_language, navigation_mode, job_title, timezone, table_density. Returns the updated object.

There is no /users/me; the profile lives under /auth/me. (POST /users exists and creates an account, but self-service signup should go through POST /auth/register, which applies the registration policies above.)

GET /auth/me/capabilities

curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ "https://app.quazzar.space/auth/me/capabilities?project_id=PROJECT_UUID"

Returns {project_id, is_owner, is_member, capabilities} — the caller’s effective permission keys in one project.

API Keys

GET/POST/DELETE /me/api-keys manage named, scoped, expiring API keys, and require both the crud_pro and crud_api features. The key value is returned once, on creation.

Unverified: what a key authenticates. Nothing in the backend reads user_api_keys.token_hash back — it is written on create and never compared, and no request path accepts a key in place of an access token. On the evidence in the code these keys are managed but not yet honoured as request credentials, so treat “authenticate with an API key” as unconfirmed until someone runs a request with one. To check: create a key and call any authenticated endpoint with it as Authorization: Bearer <key>; a 401 Invalid or expired token confirms it is not accepted.

The /metrics endpoint has its own separate keys (/me/metrics-api-keys), and Pulsar has its own (/pulsar/api-keys).

Rate Limits

EndpointLimit
POST /auth/login10 requests per minute per IP
POST /auth/password/reset5 requests per minute per IP
POST /billing/stripe/webhook120 requests per minute per IP

These three are the only rate-limited routes. POST /auth/register is not rate-limited, and there is no global per-token limit on authenticated endpoints. Limits are keyed on the remote address. Exceeding one returns 429 Too Many Requests.

Next Steps