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

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 +2880 -1667
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +810 -169
  17. package/dist/server.d.ts +810 -169
  18. package/dist/server.js +2848 -1646
  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 +365 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +335 -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 +285 -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 +338 -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,491 @@
1
+ import { KoraError, type ScopeMap, claimScopes } from '@korajs/core'
2
+ import { decodeJwt, isExpired, verifyJwt } from '../../tokens/jwt'
3
+ import type { AuthTokens } from '../../types'
4
+ import type { AuthProviderAdapter, SignInParams, SignUpParams } from '../adapter'
5
+ import type { AuthUser } from '../built-in/user-store'
6
+ import type { AuthDevice } from '../built-in/user-store'
7
+
8
+ // ============================================================================
9
+ // Error classes
10
+ // ============================================================================
11
+
12
+ /**
13
+ * Thrown when an operation is not supported by external auth providers.
14
+ *
15
+ * External providers delegate user management to the third-party service
16
+ * (Clerk, Auth0, Supabase, etc.). Operations like sign-up and sign-in must
17
+ * be performed through the external provider's SDK or UI, not through Kora.
18
+ */
19
+ export class ExternalAuthOperationNotSupportedError extends KoraError {
20
+ constructor(operation: string, provider: string) {
21
+ super(
22
+ `The "${operation}" operation is not supported by the external auth provider "${provider}". Perform this operation through your external auth provider's SDK or dashboard instead.`,
23
+ 'AUTH_EXTERNAL_OPERATION_NOT_SUPPORTED',
24
+ { operation, provider },
25
+ )
26
+ this.name = 'ExternalAuthOperationNotSupportedError'
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Thrown when an external JWT token fails validation.
32
+ */
33
+ export class ExternalTokenValidationError extends KoraError {
34
+ constructor(reason: string, context?: Record<string, unknown>) {
35
+ super(`External token validation failed: ${reason}`, 'AUTH_EXTERNAL_TOKEN_INVALID', context)
36
+ this.name = 'ExternalTokenValidationError'
37
+ }
38
+ }
39
+
40
+ // ============================================================================
41
+ // Configuration
42
+ // ============================================================================
43
+
44
+ /**
45
+ * Default claim mapping for external JWT tokens.
46
+ * Maps standard JWT claims to Kora's expected user format.
47
+ */
48
+ function defaultMapClaims(claims: Record<string, unknown>): ExternalUserInfo {
49
+ const sub = claims.sub
50
+ if (typeof sub !== 'string' || sub.length === 0) {
51
+ throw new ExternalTokenValidationError(
52
+ 'JWT is missing a valid "sub" (subject) claim. The "sub" claim must be a non-empty string identifying the user.',
53
+ { availableClaims: Object.keys(claims) },
54
+ )
55
+ }
56
+
57
+ return {
58
+ userId: sub,
59
+ email: typeof claims.email === 'string' ? claims.email : undefined,
60
+ name: typeof claims.name === 'string' ? claims.name : undefined,
61
+ metadata: undefined,
62
+ }
63
+ }
64
+
65
+ /**
66
+ * User information extracted from an external JWT token.
67
+ * Returned by the claims mapping function.
68
+ */
69
+ export interface ExternalUserInfo {
70
+ /** Unique user identifier from the external provider */
71
+ userId: string
72
+ /** User's email address, if available in the token claims */
73
+ email?: string
74
+ /** User's display name, if available in the token claims */
75
+ name?: string
76
+ /** Additional metadata from the token claims */
77
+ metadata?: Record<string, unknown>
78
+ }
79
+
80
+ /**
81
+ * Configuration for the external JWT authentication provider.
82
+ *
83
+ * Supports two validation modes:
84
+ * 1. **HMAC secret** (`jwtSecret`): For providers that sign tokens with a shared
85
+ * secret (e.g., Supabase). Uses HS256 verification via the existing `verifyJwt` utility.
86
+ * 2. **Custom validator** (`validateToken`): For providers that use asymmetric keys
87
+ * (RS256, ES256) or require custom validation logic (e.g., Clerk with JWKS rotation).
88
+ * The developer provides their own validation function.
89
+ *
90
+ * At least one of `jwtSecret` or `validateToken` must be provided.
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * // With a shared secret (e.g., Supabase)
95
+ * const provider = new ExternalJwtProvider({
96
+ * providerName: 'supabase',
97
+ * jwtSecret: process.env.SUPABASE_JWT_SECRET,
98
+ * })
99
+ *
100
+ * // With a custom validator (e.g., Clerk JWKS)
101
+ * const provider = new ExternalJwtProvider({
102
+ * providerName: 'clerk',
103
+ * validateToken: async (token) => {
104
+ * const claims = await clerkClient.verifyToken(token)
105
+ * return claims ? { sub: claims.sub, ...claims } : null
106
+ * },
107
+ * })
108
+ * ```
109
+ */
110
+ export interface ExternalJwtProviderConfig {
111
+ /**
112
+ * Human-readable name of the external auth provider.
113
+ * Used in error messages and DevTools for identification.
114
+ * @example 'clerk', 'auth0', 'supabase', 'firebase'
115
+ */
116
+ providerName: string
117
+
118
+ /**
119
+ * Shared secret for HS256 JWT verification.
120
+ * Used when the external provider signs tokens with HMAC-SHA256.
121
+ * Mutually exclusive with `validateToken` (if both are provided,
122
+ * `validateToken` takes precedence).
123
+ */
124
+ jwtSecret?: string
125
+
126
+ /**
127
+ * Custom token validator function.
128
+ * When provided, this is used instead of HS256 HMAC verification.
129
+ * Receives the raw JWT string and must return the decoded claims
130
+ * (with at least a `sub` field) or null if the token is invalid.
131
+ *
132
+ * Use this for providers that use asymmetric signing (RS256, ES256)
133
+ * or require JWKS-based key rotation.
134
+ *
135
+ * @param token - The raw JWT string to validate
136
+ * @returns The decoded claims object with at least a `sub` field, or null if invalid
137
+ */
138
+ validateToken?: (token: string) => Promise<{ sub: string; [key: string]: unknown } | null>
139
+
140
+ /**
141
+ * Map external JWT claims to Kora's expected user format.
142
+ *
143
+ * The default mapping extracts:
144
+ * - `sub` -> `userId` (required)
145
+ * - `email` -> `email` (optional)
146
+ * - `name` -> `name` (optional)
147
+ *
148
+ * Override this to extract custom claims from your provider's tokens.
149
+ *
150
+ * @param claims - The decoded JWT claims object
151
+ * @returns Kora-compatible user information
152
+ *
153
+ * @example
154
+ * ```typescript
155
+ * mapClaims: (claims) => ({
156
+ * userId: claims.sub as string,
157
+ * email: claims.email_address as string,
158
+ * name: `${claims.first_name} ${claims.last_name}`,
159
+ * metadata: { org: claims.org_id },
160
+ * })
161
+ * ```
162
+ */
163
+ mapClaims?: (claims: Record<string, unknown>) => ExternalUserInfo
164
+
165
+ /**
166
+ * Accepted audience(s) (`aud`). When set, tokens for any other audience are
167
+ * rejected, even if they share the signing secret. Defaults to
168
+ * `'authenticated'` when `providerName` is `'supabase'`.
169
+ */
170
+ audience?: string | string[]
171
+
172
+ /** Accepted issuer(s) (`iss`). When set, tokens from any other issuer are rejected. */
173
+ issuer?: string | string[]
174
+
175
+ /**
176
+ * Verified scope values for the sync grant, derived from the validated claims
177
+ * (AUTH-1). Merged over `{ userId }`; every schema-scoped collection is bound
178
+ * from them, and a collection whose binding is missing is denied.
179
+ */
180
+ scopeValues?: (claims: Record<string, unknown>) => Record<string, unknown>
181
+
182
+ /** Full explicit sync grant from the validated claims (replaces the default). */
183
+ resolveScopes?: (claims: Record<string, unknown>) => ScopeMap | Promise<ScopeMap>
184
+ }
185
+
186
+ // ============================================================================
187
+ // Implementation
188
+ // ============================================================================
189
+
190
+ /**
191
+ * Authentication provider adapter for external JWT issuers.
192
+ *
193
+ * This adapter validates JWTs issued by third-party auth services (Clerk, Auth0,
194
+ * Supabase, Firebase, or any custom JWT issuer) and maps their claims to Kora's
195
+ * internal auth context. It bridges external identity providers with Kora's
196
+ * sync authentication layer.
197
+ *
198
+ * **How it works:**
199
+ * 1. The client authenticates with the external provider and receives a JWT
200
+ * 2. The client passes this JWT to Kora's sync server
201
+ * 3. This adapter validates the JWT and extracts user identity
202
+ * 4. Kora uses the extracted identity for sync authorization
203
+ *
204
+ * **What it does NOT do:**
205
+ * - Sign up or sign in users (that happens through the external provider)
206
+ * - Issue or refresh tokens (that is the external provider's responsibility)
207
+ * - Manage devices (external providers handle their own device tracking)
208
+ *
209
+ * @example
210
+ * ```typescript
211
+ * import { ExternalJwtProvider } from '@korajs/auth/server'
212
+ *
213
+ * const auth = new ExternalJwtProvider({
214
+ * providerName: 'clerk',
215
+ * validateToken: async (token) => {
216
+ * // Use Clerk's SDK or JWKS endpoint to verify
217
+ * return verifiedClaims
218
+ * },
219
+ * })
220
+ *
221
+ * // Use with Kora sync server
222
+ * const result = await auth.validateAccessToken('eyJhbG...')
223
+ * if (result) {
224
+ * console.log('User:', result.userId)
225
+ * }
226
+ * ```
227
+ */
228
+ export class ExternalJwtProvider implements AuthProviderAdapter {
229
+ private readonly providerName: string
230
+ private readonly jwtSecret: string | undefined
231
+ private readonly customValidateToken:
232
+ | ((token: string) => Promise<{ sub: string; [key: string]: unknown } | null>)
233
+ | undefined
234
+ private readonly mapClaims: (claims: Record<string, unknown>) => ExternalUserInfo
235
+ private readonly audiences: string[] | null
236
+ private readonly issuers: string[] | null
237
+ private readonly scopeValues: ExternalJwtProviderConfig['scopeValues']
238
+ private readonly resolveScopes: ExternalJwtProviderConfig['resolveScopes']
239
+
240
+ constructor(config: ExternalJwtProviderConfig) {
241
+ if (config.validateToken === undefined && config.jwtSecret === undefined) {
242
+ throw new ExternalTokenValidationError(
243
+ 'ExternalJwtProvider requires either a "jwtSecret" for HS256 verification ' +
244
+ 'or a custom "validateToken" function. Provide at least one.',
245
+ { providerName: config.providerName },
246
+ )
247
+ }
248
+
249
+ this.providerName = config.providerName
250
+ this.jwtSecret = config.jwtSecret
251
+ this.customValidateToken = config.validateToken
252
+ this.mapClaims = config.mapClaims ?? defaultMapClaims
253
+ const audience =
254
+ config.audience ?? (config.providerName === 'supabase' ? 'authenticated' : undefined)
255
+ this.audiences = audience === undefined ? null : Array.isArray(audience) ? audience : [audience]
256
+ this.issuers =
257
+ config.issuer === undefined
258
+ ? null
259
+ : Array.isArray(config.issuer)
260
+ ? config.issuer
261
+ : [config.issuer]
262
+ this.scopeValues = config.scopeValues
263
+ this.resolveScopes = config.resolveScopes
264
+ }
265
+
266
+ /**
267
+ * Not supported for external providers.
268
+ *
269
+ * User registration must be performed through the external auth provider's
270
+ * SDK or UI. Kora does not manage user accounts for external providers.
271
+ *
272
+ * @throws {ExternalAuthOperationNotSupportedError} Always
273
+ */
274
+ async signUp(_params: SignUpParams): Promise<{ user: AuthUser; tokens: AuthTokens }> {
275
+ throw new ExternalAuthOperationNotSupportedError('signUp', this.providerName)
276
+ }
277
+
278
+ /**
279
+ * Not supported for external providers.
280
+ *
281
+ * User authentication must be performed through the external auth provider's
282
+ * SDK or UI. Kora does not manage credentials for external providers.
283
+ *
284
+ * @throws {ExternalAuthOperationNotSupportedError} Always
285
+ */
286
+ async signIn(_params: SignInParams): Promise<{ user: AuthUser; tokens: AuthTokens }> {
287
+ throw new ExternalAuthOperationNotSupportedError('signIn', this.providerName)
288
+ }
289
+
290
+ /**
291
+ * Not supported for external providers.
292
+ *
293
+ * Token refresh must be performed through the external auth provider's SDK.
294
+ * Kora does not manage token lifecycle for external providers.
295
+ *
296
+ * @throws {ExternalAuthOperationNotSupportedError} Always
297
+ */
298
+ async refreshTokens(_refreshToken: string): Promise<AuthTokens> {
299
+ throw new ExternalAuthOperationNotSupportedError('refreshTokens', this.providerName)
300
+ }
301
+
302
+ /**
303
+ * Validate an access token from the external auth provider.
304
+ *
305
+ * Uses either the custom `validateToken` function or HS256 HMAC verification
306
+ * (depending on configuration) to validate the JWT. On success, maps the claims
307
+ * to Kora's expected format and returns the user ID and device ID.
308
+ *
309
+ * The device ID for external providers is derived from the user ID with a
310
+ * "external-" prefix, since external providers typically don't use Kora's
311
+ * device identity system.
312
+ *
313
+ * @param token - The JWT access token issued by the external provider
314
+ * @returns User ID and device ID if the token is valid, or null if invalid/expired
315
+ */
316
+ async validateAccessToken(token: string): Promise<{ userId: string; deviceId: string } | null> {
317
+ const claims = await this.extractClaims(token)
318
+ if (claims === null) {
319
+ return null
320
+ }
321
+
322
+ let userInfo: ExternalUserInfo
323
+ try {
324
+ userInfo = this.mapClaims(claims)
325
+ } catch {
326
+ return null
327
+ }
328
+
329
+ if (typeof userInfo.userId !== 'string' || userInfo.userId.length === 0) {
330
+ return null
331
+ }
332
+
333
+ // External providers don't use Kora's device identity system.
334
+ // Derive a stable device ID from the user ID so sync authorization works.
335
+ const deviceId = `external-${this.providerName}-${userInfo.userId}`
336
+
337
+ return { userId: userInfo.userId, deviceId }
338
+ }
339
+
340
+ /**
341
+ * Not supported for external providers.
342
+ *
343
+ * User lookup must be performed through the external auth provider's API.
344
+ *
345
+ * @throws {ExternalAuthOperationNotSupportedError} Always
346
+ */
347
+ async getUser(_userId: string): Promise<AuthUser | null> {
348
+ throw new ExternalAuthOperationNotSupportedError('getUser', this.providerName)
349
+ }
350
+
351
+ /**
352
+ * Not supported for external providers.
353
+ *
354
+ * Device revocation must be managed through the external auth provider
355
+ * or by revoking the user's tokens at the provider level.
356
+ *
357
+ * @throws {ExternalAuthOperationNotSupportedError} Always
358
+ */
359
+ async revokeDevice(_accessToken: string, _deviceId: string): Promise<void> {
360
+ throw new ExternalAuthOperationNotSupportedError('revokeDevice', this.providerName)
361
+ }
362
+
363
+ /**
364
+ * Not supported for external providers.
365
+ *
366
+ * Device listing must be performed through the external auth provider's API.
367
+ *
368
+ * @throws {ExternalAuthOperationNotSupportedError} Always
369
+ */
370
+ async listDevices(_accessToken: string): Promise<AuthDevice[]> {
371
+ throw new ExternalAuthOperationNotSupportedError('listDevices', this.providerName)
372
+ }
373
+
374
+ /**
375
+ * Creates a sync server auth provider compatible with `@korajs/server`.
376
+ *
377
+ * Returns an object with an `authenticate` method that validates the external
378
+ * JWT and returns a Kora-compatible auth context. Use this to wire the external
379
+ * auth provider into KoraSyncServer.
380
+ *
381
+ * @returns An object with an `authenticate` method for KoraSyncServer's `auth` config
382
+ *
383
+ * @example
384
+ * ```typescript
385
+ * const externalAuth = new ExternalJwtProvider({ ... })
386
+ * const syncServer = new KoraSyncServer({
387
+ * store,
388
+ * auth: externalAuth.toSyncAuthProvider(),
389
+ * })
390
+ * ```
391
+ */
392
+ toSyncAuthProvider(): {
393
+ authenticate(token: string): Promise<{
394
+ userId: string
395
+ scopes?: Record<string, Record<string, unknown>>
396
+ metadata?: Record<string, unknown>
397
+ } | null>
398
+ } {
399
+ return {
400
+ authenticate: async (token: string) => {
401
+ const claims = await this.extractClaims(token)
402
+ if (claims === null) {
403
+ return null
404
+ }
405
+
406
+ let userInfo: ExternalUserInfo
407
+ try {
408
+ userInfo = this.mapClaims(claims)
409
+ } catch {
410
+ return null
411
+ }
412
+
413
+ if (typeof userInfo.userId !== 'string' || userInfo.userId.length === 0) {
414
+ return null
415
+ }
416
+
417
+ // Server-derived grant (AUTH-1): never let the client handshake pick.
418
+ const scopes = this.resolveScopes
419
+ ? await this.resolveScopes(claims)
420
+ : claimScopes({ ...(this.scopeValues?.(claims) ?? {}), userId: userInfo.userId })
421
+
422
+ return {
423
+ userId: userInfo.userId,
424
+ scopes,
425
+ metadata: {
426
+ provider: this.providerName,
427
+ email: userInfo.email,
428
+ name: userInfo.name,
429
+ ...userInfo.metadata,
430
+ },
431
+ }
432
+ },
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Extract and validate claims from a JWT token.
438
+ *
439
+ * Uses the custom validator if configured, otherwise falls back to
440
+ * HS256 HMAC verification with the configured secret.
441
+ *
442
+ * @param token - The raw JWT string
443
+ * @returns The decoded claims object, or null if the token is invalid
444
+ */
445
+ private async extractClaims(token: string): Promise<Record<string, unknown> | null> {
446
+ // Custom validator takes precedence
447
+ if (this.customValidateToken !== undefined) {
448
+ try {
449
+ const result = await this.customValidateToken(token)
450
+ if (result === null) {
451
+ return null
452
+ }
453
+ const claims = result as Record<string, unknown>
454
+ return this.audienceAndIssuerMatch(claims) ? claims : null
455
+ } catch {
456
+ return null
457
+ }
458
+ }
459
+
460
+ // Fall back to HS256 HMAC verification
461
+ if (this.jwtSecret !== undefined) {
462
+ const claims = verifyJwt(token, this.jwtSecret)
463
+ if (claims === null) {
464
+ return null
465
+ }
466
+
467
+ // A token without a numeric exp would be valid forever (AUTH-14).
468
+ if (typeof claims.exp !== 'number' || isExpired(claims as { exp?: number })) {
469
+ return null
470
+ }
471
+
472
+ return this.audienceAndIssuerMatch(claims) ? claims : null
473
+ }
474
+
475
+ // Should not reach here due to constructor validation, but handle defensively
476
+ return null
477
+ }
478
+
479
+ /** Enforce configured `aud` / `iss` so tokens for another service are refused. */
480
+ private audienceAndIssuerMatch(claims: Record<string, unknown>): boolean {
481
+ if (this.audiences) {
482
+ const aud = claims.aud
483
+ const values = Array.isArray(aud) ? aud : [aud]
484
+ if (!values.some((v) => typeof v === 'string' && this.audiences?.includes(v))) return false
485
+ }
486
+ if (this.issuers) {
487
+ if (typeof claims.iss !== 'string' || !this.issuers.includes(claims.iss)) return false
488
+ }
489
+ return true
490
+ }
491
+ }
@@ -0,0 +1,163 @@
1
+ import {
2
+ ExternalJwtProvider,
3
+ type ExternalJwtProviderConfig,
4
+ type ExternalUserInfo,
5
+ } from './external-jwt-provider'
6
+
7
+ // ============================================================================
8
+ // Supabase Adapter Configuration
9
+ // ============================================================================
10
+
11
+ /**
12
+ * Configuration for the Supabase authentication adapter.
13
+ *
14
+ * Supabase Auth signs JWTs with HS256 using the project's JWT secret,
15
+ * which is available in your Supabase project settings under
16
+ * Settings -> API -> JWT Secret.
17
+ *
18
+ * Supabase JWTs typically contain:
19
+ * - `sub`: User UUID
20
+ * - `email`: User's email address
21
+ * - `role`: The database role (e.g., "authenticated", "anon")
22
+ * - `aud`: Audience (usually "authenticated")
23
+ * - `app_metadata`: Provider info, roles, etc.
24
+ * - `user_metadata`: Custom user data (name, avatar, etc.)
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * import { createSupabaseAdapter } from '@korajs/auth/server'
29
+ *
30
+ * const supabaseAuth = createSupabaseAdapter({
31
+ * jwtSecret: process.env.SUPABASE_JWT_SECRET,
32
+ * })
33
+ *
34
+ * // Use with Kora sync server
35
+ * const syncServer = new KoraSyncServer({
36
+ * store,
37
+ * auth: supabaseAuth.toSyncAuthProvider(),
38
+ * })
39
+ * ```
40
+ */
41
+ export interface SupabaseAdapterConfig {
42
+ /**
43
+ * Supabase JWT secret from your project settings.
44
+ *
45
+ * Found in: Supabase Dashboard -> Settings -> API -> JWT Secret
46
+ *
47
+ * This is the HS256 HMAC secret used to sign and verify Supabase Auth JWTs.
48
+ * Keep this secret secure and never expose it in client-side code.
49
+ */
50
+ jwtSecret: string
51
+
52
+ /**
53
+ * Custom claim mapping override.
54
+ *
55
+ * By default, the Supabase adapter maps:
56
+ * - `sub` -> `userId`
57
+ * - `email` -> `email`
58
+ * - `user_metadata.full_name` or `user_metadata.name` -> `name`
59
+ * - `role`, `aud`, `app_metadata` -> `metadata`
60
+ *
61
+ * Override this to customize how Supabase claims are mapped to Kora's format.
62
+ */
63
+ mapClaims?: ExternalJwtProviderConfig['mapClaims']
64
+ }
65
+
66
+ // ============================================================================
67
+ // Default Supabase claim mapping
68
+ // ============================================================================
69
+
70
+ /**
71
+ * Default claim mapping for Supabase Auth JWTs.
72
+ *
73
+ * Extracts user identity from Supabase's standard JWT claims and maps
74
+ * role/audience information into Kora metadata.
75
+ */
76
+ function defaultSupabaseClaimMapping(claims: Record<string, unknown>): ExternalUserInfo {
77
+ const sub = claims.sub
78
+ if (typeof sub !== 'string' || sub.length === 0) {
79
+ return { userId: '' }
80
+ }
81
+
82
+ const email = typeof claims.email === 'string' ? claims.email : undefined
83
+
84
+ // Extract name from user_metadata (Supabase stores user profile data here)
85
+ let name: string | undefined
86
+ const userMetadata = claims.user_metadata
87
+ if (typeof userMetadata === 'object' && userMetadata !== null && !Array.isArray(userMetadata)) {
88
+ const meta = userMetadata as Record<string, unknown>
89
+ if (typeof meta.full_name === 'string' && meta.full_name.length > 0) {
90
+ name = meta.full_name
91
+ } else if (typeof meta.name === 'string' && meta.name.length > 0) {
92
+ name = meta.name
93
+ }
94
+ }
95
+
96
+ // Collect metadata from Supabase-specific claims
97
+ const metadata: Record<string, unknown> = {}
98
+ if (typeof claims.role === 'string') {
99
+ metadata.role = claims.role
100
+ }
101
+ if (typeof claims.aud === 'string') {
102
+ metadata.aud = claims.aud
103
+ }
104
+ if (
105
+ typeof claims.app_metadata === 'object' &&
106
+ claims.app_metadata !== null &&
107
+ !Array.isArray(claims.app_metadata)
108
+ ) {
109
+ metadata.appMetadata = claims.app_metadata
110
+ }
111
+
112
+ return {
113
+ userId: sub,
114
+ email,
115
+ name,
116
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
117
+ }
118
+ }
119
+
120
+ // ============================================================================
121
+ // Factory function
122
+ // ============================================================================
123
+
124
+ /**
125
+ * Creates an ExternalJwtProvider configured for Supabase Auth.
126
+ *
127
+ * Supabase Auth uses HS256 signing with the project's JWT secret, making it
128
+ * compatible with Kora's built-in `verifyJwt` utility. No external SDK is needed.
129
+ *
130
+ * This adapter validates the JWT signature and expiration, then maps Supabase's
131
+ * standard claims (sub, email, role, user_metadata) to Kora's auth context format.
132
+ *
133
+ * @param config - Supabase adapter configuration
134
+ * @returns An ExternalJwtProvider instance configured for Supabase
135
+ *
136
+ * @example
137
+ * ```typescript
138
+ * import { createSupabaseAdapter } from '@korajs/auth/server'
139
+ *
140
+ * const supabaseAuth = createSupabaseAdapter({
141
+ * jwtSecret: process.env.SUPABASE_JWT_SECRET,
142
+ * })
143
+ *
144
+ * // Validate a Supabase access token
145
+ * const result = await supabaseAuth.validateAccessToken(supabaseAccessToken)
146
+ * if (result) {
147
+ * console.log('Supabase user:', result.userId)
148
+ * }
149
+ *
150
+ * // Or use with the sync server
151
+ * const syncServer = new KoraSyncServer({
152
+ * store,
153
+ * auth: supabaseAuth.toSyncAuthProvider(),
154
+ * })
155
+ * ```
156
+ */
157
+ export function createSupabaseAdapter(config: SupabaseAdapterConfig): ExternalJwtProvider {
158
+ return new ExternalJwtProvider({
159
+ providerName: 'supabase',
160
+ jwtSecret: config.jwtSecret,
161
+ mapClaims: config.mapClaims ?? defaultSupabaseClaimMapping,
162
+ })
163
+ }