|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#5892 / cloud ADR-0024 D5.2] The PASSWORD half of the break-glass |
| 5 | + * invariant — pinned, not implemented. |
| 6 | + * |
| 7 | + * #5892 asked for two things. The ban half was missing and is built in |
| 8 | + * `last-admin-ban-guard.ts`; this half — "`enforced` SSO must never disable the |
| 9 | + * last local admin's password" — was **already implemented** on `origin/main` |
| 10 | + * and had no test of its own, which is the state that lets a security |
| 11 | + * behaviour be refactored away without anything going red. So this file adds |
| 12 | + * the pins, over the shipped code, unchanged: |
| 13 | + * |
| 14 | + * 1. **The escape hatch stays wired under enforced SSO.** `resolveSsoOnly()` |
| 15 | + * forces `disableSignUp` on and tells the console to hide the password |
| 16 | + * form (`features.ssoEnforced`), but it must NEVER touch |
| 17 | + * `emailAndPassword.enabled` — the endpoint has to remain callable, or the |
| 18 | + * "use a password" link the login UI keeps for the env owner leads |
| 19 | + * nowhere the day the IdP is down (`auth-manager.ts`, the |
| 20 | + * `emailAndPassword` block and `getPublicConfig`). |
| 21 | + * 2. **The last local password cannot be removed.** The global before-hook |
| 22 | + * refuses `/admin/ban-user`, `/admin/remove-user` and `/delete-user` when |
| 23 | + * the target is the only user holding a `credential` account |
| 24 | + * (`LAST_LOCAL_CREDENTIAL`). Under enforced SSO the managed team has no |
| 25 | + * local credential at all, so that one account IS the escape hatch. |
| 26 | + * |
| 27 | + * The middleware is driven directly with a synthetic `ctx` — the same shape |
| 28 | + * better-auth passes it (`path`, `body`, `context.adapter`) — because the |
| 29 | + * decision under test is entirely a function of those three, and standing up |
| 30 | + * a real better-auth server would test better-auth's router instead. |
| 31 | + * |
| 32 | + * Fail-OPEN is deliberate here and is NOT a drift from the ban guard's |
| 33 | + * fail-closed posture: this check's failure mode is a blocked legitimate |
| 34 | + * removal, whereas the ban guard's is a permanently locked-out environment. |
| 35 | + * The last case below pins that direction so a future "make it consistent" |
| 36 | + * refactor has to argue with a test. |
| 37 | + */ |
| 38 | + |
| 39 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 40 | +import { isAPIError } from 'better-auth/api'; |
| 41 | +import { AuthManager } from './auth-manager'; |
| 42 | + |
| 43 | +// Mock better-auth so building the instance neither needs a database nor |
| 44 | +// starts a server; the config object is what these assertions read. |
| 45 | +vi.mock('better-auth', () => ({ |
| 46 | + betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })), |
| 47 | +})); |
| 48 | + |
| 49 | +import { betterAuth } from 'better-auth'; |
| 50 | + |
| 51 | +const SECRET = 'test-secret-at-least-32-chars-long'; |
| 52 | +const BASE_URL = 'http://localhost:3000'; |
| 53 | + |
| 54 | +type CapturedConfig = { |
| 55 | + emailAndPassword?: { enabled?: boolean; disableSignUp?: boolean }; |
| 56 | + hooks?: { before?: (ctx: unknown) => Promise<unknown> }; |
| 57 | +}; |
| 58 | + |
| 59 | +async function buildConfig( |
| 60 | + options: Record<string, unknown> = {}, |
| 61 | +): Promise<{ config: CapturedConfig; manager: AuthManager }> { |
| 62 | + let captured: CapturedConfig = {}; |
| 63 | + (betterAuth as unknown as { mockImplementation: (f: (c: CapturedConfig) => unknown) => void }) |
| 64 | + .mockImplementation((config: CapturedConfig) => { |
| 65 | + captured = config; |
| 66 | + return { handler: vi.fn(), api: {} }; |
| 67 | + }); |
| 68 | + const manager = new AuthManager({ secret: SECRET, baseUrl: BASE_URL, ...options } as never); |
| 69 | + await manager.getAuthInstance(); |
| 70 | + return { config: captured, manager }; |
| 71 | +} |
| 72 | + |
| 73 | +/** |
| 74 | + * The `account` rows a deployment holds. `findOne` answers the target's own |
| 75 | + * credential lookup; `findMany` answers "who else holds one". |
| 76 | + */ |
| 77 | +function adapterWithCredentials(holders: string[]) { |
| 78 | + const rows = holders.map((userId) => ({ userId, providerId: 'credential' })); |
| 79 | + return { |
| 80 | + findOne: vi.fn(async ({ where }: { where: Array<{ field: string; value: unknown }> }) => { |
| 81 | + const userId = where.find((w) => w.field === 'userId')?.value; |
| 82 | + return rows.find((r) => r.userId === userId) ?? null; |
| 83 | + }), |
| 84 | + findMany: vi.fn(async () => rows), |
| 85 | + }; |
| 86 | +} |
| 87 | + |
| 88 | +let consoleSpy: ReturnType<typeof vi.spyOn>; |
| 89 | +let warnSpy: ReturnType<typeof vi.spyOn>; |
| 90 | +const prevMcp = process.env.OS_MCP_SERVER_ENABLED; |
| 91 | +const prevSsoOnly = process.env.OS_AUTH_SSO_ONLY; |
| 92 | + |
| 93 | +beforeEach(() => { |
| 94 | + vi.clearAllMocks(); |
| 95 | + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); |
| 96 | + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 97 | + // Keep the plugin list to the default surface — the MCP pair has its own |
| 98 | + // coverage and only lengthens these boots. |
| 99 | + process.env.OS_MCP_SERVER_ENABLED = 'false'; |
| 100 | + delete process.env.OS_AUTH_SSO_ONLY; |
| 101 | +}); |
| 102 | + |
| 103 | +afterEach(() => { |
| 104 | + consoleSpy.mockRestore(); |
| 105 | + warnSpy.mockRestore(); |
| 106 | + if (prevMcp === undefined) delete process.env.OS_MCP_SERVER_ENABLED; |
| 107 | + else process.env.OS_MCP_SERVER_ENABLED = prevMcp; |
| 108 | + if (prevSsoOnly === undefined) delete process.env.OS_AUTH_SSO_ONLY; |
| 109 | + else process.env.OS_AUTH_SSO_ONLY = prevSsoOnly; |
| 110 | +}); |
| 111 | + |
| 112 | +// --------------------------------------------------------------------------- |
| 113 | +// 1. Enforced SSO keeps the password endpoint alive |
| 114 | +// --------------------------------------------------------------------------- |
| 115 | + |
| 116 | +describe('[#5892] enforced SSO hides the password form — it never disables it', () => { |
| 117 | + it('config knob: `emailAndPassword.enabled` stays true while sign-up is forced off', async () => { |
| 118 | + const { config, manager } = await buildConfig({ ssoOnlyMode: true }); |
| 119 | + |
| 120 | + expect(config.emailAndPassword?.enabled).toBe(true); |
| 121 | + expect(config.emailAndPassword?.disableSignUp).toBe(true); |
| 122 | + |
| 123 | + const publicConfig = manager.getPublicConfig() as { |
| 124 | + emailPassword: { enabled: boolean; disableSignUp: boolean }; |
| 125 | + features: { ssoEnforced: boolean }; |
| 126 | + }; |
| 127 | + // The console is told to HIDE the form (ssoEnforced) while the capability |
| 128 | + // it hides is still advertised as enabled — that gap is the break-glass |
| 129 | + // link, not an inconsistency. |
| 130 | + expect(publicConfig.features.ssoEnforced).toBe(true); |
| 131 | + expect(publicConfig.emailPassword.enabled).toBe(true); |
| 132 | + expect(publicConfig.emailPassword.disableSignUp).toBe(true); |
| 133 | + }); |
| 134 | + |
| 135 | + it('env knob: `OS_AUTH_SSO_ONLY` reaches the same place', async () => { |
| 136 | + process.env.OS_AUTH_SSO_ONLY = 'true'; |
| 137 | + const { config, manager } = await buildConfig(); |
| 138 | + |
| 139 | + expect(config.emailAndPassword?.enabled).toBe(true); |
| 140 | + expect(config.emailAndPassword?.disableSignUp).toBe(true); |
| 141 | + expect( |
| 142 | + (manager.getPublicConfig() as { features: { ssoEnforced: boolean } }).features.ssoEnforced, |
| 143 | + ).toBe(true); |
| 144 | + }); |
| 145 | + |
| 146 | + it('a deployment that really wants passwords off can still say so explicitly', async () => { |
| 147 | + // The invariant is "enforced SSO does not disable it", not "it can never be |
| 148 | + // disabled" — otherwise the assertion above would pass against code that |
| 149 | + // ignores the option entirely. |
| 150 | + const { config } = await buildConfig({ emailAndPassword: { enabled: false } }); |
| 151 | + expect(config.emailAndPassword?.enabled).toBe(false); |
| 152 | + }); |
| 153 | +}); |
| 154 | + |
| 155 | +// --------------------------------------------------------------------------- |
| 156 | +// 2. The last local credential cannot be banned / removed / deleted |
| 157 | +// --------------------------------------------------------------------------- |
| 158 | + |
| 159 | +describe('[#5892] the last local password login survives ban / remove / delete', () => { |
| 160 | + const BAN_PATHS = ['/admin/ban-user', '/admin/remove-user', '/delete-user']; |
| 161 | + |
| 162 | + it('refuses the removal when the target holds the ONLY credential account', async () => { |
| 163 | + const { config } = await buildConfig({ ssoOnlyMode: true }); |
| 164 | + const before = config.hooks?.before; |
| 165 | + expect(typeof before).toBe('function'); |
| 166 | + |
| 167 | + for (const path of BAN_PATHS) { |
| 168 | + const adapter = adapterWithCredentials(['usr_owner']); |
| 169 | + let caught: unknown; |
| 170 | + try { |
| 171 | + await before!({ path, body: { userId: 'usr_owner' }, context: { adapter } }); |
| 172 | + } catch (e) { |
| 173 | + caught = e; |
| 174 | + } |
| 175 | + expect(isAPIError(caught)).toBe(true); |
| 176 | + const api = caught as { statusCode: number; body: { code?: string; message?: string } }; |
| 177 | + expect(api.body.code).toBe('LAST_LOCAL_CREDENTIAL'); |
| 178 | + expect(api.body.message).toMatch(/identity-\s*provider outage|provider outage/); |
| 179 | + } |
| 180 | + }); |
| 181 | + |
| 182 | + it('allows it when another user still holds a local password', async () => { |
| 183 | + const { config } = await buildConfig({ ssoOnlyMode: true }); |
| 184 | + const adapter = adapterWithCredentials(['usr_owner', 'usr_second_admin']); |
| 185 | + |
| 186 | + await expect( |
| 187 | + config.hooks!.before!({ |
| 188 | + path: '/admin/ban-user', |
| 189 | + body: { userId: 'usr_owner' }, |
| 190 | + context: { adapter }, |
| 191 | + }), |
| 192 | + ).resolves.toBeUndefined(); |
| 193 | + }); |
| 194 | + |
| 195 | + it('never fires for a credential-less (IdP-managed) target', async () => { |
| 196 | + // The managed population signs in through the IdP and holds no local |
| 197 | + // password, so removing one of them cannot cost anyone the escape hatch. |
| 198 | + const { config } = await buildConfig({ ssoOnlyMode: true }); |
| 199 | + const adapter = adapterWithCredentials(['usr_owner']); |
| 200 | + |
| 201 | + await expect( |
| 202 | + config.hooks!.before!({ |
| 203 | + path: '/admin/ban-user', |
| 204 | + body: { userId: 'usr_managed' }, |
| 205 | + context: { adapter }, |
| 206 | + }), |
| 207 | + ).resolves.toBeUndefined(); |
| 208 | + // Only the target's own lookup ran — the whole-table scan is skipped. |
| 209 | + expect(adapter.findMany).not.toHaveBeenCalled(); |
| 210 | + }); |
| 211 | + |
| 212 | + it('fails OPEN on a lookup error — the opposite direction from the ban guard, on purpose', async () => { |
| 213 | + const { config } = await buildConfig({ ssoOnlyMode: true }); |
| 214 | + const adapter = { |
| 215 | + findOne: vi.fn(async () => { |
| 216 | + throw new Error('account table unreadable'); |
| 217 | + }), |
| 218 | + findMany: vi.fn(async () => []), |
| 219 | + }; |
| 220 | + |
| 221 | + // A blocked legitimate removal is the cost here; a locked-out environment |
| 222 | + // is the cost in `last-admin-ban-guard.ts`. Different failure modes, |
| 223 | + // different directions — see that file's header. |
| 224 | + await expect( |
| 225 | + config.hooks!.before!({ |
| 226 | + path: '/admin/ban-user', |
| 227 | + body: { userId: 'usr_owner' }, |
| 228 | + context: { adapter }, |
| 229 | + }), |
| 230 | + ).resolves.toBeUndefined(); |
| 231 | + }); |
| 232 | +}); |
0 commit comments