> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Authentication model for Stralos APIs

Sign-in happens on the central login host, backed by the login service and its central refresh-token session. After hand-off, the regional shell persists the browser session and provides the refresh and organisation-switch bridges used by feature bundles. The Global API retains OTP, the legacy OIDC exchange, regional sign-out-everywhere, public signing keys, and the service-token mint.

## Auth endpoints

### Current central session routes

| Method | Path | Purpose |
| - | - | - |
| `POST` | `/api/login/auth/refresh` | Rotate a central refresh token and return a new access token and refresh token. |
| `POST` | `/api/login/auth/switch-org` | Switch the active organisation without re-entering credentials. |
| `POST` | `/api/login/auth/logout` | Delete the presented central refresh-token session. |

In deployed regions, `/api/login/*` is proxied on the serving origin. Local shell development uses the login API at `http://localhost:3400`.

### Retained regional routes

| Method | Path | Purpose |
| - | - | - |
| `GET` | `/api/global/auth/tenant/:slug` | Resolve a workspace slug to its home region. No authentication required. |
| `POST` | `/api/global/auth/otp/request` | Create an OTP challenge for an email address. |
| `POST` | `/api/global/auth/otp/verify` | Verify a six-digit OTP code and issue a regional session. |
| `POST` | `/api/global/auth/oidc/exchange` | Redeem an OAuth2 authorisation code and issue a regional session. |
| `POST` | `/api/global/auth/refresh` | Rotate a regional refresh token. |
| `POST` | `/api/global/auth/switch-org` | Switch a regional session's active organisation. |
| `POST` | `/api/global/auth/logout` | Delete the presented regional session. |
| `POST` | `/api/global/auth/sessions/revoke-all` | Sign out everywhere for retained regional sessions. |

Central and regional refresh tokens use separate tables and peppers and are not interchangeable. Use `/api/login/auth/*` only with a central login token, and `/api/global/auth/*` only with a retained regional token. The current shell uses the central routes; its regional callback remains only for a ceremony that was already in progress during the migration.

## OTP verify response

A successful `POST /api/global/auth/otp/verify` returns a JSON object with the following fields:

```json theme={null}
{
  "accessToken": "<jwt>",
  "refreshToken": "<opaque-string>",
  "user": {
    "id": "<uuid>",
    "name": "<display name>",
    "email": "<email>",
    "organisationId": "<uuid>",
    "role": "admin | member"
  },
  "memberships": [
    {
      "organisationId": "<uuid>",
      "organisationName": "<display name>",
      "role": "admin | member",
      "status": "active"
    }
  ]
}
```

| Field | Notes |
| - | - |
| `accessToken` | Short-lived EdDSA JWT for API calls. |
| `refreshToken` | Opaque regional bearer string. Use it only with the retained `/api/global/auth/*` session routes. |
| `user` | The authenticated user with the selected active organisation and role. |
| `memberships` | All active memberships of active organisations for this user. Each entry includes the organisation's display name (`organisationName`) so a compatible regional client can label an organisation picker without a separate lookup. |

The active organisation is auto-selected: if the user has one active membership it is chosen automatically; if there are multiple, the user's home organisation is preferred, falling back to the earliest membership. Zero active memberships returns `403`.

## OIDC exchange

`POST /api/global/auth/oidc/exchange` redeems an OAuth2 authorisation code (PKCE flow) against the configured identity provider and issues a Stralos session. The code redemption happens server-side; the browser never holds an IdP token.

This endpoint runs alongside `POST /api/global/auth/otp/verify`. Both credential paths produce an identical session DTO.

No Stralos web client calls this endpoint. The shell retired its regional `/auth/callback` page, the only caller; sign-in goes through the central login host.

**Request body:**

```json theme={null}
{
  "code": "<authorization-code>",
  "codeVerifier": "<pkce-code-verifier>",
  "nonce": "<nonce-from-authorize-request>",
  "acceptMembershipId": "<invite-uuid>"
}
```

| Field | Required | Notes |
| - | - | - |
| `code` | Yes | The authorisation code returned to the redirect URI. |
| `codeVerifier` | Yes | RFC 7636 code verifier (43–128 unreserved characters). |
| `nonce` | Yes | Must match the `nonce` sent in the original authorisation request. |
| `acceptMembershipId` | No | UUID of a pending invitation to accept at login. |

**Success response:** the same shape as `POST /api/global/auth/otp/verify` — `accessToken`, `refreshToken`, `user`, and `memberships`.

The returned refresh token belongs to the regional session store. Do not send it to `/api/login/auth/*`. A client that needs the current central session flow must start again through the central login host.

**Error responses:**

| Status | Code | Meaning |
| - | - | - |
| `401` | — | Code redemption or ID token verification failed. |
| `403` | `account_disabled` | The authenticated user's account is disabled. |
| `403` | `no_membership_in_region` | The user is authenticated but has no access in this region. The response includes a `regions` array and a `pendingInvites` array. |
| `409` | `invite_not_acceptable` | The invitation in `acceptMembershipId` can no longer be accepted. |
| `403` | `invalid_invite` | The invitation in `acceptMembershipId` was not found or belongs to another user. |
| `503` | — | OIDC is not configured in this environment. |

**User activation:** if the authenticated user's status is `invited`, the exchange automatically transitions it to `active` on first successful login. This is an identity fact — no additional action is required.

**Rate limiting:** the exchange endpoint allows up to 10 attempts per IP address per minute. Unlike
the OTP endpoints, it has no per-email limit because the request contains no email address.

## Switching central sessions

`POST /api/login/auth/switch-org` re-points the central session to a different organisation without requiring the user to re-enter their credentials. It is authenticated with a refresh token.

**Request body:**

```json theme={null}
{
  "refreshToken": "<opaque-string>",
  "organisationId": "<target-org-uuid>"
}
```

**Response:** the same shape as `POST /api/login/auth/refresh` — a new `accessToken`, a rotated `refreshToken`, and the updated `user` object. The response does not include `memberships`.

**Error responses:**

| Status | Meaning |
| - | - |
| `400` | Request body is missing, malformed, or contains unsupported fields. |
| `401` | Refresh token is invalid, expired, or was already rotated. |
| `403` | The user has no active membership in the requested organisation. The existing session is not consumed. |
| `503` | The central session store, canonical organisation directory, or signing key is unavailable or not configured. |

A switch that races another refresh-token operation can receive `401` after the token has been consumed. Clients should treat this as a refresh failure and prompt the user to log in again.

## Refreshing central sessions

`POST /api/login/auth/refresh` rotates a refresh token and re-mints an access token for the session's server-held active organisation. The session's active organisation is never changed by a refresh — use `POST /api/login/auth/switch-org` to change it. The request body accepts only `refreshToken`; an extra client-supplied `organisationId` is rejected with `400` without consuming the token.

**Error responses:**

| Status | Code | Meaning |
| - | - | - |
| `400` | — | Request body is missing, malformed, or contains unsupported fields. |
| `401` | — | Refresh token is invalid, expired, revoked, or was already rotated. Re-login required. |
| `403` | `identity_session_cannot_refresh` | The session has no organisation selected. Select an organisation with `POST /api/login/auth/switch-org` before refreshing. Do not treat this as a sign-in failure. |
| `503` | — | The central session store, canonical organisation directory, or signing key is unavailable or not configured. |

The `identity_session_cannot_refresh` 403 is a distinct, typed outcome from a 401. The caller still holds a usable refresh token and can proceed to organisation selection.

## Legacy regional sign-out everywhere

`POST /api/global/auth/sessions/revoke-all` records a request to invalidate all of the authenticated user's retained regional sessions. It accepts only a refresh token from the Global API's regional session store. Central login sessions use a separate table and pepper, so a central refresh token cannot authenticate to this route and receives `401`.

**Request body:**

```json theme={null}
{
  "refreshToken": "<opaque-string>"
}
```

**Response:** `202 {}` — an empty JSON object. The endpoint writes no session state and mints
nothing. The admin store applies the regional revocation asynchronously. Access tokens may continue to work
until that state propagates, bounded by their configured lifetime of at most one hour.

The caller's own regional session expires along with the other regional sessions. This route covers only retained regional sessions. Central browser sessions use `POST /api/login/auth/sessions/revoke-all`, which relays a signed command to the admin API. A successful request returns `202 {}`; a missing dependency or refused relay returns an error.

**Error responses:**

| Status | Code | Meaning |
| - | - | - |
| `401` | — | Refresh token is invalid, expired, or was already rotated. |
| `403` | `user_not_active` | The authenticated identity is not active in this region. Terminal — do not retry. |

## Token types

Feature bundles attach the user access token to API calls:

```http theme={null}
Authorization: Bearer <access-token>
```

Stralos uses two EdDSA (Ed25519) JWT types. User tokens are issued by the active credential or session service; service tokens are minted by `stralos-api-global`.

| Type | Purpose | Subject | Audience | TTL |
| - | - | - | - | - |
| User | Browser and user-delegated API calls | User UUID | `stralos-api` | Up to 1 hour; 1 hour by default |
| Service | Bounded inter-service calls | `service:<caller>` | Array of target service names | 10 minutes |

Both carry `typ`, `iss`, `iat`, `exp`, and a `kid` in the JWS header. User tokens also carry `email`, `org`, and `role` (`admin` or `member`). Service tokens carry an organisation UUID or `null`, plus non-empty `scope` and `aud` arrays. A null-organisation service token must include the `platform` scope.

User tokens must not carry `scope`. Service tokens must not carry `role`.

## Token verification

Global, Estimate, Activities, Documents, Rates, Scheduler, and Assistant use EdDSA
verification for protected routes. A token must carry `alg: EdDSA`, verify
against the Global API JWKS, and satisfy the permitted strict claim schema.
All except Scheduler accept user or authorised service tokens; Scheduler accepts
user tokens only. Missing, malformed, forged, HS256, or expired credentials
receive `401`. These seven services have no
shared-secret fallback or log-only authentication mode.

## JWKS endpoint

The Global API publishes public signing keys at:

```text theme={null}
GET /api/global/.well-known/jwks.json
```

The endpoint is public and returns `cache-control: public, max-age=300`. Consumers cache the key set for up to five minutes. When a token references an unknown `kid`, the verifier performs one immediate refresh. A 30-second negative cache per unknown `kid` prevents repeated fetches for invalid identifiers.

The current signer is the first configured key. Retained public keys remain available during rotation until the tokens they signed have expired.

## Logout

`POST /api/login/auth/logout` deletes the central refresh-token session named by the presented token. It does not change other sessions or invalidate outstanding access tokens. Use `POST /api/login/auth/sessions/revoke-all` to request revocation of all central browser sessions.

**Request body:**

```json theme={null}
{
  "refreshToken": "<opaque-string>"
}
```

**Response:** `200 {}`. An unrecognised or already-removed token is treated as an idempotent no-op and also returns `200`.

**Error responses:**

| Status | Meaning |
| - | - |
| `400` | Request body is missing or malformed. |
| `503` | The central session store is unavailable or not configured. |

## Refresh tokens

Refresh tokens are bearer strings that are opaque to clients. The current implementation structures them as `userId.sessionId.<256-bit secret>`, stores only a hash in DynamoDB, rotates them on refresh, and uses a 30-day default TTL. Reuse detection revokes the session family.

Sessions carry a model version. Sessions issued before the current model version are invalidated on their next refresh, requiring re-login. The session model is currently at version 3 (IDP-M7a); version 2 sessions are refused with `stale` on their next refresh.

## Shared browser contract

The browser contract uses these `localStorage` keys:

* `stralos.auth.accessToken`
* `stralos.auth.refreshToken`
* `stralos.auth.user`

The shell publishes a single-flight refresh bridge for feature bundles. See [Frontend architecture](https://help.stralos.app/internal/frontend-architecture.md#cross-bundle-refresh-bridge).

## Service enforcement

For Global, Estimate, Activities, Documents, Rates, Scheduler, and Assistant, each auth
plugin applies to its non-exempt routes and rejects missing or invalid
credentials.

| Variable | Purpose |
| - | - |
| `AUTH_JWKS_URL` | Global API JWKS URL used by the seven EdDSA-only services. Required, with no default: each service refuses to start when it is unset or blank. `stralos-local` sets it to `http://localhost:3300/api/global/.well-known/jwks.json`. |

## OTP rate limiting

The OTP request and verify endpoints each enforce independent one-minute limits:

* 10 requests per IP address;
* 5 requests per email address.

Exceeding either limit returns `429` with a `retry-after` header. The counters are in-memory and per running service instance.

## OTP request response

A successful `POST /api/global/auth/otp/request` returns a JSON object:

```json theme={null}
{
  "email": "<normalised-email>",
  "challengeId": "<uuid>",
  "expiresAt": "<iso8601>"
}
```

In a local development environment with `OTP_EXPOSE_GENERATED=true`, the response also includes the generated code:

```json theme={null}
{
  "email": "<normalised-email>",
  "challengeId": "<uuid>",
  "expiresAt": "<iso8601>",
  "code": "<six-digit-code>"
}
```

The response shape is the same whether or not the email maps to a registered user; no information about account existence is returned.

## Local OTP behaviour

In the standard local setup, SES email delivery is disabled. Set `OTP_EXPOSE_GENERATED=true` in `.env` (the default in `.env.example`) so that `POST /api/global/auth/otp/request` returns the generated OTP code directly in the response. Without exposure or a separately configured email transport, the local login flow cannot receive the code.

Use generated-code exposure only in a local, isolated development environment. Any environment reachable from the internet — including shared dev, test, and production deployments — must not set this flag. An internet-accessible endpoint with `OTP_EXPOSE_GENERATED=true` returns the OTP code to any unauthenticated caller, reducing the login flow to zero-factor: knowing an email address is sufficient to obtain a valid session for an active user.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.