@tulipes/cli 0.1.1 → 0.2.0-rc.2
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/README.md +10 -7
- package/dist/agent.d.ts +12 -0
- package/dist/agent.js +29 -0
- package/dist/agent.js.map +1 -0
- package/dist/app-core.d.ts +8 -6
- package/dist/app-core.js +23 -14
- package/dist/app-core.js.map +1 -1
- package/dist/bin.js +3 -3
- package/dist/bin.js.map +1 -1
- package/dist/config-check.d.ts +8 -0
- package/dist/config-check.js +43 -0
- package/dist/config-check.js.map +1 -0
- package/dist/config-import.d.ts +20 -0
- package/dist/config-import.js +133 -0
- package/dist/config-import.js.map +1 -0
- package/dist/dev.d.ts +4 -4
- package/dist/dev.js +7 -10
- package/dist/dev.js.map +1 -1
- package/dist/init.d.ts +6 -11
- package/dist/init.js +11 -6422
- package/dist/init.js.map +1 -1
- package/dist/inspection.d.ts +9 -8
- package/dist/inspection.js +10 -11
- package/dist/inspection.js.map +1 -1
- package/dist/main.js +82 -11
- package/dist/main.js.map +1 -1
- package/dist/migrate.d.ts +30 -0
- package/dist/migrate.js +772 -0
- package/dist/migrate.js.map +1 -0
- package/dist/new-module.js +84 -43
- package/dist/new-module.js.map +1 -1
- package/dist/routes.d.ts +3 -5
- package/dist/routes.js +4 -6
- package/dist/routes.js.map +1 -1
- package/dist/spec.d.ts +2 -5
- package/dist/spec.js +13 -6
- package/dist/spec.js.map +1 -1
- package/dist/sync.d.ts +3 -11
- package/dist/sync.js +81 -20
- package/dist/sync.js.map +1 -1
- package/dist/template.d.ts +58 -0
- package/dist/template.js +244 -0
- package/dist/template.js.map +1 -0
- package/dist/update.d.ts +8 -3
- package/dist/update.js +111 -38
- package/dist/update.js.map +1 -1
- package/package.json +17 -16
- package/templates/AGENTS.md +68 -31
- package/templates/CLAUDE.md +5 -4
- package/templates/app/.envs/.env.development +18 -0
- package/templates/app/.envs/.env.production +19 -0
- package/templates/app/.envs/.env.staging +19 -0
- package/templates/app/README.md +327 -0
- package/templates/app/app.ts +3 -0
- package/templates/app/config/app.config.ts +73 -0
- package/templates/app/docs/agent-manifest.json +724 -0
- package/templates/app/ecosystem.config.cjs +60 -0
- package/templates/app/modules/agent/controllers/agent.controllers.ts +31 -0
- package/templates/app/modules/agent/i18n/en.json +5 -0
- package/templates/app/modules/agent/i18n/fr.json +5 -0
- package/templates/app/modules/agent/module.acl.ts +14 -0
- package/templates/app/modules/agent/package.json +21 -0
- package/templates/app/modules/agent/routes/agent.routes.ts +29 -0
- package/templates/app/modules/agent/schemas/agent.schemas.ts +9 -0
- package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +26 -0
- package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +44 -0
- package/templates/app/modules/auth/controllers/auth.controllers.ts +312 -0
- package/templates/app/modules/auth/helpers/access-check.ts +42 -0
- package/templates/app/modules/auth/helpers/auth.ts +48 -0
- package/templates/app/modules/auth/helpers/documents.ts +57 -0
- package/templates/app/modules/auth/helpers/passport.ts +180 -0
- package/templates/app/modules/auth/helpers/passwords.ts +58 -0
- package/templates/app/modules/auth/helpers/session-cache.ts +67 -0
- package/templates/app/modules/auth/helpers/tokens.ts +329 -0
- package/templates/app/modules/auth/i18n/en.json +19 -0
- package/templates/app/modules/auth/i18n/fr.json +19 -0
- package/templates/app/modules/auth/lib/index.ts +12 -0
- package/templates/app/modules/auth/models/credential.model.ts +31 -0
- package/templates/app/modules/auth/models/revoked-token.model.ts +20 -0
- package/templates/app/modules/auth/models/session.model.ts +42 -0
- package/templates/app/modules/auth/module.acl.ts +41 -0
- package/templates/app/modules/auth/module.config.ts +30 -0
- package/templates/app/modules/auth/package.json +61 -0
- package/templates/app/modules/auth/routes/auth.routes.ts +94 -0
- package/templates/app/modules/auth/schemas/auth.schemas.ts +37 -0
- package/templates/app/modules/auth/tests/auth.test.ts +1079 -0
- package/templates/app/modules/config/controllers/config.controllers.ts +89 -0
- package/templates/app/modules/config/helpers/audit.ts +41 -0
- package/templates/app/modules/config/helpers/describe.ts +72 -0
- package/templates/app/modules/config/i18n/en.json +6 -0
- package/templates/app/modules/config/i18n/fr.json +6 -0
- package/templates/app/modules/config/models/config-audit.model.ts +26 -0
- package/templates/app/modules/config/module.acl.ts +12 -0
- package/templates/app/modules/config/package.json +23 -0
- package/templates/app/modules/config/routes/config.routes.ts +37 -0
- package/templates/app/modules/config/schemas/config.schemas.ts +13 -0
- package/templates/app/modules/config/tests/config.test.ts +126 -0
- package/templates/app/modules/core/bootstrap/cache.bootstrap.ts +12 -0
- package/templates/app/modules/core/helpers/cache/index.ts +60 -0
- package/templates/app/modules/core/helpers/cache/memory-cache.ts +53 -0
- package/templates/app/modules/core/helpers/cache/redis-cache.ts +68 -0
- package/templates/app/modules/core/helpers/cache/types.ts +37 -0
- package/templates/app/modules/core/helpers/lazy.ts +13 -0
- package/templates/app/modules/core/helpers/logger/index.ts +115 -0
- package/templates/app/modules/core/helpers/mongoose/index.ts +27 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/base.plugin.ts +14 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/soft-delete.plugin.ts +124 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/timestamps.plugin.ts +13 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/types.ts +45 -0
- package/templates/app/modules/core/helpers/mongoose/register.ts +20 -0
- package/templates/app/modules/core/lib/index.ts +34 -0
- package/templates/app/modules/core/module.config.ts +24 -0
- package/templates/app/modules/core/package.json +80 -0
- package/templates/app/modules/core/routes/core.routes.ts +24 -0
- package/templates/app/modules/core/tests/core.test.ts +19 -0
- package/templates/app/modules/hello/agent/hello.agent.ts +33 -0
- package/templates/app/modules/hello/bootstrap/seed-greetings.bootstrap.ts +27 -0
- package/templates/app/modules/hello/controllers/greetings.controllers.ts +43 -0
- package/templates/app/modules/hello/controllers/hello.controllers.ts +50 -0
- package/templates/app/modules/hello/helpers/greeting.ts +8 -0
- package/templates/app/modules/hello/i18n/en.json +8 -0
- package/templates/app/modules/hello/i18n/fr.json +8 -0
- package/templates/app/modules/hello/models/greeting.model.ts +24 -0
- package/templates/app/modules/hello/module.acl.ts +16 -0
- package/templates/app/modules/hello/module.config.ts +14 -0
- package/templates/app/modules/hello/package.json +24 -0
- package/templates/app/modules/hello/queues/hello.queues.ts +38 -0
- package/templates/app/modules/hello/routes/greetings.routes.ts +25 -0
- package/templates/app/modules/hello/routes/hello.routes.ts +44 -0
- package/templates/app/modules/hello/sockets/hello.sockets.ts +21 -0
- package/templates/app/modules/hello/tests/hello.test.ts +19 -0
- package/templates/app/modules/security/bootstrap/rate-limit.bootstrap.ts +8 -0
- package/templates/app/modules/security/helpers/hardening.ts +65 -0
- package/templates/app/modules/security/helpers/proxy.ts +16 -0
- package/templates/app/modules/security/helpers/rate-limit.ts +150 -0
- package/templates/app/modules/security/module.config.ts +59 -0
- package/templates/app/modules/security/package.json +26 -0
- package/templates/app/modules/security/routes/security.routes.ts +42 -0
- package/templates/app/modules/security/tests/security.test.ts +174 -0
- package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +12 -0
- package/templates/app/modules/settings/controllers/settings.controllers.ts +58 -0
- package/templates/app/modules/settings/helpers/access-lease.ts +99 -0
- package/templates/app/modules/settings/helpers/log-stream.ts +272 -0
- package/templates/app/modules/settings/helpers/log-tail.ts +253 -0
- package/templates/app/modules/settings/i18n/en.json +3 -0
- package/templates/app/modules/settings/i18n/fr.json +3 -0
- package/templates/app/modules/settings/module.acl.ts +12 -0
- package/templates/app/modules/settings/package.json +21 -0
- package/templates/app/modules/settings/routes/settings.routes.ts +33 -0
- package/templates/app/modules/settings/schemas/settings.schemas.ts +8 -0
- package/templates/app/modules/settings/tests/settings.test.ts +409 -0
- package/templates/app/modules/users/controllers/users.controllers.ts +113 -0
- package/templates/app/modules/users/i18n/en.json +14 -0
- package/templates/app/modules/users/i18n/fr.json +14 -0
- package/templates/app/modules/users/models/user.model.ts +18 -0
- package/templates/app/modules/users/module.acl.ts +10 -0
- package/templates/app/modules/users/module.config.ts +11 -0
- package/templates/app/modules/users/package.json +24 -0
- package/templates/app/modules/users/queues/users.queues.ts +31 -0
- package/templates/app/modules/users/routes/users.routes.ts +60 -0
- package/templates/app/modules/users/schemas/users.schemas.ts +30 -0
- package/templates/app/modules/users/sockets/users.sockets.ts +18 -0
- package/templates/app/nginx/development.conf +44 -0
- package/templates/app/nginx/production.conf +75 -0
- package/templates/app/nginx/staging.conf +51 -0
- package/templates/app/package.json +58 -0
- package/templates/app/scripts/count-greetings.ts +29 -0
- package/templates/app/scripts/deploy-nginx.ts +143 -0
- package/templates/app/scripts/list-users.ts +34 -0
- package/templates/app/tests/app.test.ts +28 -0
- package/templates/app/tests/support.ts +36 -0
- package/templates/app/tsconfig.json +28 -0
- package/templates/app/vitest.config.ts +6 -0
- package/templates/app/worker.ts +6 -0
- package/templates/claude/skills/tulipes-agent-tool/SKILL.md +87 -0
- package/templates/claude/skills/tulipes-boot-errors/SKILL.md +11 -5
- package/templates/claude/skills/tulipes-config/SKILL.md +74 -0
- package/templates/claude/skills/tulipes-endpoint/SKILL.md +38 -25
- package/templates/claude/skills/tulipes-env-variable/SKILL.md +54 -26
- package/templates/claude/skills/tulipes-model/SKILL.md +11 -4
- package/templates/claude/skills/tulipes-module/SKILL.md +11 -10
- package/templates/claude/skills/tulipes-queue/SKILL.md +20 -6
- package/templates/claude/skills/tulipes-settings/SKILL.md +49 -100
- package/templates/claude/skills/tulipes-spec/SKILL.md +10 -12
- package/templates/minimal/README.md +21 -0
- package/templates/minimal/app.ts +3 -0
- package/templates/minimal/config/app.config.ts +16 -0
- package/templates/minimal/modules/agent/module.acl.ts +3 -0
- package/templates/minimal/modules/agent/package.json +14 -0
- package/templates/minimal/modules/agent/routes/agent.routes.ts +20 -0
- package/templates/minimal/modules/core/module.acl.ts +4 -0
- package/templates/minimal/modules/core/package.json +21 -0
- package/templates/minimal/modules/hello/i18n/en.json +3 -0
- package/templates/minimal/modules/hello/module.acl.ts +2 -0
- package/templates/minimal/modules/hello/package.json +11 -0
- package/templates/minimal/modules/hello/routes/health.routes.ts +9 -0
- package/templates/minimal/modules/hello/routes/hello.routes.ts +10 -0
- package/templates/minimal/modules/security/package.json +15 -0
- package/templates/minimal/modules/security/routes/security.routes.ts +8 -0
- package/templates/minimal/package.json +38 -0
- package/templates/minimal/tests/app.test.ts +52 -0
- package/templates/minimal/tsconfig.json +22 -0
- package/templates/minimal/vitest.config.ts +5 -0
- package/templates/overlays/no-queues/ecosystem.config.cjs +52 -0
- package/templates/overlays/no-queues/modules/hello/controllers/greetings.controllers.ts +41 -0
- package/templates/overlays/no-queues/modules/users/controllers/users.controllers.ts +107 -0
- package/templates/versions.json +11 -0
- package/dist/minimal.d.ts +0 -2
- package/dist/minimal.js +0 -2200
- package/dist/minimal.js.map +0 -1
- /package/templates/{browser-auth.md → app/docs/browser-auth.md} +0 -0
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { z } from "zod/v4";
|
|
4
|
+
import { afterAll, beforeAll, expect, test, vi } from "vitest";
|
|
5
|
+
import { boot } from "@tulipes/core/boot";
|
|
6
|
+
import { useTestApp } from "@tulipes/testing";
|
|
7
|
+
|
|
8
|
+
import { testEnv, testSettings } from "../../../tests/support.ts";
|
|
9
|
+
|
|
10
|
+
const rootDir = fileURLToPath(new URL("../../../", import.meta.url));
|
|
11
|
+
const app = useTestApp({ rootDir, env: testEnv(), settings: testSettings() }, { beforeAll, afterAll });
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
const tokens = z.object({
|
|
15
|
+
data: z.object({ access_token: z.string(), refresh_token: z.string() }),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function post(path: string, body: unknown, token?: string) {
|
|
19
|
+
return fetch(app.base + path, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"content-type": "application/json",
|
|
23
|
+
...(token ? { authorization: "Bearer " + token } : {}),
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function account() {
|
|
30
|
+
const credentials = {
|
|
31
|
+
email: randomBytes(12).toString("hex") + "@example.test",
|
|
32
|
+
password: randomBytes(24).toString("hex"),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const response = await post("/api/v1/auth/register", credentials);
|
|
36
|
+
|
|
37
|
+
expect(response.status).toBe(201);
|
|
38
|
+
|
|
39
|
+
const body = await response.json();
|
|
40
|
+
|
|
41
|
+
const { user } = z
|
|
42
|
+
.object({ data: z.object({ user: z.object({ id: z.string() }) }) })
|
|
43
|
+
.parse(body).data;
|
|
44
|
+
|
|
45
|
+
return { ...tokens.parse(body).data, ...credentials, userId: user.id };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function get(path: string, token: string) {
|
|
49
|
+
return fetch(app.base + path, { headers: { authorization: "Bearer " + token } });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function assertRejected(pair: {
|
|
53
|
+
access_token: string;
|
|
54
|
+
refresh_token: string;
|
|
55
|
+
}) {
|
|
56
|
+
expect((await get("/api/v1/auth/me", pair.access_token)).status).toBe(401);
|
|
57
|
+
|
|
58
|
+
expect(
|
|
59
|
+
(await post("/api/v1/auth/refresh", { refresh_token: pair.refresh_token }))
|
|
60
|
+
.status,
|
|
61
|
+
).toBe(401);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function barrier() {
|
|
65
|
+
let resolve!: () => void;
|
|
66
|
+
|
|
67
|
+
const promise = new Promise<void>((done) => {
|
|
68
|
+
resolve = done;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return { promise, resolve };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function sysadmin() {
|
|
75
|
+
const user = await account();
|
|
76
|
+
|
|
77
|
+
await app.handle.ctx
|
|
78
|
+
.models!.get("User")
|
|
79
|
+
.updateOne({ _id: user.userId }, { role: "sysadmin" });
|
|
80
|
+
|
|
81
|
+
return user;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
test("concurrent refresh has one winner but reuse invalidates its family", async () => {
|
|
85
|
+
const initial = await account();
|
|
86
|
+
|
|
87
|
+
const attempts = await Promise.all([
|
|
88
|
+
post("/api/v1/auth/refresh", { refresh_token: initial.refresh_token }),
|
|
89
|
+
post("/api/v1/auth/refresh", { refresh_token: initial.refresh_token }),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
expect(attempts.map((response) => response.status).sort()).toEqual([
|
|
93
|
+
200, 401,
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const winner = attempts.find((response) => response.status === 200)!;
|
|
97
|
+
const rotated = tokens.parse(await winner.json()).data;
|
|
98
|
+
|
|
99
|
+
// The server cannot tell a duplicate client request from a stolen token.
|
|
100
|
+
await assertRejected(initial);
|
|
101
|
+
|
|
102
|
+
await assertRejected(rotated);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("refresh reuse revokes its whole device family while other devices survive", async () => {
|
|
106
|
+
const initial = await account();
|
|
107
|
+
|
|
108
|
+
const login = await post("/api/v1/auth/login", {
|
|
109
|
+
email: initial.email,
|
|
110
|
+
password: initial.password,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const other = tokens.parse(await login.json()).data;
|
|
114
|
+
|
|
115
|
+
// Reuse an ancestor after two rotations, not just the immediately prior token.
|
|
116
|
+
const first = await post("/api/v1/auth/refresh", {
|
|
117
|
+
refresh_token: initial.refresh_token,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const firstPair = tokens.parse(await first.json()).data;
|
|
121
|
+
|
|
122
|
+
const second = await post("/api/v1/auth/refresh", {
|
|
123
|
+
refresh_token: firstPair.refresh_token,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const latest = tokens.parse(await second.json()).data;
|
|
127
|
+
|
|
128
|
+
const replay = await post("/api/v1/auth/refresh", {
|
|
129
|
+
refresh_token: initial.refresh_token,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(replay.status).toBe(401);
|
|
133
|
+
|
|
134
|
+
for (const pair of [initial, firstPair, latest]) {
|
|
135
|
+
await assertRejected(pair);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
expect((await get("/api/v1/auth/me", other.access_token)).status).toBe(200);
|
|
139
|
+
|
|
140
|
+
const devices = await get("/api/v1/auth/sessions", other.access_token);
|
|
141
|
+
|
|
142
|
+
expect(
|
|
143
|
+
z.object({ data: z.array(z.unknown()) }).parse(await devices.json()).data,
|
|
144
|
+
).toHaveLength(1);
|
|
145
|
+
|
|
146
|
+
expect(
|
|
147
|
+
(
|
|
148
|
+
await post("/api/v1/auth/refresh", {
|
|
149
|
+
refresh_token: other.refresh_token,
|
|
150
|
+
})
|
|
151
|
+
).status,
|
|
152
|
+
).toBe(200);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a delayed successful rotation cannot resurrect a family revoked by reuse", async () => {
|
|
156
|
+
const initial = await account();
|
|
157
|
+
const collection = app.handle.ctx.models!.get("Session").collection;
|
|
158
|
+
const update = collection.findOneAndUpdate.bind(collection);
|
|
159
|
+
const committed = barrier();
|
|
160
|
+
const release = barrier();
|
|
161
|
+
|
|
162
|
+
// Hold the winner after its database write, before the HTTP response returns.
|
|
163
|
+
const spy = vi
|
|
164
|
+
.spyOn(collection, "findOneAndUpdate")
|
|
165
|
+
.mockImplementationOnce(async (...args) => {
|
|
166
|
+
const result = await update(...args);
|
|
167
|
+
|
|
168
|
+
committed.resolve();
|
|
169
|
+
|
|
170
|
+
await release.promise;
|
|
171
|
+
|
|
172
|
+
return result;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const pending = post("/api/v1/auth/refresh", {
|
|
176
|
+
refresh_token: initial.refresh_token,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await Promise.race([
|
|
181
|
+
committed.promise,
|
|
182
|
+
pending.then(() => {
|
|
183
|
+
throw new Error("Rotation did not reach the write barrier");
|
|
184
|
+
}),
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
const replay = await post("/api/v1/auth/refresh", {
|
|
188
|
+
refresh_token: initial.refresh_token,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(replay.status).toBe(401);
|
|
192
|
+
|
|
193
|
+
release.resolve();
|
|
194
|
+
|
|
195
|
+
const winner = await pending;
|
|
196
|
+
|
|
197
|
+
expect(winner.status).toBe(200);
|
|
198
|
+
|
|
199
|
+
await assertRejected(tokens.parse(await winner.json()).data);
|
|
200
|
+
|
|
201
|
+
await assertRejected(initial);
|
|
202
|
+
} finally {
|
|
203
|
+
release.resolve();
|
|
204
|
+
|
|
205
|
+
await pending;
|
|
206
|
+
|
|
207
|
+
spy.mockRestore();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("unacknowledged refresh writes return a sanitized failure without claiming revocation", async () => {
|
|
212
|
+
const initial = await account();
|
|
213
|
+
|
|
214
|
+
const first = await post("/api/v1/auth/refresh", {
|
|
215
|
+
refresh_token: initial.refresh_token,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const latest = tokens.parse(await first.json()).data;
|
|
219
|
+
const canary = "private refresh database error";
|
|
220
|
+
|
|
221
|
+
const spy = vi
|
|
222
|
+
.spyOn(app.handle.ctx.models!.get("Session").collection, "findOneAndUpdate")
|
|
223
|
+
.mockRejectedValueOnce(new Error(canary));
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const failed = await post("/api/v1/auth/refresh", {
|
|
227
|
+
refresh_token: initial.refresh_token,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(failed.status).toBe(503);
|
|
231
|
+
|
|
232
|
+
expect(await failed.json()).toMatchObject({
|
|
233
|
+
errors: [{ code: "REFRESH_UNAVAILABLE" }],
|
|
234
|
+
});
|
|
235
|
+
} finally {
|
|
236
|
+
spy.mockRestore();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The failed write did not revoke the family. A later replay must still do so.
|
|
240
|
+
expect((await get("/api/v1/auth/me", latest.access_token)).status).toBe(200);
|
|
241
|
+
|
|
242
|
+
expect(
|
|
243
|
+
(
|
|
244
|
+
await post("/api/v1/auth/refresh", {
|
|
245
|
+
refresh_token: initial.refresh_token,
|
|
246
|
+
})
|
|
247
|
+
).status,
|
|
248
|
+
).toBe(401);
|
|
249
|
+
|
|
250
|
+
await assertRejected(latest);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("invalid refresh credentials cannot revoke a live family", async () => {
|
|
254
|
+
const { default: jwt } = await import("jsonwebtoken");
|
|
255
|
+
const initial = await account();
|
|
256
|
+
const other = await account();
|
|
257
|
+
const original = jwt.decode(initial.refresh_token) as Record<string, unknown>;
|
|
258
|
+
const key = String(app.handle.ctx.Environment.get("JWT_REFRESH_SECRET"));
|
|
259
|
+
|
|
260
|
+
const sign = (changes: Record<string, unknown>) =>
|
|
261
|
+
jwt.sign({ ...original, ...changes }, key);
|
|
262
|
+
|
|
263
|
+
const missingJti = { ...original };
|
|
264
|
+
|
|
265
|
+
delete missingJti.jti;
|
|
266
|
+
|
|
267
|
+
const invalid = [
|
|
268
|
+
jwt.sign(original, "different-signing-key"),
|
|
269
|
+
sign({ typ: "access" }),
|
|
270
|
+
sign({ exp: Math.floor(Date.now() / 1000) - 1 }),
|
|
271
|
+
jwt.sign(missingJti, key),
|
|
272
|
+
sign({ jti: { $ne: null } }),
|
|
273
|
+
sign({ sid: { $ne: null } }),
|
|
274
|
+
sign({ sub: { $ne: null } }),
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
// Signed malformed claims still cannot become filters or replay evidence.
|
|
278
|
+
for (const token of invalid) {
|
|
279
|
+
const response = await post("/api/v1/auth/refresh", {
|
|
280
|
+
refresh_token: token,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(response.status).toBe(401);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Valid shape and signature alone do not establish ownership of another sid.
|
|
287
|
+
const wrongOwner = await post("/api/v1/auth/refresh", {
|
|
288
|
+
refresh_token: sign({ sub: other.userId }),
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(wrongOwner.status).toBe(401);
|
|
292
|
+
expect((await get("/api/v1/auth/me", initial.access_token)).status).toBe(200);
|
|
293
|
+
expect((await get("/api/v1/auth/me", other.access_token)).status).toBe(200);
|
|
294
|
+
|
|
295
|
+
expect(
|
|
296
|
+
(
|
|
297
|
+
await post("/api/v1/auth/refresh", {
|
|
298
|
+
refresh_token: initial.refresh_token,
|
|
299
|
+
})
|
|
300
|
+
).status,
|
|
301
|
+
).toBe(200);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("an expired ancestor is rejected without revoking its still-live descendants", async () => {
|
|
305
|
+
const initial = await account();
|
|
306
|
+
const { default: jwt } = await import("jsonwebtoken");
|
|
307
|
+
const ancestor = jwt.decode(initial.refresh_token) as { exp: number };
|
|
308
|
+
let latest!: { access_token: string; refresh_token: string };
|
|
309
|
+
|
|
310
|
+
// Move only the app clock so the ancestor expires while the rotated pair does not.
|
|
311
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
vi.setSystemTime(new Date((ancestor.exp - 60) * 1000));
|
|
315
|
+
|
|
316
|
+
const rotated = await post("/api/v1/auth/refresh", {
|
|
317
|
+
refresh_token: initial.refresh_token,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(rotated.status).toBe(200);
|
|
321
|
+
|
|
322
|
+
latest = tokens.parse(await rotated.json()).data;
|
|
323
|
+
|
|
324
|
+
vi.setSystemTime(new Date(ancestor.exp * 1000));
|
|
325
|
+
|
|
326
|
+
const expired = await post("/api/v1/auth/refresh", {
|
|
327
|
+
refresh_token: initial.refresh_token,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(expired.status).toBe(401);
|
|
331
|
+
|
|
332
|
+
expect((await get("/api/v1/auth/me", latest.access_token)).status).toBe(
|
|
333
|
+
200,
|
|
334
|
+
);
|
|
335
|
+
} finally {
|
|
336
|
+
vi.useRealTimers();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("a lost acknowledgment after replay revocation never restores the family", async () => {
|
|
341
|
+
const initial = await account();
|
|
342
|
+
|
|
343
|
+
const first = await post("/api/v1/auth/refresh", {
|
|
344
|
+
refresh_token: initial.refresh_token,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const latest = tokens.parse(await first.json()).data;
|
|
348
|
+
const collection = app.handle.ctx.models!.get("Session").collection;
|
|
349
|
+
const update = collection.findOneAndUpdate.bind(collection);
|
|
350
|
+
|
|
351
|
+
// Simulate MongoDB committing the update before the reply is lost in transit.
|
|
352
|
+
const spy = vi
|
|
353
|
+
.spyOn(collection, "findOneAndUpdate")
|
|
354
|
+
.mockImplementationOnce(async (...args) => {
|
|
355
|
+
await update(...args);
|
|
356
|
+
|
|
357
|
+
throw new Error("private lost acknowledgment");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
const failed = await post("/api/v1/auth/refresh", {
|
|
362
|
+
refresh_token: initial.refresh_token,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
expect(spy).toHaveBeenCalledWith(
|
|
366
|
+
expect.anything(),
|
|
367
|
+
expect.anything(),
|
|
368
|
+
expect.objectContaining({
|
|
369
|
+
writeConcern: { w: "majority", j: true, wtimeout: 5000 },
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
expect(failed.status).toBe(503);
|
|
374
|
+
|
|
375
|
+
expect(await failed.json()).toMatchObject({
|
|
376
|
+
errors: [{ code: "REFRESH_UNAVAILABLE" }],
|
|
377
|
+
});
|
|
378
|
+
} finally {
|
|
379
|
+
spy.mockRestore();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
await assertRejected(latest);
|
|
383
|
+
|
|
384
|
+
await assertRejected(initial);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("browser credentials use headers and JSON without issuing cookies", async () => {
|
|
388
|
+
const credentials = {
|
|
389
|
+
email: randomBytes(12).toString("hex") + "@example.test",
|
|
390
|
+
password: randomBytes(24).toString("hex"),
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const registered = await post("/api/v1/auth/register", credentials);
|
|
394
|
+
|
|
395
|
+
expect(registered.status).toBe(201);
|
|
396
|
+
expect(registered.headers.has("set-cookie")).toBe(false);
|
|
397
|
+
|
|
398
|
+
const login = await post("/api/v1/auth/login", credentials);
|
|
399
|
+
|
|
400
|
+
expect(login.status).toBe(200);
|
|
401
|
+
expect(login.headers.has("set-cookie")).toBe(false);
|
|
402
|
+
|
|
403
|
+
const initial = tokens.parse(await login.json()).data;
|
|
404
|
+
const cookie = "access_token=" + initial.access_token;
|
|
405
|
+
|
|
406
|
+
// A valid token in an ambient cookie or URL must not establish identity.
|
|
407
|
+
const cookieOnly = await fetch(app.base + "/api/v1/auth/me", {
|
|
408
|
+
headers: { cookie },
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const queryOnly = await fetch(
|
|
412
|
+
app.base + "/api/v1/auth/me?access_token=" + initial.access_token,
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
expect(cookieOnly.status).toBe(401);
|
|
416
|
+
expect(queryOnly.status).toBe(401);
|
|
417
|
+
expect((await get("/api/v1/auth/me", initial.access_token)).status).toBe(200);
|
|
418
|
+
|
|
419
|
+
// Cookie-only logout must also leave the real session usable.
|
|
420
|
+
const logoutWithCookie = await fetch(app.base + "/api/v1/auth/logout", {
|
|
421
|
+
method: "POST",
|
|
422
|
+
headers: { "content-type": "application/json", cookie },
|
|
423
|
+
body: "{}",
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
expect(logoutWithCookie.status).toBe(401);
|
|
427
|
+
|
|
428
|
+
const refreshed = await post("/api/v1/auth/refresh", {
|
|
429
|
+
refresh_token: initial.refresh_token,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
expect(refreshed.status).toBe(200);
|
|
433
|
+
expect(refreshed.headers.has("set-cookie")).toBe(false);
|
|
434
|
+
|
|
435
|
+
const rotated = tokens.parse(await refreshed.json()).data;
|
|
436
|
+
const logout = await post("/api/v1/auth/logout", {}, rotated.access_token);
|
|
437
|
+
|
|
438
|
+
expect(logout.status).toBe(200);
|
|
439
|
+
expect(logout.headers.has("set-cookie")).toBe(false);
|
|
440
|
+
|
|
441
|
+
await assertRejected(rotated);
|
|
442
|
+
|
|
443
|
+
await assertRejected(initial);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("refresh ignores cookie and URL credentials and does not parse simple form bodies", async () => {
|
|
447
|
+
const initial = await account();
|
|
448
|
+
const endpoint = app.base + "/api/v1/auth/refresh";
|
|
449
|
+
const refresh = initial.refresh_token;
|
|
450
|
+
|
|
451
|
+
const attempts = [
|
|
452
|
+
await fetch(endpoint, {
|
|
453
|
+
method: "POST",
|
|
454
|
+
headers: {
|
|
455
|
+
"content-type": "application/json",
|
|
456
|
+
cookie: "refresh_token=" + refresh,
|
|
457
|
+
},
|
|
458
|
+
body: "{}",
|
|
459
|
+
}),
|
|
460
|
+
await fetch(endpoint + "?refresh_token=" + refresh, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
headers: { "content-type": "application/json" },
|
|
463
|
+
body: "{}",
|
|
464
|
+
}),
|
|
465
|
+
await fetch(endpoint, {
|
|
466
|
+
method: "POST",
|
|
467
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
468
|
+
body: new URLSearchParams({ refresh_token: refresh }),
|
|
469
|
+
}),
|
|
470
|
+
await fetch(endpoint, {
|
|
471
|
+
method: "POST",
|
|
472
|
+
headers: { "content-type": "text/plain" },
|
|
473
|
+
body: JSON.stringify({ refresh_token: refresh }),
|
|
474
|
+
}),
|
|
475
|
+
];
|
|
476
|
+
|
|
477
|
+
const form = new FormData();
|
|
478
|
+
|
|
479
|
+
form.set("refresh_token", refresh);
|
|
480
|
+
|
|
481
|
+
attempts.push(await fetch(endpoint, { method: "POST", body: form }));
|
|
482
|
+
|
|
483
|
+
for (const response of attempts) {
|
|
484
|
+
expect(response.status).toBe(422);
|
|
485
|
+
expect(response.headers.has("set-cookie")).toBe(false);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Rejections must not consume the credential that was carried incorrectly.
|
|
489
|
+
const accepted = await post("/api/v1/auth/refresh", {
|
|
490
|
+
refresh_token: refresh,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
expect(accepted.status).toBe(200);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("session expiry rejects access and refresh before TTL cleanup and hides the device", async () => {
|
|
497
|
+
const initial = await account();
|
|
498
|
+
const Session = app.handle.ctx.models!.get("Session");
|
|
499
|
+
|
|
500
|
+
// Keep the row in Mongo's future; advance only the app clock to the exact
|
|
501
|
+
// boundary so asynchronous TTL deletion cannot make this regression pass.
|
|
502
|
+
const expires = new Date(Date.now() + 120_000);
|
|
503
|
+
|
|
504
|
+
await Session.updateOne({ user: initial.userId }, { expires_at: expires });
|
|
505
|
+
|
|
506
|
+
const login = await post("/api/v1/auth/login", {
|
|
507
|
+
email: initial.email,
|
|
508
|
+
password: initial.password,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
expect(login.status).toBe(200);
|
|
512
|
+
|
|
513
|
+
const other = tokens.parse(await login.json()).data;
|
|
514
|
+
|
|
515
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
516
|
+
|
|
517
|
+
vi.setSystemTime(expires);
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
const sessions = await get("/api/v1/auth/sessions", other.access_token);
|
|
521
|
+
|
|
522
|
+
expect(sessions.status).toBe(200);
|
|
523
|
+
|
|
524
|
+
expect
|
|
525
|
+
.soft(
|
|
526
|
+
z.object({ data: z.array(z.unknown()) }).parse(await sessions.json())
|
|
527
|
+
.data,
|
|
528
|
+
)
|
|
529
|
+
.toHaveLength(1);
|
|
530
|
+
|
|
531
|
+
// Check both independently so a failed access assertion cannot hide refresh.
|
|
532
|
+
expect
|
|
533
|
+
.soft((await get("/api/v1/auth/me", initial.access_token)).status)
|
|
534
|
+
.toBe(401);
|
|
535
|
+
|
|
536
|
+
expect
|
|
537
|
+
.soft(
|
|
538
|
+
(
|
|
539
|
+
await post("/api/v1/auth/refresh", {
|
|
540
|
+
refresh_token: initial.refresh_token,
|
|
541
|
+
})
|
|
542
|
+
).status,
|
|
543
|
+
)
|
|
544
|
+
.toBe(401);
|
|
545
|
+
|
|
546
|
+
expect(await Session.countDocuments({ user: initial.userId })).toBe(2);
|
|
547
|
+
} finally {
|
|
548
|
+
vi.useRealTimers();
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("a session reassigned to another account cannot authenticate or refresh its old tokens", async () => {
|
|
553
|
+
const initial = await account();
|
|
554
|
+
const other = await account();
|
|
555
|
+
|
|
556
|
+
await app.handle.ctx
|
|
557
|
+
.models!.get("Session")
|
|
558
|
+
.updateOne({ user: initial.userId }, { user: other.userId });
|
|
559
|
+
|
|
560
|
+
expect
|
|
561
|
+
.soft((await get("/api/v1/auth/me", initial.access_token)).status)
|
|
562
|
+
.toBe(401);
|
|
563
|
+
|
|
564
|
+
expect
|
|
565
|
+
.soft(
|
|
566
|
+
(
|
|
567
|
+
await post("/api/v1/auth/refresh", {
|
|
568
|
+
refresh_token: initial.refresh_token,
|
|
569
|
+
})
|
|
570
|
+
).status,
|
|
571
|
+
)
|
|
572
|
+
.toBe(401);
|
|
573
|
+
|
|
574
|
+
expect((await get("/api/v1/auth/me", other.access_token)).status).toBe(200);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("password change in the same JWT second revokes both devices and keeps the replacement usable", async () => {
|
|
578
|
+
// Freeze only Date, leaving HTTP, Mongo and bcrypt timers operational.
|
|
579
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
580
|
+
|
|
581
|
+
vi.setSystemTime(new Date(Math.floor(Date.now() / 1000) * 1000 + 100));
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
const initial = await account();
|
|
585
|
+
|
|
586
|
+
const login = await post("/api/v1/auth/login", {
|
|
587
|
+
email: initial.email,
|
|
588
|
+
password: initial.password,
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
expect(login.status).toBe(200);
|
|
592
|
+
|
|
593
|
+
const other = tokens.parse(await login.json()).data;
|
|
594
|
+
|
|
595
|
+
vi.setSystemTime(new Date(Date.now() + 500));
|
|
596
|
+
|
|
597
|
+
const password = randomBytes(24).toString("hex");
|
|
598
|
+
|
|
599
|
+
const changed = await post(
|
|
600
|
+
"/api/v1/auth/password",
|
|
601
|
+
{ password },
|
|
602
|
+
initial.access_token,
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
expect(changed.status).toBe(200);
|
|
606
|
+
|
|
607
|
+
const replacement = tokens.parse(await changed.json()).data;
|
|
608
|
+
|
|
609
|
+
await assertRejected(initial);
|
|
610
|
+
|
|
611
|
+
await assertRejected(other);
|
|
612
|
+
|
|
613
|
+
expect(
|
|
614
|
+
(await get("/api/v1/auth/me", replacement.access_token)).status,
|
|
615
|
+
).toBe(200);
|
|
616
|
+
|
|
617
|
+
expect(
|
|
618
|
+
(
|
|
619
|
+
await post("/api/v1/auth/refresh", {
|
|
620
|
+
refresh_token: replacement.refresh_token,
|
|
621
|
+
})
|
|
622
|
+
).status,
|
|
623
|
+
).toBe(200);
|
|
624
|
+
|
|
625
|
+
expect(
|
|
626
|
+
(
|
|
627
|
+
await post("/api/v1/auth/login", {
|
|
628
|
+
email: initial.email,
|
|
629
|
+
password: initial.password,
|
|
630
|
+
})
|
|
631
|
+
).status,
|
|
632
|
+
).toBe(401);
|
|
633
|
+
|
|
634
|
+
expect(
|
|
635
|
+
(await post("/api/v1/auth/login", { email: initial.email, password }))
|
|
636
|
+
.status,
|
|
637
|
+
).toBe(200);
|
|
638
|
+
} finally {
|
|
639
|
+
vi.useRealTimers();
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("refresh racing with device revocation cannot leave a usable token pair", async () => {
|
|
644
|
+
const initial = await account();
|
|
645
|
+
|
|
646
|
+
const login = await post("/api/v1/auth/login", {
|
|
647
|
+
email: initial.email,
|
|
648
|
+
password: initial.password,
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
expect(login.status).toBe(200);
|
|
652
|
+
|
|
653
|
+
const other = tokens.parse(await login.json()).data;
|
|
654
|
+
const identity = await get("/api/v1/auth/me", other.access_token);
|
|
655
|
+
|
|
656
|
+
const { session } = z
|
|
657
|
+
.object({ data: z.object({ session: z.object({ sid: z.string() }) }) })
|
|
658
|
+
.parse(await identity.json()).data;
|
|
659
|
+
|
|
660
|
+
const [revoked, refreshed] = await Promise.all([
|
|
661
|
+
fetch(app.base + "/api/v1/auth/sessions/" + session.sid, {
|
|
662
|
+
method: "DELETE",
|
|
663
|
+
headers: { authorization: "Bearer " + initial.access_token },
|
|
664
|
+
}),
|
|
665
|
+
post("/api/v1/auth/refresh", { refresh_token: other.refresh_token }),
|
|
666
|
+
]);
|
|
667
|
+
|
|
668
|
+
expect(revoked.status).toBe(200);
|
|
669
|
+
expect([200, 401]).toContain(refreshed.status);
|
|
670
|
+
|
|
671
|
+
await assertRejected(other);
|
|
672
|
+
|
|
673
|
+
if (refreshed.status === 200)
|
|
674
|
+
await assertRejected(tokens.parse(await refreshed.json()).data);
|
|
675
|
+
|
|
676
|
+
expect((await get("/api/v1/auth/me", initial.access_token)).status).toBe(200);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test.each(["login", "password", "cleanup", "credential-write"])(
|
|
680
|
+
"a paused %s cannot create a valid session after a later password change",
|
|
681
|
+
async (operation) => {
|
|
682
|
+
const initial = await account();
|
|
683
|
+
const firstPassword = randomBytes(24).toString("hex");
|
|
684
|
+
const latestPassword = randomBytes(24).toString("hex");
|
|
685
|
+
const collection = app.handle.ctx.models!.get("Session").collection;
|
|
686
|
+
const insert = collection.insertOne.bind(collection);
|
|
687
|
+
const remove = collection.deleteMany.bind(collection);
|
|
688
|
+
const credentials = app.handle.ctx.models!.get("Credential").collection;
|
|
689
|
+
const replace = credentials.findOneAndUpdate.bind(credentials);
|
|
690
|
+
const entered = barrier();
|
|
691
|
+
const release = barrier();
|
|
692
|
+
|
|
693
|
+
const spy =
|
|
694
|
+
operation === "credential-write"
|
|
695
|
+
? vi
|
|
696
|
+
.spyOn(credentials, "findOneAndUpdate")
|
|
697
|
+
.mockImplementationOnce(async (...args) => {
|
|
698
|
+
entered.resolve();
|
|
699
|
+
|
|
700
|
+
await release.promise;
|
|
701
|
+
|
|
702
|
+
return replace(...args);
|
|
703
|
+
})
|
|
704
|
+
: operation === "cleanup"
|
|
705
|
+
? vi
|
|
706
|
+
.spyOn(collection, "deleteMany")
|
|
707
|
+
.mockImplementationOnce(async (...args) => {
|
|
708
|
+
entered.resolve();
|
|
709
|
+
|
|
710
|
+
await release.promise;
|
|
711
|
+
|
|
712
|
+
return remove(...args);
|
|
713
|
+
})
|
|
714
|
+
: vi
|
|
715
|
+
.spyOn(collection, "insertOne")
|
|
716
|
+
.mockImplementationOnce(async (...args) => {
|
|
717
|
+
entered.resolve();
|
|
718
|
+
|
|
719
|
+
await release.promise;
|
|
720
|
+
|
|
721
|
+
return insert(...args);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
const pending =
|
|
725
|
+
operation === "login"
|
|
726
|
+
? post("/api/v1/auth/login", {
|
|
727
|
+
email: initial.email,
|
|
728
|
+
password: initial.password,
|
|
729
|
+
})
|
|
730
|
+
: post(
|
|
731
|
+
"/api/v1/auth/password",
|
|
732
|
+
{ password: firstPassword },
|
|
733
|
+
initial.access_token,
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
try {
|
|
737
|
+
// The real request has verified/written its password and reached session
|
|
738
|
+
// insertion or cleanup. Complete a later change before releasing that write.
|
|
739
|
+
await Promise.race([
|
|
740
|
+
entered.promise,
|
|
741
|
+
pending.then(() => {
|
|
742
|
+
throw new Error("Request never reached the session barrier");
|
|
743
|
+
}),
|
|
744
|
+
]);
|
|
745
|
+
|
|
746
|
+
let access = initial.access_token;
|
|
747
|
+
|
|
748
|
+
if (operation === "password" || operation === "cleanup") {
|
|
749
|
+
const login = await post("/api/v1/auth/login", {
|
|
750
|
+
email: initial.email,
|
|
751
|
+
password: firstPassword,
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
expect(login.status).toBe(200);
|
|
755
|
+
|
|
756
|
+
access = tokens.parse(await login.json()).data.access_token;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const changed = await post(
|
|
760
|
+
"/api/v1/auth/password",
|
|
761
|
+
{ password: latestPassword },
|
|
762
|
+
access,
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
expect(changed.status).toBe(200);
|
|
766
|
+
|
|
767
|
+
const latest = tokens.parse(await changed.json()).data;
|
|
768
|
+
|
|
769
|
+
release.resolve();
|
|
770
|
+
|
|
771
|
+
const stale = await pending;
|
|
772
|
+
|
|
773
|
+
expect.soft(stale.status).toBe(401);
|
|
774
|
+
|
|
775
|
+
if (stale.status === 200)
|
|
776
|
+
await assertRejected(tokens.parse(await stale.json()).data);
|
|
777
|
+
|
|
778
|
+
expect((await get("/api/v1/auth/me", latest.access_token)).status).toBe(
|
|
779
|
+
200,
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
expect(
|
|
783
|
+
(
|
|
784
|
+
await post("/api/v1/auth/login", {
|
|
785
|
+
email: initial.email,
|
|
786
|
+
password: latestPassword,
|
|
787
|
+
})
|
|
788
|
+
).status,
|
|
789
|
+
).toBe(200);
|
|
790
|
+
} finally {
|
|
791
|
+
release.resolve();
|
|
792
|
+
|
|
793
|
+
await pending;
|
|
794
|
+
|
|
795
|
+
spy.mockRestore();
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
test("failed session cleanup after password replacement cannot preserve old sessions", async () => {
|
|
801
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
802
|
+
|
|
803
|
+
vi.setSystemTime(new Date(Math.floor(Date.now() / 1000) * 1000 + 100));
|
|
804
|
+
|
|
805
|
+
try {
|
|
806
|
+
const initial = await account();
|
|
807
|
+
const password = randomBytes(24).toString("hex");
|
|
808
|
+
|
|
809
|
+
const spy = vi
|
|
810
|
+
.spyOn(app.handle.ctx.models!.get("Session").collection, "deleteMany")
|
|
811
|
+
.mockRejectedValueOnce(new Error("injected cleanup failure"));
|
|
812
|
+
|
|
813
|
+
try {
|
|
814
|
+
const changed = await post(
|
|
815
|
+
"/api/v1/auth/password",
|
|
816
|
+
{ password },
|
|
817
|
+
initial.access_token,
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
expect(changed.status).toBe(500);
|
|
821
|
+
} finally {
|
|
822
|
+
spy.mockRestore();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
expect(
|
|
826
|
+
await app.handle.ctx
|
|
827
|
+
.models!.get("Session")
|
|
828
|
+
.countDocuments({ user: initial.userId }),
|
|
829
|
+
).toBe(1);
|
|
830
|
+
|
|
831
|
+
expect
|
|
832
|
+
.soft((await get("/api/v1/auth/me", initial.access_token)).status)
|
|
833
|
+
.toBe(401);
|
|
834
|
+
|
|
835
|
+
expect
|
|
836
|
+
.soft(
|
|
837
|
+
(
|
|
838
|
+
await post("/api/v1/auth/refresh", {
|
|
839
|
+
refresh_token: initial.refresh_token,
|
|
840
|
+
})
|
|
841
|
+
).status,
|
|
842
|
+
)
|
|
843
|
+
.toBe(401);
|
|
844
|
+
|
|
845
|
+
expect(
|
|
846
|
+
(
|
|
847
|
+
await post("/api/v1/auth/login", {
|
|
848
|
+
email: initial.email,
|
|
849
|
+
password: initial.password,
|
|
850
|
+
})
|
|
851
|
+
).status,
|
|
852
|
+
).toBe(401);
|
|
853
|
+
|
|
854
|
+
const login = await post("/api/v1/auth/login", {
|
|
855
|
+
email: initial.email,
|
|
856
|
+
password,
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
expect(login.status).toBe(200);
|
|
860
|
+
|
|
861
|
+
const current = tokens.parse(await login.json()).data;
|
|
862
|
+
const sessions = await get("/api/v1/auth/sessions", current.access_token);
|
|
863
|
+
|
|
864
|
+
expect(
|
|
865
|
+
z.object({ data: z.array(z.unknown()) }).parse(await sessions.json())
|
|
866
|
+
.data,
|
|
867
|
+
).toHaveLength(1);
|
|
868
|
+
} finally {
|
|
869
|
+
vi.useRealTimers();
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test("a failed credential write preserves the old password and session", async () => {
|
|
874
|
+
const initial = await account();
|
|
875
|
+
const password = randomBytes(24).toString("hex");
|
|
876
|
+
|
|
877
|
+
const spy = vi
|
|
878
|
+
.spyOn(app.handle.ctx.models!.get("Credential").collection, "findOneAndUpdate")
|
|
879
|
+
.mockRejectedValueOnce(new Error("injected credential failure"));
|
|
880
|
+
|
|
881
|
+
try {
|
|
882
|
+
expect(
|
|
883
|
+
(await post("/api/v1/auth/password", { password }, initial.access_token))
|
|
884
|
+
.status,
|
|
885
|
+
).toBe(500);
|
|
886
|
+
} finally {
|
|
887
|
+
spy.mockRestore();
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
expect((await get("/api/v1/auth/me", initial.access_token)).status).toBe(200);
|
|
891
|
+
|
|
892
|
+
expect(
|
|
893
|
+
(
|
|
894
|
+
await post("/api/v1/auth/refresh", {
|
|
895
|
+
refresh_token: initial.refresh_token,
|
|
896
|
+
})
|
|
897
|
+
).status,
|
|
898
|
+
).toBe(200);
|
|
899
|
+
|
|
900
|
+
expect(
|
|
901
|
+
(
|
|
902
|
+
await post("/api/v1/auth/login", {
|
|
903
|
+
email: initial.email,
|
|
904
|
+
password: initial.password,
|
|
905
|
+
})
|
|
906
|
+
).status,
|
|
907
|
+
).toBe(200);
|
|
908
|
+
|
|
909
|
+
expect(
|
|
910
|
+
(await post("/api/v1/auth/login", { email: initial.email, password }))
|
|
911
|
+
.status,
|
|
912
|
+
).toBe(401);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test("legacy credentials upgrade on login while sessions without a version require reauthentication", async () => {
|
|
916
|
+
const initial = await account();
|
|
917
|
+
const Credential = app.handle.ctx.models!.get("Credential");
|
|
918
|
+
const Session = app.handle.ctx.models!.get("Session");
|
|
919
|
+
|
|
920
|
+
// Use collection writes to simulate persisted data from the previous starter.
|
|
921
|
+
const credential = await Credential.findOne({
|
|
922
|
+
user: initial.userId,
|
|
923
|
+
}).orFail();
|
|
924
|
+
|
|
925
|
+
await Credential.collection.updateOne(
|
|
926
|
+
{ _id: credential._id },
|
|
927
|
+
{ $unset: { version: "" } },
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
await Session.collection.updateMany(
|
|
931
|
+
{ user: credential.get("user") },
|
|
932
|
+
{ $unset: { credential_version: "" } },
|
|
933
|
+
);
|
|
934
|
+
|
|
935
|
+
await assertRejected(initial);
|
|
936
|
+
|
|
937
|
+
const login = await post("/api/v1/auth/login", {
|
|
938
|
+
email: initial.email,
|
|
939
|
+
password: initial.password,
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
expect(login.status).toBe(200);
|
|
943
|
+
|
|
944
|
+
const current = tokens.parse(await login.json()).data;
|
|
945
|
+
|
|
946
|
+
expect((await get("/api/v1/auth/me", current.access_token)).status).toBe(200);
|
|
947
|
+
|
|
948
|
+
await assertRejected(initial);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
test("a legacy login cannot upgrade credentials replaced while its verification was in flight", async () => {
|
|
952
|
+
const initial = await account();
|
|
953
|
+
const Credential = app.handle.ctx.models!.get("Credential");
|
|
954
|
+
const row = await Credential.findOne({ user: initial.userId }).orFail();
|
|
955
|
+
|
|
956
|
+
await Credential.collection.updateOne(
|
|
957
|
+
{ _id: row._id },
|
|
958
|
+
{ $unset: { version: "" } },
|
|
959
|
+
);
|
|
960
|
+
|
|
961
|
+
const update = Credential.collection.updateOne.bind(Credential.collection);
|
|
962
|
+
const entered = barrier();
|
|
963
|
+
const release = barrier();
|
|
964
|
+
|
|
965
|
+
const spy = vi
|
|
966
|
+
.spyOn(Credential.collection, "updateOne")
|
|
967
|
+
.mockImplementationOnce(async (...args) => {
|
|
968
|
+
entered.resolve();
|
|
969
|
+
|
|
970
|
+
await release.promise;
|
|
971
|
+
|
|
972
|
+
return update(...args);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
const pending = post("/api/v1/auth/login", {
|
|
976
|
+
email: initial.email,
|
|
977
|
+
password: initial.password,
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
try {
|
|
981
|
+
await Promise.race([
|
|
982
|
+
entered.promise,
|
|
983
|
+
pending.then(() => {
|
|
984
|
+
throw new Error("Login never reached the credential barrier");
|
|
985
|
+
}),
|
|
986
|
+
]);
|
|
987
|
+
|
|
988
|
+
const password = randomBytes(24).toString("hex");
|
|
989
|
+
|
|
990
|
+
const { setPassword } = await import(
|
|
991
|
+
"../helpers/passwords.ts"
|
|
992
|
+
);
|
|
993
|
+
|
|
994
|
+
await setPassword(app.handle.ctx, initial.userId, password);
|
|
995
|
+
|
|
996
|
+
release.resolve();
|
|
997
|
+
|
|
998
|
+
expect((await pending).status).toBe(401);
|
|
999
|
+
|
|
1000
|
+
expect(
|
|
1001
|
+
(await post("/api/v1/auth/login", { email: initial.email, password }))
|
|
1002
|
+
.status,
|
|
1003
|
+
).toBe(200);
|
|
1004
|
+
} finally {
|
|
1005
|
+
release.resolve();
|
|
1006
|
+
|
|
1007
|
+
await pending;
|
|
1008
|
+
|
|
1009
|
+
spy.mockRestore();
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
test("an authenticated request on a cache hit performs no database read", async () => {
|
|
1014
|
+
const user = await account();
|
|
1015
|
+
const cold = await get("/api/v1/auth/me", user.access_token);
|
|
1016
|
+
expect(cold.status).toBe(200);
|
|
1017
|
+
|
|
1018
|
+
const User = app.handle.ctx.models!.get("User");
|
|
1019
|
+
const Session = app.handle.ctx.models!.get("Session");
|
|
1020
|
+
const Revoked = app.handle.ctx.models!.get("RevokedToken");
|
|
1021
|
+
const reads = [
|
|
1022
|
+
vi.spyOn(User, "findById"), vi.spyOn(User, "findOne"),
|
|
1023
|
+
vi.spyOn(Session, "findOne"), vi.spyOn(Session, "find"),
|
|
1024
|
+
vi.spyOn(Revoked, "countDocuments"),
|
|
1025
|
+
];
|
|
1026
|
+
try {
|
|
1027
|
+
const warm = await get("/api/v1/auth/me", user.access_token);
|
|
1028
|
+
expect(warm.status).toBe(200);
|
|
1029
|
+
expect(await warm.json()).toMatchObject({ data: { user: { email: user.email, role: "user" }, session: { sid: expect.any(String) } } });
|
|
1030
|
+
expect(reads.map((spy) => spy.mock.calls.length)).toEqual([0, 0, 0, 0, 0]);
|
|
1031
|
+
} finally {
|
|
1032
|
+
reads.forEach((spy) => spy.mockRestore());
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
test("a revocation on one replica is refused on the very next request on another", async () => {
|
|
1037
|
+
const next = await boot({
|
|
1038
|
+
rootDir,
|
|
1039
|
+
appEnv: "test",
|
|
1040
|
+
handleSignals: false,
|
|
1041
|
+
});
|
|
1042
|
+
try {
|
|
1043
|
+
const address = next.server!.address();
|
|
1044
|
+
if (!address || typeof address === "string") throw new Error("No listener on the second replica");
|
|
1045
|
+
const other = "http://127.0.0.1:" + address.port;
|
|
1046
|
+
const me = (token: string) => fetch(other + "/api/v1/auth/me", { headers: { authorization: "Bearer " + token } });
|
|
1047
|
+
|
|
1048
|
+
// Logged out on the first replica: the second one, which had the
|
|
1049
|
+
// identity cached from its own request, refuses the next one.
|
|
1050
|
+
const first = await account();
|
|
1051
|
+
expect((await get("/api/v1/auth/me", first.access_token)).status).toBe(200);
|
|
1052
|
+
expect((await me(first.access_token)).status).toBe(200);
|
|
1053
|
+
expect((await post("/api/v1/auth/logout", {}, first.access_token)).status).toBe(200);
|
|
1054
|
+
expect((await me(first.access_token)).status).toBe(401);
|
|
1055
|
+
|
|
1056
|
+
// A password change, a session ended by its owner, and a soft-deleted
|
|
1057
|
+
// account: every path that revokes forgets the cached identity first.
|
|
1058
|
+
const second = await account();
|
|
1059
|
+
expect((await me(second.access_token)).status).toBe(200);
|
|
1060
|
+
const changed = await post("/api/v1/auth/password", { password: randomBytes(24).toString("hex") }, second.access_token);
|
|
1061
|
+
expect(changed.status).toBe(200);
|
|
1062
|
+
expect((await me(second.access_token)).status).toBe(401);
|
|
1063
|
+
|
|
1064
|
+
const third = await account();
|
|
1065
|
+
expect((await me(third.access_token)).status).toBe(200);
|
|
1066
|
+
const { verify } = await import("../helpers/tokens.ts");
|
|
1067
|
+
const claims = verify(app.handle.ctx, third.access_token, "access")!;
|
|
1068
|
+
expect((await fetch(app.base + "/api/v1/auth/sessions/" + claims.sid, { method: "DELETE", headers: { authorization: "Bearer " + third.access_token } })).status).toBe(200);
|
|
1069
|
+
expect((await me(third.access_token)).status).toBe(401);
|
|
1070
|
+
|
|
1071
|
+
const fourth = await account();
|
|
1072
|
+
expect((await me(fourth.access_token)).status).toBe(200);
|
|
1073
|
+
const admin = await sysadmin();
|
|
1074
|
+
expect((await fetch(app.base + "/users/" + encodeURIComponent(fourth.email), { method: "DELETE", headers: { authorization: "Bearer " + admin.access_token } })).status).toBe(200);
|
|
1075
|
+
expect((await me(fourth.access_token)).status).toBe(401);
|
|
1076
|
+
} finally {
|
|
1077
|
+
await next.shutdown();
|
|
1078
|
+
}
|
|
1079
|
+
});
|