@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.13

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.
Files changed (101) hide show
  1. package/README.md +52 -47
  2. package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
  3. package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
  4. package/dist/index.cjs +645 -150
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -9
  7. package/dist/index.d.ts +27 -9
  8. package/dist/index.js +644 -150
  9. package/dist/index.js.map +1 -1
  10. package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
  11. package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
  12. package/dist/react.d.cts +2 -2
  13. package/dist/react.d.ts +2 -2
  14. package/dist/server.cjs +2852 -1675
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +779 -168
  17. package/dist/server.d.ts +779 -168
  18. package/dist/server.js +2831 -1665
  19. package/dist/server.js.map +1 -1
  20. package/dist/svelte.cjs +2 -2
  21. package/dist/svelte.cjs.map +1 -1
  22. package/dist/svelte.d.cts +2 -2
  23. package/dist/svelte.d.ts +2 -2
  24. package/dist/svelte.js +2 -2
  25. package/dist/svelte.js.map +1 -1
  26. package/dist/vue.d.cts +1 -1
  27. package/dist/vue.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/admin/admin-api.ts +327 -0
  30. package/src/admin/audit-log.ts +324 -0
  31. package/src/admin/webhooks.ts +576 -0
  32. package/src/bindings/create-auth-session.ts +184 -0
  33. package/src/bindings/create-org-session.ts +130 -0
  34. package/src/client/auth-client.ts +1592 -0
  35. package/src/client/auth-sync.ts +213 -0
  36. package/src/client/device-session.ts +104 -0
  37. package/src/client/org-client.ts +399 -0
  38. package/src/client/quickstart.ts +108 -0
  39. package/src/client/storage.ts +94 -0
  40. package/src/device/device-identity.ts +330 -0
  41. package/src/device/device-store.ts +379 -0
  42. package/src/encryption/auto-lock.ts +170 -0
  43. package/src/encryption/database-encryption.ts +265 -0
  44. package/src/encryption/key-derivation.ts +149 -0
  45. package/src/encryption/operation-encryptor.ts +361 -0
  46. package/src/index.ts +132 -0
  47. package/src/mfa/totp.ts +826 -0
  48. package/src/org/org-routes.ts +758 -0
  49. package/src/org/org-store.ts +490 -0
  50. package/src/org/org-types.ts +230 -0
  51. package/src/passkey/passkey-client.ts +597 -0
  52. package/src/passkey/passkey-server.ts +779 -0
  53. package/src/postgres/ensure-schema.ts +65 -0
  54. package/src/provider/adapter.ts +246 -0
  55. package/src/provider/built-in/auth-routes.ts +1313 -0
  56. package/src/provider/built-in/email-verification.ts +303 -0
  57. package/src/provider/built-in/password-hash.ts +118 -0
  58. package/src/provider/built-in/password-reset.ts +416 -0
  59. package/src/provider/built-in/postgres-user-store.ts +328 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +322 -0
  62. package/src/provider/built-in/sync-scopes.ts +85 -0
  63. package/src/provider/built-in/user-store.ts +465 -0
  64. package/src/provider/external/clerk-adapter.ts +157 -0
  65. package/src/provider/external/external-jwt-provider.ts +491 -0
  66. package/src/provider/external/supabase-adapter.ts +163 -0
  67. package/src/provider/oauth/linked-identity-store.ts +108 -0
  68. package/src/provider/oauth/oauth-flow.ts +550 -0
  69. package/src/provider/oauth/oauth-types.ts +184 -0
  70. package/src/provider/oauth/postgres-oauth-store.ts +296 -0
  71. package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
  72. package/src/rbac/rbac-engine.ts +323 -0
  73. package/src/rbac/rbac-types.ts +210 -0
  74. package/src/rbac/scope-resolver.ts +140 -0
  75. package/src/react/AuthProvider.tsx +97 -0
  76. package/src/react/OrgProvider.tsx +41 -0
  77. package/src/react/auth-context.ts +26 -0
  78. package/src/react/hooks.ts +110 -0
  79. package/src/react/org-hooks.ts +214 -0
  80. package/src/react.ts +26 -0
  81. package/src/server.ts +334 -0
  82. package/src/session/session.ts +401 -0
  83. package/src/svelte/auth-context.ts +50 -0
  84. package/src/svelte/org-context.ts +32 -0
  85. package/src/svelte/org-hooks.ts +201 -0
  86. package/src/svelte/use-auth.ts +115 -0
  87. package/src/svelte.ts +25 -0
  88. package/src/tokens/encrypted-token-store.ts +360 -0
  89. package/src/tokens/jwt.ts +236 -0
  90. package/src/tokens/postgres-token-revocation-store.ts +140 -0
  91. package/src/tokens/sqlite-token-revocation-store.ts +121 -0
  92. package/src/tokens/token-manager.ts +821 -0
  93. package/src/tokens/token-store.ts +192 -0
  94. package/src/types.ts +394 -0
  95. package/src/vue/auth-context.ts +10 -0
  96. package/src/vue/auth-provider-types.ts +5 -0
  97. package/src/vue/auth-provider.ts +76 -0
  98. package/src/vue/org-hooks.ts +193 -0
  99. package/src/vue/org-provider.ts +49 -0
  100. package/src/vue/use-auth.ts +139 -0
  101. package/src/vue.ts +10 -0
@@ -0,0 +1,760 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { KoraError } from '@korajs/core'
3
+ import {
4
+ InMemoryTokenRevocationStore,
5
+ TokenManager,
6
+ type TokenManagerConfig,
7
+ type TokenRevocationStore,
8
+ } from '../../tokens/token-manager'
9
+ import type { AuthTokens } from '../../types'
10
+ import {
11
+ DuplicateLinkedIdentityError,
12
+ InMemoryLinkedIdentityStore,
13
+ type LinkedIdentityStore,
14
+ } from '../oauth/linked-identity-store'
15
+ import { OAuthManager, type OAuthManagerConfig, generateOAuthBinding } from '../oauth/oauth-flow'
16
+ import {
17
+ type LinkedIdentity,
18
+ OAuthError,
19
+ type OAuthProviderConfig,
20
+ type OAuthUserInfo,
21
+ } from '../oauth/oauth-types'
22
+ import {
23
+ type AuthRevocationEvent,
24
+ type AuthRouteResponse,
25
+ type AuthRoutesConfig,
26
+ BuiltInAuthRoutes,
27
+ type ChallengeStore,
28
+ type MfaVerifier,
29
+ type RateLimiter,
30
+ type SignInResult,
31
+ } from './auth-routes'
32
+ import { hashPassword } from './password-hash'
33
+ import type { SyncAuthProvider, SyncScopeOptions } from './sync-scopes'
34
+ import {
35
+ type AuthUser,
36
+ DeviceOwnershipError,
37
+ InMemoryUserStore,
38
+ type StoredUser,
39
+ type UserStore,
40
+ } from './user-store'
41
+
42
+ type SignUpBody = Parameters<BuiltInAuthRoutes['handleSignUp']>[0]
43
+ type SignInBody = Parameters<BuiltInAuthRoutes['handleSignIn']>[0]
44
+ type RefreshBody = Parameters<BuiltInAuthRoutes['handleRefresh']>[0]
45
+ type SignOutBody = Parameters<BuiltInAuthRoutes['handleSignOut']>[1]
46
+ type DeviceRegisterBody = Parameters<BuiltInAuthRoutes['handleDeviceRegister']>[1]
47
+ type DeviceVerifyBody = Parameters<BuiltInAuthRoutes['handleDeviceVerify']>[0]
48
+
49
+ export interface KoraAuthHttpRequest {
50
+ method: string
51
+ path: string
52
+ body?: unknown
53
+ headers?: Record<string, string | string[] | undefined>
54
+ query?: Record<string, string | string[] | undefined>
55
+ ip?: string
56
+ }
57
+
58
+ export interface OAuthServerConfig extends Omit<OAuthManagerConfig, 'providers'> {
59
+ providers: OAuthProviderConfig[]
60
+ linkedIdentityStore?: LinkedIdentityStore
61
+ /** Create a Kora user on first OAuth sign-in. Defaults to true. */
62
+ createNewUsers?: boolean
63
+ /**
64
+ * Link OAuth identities to an existing Kora user with the same verified email.
65
+ * Defaults to false so apps choose this trust boundary explicitly.
66
+ */
67
+ autoLinkVerifiedEmail?: boolean
68
+ /**
69
+ * Allow a user to unlink their last OAuth identity. Defaults to false to avoid
70
+ * locking out OAuth-created accounts that do not have a known password.
71
+ */
72
+ allowUnlinkLastIdentity?: boolean
73
+ }
74
+
75
+ export interface CreateKoraAuthServerOptions extends SyncScopeOptions {
76
+ /** Existing user store. Defaults to InMemoryUserStore for development. */
77
+ userStore?: UserStore
78
+ /**
79
+ * Token revocation store. Defaults to the user store's own revocation store
80
+ * (`userStore.getTokenRevocationStore()`), so revocations persist and are shared
81
+ * exactly as far as the users are.
82
+ */
83
+ revocationStore?: TokenRevocationStore
84
+ /**
85
+ * Allow in-memory user or revocation stores when `NODE_ENV=production`.
86
+ * In-memory stores lose every account and every revocation on restart and are
87
+ * not shared between instances, so production refuses them by default.
88
+ */
89
+ allowInMemory?: boolean
90
+ /** Existing token manager. Overrides `jwtSecret` and `tokenManager` options. */
91
+ tokenManager?: TokenManager
92
+ /** JWT secret. Required in production when `tokenManager` is not provided. */
93
+ jwtSecret?: string | string[]
94
+ /** Additional TokenManager options. */
95
+ tokenManagerOptions?: Omit<TokenManagerConfig, 'secret'>
96
+ /** Auth HTTP path prefix. Defaults to `/auth`. */
97
+ path?: string
98
+ /** OAuth provider routes and account-linking storage. */
99
+ oauth?: OAuthServerConfig
100
+ challengeStore?: ChallengeStore
101
+ rateLimiter?: RateLimiter
102
+ /**
103
+ * Second-factor verifier (for example a `TotpManager`). Users with MFA enabled
104
+ * then sign in in two steps: `{ mfaRequired, mfaToken }`, then
105
+ * `POST /auth/mfa/verify` with `{ mfaToken, code }`.
106
+ */
107
+ mfa?: MfaVerifier
108
+ }
109
+
110
+ /**
111
+ * The part of `KoraSyncServer` the auth server needs to end live sessions.
112
+ * Structural, so `@korajs/auth` does not depend on `@korajs/server`.
113
+ */
114
+ export interface SyncSessionTerminator {
115
+ terminateSessions(filter: { userId?: string; deviceId?: string }): unknown
116
+ }
117
+
118
+ export interface KoraAuthServer {
119
+ routes: BuiltInAuthRoutes
120
+ userStore: UserStore
121
+ tokenManager: TokenManager
122
+ oauth?: OAuthManager
123
+ linkedIdentityStore?: LinkedIdentityStore
124
+ /** Sync auth provider with server-derived scopes (pass to `KoraSyncServer`). */
125
+ auth: SyncAuthProvider
126
+ handleRequest(request: KoraAuthHttpRequest): Promise<AuthRouteResponse<unknown>>
127
+ /**
128
+ * Revoke every credential a user holds (call after a password change made
129
+ * outside these routes, an admin action or an account deletion).
130
+ */
131
+ revokeAllForUser(userId: string): Promise<void>
132
+ /** Subscribe to credential revocations. Returns an unsubscribe function. */
133
+ onRevoke(listener: (event: AuthRevocationEvent) => void | Promise<void>): () => void
134
+ /**
135
+ * End live sync sessions when their credentials are revoked (AUTH-11).
136
+ * Requires a sync server exposing `terminateSessions({ userId, deviceId })`.
137
+ * A `KoraSyncServer` constructed with this server's `auth` provider already
138
+ * follows its revocations; call this for a server built with a wrapping or
139
+ * custom provider. Binding both is harmless. Returns an unsubscribe function.
140
+ */
141
+ bindSyncServer(server: SyncSessionTerminator): () => void
142
+ }
143
+
144
+ /**
145
+ * Thrown by `createKoraAuthServer` in production when a store would silently
146
+ * lose accounts or revocations on restart.
147
+ */
148
+ export class InMemoryAuthStoreError extends KoraError {
149
+ constructor(which: 'userStore' | 'revocationStore') {
150
+ super(
151
+ `createKoraAuthServer refuses an in-memory ${which} in production: it loses every ${
152
+ which === 'userStore' ? 'account' : 'sign-out and revocation'
153
+ } on restart and is not shared between instances.`,
154
+ 'IN_MEMORY_AUTH_STORE',
155
+ {
156
+ which,
157
+ fix: 'Pass a persistent store (createSqliteUserStore / createPostgresUserStore, whose getTokenRevocationStore() is used automatically), or set allowInMemory: true for a throwaway deployment.',
158
+ },
159
+ )
160
+ this.name = 'InMemoryAuthStoreError'
161
+ }
162
+ }
163
+
164
+ interface OAuthServerRuntime {
165
+ manager: OAuthManager
166
+ linkedIdentityStore: LinkedIdentityStore
167
+ createNewUsers: boolean
168
+ autoLinkVerifiedEmail: boolean
169
+ allowUnlinkLastIdentity: boolean
170
+ }
171
+
172
+ /**
173
+ * Create the built-in Kora auth server with production-shaped defaults.
174
+ *
175
+ * Simple apps can use `handleRequest()` for all `/auth/*` HTTP endpoints and
176
+ * pass `auth` directly to `createProductionServer({ syncOptions: { auth } })`.
177
+ */
178
+ export function createKoraAuthServer(options: CreateKoraAuthServerOptions = {}): KoraAuthServer {
179
+ const userStore = options.userStore ?? new InMemoryUserStore()
180
+ const revocationStore =
181
+ options.revocationStore ??
182
+ options.tokenManagerOptions?.revocationStore ??
183
+ userStore.getTokenRevocationStore?.() ??
184
+ new InMemoryTokenRevocationStore()
185
+ const tokenManager = options.tokenManager ?? createDefaultTokenManager(options, revocationStore)
186
+ if (isProduction() && !options.allowInMemory) {
187
+ if (userStore instanceof InMemoryUserStore) throw new InMemoryAuthStoreError('userStore')
188
+ const effectiveRevocation = tokenManager.getRevocationStore()
189
+ if (!effectiveRevocation || effectiveRevocation instanceof InMemoryTokenRevocationStore) {
190
+ throw new InMemoryAuthStoreError('revocationStore')
191
+ }
192
+ }
193
+ const routes = new BuiltInAuthRoutes({
194
+ userStore,
195
+ tokenManager,
196
+ challengeStore: options.challengeStore,
197
+ rateLimiter: options.rateLimiter,
198
+ mfa: options.mfa,
199
+ })
200
+ const oauth = options.oauth ? createOAuthRuntime(options.oauth) : undefined
201
+ const path = normalizePath(options.path ?? '/auth')
202
+
203
+ return {
204
+ routes,
205
+ userStore,
206
+ tokenManager,
207
+ oauth: oauth?.manager,
208
+ linkedIdentityStore: oauth?.linkedIdentityStore,
209
+ auth: routes.toSyncAuthProvider({
210
+ scopeValues: options.scopeValues,
211
+ resolveScopes: options.resolveScopes,
212
+ }),
213
+ handleRequest(request) {
214
+ return handleAuthRequest(routes, path, request, oauth, userStore, tokenManager)
215
+ },
216
+ revokeAllForUser(userId) {
217
+ return routes.revokeAllForUser(userId)
218
+ },
219
+ onRevoke(listener) {
220
+ return routes.onRevoke(listener)
221
+ },
222
+ bindSyncServer(server) {
223
+ return routes.onRevoke((event) => {
224
+ if (event.kind === 'user') {
225
+ server.terminateSessions({ userId: event.userId })
226
+ } else {
227
+ server.terminateSessions({ userId: event.userId, deviceId: event.deviceId })
228
+ }
229
+ })
230
+ },
231
+ }
232
+ }
233
+
234
+ function createDefaultTokenManager(
235
+ options: CreateKoraAuthServerOptions,
236
+ revocationStore: TokenRevocationStore,
237
+ ): TokenManager {
238
+ const secret = options.jwtSecret ?? readEnvSecret()
239
+ if (!secret && isProduction()) {
240
+ throw new Error(
241
+ 'createKoraAuthServer requires jwtSecret in production. Set KORA_AUTH_SECRET or pass jwtSecret.',
242
+ )
243
+ }
244
+
245
+ if (!secret) {
246
+ // Outside production we fall back to an ephemeral random secret so local
247
+ // development works with zero setup. Warn loudly: this secret is
248
+ // regenerated on every process start, so every previously issued token is
249
+ // silently invalidated on restart. That is a confusing failure mode if it
250
+ // ever reaches a deployed environment where NODE_ENV was simply never set
251
+ // to "production" — the production guard above only fires when NODE_ENV
252
+ // explicitly equals "production".
253
+ console.warn(
254
+ '[kora] No JWT secret configured; using an ephemeral random secret. ' +
255
+ 'Every token is invalidated when the process restarts. ' +
256
+ 'Set KORA_AUTH_SECRET or pass jwtSecret to createKoraAuthServer for stable sessions.',
257
+ )
258
+ }
259
+
260
+ return new TokenManager({
261
+ secret: secret ?? TokenManager.generateSecret(),
262
+ ...options.tokenManagerOptions,
263
+ revocationStore,
264
+ })
265
+ }
266
+
267
+ function createOAuthRuntime(config: OAuthServerConfig): OAuthServerRuntime {
268
+ return {
269
+ manager: new OAuthManager(config),
270
+ linkedIdentityStore: config.linkedIdentityStore ?? new InMemoryLinkedIdentityStore(),
271
+ createNewUsers: config.createNewUsers ?? true,
272
+ autoLinkVerifiedEmail: config.autoLinkVerifiedEmail ?? false,
273
+ allowUnlinkLastIdentity: config.allowUnlinkLastIdentity ?? false,
274
+ }
275
+ }
276
+
277
+ async function handleAuthRequest(
278
+ routes: BuiltInAuthRoutes,
279
+ pathPrefix: string,
280
+ request: KoraAuthHttpRequest,
281
+ oauth: OAuthServerRuntime | undefined,
282
+ userStore: UserStore,
283
+ tokenManager: TokenManager,
284
+ ): Promise<AuthRouteResponse<unknown>> {
285
+ const path = normalizePath(request.path)
286
+ const relativePath = path === pathPrefix ? '/' : path.slice(pathPrefix.length)
287
+ const method = request.method.toUpperCase()
288
+ const body = isRecord(request.body) ? request.body : {}
289
+ const token = extractBearerToken(request.headers)
290
+
291
+ if (path !== pathPrefix && !path.startsWith(`${pathPrefix}/`)) {
292
+ return notFound()
293
+ }
294
+
295
+ if (relativePath.startsWith('/oauth/')) {
296
+ return handleOAuthRequest({
297
+ oauth,
298
+ routes,
299
+ userStore,
300
+ tokenManager,
301
+ relativePath,
302
+ method,
303
+ body,
304
+ query: request.query,
305
+ token,
306
+ cookieBinding: readCookie(request.headers, OAUTH_BINDING_COOKIE),
307
+ pathPrefix,
308
+ })
309
+ }
310
+
311
+ if (method === 'POST' && relativePath === '/signup') {
312
+ return routes.handleSignUp(body as SignUpBody, request.ip)
313
+ }
314
+ if (method === 'POST' && relativePath === '/signin') {
315
+ return routes.handleSignIn(body as SignInBody, request.ip)
316
+ }
317
+ if (method === 'POST' && relativePath === '/mfa/verify') {
318
+ return routes.handleMfaVerify(body)
319
+ }
320
+ if (method === 'POST' && relativePath === '/refresh') {
321
+ return routes.handleRefresh(body as RefreshBody)
322
+ }
323
+ if (method === 'POST' && relativePath === '/signout') {
324
+ return routes.handleSignOut(token, body as SignOutBody)
325
+ }
326
+ if (method === 'GET' && relativePath === '/me') {
327
+ return routes.handleGetMe(token)
328
+ }
329
+ if (method === 'GET' && relativePath === '/devices') {
330
+ return routes.handleListDevices(token)
331
+ }
332
+ if (method === 'POST' && relativePath === '/device/register') {
333
+ return routes.handleDeviceRegister(token, body as DeviceRegisterBody)
334
+ }
335
+ if (method === 'POST' && relativePath === '/device/challenge') {
336
+ const deviceId = typeof body.deviceId === 'string' ? body.deviceId : ''
337
+ return routes.handleDeviceChallenge(token, deviceId)
338
+ }
339
+ if (method === 'POST' && relativePath === '/device/verify') {
340
+ return routes.handleDeviceVerify(body as DeviceVerifyBody)
341
+ }
342
+ if (method === 'DELETE' && relativePath.startsWith('/device/')) {
343
+ return routes.handleRevokeDevice(token, relativePath.slice('/device/'.length))
344
+ }
345
+
346
+ return notFound()
347
+ }
348
+
349
+ async function handleOAuthRequest(params: {
350
+ oauth: OAuthServerRuntime | undefined
351
+ routes: BuiltInAuthRoutes
352
+ userStore: UserStore
353
+ tokenManager: TokenManager
354
+ relativePath: string
355
+ method: string
356
+ body: Record<string, unknown>
357
+ query: KoraAuthHttpRequest['query']
358
+ token: string
359
+ cookieBinding: string | undefined
360
+ pathPrefix: string
361
+ }): Promise<AuthRouteResponse<unknown>> {
362
+ const {
363
+ oauth,
364
+ routes,
365
+ userStore,
366
+ tokenManager,
367
+ relativePath,
368
+ method,
369
+ body,
370
+ query,
371
+ token,
372
+ cookieBinding,
373
+ pathPrefix,
374
+ } = params
375
+ if (!oauth) {
376
+ return notFound()
377
+ }
378
+
379
+ try {
380
+ if (method === 'GET' && relativePath === '/oauth/links') {
381
+ const authUser = await requireAuthUser(routes, token)
382
+ if ('status' in authUser) return authUser
383
+ const identities = await oauth.linkedIdentityStore.findByUser(authUser.id)
384
+ return { status: 200, body: { data: identities } }
385
+ }
386
+
387
+ const match = /^\/oauth\/([^/]+)(?:\/(callback|link|link\/start))?$/.exec(relativePath)
388
+ if (!match) {
389
+ return notFound()
390
+ }
391
+
392
+ const provider = decodeURIComponent(match[1] as string)
393
+ const action = match[2]
394
+
395
+ // Every flow is bound to its purpose and to the initiating client (AUTH-3):
396
+ // a random binding goes to the client as an HttpOnly cookie (web) and in the
397
+ // body (native/PKCE apps keep it in memory); only its hash is stored.
398
+ if (method === 'GET' && !action) {
399
+ const binding = generateOAuthBinding()
400
+ const { url, state } = await oauth.manager.getAuthorizationUrl(
401
+ provider,
402
+ metadataFromQuery(query),
403
+ { purpose: 'signin', binding },
404
+ )
405
+ return flowStarted(url, state, binding, pathPrefix)
406
+ }
407
+
408
+ if (method === 'POST' && action === 'link/start') {
409
+ const authUser = await requireAuthUser(routes, token)
410
+ if ('status' in authUser) return authUser
411
+ const binding = generateOAuthBinding()
412
+ const { url, state } = await oauth.manager.getAuthorizationUrl(provider, undefined, {
413
+ purpose: 'link',
414
+ userId: authUser.id,
415
+ binding,
416
+ })
417
+ return flowStarted(url, state, binding, pathPrefix)
418
+ }
419
+
420
+ if ((method === 'GET' || method === 'POST') && action === 'callback') {
421
+ const code = readString(method === 'GET' ? queryValue(query, 'code') : body.code)
422
+ const state = readString(method === 'GET' ? queryValue(query, 'state') : body.state)
423
+ if (!code || !state) {
424
+ return { status: 400, body: { error: 'OAuth callback requires code and state.' } }
425
+ }
426
+ return await completeOAuthSignIn({
427
+ oauth,
428
+ routes,
429
+ userStore,
430
+ provider,
431
+ code,
432
+ state,
433
+ binding: readString(body.binding) ?? cookieBinding,
434
+ deviceId: readString(body.deviceId),
435
+ devicePublicKey: readString(body.devicePublicKey),
436
+ })
437
+ }
438
+
439
+ if (method === 'POST' && action === 'link') {
440
+ const authUser = await requireAuthUser(routes, token)
441
+ if ('status' in authUser) return authUser
442
+ const code = readString(body.code)
443
+ const state = readString(body.state)
444
+ if (!code || !state) {
445
+ return { status: 400, body: { error: 'OAuth linking requires code and state.' } }
446
+ }
447
+ return await linkOAuthIdentity(
448
+ oauth,
449
+ authUser.id,
450
+ provider,
451
+ code,
452
+ state,
453
+ readString(body.binding) ?? cookieBinding,
454
+ )
455
+ }
456
+
457
+ if (method === 'DELETE' && action === 'link') {
458
+ const authUser = await requireAuthUser(routes, token)
459
+ if ('status' in authUser) return authUser
460
+ const identities = await oauth.linkedIdentityStore.findByUser(authUser.id)
461
+ if (!oauth.allowUnlinkLastIdentity && identities.length <= 1) {
462
+ return {
463
+ status: 409,
464
+ body: {
465
+ error:
466
+ 'Cannot unlink the last OAuth identity unless allowUnlinkLastIdentity is enabled.',
467
+ },
468
+ }
469
+ }
470
+ await oauth.linkedIdentityStore.delete(authUser.id, provider)
471
+ return { status: 200, body: { data: { ok: true } } }
472
+ }
473
+ } catch (error) {
474
+ return oauthErrorResponse(error)
475
+ }
476
+
477
+ return notFound()
478
+ }
479
+
480
+ async function completeOAuthSignIn(params: {
481
+ oauth: OAuthServerRuntime
482
+ routes: BuiltInAuthRoutes
483
+ userStore: UserStore
484
+ provider: string
485
+ code: string
486
+ state: string
487
+ binding: string | undefined
488
+ deviceId?: string
489
+ devicePublicKey?: string
490
+ }): Promise<AuthRouteResponse<SignInResult & { identity: LinkedIdentity }>> {
491
+ const { oauth, routes, userStore, provider, code, state, deviceId, devicePublicKey } = params
492
+ const { userInfo } = await oauth.manager.handleCallback(provider, code, state, {
493
+ purpose: 'signin',
494
+ binding: params.binding,
495
+ })
496
+ const linkedIdentity = await oauth.linkedIdentityStore.findByProvider(
497
+ userInfo.provider,
498
+ userInfo.providerId,
499
+ )
500
+
501
+ let user: AuthUser
502
+ let identity: LinkedIdentity
503
+ if (linkedIdentity) {
504
+ const storedUser = await userStore.findById(linkedIdentity.userId)
505
+ if (!storedUser) {
506
+ return { status: 409, body: { error: 'Linked OAuth account has no matching user.' } }
507
+ }
508
+ user = toAuthUser(storedUser)
509
+ identity = linkedIdentity
510
+ } else {
511
+ const resolved = await resolveOAuthUser(userStore, oauth, userInfo)
512
+ if ('status' in resolved) return resolved
513
+ user = resolved
514
+ identity = await oauth.linkedIdentityStore.create({
515
+ userId: user.id,
516
+ provider: userInfo.provider,
517
+ providerUserId: userInfo.providerId,
518
+ email: userInfo.email,
519
+ })
520
+ }
521
+
522
+ // Never trust a device id carried in OAuth state metadata (it comes from the
523
+ // query string of whoever started the flow), and never default to a per-user
524
+ // id that every browser of the user would share (AUTH-3, AUTH-5).
525
+ const resolvedDeviceId = deviceId ?? `dev-${randomUUID()}`
526
+ try {
527
+ await userStore.registerDevice({
528
+ id: resolvedDeviceId,
529
+ userId: user.id,
530
+ publicKey: devicePublicKey ?? '',
531
+ name: deviceId ? 'Device' : 'Browser',
532
+ })
533
+ } catch (error) {
534
+ if (error instanceof DeviceOwnershipError) {
535
+ return {
536
+ status: 409,
537
+ body: {
538
+ error: 'This device id is registered to another account.',
539
+ code: 'DEVICE_OWNERSHIP_CONFLICT',
540
+ },
541
+ }
542
+ }
543
+ throw error
544
+ }
545
+
546
+ // Same rule as password sign-in: an MFA user gets a challenge, not tokens.
547
+ const result = await routes.completePrimaryAuthentication(user, resolvedDeviceId, ['oauth'])
548
+ if (!('data' in result.body)) return result as AuthRouteResponse<never>
549
+ return { status: result.status, body: { data: { ...result.body.data, identity } } }
550
+ }
551
+
552
+ async function resolveOAuthUser(
553
+ userStore: UserStore,
554
+ oauth: OAuthServerRuntime,
555
+ userInfo: OAuthUserInfo,
556
+ ): Promise<AuthUser | AuthRouteResponse<never>> {
557
+ if (!userInfo.email) {
558
+ return { status: 400, body: { error: 'OAuth provider did not return an email address.' } }
559
+ }
560
+
561
+ const existingUser = await userStore.findByEmail(userInfo.email)
562
+ if (existingUser) {
563
+ if (oauth.autoLinkVerifiedEmail && userInfo.emailVerified) {
564
+ return toAuthUser(existingUser)
565
+ }
566
+ return {
567
+ status: 409,
568
+ body: { error: 'OAuth account is not linked. Sign in and link this provider first.' },
569
+ }
570
+ }
571
+
572
+ if (!oauth.createNewUsers) {
573
+ return { status: 403, body: { error: 'OAuth sign-up is disabled for this application.' } }
574
+ }
575
+
576
+ const credential = await hashPassword(randomUUID())
577
+ const user = await userStore.createUser({
578
+ email: userInfo.email,
579
+ passwordHash: credential.hash,
580
+ salt: credential.salt,
581
+ name: userInfo.name ?? userInfo.email.split('@')[0] ?? userInfo.email,
582
+ })
583
+ if (userInfo.emailVerified) {
584
+ await userStore.setEmailVerified(user.id, true)
585
+ return { ...user, emailVerified: true }
586
+ }
587
+ return user
588
+ }
589
+
590
+ async function linkOAuthIdentity(
591
+ oauth: OAuthServerRuntime,
592
+ userId: string,
593
+ provider: string,
594
+ code: string,
595
+ state: string,
596
+ binding: string | undefined,
597
+ ): Promise<AuthRouteResponse<LinkedIdentity>> {
598
+ const { userInfo } = await oauth.manager.handleCallback(provider, code, state, {
599
+ purpose: 'link',
600
+ userId,
601
+ binding,
602
+ })
603
+ const existing = await oauth.linkedIdentityStore.findByProvider(
604
+ userInfo.provider,
605
+ userInfo.providerId,
606
+ )
607
+ if (existing && existing.userId !== userId) {
608
+ return { status: 409, body: { error: 'This OAuth account is already linked to another user.' } }
609
+ }
610
+ if (existing) {
611
+ return { status: 200, body: { data: existing } }
612
+ }
613
+
614
+ const identity = await oauth.linkedIdentityStore.create({
615
+ userId,
616
+ provider: userInfo.provider,
617
+ providerUserId: userInfo.providerId,
618
+ email: userInfo.email,
619
+ })
620
+ return { status: 201, body: { data: identity } }
621
+ }
622
+
623
+ async function requireAuthUser(
624
+ routes: BuiltInAuthRoutes,
625
+ token: string,
626
+ ): Promise<AuthUser | AuthRouteResponse<never>> {
627
+ if (!token) {
628
+ return {
629
+ status: 401,
630
+ body: { error: 'Authorization token required.', code: 'ACCESS_TOKEN_REQUIRED' },
631
+ }
632
+ }
633
+ // The same revocation-aware check every route uses (AUTH-2, AUTH-8).
634
+ const access = await routes.authenticateAccess(token)
635
+ if (!access) {
636
+ return {
637
+ status: 401,
638
+ body: { error: 'Invalid or expired token.', code: 'ACCESS_TOKEN_INVALID' },
639
+ }
640
+ }
641
+ return toAuthUser(access.user)
642
+ }
643
+
644
+ const OAUTH_BINDING_COOKIE = 'kora_oauth_binding'
645
+
646
+ function flowStarted(
647
+ url: string,
648
+ state: string,
649
+ binding: string,
650
+ pathPrefix: string,
651
+ ): AuthRouteResponse<{ url: string; state: string; binding: string }> {
652
+ return {
653
+ status: 200,
654
+ body: { data: { url, state, binding } },
655
+ headers: {
656
+ 'Set-Cookie': `${OAUTH_BINDING_COOKIE}=${binding}; Path=${pathPrefix}/oauth; Max-Age=600; HttpOnly; Secure; SameSite=Lax`,
657
+ },
658
+ }
659
+ }
660
+
661
+ function readCookie(headers: KoraAuthHttpRequest['headers'], name: string): string | undefined {
662
+ const raw = headers?.cookie ?? headers?.Cookie
663
+ const value = Array.isArray(raw) ? raw.join('; ') : raw
664
+ if (!value) return undefined
665
+ for (const part of value.split(';')) {
666
+ const [key, ...rest] = part.trim().split('=')
667
+ if (key === name) return rest.join('=')
668
+ }
669
+ return undefined
670
+ }
671
+
672
+ function extractBearerToken(headers: KoraAuthHttpRequest['headers']): string {
673
+ const authorization = headers?.authorization ?? headers?.Authorization
674
+ const value = Array.isArray(authorization) ? authorization[0] : authorization
675
+ if (!value?.startsWith('Bearer ')) {
676
+ return ''
677
+ }
678
+ return value.slice('Bearer '.length).trim()
679
+ }
680
+
681
+ function normalizePath(path: string): string {
682
+ const withoutQuery = path.split('?')[0] || '/'
683
+ const normalized = withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`
684
+ return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized
685
+ }
686
+
687
+ function metadataFromQuery(
688
+ query: KoraAuthHttpRequest['query'],
689
+ ): Record<string, unknown> | undefined {
690
+ if (!query) return undefined
691
+ const metadata: Record<string, unknown> = {}
692
+ for (const [key, value] of Object.entries(query)) {
693
+ if (key === 'code' || key === 'state') continue
694
+ if (value !== undefined) {
695
+ metadata[key] = value
696
+ }
697
+ }
698
+ return Object.keys(metadata).length > 0 ? metadata : undefined
699
+ }
700
+
701
+ function queryValue(
702
+ query: KoraAuthHttpRequest['query'],
703
+ key: string,
704
+ ): string | string[] | undefined {
705
+ return query?.[key]
706
+ }
707
+
708
+ function readString(value: unknown): string | undefined {
709
+ if (typeof value === 'string' && value.length > 0) return value
710
+ if (Array.isArray(value) && typeof value[0] === 'string' && value[0].length > 0) {
711
+ return value[0]
712
+ }
713
+ return undefined
714
+ }
715
+
716
+ function oauthErrorResponse(error: unknown): AuthRouteResponse<never> {
717
+ if (error instanceof DuplicateLinkedIdentityError) {
718
+ return { status: 409, body: { error: error.message } }
719
+ }
720
+ if (error instanceof OAuthError) {
721
+ const status = error.code === 'OAUTH_PROVIDER_NOT_FOUND' ? 404 : 400
722
+ return { status, body: { error: error.message } }
723
+ }
724
+ throw error
725
+ }
726
+
727
+ function toAuthUser(user: StoredUser): AuthUser {
728
+ return {
729
+ id: user.id,
730
+ email: user.email,
731
+ name: user.name,
732
+ emailVerified: user.emailVerified,
733
+ createdAt: user.createdAt,
734
+ }
735
+ }
736
+
737
+ function isRecord(value: unknown): value is Record<string, unknown> {
738
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
739
+ }
740
+
741
+ function notFound(): AuthRouteResponse<never> {
742
+ return { status: 404, body: { error: 'Not found' } }
743
+ }
744
+
745
+ function readEnvSecret(): string | undefined {
746
+ if (typeof process === 'undefined') {
747
+ return undefined
748
+ }
749
+ // Treat an explicitly empty or whitespace-only KORA_AUTH_SECRET as "not set"
750
+ // rather than as a real (invalid) secret. Otherwise an empty string slips past
751
+ // the nullish-coalescing fallback below and reaches TokenManager, which throws
752
+ // on secrets shorter than 32 chars — turning a blank env var into a hard crash
753
+ // instead of the intended dev fallback / production guard.
754
+ const value = process.env.KORA_AUTH_SECRET
755
+ return value && value.trim().length > 0 ? value : undefined
756
+ }
757
+
758
+ function isProduction(): boolean {
759
+ return typeof process !== 'undefined' && process.env.NODE_ENV === 'production'
760
+ }