|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #9653 — the four `/admin/sso/*` bridges carry the ADR-0068 D4 platform-admin |
| 5 | + * gate BEFORE delegating into better-auth. |
| 6 | + * |
| 7 | + * ── Why the gate is ObjectStack's to run, not the vendor's ────────────────── |
| 8 | + * |
| 9 | + * The bridges re-dispatch into `@better-auth/sso` "so all of its gates run" |
| 10 | + * (register-sso-provider.ts). The premise this file pins is that the vendor's |
| 11 | + * gates are NOT a platform-admin gate. Measured on the INSTALLED |
| 12 | + * `@better-auth/sso` 1.7.1 (dist/index.mjs, `registerSSOProvider`): |
| 13 | + * |
| 14 | + * • `/sso/register` requires a session — and nothing more — when the body |
| 15 | + * carries no `organizationId`: the org owner/admin check sits inside |
| 16 | + * `if (ctx.body.organizationId) { … }`, so an org-less registration is |
| 17 | + * admitted for ANY authenticated user (up to `providersLimit`, default 10). |
| 18 | + * • `/sso/{request-domain-verification,verify-domain}` authorize per |
| 19 | + * provider (`checkProviderAccess`): the registrar, or an org admin for an |
| 20 | + * org-scoped provider. A member who registered an org-less provider can |
| 21 | + * drive its domain verification end to end. |
| 22 | + * |
| 23 | + * The `auth-manager.ts` before-hook on `/sso/register` (ADR-0024) narrows |
| 24 | + * the first bullet on ObjectStack deployments, but it admits org |
| 25 | + * owners/admins — who are NOT platform admins under ADR-0068 — and until |
| 26 | + * this card nothing pinned any of it: with SSO off (the stock boot) every |
| 27 | + * caller got the identical capability error, so the authorization answer |
| 28 | + * was unobservable. |
| 29 | + * |
| 30 | + * The SSO capability is ON in every fixture here (`sso()` really mounted, with |
| 31 | + * `domainVerification.enabled`), so the refusals asserted below are |
| 32 | + * authorization verdicts — not capability errors masking the question. |
| 33 | + * |
| 34 | + * ── Hook-detach check (the #9970 hazard class) ────────────────────────────── |
| 35 | + * |
| 36 | + * The gate WRAPS the existing mounts: paths are unchanged and the admitted |
| 37 | + * path still re-dispatches through `authManager.handleRequest`, so better-auth |
| 38 | + * hooks keyed on the INNER paths (`/sso/register` — the ADR-0024 before-hook |
| 39 | + * in auth-manager.ts) keep firing exactly as before. No better-auth hook is |
| 40 | + * keyed on `/admin/sso/*` itself (better-auth never serves those paths). The |
| 41 | + * "platform admin is delegated" cases below pin that delegation survives. |
| 42 | + */ |
| 43 | + |
| 44 | +import { describe, it, expect, vi, beforeAll } from 'vitest'; |
| 45 | +import { Hono } from 'hono'; |
| 46 | +import { betterAuth } from 'better-auth'; |
| 47 | +import { memoryAdapter } from 'better-auth/adapters/memory'; |
| 48 | +import { sso } from '@better-auth/sso'; |
| 49 | +import { AuthPlugin } from './auth-plugin'; |
| 50 | +import type { PluginContext } from '@objectstack/core'; |
| 51 | + |
| 52 | +const BASE = '/api/v1/auth'; |
| 53 | +const ORIGIN = 'http://localhost:3000'; |
| 54 | + |
| 55 | +/** The flat form bodies the metadata actions post — valid per the bridges. */ |
| 56 | +const BRIDGE_BODIES: Record<string, Record<string, unknown>> = { |
| 57 | + [`${BASE}/admin/sso/register`]: { |
| 58 | + providerId: 'acme', |
| 59 | + issuer: 'https://idp.acme.example', |
| 60 | + domain: 'acme.example', |
| 61 | + clientId: 'cid', |
| 62 | + clientSecret: 'csecret', |
| 63 | + }, |
| 64 | + [`${BASE}/admin/sso/register-saml`]: { |
| 65 | + providerId: 'acme-saml', |
| 66 | + issuer: 'https://idp.acme.example/entity', |
| 67 | + domain: 'acme.example', |
| 68 | + entryPoint: 'https://idp.acme.example/sso', |
| 69 | + cert: 'PROBE-CERT', |
| 70 | + }, |
| 71 | + [`${BASE}/admin/sso/request-domain-verification`]: { providerId: 'acme' }, |
| 72 | + [`${BASE}/admin/sso/verify-domain`]: { providerId: 'acme' }, |
| 73 | +}; |
| 74 | +const BRIDGE_PATHS = Object.keys(BRIDGE_BODIES); |
| 75 | + |
| 76 | +const mockCtx = (): PluginContext => |
| 77 | + ({ |
| 78 | + registerService: vi.fn(), |
| 79 | + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), |
| 80 | + getServices: vi.fn(() => new Map()), |
| 81 | + hook: vi.fn(), |
| 82 | + trigger: vi.fn(), |
| 83 | + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, |
| 84 | + getKernel: vi.fn(), |
| 85 | + }) as any; |
| 86 | + |
| 87 | +/** |
| 88 | + * Mount the plugin's REAL route registration on a real Hono app (the |
| 89 | + * auth-catchall-fallthrough.test.ts harness), with the auth manager reduced to |
| 90 | + * the two seams the bridges read: `getApi().getSession` (what the gate judges) |
| 91 | + * and `handleRequest` (where the bridge delegates). |
| 92 | + */ |
| 93 | +async function mountBridges(deps: { |
| 94 | + getSession: (headers: Headers) => unknown | Promise<unknown>; |
| 95 | + handleRequest: (req: Request) => Promise<Response>; |
| 96 | +}) { |
| 97 | + const app = new Hono(); |
| 98 | + const ctx = mockCtx(); |
| 99 | + const plugin = new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long!!' }); |
| 100 | + await plugin.init(ctx); |
| 101 | + (plugin as any).authManager = { |
| 102 | + handleRequest: deps.handleRequest, |
| 103 | + getApi: async () => ({ |
| 104 | + getSession: async ({ headers }: { headers: Headers }) => deps.getSession(headers), |
| 105 | + }), |
| 106 | + }; |
| 107 | + (plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); |
| 108 | + return app; |
| 109 | +} |
| 110 | + |
| 111 | +const fire = (app: Hono, path: string, opts: { session?: string; cookie?: string } = {}) => |
| 112 | + app.request(`${ORIGIN}${path}`, { |
| 113 | + method: 'POST', |
| 114 | + headers: { |
| 115 | + 'content-type': 'application/json', |
| 116 | + origin: ORIGIN, |
| 117 | + ...(opts.session ? { 'x-test-session': opts.session } : {}), |
| 118 | + ...(opts.cookie ? { cookie: opts.cookie } : {}), |
| 119 | + }, |
| 120 | + body: JSON.stringify(BRIDGE_BODIES[path]), |
| 121 | + }); |
| 122 | + |
| 123 | +/** A REAL better-auth instance with the REAL sso plugin — SSO capability ON. */ |
| 124 | +const makeSsoVendor = () => |
| 125 | + betterAuth({ |
| 126 | + baseURL: ORIGIN, |
| 127 | + basePath: BASE, |
| 128 | + secret: 'admin-sso-bridge-gate-test-secret-0123456789', |
| 129 | + // The memory adapter reads only the tables the DB object declares. |
| 130 | + database: memoryAdapter({ user: [], session: [], account: [], verification: [], ssoProvider: [] }), |
| 131 | + emailAndPassword: { enabled: true }, |
| 132 | + plugins: [sso({ domainVerification: { enabled: true } })], |
| 133 | + }); |
| 134 | + |
| 135 | +describe('#9653 the /admin/sso/* bridges run the ADR-0068 platform-admin gate before delegating', () => { |
| 136 | + // Session shapes are the exact ones platform-admin-gate.ts is unit-tested |
| 137 | + // for; here they drive the MOUNTED routes so the pin is on the wiring. |
| 138 | + const SESSIONS: Record<string, unknown> = { |
| 139 | + member: { user: { id: 'usr_member', positions: ['user'], role: 'user' } }, |
| 140 | + 'org-admin': { user: { id: 'usr_orgadmin', positions: ['user', 'org_admin', 'org_owner'], role: 'user' } }, |
| 141 | + 'platform-admin': { user: { id: 'usr_admin', positions: ['user', 'platform_admin'], role: 'user' } }, |
| 142 | + }; |
| 143 | + |
| 144 | + let app: Hono; |
| 145 | + let vendor: ReturnType<typeof makeSsoVendor>; |
| 146 | + let delegated: ReturnType<typeof vi.fn>; |
| 147 | + |
| 148 | + beforeAll(async () => { |
| 149 | + vendor = makeSsoVendor(); |
| 150 | + delegated = vi.fn(async (req: Request) => vendor.handler(req)); |
| 151 | + app = await mountBridges({ |
| 152 | + getSession: (headers) => SESSIONS[headers.get('x-test-session') ?? ''] ?? null, |
| 153 | + handleRequest: delegated as any, |
| 154 | + }); |
| 155 | + }); |
| 156 | + |
| 157 | + for (const path of BRIDGE_PATHS) { |
| 158 | + it(`${path}: anonymous → 401 UNAUTHENTICATED, and better-auth is never consulted`, async () => { |
| 159 | + delegated.mockClear(); |
| 160 | + const res = await fire(app, path); |
| 161 | + const body: any = await res.json(); |
| 162 | + // ADR-0112 envelope — code AND status. This replaces the old masked |
| 163 | + // answer (the capability error, e.g. 404 SSO_REGISTER_FAILED for anon). |
| 164 | + expect(res.status).toBe(401); |
| 165 | + expect(body.error?.code).toBe('UNAUTHENTICATED'); |
| 166 | + expect(body.success).toBe(false); |
| 167 | + expect(delegated).not.toHaveBeenCalled(); |
| 168 | + }); |
| 169 | + |
| 170 | + it(`${path}: signed-in plain member → 403 PERMISSION_DENIED, never delegated`, async () => { |
| 171 | + delegated.mockClear(); |
| 172 | + const res = await fire(app, path, { session: 'member' }); |
| 173 | + const body: any = await res.json(); |
| 174 | + expect(res.status).toBe(403); |
| 175 | + expect(body.error?.code).toBe('PERMISSION_DENIED'); |
| 176 | + expect(delegated).not.toHaveBeenCalled(); |
| 177 | + }); |
| 178 | + |
| 179 | + it(`${path}: an ORG admin is not a platform admin — 403 PERMISSION_DENIED`, async () => { |
| 180 | + // The deliberate tightening this card lands (ADR-0068 D4: platform- |
| 181 | + // operator actions gate on isPlatformAdmin, sole operator). Before the |
| 182 | + // gate, the auth-manager /sso/register before-hook admitted org |
| 183 | + // owners/admins to the two register bridges. |
| 184 | + delegated.mockClear(); |
| 185 | + const res = await fire(app, path, { session: 'org-admin' }); |
| 186 | + const body: any = await res.json(); |
| 187 | + expect(res.status).toBe(403); |
| 188 | + expect(body.error?.code).toBe('PERMISSION_DENIED'); |
| 189 | + expect(delegated).not.toHaveBeenCalled(); |
| 190 | + }); |
| 191 | + |
| 192 | + it(`${path}: a platform admin passes the gate and IS delegated into better-auth`, async () => { |
| 193 | + delegated.mockClear(); |
| 194 | + const res = await fire(app, path, { session: 'platform-admin' }); |
| 195 | + const body: any = await res.json(); |
| 196 | + // The gate did not refuse — whatever comes back is the vendor's own |
| 197 | + // judgment of the INNER request (here 401/unauthenticated, because the |
| 198 | + // fabricated platform-admin session has no real better-auth cookie for |
| 199 | + // the re-dispatch to carry). The pin is on delegation surviving the |
| 200 | + // gate: hooks keyed on the inner paths still run (see header). The |
| 201 | + // register bridges also probe `/get-session` for the caller's active |
| 202 | + // org before dispatching, so count the non-probe dispatches. |
| 203 | + expect(body.error?.code).not.toBe('UNAUTHENTICATED'); |
| 204 | + expect(body.error?.code).not.toBe('PERMISSION_DENIED'); |
| 205 | + const dispatches = delegated.mock.calls |
| 206 | + .map((call) => new URL((call[0] as Request).url).pathname) |
| 207 | + .filter((p) => !p.endsWith('/get-session')); |
| 208 | + expect(dispatches).toHaveLength(1); |
| 209 | + expect(dispatches[0].startsWith(`${BASE}/sso/`)).toBe(true); |
| 210 | + }); |
| 211 | + } |
| 212 | +}); |
| 213 | + |
| 214 | +describe('#9653 the card’s assertion: on an SSO-ENABLED fixture a real plain member cannot register an SSO provider', () => { |
| 215 | + let vendor: ReturnType<typeof makeSsoVendor>; |
| 216 | + let memberCookie: string; |
| 217 | + |
| 218 | + /** First `name=value` pair of every Set-Cookie the response carries. */ |
| 219 | + const cookiesOf = (res: Response): string => |
| 220 | + (res.headers.getSetCookie?.() ?? []) |
| 221 | + .map((c) => c.split(';')[0]) |
| 222 | + .join('; '); |
| 223 | + |
| 224 | + beforeAll(async () => { |
| 225 | + vendor = makeSsoVendor(); |
| 226 | + const signUp = await vendor.handler( |
| 227 | + new Request(`${ORIGIN}${BASE}/sign-up/email`, { |
| 228 | + method: 'POST', |
| 229 | + headers: { 'content-type': 'application/json', origin: ORIGIN }, |
| 230 | + body: JSON.stringify({ |
| 231 | + email: 'plain.member@example.com', |
| 232 | + password: 'Member-Pass-123!', |
| 233 | + name: 'Plain Member', |
| 234 | + }), |
| 235 | + }), |
| 236 | + ); |
| 237 | + expect(signUp.status, await signUp.clone().text()).toBe(200); |
| 238 | + memberCookie = cookiesOf(signUp); |
| 239 | + expect(memberCookie, 'sign-up returned no session cookie').toContain('session_token'); |
| 240 | + }); |
| 241 | + |
| 242 | + it('the member (REAL session, capability ON) is refused 403 PERMISSION_DENIED at the bridge, and the vendor is never consulted', async () => { |
| 243 | + const delegated = vi.fn(async (req: Request) => vendor.handler(req)); |
| 244 | + const app = await mountBridges({ |
| 245 | + // The REAL better-auth session resolution — the same seam the shipped |
| 246 | + // gate reads (`authApi.getSession({ headers })`). |
| 247 | + getSession: (headers) => vendor.api.getSession({ headers }), |
| 248 | + handleRequest: delegated as any, |
| 249 | + }); |
| 250 | + |
| 251 | + const res = await fire(app, `${BASE}/admin/sso/register`, { cookie: memberCookie }); |
| 252 | + const body: any = await res.json(); |
| 253 | + expect(res.status).toBe(403); |
| 254 | + expect(body.error?.code).toBe('PERMISSION_DENIED'); |
| 255 | + expect(delegated).not.toHaveBeenCalled(); |
| 256 | + }); |
| 257 | + |
| 258 | + // ── The premise measurement, kept live ──────────────────────────────────── |
| 259 | + // |
| 260 | + // The vendor's own authorization for an ORG-LESS registration is a session |
| 261 | + // and nothing else (see header). Pinned network-free by aiming the same |
| 262 | + // member at the vendor's reserved-providerId refusal, which sits AFTER every |
| 263 | + // authorization check (session → providersLimit → organizationId block) and |
| 264 | + // BEFORE any endpoint-URL validation or discovery fetch: reaching it proves |
| 265 | + // the member cleared the vendor's whole authorization prologue. |
| 266 | + // |
| 267 | + // If a vendor bump turns this red with a 401/403 instead, better-auth has |
| 268 | + // started refusing non-admins itself — the ObjectStack gate then stands as |
| 269 | + // pure ADR-0068 D4 defense-in-depth; re-measure and update the posture notes |
| 270 | + // rather than deleting the gate. |
| 271 | + it('measured vendor posture (installed 1.7.1): an org-less /sso/register admits any authenticated user', async () => { |
| 272 | + const registerBody = { |
| 273 | + providerId: 'credential', // always in the vendor's reserved set |
| 274 | + issuer: 'https://idp.example.com', |
| 275 | + domain: 'example.com', |
| 276 | + oidcConfig: { |
| 277 | + clientId: 'cid', |
| 278 | + clientSecret: 'csecret', |
| 279 | + scopes: ['openid', 'email', 'profile'], |
| 280 | + mapping: { email: 'email', name: 'name' }, |
| 281 | + }, |
| 282 | + }; |
| 283 | + const post = (cookie?: string) => |
| 284 | + vendor.handler( |
| 285 | + new Request(`${ORIGIN}${BASE}/sso/register`, { |
| 286 | + method: 'POST', |
| 287 | + headers: { |
| 288 | + 'content-type': 'application/json', |
| 289 | + origin: ORIGIN, |
| 290 | + ...(cookie ? { cookie } : {}), |
| 291 | + }, |
| 292 | + body: JSON.stringify(registerBody), |
| 293 | + }), |
| 294 | + ); |
| 295 | + |
| 296 | + // Anonymous: the vendor's session gate refuses. |
| 297 | + const anon = await post(); |
| 298 | + expect(anon.status).toBe(401); |
| 299 | + |
| 300 | + // The authenticated PLAIN MEMBER sails past authorization into business |
| 301 | + // validation — the reserved-id 422, not a 401/403 refusal. |
| 302 | + const member = await post(memberCookie); |
| 303 | + const memberBody = await member.clone().text(); |
| 304 | + expect(member.status, memberBody).toBe(422); |
| 305 | + expect(memberBody).toMatch(/reserved/i); |
| 306 | + }); |
| 307 | +}); |
0 commit comments