Skip to content
Open
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ permissions:
pages: write
id-token: write

# Allow one concurrent deployment; don't cancel an in-progress production deploy.
# Deploys serialize on one group, so a production publish is never interrupted. PR builds get a
# group per branch: queued on the shared one, GitHub keeps only the newest PENDING run and cancels
# the rest — three docs PRs opened together left one with no checks at all.
concurrency:
group: pages
group: ${{ github.event_name == 'pull_request' && format('pages-pr-{0}', github.ref) || 'pages' }}
cancel-in-progress: false

jobs:
Expand Down
2 changes: 1 addition & 1 deletion docs/abilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ Both step-up routes are gated, not just the password one — `confirm-passkey` i
$pair = app(StartSession::class)($user->getKey(), [], ['ci.deploy', Abilities::SESSIONS]);
```

`POST /auth/logout` and `POST /auth/refresh` are never gated: they act on the calling session alone, and a pinned token has to be able to end and renew itself. Set `features.gate_auth_routes` to `false` to switch the whole thing off.
`POST /auth/logout`, `POST /auth/refresh` and `POST /auth/session/claim` are never gated: they act on the calling session alone and grant nothing, and a pinned token has to be able to end, renew and claim itself. Set `features.gate_auth_routes` to `false` to switch the whole thing off.

## Responses

Expand Down
30 changes: 23 additions & 7 deletions docs/authentication.md

Large diffs are not rendered by default.

39 changes: 30 additions & 9 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,19 @@ The `iss` and `aud` claims stamped into every token and validated on every reque

```php
'grace_seconds' => (int) env('LUKK_GRACE', 30),
'claim_seconds' => (int) env('LUKK_CLAIM_SECONDS', 0),
'leeway' => (int) env('LUKK_LEEWAY', 5),
```

| Key | Default | Description |
|---|---|---|
| `grace_seconds` | `30` | The overlap window during which a just-rotated token is still tolerated, so concurrent refreshes (multiple tabs, SSR + hydration) do not trip reuse detection. Within this window the old token yields a full token pair — a sibling refresh token in the same session, which must be stored — see [Authentication → Refreshing tokens](/authentication#refreshing-tokens). |
| `claim_seconds` | `0` (off) | **lukk 0.7.0.** A session a sign-in starts must be *claimed* within this window — by `POST /auth/session/claim`, an authenticated request to this lukk app, or a refresh. If it isn't, the first late use of the credential issued at sign-in revokes the whole session and fires [`SessionUnclaimed`](/events#sessionunclaimed). It ends sessions whose sign-in response never reached the client (a dropped connection, an aborted request), which otherwise stay alive until their refresh token expires. `600` suits most apps.<br><br>**The contract:** every client must claim within the window. lukk-js calls the claim route right after each sign-in. A client that uses its access token only on *another* service never touches this app, so it must call the route too. Only the original sign-in credential is ever revoked: a token minted by a later refresh counts as a claim. For a refresh token that is exact — the family's first row, the one with no predecessor — while an access token, which carries no lineage, is matched on its `iat` within a couple of seconds of the sign-in. The window is never shorter than `access_ttl` + `leeway`, or 60 seconds. Sessions with a pinned grant (personal access tokens, impersonation) are never marked. A replacement `RefreshTokenRepository` must return [`original`](/customization#swapping-storage) on its records (or, less precisely, `createdAt`); with neither, a late original refresh token isn't recognised (it fails open, with a warning logged once per worker process). Markers live in the denylist cache: losing them fails open, and restoring an old snapshot can at most revoke a session still presenting its original refresh token after the window. Costs one cache read per authenticated request when on, none when off. |
| `leeway` | `5` | Clock-skew tolerance, in seconds, applied when validating the `exp` and `nbf` claims. |

### Rate limits

Every throttle lives here, each shaped as `{ max_attempts, decay_seconds }` (login adds `ip_max_attempts` and `account_max_attempts`), plus one scalar — `ipv6_prefix` — that applies to them all:
The configurable throttles live here, each shaped as `{ max_attempts, decay_seconds }` (login adds `ip_max_attempts` and `account_max_attempts`), plus one scalar — `ipv6_prefix` — that applies to them all. Two more buckets exist without a key of their own; they [borrow `refresh`](#throttles-without-a-key-of-their-own) and are described below the table.

```php
'rate_limits' => [
Expand All @@ -84,6 +86,13 @@ These bound a **rate**, not a run: the window keeps resetting, so `account_max_a

Each maps to a named limiter (`lukk-refresh`, `lukk-passkeys`, `lukk-2fa`) you can also override with your own `RateLimiter::for()`. Tune any of them with the matching env vars — `LUKK_REFRESH_MAX_ATTEMPTS`, `LUKK_2FA_DECAY`, and so on.

#### Throttles without a key of their own

Two buckets read `rate_limits.refresh` rather than owning a key, because each is at most one call per session and a second knob would be one more thing to get wrong. They are separate *buckets*, though — neither shares counters with `POST /auth/refresh`, so junk refresh traffic from one address can't refuse them.

- **The logout refresh-token lookup** (lukk 0.7.0), keyed per guard and per caller. `POST /auth/logout` has no route throttle; what is metered is the lookup of a presented refresh token, and only when it comes up empty or names an already-revoked family. A logout that ends a live session costs nothing, and one carrying a valid access token skips the bucket entirely. Exhausted, it answers `429` with `Retry-After` before looking anything up — see [Authentication → Logging out](/authentication#logging-out).
- **The claim route** (`lukk-claim`, and `lukk-{guard}-claim` on an extra guard), which gates `POST /auth/session/claim`. Keyed per **user**, not per address: every call is already authenticated, and an address-keyed bucket is the one a NAT or a BFF shares. It falls back to the caller's address only when no user resolves.

**What "keyed on IP" actually means.** Every throttle buckets on `Lukk::rateLimitKey()`, which is the caller's address with IPv6 collapsed to **`ipv6_prefix`** (default `/64`, env `LUKK_RATE_LIMIT_IPV6_PREFIX`). A subscriber is typically handed a whole `/64`, so keying on the full address would let one visitor mint effectively unlimited buckets and walk through every per-IP limit. IPv4 is used as-is, and addresses that embed IPv4 (IPv4-mapped, NAT64's `64:ff9b::/96`) are unwrapped rather than masked — otherwise a whole translated client population would share one counter. Raise it toward `128` if your users share a `/64` (an office or campus LAN does); lower it if your attackers hold larger delegations.

Behind a BFF or reverse proxy that address is the **proxy** until the deployment forwards the real client — see [`clientIpHeader`](#clientipheader). Replace the identity entirely when the source address isn't the right bucket for you (a shared API gateway, a tenant, a CDN's own visitor token):
Expand Down Expand Up @@ -201,8 +210,8 @@ Rotation, reuse detection and the denylist are not switches: they are the securi
| `password_reset` | `false` | Enable [password reset](/password-reset). |
| `registration` | `false` | Enable [registration](/registration). |

> [!WARNING]
> The rotation, reuse-detection, and denylist features are the security core of the package. Disable them only if you fully understand the consequence.
> [!NOTE]
> Rotation, reuse detection and the denylist are **not** in this table, and there is no switch for them: they are the security model, not a feature. The `rotation` / `reuse_detection` / `denylist` keys older releases listed were never read, and 0.7.0 removes them.

### Two-factor

Expand Down Expand Up @@ -350,6 +359,9 @@ Set `false` to keep the client-only restore. No effect in `direct` mode (there's
> [!NOTE]
> Enabling this (the default) means SSR `user` is now populated in BFF mode where it was previously `null` until client hydration. Review any page that assumed the server always renders anonymous.

> [!NOTE]
> **Not on a route behind a `swr`/`isr`/`cache` rule.** Nitro hands a cached handler a request cloned with only the rule's `varies` headers, so with the default `varies` that render never sees the session cookie: the HTML is anonymous, and the client restores after hydration. It also means `ssrHydrate` has no effect on those routes. Add the cookie to the rule (`cache: { varies: ['cookie'] }`) if you need the user in the cached HTML — the whole `Cookie` header then joins the cache key, so entries fragment per browser rather than per session, and a per-user render is held in the server-side cache for the rule's window. See [Transport Modes → SSR hydration](/transport-modes#ssr-hydration).

### `user.endpoint`

```ts
Expand Down Expand Up @@ -399,12 +411,21 @@ Namespaces the BFF sealed-session cookie so **multiple lukk apps can share a hos
lukk: { session: { name: 'admin' } }
```

| `session.name` | Secure (prod / `--https`) | Dev over http |
|---|---|---|
| unset | `__Host-lukk-session` | `lukk-session` |
| `'admin'` | `__Host-lukk-admin-session` | `lukk-admin-session` |
Three cookies carry the namespace — the sealed session, and the two that drive a [logout](/authentication#logging-out-1):

| Cookie | `session.name` | Secure (prod / `--https`) | Dev over http |
|---|---|---|---|
| Sealed session | unset | `__Host-lukk-session` | `lukk-session` |
| Sealed session | `'admin'` | `__Host-lukk-admin-session` | `lukk-admin-session` |
| Logout note | unset | `__Host-lukk-logout` | `lukk-logout` |
| Logout note | `'admin'` | `__Host-lukk-admin-logout` | `lukk-admin-logout` |
| Signed-out answer | unset | `__Host-lukk-signed-out` | `lukk-signed-out` |
| Signed-out answer | `'admin'` | `__Host-lukk-admin-signed-out` | `lukk-admin-signed-out` |

Unset keeps the default names, so adding it to one app doesn't change the other. The cookies are `bff`-only, but the name is read in **both** modes: it also scopes the cross-tab lock, the broadcast channel and direct mode's per-tab notes. The **logout note** is written by the browser the moment `logout()` is called and lives a minute; the **signed-out answer** is the server's reply to it and lives ten seconds. Both are `Path=/`, `SameSite=Strict`, and `Secure` exactly where the name carries `__Host-`. Neither is `HttpOnly` — the browser writes the first and reads the second — and neither carries anything: their presence is the whole message. Their names reach the browser as `runtimeConfig.public.lukk.logoutCookie` and `runtimeConfig.public.lukk.signedOutCookie`, so if you override `cookieSecure` at runtime, override both of those too.

Unset keeps the default names, so adding it to one app doesn't change the other. Only used in `bff` mode.
> [!WARNING]
> **Co-hosted apps need a distinct `session.name`.** Everything that keeps two apps on one origin apart is named from it: the cookies, and — together with [`app.baseURL`](https://nuxt.com/docs/api/nuxt-config#baseurl) — the cross-tab Web Lock, the `BroadcastChannel` and the direct-mode web-storage keys. Leave both the same and one app's pending-logout note is read by the other, which then ends a session it doesn't own, while their tabs queue behind each other's lock for no reason.

> [!WARNING]
> `session.name` is **de-confliction, not a trust boundary.** Apps that share an origin — the same host with path routing, or `localhost` across ports — share one cookie jar, and the namespace only keeps their cookies from overwriting one another. The real isolation is the per-app [`session.password`](#session-password) (the seal): a co-hosted app can't decrypt or forge another app's session without its password. For apps in **distinct trust domains**, put them on **separate subdomains** — where the `__Host-` prefix plus the proxy's `Origin` check give real isolation — and give each a distinct, strong `session.password`.
Expand Down Expand Up @@ -438,7 +459,7 @@ nitro: {

### `clientIpHeader`

**The problem it solves.** In BFF mode every upstream call is a fresh connection from your Nitro server, so the address your API sees is the *proxy*, not the visitor. Anything keying on `$request->ip()` therefore treats your entire user base as one identity — a `throttle:5,1` on a public form becomes **5 requests per minute globally**, and one user can lock out everyone else. lukk's own auth throttles (`login`, `forgot-password`, `two-factor-challenge`, and `refresh` at 30/60s) collapse the same way.
**The problem it solves.** In BFF mode every upstream call is a fresh connection from your Nitro server, so the address your API sees is the *proxy*, not the visitor. Anything keying on `$request->ip()` therefore treats your entire user base as one identity — a `throttle:5,1` on a public form becomes **5 requests per minute globally**, and one user can lock out everyone else. lukk's own auth throttles (`login`, `forgot-password`, `two-factor-challenge`, `refresh` at 30/60s, and the [logout refresh-token lookup](#throttles-without-a-key-of-their-own) that borrows `refresh`'s limits) collapse the same way. The logout bucket is the one to watch after a "log out everywhere": the BFF's own background revokes of replaced sessions count against it like any visitor's retried logout. The claim route is the exception — it keys on the authenticated user, so it is unaffected.

Blanking the browser-settable forwarding headers is the right default — otherwise any client could claim any IP and defeat your rate limiting. This option is how you say *"this hop is trusted"* when it genuinely is:

Expand Down
21 changes: 21 additions & 0 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ use App\Models\RefreshToken;
Lukk::useRefreshTokenModel(RefreshToken::class);
```

> [!NOTE]
> **`$timestamps = false` is safe here.** [`claim_seconds`](/configuration#refresh-behavior) recognises a session's original refresh token by its lineage — the row persisted with no predecessor — not by `created_at`, and the window itself is measured from the marker lukk wrote at sign-in. Timestamps are still worth keeping for the data-subject export, which reports when each session began.

## Swapping storage

Refresh-token **storage** sits behind `Contracts\RefreshTokenRepository`, separate from the rotation **policy** (which lives in `Actions\RotateRefreshToken`). To move storage from the database to Redis, bind your own implementation — the policy is untouched:
Expand All @@ -81,6 +84,12 @@ use App\Auth\RedisRefreshTokenRepository;
$this->app->bind(RefreshTokenRepository::class, RedisRefreshTokenRepository::class);
```

Three fields on `RefreshTokenRecord` carry policy that the repository is the only thing able to supply, and all of them fail quietly if you leave them out:

- **`original`** — whether this row is the family's first, the one you were handed with a `null` `$previousId`. It is how [`claim_seconds`](/configuration#refresh-behavior) recognises the refresh token the sign-in itself issued, and it is the field to return. It is a tri-state: leave it `null` ("I can't say") and lukk falls back to `createdAt`. Never answer `true` for a row you are unsure about — a successor reported as the original is a session in active use being logged out.
- **`createdAt`** — the row's creation time, and the fallback for `claim_seconds` when `original` is unknown. It only compares mint times, so a successor minted within a couple of seconds of the sign-in reads as the original; that tolerance is a fixed constant, deliberately not `leeway` (both timestamps come from the same server in one sign-in, so only write skew has to be absorbed). Leave **both** fields out and the revocation is disabled entirely (lukk logs one warning per worker process and carries on).
- **`scope`** — the family's pinned [ability](/abilities) grant. `null` and `''` are different answers: `null` means *derive the grant on every mint*, `''` means *pinned to nothing*. Round-trip the empty string. Collapsing the two lets the most restricted token in the system widen to its subject's full grant on the first refresh.

## Reshaping responses

The login, refresh, and logout responses are `Responsable` contracts. Rebind one to change the body shape, add headers, or switch between JSON and cookies:
Expand All @@ -94,6 +103,18 @@ $this->app->bind(LoginResponse::class, MyLoginResponse::class);

The response contracts are `LoginResponse`, `RefreshResponse`, `LogoutResponse`, and `TwoFactorChallengeResponse`.

> [!WARNING]
> **`LogoutResponse` takes `bool $clearRefreshCookie` (lukk 0.7.0), and a rebound implementation must honour it.** It is `false` when `POST /auth/logout` did not accept the refresh cookie from that request — none was presented, or the request had a shape a cross-site form could produce. Sending the clearing `Set-Cookie` anyway re-opens the forced-logout CSRF the check exists to close: a clearing cookie on a `204` is stored even after a fully cross-site top-level navigation, so any page could sign your visitors out.
>
> ```php
> public function __construct(private readonly bool $clearRefreshCookie = true) {}
> ```
>
> Read `cookie_mode` through `Lukk::guardConfig()` rather than the global config block, as the default does — read globally, a guard that opted into cookie mode is never sent the clear for the cookie its own login set.

> [!NOTE]
> **`DELETE /auth/sessions/others` no longer goes through `LogoutResponse`** (lukk 0.7.0). That route keeps the caller's session alive, while the contract's job is to end it — in cookie mode it cleared the caller's own refresh cookie, and the client could no longer refresh. It now answers a bare `204` directly: the same status and empty body as before, so no client changes, but a rebound `LogoutResponse` no longer applies there. It still shapes `POST /auth/logout` and `DELETE /auth/sessions`.

> [!NOTE]
> The default response shape is the contract the lukk-js clients consume. If you reshape it, keep the client in sync (or adapt it) so the two don't drift — see [Authentication](/authentication) and [Using lukk-core](/lukk-core).

Expand Down
Loading
Loading