Skip to content

Commit 7cf81a7

Browse files
os-zhuangclaude
andauthored
fix(auth): org-scope registered SSO/SAML providers so any org admin can manage them (ADR-0024 / cloud#551) (#2407)
@better-auth/sso gates ORG-LESS providers' delete/update/domain-verification on provider.userId === caller (registrar-only). The register bridges now resolve the caller's active org (best-effort /get-session re-dispatch) and scope the provider to it, so management gates on isOrgAdmin — any org owner/admin can manage the env's IdPs. Falls back to org-less when no active org (no regression). Verified E2E: form-registered OIDC provider lands with organization_id = env org (was null); register + delete still 200. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 574e7a3 commit 7cf81a7

2 files changed

Lines changed: 56 additions & 2 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
Auth: org-scope registered SSO/SAML providers so any org admin can manage them (ADR-0024 / cloud#551)
6+
7+
`@better-auth/sso`'s provider-management endpoints (delete / update / domain verification) gate ORG-LESS providers on `provider.userId === caller` — only the original registrar could manage them, so a second org admin couldn't delete or verify an IdP someone else registered. The register bridges now resolve the caller's active organization (best-effort, via a `/get-session` re-dispatch) and scope the provider to it, so management gates on `isOrgAdmin` instead — **any** org owner/admin can manage the environment's IdPs. Falls back to org-less (no behavior change) when no active org is set.
8+
9+
Verified E2E: an OIDC provider registered through the form lands with `organization_id` set to the env's org (was null); register + delete still succeed.

packages/plugins/plugin-auth/src/register-sso-provider.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,37 @@ export interface RegisterSsoFormResult {
3636
/** A better-auth universal handler: `(request) => Response`. */
3737
export type AuthRequestHandler = (request: Request) => Promise<Response>;
3838

39+
/**
40+
* Resolve the caller's active organization id by re-dispatching a
41+
* `/get-session` through the same better-auth handler. Returns `undefined` on
42+
* any failure / when no active org is set — callers fall back to an org-less
43+
* (registrar-only) provider, so this is strictly best-effort. `registerUrl` is
44+
* the resolved `…/sso/register` URL; we swap the trailing path for
45+
* `…/get-session` on the same origin/basePath.
46+
*/
47+
async function resolveActiveOrganizationId(
48+
handle: AuthRequestHandler,
49+
registerUrl: string,
50+
headers: Headers,
51+
): Promise<string | undefined> {
52+
try {
53+
const sessionUrl = registerUrl.replace(/\/sso\/register$/, '/get-session');
54+
if (sessionUrl === registerUrl) return undefined;
55+
const h = new Headers({ accept: 'application/json' });
56+
const cookie = headers.get('cookie');
57+
if (cookie) h.set('cookie', cookie);
58+
const authz = headers.get('authorization');
59+
if (authz) h.set('authorization', authz);
60+
const resp = await handle(new Request(sessionUrl, { method: 'GET', headers: h }));
61+
if (!resp.ok) return undefined;
62+
const data: any = await resp.json().catch(() => null);
63+
const org = data?.session?.activeOrganizationId ?? data?.activeOrganizationId;
64+
return typeof org === 'string' && org.length > 0 ? org : undefined;
65+
} catch {
66+
return undefined;
67+
}
68+
}
69+
3970
/**
4071
* Reshape a flat SSO-provider registration form body and register it.
4172
*
@@ -112,10 +143,20 @@ export async function runRegisterSsoProviderFromForm(
112143
if (authz) headers.set('authorization', authz);
113144
headers.set('origin', request.headers.get('origin') || origin);
114145

146+
// Org-scope the provider to the caller's active organization (best-effort).
147+
// `@better-auth/sso`'s management endpoints (delete / update / domain
148+
// verification) gate org-scoped providers on `isOrgAdmin` but gate ORG-LESS
149+
// ones on `provider.userId === caller` — i.e. only the original registrar can
150+
// manage them. Scoping to the org means ANY org owner/admin can manage the
151+
// env's IdPs (the env is single-org in V1). Resolved by re-dispatching a
152+
// `/get-session` through the same handler; falls back to org-less (no
153+
// regression) when no active org is set.
154+
const organizationId = await resolveActiveOrganizationId(handle, innerUrl, headers);
155+
115156
const innerReq = new Request(innerUrl, {
116157
method: 'POST',
117158
headers,
118-
body: JSON.stringify({ providerId, issuer, domain, oidcConfig }),
159+
body: JSON.stringify({ providerId, issuer, domain, oidcConfig, ...(organizationId ? { organizationId } : {}) }),
119160
});
120161

121162
const resp = await handle(innerReq);
@@ -205,10 +246,14 @@ export async function runRegisterSamlProviderFromForm(
205246
if (authz) headers.set('authorization', authz);
206247
headers.set('origin', request.headers.get('origin') || origin);
207248

249+
// Org-scope to the caller's active org (best-effort) so any org owner/admin
250+
// can manage the provider — see the OIDC helper above.
251+
const organizationId = await resolveActiveOrganizationId(handle, innerUrl, headers);
252+
208253
const innerReq = new Request(innerUrl, {
209254
method: 'POST',
210255
headers,
211-
body: JSON.stringify({ providerId, issuer, domain, samlConfig }),
256+
body: JSON.stringify({ providerId, issuer, domain, samlConfig, ...(organizationId ? { organizationId } : {}) }),
212257
});
213258
const resp = await handle(innerReq);
214259
let parsed: any = {};

0 commit comments

Comments
 (0)