@shipfox/api-auth 10.1.0 → 10.2.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 (70) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +16 -0
  3. package/README.md +15 -0
  4. package/dist/core/administration.d.ts +17 -1
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +107 -14
  7. package/dist/core/administration.js.map +1 -1
  8. package/dist/core/auth.d.ts.map +1 -1
  9. package/dist/core/auth.js +11 -2
  10. package/dist/core/auth.js.map +1 -1
  11. package/dist/db/admin-command.d.ts +18 -0
  12. package/dist/db/admin-command.d.ts.map +1 -0
  13. package/dist/db/admin-command.js +37 -0
  14. package/dist/db/admin-command.js.map +1 -0
  15. package/dist/db/admin-grants.d.ts +1 -1
  16. package/dist/db/admin-grants.d.ts.map +1 -1
  17. package/dist/db/admin-grants.js +12 -30
  18. package/dist/db/admin-grants.js.map +1 -1
  19. package/dist/db/admin-user-moderation.d.ts +29 -0
  20. package/dist/db/admin-user-moderation.d.ts.map +1 -0
  21. package/dist/db/admin-user-moderation.js +156 -0
  22. package/dist/db/admin-user-moderation.js.map +1 -0
  23. package/dist/db/admin-user-summary.d.ts +14 -0
  24. package/dist/db/admin-user-summary.d.ts.map +1 -0
  25. package/dist/db/admin-user-summary.js +31 -0
  26. package/dist/db/admin-user-summary.js.map +1 -0
  27. package/dist/db/admin-users.d.ts +2 -11
  28. package/dist/db/admin-users.d.ts.map +1 -1
  29. package/dist/db/admin-users.js +2 -27
  30. package/dist/db/admin-users.js.map +1 -1
  31. package/dist/db/refresh-tokens.d.ts +6 -0
  32. package/dist/db/refresh-tokens.d.ts.map +1 -1
  33. package/dist/db/refresh-tokens.js +31 -0
  34. package/dist/db/refresh-tokens.js.map +1 -1
  35. package/dist/db/schema/admin-command-results.d.ts +19 -2
  36. package/dist/db/schema/admin-command-results.d.ts.map +1 -1
  37. package/dist/db/schema/admin-command-results.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/presentation/auth/bearer-token-auth.d.ts +1 -0
  43. package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
  44. package/dist/presentation/auth/bearer-token-auth.js +4 -3
  45. package/dist/presentation/auth/bearer-token-auth.js.map +1 -1
  46. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  47. package/dist/presentation/auth/jwt-auth.js +32 -4
  48. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  49. package/dist/presentation/routes/administration.d.ts.map +1 -1
  50. package/dist/presentation/routes/administration.js +94 -3
  51. package/dist/presentation/routes/administration.js.map +1 -1
  52. package/dist/tsconfig.test.tsbuildinfo +1 -1
  53. package/package.json +4 -4
  54. package/src/core/administration.ts +137 -15
  55. package/src/core/auth.ts +9 -2
  56. package/src/db/admin-command.ts +85 -0
  57. package/src/db/admin-grants.ts +19 -57
  58. package/src/db/admin-user-moderation.ts +230 -0
  59. package/src/db/admin-user-summary.ts +51 -0
  60. package/src/db/admin-users.ts +4 -49
  61. package/src/db/refresh-tokens.ts +54 -0
  62. package/src/db/schema/admin-command-results.ts +23 -2
  63. package/src/index.ts +3 -0
  64. package/src/presentation/auth/bearer-token-auth.test.ts +17 -0
  65. package/src/presentation/auth/bearer-token-auth.ts +9 -2
  66. package/src/presentation/auth/jwt-auth.test.ts +1 -1
  67. package/src/presentation/auth/jwt-auth.ts +30 -1
  68. package/src/presentation/routes/administration.test.ts +245 -2
  69. package/src/presentation/routes/administration.ts +88 -1
  70. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,230 @@
1
+ import type {AdministrationActionEvent} from '@shipfox/api-common-dto';
2
+ import {and, eq, gt, isNull, sql} from 'drizzle-orm';
3
+ import {hasMinimumAdminRole, highestAdminRole} from '#core/admin-role-model.js';
4
+ import type {AdministratorUserSummary} from '#core/entities/administrator-read-model.js';
5
+ import {AdminRoleRequiredError, LastAdminOwnerError, UserNotFoundError} from '#core/errors.js';
6
+ import {
7
+ findAdminCommandResult,
8
+ lockAdminCommand,
9
+ lockAdminOwnerGrants,
10
+ storeAdminCommandResult,
11
+ type Tx,
12
+ writeAdminAction,
13
+ } from './admin-command.js';
14
+ import {findAdministratorUserSummary} from './admin-user-summary.js';
15
+ import {db} from './db.js';
16
+ import {lockUserSessionMutations} from './refresh-tokens.js';
17
+ import type {
18
+ StoredAdministratorUserSummary,
19
+ StoredAdminUserModerationResult,
20
+ } from './schema/admin-command-results.js';
21
+ import {adminGrants} from './schema/admin-grants.js';
22
+ import {refreshTokens} from './schema/refresh-tokens.js';
23
+ import {users} from './schema/users.js';
24
+
25
+ interface UserModerationCommandParams {
26
+ actorId: string;
27
+ userId: string;
28
+ idempotencyKeyFingerprint: string;
29
+ requestFingerprint: string;
30
+ event: AdministrationActionEvent;
31
+ }
32
+
33
+ function toStoredUserSummary(user: AdministratorUserSummary): StoredAdministratorUserSummary {
34
+ return {
35
+ id: user.id,
36
+ email: user.email,
37
+ name: user.name,
38
+ emailVerifiedAt: user.emailVerifiedAt?.toISOString() ?? null,
39
+ status: user.status,
40
+ createdAt: user.createdAt.toISOString(),
41
+ adminRole: user.adminRole,
42
+ };
43
+ }
44
+
45
+ async function findUserSummary(tx: Tx, userId: string): Promise<StoredAdministratorUserSummary> {
46
+ const user = await findAdministratorUserSummary(tx, {id: userId});
47
+ if (!user) throw new Error('User summary query returned no rows');
48
+ return toStoredUserSummary(user);
49
+ }
50
+
51
+ function fromStoredResult(result: StoredAdminUserModerationResult) {
52
+ return {
53
+ user: {
54
+ id: result.user.id,
55
+ email: result.user.email,
56
+ name: result.user.name,
57
+ emailVerifiedAt: result.user.emailVerifiedAt ? new Date(result.user.emailVerifiedAt) : null,
58
+ status: result.user.status,
59
+ createdAt: new Date(result.user.createdAt),
60
+ adminRole: result.user.adminRole,
61
+ },
62
+ correlationId: result.correlationId,
63
+ sessionsRevoked: result.sessionsRevoked,
64
+ };
65
+ }
66
+
67
+ async function findCommandResult(
68
+ tx: Tx,
69
+ params: Pick<
70
+ UserModerationCommandParams,
71
+ 'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
72
+ > & {command: string},
73
+ ) {
74
+ const result = await findAdminCommandResult(tx, params);
75
+ if (!result) return undefined;
76
+ if (!('userModeration' in result.result)) {
77
+ throw new Error('Administrator command result has an unexpected shape');
78
+ }
79
+ return fromStoredResult(result.result.userModeration);
80
+ }
81
+
82
+ async function storeCommandResult(
83
+ tx: Tx,
84
+ params: UserModerationCommandParams,
85
+ result: StoredAdminUserModerationResult,
86
+ ): Promise<void> {
87
+ await storeAdminCommandResult(tx, params, {userModeration: result});
88
+ }
89
+
90
+ async function readTargetUserForUpdate(tx: Tx, userId: string) {
91
+ const rows = await tx
92
+ .select({id: users.id, status: users.status})
93
+ .from(users)
94
+ .where(eq(users.id, userId))
95
+ .limit(1)
96
+ .for('update');
97
+ const user = rows[0];
98
+ if (!user || user.status === 'deleted') throw new UserNotFoundError(userId);
99
+ return user;
100
+ }
101
+
102
+ async function requireActiveAdminOperator(tx: Tx, actorId: string): Promise<void> {
103
+ const actorRows = await tx
104
+ .select({status: users.status})
105
+ .from(users)
106
+ .where(eq(users.id, actorId))
107
+ .limit(1);
108
+ const actor = actorRows[0];
109
+ const grants = await tx
110
+ .select({role: adminGrants.role})
111
+ .from(adminGrants)
112
+ .where(and(eq(adminGrants.userId, actorId), isNull(adminGrants.revokedAt)));
113
+ const role = highestAdminRole(grants.map(({role}) => role));
114
+
115
+ if (actor?.status !== 'active' || !role || !hasMinimumAdminRole(role, 'admin-operator')) {
116
+ throw new AdminRoleRequiredError('admin-operator');
117
+ }
118
+ }
119
+
120
+ async function revokeActiveSessions(tx: Tx, userId: string): Promise<number> {
121
+ const revoked = await tx
122
+ .update(refreshTokens)
123
+ .set({revokedAt: sql`now()`, updatedAt: sql`now()`})
124
+ .where(
125
+ and(
126
+ eq(refreshTokens.userId, userId),
127
+ isNull(refreshTokens.revokedAt),
128
+ gt(refreshTokens.expiresAt, sql`now()`),
129
+ ),
130
+ )
131
+ .returning({sessionId: refreshTokens.sessionId});
132
+
133
+ return new Set(revoked.map(({sessionId}) => sessionId)).size;
134
+ }
135
+
136
+ async function executeUserModerationCommand(
137
+ params: UserModerationCommandParams & {
138
+ operation: 'suspend' | 'reactivate' | 'revoke-sessions';
139
+ },
140
+ ) {
141
+ return await db().transaction(async (tx) => {
142
+ await lockAdminCommand(tx, params);
143
+ if (params.operation === 'suspend') await lockAdminOwnerGrants(tx);
144
+
145
+ const existing = await findCommandResult(tx, {
146
+ ...params,
147
+ command: params.event.command,
148
+ });
149
+ if (existing) return existing;
150
+
151
+ await lockUserSessionMutations(tx, params.userId);
152
+ const user = await readTargetUserForUpdate(tx, params.userId);
153
+ await requireActiveAdminOperator(tx, params.actorId);
154
+ let sessionsRevoked = 0;
155
+
156
+ if (params.operation === 'suspend') {
157
+ if (user.status === 'active') {
158
+ const activeOwners = await tx
159
+ .select({id: adminGrants.id})
160
+ .from(adminGrants)
161
+ .innerJoin(users, eq(adminGrants.userId, users.id))
162
+ .where(
163
+ and(
164
+ eq(adminGrants.role, 'admin-owner'),
165
+ isNull(adminGrants.revokedAt),
166
+ eq(users.status, 'active'),
167
+ ),
168
+ )
169
+ .limit(2);
170
+ const targetIsActiveOwner = await tx
171
+ .select({id: adminGrants.id})
172
+ .from(adminGrants)
173
+ .where(
174
+ and(
175
+ eq(adminGrants.userId, user.id),
176
+ eq(adminGrants.role, 'admin-owner'),
177
+ isNull(adminGrants.revokedAt),
178
+ ),
179
+ )
180
+ .limit(1);
181
+ if (targetIsActiveOwner.length > 0 && activeOwners.length <= 1) {
182
+ throw new LastAdminOwnerError();
183
+ }
184
+
185
+ await tx
186
+ .update(users)
187
+ .set({status: 'suspended', updatedAt: sql`now()`})
188
+ .where(eq(users.id, user.id));
189
+ }
190
+ sessionsRevoked = await revokeActiveSessions(tx, user.id);
191
+ } else if (params.operation === 'reactivate' && user.status === 'suspended') {
192
+ await tx
193
+ .update(users)
194
+ .set({status: 'active', updatedAt: sql`now()`})
195
+ .where(eq(users.id, user.id));
196
+ } else if (params.operation === 'revoke-sessions') {
197
+ sessionsRevoked = await revokeActiveSessions(tx, user.id);
198
+ }
199
+
200
+ const summary = await findUserSummary(tx, user.id);
201
+ const result: StoredAdminUserModerationResult = {
202
+ user: summary,
203
+ correlationId: params.event.correlationId,
204
+ sessionsRevoked,
205
+ };
206
+ await writeAdminAction(tx, params.event);
207
+ await storeCommandResult(tx, params, result);
208
+ return fromStoredResult(result);
209
+ });
210
+ }
211
+
212
+ export type UserModerationResult = Awaited<ReturnType<typeof executeUserModerationCommand>>;
213
+
214
+ export async function suspendUserWithAudit(
215
+ params: UserModerationCommandParams,
216
+ ): Promise<UserModerationResult> {
217
+ return await executeUserModerationCommand({...params, operation: 'suspend'});
218
+ }
219
+
220
+ export async function reactivateUserWithAudit(
221
+ params: UserModerationCommandParams,
222
+ ): Promise<UserModerationResult> {
223
+ return await executeUserModerationCommand({...params, operation: 'reactivate'});
224
+ }
225
+
226
+ export async function revokeUserSessionsWithAudit(
227
+ params: UserModerationCommandParams,
228
+ ): Promise<UserModerationResult> {
229
+ return await executeUserModerationCommand({...params, operation: 'revoke-sessions'});
230
+ }
@@ -0,0 +1,51 @@
1
+ import {and, eq, isNull} from 'drizzle-orm';
2
+ import {highestAdminRole} from '#core/admin-role-model.js';
3
+ import type {AdministratorUserSummary} from '#core/entities/administrator-read-model.js';
4
+ import type {db} from './db.js';
5
+ import {adminGrants} from './schema/admin-grants.js';
6
+ import {users} from './schema/users.js';
7
+
8
+ type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
9
+ export type AdministratorUserSummaryExecutor = ReturnType<typeof db> | Tx;
10
+
11
+ type AdministratorUserLookup = {id: string; email?: never} | {email: string; id?: never};
12
+
13
+ export async function findAdministratorUserSummary(
14
+ executor: AdministratorUserSummaryExecutor,
15
+ params: AdministratorUserLookup,
16
+ ): Promise<AdministratorUserSummary | undefined> {
17
+ const identifier = 'id' in params ? eq(users.id, params.id) : eq(users.email, params.email);
18
+ const rows = await executor
19
+ .select({
20
+ id: users.id,
21
+ email: users.email,
22
+ name: users.name,
23
+ emailVerifiedAt: users.emailVerifiedAt,
24
+ status: users.status,
25
+ createdAt: users.createdAt,
26
+ adminRole: adminGrants.role,
27
+ })
28
+ .from(users)
29
+ .leftJoin(
30
+ adminGrants,
31
+ and(
32
+ eq(adminGrants.userId, users.id),
33
+ isNull(adminGrants.revokedAt),
34
+ eq(users.status, 'active'),
35
+ ),
36
+ )
37
+ .where(identifier);
38
+
39
+ const first = rows[0];
40
+ if (!first) return undefined;
41
+
42
+ return {
43
+ id: first.id,
44
+ email: first.email,
45
+ name: first.name,
46
+ emailVerifiedAt: first.emailVerifiedAt,
47
+ status: first.status,
48
+ createdAt: first.createdAt,
49
+ adminRole: highestAdminRole(rows.flatMap(({adminRole}) => (adminRole ? [adminRole] : []))),
50
+ } satisfies AdministratorUserSummary;
51
+ }
@@ -1,58 +1,13 @@
1
- import type {AdminRole} from '@shipfox/api-auth-dto';
2
- import {and, eq, isNull} from 'drizzle-orm';
3
- import {highestAdminRole} from '#core/admin-role-model.js';
4
- import type {UserStatus} from '#core/entities/user.js';
1
+ import type {AdministratorUserSummary} from '#core/entities/administrator-read-model.js';
2
+ import {findAdministratorUserSummary} from './admin-user-summary.js';
5
3
  import {db} from './db.js';
6
- import {adminGrants} from './schema/admin-grants.js';
7
- import {users} from './schema/users.js';
8
4
 
9
- export interface AdministratorUserRecord {
10
- id: string;
11
- email: string;
12
- name: string | null;
13
- emailVerifiedAt: Date | null;
14
- status: UserStatus;
15
- createdAt: Date;
16
- adminRole: AdminRole | null;
17
- }
5
+ export type AdministratorUserRecord = AdministratorUserSummary;
18
6
 
19
7
  type AdministratorUserLookup = {id: string; email?: never} | {email: string; id?: never};
20
8
 
21
9
  export async function findAdministratorUser(
22
10
  params: AdministratorUserLookup,
23
11
  ): Promise<AdministratorUserRecord | undefined> {
24
- const identifier = 'id' in params ? eq(users.id, params.id) : eq(users.email, params.email);
25
- const rows = await db()
26
- .select({
27
- id: users.id,
28
- email: users.email,
29
- name: users.name,
30
- emailVerifiedAt: users.emailVerifiedAt,
31
- status: users.status,
32
- createdAt: users.createdAt,
33
- adminRole: adminGrants.role,
34
- })
35
- .from(users)
36
- .leftJoin(
37
- adminGrants,
38
- and(
39
- eq(adminGrants.userId, users.id),
40
- isNull(adminGrants.revokedAt),
41
- eq(users.status, 'active'),
42
- ),
43
- )
44
- .where(identifier);
45
-
46
- const first = rows[0];
47
- if (!first) return undefined;
48
-
49
- return {
50
- id: first.id,
51
- email: first.email,
52
- name: first.name,
53
- emailVerifiedAt: first.emailVerifiedAt,
54
- status: first.status,
55
- createdAt: first.createdAt,
56
- adminRole: highestAdminRole(rows.flatMap(({adminRole}) => (adminRole ? [adminRole] : []))),
57
- };
12
+ return await findAdministratorUserSummary(db(), params);
58
13
  }
@@ -2,6 +2,13 @@ import {and, eq, gt, isNull, ne, sql} from 'drizzle-orm';
2
2
  import type {RefreshToken} from '#core/entities/refresh-token.js';
3
3
  import {db} from './db.js';
4
4
  import {refreshTokens, toRefreshToken} from './schema/refresh-tokens.js';
5
+ import {users} from './schema/users.js';
6
+
7
+ type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
8
+
9
+ export async function lockUserSessionMutations(tx: Tx, userId: string): Promise<void> {
10
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`auth_user_sessions:${userId}`}))`);
11
+ }
5
12
 
6
13
  export interface CreateRefreshTokenParams {
7
14
  sessionId?: string | undefined;
@@ -26,6 +33,33 @@ export async function createRefreshToken(params: CreateRefreshTokenParams): Prom
26
33
  return toRefreshToken(row);
27
34
  }
28
35
 
36
+ export async function createRefreshTokenForActiveUser(
37
+ params: CreateRefreshTokenParams,
38
+ ): Promise<RefreshToken | undefined> {
39
+ return await db().transaction(async (tx) => {
40
+ await lockUserSessionMutations(tx, params.userId);
41
+ const userRows = await tx
42
+ .select({status: users.status})
43
+ .from(users)
44
+ .where(eq(users.id, params.userId))
45
+ .limit(1);
46
+ if (userRows[0]?.status !== 'active') return undefined;
47
+
48
+ const rows = await tx
49
+ .insert(refreshTokens)
50
+ .values({
51
+ sessionId: params.sessionId,
52
+ userId: params.userId,
53
+ hashedToken: params.hashedToken,
54
+ expiresAt: params.expiresAt,
55
+ })
56
+ .returning();
57
+ const row = rows[0];
58
+ if (!row) throw new Error('Insert returned no rows');
59
+ return toRefreshToken(row);
60
+ });
61
+ }
62
+
29
63
  /**
30
64
  * Looks up the live session token by hash — one that can still authenticate as
31
65
  * the current session. Returns `undefined` for revoked, expired, or already
@@ -108,12 +142,32 @@ export async function findRefreshTokenByHash(params: {
108
142
  * If the successor insert fails, the predecessor rotation rolls back.
109
143
  */
110
144
  export async function rotateRefreshToken(params: {
145
+ userId?: string | undefined;
111
146
  id: string;
112
147
  currentHashedToken: string;
113
148
  nextHashedToken: string;
114
149
  expiresAt: Date;
115
150
  }): Promise<RefreshToken | undefined> {
116
151
  return await db().transaction(async (tx) => {
152
+ const userId =
153
+ params.userId ??
154
+ (
155
+ await tx
156
+ .select({userId: refreshTokens.userId})
157
+ .from(refreshTokens)
158
+ .where(eq(refreshTokens.id, params.id))
159
+ .limit(1)
160
+ )[0]?.userId;
161
+ if (!userId) return undefined;
162
+
163
+ await lockUserSessionMutations(tx, userId);
164
+ const userRows = await tx
165
+ .select({status: users.status})
166
+ .from(users)
167
+ .where(eq(users.id, userId))
168
+ .limit(1);
169
+ if (userRows[0]?.status !== 'active') return undefined;
170
+
117
171
  const rotatedRows = await tx
118
172
  .update(refreshTokens)
119
173
  .set({
@@ -1,6 +1,7 @@
1
1
  import type {AdminRole} from '@shipfox/api-auth-dto';
2
2
  import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
3
3
  import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
4
+ import type {UserStatus} from '#core/entities/user.js';
4
5
  import {pgTable} from './common.js';
5
6
  import {users} from './users.js';
6
7
 
@@ -13,10 +14,30 @@ export interface StoredAdminGrant {
13
14
  updatedAt: string;
14
15
  }
15
16
 
16
- export interface StoredAdminCommandResult {
17
- grant: StoredAdminGrant;
17
+ export interface StoredAdministratorUserSummary {
18
+ id: string;
19
+ email: string;
20
+ name: string | null;
21
+ emailVerifiedAt: string | null;
22
+ status: UserStatus;
23
+ createdAt: string;
24
+ adminRole: AdminRole | null;
18
25
  }
19
26
 
27
+ export interface StoredAdminUserModerationResult {
28
+ user: StoredAdministratorUserSummary;
29
+ correlationId: string;
30
+ sessionsRevoked: number;
31
+ }
32
+
33
+ export type StoredAdminCommandResult =
34
+ | {
35
+ grant: StoredAdminGrant;
36
+ }
37
+ | {
38
+ userModeration: StoredAdminUserModerationResult;
39
+ };
40
+
20
41
  export const adminCommandResults = pgTable(
21
42
  'admin_command_results',
22
43
  {
package/src/index.ts CHANGED
@@ -40,7 +40,10 @@ export {
40
40
  export {
41
41
  bootstrapFirstAdminOwner,
42
42
  grantAdministratorRole,
43
+ reactivateAdministratorUser,
43
44
  revokeAdministratorGrant,
45
+ revokeAdministratorUserSessions,
46
+ suspendAdministratorUser,
44
47
  } from '#core/administration.js';
45
48
  export type {
46
49
  CreateSessionForUserError,
@@ -13,9 +13,11 @@ interface TestClaims {
13
13
  describe('bearer-token-auth', () => {
14
14
  let app: FastifyInstance;
15
15
  let verifyToken: ReturnType<typeof vi.fn<(token: string) => Promise<TestClaims | null>>>;
16
+ let verifierErrorIsInvalid = true;
16
17
 
17
18
  beforeEach(async () => {
18
19
  verifyToken = vi.fn<(token: string) => Promise<TestClaims | null>>();
20
+ verifierErrorIsInvalid = true;
19
21
  app = Fastify();
20
22
  app.setValidatorCompiler(validatorCompiler);
21
23
  app.setSerializerCompiler(serializerCompiler);
@@ -24,6 +26,7 @@ describe('bearer-token-auth', () => {
24
26
  const authMethod = createBearerTokenAuthMethod({
25
27
  name: 'test-bearer',
26
28
  verifyToken,
29
+ isInvalidTokenError: () => verifierErrorIsInvalid,
27
30
  invalidTokenError: {message: 'Invalid test bearer token', code: 'invalid-test-token'},
28
31
  setContext: (request, claims) => {
29
32
  (request as FastifyRequest & Record<typeof CONTEXT_KEY, typeof claims>)[CONTEXT_KEY] =
@@ -96,4 +99,18 @@ describe('bearer-token-auth', () => {
96
99
  expect(res.json()).toEqual({code: 'invalid-test-token'});
97
100
  expect(verifyToken).toHaveBeenCalledWith('invalid-token');
98
101
  });
102
+
103
+ test('propagates verifier errors classified as service failures', async () => {
104
+ verifierErrorIsInvalid = false;
105
+ verifyToken.mockRejectedValue(new Error('database unavailable'));
106
+
107
+ const res = await app.inject({
108
+ method: 'GET',
109
+ url: '/protected',
110
+ headers: {authorization: 'Bearer valid-token'},
111
+ });
112
+
113
+ expect(res.statusCode).toBe(500);
114
+ expect(res.json().code).not.toBe('invalid-test-token');
115
+ });
99
116
  });
@@ -9,6 +9,7 @@ interface UnauthorizedErrorParams {
9
9
  interface BearerTokenAuthMethodOptions<TClaims> {
10
10
  name: string;
11
11
  verifyToken: (token: string) => Promise<TClaims | null>;
12
+ isInvalidTokenError?: (error: unknown) => boolean;
12
13
  invalidTokenError: UnauthorizedErrorParams;
13
14
  setContext: (request: FastifyRequest, claims: TClaims) => void;
14
15
  }
@@ -27,7 +28,11 @@ export function createBearerTokenAuthMethod<TClaims>(
27
28
  const token = extractBearerToken(request.headers.authorization);
28
29
  if (!token) throwUnauthorized(missingBearerError);
29
30
 
30
- const claims = await verifyBearerToken(token, options.verifyToken);
31
+ const claims = await verifyBearerToken(
32
+ token,
33
+ options.verifyToken,
34
+ options.isInvalidTokenError,
35
+ );
31
36
  if (!claims) throwUnauthorized(options.invalidTokenError);
32
37
 
33
38
  options.setContext(request, claims);
@@ -38,10 +43,12 @@ export function createBearerTokenAuthMethod<TClaims>(
38
43
  async function verifyBearerToken<TClaims>(
39
44
  token: string,
40
45
  verifyToken: (token: string) => Promise<TClaims | null>,
46
+ isInvalidTokenError: ((error: unknown) => boolean) | undefined,
41
47
  ): Promise<TClaims | null> {
42
48
  try {
43
49
  return await verifyToken(token);
44
- } catch {
50
+ } catch (error) {
51
+ if (isInvalidTokenError && !isInvalidTokenError(error)) throw error;
45
52
  return null;
46
53
  }
47
54
  }
@@ -116,7 +116,7 @@ describe('jwt-auth', () => {
116
116
  expect(res.statusCode).toBe(401);
117
117
  });
118
118
 
119
- test('does not read user state during JWT validation', async () => {
119
+ test('keeps a legacy token verifiable during the migration window', async () => {
120
120
  const userId = crypto.randomUUID();
121
121
  const email = emailFor('jwt-stateless');
122
122
  const token = await signUserToken({
@@ -13,6 +13,7 @@ import type {User} from '#core/entities/user.js';
13
13
  import type {UserTokenClaims} from '#core/jwt.js';
14
14
  import {verifyUserToken} from '#core/jwt.js';
15
15
  import {findActiveRefreshSession} from '#db/refresh-tokens.js';
16
+ import {findUserById} from '#db/users.js';
16
17
  import {createBearerTokenAuthMethod} from './bearer-token-auth.js';
17
18
 
18
19
  const AUTHENTICATED_SESSION_CONTEXT_KEY = Symbol.for('@shipfox/api-auth/session');
@@ -31,6 +32,13 @@ export interface CreateJwtAuthMethodOptions {
31
32
  secret: string;
32
33
  }
33
34
 
35
+ class InvalidJwtTokenError extends Error {
36
+ constructor() {
37
+ super('Invalid JWT');
38
+ this.name = 'InvalidJwtTokenError';
39
+ }
40
+ }
41
+
34
42
  export function getClientContext(request: FastifyRequest): ClientContext | null {
35
43
  return getUserContext(request);
36
44
  }
@@ -77,7 +85,28 @@ export async function getAuthenticatedSessionContext(
77
85
  export function createJwtAuthMethod(): AuthMethod {
78
86
  return createBearerTokenAuthMethod({
79
87
  name: AUTH_USER,
80
- verifyToken: (token) => verifyUserToken({token, secret: userAccessTokenKey()}),
88
+ verifyToken: async (token) => {
89
+ let claims: UserTokenClaims;
90
+ try {
91
+ claims = await verifyUserToken({token, secret: userAccessTokenKey()});
92
+ } catch {
93
+ throw new InvalidJwtTokenError();
94
+ }
95
+ const user = await findUserById({id: claims.sub});
96
+
97
+ // Legacy access tokens without a session claim remain verifiable for the
98
+ // migration window; known suspended or deleted users are still rejected.
99
+ if (user && user.status !== 'active') return null;
100
+ if (!claims.refreshSessionId) return claims;
101
+ if (!user) return null;
102
+
103
+ const session = await findActiveRefreshSession({
104
+ sessionId: claims.refreshSessionId,
105
+ userId: claims.sub,
106
+ });
107
+ return session ? claims : null;
108
+ },
109
+ isInvalidTokenError: (error) => error instanceof InvalidJwtTokenError,
81
110
  invalidTokenError: {message: 'Invalid or expired token', code: 'unauthorized'},
82
111
  setContext: (request, claims) => {
83
112
  const clientContext: ClientContext = buildUserContext({