@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,1313 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto'
|
|
2
|
+
import { computePublicKeyThumbprint, verifyChallenge } from '../../device/device-identity'
|
|
3
|
+
import type { TokenManager } from '../../tokens/token-manager'
|
|
4
|
+
import type { AuthTokens, TokenPayload } from '../../types'
|
|
5
|
+
import { hashPassword, verifyPassword } from './password-hash'
|
|
6
|
+
import {
|
|
7
|
+
type SyncAuthProvider,
|
|
8
|
+
type SyncScopeOptions,
|
|
9
|
+
type VerifiedSyncClaims,
|
|
10
|
+
resolveSyncGrant,
|
|
11
|
+
} from './sync-scopes'
|
|
12
|
+
import {
|
|
13
|
+
type AuthDevice,
|
|
14
|
+
type AuthUser,
|
|
15
|
+
DeviceOwnershipError,
|
|
16
|
+
type StoredUser,
|
|
17
|
+
type UserStore,
|
|
18
|
+
} from './user-store'
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Challenge Store
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Interface for server-side challenge storage.
|
|
26
|
+
*
|
|
27
|
+
* Challenges must be stored server-side with expiry and single-use semantics
|
|
28
|
+
* to prevent replay attacks on device verification.
|
|
29
|
+
*/
|
|
30
|
+
export interface ChallengeStore {
|
|
31
|
+
/**
|
|
32
|
+
* Store a challenge for later verification.
|
|
33
|
+
* @param challenge - The challenge string
|
|
34
|
+
* @param deviceId - The device this challenge is intended for
|
|
35
|
+
* @param expiresAt - Timestamp (ms since epoch) when this challenge expires
|
|
36
|
+
*/
|
|
37
|
+
store(challenge: string, deviceId: string, expiresAt: number): Promise<void>
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Consume a challenge (single-use). Returns the associated device ID if the
|
|
41
|
+
* challenge is valid and not expired, or null if it doesn't exist, has expired,
|
|
42
|
+
* or was already consumed.
|
|
43
|
+
*/
|
|
44
|
+
consume(challenge: string): Promise<{ deviceId: string } | null>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* In-memory challenge store with expiry and single-use semantics.
|
|
49
|
+
* Suitable for development and testing. Use Redis or a database in production.
|
|
50
|
+
*/
|
|
51
|
+
export class InMemoryChallengeStore implements ChallengeStore {
|
|
52
|
+
private readonly challenges = new Map<string, { deviceId: string; expiresAt: number }>()
|
|
53
|
+
|
|
54
|
+
async store(challenge: string, deviceId: string, expiresAt: number): Promise<void> {
|
|
55
|
+
this.challenges.set(challenge, { deviceId, expiresAt })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async consume(challenge: string): Promise<{ deviceId: string } | null> {
|
|
59
|
+
const entry = this.challenges.get(challenge)
|
|
60
|
+
if (entry === undefined) {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Always delete (single-use)
|
|
65
|
+
this.challenges.delete(challenge)
|
|
66
|
+
|
|
67
|
+
// Check expiry
|
|
68
|
+
if (Date.now() > entry.expiresAt) {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { deviceId: entry.deviceId }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Remove expired challenges to prevent unbounded memory growth.
|
|
77
|
+
*/
|
|
78
|
+
cleanup(): void {
|
|
79
|
+
const now = Date.now()
|
|
80
|
+
for (const [challenge, entry] of this.challenges) {
|
|
81
|
+
if (now > entry.expiresAt) {
|
|
82
|
+
this.challenges.delete(challenge)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// Rate Limiter
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Interface for rate limiting auth endpoints.
|
|
94
|
+
*
|
|
95
|
+
* Rate limiting is critical for preventing brute-force password guessing
|
|
96
|
+
* and credential stuffing attacks.
|
|
97
|
+
*/
|
|
98
|
+
export interface RateLimiter {
|
|
99
|
+
/**
|
|
100
|
+
* Check if an action is allowed for the given key.
|
|
101
|
+
* @param key - Rate limit key (e.g., IP address, email, or composite key)
|
|
102
|
+
* @returns true if the action is allowed, false if rate limited
|
|
103
|
+
*/
|
|
104
|
+
isAllowed(key: string): Promise<boolean>
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Record that an action was performed for the given key.
|
|
108
|
+
* Call this after each authentication attempt.
|
|
109
|
+
*/
|
|
110
|
+
record(key: string): Promise<void>
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Reset the rate limit for a key (e.g., after a successful login).
|
|
114
|
+
*/
|
|
115
|
+
reset(key: string): Promise<void>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* In-memory sliding window rate limiter.
|
|
120
|
+
* Suitable for development and single-server deployments.
|
|
121
|
+
* Use Redis-based rate limiting for multi-server production deployments.
|
|
122
|
+
*/
|
|
123
|
+
export class InMemoryRateLimiter implements RateLimiter {
|
|
124
|
+
private readonly attempts = new Map<string, number[]>()
|
|
125
|
+
private readonly maxAttempts: number
|
|
126
|
+
private readonly windowMs: number
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param maxAttempts - Maximum number of attempts within the time window (default: 10)
|
|
130
|
+
* @param windowMs - Time window in milliseconds (default: 60,000 = 1 minute)
|
|
131
|
+
*/
|
|
132
|
+
constructor(maxAttempts = 10, windowMs = 60_000) {
|
|
133
|
+
this.maxAttempts = maxAttempts
|
|
134
|
+
this.windowMs = windowMs
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async isAllowed(key: string): Promise<boolean> {
|
|
138
|
+
const now = Date.now()
|
|
139
|
+
const attempts = this.attempts.get(key) ?? []
|
|
140
|
+
const recentAttempts = attempts.filter((t) => now - t < this.windowMs)
|
|
141
|
+
return recentAttempts.length < this.maxAttempts
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async record(key: string): Promise<void> {
|
|
145
|
+
const now = Date.now()
|
|
146
|
+
const attempts = this.attempts.get(key) ?? []
|
|
147
|
+
// Keep only recent attempts to bound memory
|
|
148
|
+
const recentAttempts = attempts.filter((t) => now - t < this.windowMs)
|
|
149
|
+
recentAttempts.push(now)
|
|
150
|
+
this.attempts.set(key, recentAttempts)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async reset(key: string): Promise<void> {
|
|
154
|
+
this.attempts.delete(key)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ============================================================================
|
|
159
|
+
// Auth Routes Configuration
|
|
160
|
+
// ============================================================================
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Configuration for building the built-in auth routes.
|
|
164
|
+
*/
|
|
165
|
+
export interface AuthRoutesConfig {
|
|
166
|
+
/** The user/device store backing the auth routes */
|
|
167
|
+
userStore: UserStore
|
|
168
|
+
/** The token manager for issuing and validating JWTs */
|
|
169
|
+
tokenManager: TokenManager
|
|
170
|
+
/**
|
|
171
|
+
* Optional challenge store for device verification.
|
|
172
|
+
* Required for secure device proof-of-possession verification.
|
|
173
|
+
* If not provided, an in-memory store is created automatically.
|
|
174
|
+
*/
|
|
175
|
+
challengeStore?: ChallengeStore
|
|
176
|
+
/**
|
|
177
|
+
* Optional rate limiter for authentication endpoints.
|
|
178
|
+
* If not provided, an in-memory rate limiter is created with defaults
|
|
179
|
+
* (10 attempts per minute).
|
|
180
|
+
*/
|
|
181
|
+
rateLimiter?: RateLimiter
|
|
182
|
+
/**
|
|
183
|
+
* Called after credentials are revoked (sign-out, device revocation, user-wide
|
|
184
|
+
* revocation). `createKoraAuthServer().bindSyncServer()` uses it to end live
|
|
185
|
+
* sync sessions (AUTH-11).
|
|
186
|
+
*/
|
|
187
|
+
onRevoke?: (event: AuthRevocationEvent) => void | Promise<void>
|
|
188
|
+
/**
|
|
189
|
+
* Second-factor verifier (a `TotpManager` fits). When configured, users with
|
|
190
|
+
* MFA enabled get `{ mfaRequired, mfaToken }` from sign-in instead of tokens,
|
|
191
|
+
* and only `POST /auth/mfa/verify` issues their session (AUTH-10).
|
|
192
|
+
*/
|
|
193
|
+
mfa?: MfaVerifier
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Second-factor checks used at sign-in. `TotpManager` implements this. */
|
|
197
|
+
export interface MfaVerifier {
|
|
198
|
+
isEnabled(userId: string): Promise<boolean>
|
|
199
|
+
verify(userId: string, code: string): Promise<boolean>
|
|
200
|
+
verifyRecoveryCode?(userId: string, recoveryCode: string): Promise<boolean>
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Sign-in result for a user who still has to pass the second factor. */
|
|
204
|
+
export interface MfaChallenge {
|
|
205
|
+
mfaRequired: true
|
|
206
|
+
/** Short-lived token accepted only by `POST /auth/mfa/verify`. */
|
|
207
|
+
mfaToken: string
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Successful primary authentication: a session, or an MFA challenge. */
|
|
211
|
+
export type SignInResult = { user: AuthUser; tokens: AuthTokens } | MfaChallenge
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Describes credentials that were just revoked, so live sessions holding them
|
|
215
|
+
* (for example open sync connections) can be terminated.
|
|
216
|
+
*/
|
|
217
|
+
export type AuthRevocationEvent =
|
|
218
|
+
| { kind: 'device'; userId: string; deviceId: string }
|
|
219
|
+
| { kind: 'user'; userId: string }
|
|
220
|
+
| { kind: 'session'; userId: string; deviceId: string; family: string | null }
|
|
221
|
+
|
|
222
|
+
/** Result of {@link BuiltInAuthRoutes.authenticateAccess}. */
|
|
223
|
+
export interface AuthenticatedAccess {
|
|
224
|
+
/** Verified access-token payload. */
|
|
225
|
+
payload: TokenPayload
|
|
226
|
+
/** The token's user, as stored. */
|
|
227
|
+
user: StoredUser
|
|
228
|
+
/** The token's device record (null when the token was minted without one). */
|
|
229
|
+
device: AuthDevice | null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Response envelope returned by all auth route handlers.
|
|
234
|
+
*
|
|
235
|
+
* Successful responses include a `data` field; failures include an `error` string.
|
|
236
|
+
* The `status` field maps directly to an HTTP status code.
|
|
237
|
+
*/
|
|
238
|
+
export interface AuthRouteResponse<T> {
|
|
239
|
+
/** HTTP status code */
|
|
240
|
+
status: number
|
|
241
|
+
/**
|
|
242
|
+
* Either the success payload or an error message. `code` is a stable,
|
|
243
|
+
* machine-readable Kora error code; clients use it to tell a definitive
|
|
244
|
+
* rejection from a proxy or captive-portal response.
|
|
245
|
+
*/
|
|
246
|
+
body: { data: T } | { error: string; code?: string }
|
|
247
|
+
/** Optional response headers (for example `Retry-After`). */
|
|
248
|
+
headers?: Record<string, string>
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 401 returned for any unusable access token. */
|
|
252
|
+
function invalidAccessToken(): AuthRouteResponse<never> {
|
|
253
|
+
return {
|
|
254
|
+
status: 401,
|
|
255
|
+
body: { error: 'Invalid or expired access token.', code: 'ACCESS_TOKEN_INVALID' },
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Server-assigned default device id: random per sign-in, never derived from the user. */
|
|
260
|
+
function generateDeviceId(): string {
|
|
261
|
+
return `dev-${randomUUID()}`
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function deviceConflict(): AuthRouteResponse<never> {
|
|
265
|
+
return {
|
|
266
|
+
status: 409,
|
|
267
|
+
body: {
|
|
268
|
+
error:
|
|
269
|
+
'This device id is registered to another account. Use a device id generated for this install.',
|
|
270
|
+
code: 'DEVICE_OWNERSHIP_CONFLICT',
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Minimum password length enforced at sign-up. */
|
|
276
|
+
const MIN_PASSWORD_LENGTH = 8
|
|
277
|
+
|
|
278
|
+
/** Maximum password length to prevent hash-DoS attacks via extremely long passwords. */
|
|
279
|
+
const MAX_PASSWORD_LENGTH = 128
|
|
280
|
+
|
|
281
|
+
/** Maximum length for user/device name fields. */
|
|
282
|
+
const MAX_NAME_LENGTH = 200
|
|
283
|
+
|
|
284
|
+
/** Challenge validity window in milliseconds (60 seconds). */
|
|
285
|
+
const CHALLENGE_TTL_MS = 60_000
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Simple email format validation.
|
|
289
|
+
* Checks for the presence of exactly one @ with non-empty local and domain parts,
|
|
290
|
+
* and at least one dot in the domain. This is intentionally lenient — real email
|
|
291
|
+
* validation happens by sending a confirmation email, not by regex.
|
|
292
|
+
*/
|
|
293
|
+
function isValidEmail(email: string): boolean {
|
|
294
|
+
// Bodies come from JSON.parse'd network input, not a type-checked call
|
|
295
|
+
// site: a missing or malformed `email` field means this runs with
|
|
296
|
+
// `undefined` at runtime despite the `string` type, and `.length` would
|
|
297
|
+
// throw, crashing the request (and, since httpRoutes handlers aren't
|
|
298
|
+
// wrapped in a try/catch further up, potentially the whole process).
|
|
299
|
+
if (typeof email !== 'string' || email.length === 0 || email.length > 254) {
|
|
300
|
+
return false
|
|
301
|
+
}
|
|
302
|
+
const atIndex = email.indexOf('@')
|
|
303
|
+
if (atIndex < 1) {
|
|
304
|
+
return false
|
|
305
|
+
}
|
|
306
|
+
const domain = email.slice(atIndex + 1)
|
|
307
|
+
if (domain.length === 0 || !domain.includes('.')) {
|
|
308
|
+
return false
|
|
309
|
+
}
|
|
310
|
+
// No double-@ or spaces
|
|
311
|
+
if (email.indexOf('@', atIndex + 1) !== -1) {
|
|
312
|
+
return false
|
|
313
|
+
}
|
|
314
|
+
if (email.includes(' ')) {
|
|
315
|
+
return false
|
|
316
|
+
}
|
|
317
|
+
return true
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Sanitize and limit a name string.
|
|
322
|
+
* Trims whitespace, enforces max length, and strips control characters.
|
|
323
|
+
*/
|
|
324
|
+
function sanitizeName(name: string): string {
|
|
325
|
+
// Same reasoning as isValidEmail: `name` can come straight from a request
|
|
326
|
+
// body field (handleDeviceRegister's `body.name`), which is untyped at
|
|
327
|
+
// runtime, so guard before calling .replace on something that isn't
|
|
328
|
+
// actually a string.
|
|
329
|
+
if (typeof name !== 'string') {
|
|
330
|
+
return ''
|
|
331
|
+
}
|
|
332
|
+
// Strip ASCII control characters (0x00-0x1F, 0x7F)
|
|
333
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally stripping ASCII control characters for sanitization
|
|
334
|
+
const cleaned = name.replace(/[\x00-\x1f\x7f]/g, '')
|
|
335
|
+
const trimmed = cleaned.trim()
|
|
336
|
+
if (trimmed.length > MAX_NAME_LENGTH) {
|
|
337
|
+
return trimmed.slice(0, MAX_NAME_LENGTH)
|
|
338
|
+
}
|
|
339
|
+
return trimmed
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* HTTP route handlers for the built-in Kora auth provider.
|
|
344
|
+
*
|
|
345
|
+
* These are framework-agnostic functions that accept parsed request bodies and return
|
|
346
|
+
* structured responses. The server package is responsible for wiring them into its
|
|
347
|
+
* HTTP server (e.g., mapping `POST /auth/signup` to `handleSignUp`).
|
|
348
|
+
*
|
|
349
|
+
* All handlers follow the same pattern:
|
|
350
|
+
* - Validate input
|
|
351
|
+
* - Perform the operation
|
|
352
|
+
* - Return `{ status, body: { data } }` on success
|
|
353
|
+
* - Return `{ status, body: { error } }` on failure
|
|
354
|
+
*
|
|
355
|
+
* Security features:
|
|
356
|
+
* - Rate limiting on sign-in/sign-up to prevent brute-force attacks
|
|
357
|
+
* - Server-side challenge store for device verification (single-use, time-limited)
|
|
358
|
+
* - Token revocation on sign-out and device revocation
|
|
359
|
+
* - Input sanitization on all name fields
|
|
360
|
+
* - Maximum password length to prevent hash-DoS
|
|
361
|
+
*
|
|
362
|
+
* @example
|
|
363
|
+
* ```typescript
|
|
364
|
+
* const routes = new BuiltInAuthRoutes({
|
|
365
|
+
* userStore: new InMemoryUserStore(),
|
|
366
|
+
* tokenManager: new TokenManager({ secret: TokenManager.generateSecret() }),
|
|
367
|
+
* })
|
|
368
|
+
*
|
|
369
|
+
* // Wire into an HTTP server:
|
|
370
|
+
* app.post('/auth/signup', async (req, res) => {
|
|
371
|
+
* const result = await routes.handleSignUp(req.body)
|
|
372
|
+
* res.status(result.status).json(result.body)
|
|
373
|
+
* })
|
|
374
|
+
* ```
|
|
375
|
+
*/
|
|
376
|
+
export class BuiltInAuthRoutes {
|
|
377
|
+
private readonly userStore: UserStore
|
|
378
|
+
private readonly tokenManager: TokenManager
|
|
379
|
+
private readonly challengeStore: ChallengeStore
|
|
380
|
+
private readonly rateLimiter: RateLimiter
|
|
381
|
+
private readonly revokeListeners = new Set<(event: AuthRevocationEvent) => void | Promise<void>>()
|
|
382
|
+
/** Lazily computed hash used to equalize sign-in timing for unknown emails. */
|
|
383
|
+
private dummyCredential: Promise<{ hash: string; salt: string }> | null = null
|
|
384
|
+
private readonly mfa: MfaVerifier | undefined
|
|
385
|
+
|
|
386
|
+
constructor(config: AuthRoutesConfig) {
|
|
387
|
+
this.userStore = config.userStore
|
|
388
|
+
this.tokenManager = config.tokenManager
|
|
389
|
+
this.challengeStore = config.challengeStore ?? new InMemoryChallengeStore()
|
|
390
|
+
this.rateLimiter = config.rateLimiter ?? new InMemoryRateLimiter()
|
|
391
|
+
if (config.onRevoke) this.revokeListeners.add(config.onRevoke)
|
|
392
|
+
this.mfa = config.mfa
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Finish a successful primary authentication (password, OAuth): issue a
|
|
397
|
+
* session, or an MFA challenge when the user is enrolled in MFA. No
|
|
398
|
+
* full-privilege token is ever issued to an MFA user without a fresh second
|
|
399
|
+
* factor.
|
|
400
|
+
*
|
|
401
|
+
* @param user - The authenticated user
|
|
402
|
+
* @param deviceId - The (already registered) device
|
|
403
|
+
* @param amr - Methods satisfied so far (for example `['pwd']`)
|
|
404
|
+
*/
|
|
405
|
+
async completePrimaryAuthentication(
|
|
406
|
+
user: AuthUser,
|
|
407
|
+
deviceId: string,
|
|
408
|
+
amr: string[],
|
|
409
|
+
): Promise<AuthRouteResponse<SignInResult>> {
|
|
410
|
+
if (this.mfa && (await this.mfa.isEnabled(user.id))) {
|
|
411
|
+
return {
|
|
412
|
+
status: 200,
|
|
413
|
+
body: {
|
|
414
|
+
data: {
|
|
415
|
+
mfaRequired: true,
|
|
416
|
+
mfaToken: this.tokenManager.issueMfaPendingToken(user.id, deviceId, amr),
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const tokens = this.tokenManager.issueTokens(user.id, deviceId, undefined, { amr })
|
|
422
|
+
return { status: 200, body: { data: { user, tokens } } }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Handle the second factor (POST /auth/mfa/verify).
|
|
427
|
+
*
|
|
428
|
+
* Exchanges an `mfa_pending` token plus a TOTP code (or recovery code) for a
|
|
429
|
+
* session whose tokens carry `amr` including `otp` (or `rcv`). The pending
|
|
430
|
+
* token is redeemed once; a wrong code can be retried until it expires.
|
|
431
|
+
*/
|
|
432
|
+
async handleMfaVerify(body: {
|
|
433
|
+
mfaToken?: unknown
|
|
434
|
+
code?: unknown
|
|
435
|
+
recoveryCode?: unknown
|
|
436
|
+
}): Promise<AuthRouteResponse<{ user: AuthUser; tokens: AuthTokens }>> {
|
|
437
|
+
const invalidToken: AuthRouteResponse<never> = {
|
|
438
|
+
status: 401,
|
|
439
|
+
body: { error: 'Invalid or expired MFA session. Sign in again.', code: 'MFA_TOKEN_INVALID' },
|
|
440
|
+
}
|
|
441
|
+
if (!this.mfa || typeof body?.mfaToken !== 'string') return invalidToken
|
|
442
|
+
const pending = await this.tokenManager.verifyMfaPendingToken(body.mfaToken)
|
|
443
|
+
if (!pending) return invalidToken
|
|
444
|
+
|
|
445
|
+
const storedUser = await this.userStore.findById(pending.sub)
|
|
446
|
+
const device = await this.userStore.findDevice(pending.dev)
|
|
447
|
+
if (!storedUser || (device && (device.revoked || device.userId !== pending.sub))) {
|
|
448
|
+
return invalidToken
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let method: 'otp' | 'rcv' | null = null
|
|
452
|
+
if (typeof body.code === 'string' && (await this.mfa.verify(pending.sub, body.code))) {
|
|
453
|
+
method = 'otp'
|
|
454
|
+
} else if (
|
|
455
|
+
typeof body.recoveryCode === 'string' &&
|
|
456
|
+
this.mfa.verifyRecoveryCode &&
|
|
457
|
+
(await this.mfa.verifyRecoveryCode(pending.sub, body.recoveryCode))
|
|
458
|
+
) {
|
|
459
|
+
method = 'rcv'
|
|
460
|
+
}
|
|
461
|
+
if (method === null) {
|
|
462
|
+
return { status: 401, body: { error: 'Invalid MFA code.', code: 'MFA_CODE_INVALID' } }
|
|
463
|
+
}
|
|
464
|
+
if (!(await this.tokenManager.redeemMfaPendingToken(pending))) return invalidToken
|
|
465
|
+
|
|
466
|
+
const tokens = this.tokenManager.issueTokens(pending.sub, pending.dev, undefined, {
|
|
467
|
+
amr: [...new Set([...pending.amr, method])],
|
|
468
|
+
})
|
|
469
|
+
const user: AuthUser = {
|
|
470
|
+
id: storedUser.id,
|
|
471
|
+
email: storedUser.email,
|
|
472
|
+
name: storedUser.name,
|
|
473
|
+
emailVerified: storedUser.emailVerified,
|
|
474
|
+
createdAt: storedUser.createdAt,
|
|
475
|
+
}
|
|
476
|
+
return { status: 200, body: { data: { user, tokens } } }
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Subscribe to credential revocations (sign-out, device revocation,
|
|
481
|
+
* user-wide revocation).
|
|
482
|
+
*
|
|
483
|
+
* @param listener - Called after each revocation is persisted
|
|
484
|
+
* @returns An unsubscribe function
|
|
485
|
+
*/
|
|
486
|
+
onRevoke(listener: (event: AuthRevocationEvent) => void | Promise<void>): () => void {
|
|
487
|
+
this.revokeListeners.add(listener)
|
|
488
|
+
return () => {
|
|
489
|
+
this.revokeListeners.delete(listener)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Authenticate an access token for any request-authorization path.
|
|
495
|
+
*
|
|
496
|
+
* The single check used by every HTTP route and by the sync provider: it
|
|
497
|
+
* verifies signature and expiry, the token's own revocation, its family, the
|
|
498
|
+
* device cut-off, the per-user cut-off, that the user still exists, and that
|
|
499
|
+
* the device record is neither revoked nor owned by someone else.
|
|
500
|
+
*
|
|
501
|
+
* @param token - Raw access token (without "Bearer ")
|
|
502
|
+
* @returns The verified access, or null when the token must be rejected
|
|
503
|
+
*/
|
|
504
|
+
async authenticateAccess(token: string): Promise<AuthenticatedAccess | null> {
|
|
505
|
+
if (typeof token !== 'string' || token.length === 0) return null
|
|
506
|
+
const payload = await this.tokenManager.validateTokenWithRevocation(token)
|
|
507
|
+
if (payload === null || payload.type !== 'access') return null
|
|
508
|
+
const user = await this.userStore.findById(payload.sub)
|
|
509
|
+
if (user === null) return null
|
|
510
|
+
const device = await this.userStore.findDevice(payload.dev)
|
|
511
|
+
if (device && (device.revoked || device.userId !== payload.sub)) return null
|
|
512
|
+
return { payload, user, device }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Revoke every credential a user holds (password reset or change, admin
|
|
517
|
+
* session revocation, account deletion) and notify revocation listeners.
|
|
518
|
+
*
|
|
519
|
+
* @param userId - The user whose sessions end now
|
|
520
|
+
*/
|
|
521
|
+
async revokeAllForUser(userId: string): Promise<void> {
|
|
522
|
+
await this.tokenManager.revokeAllForUser(userId)
|
|
523
|
+
await this.emitRevoke({ kind: 'user', userId })
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private async emitRevoke(event: AuthRevocationEvent): Promise<void> {
|
|
527
|
+
for (const listener of this.revokeListeners) {
|
|
528
|
+
try {
|
|
529
|
+
await listener(event)
|
|
530
|
+
} catch (error) {
|
|
531
|
+
// A failing listener (for example a sync server that is shutting down)
|
|
532
|
+
// must not undo or block the revocation itself, but it must be visible.
|
|
533
|
+
console.error('[kora] auth revocation listener failed', error)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Register the device for a successful primary authentication, refusing ids
|
|
540
|
+
* owned by another user.
|
|
541
|
+
*/
|
|
542
|
+
private async registerSignInDevice(params: {
|
|
543
|
+
userId: string
|
|
544
|
+
deviceId: string | undefined
|
|
545
|
+
publicKey: string | undefined
|
|
546
|
+
named: string
|
|
547
|
+
}): Promise<string | AuthRouteResponse<never>> {
|
|
548
|
+
const deviceId =
|
|
549
|
+
typeof params.deviceId === 'string' && params.deviceId.length > 0
|
|
550
|
+
? params.deviceId
|
|
551
|
+
: generateDeviceId()
|
|
552
|
+
try {
|
|
553
|
+
await this.userStore.registerDevice({
|
|
554
|
+
id: deviceId,
|
|
555
|
+
userId: params.userId,
|
|
556
|
+
publicKey: params.publicKey ?? '',
|
|
557
|
+
name: params.named,
|
|
558
|
+
})
|
|
559
|
+
} catch (error) {
|
|
560
|
+
if (error instanceof DeviceOwnershipError) return deviceConflict()
|
|
561
|
+
throw error
|
|
562
|
+
}
|
|
563
|
+
return deviceId
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Handle user sign-up (POST /auth/signup).
|
|
568
|
+
*
|
|
569
|
+
* Validates email format and password length, hashes the password,
|
|
570
|
+
* creates the user, optionally registers a device, and issues tokens.
|
|
571
|
+
*
|
|
572
|
+
* @param body - Sign-up request body
|
|
573
|
+
* @param body.email - The user's email address
|
|
574
|
+
* @param body.password - The plaintext password (8-128 characters)
|
|
575
|
+
* @param body.name - Optional display name (defaults to email local part)
|
|
576
|
+
* @param body.deviceId - Optional device ID to register
|
|
577
|
+
* @param body.devicePublicKey - Optional device public key (base64url)
|
|
578
|
+
* @param clientIp - Optional client IP for rate limiting
|
|
579
|
+
* @returns Auth response with the created user and tokens, or an error
|
|
580
|
+
*/
|
|
581
|
+
async handleSignUp(
|
|
582
|
+
body: {
|
|
583
|
+
email: string
|
|
584
|
+
password: string
|
|
585
|
+
name?: string
|
|
586
|
+
deviceId?: string
|
|
587
|
+
devicePublicKey?: string
|
|
588
|
+
},
|
|
589
|
+
clientIp?: string,
|
|
590
|
+
): Promise<AuthRouteResponse<{ user: AuthUser; tokens: AuthTokens }>> {
|
|
591
|
+
// Rate limiting
|
|
592
|
+
const rateLimitKey = clientIp ?? 'global'
|
|
593
|
+
if (!(await this.rateLimiter.isAllowed(rateLimitKey))) {
|
|
594
|
+
return {
|
|
595
|
+
status: 429,
|
|
596
|
+
body: { error: 'Too many requests. Please try again later.' },
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
await this.rateLimiter.record(rateLimitKey)
|
|
600
|
+
|
|
601
|
+
// Validate email format
|
|
602
|
+
if (!isValidEmail(body.email)) {
|
|
603
|
+
return {
|
|
604
|
+
status: 400,
|
|
605
|
+
body: {
|
|
606
|
+
error:
|
|
607
|
+
'Invalid email address. Please provide a valid email in the format user@domain.com.',
|
|
608
|
+
},
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Validate password length (min and max). Same runtime-untyped-body
|
|
613
|
+
// reasoning as the email check above: a missing password must not crash
|
|
614
|
+
// this length check.
|
|
615
|
+
if (typeof body.password !== 'string' || body.password.length < MIN_PASSWORD_LENGTH) {
|
|
616
|
+
return {
|
|
617
|
+
status: 400,
|
|
618
|
+
body: {
|
|
619
|
+
error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long.`,
|
|
620
|
+
},
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (body.password.length > MAX_PASSWORD_LENGTH) {
|
|
625
|
+
return {
|
|
626
|
+
status: 400,
|
|
627
|
+
body: {
|
|
628
|
+
error: `Password must be at most ${MAX_PASSWORD_LENGTH} characters long.`,
|
|
629
|
+
},
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Hash the password
|
|
634
|
+
const { hash, salt } = await hashPassword(body.password)
|
|
635
|
+
|
|
636
|
+
// Sanitize the display name
|
|
637
|
+
const rawName = body.name ?? body.email.split('@')[0] ?? body.email
|
|
638
|
+
const name = sanitizeName(rawName)
|
|
639
|
+
|
|
640
|
+
// Create the user — may throw DuplicateEmailError
|
|
641
|
+
let user: AuthUser
|
|
642
|
+
try {
|
|
643
|
+
user = await this.userStore.createUser({
|
|
644
|
+
email: body.email,
|
|
645
|
+
passwordHash: hash,
|
|
646
|
+
salt,
|
|
647
|
+
name,
|
|
648
|
+
})
|
|
649
|
+
} catch (err: unknown) {
|
|
650
|
+
if (err instanceof Error && err.name === 'DuplicateEmailError') {
|
|
651
|
+
return {
|
|
652
|
+
status: 409,
|
|
653
|
+
body: { error: 'An account with this email already exists.' },
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
throw err
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// A client-chosen id must be owned by this user; without one the server
|
|
660
|
+
// assigns a random id (never `device-${userId}`, which every browser of the
|
|
661
|
+
// user would share).
|
|
662
|
+
const deviceId = await this.registerSignInDevice({
|
|
663
|
+
userId: user.id,
|
|
664
|
+
deviceId: body.deviceId,
|
|
665
|
+
publicKey: body.devicePublicKey,
|
|
666
|
+
named: body.deviceId ? 'Primary Device' : 'Browser',
|
|
667
|
+
})
|
|
668
|
+
if (typeof deviceId !== 'string') return deviceId
|
|
669
|
+
|
|
670
|
+
// Issue tokens
|
|
671
|
+
const tokens = this.tokenManager.issueTokens(user.id, deviceId, undefined, { amr: ['pwd'] })
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
status: 201,
|
|
675
|
+
body: { data: { user, tokens } },
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Handle user sign-in (POST /auth/signin).
|
|
681
|
+
*
|
|
682
|
+
* Looks up the user by email, verifies the password, optionally registers
|
|
683
|
+
* a new device, and issues tokens.
|
|
684
|
+
*
|
|
685
|
+
* @param body - Sign-in request body
|
|
686
|
+
* @param body.email - The user's email address
|
|
687
|
+
* @param body.password - The plaintext password
|
|
688
|
+
* @param body.deviceId - Optional device ID to register
|
|
689
|
+
* @param body.devicePublicKey - Optional device public key (base64url)
|
|
690
|
+
* @param clientIp - Optional client IP for rate limiting
|
|
691
|
+
* @returns Auth response with the user and tokens, or an error
|
|
692
|
+
*/
|
|
693
|
+
async handleSignIn(
|
|
694
|
+
body: {
|
|
695
|
+
email: string
|
|
696
|
+
password: string
|
|
697
|
+
deviceId?: string
|
|
698
|
+
devicePublicKey?: string
|
|
699
|
+
},
|
|
700
|
+
clientIp?: string,
|
|
701
|
+
): Promise<AuthRouteResponse<SignInResult>> {
|
|
702
|
+
// Request bodies are untyped at runtime (JSON.parse'd network input, not
|
|
703
|
+
// a checked call site), so a missing/malformed `email` or `password`
|
|
704
|
+
// field reaches here as `undefined` despite the `string` type. Reject it
|
|
705
|
+
// before it's used, rather than crashing on `.toLowerCase()` below.
|
|
706
|
+
if (typeof body.email !== 'string' || typeof body.password !== 'string') {
|
|
707
|
+
return {
|
|
708
|
+
status: 400,
|
|
709
|
+
body: { error: 'Email and password are required.' },
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Two independent limits (AUTH-9): one per account, so rotating source IPs
|
|
714
|
+
// cannot buy unlimited guesses against one user, and one per client IP, so
|
|
715
|
+
// one client cannot spray many accounts. The IP must come from a trusted
|
|
716
|
+
// source (the socket, or a configured proxy hop), never a raw header.
|
|
717
|
+
const accountKey = `signin:account:${body.email.trim().toLowerCase()}`
|
|
718
|
+
const ipKey = clientIp ? `signin:ip:${clientIp}` : null
|
|
719
|
+
if (
|
|
720
|
+
!(await this.rateLimiter.isAllowed(accountKey)) ||
|
|
721
|
+
(ipKey !== null && !(await this.rateLimiter.isAllowed(ipKey)))
|
|
722
|
+
) {
|
|
723
|
+
return {
|
|
724
|
+
status: 429,
|
|
725
|
+
body: {
|
|
726
|
+
error: 'Too many sign-in attempts. Please try again later.',
|
|
727
|
+
code: 'RATE_LIMITED',
|
|
728
|
+
},
|
|
729
|
+
headers: { 'Retry-After': '60' },
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
await this.rateLimiter.record(accountKey)
|
|
733
|
+
if (ipKey !== null) await this.rateLimiter.record(ipKey)
|
|
734
|
+
|
|
735
|
+
const storedUser = await this.userStore.findByEmail(body.email)
|
|
736
|
+
// Always run the password KDF, against a fixed dummy credential when the
|
|
737
|
+
// account does not exist, so response time does not reveal registration.
|
|
738
|
+
const credential = storedUser
|
|
739
|
+
? { hash: storedUser.passwordHash, salt: storedUser.salt }
|
|
740
|
+
: await this.getDummyCredential()
|
|
741
|
+
const passwordValid = await verifyPassword(body.password, credential.hash, credential.salt)
|
|
742
|
+
if (storedUser === null || !passwordValid) {
|
|
743
|
+
return {
|
|
744
|
+
status: 401,
|
|
745
|
+
body: { error: 'Invalid email or password.', code: 'INVALID_CREDENTIALS' },
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Successful login: clear this account's failure budget only.
|
|
750
|
+
await this.rateLimiter.reset(accountKey)
|
|
751
|
+
|
|
752
|
+
const deviceId = await this.registerSignInDevice({
|
|
753
|
+
userId: storedUser.id,
|
|
754
|
+
deviceId: body.deviceId,
|
|
755
|
+
publicKey: body.devicePublicKey,
|
|
756
|
+
named: body.deviceId ? 'Device' : 'Browser',
|
|
757
|
+
})
|
|
758
|
+
if (typeof deviceId !== 'string') return deviceId
|
|
759
|
+
|
|
760
|
+
const user: AuthUser = {
|
|
761
|
+
id: storedUser.id,
|
|
762
|
+
email: storedUser.email,
|
|
763
|
+
name: storedUser.name,
|
|
764
|
+
emailVerified: storedUser.emailVerified,
|
|
765
|
+
createdAt: storedUser.createdAt,
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return this.completePrimaryAuthentication(user, deviceId, ['pwd'])
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Handle token refresh (POST /auth/refresh).
|
|
773
|
+
*
|
|
774
|
+
* Validates the provided refresh token and issues a new token pair
|
|
775
|
+
* (refresh token rotation with reuse detection). The old refresh token
|
|
776
|
+
* is marked as consumed in the revocation store.
|
|
777
|
+
*
|
|
778
|
+
* @param body - Refresh request body
|
|
779
|
+
* @param body.refreshToken - The current refresh token
|
|
780
|
+
* @returns Auth response with new tokens, or an error
|
|
781
|
+
*/
|
|
782
|
+
async handleRefresh(body: {
|
|
783
|
+
refreshToken: string
|
|
784
|
+
}): Promise<AuthRouteResponse<AuthTokens>> {
|
|
785
|
+
const rejected: AuthRouteResponse<never> = {
|
|
786
|
+
status: 401,
|
|
787
|
+
body: { error: 'Invalid or expired refresh token.', code: 'REFRESH_TOKEN_INVALID' },
|
|
788
|
+
}
|
|
789
|
+
const refreshToken = body?.refreshToken
|
|
790
|
+
const presented =
|
|
791
|
+
typeof refreshToken === 'string' ? this.tokenManager.validateToken(refreshToken) : null
|
|
792
|
+
if (presented === null || presented.type !== 'refresh') {
|
|
793
|
+
return rejected
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// The device and the user must still be in good standing (AUTH-2): a revoked
|
|
797
|
+
// device keeps no refresh rights, whatever the revocation store says.
|
|
798
|
+
const user = await this.userStore.findById(presented.sub)
|
|
799
|
+
const device = await this.userStore.findDevice(presented.dev)
|
|
800
|
+
if (user === null || (device && (device.revoked || device.userId !== presented.sub))) {
|
|
801
|
+
return rejected
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const result = await this.tokenManager.rotateRefreshToken(refreshToken as string)
|
|
805
|
+
if (!result.ok) {
|
|
806
|
+
if (result.reason === 'in_progress') {
|
|
807
|
+
// A duplicate of a rotation still running on this instance: transient.
|
|
808
|
+
return {
|
|
809
|
+
status: 409,
|
|
810
|
+
body: { error: 'A refresh with this token is in progress.', code: 'REFRESH_IN_PROGRESS' },
|
|
811
|
+
headers: { 'Retry-After': '1' },
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return rejected
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return {
|
|
818
|
+
status: 200,
|
|
819
|
+
body: { data: result.tokens },
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Handle sign-out (POST /auth/signout).
|
|
825
|
+
*
|
|
826
|
+
* Validates the access token and revokes the current refresh token
|
|
827
|
+
* (if a revocation store is configured). This ensures that stolen
|
|
828
|
+
* refresh tokens cannot be used after the user signs out.
|
|
829
|
+
*
|
|
830
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
831
|
+
* @param body - Sign-out request body
|
|
832
|
+
* @param body.refreshToken - The current refresh token to revoke
|
|
833
|
+
* @returns Auth response with success flag, or an error
|
|
834
|
+
*/
|
|
835
|
+
async handleSignOut(
|
|
836
|
+
accessToken: string,
|
|
837
|
+
body: { refreshToken?: string },
|
|
838
|
+
): Promise<AuthRouteResponse<{ success: boolean }>> {
|
|
839
|
+
const access = await this.authenticateAccess(accessToken)
|
|
840
|
+
if (access === null) {
|
|
841
|
+
return invalidAccessToken()
|
|
842
|
+
}
|
|
843
|
+
const { payload } = access
|
|
844
|
+
|
|
845
|
+
// Revoke the access token itself
|
|
846
|
+
await this.tokenManager.revokeToken(payload.jti, payload.exp)
|
|
847
|
+
|
|
848
|
+
// Revoke the refresh token if provided, and its whole family, so a successor
|
|
849
|
+
// minted by a just-completed rotation (or its grace replay) dies with it.
|
|
850
|
+
let refreshExp = payload.exp
|
|
851
|
+
if (body?.refreshToken) {
|
|
852
|
+
const refreshPayload = this.tokenManager.validateToken(body.refreshToken)
|
|
853
|
+
if (
|
|
854
|
+
refreshPayload !== null &&
|
|
855
|
+
refreshPayload.type === 'refresh' &&
|
|
856
|
+
refreshPayload.sub === payload.sub
|
|
857
|
+
) {
|
|
858
|
+
await this.tokenManager.revokeToken(refreshPayload.jti, refreshPayload.exp)
|
|
859
|
+
refreshExp = Math.max(refreshExp, refreshPayload.exp)
|
|
860
|
+
if (refreshPayload.fam) {
|
|
861
|
+
await this.tokenManager.revokeFamily(refreshPayload.fam, refreshPayload.exp)
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (payload.fam) {
|
|
866
|
+
await this.tokenManager.revokeFamily(payload.fam, refreshExp)
|
|
867
|
+
}
|
|
868
|
+
await this.emitRevoke({
|
|
869
|
+
kind: 'session',
|
|
870
|
+
userId: payload.sub,
|
|
871
|
+
deviceId: payload.dev,
|
|
872
|
+
family: payload.fam ?? null,
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
return {
|
|
876
|
+
status: 200,
|
|
877
|
+
body: { data: { success: true } },
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Handle get-current-user (GET /auth/me).
|
|
883
|
+
*
|
|
884
|
+
* Validates the access token and returns the authenticated user's profile.
|
|
885
|
+
*
|
|
886
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
887
|
+
* @returns Auth response with the user profile, or an error
|
|
888
|
+
*/
|
|
889
|
+
async handleGetMe(accessToken: string): Promise<AuthRouteResponse<AuthUser>> {
|
|
890
|
+
const access = await this.authenticateAccess(accessToken)
|
|
891
|
+
if (access === null) {
|
|
892
|
+
return invalidAccessToken()
|
|
893
|
+
}
|
|
894
|
+
const storedUser = access.user
|
|
895
|
+
|
|
896
|
+
const user: AuthUser = {
|
|
897
|
+
id: storedUser.id,
|
|
898
|
+
email: storedUser.email,
|
|
899
|
+
name: storedUser.name,
|
|
900
|
+
emailVerified: storedUser.emailVerified,
|
|
901
|
+
createdAt: storedUser.createdAt,
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return {
|
|
905
|
+
status: 200,
|
|
906
|
+
body: { data: user },
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Handle list-devices (GET /auth/devices).
|
|
912
|
+
*
|
|
913
|
+
* Validates the access token and returns all devices registered for the user.
|
|
914
|
+
*
|
|
915
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
916
|
+
* @returns Auth response with the device list, or an error
|
|
917
|
+
*/
|
|
918
|
+
async handleListDevices(accessToken: string): Promise<AuthRouteResponse<AuthDevice[]>> {
|
|
919
|
+
const access = await this.authenticateAccess(accessToken)
|
|
920
|
+
if (access === null) {
|
|
921
|
+
return invalidAccessToken()
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const devices = await this.userStore.listDevices(access.payload.sub)
|
|
925
|
+
|
|
926
|
+
return {
|
|
927
|
+
status: 200,
|
|
928
|
+
body: { data: devices },
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Handle device revocation (DELETE /auth/device/:id).
|
|
934
|
+
*
|
|
935
|
+
* Validates the access token, revokes the specified device, and invalidates
|
|
936
|
+
* all tokens issued to that device. Only the device's owner can revoke it.
|
|
937
|
+
*
|
|
938
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
939
|
+
* @param deviceId - The ID of the device to revoke
|
|
940
|
+
* @returns Auth response with success flag, or an error
|
|
941
|
+
*/
|
|
942
|
+
async handleRevokeDevice(
|
|
943
|
+
accessToken: string,
|
|
944
|
+
deviceId: string,
|
|
945
|
+
): Promise<AuthRouteResponse<{ success: boolean }>> {
|
|
946
|
+
const access = await this.authenticateAccess(accessToken)
|
|
947
|
+
if (access === null) {
|
|
948
|
+
return invalidAccessToken()
|
|
949
|
+
}
|
|
950
|
+
const { payload } = access
|
|
951
|
+
|
|
952
|
+
// Verify the device belongs to the authenticated user
|
|
953
|
+
const device = await this.userStore.findDevice(deviceId)
|
|
954
|
+
if (device === null) {
|
|
955
|
+
return {
|
|
956
|
+
status: 404,
|
|
957
|
+
body: { error: 'Device not found.' },
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (device.userId !== payload.sub) {
|
|
962
|
+
return {
|
|
963
|
+
status: 403,
|
|
964
|
+
body: { error: 'You can only revoke your own devices.' },
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
await this.userStore.revokeDevice(deviceId)
|
|
969
|
+
|
|
970
|
+
// Invalidate every token issued to this device so far. A later sign-in on
|
|
971
|
+
// the same device issues fresh tokens that are accepted (NEW-AUTH-1).
|
|
972
|
+
await this.tokenManager.revokeDeviceTokens(deviceId)
|
|
973
|
+
await this.emitRevoke({ kind: 'device', userId: payload.sub, deviceId })
|
|
974
|
+
|
|
975
|
+
return {
|
|
976
|
+
status: 200,
|
|
977
|
+
body: { data: { success: true } },
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Handle device registration (POST /auth/device/register).
|
|
983
|
+
*
|
|
984
|
+
* Requires a valid access token. Registers a new device for the authenticated
|
|
985
|
+
* user and issues a device credential token bound to the device's public key.
|
|
986
|
+
*
|
|
987
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
988
|
+
* @param body - Device registration request body
|
|
989
|
+
* @param body.deviceId - Unique identifier for the device
|
|
990
|
+
* @param body.publicKey - The device's public key as a JWK JSON string
|
|
991
|
+
* @param body.name - Human-readable device name (e.g., "Chrome on MacBook")
|
|
992
|
+
* @returns Auth response with the registered device and device credential, or an error
|
|
993
|
+
*/
|
|
994
|
+
async handleDeviceRegister(
|
|
995
|
+
accessToken: string,
|
|
996
|
+
body: {
|
|
997
|
+
deviceId: string
|
|
998
|
+
publicKey: string
|
|
999
|
+
name: string
|
|
1000
|
+
},
|
|
1001
|
+
): Promise<AuthRouteResponse<{ device: AuthDevice; deviceCredential: string }>> {
|
|
1002
|
+
const access = await this.authenticateAccess(accessToken)
|
|
1003
|
+
if (access === null) {
|
|
1004
|
+
return invalidAccessToken()
|
|
1005
|
+
}
|
|
1006
|
+
const { payload } = access
|
|
1007
|
+
|
|
1008
|
+
// Sanitize the device name
|
|
1009
|
+
const deviceName = sanitizeName(body.name)
|
|
1010
|
+
if (deviceName.length === 0) {
|
|
1011
|
+
return {
|
|
1012
|
+
status: 400,
|
|
1013
|
+
body: { error: 'Device name must not be empty.' },
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Parse the JWK JSON string into a JsonWebKey object
|
|
1018
|
+
let publicKeyJwk: JsonWebKey
|
|
1019
|
+
try {
|
|
1020
|
+
publicKeyJwk = JSON.parse(body.publicKey) as JsonWebKey
|
|
1021
|
+
} catch {
|
|
1022
|
+
return {
|
|
1023
|
+
status: 400,
|
|
1024
|
+
body: { error: 'Invalid public key format. Expected a JSON-encoded JWK string.' },
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Compute the SHA-256 thumbprint of the public key for binding to the credential
|
|
1029
|
+
let thumbprint: string
|
|
1030
|
+
try {
|
|
1031
|
+
thumbprint = await computePublicKeyThumbprint(publicKeyJwk)
|
|
1032
|
+
} catch {
|
|
1033
|
+
return {
|
|
1034
|
+
status: 400,
|
|
1035
|
+
body: {
|
|
1036
|
+
error: 'Failed to compute public key thumbprint. Ensure the key is a valid EC P-256 JWK.',
|
|
1037
|
+
},
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Register the device in the user store (refused if another user owns the id)
|
|
1042
|
+
let device: AuthDevice
|
|
1043
|
+
try {
|
|
1044
|
+
device = await this.userStore.registerDevice({
|
|
1045
|
+
id: body.deviceId,
|
|
1046
|
+
userId: payload.sub,
|
|
1047
|
+
publicKey: body.publicKey,
|
|
1048
|
+
name: deviceName,
|
|
1049
|
+
})
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
if (error instanceof DeviceOwnershipError) return deviceConflict()
|
|
1052
|
+
throw error
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Issue a device credential token bound to the public key thumbprint
|
|
1056
|
+
const deviceCredential = this.tokenManager.issueDeviceCredential(
|
|
1057
|
+
payload.sub,
|
|
1058
|
+
body.deviceId,
|
|
1059
|
+
thumbprint,
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
return {
|
|
1063
|
+
status: 201,
|
|
1064
|
+
body: { data: { device, deviceCredential } },
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Generate a challenge for device proof-of-possession verification.
|
|
1070
|
+
*
|
|
1071
|
+
* Creates a cryptographically random challenge, stores it server-side with
|
|
1072
|
+
* a 60-second TTL and the target device ID, and returns the challenge string.
|
|
1073
|
+
* The client signs this challenge with its private key and submits it via
|
|
1074
|
+
* {@link handleDeviceVerify}.
|
|
1075
|
+
*
|
|
1076
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
1077
|
+
* @param deviceId - The device this challenge is intended for
|
|
1078
|
+
* @returns Auth response with the challenge string, or an error
|
|
1079
|
+
*/
|
|
1080
|
+
async handleDeviceChallenge(
|
|
1081
|
+
accessToken: string,
|
|
1082
|
+
deviceId: string,
|
|
1083
|
+
): Promise<AuthRouteResponse<{ challenge: string }>> {
|
|
1084
|
+
const access = await this.authenticateAccess(accessToken)
|
|
1085
|
+
if (access === null) {
|
|
1086
|
+
return invalidAccessToken()
|
|
1087
|
+
}
|
|
1088
|
+
const { payload } = access
|
|
1089
|
+
|
|
1090
|
+
// Verify the device exists and belongs to this user
|
|
1091
|
+
const device = await this.userStore.findDevice(deviceId)
|
|
1092
|
+
if (device === null || device.userId !== payload.sub) {
|
|
1093
|
+
return {
|
|
1094
|
+
status: 404,
|
|
1095
|
+
body: { error: 'Device not found.' },
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (device.revoked) {
|
|
1100
|
+
return {
|
|
1101
|
+
status: 403,
|
|
1102
|
+
body: { error: 'Device has been revoked.' },
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const challenge = randomBytes(32).toString('hex')
|
|
1107
|
+
const expiresAt = Date.now() + CHALLENGE_TTL_MS
|
|
1108
|
+
|
|
1109
|
+
await this.challengeStore.store(challenge, deviceId, expiresAt)
|
|
1110
|
+
|
|
1111
|
+
return {
|
|
1112
|
+
status: 200,
|
|
1113
|
+
body: { data: { challenge } },
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Handle device proof-of-possession verification (POST /auth/device/verify).
|
|
1119
|
+
*
|
|
1120
|
+
* Verifies that the device holds the private key corresponding to its registered
|
|
1121
|
+
* public key by checking a signed challenge. The challenge must have been previously
|
|
1122
|
+
* issued via {@link handleDeviceChallenge} and is single-use.
|
|
1123
|
+
*
|
|
1124
|
+
* On success, issues fresh tokens for the device.
|
|
1125
|
+
*
|
|
1126
|
+
* @param body - Device verification request body
|
|
1127
|
+
* @param body.deviceId - The ID of the device to verify
|
|
1128
|
+
* @param body.challenge - The challenge string (from handleDeviceChallenge)
|
|
1129
|
+
* @param body.signature - The base64url-encoded ECDSA signature of the challenge
|
|
1130
|
+
* @returns Auth response with fresh tokens on success, or an error
|
|
1131
|
+
*/
|
|
1132
|
+
async handleDeviceVerify(body: {
|
|
1133
|
+
deviceId: string
|
|
1134
|
+
challenge: string
|
|
1135
|
+
signature: string
|
|
1136
|
+
}): Promise<AuthRouteResponse<{ tokens: AuthTokens }>> {
|
|
1137
|
+
// Consume the challenge (single-use, time-limited)
|
|
1138
|
+
const challengeEntry = await this.challengeStore.consume(body.challenge)
|
|
1139
|
+
if (challengeEntry === null) {
|
|
1140
|
+
return {
|
|
1141
|
+
status: 401,
|
|
1142
|
+
body: { error: 'Invalid or expired challenge. Request a new challenge and try again.' },
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Verify the challenge was issued for this device
|
|
1147
|
+
if (challengeEntry.deviceId !== body.deviceId) {
|
|
1148
|
+
return {
|
|
1149
|
+
status: 401,
|
|
1150
|
+
body: { error: 'Challenge was not issued for this device.' },
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Look up the device in the store
|
|
1155
|
+
const device = await this.userStore.findDevice(body.deviceId)
|
|
1156
|
+
if (device === null) {
|
|
1157
|
+
return {
|
|
1158
|
+
status: 404,
|
|
1159
|
+
body: { error: 'Device not found.' },
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Revoked devices cannot verify
|
|
1164
|
+
if (device.revoked) {
|
|
1165
|
+
return {
|
|
1166
|
+
status: 403,
|
|
1167
|
+
body: { error: 'Device has been revoked and cannot authenticate.' },
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Parse the stored public key JWK
|
|
1172
|
+
let publicKeyJwk: JsonWebKey
|
|
1173
|
+
try {
|
|
1174
|
+
publicKeyJwk = JSON.parse(device.publicKey) as JsonWebKey
|
|
1175
|
+
} catch {
|
|
1176
|
+
return {
|
|
1177
|
+
status: 500,
|
|
1178
|
+
body: { error: 'Device has an invalid stored public key.' },
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Verify the signature against the challenge using the device's public key
|
|
1183
|
+
let isValid: boolean
|
|
1184
|
+
try {
|
|
1185
|
+
isValid = await verifyChallenge(publicKeyJwk, body.challenge, body.signature)
|
|
1186
|
+
} catch {
|
|
1187
|
+
return {
|
|
1188
|
+
status: 400,
|
|
1189
|
+
body: {
|
|
1190
|
+
error:
|
|
1191
|
+
'Signature verification failed. The signature or public key format may be invalid.',
|
|
1192
|
+
},
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (!isValid) {
|
|
1197
|
+
return {
|
|
1198
|
+
status: 401,
|
|
1199
|
+
body: { error: 'Invalid signature. Proof-of-possession verification failed.' },
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Compute thumbprint for the device credential
|
|
1204
|
+
let thumbprint: string
|
|
1205
|
+
try {
|
|
1206
|
+
thumbprint = await computePublicKeyThumbprint(publicKeyJwk)
|
|
1207
|
+
} catch {
|
|
1208
|
+
return {
|
|
1209
|
+
status: 500,
|
|
1210
|
+
body: { error: 'Failed to compute public key thumbprint.' },
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Issue fresh tokens for this device
|
|
1215
|
+
const tokens = this.tokenManager.issueTokens(device.userId, device.id, thumbprint)
|
|
1216
|
+
|
|
1217
|
+
return {
|
|
1218
|
+
status: 200,
|
|
1219
|
+
body: { data: { tokens } },
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Generates a random challenge string for proof-of-possession verification.
|
|
1225
|
+
*
|
|
1226
|
+
* **Deprecated:** Use {@link handleDeviceChallenge} instead, which stores
|
|
1227
|
+
* the challenge server-side with expiry and single-use semantics.
|
|
1228
|
+
*
|
|
1229
|
+
* @returns A 64-character hex string (32 random bytes)
|
|
1230
|
+
*/
|
|
1231
|
+
static generateChallenge(): string {
|
|
1232
|
+
return randomBytes(32).toString('hex')
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
private getDummyCredential(): Promise<{ hash: string; salt: string }> {
|
|
1236
|
+
if (!this.dummyCredential) {
|
|
1237
|
+
this.dummyCredential = hashPassword(randomUUID())
|
|
1238
|
+
}
|
|
1239
|
+
return this.dummyCredential
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Creates a sync server auth provider compatible with `@korajs/server`.
|
|
1244
|
+
*
|
|
1245
|
+
* The returned object implements the `AuthProvider` interface from
|
|
1246
|
+
* `@korajs/server`, validating access tokens and returning an auth
|
|
1247
|
+
* context containing the user ID, device metadata and a SERVER-DERIVED
|
|
1248
|
+
* scope grant. The client handshake can only narrow that grant (AUTH-1).
|
|
1249
|
+
*
|
|
1250
|
+
* By default the grant binds every schema-scoped collection from
|
|
1251
|
+
* `{ userId: <verified sub> }`. Collections scoped by any other key (for
|
|
1252
|
+
* example `orgId`) are denied until `scopeValues` or `resolveScopes`
|
|
1253
|
+
* supplies it; they are never widened to "every tenant".
|
|
1254
|
+
*
|
|
1255
|
+
* Also checks device revocation status during authentication, ensuring
|
|
1256
|
+
* that revoked devices are rejected even if their tokens haven't expired.
|
|
1257
|
+
*
|
|
1258
|
+
* @param options - Optional server-side scope derivation
|
|
1259
|
+
* @returns An object with an `authenticate` method suitable for KoraSyncServer's `auth` config
|
|
1260
|
+
*
|
|
1261
|
+
* @example
|
|
1262
|
+
* ```typescript
|
|
1263
|
+
* const routes = new BuiltInAuthRoutes({ userStore, tokenManager })
|
|
1264
|
+
* const syncServer = new KoraSyncServer({
|
|
1265
|
+
* store,
|
|
1266
|
+
* auth: routes.toSyncAuthProvider({
|
|
1267
|
+
* scopeValues: async ({ userId }) => ({ orgId: await orgOf(userId) }),
|
|
1268
|
+
* }),
|
|
1269
|
+
* })
|
|
1270
|
+
* ```
|
|
1271
|
+
*/
|
|
1272
|
+
toSyncAuthProvider(options: SyncScopeOptions = {}): SyncAuthProvider {
|
|
1273
|
+
return {
|
|
1274
|
+
authenticate: async (token: string) => {
|
|
1275
|
+
const authenticated = await this.authenticateAccess(token)
|
|
1276
|
+
if (!authenticated) {
|
|
1277
|
+
return null
|
|
1278
|
+
}
|
|
1279
|
+
const { user, payload } = authenticated
|
|
1280
|
+
|
|
1281
|
+
// Touch the device to update last-seen timestamp
|
|
1282
|
+
await this.userStore.touchDevice(payload.dev)
|
|
1283
|
+
|
|
1284
|
+
const claims: VerifiedSyncClaims = {
|
|
1285
|
+
userId: payload.sub,
|
|
1286
|
+
deviceId: payload.dev,
|
|
1287
|
+
email: user.email,
|
|
1288
|
+
name: user.name,
|
|
1289
|
+
}
|
|
1290
|
+
const scopes = await resolveSyncGrant(claims, options)
|
|
1291
|
+
|
|
1292
|
+
return {
|
|
1293
|
+
userId: payload.sub,
|
|
1294
|
+
scopes,
|
|
1295
|
+
expiresAt: payload.exp * 1000,
|
|
1296
|
+
metadata: {
|
|
1297
|
+
deviceId: payload.dev,
|
|
1298
|
+
email: user.email,
|
|
1299
|
+
name: user.name,
|
|
1300
|
+
},
|
|
1301
|
+
}
|
|
1302
|
+
},
|
|
1303
|
+
onRevoke: (listener) =>
|
|
1304
|
+
this.onRevoke((event) =>
|
|
1305
|
+
listener(
|
|
1306
|
+
event.kind === 'user'
|
|
1307
|
+
? { userId: event.userId }
|
|
1308
|
+
: { userId: event.userId, deviceId: event.deviceId },
|
|
1309
|
+
),
|
|
1310
|
+
),
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|