@spfn/auth 0.3.0-beta.2 → 0.3.0-beta.20
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 +1382 -23
- package/dist/client-proof.d.ts +45 -15
- package/dist/client-proof.js +198 -4
- 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 +302 -0
- package/dist/config.js +134 -4
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +370 -3
- package/dist/errors.js +245 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +185 -2
- package/dist/index.js +256 -2
- package/dist/index.js.map +1 -1
- package/dist/machine-principals-BD4tnASp.d.ts +2739 -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 +3761 -414
- package/dist/server.js +5865 -1043
- 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/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,14 @@ 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_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) |
|
|
169
|
+
| `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) |
|
|
170
|
+
| `SPFN_AUTH_PASSKEY_USER_VERIFICATION` | `.env.server` | — | `preferred` (default) or `required`; `discouraged` refuses boot |
|
|
171
|
+
| `SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS` / `_RECENT_AUTH_MINUTES` | `.env.server` | — | defaults `300` / `10` — see [Passkeys](#passkeys-webauthn) |
|
|
157
172
|
| `NEXT_PUBLIC_SPFN_API_URL` / `NEXT_PUBLIC_SPFN_APP_URL` | `.env.local` | — | browser-facing URLs for OAuth redirects |
|
|
158
173
|
|
|
159
174
|
Read validated values via `import { env } from '@spfn/auth/config'` (a proxy validated at
|
|
@@ -178,7 +193,25 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
|
|
|
178
193
|
| `sendVerificationCode` | POST `/_auth/codes` | public | send 6-digit OTP |
|
|
179
194
|
| `verifyCode` | POST `/_auth/codes/verify` | public | verify OTP → verification token |
|
|
180
195
|
| `register` | POST `/_auth/register` | public | create user + register public key |
|
|
196
|
+
| `requestSignupLink` | POST `/_auth/signup/email` | public | email a one-time signup confirmation link — see [Verified-email signup](#verified-email-signup) |
|
|
197
|
+
| `confirmSignupLink` | POST `/_auth/signup/email/confirm` | public | exchange the link for a password-setup session |
|
|
198
|
+
| `completeSignup` | POST `/_auth/signup/password` | setup session | set the password, which creates the account and signs in |
|
|
199
|
+
| `requestPasswordReset` | POST `/_auth/password/reset` | public | email a one-time password reset link — see [Password reset](#password-reset-verified-email) |
|
|
200
|
+
| `confirmPasswordReset` | POST `/_auth/password/reset/confirm` | public | exchange the link for a password-setup session |
|
|
201
|
+
| `completePasswordReset` | POST `/_auth/password/reset/complete` | setup session | set the new password, sign every other device out, sign this one in |
|
|
181
202
|
| `login` | POST `/_auth/login` | public | password login + new session key |
|
|
203
|
+
| `startDeviceAuth` | POST `/_auth/device/start` | public | begin a device-code login — see [Device-code login](#device-code-login) |
|
|
204
|
+
| `pollDeviceAuth` | POST `/_auth/device/poll` | public | ask whether the request was answered; the approved answer *is* the login |
|
|
205
|
+
| `getDeviceAuthInfo` | POST `/_auth/device/info` | yes | what device is asking, so the approval screen can show it |
|
|
206
|
+
| `approveDeviceAuth` | POST `/_auth/device/approve` | yes | let the waiting device in |
|
|
207
|
+
| `denyDeviceAuth` | POST `/_auth/device/deny` | yes | refuse it |
|
|
208
|
+
| `passkeyRegisterOptions` | POST `/_auth/passkeys/register/options` | yes | begin enrolling a passkey — see [Passkeys](#passkeys-webauthn) |
|
|
209
|
+
| `passkeyRegisterVerify` | POST `/_auth/passkeys/register/verify` | yes | verify the attestation and keep the credential |
|
|
210
|
+
| `passkeyLoginOptions` | POST `/_auth/passkeys/login/options` | public | begin a passkey sign-in; takes no identifier |
|
|
211
|
+
| `passkeyLoginVerify` | POST `/_auth/passkeys/login/verify` | public | verify the assertion; answers exactly as `login` |
|
|
212
|
+
| `listPasskeys` | POST `/_auth/passkeys/list` | yes | the caller's enrolled passkeys |
|
|
213
|
+
| `renamePasskey` | POST `/_auth/passkeys/rename` | yes | rename one |
|
|
214
|
+
| `revokePasskey` | POST `/_auth/passkeys/revoke` | yes | retire one (refused if it is the last way in) |
|
|
182
215
|
| `logout` | POST `/_auth/logout` | yes | revoke current key |
|
|
183
216
|
| `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
|
|
184
217
|
| `listKeys` | POST `/_auth/keys/list` | yes | the caller's registered devices — see [Registered devices](#registered-devices-key-management) |
|
|
@@ -194,6 +227,7 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
|
|
|
194
227
|
| `cancelAccountDeletion` | POST `/_auth/deletion/cancel` | public | cancel a pending deletion (credential-based recovery) |
|
|
195
228
|
| `listRoles` / `createAdminRole` / `updateAdminRole` / `deleteAdminRole` / `updateUserRole` | — | superadmin | admin RBAC management |
|
|
196
229
|
| OAuth routes | — | — | see OAuth section |
|
|
230
|
+
| `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
231
|
|
|
198
232
|
There is deliberately **no account-existence endpoint**. `POST /_auth/exists` was removed
|
|
199
233
|
because it answered "does this account exist" directly, which is user enumeration; the
|
|
@@ -205,8 +239,280 @@ sends the public key on register/login, signs request JWTs locally, and the serv
|
|
|
205
239
|
with the stored public key (`keyId` carried in the JWT). The server never holds a private key.
|
|
206
240
|
Keys expire after 90 days — rotate with `rotateKey`.
|
|
207
241
|
|
|
242
|
+
### Verified-email signup
|
|
243
|
+
|
|
244
|
+
A second way in, alongside the six-digit code. The address is proven before a password
|
|
245
|
+
exists, so nothing is stored for someone who never confirms.
|
|
246
|
+
|
|
247
|
+
```
|
|
248
|
+
request → a one-time link is emailed
|
|
249
|
+
confirm → the link becomes a short-lived, HttpOnly password-setup session
|
|
250
|
+
password → the account is created, the device registered, the user signed in
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The six-digit-code path (`sendVerificationCode` → `verifyCode` → `register`) is unchanged.
|
|
254
|
+
Offer whichever suits your product, or both.
|
|
255
|
+
|
|
256
|
+
**1 — request the link.** The response is identical whether or not the address already has
|
|
257
|
+
an account, so it cannot be used to probe for accounts. When one exists, the owner gets a
|
|
258
|
+
"you already have an account" notice instead of a usable link.
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
await authApi.requestSignupLink.call({
|
|
262
|
+
body: { email: 'user@example.com', returnPath: '/welcome' }, // returnPath optional
|
|
263
|
+
});
|
|
264
|
+
// → { success: true, expiresAt }
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Calling it again is how a resend works: it invalidates the previous link and any setup
|
|
268
|
+
session opened from it. `returnPath` must be a path inside your app — absolute URLs,
|
|
269
|
+
`//host`, and `..` are refused, so the link cannot become an open redirect.
|
|
270
|
+
|
|
271
|
+
The mail leaves through the `auth.link-mail` job when pg-boss is initialised — register
|
|
272
|
+
`authJobRouter` — so neither branch of this endpoint waits on a mail provider; see
|
|
273
|
+
[Link mail delivery](#link-mail-delivery).
|
|
274
|
+
|
|
275
|
+
**2 — the page the link opens.** The email points at a page in *your* app
|
|
276
|
+
(`SPFN_AUTH_SIGNUP_CONFIRM_PATH`, default `/signup/confirm`), not at an API route. That page
|
|
277
|
+
reads the token from the query string and posts it:
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
'use client';
|
|
281
|
+
|
|
282
|
+
const token = useSearchParams().get('token');
|
|
283
|
+
|
|
284
|
+
const { email, returnPath } = await authApi.confirmSignupLink.call({ body: { token } });
|
|
285
|
+
|
|
286
|
+
// Drop the token from the URL so it does not linger in history or a Referer header.
|
|
287
|
+
window.history.replaceState({}, '', window.location.pathname);
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The setup session comes back as an HttpOnly cookie — the proxy interceptor moves it there
|
|
291
|
+
and strips it from the response body, so page script never holds it. Serve this page with
|
|
292
|
+
`Referrer-Policy: no-referrer`.
|
|
293
|
+
|
|
294
|
+
**3 — set the password.** This is the step that creates the account. The setup cookie
|
|
295
|
+
authorizes it; the device keypair is injected by the interceptor exactly as it is for
|
|
296
|
+
`register`.
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
await authApi.completeSignup.call({ body: { password } });
|
|
300
|
+
// → { userId, publicId, email } + session cookie, same as register
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Creating the user, registering the device key, and marking the setup session used all commit
|
|
304
|
+
together. A password that fails the strength policy leaves the session usable, so the user
|
|
305
|
+
retypes rather than requesting a fresh email.
|
|
306
|
+
|
|
307
|
+
**Settings.**
|
|
308
|
+
|
|
309
|
+
| Variable | Default | Meaning |
|
|
310
|
+
|----------|---------|---------|
|
|
311
|
+
| `SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES` | `30` | how long the emailed link works |
|
|
312
|
+
| `SPFN_AUTH_SIGNUP_SETUP_TTL_MINUTES` | `15` | how long the password-setup session works |
|
|
313
|
+
| `SPFN_AUTH_SIGNUP_CONFIRM_PATH` | `/signup/confirm` | the page in your app the link opens |
|
|
314
|
+
|
|
315
|
+
The link URL is built on `NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL`, the same resolution the
|
|
316
|
+
OAuth callbacks use. Delivery uses the `signup-link` template in `@spfn/notification` —
|
|
317
|
+
override it there to change the copy.
|
|
318
|
+
|
|
319
|
+
**What is stored.** Only SHA-256 hashes of the link token and the setup secret, in
|
|
320
|
+
`spfn_auth.signup_link_tokens`. Neither credential is recoverable from the database, and
|
|
321
|
+
both are one-time: a link opens one setup session, and a setup session sets one password.
|
|
322
|
+
|
|
323
|
+
### Password reset (verified email)
|
|
324
|
+
|
|
325
|
+
The way back into an account whose password is gone, using the address the account already
|
|
326
|
+
proved. Same three steps as the signup above, and the same posture on the two credentials.
|
|
327
|
+
|
|
328
|
+
```
|
|
329
|
+
request → a one-time link is emailed
|
|
330
|
+
confirm → the link becomes a short-lived, HttpOnly password-setup session
|
|
331
|
+
complete → the new password is written, every other device is signed out, this one is signed in
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
**Who can reset.** An `active` account whose `emailVerifiedAt` is set **or** that already has
|
|
335
|
+
a password. The second half is what makes the rule work on accounts created before the
|
|
336
|
+
column was stamped: both register paths proved the address at signup. An OAuth-only account
|
|
337
|
+
whose provider reported the address unverified has neither and is excluded — for it, a reset
|
|
338
|
+
would be a way in built on an address nobody proved.
|
|
339
|
+
|
|
340
|
+
**1 — request the link.** The response is identical for every input — same status, same two
|
|
341
|
+
fields, same `expiresAt` arithmetic — and mail goes only to an account that can be reset, so
|
|
342
|
+
neither the answer nor the mailbox reveals whether an address has an account here.
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
await authApi.requestPasswordReset.call({
|
|
346
|
+
body: { email: 'user@example.com', returnPath: '/account' }, // returnPath optional
|
|
347
|
+
});
|
|
348
|
+
// → { success: true, expiresAt }
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Calling it again is how a resend works: it invalidates the previous link and any setup
|
|
352
|
+
session opened from it. `returnPath` must be a path inside your app — absolute URLs,
|
|
353
|
+
`//host`, and `..` are refused, so the link cannot become an open redirect.
|
|
354
|
+
|
|
355
|
+
The mail leaves through the `auth.link-mail` job when pg-boss is initialised — register
|
|
356
|
+
`authJobRouter` — so an address with an account and one without cost the same; see
|
|
357
|
+
[Link mail delivery](#link-mail-delivery).
|
|
358
|
+
|
|
359
|
+
**2 — the page the link opens.** The email points at a page in *your* app
|
|
360
|
+
(`SPFN_AUTH_PASSWORD_RESET_CONFIRM_PATH`, default `/password/reset`), not at an API route.
|
|
361
|
+
That page reads the token from the query string and posts it:
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
'use client';
|
|
365
|
+
|
|
366
|
+
const token = useSearchParams().get('token');
|
|
367
|
+
|
|
368
|
+
const { email, returnPath } = await authApi.confirmPasswordReset.call({ body: { token } });
|
|
369
|
+
|
|
370
|
+
// Drop the token from the URL so it does not linger in history or a Referer header.
|
|
371
|
+
window.history.replaceState({}, '', window.location.pathname);
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
The setup session comes back as an HttpOnly cookie — the proxy interceptor moves it there
|
|
375
|
+
and strips it from the response body, so page script never holds it. It is a cookie of its
|
|
376
|
+
own, not the signup one, so neither secret is ever accepted by the other flow. Serve this
|
|
377
|
+
page with `Referrer-Policy: no-referrer`.
|
|
378
|
+
|
|
379
|
+
**3 — set the new password.** The setup cookie authorizes it; the device keypair is injected
|
|
380
|
+
by the interceptor exactly as it is for `login`.
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
await authApi.completePasswordReset.call({ body: { password } });
|
|
384
|
+
// → { userId, publicId, email } + session cookie, same as login
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
**Every other device is signed out.** Completing a reset denies every pending device
|
|
388
|
+
authorization and revokes every active key, exactly as `changePassword` does — whoever was
|
|
389
|
+
signed in on the old password, including the person the reset was needed for, has to sign in
|
|
390
|
+
again. The browser that performed the reset is signed in on a fresh key registered after the
|
|
391
|
+
revocation, so it does not have to retype the new password. `emailVerifiedAt` is stamped if
|
|
392
|
+
it was not already, `passwordChangeRequired` is cleared, and `auth.password.reset` is emitted
|
|
393
|
+
after commit.
|
|
394
|
+
|
|
395
|
+
The new hash, the revocations, the new device key and the completion mark commit together. A
|
|
396
|
+
password that fails the strength policy leaves the session usable, so the user retypes rather
|
|
397
|
+
than requesting a fresh email.
|
|
398
|
+
|
|
399
|
+
**Settings.**
|
|
400
|
+
|
|
401
|
+
| Variable | Default | Meaning |
|
|
402
|
+
|----------|---------|---------|
|
|
403
|
+
| `SPFN_AUTH_PASSWORD_RESET_LINK_TTL_MINUTES` | `30` | how long the emailed link works |
|
|
404
|
+
| `SPFN_AUTH_PASSWORD_RESET_SETUP_TTL_MINUTES` | `15` | how long the password-setup session works |
|
|
405
|
+
| `SPFN_AUTH_PASSWORD_RESET_CONFIRM_PATH` | `/password/reset` | the page in your app the link opens |
|
|
406
|
+
|
|
407
|
+
The link URL is built on `NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL`, the same resolution the
|
|
408
|
+
signup link uses. Delivery uses the `password-reset` template in `@spfn/notification` —
|
|
409
|
+
override it there to change the copy.
|
|
410
|
+
|
|
411
|
+
**What is stored.** Only SHA-256 hashes of the link token and the setup secret, in
|
|
412
|
+
`spfn_auth.password_reset_tokens`. Neither credential is recoverable from the database, and
|
|
413
|
+
both are one-time: a link opens one setup session, and a setup session sets one password.
|
|
414
|
+
A separate table from `signup_link_tokens`, so a signup secret can never address a reset row.
|
|
415
|
+
|
|
416
|
+
### Device-code login
|
|
417
|
+
|
|
418
|
+
A way in for a device that has a screen but no comfortable keyboard — a TV, a console, a CLI
|
|
419
|
+
on a headless box. The new device shows a short code; the account owner types that code on a
|
|
420
|
+
device that is already signed in.
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
// On the new device — it has no key on file, so this call is public.
|
|
424
|
+
const { deviceCode, userCode, expiresAtMillis, intervalMillis } =
|
|
425
|
+
await authApi.startDeviceAuth.call({ body: {
|
|
426
|
+
publicKey, keyId, fingerprint, algorithm: 'ES256',
|
|
427
|
+
deviceName: 'Living room TV', platform: 'desktop',
|
|
428
|
+
} });
|
|
429
|
+
|
|
430
|
+
// Show `userCode` (XXXX-XXXX) on this device's screen, then poll every intervalMillis.
|
|
431
|
+
const answer = await authApi.pollDeviceAuth.call({ body: { deviceCode } });
|
|
432
|
+
// → { status: 'pending', intervalMillis }
|
|
433
|
+
// → { status: 'approved', userId, publicId, email?, phone?, passwordChangeRequired }
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
// On the signed-in device — the user typed the code they read off the other screen.
|
|
438
|
+
const asking = await authApi.getDeviceAuthInfo.call({ body: { userCode } });
|
|
439
|
+
// → { deviceName?, platform?, fingerprintPrefix, requestedAtMillis, expiresAtMillis }
|
|
440
|
+
|
|
441
|
+
await authApi.approveDeviceAuth.call({ body: { userCode } }); // or denyDeviceAuth
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
**There is no token handed over, because there is no token.** Every request in this system is
|
|
445
|
+
signed by the calling device's own key, so "logging a device in" means getting its public key
|
|
446
|
+
into `user_public_keys` under the right account — which is exactly what the winning poll does.
|
|
447
|
+
That is why the approved answer is the same shape `login` returns: from the client's side the
|
|
448
|
+
two ways in are indistinguishable.
|
|
449
|
+
|
|
450
|
+
- **Only ever show the code on the new device's screen.** The whole attack on this flow is
|
|
451
|
+
someone sending a victim a code and asking them to approve it — a support call, a chat
|
|
452
|
+
message, a "verify your account" email. A code that arrived any way other than off the
|
|
453
|
+
device in front of you is an attack. This is why `info` and `approve` answer with the
|
|
454
|
+
requesting device's name, platform and fingerprint prefix, and why an approval screen that
|
|
455
|
+
shows only the code is doing it wrong: it is asking the user to confirm a number they were
|
|
456
|
+
just told.
|
|
457
|
+
- **The device code is stored only as a SHA-256 hash**, like the ops-token and signup-link
|
|
458
|
+
secrets. It is returned once. A dump of `spfn_auth.device_authorizations` does not let its
|
|
459
|
+
reader finish anyone's login.
|
|
460
|
+
- **The user code is stored in the clear, and that is fine** — it authorizes nothing without
|
|
461
|
+
an approver who is already signed in. It is drawn from an alphabet with no `0`/`O` or
|
|
462
|
+
`1`/`I`/`L`, since it is read off one screen and typed on another.
|
|
463
|
+
- **A decision is made once.** Approve and deny move the record from `pending` and nowhere
|
|
464
|
+
else, so a second approval, a deny after an approve, or two approvals racing each other all
|
|
465
|
+
get `DeviceAuthAlreadyHandledError` (409) — a refusal is never undone.
|
|
466
|
+
- **The approval is one-shot.** The poll that registers the key spends the record in the same
|
|
467
|
+
statement that reads it, so of two polls arriving together exactly one registers the key and
|
|
468
|
+
the other is answered as if the code were unknown.
|
|
469
|
+
- **A spent code and a code that never existed answer identically** (`DeviceAuthNotFoundError`,
|
|
470
|
+
404). Saying "that one was real, but it is used up" is the difference between guessing at
|
|
471
|
+
random and knowing a guess landed. Every route that accepts a code is rate limited for the
|
|
472
|
+
same reason: `start` and `poll` per IP, `info` / `approve` / `deny` per IP *and* per calling
|
|
473
|
+
account.
|
|
474
|
+
- **Expiry outranks state.** A code that sat past its TTL is expired whatever it says, so an
|
|
475
|
+
approval nobody collected in time registers nothing. The TTL travels in the statement that
|
|
476
|
+
moves the record, not only in the read before it, so a code cannot be spent by a poll that
|
|
477
|
+
read it a moment before it died.
|
|
478
|
+
- **A global revocation reaches the codes too.** `revoke-all`, a password change and a
|
|
479
|
+
deletion request each refuse the account's live device authorizations, so an approval nobody
|
|
480
|
+
collected cannot register a fresh key seconds after the user signed everything out — which
|
|
481
|
+
would hand one back to exactly the device they were cutting off. Revoking a single key,
|
|
482
|
+
logging out and rotating a key do not: those name one device, and the waiting one is not it.
|
|
483
|
+
- **The poll re-checks the account.** It is a login, so it refuses a suspended or
|
|
484
|
+
pending-deletion account with the same errors `/_auth/login` does. Approval and collection
|
|
485
|
+
are separate moments, and what the account is when the key is registered is what counts.
|
|
486
|
+
- **`start` bounds what it stores.** It is the one route that takes key material from a caller
|
|
487
|
+
who cannot authenticate, so `publicKey`, `keyId` and `fingerprint` carry length limits —
|
|
488
|
+
generous next to a real key (an RSA-2048 SPKI is 392 base64 characters against a 2048 limit)
|
|
489
|
+
and small next to the megabyte that would otherwise sit in a table no job clears.
|
|
490
|
+
- **Clock skew cannot affect this.** Every timestamp in the decision is the server's. The
|
|
491
|
+
`expiresAtMillis` in the start response is for the waiting device's countdown display, and
|
|
492
|
+
nothing the client believes about the time reaches the server's judgement.
|
|
493
|
+
|
|
494
|
+
Two knobs, both announced to the waiting device in the start response and therefore resolved
|
|
495
|
+
at lifecycle time rather than read per call:
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
createAuthLifecycle({
|
|
499
|
+
deviceAuth: {
|
|
500
|
+
ttlMs: 10 * 60 * 1000, // how long a code lives. default 10 minutes
|
|
501
|
+
intervalMs: 5000, // poll interval the server asks for. default 5s
|
|
502
|
+
},
|
|
503
|
+
})
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
No job sweeps the table. Rows are judged by `expiresAt` whenever they are read or moved, so a
|
|
507
|
+
stale row authorizes nothing; it only keeps its user code out of circulation, and 31⁸ codes do
|
|
508
|
+
not run out.
|
|
509
|
+
|
|
208
510
|
### Registered devices (key management)
|
|
209
511
|
|
|
512
|
+
A [passkey](#passkeys-webauthn) is **not** one of these keys: it is a credential that proves
|
|
513
|
+
identity at sign-in, after which an ordinary device key is registered exactly as a password
|
|
514
|
+
login registers one.
|
|
515
|
+
|
|
210
516
|
Keys are per-device, so a login never revokes the previous key and they accumulate on purpose.
|
|
211
517
|
`listKeys` / `revokeKey` / `revokeAllKeys` are what let the account owner see what accumulated and
|
|
212
518
|
cut off anything they no longer recognise.
|
|
@@ -236,13 +542,19 @@ await authApi.revokeAllKeys.call({ body: {} }); // other devices o
|
|
|
236
542
|
await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // everything
|
|
237
543
|
```
|
|
238
544
|
|
|
239
|
-
> **All three are POST with their arguments in the body, deliberately.** The mobile auth
|
|
545
|
+
> **All three key-management operations are POST with their arguments in the body, deliberately.** The mobile auth
|
|
240
546
|
> profile (clientProofV1) signs the request body, and `canonical-json` fixes exactly how those
|
|
241
547
|
> bytes are written. A `GET` has no body to sign, and a value in the path has no such rule —
|
|
242
548
|
> client and server could disagree on the signed string over percent-encoding, a trailing
|
|
243
549
|
> slash, or a proxy rewrite alone, and the request would be refused with nothing in the logs
|
|
244
|
-
> naming the cause.
|
|
245
|
-
|
|
550
|
+
> naming the cause. Proof-bearing auth operations are shaped this way; the unproven,
|
|
551
|
+
> bodyless `core.time` synchronization prerequisite is the explicit exception.
|
|
552
|
+
|
|
553
|
+
- **A key must be the type its algorithm names.** A P-256 SPKI declared `RS256`, an RSA key
|
|
554
|
+
declared `ES256`, and a curve other than P-256 declared `ES256` are each refused 400 with
|
|
555
|
+
`KeyAlgorithmMismatchError` on register, login, rotate and device start — the algorithm is
|
|
556
|
+
stored beside the key and read back at proof verification, so a mismatch accepted at
|
|
557
|
+
enrollment would surface only once the device already believed it was enrolled.
|
|
246
558
|
- **The public key never leaves the server**, and the fingerprint is truncated to 8 characters.
|
|
247
559
|
The list exists to recognise a device and point at it; the full fingerprint is what a native
|
|
248
560
|
sign-in sends as its nonce, not a label.
|
|
@@ -254,6 +566,10 @@ await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // every
|
|
|
254
566
|
- **`revokeAllKeys` spares the calling device unless you ask otherwise**, so the common case is
|
|
255
567
|
"sign out my other devices". `includeCurrent: true` is the full sign-out — until now reachable
|
|
256
568
|
only as a side effect of changing a password, which nobody does for that reason.
|
|
569
|
+
- **It also refuses device-code approvals still in flight**, in both modes, because an approved
|
|
570
|
+
code is a key that has not been handed out yet: the next poll would register a fresh active one
|
|
571
|
+
and undo the sign-out. `revokedCount` still counts keys only — a code nobody collected was
|
|
572
|
+
never a session. See [Device-code login](#device-code-login).
|
|
257
573
|
- **A key id you do not own answers 404** (`KeyNotFoundError`). Every lookup is scoped by user, so
|
|
258
574
|
the answer is only ever "not yours" and reveals nothing about other accounts.
|
|
259
575
|
- **Revocation takes effect immediately.** `authenticate` reads the key from the database on every
|
|
@@ -278,6 +594,280 @@ is still active is the one
|
|
|
278
594
|
exception: it stays a no-op success, so repeated logins from the same device keep working, and an
|
|
279
595
|
expired-but-active key has its expiry extended by the sign-in that proved the identity again.
|
|
280
596
|
|
|
597
|
+
### Passkeys (WebAuthn)
|
|
598
|
+
|
|
599
|
+
A passkey is an **optional additional credential** on an account, alongside a password and a
|
|
600
|
+
linked social account rather than in place of either. Enroll one from a session that already
|
|
601
|
+
exists; sign in with it afterwards without typing an identifier at all.
|
|
602
|
+
|
|
603
|
+
```
|
|
604
|
+
enroll → register/options (session) → the browser mints a credential → register/verify
|
|
605
|
+
sign in → login/options (public) → the browser picks a credential → login/verify
|
|
606
|
+
manage → list / rename / revoke
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
**A passkey is not a device key.** The assertion proves *who* is asking; the device key the
|
|
610
|
+
Next.js proxy registers right after it is what every later request is signed with, exactly as
|
|
611
|
+
after a password login. Nothing in clientProofV1, in the JWT path, or in
|
|
612
|
+
[Registered devices](#registered-devices-key-management) changes because a session started
|
|
613
|
+
this way — a passkey sign-in produces the same `LoginResult` and the same key row as `login`.
|
|
614
|
+
|
|
615
|
+
#### Setup
|
|
616
|
+
|
|
617
|
+
```bash
|
|
618
|
+
# .env.server — nothing is required; these are the overrides
|
|
619
|
+
SPFN_AUTH_PASSKEY_RP_ID=example.com
|
|
620
|
+
SPFN_AUTH_PASSKEY_ORIGINS=https://app.example.com,https://admin.example.com
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
With neither set, the relying party is derived from `{NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}`:
|
|
624
|
+
its host becomes the rpId and its origin becomes the single allowed origin. That is the whole
|
|
625
|
+
configuration for a one-origin app.
|
|
626
|
+
|
|
627
|
+
| Var | File | Notes |
|
|
628
|
+
|-----|------|-------|
|
|
629
|
+
| `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** |
|
|
630
|
+
| `SPFN_AUTH_PASSKEY_RP_NAME` | `.env.server` | name the authenticator's own prompt shows. Default: the rpId |
|
|
631
|
+
| `SPFN_AUTH_PASSKEY_ORIGINS` | `.env.server` | comma-separated full origins allowed to run a ceremony. Default: the app URL's origin |
|
|
632
|
+
| `SPFN_AUTH_PASSKEY_USER_VERIFICATION` | `.env.server` | `preferred` (default) or `required`. `discouraged` refuses boot |
|
|
633
|
+
| `SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS` | `.env.server` | default `300` — one ceremony at the authenticator, not an abandoned tab |
|
|
634
|
+
| `SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES` | `.env.server` | default `10` — see [the recent-authentication gate](#the-recent-authentication-gate) |
|
|
635
|
+
|
|
636
|
+
Two rules on those origins, **checked at boot** and refused with `PasskeyConfigError`:
|
|
637
|
+
|
|
638
|
+
- each origin must be `https`, and `localhost` is the one host a browser treats as a secure
|
|
639
|
+
context over plain `http` — so `http://localhost:3000` is legal and `http://app.example.com`
|
|
640
|
+
is not;
|
|
641
|
+
- each origin's host must be the rpId or a subdomain of it, because the browser will refuse
|
|
642
|
+
the ceremony otherwise.
|
|
643
|
+
|
|
644
|
+
The check runs at `initializeAuth`, deliberately: every one of these values makes *every*
|
|
645
|
+
passkey operation fail, the drift is between environments, and the deploy that introduces it
|
|
646
|
+
is where it has to surface — not the first sign-in after it.
|
|
647
|
+
|
|
648
|
+
**Boot is only refused for a configuration you wrote.** If no `SPFN_AUTH_PASSKEY_*` variable
|
|
649
|
+
is set, the derived relying party can still be unusable — `SPFN_APP_URL=http://192.168.1.5:3000`
|
|
650
|
+
so a phone on the same network can reach your laptop, say, which is neither https nor
|
|
651
|
+
localhost. Refusing to start over a feature nobody asked for would take that app down to fix
|
|
652
|
+
something it does not use, so it is logged once instead and only a ceremony fails. Set any
|
|
653
|
+
passkey variable and the same configuration refuses to start. This is the posture
|
|
654
|
+
[the OAuth callback origin check](#oauth-callback-origin-web-app-host--rewrite) already takes.
|
|
655
|
+
|
|
656
|
+
#### Enrolling, from a Next.js client component
|
|
657
|
+
|
|
658
|
+
```tsx
|
|
659
|
+
'use client';
|
|
660
|
+
import { authApi } from '@spfn/auth';
|
|
661
|
+
import { enrollPasskey, isPasskeySupported } from '@spfn/auth/client';
|
|
662
|
+
|
|
663
|
+
async function addPasskey()
|
|
664
|
+
{
|
|
665
|
+
const result = await enrollPasskey(authApi, { label: 'MacBook Touch ID' });
|
|
666
|
+
|
|
667
|
+
if (!result.ok)
|
|
668
|
+
{
|
|
669
|
+
// 'unsupported' | 'cancelled' | 'error' — 'cancelled' is not an error to show
|
|
670
|
+
return result.reason === 'cancelled' ? undefined : showError(result.reason);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
showAdded(result.passkeyId, result.label);
|
|
674
|
+
}
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
`isPasskeySupported()` is what decides whether to render the button at all.
|
|
678
|
+
|
|
679
|
+
#### Signing in, with conditional UI
|
|
680
|
+
|
|
681
|
+
The passkey appears in the browser's ordinary autofill dropdown. That needs an input whose
|
|
682
|
+
`autocomplete` ends in `webauthn`, and a `signInWithPasskey` call started **when the form
|
|
683
|
+
renders**, not on a click:
|
|
684
|
+
|
|
685
|
+
```tsx
|
|
686
|
+
'use client';
|
|
687
|
+
import { useEffect } from 'react';
|
|
688
|
+
import { authApi } from '@spfn/auth';
|
|
689
|
+
import { isConditionalMediationAvailable, signInWithPasskey } from '@spfn/auth/client';
|
|
690
|
+
|
|
691
|
+
export function SignInForm()
|
|
692
|
+
{
|
|
693
|
+
useEffect(() =>
|
|
694
|
+
{
|
|
695
|
+
void (async () =>
|
|
696
|
+
{
|
|
697
|
+
if (!await isConditionalMediationAvailable()) return;
|
|
698
|
+
|
|
699
|
+
const result = await signInWithPasskey(authApi, { conditional: true });
|
|
700
|
+
if (result.ok) router.replace('/');
|
|
701
|
+
})();
|
|
702
|
+
}, []);
|
|
703
|
+
|
|
704
|
+
return (
|
|
705
|
+
<form>
|
|
706
|
+
<input name="email" autoComplete="username webauthn" />
|
|
707
|
+
<input name="password" type="password" autoComplete="current-password" />
|
|
708
|
+
</form>
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
Where conditional mediation is missing, render a visible "Sign in with a passkey" button that
|
|
714
|
+
calls `signInWithPasskey(authApi)` instead.
|
|
715
|
+
|
|
716
|
+
Both helpers answer with a discriminated union and **never throw a cancellation**: a person
|
|
717
|
+
who dismisses the system sheet raises `NotAllowedError`, and so does a person whose
|
|
718
|
+
authenticator had nothing to offer — neither is an application error, and code that has to
|
|
719
|
+
tell them apart by re-reading `error.name` gets it wrong once and shows a red banner to
|
|
720
|
+
someone who simply changed their mind.
|
|
721
|
+
|
|
722
|
+
| result | meaning |
|
|
723
|
+
|--------|---------|
|
|
724
|
+
| `{ ok: true, ... }` | signed in / enrolled; the rest of the object is the server's answer |
|
|
725
|
+
| `{ ok: false, reason: 'unsupported' }` | this browser has no WebAuthn; nothing was sent to the server |
|
|
726
|
+
| `{ ok: false, reason: 'cancelled' }` | the person dismissed the enrollment prompt |
|
|
727
|
+
| `{ ok: false, reason: 'no-credential' }` | sign-in: the authenticator offered nothing, or the person dismissed it |
|
|
728
|
+
| `{ ok: false, reason: 'error', error }` | anything else, with the original error attached |
|
|
729
|
+
|
|
730
|
+
#### The recent-authentication gate
|
|
731
|
+
|
|
732
|
+
Adding a credential is adding a way in, and removing one can lock an account. Both are refused
|
|
733
|
+
unless the caller has recently proved themselves, in one of two ways:
|
|
734
|
+
|
|
735
|
+
- **the device key this request is signed with was registered within
|
|
736
|
+
`SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES`** — that is when this device last presented a
|
|
737
|
+
credential, and it needs no new state; or
|
|
738
|
+
- **the body carries `currentPassword`** and it verifies.
|
|
739
|
+
|
|
740
|
+
Otherwise: **403 with `code: 'RECENT_AUTH_REQUIRED'`**. Branch on that code to prompt for the
|
|
741
|
+
password and retry — it is a stable field, not a message to match on.
|
|
742
|
+
|
|
743
|
+
An account with no password stored **cannot** satisfy the gate with a password, however
|
|
744
|
+
plausible the value; it has to sign in again. The comparison still runs, against a dummy hash,
|
|
745
|
+
so "no password on file" costs exactly what "wrong password" costs — otherwise response time
|
|
746
|
+
becomes an oracle for which accounts are OAuth-only.
|
|
747
|
+
|
|
748
|
+
#### Managing passkeys
|
|
749
|
+
|
|
750
|
+
```typescript
|
|
751
|
+
const { passkeys } = await authApi.listPasskeys.call({ body: {} });
|
|
752
|
+
// → [{ passkeyId, label, deviceType, backedUp, transports, createdAt, lastUsedAt }]
|
|
753
|
+
|
|
754
|
+
await authApi.renamePasskey.call({ body: { passkeyId, label: 'Old iPhone' } });
|
|
755
|
+
await authApi.revokePasskey.call({ body: { passkeyId } });
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
- **Neither `credentialId` nor the public key is ever returned.** They are what an
|
|
759
|
+
authenticator is addressed by; the list exists to let someone recognise a credential and
|
|
760
|
+
point at it, which the label, the device type and the last-used moment do.
|
|
761
|
+
- **`deviceType` is `singleDevice` or `multiDevice`**, and `backedUp` says whether a
|
|
762
|
+
multi-device credential actually has been. "This one only exists on that phone" is what the
|
|
763
|
+
owner needs before revoking the other entry.
|
|
764
|
+
- **Revocation is soft, and the credential id stays reserved for good.** A credential someone
|
|
765
|
+
cut off can never be enrolled again — not on another account, and not on the same one
|
|
766
|
+
(`PasskeyAlreadyRegisteredError`, 409). Re-enrolling means a fresh credential.
|
|
767
|
+
- **A passkey id you do not own answers 404.** Every lookup is owner-scoped, so the answer is
|
|
768
|
+
only ever "not yours".
|
|
769
|
+
- **Renaming has no recent-authentication gate**: a label is display only and nothing is
|
|
770
|
+
authorized by it.
|
|
771
|
+
|
|
772
|
+
#### Recovery — read this before shipping a passkey-only sign-up
|
|
773
|
+
|
|
774
|
+
The ways back into an account are: a live passkey, a password, a linked social account, or a
|
|
775
|
+
verified email address — the last one because [Password reset](#password-reset-verified-email)
|
|
776
|
+
can always give such an account a password back. Nothing else; support cannot restore an
|
|
777
|
+
account that has none of the four.
|
|
778
|
+
|
|
779
|
+
That is why **revoking the last live passkey is refused (409, `code:
|
|
780
|
+
'LAST_RECOVERY_CREDENTIAL'`) when the account has no password, no linked social account and no
|
|
781
|
+
verified email.** A phone-only account is the case that reaches it. The refusal is not
|
|
782
|
+
paternalism; it is the absence of an undo. Branch on that code to offer "set a password
|
|
783
|
+
first", "link an account first", or "confirm your email address first".
|
|
784
|
+
|
|
785
|
+
The same fact should shape your sign-up: an account created without a password, without an
|
|
786
|
+
email and given one passkey has exactly one way in, and losing the device loses the account.
|
|
787
|
+
Ask for a password, an address, or a social link before, or shortly after, the passkey.
|
|
788
|
+
|
|
789
|
+
#### How the ceremonies are kept honest
|
|
790
|
+
|
|
791
|
+
- **Discoverable credentials only** (`residentKey: 'required'`). `login/options` takes an empty
|
|
792
|
+
body — `additionalProperties: false`, so an `email` field is a 400 rather than something
|
|
793
|
+
quietly ignored — and always answers with an empty `allowCredentials`. There is no input
|
|
794
|
+
that could make its answer differ by whether an account exists.
|
|
795
|
+
- **A revoked credential and one that was never here answer identically** on `login/verify`.
|
|
796
|
+
Anything else would say whether this account once had it.
|
|
797
|
+
- **Challenges are one-time database rows**, spent by a single conditional `UPDATE`. Two
|
|
798
|
+
verifies arriving with the same challenge produce one winner and one refusal, across
|
|
799
|
+
instances. A challenge is bound to its ceremony (`registration` / `authentication`) and, for
|
|
800
|
+
enrollment, to the account that minted it.
|
|
801
|
+
- **A refusal leaves the challenge live.** Spending happens inside the transaction that writes
|
|
802
|
+
what it authorizes, so a failure rolls it back and the ceremony is retryable; only a success
|
|
803
|
+
is unrepeatable.
|
|
804
|
+
- **A signature counter that goes backwards refuses the sign-in and leaves the row alone.** It
|
|
805
|
+
is the signal a cloned authenticator would produce — but a synced passkey reports 0 forever
|
|
806
|
+
and a restored device can hit it, so auto-revoking would lock people out on a false
|
|
807
|
+
positive. The refusal is logged at `warn` with the passkey id; a human decides what it meant.
|
|
808
|
+
- **Attestation is `none`.** Verifying an attestation statement would tell us which
|
|
809
|
+
authenticator model was used and nothing about who is holding it.
|
|
810
|
+
|
|
811
|
+
#### Errors
|
|
812
|
+
|
|
813
|
+
| error | status | `code` | when |
|
|
814
|
+
|-------|--------|--------|------|
|
|
815
|
+
| `PasskeyChallengeError` | 401 | — | the challenge is unknown, expired, already spent, of the other ceremony, or of another account |
|
|
816
|
+
| `PasskeyVerificationError` | 401 | — | origin, rpId, signature or counter — and, on sign-in, an unknown or revoked credential |
|
|
817
|
+
| `PasskeyNotFoundError` | 404 | — | a passkey the caller does not own, or one already revoked |
|
|
818
|
+
| `PasskeyAlreadyRegisteredError` | 409 | — | that credential is on file for some account, revoked ones included |
|
|
819
|
+
| `RecentAuthenticationRequiredError` | 403 | `RECENT_AUTH_REQUIRED` | the session proved itself too long ago and carried no password |
|
|
820
|
+
| `LastRecoveryCredentialError` | 409 | `LAST_RECOVERY_CREDENTIAL` | revoking it would leave no way back in |
|
|
821
|
+
| `PasskeyConfigError` | boot | — | an origin off the rpId or not https, or an unsupported user-verification value |
|
|
822
|
+
|
|
823
|
+
#### Events
|
|
824
|
+
|
|
825
|
+
`passkeyEnrolledEvent` (`auth.passkey.enrolled`: `userId`, `passkeyId`, `label?`) and
|
|
826
|
+
`passkeyRevokedEvent` (`auth.passkey.revoked`: `userId`, `passkeyId`, `reason`) fire after
|
|
827
|
+
commit. `authLoginEvent.provider` gains `'passkey'`. Subscribe to the first to tell the owner
|
|
828
|
+
a new way into their account appeared — which is what it is.
|
|
829
|
+
|
|
830
|
+
#### The case table
|
|
831
|
+
|
|
832
|
+
The behaviour above is asserted row by row in
|
|
833
|
+
`src/__tests__/integration/passkeys.test.ts`; each `it` is named for its row.
|
|
834
|
+
|
|
835
|
+
| row | situation | outcome |
|
|
836
|
+
|-----|-----------|---------|
|
|
837
|
+
| E1 | fresh session, no passkeys | 200, empty `excludeCredentials` |
|
|
838
|
+
| E2 | session key 11 min old, no password | 403 `RECENT_AUTH_REQUIRED` |
|
|
839
|
+
| E3 / E4 | 11 min old, correct / wrong password | 200 / 403 — byte-identical to E2 |
|
|
840
|
+
| E5 | no password on the account, 11 min old | 403; a password can never speak for it |
|
|
841
|
+
| E6 | valid attestation | 200; row written, challenge spent, event emitted |
|
|
842
|
+
| E7 / E8 | challenge replayed / expired | 401; one row, no row |
|
|
843
|
+
| E9 / E10 | another account's / the other ceremony's challenge | 401 |
|
|
844
|
+
| E11 | credential already on some account | 409 |
|
|
845
|
+
| E12 / E13 | wrong origin / wrong rpId | 401 |
|
|
846
|
+
| E14 | two live passkeys | both listed in `excludeCredentials` |
|
|
847
|
+
| E15 | label empty or over 64 chars | 400; challenge stays live |
|
|
848
|
+
| E16 | two concurrent verifies, one challenge | one 200, one 401, one row |
|
|
849
|
+
| L1 / L2 | empty options body / an `email` in it | 200 with empty `allowCredentials` / 400 |
|
|
850
|
+
| L3 | valid assertion | 200, same answer as `login`; counter and device key move |
|
|
851
|
+
| L4 / L5 | revoked / unknown credential | 401, byte-identical |
|
|
852
|
+
| L6 / L7 / L8 | challenge spent / expired / wrong kind | 401; no device key |
|
|
853
|
+
| L9 / L17 | bad signature / wrong origin | 401; counter unmoved |
|
|
854
|
+
| L10 | counter went backwards | 401; row untouched, warn logged, not revoked |
|
|
855
|
+
| L11 | synced passkey reporting 0 both times | 200 |
|
|
856
|
+
| L12 / L13 | disabled / pending deletion | 403, the same errors password login gives |
|
|
857
|
+
| L14 | device-key fields missing (proxy bypassed) | 400; challenge stays live |
|
|
858
|
+
| L15 | an old session key named in the body | it is revoked as the new one is registered |
|
|
859
|
+
| L16 | two concurrent verifies, one assertion | one 200, one 401, one device key |
|
|
860
|
+
| M1 | 2 live + 1 revoked | 2 entries, no credential id, no public key |
|
|
861
|
+
| M2 / M9 | someone else's / an already revoked passkey | 404 |
|
|
862
|
+
| M3 / M4 | rename / revoke on a recent session | 200; revoke emits its event |
|
|
863
|
+
| M5 | revoke on an 11-minute-old session | 403 `RECENT_AUTH_REQUIRED` |
|
|
864
|
+
| M6 / M7 / M8 | last passkey, no password: alone / with a social account / with a second passkey | 409 / 200 / 200 |
|
|
865
|
+
| M10 | re-enrolling a revoked credential | 409 |
|
|
866
|
+
| K1 / K4 | two concurrent revokes: 2 passkeys and nothing else / 1 passkey and a password | 200 + 409 / 200 + 404 |
|
|
867
|
+
| K2 / K3 | last passkey, no password: with a verified email / phone-only | 200 / 409 |
|
|
868
|
+
|
|
869
|
+
Configuration rows C1–C6 are in `src/__tests__/unit/passkey-config.test.ts`.
|
|
870
|
+
|
|
281
871
|
### Writing protected routes (route DSL)
|
|
282
872
|
|
|
283
873
|
This is the current SPFN route DSL — `route.<method>().input().use().skip().handler()` registered
|
|
@@ -360,6 +950,16 @@ Both convenience URL APIs seal `metadata` into the encrypted OAuth state. On a n
|
|
|
360
950
|
signup, the callback passes it to `beforeRegister` and `authRegisterEvent`; existing-account
|
|
361
951
|
logins do not run the registration hook.
|
|
362
952
|
|
|
953
|
+
`returnUrl` must be a path inside your app — absolute URLs, `//host`, `..`, a backslash, and a
|
|
954
|
+
tab/CR/LF (which a URL parser strips, turning `/<tab>/host` into `//host`) are refused, so a
|
|
955
|
+
real login cannot become an open redirect. The start seams answer an unsafe value with a 400
|
|
956
|
+
`ValidationError`; the seams that already hold a logged-in user replace the destination instead
|
|
957
|
+
of failing the login — `OAuthCallback` navigates to `/` and `createOAuthCallbackHandler`
|
|
958
|
+
redirects to its `defaultRedirectUrl` (`/` unless you pass one). The rule is exported as
|
|
959
|
+
`isSafeReturnPath` from `@spfn/auth/server`, `@spfn/auth/nextjs/server`, and
|
|
960
|
+
`@spfn/auth/nextjs/client` for apps that validate a destination before calling
|
|
961
|
+
`getGoogleOAuthUrl`.
|
|
962
|
+
|
|
363
963
|
Built-in OAuth routes: `POST /_auth/oauth/google/url`, `GET /_auth/oauth/google` (redirect),
|
|
364
964
|
`GET /_auth/oauth/google/callback`, `POST /_auth/oauth/finalize`, `GET /_auth/oauth/providers`,
|
|
365
965
|
plus the provider-generic `POST /_auth/oauth/start`. `getGoogleAccessToken(userId)` returns a
|
|
@@ -446,9 +1046,25 @@ process), which differs from the API process in a split deployment — the callb
|
|
|
446
1046
|
matches every `spfn_oauth_csrf*` cookie candidate against the state nonce, so no PORT
|
|
447
1047
|
coordination is needed.
|
|
448
1048
|
|
|
1049
|
+
An explicit `SPFN_AUTH_<PROVIDER>_REDIRECT_URI` is checked when the server boots, because the
|
|
1050
|
+
value used to be read lazily on the first OAuth request and a wrong one surfaced much later as
|
|
1051
|
+
a CSRF refusal nobody traced back to it. A value that does not parse, or whose origin is not the
|
|
1052
|
+
web app origin, or whose path is not `/_auth/oauth/<provider>/callback`, refuses to start — one
|
|
1053
|
+
error naming every offending variable:
|
|
1054
|
+
|
|
1055
|
+
```
|
|
1056
|
+
SPFN_AUTH_GOOGLE_REDIRECT_URI must be on the web app origin (http://localhost:3790) at
|
|
1057
|
+
/_auth/oauth/google/callback: the callback's CSRF cookie is host-only and /_auth/* is forwarded
|
|
1058
|
+
to the API by the app's rewrite. Unset it to use the default, fix the origin, or set
|
|
1059
|
+
SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK=off for a deployment that deliberately terminates the
|
|
1060
|
+
callback elsewhere.
|
|
1061
|
+
```
|
|
1062
|
+
|
|
449
1063
|
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
|
-
|
|
1064
|
+
cookie on the **API host**. If you use that flow in a split deployment, set the corresponding
|
|
1065
|
+
provider redirect URI explicitly to the API host callback **and**
|
|
1066
|
+
`SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK=off` — that is the one deployment the check is wrong
|
|
1067
|
+
about, and `off` is the only value that disables it.
|
|
452
1068
|
|
|
453
1069
|
### Native social sign-in (mobile / web id_token)
|
|
454
1070
|
|
|
@@ -696,6 +1312,155 @@ export default async function AdminPage()
|
|
|
696
1312
|
Also exported: `getAuthSessionData`, `getUserRole`, `getUserPermissions`, `hasAnyRole`,
|
|
697
1313
|
`hasAnyPermission`, the OAuth pending-session helpers, and `createOAuthCallbackHandler`.
|
|
698
1314
|
|
|
1315
|
+
### Emptying the cookie jar from a route handler or middleware
|
|
1316
|
+
|
|
1317
|
+
`clearSession()` works where `next/headers` is writable. The page that answers *the API
|
|
1318
|
+
refused your session* is usually a route handler or middleware holding a `NextResponse`
|
|
1319
|
+
instead — `clearSessionCookies(response)` expires the session, key-id, OAuth-pending and
|
|
1320
|
+
CSRF cookies on it and returns the same response, so the call chains:
|
|
1321
|
+
|
|
1322
|
+
```typescript
|
|
1323
|
+
import { clearSessionCookies } from '@spfn/auth/nextjs/server';
|
|
1324
|
+
|
|
1325
|
+
export function GET(request: NextRequest)
|
|
1326
|
+
{
|
|
1327
|
+
return clearSessionCookies(NextResponse.redirect(new URL('/login', request.url)));
|
|
1328
|
+
}
|
|
1329
|
+
```
|
|
1330
|
+
|
|
1331
|
+
Never spell the names in your app. They carry an `SPFN_PORT` suffix (`spfn_session_4001`),
|
|
1332
|
+
so two dev instances do not overwrite each other's cookies, and a hand-written copy of that
|
|
1333
|
+
rule clears the wrong cookie without failing. Read them from `sessionCookieNames()`, which
|
|
1334
|
+
returns `{ session, keyId, oauthPending, csrf }` at call time.
|
|
1335
|
+
|
|
1336
|
+
## CSRF protection
|
|
1337
|
+
|
|
1338
|
+
Cookie-authenticated mutations carry a CSRF token by default. Nothing to write: the
|
|
1339
|
+
Next.js proxy issues the token with the session and the api client sends it back.
|
|
1340
|
+
|
|
1341
|
+
**What it protects.** The session cookie is `SameSite=Lax`, which already blocks the
|
|
1342
|
+
classic cross-site form POST. What remains is what Lax does not cover: a sibling
|
|
1343
|
+
subdomain that can write cookies on your parent domain (an XSS on `blog.example.com`
|
|
1344
|
+
against `app.example.com`), browsers that predate or mis-implement Lax, and a domain
|
|
1345
|
+
layout that drifts into `SameSite=None` later. This closes those.
|
|
1346
|
+
|
|
1347
|
+
**What it does not protect.** Nothing here helps against XSS on your own origin.
|
|
1348
|
+
Script running on your origin can read the token cookie and call your API as the
|
|
1349
|
+
user — that is true of every CSRF scheme, and no token design changes it. Same-origin
|
|
1350
|
+
XSS is out of scope; Content-Security-Policy and output escaping are the answer to it.
|
|
1351
|
+
|
|
1352
|
+
### How it works
|
|
1353
|
+
|
|
1354
|
+
- On login, OAuth finalize, key rotation and every session renewal, the proxy sets
|
|
1355
|
+
`spfn_csrf` — a readable (non-HttpOnly) cookie holding only an HMAC of the session's
|
|
1356
|
+
key id, keyed by a subkey derived from `SPFN_AUTH_SESSION_SECRET`. No new variable,
|
|
1357
|
+
and the raw session secret is never used as the token key. Sessions that predate
|
|
1358
|
+
the feature get one on their first authenticated response, so upgrading does not
|
|
1359
|
+
require anyone to sign in again.
|
|
1360
|
+
- The api client mirrors the cookie into the `x-spfn-csrf` header on **every** RPC call,
|
|
1361
|
+
GET-shaped ones included — see "Which requests are checked" for why it cannot narrow
|
|
1362
|
+
that itself. Where the header is *checked* is the proxy's decision, not the client's.
|
|
1363
|
+
- The proxy **recomputes** the expected value from the session it just unsealed and
|
|
1364
|
+
compares it to the header, in constant time. It never compares the cookie to the
|
|
1365
|
+
header — that is the classic double-submit weakness, and it is exactly what a
|
|
1366
|
+
sibling subdomain defeats by tossing a cookie it chose. A tossed cookie fails here.
|
|
1367
|
+
- The token derives from the session key id, so rotating the key invalidates it. The
|
|
1368
|
+
proxy reissues the cookie in the same response that rotates or renews the session.
|
|
1369
|
+
|
|
1370
|
+
The check runs in the proxy, not the backend, because only the proxy knows the
|
|
1371
|
+
request's credential was ambient: it turns the session cookie into a short-lived
|
|
1372
|
+
bearer JWT, so the backend sees `scheme:'bearer'` for cookie callers and for genuine
|
|
1373
|
+
bearer clients alike.
|
|
1374
|
+
|
|
1375
|
+
### Which requests are checked
|
|
1376
|
+
|
|
1377
|
+
Only requests the proxy authenticates from the session cookie, and only when the
|
|
1378
|
+
resolved **route** method is not GET/HEAD/OPTIONS.
|
|
1379
|
+
|
|
1380
|
+
Route method, not the method the browser used to reach the proxy. The api client picks
|
|
1381
|
+
its wire method from whether the input has a body, and holds no route map — that is the
|
|
1382
|
+
point of "no metadata codegen required" — so a mutation with nothing to send travels as
|
|
1383
|
+
GET. `logout` is `POST /_auth/logout`; `revokeOpsToken` is
|
|
1384
|
+
`DELETE /_auth/ops-tokens/:id`, called with only a path param. Both are `GET` on the
|
|
1385
|
+
wire and both are forwarded as the route's real method. A client that withheld the
|
|
1386
|
+
header on GET-shaped calls would therefore 403 them under `enforce`, which is why the
|
|
1387
|
+
contract is "every call carries it" and the proxy alone decides where it is checked.
|
|
1388
|
+
Gating in the proxy on the wire method would be worse still: a cross-site top-level GET
|
|
1389
|
+
navigation *does* carry a `SameSite=Lax` cookie, so every mutation would stay reachable
|
|
1390
|
+
that way.
|
|
1391
|
+
|
|
1392
|
+
Untouched, by construction: requests with no session, direct-to-backend bearer
|
|
1393
|
+
clients, `clientProofV1` mobile callers, machine and ops tokens. None of them pass
|
|
1394
|
+
through this code. A request without a session is answered exactly as before (the
|
|
1395
|
+
backend returns 401) — a CSRF refusal only ever answers an authenticated request, so
|
|
1396
|
+
the refusal itself cannot tell an anonymous caller whether anyone is signed in.
|
|
1397
|
+
|
|
1398
|
+
### Modes
|
|
1399
|
+
|
|
1400
|
+
| Mode | Behaviour |
|
|
1401
|
+
|---|---|
|
|
1402
|
+
| `off` | No check. |
|
|
1403
|
+
| `warn` | **Default.** Allows the request, logs one line per request that would be refused. |
|
|
1404
|
+
| `enforce` | Refuses with `403 {"error":"Forbidden","message":"CSRF token missing or invalid"}`. |
|
|
1405
|
+
|
|
1406
|
+
Existing apps get signal before breakage: unset means `warn`. Watch for
|
|
1407
|
+
`@spfn/auth:interceptor:csrf` lines, then switch on. Apps scaffolded by `spfn init`
|
|
1408
|
+
start at `enforce`.
|
|
1409
|
+
|
|
1410
|
+
```bash
|
|
1411
|
+
# .env.local — read by the Next.js process, where the proxy runs
|
|
1412
|
+
SPFN_AUTH_CSRF=enforce
|
|
1413
|
+
```
|
|
1414
|
+
|
|
1415
|
+
```typescript
|
|
1416
|
+
import { configureAuth } from '@spfn/auth/server';
|
|
1417
|
+
|
|
1418
|
+
configureAuth({
|
|
1419
|
+
csrf: {
|
|
1420
|
+
mode: 'enforce',
|
|
1421
|
+
// Exact backend route paths, params already substituted — not /api/rpc/… URLs.
|
|
1422
|
+
// For endpoints a browser session never calls, e.g. webhook receivers that
|
|
1423
|
+
// authenticate themselves by signature. An exempt path is unprotected for
|
|
1424
|
+
// cookie callers too, so list only endpoints that carry their own auth.
|
|
1425
|
+
exemptPaths: ['/webhooks/stripe'],
|
|
1426
|
+
},
|
|
1427
|
+
});
|
|
1428
|
+
```
|
|
1429
|
+
|
|
1430
|
+
`configureAuth` wins over the environment variable. `enforce` and `warn` both need
|
|
1431
|
+
`SPFN_AUTH_SESSION_SECRET` — sessions need it anyway — and refuse rather than quietly
|
|
1432
|
+
passing everything if it is missing.
|
|
1433
|
+
|
|
1434
|
+
### If a request is refused
|
|
1435
|
+
|
|
1436
|
+
A refusal in a running app almost always means the token cookie is gone or stale while
|
|
1437
|
+
the session is not — cleared by hand or by an extension, or a session that predates this
|
|
1438
|
+
feature. Rotation is not a cause: the response that rotates the key reissues the cookie
|
|
1439
|
+
in the same breath, and one browser has one jar, so other tabs pick the new value up
|
|
1440
|
+
with it.
|
|
1441
|
+
|
|
1442
|
+
Two things repair it, and both are mechanical:
|
|
1443
|
+
|
|
1444
|
+
- **The 403 carries the fix.** The proxy is the one emitting the refusal, so it sets a
|
|
1445
|
+
fresh `spfn_csrf` on that very response. A browser that repeats the mutation succeeds.
|
|
1446
|
+
The refusal is otherwise unchanged — same status, same body.
|
|
1447
|
+
- **Any authenticated response reissues a wrong one.** A response whose request arrived
|
|
1448
|
+
with no CSRF cookie, or with one that no longer matches the session, queues the
|
|
1449
|
+
correct value. A cookie that is merely *present* is not taken as proof it is right.
|
|
1450
|
+
|
|
1451
|
+
**The client does not retry a refused call**, so a user sees one failure before the
|
|
1452
|
+
repaired state takes effect — the framework fixes the browser, not the click.
|
|
1453
|
+
|
|
1454
|
+
**Limitation — calls made from the server.** A Server Component cannot set cookies at
|
|
1455
|
+
all, and Next.js does not forward `Set-Cookie` from a fetch the api client made on the
|
|
1456
|
+
server to the browser. So neither repair reaches the jar when the refused call came from
|
|
1457
|
+
a Server Component, a Server Action or a Route Handler; the next browser-originated
|
|
1458
|
+
request through the proxy is what heals it. Server-side callers otherwise need no
|
|
1459
|
+
change: the api client reads the whole jar through `next/headers`, and an explicit
|
|
1460
|
+
`cookies` option merges over that rather than replacing it. Only a caller that
|
|
1461
|
+
hand-builds a jar somewhere `cookies()` cannot be reached — build time, static
|
|
1462
|
+
generation — has to include the CSRF cookie itself.
|
|
1463
|
+
|
|
699
1464
|
## How do I define roles and permissions?
|
|
700
1465
|
|
|
701
1466
|
Built-in roles: `superadmin` (priority 100), `admin` (80), `user` (10). Built-in permissions:
|
|
@@ -788,10 +1553,26 @@ authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
|
|
|
788
1553
|
});
|
|
789
1554
|
```
|
|
790
1555
|
|
|
791
|
-
|
|
1556
|
+
`authLoginEvent`'s `provider` is `'email'`, `'phone'`, a social provider, `'device'` or
|
|
1557
|
+
`'passkey'`. `'device'` is a [device-code login](#device-code-login), where the account was
|
|
1558
|
+
proven on another device that was already signed in and no credential was presented here;
|
|
1559
|
+
`'passkey'` is a [WebAuthn assertion](#passkeys-webauthn). `authRegisterEvent` accepts
|
|
1560
|
+
neither: a device-code request can only ever be approved by an account that already exists,
|
|
1561
|
+
and a passkey has to be enrolled from a session that already exists, so neither is a signup.
|
|
1562
|
+
|
|
1563
|
+
`passkeyEnrolledEvent` (`auth.passkey.enrolled`) and `passkeyRevokedEvent`
|
|
1564
|
+
(`auth.passkey.revoked`) fire after commit when a passkey is added or retired.
|
|
1565
|
+
|
|
1566
|
+
`authPasswordResetEvent` (`auth.password.reset`: `userId`, `email`) fires after commit when a
|
|
1567
|
+
[password reset](#password-reset-verified-email) completes. Distinct from a password
|
|
1568
|
+
*change*, which is made from a session that already proved itself: this one is made by
|
|
1569
|
+
whoever opened a link in a mailbox, so it is the notice to send the owner.
|
|
1570
|
+
|
|
1571
|
+
Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `AuthPasswordResetPayload`, `InvitationCreatedPayload`,
|
|
792
1572
|
`InvitationAcceptedPayload`, `AuthDeletionRequestedPayload`, `AuthDeletionCancelledPayload`,
|
|
793
1573
|
`AuthDeletionCompletedPayload`, `OAuthUnlinkedPayload` (`auth.oauth.unlinked` — provider-side
|
|
794
|
-
disconnect, see the OAuth unlink-notify section)
|
|
1574
|
+
disconnect, see the OAuth unlink-notify section), `PasskeyEnrolledPayload`,
|
|
1575
|
+
`PasskeyRevokedPayload`. These events also bind to `@spfn/core/job`
|
|
795
1576
|
jobs via `.on(event)`.
|
|
796
1577
|
|
|
797
1578
|
## Registration gate (`beforeRegister`)
|
|
@@ -826,6 +1607,8 @@ Notes:
|
|
|
826
1607
|
seeding in `initializeAuth()`.
|
|
827
1608
|
- OAuth signups have no client-typed fields unless you pass `metadata` at OAuth start — decide
|
|
828
1609
|
per channel (reject, or allow and collect during onboarding).
|
|
1610
|
+
- `email` arrives trimmed and lower-cased, the same form the account is stored under, so a
|
|
1611
|
+
denylist or domain allowlist keyed on the address is not walked past by capitalizing it.
|
|
829
1612
|
- On the `oauth` channel `email` is the provider-reported address and may be **unverified**
|
|
830
1613
|
(the created account then stores `email` as `null`). The context carries
|
|
831
1614
|
`emailVerified` — an email-based allow/block policy must check it before trusting `email`.
|
|
@@ -860,6 +1643,13 @@ export const opsRouter = createOpsRouter({
|
|
|
860
1643
|
}, { auth: opsTokenAuth });
|
|
861
1644
|
```
|
|
862
1645
|
|
|
1646
|
+
An application that admits both credentials on one route has to tell an ops token from a
|
|
1647
|
+
session JWT *before* either is verified. Do not re-type the literal: `isOpsToken(bearer)`
|
|
1648
|
+
and the `OPS_TOKEN_PREFIX` it tests against are both exported from `@spfn/auth/server`, so
|
|
1649
|
+
the shape has one definition and a copy in application code cannot drift from it.
|
|
1650
|
+
`isOpsToken` answers shape only — it takes a raw header value, returns `false` for a
|
|
1651
|
+
missing or non-string one, and leaves unknown/revoked/expired to verification.
|
|
1652
|
+
|
|
863
1653
|
`opsRoute` comes from `@spfn/core` **0.3.0-beta.2** onwards; before that release an ops
|
|
864
1654
|
route spelled its own `/_ops/` prefix with `route`.
|
|
865
1655
|
|
|
@@ -903,6 +1693,81 @@ same 401, so whether a presented secret ever existed is not inferable. A valid t
|
|
|
903
1693
|
missing a route's scope answers 403 naming only the missing scope. `'*'` grants every
|
|
904
1694
|
scope.
|
|
905
1695
|
|
|
1696
|
+
### One route, two credentials (`opsOrUser`)
|
|
1697
|
+
|
|
1698
|
+
An operator action is scripted today — the CLI, holding an ops token — and driven from an
|
|
1699
|
+
admin console tomorrow, a browser holding a user session. That is one route with two
|
|
1700
|
+
admissible credentials, and neither middleware admits both: `authenticate` refuses an
|
|
1701
|
+
`spfn_ops_` bearer before any scope guard runs (see [Machine principals](#machine-principals-registermachineverifier)),
|
|
1702
|
+
and `opsTokenAuth` admits nothing else.
|
|
1703
|
+
|
|
1704
|
+
```typescript
|
|
1705
|
+
import { opsOrUser, getAuth, getOpsToken } from '@spfn/auth/server';
|
|
1706
|
+
|
|
1707
|
+
export const exportSignups = route.get('/admin/signups/export')
|
|
1708
|
+
.use([opsOrUser({ opsScopes: ['waitlist:read'], permissions: ['admin.waitlist'] })])
|
|
1709
|
+
// or by role: opsOrUser({ opsScopes: ['waitlist:read'], roles: ['admin'] })
|
|
1710
|
+
// or both (AND): opsOrUser({ opsScopes: ['waitlist:read'], roles: ['admin'], permissions: ['admin.waitlist'] })
|
|
1711
|
+
.handler(async (c) =>
|
|
1712
|
+
{
|
|
1713
|
+
// exactly one of these is set
|
|
1714
|
+
const ops = getOpsToken(c.raw); // the ops branch
|
|
1715
|
+
const user = getAuth(c.raw); // the session branch
|
|
1716
|
+
});
|
|
1717
|
+
```
|
|
1718
|
+
|
|
1719
|
+
**The branch is chosen by credential shape, never by caller choice.** The raw
|
|
1720
|
+
`Authorization` bearer is tested with [`isOpsToken`](#ops-tokens-spfn-ops); a match runs
|
|
1721
|
+
`opsTokenAuth` then `requireOpsScope(...opsScopes)`, and everything else — a user JWT,
|
|
1722
|
+
another machine namespace, a malformed header, no header — runs `authenticate` then the
|
|
1723
|
+
session guards. Nothing in the request selects a branch except the credential it presents,
|
|
1724
|
+
so a caller cannot ask for the weaker check.
|
|
1725
|
+
|
|
1726
|
+
**`roles` and `permissions` are AND, roles first.** Two lists only ever narrow. An OR would
|
|
1727
|
+
mean that adding one role voids the whole permission list, which is the opposite of what a
|
|
1728
|
+
reader of the two lists expects. Roles run first because the role is already on the auth
|
|
1729
|
+
context while permissions cost a lookup — so a caller with the wrong role is refused for the
|
|
1730
|
+
wrong role. Giving neither list is a definition-time error, as is an empty `opsScopes`: a
|
|
1731
|
+
configuration that would admit a credential unchecked fails at boot, not on a request.
|
|
1732
|
+
|
|
1733
|
+
**No implicit admin bypass.** Permissions match by name only, and the ops branch has no role
|
|
1734
|
+
concept, so neither branch has a principal that passes by virtue of being an administrator.
|
|
1735
|
+
A refusal is the selected branch's own refusal, with that branch's existing status and
|
|
1736
|
+
message — no error class and no wire message is introduced here.
|
|
1737
|
+
|
|
1738
|
+
`opsOrUser` carries `skips: ['auth']`, so a route using it auto-skips the server-level
|
|
1739
|
+
`auth` middleware exactly as `optionalAuth` and `opsTokenAuth` do. No `.skip(['auth'])` by
|
|
1740
|
+
hand.
|
|
1741
|
+
|
|
1742
|
+
**Cookies.** The backend never reads them. A browser session reaches a route as a Bearer
|
|
1743
|
+
token because `@spfn/auth/nextjs/api` forwards it as one, so through the app a console
|
|
1744
|
+
request is the session rows below; a request carrying only a `Cookie` header is an
|
|
1745
|
+
unauthenticated request here.
|
|
1746
|
+
|
|
1747
|
+
| bearer | branch | answer |
|
|
1748
|
+
|---|---|---|
|
|
1749
|
+
| `spfn_ops_…` valid, scope present (or `*`) | ops | 200; `getOpsToken` set, `getAuth` null |
|
|
1750
|
+
| `spfn_ops_…` valid, scope missing | ops | 403 `Ops token lacks scope` |
|
|
1751
|
+
| `spfn_ops_…` unknown / revoked / expired | ops | 401 `Invalid ops token` (one message for all three) |
|
|
1752
|
+
| `spfn_ops_` prefix alone | ops | 401 `Invalid ops token` |
|
|
1753
|
+
| user JWT valid, permission held | user | 200; `getAuth` set, `getOpsToken` null |
|
|
1754
|
+
| user JWT valid, permission missing | user | 403 `InsufficientPermissionsError` |
|
|
1755
|
+
| user JWT expired / bad signature | user | 401 (the existing `authenticate` message) |
|
|
1756
|
+
| token in a *registered* machine namespace, not ops | user | 401 — the user path admits no machine credential |
|
|
1757
|
+
| malformed bearer / no `Authorization` | user | 401 |
|
|
1758
|
+
| session cookie only, no bearer | user | 401 — see Cookies above |
|
|
1759
|
+
| `x-spfn-auth-profile` + user JWT | user | `PROFILE_REJECTED` (existing `authenticate` behaviour) |
|
|
1760
|
+
| `x-spfn-auth-profile` + ops token | ops | header ignored; `opsTokenAuth` reads `Authorization` only |
|
|
1761
|
+
| ops token on a plain `authenticate` route | — | 401, unchanged |
|
|
1762
|
+
| `opsScopes: []`, or neither `roles` nor `permissions` | — | throws at definition |
|
|
1763
|
+
| server-level `auth` registered | — | auto-skipped on this route |
|
|
1764
|
+
| `roles: ['admin']` only; role admin | user | 200 |
|
|
1765
|
+
| `roles: ['admin']` only; role user | user | 403 `InsufficientRoleError` |
|
|
1766
|
+
| `roles` + `permissions`; role matches, permission missing | user | 403 `InsufficientPermissionsError` |
|
|
1767
|
+
| `roles` + `permissions`; permission held, role wrong | user | 403 `InsufficientRoleError` (role is checked first) |
|
|
1768
|
+
|
|
1769
|
+
`opsOrUser` is available from **0.3.0-beta.11**.
|
|
1770
|
+
|
|
906
1771
|
## Mobile clientProofV1 (`@spfn/auth/client-proof`)
|
|
907
1772
|
|
|
908
1773
|
Server side of the spfn-mobile native SDK auth profile (issue #46; asymmetric revision in
|
|
@@ -917,6 +1782,12 @@ the fixed-string contract error envelope (`PROOF_INVALID` · `PROOF_REPLAYED` ·
|
|
|
917
1782
|
`SESSION_REVOKED` · `PROFILE_REJECTED` · `CONTRACT_UNSUPPORTED` — SDKs classify by code, never
|
|
918
1783
|
HTTP status).
|
|
919
1784
|
|
|
1785
|
+
Before minting the first proof in each client process, the client calls the built-in
|
|
1786
|
+
`GET /_core/time` operation (`core.time`) and establishes its proof epoch from
|
|
1787
|
+
`serverTimeMillis`. This prerequisite is unproven and session-free. If the operation is
|
|
1788
|
+
unavailable or its response cannot be decoded, proof minting fails closed — there is no silent
|
|
1789
|
+
fallback to the device's unsynchronized wall clock.
|
|
1790
|
+
|
|
920
1791
|
- Wire headers (D23, ratified): `x-spfn-auth-profile`, `x-spfn-client-id`, `x-spfn-key-id`,
|
|
921
1792
|
`x-spfn-nonce`, `x-spfn-issued-at`, `x-spfn-proof`, `x-spfn-session`.
|
|
922
1793
|
- A request body must be **byte-canonical** — a body that parses but re-encodes differently is
|
|
@@ -944,6 +1815,29 @@ HTTP status).
|
|
|
944
1815
|
construction or through the `/control/register-key` hook; the private half never reaches
|
|
945
1816
|
the server. No persistence — a production enrollment/rotation story is phase 2.
|
|
946
1817
|
|
|
1818
|
+
### Clock synchronization and proof-time boundaries (contract 0.9.0)
|
|
1819
|
+
|
|
1820
|
+
`core.time` is imported from `@spfn/core` rather than restated by auth: operation ID, method,
|
|
1821
|
+
path, auth class, session requirement, and the closed `ServerTimeResponse` schema all come from
|
|
1822
|
+
the core route contract. The mobile contract records it as a bodyless GET prerequisite and
|
|
1823
|
+
requires one synchronization before the first proof minted in each process. It does not prescribe
|
|
1824
|
+
persistent offset storage, retry sleeps, or device-specific margins.
|
|
1825
|
+
|
|
1826
|
+
The server admission rule remains strict: `age = serverNow - issuedAtMillis` must satisfy
|
|
1827
|
+
`0 <= age <= 300000`. Synchronization does not widen the replay window or change nonce retention.
|
|
1828
|
+
A refused request still leaves its nonce unused; only admission spends it.
|
|
1829
|
+
|
|
1830
|
+
| `serverNow - issuedAtMillis` | Result |
|
|
1831
|
+
|---:|---|
|
|
1832
|
+
| `0` | accept |
|
|
1833
|
+
| `-1` (proof is 1 ms in the future) | `PROOF_EXPIRED` |
|
|
1834
|
+
| `300000` | accept |
|
|
1835
|
+
| `300001` | `PROOF_EXPIRED` |
|
|
1836
|
+
|
|
1837
|
+
When `core.time` cannot be read, the client must surface that synchronization failure and stop
|
|
1838
|
+
before sending a proof. Using `Date.now()` or a platform wall clock as an implicit fallback would
|
|
1839
|
+
reintroduce the skew failure this prerequisite closes.
|
|
1840
|
+
|
|
947
1841
|
### The contract version on the wire (contract 0.6.0)
|
|
948
1842
|
|
|
949
1843
|
A client compiled and shipped separately from the server cannot be fixed by redeploying. Until
|
|
@@ -992,6 +1886,8 @@ Every operation in the exported bundle carries `since` — the contract version
|
|
|
992
1886
|
| `auth.clientProof.handshake`, `echo.send`, `items.list` | 0.1.0 |
|
|
993
1887
|
| `auth.enroll.register`, `auth.enroll.login`, `auth.enroll.oauthNative`, `auth.keys.rotate` | 0.3.0 |
|
|
994
1888
|
| `auth.keys.list`, `auth.keys.revoke`, `auth.keys.revokeAll` | 0.4.1 |
|
|
1889
|
+
| `core.time` | 0.9.0 |
|
|
1890
|
+
| `auth.device.start`, `auth.device.poll`, `auth.device.info`, `auth.device.approve`, `auth.device.deny` | 0.10.0 |
|
|
995
1891
|
|
|
996
1892
|
- **This is history, not policy.** The mobile contract's compatibility policy is `allOrNothing`: one
|
|
997
1893
|
contract version passes or refuses the whole surface, so these three fields change no verdict here.
|
|
@@ -1063,6 +1959,386 @@ app.post('/v1/echo', createClientProofGuard(state), (c) => { /* handler */ });
|
|
|
1063
1959
|
Responses and errors MUST be canonical bytes with the contract envelope — build them with
|
|
1064
1960
|
`encodeCanonicalJson`/`ClientProofRefusal`, never `c.json()` (key order and int64 differ).
|
|
1065
1961
|
|
|
1962
|
+
## Custom auth profiles (`registerAuthProfile`)
|
|
1963
|
+
|
|
1964
|
+
`clientProofV1` is not a special case in the middleware — it is one entry in a registry
|
|
1965
|
+
`authenticate` and `optionalAuth` dispatch on. An app registers its own scheme the same way,
|
|
1966
|
+
without forking the middleware or wrapping it:
|
|
1967
|
+
|
|
1968
|
+
```typescript
|
|
1969
|
+
import { registerAuthProfile, type AuthContext } from '@spfn/auth/server';
|
|
1970
|
+
import { UnauthorizedError } from '@spfn/core/errors';
|
|
1971
|
+
|
|
1972
|
+
// At boot — server.config.ts, before the server starts taking requests.
|
|
1973
|
+
registerAuthProfile('serviceTokenV1', {
|
|
1974
|
+
verify: async (c): Promise<AuthContext> =>
|
|
1975
|
+
{
|
|
1976
|
+
const user = await findServiceAccount(c.req.header('x-acme-service-token'));
|
|
1977
|
+
if (user === null)
|
|
1978
|
+
{
|
|
1979
|
+
// A refusal leaves the verifier as a throw. It reaches the app's
|
|
1980
|
+
// error handler exactly as the Bearer path's does.
|
|
1981
|
+
throw new UnauthorizedError({ message: 'Invalid service token' });
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
return {
|
|
1985
|
+
user,
|
|
1986
|
+
userId: String(user.id),
|
|
1987
|
+
keyId: 'service-token',
|
|
1988
|
+
role: null,
|
|
1989
|
+
locale: 'en',
|
|
1990
|
+
scheme: 'serviceTokenV1',
|
|
1991
|
+
};
|
|
1992
|
+
},
|
|
1993
|
+
});
|
|
1994
|
+
```
|
|
1995
|
+
|
|
1996
|
+
A request naming the profile is then answered by that verifier:
|
|
1997
|
+
|
|
1998
|
+
```http
|
|
1999
|
+
POST /v1/reports
|
|
2000
|
+
x-spfn-auth-profile: serviceTokenV1
|
|
2001
|
+
x-acme-service-token: <the app's own credential>
|
|
2002
|
+
```
|
|
2003
|
+
|
|
2004
|
+
- **Register at boot, before the first request.** The registry is read on every dispatch, so a
|
|
2005
|
+
profile registered later is simply a profile the requests before it did not have. Registration
|
|
2006
|
+
is not frozen after startup — it is a contract, not a runtime check.
|
|
2007
|
+
- **A duplicate name throws**, `clientProofV1` included. Replacing a registered verifier silently
|
|
2008
|
+
is how an import order or a copied profile name swaps the code that decides who is admitted, so
|
|
2009
|
+
there is no override — and no unregistration API for the same reason.
|
|
2010
|
+
- **The verifier must expose a callable `verify`**, and what it resolves must carry a `userId` — a
|
|
2011
|
+
verifier that cannot admit anyone is refused at boot, and a resolve without a principal (`null`,
|
|
2012
|
+
the JS idiom for "no user") is refused as a throw rather than routed as authenticated.
|
|
2013
|
+
- **An unknown profile is still refused** (`PROFILE_REJECTED`, 400): registering one name does not
|
|
2014
|
+
open the header to others.
|
|
2015
|
+
- **Mixing is still refused.** A request carrying both `x-spfn-auth-profile` and `Authorization` is
|
|
2016
|
+
rejected before either path runs; a custom verifier never sees it.
|
|
2017
|
+
- **A verifier's throw propagates**, and only the internal clientProofV1 contract refusal is
|
|
2018
|
+
answered with the canonical envelope. Under `optionalAuth` too: credentials that were presented
|
|
2019
|
+
and refused are never downgraded to anonymous passage — only "presented nothing" continues
|
|
2020
|
+
without an auth context.
|
|
2021
|
+
- **`AuthContext.scheme` is an open union** — `'bearer' | 'clientProofV1' | 'oneTimeToken' | (string
|
|
2022
|
+
& {})`. The built-in names keep their autocomplete and a registered profile names its own scheme.
|
|
2023
|
+
The field stays informational: downstream permission and tenant code takes one principal shape and
|
|
2024
|
+
never branches on how it was produced.
|
|
2025
|
+
|
|
2026
|
+
## Authorization server for MCP clients
|
|
2027
|
+
|
|
2028
|
+
Let Claude Code and Codex connect to your app's `/mcp` endpoint as the user, over the flow
|
|
2029
|
+
they already speak: OAuth 2.1 with dynamic client registration and PKCE.
|
|
2030
|
+
|
|
2031
|
+
```console
|
|
2032
|
+
$ claude mcp add --transport http acme https://api.acme.com/mcp
|
|
2033
|
+
$ claude
|
|
2034
|
+
> /mcp
|
|
2035
|
+
```
|
|
2036
|
+
|
|
2037
|
+
Between those two lines the CLI discovers `/.well-known/oauth-authorization-server`, registers
|
|
2038
|
+
itself, opens a browser at your consent screen, catches the redirect on a loopback port, and
|
|
2039
|
+
exchanges the code for a token. Nobody pastes anything.
|
|
2040
|
+
|
|
2041
|
+
The feature is opt-in and the opt-in is one block:
|
|
2042
|
+
|
|
2043
|
+
```typescript
|
|
2044
|
+
createAuthLifecycle({
|
|
2045
|
+
authorizationServer: {
|
|
2046
|
+
scopes: {
|
|
2047
|
+
'mcp:read': 'Read your projects and tasks',
|
|
2048
|
+
'mcp:write': 'Create and edit your tasks',
|
|
2049
|
+
},
|
|
2050
|
+
defaultScopes: ['mcp:read'], // what a request with no `scope` asks for. default: all of them
|
|
2051
|
+
// issuer: 'https://api.acme.com', // default: SPFN_API_URL
|
|
2052
|
+
// authorizeUrl: 'https://acme.com/oauth/authorize', // default: {app url}/oauth/authorize
|
|
2053
|
+
// allowedRedirectOrigins: [], // https origins a client may register. loopback needs no entry
|
|
2054
|
+
// accessTokenTtlMs: 8 * 60 * 60 * 1000, // default 8 hours
|
|
2055
|
+
// refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000, // default 30 days
|
|
2056
|
+
// codeTtlMs: 60 * 1000, // default 60 seconds
|
|
2057
|
+
},
|
|
2058
|
+
})
|
|
2059
|
+
```
|
|
2060
|
+
|
|
2061
|
+
Without that block every endpoint below answers 404 and nothing else changes — including the
|
|
2062
|
+
boot check, which does not run. `scopes` is the one setting with no default: the names are
|
|
2063
|
+
your application's vocabulary, they are published in the metadata document and read aloud on
|
|
2064
|
+
the consent screen, and there is nothing to derive them from.
|
|
2065
|
+
|
|
2066
|
+
| Endpoint | Host | Auth | What it is |
|
|
2067
|
+
| --- | --- | --- | --- |
|
|
2068
|
+
| `GET /.well-known/oauth-authorization-server` | API | public | RFC 8414 discovery — the first request any client makes |
|
|
2069
|
+
| `POST /_auth/oauth2/register` | API | public, IP rate limited | RFC 7591 dynamic registration. Public clients only |
|
|
2070
|
+
| `GET /_auth/oauth2/authorize` | API | `authenticate` | What the consent screen should say. Records nothing |
|
|
2071
|
+
| `POST /_auth/oauth2/authorize` | API | `authenticate` | The decision. Mints the code |
|
|
2072
|
+
| `POST /_auth/oauth2/token` | API | public, IP rate limited | `authorization_code` and `refresh_token` |
|
|
2073
|
+
| `POST /_auth/oauth2/revoke` | API | public (RFC 7009) | `client_id` required; 200 for an unknown token as surely as for a real one |
|
|
2074
|
+
| `GET /_auth/oauth2/grants` · `DELETE /_auth/oauth2/grants/:id` | API | `authenticate` | What the user has connected, and the button that disconnects it |
|
|
2075
|
+
| `GET /oauth/authorize` · `POST /oauth/authorize` | web | session | The consent screen itself — see the note at the end |
|
|
2076
|
+
|
|
2077
|
+
Two lines wire it to `@spfn/mcp`:
|
|
2078
|
+
|
|
2079
|
+
```typescript
|
|
2080
|
+
import { verifyAccessToken } from '@spfn/auth/server';
|
|
2081
|
+
|
|
2082
|
+
export const mcp = createMcpRoute({ validateToken: verifyAccessToken, tools: [...] });
|
|
2083
|
+
```
|
|
2084
|
+
|
|
2085
|
+
`verifyAccessToken(token, resource)` answers `{ clientId, scopes, expiresAt, userId }` or
|
|
2086
|
+
`null`, and `null` is a refusal — `@spfn/mcp` ≥ 0.3.0-beta.3 accepts it as one rather than
|
|
2087
|
+
requiring a throw. `expiresAt` is seconds since the epoch, like every other OAuth field here.
|
|
2088
|
+
|
|
2089
|
+
- **Only loopback and origins you allowed.** A client may register `http://localhost:*`,
|
|
2090
|
+
`http://127.0.0.1:*` or `http://[::1]:*` — a CLI cannot know which port the OS will hand it,
|
|
2091
|
+
so the **port** is the one thing allowed to vary. Nothing else does: host, path and query
|
|
2092
|
+
must match the registration exactly, a fragment is refused at registration and at request,
|
|
2093
|
+
and plain `http` anywhere else is refused outright. An `https` redirect URI has to be on an
|
|
2094
|
+
origin listed in `allowedRedirectOrigins`.
|
|
2095
|
+
- **Those three spellings are three registrations.** `localhost`, `127.0.0.1` and `[::1]` do
|
|
2096
|
+
not stand in for one another — they resolve differently on a machine with a split-horizon
|
|
2097
|
+
resolver, and a client answered on a host it did not register is a client something
|
|
2098
|
+
redirected. IPv6 is the one place spelling is folded: `http://[0:0:0:0:0:0:0:1]:5/cb` and
|
|
2099
|
+
`http://[::1]:5/cb` are the same registration, because both sides are read through
|
|
2100
|
+
`new URL(...).hostname`.
|
|
2101
|
+
- **An unknown client or a mismatched redirect URI is shown, never redirected.** There is no
|
|
2102
|
+
vetted URI to send that error to, and sending it to the one the request supplied is the open
|
|
2103
|
+
redirect the whole rule exists to close. Every other authorize-time error —
|
|
2104
|
+
`invalid_request`, `invalid_scope`, `invalid_target`, `access_denied` — goes back to the
|
|
2105
|
+
client on its registered URI, which is the only form the waiting CLI can read.
|
|
2106
|
+
- **PKCE S256, and nothing else.** No `plain`, and no request without a challenge. The code
|
|
2107
|
+
arrives on a loopback port that any process on the machine could have been listening on.
|
|
2108
|
+
- **`resource` is required** (RFC 8707) and the token is only good against it. A token your
|
|
2109
|
+
user approved for your MCP server cannot be replayed against a neighbouring deployment that
|
|
2110
|
+
shares this authorization server.
|
|
2111
|
+
- **A code is spent by the statement that reads it**, so of two exchanges arriving together
|
|
2112
|
+
exactly one gets tokens — and **presenting a code twice revokes the grant**, because by then
|
|
2113
|
+
somebody else may hold what the first exchange produced.
|
|
2114
|
+
- **Refresh tokens rotate, and a rotated one is marked rather than deleted.** Presenting it
|
|
2115
|
+
again revokes the grant, which kills the replacement as well as the replayed token: both
|
|
2116
|
+
hang off the grant and there is no telling which holder is the thief. A refresh may ask for
|
|
2117
|
+
a **subset** of the granted scopes and never for more; narrowing applies to that request and
|
|
2118
|
+
leaves the user's consent record as they gave it.
|
|
2119
|
+
- **Every code and refresh failure is one `invalid_grant`, word for word.** Unknown, expired,
|
|
2120
|
+
spent, wrong verifier, another client's. The endpoint is public, and an error that told
|
|
2121
|
+
those apart would answer the question somebody holding a stolen value is asking.
|
|
2122
|
+
- **Token endpoint errors are RFC 6749 §5.2, not the SPFN envelope** —
|
|
2123
|
+
`{ "error": "invalid_grant", "error_description": "..." }`, status 400,
|
|
2124
|
+
`Cache-Control: no-store`. The client reading it is an OAuth library that knows those two
|
|
2125
|
+
field names and nothing about this framework. Registration refusals are RFC 7591 §3.2.2 the
|
|
2126
|
+
same way (`invalid_redirect_uri`, `invalid_client_metadata`).
|
|
2127
|
+
- **Nothing but a hash is stored.** Codes and tokens are `spfn_at_<64 hex>` /
|
|
2128
|
+
`spfn_rt_<64 hex>` / 43 url-safe characters, and the value exists in the clear exactly once,
|
|
2129
|
+
in the response that issues it. It is never logged and never put in an event.
|
|
2130
|
+
- **A global revocation reaches the grants.** `revoke-all`, a password change, a completed
|
|
2131
|
+
password reset and a deletion request each revoke every grant the account has — so a CLI
|
|
2132
|
+
holding a refresh token through "sign me out everywhere" cannot be back within the hour,
|
|
2133
|
+
which is exactly the client that call was aimed at. The user's own
|
|
2134
|
+
`DELETE /_auth/oauth2/grants/:id` does the same for one client, immediately.
|
|
2135
|
+
- **The issuer is checked at boot.** It must be an absolute URL with no path — the metadata
|
|
2136
|
+
document is served at an origin's root and nowhere else — and it must be `https`, or `http`
|
|
2137
|
+
on `localhost` / `127.0.0.1` / `[::1]` for development. Anything else refuses to start with
|
|
2138
|
+
a message naming `SPFN_API_URL` or `authorizationServer.issuer`, whichever the value came
|
|
2139
|
+
from. An application with no `authorizationServer` block never reaches this check. The one
|
|
2140
|
+
value that is accepted and rewritten is a bare trailing slash: `https://api.acme.com/` is
|
|
2141
|
+
stored as `https://api.acme.com`, the form `@spfn/mcp` derives, so the two documents naming
|
|
2142
|
+
this server agree (RFC 8414 §3.3). That reduction happens where the config is resolved, not
|
|
2143
|
+
in the boot check, so a document read without the lifecycle hook publishes the same issuer.
|
|
2144
|
+
- **Unapproved client rows are swept.** Registration is unauthenticated by necessity, so
|
|
2145
|
+
`auth.oauth2.client-purge` (in `authJobRouter`, daily at 05:00) deletes clients older than a
|
|
2146
|
+
day that no user ever approved. One with a grant against it is never touched. Registration
|
|
2147
|
+
is also capped per IP two ways — a burst rate limit, and a cap on how many unapproved
|
|
2148
|
+
clients one address may have standing, which a rate limit cannot express.
|
|
2149
|
+
- **`/mcp` tokens are not sessions.** An access token issued here authorizes the MCP surface
|
|
2150
|
+
for the resource it names. It is not a user session and is not accepted by ordinary API
|
|
2151
|
+
routes.
|
|
2152
|
+
|
|
2153
|
+
### The consent screen
|
|
2154
|
+
|
|
2155
|
+
The screen itself is one route file on the web app, at the path published as
|
|
2156
|
+
`authorization_endpoint`:
|
|
2157
|
+
|
|
2158
|
+
```typescript
|
|
2159
|
+
// app/oauth/authorize/route.ts
|
|
2160
|
+
import { createOAuth2AuthorizeHandlers } from '@spfn/auth/nextjs/server';
|
|
2161
|
+
|
|
2162
|
+
export const { GET, POST } = createOAuth2AuthorizeHandlers({ loginPath: '/login' });
|
|
2163
|
+
```
|
|
2164
|
+
|
|
2165
|
+
`GET` asks `GET /_auth/oauth2/authorize` what the request is and draws it; `POST` checks the
|
|
2166
|
+
form's own CSRF token, sends the decision to `POST /_auth/oauth2/authorize`, and redirects the
|
|
2167
|
+
browser back to the waiting CLI. Neither decides anything — the API validates the request from
|
|
2168
|
+
scratch both times, because the form between the two calls is in the user's browser.
|
|
2169
|
+
|
|
2170
|
+
| Option | What it is |
|
|
2171
|
+
| --- | --- |
|
|
2172
|
+
| `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 |
|
|
2173
|
+
| `render?` | `(view: OAuth2ConsentView) => string`, replacing the default body. Status, headers and the field set stay the handler's |
|
|
2174
|
+
|
|
2175
|
+
Every answer carries `Cache-Control: no-store`, and every page also carries
|
|
2176
|
+
`Content-Type: text/html; charset=utf-8` and `Content-Security-Policy: frame-ancestors 'none'`
|
|
2177
|
+
— a consent screen that can be framed is a consent screen that can be clickjacked.
|
|
2178
|
+
|
|
2179
|
+
- **The two refusal kinds become the two answers.** `unknown_client` and
|
|
2180
|
+
`redirect_uri_mismatch` are shown on a 400 screen with no `Location` at all. Every other
|
|
2181
|
+
refusal — `invalid_request`, `invalid_target`, `invalid_scope`, `access_denied` — is a 302 to
|
|
2182
|
+
the redirect URI **the API returned**, carrying `error=` and the `state` verbatim. The
|
|
2183
|
+
`redirect_uri` in the request is forwarded to the API and never built into a `Location`: the
|
|
2184
|
+
API's value is the one that matched a registration, which is the whole difference between a
|
|
2185
|
+
redirect and an open redirect.
|
|
2186
|
+
- **The POST carries its own CSRF token.** The page puts the readable CSRF cookie in a hidden
|
|
2187
|
+
`csrf` field and the POST refuses, before calling the API at all, unless the field matches
|
|
2188
|
+
the cookie. The handler's server-side call to the API mints the CSRF header itself and would
|
|
2189
|
+
always pass, so the form's token is the only check that means anything here.
|
|
2190
|
+
- **`render` owns the body and nothing else.** `OAuth2ConsentView` carries `clientName`,
|
|
2191
|
+
`redirectHost`, `scopes`, `resource`, the `fields` to echo as hidden inputs, and the
|
|
2192
|
+
`csrfToken`, all raw — put every one of them through the exported `escapeHtml`. `clientName`
|
|
2193
|
+
arrives from unauthenticated dynamic registration, and a renderer that drops `fields` or
|
|
2194
|
+
`csrfToken` produces a form the API refuses.
|
|
2195
|
+
|
|
2196
|
+
The end-to-end path — lifecycle config, this route, `/mcp`, and connecting from Claude Code
|
|
2197
|
+
and Codex — is [docs/guides/mcp-clients.md](../../docs/guides/mcp-clients.md).
|
|
2198
|
+
|
|
2199
|
+
## Machine principals (`registerMachineVerifier`)
|
|
2200
|
+
|
|
2201
|
+
A machine credential is issued by a service to a non-interactive process, and its subject is
|
|
2202
|
+
an account or a tenant, not a person. `AuthContext` cannot hold one — it requires a `users`
|
|
2203
|
+
row — and resolving a machine token to its owning user is worse than the type error: it makes
|
|
2204
|
+
the machine's request indistinguishable from that user's own session.
|
|
2205
|
+
|
|
2206
|
+
So a machine principal never enters `AuthContext`. It lives in its own context key, is read by
|
|
2207
|
+
its own helper, and is admitted by its own middleware:
|
|
2208
|
+
|
|
2209
|
+
```typescript
|
|
2210
|
+
import { machineAuth, requireMachineScope, getMachinePrincipal } from '@spfn/auth/server';
|
|
2211
|
+
|
|
2212
|
+
export const ingest = route.post('/v1/ingest')
|
|
2213
|
+
.use([machineAuth, requireMachineScope('events:write')])
|
|
2214
|
+
.handler(async (c) =>
|
|
2215
|
+
{
|
|
2216
|
+
const { subjectType, subjectId } = getMachinePrincipal(c.raw)!;
|
|
2217
|
+
// subjectType: 'account' | 'service' | whatever the verifier named
|
|
2218
|
+
});
|
|
2219
|
+
```
|
|
2220
|
+
|
|
2221
|
+
`getAuth(c)` on that route returns nothing, because nothing put a user there. That is the
|
|
2222
|
+
whole design: a machine request cannot impersonate a user session, not because a check
|
|
2223
|
+
forbids it but because no code path leads there.
|
|
2224
|
+
|
|
2225
|
+
**Ownership is not authentication.** Who issued a machine token, who owns it, and who may
|
|
2226
|
+
revoke or audit it are the registrant's data-level concerns — put the token id in `claims` and
|
|
2227
|
+
answer them from your own tables. What the request *acts as* is the token's own subject and
|
|
2228
|
+
scopes, and nothing here resolves a machine subject to a user.
|
|
2229
|
+
|
|
2230
|
+
### Registering a verifier
|
|
2231
|
+
|
|
2232
|
+
A verifier claims one namespace, by a raw `tokenPrefix` (for an opaque secret, the
|
|
2233
|
+
`spfn_ops_` shape) or by a `kidPrefix` on the unverified JOSE header of a JWS. The built-in
|
|
2234
|
+
ops token's own shape is exported rather than spelled out — match it with `isOpsToken` or
|
|
2235
|
+
`OPS_TOKEN_PREFIX` from `@spfn/auth/server`. Register at boot, before the first request:
|
|
2236
|
+
|
|
2237
|
+
```typescript
|
|
2238
|
+
import { registerMachineVerifier } from '@spfn/auth/server';
|
|
2239
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
2240
|
+
|
|
2241
|
+
const RUNTIME_JWKS = createRemoteJWKSet(new URL('https://issuer.example.com/.well-known/jwks.json'));
|
|
2242
|
+
|
|
2243
|
+
registerMachineVerifier({
|
|
2244
|
+
id: 'runtimeJwsV1',
|
|
2245
|
+
match: { kidPrefix: 'machine:runtime:' },
|
|
2246
|
+
verify: async (token) =>
|
|
2247
|
+
{
|
|
2248
|
+
const { payload } = await jwtVerify(token, RUNTIME_JWKS, { issuer: 'https://issuer.example.com' });
|
|
2249
|
+
|
|
2250
|
+
return {
|
|
2251
|
+
subjectType: 'account',
|
|
2252
|
+
subjectId: String(payload.sub),
|
|
2253
|
+
scopes: String(payload.scope ?? '').split(' ').filter(Boolean),
|
|
2254
|
+
claims: { tokenId: payload.jti },
|
|
2255
|
+
scheme: 'runtimeJwsV1',
|
|
2256
|
+
};
|
|
2257
|
+
},
|
|
2258
|
+
});
|
|
2259
|
+
```
|
|
2260
|
+
|
|
2261
|
+
The request carries it as an ordinary bearer token — no new wire format, and the
|
|
2262
|
+
profile-header channel is not involved:
|
|
2263
|
+
|
|
2264
|
+
```http
|
|
2265
|
+
POST /v1/ingest
|
|
2266
|
+
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Im1hY2hpbmU6cnVudGltZTo...
|
|
2267
|
+
```
|
|
2268
|
+
|
|
2269
|
+
- **Namespace your kids.** `machine:` is the convention this package documents, and a user
|
|
2270
|
+
session JWT never carries that shape. The prefix is what tells the two apart before either
|
|
2271
|
+
is verified.
|
|
2272
|
+
- **Conflicting discriminators are refused at registration** — a duplicate `id`, a duplicate
|
|
2273
|
+
prefix, or a prefix that would shadow an already-registered one (`machine:` swallowing
|
|
2274
|
+
`machine:runtime:`). Two verifiers one token could match would make admission depend on
|
|
2275
|
+
registration order, so that is a boot-time error rather than something the dispatch
|
|
2276
|
+
resolves per request.
|
|
2277
|
+
- **A `tokenPrefix` claims every token that starts with it**, and `authenticate` consults the
|
|
2278
|
+
registry before it decodes anything. A prefix a user's JWT could begin with (`ey…`) would
|
|
2279
|
+
therefore refuse every user session — pick a prefix no other credential on your surface
|
|
2280
|
+
shares, as `spfn_ops_` does.
|
|
2281
|
+
- **Register at boot, before the first request.** The registry is module state read on every
|
|
2282
|
+
dispatch, so a verifier registered later is simply a verifier the requests before it did
|
|
2283
|
+
not have. There is no unregistration and no reset — the same contract, and the same reason,
|
|
2284
|
+
as [`registerAuthProfile`](#custom-auth-profiles-registerauthprofile).
|
|
2285
|
+
- **Registering nothing costs nothing.** With no verifier registered, `authenticate` is two
|
|
2286
|
+
array-length checks away from what it was. The unverified JOSE header peek happens only
|
|
2287
|
+
once a `kidPrefix` verifier exists.
|
|
2288
|
+
- **`scheme` is the registry's answer**, not the verifier's: whatever a verifier returns
|
|
2289
|
+
there, the principal carries the `id` that admitted it, so an audit trail cannot be made to
|
|
2290
|
+
name the wrong verifier.
|
|
2291
|
+
|
|
2292
|
+
### The case table
|
|
2293
|
+
|
|
2294
|
+
| credential ↓ route → | `authenticate` (user) | `machineAuth` | `optionalAuth` |
|
|
2295
|
+
|---|---|---|---|
|
|
2296
|
+
| user bearer JWT | ✓ user (unchanged) | 401 | ✓ user (unchanged) |
|
|
2297
|
+
| machine token, registered namespace, valid | 401 — refused before the token is decoded | ✓ sets `machinePrincipal` | 401 |
|
|
2298
|
+
| machine token, registered namespace, verifier rejects | 401 | 401 | 401 |
|
|
2299
|
+
| machine-shaped token, unregistered namespace | 401 (the existing invalid-token path) | 401 | continues, no auth |
|
|
2300
|
+
| profile header + any Bearer | `PROFILE_REJECTED` (unchanged) | `PROFILE_REJECTED` | `PROFILE_REJECTED` |
|
|
2301
|
+
| nothing | 401 (unchanged) | 401 | continues, no auth |
|
|
2302
|
+
| valid principal, missing scope | — | 403 | — |
|
|
2303
|
+
| valid principal, sufficient scope | — | 200 | — |
|
|
2304
|
+
|
|
2305
|
+
Every 401 above is one message. Whether a namespace is registered, whether a presented token
|
|
2306
|
+
was ever valid, and whether a verifier rejected it are not inferable from the answer — the
|
|
2307
|
+
same non-disclosure rule the [ops-token](#ops-tokens-spfn-ops) table keeps. 403 is reserved
|
|
2308
|
+
for scope, where the caller is already authenticated; `requireMachineScope` matches scopes
|
|
2309
|
+
exactly and has no wildcard, and it fails closed with a 401 if it runs without `machineAuth`
|
|
2310
|
+
before it.
|
|
2311
|
+
|
|
2312
|
+
A verifier that throws something other than a refusal — a bug in registrant code — is the
|
|
2313
|
+
same generic 401 on the wire, with the real error logged. Never a 500 carrying registrant
|
|
2314
|
+
internals, and never a silent pass.
|
|
2315
|
+
|
|
2316
|
+
The last row of the unregistered-namespace case is the one asymmetry: a token in a namespace
|
|
2317
|
+
nobody registered is not a machine credential as far as this package can tell, so under
|
|
2318
|
+
`optionalAuth` it gets what any unusable bearer token has always got. A token in a
|
|
2319
|
+
*registered* namespace is refused there, because refusing it is the difference between
|
|
2320
|
+
"presented the wrong credential" and "presented none".
|
|
2321
|
+
|
|
2322
|
+
The non-disclosure above is therefore an `authenticate` and `machineAuth` property, not an
|
|
2323
|
+
`optionalAuth` one: on an `optionalAuth` route a caller can tell a registered namespace from
|
|
2324
|
+
an unregistered one, because one is refused and the other is served anonymously. Closing that
|
|
2325
|
+
gap would mean refusing every unusable bearer token on those routes — a change to behaviour
|
|
2326
|
+
that predates machine principals, and a worse trade than the inference it prevents. Mount
|
|
2327
|
+
`machineAuth` where the distinction matters.
|
|
2328
|
+
|
|
2329
|
+
### Issuance is yours
|
|
2330
|
+
|
|
2331
|
+
This package verifies machine tokens; it does not mint them. Issuance, rotation, and
|
|
2332
|
+
revocation belong to whoever owns the subject — keep the tokens short-lived, and prefer a
|
|
2333
|
+
signature you can verify offline (`kidPrefix` + JWKS) over a secret you must look up.
|
|
2334
|
+
|
|
2335
|
+
`opsTokenAuth` is the built-in instance of exactly this pattern, hand-written for one
|
|
2336
|
+
credential before the registry existed: its own context key (`opsToken`), its own scope guard,
|
|
2337
|
+
`AuthContext` never set. It keeps its own implementation and is not registered here.
|
|
2338
|
+
A route that must admit an ops token *or* a user session uses
|
|
2339
|
+
[`opsOrUser`](#one-route-two-credentials-opsoruser), which composes the two existing
|
|
2340
|
+
middleware pairs behind one branch on credential shape rather than widening either path.
|
|
2341
|
+
|
|
1066
2342
|
## Account Deletion & Recovery
|
|
1067
2343
|
|
|
1068
2344
|
Grace-period deletion with in-window recovery, an admin/GDPR-response entry point for immediate
|
|
@@ -1117,7 +2393,7 @@ export default defineServerConfig()
|
|
|
1117
2393
|
},
|
|
1118
2394
|
},
|
|
1119
2395
|
}))
|
|
1120
|
-
.jobs(authJobRouter) //
|
|
2396
|
+
.jobs(authJobRouter) // the daily (04:00 UTC) purge sweep, and auth.link-mail
|
|
1121
2397
|
.routes(appRouter)
|
|
1122
2398
|
.build();
|
|
1123
2399
|
```
|
|
@@ -1154,15 +2430,68 @@ is fixed at module-import time, which happens before `createAuthLifecycle()` run
|
|
|
1154
2430
|
`createAuthLifecycle()` call, and register that instead:
|
|
1155
2431
|
|
|
1156
2432
|
```typescript
|
|
1157
|
-
import {
|
|
2433
|
+
import { createAuthJobRouter } from '@spfn/auth/server';
|
|
1158
2434
|
|
|
1159
2435
|
// ... after .lifecycle(createAuthLifecycle({ deletion: { purgeCron: '0 3 * * *' } }))
|
|
1160
|
-
.jobs(
|
|
2436
|
+
.jobs(createAuthJobRouter({ purgeCron: '0 3 * * *' }))
|
|
1161
2437
|
```
|
|
1162
2438
|
|
|
1163
|
-
Register **only one** of `authJobRouter` / `
|
|
1164
|
-
|
|
1165
|
-
|
|
2439
|
+
Register **only one** of `authJobRouter` / `createAuthJobRouter(...)` — both build the same job
|
|
2440
|
+
names, so registering both (e.g. the static export *and* a custom-cron router) double-registers
|
|
2441
|
+
each name against pg-boss instead of overriding it.
|
|
2442
|
+
|
|
2443
|
+
`createAuthDeletionJobRouter` is the former name of `createAuthJobRouter` and still works, with
|
|
2444
|
+
the same argument and the same result. It is deprecated because the router has carried more than
|
|
2445
|
+
the deletion purge since `auth.link-mail` joined it.
|
|
2446
|
+
|
|
2447
|
+
### Link mail delivery
|
|
2448
|
+
|
|
2449
|
+
`auth.link-mail` is the second job on the router, and the reason to register the router even in
|
|
2450
|
+
an app that never deletes an account.
|
|
2451
|
+
|
|
2452
|
+
**What it queues, and why only a row id.** Three mails leave through it: the verified-email
|
|
2453
|
+
signup link, the password reset link, and the "you already have an account" notice the signup
|
|
2454
|
+
request answers a known address with. The payload is `{ kind, rowId }` — or `{ kind, target,
|
|
2455
|
+
targetType }` for the notice — and never the token, the URL or the rendered mail.
|
|
2456
|
+
`@spfn/notification` can queue a send of its own, but its payload carries the *rendered* mail,
|
|
2457
|
+
which for these three templates would leave the link token in plaintext in `pgboss.job` until
|
|
2458
|
+
archive. So the queue carries a reference and the worker mints the credential moments before
|
|
2459
|
+
sending it: the plaintext exists in the mail and nowhere else.
|
|
2460
|
+
|
|
2461
|
+
**What that buys.** The request writes its row with `token_hash` null and answers. Both branches
|
|
2462
|
+
of both endpoints now cost the same database work, so how long a request took no longer says
|
|
2463
|
+
whether the address has an account — the mail was the only asymmetry left. A pending row is not
|
|
2464
|
+
confirmable: a null hash matches no lookup, and the worker's `issue` refuses a row that was
|
|
2465
|
+
superseded, consumed, completed or expired in the meantime, in the same statement that would
|
|
2466
|
+
write the hash. A failed send throws so pg-boss retries, and the retry re-mints, which is why a
|
|
2467
|
+
token from a failed attempt stops working.
|
|
2468
|
+
|
|
2469
|
+
**The three modes** — `SPFN_AUTH_LINK_MAIL_DELIVERY`:
|
|
2470
|
+
|
|
2471
|
+
| mode | behaviour |
|
|
2472
|
+
|------|-----------|
|
|
2473
|
+
| `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 |
|
|
2474
|
+
| `queued` | always queue; an enqueue failure surfaces as a failed request rather than becoming an inline send |
|
|
2475
|
+
| `inline` | always send on the request — today's behaviour, and the timing signal that comes with it |
|
|
2476
|
+
|
|
2477
|
+
**When the provider refuses on the request path** — `inline`, `auto` with no pg-boss, or the
|
|
2478
|
+
fallback below — the failure is logged and the request still answers as if the mail had gone out,
|
|
2479
|
+
because an answer that depended on the mail provider would be an account-existence oracle during
|
|
2480
|
+
an outage; the user asks again, and only the job path retries.
|
|
2481
|
+
|
|
2482
|
+
**The fallback warning.** In `auto`, an app that initialised pg-boss but never registered this
|
|
2483
|
+
router has no `auth.link-mail` queue, so the enqueue fails. Losing the mail there would be silent,
|
|
2484
|
+
so that request sends inline instead and the log says once per process:
|
|
2485
|
+
|
|
2486
|
+
```
|
|
2487
|
+
Queue auth.link-mail does not exist, so this link mail was sent on the request path.
|
|
2488
|
+
Register the auth job router — .jobs(authJobRouter) — or set SPFN_AUTH_LINK_MAIL_DELIVERY='inline'.
|
|
2489
|
+
```
|
|
2490
|
+
|
|
2491
|
+
The fix is in the message: register the router, or say `inline` if sending on the request is what
|
|
2492
|
+
you want. Only a missing queue falls back — every other enqueue failure, a database outage above
|
|
2493
|
+
all, surfaces, because falling back on those would hide the outage behind mail that still gets
|
|
2494
|
+
through.
|
|
1166
2495
|
|
|
1167
2496
|
## FAQ
|
|
1168
2497
|
|
|
@@ -1176,9 +2505,20 @@ deploy.
|
|
|
1176
2505
|
Almost always the callback origin. The CSRF check is a double-submit against a host-only
|
|
1177
2506
|
cookie set on your **web app** host, so the provider must return to the web app origin, and
|
|
1178
2507
|
the app must forward `/_auth/*` to the API with a Next.js rewrite. Without that rewrite the
|
|
1179
|
-
callback 404s — including in local dev.
|
|
2508
|
+
callback 404s — including in local dev. An explicit `SPFN_AUTH_<PROVIDER>_REDIRECT_URI` on the
|
|
2509
|
+
wrong origin or path no longer gets that far: it fails at boot with a message naming the
|
|
2510
|
+
variable. Details in
|
|
1180
2511
|
[OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite).
|
|
1181
2512
|
|
|
2513
|
+
**I forgot my password.**
|
|
2514
|
+
Send the address to `requestPasswordReset` and open the link that arrives. Any `active`
|
|
2515
|
+
account whose email is verified — or that already has a password, which covers every account
|
|
2516
|
+
created before the column was stamped — can be reset that way. See
|
|
2517
|
+
[Password reset](#password-reset-verified-email). Completing it signs every other device out,
|
|
2518
|
+
so it is also the answer to "someone else knows my password". An account with neither a
|
|
2519
|
+
verified address nor a password (OAuth-only, provider said unverified) cannot be reset by
|
|
2520
|
+
email; it signs in through its provider.
|
|
2521
|
+
|
|
1182
2522
|
**Does the server hold my users' private keys?**
|
|
1183
2523
|
No. The client generates an ES256/RS256 keypair, sends only the public key on register or
|
|
1184
2524
|
login, and signs each request itself. The server verifies with the stored public key. Keys
|
|
@@ -1208,6 +2548,23 @@ authorization.
|
|
|
1208
2548
|
The environment, seeded on startup by `createAuthLifecycle()`. Seeded accounts are email
|
|
1209
2549
|
verified, active, and required to change their password on first login.
|
|
1210
2550
|
|
|
2551
|
+
**Is `Foo@Example.com` the same account as `foo@example.com`?**
|
|
2552
|
+
Yes. Addresses are trimmed and lower-cased on the way in and on the way out, so one person
|
|
2553
|
+
who capitalizes differently on different days reaches one account instead of creating a
|
|
2554
|
+
second. Nothing else is folded — Gmail's dot and `+` rules are that provider's delivery
|
|
2555
|
+
behaviour, not an internet rule, and applying them would merge addresses other providers
|
|
2556
|
+
treat as different people.
|
|
2557
|
+
|
|
2558
|
+
`createAuthLifecycle()` brings existing rows into the same form on startup. If two accounts
|
|
2559
|
+
differ only by capitalization, both are left exactly as they are and their user ids are
|
|
2560
|
+
logged as an error: which one is the real account, and what becomes of the other's data, is
|
|
2561
|
+
not a question the package can answer for you. Until you resolve it, the mixed-case one
|
|
2562
|
+
cannot sign in.
|
|
2563
|
+
|
|
2564
|
+
Admin seeding is unaffected either way. It recognizes a configured admin in whatever form
|
|
2565
|
+
the address was stored, so an account the backfill has not reached is skipped rather than
|
|
2566
|
+
duplicated into a second privileged row holding the configured password.
|
|
2567
|
+
|
|
1211
2568
|
## Pitfalls & anti-patterns
|
|
1212
2569
|
|
|
1213
2570
|
- **"relation \"auth.users\" does not exist" — tables come from bundled migrations, not push.**
|
|
@@ -1245,8 +2602,10 @@ verified, active, and required to change their password on first login.
|
|
|
1245
2602
|
'@spfn/notification/server'`). Wire verification-code / invitation emails through its events.
|
|
1246
2603
|
- **`authJobRouter` isn't registered for you.** `createAuthLifecycle()`'s `afterInfrastructure`
|
|
1247
2604
|
hook runs *before* `@spfn/core` initializes pg-boss and registers jobs, so the lifecycle has no
|
|
1248
|
-
opportunity to auto-register the
|
|
1249
|
-
|
|
2605
|
+
opportunity to auto-register the jobs. Call `.jobs(authJobRouter)` yourself — see
|
|
2606
|
+
[Account Deletion & Recovery](#account-deletion--recovery). An app that initialises pg-boss and
|
|
2607
|
+
skips this keeps sending link mail, but on the request path, with a warning naming the router —
|
|
2608
|
+
see [Link mail delivery](#link-mail-delivery).
|
|
1250
2609
|
- **`USER_STATUSES` gained `pending_deletion` / `deleted`.** Any code with a `switch(user.status)`
|
|
1251
2610
|
or an exhaustive status union must handle both — `enumText` is plain `text` with no DB `CHECK`,
|
|
1252
2611
|
so nothing enforces this at the database layer.
|