@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 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, Google built-in), session cookies for Next.js, and runtime RBAC.
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 `{NEXT_PUBLIC_SPFN_API_URL\|\|SPFN_API_URL}/_auth/oauth/google/callback` |
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
- provider self-registers on module load. External packages add providers at runtime with
211
- `registerOAuthProvider()`. Google OAuth turns on automatically once both
212
- `SPFN_AUTH_GOOGLE_CLIENT_ID` and `SPFN_AUTH_GOOGLE_CLIENT_SECRET` are set.
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({ body: { returnUrl: '/dashboard' } });
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 package only ships google's callback route. A custom provider must expose its **own**
279
- callback route that calls `oauthCallbackService({ provider, code, state })`.
280
- - **Wrap that callback route in `Transactional()`** (`import { Transactional } from '@spfn/core/db'`).
281
- `oauthCallbackService` creates/links a user and stores the social account in sequence — without
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`. These events also bind to `@spfn/core/job` jobs via `.on(event)`.
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` lives in `.env.server`. Splitting
377
- them wrong yields "missing secret" failures only at runtime.
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, d as SocialProvider } from './types-BtksCI9X.js';
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. Must equal the
481
- * nonce bound into the (encrypted) state — otherwise the flow wasn't initiated
482
- * by this browser (login CSRF). Pass `undefined` when the cookie is absent;
483
- * verification then fails closed.
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, registerOAuthProvider as ab, getOAuthProvider as ac, getRegisteredProviders as ad, 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 };
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 };