From 2b2a27221a6b1778efe6a8b57e3dad403c528d87 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 12:10:51 +0000 Subject: [PATCH 1/4] fix(plugin-auth): gate the four /admin/sso/* bridges with the shared ADR-0068 platform-admin judge Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../plugins/plugin-auth/src/auth-plugin.ts | 57 +++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 9a502aae96..b7a295001d 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1807,6 +1807,20 @@ export class AuthPlugin implements Plugin { } }); + // ── ADR-0068 D2/D4 — the ONE platform-admin gate helper for every raw + // `/admin/*` mount below (see platform-admin-gate.ts for the judge and + // its unit-tested posture matrix). Returns the admitted actor, or the + // refusal Response to hand straight back. Hoisted here (#9653) so the + // four `/admin/sso/*` bridges and the #2766 admin-user block share one + // spelling instead of accreting per-mount copies. + const gateAdmin = async (c: any): Promise => { + const authApi = await this.authManager!.getApi(); + const session = await (authApi as any).getSession({ headers: c.req.raw.headers }); + const verdict = judgePlatformAdmin(session); + if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status); + return verdict.actor; + }; + // ──────────────────────────────────────────────────────────────────── // SSO admin: register an external OIDC IdP from the flat metadata form // (ADR-0024). `@better-auth/sso`'s POST /sso/register expects the protocol @@ -1818,12 +1832,27 @@ export class AuthPlugin implements Plugin { // strips them → a provider with `oidc_config = null` that can never // complete a login. This thin bridge reshapes the flat form body into the // nested shape and RE-DISPATCHES it through the real /sso/register endpoint - // (via the better-auth handler) so the admin gate, the public-routable - // trustedOrigins allowance, discovery hydration, and secret handling all - // still run. No bespoke persistence. Retire when the action framework - // gains nested-param support. + // (via the better-auth handler) so the public-routable trustedOrigins + // allowance, discovery hydration, and secret handling all still run. No + // bespoke persistence. Retire when the action framework gains nested-param + // support. + // + // #9653 — ADR-0068 D4: the platform-admin gate runs HERE, before the + // bridge delegates. Registering an identity provider is a platform- + // operator action, and the delegated authorization is NOT a substitute: + // measured on the installed @better-auth/sso 1.7.1, the vendor's + // /sso/register admits ANY authenticated user when no organizationId is + // supplied (the org-admin check is inside `if (ctx.body.organizationId)`), + // and the auth-manager before-hook that narrows this admits org + // owners/admins, who are not platform admins (ADR-0068). Gating first + // also un-masks the refusal labels: an anonymous caller used to get the + // capability error (404 SSO_REGISTER_FAILED), never a 401. Anonymous- + // first ordering — identity error before capability error — so the gate + // runs ahead of the bridge's own body validation. rawApp.post(`${basePath}/admin/sso/register`, async (c: any) => { try { + const gated = await gateAdmin(c); + if (gated instanceof Response) return gated; const { status, body } = await runRegisterSsoProviderFromForm( (req) => this.authManager!.handleRequest(req), c.req.raw, @@ -1900,13 +1929,7 @@ export class AuthPlugin implements Plugin { getTenancy: () => this.tenancy ?? undefined, logger: ctx.logger, }); - const gateAdmin = async (c: any): Promise => { - const authApi = await this.authManager!.getApi(); - const session = await (authApi as any).getSession({ headers: c.req.raw.headers }); - const verdict = judgePlatformAdmin(session); - if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status); - return verdict.actor; - }; + // Gate: the shared `gateAdmin` hoisted above the SSO mounts (#9653). rawApp.post(`${basePath}/admin/create-user`, async (c: any) => { try { @@ -2092,6 +2115,10 @@ export class AuthPlugin implements Plugin { // admin gate + provisioning all run. Returns SP ACS + metadata URLs. rawApp.post(`${basePath}/admin/sso/register-saml`, async (c: any) => { try { + // #9653 — same ADR-0068 D4 platform-admin gate as /admin/sso/register + // above, judged before the bridge delegates. + const gated = await gateAdmin(c); + if (gated instanceof Response) return gated; const { status, body } = await runRegisterSamlProviderFromForm( (req) => this.authManager!.handleRequest(req), c.req.raw, @@ -2113,6 +2140,11 @@ export class AuthPlugin implements Plugin { // success/error. A 404 from the inner endpoint = feature OFF for this env. rawApp.post(`${basePath}/admin/sso/request-domain-verification`, async (c: any) => { try { + // #9653 — same ADR-0068 D4 platform-admin gate as /admin/sso/register + // above; the identity answer comes before the capability answer, so an + // anonymous caller gets 401 even while OS_SSO_DOMAIN_VERIFICATION is off. + const gated = await gateAdmin(c); + if (gated instanceof Response) return gated; const { status, body } = await runRequestDomainVerification( (req) => this.authManager!.handleRequest(req), c.req.raw, @@ -2127,6 +2159,9 @@ export class AuthPlugin implements Plugin { rawApp.post(`${basePath}/admin/sso/verify-domain`, async (c: any) => { try { + // #9653 — same ADR-0068 D4 platform-admin gate as /admin/sso/register. + const gated = await gateAdmin(c); + if (gated instanceof Response) return gated; const { status, body } = await runVerifyDomain( (req) => this.authManager!.handleRequest(req), c.req.raw, From 0a412a5ee1e31f813c13a2c48bddd7726fb48c9b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 12:14:44 +0000 Subject: [PATCH 2/4] test(plugin-auth,dogfood): SSO-enabled fixture pins for the /admin/sso/* gate + move the four routes to the objectstack-gate bucket Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../admin-sso-bridge-platform-admin-gate.md | 13 + .../src/admin-sso-bridge-gate.test.ts | 302 ++++++++++++++++++ ...min-route-nonadmin-refusal.dogfood.test.ts | 89 +++--- 3 files changed, 353 insertions(+), 51 deletions(-) create mode 100644 .changeset/admin-sso-bridge-platform-admin-gate.md create mode 100644 packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts diff --git a/.changeset/admin-sso-bridge-platform-admin-gate.md b/.changeset/admin-sso-bridge-platform-admin-gate.md new file mode 100644 index 0000000000..220d10374e --- /dev/null +++ b/.changeset/admin-sso-bridge-platform-admin-gate.md @@ -0,0 +1,13 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(plugin-auth): the four `/admin/sso/*` bridges now run the inline ADR-0068 platform-admin gate before delegating into better-auth (#9653) + +`POST /api/v1/auth/admin/sso/{register, register-saml, request-domain-verification, verify-domain}` used to hand the raw request straight to their bridge function, resting authorization entirely on the delegated better-auth endpoints. They now run the same shared platform-admin judge their `/admin/` siblings carry (`platform-admin-gate.ts`), before anything else: + +- **anonymous caller → `401 UNAUTHENTICATED`** (previously the capability error: e.g. `404 SSO_REGISTER_FAILED` on a stock boot, which collapsed "not signed in" into "registration failed"); +- **authenticated non-platform-admin → `403 PERMISSION_DENIED`** — this includes org owners/admins, who are not platform admins under ADR-0068; previously, with SSO enabled, the register bridges admitted them (and better-auth's own `/sso/register` admits any authenticated user for an org-less registration — measured on the installed `@better-auth/sso` 1.7.1); +- **platform admin → unchanged**: the request delegates into better-auth exactly as before, so all inner gates and hooks still run. + +Registering an identity provider is a platform-operator action (ADR-0068 D4). The accept set only tightens; no successful flow for a platform admin changes. diff --git a/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts b/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts new file mode 100644 index 0000000000..715e422c56 --- /dev/null +++ b/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts @@ -0,0 +1,302 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #9653 — the four `/admin/sso/*` bridges carry the ADR-0068 D4 platform-admin + * gate BEFORE delegating into better-auth. + * + * ── Why the gate is ObjectStack's to run, not the vendor's ────────────────── + * + * The bridges re-dispatch into `@better-auth/sso` "so all of its gates run" + * (register-sso-provider.ts). The premise this file pins is that the vendor's + * gates are NOT a platform-admin gate. Measured on the INSTALLED + * `@better-auth/sso` 1.7.1 (dist/index.mjs, `registerSSOProvider`): + * + * • `/sso/register` requires a session — and nothing more — when the body + * carries no `organizationId`: the org owner/admin check sits inside + * `if (ctx.body.organizationId) { … }`, so an org-less registration is + * admitted for ANY authenticated user (up to `providersLimit`, default 10). + * • `/sso/{request-domain-verification,verify-domain}` authorize per + * provider (`checkProviderAccess`): the registrar, or an org admin for an + * org-scoped provider. A member who registered an org-less provider can + * drive its domain verification end to end. + * + * The `auth-manager.ts` before-hook on `/sso/register` (ADR-0024) narrows + * the first bullet on ObjectStack deployments, but it admits org + * owners/admins — who are NOT platform admins under ADR-0068 — and until + * this card nothing pinned any of it: with SSO off (the stock boot) every + * caller got the identical capability error, so the authorization answer + * was unobservable. + * + * The SSO capability is ON in every fixture here (`sso()` really mounted, with + * `domainVerification.enabled`), so the refusals asserted below are + * authorization verdicts — not capability errors masking the question. + * + * ── Hook-detach check (the #9970 hazard class) ────────────────────────────── + * + * The gate WRAPS the existing mounts: paths are unchanged and the admitted + * path still re-dispatches through `authManager.handleRequest`, so better-auth + * hooks keyed on the INNER paths (`/sso/register` — the ADR-0024 before-hook + * in auth-manager.ts) keep firing exactly as before. No better-auth hook is + * keyed on `/admin/sso/*` itself (better-auth never serves those paths). The + * "platform admin is delegated" cases below pin that delegation survives. + */ + +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { Hono } from 'hono'; +import { betterAuth } from 'better-auth'; +import { memoryAdapter } from 'better-auth/adapters/memory'; +import { sso } from '@better-auth/sso'; +import { AuthPlugin } from './auth-plugin'; +import type { PluginContext } from '@objectstack/core'; + +const BASE = '/api/v1/auth'; +const ORIGIN = 'http://localhost:3000'; + +/** The flat form bodies the metadata actions post — valid per the bridges. */ +const BRIDGE_BODIES: Record> = { + [`${BASE}/admin/sso/register`]: { + providerId: 'acme', + issuer: 'https://idp.acme.example', + domain: 'acme.example', + clientId: 'cid', + clientSecret: 'csecret', + }, + [`${BASE}/admin/sso/register-saml`]: { + providerId: 'acme-saml', + issuer: 'https://idp.acme.example/entity', + domain: 'acme.example', + entryPoint: 'https://idp.acme.example/sso', + cert: 'PROBE-CERT', + }, + [`${BASE}/admin/sso/request-domain-verification`]: { providerId: 'acme' }, + [`${BASE}/admin/sso/verify-domain`]: { providerId: 'acme' }, +}; +const BRIDGE_PATHS = Object.keys(BRIDGE_BODIES); + +const mockCtx = (): PluginContext => + ({ + registerService: vi.fn(), + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + }) as any; + +/** + * Mount the plugin's REAL route registration on a real Hono app (the + * auth-catchall-fallthrough.test.ts harness), with the auth manager reduced to + * the two seams the bridges read: `getApi().getSession` (what the gate judges) + * and `handleRequest` (where the bridge delegates). + */ +async function mountBridges(deps: { + getSession: (headers: Headers) => unknown | Promise; + handleRequest: (req: Request) => Promise; +}) { + const app = new Hono(); + const ctx = mockCtx(); + const plugin = new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long!!' }); + await plugin.init(ctx); + (plugin as any).authManager = { + handleRequest: deps.handleRequest, + getApi: async () => ({ + getSession: async ({ headers }: { headers: Headers }) => deps.getSession(headers), + }), + }; + (plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); + return app; +} + +const fire = (app: Hono, path: string, opts: { session?: string; cookie?: string } = {}) => + app.request(`${ORIGIN}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: ORIGIN, + ...(opts.session ? { 'x-test-session': opts.session } : {}), + ...(opts.cookie ? { cookie: opts.cookie } : {}), + }, + body: JSON.stringify(BRIDGE_BODIES[path]), + }); + +/** A REAL better-auth instance with the REAL sso plugin — SSO capability ON. */ +const makeSsoVendor = () => + betterAuth({ + baseURL: ORIGIN, + basePath: BASE, + secret: 'admin-sso-bridge-gate-test-secret-0123456789', + database: memoryAdapter({}), + emailAndPassword: { enabled: true }, + plugins: [sso({ domainVerification: { enabled: true } })], + }); + +describe('#9653 the /admin/sso/* bridges run the ADR-0068 platform-admin gate before delegating', () => { + // Session shapes are the exact ones platform-admin-gate.ts is unit-tested + // for; here they drive the MOUNTED routes so the pin is on the wiring. + const SESSIONS: Record = { + member: { user: { id: 'usr_member', positions: ['user'], role: 'user' } }, + 'org-admin': { user: { id: 'usr_orgadmin', positions: ['user', 'org_admin', 'org_owner'], role: 'user' } }, + 'platform-admin': { user: { id: 'usr_admin', positions: ['user', 'platform_admin'], role: 'user' } }, + }; + + let app: Hono; + let vendor: ReturnType; + let delegated: ReturnType; + + beforeAll(async () => { + vendor = makeSsoVendor(); + delegated = vi.fn(async (req: Request) => vendor.handler(req)); + app = await mountBridges({ + getSession: (headers) => SESSIONS[headers.get('x-test-session') ?? ''] ?? null, + handleRequest: delegated as any, + }); + }); + + for (const path of BRIDGE_PATHS) { + it(`${path}: anonymous → 401 UNAUTHENTICATED, and better-auth is never consulted`, async () => { + delegated.mockClear(); + const res = await fire(app, path); + const body: any = await res.json(); + // ADR-0112 envelope — code AND status. This replaces the old masked + // answer (the capability error, e.g. 404 SSO_REGISTER_FAILED for anon). + expect(res.status).toBe(401); + expect(body.error?.code).toBe('UNAUTHENTICATED'); + expect(body.success).toBe(false); + expect(delegated).not.toHaveBeenCalled(); + }); + + it(`${path}: signed-in plain member → 403 PERMISSION_DENIED, never delegated`, async () => { + delegated.mockClear(); + const res = await fire(app, path, { session: 'member' }); + const body: any = await res.json(); + expect(res.status).toBe(403); + expect(body.error?.code).toBe('PERMISSION_DENIED'); + expect(delegated).not.toHaveBeenCalled(); + }); + + it(`${path}: an ORG admin is not a platform admin — 403 PERMISSION_DENIED`, async () => { + // The deliberate tightening this card lands (ADR-0068 D4: platform- + // operator actions gate on isPlatformAdmin, sole operator). Before the + // gate, the auth-manager /sso/register before-hook admitted org + // owners/admins to the two register bridges. + delegated.mockClear(); + const res = await fire(app, path, { session: 'org-admin' }); + const body: any = await res.json(); + expect(res.status).toBe(403); + expect(body.error?.code).toBe('PERMISSION_DENIED'); + expect(delegated).not.toHaveBeenCalled(); + }); + + it(`${path}: a platform admin passes the gate and IS delegated into better-auth`, async () => { + delegated.mockClear(); + const res = await fire(app, path, { session: 'platform-admin' }); + const body: any = await res.json(); + // The gate did not refuse — whatever comes back is the vendor's own + // judgment of the INNER request (here 401/unauthenticated, because the + // fabricated platform-admin session has no real better-auth cookie for + // the re-dispatch to carry). The pin is on delegation surviving the + // gate: hooks keyed on the inner paths still run (see header). + expect(body.error?.code).not.toBe('UNAUTHENTICATED'); + expect(body.error?.code).not.toBe('PERMISSION_DENIED'); + expect(delegated).toHaveBeenCalledTimes(1); + const inner = delegated.mock.calls[0][0] as Request; + expect(new URL(inner.url).pathname.startsWith(`${BASE}/sso/`)).toBe(true); + }); + } +}); + +describe('#9653 the card’s assertion: on an SSO-ENABLED fixture a real plain member cannot register an SSO provider', () => { + let vendor: ReturnType; + let memberCookie: string; + + /** First `name=value` pair of every Set-Cookie the response carries. */ + const cookiesOf = (res: Response): string => + (res.headers.getSetCookie?.() ?? []) + .map((c) => c.split(';')[0]) + .join('; '); + + beforeAll(async () => { + vendor = makeSsoVendor(); + const signUp = await vendor.handler( + new Request(`${ORIGIN}${BASE}/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN }, + body: JSON.stringify({ + email: 'plain.member@example.com', + password: 'Member-Pass-123!', + name: 'Plain Member', + }), + }), + ); + expect(signUp.status, await signUp.clone().text()).toBe(200); + memberCookie = cookiesOf(signUp); + expect(memberCookie, 'sign-up returned no session cookie').toContain('session_token'); + }); + + it('the member (REAL session, capability ON) is refused 403 PERMISSION_DENIED at the bridge, and the vendor is never consulted', async () => { + const delegated = vi.fn(async (req: Request) => vendor.handler(req)); + const app = await mountBridges({ + // The REAL better-auth session resolution — the same seam the shipped + // gate reads (`authApi.getSession({ headers })`). + getSession: (headers) => vendor.api.getSession({ headers }), + handleRequest: delegated as any, + }); + + const res = await fire(app, `${BASE}/admin/sso/register`, { cookie: memberCookie }); + const body: any = await res.json(); + expect(res.status).toBe(403); + expect(body.error?.code).toBe('PERMISSION_DENIED'); + expect(delegated).not.toHaveBeenCalled(); + }); + + // ── The premise measurement, kept live ──────────────────────────────────── + // + // The vendor's own authorization for an ORG-LESS registration is a session + // and nothing else (see header). Pinned network-free by aiming the same + // member at the vendor's reserved-providerId refusal, which sits AFTER every + // authorization check (session → providersLimit → organizationId block) and + // BEFORE any endpoint-URL validation or discovery fetch: reaching it proves + // the member cleared the vendor's whole authorization prologue. + // + // If a vendor bump turns this red with a 401/403 instead, better-auth has + // started refusing non-admins itself — the ObjectStack gate then stands as + // pure ADR-0068 D4 defense-in-depth; re-measure and update the posture notes + // rather than deleting the gate. + it('measured vendor posture (installed 1.7.1): an org-less /sso/register admits any authenticated user', async () => { + const registerBody = { + providerId: 'credential', // always in the vendor's reserved set + issuer: 'https://idp.example.com', + domain: 'example.com', + oidcConfig: { + clientId: 'cid', + clientSecret: 'csecret', + scopes: ['openid', 'email', 'profile'], + mapping: { email: 'email', name: 'name' }, + }, + }; + const post = (cookie?: string) => + vendor.handler( + new Request(`${ORIGIN}${BASE}/sso/register`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: ORIGIN, + ...(cookie ? { cookie } : {}), + }, + body: JSON.stringify(registerBody), + }), + ); + + // Anonymous: the vendor's session gate refuses. + const anon = await post(); + expect(anon.status).toBe(401); + + // The authenticated PLAIN MEMBER sails past authorization into business + // validation — the reserved-id 422, not a 401/403 refusal. + const member = await post(memberCookie); + const memberBody = await member.clone().text(); + expect(member.status, memberBody).toBe(422); + expect(memberBody).toMatch(/reserved/i); + }); +}); diff --git a/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts b/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts index 2723fa5676..c1d1433459 100644 --- a/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts @@ -25,7 +25,7 @@ // `.options.method`, which is why `auth-route-ledger.conformance.test.ts` // uses the same seam. Measured here: 24 routes. // -// Union: 31 routes at the configuration this file boots (7 + 9 + 2 + 4 + 9). Both halves are +// Union: 31 routes at the configuration this file boots (11 + 9 + 2 + 9). Both halves are // asserted non-empty, and the union is cross-checked against a small ANCHOR set, // so a derivation that silently returns nothing cannot make the sweep vacuous. // @@ -46,9 +46,11 @@ // ── The payloads are load-bearing, and this is the file's sharpest edge ─────── // // MEASURED: better-auth validates the request body BEFORE it reaches the admin -// check, and so do the ObjectStack `/admin/sso/*`, `/admin/unlock-user` and +// check, and so do the ObjectStack `/admin/unlock-user` and // `/admin/oauth2/toggle-disabled` mounts (their handlers read and shape-check -// `body` before calling `getSession`). Fire an EMPTY body at +// `body` before calling `getSession`; the `/admin/sso/*` bridges gate FIRST +// since #9653, but their payloads stay valid so the admin-side probe reaches +// the semantic answer). Fire an EMPTY body at // `/admin/ban-user` and a plain member receives: // // 400 {"message":"[body.userId] Invalid input: …","code":"VALIDATION_ERROR"} @@ -65,11 +67,12 @@ // A refusal-only suite stays green if a route starts refusing EVERYONE, so each // bucket that can carry an allowed side does: // -// `objectstack-gate` (7 routes) — the full contrast. A plain member is refused +// `objectstack-gate` (11 routes) — the full contrast. A plain member is refused // 403 PERMISSION_DENIED, an anonymous caller 401 UNAUTHENTICATED, and the // platform admin is NOT refused: the same request reaches the handler and // comes back 2xx (unlock-user) or a SEMANTIC error (404 RESOURCE_NOT_FOUND -// for a missing OAuth client). That is what proves the member's 403 is a +// for a missing OAuth client; the capability errors on the `/admin/sso/*` +// bridges while SSO is off). That is what proves the member's 403 is a // gate verdict and not a payload the server rejects for everyone. // // `better-auth-gate` (9 routes) — refusal side only, DELIBERATELY. On this @@ -113,23 +116,18 @@ // self-scoped, not an admin operation). Asserting a 403 on these would be // asserting a bug. // -// `capability-disabled` (4 routes) — the `/admin/sso/*` bridges. Unlike their -// five ObjectStack siblings these carry NO ObjectStack-side gate: they -// re-dispatch the request into better-auth "so all of its gates run" -// (`register-sso-provider.ts`). On this stack the SSO capability is off, so -// all three callers — anonymous, member AND platform admin — receive the -// identical capability error (404 SSO_REGISTER_FAILED / 404 -// SAML_REGISTER_FAILED / 400 DOMAIN_VERIFICATION_DISABLED / 404 -// verify_domain_failed). Authorization is therefore NOT OBSERVABLE on these -// four here, and this file says so instead of pretending: their delegated -// gate is UNPROVEN by this pin, and an SSO-enabled deployment is where it -// would have to be proven. The universal invariant still covers them. -// -// The one assertion made is a tripwire, not a pin on the disabled state: -// member and admin must receive the SAME answer. The day SSO is enabled in -// this fixture those answers diverge, this goes red, and whoever enabled it -// has to move the route into a bucket that actually checks its gate — -// rather than inheriting a green that stopped meaning anything. +// (`capability-disabled` — RETIRED by #9653.) The four `/admin/sso/*` bridges +// used to carry no ObjectStack-side gate, so with SSO off every caller got +// the identical capability error and authorization was not observable here; +// this bucket held a member-vs-admin-must-match tripwire for exactly that. +// #9653 put the inline ADR-0068 platform-admin gate in front of all four +// (before the bridge delegates into better-auth), which is the transition +// the tripwire existed to force: the routes now live in `objectstack-gate`, +// where anon 401 / member 403 / admin-not-refused IS asserted. The member's +// refusal no longer depends on the capability at all — the gate answers +// first — and the SSO-ENABLED member-refusal assertion lives in +// `plugin-auth/src/admin-sso-bridge-gate.test.ts`, beside the measured +// vendor posture that makes the gate load-bearing. // // `not-mounted` (9 routes) — better-auth publishes the `/admin/oauth2/*` // resource and client endpoints from the oidcProvider plugin, which this @@ -159,7 +157,6 @@ type Bucket = | 'objectstack-gate' | 'better-auth-gate' | 'self-scoped' - | 'capability-disabled' | 'not-mounted'; interface RouteExpectation { @@ -215,8 +212,15 @@ function expectationsFor(targetUserId: string): Record body: { client_id: 'refusal-probe-client', disabled: true }, note: 'admin passes the gate and lands on RESOURCE_NOT_FOUND for the unknown client', }, + // ── #9653: the /admin/sso/* bridges, gated ahead of their delegation ──── + // + // The ADR-0068 gate runs BEFORE the bridge re-dispatches into better-auth, + // so anon/member get the gate's 401/403 whether or not SSO is enabled. The + // platform admin passes the gate and lands on the capability answer (SSO + // is off in this boot) — a semantic error, exactly like toggle-disabled's + // RESOURCE_NOT_FOUND, and NOT a [401,403] gate refusal. 'POST /api/v1/auth/admin/sso/register': { - bucket: 'capability-disabled', + bucket: 'objectstack-gate', body: { providerId: 'refusal-probe-oidc', issuer: 'https://issuer.example', @@ -224,9 +228,10 @@ function expectationsFor(targetUserId: string): Record clientId: 'probe-client', clientSecret: 'probe-secret', }, + note: 'admin passes the gate and lands on 404 SSO_REGISTER_FAILED while SSO is off', }, 'POST /api/v1/auth/admin/sso/register-saml': { - bucket: 'capability-disabled', + bucket: 'objectstack-gate', body: { providerId: 'refusal-probe-saml', issuer: 'https://saml-issuer.example', @@ -234,14 +239,17 @@ function expectationsFor(targetUserId: string): Record entryPoint: 'https://saml-issuer.example/sso', cert: 'PROBE-CERT', }, + note: 'admin passes the gate and lands on 404 SAML_REGISTER_FAILED while SSO is off', }, 'POST /api/v1/auth/admin/sso/request-domain-verification': { - bucket: 'capability-disabled', + bucket: 'objectstack-gate', body: { providerId: 'refusal-probe-oidc' }, + note: 'admin passes the gate and lands on 400 DOMAIN_VERIFICATION_DISABLED while SSO is off', }, 'POST /api/v1/auth/admin/sso/verify-domain': { - bucket: 'capability-disabled', + bucket: 'objectstack-gate', body: { providerId: 'refusal-probe-oidc' }, + note: 'admin passes the gate and lands on 404 verify_domain_failed while SSO is off', }, // ── #9652: ban / unban moved from the vendor to an ObjectStack mount ──── @@ -568,30 +576,9 @@ describe('#9482 C9: every derived /admin/ route refuses a non-admin', () => { // `adminRoles: ['admin']`), and pinning EITHER side of that would be wrong. }, 600_000); - it('the /admin/sso/* bridges answer identically to member and admin — authorization is not observable here', async () => { - // NOT a pin on the capability being off. It is the tripwire described in - // the header: while SSO is disabled these four cannot distinguish a caller, - // so a bucket that claimed to check their gate would be checking nothing. - // Enabling SSO makes member and admin diverge and turns this red on - // purpose, so the routes get reclassified instead of coasting on a green. - const routes = derived.all.filter((r) => expectations[r]?.bucket === 'capability-disabled'); - expect(routes.length, 'no capability-disabled routes were derived').toBeGreaterThan(0); - - for (const route of routes) { - const member = await fire(route, memberToken); - const admin = await fire(route, adminToken); - expect( - member.status >= 200 && member.status < 300, - `${route}: a plain member got a success answer — ${member.body}`, - ).toBe(false); - expect( - `${member.status} ${member.code}`, - `${route}: member and platform admin no longer receive the same answer, so this route ` + - `IS now authorization-observable. Move it to a bucket that asserts its gate — ` + - `member=${member.status} ${member.code}, admin=${admin.status} ${admin.code}`, - ).toBe(`${admin.status} ${admin.code}`); - } - }, 300_000); + // (The `capability-disabled` tripwire that used to sit here is retired: #9653 + // gated the four /admin/sso/* bridges, which now carry the objectstack-gate + // bucket's full three-way contrast above — see the header.) it('the self-scoped admin routes answer a non-admin without leaking a privileged result', async () => { const hasPermission = 'POST /api/v1/auth/admin/has-permission'; From 1c9b713bde481358c45de5eb25c9610aaf8b0d95 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 12:37:48 +0000 Subject: [PATCH 3/4] test(plugin-auth): fix memory-adapter table declaration + count non-probe dispatches in the delegation pin Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../plugin-auth/src/admin-sso-bridge-gate.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts b/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts index 715e422c56..ba175ad3ae 100644 --- a/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts +++ b/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts @@ -126,7 +126,8 @@ const makeSsoVendor = () => baseURL: ORIGIN, basePath: BASE, secret: 'admin-sso-bridge-gate-test-secret-0123456789', - database: memoryAdapter({}), + // The memory adapter reads only the tables the DB object declares. + database: memoryAdapter({ user: [], session: [], account: [], verification: [], ssoProvider: [] }), emailAndPassword: { enabled: true }, plugins: [sso({ domainVerification: { enabled: true } })], }); @@ -196,12 +197,16 @@ describe('#9653 the /admin/sso/* bridges run the ADR-0068 platform-admin gate be // judgment of the INNER request (here 401/unauthenticated, because the // fabricated platform-admin session has no real better-auth cookie for // the re-dispatch to carry). The pin is on delegation surviving the - // gate: hooks keyed on the inner paths still run (see header). + // gate: hooks keyed on the inner paths still run (see header). The + // register bridges also probe `/get-session` for the caller's active + // org before dispatching, so count the non-probe dispatches. expect(body.error?.code).not.toBe('UNAUTHENTICATED'); expect(body.error?.code).not.toBe('PERMISSION_DENIED'); - expect(delegated).toHaveBeenCalledTimes(1); - const inner = delegated.mock.calls[0][0] as Request; - expect(new URL(inner.url).pathname.startsWith(`${BASE}/sso/`)).toBe(true); + const dispatches = delegated.mock.calls + .map((call) => new URL((call[0] as Request).url).pathname) + .filter((p) => !p.endsWith('/get-session')); + expect(dispatches).toHaveLength(1); + expect(dispatches[0].startsWith(`${BASE}/sso/`)).toBe(true); }); } }); From 35dd8573cbfe88f7da99893009ed543a8683de94 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 14:26:23 +0000 Subject: [PATCH 4/4] test(plugin-auth): annotate the vendor fixtures with their concrete type so the TEST_DEBT ratchet stays at 110 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts b/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts index ba175ad3ae..b980737861 100644 --- a/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts +++ b/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts @@ -142,7 +142,7 @@ describe('#9653 the /admin/sso/* bridges run the ADR-0068 platform-admin gate be }; let app: Hono; - let vendor: ReturnType; + let vendor: ReturnType; let delegated: ReturnType; beforeAll(async () => { @@ -212,7 +212,7 @@ describe('#9653 the /admin/sso/* bridges run the ADR-0068 platform-admin gate be }); describe('#9653 the card’s assertion: on an SSO-ENABLED fixture a real plain member cannot register an SSO provider', () => { - let vendor: ReturnType; + let vendor: ReturnType; let memberCookie: string; /** First `name=value` pair of every Set-Cookie the response carries. */