@shipfox/api-auth 15.0.0 → 18.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +30 -0
- package/README.md +41 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/core/administration.d.ts +24 -2
- package/dist/core/administration.d.ts.map +1 -1
- package/dist/core/administration.js +126 -3
- package/dist/core/administration.js.map +1 -1
- package/dist/core/auth.d.ts +31 -0
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +70 -2
- package/dist/core/auth.js.map +1 -1
- package/dist/core/errors.d.ts +15 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +30 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/jwt.d.ts +2 -0
- package/dist/core/jwt.d.ts.map +1 -1
- package/dist/core/jwt.js +22 -1
- package/dist/core/jwt.js.map +1 -1
- package/dist/core/runner-session-token.d.ts.map +1 -1
- package/dist/core/runner-session-token.js +2 -1
- package/dist/core/runner-session-token.js.map +1 -1
- package/dist/db/admin-command.d.ts +12 -4
- package/dist/db/admin-command.d.ts.map +1 -1
- package/dist/db/admin-command.js +10 -1
- package/dist/db/admin-command.js.map +1 -1
- package/dist/db/admin-grants.d.ts.map +1 -1
- package/dist/db/admin-grants.js +4 -1
- package/dist/db/admin-grants.js.map +1 -1
- package/dist/db/admin-user-moderation.d.ts +3 -0
- package/dist/db/admin-user-moderation.d.ts.map +1 -1
- package/dist/db/admin-user-moderation.js +6 -2
- package/dist/db/admin-user-moderation.js.map +1 -1
- package/dist/db/impersonation.d.ts +55 -0
- package/dist/db/impersonation.d.ts.map +1 -0
- package/dist/db/impersonation.js +212 -0
- package/dist/db/impersonation.js.map +1 -0
- package/dist/db/schema/admin-command-results.d.ts +16 -0
- package/dist/db/schema/admin-command-results.d.ts.map +1 -1
- package/dist/db/schema/admin-command-results.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/metrics/index.d.ts +1 -1
- package/dist/metrics/index.d.ts.map +1 -1
- package/dist/metrics/index.js +1 -1
- package/dist/metrics/index.js.map +1 -1
- package/dist/metrics/instance.d.ts +4 -2
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +8 -0
- package/dist/metrics/instance.js.map +1 -1
- package/dist/presentation/auth/bearer-token-auth.d.ts +2 -2
- package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
- package/dist/presentation/auth/bearer-token-auth.js +2 -2
- package/dist/presentation/auth/bearer-token-auth.js.map +1 -1
- package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
- package/dist/presentation/auth/jwt-auth.js +26 -2
- package/dist/presentation/auth/jwt-auth.js.map +1 -1
- package/dist/presentation/routes/administration.d.ts +2 -1
- package/dist/presentation/routes/administration.d.ts.map +1 -1
- package/dist/presentation/routes/administration.js +127 -19
- package/dist/presentation/routes/administration.js.map +1 -1
- package/dist/presentation/routes/rate-limit.d.ts +8 -0
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +44 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/presentation/routes/session/me.d.ts.map +1 -1
- package/dist/presentation/routes/session/me.js +2 -1
- package/dist/presentation/routes/session/me.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/src/config.ts +4 -0
- package/src/core/administration.ts +172 -1
- package/src/core/auth.test.ts +79 -0
- package/src/core/auth.ts +98 -1
- package/src/core/errors.ts +35 -0
- package/src/core/jwt.test.ts +92 -0
- package/src/core/jwt.ts +38 -9
- package/src/core/runner-session-token.test.ts +2 -0
- package/src/core/runner-session-token.ts +1 -0
- package/src/db/admin-command.ts +35 -6
- package/src/db/admin-grants.ts +5 -1
- package/src/db/admin-user-moderation.ts +8 -2
- package/src/db/impersonation.ts +323 -0
- package/src/db/schema/admin-command-results.ts +18 -0
- package/src/index.test.ts +1 -1
- package/src/index.ts +12 -3
- package/src/metrics/index.ts +2 -0
- package/src/metrics/instance.ts +13 -2
- package/src/presentation/auth/bearer-token-auth.ts +4 -4
- package/src/presentation/auth/jwt-auth.test.ts +191 -0
- package/src/presentation/auth/jwt-auth.ts +19 -1
- package/src/presentation/routes/administration.test.ts +828 -3
- package/src/presentation/routes/administration.ts +130 -11
- package/src/presentation/routes/rate-limit.ts +40 -0
- package/src/presentation/routes/session/login.test.ts +5 -0
- package/src/presentation/routes/session/me.test.ts +24 -0
- package/src/presentation/routes/session/me.ts +1 -0
- package/src/presentation/routes/session/refresh.test.ts +11 -0
- package/test/routes.ts +43 -16
- 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": "
|
|
4
|
+
"version": "18.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"drizzle-orm": "^0.45.2",
|
|
28
28
|
"zod": "^4.4.3",
|
|
29
29
|
"@shipfox/api-common-dto": "15.0.0",
|
|
30
|
-
"@shipfox/api-auth-context": "
|
|
31
|
-
"@shipfox/api-auth-dto": "
|
|
32
|
-
"@shipfox/api-email-challenges": "1.1.
|
|
30
|
+
"@shipfox/api-auth-context": "18.0.0",
|
|
31
|
+
"@shipfox/api-auth-dto": "18.0.0",
|
|
32
|
+
"@shipfox/api-email-challenges": "1.1.11",
|
|
33
33
|
"@shipfox/api-workspaces-dto": "15.0.0",
|
|
34
34
|
"@shipfox/inter-module": "0.2.3",
|
|
35
35
|
"@shipfox/config": "1.2.4",
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"@shipfox/node-module": "1.0.7",
|
|
43
43
|
"@shipfox/node-opentelemetry": "0.6.5",
|
|
44
44
|
"@shipfox/node-outbox": "0.2.6",
|
|
45
|
-
"@shipfox/node-postgres": "0.5.
|
|
45
|
+
"@shipfox/node-postgres": "0.5.1",
|
|
46
46
|
"@shipfox/node-rate-limit": "0.4.0",
|
|
47
|
-
"@shipfox/node-tokens": "0.
|
|
47
|
+
"@shipfox/node-tokens": "1.0.0"
|
|
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 {
|
|
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,
|
package/src/core/auth.test.ts
CHANGED
|
@@ -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. */
|
package/src/core/errors.ts
CHANGED
|
@@ -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');
|
package/src/core/jwt.test.ts
CHANGED
|
@@ -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`;
|