# Auth · Sessions

> Who a request is, how it became that, and how it stops — sessions, registration, sign-in and one-time codes.

Everything core's auth module answers to, under `/api/auth`. Registration and sign-in
are open; the directories and the settings are admin-only; the identity surface is guarded per
identity.

## Concepts

### Identity

An identity is the account. It carries a `kind` —
`guest`, `member`, `admin`, `service`,
`developer` or `unknown` — an id, a display name, and whichever of
username, email address and phone number it was registered with. The first identity registered
on a deployment becomes the admin.

### Sessions and tokens

A successful registration or sign-in returns an _identity token_ and a
_refresh token_, and sets both as `HttpOnly` cookies named
`identity` and `refresh`. The identity token is short-lived; the refresh
token redeems a new pair. Every session is addressable by its `jti`, which is what
"sign out this device" invalidates.

A request may present the identity token as the `identity` cookie or as
`Authorization: Bearer <token>`. The bearer header wins when both are present.
Service-to-service calls present the deployment's `MODDABLE_SERVICE_KEY` the same
way, which satisfies the admin guard.

### Guards

Every route runs behind one of four postures, named on the endpoint below.

Guard |
Middleware |
Rejects |

Open |
`useRequestTelemetry`, `useRequestIdentity` |
Nothing — the session is read if present. |

Session |
`useRequestIdentity` |
Enforced by the handler against the identity in the path. |

Own identity |
`isVerifiedIdentity`, `isOwnIdentity('id')` |
Any session that is not the identity in the path — an admin's included. |

Admin |
`isVerifiedAdmin` |
Anything but an admin identity or the service key. |

### Authentication payload

Registration, sign-in and every challenge take the same body — a `kind` saying which
identifier is being presented, a `method` saying how it is being proven, and the
fields those two imply. It is documented per endpoint below.

## Endpoints

Each endpoint below is documented in the folder of the signal that serves it — a
`doc.ts` beside the `index.ts` that registers the route — and appears here
once it is named in `signals/auth/docs.ts`.

The auth module serves more routes than are documented here yet. Rather than keep a second list
by hand and let it rot, the suite walks the signal tree and names every route still missing a
`doc.ts`: run `moddable tests core unit` and read the
`auth doc coverage` report.

## Retrieve the current session

`GET /api/auth`

Guard: open

Who the request is. Answers `identity: null` when the request is signed out — that is not an
error, and it is the cheapest way to ask.

It also renews silently. If the identity token is within five minutes of expiring, or the identity's claims
have changed since the token was minted, the refresh token is redeemed and the response carries fresh
cookies. A refresh token that fails verification clears both cookies rather than erroring.

### Returns

The identity, the token's `expiry` as a Unix timestamp, and the session's `jti`.
`identity` is `null` for a signed-out request.

### Request (cURL)

```
curl https://example.com/api/auth \
  -H "authorization: Bearer $IDENTITY_TOKEN"
```

### Response

```json
{
  "identity": {
    "id": "01J2788XY98FDTA410C8S716CD",
    "kind": "member",
    "claims": {}
  },
  "expiry": 1767225600,
  "jti": "01J2788XY98FDTA410C8S716CE"
}
```

## Refresh the session

`POST /api/auth`

Guard: open

Redeem the `refresh` cookie for a new token pair explicitly, rather than waiting for
`GET /api/auth` to do it.

A rejected refresh token is recorded as `auth.refresh.rejected` — at `info` when it
merely expired, at `warn` when it did not verify, because only one of those is routine.

### Returns

An authentication result with both cookies reset. `400` when no refresh cookie was sent,
`401` when it is expired, forged, or belongs to an invalidated session — and in every failing
case both cookies are cleared.

### Request (cURL)

```
curl -X POST https://example.com/api/auth \
  --cookie "refresh=$REFRESH_TOKEN"
```

### Response

```json
{
  "identity": {
    "id": "01J2788XY98FDTA410C8S716CD",
    "kind": "member",
    "claims": {}
  },
  "expiry": 1767229200,
  "jti": "01J2788XY98FDTA410C8S716CF"
}
```

## Invalidate the session

`DELETE /api/auth`

Guard: open

Sign out. Reads the `jti` from the identity token, falling back to the refresh token, invalidates
that session and clears both cookies. Recorded as `auth.session.invalidated`.

To end a session other than the one making the request — "sign out my other devices" — use
`DELETE /api/auth/identity/:id/session` with that session's `jti`.

### Returns

`{ "invalidated": true }`. `400` when the request carries neither token,
`404` when the session is already gone.

### Request (cURL)

```
curl -X DELETE https://example.com/api/auth \
  -H "authorization: Bearer $IDENTITY_TOKEN"
```

### Response

```json
{
  "invalidated": true
}
```

## Sign in

`POST /api/auth/signin`

Guard: open

Exchange an authentication payload for a session. On success the identity token and refresh token are
returned as `HttpOnly` cookies named `identity` and `refresh`.

Which combinations of `kind` and `method` are accepted is a deployment setting —
read `GET /api/auth/signin/method` to render only the options that will work.

### Parameters

- `kind` _enum_ **Required** — Which identifier the rest of the body carries.
  - One of: `email`, `phone`, `username`, `identity`
- `method` _enum_ — How the claim is proven. Defaults to whatever the identity has configured.
  - One of: `passphrase`, `verification`, `code`, `credential`, `google`
- `username` _string_ — The username, when `kind` is `username`.
- `email` _string_ — The email address, when `kind` is `email`.
- `phone` _string_ — The phone number, when `kind` is `phone`.
- `passphrase` _string_ — Required with `method: passphrase`.
- `otp` _string_ — The one-time code, with `method: code`. Push one with `POST /api/auth/otp`.
- `credential` _object_ — A WebAuthn assertion, with `method: credential`.
  - `id` _string_ — The credential id the authenticator returned.
  - `response` _object_ — The assertion payload, as the browser produced it.

### Returns

An authentication result — the identity, the token expiry, and the session's `jti`. Both
session cookies are set on the response.

### Request (cURL)

```
curl -X POST https://example.com/api/auth/signin \
  -H "content-type: application/json" \
  -d '{
    "kind": "username",
    "method": "passphrase",
    "username": "ada",
    "passphrase": "correct-horse-battery-staple"
  }'
```

### Request (JavaScript)

```
const response = await fetch('/api/auth/signin', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    kind: 'username',
    method: 'passphrase',
    username: 'ada',
    passphrase: 'correct-horse-battery-staple'
  })
})

const { identity, expiry, jti } = await response.json()
```

### Response

```json
{
  "identity": {
    "id": "01J2788XY98FDTA410C8S716CD",
    "kind": "member",
    "claims": {}
  },
  "expiry": 1767225600,
  "jti": "01J2788XY98FDTA410C8S716CE"
}
```

## Register an identity

`POST /api/auth/register`

Guard: open

Create an identity and sign it in, in one call. Takes the same authentication payload as sign-in, plus a
`displayName`.

The first identity registered on a deployment becomes the `admin`. Both outcomes are written to
the audit ledger — `auth.registration.succeeded` and `auth.registration.failed` — with
the subject and the method, never the secret.

### Parameters

- `kind` _enum_ **Required** — Which identifier the account is being created against.
  - One of: `email`, `phone`, `username`
- `method` _enum_ **Required** — How the account will be proven at sign-in.
  - One of: `passphrase`, `verification`, `code`, `credential`
- `displayName` _string_ **Required** — The name shown to other people. Not an identifier — it need not be unique.
- `username` _string_ — The username to claim. Check availability first with `HEAD /api/auth/username/:username`, which answers `404` when it is free.
- `email` _string_ — The email address to register.
- `phone` _string_ — The phone number to register.
- `passphrase` _string_ — Required with `method: passphrase`.

### Returns

An authentication result, with both session cookies set — a registered identity is a signed-in one.

### Request (cURL)

```
curl -X POST https://example.com/api/auth/register \
  -H "content-type: application/json" \
  -d '{
    "kind": "username",
    "method": "passphrase",
    "username": "ada",
    "displayName": "Ada Lovelace",
    "passphrase": "correct-horse-battery-staple"
  }'
```

### Response

```json
{
  "identity": {
    "id": "01J2788XY98FDTA410C8S716CD",
    "kind": "admin",
    "claims": {}
  },
  "expiry": 1767225600,
  "jti": "01J2788XY98FDTA410C8S716CE"
}
```

## Authenticate

`POST /api/auth/authentication`

Guard: open

The same exchange as `POST /api/auth/signin`, with an audit record on both outcomes:
`auth.signin.succeeded` and `auth.signin.failed`.

The failed attempt is the one worth having. A successful sign-in leaves a session behind that anyone can
look at; a rejected one leaves nothing at all unless it is written down, and a run of them from one address
is the first thing an operator wants to see. The record names the subject and the method — never the secret
that was presented.

### Parameters

- `kind` _enum_ **Required** — Which identifier the body carries.
  - One of: `email`, `phone`, `username`, `identity`
- `method` _enum_ — How the claim is proven.
  - One of: `passphrase`, `verification`, `code`, `credential`, `google`
- `passphrase` _string_ — With `method: passphrase`.
- `otp` _string_ — With `method: code`.
- `credential` _object_ — A WebAuthn assertion, with `method: credential`.

### Returns

An authentication result, with both session cookies set.

### Request (cURL)

```
curl -X POST https://example.com/api/auth/authentication \
  -H "content-type: application/json" \
  -d '{ "kind": "email", "method": "passphrase", "email": "ada@example.com", "passphrase": "…" }'
```

### Response

```json
{
  "identity": {
    "id": "01J2788XY98FDTA410C8S716CD",
    "kind": "member",
    "claims": {}
  },
  "expiry": 1767225600,
  "jti": "01J2788XY98FDTA410C8S716CE"
}
```

## Push or verify a one-time code

`POST /api/auth/otp`

Guard: open

One route, two actions, told apart by `type`. A `push` sends a code to whatever the
identifier resolves to; a `verify` checks one back.

Both take the identifier by kind rather than by a fixed field, so the same call works whether the account is
reached by email, phone, username or id.

### Parameters

- `type` _enum_ **Required** — Which of the two actions to run.
  - One of: `push`, `verify`
- `to` _enum_ — With `type: push` — which identifier the code is sent to.
  - One of: `email`, `phone`, `username`, `id`
- `of` _enum_ — With `type: verify` — which identifier the code belongs to.
  - One of: `email`, `phone`, `username`, `id`
- `email | phone | username | id` _string_ **Required** — The identifier itself, under the key named by `to` or `of`.
- `otp` _string_ — The code being checked, with `type: verify`.

### Returns

A push answers `{ "sent": true }`, or `409` with
`{ sent: false, issue }` when it could not be delivered. A verify answers `200` on a
match and `400` otherwise. An unrecognised `type` is `400`.

### Request (cURL)

```
curl -X POST https://example.com/api/auth/otp \
  -H "content-type: application/json" \
  -d '{ "type": "push", "to": "email", "email": "ada@example.com" }'
```

### Request (JavaScript)

```
await fetch('/api/auth/otp', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ type: 'verify', of: 'email', email: 'ada@example.com', otp: '123456' })
})
```

### Response

```json
{
  "sent": true
}
```

## Sign out

`GET /api/auth/signout`

Guard: session

The link-friendly sign-out: no body, no method to choose. Invalidates the session the request carries and
clears both cookies — and clears them even when there was no session to invalidate, so a stale cookie
cannot leave a browser stuck signed-in-but-not.

`DELETE /api/auth` is the same effect for a client that would rather not navigate.

### Returns

`200`, with both session cookies expired.

### Request (cURL)

```
curl https://example.com/api/auth/signout \
  --cookie "identity=$IDENTITY_TOKEN"
```

### Response

```json
{
  "ok": true
}
```
