Skip to content

Commit e717ba1

Browse files
os-warrenclaude
andauthored
fix(plugin-auth): gate the four /admin/sso/* bridges with the shared ADR-0068 platform-admin judge (#9653) (#10013)
* fix(plugin-auth): gate the four /admin/sso/* bridges with the shared ADR-0068 platform-admin judge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx * test(plugin-auth): fix memory-adapter table declaration + count non-probe dispatches in the delegation pin Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 43cd348 commit e717ba1

4 files changed

Lines changed: 404 additions & 62 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): the four `/admin/sso/*` bridges now run the inline ADR-0068 platform-admin gate before delegating into better-auth (#9653)
6+
7+
`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:
8+
9+
- **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");
10+
- **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);
11+
- **platform admin → unchanged**: the request delegates into better-auth exactly as before, so all inner gates and hooks still run.
12+
13+
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.
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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

Comments
 (0)