From 9075849e1fab6781d22a8a61f70d974fed060d7f Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 26 Jun 2026 19:11:57 +0800 Subject: [PATCH] feat(auth): add oidcAuthorizeGate seam for OIDC OP app-assignment (ADR-0024 D5.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit better-auth's oauth-provider AUTHENTICATES a subject but does NOT AUTHORIZE it against the requesting client — it issues a code to any logged-in user for any registered client. This adds an optional host-supplied gate, invoked on /oauth2/authorize for an authenticated subject before a code is issued, so a multi-tenant OP (the cloud control plane) can enforce app-assignment (org-membership): a cloud user may only obtain a code for an env client (project_) of an org they belong to. Unset (open editions / self-host, where the OP is not multi-tenant) = allow, behavior unchanged. Co-Authored-By: Claude Opus 4.8 --- .../plugins/plugin-auth/src/auth-manager.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 7157b143e8..651ac802bf 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -180,6 +180,18 @@ export interface AuthManagerOptions extends Partial { slug?: string; }) => void | Promise; + /** + * D5.1 — OIDC OP authorization gate (cloud-as-IdP app-assignment). + * When set, it is called for an AUTHENTICATED subject on + * `/oauth2/authorize` before an authorization code is issued, with the + * subject + the requesting `clientId`. Return `false` to DENY (no code). + * The cloud control plane uses it to require org-membership: a cloud user + * may only obtain a code for an env client (`project_`) of an org + * they belong to. Unset (open editions / self-host, where the OP is not a + * multi-tenant issuer) = allow. Host is expected to fail CLOSED on error. + */ + oidcAuthorizeGate?: (params: { userId: string; clientId: string }) => boolean | Promise; + /** * Base path for auth routes * Forwarded to better-auth's basePath option so it can match incoming @@ -503,6 +515,39 @@ export class AuthManager { // sees `userCount > 0` and the toggle is enforced again. hooks: { before: createAuthMiddleware(async (ctx: any) => { + // ── D5.1: cloud-as-IdP authorization gate ─────────────────── + // On the OIDC OP's /oauth2/authorize, when a host gate is set + // (cloud control plane), an AUTHENTICATED subject must be + // authorized for the requesting client (env) before a code is + // issued — this enforces org-membership (app-assignment). Unset + // (open editions / self-host) → no gate. Unauthenticated → fall + // through so the OP redirects to login; the gate runs on the + // return pass (or immediately for a bearer/cookie session). + if (ctx?.path === '/oauth2/authorize' && this.config.oidcAuthorizeGate) { + const clientId = ctx?.query?.client_id; + if (clientId) { + let gateUserId: string | undefined; + try { + const { getSessionFromCtx } = await import('better-auth/api'); + const s: any = await getSessionFromCtx(ctx as any); + gateUserId = s?.user?.id ?? s?.session?.userId; + } catch { /* no session → OP redirects to login */ } + if (gateUserId) { + const allowed = await this.config.oidcAuthorizeGate({ + userId: gateUserId, + clientId: String(clientId), + }); + if (!allowed) { + const { APIError } = await import('better-auth/api'); + throw new APIError('FORBIDDEN', { + message: 'You are not authorized to sign in to this environment.', + code: 'ENV_ACCESS_DENIED', + }); + } + } + } + return; + } if (ctx?.path !== '/sign-up/email') return; const ep = ctx?.context?.options?.emailAndPassword; if (!ep?.disableSignUp) return;