@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +2880 -1667
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +810 -169
- package/dist/server.d.ts +810 -169
- package/dist/server.js +2848 -1646
- 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 +365 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +335 -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 +285 -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 +338 -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,416 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core'
|
|
2
|
+
import { hashPassword } from './password-hash'
|
|
3
|
+
import type { UserStore } from './user-store'
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Types
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A pending password reset request.
|
|
11
|
+
*/
|
|
12
|
+
export interface PasswordResetToken {
|
|
13
|
+
/** Cryptographically random single-use token */
|
|
14
|
+
token: string
|
|
15
|
+
/** User ID the token was generated for */
|
|
16
|
+
userId: string
|
|
17
|
+
/** Email the reset was requested for */
|
|
18
|
+
email: string
|
|
19
|
+
/** When the token was created (ms since epoch) */
|
|
20
|
+
createdAt: number
|
|
21
|
+
/** When the token expires (ms since epoch) */
|
|
22
|
+
expiresAt: number
|
|
23
|
+
/** Whether the token has been consumed */
|
|
24
|
+
consumed: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Persistence interface for password reset tokens.
|
|
29
|
+
*/
|
|
30
|
+
export interface PasswordResetStore {
|
|
31
|
+
/** Store a reset token. */
|
|
32
|
+
store(token: PasswordResetToken): Promise<void>
|
|
33
|
+
|
|
34
|
+
/** Look up a token. Returns null if not found. */
|
|
35
|
+
get(token: string): Promise<PasswordResetToken | null>
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Mark a token as consumed. Should be atomic and return `true` only for the
|
|
39
|
+
* call that consumed a not-yet-consumed token; `false` makes the reset fail
|
|
40
|
+
* as already used. Returning nothing is accepted for older stores.
|
|
41
|
+
*/
|
|
42
|
+
consume(token: string): Promise<boolean | undefined>
|
|
43
|
+
|
|
44
|
+
/** Count active (non-consumed, non-expired) tokens for an email. */
|
|
45
|
+
countActiveForEmail(email: string): Promise<number>
|
|
46
|
+
|
|
47
|
+
/** Remove expired tokens. */
|
|
48
|
+
cleanExpired(): Promise<number>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Configuration for the password reset flow.
|
|
53
|
+
*/
|
|
54
|
+
export interface PasswordResetConfig {
|
|
55
|
+
/** User store for looking up users and updating passwords */
|
|
56
|
+
userStore: UserStore
|
|
57
|
+
/** Store for reset tokens. Defaults to InMemoryPasswordResetStore. */
|
|
58
|
+
resetStore?: PasswordResetStore
|
|
59
|
+
/** Token TTL in milliseconds. Defaults to 1 hour. */
|
|
60
|
+
tokenTtlMs?: number
|
|
61
|
+
/** Max reset requests per email in the TTL window. Defaults to 3. */
|
|
62
|
+
maxRequestsPerEmail?: number
|
|
63
|
+
/**
|
|
64
|
+
* Callback invoked when a reset is requested. The developer must implement
|
|
65
|
+
* email sending: the token must only ever reach the account's mailbox.
|
|
66
|
+
*/
|
|
67
|
+
onResetRequested?: (email: string, token: string, expiresAt: number) => void | Promise<void>
|
|
68
|
+
/**
|
|
69
|
+
* Development only: also return the reset token in the `requestReset`
|
|
70
|
+
* response when no `onResetRequested` callback is configured. Ignored when
|
|
71
|
+
* `NODE_ENV=production`; the token is never disclosed to the requester there.
|
|
72
|
+
* @default false
|
|
73
|
+
*/
|
|
74
|
+
exposeTokenForDevelopment?: boolean
|
|
75
|
+
/**
|
|
76
|
+
* Called after a password is reset or changed, once every earlier credential
|
|
77
|
+
* of the user has been revoked. Use it to end live sessions (for example
|
|
78
|
+
* `authServer.revokeAllForUser` when the user store has no revocation store).
|
|
79
|
+
*/
|
|
80
|
+
onPasswordChanged?: (userId: string) => void | Promise<void>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Errors
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
export class PasswordResetError extends KoraError {
|
|
88
|
+
constructor(message: string, code: string, context?: Record<string, unknown>) {
|
|
89
|
+
super(message, code, context)
|
|
90
|
+
this.name = 'PasswordResetError'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class ResetTokenExpiredError extends PasswordResetError {
|
|
95
|
+
constructor() {
|
|
96
|
+
super('Password reset token has expired.', 'RESET_TOKEN_EXPIRED')
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class ResetTokenNotFoundError extends PasswordResetError {
|
|
101
|
+
constructor() {
|
|
102
|
+
super('Password reset token not found or already used.', 'RESET_TOKEN_NOT_FOUND')
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class ResetRateLimitedError extends PasswordResetError {
|
|
107
|
+
constructor() {
|
|
108
|
+
super('Too many password reset requests. Please try again later.', 'RESET_RATE_LIMITED')
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ============================================================================
|
|
113
|
+
// InMemoryPasswordResetStore
|
|
114
|
+
// ============================================================================
|
|
115
|
+
|
|
116
|
+
export class InMemoryPasswordResetStore implements PasswordResetStore {
|
|
117
|
+
private tokens = new Map<string, PasswordResetToken>()
|
|
118
|
+
|
|
119
|
+
async store(token: PasswordResetToken): Promise<void> {
|
|
120
|
+
this.tokens.set(token.token, token)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async get(token: string): Promise<PasswordResetToken | null> {
|
|
124
|
+
return this.tokens.get(token) ?? null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async consume(token: string): Promise<boolean> {
|
|
128
|
+
const entry = this.tokens.get(token)
|
|
129
|
+
if (!entry || entry.consumed) return false
|
|
130
|
+
this.tokens.set(token, { ...entry, consumed: true })
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async countActiveForEmail(email: string): Promise<number> {
|
|
135
|
+
const now = Date.now()
|
|
136
|
+
let count = 0
|
|
137
|
+
for (const token of this.tokens.values()) {
|
|
138
|
+
if (token.email === email && !token.consumed && now < token.expiresAt) {
|
|
139
|
+
count++
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return count
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async cleanExpired(): Promise<number> {
|
|
146
|
+
const now = Date.now()
|
|
147
|
+
let count = 0
|
|
148
|
+
for (const [key, token] of this.tokens) {
|
|
149
|
+
if (now > token.expiresAt) {
|
|
150
|
+
this.tokens.delete(key)
|
|
151
|
+
count++
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return count
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ============================================================================
|
|
159
|
+
// PasswordResetManager
|
|
160
|
+
// ============================================================================
|
|
161
|
+
|
|
162
|
+
/** Default TTL: 1 hour */
|
|
163
|
+
const DEFAULT_TOKEN_TTL_MS = 60 * 60 * 1000
|
|
164
|
+
|
|
165
|
+
/** Default max requests per email per TTL window */
|
|
166
|
+
const DEFAULT_MAX_REQUESTS = 3
|
|
167
|
+
|
|
168
|
+
/** Minimum password length (same as auth-routes) */
|
|
169
|
+
const MIN_PASSWORD_LENGTH = 8
|
|
170
|
+
|
|
171
|
+
/** Maximum password length (same as auth-routes) */
|
|
172
|
+
const MAX_PASSWORD_LENGTH = 128
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Manages the password reset flow.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```typescript
|
|
179
|
+
* const resetManager = new PasswordResetManager({
|
|
180
|
+
* userStore,
|
|
181
|
+
* onResetRequested: async (email, token, expiresAt) => {
|
|
182
|
+
* await sendEmail(email, `Reset link: https://app.com/reset?token=${token}`)
|
|
183
|
+
* },
|
|
184
|
+
* })
|
|
185
|
+
*
|
|
186
|
+
* // Request reset (always returns 200 to prevent email enumeration)
|
|
187
|
+
* const response = await resetManager.requestReset('user@example.com')
|
|
188
|
+
*
|
|
189
|
+
* // Consume token and set new password
|
|
190
|
+
* const response = await resetManager.resetPassword(token, 'newPassword123')
|
|
191
|
+
* ```
|
|
192
|
+
*/
|
|
193
|
+
export class PasswordResetManager {
|
|
194
|
+
private readonly userStore: UserStore
|
|
195
|
+
private readonly resetStore: PasswordResetStore
|
|
196
|
+
private readonly tokenTtlMs: number
|
|
197
|
+
private readonly maxRequestsPerEmail: number
|
|
198
|
+
private readonly onResetRequested?: (
|
|
199
|
+
email: string,
|
|
200
|
+
token: string,
|
|
201
|
+
expiresAt: number,
|
|
202
|
+
) => void | Promise<void>
|
|
203
|
+
private readonly exposeTokenForDevelopment: boolean
|
|
204
|
+
private readonly onPasswordChanged?: (userId: string) => void | Promise<void>
|
|
205
|
+
private warnedNoDelivery = false
|
|
206
|
+
|
|
207
|
+
constructor(config: PasswordResetConfig) {
|
|
208
|
+
this.userStore = config.userStore
|
|
209
|
+
this.resetStore = config.resetStore ?? new InMemoryPasswordResetStore()
|
|
210
|
+
this.tokenTtlMs = config.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS
|
|
211
|
+
this.maxRequestsPerEmail = config.maxRequestsPerEmail ?? DEFAULT_MAX_REQUESTS
|
|
212
|
+
this.onResetRequested = config.onResetRequested
|
|
213
|
+
this.exposeTokenForDevelopment = config.exposeTokenForDevelopment === true && !isProduction()
|
|
214
|
+
this.onPasswordChanged = config.onPasswordChanged
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Request a password reset for an email.
|
|
219
|
+
* Always returns the same success response to prevent email enumeration.
|
|
220
|
+
*
|
|
221
|
+
* The token is delivered only through `onResetRequested`. It is returned in
|
|
222
|
+
* the response only with `exposeTokenForDevelopment: true` outside production.
|
|
223
|
+
*/
|
|
224
|
+
async requestReset(email: string): Promise<{
|
|
225
|
+
status: number
|
|
226
|
+
body: { data: { message: string; token?: string } } | { error: string }
|
|
227
|
+
}> {
|
|
228
|
+
const normalizedEmail = email.toLowerCase().trim()
|
|
229
|
+
|
|
230
|
+
// Always return 200 to prevent email enumeration
|
|
231
|
+
const successResponse = {
|
|
232
|
+
status: 200,
|
|
233
|
+
body: {
|
|
234
|
+
data: {
|
|
235
|
+
message: 'If an account with that email exists, a password reset link has been sent.',
|
|
236
|
+
},
|
|
237
|
+
} as { data: { message: string; token?: string } },
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Look up user
|
|
241
|
+
const user = await this.userStore.findByEmail(normalizedEmail)
|
|
242
|
+
if (!user) {
|
|
243
|
+
return successResponse
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Rate limit
|
|
247
|
+
const activeCount = await this.resetStore.countActiveForEmail(normalizedEmail)
|
|
248
|
+
if (activeCount >= this.maxRequestsPerEmail) {
|
|
249
|
+
return successResponse // Still 200 to prevent enumeration
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Generate token
|
|
253
|
+
const token = generateSecureToken()
|
|
254
|
+
const now = Date.now()
|
|
255
|
+
const resetToken: PasswordResetToken = {
|
|
256
|
+
token,
|
|
257
|
+
userId: user.id,
|
|
258
|
+
email: normalizedEmail,
|
|
259
|
+
createdAt: now,
|
|
260
|
+
expiresAt: now + this.tokenTtlMs,
|
|
261
|
+
consumed: false,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await this.resetStore.store(resetToken)
|
|
265
|
+
|
|
266
|
+
// Invoke callback
|
|
267
|
+
if (this.onResetRequested) {
|
|
268
|
+
try {
|
|
269
|
+
await this.onResetRequested(normalizedEmail, token, resetToken.expiresAt)
|
|
270
|
+
} catch {
|
|
271
|
+
// Don't fail the request if callback errors
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!this.onResetRequested) {
|
|
276
|
+
if (this.exposeTokenForDevelopment) {
|
|
277
|
+
// Same message as the normal path so the response shape does not reveal
|
|
278
|
+
// whether the account exists; only the extra token field differs.
|
|
279
|
+
successResponse.body = { data: { ...successResponse.body.data, token } }
|
|
280
|
+
} else if (!this.warnedNoDelivery) {
|
|
281
|
+
this.warnedNoDelivery = true
|
|
282
|
+
console.error(
|
|
283
|
+
'[kora] PasswordResetManager has no onResetRequested callback, so reset tokens are not delivered to anyone. ' +
|
|
284
|
+
'Configure onResetRequested to email the token (or exposeTokenForDevelopment: true for local development).',
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return successResponse
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Consume a reset token and set a new password.
|
|
294
|
+
*/
|
|
295
|
+
async resetPassword(
|
|
296
|
+
token: string,
|
|
297
|
+
newPassword: string,
|
|
298
|
+
): Promise<{ status: number; body: { data: { message: string } } | { error: string } }> {
|
|
299
|
+
// Validate password
|
|
300
|
+
if (typeof newPassword !== 'string' || newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
301
|
+
return {
|
|
302
|
+
status: 400,
|
|
303
|
+
body: { error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.` },
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (newPassword.length > MAX_PASSWORD_LENGTH) {
|
|
307
|
+
return {
|
|
308
|
+
status: 400,
|
|
309
|
+
body: { error: `Password must be at most ${MAX_PASSWORD_LENGTH} characters.` },
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const resetToken = await this.resetStore.get(token)
|
|
314
|
+
if (!resetToken || resetToken.consumed) {
|
|
315
|
+
return { status: 404, body: { error: 'Password reset token not found or already used.' } }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (Date.now() > resetToken.expiresAt) {
|
|
319
|
+
await this.resetStore.consume(token)
|
|
320
|
+
return { status: 410, body: { error: 'Password reset token has expired.' } }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Consume token atomically: only one concurrent reset may use it.
|
|
324
|
+
const consumed = await this.resetStore.consume(token)
|
|
325
|
+
if (consumed === false) {
|
|
326
|
+
return { status: 404, body: { error: 'Password reset token not found or already used.' } }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Update password
|
|
330
|
+
const hashed = await hashPassword(newPassword)
|
|
331
|
+
await this.userStore.updatePassword(resetToken.userId, hashed.hash, hashed.salt)
|
|
332
|
+
await this.revokeEarlierCredentials(resetToken.userId)
|
|
333
|
+
|
|
334
|
+
return { status: 200, body: { data: { message: 'Password has been reset successfully.' } } }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Change password for an authenticated user (requires current password verification).
|
|
339
|
+
*/
|
|
340
|
+
async changePassword(
|
|
341
|
+
userId: string,
|
|
342
|
+
currentPassword: string,
|
|
343
|
+
newPassword: string,
|
|
344
|
+
): Promise<{ status: number; body: { data: { message: string } } | { error: string } }> {
|
|
345
|
+
// Validate new password
|
|
346
|
+
if (typeof newPassword !== 'string' || newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
347
|
+
return {
|
|
348
|
+
status: 400,
|
|
349
|
+
body: { error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters.` },
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (newPassword.length > MAX_PASSWORD_LENGTH) {
|
|
353
|
+
return {
|
|
354
|
+
status: 400,
|
|
355
|
+
body: { error: `New password must be at most ${MAX_PASSWORD_LENGTH} characters.` },
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const user = await this.userStore.findById(userId)
|
|
360
|
+
if (!user) {
|
|
361
|
+
return { status: 404, body: { error: 'User not found.' } }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Verify current password
|
|
365
|
+
const { verifyPassword } = await import('./password-hash')
|
|
366
|
+
const isValid = await verifyPassword(currentPassword, user.passwordHash, user.salt)
|
|
367
|
+
if (!isValid) {
|
|
368
|
+
return { status: 401, body: { error: 'Current password is incorrect.' } }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Update password
|
|
372
|
+
const hashed = await hashPassword(newPassword)
|
|
373
|
+
await this.userStore.updatePassword(userId, hashed.hash, hashed.salt)
|
|
374
|
+
await this.revokeEarlierCredentials(userId)
|
|
375
|
+
|
|
376
|
+
return { status: 200, body: { data: { message: 'Password changed successfully.' } } }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* A password change kills every earlier credential (AUTH-7b): every access
|
|
381
|
+
* and refresh token issued before now is rejected on every path.
|
|
382
|
+
*/
|
|
383
|
+
private async revokeEarlierCredentials(userId: string): Promise<void> {
|
|
384
|
+
const revocations = this.userStore.getTokenRevocationStore?.()
|
|
385
|
+
if (revocations) {
|
|
386
|
+
await revocations.revokeAllForUser(userId, Date.now())
|
|
387
|
+
}
|
|
388
|
+
if (this.onPasswordChanged) {
|
|
389
|
+
await this.onPasswordChanged(userId)
|
|
390
|
+
}
|
|
391
|
+
if (!revocations && !this.onPasswordChanged) {
|
|
392
|
+
console.warn(
|
|
393
|
+
'[kora] Password changed but earlier sessions could not be revoked: the user store has no ' +
|
|
394
|
+
'getTokenRevocationStore() and no onPasswordChanged hook is configured.',
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function isProduction(): boolean {
|
|
401
|
+
return typeof process !== 'undefined' && process.env.NODE_ENV === 'production'
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ============================================================================
|
|
405
|
+
// Helpers
|
|
406
|
+
// ============================================================================
|
|
407
|
+
|
|
408
|
+
function generateSecureToken(): string {
|
|
409
|
+
const bytes = new Uint8Array(32)
|
|
410
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
411
|
+
let binary = ''
|
|
412
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
413
|
+
binary += String.fromCharCode(bytes[i] as number)
|
|
414
|
+
}
|
|
415
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
416
|
+
}
|