Motivation / problem
AskMyDocs ships a React SPA that authenticates with Sanctum’s stateful cookie flow: the browser first hitsGET /sanctum/csrf-cookie, receives an
XSRF-TOKEN, and replays it on every mutating request. That handshake is exactly
right for a same-site browser app — and exactly wrong for a non-browser
client. A native desktop app, a CLI, a CI runner, or a mobile shell has no
cookie jar, no CSRF round-trip, and no same-origin guarantee; forcing the
cookie/CSRF dance on them either fails (a token client without an XSRF-TOKEN
cookie is rejected with 419) or invites insecure work-arounds (a wildcard
immortal token, CORS relaxed for everyone).
Two things were therefore needed:
- A stateless authentication transport for non-browser clients — Bearer tokens, issued without opening a session, scoped to exactly the abilities the client uses, and self-expiring so a token from a lost device cannot live forever.
- A reference consumer that proves the transport end to end: a real desktop (and iOS) client that logs in, chats with citations, searches, and views source documents — using nothing but the public API.
desktop/).
Theory & background
Sanctum has two authentication shapes behind oneauth:sanctum guard:
- Stateful (SPA) sessions — the request carries a session cookie; Sanctum
hydrates the user from the session. The “current access token” on such a
request is a
Laravel\Sanctum\TransientToken— a sentinel that means “this request is session-authenticated, not token-authenticated”. ATransientTokenanswerscan()astruefor everything (the session user’s real authorization comes from the route’s own gates — for the/api/kb/*group,auth:sanctum+tenant.authorizeplus the controller/model AccessScope + project-membership scoping — not from token abilities). - Personal access tokens (PATs) — the request carries
Authorization: Bearer <token>; Sanctum hydrates the user from the hashed token row inpersonal_access_tokens. The current access token is a realLaravel\Sanctum\PersonalAccessToken, which carries anabilitieslist and an optionalexpires_at.
ability / abilities middleware cannot do this:
it throws the moment the current token is null or transient, so it would 401
every cookie-authenticated request. The host therefore ships a PAT-scoped gate,
EnforceTokenAbility, that only constrains real PersonalAccessTokens and passes
session / transient / unauthenticated requests straight through to the route’s own
gates — for the /api/kb/* group, auth:sanctum + tenant.authorize plus the
controller/model AccessScope + project-membership scoping (the KB group carries no
Spatie role: / permission: middleware).
Design
Token issuance — AuthController::token()
POST /api/auth/token (handled by
App\Http\Controllers\Api\Auth\AuthController::token(), validated by
App\Http\Requests\Auth\TokenRequest) does not open a session. It:
- Applies a failure-only throttle on a bucket namespaced away from the login
bucket (
token|<lowercased-email>|<ip>):429after 5 failed attempts, the counter cleared on success — so the two flows never interfere. - Looks up the user by email and verifies the password with
Hash::check(). A wrong password or unknown email returns422with a validation error onemail(and mints no token) — credential enumeration is not leaked. - On success, mints a Sanctum PAT via
createToken()with exactly two abilities —kb:readandkb:chat— and a finite 30-day expiry, then returns201with the plaintext token.
auth-prefixed group that sits outside the web
middleware group, so a token client without an XSRF-TOKEN cookie is never
rejected with 419.
Per-route enforcement — EnforceTokenAbility
App\Http\Middleware\EnforceTokenAbility is registered as the token.ability
alias in bootstrap/app.php. Applied as ->middleware('token.ability:kb:read'),
it:
- reads
$request->user()?->currentAccessToken(); - if that is not a
PersonalAccessToken(i.e. a sessionTransientToken, or an unauthenticated request), it calls$next($request)unchanged — a no-op for the cookie SPA; - if it is a PAT, it passes when the token
can()any one of the listed abilities (mirroring Sanctum’sCheckForAnyAbility; a wildcard*token passes every check), and otherwise returns403with{ "error": "token_ability_forbidden", "message": "…" }.
| Route | Gate |
|---|---|
POST /api/kb/chat | token.ability:kb:chat |
GET /api/kb/documents/search | token.ability:kb:read |
GET /api/kb/documents/{documentId}/preview | token.ability:kb:read |
kb:read + kb:chat reaches all three; a PAT scoped
differently is rejected with 403 before it can burn provider quota or read a
document.
Revocation — AuthController::revokeToken()
POST /api/auth/token/revoke (behind auth:sanctum) is the Bearer-flow
counterpart of logout(). It deletes the PAT the caller authenticated with and
returns 204. It is stateless — registered outside the web group
(no StartSession / CSRF) — so a desktop client signs out without an
XSRF-TOKEN cookie. It is PAT-only in practice: because the route has no
session middleware, a cookie-SPA caller can’t establish a stateful session here
and should use POST /api/auth/logout instead. (revokeToken() defensively
skips the delete for a non-PersonalAccessToken currentAccessToken(), but that
branch is effectively unreachable on this session-less route.) Unauthenticated
callers get 401.
The Tauri desktop client (desktop/)
The reference consumer is a self-contained Tauri v2 + React (Vite) project
under desktop/, with its own package.json and Rust crate. It is not wired
into the Laravel CI — it is a demonstration of the public token-auth surface.
- Login →
POST /api/auth/token; the Bearer token is persisted locally via the Tauri store plugin and survives restarts. - Chat →
POST /api/kb/chat; grounded answers with citations and a confidence badge render as markdown; each citation opens its source in a full-page document viewer. Conversation threads are kept locally on disk — the server’s/conversationsendpoints use the web-session guard, so a Bearer client cannot reach them; every turn hits the stateless/api/kb/chat. - Search →
GET /api/kb/documents/search(title/path autocomplete). - Document viewer →
GET /api/kb/documents/{documentId}/previewreturns a document’s full source text (tenant + AccessScope scoped), rendered as a full-page modal.
Data model / contract
POST /api/auth/token (public)
Request (TokenRequest):
| Field | Rules |
|---|---|
email | required, email |
password | required, string |
device_name | nullable, string, max:120 (defaults to desktop-demo server-side when omitted) |
201:
abilities = ["kb:read", "kb:chat"] and
expires_at = now + 30 days. Failure modes: 422 (wrong password, unknown
email, or missing fields — validation error on email), 429 (after 5 failed
attempts on the token|email|ip bucket).
POST /api/auth/token/revoke (auth:sanctum)
No body. Deletes the caller’s PAT and returns 204. PAT-only in
practice (registered outside the web group, no session middleware) —
cookie/session clients use POST /api/auth/logout. Unauthenticated → 401.
EnforceTokenAbility (token.ability:<ability>[,<ability>…])
On a PAT request: passes if the token can() any listed ability, else 403
{ "error": "token_ability_forbidden" }. On a session / transient /
unauthenticated request: pass-through (no-op).
personal_access_tokens table
The standard Sanctum migration is vendored into the host
(database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php,
with a SQLite test mirror under tests/database/migrations/): a morph owner
(tokenable_type + tokenable_id), a unique token hash, an abilities text
column, and nullable last_used_at / expires_at. Run php artisan migrate
before issuing tokens.
Decision rationale (ADR-style)
There is no dedicated ADR for this surface; the security rationale is recorded here and is consistent with the platform-wide security & threat model.- Why personal access tokens (not a JWT / OAuth server)? Sanctum PATs are
already the platform’s second transport behind the single
auth:sanctumguard (the SPA is the first). Reusing them means the desktop client authorizes against the same user, the same RBAC, and the same tenant resolution as every other surface — no parallel identity system, no second guard to keep in sync. - Why finite expiry? The global
sanctum.expirationisnull(SPA sessions expire by cookie lifetime, not token TTL). A desktop token is set per-token to 30 days so a token leaked from a lost or stolen device self-revokes server-side instead of living forever. Expiry is enforced by Sanctum on every authenticated request, independent of whether the client ever calls revoke. - Why least-privilege abilities (not
['*'])? The desktop client only ever calls KB read + chat. Minting the token with exactlykb:read+kb:chatmeans a stolen desktop token cannot reach a route scoped to a different ability (e.g. ingest or delete), even though those routes share the sameauth:sanctumguard. The wildcard would have made the token as powerful as the user — a needless blast radius. - Why a custom
EnforceTokenAbility(not Sanctum’sabilitymiddleware)? The gated routes are dual-auth: the cookie SPA reaches them too. Sanctum’s stock middleware throws on anull/ transient token, so it would401every session request.EnforceTokenAbilityis deliberately PAT-scoped — it constrains only real PATs and is a no-op for sessions — so one route can serve both transports without weakening either. This mirrors the platform principle that a feature flag / gate must be safe in both states (R43): the SPA path is unchanged whether or not a PAT is present. - Why stateless issuance / revocation (no session, no CSRF)? A non-browser
client has no cookie jar. Issuing and revoking outside the
webgroup avoids the419CSRF rejection and keeps the Bearer flow self-contained, while the cookie SPA keeps its CSRF protection untouched.
Worked example
Issue a token, call an authed endpoint, then revoke — all withcurl:
VITE_API_BASE at build time to point at a local backend (and keep the HTTP
scope in src-tauri/capabilities/default.json in step). The full runbook —
including the iOS build flow — lives in desktop/README.md.
Gotchas
- Run the migration first.
php artisan migratemust createpersonal_access_tokensbefore any token is issued, orPOST /api/auth/tokenfails atcreateToken(). device_namefrom the real app is not the server default. The Tauri client always sends its own label; the server-sidedesktop-demofallback only applies to clients (curl, tests) that omit the field.- Conversation history is local to the desktop client. The
/conversationsendpoints are session-guarded and unreachable by a Bearer client by design; threads persist in the Tauri store, and every turn is a fresh stateless/api/kb/chatcall. - Token TTL is per-token, not global. The desktop TTL is 30 days regardless
of
sanctum.expiration(which staysnullfor SPA sessions). A token outlives neither its expiry nor an explicit revoke. - The
token.abilitygate is a no-op for the SPA. Do not assume it enforces anything for cookie-authenticated requests — those are governed by the route’s own gates (auth:sanctum+tenant.authorize+ AccessScope / project-membership scoping for the KB group). The gate only ever constrains real PATs. - The desktop project is outside Laravel CI. It is a self-contained demo with its own toolchain (Rust + Node); changes there are not exercised by the host PHP/Vitest/Playwright suites.