|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #10776 — the break-glass last-local-credential guard must run AFTER identity |
| 4 | +// is established, not before it. |
| 5 | +// |
| 6 | +// The guard is registered as a better-auth `hooks.before` keyed on `ctx.path`. |
| 7 | +// A `before` hook runs ahead of the endpoint's own `use: [adminMiddleware]`, |
| 8 | +// which is the only layer that establishes identity on that lane. The guard |
| 9 | +// therefore decided — and answered — a per-record question for a caller nobody |
| 10 | +// had authenticated. Maintainer ruling 2026-08-22 (decision-inbox digest, |
| 11 | +// accepted verbatim 「接受所有」): **Option A, authentication before the |
| 12 | +// guard**, so an unauthenticated caller hears only the ordinary refusal that |
| 13 | +// every other route on this lane gives. Option B (keep the guard early and |
| 14 | +// disguise its answer) was the fallback and is not taken; option C (accept the |
| 15 | +// disclosure) is not taken. |
| 16 | +// |
| 17 | +// ── Why this file drives the REAL seam ────────────────────────────────────── |
| 18 | +// |
| 19 | +// `break-glass-local-credential.test.ts` drives the before-hook directly with a |
| 20 | +// synthetic `ctx`. That is the right shape for the guard's own predicate, and |
| 21 | +// it is structurally blind to the defect this file pins: hook ORDER relative to |
| 22 | +// endpoint middleware does not exist in a synthetic call. So every assertion |
| 23 | +// here goes through `AuthManager.handleRequest` on the installed better-auth |
| 24 | +// 1.7.1, where the vendor's own middleware really runs, and reads a status and |
| 25 | +// a code off a real `Response`. |
| 26 | +// |
| 27 | +// ── The load-bearing half ─────────────────────────────────────────────────── |
| 28 | +// |
| 29 | +// ⛔ An implementation that simply DELETED the guard would satisfy every |
| 30 | +// disclosure assertion below while destroying the protection the guard exists |
| 31 | +// for. Two describe-blocks exist to make that impossible to pass vacuously: |
| 32 | +// the still-refused leg (an AUTHENTICATED admin removing the genuine last |
| 33 | +// local credential still gets 409 `LAST_LOCAL_CREDENTIAL`) and the admission |
| 34 | +// leg (the same admin removing an ordinary user still succeeds, so the |
| 35 | +// still-refused leg cannot be satisfied by refusing everyone). |
| 36 | +// |
| 37 | +// ADR-0112 is `code` AND `status`; every refusal assertion below carries both. |
| 38 | + |
| 39 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 40 | +import { AuthManager } from './auth-manager'; |
| 41 | +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; |
| 42 | +import { LAST_LOCAL_CREDENTIAL_CODE } from './last-local-credential'; |
| 43 | + |
| 44 | +const SECRET = 'test-secret-at-least-32-chars-long!!'; |
| 45 | +const PASSWORD = 'S3cure!Passw0rd-10776'; |
| 46 | +const BASE = 'http://localhost:3000/api/v1/auth'; |
| 47 | + |
| 48 | +const makeManager = (engine: any) => |
| 49 | + new AuthManager({ |
| 50 | + secret: SECRET, |
| 51 | + baseUrl: 'http://localhost:3000', |
| 52 | + dataEngine: engine, |
| 53 | + plugins: { admin: true }, |
| 54 | + } as any); |
| 55 | + |
| 56 | +const post = (manager: AuthManager, path: string, body: unknown, bearer?: string) => |
| 57 | + manager.handleRequest( |
| 58 | + new Request(`${BASE}${path}`, { |
| 59 | + method: 'POST', |
| 60 | + headers: { |
| 61 | + 'Content-Type': 'application/json', |
| 62 | + ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), |
| 63 | + }, |
| 64 | + body: JSON.stringify(body), |
| 65 | + }), |
| 66 | + ); |
| 67 | + |
| 68 | +/** Status + whatever error code the body carries, in either envelope shape. */ |
| 69 | +async function verdict(res: Response): Promise<{ status: number; code?: string; text: string }> { |
| 70 | + const text = await res.text(); |
| 71 | + let code: string | undefined; |
| 72 | + try { |
| 73 | + const parsed = JSON.parse(text); |
| 74 | + // ObjectStack's ADR-0112 envelope nests it; better-auth's flat shape does not. |
| 75 | + code = parsed?.error?.code ?? parsed?.code; |
| 76 | + } catch { |
| 77 | + /* non-JSON body → no code */ |
| 78 | + } |
| 79 | + return { status: res.status, code, text }; |
| 80 | +} |
| 81 | + |
| 82 | +/** |
| 83 | + * One deployment, staged to the exact posture the guard exists to protect: |
| 84 | + * |
| 85 | + * - `owner` holds the ONLY local-password (`credential`) account — the |
| 86 | + * break-glass escape hatch itself. |
| 87 | + * - `admin` is an IdP-managed platform admin holding NO local credential |
| 88 | + * (their credential row is removed after they sign in, which is what |
| 89 | + * enforced SSO looks like: a managed team with a live session and no |
| 90 | + * password). Their session keeps working, so they can act as the |
| 91 | + * authenticated caller. |
| 92 | + * - `ordinary` is a second credential-less managed user — the removable one, |
| 93 | + * so the admission direction is testable on the same fixture. |
| 94 | + * |
| 95 | + * `/admin/remove-user` is better-auth's own endpoint and still authorizes on |
| 96 | + * the legacy `role` scalar (only `/admin/impersonate-user` was re-pointed at |
| 97 | + * ObjectStack's predicate, see `auth-manager.ts`), so the scalar is what is |
| 98 | + * set here. |
| 99 | + */ |
| 100 | +async function seedDeployment() { |
| 101 | + const engine = createMemoryEngine(); |
| 102 | + const manager = makeManager(engine); |
| 103 | + |
| 104 | + for (const [email, name] of [ |
| 105 | + ['owner.10776@example.com', 'Break Glass Owner'], |
| 106 | + ['admin.10776@example.com', 'Managed Admin'], |
| 107 | + ['ordinary.10776@example.com', 'Ordinary User'], |
| 108 | + ]) { |
| 109 | + const res = await post(manager, '/sign-up/email', { email, password: PASSWORD, name }); |
| 110 | + expect(res.status, `sign-up ${email}: ${await res.clone().text()}`).toBe(200); |
| 111 | + } |
| 112 | + |
| 113 | + const users = (engine.tables.get('sys_user') ?? []) as any[]; |
| 114 | + const idFor = (email: string) => String(users.find((r) => r.email === email)!.id); |
| 115 | + const ownerId = idFor('owner.10776@example.com'); |
| 116 | + const adminId = idFor('admin.10776@example.com'); |
| 117 | + const ordinaryId = idFor('ordinary.10776@example.com'); |
| 118 | + |
| 119 | + // The vendor `/admin/` lane's own authorization scalar. |
| 120 | + users.find((r) => String(r.id) === adminId)!.role = 'admin'; |
| 121 | + |
| 122 | + const signIn = await post(manager, '/sign-in/email', { |
| 123 | + email: 'admin.10776@example.com', |
| 124 | + password: PASSWORD, |
| 125 | + }); |
| 126 | + const bearer = signIn.headers.get('set-auth-token'); |
| 127 | + expect(bearer, 'sign-in must mint a bearer or the authenticated legs prove nothing').toBeTruthy(); |
| 128 | + |
| 129 | + const ownerSignIn = await post(manager, '/sign-in/email', { |
| 130 | + email: 'owner.10776@example.com', |
| 131 | + password: PASSWORD, |
| 132 | + }); |
| 133 | + const ownerBearer = ownerSignIn.headers.get('set-auth-token'); |
| 134 | + expect(ownerBearer, 'the self-service leg needs the owner signed in').toBeTruthy(); |
| 135 | + |
| 136 | + // Strip the local password from everyone except `owner`, leaving exactly one |
| 137 | + // credential holder. This is the state the guard guards. |
| 138 | + const accounts = (engine.tables.get('sys_account') ?? []) as any[]; |
| 139 | + engine.tables.set( |
| 140 | + 'sys_account', |
| 141 | + accounts.filter( |
| 142 | + (r) => !(r.provider_id === 'credential' && String(r.user_id ?? '') !== ownerId), |
| 143 | + ), |
| 144 | + ); |
| 145 | + const remaining = (engine.tables.get('sys_account') ?? []).filter( |
| 146 | + (r: any) => r.provider_id === 'credential', |
| 147 | + ); |
| 148 | + expect( |
| 149 | + remaining.map((r: any) => String(r.user_id)), |
| 150 | + 'fixture invariant: `owner` must be the SOLE local-credential holder', |
| 151 | + ).toEqual([ownerId]); |
| 152 | + |
| 153 | + return { engine, manager, ownerId, adminId, ordinaryId, bearer: bearer!, ownerBearer: ownerBearer! }; |
| 154 | +} |
| 155 | + |
| 156 | +beforeEach(() => { |
| 157 | + vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 158 | + vi.spyOn(console, 'error').mockImplementation(() => {}); |
| 159 | +}); |
| 160 | +afterEach(() => vi.restoreAllMocks()); |
| 161 | + |
| 162 | +// ─────────────────────────────────────────────────────────────────────────── |
| 163 | +// The disclosure: an unauthenticated caller learns nothing per-record |
| 164 | +// ─────────────────────────────────────────────────────────────────────────── |
| 165 | + |
| 166 | +describe('#10776 — an anonymous caller gets the ordinary refusal, never a per-record answer', () => { |
| 167 | + it('arm 1: naming the break-glass holder answers 401 UNAUTHENTICATED, not the guard‘s 409', async () => { |
| 168 | + const { manager, ownerId } = await seedDeployment(); |
| 169 | + |
| 170 | + const v = await verdict(await post(manager, '/admin/remove-user', { userId: ownerId })); |
| 171 | + |
| 172 | + // Status AND code (ADR-0112). The status alone was the whole defect: a 409 |
| 173 | + // where every sibling route answers 401 IS the per-record answer. |
| 174 | + expect(v.status, v.text).toBe(401); |
| 175 | + expect(v.code, v.text).toBe('UNAUTHENTICATED'); |
| 176 | + expect(v.code, 'the guard‘s code must not reach an unauthenticated caller').not.toBe( |
| 177 | + LAST_LOCAL_CREDENTIAL_CODE, |
| 178 | + ); |
| 179 | + expect(v.status, 'the guard‘s status must not reach an unauthenticated caller').not.toBe(409); |
| 180 | + }, 60_000); |
| 181 | + |
| 182 | + it('arm 2: naming an ordinary user answers 401 UNAUTHENTICATED — measured, not read off the source', async () => { |
| 183 | + // The card filed this arm as an unmeasured reading. It is measured here so |
| 184 | + // the before/after is a real comparison: if this arm did NOT already land |
| 185 | + // on 401, the disclosure would be wider than the card states. |
| 186 | + const { manager, ordinaryId } = await seedDeployment(); |
| 187 | + |
| 188 | + const v = await verdict(await post(manager, '/admin/remove-user', { userId: ordinaryId })); |
| 189 | + |
| 190 | + expect(v.status, v.text).toBe(401); |
| 191 | + expect(v.code, v.text).toBe('UNAUTHENTICATED'); |
| 192 | + }, 60_000); |
| 193 | + |
| 194 | + it('arm 2b: a userId nobody holds answers the same 401 — no existence oracle either', async () => { |
| 195 | + const { manager } = await seedDeployment(); |
| 196 | + |
| 197 | + const v = await verdict(await post(manager, '/admin/remove-user', { userId: 'usr_no_such_user' })); |
| 198 | + |
| 199 | + expect(v.status, v.text).toBe(401); |
| 200 | + expect(v.code, v.text).toBe('UNAUTHENTICATED'); |
| 201 | + }, 60_000); |
| 202 | + |
| 203 | + it('the two arms are INDISTINGUISHABLE to the anonymous caller', async () => { |
| 204 | + // The substance of the card asserted directly rather than inferred from two |
| 205 | + // assertions that merely happen to agree today: whatever the platform says, |
| 206 | + // it must say the SAME thing for the break-glass holder and for anyone else. |
| 207 | + const { manager, ownerId, ordinaryId } = await seedDeployment(); |
| 208 | + |
| 209 | + const holder = await verdict(await post(manager, '/admin/remove-user', { userId: ownerId })); |
| 210 | + const other = await verdict(await post(manager, '/admin/remove-user', { userId: ordinaryId })); |
| 211 | + |
| 212 | + expect(holder.status).toBe(other.status); |
| 213 | + expect(holder.text).toBe(other.text); |
| 214 | + }, 60_000); |
| 215 | +}); |
| 216 | + |
| 217 | +// ─────────────────────────────────────────────────────────────────────────── |
| 218 | +// The load-bearing half: the invariant survives the move |
| 219 | +// ─────────────────────────────────────────────────────────────────────────── |
| 220 | + |
| 221 | +describe('#10776 — the break-glass invariant is unchanged for an AUTHENTICATED admin', () => { |
| 222 | + it('still-refused: removing the genuine last local credential is still 409 LAST_LOCAL_CREDENTIAL', async () => { |
| 223 | + // ⛔ The leg that fails on an implementation that "fixed" the disclosure by |
| 224 | + // deleting the guard. Everything in the block above stays green there. |
| 225 | + const { manager, ownerId, bearer } = await seedDeployment(); |
| 226 | + |
| 227 | + const v = await verdict( |
| 228 | + await post(manager, '/admin/remove-user', { userId: ownerId }, bearer), |
| 229 | + ); |
| 230 | + |
| 231 | + expect(v.status, v.text).toBe(409); |
| 232 | + expect(v.code, v.text).toBe(LAST_LOCAL_CREDENTIAL_CODE); |
| 233 | + }, 60_000); |
| 234 | + |
| 235 | + it('admission: the same admin removing an ordinary user still succeeds', async () => { |
| 236 | + // Without this, the still-refused leg above is satisfiable by refusing |
| 237 | + // every caller — the failure mode this lane has already paid for twice. |
| 238 | + const { manager, ordinaryId, bearer } = await seedDeployment(); |
| 239 | + |
| 240 | + const res = await post(manager, '/admin/remove-user', { userId: ordinaryId }, bearer); |
| 241 | + const v = await verdict(res); |
| 242 | + |
| 243 | + expect(v.status, v.text).toBe(200); |
| 244 | + expect(v.code, v.text).not.toBe(LAST_LOCAL_CREDENTIAL_CODE); |
| 245 | + }, 60_000); |
| 246 | +}); |
| 247 | + |
| 248 | +// ─────────────────────────────────────────────────────────────────────────── |
| 249 | +// The self-service path, whose TIMING moves with the guard |
| 250 | +// ─────────────────────────────────────────────────────────────────────────── |
| 251 | + |
| 252 | +describe('#10776 — /delete-user sits under the same guard and is covered here', () => { |
| 253 | + it('anonymous: no per-record answer, and specifically not the guard‘s 409', async () => { |
| 254 | + const { manager, ownerId } = await seedDeployment(); |
| 255 | + |
| 256 | + const v = await verdict(await post(manager, '/delete-user', { userId: ownerId })); |
| 257 | + |
| 258 | + // Measured: the vendor's own session middleware refuses first, in |
| 259 | + // better-auth's flat envelope. That is deliberate and is NOT the #10349 |
| 260 | + // envelope's business — `/delete-user` is not an `/admin/` path, and that |
| 261 | + // card's normalizer is scoped to `/admin/` on purpose. What matters here is |
| 262 | + // that the answer is an AUTHENTICATION refusal and carries nothing about |
| 263 | + // the named user. |
| 264 | + expect(v.status, v.text).toBe(401); |
| 265 | + expect(v.code, v.text).toBe('UNAUTHORIZED'); |
| 266 | + expect(v.code, v.text).not.toBe(LAST_LOCAL_CREDENTIAL_CODE); |
| 267 | + expect(v.status, v.text).not.toBe(409); |
| 268 | + }, 60_000); |
| 269 | + |
| 270 | + it('anonymous: the self-service path is INDISTINGUISHABLE across the two arms too', async () => { |
| 271 | + const { manager, ownerId, ordinaryId } = await seedDeployment(); |
| 272 | + |
| 273 | + const holder = await verdict(await post(manager, '/delete-user', { userId: ownerId })); |
| 274 | + const other = await verdict(await post(manager, '/delete-user', { userId: ordinaryId })); |
| 275 | + |
| 276 | + expect(holder.status).toBe(other.status); |
| 277 | + expect(holder.text).toBe(other.text); |
| 278 | + }, 60_000); |
| 279 | + |
| 280 | + it('authenticated: the OUTCOME for the break-glass holder is unchanged — still 409', async () => { |
| 281 | + // Triage asked whether the ordering change alters this path's outcome or |
| 282 | + // only its timing. This is that question, pinned. |
| 283 | + const { manager, ownerId, ownerBearer } = await seedDeployment(); |
| 284 | + |
| 285 | + const v = await verdict(await post(manager, '/delete-user', { userId: ownerId }, ownerBearer)); |
| 286 | + |
| 287 | + expect(v.status, v.text).toBe(409); |
| 288 | + expect(v.code, v.text).toBe(LAST_LOCAL_CREDENTIAL_CODE); |
| 289 | + }, 60_000); |
| 290 | +}); |
0 commit comments