@spfn/auth 0.2.0-beta.74 → 0.2.0-beta.80
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 +267 -15
- package/dist/{authenticate-DXVtYh8X.d.ts → authenticate-ofdEmk6x.d.ts} +43 -13
- package/dist/config.d.ts +150 -0
- package/dist/config.js +71 -4
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +75 -3
- package/dist/errors.js +50 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +28 -6
- package/dist/index.js +53 -5
- package/dist/index.js.map +1 -1
- package/dist/nextjs/api.js +13 -11
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.d.ts +2 -2
- package/dist/nextjs/server.js +3 -3
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +1040 -42
- package/dist/server.js +1894 -397
- package/dist/server.js.map +1 -1
- package/dist/{session-s_hiXmXC.d.ts → session-CGxgH3C9.d.ts} +1 -1
- package/dist/types-1BMx0OX1.d.ts +84 -0
- package/migrations/0006_easy_hardball.sql +24 -0
- package/migrations/0007_glossy_major_mapleleaf.sql +1 -0
- package/migrations/meta/0006_snapshot.json +1921 -0
- package/migrations/meta/0007_snapshot.json +1916 -0
- package/migrations/meta/_journal.json +15 -1
- package/package.json +29 -27
- package/dist/types-BtksCI9X.d.ts +0 -45
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @spfn/auth — Authentication, OAuth, and RBAC for SPFN
|
|
2
2
|
|
|
3
3
|
Asymmetric client-signed JWT auth (ES256/RS256), OTP verification, OAuth 2.0 (pluggable
|
|
4
|
-
provider registry
|
|
4
|
+
provider registry; Google, Kakao, and Naver built in), session cookies for Next.js, and runtime RBAC.
|
|
5
5
|
Routes are exposed under the `/_auth/*` namespace and reached through a type-safe `authApi`
|
|
6
6
|
client. Requires `@spfn/core`; Next.js is an optional peer (`^15 || ^16`).
|
|
7
7
|
|
|
@@ -111,6 +111,7 @@ real secret values out of band, never commit them.
|
|
|
111
111
|
| `DATABASE_URL` | both | yes | Postgres connection |
|
|
112
112
|
| `SPFN_AUTH_VERIFICATION_TOKEN_SECRET` | `.env.server` | yes | OTP / verification token signing |
|
|
113
113
|
| `SPFN_AUTH_SESSION_SECRET` | `.env.local` | yes | ≥32 chars, AES-256 session cookie encryption (validated: entropy/unique-char checks) |
|
|
114
|
+
| `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` | `.env.server` | web OAuth | OAuth token keyring: comma-separated `<keyId>:<base64-32-byte-key>` entries; first key is active |
|
|
114
115
|
| `SPFN_API_URL` | `.env.local` | — | default `http://localhost:8790` |
|
|
115
116
|
| `SPFN_AUTH_SESSION_TTL` | both | — | default `7d` (e.g. `7d`, `12h`, `45m`) |
|
|
116
117
|
| `SPFN_AUTH_JWT_SECRET` / `SPFN_AUTH_JWT_EXPIRES_IN` | `.env.server` | — | legacy server-signed JWT mode only |
|
|
@@ -119,7 +120,11 @@ real secret values out of band, never commit them.
|
|
|
119
120
|
| `SPFN_AUTH_ADMIN_*` | `.env.server` | — | admin seeding (see below) |
|
|
120
121
|
| `SPFN_AUTH_GOOGLE_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | enables Google OAuth when both set |
|
|
121
122
|
| `SPFN_AUTH_GOOGLE_SCOPES` | `.env.server` | — | comma-separated; default `email,profile` |
|
|
122
|
-
| `SPFN_AUTH_GOOGLE_REDIRECT_URI` | `.env.server` | — | default `{
|
|
123
|
+
| `SPFN_AUTH_GOOGLE_REDIRECT_URI` | `.env.server` | — | default `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/google/callback` — see [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite) |
|
|
124
|
+
| `SPFN_AUTH_KAKAO_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | REST API key enables Kakao Login; secret is included when configured |
|
|
125
|
+
| `SPFN_AUTH_KAKAO_SCOPES` / `_REDIRECT_URI` | `.env.server` | — | default scope `account_email`; callback `/_auth/oauth/kakao/callback` |
|
|
126
|
+
| `SPFN_AUTH_NAVER_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | both values enable Naver Login |
|
|
127
|
+
| `SPFN_AUTH_NAVER_REDIRECT_URI` | `.env.server` | — | default `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/naver/callback` |
|
|
123
128
|
| `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 |
|
|
124
129
|
| `SPFN_AUTH_APPLE_CLIENT_IDS` | `.env.server` | — | comma-separated Apple client IDs (bundle ID / Services ID); enables Apple native sign-in |
|
|
125
130
|
| `SPFN_AUTH_OAUTH_SUCCESS_URL` | `.env.server` | — | default `/auth/callback` |
|
|
@@ -159,6 +164,8 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
|
|
|
159
164
|
| `checkUsername` / `updateUsername` / `updateLocale` | — | mixed | username availability/update, locale |
|
|
160
165
|
| `getUserProfile` / `updateUserProfile` | — | yes | profile read/update |
|
|
161
166
|
| `createInvitation` / `acceptInvitation` / `listInvitations` / `cancelInvitation` / `resendInvitation` / `deleteInvitation` / `getInvitation` | — | mixed | invitation flow |
|
|
167
|
+
| `requestAccountDeletion` | POST `/_auth/deletion/request` | yes | request account deletion (re-auth gated) — see [Account Deletion & Recovery](#account-deletion--recovery) |
|
|
168
|
+
| `cancelAccountDeletion` | POST `/_auth/deletion/cancel` | public | cancel a pending deletion (credential-based recovery) |
|
|
162
169
|
| `listRoles` / `createAdminRole` / `updateAdminRole` / `deleteAdminRole` / `updateUserRole` | — | superadmin | admin RBAC management |
|
|
163
170
|
| OAuth routes | — | — | see OAuth section |
|
|
164
171
|
|
|
@@ -206,10 +213,11 @@ Context helpers from `@spfn/auth/server`: `getAuth`, `getOptionalAuth`, `getUser
|
|
|
206
213
|
|
|
207
214
|
## OAuth
|
|
208
215
|
|
|
209
|
-
OAuth uses a **pluggable provider registry** — not hardcoded branches. The built-in `google
|
|
210
|
-
|
|
211
|
-
`
|
|
212
|
-
|
|
216
|
+
OAuth uses a **pluggable provider registry** — not hardcoded branches. The built-in `google`,
|
|
217
|
+
`kakao`, and `naver` web providers self-register on module load; `apple` provides native
|
|
218
|
+
`id_token` sign-in. External packages add providers at runtime with `registerOAuthProvider()`.
|
|
219
|
+
Google requires its client ID and secret, Kakao requires its REST API key (and sends its optional
|
|
220
|
+
client secret when configured), and Naver requires its client ID and secret.
|
|
213
221
|
|
|
214
222
|
Client flow: call `authApi.getGoogleOAuthUrl.call({ body: { returnUrl } })`, redirect the browser
|
|
215
223
|
to the returned `authUrl`, and render `OAuthCallback` on your success page. The Next.js interceptor
|
|
@@ -222,16 +230,83 @@ export { OAuthCallback as default } from '@spfn/auth/nextjs/client';
|
|
|
222
230
|
|
|
223
231
|
```typescript
|
|
224
232
|
import { authApi } from '@spfn/auth';
|
|
225
|
-
const { authUrl } = await authApi.getGoogleOAuthUrl.call({
|
|
233
|
+
const { authUrl } = await authApi.getGoogleOAuthUrl.call({
|
|
234
|
+
body: {
|
|
235
|
+
returnUrl: '/dashboard',
|
|
236
|
+
metadata: { birthDate: '2000-01-01', termsAgreed: true },
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
window.location.href = authUrl;
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Kakao and Naver use the provider-generic URL route:
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
const { authUrl } = await authApi.getProviderOAuthUrl.call({
|
|
246
|
+
params: { provider: 'kakao' }, // or 'naver'
|
|
247
|
+
body: {
|
|
248
|
+
returnUrl: '/dashboard',
|
|
249
|
+
metadata: { birthDate: '2000-01-01', termsAgreed: true },
|
|
250
|
+
},
|
|
251
|
+
});
|
|
226
252
|
window.location.href = authUrl;
|
|
227
253
|
```
|
|
228
254
|
|
|
255
|
+
Both convenience URL APIs seal `metadata` into the encrypted OAuth state. On a new social
|
|
256
|
+
signup, the callback passes it to `beforeRegister` and `authRegisterEvent`; existing-account
|
|
257
|
+
logins do not run the registration hook.
|
|
258
|
+
|
|
229
259
|
Built-in OAuth routes: `POST /_auth/oauth/google/url`, `GET /_auth/oauth/google` (redirect),
|
|
230
260
|
`GET /_auth/oauth/google/callback`, `POST /_auth/oauth/finalize`, `GET /_auth/oauth/providers`,
|
|
231
261
|
plus the provider-generic `POST /_auth/oauth/start`. `getGoogleAccessToken(userId)` returns a
|
|
232
262
|
valid Google access token (auto-refreshing via stored refresh token when near expiry; throws if
|
|
233
263
|
no Google account is linked or no refresh token is available).
|
|
234
264
|
|
|
265
|
+
Kakao's `is_email_valid` and `is_email_verified` claims are both required before its email can
|
|
266
|
+
link an existing SPFN account. Naver provides an email address but no verified-email claim, so
|
|
267
|
+
Naver login never links an existing account by email; new Naver users can verify and add their
|
|
268
|
+
email through the application's normal onboarding flow. Until then, the OAuth account is identified
|
|
269
|
+
by its provider and provider user ID, so its user row may have both email and phone unset.
|
|
270
|
+
|
|
271
|
+
### OAuth callback origin (web app host + rewrite)
|
|
272
|
+
|
|
273
|
+
The callback's CSRF check is a double-submit: the Next.js interceptor sets an `oauth_csrf`
|
|
274
|
+
cookie on the **web app host**, and the callback compares it against the nonce sealed in the
|
|
275
|
+
state. Host-only cookies never reach a different host, so **the provider callback must return
|
|
276
|
+
to the web app origin** — redirect URIs default to
|
|
277
|
+
`{NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}/_auth/oauth/<provider>/callback`.
|
|
278
|
+
|
|
279
|
+
The app forwards `/_auth/*` to the API with a standard rewrite (**required** — without it the
|
|
280
|
+
callback 404s on the web host, including in local dev):
|
|
281
|
+
|
|
282
|
+
```javascript
|
|
283
|
+
// next.config.js
|
|
284
|
+
const nextConfig = {
|
|
285
|
+
async rewrites()
|
|
286
|
+
{
|
|
287
|
+
return [
|
|
288
|
+
{
|
|
289
|
+
source: '/_auth/:path*',
|
|
290
|
+
destination: `${process.env.SPFN_API_URL}/_auth/:path*`,
|
|
291
|
+
},
|
|
292
|
+
];
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Register each **web app host** callback URL in its provider console, for example
|
|
298
|
+
`https://app.example.com/_auth/oauth/kakao/callback` and
|
|
299
|
+
`https://app.example.com/_auth/oauth/naver/callback`.
|
|
300
|
+
|
|
301
|
+
The cookie name also carries a `_${PORT}` suffix from the process that set it (the Next.js
|
|
302
|
+
process), which differs from the API process in a split deployment — the callback therefore
|
|
303
|
+
matches every `spfn_oauth_csrf*` cookie candidate against the state nonce, so no PORT
|
|
304
|
+
coordination is needed.
|
|
305
|
+
|
|
306
|
+
One caveat: the direct `POST /_auth/oauth/start` flow (no Next.js interceptor) sets its CSRF
|
|
307
|
+
cookie on the **API host**. If you use that flow in a split deployment, set
|
|
308
|
+
the corresponding provider redirect URI explicitly to the API host callback instead.
|
|
309
|
+
|
|
235
310
|
### Native social sign-in (mobile / web id_token)
|
|
236
311
|
|
|
237
312
|
For native apps — and for Apple on Android/web, which has no native SDK — the client obtains an
|
|
@@ -273,13 +348,40 @@ import {
|
|
|
273
348
|
registerOAuthProvider(myProvider); // same id re-registers (override)
|
|
274
349
|
```
|
|
275
350
|
|
|
351
|
+
### OAuth token encryption and key rotation
|
|
352
|
+
|
|
353
|
+
Web OAuth access and refresh tokens are encrypted at rest with AES-256-GCM. Token encryption is
|
|
354
|
+
separate from session-cookie encryption: `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` is backend-only and
|
|
355
|
+
must never be exposed to the Next.js process. Generate a key with `openssl rand -base64 32` and
|
|
356
|
+
assign it a non-secret key ID:
|
|
357
|
+
|
|
358
|
+
```dotenv
|
|
359
|
+
SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v2:<base64-32-byte-key>
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
For zero-downtime rotation, prepend the new key and retain old keys for decryption:
|
|
363
|
+
|
|
364
|
+
```dotenv
|
|
365
|
+
SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v3:<new-key>,v2:<old-key>
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
New writes use the first key. Reads using an older key, the legacy session-secret-derived `enc:v1`
|
|
369
|
+
format, or historical plaintext are automatically re-encrypted with the active key. Keep every old
|
|
370
|
+
key available until all rows have been read or explicitly migrated; removing a referenced key makes
|
|
371
|
+
those tokens undecryptable. Ciphertext is bound to `provider`, `providerUserId`, and token type
|
|
372
|
+
(`access` or `refresh`) with authenticated data, preventing ciphertext from being moved to another
|
|
373
|
+
account or field.
|
|
374
|
+
|
|
375
|
+
Deployments that need a KMS or per-account envelope encryption can call
|
|
376
|
+
`configureOAuthTokenCipher()` from `@spfn/auth/server` before the server starts. The custom cipher
|
|
377
|
+
receives the same account/token context and owns its key rotation policy.
|
|
378
|
+
|
|
276
379
|
**Integration contract for custom providers:**
|
|
277
380
|
|
|
278
|
-
- The
|
|
279
|
-
|
|
280
|
-
-
|
|
281
|
-
`
|
|
282
|
-
a transaction, a mid-flow failure leaves an orphan user. The built-in google callback uses it.
|
|
381
|
+
- The built-in provider-generic callback route handles any registered provider. A custom callback is
|
|
382
|
+
only needed when the provider does not follow the standard `code` / `state` response contract.
|
|
383
|
+
- If a custom callback calls `oauthCallbackService()` directly, wrap the route in `Transactional()`
|
|
384
|
+
(`import { Transactional } from '@spfn/core/db'`).
|
|
283
385
|
- The provider `id` must be in `SOCIAL_PROVIDERS` (`enumText`, plain text — adding a value needs **no**
|
|
284
386
|
DB migration).
|
|
285
387
|
- `auth.login` / `auth.register` events now carry any `SOCIAL_PROVIDERS` value in `provider` —
|
|
@@ -354,7 +456,48 @@ authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
|
|
|
354
456
|
```
|
|
355
457
|
|
|
356
458
|
Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `InvitationCreatedPayload`,
|
|
357
|
-
`InvitationAcceptedPayload
|
|
459
|
+
`InvitationAcceptedPayload`, `AuthDeletionRequestedPayload`, `AuthDeletionCancelledPayload`,
|
|
460
|
+
`AuthDeletionCompletedPayload`. These events also bind to `@spfn/core/job` jobs via `.on(event)`.
|
|
461
|
+
|
|
462
|
+
## Registration gate (`beforeRegister`)
|
|
463
|
+
|
|
464
|
+
Events fire *after* the user exists — they cannot reject a registration. For server-enforced
|
|
465
|
+
signup policy (age gate, invite-only domains, block lists) inject a validator with
|
|
466
|
+
`configureAuth`; it runs **before the user row is created** on every registration channel:
|
|
467
|
+
`credentials` (email/phone register), `oauth` (new-user social signup, web + native), and
|
|
468
|
+
`invitation` (acceptance). Throwing rejects the registration; `RegistrationRejectedError` (403)
|
|
469
|
+
is the recommended error. The hook receives the same `metadata` the app supplied to
|
|
470
|
+
`register` / OAuth start / the invitation — never credentials.
|
|
471
|
+
|
|
472
|
+
```typescript
|
|
473
|
+
import { configureAuth, RegistrationRejectedError } from '@spfn/auth/server';
|
|
474
|
+
|
|
475
|
+
configureAuth({
|
|
476
|
+
beforeRegister: async ({ channel, provider, email, phone, metadata }) =>
|
|
477
|
+
{
|
|
478
|
+
if (!isOldEnough(metadata?.birthDate))
|
|
479
|
+
{
|
|
480
|
+
throw new RegistrationRejectedError({ message: 'Age requirement not met' });
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Notes:
|
|
487
|
+
- Runs after built-in checks (verification token, duplicate account) — existing error
|
|
488
|
+
precedence is unchanged, and the hook cannot be probed without a valid verification token.
|
|
489
|
+
- Not called when an OAuth login links a social account to an existing user, nor for admin
|
|
490
|
+
seeding in `initializeAuth()`.
|
|
491
|
+
- OAuth signups have no client-typed fields unless you pass `metadata` at OAuth start — decide
|
|
492
|
+
per channel (reject, or allow and collect during onboarding).
|
|
493
|
+
- On the `oauth` channel `email` is the provider-reported address and may be **unverified**
|
|
494
|
+
(the created account then stores `email` as `null`). The context carries
|
|
495
|
+
`emailVerified` — an email-based allow/block policy must check it before trusting `email`.
|
|
496
|
+
- The hook runs **inside the registration DB transaction** on every channel — keep it fast.
|
|
497
|
+
A slow call (e.g. an external policy API) holds a pooled DB connection open per signup.
|
|
498
|
+
- On the **web** OAuth flow a rejection surfaces as the standard OAuth error redirect
|
|
499
|
+
(302 to the app's OAuth error URL, message only) — not a 403 JSON response. The native
|
|
500
|
+
OAuth flow, credentials, and invitation channels return the error status (403) directly.
|
|
358
501
|
|
|
359
502
|
## One-Time Token
|
|
360
503
|
|
|
@@ -362,6 +505,107 @@ For short-lived authenticated handshakes (e.g. SSE) where a `Bearer` header is a
|
|
|
362
505
|
with `authApi.issueOneTimeToken`, protect the consuming route with the `oneTimeTokenAuth`
|
|
363
506
|
middleware. Call `initOneTimeTokenManager({ ttl, store })` during setup for a custom TTL/store.
|
|
364
507
|
|
|
508
|
+
## Account Deletion & Recovery
|
|
509
|
+
|
|
510
|
+
Grace-period deletion with in-window recovery, an admin/GDPR-response entry point for immediate
|
|
511
|
+
purge, and a pluggable app-data cleanup hook. Not covered by this feature: re-signup email
|
|
512
|
+
blind-index/hashing (a purged account's email becomes reusable immediately — see the project's
|
|
513
|
+
PII protection track for blind-index re-signup prevention), backup beyond-use handling, DSR
|
|
514
|
+
intake/response workflows, and webhook fan-out — those are app/ops concerns.
|
|
515
|
+
|
|
516
|
+
```
|
|
517
|
+
active ──request (re-auth)──> pending_deletion ──grace period elapses (cron)──> deleted (anonymize) | row removed (hard-delete)
|
|
518
|
+
^ │
|
|
519
|
+
└───────────cancel (re-auth)───────┘ immediate = grace period of 0, same pipeline
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
- **Request** — `POST /_auth/deletion/request` (authenticated). Step-up re-auth: password
|
|
523
|
+
holders confirm with `password`; OAuth-only/passwordless accounts confirm with a
|
|
524
|
+
`verificationToken` from `/_auth/codes` + `/_auth/codes/verify` (`purpose: 'account_deletion'`).
|
|
525
|
+
On success: status → `pending_deletion`, every active session key is revoked, a
|
|
526
|
+
`account_deletion_requests` audit row is created, `auth.deletion.requested` fires, and (if
|
|
527
|
+
the user has an email and `sendNotifications` is on) a notice is sent with the scheduled purge
|
|
528
|
+
date.
|
|
529
|
+
- **Login is blocked while pending** — password login, OAuth login, and the `authenticate`
|
|
530
|
+
middleware all reject a `pending_deletion` account with `AccountPendingDeletionError` (403,
|
|
531
|
+
`details.purgeScheduledAt`) instead of the generic `AccountDisabledError`, so the client can
|
|
532
|
+
show a recovery prompt.
|
|
533
|
+
- **Cancel (recovery)** — `POST /_auth/deletion/cancel` (public — sessions were revoked at
|
|
534
|
+
request time, so there's no Bearer token to authenticate with). Credential-based: email/phone
|
|
535
|
+
plus `password` or a fresh `verificationToken`. On success, status → `active`; the user still
|
|
536
|
+
needs to log in separately afterward.
|
|
537
|
+
- **Purge job** — sweeps `account_deletion_requests` for rows past their grace period and
|
|
538
|
+
destroys the account. Register it explicitly (see below); it is **not** wired up by
|
|
539
|
+
`createAuthLifecycle()` automatically.
|
|
540
|
+
- **Admin / GDPR-response entry points** — `requestAccountDeletionService(userId, { requestedBy: 'admin', immediate })`
|
|
541
|
+
and `purgeUserService(userId)` are exported for app-side admin routes / DSR handling; the app
|
|
542
|
+
owns the route and its authorization.
|
|
543
|
+
|
|
544
|
+
```typescript
|
|
545
|
+
import { defineServerConfig } from '@spfn/core/server';
|
|
546
|
+
import { createAuthLifecycle, authJobRouter } from '@spfn/auth/server';
|
|
547
|
+
|
|
548
|
+
export default defineServerConfig()
|
|
549
|
+
.lifecycle(createAuthLifecycle({
|
|
550
|
+
deletion: {
|
|
551
|
+
gracePeriodDays: 30, // default; 0 = immediate
|
|
552
|
+
purgeStrategy: 'anonymize', // default; or 'hard-delete'
|
|
553
|
+
allowSelfImmediate: false, // default; self-service immediate: true
|
|
554
|
+
sendNotifications: true, // default
|
|
555
|
+
onBeforePurge: async (user) =>
|
|
556
|
+
{
|
|
557
|
+
// throw to skip this user for the current sweep (retried next run)
|
|
558
|
+
await appDataCleanup(user.id);
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
}))
|
|
562
|
+
.jobs(authJobRouter) // registers the daily (04:00 UTC) purge sweep
|
|
563
|
+
.routes(appRouter)
|
|
564
|
+
.build();
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
**Purge strategies:**
|
|
568
|
+
|
|
569
|
+
- `anonymize` (default) — scrubs PII, keeps the row: `email` → `deleted-{publicId}@deleted.invalid`,
|
|
570
|
+
`phone`/`username`/`passwordHash` → `null`, `status` → `'deleted'`, `deletedAt`/`deletedBy` set
|
|
571
|
+
(`softDelete()` on `users`). Social accounts and public keys are deleted (frees the provider
|
|
572
|
+
link and revokes access), the profile's PII columns are cleared, and any leftover verification
|
|
573
|
+
codes for the original email/phone are removed. The freed email/phone can be re-registered
|
|
574
|
+
immediately.
|
|
575
|
+
- `hard-delete` — physically removes the `users` row; child rows (`user_profiles`,
|
|
576
|
+
`user_public_keys`, `user_social_accounts`, `user_permissions`) cascade-delete via their FK.
|
|
577
|
+
The `account_deletion_requests` audit row survives either strategy — its `userId` FK is
|
|
578
|
+
`set null` (not cascade), by design, so "who requested/purged what, when" outlives the user row.
|
|
579
|
+
|
|
580
|
+
The final "your account has been deleted" notice is sent **after** the purge transaction commits
|
|
581
|
+
(never before, and never on a purge that aborted or rolled back — see below), using the address
|
|
582
|
+
captured before the destructive step ran. This holds for `hard-delete` too: the row is already
|
|
583
|
+
gone by send time, but the address was captured beforehand, so the notice still goes out.
|
|
584
|
+
|
|
585
|
+
**Concurrency.** The purge job re-verifies the user is still `pending_deletion` on the write
|
|
586
|
+
primary immediately before any destructive DML, inside the same transaction as the DML itself —
|
|
587
|
+
closing the window between a stale read (the sweep's own batch, or replica lag) and a concurrent
|
|
588
|
+
`cancel`. The `account_deletion_requests` claim (`markCompleted`) is a conditional `UPDATE ...
|
|
589
|
+
WHERE status = 'pending'`; if a concurrent cancel already moved the row off `pending`, the claim
|
|
590
|
+
matches zero rows and the purge aborts with no destructive DML and no overwritten audit row.
|
|
591
|
+
|
|
592
|
+
**Cron schedule caveat.** `deletion.purgeCron` (default `0 4 * * *`) is stored for reference, but
|
|
593
|
+
the static `authJobRouter` export above always runs on the *default* cron — `job(...).cron(...)`
|
|
594
|
+
is fixed at module-import time, which happens before `createAuthLifecycle()` runs in your
|
|
595
|
+
`server.config.ts`. For a non-default schedule, build the router yourself, after the
|
|
596
|
+
`createAuthLifecycle()` call, and register that instead:
|
|
597
|
+
|
|
598
|
+
```typescript
|
|
599
|
+
import { createAuthDeletionJobRouter } from '@spfn/auth/server';
|
|
600
|
+
|
|
601
|
+
// ... after .lifecycle(createAuthLifecycle({ deletion: { purgeCron: '0 3 * * *' } }))
|
|
602
|
+
.jobs(createAuthDeletionJobRouter({ purgeCron: '0 3 * * *' }))
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
Register **only one** of `authJobRouter` / `createAuthDeletionJobRouter(...)` — both build a job
|
|
606
|
+
named `auth.deletion.purge`, so registering both (e.g. the static export *and* a custom-cron
|
|
607
|
+
router) double-registers the same job name against pg-boss instead of overriding it.
|
|
608
|
+
|
|
365
609
|
## Pitfalls & anti-patterns
|
|
366
610
|
|
|
367
611
|
- **Wrong entry point.** `@spfn/auth/server` and `@spfn/auth/nextjs/*` are server-only (Node /
|
|
@@ -373,8 +617,9 @@ middleware. Call `initOneTimeTokenManager({ ttl, store })` during setup for a cu
|
|
|
373
617
|
`authErrorRegistry` in `src/errors/index.ts`) and pass it to your `createApi({ errorRegistry })`,
|
|
374
618
|
or the client receives a generic error instead of the typed one.
|
|
375
619
|
- **Two env files, by audience.** `SPFN_AUTH_SESSION_SECRET` lives in `.env.local` (Next.js needs
|
|
376
|
-
it for cookie crypto); `SPFN_AUTH_VERIFICATION_TOKEN_SECRET`
|
|
377
|
-
|
|
620
|
+
it for cookie crypto); `SPFN_AUTH_VERIFICATION_TOKEN_SECRET` and
|
|
621
|
+
`SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` live in `.env.server`. Token encryption keys are backend-only;
|
|
622
|
+
putting them in `.env.local` unnecessarily gives the Next.js process token-decryption authority.
|
|
378
623
|
- **`SPFN_AUTH_SESSION_SECRET` is validated.** Minimum 32 chars plus entropy/unique-char checks —
|
|
379
624
|
a short or low-entropy value fails startup, not just a warning.
|
|
380
625
|
- **Forgetting the interceptor import.** Without `import '@spfn/auth/nextjs/api'` in the RPC proxy
|
|
@@ -391,6 +636,13 @@ middleware. Call `initOneTimeTokenManager({ ttl, store })` during setup for a cu
|
|
|
391
636
|
every `switch(provider)` over login/register events must handle the new value.
|
|
392
637
|
- **Email/SMS is not here.** It moved to `@spfn/notification` (`import { sendEmail, sendSMS } from
|
|
393
638
|
'@spfn/notification/server'`). Wire verification-code / invitation emails through its events.
|
|
639
|
+
- **`authJobRouter` isn't registered for you.** `createAuthLifecycle()`'s `afterInfrastructure`
|
|
640
|
+
hook runs *before* `@spfn/core` initializes pg-boss and registers jobs, so the lifecycle has no
|
|
641
|
+
opportunity to auto-register the account-deletion purge job. Call `.jobs(authJobRouter)`
|
|
642
|
+
yourself — see [Account Deletion & Recovery](#account-deletion--recovery).
|
|
643
|
+
- **`USER_STATUSES` gained `pending_deletion` / `deleted`.** Any code with a `switch(user.status)`
|
|
644
|
+
or an exhaustive status union must handle both — `enumText` is plain `text` with no DB `CHECK`,
|
|
645
|
+
so nothing enforces this at the database layer.
|
|
394
646
|
|
|
395
647
|
## Complete example
|
|
396
648
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as _spfn_core_route from '@spfn/core/route';
|
|
2
|
-
import { K as KeyAlgorithmType,
|
|
2
|
+
import { K as KeyAlgorithmType, e as SocialProvider } from './types-1BMx0OX1.js';
|
|
3
3
|
import * as _sinclair_typebox from '@sinclair/typebox';
|
|
4
4
|
import { Static } from '@sinclair/typebox';
|
|
5
5
|
import { User } from '@spfn/auth/server';
|
|
@@ -158,9 +158,9 @@ declare const PasswordSchema: _sinclair_typebox.TString;
|
|
|
158
158
|
declare const TargetTypeSchema: _sinclair_typebox.TUnion<[_sinclair_typebox.TLiteral<"email">, _sinclair_typebox.TLiteral<"phone">]>;
|
|
159
159
|
type VerificationTargetType = Static<typeof TargetTypeSchema>;
|
|
160
160
|
declare const VERIFICATION_TARGET_TYPES: readonly ["email", "phone"];
|
|
161
|
-
declare const VerificationPurposeSchema: _sinclair_typebox.TUnion<[_sinclair_typebox.TLiteral<"registration">, _sinclair_typebox.TLiteral<"login">, _sinclair_typebox.TLiteral<"password_reset">, _sinclair_typebox.TLiteral<"email_change">, _sinclair_typebox.TLiteral<"phone_change">]>;
|
|
161
|
+
declare const VerificationPurposeSchema: _sinclair_typebox.TUnion<[_sinclair_typebox.TLiteral<"registration">, _sinclair_typebox.TLiteral<"login">, _sinclair_typebox.TLiteral<"password_reset">, _sinclair_typebox.TLiteral<"email_change">, _sinclair_typebox.TLiteral<"phone_change">, _sinclair_typebox.TLiteral<"account_deletion">]>;
|
|
162
162
|
type VerificationPurpose = Static<typeof VerificationPurposeSchema>;
|
|
163
|
-
declare const VERIFICATION_PURPOSES: readonly ["registration", "login", "password_reset", "email_change", "phone_change"];
|
|
163
|
+
declare const VERIFICATION_PURPOSES: readonly ["registration", "login", "password_reset", "email_change", "phone_change", "account_deletion"];
|
|
164
164
|
|
|
165
165
|
/**
|
|
166
166
|
* @spfn/auth - Verification Service
|
|
@@ -392,6 +392,10 @@ interface NativeVerifyOptions {
|
|
|
392
392
|
/** 클라이언트가 생성한 raw nonce. provider별 규약(raw 또는 SHA-256 해시)으로 대조된다. */
|
|
393
393
|
nonce: string;
|
|
394
394
|
}
|
|
395
|
+
interface OAuthCodeExchangeOptions {
|
|
396
|
+
/** Provider가 callback에 돌려준 원본 state. 일부 provider는 token 교환에도 요구한다. */
|
|
397
|
+
state: string;
|
|
398
|
+
}
|
|
395
399
|
/**
|
|
396
400
|
* OAuth provider 구현 인터페이스
|
|
397
401
|
*
|
|
@@ -413,7 +417,7 @@ interface OAuthProvider {
|
|
|
413
417
|
/**
|
|
414
418
|
* authorization code를 토큰으로 교환
|
|
415
419
|
*/
|
|
416
|
-
exchangeCodeForTokens(code: string): Promise<OAuthTokens>;
|
|
420
|
+
exchangeCodeForTokens(code: string, options: OAuthCodeExchangeOptions): Promise<OAuthTokens>;
|
|
417
421
|
/**
|
|
418
422
|
* access token으로 사용자 정보를 조회하고 공통 형태로 정규화
|
|
419
423
|
*/
|
|
@@ -477,12 +481,15 @@ interface OAuthCallbackParams {
|
|
|
477
481
|
code: string;
|
|
478
482
|
state: string;
|
|
479
483
|
/**
|
|
480
|
-
* Value of the oauth_csrf cookie from the callback request.
|
|
481
|
-
* nonce bound into the (encrypted) state — otherwise the flow
|
|
482
|
-
* by this browser (login CSRF).
|
|
483
|
-
*
|
|
484
|
+
* Value(s) of the oauth_csrf cookie from the callback request. One of them
|
|
485
|
+
* must equal the nonce bound into the (encrypted) state — otherwise the flow
|
|
486
|
+
* wasn't initiated by this browser (login CSRF). An array arises because the
|
|
487
|
+
* cookie name carries the PORT suffix of the process that set it (the Next.js
|
|
488
|
+
* web process), which differs from the API process in a split deployment, so
|
|
489
|
+
* the callback collects every spfn_oauth_csrf* candidate. Pass `undefined` or
|
|
490
|
+
* an empty array when absent; verification then fails closed.
|
|
484
491
|
*/
|
|
485
|
-
expectedNonce: string | undefined;
|
|
492
|
+
expectedNonce: string | string[] | undefined;
|
|
486
493
|
}
|
|
487
494
|
interface OAuthCallbackResult {
|
|
488
495
|
redirectUrl: string;
|
|
@@ -586,6 +593,7 @@ declare function oauthNativeService(params: OAuthNativeParams): Promise<OAuthNat
|
|
|
586
593
|
* - OAuth: /_auth/oauth/google, /_auth/oauth/google/callback, etc.
|
|
587
594
|
* - Invitations: /_auth/invitations/*
|
|
588
595
|
* - Users: /_auth/users/*
|
|
596
|
+
* - Deletion: /_auth/deletion/request, /_auth/deletion/cancel
|
|
589
597
|
* - Admin: /_auth/admin/* (superadmin only)
|
|
590
598
|
*/
|
|
591
599
|
declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
@@ -593,7 +601,7 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
593
601
|
body: _sinclair_typebox.TObject<{
|
|
594
602
|
target: _sinclair_typebox.TString;
|
|
595
603
|
targetType: _sinclair_typebox.TUnion<[_sinclair_typebox.TLiteral<"email">, _sinclair_typebox.TLiteral<"phone">]>;
|
|
596
|
-
purpose: _sinclair_typebox.TUnion<[_sinclair_typebox.TLiteral<"registration">, _sinclair_typebox.TLiteral<"login">, _sinclair_typebox.TLiteral<"password_reset">, _sinclair_typebox.TLiteral<"email_change">, _sinclair_typebox.TLiteral<"phone_change">]>;
|
|
604
|
+
purpose: _sinclair_typebox.TUnion<[_sinclair_typebox.TLiteral<"registration">, _sinclair_typebox.TLiteral<"login">, _sinclair_typebox.TLiteral<"password_reset">, _sinclair_typebox.TLiteral<"email_change">, _sinclair_typebox.TLiteral<"phone_change">, _sinclair_typebox.TLiteral<"account_deletion">]>;
|
|
597
605
|
}>;
|
|
598
606
|
}, {}, SendVerificationCodeResult>;
|
|
599
607
|
verifyCode: _spfn_core_route.RouteDef<{
|
|
@@ -601,7 +609,7 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
601
609
|
target: _sinclair_typebox.TString;
|
|
602
610
|
targetType: _sinclair_typebox.TUnion<[_sinclair_typebox.TLiteral<"email">, _sinclair_typebox.TLiteral<"phone">]>;
|
|
603
611
|
code: _sinclair_typebox.TString;
|
|
604
|
-
purpose: _sinclair_typebox.TUnion<[_sinclair_typebox.TLiteral<"registration">, _sinclair_typebox.TLiteral<"login">, _sinclair_typebox.TLiteral<"password_reset">, _sinclair_typebox.TLiteral<"email_change">, _sinclair_typebox.TLiteral<"phone_change">]>;
|
|
612
|
+
purpose: _sinclair_typebox.TUnion<[_sinclair_typebox.TLiteral<"registration">, _sinclair_typebox.TLiteral<"login">, _sinclair_typebox.TLiteral<"password_reset">, _sinclair_typebox.TLiteral<"email_change">, _sinclair_typebox.TLiteral<"phone_change">, _sinclair_typebox.TLiteral<"account_deletion">]>;
|
|
605
613
|
}>;
|
|
606
614
|
}, {}, {
|
|
607
615
|
valid: boolean;
|
|
@@ -674,6 +682,24 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
674
682
|
hasPassword: boolean;
|
|
675
683
|
}>;
|
|
676
684
|
issueOneTimeToken: _spfn_core_route.RouteDef<{}, {}, IssueOneTimeTokenResult>;
|
|
685
|
+
requestAccountDeletion: _spfn_core_route.RouteDef<{
|
|
686
|
+
body: _sinclair_typebox.TObject<{
|
|
687
|
+
password: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
688
|
+
verificationToken: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
689
|
+
reason: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
690
|
+
immediate: _sinclair_typebox.TOptional<_sinclair_typebox.TBoolean>;
|
|
691
|
+
}>;
|
|
692
|
+
}, {}, {
|
|
693
|
+
purgeScheduledAt: string;
|
|
694
|
+
}>;
|
|
695
|
+
cancelAccountDeletion: _spfn_core_route.RouteDef<{
|
|
696
|
+
body: _sinclair_typebox.TObject<{
|
|
697
|
+
email: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
698
|
+
phone: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
699
|
+
password: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
700
|
+
verificationToken: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
701
|
+
}>;
|
|
702
|
+
}, {}, void>;
|
|
677
703
|
oauthGoogleStart: _spfn_core_route.RouteDef<{
|
|
678
704
|
query: _sinclair_typebox.TObject<{
|
|
679
705
|
state: _sinclair_typebox.TString;
|
|
@@ -704,6 +730,7 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
704
730
|
getGoogleOAuthUrl: _spfn_core_route.RouteDef<{
|
|
705
731
|
body: _sinclair_typebox.TObject<{
|
|
706
732
|
returnUrl: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
733
|
+
metadata: _sinclair_typebox.TOptional<_sinclair_typebox.TRecord<_sinclair_typebox.TString, _sinclair_typebox.TUnknown>>;
|
|
707
734
|
state: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
708
735
|
}>;
|
|
709
736
|
}, {}, {
|
|
@@ -746,6 +773,7 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
746
773
|
}>;
|
|
747
774
|
body: _sinclair_typebox.TObject<{
|
|
748
775
|
returnUrl: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
776
|
+
metadata: _sinclair_typebox.TOptional<_sinclair_typebox.TRecord<_sinclair_typebox.TString, _sinclair_typebox.TUnknown>>;
|
|
749
777
|
state: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
750
778
|
}>;
|
|
751
779
|
}, {}, {
|
|
@@ -900,6 +928,8 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
900
928
|
username: _sinclair_typebox.TUnion<[_sinclair_typebox.TString, _sinclair_typebox.TNull]>;
|
|
901
929
|
}>;
|
|
902
930
|
}, {}, {
|
|
931
|
+
deletedAt: Date | null;
|
|
932
|
+
deletedBy: string | null;
|
|
903
933
|
createdAt: Date;
|
|
904
934
|
updatedAt: Date;
|
|
905
935
|
id: number;
|
|
@@ -910,7 +940,7 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
910
940
|
passwordHash: string | null;
|
|
911
941
|
passwordChangeRequired: boolean;
|
|
912
942
|
roleId: number;
|
|
913
|
-
status: "active" | "inactive" | "suspended";
|
|
943
|
+
status: "active" | "inactive" | "suspended" | "pending_deletion" | "deleted";
|
|
914
944
|
emailVerifiedAt: Date | null;
|
|
915
945
|
phoneVerifiedAt: Date | null;
|
|
916
946
|
lastLoginAt: Date | null;
|
|
@@ -1076,4 +1106,4 @@ declare const authenticate: _spfn_core_route.NamedMiddleware<"auth">;
|
|
|
1076
1106
|
*/
|
|
1077
1107
|
declare const optionalAuth: _spfn_core_route.NamedMiddleware<"optionalAuth">;
|
|
1078
1108
|
|
|
1079
|
-
export { oauthNativeService as $, type AuthSession as A, rotateKeyService as B, type ChangePasswordParams as C, revokeKeyService as D, type RegisterPublicKeyParams as E, type RotateKeyParams as F, type RevokeKeyParams as G, issueOneTimeTokenService as H, type IssueOneTimeTokenResult as I, verifyOneTimeTokenService as J, oauthStartService as K, type LoginResult as L, oauthCallbackService as M, buildOAuthErrorUrl as N, type OAuthStartResult as O, type PermissionConfig as P, isOAuthProviderEnabled as Q, type RoleConfig as R, type SendVerificationCodeResult as S, requireEnabledProvider as T, type UserProfile as U, type VerificationTargetType as V, getEnabledOAuthProviders as W, getGoogleAccessToken as X, type OAuthStartParams as Y, type OAuthCallbackParams as Z, type OAuthCallbackResult as _, type RegisterResult as a, type OAuthNativeParams as a0, authenticate as a1, optionalAuth as a2, EmailSchema as a3, PhoneSchema as a4, PasswordSchema as a5, TargetTypeSchema as a6, VerificationPurposeSchema as a7, type NormalizedIdentity as a8, type OAuthTokens as a9, type NativeVerifyOptions as aa,
|
|
1109
|
+
export { oauthNativeService as $, type AuthSession as A, rotateKeyService as B, type ChangePasswordParams as C, revokeKeyService as D, type RegisterPublicKeyParams as E, type RotateKeyParams as F, type RevokeKeyParams as G, issueOneTimeTokenService as H, type IssueOneTimeTokenResult as I, verifyOneTimeTokenService as J, oauthStartService as K, type LoginResult as L, oauthCallbackService as M, buildOAuthErrorUrl as N, type OAuthStartResult as O, type PermissionConfig as P, isOAuthProviderEnabled as Q, type RoleConfig as R, type SendVerificationCodeResult as S, requireEnabledProvider as T, type UserProfile as U, type VerificationTargetType as V, getEnabledOAuthProviders as W, getGoogleAccessToken as X, type OAuthStartParams as Y, type OAuthCallbackParams as Z, type OAuthCallbackResult as _, type RegisterResult as a, type OAuthNativeParams as a0, authenticate as a1, optionalAuth as a2, EmailSchema as a3, PhoneSchema as a4, PasswordSchema as a5, TargetTypeSchema as a6, VerificationPurposeSchema as a7, type NormalizedIdentity as a8, type OAuthTokens as a9, type NativeVerifyOptions as aa, type OAuthCodeExchangeOptions as ab, registerOAuthProvider as ac, getOAuthProvider as ad, getRegisteredProviders as ae, type RotateKeyResult as b, type OAuthNativeResult as c, type ProfileInfo as d, type VerificationPurpose as e, VERIFICATION_TARGET_TYPES as f, VERIFICATION_PURPOSES as g, PERMISSION_CATEGORIES as h, type PermissionCategory as i, type AuthInitOptions as j, type OAuthProvider as k, type AuthContext as l, mainAuthRouter as m, loginService as n, logoutService as o, changePasswordService as p, type RegisterParams as q, registerService as r, type LoginParams as s, type LogoutParams as t, sendVerificationCodeService as u, verifyCodeService as v, type SendVerificationCodeParams as w, type VerifyCodeParams as x, type VerifyCodeResult as y, registerPublicKeyService as z };
|