API Reference
Auth API

Auth API

Sign up, exchange an OAuth code for tokens, read your own profile, and mint a WebSocket ticket. All routes use snake_case request bodies (per the API-wide convention).

For the account lifecycle (signup → pending → approved), the reCAPTCHA gate, and how custom:approved locks the rest of the API, see Authentication, Signup & Approval. This page is the endpoint-by-endpoint wire contract; the authoritative spec is specs/api/auth.md in the repo.

Route summary

MethodPathAuthDescription
POST/api/auth/signupNoneSign up via Cognito + reCAPTCHA
POST/api/auth/callbackNoneExchange an OAuth code for tokens
GET/api/auth/meJWTGet your profile + approval status
POST/api/ws/ticketJWTMint a WebSocket ticket

How every JWT route is guarded

Every route marked "JWT" above — and every other protected route in the API — goes through one shared Lambda authorizer before your handler ever runs:

  1. Your Authorization: Bearer <token> header is verified against Cognito's public keys (signature, expiry, issuer, audience).
  2. The token's custom:approved claim must be the exact string "true". If you're signed up but not yet approved by an admin, you get the same 401 an invalid token would produce — there's no separate "authenticated but pending" status code.
⚠️

Approval is baked into your JWT at the moment it's issued, not read fresh on every request. If an admin approves you while you're still signed in, your existing token keeps failing until you get a new one (sign in again, or let your refresh token rotate). This is exactly what the "Awaiting Approval" screen's polling + forced-relogin dance is working around — see Authentication, Signup & Approval.

Sign up

POST /api/auth/signup
⚠️

This endpoint only covers email/password signup. Google sign-in never calls it — the Cognito Hosted UI collects Google credentials directly, outside this API.

Body

{
  "email": "new@example.com",
  "password": "Str0ngPass!",
  "name": "New User",
  "recaptcha_token": "<reCAPTCHA Enterprise client token>"
}

Response 200

{ "message": "Account created. Check your email to verify." }
StatusMeaning
200Cognito account created — check email for the verification link
400email / password / name missing
400recaptcha_token missing, or its score is below the 0.5 threshold
400Password doesn't meet Cognito's policy
409An account with this email already exists

Creating the account here is not the end of the story — you still need to confirm your email and get admin-approved before you can call anything else. See the full lifecycle.

Callback

POST /api/auth/callback
⚠️

Exchanges an OAuth authorization code for Cognito tokens, and this is fully implemented server-side (including setting an HttpOnly session cookie) — but the shipped frontend doesn't call it. The login page exchanges the code with Cognito's token endpoint directly from the browser instead, and keeps tokens in localStorage. See Authentication, Signup & Approval for the full picture. Documented here for completeness/accuracy, not as the flow you should build against.

Body

{
  "code": "<authorization code from the Cognito redirect>",
  "redirect_uri": "https://app.example.com/login/",
  "code_verifier": "<PKCE verifier, optional>"
}

Response 200

{
  "access_token": "string",
  "id_token": "string",
  "expires_in": 3600,
  "token_type": "Bearer"
}

Also sets Set-Cookie: lkwiz_token=<id_token>; HttpOnly[; Secure]; SameSite=Strict; Path=/; Max-Age=<expires_in> (Secure is dropped only when STAGE=dev).

StatusMeaning
200Tokens issued
400code missing
400redirect_uri missing

No refresh_token in the response body, despite what specs/SPECS.md §5.1 says — the shipped code only returns access_token / id_token / expires_in / token_type.

Me

GET /api/auth/me

Response 200

{
  "user_id": "user-abc123",
  "email": "alice@example.com",
  "name": "Alice",
  "role": "member",
  "status": "approved",
  "created_at": "2026-01-10T09:00:00+00:00",
  "updated_at": "2026-01-12T14:30:00+00:00"
}

status is "approved" or "pending" — read live from DynamoDB, not from your JWT's custom:approved claim. This is the one place in the API where approval status is always fresh, which is exactly why the "Awaiting Approval" page polls this endpoint rather than just checking the token it already has.

StatusMeaning
200OK
401No sub in your token (not reachable in practice — the shared authorizer already requires a valid, approved token to get this far)
404No profile record exists for your user id
⚠️

There's no "rejected" value here. A rejected user can't reach this endpoint anyway — rejection disables the Cognito account outright — but if you're building against this contract, don't expect a third status value the way GET /api/admin/users has one.

WS Ticket

POST /api/ws/ticket

Mints a short-lived credential for the real-time collaboration WebSocket, since a $connect handshake can't carry a normal Authorization header.

Response 200

{ "ticket": "3f9e2c10-...-uuid4" }
StatusMeaning
200Ticket issued, valid for 8 hours
401No sub in your token (same practical non-reachability as Me)
⚠️

The ticket snapshots your role and approved status from your current JWT, at mint time — it does not re-check either against the database when the WebSocket connects. It also isn't deleted after first use, so despite the name it's valid for repeated connects until it expires. Mint a fresh one per session; don't try to reuse or cache it beyond that.

See also

  • Authentication, Signup & Approval — the account lifecycle, the approval gate, and known gaps between this API and the shipped frontend.
  • specs/api/auth.md — the authoritative spec, including full validation order and every Cognito trigger this domain depends on.