@simple-auth-kit/cli 1.4.2 → 1.5.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/package.json +1 -1
- package/registry/combos/express-drizzle/shared/src/{auth-core-error.middleware.ts → middleware/auth-core-error.middleware.ts} +17 -4
- package/registry/combos/express-drizzle/shared/src/{auth.middleware.ts → middleware/auth.middleware.ts} +23 -7
- package/registry/combos/{express-prisma/shared/src → express-drizzle/shared/src/middleware}/response-envelope.middleware.ts +15 -3
- package/registry/combos/express-drizzle/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +32 -9
- package/registry/combos/express-drizzle/shared/src/repositories/password-reset.repository.ts +59 -0
- package/registry/combos/express-drizzle/shared/src/{session.repository.ts → repositories/session.repository.ts} +52 -14
- package/registry/combos/express-drizzle/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +20 -6
- package/registry/combos/express-drizzle/shared/src/request-context.ts +1 -1
- package/registry/combos/express-drizzle/shared/src/routers/auth.router.ts +395 -0
- package/registry/combos/express-drizzle/variants/base/src/create-auth-app.ts +52 -19
- package/registry/combos/express-drizzle/variants/base/src/{authz.middleware.ts → middleware/authz.middleware.ts} +15 -7
- package/registry/combos/express-drizzle/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +28 -10
- package/registry/combos/express-drizzle/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +239 -48
- package/registry/combos/express-drizzle/variants/base/src/routers/admin.router.ts +497 -0
- package/registry/combos/express-drizzle/variants/base/src/{auth.service.ts → services/auth.service.ts} +480 -109
- package/registry/combos/express-drizzle/variants/workspaces/src/create-auth-app.ts +69 -23
- package/registry/combos/express-drizzle/variants/workspaces/src/{authz.middleware.ts → middleware/authz.middleware.ts} +32 -12
- package/registry/combos/express-drizzle/variants/workspaces/src/openapi-workspace.ts +147 -28
- package/registry/combos/express-drizzle/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +30 -11
- package/registry/combos/express-drizzle/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +319 -70
- package/registry/combos/express-drizzle/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +127 -30
- package/registry/combos/express-drizzle/variants/workspaces/src/routers/admin.router.ts +534 -0
- package/registry/combos/express-drizzle/variants/workspaces/src/routers/workspace.router.ts +194 -0
- package/registry/combos/express-drizzle/variants/workspaces/src/{auth.service.ts → services/auth.service.ts} +544 -122
- package/registry/combos/express-prisma/shared/src/{auth-core-error.middleware.ts → middleware/auth-core-error.middleware.ts} +17 -4
- package/registry/combos/express-prisma/shared/src/{auth.middleware.ts → middleware/auth.middleware.ts} +19 -6
- package/registry/combos/{express-drizzle/shared/src → express-prisma/shared/src/middleware}/response-envelope.middleware.ts +15 -3
- package/registry/combos/express-prisma/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +27 -9
- package/registry/combos/express-prisma/shared/src/repositories/password-reset.repository.ts +55 -0
- package/registry/combos/express-prisma/shared/src/{session.repository.ts → repositories/session.repository.ts} +30 -9
- package/registry/combos/express-prisma/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +11 -4
- package/registry/combos/express-prisma/shared/src/request-context.ts +1 -1
- package/registry/combos/express-prisma/shared/src/routers/auth.router.ts +398 -0
- package/registry/combos/express-prisma/variants/base/src/create-auth-app.ts +57 -22
- package/registry/combos/express-prisma/variants/base/src/{authz.middleware.ts → middleware/authz.middleware.ts} +15 -7
- package/registry/combos/express-prisma/variants/base/src/rbac.defaults.ts +67 -19
- package/registry/combos/express-prisma/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
- package/registry/combos/express-prisma/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +199 -48
- package/registry/combos/express-prisma/variants/base/src/routers/admin.router.ts +508 -0
- package/registry/combos/express-prisma/variants/base/src/seed.ts +78 -20
- package/registry/combos/express-prisma/variants/base/src/{auth.service.ts → services/auth.service.ts} +454 -105
- package/registry/combos/express-prisma/variants/workspaces/src/create-auth-app.ts +81 -26
- package/registry/combos/express-prisma/variants/workspaces/src/{authz.middleware.ts → middleware/authz.middleware.ts} +32 -12
- package/registry/combos/express-prisma/variants/workspaces/src/openapi-workspace.ts +147 -28
- package/registry/combos/express-prisma/variants/workspaces/src/rbac.defaults.ts +73 -21
- package/registry/combos/express-prisma/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
- package/registry/combos/express-prisma/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +272 -64
- package/registry/combos/express-prisma/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +86 -23
- package/registry/combos/express-prisma/variants/workspaces/src/routers/admin.router.ts +545 -0
- package/registry/combos/express-prisma/variants/workspaces/src/routers/workspace.router.ts +194 -0
- package/registry/combos/express-prisma/variants/workspaces/src/seed.ts +109 -29
- package/registry/combos/express-prisma/variants/workspaces/src/{auth.service.ts → services/auth.service.ts} +518 -119
- package/registry/combos/nestjs-drizzle/shared/src/{auth.controller.ts → controllers/auth.controller.ts} +6 -6
- package/registry/combos/nestjs-drizzle/shared/src/{auth-core-error.filter.ts → filters/auth-core-error.filter.ts} +29 -5
- package/registry/combos/nestjs-drizzle/shared/src/{ability.guard.ts → guards/ability.guard.ts} +20 -7
- package/registry/combos/nestjs-drizzle/shared/src/{auth.guard.ts → guards/auth.guard.ts} +19 -7
- package/registry/combos/{nestjs-prisma/shared/src → nestjs-drizzle/shared/src/interceptors}/response.interceptor.ts +10 -2
- package/registry/combos/nestjs-drizzle/shared/src/repositories/oauth.repository.ts +63 -0
- package/registry/combos/nestjs-drizzle/shared/src/repositories/password-reset.repository.ts +59 -0
- package/registry/combos/nestjs-drizzle/shared/src/{session.repository.ts → repositories/session.repository.ts} +52 -14
- package/registry/combos/nestjs-drizzle/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +21 -7
- package/registry/combos/nestjs-drizzle/shared/src/request-context.ts +1 -1
- package/registry/combos/nestjs-drizzle/test/prove-cycle.ts +1097 -307
- package/registry/combos/nestjs-drizzle/variants/base/src/auth.module.ts +44 -20
- package/registry/combos/nestjs-drizzle/variants/base/src/{admin.controller.ts → controllers/admin.controller.ts} +228 -62
- package/registry/combos/nestjs-drizzle/variants/base/src/{authz.guard.ts → guards/authz.guard.ts} +12 -5
- package/registry/combos/nestjs-drizzle/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +27 -10
- package/registry/combos/nestjs-drizzle/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +249 -51
- package/registry/combos/nestjs-drizzle/variants/base/src/{auth.service.ts → services/auth.service.ts} +499 -116
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/auth.module.ts +48 -22
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/{admin.controller.ts → controllers/admin.controller.ts} +269 -70
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/{workspace.controller.ts → controllers/workspace.controller.ts} +93 -26
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/{authz.guard.ts → guards/authz.guard.ts} +26 -8
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +29 -11
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +323 -71
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +131 -30
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/services/auth.service.ts +988 -0
- package/registry/combos/nestjs-prisma/shared/src/{auth.controller.ts → controllers/auth.controller.ts} +6 -6
- package/registry/combos/nestjs-prisma/shared/src/{auth-core-error.filter.ts → filters/auth-core-error.filter.ts} +29 -5
- package/registry/combos/nestjs-prisma/shared/src/{ability.guard.ts → guards/ability.guard.ts} +20 -7
- package/registry/combos/nestjs-prisma/shared/src/{auth.guard.ts → guards/auth.guard.ts} +19 -7
- package/registry/combos/{nestjs-drizzle/shared/src → nestjs-prisma/shared/src/interceptors}/response.interceptor.ts +10 -2
- package/registry/combos/nestjs-prisma/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +25 -8
- package/registry/combos/nestjs-prisma/shared/src/repositories/password-reset.repository.ts +55 -0
- package/registry/combos/nestjs-prisma/shared/src/{session.repository.ts → repositories/session.repository.ts} +30 -9
- package/registry/combos/nestjs-prisma/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +11 -4
- package/registry/combos/nestjs-prisma/shared/src/request-context.ts +1 -1
- package/registry/combos/nestjs-prisma/test/prove-cycle.ts +1097 -307
- package/registry/combos/nestjs-prisma/variants/base/src/auth.module.ts +59 -28
- package/registry/combos/nestjs-prisma/variants/base/src/{admin.controller.ts → controllers/admin.controller.ts} +469 -110
- package/registry/combos/nestjs-prisma/variants/base/src/{audit-log.gateway.ts → gateways/audit-log.gateway.ts} +22 -6
- package/registry/combos/nestjs-prisma/variants/base/src/{authz.guard.ts → guards/authz.guard.ts} +12 -5
- package/registry/combos/nestjs-prisma/variants/base/src/rbac.defaults.ts +98 -27
- package/registry/combos/nestjs-prisma/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
- package/registry/combos/nestjs-prisma/variants/base/src/{country.repository.ts → repositories/country.repository.ts} +98 -25
- package/registry/combos/nestjs-prisma/variants/base/src/{customer.repository.ts → repositories/customer.repository.ts} +106 -27
- package/registry/combos/nestjs-prisma/variants/base/src/{language.repository.ts → repositories/language.repository.ts} +93 -25
- package/registry/combos/nestjs-prisma/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +229 -55
- package/registry/combos/nestjs-prisma/variants/base/src/seed.ts +78 -20
- package/registry/combos/nestjs-prisma/variants/base/src/services/auth.service.ts +1064 -0
- package/registry/combos/nestjs-prisma/variants/workspaces/src/auth.module.ts +55 -27
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{admin.controller.ts → controllers/admin.controller.ts} +544 -128
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{workspace.controller.ts → controllers/workspace.controller.ts} +93 -26
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{authz.guard.ts → guards/authz.guard.ts} +26 -8
- package/registry/combos/nestjs-prisma/variants/workspaces/src/rbac.defaults.ts +104 -29
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{country.repository.ts → repositories/country.repository.ts} +127 -26
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{customer.repository.ts → repositories/customer.repository.ts} +137 -28
- package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/language.repository.ts +243 -0
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +314 -74
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +94 -24
- package/registry/combos/nestjs-prisma/variants/workspaces/src/seed.ts +109 -29
- package/registry/combos/nestjs-prisma/variants/workspaces/src/services/auth.service.ts +961 -0
- package/registry.json +6 -4
- package/simple-auth-kit.ts +582 -124
- package/registry/combos/express-drizzle/shared/src/auth.router.ts +0 -266
- package/registry/combos/express-drizzle/shared/src/password-reset.repository.ts +0 -35
- package/registry/combos/express-drizzle/variants/base/src/admin.router.ts +0 -307
- package/registry/combos/express-drizzle/variants/workspaces/src/admin.router.ts +0 -323
- package/registry/combos/express-drizzle/variants/workspaces/src/workspace.router.ts +0 -123
- package/registry/combos/express-prisma/shared/src/auth.router.ts +0 -269
- package/registry/combos/express-prisma/shared/src/password-reset.repository.ts +0 -29
- package/registry/combos/express-prisma/variants/base/src/admin.router.ts +0 -314
- package/registry/combos/express-prisma/variants/workspaces/src/admin.router.ts +0 -330
- package/registry/combos/express-prisma/variants/workspaces/src/workspace.router.ts +0 -123
- package/registry/combos/nestjs-drizzle/shared/src/oauth.repository.ts +0 -36
- package/registry/combos/nestjs-drizzle/shared/src/password-reset.repository.ts +0 -29
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/auth.service.ts +0 -553
- package/registry/combos/nestjs-prisma/shared/src/password-reset.repository.ts +0 -29
- package/registry/combos/nestjs-prisma/variants/base/src/auth.service.ts +0 -618
- package/registry/combos/nestjs-prisma/variants/workspaces/src/auth.service.ts +0 -509
- package/registry/combos/nestjs-prisma/variants/workspaces/src/language.repository.ts +0 -143
|
@@ -12,9 +12,17 @@ import { randomUUID } from "node:crypto";
|
|
|
12
12
|
import { Get } from "@nestjs/common";
|
|
13
13
|
import { generateTotpCode } from "@/lib/auth/core/two-factor.js";
|
|
14
14
|
import { ABILITY_SUBJECT, defineAbilitiesFor } from "../src/ability.js";
|
|
15
|
-
import { AuthController } from "../src/auth.controller.js";
|
|
15
|
+
import { AuthController } from "../src/controllers/auth.controller.js";
|
|
16
16
|
import { bootstrap, capturedResetTokens } from "./bootstrap.js";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
adminRouteProbes,
|
|
19
|
+
AuthTokens,
|
|
20
|
+
BASE_PORT,
|
|
21
|
+
CallOptions,
|
|
22
|
+
decodeJwtPayload,
|
|
23
|
+
ProofContext,
|
|
24
|
+
RouteProbe,
|
|
25
|
+
} from "./harness.js";
|
|
18
26
|
import { hooks } from "./variant-hooks.js";
|
|
19
27
|
|
|
20
28
|
const BASE = `http://localhost:${BASE_PORT}`;
|
|
@@ -29,7 +37,11 @@ function assert(condition: boolean, message: string) {
|
|
|
29
37
|
}
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
async function call(
|
|
40
|
+
async function call(
|
|
41
|
+
method: string,
|
|
42
|
+
path: string,
|
|
43
|
+
opts: CallOptions = {},
|
|
44
|
+
): Promise<{ status: number; body: any }> {
|
|
33
45
|
const res = await fetch(`${BASE}${path}`, {
|
|
34
46
|
method,
|
|
35
47
|
headers: {
|
|
@@ -45,7 +57,10 @@ async function call(method: string, path: string, opts: CallOptions = {}): Promi
|
|
|
45
57
|
// message, ...}` (error) — unwrap `data` here, once, so every assertion below reads the actual
|
|
46
58
|
// payload without knowing the envelope exists. Error bodies have no `data` key, so they pass
|
|
47
59
|
// through unchanged.
|
|
48
|
-
const body =
|
|
60
|
+
const body =
|
|
61
|
+
envelope && typeof envelope === "object" && "data" in envelope
|
|
62
|
+
? envelope.data
|
|
63
|
+
: envelope;
|
|
49
64
|
return { status: res.status, body: body as any };
|
|
50
65
|
}
|
|
51
66
|
|
|
@@ -56,7 +71,10 @@ const ROLE_SLUG = `billing-manager-${RUN_ID}`;
|
|
|
56
71
|
|
|
57
72
|
async function signup(email: string, password: string): Promise<AuthTokens> {
|
|
58
73
|
const res = await call("POST", "/auth/signup", { body: { email, password } });
|
|
59
|
-
if (res.status !== 200 && res.status !== 201)
|
|
74
|
+
if (res.status !== 200 && res.status !== 201)
|
|
75
|
+
throw new Error(
|
|
76
|
+
`signup failed (${res.status}): ${JSON.stringify(res.body)}`,
|
|
77
|
+
);
|
|
60
78
|
return res.body as AuthTokens;
|
|
61
79
|
}
|
|
62
80
|
|
|
@@ -72,17 +90,31 @@ const ctx: ProofContext = { call, assert, signup, uniqueEmail };
|
|
|
72
90
|
* proves that the running application refuses to start.
|
|
73
91
|
*/
|
|
74
92
|
function addUntieredRoute(): () => void {
|
|
75
|
-
const prototype = AuthController.prototype as unknown as Record<
|
|
93
|
+
const prototype = AuthController.prototype as unknown as Record<
|
|
94
|
+
string,
|
|
95
|
+
unknown
|
|
96
|
+
>;
|
|
76
97
|
const name = "untieredProofRoute";
|
|
77
|
-
Object.defineProperty(prototype, name, {
|
|
78
|
-
|
|
98
|
+
Object.defineProperty(prototype, name, {
|
|
99
|
+
value: () => ({ ok: true }),
|
|
100
|
+
configurable: true,
|
|
101
|
+
writable: true,
|
|
102
|
+
enumerable: false,
|
|
103
|
+
});
|
|
104
|
+
Get("__tier-proof")(
|
|
105
|
+
prototype,
|
|
106
|
+
name,
|
|
107
|
+
Object.getOwnPropertyDescriptor(prototype, name)!,
|
|
108
|
+
);
|
|
79
109
|
return () => {
|
|
80
110
|
delete prototype[name];
|
|
81
111
|
};
|
|
82
112
|
}
|
|
83
113
|
|
|
84
114
|
async function main() {
|
|
85
|
-
console.log(
|
|
115
|
+
console.log(
|
|
116
|
+
"0. fail-closed at startup: a route with no tier marker stops the app from booting",
|
|
117
|
+
);
|
|
86
118
|
const removeUntieredRoute = addUntieredRoute();
|
|
87
119
|
let bootError: unknown;
|
|
88
120
|
try {
|
|
@@ -92,24 +124,40 @@ async function main() {
|
|
|
92
124
|
bootError = err;
|
|
93
125
|
}
|
|
94
126
|
removeUntieredRoute();
|
|
95
|
-
const bootMessage =
|
|
96
|
-
|
|
127
|
+
const bootMessage =
|
|
128
|
+
bootError instanceof Error ? bootError.message : String(bootError);
|
|
97
129
|
assert(
|
|
98
|
-
|
|
130
|
+
bootError !== undefined,
|
|
131
|
+
"adding a route that declares no @Public/@Authenticated/@CheckAbility makes the app refuse to boot",
|
|
132
|
+
);
|
|
133
|
+
assert(
|
|
134
|
+
bootMessage.includes("GET /auth/__tier-proof") &&
|
|
135
|
+
bootMessage.includes("AuthController.untieredProofRoute"),
|
|
99
136
|
`…and the error names the offending route (got: ${bootMessage.split("\n")[0]})`,
|
|
100
137
|
);
|
|
101
138
|
|
|
102
139
|
const app = await bootstrap(BASE_PORT);
|
|
103
|
-
assert(
|
|
140
|
+
assert(
|
|
141
|
+
true,
|
|
142
|
+
"…and with the route removed again, the app boots — the check is about the marker, not the boot path",
|
|
143
|
+
);
|
|
104
144
|
console.log(`variant: ${hooks.variant}\n`);
|
|
105
145
|
|
|
106
146
|
try {
|
|
107
147
|
console.log("1. signup (session A)");
|
|
108
148
|
const emailA = uniqueEmail("a");
|
|
109
|
-
const signupA = await call("POST", "/auth/signup", {
|
|
110
|
-
|
|
149
|
+
const signupA = await call("POST", "/auth/signup", {
|
|
150
|
+
body: { email: emailA, password: "correct-horse-battery" },
|
|
151
|
+
});
|
|
152
|
+
assert(
|
|
153
|
+
signupA.status === 201 || signupA.status === 200,
|
|
154
|
+
`signup succeeds with only email + password in the body (got ${signupA.status})`,
|
|
155
|
+
);
|
|
111
156
|
const tokensA = signupA.body as AuthTokens;
|
|
112
|
-
assert(
|
|
157
|
+
assert(
|
|
158
|
+
!!tokensA.accessToken && !!tokensA.refreshToken,
|
|
159
|
+
"signup returns access + refresh tokens",
|
|
160
|
+
);
|
|
113
161
|
|
|
114
162
|
// Asserted as an exact set rather than a list of absences, and kept exact on purpose: this is
|
|
115
163
|
// the assertion that stops authorization data creeping back into the token later. There is no
|
|
@@ -121,36 +169,76 @@ async function main() {
|
|
|
121
169
|
Object.keys(claimsA).sort().join(",") === "exp,iat,jti,sessionId,sub",
|
|
122
170
|
`the access token carries exactly the expected claims — identity and session only (got ${Object.keys(claimsA).sort().join(",")})`,
|
|
123
171
|
);
|
|
124
|
-
assert(
|
|
172
|
+
assert(
|
|
173
|
+
claimsA.sub ===
|
|
174
|
+
(await call("GET", "/auth/me", { token: tokensA.accessToken })).body
|
|
175
|
+
.sub,
|
|
176
|
+
"the token's subject is the user",
|
|
177
|
+
);
|
|
125
178
|
|
|
126
|
-
const meSignup = await call("GET", "/auth/me", {
|
|
179
|
+
const meSignup = await call("GET", "/auth/me", {
|
|
180
|
+
token: tokensA.accessToken,
|
|
181
|
+
});
|
|
127
182
|
assert(
|
|
128
|
-
hooks.variant === "workspaces"
|
|
183
|
+
hooks.variant === "workspaces"
|
|
184
|
+
? meSignup.body.roles.length === 0
|
|
185
|
+
: meSignup.body.roles.length > 0,
|
|
129
186
|
`a new signup's roles come from the database, not the token (got ${JSON.stringify(meSignup.body.roles)})`,
|
|
130
187
|
);
|
|
131
188
|
|
|
132
189
|
console.log("2. login (session B, a second device for the same user)");
|
|
133
|
-
const loginB = await call("POST", "/auth/login", {
|
|
190
|
+
const loginB = await call("POST", "/auth/login", {
|
|
191
|
+
body: { identifier: emailA, password: "correct-horse-battery" },
|
|
192
|
+
});
|
|
134
193
|
const tokensB = loginB.body as AuthTokens;
|
|
135
|
-
assert(
|
|
136
|
-
|
|
194
|
+
assert(
|
|
195
|
+
loginB.status === 201 || loginB.status === 200,
|
|
196
|
+
`login succeeds (got ${loginB.status})`,
|
|
197
|
+
);
|
|
198
|
+
assert(
|
|
199
|
+
tokensB.sessionId !== tokensA.sessionId,
|
|
200
|
+
"login creates a distinct session from signup's",
|
|
201
|
+
);
|
|
137
202
|
|
|
138
203
|
console.log("2b. login with wrong password is rejected");
|
|
139
|
-
const badLogin = await call("POST", "/auth/login", {
|
|
140
|
-
|
|
204
|
+
const badLogin = await call("POST", "/auth/login", {
|
|
205
|
+
body: { identifier: emailA, password: "wrong" },
|
|
206
|
+
});
|
|
207
|
+
assert(
|
|
208
|
+
badLogin.status === 401,
|
|
209
|
+
`wrong password rejected (got ${badLogin.status})`,
|
|
210
|
+
);
|
|
141
211
|
|
|
142
|
-
console.log(
|
|
143
|
-
|
|
144
|
-
|
|
212
|
+
console.log(
|
|
213
|
+
"2c. a second signup with the same email is rejected — email is globally unique",
|
|
214
|
+
);
|
|
215
|
+
const duplicate = await call("POST", "/auth/signup", {
|
|
216
|
+
body: { email: emailA, password: "another-password-1" },
|
|
217
|
+
});
|
|
218
|
+
assert(
|
|
219
|
+
duplicate.status === 409,
|
|
220
|
+
`duplicate email rejected (got ${duplicate.status})`,
|
|
221
|
+
);
|
|
145
222
|
|
|
146
|
-
console.log(
|
|
223
|
+
console.log(
|
|
224
|
+
"3. protected route: GET /auth/me with session A's access token",
|
|
225
|
+
);
|
|
147
226
|
const meA = await call("GET", "/auth/me", { token: tokensA.accessToken });
|
|
148
|
-
assert(
|
|
149
|
-
|
|
227
|
+
assert(
|
|
228
|
+
meA.status === 200,
|
|
229
|
+
`me succeeds with a valid token (got ${meA.status})`,
|
|
230
|
+
);
|
|
231
|
+
assert(
|
|
232
|
+
meA.body?.sessionId === tokensA.sessionId,
|
|
233
|
+
"me reflects session A's identity",
|
|
234
|
+
);
|
|
150
235
|
|
|
151
236
|
console.log("3b. protected route rejects a missing token");
|
|
152
237
|
const meNoAuth = await call("GET", "/auth/me");
|
|
153
|
-
assert(
|
|
238
|
+
assert(
|
|
239
|
+
meNoAuth.status === 401,
|
|
240
|
+
`me rejects no token (got ${meNoAuth.status})`,
|
|
241
|
+
);
|
|
154
242
|
|
|
155
243
|
console.log("3c. the three route tiers, from the outside");
|
|
156
244
|
// Tier 1: no token at all. Each of these is marked @Public() in the source; the assertion is
|
|
@@ -159,211 +247,487 @@ async function main() {
|
|
|
159
247
|
// running, not the guard refusing.
|
|
160
248
|
const MISSING_TOKEN = "missing bearer token";
|
|
161
249
|
const publicProbes: Array<[string, string, unknown]> = [
|
|
162
|
-
[
|
|
250
|
+
[
|
|
251
|
+
"POST",
|
|
252
|
+
"/auth/login",
|
|
253
|
+
{ identifier: emailA, password: "wrong-on-purpose" },
|
|
254
|
+
],
|
|
163
255
|
["POST", "/auth/refresh", { refreshToken: "not-a-real-token" }],
|
|
164
256
|
["POST", "/auth/password/forgot", { email: uniqueEmail("nobody") }],
|
|
165
257
|
["POST", "/auth/login/2fa", { challengeToken: "nope", code: "000000" }],
|
|
166
|
-
[
|
|
258
|
+
[
|
|
259
|
+
"POST",
|
|
260
|
+
"/auth/password/reset",
|
|
261
|
+
{ token: "nope", newPassword: "irrelevant-1" },
|
|
262
|
+
],
|
|
167
263
|
["GET", "/auth/oauth/google/start", undefined],
|
|
168
264
|
];
|
|
169
265
|
for (const [method, path, body] of publicProbes) {
|
|
170
266
|
const res = await call(method, path, { body });
|
|
171
|
-
assert(
|
|
267
|
+
assert(
|
|
268
|
+
res.body?.message !== MISSING_TOKEN,
|
|
269
|
+
`${method} ${path} is reachable with no token (got ${res.status} ${res.body?.message ?? ""})`,
|
|
270
|
+
);
|
|
172
271
|
}
|
|
173
272
|
|
|
174
273
|
// Tier 2: any authenticated caller, no permission. `tokensA` holds no administrative
|
|
175
274
|
// permission whatsoever, and these must still answer.
|
|
176
275
|
for (const path of ["/auth/me", "/auth/sessions"]) {
|
|
177
276
|
const authed = await call("GET", path, { token: tokensA.accessToken });
|
|
178
|
-
assert(
|
|
277
|
+
assert(
|
|
278
|
+
authed.status === 200,
|
|
279
|
+
`GET ${path} is open to any authenticated user (got ${authed.status})`,
|
|
280
|
+
);
|
|
179
281
|
const anon = await call("GET", path);
|
|
180
|
-
assert(
|
|
282
|
+
assert(
|
|
283
|
+
anon.status === 401,
|
|
284
|
+
`…and refuses an anonymous caller (got ${anon.status})`,
|
|
285
|
+
);
|
|
181
286
|
}
|
|
182
287
|
|
|
183
288
|
console.log("4. refresh session A's token");
|
|
184
|
-
const refreshed = await call("POST", "/auth/refresh", {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
assert(
|
|
289
|
+
const refreshed = await call("POST", "/auth/refresh", {
|
|
290
|
+
body: { refreshToken: tokensA.refreshToken },
|
|
291
|
+
});
|
|
292
|
+
assert(
|
|
293
|
+
refreshed.status === 200 || refreshed.status === 201,
|
|
294
|
+
`refresh succeeds (got ${refreshed.status})`,
|
|
295
|
+
);
|
|
296
|
+
const rotatedA = refreshed.body as {
|
|
297
|
+
accessToken: string;
|
|
298
|
+
refreshToken: string;
|
|
299
|
+
};
|
|
300
|
+
assert(
|
|
301
|
+
rotatedA.refreshToken !== tokensA.refreshToken,
|
|
302
|
+
"refresh issues a new refresh token (rotation)",
|
|
303
|
+
);
|
|
188
304
|
|
|
189
|
-
console.log(
|
|
190
|
-
|
|
191
|
-
|
|
305
|
+
console.log(
|
|
306
|
+
"4b. reuse of the now-dead original refresh token is detected and kills the whole session family",
|
|
307
|
+
);
|
|
308
|
+
const reuse = await call("POST", "/auth/refresh", {
|
|
309
|
+
body: { refreshToken: tokensA.refreshToken },
|
|
310
|
+
});
|
|
311
|
+
assert(
|
|
312
|
+
reuse.status === 401,
|
|
313
|
+
`stale/reused refresh token rejected (got ${reuse.status})`,
|
|
314
|
+
);
|
|
192
315
|
|
|
193
|
-
const reuseFalloutB = await call("POST", "/auth/refresh", {
|
|
194
|
-
|
|
316
|
+
const reuseFalloutB = await call("POST", "/auth/refresh", {
|
|
317
|
+
body: { refreshToken: tokensB.refreshToken },
|
|
318
|
+
});
|
|
319
|
+
assert(
|
|
320
|
+
reuseFalloutB.status === 401,
|
|
321
|
+
`reuse detection also killed session B's refresh (got ${reuseFalloutB.status})`,
|
|
322
|
+
);
|
|
195
323
|
|
|
196
324
|
// Self-contained on purpose: the reuse detection just above kills every session belonging to
|
|
197
325
|
// user A, so anything asserting on a *live* session has to bring its own.
|
|
198
|
-
console.log(
|
|
199
|
-
|
|
326
|
+
console.log(
|
|
327
|
+
"4c. a session's lifetime: an absolute expiry that refreshing does not extend",
|
|
328
|
+
);
|
|
329
|
+
type LiveSession = {
|
|
330
|
+
id: string;
|
|
331
|
+
createdAt: string;
|
|
332
|
+
expiresAt: string;
|
|
333
|
+
provider?: string;
|
|
334
|
+
};
|
|
200
335
|
const lifetimeEmail = uniqueEmail("lifetime");
|
|
201
336
|
const lifetimeFirst = await signup(lifetimeEmail, "lifetime-pw-123");
|
|
202
|
-
const lifetimeSessionId = decodeJwtPayload(lifetimeFirst.accessToken)
|
|
337
|
+
const lifetimeSessionId = decodeJwtPayload(lifetimeFirst.accessToken)
|
|
338
|
+
.sessionId as string;
|
|
203
339
|
|
|
204
|
-
const listSessions = async (token: string) =>
|
|
205
|
-
|
|
340
|
+
const listSessions = async (token: string) =>
|
|
341
|
+
(await call("GET", "/auth/sessions", { token })).body as LiveSession[];
|
|
342
|
+
const live = (await listSessions(lifetimeFirst.accessToken)).find(
|
|
343
|
+
(s) => s.id === lifetimeSessionId,
|
|
344
|
+
);
|
|
206
345
|
assert(!!live, "the caller's own session is listed");
|
|
207
346
|
assert(
|
|
208
|
-
!!live &&
|
|
347
|
+
!!live &&
|
|
348
|
+
new Date(live.expiresAt).getTime() > new Date(live.createdAt).getTime(),
|
|
209
349
|
`a session expires strictly after it was created (got ${live?.createdAt} → ${live?.expiresAt})`,
|
|
210
350
|
);
|
|
211
|
-
assert(
|
|
351
|
+
assert(
|
|
352
|
+
!live?.provider,
|
|
353
|
+
"a password login records no OAuth provider on the session",
|
|
354
|
+
);
|
|
212
355
|
|
|
213
356
|
// The point of `expires_at` being written once and never rewritten: rotation issues a fresh
|
|
214
357
|
// refresh token every time, so without an absolute cap a continuously active client would hold
|
|
215
358
|
// one session open forever.
|
|
216
|
-
const lifetimeRotated = (
|
|
217
|
-
|
|
218
|
-
|
|
359
|
+
const lifetimeRotated = (
|
|
360
|
+
await call("POST", "/auth/refresh", {
|
|
361
|
+
body: { refreshToken: lifetimeFirst.refreshToken },
|
|
362
|
+
})
|
|
363
|
+
).body as AuthTokens;
|
|
364
|
+
const afterRefresh = (await listSessions(lifetimeRotated.accessToken)).find(
|
|
365
|
+
(s) => s.id === lifetimeSessionId,
|
|
366
|
+
);
|
|
367
|
+
assert(
|
|
368
|
+
afterRefresh?.expiresAt === live?.expiresAt,
|
|
369
|
+
`refreshing does not push the session's expiry out (got ${afterRefresh?.expiresAt})`,
|
|
370
|
+
);
|
|
219
371
|
|
|
220
372
|
// A second device for the same user, so the list can be read after the first one is revoked.
|
|
221
|
-
const lifetimeSecond = (
|
|
222
|
-
|
|
373
|
+
const lifetimeSecond = (
|
|
374
|
+
await call("POST", "/auth/login", {
|
|
375
|
+
body: { identifier: lifetimeEmail, password: "lifetime-pw-123" },
|
|
376
|
+
})
|
|
377
|
+
).body as AuthTokens;
|
|
378
|
+
assert(
|
|
379
|
+
(await listSessions(lifetimeSecond.accessToken)).some(
|
|
380
|
+
(s) => s.id === lifetimeSessionId,
|
|
381
|
+
),
|
|
382
|
+
"a second device sees the first device's session",
|
|
383
|
+
);
|
|
223
384
|
|
|
224
385
|
await call("POST", "/auth/logout", { token: lifetimeRotated.accessToken });
|
|
225
386
|
// Revocation is never a delete: the row survives with `is_revoked` set, and what changes is
|
|
226
387
|
// that it stops counting as active.
|
|
227
388
|
assert(
|
|
228
|
-
!(await listSessions(lifetimeSecond.accessToken)).some(
|
|
389
|
+
!(await listSessions(lifetimeSecond.accessToken)).some(
|
|
390
|
+
(s) => s.id === lifetimeSessionId,
|
|
391
|
+
),
|
|
229
392
|
"a revoked session stops being listed as active",
|
|
230
393
|
);
|
|
231
394
|
|
|
232
|
-
console.log(
|
|
233
|
-
|
|
234
|
-
|
|
395
|
+
console.log(
|
|
396
|
+
"5. logout-one-device: revoke session A specifically using its (rotated) access token",
|
|
397
|
+
);
|
|
398
|
+
const logoutA = await call("POST", "/auth/logout", {
|
|
399
|
+
token: rotatedA.accessToken,
|
|
400
|
+
});
|
|
401
|
+
assert(
|
|
402
|
+
logoutA.status === 200 || logoutA.status === 201,
|
|
403
|
+
`logout succeeds (got ${logoutA.status})`,
|
|
404
|
+
);
|
|
235
405
|
|
|
236
|
-
const meAAfterLogout = await call("GET", "/auth/me", {
|
|
237
|
-
|
|
406
|
+
const meAAfterLogout = await call("GET", "/auth/me", {
|
|
407
|
+
token: rotatedA.accessToken,
|
|
408
|
+
});
|
|
409
|
+
assert(
|
|
410
|
+
meAAfterLogout.status === 401,
|
|
411
|
+
`session A's access token is denylisted after logout (got ${meAAfterLogout.status})`,
|
|
412
|
+
);
|
|
238
413
|
|
|
239
414
|
console.log("6. block/unblock (admin-scoped)");
|
|
240
415
|
const adminEmail = uniqueEmail("admin");
|
|
241
416
|
const adminSignup = await signup(adminEmail, "admin-pw-123");
|
|
242
|
-
const adminUserId = (
|
|
243
|
-
|
|
417
|
+
const adminUserId = (
|
|
418
|
+
await call("GET", "/auth/me", { token: adminSignup.accessToken })
|
|
419
|
+
).body.sub as string;
|
|
420
|
+
const admin = await hooks.makeAdmin(ctx, {
|
|
421
|
+
userId: adminUserId,
|
|
422
|
+
email: adminEmail,
|
|
423
|
+
password: "admin-pw-123",
|
|
424
|
+
tokens: adminSignup,
|
|
425
|
+
});
|
|
244
426
|
|
|
245
427
|
const victimEmail = uniqueEmail("victim");
|
|
246
428
|
const victimTokens = await signup(victimEmail, "victim-pw-123");
|
|
247
|
-
const victimId = (
|
|
429
|
+
const victimId = (
|
|
430
|
+
await call("GET", "/auth/me", { token: victimTokens.accessToken })
|
|
431
|
+
).body.sub as string;
|
|
248
432
|
await hooks.admitUser(ctx, admin, { userId: victimId, email: victimEmail });
|
|
249
433
|
|
|
250
434
|
console.log("6a. a non-admin cannot block anyone");
|
|
251
|
-
const forbiddenBlock = await call(
|
|
252
|
-
|
|
435
|
+
const forbiddenBlock = await call(
|
|
436
|
+
"POST",
|
|
437
|
+
`/auth/admin/users/${victimId}/block`,
|
|
438
|
+
{ token: victimTokens.accessToken, workspaceId: admin.workspaceId },
|
|
439
|
+
);
|
|
440
|
+
assert(
|
|
441
|
+
forbiddenBlock.status === 403,
|
|
442
|
+
`non-admin block attempt rejected (got ${forbiddenBlock.status})`,
|
|
443
|
+
);
|
|
253
444
|
|
|
254
445
|
console.log("6b. an admin cannot block themselves");
|
|
255
|
-
const selfBlock = await call(
|
|
256
|
-
|
|
446
|
+
const selfBlock = await call(
|
|
447
|
+
"POST",
|
|
448
|
+
`/auth/admin/users/${adminUserId}/block`,
|
|
449
|
+
{ token: await admin.freshToken(), workspaceId: admin.workspaceId },
|
|
450
|
+
);
|
|
451
|
+
assert(
|
|
452
|
+
selfBlock.status === 403,
|
|
453
|
+
`admin self-block rejected (got ${selfBlock.status})`,
|
|
454
|
+
);
|
|
257
455
|
|
|
258
456
|
console.log("6b-ii. an admin cannot revoke their own role");
|
|
259
457
|
// Self-revoking `admin` strips the permission that authorised the call. In a workspace
|
|
260
458
|
// there is no way back in afterwards, so the route refuses it the same way self-block does.
|
|
261
|
-
const selfRevoke = await call(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
459
|
+
const selfRevoke = await call(
|
|
460
|
+
"POST",
|
|
461
|
+
`/auth/admin/users/${adminUserId}/roles/admin/revoke`,
|
|
462
|
+
{ token: await admin.freshToken(), workspaceId: admin.workspaceId },
|
|
463
|
+
);
|
|
464
|
+
assert(
|
|
465
|
+
selfRevoke.status === 403,
|
|
466
|
+
`admin self-role-revoke rejected (got ${selfRevoke.status})`,
|
|
467
|
+
);
|
|
468
|
+
const stillAdmin = await call("GET", "/auth/admin/users", {
|
|
469
|
+
token: await admin.freshToken(),
|
|
470
|
+
workspaceId: admin.workspaceId,
|
|
471
|
+
});
|
|
472
|
+
assert(
|
|
473
|
+
stillAdmin.status === 200,
|
|
474
|
+
`admin still authorised after the refused self-revoke (got ${stillAdmin.status})`,
|
|
475
|
+
);
|
|
265
476
|
|
|
266
477
|
console.log("6c. admin blocks the victim");
|
|
267
|
-
const block = await call("POST", `/auth/admin/users/${victimId}/block`, {
|
|
268
|
-
|
|
478
|
+
const block = await call("POST", `/auth/admin/users/${victimId}/block`, {
|
|
479
|
+
token: await admin.freshToken(),
|
|
480
|
+
workspaceId: admin.workspaceId,
|
|
481
|
+
});
|
|
482
|
+
assert(
|
|
483
|
+
block.status === 200 || block.status === 201,
|
|
484
|
+
`block succeeds (got ${block.status})`,
|
|
485
|
+
);
|
|
269
486
|
|
|
270
|
-
console.log(
|
|
271
|
-
|
|
272
|
-
|
|
487
|
+
console.log(
|
|
488
|
+
"7. get-rejected-after-block: victim cannot log in or refresh anymore",
|
|
489
|
+
);
|
|
490
|
+
const victimLoginAfterBlock = await call("POST", "/auth/login", {
|
|
491
|
+
body: { identifier: victimEmail, password: "victim-pw-123" },
|
|
492
|
+
});
|
|
493
|
+
assert(
|
|
494
|
+
victimLoginAfterBlock.status === 401,
|
|
495
|
+
`blocked user cannot log in (got ${victimLoginAfterBlock.status})`,
|
|
496
|
+
);
|
|
273
497
|
|
|
274
|
-
const victimRefreshAfterBlock = await call("POST", "/auth/refresh", {
|
|
275
|
-
|
|
498
|
+
const victimRefreshAfterBlock = await call("POST", "/auth/refresh", {
|
|
499
|
+
body: { refreshToken: victimTokens.refreshToken },
|
|
500
|
+
});
|
|
501
|
+
assert(
|
|
502
|
+
victimRefreshAfterBlock.status === 401,
|
|
503
|
+
`blocked user's refresh token rejected (got ${victimRefreshAfterBlock.status})`,
|
|
504
|
+
);
|
|
276
505
|
|
|
277
506
|
console.log("7b. unblock restores access");
|
|
278
|
-
const unblock = await call(
|
|
279
|
-
|
|
280
|
-
|
|
507
|
+
const unblock = await call(
|
|
508
|
+
"POST",
|
|
509
|
+
`/auth/admin/users/${victimId}/unblock`,
|
|
510
|
+
{ token: await admin.freshToken(), workspaceId: admin.workspaceId },
|
|
511
|
+
);
|
|
281
512
|
assert(
|
|
282
|
-
|
|
513
|
+
unblock.status === 200 || unblock.status === 201,
|
|
514
|
+
`unblock succeeds (got ${unblock.status})`,
|
|
515
|
+
);
|
|
516
|
+
const victimLoginAfterUnblock = await call("POST", "/auth/login", {
|
|
517
|
+
body: { identifier: victimEmail, password: "victim-pw-123" },
|
|
518
|
+
});
|
|
519
|
+
assert(
|
|
520
|
+
victimLoginAfterUnblock.status === 200 ||
|
|
521
|
+
victimLoginAfterUnblock.status === 201,
|
|
283
522
|
`unblocked user can log in again (got ${victimLoginAfterUnblock.status})`,
|
|
284
523
|
);
|
|
285
524
|
|
|
286
|
-
console.log(
|
|
287
|
-
|
|
288
|
-
|
|
525
|
+
console.log(
|
|
526
|
+
"7c. deactivate is a distinct toggle from block — both independently deny login, neither implies the other",
|
|
527
|
+
);
|
|
528
|
+
const deactivate = await call(
|
|
529
|
+
"POST",
|
|
530
|
+
`/auth/admin/users/${victimId}/deactivate`,
|
|
531
|
+
{ token: await admin.freshToken(), workspaceId: admin.workspaceId },
|
|
532
|
+
);
|
|
533
|
+
assert(
|
|
534
|
+
deactivate.status === 200 || deactivate.status === 201,
|
|
535
|
+
`deactivate succeeds (got ${deactivate.status})`,
|
|
536
|
+
);
|
|
289
537
|
|
|
290
|
-
const victimLoginAfterDeactivate = await call("POST", "/auth/login", {
|
|
291
|
-
|
|
538
|
+
const victimLoginAfterDeactivate = await call("POST", "/auth/login", {
|
|
539
|
+
body: { identifier: victimEmail, password: "victim-pw-123" },
|
|
540
|
+
});
|
|
541
|
+
assert(
|
|
542
|
+
victimLoginAfterDeactivate.status === 401,
|
|
543
|
+
`deactivated (but not blocked) user cannot log in either (got ${victimLoginAfterDeactivate.status})`,
|
|
544
|
+
);
|
|
292
545
|
|
|
293
|
-
const usersAfterDeactivate = await call("GET", "/auth/admin/users", {
|
|
294
|
-
|
|
295
|
-
|
|
546
|
+
const usersAfterDeactivate = await call("GET", "/auth/admin/users", {
|
|
547
|
+
token: await admin.freshToken(),
|
|
548
|
+
workspaceId: admin.workspaceId,
|
|
549
|
+
});
|
|
550
|
+
const victimAfterDeactivate = (
|
|
551
|
+
usersAfterDeactivate.body as {
|
|
552
|
+
items: Array<{
|
|
553
|
+
id?: string;
|
|
554
|
+
userId?: string;
|
|
555
|
+
blocked: boolean;
|
|
556
|
+
isActive: boolean;
|
|
557
|
+
}>;
|
|
558
|
+
}
|
|
559
|
+
).items.find((u) => (u.id ?? u.userId) === victimId);
|
|
560
|
+
assert(
|
|
561
|
+
victimAfterDeactivate?.isActive === false,
|
|
562
|
+
"…and the user list reflects isActive=false",
|
|
563
|
+
);
|
|
564
|
+
assert(
|
|
565
|
+
victimAfterDeactivate?.blocked === false,
|
|
566
|
+
"…while blocked stays false — deactivating never sets it, they are independent flags",
|
|
296
567
|
);
|
|
297
|
-
assert(victimAfterDeactivate?.isActive === false, "…and the user list reflects isActive=false");
|
|
298
|
-
assert(victimAfterDeactivate?.blocked === false, "…while blocked stays false — deactivating never sets it, they are independent flags");
|
|
299
568
|
|
|
300
|
-
const reactivate = await call(
|
|
301
|
-
|
|
302
|
-
|
|
569
|
+
const reactivate = await call(
|
|
570
|
+
"POST",
|
|
571
|
+
`/auth/admin/users/${victimId}/activate`,
|
|
572
|
+
{ token: await admin.freshToken(), workspaceId: admin.workspaceId },
|
|
573
|
+
);
|
|
574
|
+
assert(
|
|
575
|
+
reactivate.status === 200 || reactivate.status === 201,
|
|
576
|
+
`activate succeeds (got ${reactivate.status})`,
|
|
577
|
+
);
|
|
578
|
+
const victimLoginAfterReactivate = await call("POST", "/auth/login", {
|
|
579
|
+
body: { identifier: victimEmail, password: "victim-pw-123" },
|
|
580
|
+
});
|
|
303
581
|
assert(
|
|
304
|
-
victimLoginAfterReactivate.status === 200 ||
|
|
582
|
+
victimLoginAfterReactivate.status === 200 ||
|
|
583
|
+
victimLoginAfterReactivate.status === 201,
|
|
305
584
|
`reactivated user can log in again (got ${victimLoginAfterReactivate.status})`,
|
|
306
585
|
);
|
|
307
586
|
|
|
308
|
-
console.log(
|
|
587
|
+
console.log(
|
|
588
|
+
"8. two-factor: enroll -> confirm -> login now requires 2FA -> complete challenge -> logout",
|
|
589
|
+
);
|
|
309
590
|
const tfaEmail = uniqueEmail("tfa");
|
|
310
591
|
const tfaTokens = await signup(tfaEmail, "tfa-pw-12345");
|
|
311
592
|
|
|
312
|
-
const enroll = await call("POST", "/auth/2fa/enroll", {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
593
|
+
const enroll = await call("POST", "/auth/2fa/enroll", {
|
|
594
|
+
token: tfaTokens.accessToken,
|
|
595
|
+
});
|
|
596
|
+
assert(
|
|
597
|
+
enroll.status === 200 || enroll.status === 201,
|
|
598
|
+
`2fa enroll succeeds (got ${enroll.status})`,
|
|
599
|
+
);
|
|
600
|
+
const { secret } = enroll.body as {
|
|
601
|
+
secret: string;
|
|
602
|
+
provisioningUri: string;
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const confirm = await call("POST", "/auth/2fa/confirm", {
|
|
606
|
+
token: tfaTokens.accessToken,
|
|
607
|
+
body: { code: generateTotpCode(secret) },
|
|
608
|
+
});
|
|
609
|
+
assert(
|
|
610
|
+
confirm.status === 200 || confirm.status === 201,
|
|
611
|
+
`2fa confirm succeeds (got ${confirm.status})`,
|
|
612
|
+
);
|
|
318
613
|
const { backupCodes } = confirm.body as { backupCodes: string[] };
|
|
319
|
-
assert(
|
|
614
|
+
assert(
|
|
615
|
+
backupCodes.length === 10,
|
|
616
|
+
`2fa confirm returns backup codes (got ${backupCodes.length})`,
|
|
617
|
+
);
|
|
320
618
|
|
|
321
|
-
const tfaLogin = await call("POST", "/auth/login", {
|
|
322
|
-
|
|
323
|
-
|
|
619
|
+
const tfaLogin = await call("POST", "/auth/login", {
|
|
620
|
+
body: { identifier: tfaEmail, password: "tfa-pw-12345" },
|
|
621
|
+
});
|
|
622
|
+
const tfaChallenge = tfaLogin.body as {
|
|
623
|
+
twoFactorRequired: true;
|
|
624
|
+
challengeToken: string;
|
|
625
|
+
};
|
|
626
|
+
assert(
|
|
627
|
+
tfaChallenge.twoFactorRequired === true,
|
|
628
|
+
`login now returns a 2fa challenge instead of tokens (got status ${tfaLogin.status})`,
|
|
629
|
+
);
|
|
324
630
|
|
|
325
|
-
const tfaComplete = await call("POST", "/auth/login/2fa", {
|
|
326
|
-
|
|
631
|
+
const tfaComplete = await call("POST", "/auth/login/2fa", {
|
|
632
|
+
body: {
|
|
633
|
+
challengeToken: tfaChallenge.challengeToken,
|
|
634
|
+
code: generateTotpCode(secret),
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
assert(
|
|
638
|
+
tfaComplete.status === 200 || tfaComplete.status === 201,
|
|
639
|
+
`2fa challenge completes login (got ${tfaComplete.status})`,
|
|
640
|
+
);
|
|
327
641
|
const tfaSessionTokens = tfaComplete.body as { accessToken: string };
|
|
328
642
|
|
|
329
|
-
const tfaBadChallenge = await call("POST", "/auth/login/2fa", {
|
|
330
|
-
|
|
643
|
+
const tfaBadChallenge = await call("POST", "/auth/login/2fa", {
|
|
644
|
+
body: { challengeToken: tfaChallenge.challengeToken, code: "000000" },
|
|
645
|
+
});
|
|
646
|
+
assert(
|
|
647
|
+
tfaBadChallenge.status === 401,
|
|
648
|
+
`wrong 2fa code rejected (got ${tfaBadChallenge.status})`,
|
|
649
|
+
);
|
|
331
650
|
|
|
332
|
-
const tfaLogout = await call("POST", "/auth/logout", {
|
|
333
|
-
|
|
651
|
+
const tfaLogout = await call("POST", "/auth/logout", {
|
|
652
|
+
token: tfaSessionTokens.accessToken,
|
|
653
|
+
});
|
|
654
|
+
assert(
|
|
655
|
+
tfaLogout.status === 200 || tfaLogout.status === 201,
|
|
656
|
+
`logout after 2fa login succeeds (got ${tfaLogout.status})`,
|
|
657
|
+
);
|
|
334
658
|
|
|
335
|
-
console.log(
|
|
659
|
+
console.log(
|
|
660
|
+
"9. forgot/reset password -> old password stops working -> old sessions revoked",
|
|
661
|
+
);
|
|
336
662
|
const resetEmail = uniqueEmail("reset");
|
|
337
663
|
const resetTokens = await signup(resetEmail, "original-password-1");
|
|
338
664
|
|
|
339
|
-
const forgot = await call("POST", "/auth/password/forgot", {
|
|
340
|
-
|
|
665
|
+
const forgot = await call("POST", "/auth/password/forgot", {
|
|
666
|
+
body: { email: resetEmail },
|
|
667
|
+
});
|
|
668
|
+
assert(
|
|
669
|
+
forgot.status === 200 || forgot.status === 201,
|
|
670
|
+
`forgot-password always reports success (got ${forgot.status})`,
|
|
671
|
+
);
|
|
341
672
|
const rawResetToken = capturedResetTokens.get(resetEmail);
|
|
342
|
-
assert(
|
|
673
|
+
assert(
|
|
674
|
+
!!rawResetToken,
|
|
675
|
+
"the reset token reached the sendPasswordResetEmail hook",
|
|
676
|
+
);
|
|
343
677
|
|
|
344
|
-
const badReset = await call("POST", "/auth/password/reset", {
|
|
345
|
-
|
|
678
|
+
const badReset = await call("POST", "/auth/password/reset", {
|
|
679
|
+
body: { token: "not-a-real-token", newPassword: "irrelevant-1" },
|
|
680
|
+
});
|
|
681
|
+
assert(
|
|
682
|
+
badReset.status === 401,
|
|
683
|
+
`reset with an invalid token is rejected (got ${badReset.status})`,
|
|
684
|
+
);
|
|
346
685
|
|
|
347
|
-
const reset = await call("POST", "/auth/password/reset", {
|
|
348
|
-
|
|
686
|
+
const reset = await call("POST", "/auth/password/reset", {
|
|
687
|
+
body: { token: rawResetToken, newPassword: "new-password-2" },
|
|
688
|
+
});
|
|
689
|
+
assert(
|
|
690
|
+
reset.status === 200 || reset.status === 201,
|
|
691
|
+
`reset with a valid token succeeds (got ${reset.status})`,
|
|
692
|
+
);
|
|
349
693
|
|
|
350
694
|
// Session revocation bumps sessionVersion/revokedAt, which invalidates future *refreshes*
|
|
351
695
|
// immediately; it does not retroactively invalidate an already-issued, still-unexpired
|
|
352
696
|
// access token (same "denylist is for instant per-token kill, sessionVersion is for
|
|
353
697
|
// refresh-time revocation" split that /auth/logout relies on elsewhere in this file).
|
|
354
|
-
const refreshAfterReset = await call("POST", "/auth/refresh", {
|
|
355
|
-
|
|
698
|
+
const refreshAfterReset = await call("POST", "/auth/refresh", {
|
|
699
|
+
body: { refreshToken: resetTokens.refreshToken },
|
|
700
|
+
});
|
|
701
|
+
assert(
|
|
702
|
+
refreshAfterReset.status === 401,
|
|
703
|
+
`pre-reset session's refresh token is invalid after password reset (got ${refreshAfterReset.status})`,
|
|
704
|
+
);
|
|
356
705
|
|
|
357
|
-
const loginWithOldPassword = await call("POST", "/auth/login", {
|
|
358
|
-
|
|
706
|
+
const loginWithOldPassword = await call("POST", "/auth/login", {
|
|
707
|
+
body: { identifier: resetEmail, password: "original-password-1" },
|
|
708
|
+
});
|
|
709
|
+
assert(
|
|
710
|
+
loginWithOldPassword.status === 401,
|
|
711
|
+
`old password no longer works (got ${loginWithOldPassword.status})`,
|
|
712
|
+
);
|
|
359
713
|
|
|
360
|
-
const loginWithNewPassword = await call("POST", "/auth/login", {
|
|
361
|
-
|
|
714
|
+
const loginWithNewPassword = await call("POST", "/auth/login", {
|
|
715
|
+
body: { identifier: resetEmail, password: "new-password-2" },
|
|
716
|
+
});
|
|
717
|
+
assert(
|
|
718
|
+
loginWithNewPassword.status === 200 ||
|
|
719
|
+
loginWithNewPassword.status === 201,
|
|
720
|
+
`new password works (got ${loginWithNewPassword.status})`,
|
|
721
|
+
);
|
|
362
722
|
|
|
363
|
-
console.log(
|
|
723
|
+
console.log(
|
|
724
|
+
"10. RBAC: create role, attach permission, assign to user, permission takes effect",
|
|
725
|
+
);
|
|
364
726
|
const rbacEmail = uniqueEmail("rbac");
|
|
365
727
|
const rbacTokens = await signup(rbacEmail, "rbac-pw-12345");
|
|
366
|
-
const rbacUserId = (
|
|
728
|
+
const rbacUserId = (
|
|
729
|
+
await call("GET", "/auth/me", { token: rbacTokens.accessToken })
|
|
730
|
+
).body.sub as string;
|
|
367
731
|
await hooks.admitUser(ctx, admin, { userId: rbacUserId, email: rbacEmail });
|
|
368
732
|
|
|
369
733
|
const createRole = await call("POST", "/auth/admin/roles", {
|
|
@@ -371,75 +735,172 @@ async function main() {
|
|
|
371
735
|
workspaceId: admin.workspaceId,
|
|
372
736
|
body: { slug: ROLE_SLUG, displayName: "Billing manager" },
|
|
373
737
|
});
|
|
374
|
-
assert(
|
|
375
|
-
|
|
376
|
-
|
|
738
|
+
assert(
|
|
739
|
+
createRole.status === 200 || createRole.status === 201,
|
|
740
|
+
`admin can create a role (got ${createRole.status})`,
|
|
741
|
+
);
|
|
742
|
+
const role = createRole.body as {
|
|
743
|
+
id: string;
|
|
744
|
+
slug: string;
|
|
745
|
+
displayName: string;
|
|
746
|
+
};
|
|
747
|
+
assert(
|
|
748
|
+
role.slug === ROLE_SLUG && role.displayName === "Billing manager",
|
|
749
|
+
"a role carries a stable slug and a separate human label",
|
|
750
|
+
);
|
|
377
751
|
|
|
378
|
-
const attachPermission = await call(
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
752
|
+
const attachPermission = await call(
|
|
753
|
+
"POST",
|
|
754
|
+
`/auth/admin/roles/${role.id}/permissions`,
|
|
755
|
+
{
|
|
756
|
+
token: await admin.freshToken(),
|
|
757
|
+
workspaceId: admin.workspaceId,
|
|
758
|
+
body: { permission: "billing:manage" },
|
|
759
|
+
},
|
|
760
|
+
);
|
|
761
|
+
assert(
|
|
762
|
+
attachPermission.status === 200 || attachPermission.status === 201,
|
|
763
|
+
`admin can attach a permission to a role (got ${attachPermission.status})`,
|
|
764
|
+
);
|
|
384
765
|
|
|
385
|
-
const assignRole = await call(
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
766
|
+
const assignRole = await call(
|
|
767
|
+
"POST",
|
|
768
|
+
`/auth/admin/users/${rbacUserId}/roles`,
|
|
769
|
+
{
|
|
770
|
+
token: await admin.freshToken(),
|
|
771
|
+
workspaceId: admin.workspaceId,
|
|
772
|
+
body: { role: ROLE_SLUG },
|
|
773
|
+
},
|
|
774
|
+
);
|
|
775
|
+
assert(
|
|
776
|
+
assignRole.status === 200 || assignRole.status === 201,
|
|
777
|
+
`admin can assign a role to a user (got ${assignRole.status})`,
|
|
778
|
+
);
|
|
391
779
|
|
|
392
|
-
const rbacDirect = await call(
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
780
|
+
const rbacDirect = await call(
|
|
781
|
+
"POST",
|
|
782
|
+
`/auth/admin/users/${rbacUserId}/permissions`,
|
|
783
|
+
{
|
|
784
|
+
token: await admin.freshToken(),
|
|
785
|
+
workspaceId: admin.workspaceId,
|
|
786
|
+
body: { permission: "reports:export" },
|
|
787
|
+
},
|
|
788
|
+
);
|
|
789
|
+
assert(
|
|
790
|
+
rbacDirect.status === 200 || rbacDirect.status === 201,
|
|
791
|
+
`admin can grant a permission directly (got ${rbacDirect.status})`,
|
|
792
|
+
);
|
|
398
793
|
|
|
399
794
|
// No re-login. Nothing about authorization lives in the token in either variant, so both
|
|
400
795
|
// grants are in effect on the caller's very next request, on the token they already hold.
|
|
401
|
-
const rbacMe = await call("GET", "/auth/me", {
|
|
402
|
-
|
|
403
|
-
assert((rbacMe.body?.permissions ?? []).includes("reports:export"), "a direct grant is unioned with the role-derived permissions");
|
|
404
|
-
assert((rbacMe.body?.roles ?? []).includes(ROLE_SLUG), "…and /auth/me reports the role by slug");
|
|
405
|
-
|
|
406
|
-
console.log("11. admin audit log: the role_assigned event from step 10 is readable back");
|
|
407
|
-
const auditList = await call("GET", `/auth/admin/audit-log?userId=${rbacUserId}&action=role_assigned`, {
|
|
408
|
-
token: await admin.freshToken(),
|
|
796
|
+
const rbacMe = await call("GET", "/auth/me", {
|
|
797
|
+
token: rbacTokens.accessToken,
|
|
409
798
|
workspaceId: admin.workspaceId,
|
|
410
799
|
});
|
|
411
|
-
assert(auditList.status === 200, `admin can list the audit log (got ${auditList.status})`);
|
|
412
|
-
const auditEntries = (auditList.body as { items: Array<{ action: string; name: string; userId: string | null }> }).items;
|
|
413
800
|
assert(
|
|
414
|
-
|
|
801
|
+
(rbacMe.body?.permissions ?? []).includes("billing:manage"),
|
|
802
|
+
"the assigned role's permission is in effect on the user's existing token",
|
|
803
|
+
);
|
|
804
|
+
assert(
|
|
805
|
+
(rbacMe.body?.permissions ?? []).includes("reports:export"),
|
|
806
|
+
"a direct grant is unioned with the role-derived permissions",
|
|
807
|
+
);
|
|
808
|
+
assert(
|
|
809
|
+
(rbacMe.body?.roles ?? []).includes(ROLE_SLUG),
|
|
810
|
+
"…and /auth/me reports the role by slug",
|
|
811
|
+
);
|
|
812
|
+
|
|
813
|
+
console.log(
|
|
814
|
+
"11. admin audit log: the role_assigned event from step 10 is readable back",
|
|
815
|
+
);
|
|
816
|
+
const auditList = await call(
|
|
817
|
+
"GET",
|
|
818
|
+
`/auth/admin/audit-log?userId=${rbacUserId}&action=role_assigned`,
|
|
819
|
+
{
|
|
820
|
+
token: await admin.freshToken(),
|
|
821
|
+
workspaceId: admin.workspaceId,
|
|
822
|
+
},
|
|
823
|
+
);
|
|
824
|
+
assert(
|
|
825
|
+
auditList.status === 200,
|
|
826
|
+
`admin can list the audit log (got ${auditList.status})`,
|
|
827
|
+
);
|
|
828
|
+
const auditEntries = (
|
|
829
|
+
auditList.body as {
|
|
830
|
+
items: Array<{ action: string; name: string; userId: string | null }>;
|
|
831
|
+
}
|
|
832
|
+
).items;
|
|
833
|
+
assert(
|
|
834
|
+
auditEntries.some(
|
|
835
|
+
(e) => e.action === "role_assigned" && e.userId === rbacUserId,
|
|
836
|
+
),
|
|
415
837
|
"the role_assigned event for this user shows up in the audit log",
|
|
416
838
|
);
|
|
417
|
-
assert(
|
|
839
|
+
assert(
|
|
840
|
+
auditEntries.every((e) => e.name === "Role assigned"),
|
|
841
|
+
"audit entries carry a human-readable name alongside the action",
|
|
842
|
+
);
|
|
418
843
|
|
|
419
|
-
const auditForbidden = await call("GET", "/auth/admin/audit-log", {
|
|
420
|
-
|
|
844
|
+
const auditForbidden = await call("GET", "/auth/admin/audit-log", {
|
|
845
|
+
token: rbacTokens.accessToken,
|
|
846
|
+
workspaceId: admin.workspaceId,
|
|
847
|
+
});
|
|
848
|
+
assert(
|
|
849
|
+
auditForbidden.status === 403,
|
|
850
|
+
`non-admin cannot list the audit log (got ${auditForbidden.status})`,
|
|
851
|
+
);
|
|
421
852
|
|
|
422
|
-
console.log(
|
|
853
|
+
console.log(
|
|
854
|
+
"11b. user/role CRUD: fetch, edit, and soft-delete both — on disposable rows, so the shared probe target/role are untouched",
|
|
855
|
+
);
|
|
423
856
|
const crudUserEmail = uniqueEmail("crud-user");
|
|
424
857
|
const crudUserTokens = await signup(crudUserEmail, "crud-user-pw-12345");
|
|
425
|
-
const crudUserId = (
|
|
426
|
-
|
|
858
|
+
const crudUserId = (
|
|
859
|
+
await call("GET", "/auth/me", { token: crudUserTokens.accessToken })
|
|
860
|
+
).body.sub as string;
|
|
861
|
+
await hooks.admitUser(ctx, admin, {
|
|
862
|
+
userId: crudUserId,
|
|
863
|
+
email: crudUserEmail,
|
|
864
|
+
});
|
|
427
865
|
|
|
428
|
-
const getUser = await call("GET", `/auth/admin/users/${crudUserId}`, {
|
|
429
|
-
|
|
430
|
-
|
|
866
|
+
const getUser = await call("GET", `/auth/admin/users/${crudUserId}`, {
|
|
867
|
+
token: await admin.freshToken(),
|
|
868
|
+
workspaceId: admin.workspaceId,
|
|
869
|
+
});
|
|
870
|
+
assert(
|
|
871
|
+
getUser.status === 200,
|
|
872
|
+
`admin can fetch a single user (got ${getUser.status})`,
|
|
873
|
+
);
|
|
874
|
+
assert(
|
|
875
|
+
(getUser.body as { email: string }).email === crudUserEmail,
|
|
876
|
+
"…and it's the right one",
|
|
877
|
+
);
|
|
431
878
|
|
|
432
879
|
const updateUser = await call("PATCH", `/auth/admin/users/${crudUserId}`, {
|
|
433
880
|
token: await admin.freshToken(),
|
|
434
881
|
workspaceId: admin.workspaceId,
|
|
435
882
|
body: { displayName: "CRUD Test User" },
|
|
436
883
|
});
|
|
437
|
-
assert(
|
|
438
|
-
|
|
884
|
+
assert(
|
|
885
|
+
updateUser.status === 200,
|
|
886
|
+
`admin can edit a user's profile (got ${updateUser.status})`,
|
|
887
|
+
);
|
|
888
|
+
assert(
|
|
889
|
+
(updateUser.body as { displayName: string | null }).displayName ===
|
|
890
|
+
"CRUD Test User",
|
|
891
|
+
"…and the edit is reflected in the response",
|
|
892
|
+
);
|
|
439
893
|
|
|
440
894
|
const crudRoleSlug = `crud-role-${RUN_ID}`;
|
|
441
|
-
const createCrudRole = await call("POST", "/auth/admin/roles", {
|
|
442
|
-
|
|
895
|
+
const createCrudRole = await call("POST", "/auth/admin/roles", {
|
|
896
|
+
token: await admin.freshToken(),
|
|
897
|
+
workspaceId: admin.workspaceId,
|
|
898
|
+
body: { slug: crudRoleSlug },
|
|
899
|
+
});
|
|
900
|
+
assert(
|
|
901
|
+
createCrudRole.status === 201,
|
|
902
|
+
`admin can create a disposable role for the delete test (got ${createCrudRole.status})`,
|
|
903
|
+
);
|
|
443
904
|
const crudRoleId = (createCrudRole.body as { id: string }).id;
|
|
444
905
|
|
|
445
906
|
const updateRole = await call("PATCH", `/auth/admin/roles/${crudRoleId}`, {
|
|
@@ -447,150 +908,334 @@ async function main() {
|
|
|
447
908
|
workspaceId: admin.workspaceId,
|
|
448
909
|
body: { displayName: "CRUD Test Role" },
|
|
449
910
|
});
|
|
450
|
-
assert(
|
|
451
|
-
|
|
911
|
+
assert(
|
|
912
|
+
updateRole.status === 200,
|
|
913
|
+
`admin can edit a role (got ${updateRole.status})`,
|
|
914
|
+
);
|
|
915
|
+
assert(
|
|
916
|
+
(updateRole.body as { displayName: string }).displayName ===
|
|
917
|
+
"CRUD Test Role",
|
|
918
|
+
"…and the edit is reflected in the response",
|
|
919
|
+
);
|
|
452
920
|
|
|
453
|
-
const assignCrudRole = await call(
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
921
|
+
const assignCrudRole = await call(
|
|
922
|
+
"POST",
|
|
923
|
+
`/auth/admin/users/${crudUserId}/roles`,
|
|
924
|
+
{
|
|
925
|
+
token: await admin.freshToken(),
|
|
926
|
+
workspaceId: admin.workspaceId,
|
|
927
|
+
body: { role: crudRoleSlug },
|
|
928
|
+
},
|
|
929
|
+
);
|
|
930
|
+
assert(
|
|
931
|
+
assignCrudRole.status === 201,
|
|
932
|
+
`the disposable role can be assigned before it's deleted (got ${assignCrudRole.status})`,
|
|
933
|
+
);
|
|
459
934
|
|
|
460
935
|
const deleteRole = await call("DELETE", `/auth/admin/roles/${crudRoleId}`, {
|
|
461
936
|
token: await admin.freshToken(),
|
|
462
937
|
workspaceId: admin.workspaceId,
|
|
463
938
|
body: { reason: "prove-cycle cleanup" },
|
|
464
939
|
});
|
|
465
|
-
assert(
|
|
940
|
+
assert(
|
|
941
|
+
deleteRole.status === 200,
|
|
942
|
+
`admin can delete a role (got ${deleteRole.status})`,
|
|
943
|
+
);
|
|
466
944
|
|
|
467
|
-
const rolesAfterDelete = await call("GET", "/auth/admin/roles", {
|
|
945
|
+
const rolesAfterDelete = await call("GET", "/auth/admin/roles", {
|
|
946
|
+
token: await admin.freshToken(),
|
|
947
|
+
workspaceId: admin.workspaceId,
|
|
948
|
+
});
|
|
468
949
|
assert(
|
|
469
|
-
!(rolesAfterDelete.body as { roles: Array<{ id: string }> }).roles.some(
|
|
950
|
+
!(rolesAfterDelete.body as { roles: Array<{ id: string }> }).roles.some(
|
|
951
|
+
(r) => r.id === crudRoleId,
|
|
952
|
+
),
|
|
470
953
|
"a deleted role no longer appears in the role list",
|
|
471
954
|
);
|
|
472
955
|
|
|
473
|
-
const meAfterRoleDelete = await call("GET", "/auth/me", {
|
|
474
|
-
|
|
956
|
+
const meAfterRoleDelete = await call("GET", "/auth/me", {
|
|
957
|
+
token: crudUserTokens.accessToken,
|
|
958
|
+
workspaceId: admin.workspaceId,
|
|
959
|
+
});
|
|
960
|
+
assert(
|
|
961
|
+
!(meAfterRoleDelete.body?.roles ?? []).includes(crudRoleSlug),
|
|
962
|
+
"…and a member who held it no longer resolves it, with no re-login",
|
|
963
|
+
);
|
|
475
964
|
|
|
476
|
-
const deleteUser = await call("DELETE", `/auth/admin/users/${crudUserId}`, {
|
|
477
|
-
|
|
965
|
+
const deleteUser = await call("DELETE", `/auth/admin/users/${crudUserId}`, {
|
|
966
|
+
token: await admin.freshToken(),
|
|
967
|
+
workspaceId: admin.workspaceId,
|
|
968
|
+
});
|
|
969
|
+
assert(
|
|
970
|
+
deleteUser.status === 200,
|
|
971
|
+
`admin can delete a user (got ${deleteUser.status})`,
|
|
972
|
+
);
|
|
478
973
|
|
|
479
|
-
const usersAfterDelete = await call("GET", "/auth/admin/users", {
|
|
974
|
+
const usersAfterDelete = await call("GET", "/auth/admin/users", {
|
|
975
|
+
token: await admin.freshToken(),
|
|
976
|
+
workspaceId: admin.workspaceId,
|
|
977
|
+
});
|
|
480
978
|
assert(
|
|
481
|
-
!(usersAfterDelete.body as { items: Array<{ id: string }> }).items.some(
|
|
979
|
+
!(usersAfterDelete.body as { items: Array<{ id: string }> }).items.some(
|
|
980
|
+
(u) => u.id === crudUserId,
|
|
981
|
+
),
|
|
482
982
|
"a deleted user no longer appears in the user list",
|
|
483
983
|
);
|
|
484
984
|
|
|
485
|
-
const getDeletedUser = await call(
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
985
|
+
const getDeletedUser = await call(
|
|
986
|
+
"GET",
|
|
987
|
+
`/auth/admin/users/${crudUserId}`,
|
|
988
|
+
{ token: await admin.freshToken(), workspaceId: admin.workspaceId },
|
|
989
|
+
);
|
|
990
|
+
assert(
|
|
991
|
+
getDeletedUser.status === 404,
|
|
992
|
+
`…and fetching them directly by id now 404s (got ${getDeletedUser.status})`,
|
|
993
|
+
);
|
|
490
994
|
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
workspaceId: admin.workspaceId,
|
|
995
|
+
const loginAsDeletedUser = await call("POST", "/auth/login", {
|
|
996
|
+
body: { identifier: crudUserEmail, password: "crud-user-pw-12345" },
|
|
494
997
|
});
|
|
495
|
-
assert(
|
|
998
|
+
assert(
|
|
999
|
+
loginAsDeletedUser.status === 401,
|
|
1000
|
+
`…and they can no longer log in (got ${loginAsDeletedUser.status})`,
|
|
1001
|
+
);
|
|
496
1002
|
|
|
497
|
-
|
|
1003
|
+
const selfDeleteAttempt = await call(
|
|
1004
|
+
"DELETE",
|
|
1005
|
+
`/auth/admin/users/${(await call("GET", "/auth/me", { token: await admin.freshToken() })).body.sub}`,
|
|
1006
|
+
{
|
|
1007
|
+
token: await admin.freshToken(),
|
|
1008
|
+
workspaceId: admin.workspaceId,
|
|
1009
|
+
},
|
|
1010
|
+
);
|
|
1011
|
+
assert(
|
|
1012
|
+
selfDeleteAttempt.status === 403,
|
|
1013
|
+
`an admin cannot delete their own account (got ${selfDeleteAttempt.status})`,
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
console.log(
|
|
1017
|
+
"11c. login accepts a username or a phone number in place of the email, same password",
|
|
1018
|
+
);
|
|
498
1019
|
const identEmail = uniqueEmail("ident");
|
|
499
1020
|
const identTokens = await signup(identEmail, "ident-user-pw-12345");
|
|
500
|
-
const identUserId = (
|
|
501
|
-
|
|
1021
|
+
const identUserId = (
|
|
1022
|
+
await call("GET", "/auth/me", { token: identTokens.accessToken })
|
|
1023
|
+
).body.sub as string;
|
|
1024
|
+
await hooks.admitUser(ctx, admin, {
|
|
1025
|
+
userId: identUserId,
|
|
1026
|
+
email: identEmail,
|
|
1027
|
+
});
|
|
502
1028
|
|
|
503
1029
|
const identUsername = `ident-user-${RUN_ID}`;
|
|
504
1030
|
const identPhone = `+1-555-${RUN_ID}`;
|
|
505
|
-
const setIdentFields = await call(
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
1031
|
+
const setIdentFields = await call(
|
|
1032
|
+
"PATCH",
|
|
1033
|
+
`/auth/admin/users/${identUserId}`,
|
|
1034
|
+
{
|
|
1035
|
+
token: await admin.freshToken(),
|
|
1036
|
+
workspaceId: admin.workspaceId,
|
|
1037
|
+
body: { username: identUsername, phone: identPhone },
|
|
1038
|
+
},
|
|
1039
|
+
);
|
|
1040
|
+
assert(
|
|
1041
|
+
setIdentFields.status === 200,
|
|
1042
|
+
`admin can set a user's username and phone (got ${setIdentFields.status})`,
|
|
1043
|
+
);
|
|
511
1044
|
|
|
512
|
-
const loginByUsername = await call("POST", "/auth/login", {
|
|
513
|
-
|
|
514
|
-
|
|
1045
|
+
const loginByUsername = await call("POST", "/auth/login", {
|
|
1046
|
+
body: { identifier: identUsername, password: "ident-user-pw-12345" },
|
|
1047
|
+
});
|
|
1048
|
+
assert(
|
|
1049
|
+
loginByUsername.status === 201 || loginByUsername.status === 200,
|
|
1050
|
+
`login by username succeeds (got ${loginByUsername.status})`,
|
|
1051
|
+
);
|
|
1052
|
+
assert(
|
|
1053
|
+
(loginByUsername.body as AuthTokens).accessToken !== undefined,
|
|
1054
|
+
"…and returns a real access token",
|
|
1055
|
+
);
|
|
515
1056
|
|
|
516
|
-
const loginByPhone = await call("POST", "/auth/login", {
|
|
517
|
-
|
|
1057
|
+
const loginByPhone = await call("POST", "/auth/login", {
|
|
1058
|
+
body: { identifier: identPhone, password: "ident-user-pw-12345" },
|
|
1059
|
+
});
|
|
1060
|
+
assert(
|
|
1061
|
+
loginByPhone.status === 201 || loginByPhone.status === 200,
|
|
1062
|
+
`login by phone succeeds (got ${loginByPhone.status})`,
|
|
1063
|
+
);
|
|
518
1064
|
|
|
519
|
-
const loginByEmailStill = await call("POST", "/auth/login", {
|
|
520
|
-
|
|
1065
|
+
const loginByEmailStill = await call("POST", "/auth/login", {
|
|
1066
|
+
body: { identifier: identEmail, password: "ident-user-pw-12345" },
|
|
1067
|
+
});
|
|
1068
|
+
assert(
|
|
1069
|
+
loginByEmailStill.status === 201 || loginByEmailStill.status === 200,
|
|
1070
|
+
`login by email still works once username/phone are also set (got ${loginByEmailStill.status})`,
|
|
1071
|
+
);
|
|
521
1072
|
|
|
522
|
-
const loginByUsernameWrongPassword = await call("POST", "/auth/login", {
|
|
523
|
-
|
|
1073
|
+
const loginByUsernameWrongPassword = await call("POST", "/auth/login", {
|
|
1074
|
+
body: { identifier: identUsername, password: "wrong" },
|
|
1075
|
+
});
|
|
1076
|
+
assert(
|
|
1077
|
+
loginByUsernameWrongPassword.status === 401,
|
|
1078
|
+
`login by username with the wrong password is still rejected (got ${loginByUsernameWrongPassword.status})`,
|
|
1079
|
+
);
|
|
524
1080
|
|
|
525
|
-
const loginByUnknownIdentifier = await call("POST", "/auth/login", {
|
|
526
|
-
|
|
1081
|
+
const loginByUnknownIdentifier = await call("POST", "/auth/login", {
|
|
1082
|
+
body: { identifier: `nobody-${RUN_ID}`, password: "whatever" },
|
|
1083
|
+
});
|
|
1084
|
+
assert(
|
|
1085
|
+
loginByUnknownIdentifier.status === 401,
|
|
1086
|
+
`an identifier matching no email/username/phone is rejected the same way (got ${loginByUnknownIdentifier.status})`,
|
|
1087
|
+
);
|
|
527
1088
|
|
|
528
|
-
console.log(
|
|
529
|
-
|
|
1089
|
+
console.log(
|
|
1090
|
+
"11d. change password: wrong current password rejected, right one rotates it and revokes every other session but this one",
|
|
1091
|
+
);
|
|
1092
|
+
const otherDeviceLogin = await call("POST", "/auth/login", {
|
|
1093
|
+
body: { identifier: identEmail, password: "ident-user-pw-12345" },
|
|
1094
|
+
});
|
|
530
1095
|
const otherDeviceTokens = otherDeviceLogin.body as AuthTokens;
|
|
531
|
-
assert(
|
|
1096
|
+
assert(
|
|
1097
|
+
otherDeviceTokens.sessionId !== identTokens.sessionId,
|
|
1098
|
+
"a second session for the identifier-test user exists, distinct from the signup session",
|
|
1099
|
+
);
|
|
532
1100
|
|
|
533
1101
|
const wrongCurrentPassword = await call("POST", "/auth/password/change", {
|
|
534
1102
|
token: identTokens.accessToken,
|
|
535
|
-
body: {
|
|
1103
|
+
body: {
|
|
1104
|
+
currentPassword: "not-the-real-password",
|
|
1105
|
+
newPassword: "ident-new-pw-67890",
|
|
1106
|
+
},
|
|
536
1107
|
});
|
|
537
|
-
assert(
|
|
1108
|
+
assert(
|
|
1109
|
+
wrongCurrentPassword.status === 401,
|
|
1110
|
+
`changing the password with the wrong current password is rejected (got ${wrongCurrentPassword.status})`,
|
|
1111
|
+
);
|
|
538
1112
|
|
|
539
1113
|
const changePassword = await call("POST", "/auth/password/change", {
|
|
540
1114
|
token: identTokens.accessToken,
|
|
541
|
-
body: {
|
|
1115
|
+
body: {
|
|
1116
|
+
currentPassword: "ident-user-pw-12345",
|
|
1117
|
+
newPassword: "ident-new-pw-67890",
|
|
1118
|
+
},
|
|
542
1119
|
});
|
|
543
|
-
assert(
|
|
1120
|
+
assert(
|
|
1121
|
+
changePassword.status === 201 || changePassword.status === 200,
|
|
1122
|
+
`change-password succeeds with the right current password (got ${changePassword.status})`,
|
|
1123
|
+
);
|
|
544
1124
|
|
|
545
|
-
const oldPasswordNowFails = await call("POST", "/auth/login", {
|
|
546
|
-
|
|
1125
|
+
const oldPasswordNowFails = await call("POST", "/auth/login", {
|
|
1126
|
+
body: { identifier: identEmail, password: "ident-user-pw-12345" },
|
|
1127
|
+
});
|
|
1128
|
+
assert(
|
|
1129
|
+
oldPasswordNowFails.status === 401,
|
|
1130
|
+
`the old password no longer works (got ${oldPasswordNowFails.status})`,
|
|
1131
|
+
);
|
|
547
1132
|
|
|
548
|
-
const newPasswordWorks = await call("POST", "/auth/login", {
|
|
549
|
-
|
|
1133
|
+
const newPasswordWorks = await call("POST", "/auth/login", {
|
|
1134
|
+
body: { identifier: identEmail, password: "ident-new-pw-67890" },
|
|
1135
|
+
});
|
|
1136
|
+
assert(
|
|
1137
|
+
newPasswordWorks.status === 201 || newPasswordWorks.status === 200,
|
|
1138
|
+
`the new password works (got ${newPasswordWorks.status})`,
|
|
1139
|
+
);
|
|
550
1140
|
|
|
551
|
-
const callingSessionSurvives = await call("GET", "/auth/me", {
|
|
552
|
-
|
|
1141
|
+
const callingSessionSurvives = await call("GET", "/auth/me", {
|
|
1142
|
+
token: identTokens.accessToken,
|
|
1143
|
+
});
|
|
1144
|
+
assert(
|
|
1145
|
+
callingSessionSurvives.status === 200,
|
|
1146
|
+
`the session that called change-password is left alone, not logged out by its own request (got ${callingSessionSurvives.status})`,
|
|
1147
|
+
);
|
|
553
1148
|
|
|
554
1149
|
// Revocation lands on the session row (checked at refresh time), not an instant denylist of
|
|
555
1150
|
// the still-live access token — same split `logout()` vs. `logoutOthers()` relies on elsewhere.
|
|
556
|
-
const otherSessionRefreshRevoked = await call("POST", "/auth/refresh", {
|
|
1151
|
+
const otherSessionRefreshRevoked = await call("POST", "/auth/refresh", {
|
|
1152
|
+
body: { refreshToken: otherDeviceTokens.refreshToken },
|
|
1153
|
+
});
|
|
557
1154
|
assert(
|
|
558
1155
|
otherSessionRefreshRevoked.status === 401,
|
|
559
1156
|
`…while the other, unrelated session for the same user can no longer refresh (got ${otherSessionRefreshRevoked.status})`,
|
|
560
1157
|
);
|
|
561
1158
|
|
|
562
|
-
console.log(
|
|
1159
|
+
console.log(
|
|
1160
|
+
"11d-2. self-service profile update: no admin permission required, own row only",
|
|
1161
|
+
);
|
|
563
1162
|
const selfProfileUpdate = await call("PATCH", "/auth/me", {
|
|
564
1163
|
token: identTokens.accessToken,
|
|
565
|
-
body: {
|
|
1164
|
+
body: {
|
|
1165
|
+
displayName: "Self Updated",
|
|
1166
|
+
photo: "data:image/png;base64,AAAA",
|
|
1167
|
+
},
|
|
566
1168
|
});
|
|
567
|
-
assert(
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
1169
|
+
assert(
|
|
1170
|
+
selfProfileUpdate.status === 200,
|
|
1171
|
+
`a user can update their own profile with no admin permission (got ${selfProfileUpdate.status})`,
|
|
1172
|
+
);
|
|
1173
|
+
const selfProfile = selfProfileUpdate.body as {
|
|
1174
|
+
id: string;
|
|
1175
|
+
displayName: string | null;
|
|
1176
|
+
photo: string | null;
|
|
1177
|
+
};
|
|
1178
|
+
assert(
|
|
1179
|
+
selfProfile.displayName === "Self Updated",
|
|
1180
|
+
"…and the response reflects the new value",
|
|
1181
|
+
);
|
|
1182
|
+
assert(
|
|
1183
|
+
selfProfile.photo === "data:image/png;base64,AAAA",
|
|
1184
|
+
"…including the photo field",
|
|
1185
|
+
);
|
|
571
1186
|
|
|
572
|
-
const selfProfileRejectsNoToken = await call("PATCH", "/auth/me", {
|
|
573
|
-
|
|
1187
|
+
const selfProfileRejectsNoToken = await call("PATCH", "/auth/me", {
|
|
1188
|
+
body: { displayName: "No Auth" },
|
|
1189
|
+
});
|
|
1190
|
+
assert(
|
|
1191
|
+
selfProfileRejectsNoToken.status === 401,
|
|
1192
|
+
`updating a profile without a token is rejected (got ${selfProfileRejectsNoToken.status})`,
|
|
1193
|
+
);
|
|
574
1194
|
|
|
575
|
-
const meAfterProfileUpdate = await call("GET", "/auth/me", {
|
|
1195
|
+
const meAfterProfileUpdate = await call("GET", "/auth/me", {
|
|
1196
|
+
token: identTokens.accessToken,
|
|
1197
|
+
});
|
|
576
1198
|
assert(
|
|
577
|
-
(meAfterProfileUpdate.body as { displayName: string | null })
|
|
1199
|
+
(meAfterProfileUpdate.body as { displayName: string | null })
|
|
1200
|
+
.displayName === "Self Updated",
|
|
578
1201
|
`…and GET /auth/me reflects the same update, no separate refetch path (got ${JSON.stringify(meAfterProfileUpdate.body)})`,
|
|
579
1202
|
);
|
|
580
1203
|
|
|
581
|
-
console.log(
|
|
1204
|
+
console.log(
|
|
1205
|
+
"11e. admin creates a user directly — no signup, usable immediately",
|
|
1206
|
+
);
|
|
582
1207
|
const createdEmail = uniqueEmail("admin-created");
|
|
583
1208
|
const createUser = await call("POST", "/auth/admin/users", {
|
|
584
1209
|
token: await admin.freshToken(),
|
|
585
1210
|
workspaceId: admin.workspaceId,
|
|
586
|
-
body: {
|
|
1211
|
+
body: {
|
|
1212
|
+
email: createdEmail,
|
|
1213
|
+
password: "admin-created-pw-12345",
|
|
1214
|
+
displayName: "Admin Created",
|
|
1215
|
+
},
|
|
587
1216
|
});
|
|
588
|
-
assert(
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
1217
|
+
assert(
|
|
1218
|
+
createUser.status === 201 || createUser.status === 200,
|
|
1219
|
+
`admin can create a user directly (got ${createUser.status})`,
|
|
1220
|
+
);
|
|
1221
|
+
const createdUser = createUser.body as {
|
|
1222
|
+
id: string;
|
|
1223
|
+
email: string;
|
|
1224
|
+
displayName: string | null;
|
|
1225
|
+
roles: string[];
|
|
1226
|
+
};
|
|
1227
|
+
assert(
|
|
1228
|
+
createdUser.email === createdEmail,
|
|
1229
|
+
"…and the response is the new user, not something stale",
|
|
1230
|
+
);
|
|
1231
|
+
assert(
|
|
1232
|
+
createdUser.displayName === "Admin Created",
|
|
1233
|
+
"…with the profile fields given at creation time",
|
|
1234
|
+
);
|
|
592
1235
|
|
|
593
|
-
const createdUserCanLogin = await call("POST", "/auth/login", {
|
|
1236
|
+
const createdUserCanLogin = await call("POST", "/auth/login", {
|
|
1237
|
+
body: { identifier: createdEmail, password: "admin-created-pw-12345" },
|
|
1238
|
+
});
|
|
594
1239
|
assert(
|
|
595
1240
|
createdUserCanLogin.status === 201 || createdUserCanLogin.status === 200,
|
|
596
1241
|
`the account is usable immediately, no separate activation step (got ${createdUserCanLogin.status})`,
|
|
@@ -601,20 +1246,32 @@ async function main() {
|
|
|
601
1246
|
workspaceId: admin.workspaceId,
|
|
602
1247
|
body: { email: createdEmail, password: "another-pw-12345" },
|
|
603
1248
|
});
|
|
604
|
-
assert(
|
|
1249
|
+
assert(
|
|
1250
|
+
duplicateCreate.status === 409,
|
|
1251
|
+
`creating a second account with the same email is rejected (got ${duplicateCreate.status})`,
|
|
1252
|
+
);
|
|
605
1253
|
|
|
606
1254
|
const createWithRole = await call("POST", "/auth/admin/users", {
|
|
607
1255
|
token: await admin.freshToken(),
|
|
608
1256
|
workspaceId: admin.workspaceId,
|
|
609
|
-
body: {
|
|
1257
|
+
body: {
|
|
1258
|
+
email: uniqueEmail("admin-created-role"),
|
|
1259
|
+
password: "admin-created-pw-67890",
|
|
1260
|
+
roles: [ROLE_SLUG],
|
|
1261
|
+
},
|
|
610
1262
|
});
|
|
611
|
-
assert(
|
|
1263
|
+
assert(
|
|
1264
|
+
createWithRole.status === 201 || createWithRole.status === 200,
|
|
1265
|
+
`admin can name specific roles at creation time (got ${createWithRole.status})`,
|
|
1266
|
+
);
|
|
612
1267
|
assert(
|
|
613
1268
|
(createWithRole.body as { roles: string[] }).roles.includes(ROLE_SLUG),
|
|
614
1269
|
`…and the named role — not the default — is what the new account actually holds (got ${JSON.stringify((createWithRole.body as { roles: string[] }).roles)})`,
|
|
615
1270
|
);
|
|
616
1271
|
|
|
617
|
-
console.log(
|
|
1272
|
+
console.log(
|
|
1273
|
+
"12. permission enforcement: each admin route is opened by its own permission and by nothing else",
|
|
1274
|
+
);
|
|
618
1275
|
// The point of this section is that permissions — not roles — are the boundary. Every probe
|
|
619
1276
|
// user below holds a real role that carries nothing, so "has a role" can never be mistaken
|
|
620
1277
|
// for "has authority", and the only thing that ever changes between them is which single
|
|
@@ -622,18 +1279,33 @@ async function main() {
|
|
|
622
1279
|
const NO_PERMS_ROLE = `probe-no-perms-${RUN_ID}`;
|
|
623
1280
|
const ASSIGNABLE_ROLE = `probe-assignable-${RUN_ID}`;
|
|
624
1281
|
|
|
625
|
-
const noPermsRole = await call("POST", "/auth/admin/roles", {
|
|
1282
|
+
const noPermsRole = await call("POST", "/auth/admin/roles", {
|
|
1283
|
+
token: await admin.freshToken(),
|
|
1284
|
+
workspaceId: admin.workspaceId,
|
|
1285
|
+
body: { slug: NO_PERMS_ROLE },
|
|
1286
|
+
});
|
|
626
1287
|
const assignableRole = await call("POST", "/auth/admin/roles", {
|
|
627
1288
|
token: await admin.freshToken(),
|
|
628
1289
|
workspaceId: admin.workspaceId,
|
|
629
1290
|
body: { slug: ASSIGNABLE_ROLE },
|
|
630
1291
|
});
|
|
631
|
-
assert(
|
|
1292
|
+
assert(
|
|
1293
|
+
noPermsRole.status === 201 && assignableRole.status === 201,
|
|
1294
|
+
"the probe roles are created (neither carries any permission)",
|
|
1295
|
+
);
|
|
632
1296
|
|
|
633
1297
|
const probeTargetEmail = uniqueEmail("probe-target");
|
|
634
|
-
const probeTargetTokens = await signup(
|
|
635
|
-
|
|
636
|
-
|
|
1298
|
+
const probeTargetTokens = await signup(
|
|
1299
|
+
probeTargetEmail,
|
|
1300
|
+
"probe-target-pw-1",
|
|
1301
|
+
);
|
|
1302
|
+
const probeTargetId = (
|
|
1303
|
+
await call("GET", "/auth/me", { token: probeTargetTokens.accessToken })
|
|
1304
|
+
).body.sub as string;
|
|
1305
|
+
await hooks.admitUser(ctx, admin, {
|
|
1306
|
+
userId: probeTargetId,
|
|
1307
|
+
email: probeTargetEmail,
|
|
1308
|
+
});
|
|
637
1309
|
|
|
638
1310
|
const probesFor = (seq: number) =>
|
|
639
1311
|
adminRouteProbes({
|
|
@@ -648,7 +1320,11 @@ async function main() {
|
|
|
648
1320
|
async function runProbes(token: string, seq: number) {
|
|
649
1321
|
const results: Array<{ probe: RouteProbe; status: number }> = [];
|
|
650
1322
|
for (const probe of probesFor(seq)) {
|
|
651
|
-
const res = await call(probe.method, probe.path, {
|
|
1323
|
+
const res = await call(probe.method, probe.path, {
|
|
1324
|
+
token,
|
|
1325
|
+
workspaceId: admin.workspaceId,
|
|
1326
|
+
body: probe.body,
|
|
1327
|
+
});
|
|
652
1328
|
results.push({ probe, status: res.status });
|
|
653
1329
|
}
|
|
654
1330
|
return results;
|
|
@@ -666,84 +1342,168 @@ async function main() {
|
|
|
666
1342
|
const email = uniqueEmail(label);
|
|
667
1343
|
const password = `${label}-pw-12345`;
|
|
668
1344
|
const tokens = await signup(email, password);
|
|
669
|
-
const userId = (
|
|
1345
|
+
const userId = (
|
|
1346
|
+
await call("GET", "/auth/me", { token: tokens.accessToken })
|
|
1347
|
+
).body.sub as string;
|
|
670
1348
|
await hooks.admitUser(ctx, admin, { userId, email });
|
|
671
|
-
await call("POST", `/auth/admin/users/${userId}/roles`, {
|
|
1349
|
+
await call("POST", `/auth/admin/users/${userId}/roles`, {
|
|
1350
|
+
token: await admin.freshToken(),
|
|
1351
|
+
workspaceId: admin.workspaceId,
|
|
1352
|
+
body: { role: NO_PERMS_ROLE },
|
|
1353
|
+
});
|
|
672
1354
|
for (const permission of grants) {
|
|
673
|
-
await call("POST", `/auth/admin/users/${userId}/permissions`, {
|
|
1355
|
+
await call("POST", `/auth/admin/users/${userId}/permissions`, {
|
|
1356
|
+
token: await admin.freshToken(),
|
|
1357
|
+
workspaceId: admin.workspaceId,
|
|
1358
|
+
body: { permission },
|
|
1359
|
+
});
|
|
674
1360
|
}
|
|
675
1361
|
// No re-login: authorization is read from the database on every request in both variants,
|
|
676
1362
|
// so the token this user already holds sees the grants immediately.
|
|
677
1363
|
return { email, password, userId, token: tokens.accessToken };
|
|
678
1364
|
}
|
|
679
1365
|
|
|
680
|
-
console.log(
|
|
1366
|
+
console.log(
|
|
1367
|
+
"12a. holding a role that carries no permissions opens nothing",
|
|
1368
|
+
);
|
|
681
1369
|
const bare = await probeUser("probe-bare", []);
|
|
682
|
-
const bareMe = await call("GET", "/auth/me", {
|
|
683
|
-
|
|
1370
|
+
const bareMe = await call("GET", "/auth/me", {
|
|
1371
|
+
token: bare.token,
|
|
1372
|
+
workspaceId: admin.workspaceId,
|
|
1373
|
+
});
|
|
1374
|
+
assert(
|
|
1375
|
+
bareMe.body?.roles?.includes(NO_PERMS_ROLE),
|
|
1376
|
+
"the probe user really does hold a role — it just carries no permissions",
|
|
1377
|
+
);
|
|
684
1378
|
const bareResults = await runProbes(bare.token, 0);
|
|
685
|
-
assert(
|
|
1379
|
+
assert(
|
|
1380
|
+
bareResults.every((r) => r.status === 403),
|
|
1381
|
+
`all ${bareResults.length} admin routes refuse them (offenders: ${refused(bareResults) || "none"})`,
|
|
1382
|
+
);
|
|
686
1383
|
|
|
687
|
-
console.log(
|
|
1384
|
+
console.log(
|
|
1385
|
+
"12b. one directly-granted permission opens exactly the routes it names, and no others",
|
|
1386
|
+
);
|
|
688
1387
|
const permissions = [...new Set(probesFor(0).map((p) => p.permission))];
|
|
689
1388
|
let probeSeq = 1;
|
|
690
1389
|
/** Kept for 12d: one caller holding exactly one permission is the sharpest case for the equivalence check. */
|
|
691
1390
|
let singleGrantUser: { token: string; permission: string } | undefined;
|
|
692
1391
|
for (const permission of permissions) {
|
|
693
|
-
const holder = await probeUser(
|
|
694
|
-
|
|
1392
|
+
const holder = await probeUser(
|
|
1393
|
+
`probe-${permission.replace(/[^a-z]/g, "")}`,
|
|
1394
|
+
[permission],
|
|
1395
|
+
);
|
|
1396
|
+
if (permission === "users:read")
|
|
1397
|
+
singleGrantUser = { token: holder.token, permission };
|
|
695
1398
|
const results = await runProbes(holder.token, probeSeq++);
|
|
696
|
-
for (const r of results.filter(
|
|
697
|
-
|
|
1399
|
+
for (const r of results.filter(
|
|
1400
|
+
(r) => r.probe.permission === permission,
|
|
1401
|
+
)) {
|
|
1402
|
+
assert(
|
|
1403
|
+
succeeded(r.status),
|
|
1404
|
+
`"${permission}" opens ${r.probe.label} (got ${r.status})`,
|
|
1405
|
+
);
|
|
698
1406
|
}
|
|
699
1407
|
const others = results.filter((r) => r.probe.permission !== permission);
|
|
700
|
-
assert(
|
|
1408
|
+
assert(
|
|
1409
|
+
others.every((r) => r.status === 403),
|
|
1410
|
+
`…and "${permission}" opens nothing else (offenders: ${refused(others) || "none"})`,
|
|
1411
|
+
);
|
|
701
1412
|
}
|
|
702
1413
|
|
|
703
|
-
console.log(
|
|
1414
|
+
console.log(
|
|
1415
|
+
"12c. revoking a permission takes it away again — on the same token, with no re-login",
|
|
1416
|
+
);
|
|
704
1417
|
const revokee = await probeUser("probe-revokee", ["users:read"]);
|
|
705
|
-
const beforeRevoke = await call("GET", "/auth/admin/users", {
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
const revoked = await call("POST", `/auth/admin/users/${revokee.userId}/permissions/${encodeURIComponent("users:read")}/revoke`, {
|
|
709
|
-
token: await admin.freshToken(),
|
|
1418
|
+
const beforeRevoke = await call("GET", "/auth/admin/users", {
|
|
1419
|
+
token: revokee.token,
|
|
710
1420
|
workspaceId: admin.workspaceId,
|
|
711
1421
|
});
|
|
712
|
-
assert(
|
|
1422
|
+
assert(
|
|
1423
|
+
beforeRevoke.status === 200,
|
|
1424
|
+
`the grant is in effect to begin with (got ${beforeRevoke.status})`,
|
|
1425
|
+
);
|
|
713
1426
|
|
|
714
|
-
const
|
|
715
|
-
|
|
1427
|
+
const revoked = await call(
|
|
1428
|
+
"POST",
|
|
1429
|
+
`/auth/admin/users/${revokee.userId}/permissions/${encodeURIComponent("users:read")}/revoke`,
|
|
1430
|
+
{
|
|
1431
|
+
token: await admin.freshToken(),
|
|
1432
|
+
workspaceId: admin.workspaceId,
|
|
1433
|
+
},
|
|
1434
|
+
);
|
|
1435
|
+
assert(
|
|
1436
|
+
succeeded(revoked.status),
|
|
1437
|
+
`admin can revoke a direct grant (got ${revoked.status})`,
|
|
1438
|
+
);
|
|
1439
|
+
|
|
1440
|
+
const afterRevokeCall = await call("GET", "/auth/admin/users", {
|
|
1441
|
+
token: revokee.token,
|
|
1442
|
+
workspaceId: admin.workspaceId,
|
|
1443
|
+
});
|
|
1444
|
+
assert(
|
|
1445
|
+
afterRevokeCall.status === 403,
|
|
1446
|
+
`and the route closes again on the very next request (got ${afterRevokeCall.status})`,
|
|
1447
|
+
);
|
|
716
1448
|
|
|
717
|
-
console.log(
|
|
1449
|
+
console.log(
|
|
1450
|
+
"12d. front/back equivalence: an ability rebuilt from /auth/me answers exactly as the server enforces",
|
|
1451
|
+
);
|
|
718
1452
|
// This is the property the whole flat-slug model exists for. The client is given permission
|
|
719
1453
|
// slugs and builds its ability with the *same* `defineAbilitiesFor` the server's guard used.
|
|
720
1454
|
// Comparing predicted allow/deny against the real HTTP status for every gated route is what
|
|
721
1455
|
// makes "the console cannot offer a button the API refuses" a fact rather than a hope.
|
|
722
1456
|
async function proveEquivalence(label: string, token: string) {
|
|
723
|
-
const me = await call("GET", "/auth/me", {
|
|
724
|
-
|
|
1457
|
+
const me = await call("GET", "/auth/me", {
|
|
1458
|
+
token,
|
|
1459
|
+
workspaceId: admin.workspaceId,
|
|
1460
|
+
});
|
|
1461
|
+
const ability = defineAbilitiesFor(
|
|
1462
|
+
(me.body?.permissions ?? []) as string[],
|
|
1463
|
+
);
|
|
725
1464
|
const results = await runProbes(token, probeSeq++);
|
|
726
1465
|
|
|
727
1466
|
const disagreements = results
|
|
728
|
-
.map((r) => ({
|
|
1467
|
+
.map((r) => ({
|
|
1468
|
+
label: r.probe.label,
|
|
1469
|
+
predicted: ability.can(r.probe.permission, ABILITY_SUBJECT),
|
|
1470
|
+
served: r.status !== 403,
|
|
1471
|
+
}))
|
|
729
1472
|
.filter((r) => r.predicted !== r.served);
|
|
730
1473
|
assert(
|
|
731
1474
|
disagreements.length === 0,
|
|
732
1475
|
`${label}: all ${results.length} gated routes answer exactly as the client-side ability predicts` +
|
|
733
|
-
(disagreements.length
|
|
1476
|
+
(disagreements.length
|
|
1477
|
+
? ` (disagreements: ${disagreements.map((d) => `${d.label} predicted=${d.predicted}`).join(", ")})`
|
|
1478
|
+
: ""),
|
|
1479
|
+
);
|
|
1480
|
+
return new Set(
|
|
1481
|
+
results.filter((r) => r.status !== 403).map((r) => r.probe.label),
|
|
734
1482
|
);
|
|
735
|
-
return new Set(results.filter((r) => r.status !== 403).map((r) => r.probe.label));
|
|
736
1483
|
}
|
|
737
1484
|
|
|
738
|
-
const adminAllows = await proveEquivalence(
|
|
739
|
-
|
|
740
|
-
|
|
1485
|
+
const adminAllows = await proveEquivalence(
|
|
1486
|
+
"admin",
|
|
1487
|
+
await admin.freshToken(),
|
|
1488
|
+
);
|
|
1489
|
+
const bareAllows = await proveEquivalence(
|
|
1490
|
+
"a member holding a role with no permissions",
|
|
1491
|
+
bare.token,
|
|
1492
|
+
);
|
|
1493
|
+
const grantAllows = await proveEquivalence(
|
|
1494
|
+
`a user holding only "${singleGrantUser!.permission}"`,
|
|
1495
|
+
singleGrantUser!.token,
|
|
1496
|
+
);
|
|
741
1497
|
assert(
|
|
742
|
-
adminAllows.size > grantAllows.size &&
|
|
1498
|
+
adminAllows.size > grantAllows.size &&
|
|
1499
|
+
grantAllows.size > bareAllows.size &&
|
|
1500
|
+
bareAllows.size === 0,
|
|
743
1501
|
`…and the three callers genuinely differ, so the comparison is not vacuous (admin=${adminAllows.size}, single-grant=${grantAllows.size}, bare=${bareAllows.size})`,
|
|
744
1502
|
);
|
|
745
1503
|
|
|
746
|
-
console.log(
|
|
1504
|
+
console.log(
|
|
1505
|
+
"12e. a permission edited in the database changes enforcement, with no code change and no redeploy",
|
|
1506
|
+
);
|
|
747
1507
|
// `isActive: false` is the deny: the row stops appearing in any ability that would have
|
|
748
1508
|
// carried it, without unpicking a single grant. Written here through the admin API — each
|
|
749
1509
|
// variant's hooks prove the same thing again with a raw database write, to rule out the API
|
|
@@ -753,28 +1513,58 @@ async function main() {
|
|
|
753
1513
|
workspaceId: admin.workspaceId,
|
|
754
1514
|
body: { slug: "users:read", isActive: false },
|
|
755
1515
|
});
|
|
756
|
-
assert(
|
|
1516
|
+
assert(
|
|
1517
|
+
succeeded(deactivated.status),
|
|
1518
|
+
`an admin can deactivate a permission (got ${deactivated.status})`,
|
|
1519
|
+
);
|
|
757
1520
|
|
|
758
|
-
const listAfterDeactivate = await call("GET", "/auth/admin/permissions", {
|
|
1521
|
+
const listAfterDeactivate = await call("GET", "/auth/admin/permissions", {
|
|
1522
|
+
token: await admin.freshToken(),
|
|
1523
|
+
workspaceId: admin.workspaceId,
|
|
1524
|
+
});
|
|
759
1525
|
assert(
|
|
760
|
-
(listAfterDeactivate.body?.permissions ?? []).some(
|
|
1526
|
+
(listAfterDeactivate.body?.permissions ?? []).some(
|
|
1527
|
+
(p: { slug: string; isActive: boolean }) =>
|
|
1528
|
+
p.slug === "users:read" && p.isActive === false,
|
|
1529
|
+
),
|
|
761
1530
|
"the catalog reports it as inactive",
|
|
762
1531
|
);
|
|
763
1532
|
|
|
764
|
-
const deniedByEdit = await call("GET", "/auth/admin/users", {
|
|
765
|
-
|
|
1533
|
+
const deniedByEdit = await call("GET", "/auth/admin/users", {
|
|
1534
|
+
token: singleGrantUser!.token,
|
|
1535
|
+
workspaceId: admin.workspaceId,
|
|
1536
|
+
});
|
|
1537
|
+
assert(
|
|
1538
|
+
deniedByEdit.status === 403,
|
|
1539
|
+
`the route it opened is now refused for a user whose grant is untouched (got ${deniedByEdit.status})`,
|
|
1540
|
+
);
|
|
766
1541
|
|
|
767
|
-
const adminDeniedToo = await call("GET", "/auth/admin/users", {
|
|
768
|
-
|
|
1542
|
+
const adminDeniedToo = await call("GET", "/auth/admin/users", {
|
|
1543
|
+
token: await admin.freshToken(),
|
|
1544
|
+
workspaceId: admin.workspaceId,
|
|
1545
|
+
});
|
|
1546
|
+
assert(
|
|
1547
|
+
adminDeniedToo.status === 403,
|
|
1548
|
+
`…including for the administrator, whose role still carries it (got ${adminDeniedToo.status})`,
|
|
1549
|
+
);
|
|
769
1550
|
|
|
770
1551
|
const reactivated = await call("POST", "/auth/admin/permissions", {
|
|
771
1552
|
token: await admin.freshToken(),
|
|
772
1553
|
workspaceId: admin.workspaceId,
|
|
773
1554
|
body: { slug: "users:read", isActive: true },
|
|
774
1555
|
});
|
|
775
|
-
assert(
|
|
776
|
-
|
|
777
|
-
|
|
1556
|
+
assert(
|
|
1557
|
+
succeeded(reactivated.status),
|
|
1558
|
+
`an admin can reactivate it (got ${reactivated.status})`,
|
|
1559
|
+
);
|
|
1560
|
+
const restored = await call("GET", "/auth/admin/users", {
|
|
1561
|
+
token: singleGrantUser!.token,
|
|
1562
|
+
workspaceId: admin.workspaceId,
|
|
1563
|
+
});
|
|
1564
|
+
assert(
|
|
1565
|
+
restored.status === 200,
|
|
1566
|
+
`and every grant that pointed at it works again immediately (got ${restored.status})`,
|
|
1567
|
+
);
|
|
778
1568
|
|
|
779
1569
|
console.log(`13. ${hooks.variant}-specific properties`);
|
|
780
1570
|
await hooks.proveVariantProperties(ctx, admin);
|