@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
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import { type ReactNode } from 'react';
9
- import type { BylineFieldServices } from './field-services-types';
9
+ import type { BylineFieldServices } from './field-services-types.js';
10
10
  interface BylineFieldServicesProviderProps {
11
11
  services: BylineFieldServices;
12
12
  children: ReactNode;
@@ -1,4 +1,4 @@
1
- export { type ReconciledLocaleState, reconcileLocaleState } from './available-locales-reconcile';
1
+ export { type ReconciledLocaleState, reconcileLocaleState } from './available-locales-reconcile.js';
2
2
  /** A content locale to render a checkbox for. */
3
3
  export interface AvailableLocalesWidgetLocale {
4
4
  code: string;
@@ -1,4 +1,4 @@
1
- import type { PublishedVersionInfo } from './form-renderer';
1
+ import type { PublishedVersionInfo } from './form-renderer.js';
2
2
  import type { ScheduledPublicationState } from './scheduled-publication-state.js';
3
3
  /**
4
4
  * Shape of a content-locale option as consumed by the Copy-to-Locale
@@ -8,9 +8,9 @@
8
8
  import { type ReactNode } from 'react';
9
9
  import type { Field, FormAdminConfig, WorkflowStatus } from '@byline/core';
10
10
  import type { DocumentPatch } from '@byline/core/patches';
11
- import { type DocumentActionsLocaleOption } from './document-actions';
12
- import { type ScheduledPublicationInfo, type SchedulePublicationInput } from './scheduled-publication-control';
13
- import type { UseNavigationGuard } from './navigation-guard';
11
+ import { type DocumentActionsLocaleOption } from './document-actions.js';
12
+ import { type ScheduledPublicationInfo, type SchedulePublicationInput } from './scheduled-publication-control.js';
13
+ import type { UseNavigationGuard } from './navigation-guard.js';
14
14
  /** Metadata about a previously published version that is still live. */
15
15
  export interface PublishedVersionInfo {
16
16
  id: string;
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type { WorkflowStatus } from '@byline/core';
9
- import type { PublishedVersionInfo } from './form-renderer';
9
+ import type { PublishedVersionInfo } from './form-renderer.js';
10
10
  /**
11
11
  * The status / metadata strip at the top of a document form — current
12
12
  * workflow status (suppressed for single-status workflows), last-modified and
@@ -13,8 +13,8 @@
13
13
  * but only uploaded when the user clicks Save.
14
14
  */
15
15
  import type { Field, StoredFileValue } from '@byline/core';
16
- import type { UploadFieldFn } from '../fields/field-services-types';
17
- import type { PendingUpload } from './form-context';
16
+ import type { UploadFieldFn } from '../fields/field-services-types.js';
17
+ import type { PendingUpload } from './form-context.js';
18
18
  export interface UploadResult {
19
19
  fieldPath: string;
20
20
  success: boolean;
@@ -84,6 +84,24 @@ function ChangeAccountPassword({ account, onClose, onSuccess }) {
84
84
  }
85
85
  }
86
86
  });
87
+ if (successMessage) return /*#__PURE__*/ jsxs("div", {
88
+ className: clsx('byline-account-change-password-wrap', change_password_module.wrap),
89
+ children: [
90
+ /*#__PURE__*/ jsx("div", {
91
+ role: "status",
92
+ children: /*#__PURE__*/ jsx(Alert, {
93
+ intent: "success",
94
+ children: successMessage
95
+ })
96
+ }),
97
+ /*#__PURE__*/ jsx(Button, {
98
+ type: "button",
99
+ intent: "primary",
100
+ onClick: ()=>window.location.reload(),
101
+ children: t('auth.signIn.title')
102
+ })
103
+ ]
104
+ });
87
105
  return /*#__PURE__*/ jsx("div", {
88
106
  className: clsx('byline-account-change-password-wrap', change_password_module.wrap),
89
107
  children: /*#__PURE__*/ jsxs("form", {
@@ -25,12 +25,8 @@ import type { AccountResponse, ChangeAccountPasswordRequest, UpdateAccountReques
25
25
  * in the new hash. A hijacked session cannot use this flow to lock
26
26
  * out the legitimate owner.
27
27
  *
28
- * Note on session revocation: changing a password here does **not**
29
- * currently revoke other refresh tokens existing access tokens stay
30
- * valid until their 15-minute expiry, and other refresh tokens remain
31
- * useable. A "sign out everywhere on password change" follow-up should
32
- * call `RefreshTokensRepository.revokeAllExcept(adminUserId, currentJti)`
33
- * once that lands.
28
+ * Native adapter password writes atomically advance the session generation and
29
+ * revoke every refresh session. External providers own their own revocation.
34
30
  */
35
31
  export declare class AdminAccountService {
36
32
  #private;
@@ -1,5 +1,5 @@
1
1
  import { toAdminUser } from "../admin-users/dto.js";
2
- import { ERR_ADMIN_USER_EMAIL_IN_USE } from "../admin-users/errors.js";
2
+ import { ERR_ADMIN_USER_EMAIL_IN_USE, ERR_ADMIN_USER_VERSION_CONFLICT } from "../admin-users/errors.js";
3
3
  import { hashPassword, verifyPassword } from "../auth/password.js";
4
4
  import { ERR_ADMIN_ACCOUNT_INVALID_CURRENT_PASSWORD, ERR_ADMIN_ACCOUNT_NOT_FOUND } from "./errors.js";
5
5
  function _check_private_redeclaration(obj, privateCollection) {
@@ -63,6 +63,7 @@ class AdminAccountService {
63
63
  if (!withHash) throw ERR_ADMIN_ACCOUNT_NOT_FOUND();
64
64
  const ok = await verifyPassword(request.currentPassword, withHash.password_hash);
65
65
  if (!ok) throw ERR_ADMIN_ACCOUNT_INVALID_CURRENT_PASSWORD();
66
+ if (withHash.vid !== request.vid) throw ERR_ADMIN_USER_VERSION_CONFLICT();
66
67
  const newHash = await hashPassword(request.newPassword);
67
68
  const row = await _class_private_field_get(this, _repo).setPasswordHash(actorId, request.vid, newHash);
68
69
  return toAdminUser(row);
@@ -65,6 +65,8 @@ export interface AdminUserRow {
65
65
  */
66
66
  export interface AdminUserWithPasswordRow extends AdminUserRow {
67
67
  password_hash: string;
68
+ /** Native session generation, independent of edit revisions. */
69
+ session_version: number;
68
70
  }
69
71
  export interface CreateAdminUserInput {
70
72
  email: string;
@@ -143,16 +145,23 @@ export interface AdminUsersRepository {
143
145
  * Content update with optimistic concurrency. Throws
144
146
  * `AdminUsersError(VERSION_CONFLICT)` if the stored `vid` differs from
145
147
  * `expectedVid`. Bumps `vid` on success and returns the fresh row.
148
+ * A false `is_enabled` patch must atomically advance the native session
149
+ * generation and revoke refresh sessions, under the account row lock.
146
150
  */
147
151
  update(id: string, expectedVid: number, patch: UpdateAdminUserInput): Promise<AdminUserRow>;
148
152
  /**
149
153
  * Replace the stored password hash with optimistic concurrency.
150
154
  * Version-gated on `expectedVid`. Caller supplies a pre-hashed PHC string.
155
+ * Atomically advance the native session generation and revoke every refresh
156
+ * session in the same transaction. Lock the account before refresh rows.
151
157
  * Returns the updated row so callers holding the edit form can refresh
152
158
  * their cached `vid` without a second round-trip.
153
159
  */
154
160
  setPasswordHash(id: string, expectedVid: number, passwordHash: string): Promise<AdminUserRow>;
155
- /** Toggle enabled state. Vid-less — admin intent is independent of other edits. */
161
+ /**
162
+ * Toggle enabled state. Disable must atomically advance the native session
163
+ * generation and revoke refresh sessions. Enable never resets the generation.
164
+ */
156
165
  setEnabled(id: string, enabled: boolean): Promise<void>;
157
166
  /**
158
167
  * Set the admin interface locale preference. Vid-less — user preference
@@ -19,7 +19,9 @@
19
19
  * Clerk, institutional SSO) should be shipped as separate packages
20
20
  * against `@byline/auth` rather than added here.
21
21
  */
22
- export { JwtSessionProvider, type JwtSessionProviderConfig } from './jwt-session-provider.js';
22
+ export { JwtSessionProvider, type JwtSessionProviderConfig, type NativeSessionEvent, } from './jwt-session-provider.js';
23
23
  export { hashPassword, verifyPassword } from './password.js';
24
24
  export { resolveActor } from './resolve-actor.js';
25
+ export { createPasswordSignInLimiter, type SignInLimiterOptions, type SignInRateLimitPolicy, type SignInRateLimitStore, type SignInSecurityEvent, } from './sign-in-rate-limiter.js';
26
+ export type { LoginSessionRow, LoginSessionsRepository } from './login-sessions-repository.js';
25
27
  export type { IssueRefreshTokenInput, RefreshTokenRow, RefreshTokensRepository, } from './refresh-tokens-repository.js';
@@ -1,3 +1,4 @@
1
1
  export { JwtSessionProvider } from "./jwt-session-provider.js";
2
2
  export { hashPassword, verifyPassword } from "./password.js";
3
3
  export { resolveActor } from "./resolve-actor.js";
4
+ export { createPasswordSignInLimiter } from "./sign-in-rate-limiter.js";
@@ -5,9 +5,14 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { type AdminAuth, type RefreshSessionArgs, type SessionProvider, type SessionProviderCapabilities, type SessionTokens, type SignInResult, type SignInWithPasswordArgs } from '@byline/auth';
8
+ import { type AdminAuth, type RefreshSessionArgs, type RevokeSessionArgs, type SessionProvider, type SessionProviderCapabilities, type SessionTokens, type SignInResult, type SignInWithPasswordArgs } from '@byline/auth';
9
9
  import type { AdminStore } from '../../store.js';
10
+ export interface NativeSessionEvent {
11
+ type: 'refresh_attempted' | 'refresh_completed' | 'refresh_contested';
12
+ }
10
13
  export interface JwtSessionProviderConfig {
14
+ /** Best-effort, token-free renewal telemetry. Errors never change authentication outcomes. */
15
+ onEvent?: (event: NativeSessionEvent) => void;
11
16
  /**
12
17
  * Adapter-backed admin repositories. Construct via the DB adapter's
13
18
  * admin-store factory (e.g. `createAdminStore(db)` from
@@ -39,8 +44,9 @@ export declare class JwtSessionProvider implements SessionProvider {
39
44
  signInWithPassword(args: SignInWithPasswordArgs): Promise<SignInResult>;
40
45
  verifyAccessToken(token: string): Promise<{
41
46
  actor: AdminAuth;
47
+ sessionId: string;
42
48
  }>;
43
49
  refreshSession(args: RefreshSessionArgs): Promise<SessionTokens>;
44
- revokeSession(refreshToken: string): Promise<void>;
50
+ revokeSession(args: RevokeSessionArgs): Promise<void>;
45
51
  resolveActor(adminUserId: string): Promise<AdminAuth | null>;
46
52
  }
@@ -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 { };