Skip to content

Commit 831085e

Browse files
os-zhuangclaude
andauthored
feat(auth): add oidcAuthorizeGate seam for OIDC OP app-assignment (ADR-0024 D5.1) (#2343)
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_<envId>) 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 <noreply@anthropic.com>
1 parent 98a1535 commit 831085e

1 file changed

Lines changed: 45 additions & 0 deletions

File tree

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,18 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
180180
slug?: string;
181181
}) => void | Promise<void>;
182182

183+
/**
184+
* D5.1 — OIDC OP authorization gate (cloud-as-IdP app-assignment).
185+
* When set, it is called for an AUTHENTICATED subject on
186+
* `/oauth2/authorize` before an authorization code is issued, with the
187+
* subject + the requesting `clientId`. Return `false` to DENY (no code).
188+
* The cloud control plane uses it to require org-membership: a cloud user
189+
* may only obtain a code for an env client (`project_<envId>`) of an org
190+
* they belong to. Unset (open editions / self-host, where the OP is not a
191+
* multi-tenant issuer) = allow. Host is expected to fail CLOSED on error.
192+
*/
193+
oidcAuthorizeGate?: (params: { userId: string; clientId: string }) => boolean | Promise<boolean>;
194+
183195
/**
184196
* Base path for auth routes
185197
* Forwarded to better-auth's basePath option so it can match incoming
@@ -503,6 +515,39 @@ export class AuthManager {
503515
// sees `userCount > 0` and the toggle is enforced again.
504516
hooks: {
505517
before: createAuthMiddleware(async (ctx: any) => {
518+
// ── D5.1: cloud-as-IdP authorization gate ───────────────────
519+
// On the OIDC OP's /oauth2/authorize, when a host gate is set
520+
// (cloud control plane), an AUTHENTICATED subject must be
521+
// authorized for the requesting client (env) before a code is
522+
// issued — this enforces org-membership (app-assignment). Unset
523+
// (open editions / self-host) → no gate. Unauthenticated → fall
524+
// through so the OP redirects to login; the gate runs on the
525+
// return pass (or immediately for a bearer/cookie session).
526+
if (ctx?.path === '/oauth2/authorize' && this.config.oidcAuthorizeGate) {
527+
const clientId = ctx?.query?.client_id;
528+
if (clientId) {
529+
let gateUserId: string | undefined;
530+
try {
531+
const { getSessionFromCtx } = await import('better-auth/api');
532+
const s: any = await getSessionFromCtx(ctx as any);
533+
gateUserId = s?.user?.id ?? s?.session?.userId;
534+
} catch { /* no session → OP redirects to login */ }
535+
if (gateUserId) {
536+
const allowed = await this.config.oidcAuthorizeGate({
537+
userId: gateUserId,
538+
clientId: String(clientId),
539+
});
540+
if (!allowed) {
541+
const { APIError } = await import('better-auth/api');
542+
throw new APIError('FORBIDDEN', {
543+
message: 'You are not authorized to sign in to this environment.',
544+
code: 'ENV_ACCESS_DENIED',
545+
});
546+
}
547+
}
548+
}
549+
return;
550+
}
506551
if (ctx?.path !== '/sign-up/email') return;
507552
const ep = ctx?.context?.options?.emailAndPassword;
508553
if (!ep?.disableSignUp) return;

0 commit comments

Comments
 (0)