Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export default withMermaid({
{ text: 'Step-Up Confirmation', link: '/confirmation' },
{ text: 'Account Lockout', link: '/account-lockout' },
{ text: 'Abilities (Scopes)', link: '/abilities' },
{ text: 'Account Deletion & Export', link: '/account-deletion' },
{ text: 'Multiple Guards', link: '/multiple-guards' },
],
},
Expand Down
2 changes: 1 addition & 1 deletion docs/abilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,4 @@ Configuring `abilitiesUsing` turns abilities on by itself, so most installs neve

Without it lukk cannot distinguish a token pinned to *nothing* from a server that doesn't use abilities, and a client would render the full privileged UI for the most restricted token you can issue.

Next: **[Multiple Guards](/multiple-guards)**
Next: **[Account Deletion & Export](/account-deletion)**
195 changes: 195 additions & 0 deletions docs/account-deletion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# Account Deletion & Export

The right to erasure (**GDPR Art. 17**) and the right of access (**Art. 15 / 20**), for the
signed-in user's own account.

```
DELETE /auth/account erase
GET /auth/account/export everything lukk knows
```

Both need [step-up confirmation](/confirmation) **and** the `lukk.account.delete`
[ability](/abilities). Erasure is irreversible and the export discloses everything lukk holds about
someone, so authentication alone must not be enough.

> [!WARNING]
> **Step-up on its own does not keep a machine token out.** A confirmation is
> [bound to the session that earned it](/confirmation#bound-to-the-session-that-earned-it), so a
> [pinned token](/abilities#derived-vs-pinned-grants) can no longer present one the user's browser
> earned — but a pin carrying `lukk.account` can earn its *own*. That is why these routes carry a
> separate ability, and why `lukk.account.delete` is deliberately **not** covered by `lukk.account`:
> that ability already meant "manage my credentials", and widening it would have handed every
> existing token carrying it the power to destroy the account.
>
> [`features.gate_auth_routes`](/configuration#feature-toggles) does **not** switch this gate off, unlike
> lukk's other own-route gates. That flag exists to restore pre-`0.6` reach for tokens issued before
> abilities existed; these routes have no pre-`0.6` behaviour to restore, so honouring it would grant
> a narrow machine token an irreversible new power rather than give back an old one.

`features.account_deletion` defaults to **on**. Erasure is a legal right, and a default of off means
most installs quietly don't offer one. Turn it off where deletion belongs elsewhere:

```php
// config/lukk.php
'features' => ['account_deletion' => false],
```

## What erasure actually does

In this order, and the order is the substance:

1. **Reads the identifier**, while the user row is still whole.
2. **Revokes every session** — denylist first, so every access token dies immediately across every
node.
3. **Erases lukk's artifacts and the user**, in one transaction: refresh tokens, passkeys,
two-factor material, lockout counters, then the row itself.
4. **Fires `AccountDeleted`** after the commit.

> [!IMPORTANT]
> **Lockout counters occupy three key spaces**, and the raw identifier is none of them:
> `id:<user id>` for a login failure against a real account, `idn:<normalized identifier>` for one
> naming no account, and the bare user id for the step-up and two-factor caps. Step 1 exists because
> the `idn:` space is reachable only through the identifier — read it after the row is gone and those
> rows are unreachable forever, still naming someone who asked to be forgotten.

Steps 2 and 3 are ordered deliberately too. Revoking first means a failure later leaves the account
**unreachable** rather than half-erased and still usable. And running everything else in one
transaction means a partial erasure can't happen — an account with no credentials that still exists
cannot log in, cannot be recovered, and cannot be erased again.

## Erasing your own data

lukk owns only the auth side. Your domain data is yours to erase, and two events exist for it:

```php
use Lukk\Events\AccountDeleting;
use Lukk\Events\AccountDeleted;

// INSIDE the transaction. Throw to abort the whole erasure.
Event::listen(AccountDeleting::class, function (AccountDeleting $event) {
$event->user->orders()->delete(); // the user model is still intact here
});

// AFTER the commit. For work that must not be rolled back.
Event::listen(AccountDeleted::class, function (AccountDeleted $event) {
ProcessorErasure::dispatch($event->userId);
});
```

| | `AccountDeleting` | `AccountDeleted` |
|---|---|---|
| When | before anything is erased | after the commit |
| Transaction | inside it — **throwing aborts the deletion** | outside it |
| Carries | the intact user model | identifiers only |
| For | erasing your own rows | telling someone else to erase theirs |

`AccountDeleted` carries identifiers rather than the model on purpose: the row is gone, and handing
a listener a deleted Eloquent instance invites someone to `save()` it back. Note it **does** carry
the identifier — keep it out of logs.

> [!WARNING]
> A listener on `AccountDeleting` runs inside a database transaction, holding it open. Erase rows;
> don't call a payment processor. That belongs in `AccountDeleted`.
>
> Throwing aborts the **deletion**, but not the session revocation — that already happened, by
> design, so a failure can't leave an account half-erased and still usable. The subject keeps their
> account and has to log in again.

> [!NOTE]
> **The all-or-nothing guarantee is per database connection.** SQL has no cross-connection
> transaction without two-phase commit, so if your user model lives on a different connection than
> lukk's tables — identities in a shared directory database, application tables local — each gets its
> own transaction. lukk gives the user's connection one of its own, so anything that *throws* during
> erasure still rolls both back. What can't be covered is a failure during the commit itself, which
> can leave the user erased and lukk's rows restored. That's the direction lukk prefers anyway (the
> account is unreachable rather than half-erased and still usable), and `lukk:prune` sweeps what's
> left, but it isn't all-or-nothing and this page won't claim it is.

> [!CAUTION]
> **Listen to `AccountDeleting` synchronously.** A `ShouldQueue` listener is pushed when the event is
> dispatched, not when the transaction commits (Laravel's `after_commit` is off by default) — so its
> work is already on the wire if the erasure then rolls back. Your domain data is gone and the
> account it belonged to still exists: precisely the half-erased outcome the transaction is there to
> prevent. Queue from `AccountDeleted`, which fires once the erasure is a fact.
>
> For the same reason, don't queue `AccountDeleting` itself. It carries the whole user model, so
> serializing it writes the subject's email, password hash and encrypted two-factor secret into your
> `jobs` table — and into `failed_jobs`, which nothing prunes. That residue outlives the erasure that
> triggered it.

## Anonymizing instead of deleting

Sometimes the row has to survive — an anonymized order history, a retention obligation that outlives
the erasure request, a tombstone that stops the same identifier re-registering:

```php
Lukk::deleteUserUsing(function ($user) {
$user->forceFill([
'email' => 'anonymized-'.$user->getKey().'@example.invalid',
'name' => 'Deleted user',
// Two-factor material lives in columns ON this row. Miss these and the "erased"
// account keeps a working authenticator.
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
])->save();
});
```

Everything lukk owns is still erased — only the row's fate changes.

> [!WARNING]
> The default disposal is **`forceDelete()`** where the model supports it. `delete()` on a
> `SoftDeletes` model is a silent no-op for Art. 17: the name, email, password hash and a live
> encrypted TOTP secret all survive. It also leaves the subject unable to return — re-registering
> the same address hits the database's unique index, which the `unique` validation rule's
> soft-delete scope ignores. If you want the row to survive, say so here, explicitly.

## The export

```json
{
"generated_at": "2026-03-04T05:06:07+00:00",
"account": { "id": 1, "identifier": "subject@example.com" },
"sessions": [{ "session": "…", "guard": null, "created_at": "…", "expires_at": "…" }],
"passkeys": [{ "credential_id": "…", "name": "Yubikey at HQ", "last_used_at": 1787391978 }],
"two_factor": { "enabled": true, "confirmed_at": "…" }
}
```

> [!WARNING]
> **This is the auth slice, not a complete Art. 15 response.** lukk knows about sessions, passkeys
> and whether two-factor is on. It knows nothing about the data your subject actually cares about.
> Append your own before you hand it over — a half-answer that looks whole is worse than no
> endpoint.

**Credential material is deliberately excluded.** A TOTP secret, recovery codes and refresh-token
hashes are not personal data a subject benefits from receiving — they are secrets whose only use is
authenticating *as* them, and Art. 15(4) says the right of access must not adversely affect others.
Handing a live second factor to whoever intercepts the export is exactly that. What is included is
the *fact* of each credential: this passkey exists, it was last used then.

## On the client

```ts
const { deleteAccount, exportAccount, busy } = useLukkAccount()
```

Both route through the confirmation flow, so a missing step-up surfaces your confirmation UI and
retries once. `deleteAccount()` clears local auth state on success — the server has already revoked
every token, but `user` would otherwise describe an account that no longer exists. A **failed**
erasure leaves that state alone.

## What lukk does not do

- **It doesn't cascade at the database level.** No foreign keys, no `ON DELETE CASCADE` — lukk can't
know your users table's name or key type. Erasure is application-level and works with soft deletes
and anonymization, which an FK cascade would not.
- **It doesn't fully erase users deleted outside this route.** Delete a row directly and lukk's
artifacts survive: `lukk:prune` clears expired refresh tokens, spent lockout counters and
**orphaned passkeys**, but a live refresh token lives until `refresh_ttl`. If you delete users
elsewhere, call the `DeleteAccount` action, or hook your own model's `deleting` event.
- **It doesn't retain an erasure record.** If you need to evidence that a request was honoured,
listen to `AccountDeleted` and write your own — lukk deliberately keeps nothing.

Next: **[Multiple Guards](/multiple-guards)**
10 changes: 10 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,23 @@ See [Authentication → Output modes](/authentication#output-modes) for the full
'two_factor' => false,
'lockout' => false,
'change_password' => true,
'account_deletion' => true,
'passkeys' => false,
'email_verification' => false,
'password_reset' => false,
'registration' => false,
'abilities' => false,
'gate_auth_routes' => true,
],
```

Three of these default **on** and are worth knowing about: `change_password` and `account_deletion`
add routes (`account_deletion` an irreversible one — see [Account Deletion](/account-deletion)), and
`gate_auth_routes` stops a [pinned token](/abilities#lukk-s-own-routes) reaching lukk's own
session-management and account-security routes. `abilities` only matters for an install whose grants
come solely from pinned sessions; configuring `Lukk::abilitiesUsing()` turns the feature on by
itself.

| Feature | Default | Description |
|---|---|---|
| `rotation` | `true` | Rotate the refresh token on every refresh. |
Expand Down
28 changes: 25 additions & 3 deletions docs/confirmation.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Some actions are sensitive enough that a valid session isn't sufficient — chan

1. The user re-confirms with a **password** or a **passkey** and receives a short-lived `confirmation_token`.
2. The client sends that token in a request header (default `X-Lukk-Confirmation`) on subsequent sensitive requests.
3. The `lukk.confirm` middleware checks for a valid, fresh token. If it is missing or expired, the route returns **423 Locked**.
3. The `lukk.confirm` middleware checks for a valid, fresh token **earned by the session presenting it**. If it is missing, expired, or belongs to another session, the route returns **423 Locked**.

The window length is `confirm.ttl` (default 5 minutes). lukk's own [two-factor](/two-factor-authentication) and [passkey](/passkeys) management routes are protected this way.

Expand All @@ -21,13 +21,35 @@ sequenceDiagram
participant API as lukk API

App->>API: POST /auth/confirm-password { password }<br/>Authorization: Bearer access
API-->>App: 200 { confirmation_token } (~5 min, bound to this user)
API-->>App: 200 { confirmation_token } (~5 min, bound to THIS session)
App->>API: DELETE /auth/two-factor<br/>Bearer access + X-Lukk-Confirmation: token
Note over API: lukk.confirm verifies the token<br/>(fresh + subject == current user)
Note over API: lukk.confirm verifies the token<br/>fresh, right subject, and earned by this session
API-->>App: 204 — sensitive action allowed
Note over App: without a valid confirmation header → 423 Locked
```

### Bound to the session that earned it

A confirmation carries the identity of the refresh-token **family** that re-verified the credential, and `lukk.confirm` refuses one presented by a different session:

```json
// 423 Locked
{
"message": "This confirmation belongs to a different session.",
"reason": "confirmation_session_mismatch"
}
```

The `reason` key exists so a client can tell the two 423s apart. The plain "requires confirmation" 423 carries none and means *earn one*. This one means *discard the one you are holding and earn another* — matching on the English prose would be a drift surface of its own.

A step-up asserts "the person at this keyboard re-proved themselves just now". Bound to the subject alone it was instead bearer authority across **every** token that user held — so a machine token with a [pinned grant](/abilities), which can never earn a confirmation itself because the earning routes are ability-gated, could present the one the user's browser earned and act with it.

**Rotation does not invalidate it.** The binding is to the family, not to an individual access token, and a family survives refresh — so a token rotating mid-window keeps its confirmation. [BFF mode](/transport-modes#bff) is unaffected for the same reason. A *new login* does start a new family, and the BFF discards the stored confirmation when it captures a new token pair.

::: tip Verify-only and co-issuer topologies
Matching is strict, which is what keeps a [co-issuer](/deployment#splitting-auth-and-api) working: a token minted by another service sharing the secret carries no family, and neither does a confirmation earned by it, so the two still match. What is refused is an *unbound* confirmation presented by a token that does have a family.
:::

### Earning confirmation

#### With a password
Expand Down
8 changes: 8 additions & 0 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ The cryptographic and revocation seams are contracts too. Rebind `Contracts\Toke
| `WebAuthnCeremony` | `SpomkyWebAuthnCeremony` | Performs WebAuthn registration/assertion. |
| `PasskeyRepository` | `DatabasePasskeyRepository` | Persists passkey credentials. |

::: warning Replacing `PasskeyRepository` or `RefreshTokenRepository` under multiple guards
Both are **guard-scoped**: an account is `(guard, id)`, not `id`, because [providers are separate tables](/multiple-guards) where `users.id === admins.id` is the ordinary case. Every method must honour that — including `findByCredentialId`, which takes a credential id and *no* user and is therefore the authentication decision itself.

The single exception is `PasskeyRepository::existsByCredentialId()`, which must be **unscoped**. `credential_id` is globally unique (WebAuthn requires it), so registration has to ask whether *any* guard holds an id before writing one; asking the scoped question instead lets a cross-guard duplicate reach the database constraint as a `500` rather than a clean validation error.

Bind a replacement with `bind`, not `singleton` — the active guard is per-request, and a memoized instance carries the previous request's guard into the next one.
:::

That's the whole customization surface. For the design rationale behind these seams, see [Architecture](/architecture); for the events lukk fires at the security-relevant moments, see [Events](/events). Questions or contributions are welcome on the [lukk](https://github.com/stsepelin/lukk) and [lukk-js](https://github.com/stsepelin/lukk-js) repositories.

## `Lukk::rateLimitKeyUsing()`
Expand Down
2 changes: 2 additions & 0 deletions docs/multiple-guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ Protect admin routes with `auth:admin`; they resolve against the `admins` table
- **Token identity.** A token minted for `admin` is rejected by the `users` guard on the **audience** check — and the **signature** too, if you gave it a separate secret. The rejection happens *before* the user is resolved, so it can never look up the wrong table.
- **Refresh + revocation.** Refresh-token families are scoped by a `guard` column. Rotating an admin refresh token on the users refresh endpoint fails (not found); `revokeAllSessions()` on `admin` id `5` leaves the users guard's id `5` sessions **untouched**.
- **Revocation can't cross.** The denylist is shared but keyed by `jti`/`fid` (UUIDs), so admin revocation only evicts admin families — a user's tokens are a different family and are never affected.
- **[Passkeys](/passkeys) are guard-scoped too** (a `guard` column, as of `0.6.0`). This one matters most: the assertion lookup takes a credential id and *no* user, so it is the authentication decision itself — unscoped, an admin's authenticator resolved on the users guard. It also keeps [erasure and export](/account-deletion) off a colliding account's credentials. Registering a credential for a non-default guard outside lukk's own routes must set the active guard first (`Lukk::useGuard('admin')`), or the row lands on the wrong one.
- **[Lockout](/account-lockout) counters** are guard-scoped, so a flood against one guard's login can't lock a colliding account out of another.

### Separate keys vs. a shared secret

Expand Down
Loading