@@ -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 = / ^ B e a r e r \s + ( .+ ) $ / i. exec ( hdr ( 'authorization' ) ) ;
1839+ if ( bm ?. [ 1 ] ) token = bm [ 1 ] . trim ( ) ;
1840+ if ( ! token ) {
1841+ const cm = / (?: ^ | ; \s * ) (?: _ _ S e c u r e - | _ _ H o s t - ) ? b e t t e r - a u t h \. s e s s i o n _ t o k e n = ( [ ^ ; ] + ) / . 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