@shipfox/api-auth 15.0.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +20 -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/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 +5 -5
- 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/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
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import {impersonateResponseSchema} from '@shipfox/api-auth-dto';
|
|
1
2
|
import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
|
|
2
|
-
import {
|
|
3
|
+
import {userAccessTokenKey} from '@shipfox/node-auth-root-key';
|
|
4
|
+
import {hashOpaqueToken} from '@shipfox/node-tokens';
|
|
5
|
+
import {asc, eq, sql} from 'drizzle-orm';
|
|
3
6
|
import type {FastifyInstance} from 'fastify';
|
|
7
|
+
import {signUserToken, verifyUserToken} from '#core/jwt.js';
|
|
4
8
|
import {type AuthRateLimitAction, hashAuthRateLimitIdentifier} from '#core/rate-limit.js';
|
|
5
9
|
import {db} from '#db/db.js';
|
|
10
|
+
import {adminCommandResults} from '#db/schema/admin-command-results.js';
|
|
11
|
+
import {adminGrants} from '#db/schema/admin-grants.js';
|
|
6
12
|
import {authOutbox} from '#db/schema/outbox.js';
|
|
7
13
|
import {authRateLimits} from '#db/schema/rate-limits.js';
|
|
8
14
|
import {refreshTokens} from '#db/schema/refresh-tokens.js';
|
|
@@ -13,7 +19,10 @@ import {
|
|
|
13
19
|
createVerifiedSession,
|
|
14
20
|
getSetCookie,
|
|
15
21
|
login,
|
|
22
|
+
ROUTE_TEST_SECRET,
|
|
16
23
|
resetCapturedMail,
|
|
24
|
+
setAuthJwtExpiresIn,
|
|
25
|
+
setImpersonationEnabled,
|
|
17
26
|
} from '#test/routes.js';
|
|
18
27
|
|
|
19
28
|
const BOOTSTRAP_TOKEN = 'test-bootstrap-token';
|
|
@@ -31,22 +40,36 @@ function authHeaders(token: string, idempotencyKey: string) {
|
|
|
31
40
|
};
|
|
32
41
|
}
|
|
33
42
|
|
|
43
|
+
function impersonatedToken(userId: string, email: string): Promise<string> {
|
|
44
|
+
return signUserToken({
|
|
45
|
+
userId,
|
|
46
|
+
email,
|
|
47
|
+
name: 'Impersonated User',
|
|
48
|
+
memberships: [],
|
|
49
|
+
impersonatorId: crypto.randomUUID(),
|
|
50
|
+
secret: ROUTE_TEST_SECRET,
|
|
51
|
+
expiresIn: '15m',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
34
55
|
async function seedExhaustedIpBucket(params: {
|
|
35
56
|
action: AuthRateLimitAction;
|
|
36
57
|
identifier: string;
|
|
37
58
|
limit: number;
|
|
38
59
|
windowSeconds: number;
|
|
60
|
+
scope?: 'ip' | 'actor';
|
|
39
61
|
}): Promise<void> {
|
|
62
|
+
const scope = params.scope ?? 'ip';
|
|
40
63
|
const windowMs = params.windowSeconds * 1000;
|
|
41
64
|
const windowStart = new Date(Math.floor(Date.now() / windowMs) * windowMs);
|
|
42
65
|
await db()
|
|
43
66
|
.insert(authRateLimits)
|
|
44
67
|
.values({
|
|
45
68
|
action: params.action,
|
|
46
|
-
scope
|
|
69
|
+
scope,
|
|
47
70
|
identifierHmac: hashAuthRateLimitIdentifier({
|
|
48
71
|
action: params.action,
|
|
49
|
-
scope
|
|
72
|
+
scope,
|
|
50
73
|
identifier: params.identifier,
|
|
51
74
|
}),
|
|
52
75
|
windowStart,
|
|
@@ -64,6 +87,8 @@ describe('Auth administration routes', () => {
|
|
|
64
87
|
|
|
65
88
|
beforeEach(async () => {
|
|
66
89
|
resetCapturedMail();
|
|
90
|
+
setImpersonationEnabled(true);
|
|
91
|
+
setAuthJwtExpiresIn('15m');
|
|
67
92
|
await resetAdministrationState();
|
|
68
93
|
});
|
|
69
94
|
|
|
@@ -92,6 +117,92 @@ describe('Auth administration routes', () => {
|
|
|
92
117
|
expect(response.statusCode).toBe(401);
|
|
93
118
|
});
|
|
94
119
|
|
|
120
|
+
test('rejects an impersonated session on the bootstrap route before any side effect', async () => {
|
|
121
|
+
const account = await createVerifiedSession('admin-bootstrap-impersonated');
|
|
122
|
+
|
|
123
|
+
const response = await app.inject({
|
|
124
|
+
method: 'POST',
|
|
125
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
126
|
+
headers: authHeaders(
|
|
127
|
+
await impersonatedToken(account.userId, account.email),
|
|
128
|
+
'impersonated-bootstrap',
|
|
129
|
+
),
|
|
130
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(response.statusCode).toBe(403);
|
|
134
|
+
expect(response.json()).toEqual({code: 'admin-role-required'});
|
|
135
|
+
await expect(db().select().from(adminGrants)).resolves.toHaveLength(0);
|
|
136
|
+
await expect(db().select().from(authOutbox)).resolves.toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('rejects an impersonated session on the bootstrap-state route', async () => {
|
|
140
|
+
const account = await createVerifiedSession('admin-bootstrap-state-impersonated');
|
|
141
|
+
|
|
142
|
+
const response = await app.inject({
|
|
143
|
+
method: 'GET',
|
|
144
|
+
url: '/admin/auth/bootstrap-state',
|
|
145
|
+
headers: {authorization: `Bearer ${await impersonatedToken(account.userId, account.email)}`},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(response.statusCode).toBe(403);
|
|
149
|
+
expect(response.json()).toEqual({code: 'admin-role-required'});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('rejects an impersonated session on user administration before roles are consulted', async () => {
|
|
153
|
+
const owner = await createVerifiedSession('admin-users-impersonated');
|
|
154
|
+
|
|
155
|
+
const bootstrap = await app.inject({
|
|
156
|
+
method: 'POST',
|
|
157
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
158
|
+
headers: authHeaders(owner.token, 'users-impersonated-bootstrap'),
|
|
159
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
160
|
+
});
|
|
161
|
+
expect(bootstrap.statusCode).toBe(201);
|
|
162
|
+
|
|
163
|
+
const response = await app.inject({
|
|
164
|
+
method: 'GET',
|
|
165
|
+
url: `/admin/auth/users?user_id=${owner.userId}`,
|
|
166
|
+
headers: {authorization: `Bearer ${await impersonatedToken(owner.userId, owner.email)}`},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// The impersonated subject is an owner, so a guard that consulted roles
|
|
170
|
+
// would let the request through; rejection proves the mark wins first.
|
|
171
|
+
expect(response.statusCode).toBe(403);
|
|
172
|
+
expect(response.json()).toEqual({code: 'admin-role-required'});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('rejects an impersonated session on the grant route before any side effect', async () => {
|
|
176
|
+
const owner = await createVerifiedSession('admin-grant-impersonated');
|
|
177
|
+
const target = await createVerifiedSession('admin-grant-impersonated-target');
|
|
178
|
+
|
|
179
|
+
const bootstrap = await app.inject({
|
|
180
|
+
method: 'POST',
|
|
181
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
182
|
+
headers: authHeaders(owner.token, 'grant-impersonated-bootstrap'),
|
|
183
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
184
|
+
});
|
|
185
|
+
expect(bootstrap.statusCode).toBe(201);
|
|
186
|
+
|
|
187
|
+
const response = await app.inject({
|
|
188
|
+
method: 'POST',
|
|
189
|
+
url: '/admin/auth/admin-grants',
|
|
190
|
+
headers: authHeaders(
|
|
191
|
+
await impersonatedToken(owner.userId, owner.email),
|
|
192
|
+
'impersonated-grant',
|
|
193
|
+
),
|
|
194
|
+
payload: {user_id: target.userId, role: 'admin-observer', reason: 'Impersonated attempt'},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// The impersonated subject is an owner, so a guard that consulted roles
|
|
198
|
+
// would let the grant through; rejection proves the mark wins first and
|
|
199
|
+
// that no grant or audit event is left behind.
|
|
200
|
+
expect(response.statusCode).toBe(403);
|
|
201
|
+
expect(response.json()).toEqual({code: 'admin-role-required'});
|
|
202
|
+
await expect(db().select().from(adminGrants)).resolves.toHaveLength(1);
|
|
203
|
+
await expect(db().select().from(authOutbox)).resolves.toHaveLength(1);
|
|
204
|
+
});
|
|
205
|
+
|
|
95
206
|
test('reports only available or closed bootstrap state', async () => {
|
|
96
207
|
const account = await createVerifiedSession('admin-bootstrap-state');
|
|
97
208
|
|
|
@@ -913,4 +1024,718 @@ describe('Auth administration routes', () => {
|
|
|
913
1024
|
expect(response.statusCode).toBe(403);
|
|
914
1025
|
expect(response.json().code).toBe('forbidden');
|
|
915
1026
|
});
|
|
1027
|
+
|
|
1028
|
+
function impersonateMint(params: {
|
|
1029
|
+
token: string;
|
|
1030
|
+
targetUserId: string;
|
|
1031
|
+
idempotencyKey: string;
|
|
1032
|
+
reason?: string;
|
|
1033
|
+
}) {
|
|
1034
|
+
return app.inject({
|
|
1035
|
+
method: 'POST',
|
|
1036
|
+
url: `/admin/auth/users/${params.targetUserId}/impersonate`,
|
|
1037
|
+
headers: authHeaders(params.token, params.idempotencyKey),
|
|
1038
|
+
payload: {reason: params.reason ?? 'Support reproduction'},
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async function bootstrapOwner(prefix: string): Promise<string> {
|
|
1043
|
+
const owner = await createVerifiedSession(`${prefix}-owner`);
|
|
1044
|
+
const bootstrap = await app.inject({
|
|
1045
|
+
method: 'POST',
|
|
1046
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
1047
|
+
headers: authHeaders(owner.token, `${prefix}-bootstrap`),
|
|
1048
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
1049
|
+
});
|
|
1050
|
+
expect(bootstrap.statusCode).toBe(201);
|
|
1051
|
+
return owner.token;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
type ImpersonationEventPayload = {
|
|
1055
|
+
command: string;
|
|
1056
|
+
result: 'succeeded' | 'failed';
|
|
1057
|
+
idempotencyKeyFingerprint: string;
|
|
1058
|
+
actorId: string;
|
|
1059
|
+
actorRole: string;
|
|
1060
|
+
requiredRole: string;
|
|
1061
|
+
targetType: string;
|
|
1062
|
+
targetId: string;
|
|
1063
|
+
reason: string;
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
function impersonationEvents(): Promise<ImpersonationEventPayload[]> {
|
|
1067
|
+
return db()
|
|
1068
|
+
.select()
|
|
1069
|
+
.from(authOutbox)
|
|
1070
|
+
.orderBy(asc(authOutbox.createdAt))
|
|
1071
|
+
.then((rows) =>
|
|
1072
|
+
rows
|
|
1073
|
+
.filter(
|
|
1074
|
+
(event) =>
|
|
1075
|
+
event.eventType === ADMINISTRATION_ACTION_PERFORMED &&
|
|
1076
|
+
(event.payload as {command?: string}).command === 'auth.user.impersonate',
|
|
1077
|
+
)
|
|
1078
|
+
.map((event) => event.payload as ImpersonationEventPayload),
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
test('mints an impersonated session with the contract shape, no cookie, and no refresh material', async () => {
|
|
1083
|
+
const ownerToken = await bootstrapOwner('impersonate-mint');
|
|
1084
|
+
const target = await createVerifiedSession('impersonate-mint-target');
|
|
1085
|
+
|
|
1086
|
+
const minted = await impersonateMint({
|
|
1087
|
+
token: ownerToken,
|
|
1088
|
+
targetUserId: target.userId,
|
|
1089
|
+
idempotencyKey: 'impersonate-mint',
|
|
1090
|
+
reason: 'Support reproduction',
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
expect(minted.statusCode).toBe(200);
|
|
1094
|
+
expect(minted.headers['set-cookie']).toBeUndefined();
|
|
1095
|
+
const body = impersonateResponseSchema.parse(minted.json());
|
|
1096
|
+
expect(body.impersonator_id).not.toBe(target.userId);
|
|
1097
|
+
expect(body.user.id).toBe(target.userId);
|
|
1098
|
+
const ttlMs = Date.parse(body.expires_at) - Date.parse(body.server_time);
|
|
1099
|
+
expect(ttlMs).toBeGreaterThan(0);
|
|
1100
|
+
expect(ttlMs).toBeLessThanOrEqual(15 * 60 * 1000);
|
|
1101
|
+
|
|
1102
|
+
const me = await app.inject({
|
|
1103
|
+
method: 'GET',
|
|
1104
|
+
url: '/auth/me',
|
|
1105
|
+
headers: {authorization: `Bearer ${body.token}`},
|
|
1106
|
+
});
|
|
1107
|
+
expect(me.statusCode).toBe(200);
|
|
1108
|
+
expect(me.json()).toMatchObject({
|
|
1109
|
+
user: {id: target.userId, email: target.email},
|
|
1110
|
+
impersonator_id: body.impersonator_id,
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
// The minted token cannot be refreshed: no refresh session exists.
|
|
1114
|
+
const refresh = await app.inject({
|
|
1115
|
+
method: 'POST',
|
|
1116
|
+
url: '/auth/refresh',
|
|
1117
|
+
headers: {cookie: `shipfox_refresh_token=${body.token}`},
|
|
1118
|
+
payload: {},
|
|
1119
|
+
});
|
|
1120
|
+
expect(refresh.statusCode).toBe(401);
|
|
1121
|
+
const targetSessions = await db()
|
|
1122
|
+
.select()
|
|
1123
|
+
.from(refreshTokens)
|
|
1124
|
+
.where(eq(refreshTokens.userId, target.userId));
|
|
1125
|
+
// The fixture session's own refresh row is untouched: the mint creates none.
|
|
1126
|
+
expect(targetSessions).toHaveLength(1);
|
|
1127
|
+
expect(JSON.stringify(targetSessions)).not.toContain(hashOpaqueToken(body.token));
|
|
1128
|
+
|
|
1129
|
+
// One success event, redacted: no token, no raw key, no reason leak beyond the reason itself.
|
|
1130
|
+
const events = await impersonationEvents();
|
|
1131
|
+
expect(events).toHaveLength(1);
|
|
1132
|
+
expect(events[0]).toMatchObject({
|
|
1133
|
+
command: 'auth.user.impersonate',
|
|
1134
|
+
targetType: 'user',
|
|
1135
|
+
targetId: target.userId,
|
|
1136
|
+
requiredRole: 'admin-operator',
|
|
1137
|
+
reason: 'Support reproduction',
|
|
1138
|
+
result: 'succeeded',
|
|
1139
|
+
});
|
|
1140
|
+
expect(JSON.stringify(events[0])).not.toContain(body.token);
|
|
1141
|
+
expect(JSON.stringify(events[0])).not.toContain('impersonate-mint');
|
|
1142
|
+
|
|
1143
|
+
// The stored command result is fingerprint-only.
|
|
1144
|
+
const storedRows = await db()
|
|
1145
|
+
.select()
|
|
1146
|
+
.from(adminCommandResults)
|
|
1147
|
+
.where(eq(adminCommandResults.command, 'auth.user.impersonate'));
|
|
1148
|
+
expect(storedRows).toHaveLength(1);
|
|
1149
|
+
const stored = storedRows[0]?.result;
|
|
1150
|
+
if (!stored || !('impersonation' in stored)) throw new Error('Missing stored result');
|
|
1151
|
+
expect(stored.impersonation).toEqual({
|
|
1152
|
+
targetUserId: target.userId,
|
|
1153
|
+
expiresAt: body.expires_at,
|
|
1154
|
+
tokenFingerprints: [hashOpaqueToken(body.token)],
|
|
1155
|
+
});
|
|
1156
|
+
expect(JSON.stringify(storedRows[0]?.result)).not.toContain(body.token);
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
test('rejects the mint when AUTH_IMPERSONATION_ENABLED is false and audits the denial', async () => {
|
|
1160
|
+
const ownerToken = await bootstrapOwner('impersonate-disabled');
|
|
1161
|
+
const target = await createVerifiedSession('impersonate-disabled-target');
|
|
1162
|
+
setImpersonationEnabled(false);
|
|
1163
|
+
|
|
1164
|
+
const response = await impersonateMint({
|
|
1165
|
+
token: ownerToken,
|
|
1166
|
+
targetUserId: target.userId,
|
|
1167
|
+
idempotencyKey: 'impersonate-disabled',
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
expect(response.statusCode).toBe(403);
|
|
1171
|
+
expect(response.json()).toEqual({code: 'impersonation-disabled'});
|
|
1172
|
+
const events = await impersonationEvents();
|
|
1173
|
+
expect(events).toHaveLength(1);
|
|
1174
|
+
expect(events[0]).toMatchObject({result: 'failed', command: 'auth.user.impersonate'});
|
|
1175
|
+
await expect(
|
|
1176
|
+
db()
|
|
1177
|
+
.select()
|
|
1178
|
+
.from(adminCommandResults)
|
|
1179
|
+
.where(eq(adminCommandResults.command, 'auth.user.impersonate')),
|
|
1180
|
+
).resolves.toHaveLength(0);
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
test('rejects the mint for observers and audits the denial with the actor role', async () => {
|
|
1184
|
+
const ownerToken = await bootstrapOwner('impersonate-observer');
|
|
1185
|
+
const observer = await createVerifiedSession('impersonate-observer-actor');
|
|
1186
|
+
const target = await createVerifiedSession('impersonate-observer-target');
|
|
1187
|
+
await app.inject({
|
|
1188
|
+
method: 'POST',
|
|
1189
|
+
url: '/admin/auth/admin-grants',
|
|
1190
|
+
headers: authHeaders(ownerToken, 'impersonate-observer-grant'),
|
|
1191
|
+
payload: {
|
|
1192
|
+
user_id: observer.userId,
|
|
1193
|
+
role: 'admin-observer',
|
|
1194
|
+
reason: 'Read-only administration access',
|
|
1195
|
+
},
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
const response = await impersonateMint({
|
|
1199
|
+
token: observer.token,
|
|
1200
|
+
targetUserId: target.userId,
|
|
1201
|
+
idempotencyKey: 'impersonate-observer-mint',
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
expect(response.statusCode).toBe(403);
|
|
1205
|
+
expect(response.json()).toEqual({
|
|
1206
|
+
code: 'forbidden',
|
|
1207
|
+
details: {required_role: 'admin-operator'},
|
|
1208
|
+
});
|
|
1209
|
+
const events = await impersonationEvents();
|
|
1210
|
+
expect(events).toHaveLength(1);
|
|
1211
|
+
expect(events[0]).toMatchObject({
|
|
1212
|
+
actorId: observer.userId,
|
|
1213
|
+
actorRole: 'admin-observer',
|
|
1214
|
+
requiredRole: 'admin-operator',
|
|
1215
|
+
result: 'failed',
|
|
1216
|
+
});
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
test('rejects ineligible targets: unverified, suspended, and unknown users', async () => {
|
|
1220
|
+
const ownerToken = await bootstrapOwner('impersonate-ladder');
|
|
1221
|
+
const unverified = await createVerifiedSession('impersonate-ladder-unverified');
|
|
1222
|
+
await db().update(users).set({emailVerifiedAt: null}).where(eq(users.id, unverified.userId));
|
|
1223
|
+
const suspended = await createVerifiedSession('impersonate-ladder-suspended');
|
|
1224
|
+
await db().update(users).set({status: 'suspended'}).where(eq(users.id, suspended.userId));
|
|
1225
|
+
|
|
1226
|
+
const unverifiedTarget = await impersonateMint({
|
|
1227
|
+
token: ownerToken,
|
|
1228
|
+
targetUserId: unverified.userId,
|
|
1229
|
+
idempotencyKey: 'impersonate-ladder-unverified',
|
|
1230
|
+
});
|
|
1231
|
+
expect(unverifiedTarget.statusCode).toBe(403);
|
|
1232
|
+
expect(unverifiedTarget.json().code).toBe('impersonation-target-not-active');
|
|
1233
|
+
|
|
1234
|
+
const suspendedTarget = await impersonateMint({
|
|
1235
|
+
token: ownerToken,
|
|
1236
|
+
targetUserId: suspended.userId,
|
|
1237
|
+
idempotencyKey: 'impersonate-ladder-suspended',
|
|
1238
|
+
});
|
|
1239
|
+
expect(suspendedTarget.statusCode).toBe(403);
|
|
1240
|
+
expect(suspendedTarget.json().code).toBe('impersonation-target-not-active');
|
|
1241
|
+
|
|
1242
|
+
const missingTarget = await impersonateMint({
|
|
1243
|
+
token: ownerToken,
|
|
1244
|
+
targetUserId: crypto.randomUUID(),
|
|
1245
|
+
idempotencyKey: 'impersonate-ladder-missing',
|
|
1246
|
+
});
|
|
1247
|
+
expect(missingTarget.statusCode).toBe(404);
|
|
1248
|
+
expect(missingTarget.json().code).toBe('not-found');
|
|
1249
|
+
|
|
1250
|
+
// Every ladder denial by an identifiable admin role is audited with one
|
|
1251
|
+
// `failed` event carrying the actor, the reason, and the key fingerprint.
|
|
1252
|
+
const events = await impersonationEvents();
|
|
1253
|
+
expect(events).toHaveLength(2);
|
|
1254
|
+
expect(events.map((event) => event.result)).toEqual(['failed', 'failed']);
|
|
1255
|
+
expect(events.every((event) => event.actorRole === 'admin-owner')).toBe(true);
|
|
1256
|
+
expect(events.every((event) => event.reason === 'Support reproduction')).toBe(true);
|
|
1257
|
+
// The unknown-target 404 is a client-contract error, not a denial: it is
|
|
1258
|
+
// reported to the caller and never enters the failure-event stream.
|
|
1259
|
+
expect(
|
|
1260
|
+
events.some(
|
|
1261
|
+
(event) =>
|
|
1262
|
+
event.idempotencyKeyFingerprint === hashOpaqueToken('impersonate-ladder-missing'),
|
|
1263
|
+
),
|
|
1264
|
+
).toBe(false);
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
test('rejects impersonating an administrator target even for an owner', async () => {
|
|
1268
|
+
const ownerToken = await bootstrapOwner('impersonate-admin-target');
|
|
1269
|
+
const secondOwner = await createVerifiedSession('impersonate-admin-target-second');
|
|
1270
|
+
await app.inject({
|
|
1271
|
+
method: 'POST',
|
|
1272
|
+
url: '/admin/auth/admin-grants',
|
|
1273
|
+
headers: authHeaders(ownerToken, 'impersonate-admin-target-grant'),
|
|
1274
|
+
payload: {user_id: secondOwner.userId, role: 'admin-owner', reason: 'Second owner'},
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
const response = await impersonateMint({
|
|
1278
|
+
token: ownerToken,
|
|
1279
|
+
targetUserId: secondOwner.userId,
|
|
1280
|
+
idempotencyKey: 'impersonate-admin-target-mint',
|
|
1281
|
+
});
|
|
1282
|
+
expect(response.statusCode).toBe(403);
|
|
1283
|
+
expect(response.json().code).toBe('cannot-impersonate-administrator');
|
|
1284
|
+
const events = await impersonationEvents();
|
|
1285
|
+
expect(events).toHaveLength(1);
|
|
1286
|
+
expect(events[0]).toMatchObject({
|
|
1287
|
+
result: 'failed',
|
|
1288
|
+
actorRole: 'admin-owner',
|
|
1289
|
+
reason: 'Support reproduction',
|
|
1290
|
+
});
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
test('rejects self-impersonation with cannot-impersonate-self', async () => {
|
|
1294
|
+
const ownerToken = await bootstrapOwner('impersonate-self');
|
|
1295
|
+
const owner = await createVerifiedSession('impersonate-self-actor');
|
|
1296
|
+
await app.inject({
|
|
1297
|
+
method: 'POST',
|
|
1298
|
+
url: '/admin/auth/admin-grants',
|
|
1299
|
+
headers: authHeaders(ownerToken, 'impersonate-self-grant'),
|
|
1300
|
+
payload: {user_id: owner.userId, role: 'admin-operator', reason: 'Operator'},
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
const response = await impersonateMint({
|
|
1304
|
+
token: owner.token,
|
|
1305
|
+
targetUserId: owner.userId,
|
|
1306
|
+
idempotencyKey: 'impersonate-self-mint',
|
|
1307
|
+
});
|
|
1308
|
+
expect(response.statusCode).toBe(403);
|
|
1309
|
+
expect(response.json().code).toBe('cannot-impersonate-self');
|
|
1310
|
+
const events = await impersonationEvents();
|
|
1311
|
+
expect(events).toHaveLength(1);
|
|
1312
|
+
expect(events[0]).toMatchObject({
|
|
1313
|
+
result: 'failed',
|
|
1314
|
+
actorRole: 'admin-operator',
|
|
1315
|
+
reason: 'Support reproduction',
|
|
1316
|
+
});
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
test('replays in-window with the original expiry, a fresh signature, and its own event', async () => {
|
|
1320
|
+
const ownerToken = await bootstrapOwner('impersonate-replay');
|
|
1321
|
+
const target = await createVerifiedSession('impersonate-replay-target');
|
|
1322
|
+
|
|
1323
|
+
const minted = await impersonateMint({
|
|
1324
|
+
token: ownerToken,
|
|
1325
|
+
targetUserId: target.userId,
|
|
1326
|
+
idempotencyKey: 'impersonate-replay',
|
|
1327
|
+
});
|
|
1328
|
+
expect(minted.statusCode).toBe(200);
|
|
1329
|
+
const first = impersonateResponseSchema.parse(minted.json());
|
|
1330
|
+
|
|
1331
|
+
const replayed = await impersonateMint({
|
|
1332
|
+
token: ownerToken,
|
|
1333
|
+
targetUserId: target.userId,
|
|
1334
|
+
idempotencyKey: 'impersonate-replay',
|
|
1335
|
+
});
|
|
1336
|
+
expect(replayed.statusCode).toBe(200);
|
|
1337
|
+
const second = impersonateResponseSchema.parse(replayed.json());
|
|
1338
|
+
expect(second.expires_at).toBe(first.expires_at);
|
|
1339
|
+
expect(second.token).not.toBe(first.token);
|
|
1340
|
+
|
|
1341
|
+
// The re-signed token's `exp` never exceeds the advertised expiry: the
|
|
1342
|
+
// replay re-signs with the remaining lifetime to the stored `expires_at`,
|
|
1343
|
+
// and the initial mint advertises the token's actual signed `exp`.
|
|
1344
|
+
const firstClaims = await verifyUserToken({token: first.token, secret: userAccessTokenKey()});
|
|
1345
|
+
expect(firstClaims.exp * 1000).toBe(Date.parse(first.expires_at));
|
|
1346
|
+
const secondClaims = await verifyUserToken({token: second.token, secret: userAccessTokenKey()});
|
|
1347
|
+
expect(secondClaims.exp * 1000).toBeLessThanOrEqual(Date.parse(first.expires_at));
|
|
1348
|
+
|
|
1349
|
+
const me = await app.inject({
|
|
1350
|
+
method: 'GET',
|
|
1351
|
+
url: '/auth/me',
|
|
1352
|
+
headers: {authorization: `Bearer ${second.token}`},
|
|
1353
|
+
});
|
|
1354
|
+
expect(me.statusCode).toBe(200);
|
|
1355
|
+
expect(me.json().user.id).toBe(target.userId);
|
|
1356
|
+
|
|
1357
|
+
// Two events under one fingerprint (the second is the replay marker) and
|
|
1358
|
+
// two stored fingerprints; no raw token anywhere.
|
|
1359
|
+
const events = await impersonationEvents();
|
|
1360
|
+
expect(events).toHaveLength(2);
|
|
1361
|
+
expect(events[0]?.idempotencyKeyFingerprint).toBe(events[1]?.idempotencyKeyFingerprint);
|
|
1362
|
+
expect(events.every((event) => event.result === 'succeeded')).toBe(true);
|
|
1363
|
+
const storedRows = await db()
|
|
1364
|
+
.select()
|
|
1365
|
+
.from(adminCommandResults)
|
|
1366
|
+
.where(eq(adminCommandResults.command, 'auth.user.impersonate'));
|
|
1367
|
+
const stored = storedRows[0]?.result;
|
|
1368
|
+
if (!stored || !('impersonation' in stored)) throw new Error('Missing stored result');
|
|
1369
|
+
expect(stored.impersonation.tokenFingerprints).toEqual([
|
|
1370
|
+
hashOpaqueToken(first.token),
|
|
1371
|
+
hashOpaqueToken(second.token),
|
|
1372
|
+
]);
|
|
1373
|
+
expect(stored.impersonation.expiresAt).toBe(first.expires_at);
|
|
1374
|
+
expect(JSON.stringify(storedRows[0]?.result)).not.toContain(first.token);
|
|
1375
|
+
expect(JSON.stringify(storedRows[0]?.result)).not.toContain(second.token);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
test('replays after expiry with impersonation-expired', async () => {
|
|
1379
|
+
const ownerToken = await bootstrapOwner('impersonate-expired');
|
|
1380
|
+
const target = await createVerifiedSession('impersonate-expired-target');
|
|
1381
|
+
|
|
1382
|
+
const minted = await impersonateMint({
|
|
1383
|
+
token: ownerToken,
|
|
1384
|
+
targetUserId: target.userId,
|
|
1385
|
+
idempotencyKey: 'impersonate-expired',
|
|
1386
|
+
});
|
|
1387
|
+
expect(minted.statusCode).toBe(200);
|
|
1388
|
+
|
|
1389
|
+
const storedRows = await db()
|
|
1390
|
+
.select()
|
|
1391
|
+
.from(adminCommandResults)
|
|
1392
|
+
.where(eq(adminCommandResults.command, 'auth.user.impersonate'));
|
|
1393
|
+
const stored = storedRows[0]?.result;
|
|
1394
|
+
if (!stored || !('impersonation' in stored)) throw new Error('Missing stored result');
|
|
1395
|
+
await db()
|
|
1396
|
+
.update(adminCommandResults)
|
|
1397
|
+
.set({
|
|
1398
|
+
result: {
|
|
1399
|
+
impersonation: {
|
|
1400
|
+
...stored.impersonation,
|
|
1401
|
+
expiresAt: new Date(Date.now() - 1000).toISOString(),
|
|
1402
|
+
},
|
|
1403
|
+
},
|
|
1404
|
+
})
|
|
1405
|
+
.where(eq(adminCommandResults.id, storedRows[0]?.id ?? ''));
|
|
1406
|
+
|
|
1407
|
+
const replayed = await impersonateMint({
|
|
1408
|
+
token: ownerToken,
|
|
1409
|
+
targetUserId: target.userId,
|
|
1410
|
+
idempotencyKey: 'impersonate-expired',
|
|
1411
|
+
});
|
|
1412
|
+
expect(replayed.statusCode).toBe(410);
|
|
1413
|
+
expect(replayed.json().code).toBe('impersonation-expired');
|
|
1414
|
+
const events = await impersonationEvents();
|
|
1415
|
+
expect(events.at(-1)).toMatchObject({result: 'failed'});
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
test('a replay with a revoked operator grant fails closed and is audited', async () => {
|
|
1419
|
+
const ownerToken = await bootstrapOwner('impersonate-revoke');
|
|
1420
|
+
const operator = await createVerifiedSession('impersonate-revoke-operator');
|
|
1421
|
+
const target = await createVerifiedSession('impersonate-revoke-target');
|
|
1422
|
+
await app.inject({
|
|
1423
|
+
method: 'POST',
|
|
1424
|
+
url: '/admin/auth/admin-grants',
|
|
1425
|
+
headers: authHeaders(ownerToken, 'impersonate-revoke-grant'),
|
|
1426
|
+
payload: {user_id: operator.userId, role: 'admin-operator', reason: 'Operator'},
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
const minted = await impersonateMint({
|
|
1430
|
+
token: operator.token,
|
|
1431
|
+
targetUserId: target.userId,
|
|
1432
|
+
idempotencyKey: 'impersonate-revoke-mint',
|
|
1433
|
+
});
|
|
1434
|
+
expect(minted.statusCode).toBe(200);
|
|
1435
|
+
|
|
1436
|
+
// Revoke the operator's grant (keep the owner's), then replay the same key.
|
|
1437
|
+
const grants = await app.inject({
|
|
1438
|
+
method: 'GET',
|
|
1439
|
+
url: '/admin/auth/admin-grants',
|
|
1440
|
+
headers: {authorization: `Bearer ${ownerToken}`},
|
|
1441
|
+
});
|
|
1442
|
+
const grantId = (grants.json().grants as Array<{grant_id: string; user: {id: string}}>).find(
|
|
1443
|
+
(grant) => grant.user.id === operator.userId,
|
|
1444
|
+
)?.grant_id;
|
|
1445
|
+
expect(grantId).toBeDefined();
|
|
1446
|
+
const revoked = await app.inject({
|
|
1447
|
+
method: 'DELETE',
|
|
1448
|
+
url: `/admin/auth/admin-grants/${grantId}`,
|
|
1449
|
+
headers: authHeaders(ownerToken, 'impersonate-revoke-revoke'),
|
|
1450
|
+
payload: {reason: 'E2E grant revocation'},
|
|
1451
|
+
});
|
|
1452
|
+
expect(revoked.statusCode).toBe(200);
|
|
1453
|
+
|
|
1454
|
+
const replayed = await impersonateMint({
|
|
1455
|
+
token: operator.token,
|
|
1456
|
+
targetUserId: target.userId,
|
|
1457
|
+
idempotencyKey: 'impersonate-revoke-mint',
|
|
1458
|
+
});
|
|
1459
|
+
expect(replayed.statusCode).toBe(403);
|
|
1460
|
+
expect(replayed.json().code).toBe('forbidden');
|
|
1461
|
+
// The initial mint remains the only recorded event: the strict event
|
|
1462
|
+
// schema requires an actor role, and the actor is role-less after the
|
|
1463
|
+
// revocation, so the denial itself is the fail-closed record.
|
|
1464
|
+
const events = await impersonationEvents();
|
|
1465
|
+
expect(events).toHaveLength(1);
|
|
1466
|
+
expect(events[0]?.result).toBe('succeeded');
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
test('a replay with the flag turned off fails closed and is audited', async () => {
|
|
1470
|
+
const ownerToken = await bootstrapOwner('impersonate-flag-replay');
|
|
1471
|
+
const target = await createVerifiedSession('impersonate-flag-replay-target');
|
|
1472
|
+
|
|
1473
|
+
const minted = await impersonateMint({
|
|
1474
|
+
token: ownerToken,
|
|
1475
|
+
targetUserId: target.userId,
|
|
1476
|
+
idempotencyKey: 'impersonate-flag-replay',
|
|
1477
|
+
});
|
|
1478
|
+
expect(minted.statusCode).toBe(200);
|
|
1479
|
+
|
|
1480
|
+
setImpersonationEnabled(false);
|
|
1481
|
+
const replayed = await impersonateMint({
|
|
1482
|
+
token: ownerToken,
|
|
1483
|
+
targetUserId: target.userId,
|
|
1484
|
+
idempotencyKey: 'impersonate-flag-replay',
|
|
1485
|
+
});
|
|
1486
|
+
expect(replayed.statusCode).toBe(403);
|
|
1487
|
+
expect(replayed.json().code).toBe('impersonation-disabled');
|
|
1488
|
+
|
|
1489
|
+
const events = await impersonationEvents();
|
|
1490
|
+
expect(events).toHaveLength(2);
|
|
1491
|
+
expect(events[1]).toMatchObject({
|
|
1492
|
+
result: 'failed',
|
|
1493
|
+
idempotencyKeyFingerprint: events[0]?.idempotencyKeyFingerprint,
|
|
1494
|
+
});
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
test('a replay after the target is suspended mid-window fails closed and is audited', async () => {
|
|
1498
|
+
const ownerToken = await bootstrapOwner('impersonate-target-suspend');
|
|
1499
|
+
const target = await createVerifiedSession('impersonate-target-suspend-target');
|
|
1500
|
+
|
|
1501
|
+
const minted = await impersonateMint({
|
|
1502
|
+
token: ownerToken,
|
|
1503
|
+
targetUserId: target.userId,
|
|
1504
|
+
idempotencyKey: 'impersonate-target-suspend',
|
|
1505
|
+
});
|
|
1506
|
+
expect(minted.statusCode).toBe(200);
|
|
1507
|
+
|
|
1508
|
+
// Suspend the target mid-window, then replay the same key: the ladder
|
|
1509
|
+
// re-runs on every replay, so the suspension ends the capability.
|
|
1510
|
+
await db().update(users).set({status: 'suspended'}).where(eq(users.id, target.userId));
|
|
1511
|
+
|
|
1512
|
+
const replayed = await impersonateMint({
|
|
1513
|
+
token: ownerToken,
|
|
1514
|
+
targetUserId: target.userId,
|
|
1515
|
+
idempotencyKey: 'impersonate-target-suspend',
|
|
1516
|
+
});
|
|
1517
|
+
expect(replayed.statusCode).toBe(403);
|
|
1518
|
+
expect(replayed.json().code).toBe('impersonation-target-not-active');
|
|
1519
|
+
|
|
1520
|
+
const events = await impersonationEvents();
|
|
1521
|
+
expect(events).toHaveLength(2);
|
|
1522
|
+
expect(events[1]).toMatchObject({
|
|
1523
|
+
result: 'failed',
|
|
1524
|
+
actorRole: 'admin-owner',
|
|
1525
|
+
idempotencyKeyFingerprint: events[0]?.idempotencyKeyFingerprint,
|
|
1526
|
+
});
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
test('a replay after the target gains an administrator grant mid-window fails closed and is audited', async () => {
|
|
1530
|
+
const ownerToken = await bootstrapOwner('impersonate-target-grant');
|
|
1531
|
+
const target = await createVerifiedSession('impersonate-target-grant-target');
|
|
1532
|
+
|
|
1533
|
+
const minted = await impersonateMint({
|
|
1534
|
+
token: ownerToken,
|
|
1535
|
+
targetUserId: target.userId,
|
|
1536
|
+
idempotencyKey: 'impersonate-target-grant',
|
|
1537
|
+
});
|
|
1538
|
+
expect(minted.statusCode).toBe(200);
|
|
1539
|
+
|
|
1540
|
+
// Promote the target mid-window: the anti-escalation rule (no active
|
|
1541
|
+
// administrator grant on the target) must hold on the replay path too.
|
|
1542
|
+
await app.inject({
|
|
1543
|
+
method: 'POST',
|
|
1544
|
+
url: '/admin/auth/admin-grants',
|
|
1545
|
+
headers: authHeaders(ownerToken, 'impersonate-target-grant-grant'),
|
|
1546
|
+
payload: {user_id: target.userId, role: 'admin-operator', reason: 'Mid-window promotion'},
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
const replayed = await impersonateMint({
|
|
1550
|
+
token: ownerToken,
|
|
1551
|
+
targetUserId: target.userId,
|
|
1552
|
+
idempotencyKey: 'impersonate-target-grant',
|
|
1553
|
+
});
|
|
1554
|
+
expect(replayed.statusCode).toBe(403);
|
|
1555
|
+
expect(replayed.json().code).toBe('cannot-impersonate-administrator');
|
|
1556
|
+
|
|
1557
|
+
const events = await impersonationEvents();
|
|
1558
|
+
expect(events).toHaveLength(2);
|
|
1559
|
+
expect(events[1]).toMatchObject({
|
|
1560
|
+
result: 'failed',
|
|
1561
|
+
actorRole: 'admin-owner',
|
|
1562
|
+
idempotencyKeyFingerprint: events[0]?.idempotencyKeyFingerprint,
|
|
1563
|
+
});
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
test('mints with AUTH_JWT_EXPIRES_IN below the 15-minute cap', async () => {
|
|
1567
|
+
const ownerToken = await bootstrapOwner('impersonate-below-cap');
|
|
1568
|
+
const target = await createVerifiedSession('impersonate-below-cap-target');
|
|
1569
|
+
setAuthJwtExpiresIn('5m');
|
|
1570
|
+
|
|
1571
|
+
const minted = await impersonateMint({
|
|
1572
|
+
token: ownerToken,
|
|
1573
|
+
targetUserId: target.userId,
|
|
1574
|
+
idempotencyKey: 'impersonate-below-cap',
|
|
1575
|
+
});
|
|
1576
|
+
expect(minted.statusCode).toBe(200);
|
|
1577
|
+
const body = impersonateResponseSchema.parse(minted.json());
|
|
1578
|
+
const ttlSeconds = (Date.parse(body.expires_at) - Date.parse(body.server_time)) / 1000;
|
|
1579
|
+
expect(ttlSeconds).toBeGreaterThan(290);
|
|
1580
|
+
expect(ttlSeconds).toBeLessThanOrEqual(300);
|
|
1581
|
+
// The signed token carries the same whole-second expiry as the response.
|
|
1582
|
+
const claims = await verifyUserToken({token: body.token, secret: userAccessTokenKey()});
|
|
1583
|
+
expect(claims.exp - claims.iat).toBe(300);
|
|
1584
|
+
expect(new Date(claims.exp * 1000).toISOString()).toBe(body.expires_at);
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
test('rejects idempotency-key reuse across different targets', async () => {
|
|
1588
|
+
const ownerToken = await bootstrapOwner('impersonate-key-reuse');
|
|
1589
|
+
const first = await createVerifiedSession('impersonate-key-reuse-first');
|
|
1590
|
+
const second = await createVerifiedSession('impersonate-key-reuse-second');
|
|
1591
|
+
|
|
1592
|
+
const minted = await impersonateMint({
|
|
1593
|
+
token: ownerToken,
|
|
1594
|
+
targetUserId: first.userId,
|
|
1595
|
+
idempotencyKey: 'impersonate-key-reuse',
|
|
1596
|
+
});
|
|
1597
|
+
expect(minted.statusCode).toBe(200);
|
|
1598
|
+
|
|
1599
|
+
const reused = await impersonateMint({
|
|
1600
|
+
token: ownerToken,
|
|
1601
|
+
targetUserId: second.userId,
|
|
1602
|
+
idempotencyKey: 'impersonate-key-reuse',
|
|
1603
|
+
});
|
|
1604
|
+
expect(reused.statusCode).toBe(409);
|
|
1605
|
+
expect(reused.json().code).toBe('idempotency-key-reused');
|
|
1606
|
+
// The 409 is a client-contract error: no denial event is published under
|
|
1607
|
+
// the same fingerprint, so replay markers stay unambiguous.
|
|
1608
|
+
const events = await impersonationEvents();
|
|
1609
|
+
expect(events).toHaveLength(1);
|
|
1610
|
+
expect(events[0]?.result).toBe('succeeded');
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
test('rate-limits impersonation mints by source IP', async () => {
|
|
1614
|
+
const ownerToken = await bootstrapOwner('impersonate-rate-limit');
|
|
1615
|
+
const target = await createVerifiedSession('impersonate-rate-limit-target');
|
|
1616
|
+
|
|
1617
|
+
await seedExhaustedIpBucket({
|
|
1618
|
+
action: 'impersonate',
|
|
1619
|
+
identifier: '127.0.0.1',
|
|
1620
|
+
limit: 20,
|
|
1621
|
+
windowSeconds: 15 * 60,
|
|
1622
|
+
});
|
|
1623
|
+
const blocked = await impersonateMint({
|
|
1624
|
+
token: ownerToken,
|
|
1625
|
+
targetUserId: target.userId,
|
|
1626
|
+
idempotencyKey: 'impersonate-rate-limit',
|
|
1627
|
+
});
|
|
1628
|
+
expect(blocked.statusCode).toBe(429);
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
test('rate-limits impersonation mints per actor', async () => {
|
|
1632
|
+
const owner = await createVerifiedSession('impersonate-actor-limit-owner');
|
|
1633
|
+
const bootstrap = await app.inject({
|
|
1634
|
+
method: 'POST',
|
|
1635
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
1636
|
+
headers: authHeaders(owner.token, 'impersonate-actor-limit-bootstrap'),
|
|
1637
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
1638
|
+
});
|
|
1639
|
+
expect(bootstrap.statusCode).toBe(201);
|
|
1640
|
+
const target = await createVerifiedSession('impersonate-actor-limit-target');
|
|
1641
|
+
|
|
1642
|
+
await seedExhaustedIpBucket({
|
|
1643
|
+
action: 'impersonate',
|
|
1644
|
+
scope: 'actor',
|
|
1645
|
+
identifier: owner.userId,
|
|
1646
|
+
limit: 20,
|
|
1647
|
+
windowSeconds: 15 * 60,
|
|
1648
|
+
});
|
|
1649
|
+
const blocked = await impersonateMint({
|
|
1650
|
+
token: owner.token,
|
|
1651
|
+
targetUserId: target.userId,
|
|
1652
|
+
idempotencyKey: 'impersonate-actor-limit',
|
|
1653
|
+
});
|
|
1654
|
+
expect(blocked.statusCode).toBe(429);
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
test('rate-limits an impersonated-session probe on the mint route before the guard rejects it', async () => {
|
|
1658
|
+
const owner = await createVerifiedSession('impersonate-probe-owner');
|
|
1659
|
+
const target = await createVerifiedSession('impersonate-probe-target');
|
|
1660
|
+
const markedToken = await impersonatedToken(owner.userId, owner.email);
|
|
1661
|
+
|
|
1662
|
+
// The limiter runs ahead of the administration guard, so a marked-session
|
|
1663
|
+
// probe consumes the `impersonate` buckets and is throttled (429) instead
|
|
1664
|
+
// of being rejected by the guard first and bypassing the limiter (403).
|
|
1665
|
+
await seedExhaustedIpBucket({
|
|
1666
|
+
action: 'impersonate',
|
|
1667
|
+
identifier: '127.0.0.1',
|
|
1668
|
+
limit: 20,
|
|
1669
|
+
windowSeconds: 15 * 60,
|
|
1670
|
+
});
|
|
1671
|
+
const ipBlocked = await app.inject({
|
|
1672
|
+
method: 'POST',
|
|
1673
|
+
url: `/admin/auth/users/${target.userId}/impersonate`,
|
|
1674
|
+
headers: authHeaders(markedToken, 'impersonate-probe-mint'),
|
|
1675
|
+
payload: {reason: 'Nested impersonation attempt'},
|
|
1676
|
+
});
|
|
1677
|
+
expect(ipBlocked.statusCode).toBe(429);
|
|
1678
|
+
|
|
1679
|
+
// The per-actor bucket is consumed the same way: the marked token's
|
|
1680
|
+
// subject is the actor keying the bucket. The limiter enforces IP before
|
|
1681
|
+
// actor, and the first half exhausted the 127.0.0.1 IP bucket, so the
|
|
1682
|
+
// probe must come from a fresh source IP for the 429 to be attributable
|
|
1683
|
+
// to the seeded actor bucket alone.
|
|
1684
|
+
await seedExhaustedIpBucket({
|
|
1685
|
+
action: 'impersonate',
|
|
1686
|
+
scope: 'actor',
|
|
1687
|
+
identifier: owner.userId,
|
|
1688
|
+
limit: 20,
|
|
1689
|
+
windowSeconds: 15 * 60,
|
|
1690
|
+
});
|
|
1691
|
+
const actorBlocked = await app.inject({
|
|
1692
|
+
method: 'POST',
|
|
1693
|
+
url: `/admin/auth/users/${target.userId}/impersonate`,
|
|
1694
|
+
headers: authHeaders(markedToken, 'impersonate-probe-mint'),
|
|
1695
|
+
remoteAddress: '10.0.0.1',
|
|
1696
|
+
payload: {reason: 'Nested impersonation attempt'},
|
|
1697
|
+
});
|
|
1698
|
+
expect(actorBlocked.statusCode).toBe(429);
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
test('rejects an impersonated actor on the mint route before any side effect', async () => {
|
|
1702
|
+
const owner = await createVerifiedSession('impersonate-actor');
|
|
1703
|
+
const target = await createVerifiedSession('impersonate-actor-target');
|
|
1704
|
+
|
|
1705
|
+
const response = await app.inject({
|
|
1706
|
+
method: 'POST',
|
|
1707
|
+
url: `/admin/auth/users/${target.userId}/impersonate`,
|
|
1708
|
+
headers: authHeaders(
|
|
1709
|
+
await impersonatedToken(owner.userId, owner.email),
|
|
1710
|
+
'impersonate-actor-mint',
|
|
1711
|
+
),
|
|
1712
|
+
payload: {reason: 'Nested impersonation attempt'},
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
expect(response.statusCode).toBe(403);
|
|
1716
|
+
expect(response.json()).toEqual({code: 'admin-role-required'});
|
|
1717
|
+
await expect(db().select().from(adminCommandResults)).resolves.toHaveLength(0);
|
|
1718
|
+
await expect(db().select().from(authOutbox)).resolves.toHaveLength(0);
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
test('requires a bounded reason on the mint route', async () => {
|
|
1722
|
+
const ownerToken = await bootstrapOwner('impersonate-reason');
|
|
1723
|
+
const target = await createVerifiedSession('impersonate-reason-target');
|
|
1724
|
+
|
|
1725
|
+
const withoutReason = await app.inject({
|
|
1726
|
+
method: 'POST',
|
|
1727
|
+
url: `/admin/auth/users/${target.userId}/impersonate`,
|
|
1728
|
+
headers: authHeaders(ownerToken, 'impersonate-reason-missing'),
|
|
1729
|
+
payload: {},
|
|
1730
|
+
});
|
|
1731
|
+
expect(withoutReason.statusCode).toBe(400);
|
|
1732
|
+
|
|
1733
|
+
const withControlCharacter = await app.inject({
|
|
1734
|
+
method: 'POST',
|
|
1735
|
+
url: `/admin/auth/users/${target.userId}/impersonate`,
|
|
1736
|
+
headers: authHeaders(ownerToken, 'impersonate-reason-control'),
|
|
1737
|
+
payload: {reason: 'Support\u0000reproduction'},
|
|
1738
|
+
});
|
|
1739
|
+
expect(withControlCharacter.statusCode).toBe(400);
|
|
1740
|
+
});
|
|
916
1741
|
});
|