@byline/admin 5.0.0 → 5.1.0
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/dist/fields/field-services-context.d.ts +1 -1
- package/dist/forms/available-locales-widget.d.ts +1 -1
- package/dist/forms/document-actions.d.ts +1 -1
- package/dist/forms/form-renderer.d.ts +3 -3
- package/dist/forms/form-status-display.d.ts +1 -1
- package/dist/forms/upload-executor.d.ts +2 -2
- package/dist/modules/admin-account/components/change-password.js +18 -0
- package/dist/modules/admin-account/service.d.ts +2 -6
- package/dist/modules/admin-account/service.js +2 -1
- package/dist/modules/admin-users/repository.d.ts +10 -1
- package/dist/modules/auth/index.d.ts +3 -1
- package/dist/modules/auth/index.js +1 -0
- package/dist/modules/auth/jwt-session-provider.d.ts +8 -2
- package/dist/modules/auth/jwt-session-provider.js +222 -70
- package/dist/modules/auth/login-sessions-repository.d.ts +23 -0
- package/dist/modules/auth/login-sessions-repository.js +1 -0
- package/dist/modules/auth/refresh-tokens-repository.d.ts +12 -9
- package/dist/modules/auth/resolve-actor.d.ts +3 -0
- package/dist/modules/auth/resolve-actor.js +5 -2
- package/dist/modules/auth/sign-in-rate-limiter.d.ts +48 -0
- package/dist/modules/auth/sign-in-rate-limiter.js +229 -0
- package/dist/store.d.ts +15 -2
- package/package.json +17 -17
- package/src/modules/admin-account/components/change-password.test.tsx +72 -0
- package/src/modules/admin-account/components/change-password.tsx +15 -4
- package/src/modules/admin-account/service.ts +8 -7
- package/src/modules/admin-users/repository.ts +10 -1
- package/src/modules/auth/index.ts +13 -1
- package/src/modules/auth/jwt-session-provider.ts +276 -91
- package/src/modules/auth/login-sessions-repository.ts +25 -0
- package/src/modules/auth/refresh-tokens-repository.ts +12 -9
- package/src/modules/auth/resolve-actor.ts +10 -1
- package/src/modules/auth/sign-in-rate-limiter.ts +240 -0
- 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 {
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 (
|
|
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
|
|
156
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
222
|
+
this.#emit('refresh_attempted')
|
|
166
223
|
const hash = hashToken(args.refreshToken)
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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(
|
|
44
|
+
const abilities = await store.adminPermissions.listAbilitiesForUser(user.id)
|
|
36
45
|
|
|
37
46
|
return new AdminAuth({
|
|
38
47
|
id: user.id,
|