@byline/admin 5.0.0 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/fields/field-services-context.d.ts +1 -1
  2. package/dist/forms/available-locales-widget.d.ts +1 -1
  3. package/dist/forms/document-actions.d.ts +1 -1
  4. package/dist/forms/form-renderer.d.ts +3 -3
  5. package/dist/forms/form-status-display.d.ts +1 -1
  6. package/dist/forms/upload-executor.d.ts +2 -2
  7. package/dist/modules/admin-account/components/change-password.js +18 -0
  8. package/dist/modules/admin-account/service.d.ts +2 -6
  9. package/dist/modules/admin-account/service.js +2 -1
  10. package/dist/modules/admin-users/repository.d.ts +10 -1
  11. package/dist/modules/auth/index.d.ts +3 -1
  12. package/dist/modules/auth/index.js +1 -0
  13. package/dist/modules/auth/jwt-session-provider.d.ts +8 -2
  14. package/dist/modules/auth/jwt-session-provider.js +222 -70
  15. package/dist/modules/auth/login-sessions-repository.d.ts +23 -0
  16. package/dist/modules/auth/login-sessions-repository.js +1 -0
  17. package/dist/modules/auth/refresh-tokens-repository.d.ts +12 -9
  18. package/dist/modules/auth/resolve-actor.d.ts +3 -0
  19. package/dist/modules/auth/resolve-actor.js +5 -2
  20. package/dist/modules/auth/sign-in-rate-limiter.d.ts +48 -0
  21. package/dist/modules/auth/sign-in-rate-limiter.js +229 -0
  22. package/dist/store.d.ts +15 -2
  23. package/package.json +17 -17
  24. package/src/modules/admin-account/components/change-password.test.tsx +72 -0
  25. package/src/modules/admin-account/components/change-password.tsx +15 -4
  26. package/src/modules/admin-account/service.ts +8 -7
  27. package/src/modules/admin-users/repository.ts +10 -1
  28. package/src/modules/auth/index.ts +13 -1
  29. package/src/modules/auth/jwt-session-provider.ts +276 -91
  30. package/src/modules/auth/login-sessions-repository.ts +25 -0
  31. package/src/modules/auth/refresh-tokens-repository.ts +12 -9
  32. package/src/modules/auth/resolve-actor.ts +10 -1
  33. package/src/modules/auth/sign-in-rate-limiter.ts +240 -0
  34. package/src/store.ts +22 -2
@@ -11,22 +11,26 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto'
11
11
  import {
12
12
  type AccessTokenPayload,
13
13
  type AdminAuth,
14
+ ERR_ACCESS_EXPIRED,
14
15
  ERR_ACCOUNT_DISABLED,
15
16
  ERR_INVALID_CREDENTIALS,
16
17
  ERR_INVALID_TOKEN,
17
18
  ERR_REVOKED_TOKEN,
19
+ ERR_SESSION_CHANGED,
18
20
  type RefreshSessionArgs,
21
+ type RevokeSessionArgs,
19
22
  type SessionProvider,
20
23
  type SessionProviderCapabilities,
21
24
  type SessionTokens,
22
25
  type SignInResult,
23
26
  type SignInWithPasswordArgs,
24
27
  } from '@byline/auth'
25
- import { jwtVerify, SignJWT } from 'jose'
28
+ import { passwordSignInSchema } from '@byline/core/validation'
29
+ import { compactVerify, jwtVerify, SignJWT } from 'jose'
26
30
  import { v7 as uuidv7 } from 'uuid'
27
31
 
28
32
  import { verifyPassword } from './password.js'
29
- import { resolveActor } from './resolve-actor.js'
33
+ import { resolveActor, resolveActorFromUser } from './resolve-actor.js'
30
34
  import type { AdminStore } from '../../store.js'
31
35
 
32
36
  const DEFAULT_ISSUER = 'byline'
@@ -39,7 +43,13 @@ const CAPABILITIES: SessionProviderCapabilities = {
39
43
  sso: false,
40
44
  }
41
45
 
46
+ export interface NativeSessionEvent {
47
+ type: 'refresh_attempted' | 'refresh_completed' | 'refresh_contested'
48
+ }
49
+
42
50
  export interface JwtSessionProviderConfig {
51
+ /** Best-effort, token-free renewal telemetry. Errors never change authentication outcomes. */
52
+ onEvent?: (event: NativeSessionEvent) => void
43
53
  /**
44
54
  * Adapter-backed admin repositories. Construct via the DB adapter's
45
55
  * admin-store factory (e.g. `createAdminStore(db)` from
@@ -68,6 +78,7 @@ export interface JwtSessionProviderConfig {
68
78
  export class JwtSessionProvider implements SessionProvider {
69
79
  public readonly capabilities = CAPABILITIES
70
80
 
81
+ readonly #onEvent?: (event: NativeSessionEvent) => void
71
82
  readonly #store: AdminStore
72
83
  readonly #signingKey: Uint8Array
73
84
  readonly #issuer: string
@@ -76,6 +87,7 @@ export class JwtSessionProvider implements SessionProvider {
76
87
  readonly #now: () => Date
77
88
 
78
89
  constructor(config: JwtSessionProviderConfig) {
90
+ this.#onEvent = config.onEvent
79
91
  this.#store = config.store
80
92
  this.#signingKey =
81
93
  typeof config.signingSecret === 'string'
@@ -97,18 +109,19 @@ export class JwtSessionProvider implements SessionProvider {
97
109
  // -----------------------------------------------------------------------
98
110
 
99
111
  async signInWithPassword(args: SignInWithPasswordArgs): Promise<SignInResult> {
112
+ const credentials = passwordSignInSchema.parse(args)
100
113
  const users = this.#store.adminUsers
101
- const row = await users.getByEmailForSignIn(args.email)
114
+ const row = await users.getByEmailForSignIn(credentials.email)
102
115
 
103
116
  // Uniform error response for unknown email vs. wrong password — don't
104
117
  // leak which one. Still do a real verify against a dummy hash so the
105
118
  // timing is comparable; the argon2 cost dominates regardless.
106
119
  if (!row) {
107
- await verifyPassword(args.password, DUMMY_HASH_FOR_TIMING)
120
+ await verifyPassword(credentials.password, DUMMY_HASH_FOR_TIMING)
108
121
  throw ERR_INVALID_CREDENTIALS({ message: 'invalid credentials' })
109
122
  }
110
123
 
111
- const ok = await verifyPassword(args.password, row.password_hash)
124
+ const ok = await verifyPassword(credentials.password, row.password_hash)
112
125
  if (!ok) {
113
126
  await users.recordLoginFailure(row.id)
114
127
  throw ERR_INVALID_CREDENTIALS({ message: 'invalid credentials' })
@@ -118,110 +131,204 @@ export class JwtSessionProvider implements SessionProvider {
118
131
  throw ERR_ACCOUNT_DISABLED({ message: 'account disabled' })
119
132
  }
120
133
 
121
- await users.recordLoginSuccess(row.id, args.ip ?? null)
122
-
123
- const actor = await resolveActor(this.#store, row.id)
124
- // resolveActor also checks is_enabled, but we just recorded success
125
- // above, so null here would indicate a race (the account was disabled
126
- // between the check and the resolve). Treat as disabled.
127
- if (!actor) {
128
- throw ERR_ACCOUNT_DISABLED({ message: 'account disabled' })
129
- }
130
-
131
- const tokens = await this.#issueTokens({
132
- adminUserId: row.id,
133
- ip: args.ip ?? null,
134
- userAgent: args.userAgent ?? null,
134
+ const previous = await this.#observedLogins(args)
135
+ const withLocks = <T>(work: (store: AdminStore) => Promise<T>) =>
136
+ previous.length
137
+ ? this.#store.withSessionLocks(
138
+ [row.id, ...previous.map((login) => login.admin_user_id)],
139
+ work
140
+ )
141
+ : this.#store.withSessionLock(row.id, (store) => work(store))
142
+ return withLocks(async (store) => {
143
+ const current = await store.adminUsers.getByIdForSignIn(row.id)
144
+ if (!current?.is_enabled) throw ERR_ACCOUNT_DISABLED({ message: 'account disabled' })
145
+ // Password verification is deliberately outside the lock. Revalidate
146
+ // both the credential and generation before issuing under that lock.
147
+ if (
148
+ current.password_hash !== row.password_hash ||
149
+ current.session_version !== row.session_version
150
+ ) {
151
+ throw ERR_INVALID_CREDENTIALS({ message: 'credentials changed during sign-in' })
152
+ }
153
+ for (const observed of previous) {
154
+ const login = await store.loginSessions.findById(observed.id)
155
+ if (login && login.admin_user_id === observed.admin_user_id) {
156
+ await store.loginSessions.revoke(login.id, this.#now())
157
+ }
158
+ }
159
+ await store.adminUsers.recordLoginSuccess(row.id, args.ip ?? null)
160
+ const actor = await resolveActorFromUser(store, current)
161
+ if (!actor) throw ERR_ACCOUNT_DISABLED({ message: 'account disabled' })
162
+ const tokens = await this.#issueTokens(store, {
163
+ adminUserId: row.id,
164
+ sessionVersion: current.session_version,
165
+ ip: args.ip ?? null,
166
+ userAgent: args.userAgent ?? null,
167
+ })
168
+ return { ...tokens, actor }
135
169
  })
136
-
137
- return { ...tokens, actor }
138
170
  }
139
171
 
140
- async verifyAccessToken(token: string): Promise<{ actor: AdminAuth }> {
172
+ async verifyAccessToken(token: string): Promise<{ actor: AdminAuth; sessionId: string }> {
141
173
  let payload: AccessTokenPayload
142
174
  try {
143
175
  const result = await jwtVerify<AccessTokenPayload>(token, this.#signingKey, {
144
176
  issuer: this.#issuer,
177
+ algorithms: ['HS256'],
178
+ currentDate: this.#now(),
179
+ requiredClaims: ['sub', 'iat', 'exp', 'jti', 'sv', 'sid'],
145
180
  })
146
181
  payload = result.payload
147
182
  } catch (err) {
183
+ if (err instanceof Error && 'code' in err && err.code === 'ERR_JWT_EXPIRED') {
184
+ throw ERR_ACCESS_EXPIRED({ message: 'access token expired' })
185
+ }
148
186
  throw ERR_INVALID_TOKEN({ message: 'access token verification failed', cause: err })
149
187
  }
150
188
 
151
- if (payload.typ !== 'access') {
189
+ if (
190
+ payload.typ !== 'access' ||
191
+ typeof payload.sub !== 'string' ||
192
+ !validSid(payload.sid) ||
193
+ !Number.isSafeInteger(payload.sv) ||
194
+ payload.sv < 0
195
+ ) {
152
196
  throw ERR_INVALID_TOKEN({ message: 'unexpected token type' })
153
197
  }
154
198
 
155
- const actor = await resolveActor(this.#store, payload.sub)
156
- if (!actor) {
157
- // The token was valid but the user is now disabled or deleted.
199
+ const current = await this.#store.adminUsers.getByIdForSignIn(payload.sub)
200
+ if (!current?.is_enabled) {
158
201
  throw ERR_ACCOUNT_DISABLED({ message: 'account disabled or deleted' })
159
202
  }
160
-
161
- return { actor }
203
+ if (current.session_version !== payload.sv) {
204
+ throw ERR_REVOKED_TOKEN({ message: 'account session generation changed' })
205
+ }
206
+ const login = await this.#store.loginSessions.findById(payload.sid)
207
+ if (
208
+ !login ||
209
+ login.admin_user_id !== payload.sub ||
210
+ login.session_version !== payload.sv ||
211
+ login.revoked_at != null ||
212
+ login.expires_at.getTime() <= this.#now().getTime()
213
+ ) {
214
+ throw ERR_REVOKED_TOKEN({ message: 'login revoked or expired' })
215
+ }
216
+ const actor = await resolveActorFromUser(this.#store, current)
217
+ if (!actor) throw ERR_ACCOUNT_DISABLED({ message: 'account disabled or deleted' })
218
+ return { actor, sessionId: payload.sid }
162
219
  }
163
220
 
164
221
  async refreshSession(args: RefreshSessionArgs): Promise<SessionTokens> {
165
- const refreshTokens = this.#store.refreshTokens
222
+ this.#emit('refresh_attempted')
166
223
  const hash = hashToken(args.refreshToken)
167
- const row = await refreshTokens.findByHash(hash)
168
-
169
- if (!row) {
170
- throw ERR_INVALID_TOKEN({ message: 'refresh token not recognised' })
171
- }
172
-
173
- const now = this.#now()
174
-
175
- // Already revoked?
176
- if (row.revoked_at != null) {
177
- if (row.rotated_to_id != null) {
178
- // Rotated token replayed — the chain is compromised. Revoke every
179
- // descendant so the attacker and the legitimate holder are both
180
- // signed out.
181
- await refreshTokens.revokeChain(row.id, now)
182
- throw ERR_REVOKED_TOKEN({
183
- message: 'refresh token was already rotated — chain revoked',
224
+ const observed = await this.#store.refreshTokens.findByHash(hash)
225
+ if (!observed) throw ERR_INVALID_TOKEN({ message: 'refresh token not recognised' })
226
+
227
+ const outcome = await this.#store.withSessionLock(
228
+ observed.admin_user_id,
229
+ async (store, user) => {
230
+ if (!user?.is_enabled)
231
+ throw ERR_ACCOUNT_DISABLED({ message: 'account disabled or deleted' })
232
+ const refreshTokens = store.refreshTokens
233
+ const row = await refreshTokens.findByHash(hash)
234
+ if (!row) throw ERR_INVALID_TOKEN({ message: 'refresh token not recognised' })
235
+ const now = this.#now()
236
+ const login = row.sid ? await store.loginSessions.findById(row.sid) : null
237
+ if (
238
+ !login ||
239
+ login.admin_user_id !== user.id ||
240
+ login.session_version !== user.session_version ||
241
+ login.revoked_at != null
242
+ ) {
243
+ throw ERR_REVOKED_TOKEN({ message: 'login revoked' })
244
+ }
245
+ if (args.expectedSessionId && args.expectedSessionId !== login.id) {
246
+ throw ERR_SESSION_CHANGED({ message: 'session changed before renewal' })
247
+ }
248
+ if (row.revoked_at != null) {
249
+ if (row.rotated_to_id != null) {
250
+ await store.loginSessions.revoke(login.id, now)
251
+ await refreshTokens.revokeChain(row.id, now)
252
+ }
253
+ // Return the error so replay revocation commits before it is thrown.
254
+ // Strict replay revokes this login even if the replayed row is also expired.
255
+ return {
256
+ contested: row.rotated_to_id != null,
257
+ error: ERR_REVOKED_TOKEN({ message: 'refresh token has been revoked' }),
258
+ }
259
+ }
260
+ if (row.session_version !== user.session_version) {
261
+ throw ERR_REVOKED_TOKEN({ message: 'account session generation changed' })
262
+ }
263
+ // Preserve the terminal expiry classification for an ordinary aged-out login.
264
+ // Explicit revocation/replay and generation changes above retain precedence.
265
+ if (
266
+ row.expires_at.getTime() <= now.getTime() ||
267
+ login.expires_at.getTime() <= now.getTime()
268
+ ) {
269
+ throw ERR_INVALID_TOKEN({ message: 'refresh token expired' })
270
+ }
271
+ const newId = uuidv7()
272
+ const newRefreshPlain = generateOpaqueToken()
273
+ const refreshExpiresAt = new Date(now.getTime() + this.#refreshTtl * 1000)
274
+ await refreshTokens.issue({
275
+ id: newId,
276
+ admin_user_id: row.admin_user_id,
277
+ token_hash: hashToken(newRefreshPlain),
278
+ session_version: user.session_version,
279
+ sid: login.id,
280
+ expires_at: refreshExpiresAt,
281
+ user_agent: args.userAgent ?? null,
282
+ ip: args.ip ?? null,
184
283
  })
284
+ await store.loginSessions.extend(login.id, refreshExpiresAt)
285
+ await refreshTokens.markRotated(row.id, newId, now)
286
+ const accessToken = await this.#signAccessToken(
287
+ row.admin_user_id,
288
+ user.session_version,
289
+ login.id,
290
+ now
291
+ )
292
+ return {
293
+ tokens: {
294
+ sessionId: login.id,
295
+ accessToken,
296
+ refreshToken: newRefreshPlain,
297
+ accessTokenExpiresAt: new Date(now.getTime() + this.#accessTtl * 1000),
298
+ refreshTokenExpiresAt: refreshExpiresAt,
299
+ },
300
+ }
185
301
  }
186
- throw ERR_REVOKED_TOKEN({ message: 'refresh token has been revoked' })
187
- }
188
-
189
- if (row.expires_at.getTime() <= now.getTime()) {
190
- throw ERR_INVALID_TOKEN({ message: 'refresh token expired' })
302
+ )
303
+ if ('error' in outcome) {
304
+ if (outcome.contested) this.#emit('refresh_contested')
305
+ throw outcome.error
191
306
  }
307
+ this.#emit('refresh_completed')
308
+ return outcome.tokens
309
+ }
192
310
 
193
- // Rotate: mint a new token, mark the old one rotated_to the new id.
194
- const newId = uuidv7()
195
- const newRefreshPlain = generateOpaqueToken()
196
- const newRefreshHash = hashToken(newRefreshPlain)
197
- const refreshExpiresAt = new Date(now.getTime() + this.#refreshTtl * 1000)
198
-
199
- await refreshTokens.issue({
200
- id: newId,
201
- admin_user_id: row.admin_user_id,
202
- token_hash: newRefreshHash,
203
- expires_at: refreshExpiresAt,
204
- user_agent: args.userAgent ?? null,
205
- ip: args.ip ?? null,
311
+ async revokeSession(args: RevokeSessionArgs): Promise<void> {
312
+ const logins = await this.#observedLogins({
313
+ previousAccessToken: args.accessToken,
314
+ previousRefreshToken: args.refreshToken,
206
315
  })
207
- await refreshTokens.markRotated(row.id, newId, now)
208
-
209
- const accessToken = await this.#signAccessToken(row.admin_user_id, now)
210
- const accessExpiresAt = new Date(now.getTime() + this.#accessTtl * 1000)
211
-
212
- return {
213
- accessToken,
214
- refreshToken: newRefreshPlain,
215
- accessTokenExpiresAt: accessExpiresAt,
216
- refreshTokenExpiresAt: refreshExpiresAt,
316
+ if (args.expectedSessionId && logins.some((login) => login.id !== args.expectedSessionId))
317
+ throw ERR_SESSION_CHANGED({ message: 'session changed before logout' })
318
+ for (const login of logins) {
319
+ await this.#store.withSessionLock(login.admin_user_id, async (store) => {
320
+ await store.loginSessions.revoke(login.id, this.#now())
321
+ })
322
+ }
323
+ // Also retire a known legacy refresh row. Native rows are denied by login validity.
324
+ if (args.refreshToken) {
325
+ const row = await this.#store.refreshTokens.findByHash(hashToken(args.refreshToken))
326
+ if (row) {
327
+ await this.#store.withSessionLock(row.admin_user_id, async (store) => {
328
+ await store.refreshTokens.revokeChain(row.id, this.#now())
329
+ })
330
+ }
217
331
  }
218
- }
219
-
220
- async revokeSession(refreshToken: string): Promise<void> {
221
- const refreshTokens = this.#store.refreshTokens
222
- const row = await refreshTokens.findByHash(hashToken(refreshToken))
223
- if (!row) return // Idempotent — unknown tokens are a no-op.
224
- await refreshTokens.revoke(row.id, this.#now())
225
332
  }
226
333
 
227
334
  async resolveActor(adminUserId: string): Promise<AdminAuth | null> {
@@ -232,30 +339,96 @@ export class JwtSessionProvider implements SessionProvider {
232
339
  // Internals
233
340
  // -----------------------------------------------------------------------
234
341
 
235
- async #issueTokens(input: {
236
- adminUserId: string
237
- ip: string | null
238
- userAgent: string | null
239
- }): Promise<SessionTokens> {
240
- const now = this.#now()
241
- const refreshTokens = this.#store.refreshTokens
342
+ #emit(type: NativeSessionEvent['type']) {
343
+ try {
344
+ this.#onEvent?.({ type })
345
+ } catch {
346
+ /* Telemetry is not an authentication dependency. */
347
+ }
348
+ }
349
+
350
+ async #observedLogins(
351
+ args: Pick<SignInWithPasswordArgs, 'previousAccessToken' | 'previousRefreshToken'>
352
+ ) {
353
+ const ids = new Set<string>()
354
+ if (args.previousAccessToken) {
355
+ let claims: Record<string, unknown> | undefined
356
+ try {
357
+ const verified = await compactVerify(args.previousAccessToken, this.#signingKey, {
358
+ algorithms: ['HS256'],
359
+ })
360
+ claims = JSON.parse(new TextDecoder().decode(verified.payload))
361
+ } catch {
362
+ /* An invalid credential supplies no revocation authority. */
363
+ }
364
+ if (
365
+ claims?.iss === this.#issuer &&
366
+ claims.typ === 'access' &&
367
+ validSid(claims.sid) &&
368
+ typeof claims.sub === 'string'
369
+ ) {
370
+ const login = await this.#store.loginSessions.findById(claims.sid)
371
+ if (login?.admin_user_id === claims.sub) ids.add(login.id)
372
+ }
373
+ }
374
+ if (args.previousRefreshToken) {
375
+ const token = await this.#store.refreshTokens.findByHash(hashToken(args.previousRefreshToken))
376
+ if (token?.sid) {
377
+ const login = await this.#store.loginSessions.findById(token.sid)
378
+ if (login?.admin_user_id === token.admin_user_id) ids.add(login.id)
379
+ }
380
+ }
381
+ const result = []
382
+ for (const id of ids) {
383
+ const login = await this.#store.loginSessions.findById(id)
384
+ if (login) result.push(login)
385
+ }
386
+ return result
387
+ }
242
388
 
243
- const accessToken = await this.#signAccessToken(input.adminUserId, now)
389
+ async #issueTokens(
390
+ store: AdminStore,
391
+ input: {
392
+ sessionVersion: number
393
+ adminUserId: string
394
+ ip: string | null
395
+ userAgent: string | null
396
+ }
397
+ ): Promise<SessionTokens> {
398
+ const now = this.#now()
399
+ const refreshTokens = store.refreshTokens
400
+
401
+ const sessionId = randomUUID()
402
+ const accessToken = await this.#signAccessToken(
403
+ input.adminUserId,
404
+ input.sessionVersion,
405
+ sessionId,
406
+ now
407
+ )
244
408
  const accessExpiresAt = new Date(now.getTime() + this.#accessTtl * 1000)
245
409
 
246
410
  const refreshPlain = generateOpaqueToken()
247
411
  const refreshHash = hashToken(refreshPlain)
248
412
  const refreshExpiresAt = new Date(now.getTime() + this.#refreshTtl * 1000)
413
+ await store.loginSessions.create({
414
+ id: sessionId,
415
+ admin_user_id: input.adminUserId,
416
+ session_version: input.sessionVersion,
417
+ expires_at: refreshExpiresAt,
418
+ })
249
419
  await refreshTokens.issue({
250
420
  id: uuidv7(),
251
421
  admin_user_id: input.adminUserId,
252
422
  token_hash: refreshHash,
423
+ session_version: input.sessionVersion,
424
+ sid: sessionId,
253
425
  expires_at: refreshExpiresAt,
254
426
  user_agent: input.userAgent,
255
427
  ip: input.ip,
256
428
  })
257
429
 
258
430
  return {
431
+ sessionId,
259
432
  accessToken,
260
433
  refreshToken: refreshPlain,
261
434
  accessTokenExpiresAt: accessExpiresAt,
@@ -263,10 +436,15 @@ export class JwtSessionProvider implements SessionProvider {
263
436
  }
264
437
  }
265
438
 
266
- async #signAccessToken(adminUserId: string, now: Date): Promise<string> {
439
+ async #signAccessToken(
440
+ adminUserId: string,
441
+ sessionVersion: number,
442
+ sessionId: string,
443
+ now: Date
444
+ ): Promise<string> {
267
445
  const iat = Math.floor(now.getTime() / 1000)
268
446
  const exp = iat + this.#accessTtl
269
- return new SignJWT({ typ: 'access' })
447
+ return new SignJWT({ typ: 'access', sv: sessionVersion, sid: sessionId })
270
448
  .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
271
449
  .setSubject(adminUserId)
272
450
  .setIssuer(this.#issuer)
@@ -299,3 +477,10 @@ function hashToken(token: string): string {
299
477
  */
300
478
  const DUMMY_HASH_FOR_TIMING =
301
479
  '$argon2id$v=19$m=19456,t=2,p=1$c2lkZS1jaGFubmVsLW1pdGlnYXRpb24$0Hqf2vQKZqSfZZ4nJRr7K5IOjn9ngjzaQjV+yTG6iNY'
480
+
481
+ function validSid(value: unknown): value is string {
482
+ return (
483
+ typeof value === 'string' &&
484
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
485
+ )
486
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /** Native JWT login membership. This is not a browser binding or a bearer credential store. */
10
+ export interface LoginSessionRow {
11
+ id: string
12
+ admin_user_id: string
13
+ session_version: number
14
+ expires_at: Date
15
+ revoked_at: Date | null
16
+ }
17
+
18
+ export interface LoginSessionsRepository {
19
+ /** All writes run under the owning account lock, through its scoped store. */
20
+ create(input: Omit<LoginSessionRow, 'revoked_at'>): Promise<void>
21
+ findById(id: string): Promise<LoginSessionRow | null>
22
+ extend(id: string, expiresAt: Date): Promise<void>
23
+ revoke(id: string, at?: Date): Promise<void>
24
+ revokeAllForUser(adminUserId: string, at?: Date): Promise<void>
25
+ }
@@ -21,6 +21,9 @@ export interface RefreshTokenRow {
21
21
  id: string
22
22
  admin_user_id: string
23
23
  token_hash: string
24
+ /** Null only for legacy rows, which cannot authorize native sessions. */
25
+ sid: string | null
26
+ session_version: number
24
27
  issued_at: Date
25
28
  expires_at: Date
26
29
  revoked_at: Date | null
@@ -31,9 +34,13 @@ export interface RefreshTokenRow {
31
34
  }
32
35
 
33
36
  export interface IssueRefreshTokenInput {
37
+ /** Omitted only by legacy fixtures/imports; never accepted for native renewal. */
38
+ sid?: string | null
34
39
  id: string
35
40
  admin_user_id: string
36
41
  token_hash: string
42
+ /** Account generation observed under the native issuance lock. */
43
+ session_version: number
37
44
  expires_at: Date
38
45
  user_agent?: string | null
39
46
  ip?: string | null
@@ -47,19 +54,15 @@ export interface RefreshTokensRepository {
47
54
  /** Stamp `last_used_at` for observability. */
48
55
  touch(id: string, at?: Date): Promise<void>
49
56
  /**
50
- * Atomically revoke `oldId` and set its `rotated_to_id` to `newId`.
51
- * Caller is responsible for inserting the new row (via `issue`) before
52
- * calling this ordering is a contract.
57
+ * Revoke `oldId` and set its `rotated_to_id` to `newId`. This is not
58
+ * independently a compare-and-swap. Native callers must hold the account
59
+ * lock, reread the predecessor, and insert the successor before this write,
60
+ * all through the same `withSessionLock` transaction.
53
61
  */
54
62
  markRotated(oldId: string, newId: string, at?: Date): Promise<void>
55
63
  /** Revoke a single token. Idempotent. */
56
64
  revoke(id: string, at?: Date): Promise<void>
57
- /**
58
- * Walk the rotation chain starting at `startId` and revoke every token
59
- * in it. Called when a rotated token is replayed — indicates the chain
60
- * has been compromised and every descendant is suspect. Returns the
61
- * number of rows touched.
62
- */
65
+ /** Revoke all refresh rows sharing the start member's sid, without traversal. */
63
66
  revokeChain(startId: string, at?: Date): Promise<number>
64
67
  /** Revoke every non-revoked token for a user. Used on password change / sign-out everywhere. */
65
68
  revokeAllForUser(adminUserId: string, at?: Date): Promise<number>
@@ -9,6 +9,7 @@
9
9
  import { AdminAuth } from '@byline/auth'
10
10
 
11
11
  import type { AdminStore } from '../../store.js'
12
+ import type { AdminUserRow } from '../admin-users/repository.js'
12
13
 
13
14
  /**
14
15
  * Build an `AdminAuth` from a user id by reading the admin-users row and
@@ -29,10 +30,18 @@ export async function resolveActor(
29
30
  adminUserId: string
30
31
  ): Promise<AdminAuth | null> {
31
32
  const user = await store.adminUsers.getById(adminUserId)
33
+ return resolveActorFromUser(store, user)
34
+ }
35
+
36
+ /** Internal helper: reuse the account snapshot already checked by native authentication. */
37
+ export async function resolveActorFromUser(
38
+ store: AdminStore,
39
+ user: AdminUserRow | null
40
+ ): Promise<AdminAuth | null> {
32
41
  if (!user) return null
33
42
  if (!user.is_enabled) return null
34
43
 
35
- const abilities = await store.adminPermissions.listAbilitiesForUser(adminUserId)
44
+ const abilities = await store.adminPermissions.listAbilitiesForUser(user.id)
36
45
 
37
46
  return new AdminAuth({
38
47
  id: user.id,