Skip to content

Commit 22b32c1

Browse files
os-zhuangclaude
andauthored
fix(auth): admin-gate self-service SSO provider registration + default-role JIT (ADR-0024 / cloud#551) (#2363)
* fix(auth): admin-gate self-service SSO provider registration + default-role JIT (ADR-0024) @better-auth/sso's POST /sso/register only enforces org-admin when `body.organizationId` is supplied — a GLOBAL (org-less) provider passes on nothing but a valid session. So any authenticated env member could register an env-wide external IdP (a JIT-provisioning / login-routing vector), violating ADR-0024's "registerSSOProvider is admin-only". Add a before-hook gate on /sso/register requiring the caller to be a platform admin OR an owner/admin of their active org, regardless of whether organizationId is passed. Fail-closed; unauthenticated requests still fall through to sessionMiddleware's 401. New helpers resolveActor() (hook-order-independent cookie/bearer resolution) and isOrgOrPlatformAdmin() (mirrors customSession's role derivation, reads via withSystemReadContext). Also pass `organizationProvisioning.defaultRole:'member'` to sso() so a first-time federated login lands with an explicit default role (over the SecurityPlugin member_default baseline). Verified E2E on the local prod-like stack against a mock OIDC IdP: non-admin register → 403 SSO_REGISTER_FORBIDDEN; owner → 200; external-IdP login JIT-provisions the user with sys_account.provider_id set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: add changeset for SSO register admin-gate Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ab5718a commit 22b32c1

2 files changed

Lines changed: 162 additions & 5 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
Auth: admin-gate self-service SSO provider registration + default-role JIT (ADR-0024 / cloud#551)
6+
7+
`@better-auth/sso`'s `POST /sso/register` only enforces org-admin when `body.organizationId` is supplied — a **global** (org-less) provider passed on nothing but a valid session, so any authenticated env member could register an env-wide external IdP (a JIT-provisioning / login-routing vector). This closed the "registerSSOProvider is admin-only" requirement of ADR-0024's first slice.
8+
9+
- **plugin-auth**: a `before`-hook on `/sso/register` now requires the caller to be a platform admin OR an owner/admin of their active org, regardless of `organizationId`. Fail-closed; unauthenticated requests still fall through to `sessionMiddleware` (→ 401). New helpers `resolveActor()` (hook-order-independent cookie/bearer resolution) and `isOrgOrPlatformAdmin()` (mirrors `customSession`'s role derivation; reads via `withSystemReadContext`).
10+
- **plugin-auth**: `sso()` now receives `organizationProvisioning.defaultRole:'member'` so a first-time federated login lands with an explicit role (over SecurityPlugin's `member_default` baseline).
11+
12+
Additive and fail-closed — no behavior change for legitimate admins. The SSO mechanism stays framework-open (no identity-governance added).

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 150 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,35 @@ export class AuthManager {
551551
// sees `userCount > 0` and the toggle is enforced again.
552552
hooks: {
553553
before: createAuthMiddleware(async (ctx: any) => {
554+
// ── ADR-0024: admin-gate self-service SSO provider registration ──
555+
// `@better-auth/sso`'s POST /sso/register only checks org-admin when
556+
// `body.organizationId` is present (index.mjs: `if (ctx.body
557+
// .organizationId) { … hasOrgAdminRole … }`). A GLOBAL (org-less)
558+
// provider therefore passes with nothing but a valid session — so any
559+
// authenticated member can register an env-wide external IdP, a JIT-
560+
// provisioning / login-routing vector. Require the caller to be a
561+
// platform admin OR an owner/admin of their active org, regardless of
562+
// whether `organizationId` is supplied. Unauthenticated requests fall
563+
// through to better-auth's `sessionMiddleware` (→ 401). Fail-CLOSED:
564+
// an unverifiable actor is denied. (D5.1's `/oauth2/authorize` gate is
565+
// a different surface — the OP issuing codes, not the env's RP config.)
566+
if (ctx?.path === '/sso/register') {
567+
const actor = await this.resolveActor(ctx);
568+
if (actor?.userId) {
569+
const ok = await this.isOrgOrPlatformAdmin(actor.userId, actor.activeOrgId);
570+
if (!ok) {
571+
const { APIError } = await import('better-auth/api');
572+
throw new APIError('FORBIDDEN', {
573+
message:
574+
'Only an organization owner/admin or a platform admin can ' +
575+
'register an SSO provider.',
576+
code: 'SSO_REGISTER_FORBIDDEN',
577+
});
578+
}
579+
}
580+
return;
581+
}
582+
554583
// ── D5.1: cloud-as-IdP authorization gate ───────────────────
555584
// On the OIDC OP's /oauth2/authorize, when a host gate is set
556585
// (cloud control plane), an AUTHENTICATED subject must be
@@ -1198,11 +1227,17 @@ export class AuthManager {
11981227
// model and accepts NO `schema` option (verified against 1.6.20 — no
11991228
// mergeSchema, runtime never reads options.schema). Its table mapping to
12001229
// `sys_sso_provider` must therefore be resolved by the better-auth adapter
1201-
// / a global model map, not per-plugin here (see AUTH_SSO_PROVIDER_SCHEMA;
1202-
// TODO confirm the resolved table name in E2E).
1203-
// provisionUser / organizationProvisioning will assign a default env role
1204-
// on first federated login (ADR-0024 V1); JIT works via account linking.
1205-
plugins.push(sso());
1230+
// / a global model map, not per-plugin here (see AUTH_SSO_PROVIDER_SCHEMA).
1231+
//
1232+
// `organizationProvisioning.defaultRole` (ADR-0024 V1): a first-time
1233+
// federated login is JIT-provisioned into the user's domain-matched org
1234+
// with this role, so a member who arrives via an external IdP lands with
1235+
// an explicit default role (belt-and-suspenders over SecurityPlugin's
1236+
// `member_default` fallback, which already grants baseline access to any
1237+
// authenticated user). Requires the `organization` plugin — on by default.
1238+
plugins.push(sso({
1239+
organizationProvisioning: { defaultRole: 'member' },
1240+
}));
12061241
}
12071242

12081243
// External SCIM 2.0 Service Provider (@better-auth/scim, MIT) — lets an
@@ -1773,6 +1808,116 @@ export class AuthManager {
17731808
}
17741809
}
17751810

1811+
/**
1812+
* Resolve the acting user (+ their active org) for a before-hook gate,
1813+
* hook-order-independent. Tries the standard cookie session first, then falls
1814+
* back to explicit token resolution (bearer or the session cookie's token
1815+
* part) — the bearer plugin may convert `Authorization: Bearer` to a session
1816+
* AFTER this global before-hook runs. Returns `null` when no valid session
1817+
* can be resolved (→ caller lets `sessionMiddleware` issue the 401).
1818+
*/
1819+
private async resolveActor(
1820+
ctx: any,
1821+
): Promise<{ userId: string; activeOrgId?: string } | null> {
1822+
try {
1823+
const { getSessionFromCtx } = await import('better-auth/api');
1824+
const s: any = await getSessionFromCtx(ctx as any);
1825+
const userId = s?.user?.id ?? s?.session?.userId;
1826+
if (userId) {
1827+
return {
1828+
userId: String(userId),
1829+
activeOrgId:
1830+
s?.session?.activeOrganizationId ?? s?.activeOrganizationId ?? undefined,
1831+
};
1832+
}
1833+
} catch { /* fall through to explicit token resolution */ }
1834+
try {
1835+
const hdr = (k: string): string =>
1836+
((ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) as string) || '';
1837+
let token: string | undefined;
1838+
const bm = /^Bearer\s+(.+)$/i.exec(hdr('authorization'));
1839+
if (bm?.[1]) token = bm[1].trim();
1840+
if (!token) {
1841+
const cm = /(?:^|;\s*)(?:__Secure-|__Host-)?better-auth\.session_token=([^;]+)/.exec(hdr('cookie'));
1842+
if (cm?.[1]) token = decodeURIComponent(cm[1]).split('.')[0];
1843+
}
1844+
if (token) {
1845+
const sess: any = await (ctx as any).context.adapter.findOne({
1846+
model: 'session',
1847+
where: [{ field: 'token', value: token }],
1848+
});
1849+
const exp = sess?.expiresAt ?? sess?.expires_at;
1850+
if (sess && (!exp || new Date(exp).getTime() > Date.now())) {
1851+
const userId = String(sess.userId ?? sess.user_id ?? '');
1852+
if (userId) {
1853+
return {
1854+
userId,
1855+
activeOrgId:
1856+
sess.activeOrganizationId ?? sess.active_organization_id ?? undefined,
1857+
};
1858+
}
1859+
}
1860+
}
1861+
} catch { /* unresolved → null */ }
1862+
return null;
1863+
}
1864+
1865+
/**
1866+
* True when `userId` is a platform admin (a `sys_user_permission_set` row
1867+
* pointing at `admin_full_access` with `organization_id = null`) OR an
1868+
* owner/admin member of `activeOrgId` (any org membership with role
1869+
* owner/admin when no active org is set). Mirrors the role-derivation in
1870+
* `customSession`; reads through `withSystemReadContext` so the lookups are
1871+
* not themselves RLS-scoped to the acting (possibly non-privileged) user.
1872+
* Fails CLOSED (returns false) on any lookup error — this backs a security
1873+
* gate, so an unverifiable actor must never pass.
1874+
*/
1875+
private async isOrgOrPlatformAdmin(
1876+
userId: string,
1877+
activeOrgId?: string,
1878+
): Promise<boolean> {
1879+
const engine = this.getDataEngine();
1880+
if (!engine) return false;
1881+
const sys = withSystemReadContext(engine);
1882+
try {
1883+
// 1) platform admin — admin_full_access permission set, org-less link.
1884+
const links = await sys.find('sys_user_permission_set', {
1885+
where: { user_id: userId },
1886+
limit: 50,
1887+
});
1888+
const platformLinks = (Array.isArray(links) ? links : []).filter(
1889+
(l: any) => !l.organization_id,
1890+
);
1891+
if (platformLinks.length) {
1892+
const sets = await sys.find('sys_permission_set', { limit: 50 });
1893+
const adminSet = (Array.isArray(sets) ? sets : []).find(
1894+
(r: any) => r.name === 'admin_full_access',
1895+
);
1896+
if (adminSet && platformLinks.some((l: any) => l.permission_set_id === adminSet.id)) {
1897+
return true;
1898+
}
1899+
}
1900+
// 2) org owner/admin — membership role in the active org (or any org).
1901+
const where: any = { user_id: userId };
1902+
if (activeOrgId) where.organization_id = activeOrgId;
1903+
const members = await sys.find('sys_member', { where, limit: 10 });
1904+
for (const m of (Array.isArray(members) ? members : [])) {
1905+
const raw = typeof m?.role === 'string' ? m.role : '';
1906+
if (
1907+
raw
1908+
.split(',')
1909+
.map((s: string) => s.trim())
1910+
.some((r: string) => r === 'owner' || r === 'admin')
1911+
) {
1912+
return true;
1913+
}
1914+
}
1915+
return false;
1916+
} catch {
1917+
return false;
1918+
}
1919+
}
1920+
17761921
/**
17771922
* Compose the framework's identity-source stamp (`account.create.after`)
17781923
* with any host-supplied `databaseHooks`, preserving BOTH. The cloud passes

0 commit comments

Comments
 (0)