@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.
- package/README.md +52 -47
- package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
- package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
- package/dist/index.cjs +645 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -9
- package/dist/index.d.ts +27 -9
- package/dist/index.js +644 -150
- package/dist/index.js.map +1 -1
- package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
- package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/server.cjs +2852 -1675
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +779 -168
- package/dist/server.d.ts +779 -168
- package/dist/server.js +2831 -1665
- package/dist/server.js.map +1 -1
- package/dist/svelte.cjs +2 -2
- package/dist/svelte.cjs.map +1 -1
- package/dist/svelte.d.cts +2 -2
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +2 -2
- package/dist/svelte.js.map +1 -1
- package/dist/vue.d.cts +1 -1
- package/dist/vue.d.ts +1 -1
- package/package.json +7 -7
- package/src/admin/admin-api.ts +327 -0
- package/src/admin/audit-log.ts +324 -0
- package/src/admin/webhooks.ts +576 -0
- package/src/bindings/create-auth-session.ts +184 -0
- package/src/bindings/create-org-session.ts +130 -0
- package/src/client/auth-client.ts +1592 -0
- package/src/client/auth-sync.ts +213 -0
- package/src/client/device-session.ts +104 -0
- package/src/client/org-client.ts +399 -0
- package/src/client/quickstart.ts +108 -0
- package/src/client/storage.ts +94 -0
- package/src/device/device-identity.ts +330 -0
- package/src/device/device-store.ts +379 -0
- package/src/encryption/auto-lock.ts +170 -0
- package/src/encryption/database-encryption.ts +265 -0
- package/src/encryption/key-derivation.ts +149 -0
- package/src/encryption/operation-encryptor.ts +361 -0
- package/src/index.ts +132 -0
- package/src/mfa/totp.ts +826 -0
- package/src/org/org-routes.ts +758 -0
- package/src/org/org-store.ts +490 -0
- package/src/org/org-types.ts +230 -0
- package/src/passkey/passkey-client.ts +597 -0
- package/src/passkey/passkey-server.ts +779 -0
- package/src/postgres/ensure-schema.ts +65 -0
- package/src/provider/adapter.ts +246 -0
- package/src/provider/built-in/auth-routes.ts +1313 -0
- package/src/provider/built-in/email-verification.ts +303 -0
- package/src/provider/built-in/password-hash.ts +118 -0
- package/src/provider/built-in/password-reset.ts +416 -0
- package/src/provider/built-in/postgres-user-store.ts +328 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +322 -0
- package/src/provider/built-in/sync-scopes.ts +85 -0
- package/src/provider/built-in/user-store.ts +465 -0
- package/src/provider/external/clerk-adapter.ts +157 -0
- package/src/provider/external/external-jwt-provider.ts +491 -0
- package/src/provider/external/supabase-adapter.ts +163 -0
- package/src/provider/oauth/linked-identity-store.ts +108 -0
- package/src/provider/oauth/oauth-flow.ts +550 -0
- package/src/provider/oauth/oauth-types.ts +184 -0
- package/src/provider/oauth/postgres-oauth-store.ts +296 -0
- package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
- package/src/rbac/rbac-engine.ts +323 -0
- package/src/rbac/rbac-types.ts +210 -0
- package/src/rbac/scope-resolver.ts +140 -0
- package/src/react/AuthProvider.tsx +97 -0
- package/src/react/OrgProvider.tsx +41 -0
- package/src/react/auth-context.ts +26 -0
- package/src/react/hooks.ts +110 -0
- package/src/react/org-hooks.ts +214 -0
- package/src/react.ts +26 -0
- package/src/server.ts +334 -0
- package/src/session/session.ts +401 -0
- package/src/svelte/auth-context.ts +50 -0
- package/src/svelte/org-context.ts +32 -0
- package/src/svelte/org-hooks.ts +201 -0
- package/src/svelte/use-auth.ts +115 -0
- package/src/svelte.ts +25 -0
- package/src/tokens/encrypted-token-store.ts +360 -0
- package/src/tokens/jwt.ts +236 -0
- package/src/tokens/postgres-token-revocation-store.ts +140 -0
- package/src/tokens/sqlite-token-revocation-store.ts +121 -0
- package/src/tokens/token-manager.ts +821 -0
- package/src/tokens/token-store.ts +192 -0
- package/src/types.ts +394 -0
- package/src/vue/auth-context.ts +10 -0
- package/src/vue/auth-provider-types.ts +5 -0
- package/src/vue/auth-provider.ts +76 -0
- package/src/vue/org-hooks.ts +193 -0
- package/src/vue/org-provider.ts +49 -0
- package/src/vue/use-auth.ts +139 -0
- 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
|
+
}
|