@byline/admin 4.19.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.
Files changed (57) hide show
  1. package/dist/fields/field-services-context.d.ts +1 -1
  2. package/dist/fields/field-services-types.d.ts +5 -2
  3. package/dist/forms/available-locales-widget.d.ts +3 -2
  4. package/dist/forms/available-locales-widget.js +4 -2
  5. package/dist/forms/document-actions.d.ts +3 -2
  6. package/dist/forms/document-actions.js +32 -14
  7. package/dist/forms/form-renderer.d.ts +12 -3
  8. package/dist/forms/form-renderer.js +98 -8
  9. package/dist/forms/form-renderer.module.js +1 -0
  10. package/dist/forms/form-renderer_module.css +12 -0
  11. package/dist/forms/form-status-display.d.ts +3 -2
  12. package/dist/forms/form-status-display.js +5 -2
  13. package/dist/forms/path-widget.d.ts +2 -1
  14. package/dist/forms/path-widget.js +7 -2
  15. package/dist/forms/scheduled-publication-control.d.ts +2 -1
  16. package/dist/forms/scheduled-publication-control.js +12 -8
  17. package/dist/forms/tree-placement-widget.d.ts +5 -1
  18. package/dist/forms/tree-placement-widget.js +14 -7
  19. package/dist/forms/upload-executor.d.ts +2 -2
  20. package/dist/modules/admin-account/components/change-password.js +18 -0
  21. package/dist/modules/admin-account/service.d.ts +2 -6
  22. package/dist/modules/admin-account/service.js +2 -1
  23. package/dist/modules/admin-users/repository.d.ts +10 -1
  24. package/dist/modules/auth/index.d.ts +3 -1
  25. package/dist/modules/auth/index.js +1 -0
  26. package/dist/modules/auth/jwt-session-provider.d.ts +8 -2
  27. package/dist/modules/auth/jwt-session-provider.js +222 -70
  28. package/dist/modules/auth/login-sessions-repository.d.ts +23 -0
  29. package/dist/modules/auth/login-sessions-repository.js +1 -0
  30. package/dist/modules/auth/refresh-tokens-repository.d.ts +12 -9
  31. package/dist/modules/auth/resolve-actor.d.ts +3 -0
  32. package/dist/modules/auth/resolve-actor.js +5 -2
  33. package/dist/modules/auth/sign-in-rate-limiter.d.ts +48 -0
  34. package/dist/modules/auth/sign-in-rate-limiter.js +229 -0
  35. package/dist/store.d.ts +15 -2
  36. package/package.json +17 -17
  37. package/src/fields/field-services-types.ts +10 -2
  38. package/src/forms/available-locales-widget.tsx +5 -2
  39. package/src/forms/document-actions.tsx +55 -20
  40. package/src/forms/form-renderer-submit.test.tsx +131 -2
  41. package/src/forms/form-renderer.module.css +20 -0
  42. package/src/forms/form-renderer.tsx +122 -7
  43. package/src/forms/form-status-display.tsx +6 -1
  44. package/src/forms/path-widget.tsx +8 -3
  45. package/src/forms/scheduled-publication-control.tsx +16 -7
  46. package/src/forms/tree-placement-widget.tsx +34 -7
  47. package/src/modules/admin-account/components/change-password.test.tsx +72 -0
  48. package/src/modules/admin-account/components/change-password.tsx +15 -4
  49. package/src/modules/admin-account/service.ts +8 -7
  50. package/src/modules/admin-users/repository.ts +10 -1
  51. package/src/modules/auth/index.ts +13 -1
  52. package/src/modules/auth/jwt-session-provider.ts +276 -91
  53. package/src/modules/auth/login-sessions-repository.ts +25 -0
  54. package/src/modules/auth/refresh-tokens-repository.ts +12 -9
  55. package/src/modules/auth/resolve-actor.ts +10 -1
  56. package/src/modules/auth/sign-in-rate-limiter.ts +240 -0
  57. package/src/store.ts +22 -2
@@ -1,9 +1,10 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
- import { ERR_ACCOUNT_DISABLED, ERR_INVALID_CREDENTIALS, ERR_INVALID_TOKEN, ERR_REVOKED_TOKEN } from "@byline/auth";
3
- import { SignJWT, jwtVerify } from "jose";
2
+ import { ERR_ACCESS_EXPIRED, ERR_ACCOUNT_DISABLED, ERR_INVALID_CREDENTIALS, ERR_INVALID_TOKEN, ERR_REVOKED_TOKEN, ERR_SESSION_CHANGED } from "@byline/auth";
3
+ import { passwordSignInSchema } from "@byline/core/validation";
4
+ import { SignJWT, compactVerify, jwtVerify } from "jose";
4
5
  import { v7 } from "uuid";
5
6
  import { verifyPassword } from "./password.js";
6
- import { resolveActor } from "./resolve-actor.js";
7
+ import { resolveActor, resolveActorFromUser } from "./resolve-actor.js";
7
8
  function _check_private_redeclaration(obj, privateCollection) {
8
9
  if (privateCollection.has(obj)) throw new TypeError("Cannot initialize the same private elements twice on an object");
9
10
  }
@@ -51,18 +52,19 @@ const CAPABILITIES = {
51
52
  magicLink: false,
52
53
  sso: false
53
54
  };
54
- var _store = /*#__PURE__*/ new WeakMap(), _signingKey = /*#__PURE__*/ new WeakMap(), _issuer = /*#__PURE__*/ new WeakMap(), _accessTtl = /*#__PURE__*/ new WeakMap(), _refreshTtl = /*#__PURE__*/ new WeakMap(), _now = /*#__PURE__*/ new WeakMap(), _issueTokens = /*#__PURE__*/ new WeakSet(), _signAccessToken = /*#__PURE__*/ new WeakSet();
55
+ var _onEvent = /*#__PURE__*/ new WeakMap(), _store = /*#__PURE__*/ new WeakMap(), _signingKey = /*#__PURE__*/ new WeakMap(), _issuer = /*#__PURE__*/ new WeakMap(), _accessTtl = /*#__PURE__*/ new WeakMap(), _refreshTtl = /*#__PURE__*/ new WeakMap(), _now = /*#__PURE__*/ new WeakMap(), _emit = /*#__PURE__*/ new WeakSet(), _observedLogins = /*#__PURE__*/ new WeakSet(), _issueTokens = /*#__PURE__*/ new WeakSet(), _signAccessToken = /*#__PURE__*/ new WeakSet();
55
56
  class JwtSessionProvider {
56
57
  async signInWithPassword(args) {
58
+ const credentials = passwordSignInSchema.parse(args);
57
59
  const users = _class_private_field_get(this, _store).adminUsers;
58
- const row = await users.getByEmailForSignIn(args.email);
60
+ const row = await users.getByEmailForSignIn(credentials.email);
59
61
  if (!row) {
60
- await verifyPassword(args.password, DUMMY_HASH_FOR_TIMING);
62
+ await verifyPassword(credentials.password, DUMMY_HASH_FOR_TIMING);
61
63
  throw ERR_INVALID_CREDENTIALS({
62
64
  message: 'invalid credentials'
63
65
  });
64
66
  }
65
- const ok = await verifyPassword(args.password, row.password_hash);
67
+ const ok = await verifyPassword(credentials.password, row.password_hash);
66
68
  if (!ok) {
67
69
  await users.recordLoginFailure(row.id);
68
70
  throw ERR_INVALID_CREDENTIALS({
@@ -72,101 +74,196 @@ class JwtSessionProvider {
72
74
  if (!row.is_enabled) throw ERR_ACCOUNT_DISABLED({
73
75
  message: 'account disabled'
74
76
  });
75
- await users.recordLoginSuccess(row.id, args.ip ?? null);
76
- const actor = await resolveActor(_class_private_field_get(this, _store), row.id);
77
- if (!actor) throw ERR_ACCOUNT_DISABLED({
78
- message: 'account disabled'
79
- });
80
- const tokens = await _class_private_method_get(this, _issueTokens, issueTokens).call(this, {
81
- adminUserId: row.id,
82
- ip: args.ip ?? null,
83
- userAgent: args.userAgent ?? null
77
+ const previous = await _class_private_method_get(this, _observedLogins, observedLogins).call(this, args);
78
+ const withLocks = (work)=>previous.length ? _class_private_field_get(this, _store).withSessionLocks([
79
+ row.id,
80
+ ...previous.map((login)=>login.admin_user_id)
81
+ ], work) : _class_private_field_get(this, _store).withSessionLock(row.id, (store)=>work(store));
82
+ return withLocks(async (store)=>{
83
+ const current = await store.adminUsers.getByIdForSignIn(row.id);
84
+ if (!current?.is_enabled) throw ERR_ACCOUNT_DISABLED({
85
+ message: 'account disabled'
86
+ });
87
+ if (current.password_hash !== row.password_hash || current.session_version !== row.session_version) throw ERR_INVALID_CREDENTIALS({
88
+ message: 'credentials changed during sign-in'
89
+ });
90
+ for (const observed of previous){
91
+ const login = await store.loginSessions.findById(observed.id);
92
+ if (login && login.admin_user_id === observed.admin_user_id) await store.loginSessions.revoke(login.id, _class_private_field_get(this, _now).call(this));
93
+ }
94
+ await store.adminUsers.recordLoginSuccess(row.id, args.ip ?? null);
95
+ const actor = await resolveActorFromUser(store, current);
96
+ if (!actor) throw ERR_ACCOUNT_DISABLED({
97
+ message: 'account disabled'
98
+ });
99
+ const tokens = await _class_private_method_get(this, _issueTokens, issueTokens).call(this, store, {
100
+ adminUserId: row.id,
101
+ sessionVersion: current.session_version,
102
+ ip: args.ip ?? null,
103
+ userAgent: args.userAgent ?? null
104
+ });
105
+ return {
106
+ ...tokens,
107
+ actor
108
+ };
84
109
  });
85
- return {
86
- ...tokens,
87
- actor
88
- };
89
110
  }
90
111
  async verifyAccessToken(token) {
91
112
  let payload;
92
113
  try {
93
114
  const result = await jwtVerify(token, _class_private_field_get(this, _signingKey), {
94
- issuer: _class_private_field_get(this, _issuer)
115
+ issuer: _class_private_field_get(this, _issuer),
116
+ algorithms: [
117
+ 'HS256'
118
+ ],
119
+ currentDate: _class_private_field_get(this, _now).call(this),
120
+ requiredClaims: [
121
+ 'sub',
122
+ 'iat',
123
+ 'exp',
124
+ 'jti',
125
+ 'sv',
126
+ 'sid'
127
+ ]
95
128
  });
96
129
  payload = result.payload;
97
130
  } catch (err) {
131
+ if (err instanceof Error && 'code' in err && 'ERR_JWT_EXPIRED' === err.code) throw ERR_ACCESS_EXPIRED({
132
+ message: 'access token expired'
133
+ });
98
134
  throw ERR_INVALID_TOKEN({
99
135
  message: 'access token verification failed',
100
136
  cause: err
101
137
  });
102
138
  }
103
- if ('access' !== payload.typ) throw ERR_INVALID_TOKEN({
139
+ if ('access' !== payload.typ || 'string' != typeof payload.sub || !validSid(payload.sid) || !Number.isSafeInteger(payload.sv) || payload.sv < 0) throw ERR_INVALID_TOKEN({
104
140
  message: 'unexpected token type'
105
141
  });
106
- const actor = await resolveActor(_class_private_field_get(this, _store), payload.sub);
142
+ const current = await _class_private_field_get(this, _store).adminUsers.getByIdForSignIn(payload.sub);
143
+ if (!current?.is_enabled) throw ERR_ACCOUNT_DISABLED({
144
+ message: 'account disabled or deleted'
145
+ });
146
+ if (current.session_version !== payload.sv) throw ERR_REVOKED_TOKEN({
147
+ message: 'account session generation changed'
148
+ });
149
+ const login = await _class_private_field_get(this, _store).loginSessions.findById(payload.sid);
150
+ if (!login || login.admin_user_id !== payload.sub || login.session_version !== payload.sv || null != login.revoked_at || login.expires_at.getTime() <= _class_private_field_get(this, _now).call(this).getTime()) throw ERR_REVOKED_TOKEN({
151
+ message: 'login revoked or expired'
152
+ });
153
+ const actor = await resolveActorFromUser(_class_private_field_get(this, _store), current);
107
154
  if (!actor) throw ERR_ACCOUNT_DISABLED({
108
155
  message: 'account disabled or deleted'
109
156
  });
110
157
  return {
111
- actor
158
+ actor,
159
+ sessionId: payload.sid
112
160
  };
113
161
  }
114
162
  async refreshSession(args) {
115
- const refreshTokens = _class_private_field_get(this, _store).refreshTokens;
163
+ _class_private_method_get(this, _emit, emit).call(this, 'refresh_attempted');
116
164
  const hash = hashToken(args.refreshToken);
117
- const row = await refreshTokens.findByHash(hash);
118
- if (!row) throw ERR_INVALID_TOKEN({
165
+ const observed = await _class_private_field_get(this, _store).refreshTokens.findByHash(hash);
166
+ if (!observed) throw ERR_INVALID_TOKEN({
119
167
  message: 'refresh token not recognised'
120
168
  });
121
- const now = _class_private_field_get(this, _now).call(this);
122
- if (null != row.revoked_at) {
123
- if (null != row.rotated_to_id) {
124
- await refreshTokens.revokeChain(row.id, now);
125
- throw ERR_REVOKED_TOKEN({
126
- message: 'refresh token was already rotated — chain revoked'
127
- });
169
+ const outcome = await _class_private_field_get(this, _store).withSessionLock(observed.admin_user_id, async (store, user)=>{
170
+ if (!user?.is_enabled) throw ERR_ACCOUNT_DISABLED({
171
+ message: 'account disabled or deleted'
172
+ });
173
+ const refreshTokens = store.refreshTokens;
174
+ const row = await refreshTokens.findByHash(hash);
175
+ if (!row) throw ERR_INVALID_TOKEN({
176
+ message: 'refresh token not recognised'
177
+ });
178
+ const now = _class_private_field_get(this, _now).call(this);
179
+ const login = row.sid ? await store.loginSessions.findById(row.sid) : null;
180
+ if (!login || login.admin_user_id !== user.id || login.session_version !== user.session_version || null != login.revoked_at) throw ERR_REVOKED_TOKEN({
181
+ message: 'login revoked'
182
+ });
183
+ if (args.expectedSessionId && args.expectedSessionId !== login.id) throw ERR_SESSION_CHANGED({
184
+ message: 'session changed before renewal'
185
+ });
186
+ if (null != row.revoked_at) {
187
+ if (null != row.rotated_to_id) {
188
+ await store.loginSessions.revoke(login.id, now);
189
+ await refreshTokens.revokeChain(row.id, now);
190
+ }
191
+ return {
192
+ contested: null != row.rotated_to_id,
193
+ error: ERR_REVOKED_TOKEN({
194
+ message: 'refresh token has been revoked'
195
+ })
196
+ };
128
197
  }
129
- throw ERR_REVOKED_TOKEN({
130
- message: 'refresh token has been revoked'
198
+ if (row.session_version !== user.session_version) throw ERR_REVOKED_TOKEN({
199
+ message: 'account session generation changed'
200
+ });
201
+ if (row.expires_at.getTime() <= now.getTime() || login.expires_at.getTime() <= now.getTime()) throw ERR_INVALID_TOKEN({
202
+ message: 'refresh token expired'
203
+ });
204
+ const newId = v7();
205
+ const newRefreshPlain = generateOpaqueToken();
206
+ const refreshExpiresAt = new Date(now.getTime() + 1000 * _class_private_field_get(this, _refreshTtl));
207
+ await refreshTokens.issue({
208
+ id: newId,
209
+ admin_user_id: row.admin_user_id,
210
+ token_hash: hashToken(newRefreshPlain),
211
+ session_version: user.session_version,
212
+ sid: login.id,
213
+ expires_at: refreshExpiresAt,
214
+ user_agent: args.userAgent ?? null,
215
+ ip: args.ip ?? null
131
216
  });
217
+ await store.loginSessions.extend(login.id, refreshExpiresAt);
218
+ await refreshTokens.markRotated(row.id, newId, now);
219
+ const accessToken = await _class_private_method_get(this, _signAccessToken, signAccessToken).call(this, row.admin_user_id, user.session_version, login.id, now);
220
+ return {
221
+ tokens: {
222
+ sessionId: login.id,
223
+ accessToken,
224
+ refreshToken: newRefreshPlain,
225
+ accessTokenExpiresAt: new Date(now.getTime() + 1000 * _class_private_field_get(this, _accessTtl)),
226
+ refreshTokenExpiresAt: refreshExpiresAt
227
+ }
228
+ };
229
+ });
230
+ if ('error' in outcome) {
231
+ if (outcome.contested) _class_private_method_get(this, _emit, emit).call(this, 'refresh_contested');
232
+ throw outcome.error;
132
233
  }
133
- if (row.expires_at.getTime() <= now.getTime()) throw ERR_INVALID_TOKEN({
134
- message: 'refresh token expired'
135
- });
136
- const newId = v7();
137
- const newRefreshPlain = generateOpaqueToken();
138
- const newRefreshHash = hashToken(newRefreshPlain);
139
- const refreshExpiresAt = new Date(now.getTime() + 1000 * _class_private_field_get(this, _refreshTtl));
140
- await refreshTokens.issue({
141
- id: newId,
142
- admin_user_id: row.admin_user_id,
143
- token_hash: newRefreshHash,
144
- expires_at: refreshExpiresAt,
145
- user_agent: args.userAgent ?? null,
146
- ip: args.ip ?? null
147
- });
148
- await refreshTokens.markRotated(row.id, newId, now);
149
- const accessToken = await _class_private_method_get(this, _signAccessToken, signAccessToken).call(this, row.admin_user_id, now);
150
- const accessExpiresAt = new Date(now.getTime() + 1000 * _class_private_field_get(this, _accessTtl));
151
- return {
152
- accessToken,
153
- refreshToken: newRefreshPlain,
154
- accessTokenExpiresAt: accessExpiresAt,
155
- refreshTokenExpiresAt: refreshExpiresAt
156
- };
234
+ _class_private_method_get(this, _emit, emit).call(this, 'refresh_completed');
235
+ return outcome.tokens;
157
236
  }
158
- async revokeSession(refreshToken) {
159
- const refreshTokens = _class_private_field_get(this, _store).refreshTokens;
160
- const row = await refreshTokens.findByHash(hashToken(refreshToken));
161
- if (!row) return;
162
- await refreshTokens.revoke(row.id, _class_private_field_get(this, _now).call(this));
237
+ async revokeSession(args) {
238
+ const logins = await _class_private_method_get(this, _observedLogins, observedLogins).call(this, {
239
+ previousAccessToken: args.accessToken,
240
+ previousRefreshToken: args.refreshToken
241
+ });
242
+ if (args.expectedSessionId && logins.some((login)=>login.id !== args.expectedSessionId)) throw ERR_SESSION_CHANGED({
243
+ message: 'session changed before logout'
244
+ });
245
+ for (const login of logins)await _class_private_field_get(this, _store).withSessionLock(login.admin_user_id, async (store)=>{
246
+ await store.loginSessions.revoke(login.id, _class_private_field_get(this, _now).call(this));
247
+ });
248
+ if (args.refreshToken) {
249
+ const row = await _class_private_field_get(this, _store).refreshTokens.findByHash(hashToken(args.refreshToken));
250
+ if (row) await _class_private_field_get(this, _store).withSessionLock(row.admin_user_id, async (store)=>{
251
+ await store.refreshTokens.revokeChain(row.id, _class_private_field_get(this, _now).call(this));
252
+ });
253
+ }
163
254
  }
164
255
  async resolveActor(adminUserId) {
165
256
  return resolveActor(_class_private_field_get(this, _store), adminUserId);
166
257
  }
167
258
  constructor(config){
259
+ _class_private_method_init(this, _emit);
260
+ _class_private_method_init(this, _observedLogins);
168
261
  _class_private_method_init(this, _issueTokens);
169
262
  _class_private_method_init(this, _signAccessToken);
263
+ _class_private_field_init(this, _onEvent, {
264
+ writable: true,
265
+ value: void 0
266
+ });
170
267
  _class_private_field_init(this, _store, {
171
268
  writable: true,
172
269
  value: void 0
@@ -192,6 +289,7 @@ class JwtSessionProvider {
192
289
  value: void 0
193
290
  });
194
291
  this.capabilities = CAPABILITIES;
292
+ _class_private_field_set(this, _onEvent, config.onEvent);
195
293
  _class_private_field_set(this, _store, config.store);
196
294
  _class_private_field_set(this, _signingKey, 'string' == typeof config.signingSecret ? new TextEncoder().encode(config.signingSecret) : config.signingSecret);
197
295
  if (_class_private_field_get(this, _signingKey).byteLength < 32) throw new Error('JwtSessionProvider: signingSecret must carry at least 32 bytes of entropy (256 bits)');
@@ -201,34 +299,85 @@ class JwtSessionProvider {
201
299
  _class_private_field_set(this, _now, config.now ?? (()=>new Date()));
202
300
  }
203
301
  }
204
- async function issueTokens(input) {
302
+ function emit(type) {
303
+ var _this, _this1;
304
+ try {
305
+ null == (_this = _class_private_field_get(_this1 = this, _onEvent)) || _this.call(_this1, {
306
+ type
307
+ });
308
+ } catch {}
309
+ }
310
+ async function observedLogins(args) {
311
+ const ids = new Set();
312
+ if (args.previousAccessToken) {
313
+ let claims;
314
+ try {
315
+ const verified = await compactVerify(args.previousAccessToken, _class_private_field_get(this, _signingKey), {
316
+ algorithms: [
317
+ 'HS256'
318
+ ]
319
+ });
320
+ claims = JSON.parse(new TextDecoder().decode(verified.payload));
321
+ } catch {}
322
+ if (claims?.iss === _class_private_field_get(this, _issuer) && 'access' === claims.typ && validSid(claims.sid) && 'string' == typeof claims.sub) {
323
+ const login = await _class_private_field_get(this, _store).loginSessions.findById(claims.sid);
324
+ if (login?.admin_user_id === claims.sub) ids.add(login.id);
325
+ }
326
+ }
327
+ if (args.previousRefreshToken) {
328
+ const token = await _class_private_field_get(this, _store).refreshTokens.findByHash(hashToken(args.previousRefreshToken));
329
+ if (token?.sid) {
330
+ const login = await _class_private_field_get(this, _store).loginSessions.findById(token.sid);
331
+ if (login?.admin_user_id === token.admin_user_id) ids.add(login.id);
332
+ }
333
+ }
334
+ const result = [];
335
+ for (const id of ids){
336
+ const login = await _class_private_field_get(this, _store).loginSessions.findById(id);
337
+ if (login) result.push(login);
338
+ }
339
+ return result;
340
+ }
341
+ async function issueTokens(store, input) {
205
342
  const now = _class_private_field_get(this, _now).call(this);
206
- const refreshTokens = _class_private_field_get(this, _store).refreshTokens;
207
- const accessToken = await _class_private_method_get(this, _signAccessToken, signAccessToken).call(this, input.adminUserId, now);
343
+ const refreshTokens = store.refreshTokens;
344
+ const sessionId = randomUUID();
345
+ const accessToken = await _class_private_method_get(this, _signAccessToken, signAccessToken).call(this, input.adminUserId, input.sessionVersion, sessionId, now);
208
346
  const accessExpiresAt = new Date(now.getTime() + 1000 * _class_private_field_get(this, _accessTtl));
209
347
  const refreshPlain = generateOpaqueToken();
210
348
  const refreshHash = hashToken(refreshPlain);
211
349
  const refreshExpiresAt = new Date(now.getTime() + 1000 * _class_private_field_get(this, _refreshTtl));
350
+ await store.loginSessions.create({
351
+ id: sessionId,
352
+ admin_user_id: input.adminUserId,
353
+ session_version: input.sessionVersion,
354
+ expires_at: refreshExpiresAt
355
+ });
212
356
  await refreshTokens.issue({
213
357
  id: v7(),
214
358
  admin_user_id: input.adminUserId,
215
359
  token_hash: refreshHash,
360
+ session_version: input.sessionVersion,
361
+ sid: sessionId,
216
362
  expires_at: refreshExpiresAt,
217
363
  user_agent: input.userAgent,
218
364
  ip: input.ip
219
365
  });
220
366
  return {
367
+ sessionId,
221
368
  accessToken,
222
369
  refreshToken: refreshPlain,
223
370
  accessTokenExpiresAt: accessExpiresAt,
224
371
  refreshTokenExpiresAt: refreshExpiresAt
225
372
  };
226
373
  }
227
- async function signAccessToken(adminUserId, now) {
374
+ async function signAccessToken(adminUserId, sessionVersion, sessionId, now) {
228
375
  const iat = Math.floor(now.getTime() / 1000);
229
376
  const exp = iat + _class_private_field_get(this, _accessTtl);
230
377
  return new SignJWT({
231
- typ: 'access'
378
+ typ: 'access',
379
+ sv: sessionVersion,
380
+ sid: sessionId
232
381
  }).setProtectedHeader({
233
382
  alg: 'HS256',
234
383
  typ: 'JWT'
@@ -241,4 +390,7 @@ function hashToken(token) {
241
390
  return createHash('sha256').update(token).digest('hex');
242
391
  }
243
392
  const DUMMY_HASH_FOR_TIMING = '$argon2id$v=19$m=19456,t=2,p=1$c2lkZS1jaGFubmVsLW1pdGlnYXRpb24$0Hqf2vQKZqSfZZ4nJRr7K5IOjn9ngjzaQjV+yTG6iNY';
393
+ function validSid(value) {
394
+ return 'string' == typeof value && /^[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);
395
+ }
244
396
  export { JwtSessionProvider };
@@ -0,0 +1,23 @@
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
+ /** Native JWT login membership. This is not a browser binding or a bearer credential store. */
9
+ export interface LoginSessionRow {
10
+ id: string;
11
+ admin_user_id: string;
12
+ session_version: number;
13
+ expires_at: Date;
14
+ revoked_at: Date | null;
15
+ }
16
+ export interface LoginSessionsRepository {
17
+ /** All writes run under the owning account lock, through its scoped store. */
18
+ create(input: Omit<LoginSessionRow, 'revoked_at'>): Promise<void>;
19
+ findById(id: string): Promise<LoginSessionRow | null>;
20
+ extend(id: string, expiresAt: Date): Promise<void>;
21
+ revoke(id: string, at?: Date): Promise<void>;
22
+ revokeAllForUser(adminUserId: string, at?: Date): Promise<void>;
23
+ }
@@ -0,0 +1 @@
1
+ export { };
@@ -19,6 +19,9 @@ export interface RefreshTokenRow {
19
19
  id: string;
20
20
  admin_user_id: string;
21
21
  token_hash: string;
22
+ /** Null only for legacy rows, which cannot authorize native sessions. */
23
+ sid: string | null;
24
+ session_version: number;
22
25
  issued_at: Date;
23
26
  expires_at: Date;
24
27
  revoked_at: Date | null;
@@ -28,9 +31,13 @@ export interface RefreshTokenRow {
28
31
  ip: string | null;
29
32
  }
30
33
  export interface IssueRefreshTokenInput {
34
+ /** Omitted only by legacy fixtures/imports; never accepted for native renewal. */
35
+ sid?: string | null;
31
36
  id: string;
32
37
  admin_user_id: string;
33
38
  token_hash: string;
39
+ /** Account generation observed under the native issuance lock. */
40
+ session_version: number;
34
41
  expires_at: Date;
35
42
  user_agent?: string | null;
36
43
  ip?: string | null;
@@ -43,19 +50,15 @@ export interface RefreshTokensRepository {
43
50
  /** Stamp `last_used_at` for observability. */
44
51
  touch(id: string, at?: Date): Promise<void>;
45
52
  /**
46
- * Atomically revoke `oldId` and set its `rotated_to_id` to `newId`.
47
- * Caller is responsible for inserting the new row (via `issue`) before
48
- * calling this ordering is a contract.
53
+ * Revoke `oldId` and set its `rotated_to_id` to `newId`. This is not
54
+ * independently a compare-and-swap. Native callers must hold the account
55
+ * lock, reread the predecessor, and insert the successor before this write,
56
+ * all through the same `withSessionLock` transaction.
49
57
  */
50
58
  markRotated(oldId: string, newId: string, at?: Date): Promise<void>;
51
59
  /** Revoke a single token. Idempotent. */
52
60
  revoke(id: string, at?: Date): Promise<void>;
53
- /**
54
- * Walk the rotation chain starting at `startId` and revoke every token
55
- * in it. Called when a rotated token is replayed — indicates the chain
56
- * has been compromised and every descendant is suspect. Returns the
57
- * number of rows touched.
58
- */
61
+ /** Revoke all refresh rows sharing the start member's sid, without traversal. */
59
62
  revokeChain(startId: string, at?: Date): Promise<number>;
60
63
  /** Revoke every non-revoked token for a user. Used on password change / sign-out everywhere. */
61
64
  revokeAllForUser(adminUserId: string, at?: Date): Promise<number>;
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { AdminAuth } from '@byline/auth';
9
9
  import type { AdminStore } from '../../store.js';
10
+ import type { AdminUserRow } from '../admin-users/repository.js';
10
11
  /**
11
12
  * Build an `AdminAuth` from a user id by reading the admin-users row and
12
13
  * collecting the distinct abilities granted through every role the user
@@ -22,3 +23,5 @@ import type { AdminStore } from '../../store.js';
22
23
  * `AdminStore` bundle; adapter-agnostic.
23
24
  */
24
25
  export declare function resolveActor(store: AdminStore, adminUserId: string): Promise<AdminAuth | null>;
26
+ /** Internal helper: reuse the account snapshot already checked by native authentication. */
27
+ export declare function resolveActorFromUser(store: AdminStore, user: AdminUserRow | null): Promise<AdminAuth | null>;
@@ -1,13 +1,16 @@
1
1
  import { AdminAuth } from "@byline/auth";
2
2
  async function resolveActor(store, adminUserId) {
3
3
  const user = await store.adminUsers.getById(adminUserId);
4
+ return resolveActorFromUser(store, user);
5
+ }
6
+ async function resolveActorFromUser(store, user) {
4
7
  if (!user) return null;
5
8
  if (!user.is_enabled) return null;
6
- const abilities = await store.adminPermissions.listAbilitiesForUser(adminUserId);
9
+ const abilities = await store.adminPermissions.listAbilitiesForUser(user.id);
7
10
  return new AdminAuth({
8
11
  id: user.id,
9
12
  abilities,
10
13
  isSuperAdmin: user.is_super_admin
11
14
  });
12
15
  }
13
- export { resolveActor };
16
+ export { resolveActor, resolveActorFromUser };
@@ -0,0 +1,48 @@
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
+ import type { PasswordSignInLimiter } from '@byline/auth';
9
+ import type { RecurringTaskDefinition } from '@byline/core';
10
+ export interface SignInRateLimitStore {
11
+ /** Atomically increment, saturating at limit + 1; true only for the first limit calls. */
12
+ consume(key: string, limit: number, expiresAt: Date): Promise<boolean>;
13
+ /** Remove at most 100 expired counters and return the number removed. */
14
+ purgeExpired(before: Date): Promise<number>;
15
+ }
16
+ export interface SignInRateLimitPolicy {
17
+ account: {
18
+ limit: number;
19
+ windowSeconds: number;
20
+ };
21
+ ip: {
22
+ limit: number;
23
+ windowSeconds: number;
24
+ };
25
+ }
26
+ export interface SignInSecurityEvent {
27
+ type: 'admitted' | 'denied' | 'capacity' | 'store-error' | 'cleanup-error' | 'success' | 'failure';
28
+ scope?: 'account' | 'ip';
29
+ /** Stable, pseudonymous digests for cross-network correlation. Never passwords or raw IPs. */
30
+ account?: string;
31
+ network?: string;
32
+ }
33
+ export interface SignInLimiterOptions {
34
+ onEvent?: (event: SignInSecurityEvent) => void;
35
+ slots?: number;
36
+ maxQueue?: number;
37
+ queueTimeoutMs?: number;
38
+ /** Also bounds counter creation when the provider or a denial completes very quickly. */
39
+ minimumSlotMs?: number;
40
+ }
41
+ /**
42
+ * Instantiate once per process. Acquire before consume and release in finally after verification.
43
+ * Network admission precedes account-plus-network admission. There is no account-wide lockout.
44
+ */
45
+ export declare function createPasswordSignInLimiter(store: SignInRateLimitStore, secret: string | Uint8Array, policy?: SignInRateLimitPolicy, now?: () => number, options?: SignInLimiterOptions): PasswordSignInLimiter & {
46
+ cleanupTask: RecurringTaskDefinition;
47
+ dispose(): void;
48
+ };