From 48cd4ea3552d4099794a5d84cdb5a64f5367101f Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 4 Nov 2026 13:13:15 -0700 Subject: [PATCH] fix: oidc toggle (#4494) --- src/backend/clients/event/types.ts | 24 ++++- .../controllers/oidc/OIDCController.test.ts | 5 +- .../controllers/oidc/OIDCController.ts | 9 +- .../services/user/SignupService.test.ts | 53 ++++++++++ src/backend/services/user/SignupService.ts | 44 ++++++++ src/gui/src/UI/UIWindowSignup.js | 26 +++++ .../src/UI/UIWindowSignup.oidcOnly.test.js | 100 ++++++++++++++++++ src/gui/src/css/style.css | 7 ++ src/gui/src/i18n/translations/en.js | 1 + 9 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 src/gui/src/UI/UIWindowSignup.oidcOnly.test.js diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index c60c6320f..b5e0e9e41 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -200,6 +200,16 @@ export type EventMap = { }; // ---- Auth / signup ---- + /** + * Which signup methods are open, emitted via `emitAndWait` before every + * signup and whenever the GUI reads the provider list. A listener sets + * `oidcOnly` to refuse form and temp signups, leaving OIDC and team + * provisioning open; `message` is what a refused form signup is told. + */ + 'puter.signup.policy': { + oidcOnly: boolean; + message: string | null; + }; 'puter.signup.validate': { allow: boolean; email?: string; @@ -1041,10 +1051,11 @@ export type EventKey = keyof EventMap & string; // Generates a wildcard for every non-final dot-separated prefix of K. export type WildcardPrefixes = K extends `${infer Head}.${infer Tail}` - ? | `${Head}.*` - | (Tail extends `${string}.${string}` - ? `${Head}.${WildcardPrefixes}` - : never) + ? + | `${Head}.*` + | (Tail extends `${string}.${string}` + ? `${Head}.${WildcardPrefixes}` + : never) : never; export type ListenKey = EventKey | WildcardPrefixes; @@ -1055,7 +1066,10 @@ export type MatchingEvents

= P extends `${infer Prefix}.*` /** Acked, not acked, no socket anywhere, or sockets only in a peer region. */ export type NotifDeliveryOutcome = - 'received' | 'unreceived' | 'no-socket' | 'remote-only'; + | 'received' + | 'unreceived' + | 'no-socket' + | 'remote-only'; /** One socket's receipt for a live notification. */ export interface NotifDeliveryResponder { diff --git a/src/backend/controllers/oidc/OIDCController.test.ts b/src/backend/controllers/oidc/OIDCController.test.ts index db83ec4fa..c49c6bf7f 100644 --- a/src/backend/controllers/oidc/OIDCController.test.ts +++ b/src/backend/controllers/oidc/OIDCController.test.ts @@ -244,7 +244,10 @@ describe('OIDCController GET /auth/oidc/providers', () => { await callRoute('get', '/auth/oidc/providers', makeReq({}), res); // We configured a single `custom` provider; the live service // walks the config and returns its id. - expect(captured.body).toEqual({ providers: ['custom'] }); + expect(captured.body).toEqual({ + providers: ['custom'], + signupOidcOnly: false, + }); }); }); diff --git a/src/backend/controllers/oidc/OIDCController.ts b/src/backend/controllers/oidc/OIDCController.ts index c426e7bcb..c897bef2d 100644 --- a/src/backend/controllers/oidc/OIDCController.ts +++ b/src/backend/controllers/oidc/OIDCController.ts @@ -396,9 +396,12 @@ export class OIDCController extends PuterController { }, }, async (_req: Request, res: Response) => { - const providers = - await this.services.oidc.getEnabledProviderIds(); - res.json({ providers }); + const [providers, policy] = await Promise.all([ + this.services.oidc.getEnabledProviderIds(), + this.services.signup.signupPolicy(), + ]); + // The signup window drops its email form when this is set. + res.json({ providers, signupOidcOnly: policy.oidcOnly }); }, ); diff --git a/src/backend/services/user/SignupService.test.ts b/src/backend/services/user/SignupService.test.ts index 9bb847684..98c252cb6 100644 --- a/src/backend/services/user/SignupService.test.ts +++ b/src/backend/services/user/SignupService.test.ts @@ -122,6 +122,59 @@ describe('SignupService.runSignupGate', () => { } }); + it('an OIDC-only policy refuses form and temp signups before the validate hook, and lets OIDC and team provisioning through', async () => { + const oidcOnly = (_k: string, data: unknown) => { + Object.assign(data as object, { oidcOnly: true }); + }; + server.clients.event.on('puter.signup.policy', oidcOnly); + try { + const gate = (input: { + isTemp: boolean; + source?: 'oidc' | 'team_provision'; + }) => + capture('puter.signup.validate', () => + server.services.signup.runSignupGate({ + req, + data: {}, + email: `policy-${slug()}@example.com`, + bonusCode: null, + ...input, + }), + ); + const form = await gate({ isTemp: false }); + const temp = await gate({ isTemp: true }); + const oidc = await gate({ isTemp: false, source: 'oidc' }); + const team = await gate({ + isTemp: false, + source: 'team_provision', + }); + + expect(form.result).toMatchObject({ + verdict: 'blocked', + code: 'signup_oidc_only', + }); + expect(temp.result).toEqual({ + verdict: 'no_temp_user', + message: null, + code: null, + }); + expect(form.seen).toHaveLength(0); + expect(temp.seen).toHaveLength(0); + expect(oidc.result.verdict).toBe('allowed'); + expect(team.result.verdict).toBe('allowed'); + expect(await server.services.signup.signupPolicy()).toEqual({ + oidcOnly: true, + message: null, + }); + } finally { + server.clients.event.off('puter.signup.policy', oidcOnly); + } + expect(await server.services.signup.signupPolicy()).toEqual({ + oidcOnly: false, + message: null, + }); + }); + it('refuses an unknown bonus code before the validate hook runs', async () => { const { result, seen } = await capture('puter.signup.validate', () => server.services.signup.runSignupGate({ diff --git a/src/backend/services/user/SignupService.ts b/src/backend/services/user/SignupService.ts index 7b9e45e6f..c6638efc5 100644 --- a/src/backend/services/user/SignupService.ts +++ b/src/backend/services/user/SignupService.ts @@ -89,6 +89,9 @@ export interface SignupInput { export const signupClientIp = (req: Request): string | null => req.ip || req.socket?.remoteAddress || null; +const OIDC_ONLY_MESSAGE = + 'New accounts can only be created with a sign-in provider right now.'; + /** * Account creation for every signup route (form, temp, OIDC) and email * confirmation for every confirmation route. Callers parse requests and set @@ -147,6 +150,30 @@ export class SignupService extends PuterService { } } + /** + * The signup methods extensions allow right now; a failing hook restricts + * nothing. + */ + async signupPolicy(): Promise<{ + oidcOnly: boolean; + message: string | null; + }> { + const policy = { oidcOnly: false, message: null as string | null }; + try { + await this.clients.event?.emitAndWait( + 'puter.signup.policy', + policy, + {}, + ); + } catch (e) { + console.warn('[signup] policy hook failed:', e); + } + return { + oidcOnly: policy.oidcOnly === true, + message: typeof policy.message === 'string' ? policy.message : null, + }; + } + /** * The extension-owned verdict on a signup: a dead bonus code is refused * first (before `puter.signup.validate` records the attempt), then the @@ -157,6 +184,23 @@ export class SignupService extends PuterService { const clientIp = signupClientIp(req); const fingerprint = input.fingerprint ?? null; + // Only form and temp signups carry no source; OIDC and team + // provisioning are never restricted by the policy. + if (!input.source) { + const policy = await this.signupPolicy(); + if (policy.oidcOnly) { + // A refused temp signup sends the GUI to its signup window, + // which then offers only the providers. + return isTemp + ? { verdict: 'no_temp_user', message: null, code: null } + : { + verdict: 'blocked', + message: policy.message ?? OIDC_ONLY_MESSAGE, + code: 'signup_oidc_only', + }; + } + } + if ( bonusCode && !( diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 000e76ab4..553b075d6 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -68,6 +68,15 @@ function UIWindowSignup(options) { return new Promise(async (resolve) => { const internal_id = window.uuidv4(); + let offersProviders = false; + // Form signups are closed, so the providers are the only way in. + const showOidcOnly = (el_window) => { + $(el_window).find('.signup-form').hide(); + $(el_window).find('.signup-with-email-wrapper').hide(); + $(el_window).find('.oidc-providers-wrapper').show(); + $(el_window).find('.signup-oidc-only-notice').show(); + }; + const logo_clickable = !!options.window_options?.cover_page && !window.embedded_in_popup; @@ -149,6 +158,7 @@ function UIWindowSignup(options) { h += ''; h += '