@spfn/auth 0.3.0-beta.2 → 0.3.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1488 -25
- package/dist/client-proof.d.ts +45 -15
- package/dist/client-proof.js +201 -5
- package/dist/client-proof.js.map +1 -1
- package/dist/client.d.ts +92 -1
- package/dist/client.js +58 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +342 -0
- package/dist/config.js +153 -4
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +396 -3
- package/dist/errors.js +257 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +202 -2
- package/dist/index.js +277 -3
- package/dist/index.js.map +1 -1
- package/dist/machine-principals-B7N8gux0.d.ts +3087 -0
- package/dist/nextjs/api.js +350 -12
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/client.d.ts +28 -1
- package/dist/nextjs/client.js +24 -3
- package/dist/nextjs/client.js.map +1 -1
- package/dist/nextjs/server.d.ts +173 -3
- package/dist/nextjs/server.js +372 -10
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +4167 -582
- package/dist/server.js +9618 -4341
- package/dist/server.js.map +1 -1
- package/dist/{session-DTHahDQ9.d.ts → session-Dfwu5g2W.d.ts} +28 -1
- package/migrations/20260810112144_colorful_tomorrow_man/migration.sql +18 -0
- package/migrations/20260810112144_colorful_tomorrow_man/snapshot.json +3576 -0
- package/migrations/20260901091716_fine_arclight/migration.sql +21 -0
- package/migrations/20260901091716_fine_arclight/snapshot.json +3849 -0
- package/migrations/20260906155957_natural_moonstone/migration.sql +33 -0
- package/migrations/20260906155957_natural_moonstone/snapshot.json +4275 -0
- package/migrations/20260907020904_giant_eternals/migration.sql +21 -0
- package/migrations/20260907020904_giant_eternals/snapshot.json +4561 -0
- package/migrations/20260907044807_eminent_angel/migration.sql +2 -0
- package/migrations/20260907044807_eminent_angel/snapshot.json +4561 -0
- package/migrations/20260918083158_foamy_roughhouse/migration.sql +55 -0
- package/migrations/20260918083158_foamy_roughhouse/snapshot.json +5271 -0
- package/migrations/20260918142743_smart_cassandra_nova/migration.sql +2 -0
- package/migrations/20260918142743_smart_cassandra_nova/snapshot.json +5297 -0
- package/migrations/20260918143800_fat_princess_powerful/migration.sql +17 -0
- package/migrations/20260918143800_fat_princess_powerful/snapshot.json +5523 -0
- package/package.json +9 -6
- package/dist/authenticate-55LeXHqZ.d.ts +0 -1447
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ Underneath: asymmetric client-signed JWTs (ES256/RS256), OTP verification, OAuth
|
|
|
21
21
|
through a pluggable provider registry (Google, GitHub, Kakao and Naver built in), session
|
|
22
22
|
cookies for Next.js, and runtime RBAC. Routes mount under `/_auth/*` and are reached
|
|
23
23
|
through a typed `authApi` client. Requires `@spfn/core`; Next.js is an optional peer
|
|
24
|
-
(`^16.
|
|
24
|
+
(`^16.3.3`).
|
|
25
25
|
|
|
26
26
|
## Install
|
|
27
27
|
|
|
@@ -29,6 +29,11 @@ through a typed `authApi` client. Requires `@spfn/core`; Next.js is an optional
|
|
|
29
29
|
pnpm add @spfn/auth drizzle-orm@1.0.0-rc.4
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
`@simplewebauthn/server` and `@simplewebauthn/browser` come along as dependencies —
|
|
33
|
+
[passkeys](#passkeys-webauthn) need them, and standards conformance is the whole risk there.
|
|
34
|
+
The browser half is bundled into the `./client` entry rather than marked external, so nothing
|
|
35
|
+
in your app has to know about it.
|
|
36
|
+
|
|
32
37
|
## Import paths
|
|
33
38
|
|
|
34
39
|
Entry points (from `package.json` `exports`). Picking the wrong one breaks the build —
|
|
@@ -78,10 +83,10 @@ applied globally via `.use()`. Public routes opt out per-route with `.skip(['aut
|
|
|
78
83
|
```typescript
|
|
79
84
|
import { defineRouter } from '@spfn/core/route';
|
|
80
85
|
import { authRouter, authenticate } from '@spfn/auth/server';
|
|
81
|
-
import {
|
|
86
|
+
import { getStatus } from './routes/status';
|
|
82
87
|
|
|
83
88
|
export const appRouter = defineRouter({
|
|
84
|
-
|
|
89
|
+
getStatus,
|
|
85
90
|
// ...your routes
|
|
86
91
|
})
|
|
87
92
|
.packages([authRouter]) // mounts /_auth/* and exposes routes on authApi
|
|
@@ -136,17 +141,19 @@ real secret values out of band, never commit them.
|
|
|
136
141
|
| `SPFN_AUTH_JWT_SECRET` / `SPFN_AUTH_JWT_EXPIRES_IN` | `.env.server` | — | legacy server-signed JWT mode only |
|
|
137
142
|
| `SPFN_AUTH_BCRYPT_SALT_ROUNDS` | `.env.server` | — | default `12` (native bcrypt, off the event loop) |
|
|
138
143
|
| `SPFN_AUTH_COOKIE_SECURE` | both | — | override Secure flag (defaults to `NODE_ENV==='production'`) |
|
|
144
|
+
| `SPFN_AUTH_CSRF` | `.env.local` | — | `off` \| `warn` \| `enforce`; unset behaves as `warn` — see [CSRF protection](#csrf-protection) |
|
|
139
145
|
| `SPFN_AUTH_ADMIN_*` | `.env.server` | — | admin seeding (see below) |
|
|
140
146
|
| `SPFN_AUTH_GOOGLE_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | enables Google OAuth when both set |
|
|
141
147
|
| `SPFN_AUTH_GOOGLE_SCOPES` | `.env.server` | — | comma-separated; default `email,profile` |
|
|
142
|
-
| `SPFN_AUTH_GOOGLE_REDIRECT_URI` | `.env.server` | — | default `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/google/callback
|
|
148
|
+
| `SPFN_AUTH_GOOGLE_REDIRECT_URI` | `.env.server` | — | default `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/google/callback`; an override must stay on the web app origin at that path and is **checked at boot** — see [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite) |
|
|
143
149
|
| `SPFN_AUTH_KAKAO_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | REST API key enables Kakao Login; secret is included when configured |
|
|
144
150
|
| `SPFN_AUTH_KAKAO_ADMIN_KEY` | `.env.server` | — | app admin key; required to verify the Kakao User Unlinked webhook |
|
|
145
|
-
| `SPFN_AUTH_KAKAO_SCOPES` / `_REDIRECT_URI` | `.env.server` | — | default scope `account_email`; callback `/_auth/oauth/kakao/callback` |
|
|
151
|
+
| `SPFN_AUTH_KAKAO_SCOPES` / `_REDIRECT_URI` | `.env.server` | — | default scope `account_email`; callback `/_auth/oauth/kakao/callback` on the web app origin, **checked at boot** — see [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite) |
|
|
146
152
|
| `SPFN_AUTH_NAVER_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | both values enable Naver Login |
|
|
147
|
-
| `SPFN_AUTH_NAVER_REDIRECT_URI` | `.env.server` | — | default `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/naver/callback
|
|
153
|
+
| `SPFN_AUTH_NAVER_REDIRECT_URI` | `.env.server` | — | default `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/naver/callback`; an override must stay on the web app origin at that path and is **checked at boot** — see [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite) |
|
|
148
154
|
| `SPFN_AUTH_GITHUB_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | both values enable GitHub OAuth |
|
|
149
|
-
| `SPFN_AUTH_GITHUB_SCOPES` / `_REDIRECT_URI` | `.env.server` | — | default scopes `read:user,user:email`; callback `/_auth/oauth/github/callback` |
|
|
155
|
+
| `SPFN_AUTH_GITHUB_SCOPES` / `_REDIRECT_URI` | `.env.server` | — | default scopes `read:user,user:email`; callback `/_auth/oauth/github/callback` on the web app origin, **checked at boot** — see [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite) |
|
|
156
|
+
| `SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK` | `.env.server` | — | `off` disables the boot check of the four `_REDIRECT_URI` overrides; any other value (unset included) runs it — see [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite) |
|
|
150
157
|
| `SPFN_AUTH_GOOGLE_NATIVE_CLIENT_IDS` | `.env.server` | — | comma-separated client IDs accepted as native id_token audience (iOS/Android/web); enables Google native sign-in |
|
|
151
158
|
| `SPFN_AUTH_APPLE_CLIENT_IDS` | `.env.server` | — | comma-separated Apple client IDs (bundle ID / Services ID); enables Apple native sign-in |
|
|
152
159
|
| `SPFN_AUTH_KAKAO_NATIVE_CLIENT_IDS` | `.env.server` | — | comma-separated Kakao app keys accepted as native id_token audience (native app key); `SPFN_AUTH_KAKAO_CLIENT_ID` is also accepted, so either one enables Kakao native sign-in |
|
|
@@ -154,6 +161,16 @@ real secret values out of band, never commit them.
|
|
|
154
161
|
| `SPFN_AUTH_OAUTH_SUCCESS_URL` | `.env.server` | — | default `/auth/callback` |
|
|
155
162
|
| `SPFN_AUTH_OAUTH_ERROR_URL` | `.env.server` | — | default `/auth/error?error={error}` |
|
|
156
163
|
| `SPFN_AUTH_RESERVED_USERNAMES` / `_USERNAME_MIN_LENGTH` / `_USERNAME_MAX_LENGTH` | `.env.server` | — | username rules |
|
|
164
|
+
| `SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES` / `_SETUP_TTL_MINUTES` | `.env.server` | — | defaults `30` / `15` — see [Verified-email signup](#verified-email-signup) |
|
|
165
|
+
| `SPFN_AUTH_SIGNUP_CONFIRM_PATH` | `.env.server` | — | default `/signup/confirm`; the page in your app the emailed link opens |
|
|
166
|
+
| `SPFN_AUTH_PASSWORD_RESET_LINK_TTL_MINUTES` / `_SETUP_TTL_MINUTES` | `.env.server` | — | defaults `30` / `15` — see [Password reset](#password-reset-verified-email) |
|
|
167
|
+
| `SPFN_AUTH_PASSWORD_RESET_CONFIRM_PATH` | `.env.server` | — | default `/password/reset`; the page in your app the emailed link opens |
|
|
168
|
+
| `SPFN_AUTH_REVOKE_ALL_LINK_TTL_MINUTES` | `.env.server` | — | default `30` — see [The sign-out-everywhere link](#the-sign-out-everywhere-link) |
|
|
169
|
+
| `SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH` | `.env.server` | — | default `/account/revoke-all`; the page in your app the link opens |
|
|
170
|
+
| `SPFN_AUTH_LINK_MAIL_DELIVERY` | `.env.server` | — | `auto` (default) \| `inline` \| `queued`; who sends signup-link, reset and account-exists mail — see [Link mail delivery](#link-mail-delivery) |
|
|
171
|
+
| `SPFN_AUTH_PASSKEY_RP_ID` / `_RP_NAME` / `_ORIGINS` | `.env.server` | — | relying party for passkeys; defaults derive from `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}` and are **checked at boot** — see [Passkeys](#passkeys-webauthn) |
|
|
172
|
+
| `SPFN_AUTH_PASSKEY_USER_VERIFICATION` | `.env.server` | — | `preferred` (default) or `required`; `discouraged` refuses boot |
|
|
173
|
+
| `SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS` / `_RECENT_AUTH_MINUTES` | `.env.server` | — | defaults `300` / `10` — see [Passkeys](#passkeys-webauthn) |
|
|
157
174
|
| `NEXT_PUBLIC_SPFN_API_URL` / `NEXT_PUBLIC_SPFN_APP_URL` | `.env.local` | — | browser-facing URLs for OAuth redirects |
|
|
158
175
|
|
|
159
176
|
Read validated values via `import { env } from '@spfn/auth/config'` (a proxy validated at
|
|
@@ -178,7 +195,25 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
|
|
|
178
195
|
| `sendVerificationCode` | POST `/_auth/codes` | public | send 6-digit OTP |
|
|
179
196
|
| `verifyCode` | POST `/_auth/codes/verify` | public | verify OTP → verification token |
|
|
180
197
|
| `register` | POST `/_auth/register` | public | create user + register public key |
|
|
198
|
+
| `requestSignupLink` | POST `/_auth/signup/email` | public | email a one-time signup confirmation link — see [Verified-email signup](#verified-email-signup) |
|
|
199
|
+
| `confirmSignupLink` | POST `/_auth/signup/email/confirm` | public | exchange the link for a password-setup session |
|
|
200
|
+
| `completeSignup` | POST `/_auth/signup/password` | setup session | set the password, which creates the account and signs in |
|
|
201
|
+
| `requestPasswordReset` | POST `/_auth/password/reset` | public | email a one-time password reset link — see [Password reset](#password-reset-verified-email) |
|
|
202
|
+
| `confirmPasswordReset` | POST `/_auth/password/reset/confirm` | public | exchange the link for a password-setup session |
|
|
203
|
+
| `completePasswordReset` | POST `/_auth/password/reset/complete` | setup session | set the new password, sign every other device out, sign this one in |
|
|
181
204
|
| `login` | POST `/_auth/login` | public | password login + new session key |
|
|
205
|
+
| `startDeviceAuth` | POST `/_auth/device/start` | public | begin a device-code login — see [Device-code login](#device-code-login) |
|
|
206
|
+
| `pollDeviceAuth` | POST `/_auth/device/poll` | public | ask whether the request was answered; the approved answer *is* the login |
|
|
207
|
+
| `getDeviceAuthInfo` | POST `/_auth/device/info` | yes | what device is asking, so the approval screen can show it |
|
|
208
|
+
| `approveDeviceAuth` | POST `/_auth/device/approve` | yes | let the waiting device in |
|
|
209
|
+
| `denyDeviceAuth` | POST `/_auth/device/deny` | yes | refuse it |
|
|
210
|
+
| `passkeyRegisterOptions` | POST `/_auth/passkeys/register/options` | yes | begin enrolling a passkey — see [Passkeys](#passkeys-webauthn) |
|
|
211
|
+
| `passkeyRegisterVerify` | POST `/_auth/passkeys/register/verify` | yes | verify the attestation and keep the credential |
|
|
212
|
+
| `passkeyLoginOptions` | POST `/_auth/passkeys/login/options` | public | begin a passkey sign-in; takes no identifier |
|
|
213
|
+
| `passkeyLoginVerify` | POST `/_auth/passkeys/login/verify` | public | verify the assertion; answers exactly as `login` |
|
|
214
|
+
| `listPasskeys` | POST `/_auth/passkeys/list` | yes | the caller's enrolled passkeys |
|
|
215
|
+
| `renamePasskey` | POST `/_auth/passkeys/rename` | yes | rename one |
|
|
216
|
+
| `revokePasskey` | POST `/_auth/passkeys/revoke` | yes | retire one (refused if it is the last way in) |
|
|
182
217
|
| `logout` | POST `/_auth/logout` | yes | revoke current key |
|
|
183
218
|
| `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
|
|
184
219
|
| `listKeys` | POST `/_auth/keys/list` | yes | the caller's registered devices — see [Registered devices](#registered-devices-key-management) |
|
|
@@ -194,6 +229,7 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
|
|
|
194
229
|
| `cancelAccountDeletion` | POST `/_auth/deletion/cancel` | public | cancel a pending deletion (credential-based recovery) |
|
|
195
230
|
| `listRoles` / `createAdminRole` / `updateAdminRole` / `deleteAdminRole` / `updateUserRole` | — | superadmin | admin RBAC management |
|
|
196
231
|
| OAuth routes | — | — | see OAuth section |
|
|
232
|
+
| `registerOAuth2Client` / `getOAuth2Authorize` / `createOAuth2AuthorizationCode` / `oauth2Token` / `oauth2Revoke` / `listOAuth2Grants` / `revokeOAuth2Grant` | `/_auth/oauth2/*` | mixed | OAuth 2.1 authorization server for MCP clients — see [Authorization server for MCP clients](#authorization-server-for-mcp-clients). 404 unless configured |
|
|
197
233
|
|
|
198
234
|
There is deliberately **no account-existence endpoint**. `POST /_auth/exists` was removed
|
|
199
235
|
because it answered "does this account exist" directly, which is user enumeration; the
|
|
@@ -205,8 +241,280 @@ sends the public key on register/login, signs request JWTs locally, and the serv
|
|
|
205
241
|
with the stored public key (`keyId` carried in the JWT). The server never holds a private key.
|
|
206
242
|
Keys expire after 90 days — rotate with `rotateKey`.
|
|
207
243
|
|
|
244
|
+
### Verified-email signup
|
|
245
|
+
|
|
246
|
+
A second way in, alongside the six-digit code. The address is proven before a password
|
|
247
|
+
exists, so nothing is stored for someone who never confirms.
|
|
248
|
+
|
|
249
|
+
```
|
|
250
|
+
request → a one-time link is emailed
|
|
251
|
+
confirm → the link becomes a short-lived, HttpOnly password-setup session
|
|
252
|
+
password → the account is created, the device registered, the user signed in
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The six-digit-code path (`sendVerificationCode` → `verifyCode` → `register`) is unchanged.
|
|
256
|
+
Offer whichever suits your product, or both.
|
|
257
|
+
|
|
258
|
+
**1 — request the link.** The response is identical whether or not the address already has
|
|
259
|
+
an account, so it cannot be used to probe for accounts. When one exists, the owner gets a
|
|
260
|
+
"you already have an account" notice instead of a usable link.
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
await authApi.requestSignupLink.call({
|
|
264
|
+
body: { email: 'user@example.com', returnPath: '/welcome' }, // returnPath optional
|
|
265
|
+
});
|
|
266
|
+
// → { success: true, expiresAt }
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Calling it again is how a resend works: it invalidates the previous link and any setup
|
|
270
|
+
session opened from it. `returnPath` must be a path inside your app — absolute URLs,
|
|
271
|
+
`//host`, and `..` are refused, so the link cannot become an open redirect.
|
|
272
|
+
|
|
273
|
+
The mail leaves through the `auth.link-mail` job when pg-boss is initialised — register
|
|
274
|
+
`authJobRouter` — so neither branch of this endpoint waits on a mail provider; see
|
|
275
|
+
[Link mail delivery](#link-mail-delivery).
|
|
276
|
+
|
|
277
|
+
**2 — the page the link opens.** The email points at a page in *your* app
|
|
278
|
+
(`SPFN_AUTH_SIGNUP_CONFIRM_PATH`, default `/signup/confirm`), not at an API route. That page
|
|
279
|
+
reads the token from the query string and posts it:
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
'use client';
|
|
283
|
+
|
|
284
|
+
const token = useSearchParams().get('token');
|
|
285
|
+
|
|
286
|
+
const { email, returnPath } = await authApi.confirmSignupLink.call({ body: { token } });
|
|
287
|
+
|
|
288
|
+
// Drop the token from the URL so it does not linger in history or a Referer header.
|
|
289
|
+
window.history.replaceState({}, '', window.location.pathname);
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
The setup session comes back as an HttpOnly cookie — the proxy interceptor moves it there
|
|
293
|
+
and strips it from the response body, so page script never holds it. Serve this page with
|
|
294
|
+
`Referrer-Policy: no-referrer`.
|
|
295
|
+
|
|
296
|
+
**3 — set the password.** This is the step that creates the account. The setup cookie
|
|
297
|
+
authorizes it; the device keypair is injected by the interceptor exactly as it is for
|
|
298
|
+
`register`.
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
await authApi.completeSignup.call({ body: { password } });
|
|
302
|
+
// → { userId, publicId, email } + session cookie, same as register
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Creating the user, registering the device key, and marking the setup session used all commit
|
|
306
|
+
together. A password that fails the strength policy leaves the session usable, so the user
|
|
307
|
+
retypes rather than requesting a fresh email.
|
|
308
|
+
|
|
309
|
+
**Settings.**
|
|
310
|
+
|
|
311
|
+
| Variable | Default | Meaning |
|
|
312
|
+
|----------|---------|---------|
|
|
313
|
+
| `SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES` | `30` | how long the emailed link works |
|
|
314
|
+
| `SPFN_AUTH_SIGNUP_SETUP_TTL_MINUTES` | `15` | how long the password-setup session works |
|
|
315
|
+
| `SPFN_AUTH_SIGNUP_CONFIRM_PATH` | `/signup/confirm` | the page in your app the link opens |
|
|
316
|
+
|
|
317
|
+
The link URL is built on `NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL`, the same resolution the
|
|
318
|
+
OAuth callbacks use. Delivery uses the `signup-link` template in `@spfn/notification` —
|
|
319
|
+
override it there to change the copy.
|
|
320
|
+
|
|
321
|
+
**What is stored.** Only SHA-256 hashes of the link token and the setup secret, in
|
|
322
|
+
`spfn_auth.signup_link_tokens`. Neither credential is recoverable from the database, and
|
|
323
|
+
both are one-time: a link opens one setup session, and a setup session sets one password.
|
|
324
|
+
|
|
325
|
+
### Password reset (verified email)
|
|
326
|
+
|
|
327
|
+
The way back into an account whose password is gone, using the address the account already
|
|
328
|
+
proved. Same three steps as the signup above, and the same posture on the two credentials.
|
|
329
|
+
|
|
330
|
+
```
|
|
331
|
+
request → a one-time link is emailed
|
|
332
|
+
confirm → the link becomes a short-lived, HttpOnly password-setup session
|
|
333
|
+
complete → the new password is written, every other device is signed out, this one is signed in
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
**Who can reset.** An `active` account whose `emailVerifiedAt` is set **or** that already has
|
|
337
|
+
a password. The second half is what makes the rule work on accounts created before the
|
|
338
|
+
column was stamped: both register paths proved the address at signup. An OAuth-only account
|
|
339
|
+
whose provider reported the address unverified has neither and is excluded — for it, a reset
|
|
340
|
+
would be a way in built on an address nobody proved.
|
|
341
|
+
|
|
342
|
+
**1 — request the link.** The response is identical for every input — same status, same two
|
|
343
|
+
fields, same `expiresAt` arithmetic — and mail goes only to an account that can be reset, so
|
|
344
|
+
neither the answer nor the mailbox reveals whether an address has an account here.
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
await authApi.requestPasswordReset.call({
|
|
348
|
+
body: { email: 'user@example.com', returnPath: '/account' }, // returnPath optional
|
|
349
|
+
});
|
|
350
|
+
// → { success: true, expiresAt }
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Calling it again is how a resend works: it invalidates the previous link and any setup
|
|
354
|
+
session opened from it. `returnPath` must be a path inside your app — absolute URLs,
|
|
355
|
+
`//host`, and `..` are refused, so the link cannot become an open redirect.
|
|
356
|
+
|
|
357
|
+
The mail leaves through the `auth.link-mail` job when pg-boss is initialised — register
|
|
358
|
+
`authJobRouter` — so an address with an account and one without cost the same; see
|
|
359
|
+
[Link mail delivery](#link-mail-delivery).
|
|
360
|
+
|
|
361
|
+
**2 — the page the link opens.** The email points at a page in *your* app
|
|
362
|
+
(`SPFN_AUTH_PASSWORD_RESET_CONFIRM_PATH`, default `/password/reset`), not at an API route.
|
|
363
|
+
That page reads the token from the query string and posts it:
|
|
364
|
+
|
|
365
|
+
```typescript
|
|
366
|
+
'use client';
|
|
367
|
+
|
|
368
|
+
const token = useSearchParams().get('token');
|
|
369
|
+
|
|
370
|
+
const { email, returnPath } = await authApi.confirmPasswordReset.call({ body: { token } });
|
|
371
|
+
|
|
372
|
+
// Drop the token from the URL so it does not linger in history or a Referer header.
|
|
373
|
+
window.history.replaceState({}, '', window.location.pathname);
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
The setup session comes back as an HttpOnly cookie — the proxy interceptor moves it there
|
|
377
|
+
and strips it from the response body, so page script never holds it. It is a cookie of its
|
|
378
|
+
own, not the signup one, so neither secret is ever accepted by the other flow. Serve this
|
|
379
|
+
page with `Referrer-Policy: no-referrer`.
|
|
380
|
+
|
|
381
|
+
**3 — set the new password.** The setup cookie authorizes it; the device keypair is injected
|
|
382
|
+
by the interceptor exactly as it is for `login`.
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
await authApi.completePasswordReset.call({ body: { password } });
|
|
386
|
+
// → { userId, publicId, email } + session cookie, same as login
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
**Every other device is signed out.** Completing a reset denies every pending device
|
|
390
|
+
authorization and revokes every active key, exactly as `changePassword` does — whoever was
|
|
391
|
+
signed in on the old password, including the person the reset was needed for, has to sign in
|
|
392
|
+
again. The browser that performed the reset is signed in on a fresh key registered after the
|
|
393
|
+
revocation, so it does not have to retype the new password. `emailVerifiedAt` is stamped if
|
|
394
|
+
it was not already, `passwordChangeRequired` is cleared, and `auth.password.reset` is emitted
|
|
395
|
+
after commit.
|
|
396
|
+
|
|
397
|
+
The new hash, the revocations, the new device key and the completion mark commit together. A
|
|
398
|
+
password that fails the strength policy leaves the session usable, so the user retypes rather
|
|
399
|
+
than requesting a fresh email.
|
|
400
|
+
|
|
401
|
+
**Settings.**
|
|
402
|
+
|
|
403
|
+
| Variable | Default | Meaning |
|
|
404
|
+
|----------|---------|---------|
|
|
405
|
+
| `SPFN_AUTH_PASSWORD_RESET_LINK_TTL_MINUTES` | `30` | how long the emailed link works |
|
|
406
|
+
| `SPFN_AUTH_PASSWORD_RESET_SETUP_TTL_MINUTES` | `15` | how long the password-setup session works |
|
|
407
|
+
| `SPFN_AUTH_PASSWORD_RESET_CONFIRM_PATH` | `/password/reset` | the page in your app the link opens |
|
|
408
|
+
|
|
409
|
+
The link URL is built on `NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL`, the same resolution the
|
|
410
|
+
signup link uses. Delivery uses the `password-reset` template in `@spfn/notification` —
|
|
411
|
+
override it there to change the copy.
|
|
412
|
+
|
|
413
|
+
**What is stored.** Only SHA-256 hashes of the link token and the setup secret, in
|
|
414
|
+
`spfn_auth.password_reset_tokens`. Neither credential is recoverable from the database, and
|
|
415
|
+
both are one-time: a link opens one setup session, and a setup session sets one password.
|
|
416
|
+
A separate table from `signup_link_tokens`, so a signup secret can never address a reset row.
|
|
417
|
+
|
|
418
|
+
### Device-code login
|
|
419
|
+
|
|
420
|
+
A way in for a device that has a screen but no comfortable keyboard — a TV, a console, a CLI
|
|
421
|
+
on a headless box. The new device shows a short code; the account owner types that code on a
|
|
422
|
+
device that is already signed in.
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
// On the new device — it has no key on file, so this call is public.
|
|
426
|
+
const { deviceCode, userCode, expiresAtMillis, intervalMillis } =
|
|
427
|
+
await authApi.startDeviceAuth.call({ body: {
|
|
428
|
+
publicKey, keyId, fingerprint, algorithm: 'ES256',
|
|
429
|
+
deviceName: 'Living room TV', platform: 'desktop',
|
|
430
|
+
} });
|
|
431
|
+
|
|
432
|
+
// Show `userCode` (XXXX-XXXX) on this device's screen, then poll every intervalMillis.
|
|
433
|
+
const answer = await authApi.pollDeviceAuth.call({ body: { deviceCode } });
|
|
434
|
+
// → { status: 'pending', intervalMillis }
|
|
435
|
+
// → { status: 'approved', userId, publicId, email?, phone?, passwordChangeRequired }
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
// On the signed-in device — the user typed the code they read off the other screen.
|
|
440
|
+
const asking = await authApi.getDeviceAuthInfo.call({ body: { userCode } });
|
|
441
|
+
// → { deviceName?, platform?, fingerprintPrefix, requestedAtMillis, expiresAtMillis }
|
|
442
|
+
|
|
443
|
+
await authApi.approveDeviceAuth.call({ body: { userCode } }); // or denyDeviceAuth
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
**There is no token handed over, because there is no token.** Every request in this system is
|
|
447
|
+
signed by the calling device's own key, so "logging a device in" means getting its public key
|
|
448
|
+
into `user_public_keys` under the right account — which is exactly what the winning poll does.
|
|
449
|
+
That is why the approved answer is the same shape `login` returns: from the client's side the
|
|
450
|
+
two ways in are indistinguishable.
|
|
451
|
+
|
|
452
|
+
- **Only ever show the code on the new device's screen.** The whole attack on this flow is
|
|
453
|
+
someone sending a victim a code and asking them to approve it — a support call, a chat
|
|
454
|
+
message, a "verify your account" email. A code that arrived any way other than off the
|
|
455
|
+
device in front of you is an attack. This is why `info` and `approve` answer with the
|
|
456
|
+
requesting device's name, platform and fingerprint prefix, and why an approval screen that
|
|
457
|
+
shows only the code is doing it wrong: it is asking the user to confirm a number they were
|
|
458
|
+
just told.
|
|
459
|
+
- **The device code is stored only as a SHA-256 hash**, like the ops-token and signup-link
|
|
460
|
+
secrets. It is returned once. A dump of `spfn_auth.device_authorizations` does not let its
|
|
461
|
+
reader finish anyone's login.
|
|
462
|
+
- **The user code is stored in the clear, and that is fine** — it authorizes nothing without
|
|
463
|
+
an approver who is already signed in. It is drawn from an alphabet with no `0`/`O` or
|
|
464
|
+
`1`/`I`/`L`, since it is read off one screen and typed on another.
|
|
465
|
+
- **A decision is made once.** Approve and deny move the record from `pending` and nowhere
|
|
466
|
+
else, so a second approval, a deny after an approve, or two approvals racing each other all
|
|
467
|
+
get `DeviceAuthAlreadyHandledError` (409) — a refusal is never undone.
|
|
468
|
+
- **The approval is one-shot.** The poll that registers the key spends the record in the same
|
|
469
|
+
statement that reads it, so of two polls arriving together exactly one registers the key and
|
|
470
|
+
the other is answered as if the code were unknown.
|
|
471
|
+
- **A spent code and a code that never existed answer identically** (`DeviceAuthNotFoundError`,
|
|
472
|
+
404). Saying "that one was real, but it is used up" is the difference between guessing at
|
|
473
|
+
random and knowing a guess landed. Every route that accepts a code is rate limited for the
|
|
474
|
+
same reason: `start` and `poll` per IP, `info` / `approve` / `deny` per IP *and* per calling
|
|
475
|
+
account.
|
|
476
|
+
- **Expiry outranks state.** A code that sat past its TTL is expired whatever it says, so an
|
|
477
|
+
approval nobody collected in time registers nothing. The TTL travels in the statement that
|
|
478
|
+
moves the record, not only in the read before it, so a code cannot be spent by a poll that
|
|
479
|
+
read it a moment before it died.
|
|
480
|
+
- **A global revocation reaches the codes too.** `revoke-all`, a password change and a
|
|
481
|
+
deletion request each refuse the account's live device authorizations, so an approval nobody
|
|
482
|
+
collected cannot register a fresh key seconds after the user signed everything out — which
|
|
483
|
+
would hand one back to exactly the device they were cutting off. Revoking a single key,
|
|
484
|
+
logging out and rotating a key do not: those name one device, and the waiting one is not it.
|
|
485
|
+
- **The poll re-checks the account.** It is a login, so it refuses a suspended or
|
|
486
|
+
pending-deletion account with the same errors `/_auth/login` does. Approval and collection
|
|
487
|
+
are separate moments, and what the account is when the key is registered is what counts.
|
|
488
|
+
- **`start` bounds what it stores.** It is the one route that takes key material from a caller
|
|
489
|
+
who cannot authenticate, so `publicKey`, `keyId` and `fingerprint` carry length limits —
|
|
490
|
+
generous next to a real key (an RSA-2048 SPKI is 392 base64 characters against a 2048 limit)
|
|
491
|
+
and small next to the megabyte that would otherwise sit in a table no job clears.
|
|
492
|
+
- **Clock skew cannot affect this.** Every timestamp in the decision is the server's. The
|
|
493
|
+
`expiresAtMillis` in the start response is for the waiting device's countdown display, and
|
|
494
|
+
nothing the client believes about the time reaches the server's judgement.
|
|
495
|
+
|
|
496
|
+
Two knobs, both announced to the waiting device in the start response and therefore resolved
|
|
497
|
+
at lifecycle time rather than read per call:
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
createAuthLifecycle({
|
|
501
|
+
deviceAuth: {
|
|
502
|
+
ttlMs: 10 * 60 * 1000, // how long a code lives. default 10 minutes
|
|
503
|
+
intervalMs: 5000, // poll interval the server asks for. default 5s
|
|
504
|
+
},
|
|
505
|
+
})
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
No job sweeps the table. Rows are judged by `expiresAt` whenever they are read or moved, so a
|
|
509
|
+
stale row authorizes nothing; it only keeps its user code out of circulation, and 31⁸ codes do
|
|
510
|
+
not run out.
|
|
511
|
+
|
|
208
512
|
### Registered devices (key management)
|
|
209
513
|
|
|
514
|
+
A [passkey](#passkeys-webauthn) is **not** one of these keys: it is a credential that proves
|
|
515
|
+
identity at sign-in, after which an ordinary device key is registered exactly as a password
|
|
516
|
+
login registers one.
|
|
517
|
+
|
|
210
518
|
Keys are per-device, so a login never revokes the previous key and they accumulate on purpose.
|
|
211
519
|
`listKeys` / `revokeKey` / `revokeAllKeys` are what let the account owner see what accumulated and
|
|
212
520
|
cut off anything they no longer recognise.
|
|
@@ -214,7 +522,8 @@ cut off anything they no longer recognise.
|
|
|
214
522
|
```typescript
|
|
215
523
|
const { keys } = await authApi.listKeys.call({ body: {} });
|
|
216
524
|
// → [{ keyId, deviceName?, platform?, algorithm, fingerprintPrefix, createdAtMillis,
|
|
217
|
-
// lastUsedAtMillis?, expiresAtMillis?, isExpired, isActive, revokedAtMillis
|
|
525
|
+
// lastUsedAtMillis?, expiresAtMillis?, isExpired, isActive, revokedAtMillis?,
|
|
526
|
+
// registeredIp?, registeredUserAgent? }]
|
|
218
527
|
|
|
219
528
|
await authApi.listKeys.call({ body: { includeRevoked: true } }); // also what was cut off
|
|
220
529
|
```
|
|
@@ -236,13 +545,19 @@ await authApi.revokeAllKeys.call({ body: {} }); // other devices o
|
|
|
236
545
|
await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // everything
|
|
237
546
|
```
|
|
238
547
|
|
|
239
|
-
> **All three are POST with their arguments in the body, deliberately.** The mobile auth
|
|
548
|
+
> **All three key-management operations are POST with their arguments in the body, deliberately.** The mobile auth
|
|
240
549
|
> profile (clientProofV1) signs the request body, and `canonical-json` fixes exactly how those
|
|
241
550
|
> bytes are written. A `GET` has no body to sign, and a value in the path has no such rule —
|
|
242
551
|
> client and server could disagree on the signed string over percent-encoding, a trailing
|
|
243
552
|
> slash, or a proxy rewrite alone, and the request would be refused with nothing in the logs
|
|
244
|
-
> naming the cause.
|
|
245
|
-
|
|
553
|
+
> naming the cause. Proof-bearing auth operations are shaped this way; the unproven,
|
|
554
|
+
> bodyless `core.time` synchronization prerequisite is the explicit exception.
|
|
555
|
+
|
|
556
|
+
- **A key must be the type its algorithm names.** A P-256 SPKI declared `RS256`, an RSA key
|
|
557
|
+
declared `ES256`, and a curve other than P-256 declared `ES256` are each refused 400 with
|
|
558
|
+
`KeyAlgorithmMismatchError` on register, login, rotate and device start — the algorithm is
|
|
559
|
+
stored beside the key and read back at proof verification, so a mismatch accepted at
|
|
560
|
+
enrollment would surface only once the device already believed it was enrolled.
|
|
246
561
|
- **The public key never leaves the server**, and the fingerprint is truncated to 8 characters.
|
|
247
562
|
The list exists to recognise a device and point at it; the full fingerprint is what a native
|
|
248
563
|
sign-in sends as its nonce, not a label.
|
|
@@ -254,6 +569,10 @@ await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // every
|
|
|
254
569
|
- **`revokeAllKeys` spares the calling device unless you ask otherwise**, so the common case is
|
|
255
570
|
"sign out my other devices". `includeCurrent: true` is the full sign-out — until now reachable
|
|
256
571
|
only as a side effect of changing a password, which nobody does for that reason.
|
|
572
|
+
- **It also refuses device-code approvals still in flight**, in both modes, because an approved
|
|
573
|
+
code is a key that has not been handed out yet: the next poll would register a fresh active one
|
|
574
|
+
and undo the sign-out. `revokedCount` still counts keys only — a code nobody collected was
|
|
575
|
+
never a session. See [Device-code login](#device-code-login).
|
|
257
576
|
- **A key id you do not own answers 404** (`KeyNotFoundError`). Every lookup is scoped by user, so
|
|
258
577
|
the answer is only ever "not yours" and reveals nothing about other accounts.
|
|
259
578
|
- **Revocation takes effect immediately.** `authenticate` reads the key from the database on every
|
|
@@ -266,6 +585,14 @@ Every path that registers a key (`register`, `login`, `rotateKey`, native OAuth)
|
|
|
266
585
|
only — nothing is authorized by them — and both are absent on keys registered before they existed.
|
|
267
586
|
Rotation carries the replaced key's label over unless the client sends a new one.
|
|
268
587
|
|
|
588
|
+
- **`registeredIp` and `registeredUserAgent` are where the device came from**, captured once from
|
|
589
|
+
the request that registered the key and never updated — a device that later signs requests from
|
|
590
|
+
another network still shows the address it appeared from, which is what makes an entry the owner
|
|
591
|
+
does not recognise recognisable. Both are absent when the request resolved neither and on keys
|
|
592
|
+
registered before the columns existed; the literal string `unknown` is never stored. They are
|
|
593
|
+
unauthenticated display material, spoofable on any request that does not come through a verified
|
|
594
|
+
proxy, so render them and decide nothing by them. Mobile contract 0.11.0.
|
|
595
|
+
|
|
269
596
|
All three are in the mobile contract (0.4.1) as `auth.keys.list` / `auth.keys.revoke` /
|
|
270
597
|
`auth.keys.revokeAll`, so a generated mobile client reaches them the same way it reaches key
|
|
271
598
|
rotation.
|
|
@@ -278,6 +605,355 @@ is still active is the one
|
|
|
278
605
|
exception: it stays a no-op success, so repeated logins from the same device keep working, and an
|
|
279
606
|
expired-but-active key has its expiry extended by the sign-in that proved the identity again.
|
|
280
607
|
|
|
608
|
+
### The sign-out-everywhere link
|
|
609
|
+
|
|
610
|
+
The key operations above all need a session, which is exactly what an owner who no longer trusts
|
|
611
|
+
the device in front of them does not want to use. `createRevokeAllLink` mints a one-time link your
|
|
612
|
+
app mails to the address the account has already proved; opening it signs every device out with no
|
|
613
|
+
session at all.
|
|
614
|
+
|
|
615
|
+
```typescript
|
|
616
|
+
import { createRevokeAllLink } from '@spfn/auth/server';
|
|
617
|
+
|
|
618
|
+
const { url, expiresAt } = await createRevokeAllLink(userId); // default TTL 30 minutes
|
|
619
|
+
const short = await createRevokeAllLink(userId, { ttlMinutes: 10 });
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
**The link opens a page in your app** (`SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH`, default
|
|
623
|
+
`/account/revoke-all`), not an API route — the same shape the signup and reset links use. That page
|
|
624
|
+
reads the token out of the query string and makes two calls: one to render, one when the owner
|
|
625
|
+
presses the button.
|
|
626
|
+
|
|
627
|
+
```typescript
|
|
628
|
+
'use client';
|
|
629
|
+
|
|
630
|
+
const token = useSearchParams().get('token');
|
|
631
|
+
|
|
632
|
+
// Describing the link changes nothing at all, so a mail scanner that prefetches
|
|
633
|
+
// the page has not signed anybody out.
|
|
634
|
+
const { expiresAt, activeKeyCount } = await authApi.confirmRevokeAllLink.call({ body: { token } });
|
|
635
|
+
|
|
636
|
+
// The button.
|
|
637
|
+
const { revokedCount } = await authApi.consumeRevokeAllLink.call({ body: { token } });
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
- **Every refusal is the same 404** (`RevokeAllLinkError`), with the same body: unknown, expired,
|
|
641
|
+
already spent, superseded by a newer link, issued against a key generation that has since moved,
|
|
642
|
+
or belonging to an account that is not active. Telling those apart would tell whoever holds a
|
|
643
|
+
random value that it named something real. 404 rather than the 401 the [password reset
|
|
644
|
+
link](#password-reset-verified-email) answers with, because there is no credential here to have
|
|
645
|
+
been wrong: the mailbox is the proof, and what arrives either names an outstanding link or names
|
|
646
|
+
nothing.
|
|
647
|
+
- **The token travels in the request body, never in a path segment.** The request logger records
|
|
648
|
+
the path of every request, and so does whatever proxy sits in front of it.
|
|
649
|
+
- **Your obligation, which this package cannot enforce:** the returned `url` carries the plaintext
|
|
650
|
+
token, because this flow sends no mail of its own. Do not log it, do not persist it, do not put
|
|
651
|
+
it in a job payload — hand it to the mail template and let it go. The package's other two links
|
|
652
|
+
are minted inside the worker that sends them precisely so no caller ever holds one; this one
|
|
653
|
+
cannot be.
|
|
654
|
+
- **It is one-time and generation-bound.** Consuming it is a single statement, so two clicks
|
|
655
|
+
produce one sign-out and one 404. It also dies the moment anything else ends the account's key
|
|
656
|
+
generation — a completed password reset, a password change, a deletion request, or the
|
|
657
|
+
`revokeAllKeys` route in either mode.
|
|
658
|
+
- **It does not change the password.** Send it alongside a password reset link: this one ends the
|
|
659
|
+
sessions, that one ends the credential that started them.
|
|
660
|
+
- **Issuing again supersedes.** A second link retires the first, so asking twice does not leave a
|
|
661
|
+
spare capability in the mailbox.
|
|
662
|
+
- **`ttlMinutes` must be a positive whole number.** Zero or negative is a `ValidationError` and
|
|
663
|
+
writes no row; an unknown `userId` is refused explicitly rather than surfacing as a foreign-key
|
|
664
|
+
500.
|
|
665
|
+
- **Rate limited 10/minute per address** across both endpoints, on one counter — valid and invalid
|
|
666
|
+
tokens are not counted separately, which would be a way to tell them apart.
|
|
667
|
+
- **Expired and spent rows are swept** by `auth.revoke-all-token-purge` (daily 06:00), part of
|
|
668
|
+
`authJobRouter`: a week after expiry, a day after being spent or superseded.
|
|
669
|
+
|
|
670
|
+
**Settings.**
|
|
671
|
+
|
|
672
|
+
| Variable | Default | Meaning |
|
|
673
|
+
|----------|---------|---------|
|
|
674
|
+
| `SPFN_AUTH_REVOKE_ALL_LINK_TTL_MINUTES` | `30` | how long the link works |
|
|
675
|
+
| `SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH` | `/account/revoke-all` | the page in your app the link opens |
|
|
676
|
+
|
|
677
|
+
The link URL is built on `NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL`, the same resolution the other
|
|
678
|
+
two links use. Only the SHA-256 of the token is stored, in `spfn_auth.key_revoke_all_tokens`.
|
|
679
|
+
|
|
680
|
+
Neither route is in the mobile contract: both are answered for a browser on a page in your app,
|
|
681
|
+
with no session and no client proof, and a generated mobile client has a session by definition.
|
|
682
|
+
|
|
683
|
+
### Passkeys (WebAuthn)
|
|
684
|
+
|
|
685
|
+
A passkey is an **optional additional credential** on an account, alongside a password and a
|
|
686
|
+
linked social account rather than in place of either. Enroll one from a session that already
|
|
687
|
+
exists; sign in with it afterwards without typing an identifier at all.
|
|
688
|
+
|
|
689
|
+
```
|
|
690
|
+
enroll → register/options (session) → the browser mints a credential → register/verify
|
|
691
|
+
sign in → login/options (public) → the browser picks a credential → login/verify
|
|
692
|
+
manage → list / rename / revoke
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
**A passkey is not a device key.** The assertion proves *who* is asking; the device key the
|
|
696
|
+
Next.js proxy registers right after it is what every later request is signed with, exactly as
|
|
697
|
+
after a password login. Nothing in clientProofV1, in the JWT path, or in
|
|
698
|
+
[Registered devices](#registered-devices-key-management) changes because a session started
|
|
699
|
+
this way — a passkey sign-in produces the same `LoginResult` and the same key row as `login`.
|
|
700
|
+
|
|
701
|
+
#### Setup
|
|
702
|
+
|
|
703
|
+
```bash
|
|
704
|
+
# .env.server — nothing is required; these are the overrides
|
|
705
|
+
SPFN_AUTH_PASSKEY_RP_ID=example.com
|
|
706
|
+
SPFN_AUTH_PASSKEY_ORIGINS=https://app.example.com,https://admin.example.com
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
With neither set, the relying party is derived from `{NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}`:
|
|
710
|
+
its host becomes the rpId and its origin becomes the single allowed origin. That is the whole
|
|
711
|
+
configuration for a one-origin app.
|
|
712
|
+
|
|
713
|
+
| Var | File | Notes |
|
|
714
|
+
|-----|------|-------|
|
|
715
|
+
| `SPFN_AUTH_PASSKEY_RP_ID` | `.env.server` | domain credentials are bound to — no protocol, no port. Default: the app URL's host. **Changing it orphans every passkey already enrolled** |
|
|
716
|
+
| `SPFN_AUTH_PASSKEY_RP_NAME` | `.env.server` | name the authenticator's own prompt shows. Default: the rpId |
|
|
717
|
+
| `SPFN_AUTH_PASSKEY_ORIGINS` | `.env.server` | comma-separated full origins allowed to run a ceremony. Default: the app URL's origin |
|
|
718
|
+
| `SPFN_AUTH_PASSKEY_USER_VERIFICATION` | `.env.server` | `preferred` (default) or `required`. `discouraged` refuses boot |
|
|
719
|
+
| `SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS` | `.env.server` | default `300` — one ceremony at the authenticator, not an abandoned tab |
|
|
720
|
+
| `SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES` | `.env.server` | default `10` — see [the recent-authentication gate](#the-recent-authentication-gate) |
|
|
721
|
+
|
|
722
|
+
Two rules on those origins, **checked at boot** and refused with `PasskeyConfigError`:
|
|
723
|
+
|
|
724
|
+
- each origin must be `https`, and `localhost` is the one host a browser treats as a secure
|
|
725
|
+
context over plain `http` — so `http://localhost:3000` is legal and `http://app.example.com`
|
|
726
|
+
is not;
|
|
727
|
+
- each origin's host must be the rpId or a subdomain of it, because the browser will refuse
|
|
728
|
+
the ceremony otherwise.
|
|
729
|
+
|
|
730
|
+
The check runs at `initializeAuth`, deliberately: every one of these values makes *every*
|
|
731
|
+
passkey operation fail, the drift is between environments, and the deploy that introduces it
|
|
732
|
+
is where it has to surface — not the first sign-in after it.
|
|
733
|
+
|
|
734
|
+
**Boot is only refused for a configuration you wrote.** If no `SPFN_AUTH_PASSKEY_*` variable
|
|
735
|
+
is set, the derived relying party can still be unusable — `SPFN_APP_URL=http://192.168.1.5:3000`
|
|
736
|
+
so a phone on the same network can reach your laptop, say, which is neither https nor
|
|
737
|
+
localhost. Refusing to start over a feature nobody asked for would take that app down to fix
|
|
738
|
+
something it does not use, so it is logged once instead and only a ceremony fails. Set any
|
|
739
|
+
passkey variable and the same configuration refuses to start. This is the posture
|
|
740
|
+
[the OAuth callback origin check](#oauth-callback-origin-web-app-host--rewrite) already takes.
|
|
741
|
+
|
|
742
|
+
#### Enrolling, from a Next.js client component
|
|
743
|
+
|
|
744
|
+
```tsx
|
|
745
|
+
'use client';
|
|
746
|
+
import { authApi } from '@spfn/auth';
|
|
747
|
+
import { enrollPasskey, isPasskeySupported } from '@spfn/auth/client';
|
|
748
|
+
|
|
749
|
+
async function addPasskey()
|
|
750
|
+
{
|
|
751
|
+
const result = await enrollPasskey(authApi, { label: 'MacBook Touch ID' });
|
|
752
|
+
|
|
753
|
+
if (!result.ok)
|
|
754
|
+
{
|
|
755
|
+
// 'unsupported' | 'cancelled' | 'error' — 'cancelled' is not an error to show
|
|
756
|
+
return result.reason === 'cancelled' ? undefined : showError(result.reason);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
showAdded(result.passkeyId, result.label);
|
|
760
|
+
}
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
`isPasskeySupported()` is what decides whether to render the button at all.
|
|
764
|
+
|
|
765
|
+
#### Signing in, with conditional UI
|
|
766
|
+
|
|
767
|
+
The passkey appears in the browser's ordinary autofill dropdown. That needs an input whose
|
|
768
|
+
`autocomplete` ends in `webauthn`, and a `signInWithPasskey` call started **when the form
|
|
769
|
+
renders**, not on a click:
|
|
770
|
+
|
|
771
|
+
```tsx
|
|
772
|
+
'use client';
|
|
773
|
+
import { useEffect } from 'react';
|
|
774
|
+
import { authApi } from '@spfn/auth';
|
|
775
|
+
import { isConditionalMediationAvailable, signInWithPasskey } from '@spfn/auth/client';
|
|
776
|
+
|
|
777
|
+
export function SignInForm()
|
|
778
|
+
{
|
|
779
|
+
useEffect(() =>
|
|
780
|
+
{
|
|
781
|
+
void (async () =>
|
|
782
|
+
{
|
|
783
|
+
if (!await isConditionalMediationAvailable()) return;
|
|
784
|
+
|
|
785
|
+
const result = await signInWithPasskey(authApi, { conditional: true });
|
|
786
|
+
if (result.ok) router.replace('/');
|
|
787
|
+
})();
|
|
788
|
+
}, []);
|
|
789
|
+
|
|
790
|
+
return (
|
|
791
|
+
<form>
|
|
792
|
+
<input name="email" autoComplete="username webauthn" />
|
|
793
|
+
<input name="password" type="password" autoComplete="current-password" />
|
|
794
|
+
</form>
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
Where conditional mediation is missing, render a visible "Sign in with a passkey" button that
|
|
800
|
+
calls `signInWithPasskey(authApi)` instead.
|
|
801
|
+
|
|
802
|
+
Both helpers answer with a discriminated union and **never throw a cancellation**: a person
|
|
803
|
+
who dismisses the system sheet raises `NotAllowedError`, and so does a person whose
|
|
804
|
+
authenticator had nothing to offer — neither is an application error, and code that has to
|
|
805
|
+
tell them apart by re-reading `error.name` gets it wrong once and shows a red banner to
|
|
806
|
+
someone who simply changed their mind.
|
|
807
|
+
|
|
808
|
+
| result | meaning |
|
|
809
|
+
|--------|---------|
|
|
810
|
+
| `{ ok: true, ... }` | signed in / enrolled; the rest of the object is the server's answer |
|
|
811
|
+
| `{ ok: false, reason: 'unsupported' }` | this browser has no WebAuthn; nothing was sent to the server |
|
|
812
|
+
| `{ ok: false, reason: 'cancelled' }` | the person dismissed the enrollment prompt |
|
|
813
|
+
| `{ ok: false, reason: 'no-credential' }` | sign-in: the authenticator offered nothing, or the person dismissed it |
|
|
814
|
+
| `{ ok: false, reason: 'error', error }` | anything else, with the original error attached |
|
|
815
|
+
|
|
816
|
+
#### The recent-authentication gate
|
|
817
|
+
|
|
818
|
+
Adding a credential is adding a way in, and removing one can lock an account. Both are refused
|
|
819
|
+
unless the caller has recently proved themselves, in one of two ways:
|
|
820
|
+
|
|
821
|
+
- **the device key this request is signed with was registered within
|
|
822
|
+
`SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES`** — that is when this device last presented a
|
|
823
|
+
credential, and it needs no new state; or
|
|
824
|
+
- **the body carries `currentPassword`** and it verifies.
|
|
825
|
+
|
|
826
|
+
Otherwise: **403 with `code: 'RECENT_AUTH_REQUIRED'`**. Branch on that code to prompt for the
|
|
827
|
+
password and retry — it is a stable field, not a message to match on.
|
|
828
|
+
|
|
829
|
+
An account with no password stored **cannot** satisfy the gate with a password, however
|
|
830
|
+
plausible the value; it has to sign in again. The comparison still runs, against a dummy hash,
|
|
831
|
+
so "no password on file" costs exactly what "wrong password" costs — otherwise response time
|
|
832
|
+
becomes an oracle for which accounts are OAuth-only.
|
|
833
|
+
|
|
834
|
+
#### Managing passkeys
|
|
835
|
+
|
|
836
|
+
```typescript
|
|
837
|
+
const { passkeys } = await authApi.listPasskeys.call({ body: {} });
|
|
838
|
+
// → [{ passkeyId, label, deviceType, backedUp, transports, createdAt, lastUsedAt }]
|
|
839
|
+
|
|
840
|
+
await authApi.renamePasskey.call({ body: { passkeyId, label: 'Old iPhone' } });
|
|
841
|
+
await authApi.revokePasskey.call({ body: { passkeyId } });
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
- **Neither `credentialId` nor the public key is ever returned.** They are what an
|
|
845
|
+
authenticator is addressed by; the list exists to let someone recognise a credential and
|
|
846
|
+
point at it, which the label, the device type and the last-used moment do.
|
|
847
|
+
- **`deviceType` is `singleDevice` or `multiDevice`**, and `backedUp` says whether a
|
|
848
|
+
multi-device credential actually has been. "This one only exists on that phone" is what the
|
|
849
|
+
owner needs before revoking the other entry.
|
|
850
|
+
- **Revocation is soft, and the credential id stays reserved for good.** A credential someone
|
|
851
|
+
cut off can never be enrolled again — not on another account, and not on the same one
|
|
852
|
+
(`PasskeyAlreadyRegisteredError`, 409). Re-enrolling means a fresh credential.
|
|
853
|
+
- **A passkey id you do not own answers 404.** Every lookup is owner-scoped, so the answer is
|
|
854
|
+
only ever "not yours".
|
|
855
|
+
- **Renaming has no recent-authentication gate**: a label is display only and nothing is
|
|
856
|
+
authorized by it.
|
|
857
|
+
|
|
858
|
+
#### Recovery — read this before shipping a passkey-only sign-up
|
|
859
|
+
|
|
860
|
+
The ways back into an account are: a live passkey, a password, a linked social account, or a
|
|
861
|
+
verified email address — the last one because [Password reset](#password-reset-verified-email)
|
|
862
|
+
can always give such an account a password back. Nothing else; support cannot restore an
|
|
863
|
+
account that has none of the four.
|
|
864
|
+
|
|
865
|
+
That is why **revoking the last live passkey is refused (409, `code:
|
|
866
|
+
'LAST_RECOVERY_CREDENTIAL'`) when the account has no password, no linked social account and no
|
|
867
|
+
verified email.** A phone-only account is the case that reaches it. The refusal is not
|
|
868
|
+
paternalism; it is the absence of an undo. Branch on that code to offer "set a password
|
|
869
|
+
first", "link an account first", or "confirm your email address first".
|
|
870
|
+
|
|
871
|
+
The same fact should shape your sign-up: an account created without a password, without an
|
|
872
|
+
email and given one passkey has exactly one way in, and losing the device loses the account.
|
|
873
|
+
Ask for a password, an address, or a social link before, or shortly after, the passkey.
|
|
874
|
+
|
|
875
|
+
#### How the ceremonies are kept honest
|
|
876
|
+
|
|
877
|
+
- **Discoverable credentials only** (`residentKey: 'required'`). `login/options` takes an empty
|
|
878
|
+
body — `additionalProperties: false`, so an `email` field is a 400 rather than something
|
|
879
|
+
quietly ignored — and always answers with an empty `allowCredentials`. There is no input
|
|
880
|
+
that could make its answer differ by whether an account exists.
|
|
881
|
+
- **A revoked credential and one that was never here answer identically** on `login/verify`.
|
|
882
|
+
Anything else would say whether this account once had it.
|
|
883
|
+
- **Challenges are one-time database rows**, spent by a single conditional `UPDATE`. Two
|
|
884
|
+
verifies arriving with the same challenge produce one winner and one refusal, across
|
|
885
|
+
instances. A challenge is bound to its ceremony (`registration` / `authentication`) and, for
|
|
886
|
+
enrollment, to the account that minted it.
|
|
887
|
+
- **A refusal leaves the challenge live.** Spending happens inside the transaction that writes
|
|
888
|
+
what it authorizes, so a failure rolls it back and the ceremony is retryable; only a success
|
|
889
|
+
is unrepeatable.
|
|
890
|
+
- **A signature counter that goes backwards refuses the sign-in and leaves the row alone.** It
|
|
891
|
+
is the signal a cloned authenticator would produce — but a synced passkey reports 0 forever
|
|
892
|
+
and a restored device can hit it, so auto-revoking would lock people out on a false
|
|
893
|
+
positive. The refusal is logged at `warn` with the passkey id; a human decides what it meant.
|
|
894
|
+
- **Attestation is `none`.** Verifying an attestation statement would tell us which
|
|
895
|
+
authenticator model was used and nothing about who is holding it.
|
|
896
|
+
|
|
897
|
+
#### Errors
|
|
898
|
+
|
|
899
|
+
| error | status | `code` | when |
|
|
900
|
+
|-------|--------|--------|------|
|
|
901
|
+
| `PasskeyChallengeError` | 401 | — | the challenge is unknown, expired, already spent, of the other ceremony, or of another account |
|
|
902
|
+
| `PasskeyVerificationError` | 401 | — | origin, rpId, signature or counter — and, on sign-in, an unknown or revoked credential |
|
|
903
|
+
| `PasskeyNotFoundError` | 404 | — | a passkey the caller does not own, or one already revoked |
|
|
904
|
+
| `PasskeyAlreadyRegisteredError` | 409 | — | that credential is on file for some account, revoked ones included |
|
|
905
|
+
| `RecentAuthenticationRequiredError` | 403 | `RECENT_AUTH_REQUIRED` | the session proved itself too long ago and carried no password |
|
|
906
|
+
| `LastRecoveryCredentialError` | 409 | `LAST_RECOVERY_CREDENTIAL` | revoking it would leave no way back in |
|
|
907
|
+
| `PasskeyConfigError` | boot | — | an origin off the rpId or not https, or an unsupported user-verification value |
|
|
908
|
+
|
|
909
|
+
#### Events
|
|
910
|
+
|
|
911
|
+
`passkeyEnrolledEvent` (`auth.passkey.enrolled`: `userId`, `passkeyId`, `label?`) and
|
|
912
|
+
`passkeyRevokedEvent` (`auth.passkey.revoked`: `userId`, `passkeyId`, `reason`) fire after
|
|
913
|
+
commit. `authLoginEvent.provider` gains `'passkey'`. Subscribe to the first to tell the owner
|
|
914
|
+
a new way into their account appeared — which is what it is.
|
|
915
|
+
|
|
916
|
+
#### The case table
|
|
917
|
+
|
|
918
|
+
The behaviour above is asserted row by row in
|
|
919
|
+
`src/__tests__/integration/passkeys.test.ts`; each `it` is named for its row.
|
|
920
|
+
|
|
921
|
+
| row | situation | outcome |
|
|
922
|
+
|-----|-----------|---------|
|
|
923
|
+
| E1 | fresh session, no passkeys | 200, empty `excludeCredentials` |
|
|
924
|
+
| E2 | session key 11 min old, no password | 403 `RECENT_AUTH_REQUIRED` |
|
|
925
|
+
| E3 / E4 | 11 min old, correct / wrong password | 200 / 403 — byte-identical to E2 |
|
|
926
|
+
| E5 | no password on the account, 11 min old | 403; a password can never speak for it |
|
|
927
|
+
| E6 | valid attestation | 200; row written, challenge spent, event emitted |
|
|
928
|
+
| E7 / E8 | challenge replayed / expired | 401; one row, no row |
|
|
929
|
+
| E9 / E10 | another account's / the other ceremony's challenge | 401 |
|
|
930
|
+
| E11 | credential already on some account | 409 |
|
|
931
|
+
| E12 / E13 | wrong origin / wrong rpId | 401 |
|
|
932
|
+
| E14 | two live passkeys | both listed in `excludeCredentials` |
|
|
933
|
+
| E15 | label empty or over 64 chars | 400; challenge stays live |
|
|
934
|
+
| E16 | two concurrent verifies, one challenge | one 200, one 401, one row |
|
|
935
|
+
| L1 / L2 | empty options body / an `email` in it | 200 with empty `allowCredentials` / 400 |
|
|
936
|
+
| L3 | valid assertion | 200, same answer as `login`; counter and device key move |
|
|
937
|
+
| L4 / L5 | revoked / unknown credential | 401, byte-identical |
|
|
938
|
+
| L6 / L7 / L8 | challenge spent / expired / wrong kind | 401; no device key |
|
|
939
|
+
| L9 / L17 | bad signature / wrong origin | 401; counter unmoved |
|
|
940
|
+
| L10 | counter went backwards | 401; row untouched, warn logged, not revoked |
|
|
941
|
+
| L11 | synced passkey reporting 0 both times | 200 |
|
|
942
|
+
| L12 / L13 | disabled / pending deletion | 403, the same errors password login gives |
|
|
943
|
+
| L14 | device-key fields missing (proxy bypassed) | 400; challenge stays live |
|
|
944
|
+
| L15 | an old session key named in the body | it is revoked as the new one is registered |
|
|
945
|
+
| L16 | two concurrent verifies, one assertion | one 200, one 401, one device key |
|
|
946
|
+
| M1 | 2 live + 1 revoked | 2 entries, no credential id, no public key |
|
|
947
|
+
| M2 / M9 | someone else's / an already revoked passkey | 404 |
|
|
948
|
+
| M3 / M4 | rename / revoke on a recent session | 200; revoke emits its event |
|
|
949
|
+
| M5 | revoke on an 11-minute-old session | 403 `RECENT_AUTH_REQUIRED` |
|
|
950
|
+
| M6 / M7 / M8 | last passkey, no password: alone / with a social account / with a second passkey | 409 / 200 / 200 |
|
|
951
|
+
| M10 | re-enrolling a revoked credential | 409 |
|
|
952
|
+
| K1 / K4 | two concurrent revokes: 2 passkeys and nothing else / 1 passkey and a password | 200 + 409 / 200 + 404 |
|
|
953
|
+
| K2 / K3 | last passkey, no password: with a verified email / phone-only | 200 / 409 |
|
|
954
|
+
|
|
955
|
+
Configuration rows C1–C6 are in `src/__tests__/unit/passkey-config.test.ts`.
|
|
956
|
+
|
|
281
957
|
### Writing protected routes (route DSL)
|
|
282
958
|
|
|
283
959
|
This is the current SPFN route DSL — `route.<method>().input().use().skip().handler()` registered
|
|
@@ -360,6 +1036,16 @@ Both convenience URL APIs seal `metadata` into the encrypted OAuth state. On a n
|
|
|
360
1036
|
signup, the callback passes it to `beforeRegister` and `authRegisterEvent`; existing-account
|
|
361
1037
|
logins do not run the registration hook.
|
|
362
1038
|
|
|
1039
|
+
`returnUrl` must be a path inside your app — absolute URLs, `//host`, `..`, a backslash, and a
|
|
1040
|
+
tab/CR/LF (which a URL parser strips, turning `/<tab>/host` into `//host`) are refused, so a
|
|
1041
|
+
real login cannot become an open redirect. The start seams answer an unsafe value with a 400
|
|
1042
|
+
`ValidationError`; the seams that already hold a logged-in user replace the destination instead
|
|
1043
|
+
of failing the login — `OAuthCallback` navigates to `/` and `createOAuthCallbackHandler`
|
|
1044
|
+
redirects to its `defaultRedirectUrl` (`/` unless you pass one). The rule is exported as
|
|
1045
|
+
`isSafeReturnPath` from `@spfn/auth/server`, `@spfn/auth/nextjs/server`, and
|
|
1046
|
+
`@spfn/auth/nextjs/client` for apps that validate a destination before calling
|
|
1047
|
+
`getGoogleOAuthUrl`.
|
|
1048
|
+
|
|
363
1049
|
Built-in OAuth routes: `POST /_auth/oauth/google/url`, `GET /_auth/oauth/google` (redirect),
|
|
364
1050
|
`GET /_auth/oauth/google/callback`, `POST /_auth/oauth/finalize`, `GET /_auth/oauth/providers`,
|
|
365
1051
|
plus the provider-generic `POST /_auth/oauth/start`. `getGoogleAccessToken(userId)` returns a
|
|
@@ -446,9 +1132,25 @@ process), which differs from the API process in a split deployment — the callb
|
|
|
446
1132
|
matches every `spfn_oauth_csrf*` cookie candidate against the state nonce, so no PORT
|
|
447
1133
|
coordination is needed.
|
|
448
1134
|
|
|
1135
|
+
An explicit `SPFN_AUTH_<PROVIDER>_REDIRECT_URI` is checked when the server boots, because the
|
|
1136
|
+
value used to be read lazily on the first OAuth request and a wrong one surfaced much later as
|
|
1137
|
+
a CSRF refusal nobody traced back to it. A value that does not parse, or whose origin is not the
|
|
1138
|
+
web app origin, or whose path is not `/_auth/oauth/<provider>/callback`, refuses to start — one
|
|
1139
|
+
error naming every offending variable:
|
|
1140
|
+
|
|
1141
|
+
```
|
|
1142
|
+
SPFN_AUTH_GOOGLE_REDIRECT_URI must be on the web app origin (http://localhost:3790) at
|
|
1143
|
+
/_auth/oauth/google/callback: the callback's CSRF cookie is host-only and /_auth/* is forwarded
|
|
1144
|
+
to the API by the app's rewrite. Unset it to use the default, fix the origin, or set
|
|
1145
|
+
SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK=off for a deployment that deliberately terminates the
|
|
1146
|
+
callback elsewhere.
|
|
1147
|
+
```
|
|
1148
|
+
|
|
449
1149
|
One caveat: the direct `POST /_auth/oauth/start` flow (no Next.js interceptor) sets its CSRF
|
|
450
|
-
cookie on the **API host**. If you use that flow in a split deployment, set
|
|
451
|
-
|
|
1150
|
+
cookie on the **API host**. If you use that flow in a split deployment, set the corresponding
|
|
1151
|
+
provider redirect URI explicitly to the API host callback **and**
|
|
1152
|
+
`SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK=off` — that is the one deployment the check is wrong
|
|
1153
|
+
about, and `off` is the only value that disables it.
|
|
452
1154
|
|
|
453
1155
|
### Native social sign-in (mobile / web id_token)
|
|
454
1156
|
|
|
@@ -696,6 +1398,155 @@ export default async function AdminPage()
|
|
|
696
1398
|
Also exported: `getAuthSessionData`, `getUserRole`, `getUserPermissions`, `hasAnyRole`,
|
|
697
1399
|
`hasAnyPermission`, the OAuth pending-session helpers, and `createOAuthCallbackHandler`.
|
|
698
1400
|
|
|
1401
|
+
### Emptying the cookie jar from a route handler or middleware
|
|
1402
|
+
|
|
1403
|
+
`clearSession()` works where `next/headers` is writable. The page that answers *the API
|
|
1404
|
+
refused your session* is usually a route handler or middleware holding a `NextResponse`
|
|
1405
|
+
instead — `clearSessionCookies(response)` expires the session, key-id, OAuth-pending and
|
|
1406
|
+
CSRF cookies on it and returns the same response, so the call chains:
|
|
1407
|
+
|
|
1408
|
+
```typescript
|
|
1409
|
+
import { clearSessionCookies } from '@spfn/auth/nextjs/server';
|
|
1410
|
+
|
|
1411
|
+
export function GET(request: NextRequest)
|
|
1412
|
+
{
|
|
1413
|
+
return clearSessionCookies(NextResponse.redirect(new URL('/login', request.url)));
|
|
1414
|
+
}
|
|
1415
|
+
```
|
|
1416
|
+
|
|
1417
|
+
Never spell the names in your app. They carry an `SPFN_PORT` suffix (`spfn_session_4001`),
|
|
1418
|
+
so two dev instances do not overwrite each other's cookies, and a hand-written copy of that
|
|
1419
|
+
rule clears the wrong cookie without failing. Read them from `sessionCookieNames()`, which
|
|
1420
|
+
returns `{ session, keyId, oauthPending, csrf }` at call time.
|
|
1421
|
+
|
|
1422
|
+
## CSRF protection
|
|
1423
|
+
|
|
1424
|
+
Cookie-authenticated mutations carry a CSRF token by default. Nothing to write: the
|
|
1425
|
+
Next.js proxy issues the token with the session and the api client sends it back.
|
|
1426
|
+
|
|
1427
|
+
**What it protects.** The session cookie is `SameSite=Lax`, which already blocks the
|
|
1428
|
+
classic cross-site form POST. What remains is what Lax does not cover: a sibling
|
|
1429
|
+
subdomain that can write cookies on your parent domain (an XSS on `blog.example.com`
|
|
1430
|
+
against `app.example.com`), browsers that predate or mis-implement Lax, and a domain
|
|
1431
|
+
layout that drifts into `SameSite=None` later. This closes those.
|
|
1432
|
+
|
|
1433
|
+
**What it does not protect.** Nothing here helps against XSS on your own origin.
|
|
1434
|
+
Script running on your origin can read the token cookie and call your API as the
|
|
1435
|
+
user — that is true of every CSRF scheme, and no token design changes it. Same-origin
|
|
1436
|
+
XSS is out of scope; Content-Security-Policy and output escaping are the answer to it.
|
|
1437
|
+
|
|
1438
|
+
### How it works
|
|
1439
|
+
|
|
1440
|
+
- On login, OAuth finalize, key rotation and every session renewal, the proxy sets
|
|
1441
|
+
`spfn_csrf` — a readable (non-HttpOnly) cookie holding only an HMAC of the session's
|
|
1442
|
+
key id, keyed by a subkey derived from `SPFN_AUTH_SESSION_SECRET`. No new variable,
|
|
1443
|
+
and the raw session secret is never used as the token key. Sessions that predate
|
|
1444
|
+
the feature get one on their first authenticated response, so upgrading does not
|
|
1445
|
+
require anyone to sign in again.
|
|
1446
|
+
- The api client mirrors the cookie into the `x-spfn-csrf` header on **every** RPC call,
|
|
1447
|
+
GET-shaped ones included — see "Which requests are checked" for why it cannot narrow
|
|
1448
|
+
that itself. Where the header is *checked* is the proxy's decision, not the client's.
|
|
1449
|
+
- The proxy **recomputes** the expected value from the session it just unsealed and
|
|
1450
|
+
compares it to the header, in constant time. It never compares the cookie to the
|
|
1451
|
+
header — that is the classic double-submit weakness, and it is exactly what a
|
|
1452
|
+
sibling subdomain defeats by tossing a cookie it chose. A tossed cookie fails here.
|
|
1453
|
+
- The token derives from the session key id, so rotating the key invalidates it. The
|
|
1454
|
+
proxy reissues the cookie in the same response that rotates or renews the session.
|
|
1455
|
+
|
|
1456
|
+
The check runs in the proxy, not the backend, because only the proxy knows the
|
|
1457
|
+
request's credential was ambient: it turns the session cookie into a short-lived
|
|
1458
|
+
bearer JWT, so the backend sees `scheme:'bearer'` for cookie callers and for genuine
|
|
1459
|
+
bearer clients alike.
|
|
1460
|
+
|
|
1461
|
+
### Which requests are checked
|
|
1462
|
+
|
|
1463
|
+
Only requests the proxy authenticates from the session cookie, and only when the
|
|
1464
|
+
resolved **route** method is not GET/HEAD/OPTIONS.
|
|
1465
|
+
|
|
1466
|
+
Route method, not the method the browser used to reach the proxy. The api client picks
|
|
1467
|
+
its wire method from whether the input has a body, and holds no route map — that is the
|
|
1468
|
+
point of "no metadata codegen required" — so a mutation with nothing to send travels as
|
|
1469
|
+
GET. `logout` is `POST /_auth/logout`; `revokeOpsToken` is
|
|
1470
|
+
`DELETE /_auth/ops-tokens/:id`, called with only a path param. Both are `GET` on the
|
|
1471
|
+
wire and both are forwarded as the route's real method. A client that withheld the
|
|
1472
|
+
header on GET-shaped calls would therefore 403 them under `enforce`, which is why the
|
|
1473
|
+
contract is "every call carries it" and the proxy alone decides where it is checked.
|
|
1474
|
+
Gating in the proxy on the wire method would be worse still: a cross-site top-level GET
|
|
1475
|
+
navigation *does* carry a `SameSite=Lax` cookie, so every mutation would stay reachable
|
|
1476
|
+
that way.
|
|
1477
|
+
|
|
1478
|
+
Untouched, by construction: requests with no session, direct-to-backend bearer
|
|
1479
|
+
clients, `clientProofV1` mobile callers, machine and ops tokens. None of them pass
|
|
1480
|
+
through this code. A request without a session is answered exactly as before (the
|
|
1481
|
+
backend returns 401) — a CSRF refusal only ever answers an authenticated request, so
|
|
1482
|
+
the refusal itself cannot tell an anonymous caller whether anyone is signed in.
|
|
1483
|
+
|
|
1484
|
+
### Modes
|
|
1485
|
+
|
|
1486
|
+
| Mode | Behaviour |
|
|
1487
|
+
|---|---|
|
|
1488
|
+
| `off` | No check. |
|
|
1489
|
+
| `warn` | **Default.** Allows the request, logs one line per request that would be refused. |
|
|
1490
|
+
| `enforce` | Refuses with `403 {"error":"Forbidden","message":"CSRF token missing or invalid"}`. |
|
|
1491
|
+
|
|
1492
|
+
Existing apps get signal before breakage: unset means `warn`. Watch for
|
|
1493
|
+
`@spfn/auth:interceptor:csrf` lines, then switch on. Apps scaffolded by `spfn init`
|
|
1494
|
+
start at `enforce`.
|
|
1495
|
+
|
|
1496
|
+
```bash
|
|
1497
|
+
# .env.local — read by the Next.js process, where the proxy runs
|
|
1498
|
+
SPFN_AUTH_CSRF=enforce
|
|
1499
|
+
```
|
|
1500
|
+
|
|
1501
|
+
```typescript
|
|
1502
|
+
import { configureAuth } from '@spfn/auth/server';
|
|
1503
|
+
|
|
1504
|
+
configureAuth({
|
|
1505
|
+
csrf: {
|
|
1506
|
+
mode: 'enforce',
|
|
1507
|
+
// Exact backend route paths, params already substituted — not /api/rpc/… URLs.
|
|
1508
|
+
// For endpoints a browser session never calls, e.g. webhook receivers that
|
|
1509
|
+
// authenticate themselves by signature. An exempt path is unprotected for
|
|
1510
|
+
// cookie callers too, so list only endpoints that carry their own auth.
|
|
1511
|
+
exemptPaths: ['/webhooks/stripe'],
|
|
1512
|
+
},
|
|
1513
|
+
});
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
`configureAuth` wins over the environment variable. `enforce` and `warn` both need
|
|
1517
|
+
`SPFN_AUTH_SESSION_SECRET` — sessions need it anyway — and refuse rather than quietly
|
|
1518
|
+
passing everything if it is missing.
|
|
1519
|
+
|
|
1520
|
+
### If a request is refused
|
|
1521
|
+
|
|
1522
|
+
A refusal in a running app almost always means the token cookie is gone or stale while
|
|
1523
|
+
the session is not — cleared by hand or by an extension, or a session that predates this
|
|
1524
|
+
feature. Rotation is not a cause: the response that rotates the key reissues the cookie
|
|
1525
|
+
in the same breath, and one browser has one jar, so other tabs pick the new value up
|
|
1526
|
+
with it.
|
|
1527
|
+
|
|
1528
|
+
Two things repair it, and both are mechanical:
|
|
1529
|
+
|
|
1530
|
+
- **The 403 carries the fix.** The proxy is the one emitting the refusal, so it sets a
|
|
1531
|
+
fresh `spfn_csrf` on that very response. A browser that repeats the mutation succeeds.
|
|
1532
|
+
The refusal is otherwise unchanged — same status, same body.
|
|
1533
|
+
- **Any authenticated response reissues a wrong one.** A response whose request arrived
|
|
1534
|
+
with no CSRF cookie, or with one that no longer matches the session, queues the
|
|
1535
|
+
correct value. A cookie that is merely *present* is not taken as proof it is right.
|
|
1536
|
+
|
|
1537
|
+
**The client does not retry a refused call**, so a user sees one failure before the
|
|
1538
|
+
repaired state takes effect — the framework fixes the browser, not the click.
|
|
1539
|
+
|
|
1540
|
+
**Limitation — calls made from the server.** A Server Component cannot set cookies at
|
|
1541
|
+
all, and Next.js does not forward `Set-Cookie` from a fetch the api client made on the
|
|
1542
|
+
server to the browser. So neither repair reaches the jar when the refused call came from
|
|
1543
|
+
a Server Component, a Server Action or a Route Handler; the next browser-originated
|
|
1544
|
+
request through the proxy is what heals it. Server-side callers otherwise need no
|
|
1545
|
+
change: the api client reads the whole jar through `next/headers`, and an explicit
|
|
1546
|
+
`cookies` option merges over that rather than replacing it. Only a caller that
|
|
1547
|
+
hand-builds a jar somewhere `cookies()` cannot be reached — build time, static
|
|
1548
|
+
generation — has to include the CSRF cookie itself.
|
|
1549
|
+
|
|
699
1550
|
## How do I define roles and permissions?
|
|
700
1551
|
|
|
701
1552
|
Built-in roles: `superadmin` (priority 100), `admin` (80), `user` (10). Built-in permissions:
|
|
@@ -780,7 +1631,7 @@ control, not authorization.
|
|
|
780
1631
|
analytics, onboarding, etc. Client-supplied `metadata` on register/OAuth flows is forwarded verbatim.
|
|
781
1632
|
|
|
782
1633
|
```typescript
|
|
783
|
-
import { authLoginEvent, authRegisterEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
|
|
1634
|
+
import { authLoginEvent, authRegisterEvent, authDeviceRegisteredEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
|
|
784
1635
|
|
|
785
1636
|
authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
|
|
786
1637
|
{
|
|
@@ -788,10 +1639,44 @@ authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
|
|
|
788
1639
|
});
|
|
789
1640
|
```
|
|
790
1641
|
|
|
791
|
-
|
|
1642
|
+
`authLoginEvent`'s `provider` is `'email'`, `'phone'`, a social provider, `'device'` or
|
|
1643
|
+
`'passkey'`. `'device'` is a [device-code login](#device-code-login), where the account was
|
|
1644
|
+
proven on another device that was already signed in and no credential was presented here;
|
|
1645
|
+
`'passkey'` is a [WebAuthn assertion](#passkeys-webauthn). `authRegisterEvent` accepts
|
|
1646
|
+
neither: a device-code request can only ever be approved by an account that already exists,
|
|
1647
|
+
and a passkey has to be enrolled from a session that already exists, so neither is a signup.
|
|
1648
|
+
|
|
1649
|
+
`passkeyEnrolledEvent` (`auth.passkey.enrolled`) and `passkeyRevokedEvent`
|
|
1650
|
+
(`auth.passkey.revoked`) fire after commit when a passkey is added or retired.
|
|
1651
|
+
|
|
1652
|
+
`authPasswordResetEvent` (`auth.password.reset`: `userId`, `email`) fires after commit when a
|
|
1653
|
+
[password reset](#password-reset-verified-email) completes. Distinct from a password
|
|
1654
|
+
*change*, which is made from a session that already proved itself: this one is made by
|
|
1655
|
+
whoever opened a link in a mailbox, so it is the notice to send the owner.
|
|
1656
|
+
|
|
1657
|
+
`authDeviceRegisteredEvent` (`auth.device.registered`) fires after commit whenever a device key is
|
|
1658
|
+
registered on an account, on every channel that registers one — `channel` says which: `register`,
|
|
1659
|
+
`signup-link`, `invitation`, `password`, `oauth`, `oauth-native`, `device-code`, `password-reset`
|
|
1660
|
+
or `passkey`. It carries `userId`, `keyId`, `algorithm`, a 12-character `fingerprintPrefix`,
|
|
1661
|
+
`createdAtMillis`, and whatever the registration knew about the device: `deviceName?`, `platform?`,
|
|
1662
|
+
`ip?` and `userAgent?` — the web OAuth callback has neither label, because the sealed state does
|
|
1663
|
+
not carry them. Subscribe to tell the owner a device was added: a login event says a session began
|
|
1664
|
+
and not what it began on, so a stolen password used on a new machine was silent until this event.
|
|
1665
|
+
Send it with a [sign-out-everywhere link](#the-sign-out-everywhere-link), which is the action the
|
|
1666
|
+
notice should offer.
|
|
1667
|
+
|
|
1668
|
+
Key **rotation** is deliberately not announced — replacing the key of a device that is already
|
|
1669
|
+
signed in is not a new device, and a notice for it would teach the owner to ignore the ones that
|
|
1670
|
+
matter. A login that names an `oldKeyId` is only a rotation when that key was actually revoked: an
|
|
1671
|
+
`oldKeyId` naming somebody else's key, an already-revoked one or nothing at all registers a new
|
|
1672
|
+
device and fires the event. `ip` and `userAgent` are unauthenticated and display-only.
|
|
1673
|
+
|
|
1674
|
+
Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `AuthPasswordResetPayload`,
|
|
1675
|
+
`AuthDeviceRegisteredPayload`, `InvitationCreatedPayload`,
|
|
792
1676
|
`InvitationAcceptedPayload`, `AuthDeletionRequestedPayload`, `AuthDeletionCancelledPayload`,
|
|
793
1677
|
`AuthDeletionCompletedPayload`, `OAuthUnlinkedPayload` (`auth.oauth.unlinked` — provider-side
|
|
794
|
-
disconnect, see the OAuth unlink-notify section)
|
|
1678
|
+
disconnect, see the OAuth unlink-notify section), `PasskeyEnrolledPayload`,
|
|
1679
|
+
`PasskeyRevokedPayload`. These events also bind to `@spfn/core/job`
|
|
795
1680
|
jobs via `.on(event)`.
|
|
796
1681
|
|
|
797
1682
|
## Registration gate (`beforeRegister`)
|
|
@@ -826,6 +1711,8 @@ Notes:
|
|
|
826
1711
|
seeding in `initializeAuth()`.
|
|
827
1712
|
- OAuth signups have no client-typed fields unless you pass `metadata` at OAuth start — decide
|
|
828
1713
|
per channel (reject, or allow and collect during onboarding).
|
|
1714
|
+
- `email` arrives trimmed and lower-cased, the same form the account is stored under, so a
|
|
1715
|
+
denylist or domain allowlist keyed on the address is not walked past by capitalizing it.
|
|
829
1716
|
- On the `oauth` channel `email` is the provider-reported address and may be **unverified**
|
|
830
1717
|
(the created account then stores `email` as `null`). The context carries
|
|
831
1718
|
`emailVerified` — an email-based allow/block policy must check it before trusting `email`.
|
|
@@ -860,6 +1747,13 @@ export const opsRouter = createOpsRouter({
|
|
|
860
1747
|
}, { auth: opsTokenAuth });
|
|
861
1748
|
```
|
|
862
1749
|
|
|
1750
|
+
An application that admits both credentials on one route has to tell an ops token from a
|
|
1751
|
+
session JWT *before* either is verified. Do not re-type the literal: `isOpsToken(bearer)`
|
|
1752
|
+
and the `OPS_TOKEN_PREFIX` it tests against are both exported from `@spfn/auth/server`, so
|
|
1753
|
+
the shape has one definition and a copy in application code cannot drift from it.
|
|
1754
|
+
`isOpsToken` answers shape only — it takes a raw header value, returns `false` for a
|
|
1755
|
+
missing or non-string one, and leaves unknown/revoked/expired to verification.
|
|
1756
|
+
|
|
863
1757
|
`opsRoute` comes from `@spfn/core` **0.3.0-beta.2** onwards; before that release an ops
|
|
864
1758
|
route spelled its own `/_ops/` prefix with `route`.
|
|
865
1759
|
|
|
@@ -903,6 +1797,81 @@ same 401, so whether a presented secret ever existed is not inferable. A valid t
|
|
|
903
1797
|
missing a route's scope answers 403 naming only the missing scope. `'*'` grants every
|
|
904
1798
|
scope.
|
|
905
1799
|
|
|
1800
|
+
### One route, two credentials (`opsOrUser`)
|
|
1801
|
+
|
|
1802
|
+
An operator action is scripted today — the CLI, holding an ops token — and driven from an
|
|
1803
|
+
admin console tomorrow, a browser holding a user session. That is one route with two
|
|
1804
|
+
admissible credentials, and neither middleware admits both: `authenticate` refuses an
|
|
1805
|
+
`spfn_ops_` bearer before any scope guard runs (see [Machine principals](#machine-principals-registermachineverifier)),
|
|
1806
|
+
and `opsTokenAuth` admits nothing else.
|
|
1807
|
+
|
|
1808
|
+
```typescript
|
|
1809
|
+
import { opsOrUser, getAuth, getOpsToken } from '@spfn/auth/server';
|
|
1810
|
+
|
|
1811
|
+
export const exportSignups = route.get('/admin/signups/export')
|
|
1812
|
+
.use([opsOrUser({ opsScopes: ['waitlist:read'], permissions: ['admin.waitlist'] })])
|
|
1813
|
+
// or by role: opsOrUser({ opsScopes: ['waitlist:read'], roles: ['admin'] })
|
|
1814
|
+
// or both (AND): opsOrUser({ opsScopes: ['waitlist:read'], roles: ['admin'], permissions: ['admin.waitlist'] })
|
|
1815
|
+
.handler(async (c) =>
|
|
1816
|
+
{
|
|
1817
|
+
// exactly one of these is set
|
|
1818
|
+
const ops = getOpsToken(c.raw); // the ops branch
|
|
1819
|
+
const user = getAuth(c.raw); // the session branch
|
|
1820
|
+
});
|
|
1821
|
+
```
|
|
1822
|
+
|
|
1823
|
+
**The branch is chosen by credential shape, never by caller choice.** The raw
|
|
1824
|
+
`Authorization` bearer is tested with [`isOpsToken`](#ops-tokens-spfn-ops); a match runs
|
|
1825
|
+
`opsTokenAuth` then `requireOpsScope(...opsScopes)`, and everything else — a user JWT,
|
|
1826
|
+
another machine namespace, a malformed header, no header — runs `authenticate` then the
|
|
1827
|
+
session guards. Nothing in the request selects a branch except the credential it presents,
|
|
1828
|
+
so a caller cannot ask for the weaker check.
|
|
1829
|
+
|
|
1830
|
+
**`roles` and `permissions` are AND, roles first.** Two lists only ever narrow. An OR would
|
|
1831
|
+
mean that adding one role voids the whole permission list, which is the opposite of what a
|
|
1832
|
+
reader of the two lists expects. Roles run first because the role is already on the auth
|
|
1833
|
+
context while permissions cost a lookup — so a caller with the wrong role is refused for the
|
|
1834
|
+
wrong role. Giving neither list is a definition-time error, as is an empty `opsScopes`: a
|
|
1835
|
+
configuration that would admit a credential unchecked fails at boot, not on a request.
|
|
1836
|
+
|
|
1837
|
+
**No implicit admin bypass.** Permissions match by name only, and the ops branch has no role
|
|
1838
|
+
concept, so neither branch has a principal that passes by virtue of being an administrator.
|
|
1839
|
+
A refusal is the selected branch's own refusal, with that branch's existing status and
|
|
1840
|
+
message — no error class and no wire message is introduced here.
|
|
1841
|
+
|
|
1842
|
+
`opsOrUser` carries `skips: ['auth']`, so a route using it auto-skips the server-level
|
|
1843
|
+
`auth` middleware exactly as `optionalAuth` and `opsTokenAuth` do. No `.skip(['auth'])` by
|
|
1844
|
+
hand.
|
|
1845
|
+
|
|
1846
|
+
**Cookies.** The backend never reads them. A browser session reaches a route as a Bearer
|
|
1847
|
+
token because `@spfn/auth/nextjs/api` forwards it as one, so through the app a console
|
|
1848
|
+
request is the session rows below; a request carrying only a `Cookie` header is an
|
|
1849
|
+
unauthenticated request here.
|
|
1850
|
+
|
|
1851
|
+
| bearer | branch | answer |
|
|
1852
|
+
|---|---|---|
|
|
1853
|
+
| `spfn_ops_…` valid, scope present (or `*`) | ops | 200; `getOpsToken` set, `getAuth` null |
|
|
1854
|
+
| `spfn_ops_…` valid, scope missing | ops | 403 `Ops token lacks scope` |
|
|
1855
|
+
| `spfn_ops_…` unknown / revoked / expired | ops | 401 `Invalid ops token` (one message for all three) |
|
|
1856
|
+
| `spfn_ops_` prefix alone | ops | 401 `Invalid ops token` |
|
|
1857
|
+
| user JWT valid, permission held | user | 200; `getAuth` set, `getOpsToken` null |
|
|
1858
|
+
| user JWT valid, permission missing | user | 403 `InsufficientPermissionsError` |
|
|
1859
|
+
| user JWT expired / bad signature | user | 401 (the existing `authenticate` message) |
|
|
1860
|
+
| token in a *registered* machine namespace, not ops | user | 401 — the user path admits no machine credential |
|
|
1861
|
+
| malformed bearer / no `Authorization` | user | 401 |
|
|
1862
|
+
| session cookie only, no bearer | user | 401 — see Cookies above |
|
|
1863
|
+
| `x-spfn-auth-profile` + user JWT | user | `PROFILE_REJECTED` (existing `authenticate` behaviour) |
|
|
1864
|
+
| `x-spfn-auth-profile` + ops token | ops | header ignored; `opsTokenAuth` reads `Authorization` only |
|
|
1865
|
+
| ops token on a plain `authenticate` route | — | 401, unchanged |
|
|
1866
|
+
| `opsScopes: []`, or neither `roles` nor `permissions` | — | throws at definition |
|
|
1867
|
+
| server-level `auth` registered | — | auto-skipped on this route |
|
|
1868
|
+
| `roles: ['admin']` only; role admin | user | 200 |
|
|
1869
|
+
| `roles: ['admin']` only; role user | user | 403 `InsufficientRoleError` |
|
|
1870
|
+
| `roles` + `permissions`; role matches, permission missing | user | 403 `InsufficientPermissionsError` |
|
|
1871
|
+
| `roles` + `permissions`; permission held, role wrong | user | 403 `InsufficientRoleError` (role is checked first) |
|
|
1872
|
+
|
|
1873
|
+
`opsOrUser` is available from **0.3.0-beta.11**.
|
|
1874
|
+
|
|
906
1875
|
## Mobile clientProofV1 (`@spfn/auth/client-proof`)
|
|
907
1876
|
|
|
908
1877
|
Server side of the spfn-mobile native SDK auth profile (issue #46; asymmetric revision in
|
|
@@ -917,6 +1886,12 @@ the fixed-string contract error envelope (`PROOF_INVALID` · `PROOF_REPLAYED` ·
|
|
|
917
1886
|
`SESSION_REVOKED` · `PROFILE_REJECTED` · `CONTRACT_UNSUPPORTED` — SDKs classify by code, never
|
|
918
1887
|
HTTP status).
|
|
919
1888
|
|
|
1889
|
+
Before minting the first proof in each client process, the client calls the built-in
|
|
1890
|
+
`GET /_core/time` operation (`core.time`) and establishes its proof epoch from
|
|
1891
|
+
`serverTimeMillis`. This prerequisite is unproven and session-free. If the operation is
|
|
1892
|
+
unavailable or its response cannot be decoded, proof minting fails closed — there is no silent
|
|
1893
|
+
fallback to the device's unsynchronized wall clock.
|
|
1894
|
+
|
|
920
1895
|
- Wire headers (D23, ratified): `x-spfn-auth-profile`, `x-spfn-client-id`, `x-spfn-key-id`,
|
|
921
1896
|
`x-spfn-nonce`, `x-spfn-issued-at`, `x-spfn-proof`, `x-spfn-session`.
|
|
922
1897
|
- A request body must be **byte-canonical** — a body that parses but re-encodes differently is
|
|
@@ -944,6 +1919,29 @@ HTTP status).
|
|
|
944
1919
|
construction or through the `/control/register-key` hook; the private half never reaches
|
|
945
1920
|
the server. No persistence — a production enrollment/rotation story is phase 2.
|
|
946
1921
|
|
|
1922
|
+
### Clock synchronization and proof-time boundaries (contract 0.9.0)
|
|
1923
|
+
|
|
1924
|
+
`core.time` is imported from `@spfn/core` rather than restated by auth: operation ID, method,
|
|
1925
|
+
path, auth class, session requirement, and the closed `ServerTimeResponse` schema all come from
|
|
1926
|
+
the core route contract. The mobile contract records it as a bodyless GET prerequisite and
|
|
1927
|
+
requires one synchronization before the first proof minted in each process. It does not prescribe
|
|
1928
|
+
persistent offset storage, retry sleeps, or device-specific margins.
|
|
1929
|
+
|
|
1930
|
+
The server admission rule remains strict: `age = serverNow - issuedAtMillis` must satisfy
|
|
1931
|
+
`0 <= age <= 300000`. Synchronization does not widen the replay window or change nonce retention.
|
|
1932
|
+
A refused request still leaves its nonce unused; only admission spends it.
|
|
1933
|
+
|
|
1934
|
+
| `serverNow - issuedAtMillis` | Result |
|
|
1935
|
+
|---:|---|
|
|
1936
|
+
| `0` | accept |
|
|
1937
|
+
| `-1` (proof is 1 ms in the future) | `PROOF_EXPIRED` |
|
|
1938
|
+
| `300000` | accept |
|
|
1939
|
+
| `300001` | `PROOF_EXPIRED` |
|
|
1940
|
+
|
|
1941
|
+
When `core.time` cannot be read, the client must surface that synchronization failure and stop
|
|
1942
|
+
before sending a proof. Using `Date.now()` or a platform wall clock as an implicit fallback would
|
|
1943
|
+
reintroduce the skew failure this prerequisite closes.
|
|
1944
|
+
|
|
947
1945
|
### The contract version on the wire (contract 0.6.0)
|
|
948
1946
|
|
|
949
1947
|
A client compiled and shipped separately from the server cannot be fixed by redeploying. Until
|
|
@@ -992,6 +1990,8 @@ Every operation in the exported bundle carries `since` — the contract version
|
|
|
992
1990
|
| `auth.clientProof.handshake`, `echo.send`, `items.list` | 0.1.0 |
|
|
993
1991
|
| `auth.enroll.register`, `auth.enroll.login`, `auth.enroll.oauthNative`, `auth.keys.rotate` | 0.3.0 |
|
|
994
1992
|
| `auth.keys.list`, `auth.keys.revoke`, `auth.keys.revokeAll` | 0.4.1 |
|
|
1993
|
+
| `core.time` | 0.9.0 |
|
|
1994
|
+
| `auth.device.start`, `auth.device.poll`, `auth.device.info`, `auth.device.approve`, `auth.device.deny` | 0.10.0 |
|
|
995
1995
|
|
|
996
1996
|
- **This is history, not policy.** The mobile contract's compatibility policy is `allOrNothing`: one
|
|
997
1997
|
contract version passes or refuses the whole surface, so these three fields change no verdict here.
|
|
@@ -1063,6 +2063,386 @@ app.post('/v1/echo', createClientProofGuard(state), (c) => { /* handler */ });
|
|
|
1063
2063
|
Responses and errors MUST be canonical bytes with the contract envelope — build them with
|
|
1064
2064
|
`encodeCanonicalJson`/`ClientProofRefusal`, never `c.json()` (key order and int64 differ).
|
|
1065
2065
|
|
|
2066
|
+
## Custom auth profiles (`registerAuthProfile`)
|
|
2067
|
+
|
|
2068
|
+
`clientProofV1` is not a special case in the middleware — it is one entry in a registry
|
|
2069
|
+
`authenticate` and `optionalAuth` dispatch on. An app registers its own scheme the same way,
|
|
2070
|
+
without forking the middleware or wrapping it:
|
|
2071
|
+
|
|
2072
|
+
```typescript
|
|
2073
|
+
import { registerAuthProfile, type AuthContext } from '@spfn/auth/server';
|
|
2074
|
+
import { UnauthorizedError } from '@spfn/core/errors';
|
|
2075
|
+
|
|
2076
|
+
// At boot — server.config.ts, before the server starts taking requests.
|
|
2077
|
+
registerAuthProfile('serviceTokenV1', {
|
|
2078
|
+
verify: async (c): Promise<AuthContext> =>
|
|
2079
|
+
{
|
|
2080
|
+
const user = await findServiceAccount(c.req.header('x-acme-service-token'));
|
|
2081
|
+
if (user === null)
|
|
2082
|
+
{
|
|
2083
|
+
// A refusal leaves the verifier as a throw. It reaches the app's
|
|
2084
|
+
// error handler exactly as the Bearer path's does.
|
|
2085
|
+
throw new UnauthorizedError({ message: 'Invalid service token' });
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
return {
|
|
2089
|
+
user,
|
|
2090
|
+
userId: String(user.id),
|
|
2091
|
+
keyId: 'service-token',
|
|
2092
|
+
role: null,
|
|
2093
|
+
locale: 'en',
|
|
2094
|
+
scheme: 'serviceTokenV1',
|
|
2095
|
+
};
|
|
2096
|
+
},
|
|
2097
|
+
});
|
|
2098
|
+
```
|
|
2099
|
+
|
|
2100
|
+
A request naming the profile is then answered by that verifier:
|
|
2101
|
+
|
|
2102
|
+
```http
|
|
2103
|
+
POST /v1/reports
|
|
2104
|
+
x-spfn-auth-profile: serviceTokenV1
|
|
2105
|
+
x-acme-service-token: <the app's own credential>
|
|
2106
|
+
```
|
|
2107
|
+
|
|
2108
|
+
- **Register at boot, before the first request.** The registry is read on every dispatch, so a
|
|
2109
|
+
profile registered later is simply a profile the requests before it did not have. Registration
|
|
2110
|
+
is not frozen after startup — it is a contract, not a runtime check.
|
|
2111
|
+
- **A duplicate name throws**, `clientProofV1` included. Replacing a registered verifier silently
|
|
2112
|
+
is how an import order or a copied profile name swaps the code that decides who is admitted, so
|
|
2113
|
+
there is no override — and no unregistration API for the same reason.
|
|
2114
|
+
- **The verifier must expose a callable `verify`**, and what it resolves must carry a `userId` — a
|
|
2115
|
+
verifier that cannot admit anyone is refused at boot, and a resolve without a principal (`null`,
|
|
2116
|
+
the JS idiom for "no user") is refused as a throw rather than routed as authenticated.
|
|
2117
|
+
- **An unknown profile is still refused** (`PROFILE_REJECTED`, 400): registering one name does not
|
|
2118
|
+
open the header to others.
|
|
2119
|
+
- **Mixing is still refused.** A request carrying both `x-spfn-auth-profile` and `Authorization` is
|
|
2120
|
+
rejected before either path runs; a custom verifier never sees it.
|
|
2121
|
+
- **A verifier's throw propagates**, and only the internal clientProofV1 contract refusal is
|
|
2122
|
+
answered with the canonical envelope. Under `optionalAuth` too: credentials that were presented
|
|
2123
|
+
and refused are never downgraded to anonymous passage — only "presented nothing" continues
|
|
2124
|
+
without an auth context.
|
|
2125
|
+
- **`AuthContext.scheme` is an open union** — `'bearer' | 'clientProofV1' | 'oneTimeToken' | (string
|
|
2126
|
+
& {})`. The built-in names keep their autocomplete and a registered profile names its own scheme.
|
|
2127
|
+
The field stays informational: downstream permission and tenant code takes one principal shape and
|
|
2128
|
+
never branches on how it was produced.
|
|
2129
|
+
|
|
2130
|
+
## Authorization server for MCP clients
|
|
2131
|
+
|
|
2132
|
+
Let Claude Code and Codex connect to your app's `/mcp` endpoint as the user, over the flow
|
|
2133
|
+
they already speak: OAuth 2.1 with dynamic client registration and PKCE.
|
|
2134
|
+
|
|
2135
|
+
```console
|
|
2136
|
+
$ claude mcp add --transport http acme https://api.acme.com/mcp
|
|
2137
|
+
$ claude
|
|
2138
|
+
> /mcp
|
|
2139
|
+
```
|
|
2140
|
+
|
|
2141
|
+
Between those two lines the CLI discovers `/.well-known/oauth-authorization-server`, registers
|
|
2142
|
+
itself, opens a browser at your consent screen, catches the redirect on a loopback port, and
|
|
2143
|
+
exchanges the code for a token. Nobody pastes anything.
|
|
2144
|
+
|
|
2145
|
+
The feature is opt-in and the opt-in is one block:
|
|
2146
|
+
|
|
2147
|
+
```typescript
|
|
2148
|
+
createAuthLifecycle({
|
|
2149
|
+
authorizationServer: {
|
|
2150
|
+
scopes: {
|
|
2151
|
+
'mcp:read': 'Read your projects and tasks',
|
|
2152
|
+
'mcp:write': 'Create and edit your tasks',
|
|
2153
|
+
},
|
|
2154
|
+
defaultScopes: ['mcp:read'], // what a request with no `scope` asks for. default: all of them
|
|
2155
|
+
// issuer: 'https://api.acme.com', // default: SPFN_API_URL
|
|
2156
|
+
// authorizeUrl: 'https://acme.com/oauth/authorize', // default: {app url}/oauth/authorize
|
|
2157
|
+
// allowedRedirectOrigins: [], // https origins a client may register. loopback needs no entry
|
|
2158
|
+
// accessTokenTtlMs: 8 * 60 * 60 * 1000, // default 8 hours
|
|
2159
|
+
// refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000, // default 30 days
|
|
2160
|
+
// codeTtlMs: 60 * 1000, // default 60 seconds
|
|
2161
|
+
},
|
|
2162
|
+
})
|
|
2163
|
+
```
|
|
2164
|
+
|
|
2165
|
+
Without that block every endpoint below answers 404 and nothing else changes — including the
|
|
2166
|
+
boot check, which does not run. `scopes` is the one setting with no default: the names are
|
|
2167
|
+
your application's vocabulary, they are published in the metadata document and read aloud on
|
|
2168
|
+
the consent screen, and there is nothing to derive them from.
|
|
2169
|
+
|
|
2170
|
+
| Endpoint | Host | Auth | What it is |
|
|
2171
|
+
| --- | --- | --- | --- |
|
|
2172
|
+
| `GET /.well-known/oauth-authorization-server` | API | public | RFC 8414 discovery — the first request any client makes |
|
|
2173
|
+
| `POST /_auth/oauth2/register` | API | public, IP rate limited | RFC 7591 dynamic registration. Public clients only |
|
|
2174
|
+
| `GET /_auth/oauth2/authorize` | API | `authenticate` | What the consent screen should say. Records nothing |
|
|
2175
|
+
| `POST /_auth/oauth2/authorize` | API | `authenticate` | The decision. Mints the code |
|
|
2176
|
+
| `POST /_auth/oauth2/token` | API | public, IP rate limited | `authorization_code` and `refresh_token` |
|
|
2177
|
+
| `POST /_auth/oauth2/revoke` | API | public (RFC 7009) | `client_id` required; 200 for an unknown token as surely as for a real one |
|
|
2178
|
+
| `GET /_auth/oauth2/grants` · `DELETE /_auth/oauth2/grants/:id` | API | `authenticate` | What the user has connected, and the button that disconnects it |
|
|
2179
|
+
| `GET /oauth/authorize` · `POST /oauth/authorize` | web | session | The consent screen itself — see the note at the end |
|
|
2180
|
+
|
|
2181
|
+
Two lines wire it to `@spfn/mcp`:
|
|
2182
|
+
|
|
2183
|
+
```typescript
|
|
2184
|
+
import { verifyAccessToken } from '@spfn/auth/server';
|
|
2185
|
+
|
|
2186
|
+
export const mcp = createMcpRoute({ validateToken: verifyAccessToken, tools: [...] });
|
|
2187
|
+
```
|
|
2188
|
+
|
|
2189
|
+
`verifyAccessToken(token, resource)` answers `{ clientId, scopes, expiresAt, userId }` or
|
|
2190
|
+
`null`, and `null` is a refusal — `@spfn/mcp` ≥ 0.3.0-beta.3 accepts it as one rather than
|
|
2191
|
+
requiring a throw. `expiresAt` is seconds since the epoch, like every other OAuth field here.
|
|
2192
|
+
|
|
2193
|
+
- **Only loopback and origins you allowed.** A client may register `http://localhost:*`,
|
|
2194
|
+
`http://127.0.0.1:*` or `http://[::1]:*` — a CLI cannot know which port the OS will hand it,
|
|
2195
|
+
so the **port** is the one thing allowed to vary. Nothing else does: host, path and query
|
|
2196
|
+
must match the registration exactly, a fragment is refused at registration and at request,
|
|
2197
|
+
and plain `http` anywhere else is refused outright. An `https` redirect URI has to be on an
|
|
2198
|
+
origin listed in `allowedRedirectOrigins`.
|
|
2199
|
+
- **Those three spellings are three registrations.** `localhost`, `127.0.0.1` and `[::1]` do
|
|
2200
|
+
not stand in for one another — they resolve differently on a machine with a split-horizon
|
|
2201
|
+
resolver, and a client answered on a host it did not register is a client something
|
|
2202
|
+
redirected. IPv6 is the one place spelling is folded: `http://[0:0:0:0:0:0:0:1]:5/cb` and
|
|
2203
|
+
`http://[::1]:5/cb` are the same registration, because both sides are read through
|
|
2204
|
+
`new URL(...).hostname`.
|
|
2205
|
+
- **An unknown client or a mismatched redirect URI is shown, never redirected.** There is no
|
|
2206
|
+
vetted URI to send that error to, and sending it to the one the request supplied is the open
|
|
2207
|
+
redirect the whole rule exists to close. Every other authorize-time error —
|
|
2208
|
+
`invalid_request`, `invalid_scope`, `invalid_target`, `access_denied` — goes back to the
|
|
2209
|
+
client on its registered URI, which is the only form the waiting CLI can read.
|
|
2210
|
+
- **PKCE S256, and nothing else.** No `plain`, and no request without a challenge. The code
|
|
2211
|
+
arrives on a loopback port that any process on the machine could have been listening on.
|
|
2212
|
+
- **`resource` is required** (RFC 8707) and the token is only good against it. A token your
|
|
2213
|
+
user approved for your MCP server cannot be replayed against a neighbouring deployment that
|
|
2214
|
+
shares this authorization server.
|
|
2215
|
+
- **A code is spent by the statement that reads it**, so of two exchanges arriving together
|
|
2216
|
+
exactly one gets tokens — and **presenting a code twice revokes the grant**, because by then
|
|
2217
|
+
somebody else may hold what the first exchange produced.
|
|
2218
|
+
- **Refresh tokens rotate, and a rotated one is marked rather than deleted.** Presenting it
|
|
2219
|
+
again revokes the grant, which kills the replacement as well as the replayed token: both
|
|
2220
|
+
hang off the grant and there is no telling which holder is the thief. A refresh may ask for
|
|
2221
|
+
a **subset** of the granted scopes and never for more; narrowing applies to that request and
|
|
2222
|
+
leaves the user's consent record as they gave it.
|
|
2223
|
+
- **Every code and refresh failure is one `invalid_grant`, word for word.** Unknown, expired,
|
|
2224
|
+
spent, wrong verifier, another client's. The endpoint is public, and an error that told
|
|
2225
|
+
those apart would answer the question somebody holding a stolen value is asking.
|
|
2226
|
+
- **Token endpoint errors are RFC 6749 §5.2, not the SPFN envelope** —
|
|
2227
|
+
`{ "error": "invalid_grant", "error_description": "..." }`, status 400,
|
|
2228
|
+
`Cache-Control: no-store`. The client reading it is an OAuth library that knows those two
|
|
2229
|
+
field names and nothing about this framework. Registration refusals are RFC 7591 §3.2.2 the
|
|
2230
|
+
same way (`invalid_redirect_uri`, `invalid_client_metadata`).
|
|
2231
|
+
- **Nothing but a hash is stored.** Codes and tokens are `spfn_at_<64 hex>` /
|
|
2232
|
+
`spfn_rt_<64 hex>` / 43 url-safe characters, and the value exists in the clear exactly once,
|
|
2233
|
+
in the response that issues it. It is never logged and never put in an event.
|
|
2234
|
+
- **A global revocation reaches the grants.** `revoke-all`, a password change, a completed
|
|
2235
|
+
password reset and a deletion request each revoke every grant the account has — so a CLI
|
|
2236
|
+
holding a refresh token through "sign me out everywhere" cannot be back within the hour,
|
|
2237
|
+
which is exactly the client that call was aimed at. The user's own
|
|
2238
|
+
`DELETE /_auth/oauth2/grants/:id` does the same for one client, immediately.
|
|
2239
|
+
- **The issuer is checked at boot.** It must be an absolute URL with no path — the metadata
|
|
2240
|
+
document is served at an origin's root and nowhere else — and it must be `https`, or `http`
|
|
2241
|
+
on `localhost` / `127.0.0.1` / `[::1]` for development. Anything else refuses to start with
|
|
2242
|
+
a message naming `SPFN_API_URL` or `authorizationServer.issuer`, whichever the value came
|
|
2243
|
+
from. An application with no `authorizationServer` block never reaches this check. The one
|
|
2244
|
+
value that is accepted and rewritten is a bare trailing slash: `https://api.acme.com/` is
|
|
2245
|
+
stored as `https://api.acme.com`, the form `@spfn/mcp` derives, so the two documents naming
|
|
2246
|
+
this server agree (RFC 8414 §3.3). That reduction happens where the config is resolved, not
|
|
2247
|
+
in the boot check, so a document read without the lifecycle hook publishes the same issuer.
|
|
2248
|
+
- **Unapproved client rows are swept.** Registration is unauthenticated by necessity, so
|
|
2249
|
+
`auth.oauth2.client-purge` (in `authJobRouter`, daily at 05:00) deletes clients older than a
|
|
2250
|
+
day that no user ever approved. One with a grant against it is never touched. Registration
|
|
2251
|
+
is also capped per IP two ways — a burst rate limit, and a cap on how many unapproved
|
|
2252
|
+
clients one address may have standing, which a rate limit cannot express.
|
|
2253
|
+
- **`/mcp` tokens are not sessions.** An access token issued here authorizes the MCP surface
|
|
2254
|
+
for the resource it names. It is not a user session and is not accepted by ordinary API
|
|
2255
|
+
routes.
|
|
2256
|
+
|
|
2257
|
+
### The consent screen
|
|
2258
|
+
|
|
2259
|
+
The screen itself is one route file on the web app, at the path published as
|
|
2260
|
+
`authorization_endpoint`:
|
|
2261
|
+
|
|
2262
|
+
```typescript
|
|
2263
|
+
// app/oauth/authorize/route.ts
|
|
2264
|
+
import { createOAuth2AuthorizeHandlers } from '@spfn/auth/nextjs/server';
|
|
2265
|
+
|
|
2266
|
+
export const { GET, POST } = createOAuth2AuthorizeHandlers({ loginPath: '/login' });
|
|
2267
|
+
```
|
|
2268
|
+
|
|
2269
|
+
`GET` asks `GET /_auth/oauth2/authorize` what the request is and draws it; `POST` checks the
|
|
2270
|
+
form's own CSRF token, sends the decision to `POST /_auth/oauth2/authorize`, and redirects the
|
|
2271
|
+
browser back to the waiting CLI. Neither decides anything — the API validates the request from
|
|
2272
|
+
scratch both times, because the form between the two calls is in the user's browser.
|
|
2273
|
+
|
|
2274
|
+
| Option | What it is |
|
|
2275
|
+
| --- | --- |
|
|
2276
|
+
| `loginPath` | Where a visitor with no session goes. The handler appends `?returnUrl=` pointing at this request's own path and query, so signing in lands back on the screen with its parameters intact. The value is held to `isSafeReturnPath` like every other return destination in this package, and a refusal is a 400 screen rather than a redirect |
|
|
2277
|
+
| `render?` | `(view: OAuth2ConsentView) => string`, replacing the default body. Status, headers and the field set stay the handler's |
|
|
2278
|
+
|
|
2279
|
+
Every answer carries `Cache-Control: no-store`, and every page also carries
|
|
2280
|
+
`Content-Type: text/html; charset=utf-8` and `Content-Security-Policy: frame-ancestors 'none'`
|
|
2281
|
+
— a consent screen that can be framed is a consent screen that can be clickjacked.
|
|
2282
|
+
|
|
2283
|
+
- **The two refusal kinds become the two answers.** `unknown_client` and
|
|
2284
|
+
`redirect_uri_mismatch` are shown on a 400 screen with no `Location` at all. Every other
|
|
2285
|
+
refusal — `invalid_request`, `invalid_target`, `invalid_scope`, `access_denied` — is a 302 to
|
|
2286
|
+
the redirect URI **the API returned**, carrying `error=` and the `state` verbatim. The
|
|
2287
|
+
`redirect_uri` in the request is forwarded to the API and never built into a `Location`: the
|
|
2288
|
+
API's value is the one that matched a registration, which is the whole difference between a
|
|
2289
|
+
redirect and an open redirect.
|
|
2290
|
+
- **The POST carries its own CSRF token.** The page puts the readable CSRF cookie in a hidden
|
|
2291
|
+
`csrf` field and the POST refuses, before calling the API at all, unless the field matches
|
|
2292
|
+
the cookie. The handler's server-side call to the API mints the CSRF header itself and would
|
|
2293
|
+
always pass, so the form's token is the only check that means anything here.
|
|
2294
|
+
- **`render` owns the body and nothing else.** `OAuth2ConsentView` carries `clientName`,
|
|
2295
|
+
`redirectHost`, `scopes`, `resource`, the `fields` to echo as hidden inputs, and the
|
|
2296
|
+
`csrfToken`, all raw — put every one of them through the exported `escapeHtml`. `clientName`
|
|
2297
|
+
arrives from unauthenticated dynamic registration, and a renderer that drops `fields` or
|
|
2298
|
+
`csrfToken` produces a form the API refuses.
|
|
2299
|
+
|
|
2300
|
+
The end-to-end path — lifecycle config, this route, `/mcp`, and connecting from Claude Code
|
|
2301
|
+
and Codex — is [docs/guides/mcp-clients.md](../../docs/guides/mcp-clients.md).
|
|
2302
|
+
|
|
2303
|
+
## Machine principals (`registerMachineVerifier`)
|
|
2304
|
+
|
|
2305
|
+
A machine credential is issued by a service to a non-interactive process, and its subject is
|
|
2306
|
+
an account or a tenant, not a person. `AuthContext` cannot hold one — it requires a `users`
|
|
2307
|
+
row — and resolving a machine token to its owning user is worse than the type error: it makes
|
|
2308
|
+
the machine's request indistinguishable from that user's own session.
|
|
2309
|
+
|
|
2310
|
+
So a machine principal never enters `AuthContext`. It lives in its own context key, is read by
|
|
2311
|
+
its own helper, and is admitted by its own middleware:
|
|
2312
|
+
|
|
2313
|
+
```typescript
|
|
2314
|
+
import { machineAuth, requireMachineScope, getMachinePrincipal } from '@spfn/auth/server';
|
|
2315
|
+
|
|
2316
|
+
export const ingest = route.post('/v1/ingest')
|
|
2317
|
+
.use([machineAuth, requireMachineScope('events:write')])
|
|
2318
|
+
.handler(async (c) =>
|
|
2319
|
+
{
|
|
2320
|
+
const { subjectType, subjectId } = getMachinePrincipal(c.raw)!;
|
|
2321
|
+
// subjectType: 'account' | 'service' | whatever the verifier named
|
|
2322
|
+
});
|
|
2323
|
+
```
|
|
2324
|
+
|
|
2325
|
+
`getAuth(c)` on that route returns nothing, because nothing put a user there. That is the
|
|
2326
|
+
whole design: a machine request cannot impersonate a user session, not because a check
|
|
2327
|
+
forbids it but because no code path leads there.
|
|
2328
|
+
|
|
2329
|
+
**Ownership is not authentication.** Who issued a machine token, who owns it, and who may
|
|
2330
|
+
revoke or audit it are the registrant's data-level concerns — put the token id in `claims` and
|
|
2331
|
+
answer them from your own tables. What the request *acts as* is the token's own subject and
|
|
2332
|
+
scopes, and nothing here resolves a machine subject to a user.
|
|
2333
|
+
|
|
2334
|
+
### Registering a verifier
|
|
2335
|
+
|
|
2336
|
+
A verifier claims one namespace, by a raw `tokenPrefix` (for an opaque secret, the
|
|
2337
|
+
`spfn_ops_` shape) or by a `kidPrefix` on the unverified JOSE header of a JWS. The built-in
|
|
2338
|
+
ops token's own shape is exported rather than spelled out — match it with `isOpsToken` or
|
|
2339
|
+
`OPS_TOKEN_PREFIX` from `@spfn/auth/server`. Register at boot, before the first request:
|
|
2340
|
+
|
|
2341
|
+
```typescript
|
|
2342
|
+
import { registerMachineVerifier } from '@spfn/auth/server';
|
|
2343
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
2344
|
+
|
|
2345
|
+
const RUNTIME_JWKS = createRemoteJWKSet(new URL('https://issuer.example.com/.well-known/jwks.json'));
|
|
2346
|
+
|
|
2347
|
+
registerMachineVerifier({
|
|
2348
|
+
id: 'runtimeJwsV1',
|
|
2349
|
+
match: { kidPrefix: 'machine:runtime:' },
|
|
2350
|
+
verify: async (token) =>
|
|
2351
|
+
{
|
|
2352
|
+
const { payload } = await jwtVerify(token, RUNTIME_JWKS, { issuer: 'https://issuer.example.com' });
|
|
2353
|
+
|
|
2354
|
+
return {
|
|
2355
|
+
subjectType: 'account',
|
|
2356
|
+
subjectId: String(payload.sub),
|
|
2357
|
+
scopes: String(payload.scope ?? '').split(' ').filter(Boolean),
|
|
2358
|
+
claims: { tokenId: payload.jti },
|
|
2359
|
+
scheme: 'runtimeJwsV1',
|
|
2360
|
+
};
|
|
2361
|
+
},
|
|
2362
|
+
});
|
|
2363
|
+
```
|
|
2364
|
+
|
|
2365
|
+
The request carries it as an ordinary bearer token — no new wire format, and the
|
|
2366
|
+
profile-header channel is not involved:
|
|
2367
|
+
|
|
2368
|
+
```http
|
|
2369
|
+
POST /v1/ingest
|
|
2370
|
+
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Im1hY2hpbmU6cnVudGltZTo...
|
|
2371
|
+
```
|
|
2372
|
+
|
|
2373
|
+
- **Namespace your kids.** `machine:` is the convention this package documents, and a user
|
|
2374
|
+
session JWT never carries that shape. The prefix is what tells the two apart before either
|
|
2375
|
+
is verified.
|
|
2376
|
+
- **Conflicting discriminators are refused at registration** — a duplicate `id`, a duplicate
|
|
2377
|
+
prefix, or a prefix that would shadow an already-registered one (`machine:` swallowing
|
|
2378
|
+
`machine:runtime:`). Two verifiers one token could match would make admission depend on
|
|
2379
|
+
registration order, so that is a boot-time error rather than something the dispatch
|
|
2380
|
+
resolves per request.
|
|
2381
|
+
- **A `tokenPrefix` claims every token that starts with it**, and `authenticate` consults the
|
|
2382
|
+
registry before it decodes anything. A prefix a user's JWT could begin with (`ey…`) would
|
|
2383
|
+
therefore refuse every user session — pick a prefix no other credential on your surface
|
|
2384
|
+
shares, as `spfn_ops_` does.
|
|
2385
|
+
- **Register at boot, before the first request.** The registry is module state read on every
|
|
2386
|
+
dispatch, so a verifier registered later is simply a verifier the requests before it did
|
|
2387
|
+
not have. There is no unregistration and no reset — the same contract, and the same reason,
|
|
2388
|
+
as [`registerAuthProfile`](#custom-auth-profiles-registerauthprofile).
|
|
2389
|
+
- **Registering nothing costs nothing.** With no verifier registered, `authenticate` is two
|
|
2390
|
+
array-length checks away from what it was. The unverified JOSE header peek happens only
|
|
2391
|
+
once a `kidPrefix` verifier exists.
|
|
2392
|
+
- **`scheme` is the registry's answer**, not the verifier's: whatever a verifier returns
|
|
2393
|
+
there, the principal carries the `id` that admitted it, so an audit trail cannot be made to
|
|
2394
|
+
name the wrong verifier.
|
|
2395
|
+
|
|
2396
|
+
### The case table
|
|
2397
|
+
|
|
2398
|
+
| credential ↓ route → | `authenticate` (user) | `machineAuth` | `optionalAuth` |
|
|
2399
|
+
|---|---|---|---|
|
|
2400
|
+
| user bearer JWT | ✓ user (unchanged) | 401 | ✓ user (unchanged) |
|
|
2401
|
+
| machine token, registered namespace, valid | 401 — refused before the token is decoded | ✓ sets `machinePrincipal` | 401 |
|
|
2402
|
+
| machine token, registered namespace, verifier rejects | 401 | 401 | 401 |
|
|
2403
|
+
| machine-shaped token, unregistered namespace | 401 (the existing invalid-token path) | 401 | continues, no auth |
|
|
2404
|
+
| profile header + any Bearer | `PROFILE_REJECTED` (unchanged) | `PROFILE_REJECTED` | `PROFILE_REJECTED` |
|
|
2405
|
+
| nothing | 401 (unchanged) | 401 | continues, no auth |
|
|
2406
|
+
| valid principal, missing scope | — | 403 | — |
|
|
2407
|
+
| valid principal, sufficient scope | — | 200 | — |
|
|
2408
|
+
|
|
2409
|
+
Every 401 above is one message. Whether a namespace is registered, whether a presented token
|
|
2410
|
+
was ever valid, and whether a verifier rejected it are not inferable from the answer — the
|
|
2411
|
+
same non-disclosure rule the [ops-token](#ops-tokens-spfn-ops) table keeps. 403 is reserved
|
|
2412
|
+
for scope, where the caller is already authenticated; `requireMachineScope` matches scopes
|
|
2413
|
+
exactly and has no wildcard, and it fails closed with a 401 if it runs without `machineAuth`
|
|
2414
|
+
before it.
|
|
2415
|
+
|
|
2416
|
+
A verifier that throws something other than a refusal — a bug in registrant code — is the
|
|
2417
|
+
same generic 401 on the wire, with the real error logged. Never a 500 carrying registrant
|
|
2418
|
+
internals, and never a silent pass.
|
|
2419
|
+
|
|
2420
|
+
The last row of the unregistered-namespace case is the one asymmetry: a token in a namespace
|
|
2421
|
+
nobody registered is not a machine credential as far as this package can tell, so under
|
|
2422
|
+
`optionalAuth` it gets what any unusable bearer token has always got. A token in a
|
|
2423
|
+
*registered* namespace is refused there, because refusing it is the difference between
|
|
2424
|
+
"presented the wrong credential" and "presented none".
|
|
2425
|
+
|
|
2426
|
+
The non-disclosure above is therefore an `authenticate` and `machineAuth` property, not an
|
|
2427
|
+
`optionalAuth` one: on an `optionalAuth` route a caller can tell a registered namespace from
|
|
2428
|
+
an unregistered one, because one is refused and the other is served anonymously. Closing that
|
|
2429
|
+
gap would mean refusing every unusable bearer token on those routes — a change to behaviour
|
|
2430
|
+
that predates machine principals, and a worse trade than the inference it prevents. Mount
|
|
2431
|
+
`machineAuth` where the distinction matters.
|
|
2432
|
+
|
|
2433
|
+
### Issuance is yours
|
|
2434
|
+
|
|
2435
|
+
This package verifies machine tokens; it does not mint them. Issuance, rotation, and
|
|
2436
|
+
revocation belong to whoever owns the subject — keep the tokens short-lived, and prefer a
|
|
2437
|
+
signature you can verify offline (`kidPrefix` + JWKS) over a secret you must look up.
|
|
2438
|
+
|
|
2439
|
+
`opsTokenAuth` is the built-in instance of exactly this pattern, hand-written for one
|
|
2440
|
+
credential before the registry existed: its own context key (`opsToken`), its own scope guard,
|
|
2441
|
+
`AuthContext` never set. It keeps its own implementation and is not registered here.
|
|
2442
|
+
A route that must admit an ops token *or* a user session uses
|
|
2443
|
+
[`opsOrUser`](#one-route-two-credentials-opsoruser), which composes the two existing
|
|
2444
|
+
middleware pairs behind one branch on credential shape rather than widening either path.
|
|
2445
|
+
|
|
1066
2446
|
## Account Deletion & Recovery
|
|
1067
2447
|
|
|
1068
2448
|
Grace-period deletion with in-window recovery, an admin/GDPR-response entry point for immediate
|
|
@@ -1117,7 +2497,7 @@ export default defineServerConfig()
|
|
|
1117
2497
|
},
|
|
1118
2498
|
},
|
|
1119
2499
|
}))
|
|
1120
|
-
.jobs(authJobRouter) //
|
|
2500
|
+
.jobs(authJobRouter) // the daily (04:00 UTC) purge sweep, and auth.link-mail
|
|
1121
2501
|
.routes(appRouter)
|
|
1122
2502
|
.build();
|
|
1123
2503
|
```
|
|
@@ -1154,15 +2534,68 @@ is fixed at module-import time, which happens before `createAuthLifecycle()` run
|
|
|
1154
2534
|
`createAuthLifecycle()` call, and register that instead:
|
|
1155
2535
|
|
|
1156
2536
|
```typescript
|
|
1157
|
-
import {
|
|
2537
|
+
import { createAuthJobRouter } from '@spfn/auth/server';
|
|
1158
2538
|
|
|
1159
2539
|
// ... after .lifecycle(createAuthLifecycle({ deletion: { purgeCron: '0 3 * * *' } }))
|
|
1160
|
-
.jobs(
|
|
2540
|
+
.jobs(createAuthJobRouter({ purgeCron: '0 3 * * *' }))
|
|
1161
2541
|
```
|
|
1162
2542
|
|
|
1163
|
-
Register **only one** of `authJobRouter` / `
|
|
1164
|
-
|
|
1165
|
-
|
|
2543
|
+
Register **only one** of `authJobRouter` / `createAuthJobRouter(...)` — both build the same job
|
|
2544
|
+
names, so registering both (e.g. the static export *and* a custom-cron router) double-registers
|
|
2545
|
+
each name against pg-boss instead of overriding it.
|
|
2546
|
+
|
|
2547
|
+
`createAuthDeletionJobRouter` is the former name of `createAuthJobRouter` and still works, with
|
|
2548
|
+
the same argument and the same result. It is deprecated because the router has carried more than
|
|
2549
|
+
the deletion purge since `auth.link-mail` joined it.
|
|
2550
|
+
|
|
2551
|
+
### Link mail delivery
|
|
2552
|
+
|
|
2553
|
+
`auth.link-mail` is the second job on the router, and the reason to register the router even in
|
|
2554
|
+
an app that never deletes an account.
|
|
2555
|
+
|
|
2556
|
+
**What it queues, and why only a row id.** Three mails leave through it: the verified-email
|
|
2557
|
+
signup link, the password reset link, and the "you already have an account" notice the signup
|
|
2558
|
+
request answers a known address with. The payload is `{ kind, rowId }` — or `{ kind, target,
|
|
2559
|
+
targetType }` for the notice — and never the token, the URL or the rendered mail.
|
|
2560
|
+
`@spfn/notification` can queue a send of its own, but its payload carries the *rendered* mail,
|
|
2561
|
+
which for these three templates would leave the link token in plaintext in `pgboss.job` until
|
|
2562
|
+
archive. So the queue carries a reference and the worker mints the credential moments before
|
|
2563
|
+
sending it: the plaintext exists in the mail and nowhere else.
|
|
2564
|
+
|
|
2565
|
+
**What that buys.** The request writes its row with `token_hash` null and answers. Both branches
|
|
2566
|
+
of both endpoints now cost the same database work, so how long a request took no longer says
|
|
2567
|
+
whether the address has an account — the mail was the only asymmetry left. A pending row is not
|
|
2568
|
+
confirmable: a null hash matches no lookup, and the worker's `issue` refuses a row that was
|
|
2569
|
+
superseded, consumed, completed or expired in the meantime, in the same statement that would
|
|
2570
|
+
write the hash. A failed send throws so pg-boss retries, and the retry re-mints, which is why a
|
|
2571
|
+
token from a failed attempt stops working.
|
|
2572
|
+
|
|
2573
|
+
**The three modes** — `SPFN_AUTH_LINK_MAIL_DELIVERY`:
|
|
2574
|
+
|
|
2575
|
+
| mode | behaviour |
|
|
2576
|
+
|------|-----------|
|
|
2577
|
+
| `auto` (default) | queue when pg-boss is initialised, send on the request when it is not — an app with no jobs keeps working exactly as before |
|
|
2578
|
+
| `queued` | always queue; an enqueue failure surfaces as a failed request rather than becoming an inline send |
|
|
2579
|
+
| `inline` | always send on the request — today's behaviour, and the timing signal that comes with it |
|
|
2580
|
+
|
|
2581
|
+
**When the provider refuses on the request path** — `inline`, `auto` with no pg-boss, or the
|
|
2582
|
+
fallback below — the failure is logged and the request still answers as if the mail had gone out,
|
|
2583
|
+
because an answer that depended on the mail provider would be an account-existence oracle during
|
|
2584
|
+
an outage; the user asks again, and only the job path retries.
|
|
2585
|
+
|
|
2586
|
+
**The fallback warning.** In `auto`, an app that initialised pg-boss but never registered this
|
|
2587
|
+
router has no `auth.link-mail` queue, so the enqueue fails. Losing the mail there would be silent,
|
|
2588
|
+
so that request sends inline instead and the log says once per process:
|
|
2589
|
+
|
|
2590
|
+
```
|
|
2591
|
+
Queue auth.link-mail does not exist, so this link mail was sent on the request path.
|
|
2592
|
+
Register the auth job router — .jobs(authJobRouter) — or set SPFN_AUTH_LINK_MAIL_DELIVERY='inline'.
|
|
2593
|
+
```
|
|
2594
|
+
|
|
2595
|
+
The fix is in the message: register the router, or say `inline` if sending on the request is what
|
|
2596
|
+
you want. Only a missing queue falls back — every other enqueue failure, a database outage above
|
|
2597
|
+
all, surfaces, because falling back on those would hide the outage behind mail that still gets
|
|
2598
|
+
through.
|
|
1166
2599
|
|
|
1167
2600
|
## FAQ
|
|
1168
2601
|
|
|
@@ -1176,9 +2609,20 @@ deploy.
|
|
|
1176
2609
|
Almost always the callback origin. The CSRF check is a double-submit against a host-only
|
|
1177
2610
|
cookie set on your **web app** host, so the provider must return to the web app origin, and
|
|
1178
2611
|
the app must forward `/_auth/*` to the API with a Next.js rewrite. Without that rewrite the
|
|
1179
|
-
callback 404s — including in local dev.
|
|
2612
|
+
callback 404s — including in local dev. An explicit `SPFN_AUTH_<PROVIDER>_REDIRECT_URI` on the
|
|
2613
|
+
wrong origin or path no longer gets that far: it fails at boot with a message naming the
|
|
2614
|
+
variable. Details in
|
|
1180
2615
|
[OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite).
|
|
1181
2616
|
|
|
2617
|
+
**I forgot my password.**
|
|
2618
|
+
Send the address to `requestPasswordReset` and open the link that arrives. Any `active`
|
|
2619
|
+
account whose email is verified — or that already has a password, which covers every account
|
|
2620
|
+
created before the column was stamped — can be reset that way. See
|
|
2621
|
+
[Password reset](#password-reset-verified-email). Completing it signs every other device out,
|
|
2622
|
+
so it is also the answer to "someone else knows my password". An account with neither a
|
|
2623
|
+
verified address nor a password (OAuth-only, provider said unverified) cannot be reset by
|
|
2624
|
+
email; it signs in through its provider.
|
|
2625
|
+
|
|
1182
2626
|
**Does the server hold my users' private keys?**
|
|
1183
2627
|
No. The client generates an ES256/RS256 keypair, sends only the public key on register or
|
|
1184
2628
|
login, and signs each request itself. The server verifies with the stored public key. Keys
|
|
@@ -1208,6 +2652,23 @@ authorization.
|
|
|
1208
2652
|
The environment, seeded on startup by `createAuthLifecycle()`. Seeded accounts are email
|
|
1209
2653
|
verified, active, and required to change their password on first login.
|
|
1210
2654
|
|
|
2655
|
+
**Is `Foo@Example.com` the same account as `foo@example.com`?**
|
|
2656
|
+
Yes. Addresses are trimmed and lower-cased on the way in and on the way out, so one person
|
|
2657
|
+
who capitalizes differently on different days reaches one account instead of creating a
|
|
2658
|
+
second. Nothing else is folded — Gmail's dot and `+` rules are that provider's delivery
|
|
2659
|
+
behaviour, not an internet rule, and applying them would merge addresses other providers
|
|
2660
|
+
treat as different people.
|
|
2661
|
+
|
|
2662
|
+
`createAuthLifecycle()` brings existing rows into the same form on startup. If two accounts
|
|
2663
|
+
differ only by capitalization, both are left exactly as they are and their user ids are
|
|
2664
|
+
logged as an error: which one is the real account, and what becomes of the other's data, is
|
|
2665
|
+
not a question the package can answer for you. Until you resolve it, the mixed-case one
|
|
2666
|
+
cannot sign in.
|
|
2667
|
+
|
|
2668
|
+
Admin seeding is unaffected either way. It recognizes a configured admin in whatever form
|
|
2669
|
+
the address was stored, so an account the backfill has not reached is skipped rather than
|
|
2670
|
+
duplicated into a second privileged row holding the configured password.
|
|
2671
|
+
|
|
1211
2672
|
## Pitfalls & anti-patterns
|
|
1212
2673
|
|
|
1213
2674
|
- **"relation \"auth.users\" does not exist" — tables come from bundled migrations, not push.**
|
|
@@ -1245,8 +2706,10 @@ verified, active, and required to change their password on first login.
|
|
|
1245
2706
|
'@spfn/notification/server'`). Wire verification-code / invitation emails through its events.
|
|
1246
2707
|
- **`authJobRouter` isn't registered for you.** `createAuthLifecycle()`'s `afterInfrastructure`
|
|
1247
2708
|
hook runs *before* `@spfn/core` initializes pg-boss and registers jobs, so the lifecycle has no
|
|
1248
|
-
opportunity to auto-register the
|
|
1249
|
-
|
|
2709
|
+
opportunity to auto-register the jobs. Call `.jobs(authJobRouter)` yourself — see
|
|
2710
|
+
[Account Deletion & Recovery](#account-deletion--recovery). An app that initialises pg-boss and
|
|
2711
|
+
skips this keeps sending link mail, but on the request path, with a warning naming the router —
|
|
2712
|
+
see [Link mail delivery](#link-mail-delivery).
|
|
1250
2713
|
- **`USER_STATUSES` gained `pending_deletion` / `deleted`.** Any code with a `switch(user.status)`
|
|
1251
2714
|
or an exhaustive status union must handle both — `enumText` is plain `text` with no DB `CHECK`,
|
|
1252
2715
|
so nothing enforces this at the database layer.
|