@shipfox/api-auth 13.1.0 → 17.0.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 (101) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +35 -0
  3. package/README.md +41 -0
  4. package/dist/config.d.ts +1 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +4 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/core/administration.d.ts +24 -2
  9. package/dist/core/administration.d.ts.map +1 -1
  10. package/dist/core/administration.js +126 -3
  11. package/dist/core/administration.js.map +1 -1
  12. package/dist/core/auth.d.ts +31 -0
  13. package/dist/core/auth.d.ts.map +1 -1
  14. package/dist/core/auth.js +70 -2
  15. package/dist/core/auth.js.map +1 -1
  16. package/dist/core/errors.d.ts +15 -0
  17. package/dist/core/errors.d.ts.map +1 -1
  18. package/dist/core/errors.js +30 -0
  19. package/dist/core/errors.js.map +1 -1
  20. package/dist/core/jwt.d.ts +2 -0
  21. package/dist/core/jwt.d.ts.map +1 -1
  22. package/dist/core/jwt.js +22 -1
  23. package/dist/core/jwt.js.map +1 -1
  24. package/dist/db/admin-command.d.ts +12 -4
  25. package/dist/db/admin-command.d.ts.map +1 -1
  26. package/dist/db/admin-command.js +10 -1
  27. package/dist/db/admin-command.js.map +1 -1
  28. package/dist/db/admin-grants.d.ts.map +1 -1
  29. package/dist/db/admin-grants.js +4 -1
  30. package/dist/db/admin-grants.js.map +1 -1
  31. package/dist/db/admin-user-moderation.d.ts +3 -0
  32. package/dist/db/admin-user-moderation.d.ts.map +1 -1
  33. package/dist/db/admin-user-moderation.js +6 -2
  34. package/dist/db/admin-user-moderation.js.map +1 -1
  35. package/dist/db/impersonation.d.ts +55 -0
  36. package/dist/db/impersonation.d.ts.map +1 -0
  37. package/dist/db/impersonation.js +212 -0
  38. package/dist/db/impersonation.js.map +1 -0
  39. package/dist/db/schema/admin-command-results.d.ts +16 -0
  40. package/dist/db/schema/admin-command-results.d.ts.map +1 -1
  41. package/dist/db/schema/admin-command-results.js.map +1 -1
  42. package/dist/index.d.ts +5 -4
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +5 -5
  45. package/dist/index.js.map +1 -1
  46. package/dist/metrics/index.d.ts +1 -1
  47. package/dist/metrics/index.d.ts.map +1 -1
  48. package/dist/metrics/index.js +1 -1
  49. package/dist/metrics/index.js.map +1 -1
  50. package/dist/metrics/instance.d.ts +4 -2
  51. package/dist/metrics/instance.d.ts.map +1 -1
  52. package/dist/metrics/instance.js +8 -0
  53. package/dist/metrics/instance.js.map +1 -1
  54. package/dist/presentation/auth/bearer-token-auth.d.ts +2 -2
  55. package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
  56. package/dist/presentation/auth/bearer-token-auth.js +2 -2
  57. package/dist/presentation/auth/bearer-token-auth.js.map +1 -1
  58. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  59. package/dist/presentation/auth/jwt-auth.js +26 -2
  60. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  61. package/dist/presentation/routes/administration.d.ts +2 -1
  62. package/dist/presentation/routes/administration.d.ts.map +1 -1
  63. package/dist/presentation/routes/administration.js +127 -19
  64. package/dist/presentation/routes/administration.js.map +1 -1
  65. package/dist/presentation/routes/rate-limit.d.ts +8 -0
  66. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  67. package/dist/presentation/routes/rate-limit.js +44 -0
  68. package/dist/presentation/routes/rate-limit.js.map +1 -1
  69. package/dist/presentation/routes/session/me.d.ts.map +1 -1
  70. package/dist/presentation/routes/session/me.js +2 -1
  71. package/dist/presentation/routes/session/me.js.map +1 -1
  72. package/dist/tsconfig.test.tsbuildinfo +1 -1
  73. package/package.json +12 -12
  74. package/src/config.ts +4 -0
  75. package/src/core/administration.ts +172 -1
  76. package/src/core/auth.test.ts +79 -0
  77. package/src/core/auth.ts +98 -1
  78. package/src/core/errors.ts +35 -0
  79. package/src/core/jwt.test.ts +92 -0
  80. package/src/core/jwt.ts +38 -9
  81. package/src/db/admin-command.ts +35 -6
  82. package/src/db/admin-grants.ts +5 -1
  83. package/src/db/admin-user-moderation.ts +8 -2
  84. package/src/db/impersonation.ts +323 -0
  85. package/src/db/schema/admin-command-results.ts +18 -0
  86. package/src/index.test.ts +1 -1
  87. package/src/index.ts +12 -3
  88. package/src/metrics/index.ts +2 -0
  89. package/src/metrics/instance.ts +13 -2
  90. package/src/presentation/auth/bearer-token-auth.ts +4 -4
  91. package/src/presentation/auth/jwt-auth.test.ts +191 -0
  92. package/src/presentation/auth/jwt-auth.ts +19 -1
  93. package/src/presentation/routes/administration.test.ts +828 -3
  94. package/src/presentation/routes/administration.ts +130 -11
  95. package/src/presentation/routes/rate-limit.ts +40 -0
  96. package/src/presentation/routes/session/login.test.ts +5 -0
  97. package/src/presentation/routes/session/me.test.ts +24 -0
  98. package/src/presentation/routes/session/me.ts +1 -0
  99. package/src/presentation/routes/session/refresh.test.ts +11 -0
  100. package/test/routes.ts +43 -16
  101. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-auth",
3
3
  "license": "MIT",
4
- "version": "13.1.0",
4
+ "version": "17.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -26,25 +26,25 @@
26
26
  "@node-rs/argon2": "^2.0.2",
27
27
  "drizzle-orm": "^0.45.2",
28
28
  "zod": "^4.4.3",
29
- "@shipfox/api-common-dto": "12.0.0",
30
- "@shipfox/api-auth-context": "12.2.0",
31
- "@shipfox/api-auth-dto": "12.0.0",
32
- "@shipfox/api-email-challenges": "1.1.9",
33
- "@shipfox/api-workspaces-dto": "12.0.0",
29
+ "@shipfox/api-common-dto": "15.0.0",
30
+ "@shipfox/api-auth-context": "17.0.0",
31
+ "@shipfox/api-auth-dto": "17.0.0",
32
+ "@shipfox/api-email-challenges": "1.1.11",
33
+ "@shipfox/api-workspaces-dto": "15.0.0",
34
34
  "@shipfox/inter-module": "0.2.3",
35
35
  "@shipfox/config": "1.2.4",
36
36
  "@shipfox/node-drizzle": "0.3.5",
37
37
  "@shipfox/node-auth-root-key": "0.2.3",
38
- "@shipfox/node-fastify": "0.4.2",
38
+ "@shipfox/node-fastify": "0.4.3",
39
39
  "@shipfox/node-jwt": "0.4.0",
40
40
  "@shipfox/node-email": "0.3.5",
41
- "@shipfox/node-mailer": "0.2.5",
42
- "@shipfox/node-module": "1.0.6",
43
- "@shipfox/node-opentelemetry": "0.6.4",
41
+ "@shipfox/node-mailer": "0.2.6",
42
+ "@shipfox/node-module": "1.0.7",
43
+ "@shipfox/node-opentelemetry": "0.6.5",
44
44
  "@shipfox/node-outbox": "0.2.6",
45
- "@shipfox/node-postgres": "0.5.0",
45
+ "@shipfox/node-postgres": "0.5.1",
46
46
  "@shipfox/node-rate-limit": "0.4.0",
47
- "@shipfox/node-tokens": "0.3.2"
47
+ "@shipfox/node-tokens": "0.3.3"
48
48
  },
49
49
  "imports": {
50
50
  "#*": "./dist/*"
package/src/config.ts CHANGED
@@ -10,6 +10,10 @@ export const config = createConfig({
10
10
  desc: 'How long an access token stays valid. Accepts a duration string such as 15m, 1h, or 7d.',
11
11
  default: '15m',
12
12
  }),
13
+ AUTH_IMPERSONATION_ENABLED: bool({
14
+ desc: 'Whether administrators can mint impersonated sessions for target users. Defaults to false: the source-available client ships no impersonation banner, so enable it only where every signed-in surface renders one.',
15
+ default: false,
16
+ }),
13
17
  AUTH_JOB_LEASE_TOKEN_EXPIRES_IN: str({
14
18
  desc: 'How long a job lease token stays valid. Set it longer than the longest job (JOB_MAX_DURATION is 60 minutes) plus a safety margin.',
15
19
  default: '90m',
@@ -1,6 +1,7 @@
1
1
  import {timingSafeEqual} from 'node:crypto';
2
2
  import type {AdminRole} from '@shipfox/api-auth-dto';
3
3
  import {createAdministrationActionEvent} from '@shipfox/api-common-dto';
4
+ import type {WorkspacesInterModuleClient} from '@shipfox/api-workspaces-dto/inter-module';
4
5
  import type {TimestampIdCursor} from '@shipfox/node-drizzle';
5
6
  import {hashOpaqueToken} from '@shipfox/node-tokens';
6
7
  import {config} from '#config.js';
@@ -18,7 +19,14 @@ import {
18
19
  type UserModerationResult,
19
20
  } from '#db/admin-user-moderation.js';
20
21
  import {findAdministratorUser as findAdministratorUserInDb} from '#db/admin-users.js';
21
- import {requireAdminRole} from './admin-role.js';
22
+ import {
23
+ type ImpersonationResult,
24
+ impersonateUserWithAudit,
25
+ impersonationSucceededEventExists,
26
+ publishImpersonationFailure,
27
+ } from '#db/impersonation.js';
28
+ import {recordImpersonationOutcome} from '#metrics/index.js';
29
+ import {getCurrentAdminRole, requireAdminRole} from './admin-role.js';
22
30
  import type {AdminGrant} from './entities/admin-grant.js';
23
31
  import type {
24
32
  AdministratorGrantSummary,
@@ -29,7 +37,15 @@ import {
29
37
  AdminGrantAlreadyExistsError,
30
38
  AdminGrantNotFoundError,
31
39
  AdminIdempotencyKeyReuseError,
40
+ AdminRoleRequiredError,
41
+ CannotImpersonateAdministratorError,
42
+ CannotImpersonateSelfError,
43
+ EmailNotVerifiedError,
44
+ ImpersonationDisabledError,
45
+ ImpersonationExpiredError,
46
+ ImpersonationTargetNotActiveError,
32
47
  InvalidAdminBootstrapTokenError,
48
+ InvalidCredentialsError,
33
49
  LastAdminOwnerError,
34
50
  UserNotFoundError,
35
51
  } from './errors.js';
@@ -43,6 +59,35 @@ const REVOKE_COMMAND = 'auth.admin_grant.revoke';
43
59
  const SUSPEND_USER_COMMAND = 'auth.user.suspend';
44
60
  const REACTIVATE_USER_COMMAND = 'auth.user.reactivate';
45
61
  const REVOKE_USER_SESSIONS_COMMAND = 'auth.user.revoke-sessions';
62
+ const IMPERSONATE_COMMAND = 'auth.user.impersonate';
63
+
64
+ /**
65
+ * Client-contract errors are reported to the caller, not the denial stream: a
66
+ * 404 for an unknown target or a 409 for a reused key is a request mistake,
67
+ * and auditing each retry would drown the `failed` event stream under durable
68
+ * rows that a security review cannot distinguish from genuine denials.
69
+ */
70
+ function isImpersonationClientContractError(error: unknown): boolean {
71
+ return error instanceof AdminIdempotencyKeyReuseError || error instanceof UserNotFoundError;
72
+ }
73
+
74
+ /**
75
+ * Deterministic authorization and eligibility denials. They are raised before
76
+ * the command transaction writes anything, so the transaction is known to have
77
+ * rolled back and the failure event is an unambiguous audit of the denial.
78
+ */
79
+ function isImpersonationDenial(error: unknown): boolean {
80
+ return (
81
+ error instanceof ImpersonationDisabledError ||
82
+ error instanceof AdminRoleRequiredError ||
83
+ error instanceof CannotImpersonateSelfError ||
84
+ error instanceof CannotImpersonateAdministratorError ||
85
+ error instanceof ImpersonationTargetNotActiveError ||
86
+ error instanceof ImpersonationExpiredError ||
87
+ error instanceof EmailNotVerifiedError ||
88
+ error instanceof InvalidCredentialsError
89
+ );
90
+ }
46
91
 
47
92
  export function administrationCommandFingerprint(command: string, input: unknown): string {
48
93
  return hashOpaqueToken(`${command}:${JSON.stringify(input)}`);
@@ -329,11 +374,137 @@ export async function revokeAdministratorUserSessions(
329
374
  );
330
375
  }
331
376
 
377
+ export interface ImpersonateUserParams extends AdministrationMutationContext {
378
+ targetUserId: string;
379
+ reason: string;
380
+ /**
381
+ * The actor's own session mark, from the verified request context. The
382
+ * positional `/admin` guard rejects an impersonated actor before this
383
+ * command runs; the command refuses the same mark defensively.
384
+ */
385
+ actorImpersonatorId?: string | undefined;
386
+ workspaces: WorkspacesInterModuleClient;
387
+ }
388
+
389
+ /**
390
+ * Mints a short-lived, marked, audited impersonated session for a target
391
+ * user. Enforces the authorization and eligibility ladder (rules 1-6), the
392
+ * fingerprint-only idempotency flow (replay re-runs the ladder and re-signs
393
+ * with the original expiry), and the audit event contract: success and replay
394
+ * events commit atomically with the command result, and failure events commit
395
+ * in their own transaction after the rollback.
396
+ */
397
+ export async function impersonateUser(params: ImpersonateUserParams): Promise<ImpersonationResult> {
398
+ const idempotencyKeyFingerprint = hashOpaqueToken(params.idempotencyKey);
399
+ const requestFingerprint = administrationCommandFingerprint(IMPERSONATE_COMMAND, {
400
+ targetUserId: params.targetUserId,
401
+ reason: params.reason,
402
+ });
403
+ try {
404
+ // Rule 1: the capability is an explicit opt-in; the flag is a kill switch
405
+ // and must hold on the replay path as well as the initial mint.
406
+ if (!config.AUTH_IMPERSONATION_ENABLED) throw new ImpersonationDisabledError();
407
+ // Rule 3: an impersonated actor cannot impersonate (nested impersonation).
408
+ if (params.actorImpersonatorId !== undefined) {
409
+ throw new AdminRoleRequiredError(ADMIN_OPERATOR_ROLE);
410
+ }
411
+ // Rule 2: minimum admin-operator, the same bar as suspension and session
412
+ // revocation. Re-checked inside the transaction on every path, replay
413
+ // included, so a revocation mid-window ends the capability immediately.
414
+ await requireAdminRole({
415
+ userId: params.actorId,
416
+ minimumRole: ADMIN_OPERATOR_ROLE,
417
+ });
418
+ const result = await impersonateUserWithAudit({
419
+ actorId: params.actorId,
420
+ targetUserId: params.targetUserId,
421
+ reason: params.reason,
422
+ idempotencyKeyFingerprint,
423
+ requestFingerprint,
424
+ correlationId: params.correlationId,
425
+ workspaces: params.workspaces,
426
+ });
427
+ recordImpersonationOutcome('succeeded');
428
+ return result;
429
+ } catch (error) {
430
+ // The command did not hand out a token, so the attempt is a failed outcome
431
+ // regardless of why: mint-volume and denial-spike alerts key off this.
432
+ recordImpersonationOutcome('failed');
433
+ // Client-contract errors (unknown target, reused key) are reported to the
434
+ // caller and never enter the denial stream.
435
+ if (isImpersonationClientContractError(error)) throw error;
436
+ // Deterministic denials always audit: their transaction rolled back with
437
+ // nothing written, so the `failed` event is unambiguous even when a
438
+ // previous mint under the same key left a committed result row.
439
+ if (isImpersonationDenial(error)) {
440
+ await publishImpersonationFailureForActor(params, {
441
+ idempotencyKeyFingerprint,
442
+ correlationId: params.correlationId,
443
+ });
444
+ throw error;
445
+ }
446
+ // An unexpected error may be an ambiguous COMMIT: the mint transaction
447
+ // committed (result row and `succeeded` event are durable) but the driver
448
+ // raised on the acknowledgement. Publishing a `failed` event then would
449
+ // contradict the committed trail, so reconcile against the committed
450
+ // `succeeded` event for THIS invocation before writing anything: the event
451
+ // is written atomically with the result row, and its correlationId is
452
+ // unique per request, so a result row committed by an earlier mint or
453
+ // replay under the same key is never mistaken for this invocation's
454
+ // commit. The reconcile is best-effort: never mask the original error.
455
+ let committed = false;
456
+ try {
457
+ committed = await impersonationSucceededEventExists({
458
+ actorId: params.actorId,
459
+ idempotencyKeyFingerprint,
460
+ correlationId: params.correlationId,
461
+ });
462
+ } catch {
463
+ // Fall through: publish the failure event rather than losing the denial.
464
+ }
465
+ if (!committed) {
466
+ await publishImpersonationFailureForActor(params, {
467
+ idempotencyKeyFingerprint,
468
+ correlationId: params.correlationId,
469
+ });
470
+ }
471
+ throw error;
472
+ }
473
+ }
474
+
475
+ async function publishImpersonationFailureForActor(
476
+ params: ImpersonateUserParams,
477
+ audit: {idempotencyKeyFingerprint: string; correlationId: string},
478
+ ): Promise<void> {
479
+ // Failures publish from a separate committed transaction after the rollback:
480
+ // the main transaction is gone, so an event written inside it would
481
+ // disappear, and the role check runs before it even opens.
482
+ let actorRole: AdminRole | null = null;
483
+ try {
484
+ actorRole = await getCurrentAdminRole({userId: params.actorId});
485
+ } catch {
486
+ // The failure event is best-effort; never mask the original error.
487
+ }
488
+ await publishImpersonationFailure({
489
+ actorId: params.actorId,
490
+ targetUserId: params.targetUserId,
491
+ reason: params.reason,
492
+ actorRole,
493
+ idempotencyKeyFingerprint: audit.idempotencyKeyFingerprint,
494
+ correlationId: audit.correlationId,
495
+ });
496
+ }
497
+
332
498
  export {
333
499
  AdminBootstrapClosedError,
334
500
  AdminGrantAlreadyExistsError,
335
501
  AdminGrantNotFoundError,
336
502
  AdminIdempotencyKeyReuseError,
503
+ CannotImpersonateAdministratorError,
504
+ CannotImpersonateSelfError,
505
+ ImpersonationDisabledError,
506
+ ImpersonationExpiredError,
507
+ ImpersonationTargetNotActiveError,
337
508
  InvalidAdminBootstrapTokenError,
338
509
  LastAdminOwnerError,
339
510
  UserNotFoundError,
@@ -6,6 +6,7 @@ import {and, desc, eq, sql} from 'drizzle-orm';
6
6
  import {
7
7
  changePassword,
8
8
  confirmPasswordReset as coreConfirmPasswordReset,
9
+ createImpersonatedSessionToken as coreCreateImpersonatedSessionToken,
9
10
  createSessionForUser as coreCreateSessionForUser,
10
11
  login as coreLogin,
11
12
  refreshAccessToken as coreRefreshAccessToken,
@@ -58,6 +59,7 @@ const testConfig = vi.hoisted(
58
59
  vi.mock('#config.js', () => ({
59
60
  config: {
60
61
  AUTH_JWT_EXPIRES_IN: '15m',
62
+ AUTH_IMPERSONATION_ENABLED: true,
61
63
  AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS: 14,
62
64
  AUTH_REFRESH_ROTATION_GRACE_SECONDS: 30,
63
65
  AUTH_REFRESH_COOKIE_NAME: 'shipfox_refresh_token',
@@ -93,6 +95,11 @@ const workspaces = {
93
95
  const login = (params: {email: string; password: string}) => coreLogin({...params, workspaces});
94
96
  const createSessionForUser = (params: {userId?: string; email?: string}) =>
95
97
  coreCreateSessionForUser({...params, workspaces});
98
+ const createImpersonatedSessionToken = (params: {
99
+ targetUserId: string;
100
+ impersonatorId: string;
101
+ expiresIn?: string;
102
+ }) => coreCreateImpersonatedSessionToken({...params, workspaces});
96
103
  const refreshAccessToken = (params: {refreshToken: string}) =>
97
104
  coreRefreshAccessToken({...params, workspaces});
98
105
  const confirmPasswordReset = (params: {token: string; newPassword: string}) =>
@@ -445,6 +452,78 @@ describe('auth core', () => {
445
452
  await Promise.all([unverifiedExpectation, suspendedExpectation]);
446
453
  });
447
454
 
455
+ test('createImpersonatedSessionToken mints a marked access-token-only session with a capped TTL', async () => {
456
+ const target = await userFactory.create({emailVerifiedAt: new Date()});
457
+ const impersonatorId = crypto.randomUUID();
458
+ const membership = {
459
+ workspaceId: crypto.randomUUID(),
460
+ role: 'admin' as const,
461
+ workspaceStatus: 'active' as const,
462
+ };
463
+ listMembershipsByUserMock.mockResolvedValue({memberships: [membership]});
464
+
465
+ const result = await createImpersonatedSessionToken({
466
+ targetUserId: target.id,
467
+ impersonatorId,
468
+ });
469
+
470
+ expect(result.user.id).toBe(target.id);
471
+ expect(result.expiresAt.getTime() - Date.now()).toBeGreaterThan(0);
472
+ expect(result.expiresAt.getTime() - Date.now()).toBeLessThanOrEqual(15 * 60 * 1000);
473
+
474
+ const claims = await verifyUserToken({token: result.token, secret: userAccessTokenKey()});
475
+ expect(claims.sub).toBe(target.id);
476
+ expect(claims.impersonatorId).toBe(impersonatorId);
477
+ expect(claims.refreshSessionId).toBeUndefined();
478
+ expect(claims.memberships).toEqual([membership]);
479
+ // TTL is capped at 15 minutes (AUTH_JWT_EXPIRES_IN is 15m in this suite).
480
+ expect(claims.exp - claims.iat).toBeLessThanOrEqual(15 * 60);
481
+ expect(claims.exp - claims.iat).toBeGreaterThan(0);
482
+ // The advertised expiry is the token's actual signed `exp`, never a
483
+ // clock-derived estimate: the response and the bearer token cannot diverge.
484
+ expect(result.expiresAt.getTime()).toBe(claims.exp * 1000);
485
+
486
+ // No refresh session row and no refresh token material for the target.
487
+ const sessions = await db()
488
+ .select({sessionId: refreshTokens.sessionId})
489
+ .from(refreshTokens)
490
+ .where(eq(refreshTokens.userId, target.id));
491
+ expect(sessions).toHaveLength(0);
492
+ });
493
+
494
+ test('createImpersonatedSessionToken re-signs with an explicit lifetime without extending the cap', async () => {
495
+ const target = await userFactory.create({emailVerifiedAt: new Date()});
496
+
497
+ const result = await createImpersonatedSessionToken({
498
+ targetUserId: target.id,
499
+ impersonatorId: crypto.randomUUID(),
500
+ expiresIn: '30m',
501
+ });
502
+
503
+ const claims = await verifyUserToken({token: result.token, secret: userAccessTokenKey()});
504
+ expect(claims.exp - claims.iat).toBeLessThanOrEqual(15 * 60);
505
+ });
506
+
507
+ test('createImpersonatedSessionToken reuses the createSessionForUser eligibility rules', async () => {
508
+ const unverified = await userFactory.create();
509
+ const suspended = await userFactory.create({emailVerifiedAt: new Date()});
510
+ await db().update(users).set({status: 'suspended'}).where(eq(users.id, suspended.id));
511
+ const impersonatorId = crypto.randomUUID();
512
+
513
+ await expect(
514
+ createImpersonatedSessionToken({targetUserId: unverified.id, impersonatorId}),
515
+ ).rejects.toBeInstanceOf(EmailNotVerifiedError);
516
+ await expect(
517
+ createImpersonatedSessionToken({targetUserId: suspended.id, impersonatorId}),
518
+ ).rejects.toBeInstanceOf(InvalidCredentialsError);
519
+ await expect(
520
+ createImpersonatedSessionToken({
521
+ targetUserId: crypto.randomUUID(),
522
+ impersonatorId,
523
+ }),
524
+ ).rejects.toBeInstanceOf(UserNotFoundError);
525
+ });
526
+
448
527
  test('refreshAccessToken rotates the refresh token', async () => {
449
528
  const user = await userFactory.create({emailVerifiedAt: new Date()});
450
529
  const loginResult = await login({email: user.email, password: user.plainPassword});
package/src/core/auth.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from '@shipfox/api-workspaces-dto/inter-module';
12
12
  import {isInterModuleKnownError} from '@shipfox/inter-module';
13
13
  import {userAccessTokenKey} from '@shipfox/node-auth-root-key';
14
+ import {durationToSeconds} from '@shipfox/node-jwt';
14
15
  import {generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
15
16
  import {config} from '#config.js';
16
17
  import {consumePasswordReset, createPasswordReset} from '#db/password-resets.js';
@@ -38,6 +39,7 @@ import {
38
39
  AuthDependencyUnavailableError,
39
40
  EmailNotVerifiedError,
40
41
  EmailTakenError,
42
+ ImpersonationDisabledError,
41
43
  InvalidCredentialsError,
42
44
  InvitationEmailMismatchError,
43
45
  SignupNotAllowedError,
@@ -46,7 +48,7 @@ import {
46
48
  TokenInvalidError,
47
49
  UserNotFoundError,
48
50
  } from './errors.js';
49
- import {signUserToken, type TokenMembership} from './jwt.js';
51
+ import {signUserToken, type TokenMembership, verifyUserToken} from './jwt.js';
50
52
  import {hashPassword, verifyPassword} from './password.js';
51
53
  import type {SignupPolicy} from './ports.js';
52
54
  import {createEnvironmentSignupPolicy} from './signup-policy.js';
@@ -461,6 +463,101 @@ export async function createSessionForUser(
461
463
  return {token, refreshToken, user, adminRole};
462
464
  }
463
465
 
466
+ /**
467
+ * Cap for impersonated session tokens: the TTL is min(`AUTH_JWT_EXPIRES_IN`,
468
+ * this), so an impersonated window can never outlive 15 minutes no matter how
469
+ * the deployment configures ordinary access tokens.
470
+ */
471
+ export const IMPERSONATION_MAX_TTL_SECONDS = 15 * 60;
472
+
473
+ export interface CreateImpersonatedSessionTokenParams {
474
+ targetUserId: string;
475
+ /** The administrator the token is minted for; signed into the `impersonatorId` claim. */
476
+ impersonatorId: string;
477
+ workspaces: WorkspacesInterModuleClient;
478
+ /**
479
+ * Re-sign lifetime override used by an idempotent replay: the remaining
480
+ * lifetime to the stored `expires_at`, so a replay never extends the window.
481
+ * Defaults to min(`AUTH_JWT_EXPIRES_IN`, 15 minutes).
482
+ */
483
+ expiresIn?: string | undefined;
484
+ }
485
+
486
+ export interface CreateImpersonatedSessionTokenResult {
487
+ token: string;
488
+ expiresAt: Date;
489
+ user: User;
490
+ }
491
+
492
+ function impersonationTtlSeconds(): number {
493
+ const configuredSeconds = durationToSeconds(config.AUTH_JWT_EXPIRES_IN);
494
+ const ttlSeconds = Math.min(configuredSeconds, IMPERSONATION_MAX_TTL_SECONDS);
495
+ if (ttlSeconds <= 0) {
496
+ throw new TypeError(
497
+ `AUTH_JWT_EXPIRES_IN must be a valid duration of at least 1 second, got ${config.AUTH_JWT_EXPIRES_IN}`,
498
+ );
499
+ }
500
+ return ttlSeconds;
501
+ }
502
+
503
+ /**
504
+ * Mints an access-token-only impersonated session for a target user: the same
505
+ * eligibility as login (active account, verified email), the target's real
506
+ * membership claims, a capped TTL, the `impersonatorId` claim, and **no**
507
+ * `refreshSessionId`. It creates no refresh session and sets no cookie, so
508
+ * nothing persisted can resurrect the session after the token window.
509
+ */
510
+ export async function createImpersonatedSessionToken(
511
+ params: CreateImpersonatedSessionTokenParams,
512
+ ): Promise<CreateImpersonatedSessionTokenResult> {
513
+ // Rule 1 lives in the mint primitive as well as the command entry, so the
514
+ // exported package API can never bypass the kill switch: the flag is a
515
+ // configuration read that also holds on the in-transaction replay path.
516
+ if (!config.AUTH_IMPERSONATION_ENABLED) throw new ImpersonationDisabledError();
517
+
518
+ const user = await findUserById({id: params.targetUserId});
519
+ if (!user) {
520
+ throw new UserNotFoundError(params.targetUserId);
521
+ }
522
+ if (user.emailVerifiedAt === null) {
523
+ throw new EmailNotVerifiedError();
524
+ }
525
+ if (user.status !== 'active') {
526
+ throw new InvalidCredentialsError();
527
+ }
528
+
529
+ const memberships = await loadTokenMemberships(user.id, params.workspaces);
530
+ const ttlSeconds =
531
+ params.expiresIn === undefined
532
+ ? impersonationTtlSeconds()
533
+ : Math.min(durationToSeconds(params.expiresIn), IMPERSONATION_MAX_TTL_SECONDS);
534
+ if (ttlSeconds <= 0) {
535
+ throw new TypeError(
536
+ `Impersonation token TTL must be at least 1 second, got ${params.expiresIn}`,
537
+ );
538
+ }
539
+
540
+ const token = await signUserToken({
541
+ userId: user.id,
542
+ email: user.email,
543
+ name: user.name,
544
+ memberships,
545
+ impersonatorId: params.impersonatorId,
546
+ secret: userAccessTokenKey(),
547
+ expiresIn: `${ttlSeconds}s`,
548
+ });
549
+
550
+ // The advertised expiry is the token's actual signed `exp`, never a
551
+ // clock-derived estimate: the signer stamps `iat`/`exp` in whole seconds, so
552
+ // `Date.now() + ttl` could drift up to a second from the signed claims in
553
+ // either direction. Deriving `expiresAt` from the signed token keeps the
554
+ // response, the stored command result, and the bearer token exactly aligned,
555
+ // which is what lets a replay re-sign with a TTL that never extends the
556
+ // window (`exp` of the re-signed token is at most the original `exp`).
557
+ const claims = await verifyUserToken({token, secret: userAccessTokenKey()});
558
+ return {token, expiresAt: new Date(claims.exp * 1000), user};
559
+ }
560
+
464
561
  export interface RefreshAccessTokenResult {
465
562
  token: string;
466
563
  /** Undefined on a grace-window hit: keep the existing cookie instead of rotating it. */
@@ -137,6 +137,41 @@ export class AdminIdempotencyKeyReuseError extends Error {
137
137
  }
138
138
  }
139
139
 
140
+ export class ImpersonationDisabledError extends Error {
141
+ constructor() {
142
+ super('Impersonation is disabled');
143
+ this.name = 'ImpersonationDisabledError';
144
+ }
145
+ }
146
+
147
+ export class CannotImpersonateSelfError extends Error {
148
+ constructor() {
149
+ super('Cannot impersonate yourself');
150
+ this.name = 'CannotImpersonateSelfError';
151
+ }
152
+ }
153
+
154
+ export class CannotImpersonateAdministratorError extends Error {
155
+ constructor() {
156
+ super('Cannot impersonate an administrator');
157
+ this.name = 'CannotImpersonateAdministratorError';
158
+ }
159
+ }
160
+
161
+ export class ImpersonationTargetNotActiveError extends Error {
162
+ constructor() {
163
+ super('Impersonation target is not active or verified');
164
+ this.name = 'ImpersonationTargetNotActiveError';
165
+ }
166
+ }
167
+
168
+ export class ImpersonationExpiredError extends Error {
169
+ constructor() {
170
+ super('Impersonation session has expired');
171
+ this.name = 'ImpersonationExpiredError';
172
+ }
173
+ }
174
+
140
175
  export class TokenInvalidError extends Error {
141
176
  constructor(reason?: string) {
142
177
  super(reason ? `Invalid token: ${reason}` : 'Invalid token');
@@ -31,10 +31,102 @@ describe('jwt', () => {
31
31
  expect(claims.name).toBe('Token User');
32
32
  expect(claims.memberships).toEqual(memberships);
33
33
  expect(claims).not.toHaveProperty('adminRole');
34
+ expect(claims.impersonatorId).toBeUndefined();
34
35
  expect(claims.iat).toBeTypeOf('number');
35
36
  expect(claims.exp).toBeGreaterThan(claims.iat);
36
37
  });
37
38
 
39
+ test('signs and verifies a token carrying an impersonatorId claim', async () => {
40
+ const userId = crypto.randomUUID();
41
+ const impersonatorId = crypto.randomUUID();
42
+
43
+ const token = await signUserToken({
44
+ userId,
45
+ impersonatorId,
46
+ email: `jwt-${crypto.randomUUID()}@example.com`,
47
+ memberships: [],
48
+ secret: SECRET,
49
+ expiresIn: '15m',
50
+ });
51
+ const claims = await verifyUserToken({token, secret: SECRET});
52
+
53
+ expect(claims.sub).toBe(userId);
54
+ expect(claims.impersonatorId).toBe(impersonatorId);
55
+ });
56
+
57
+ test('rejects a non-UUID impersonatorId at signing time', async () => {
58
+ await expect(
59
+ signUserToken({
60
+ userId: crypto.randomUUID(),
61
+ impersonatorId: 'not-a-uuid',
62
+ email: `jwt-${crypto.randomUUID()}@example.com`,
63
+ memberships: [],
64
+ secret: SECRET,
65
+ expiresIn: '15m',
66
+ }),
67
+ ).rejects.toThrow('impersonatorId must be a UUID');
68
+ });
69
+
70
+ test('rejects a self-impersonation impersonatorId at signing time', async () => {
71
+ const userId = crypto.randomUUID();
72
+ await expect(
73
+ signUserToken({
74
+ userId,
75
+ impersonatorId: userId,
76
+ email: `jwt-${crypto.randomUUID()}@example.com`,
77
+ memberships: [],
78
+ secret: SECRET,
79
+ expiresIn: '15m',
80
+ }),
81
+ ).rejects.toThrow('impersonatorId must differ from userId');
82
+ });
83
+
84
+ test('rejects a self-impersonation impersonatorId in a different UUID casing', async () => {
85
+ const userId = crypto.randomUUID();
86
+ await expect(
87
+ signUserToken({
88
+ userId,
89
+ impersonatorId: userId.toUpperCase(),
90
+ email: `jwt-${crypto.randomUUID()}@example.com`,
91
+ memberships: [],
92
+ secret: SECRET,
93
+ expiresIn: '15m',
94
+ }),
95
+ ).rejects.toThrow('impersonatorId must differ from userId');
96
+ });
97
+
98
+ test('rejects a crafted token whose impersonatorId equals sub', async () => {
99
+ const userId = crypto.randomUUID();
100
+ const token = await new SignJWT({
101
+ email: `jwt-${crypto.randomUUID()}@example.com`,
102
+ memberships: [],
103
+ impersonatorId: userId,
104
+ })
105
+ .setProtectedHeader({alg: 'HS256'})
106
+ .setSubject(userId)
107
+ .setIssuedAt()
108
+ .setExpirationTime('7d')
109
+ .sign(encodeSecret(SECRET));
110
+
111
+ await expect(verifyUserToken({token, secret: SECRET})).rejects.toThrow();
112
+ });
113
+
114
+ test('rejects a crafted token whose impersonatorId equals sub in a different casing', async () => {
115
+ const userId = crypto.randomUUID();
116
+ const token = await new SignJWT({
117
+ email: `jwt-${crypto.randomUUID()}@example.com`,
118
+ memberships: [],
119
+ impersonatorId: userId.toUpperCase(),
120
+ })
121
+ .setProtectedHeader({alg: 'HS256'})
122
+ .setSubject(userId)
123
+ .setIssuedAt()
124
+ .setExpirationTime('7d')
125
+ .sign(encodeSecret(SECRET));
126
+
127
+ await expect(verifyUserToken({token, secret: SECRET})).rejects.toThrow();
128
+ });
129
+
38
130
  test('signs and verifies a token with empty memberships', async () => {
39
131
  const userId = crypto.randomUUID();
40
132
  const email = `jwt-${crypto.randomUUID()}@example.com`;