diff --git a/src/backend/controllers/oidc/OIDCController.test.ts b/src/backend/controllers/oidc/OIDCController.test.ts
new file mode 100644
index 000000000..12ffb4d58
--- /dev/null
+++ b/src/backend/controllers/oidc/OIDCController.test.ts
@@ -0,0 +1,480 @@
+/**
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import type { Request, RequestHandler, Response } from 'express';
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vitest';
+import { v4 as uuidv4 } from 'uuid';
+import { runWithContext } from '../../core/context.js';
+import { PuterRouter } from '../../core/http/PuterRouter.js';
+import type { OIDCService } from '../../services/auth/OIDCService.js';
+import { PuterServer } from '../../server.js';
+import { setupTestServer } from '../../testUtil.js';
+
+const TEST_ORIGIN = 'http://test.local';
+
+// ── Test harness ────────────────────────────────────────────────────
+//
+// Boots one real PuterServer with a custom OIDC provider configured,
+// then re-registers OIDCController's inline lambda routes onto a
+// fresh PuterRouter so each handler is reachable. Tests run against
+// the live wired OIDCService (real signState / verifyState, real
+// findUserByProviderSub / linkProviderToUser, real createUserFromOIDC)
+// and AuthService (real session token).
+//
+// The two methods that hit external HTTP — `exchangeCodeForTokens`
+// and `getUserInfo` — are stubbed per-test with `vi.spyOn` so the
+// callback flow can be exercised without standing up a fake IdP.
+
+let server: PuterServer;
+let router: PuterRouter;
+
+beforeAll(async () => {
+ server = await setupTestServer({
+ origin: TEST_ORIGIN,
+ // A "custom" OIDC provider just needs static endpoints — no
+ // discovery fetch happens. The endpoint URLs aren't actually
+ // hit during the tests we keep here (we spy past them when
+ // necessary), but they must be set so `getProviderConfig`
+ // accepts the entry.
+ oidc: {
+ providers: {
+ custom: {
+ client_id: 'test-client',
+ client_secret: 'test-secret',
+ authorization_endpoint:
+ 'https://idp.test.invalid/authorize',
+ token_endpoint: 'https://idp.test.invalid/token',
+ userinfo_endpoint: 'https://idp.test.invalid/userinfo',
+ },
+ },
+ },
+ } as never);
+ router = new PuterRouter();
+ server.controllers.oidc.registerRoutes(router);
+});
+
+afterAll(async () => {
+ await server?.shutdown();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+interface CapturedResponse {
+ statusCode: number;
+ body: unknown;
+ redirectStatus?: number;
+ redirectUrl?: string;
+ headers: Record;
+ cookies: Array<{ name: string; value: string; opts?: unknown }>;
+ contentType?: string;
+}
+
+const makeReq = (init: {
+ body?: unknown;
+ query?: Record;
+ params?: Record;
+ headers?: Record;
+ method?: string;
+}): Request => {
+ return {
+ body: init.body ?? {},
+ query: init.query ?? {},
+ params: init.params ?? {},
+ headers: init.headers ?? {},
+ method: init.method ?? 'GET',
+ } as unknown as Request;
+};
+
+const makeRes = () => {
+ const captured: CapturedResponse = {
+ statusCode: 200,
+ body: undefined,
+ headers: {},
+ cookies: [],
+ };
+ const res = {
+ json: vi.fn((value: unknown) => {
+ captured.body = value;
+ return res;
+ }),
+ send: vi.fn((value: unknown) => {
+ captured.body = value;
+ return res;
+ }),
+ status: vi.fn((code: number) => {
+ captured.statusCode = code;
+ return res;
+ }),
+ set: vi.fn((key: string, value: string) => {
+ if (typeof key === 'string') {
+ captured.headers[key.toLowerCase()] = value;
+ if (key.toLowerCase() === 'content-type') {
+ captured.contentType = value;
+ }
+ }
+ return res;
+ }),
+ setHeader: vi.fn(() => res),
+ redirect: vi.fn((status: number | string, url?: string) => {
+ if (typeof status === 'number' && typeof url === 'string') {
+ captured.redirectStatus = status;
+ captured.redirectUrl = url;
+ } else if (typeof status === 'string') {
+ captured.redirectStatus = 302;
+ captured.redirectUrl = status;
+ }
+ return res;
+ }),
+ cookie: vi.fn((name: string, value: string, opts?: unknown) => {
+ captured.cookies.push({ name, value, opts });
+ return res;
+ }),
+ type: vi.fn(() => res),
+ };
+ return { res: res as unknown as Response, captured };
+};
+
+const findHandler = (method: string, path: string): RequestHandler => {
+ const route = router.routes.find(
+ (r) => r.method === method && r.path === path,
+ );
+ if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`);
+ return route.handler;
+};
+
+const callRoute = async (
+ method: string,
+ path: string,
+ req: Request,
+ res: Response,
+) => {
+ const handler = findHandler(method, path);
+ // OIDCService.createUserFromOIDC pulls `req` off the request
+ // context (for IP / signup-validate hooks). Express middleware
+ // sets it in production; the test driver mirrors that here.
+ await runWithContext({ req }, () =>
+ handler(req, res, () => {
+ throw new Error('handler called next() unexpectedly');
+ }),
+ );
+};
+
+const oidc = (): OIDCService =>
+ server.services.oidc as unknown as OIDCService;
+
+// ── GET /auth/oidc/providers ────────────────────────────────────────
+
+describe('OIDCController GET /auth/oidc/providers', () => {
+ it('lists every provider whose config validates', async () => {
+ const { res, captured } = makeRes();
+ 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'] });
+ });
+});
+
+// ── GET /auth/oidc/:provider/start ──────────────────────────────────
+
+describe('OIDCController GET /auth/oidc/:provider/start', () => {
+ it('throws 404 for an unconfigured provider', async () => {
+ const { res } = makeRes();
+ await expect(
+ callRoute(
+ 'get',
+ '/auth/oidc/:provider/start',
+ makeReq({ params: { provider: 'no-such-idp' } }),
+ res,
+ ),
+ ).rejects.toMatchObject({ statusCode: 404 });
+ });
+
+ it('redirects to the IdP authorization URL with a signed state', async () => {
+ const { res, captured } = makeRes();
+ await callRoute(
+ 'get',
+ '/auth/oidc/:provider/start',
+ makeReq({ params: { provider: 'custom' } }),
+ res,
+ );
+
+ expect(captured.redirectStatus).toBe(302);
+ const url = new URL(captured.redirectUrl ?? '');
+ expect(url.origin).toBe('https://idp.test.invalid');
+ expect(url.pathname).toBe('/authorize');
+ // Authorization URL carries the live-signed state token;
+ // pull it back out and confirm the OIDC service can verify it.
+ const state = url.searchParams.get('state');
+ expect(state).toBeTruthy();
+ const decoded = oidc().verifyState(state!);
+ expect(decoded).toMatchObject({ provider: 'custom' });
+ });
+
+ it('returns 400 for the revalidate flow without user_uuid', async () => {
+ const { res } = makeRes();
+ await expect(
+ callRoute(
+ 'get',
+ '/auth/oidc/:provider/start',
+ makeReq({
+ params: { provider: 'custom' },
+ query: { flow: 'revalidate' },
+ }),
+ res,
+ ),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ });
+});
+
+// ── /auth/oidc/callback/login ───────────────────────────────────────
+
+describe('OIDCController login callback', () => {
+ it('redirects with auth_error=1 when the state is invalid', async () => {
+ const { res, captured } = makeRes();
+ await callRoute(
+ 'get',
+ '/auth/oidc/callback/login',
+ makeReq({ query: { code: 'c', state: 'not-a-real-token' } }),
+ res,
+ );
+ expect(captured.redirectStatus).toBe(302);
+ expect(captured.redirectUrl).toContain('auth_error=1');
+ expect(captured.redirectUrl).toContain('action=login');
+ });
+
+ it('redirects with auth_error=1 when code or state is missing', async () => {
+ const { res, captured } = makeRes();
+ await callRoute(
+ 'get',
+ '/auth/oidc/callback/login',
+ makeReq({ query: {} }),
+ res,
+ );
+ expect(captured.redirectStatus).toBe(302);
+ expect(captured.redirectUrl).toContain('auth_error=1');
+ });
+
+ it('creates a fresh user and sets the session cookie on first sign-in', async () => {
+ // Sign a real state token so verifyState succeeds; spy past the
+ // two methods that hit external HTTP.
+ const state = oidc().signState({
+ provider: 'custom',
+ redirect_uri: 'http://test.local/',
+ });
+ const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
+ const email = `oidc-${Math.random().toString(36).slice(2, 8)}@test.local`;
+
+ vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
+ access_token: 'access',
+ id_token: 'id',
+ } as never);
+ vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
+ sub,
+ email,
+ email_verified: true,
+ } as never);
+
+ const { res, captured } = makeRes();
+ await callRoute(
+ 'get',
+ '/auth/oidc/callback/login',
+ makeReq({ query: { code: 'authcode', state } }),
+ res,
+ );
+
+ // Session cookie was set with the configured cookie name.
+ expect(captured.cookies).toHaveLength(1);
+ expect(captured.cookies[0]?.value).toBeTruthy();
+ // Same-origin redirect target is preserved.
+ expect(captured.redirectUrl).toBe('http://test.local/');
+
+ // The real OIDCService linked the new user — verify via the
+ // live store.
+ const linkedUser = await oidc().findUserByProviderSub(
+ 'custom',
+ sub,
+ );
+ expect(linkedUser).not.toBeNull();
+ expect(linkedUser?.email).toBe(email);
+ });
+
+ it('clamps redirect_uri to the configured origin (rejects external)', async () => {
+ const state = oidc().signState({
+ provider: 'custom',
+ // External attacker-supplied target.
+ redirect_uri: 'https://evil.test/steal',
+ });
+ vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
+ access_token: 'access',
+ id_token: 'id',
+ } as never);
+ vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
+ sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
+ email: `oidc-${Math.random().toString(36).slice(2, 8)}@test.local`,
+ email_verified: true,
+ } as never);
+
+ const { res, captured } = makeRes();
+ await callRoute(
+ 'get',
+ '/auth/oidc/callback/login',
+ makeReq({ query: { code: 'c', state } }),
+ res,
+ );
+
+ // Falls back to the configured origin since the requested URL
+ // is on a different host.
+ expect(captured.redirectUrl).toBe(TEST_ORIGIN);
+ });
+
+ it('refuses to link to an existing account with an unconfirmed email', async () => {
+ // Seed an existing UNCONFIRMED user with the OIDC-claimed email.
+ const email = `pending-${Math.random().toString(36).slice(2, 8)}@test.local`;
+ await server.stores.user.create({
+ username: `pending-${Math.random().toString(36).slice(2, 8)}`,
+ uuid: uuidv4(),
+ password: 'hashed',
+ email,
+ free_storage: 100 * 1024 * 1024,
+ requires_email_confirmation: true,
+ });
+
+ const state = oidc().signState({
+ provider: 'custom',
+ redirect_uri: 'http://test.local/',
+ });
+ vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
+ access_token: 'access',
+ id_token: 'id',
+ } as never);
+ vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
+ sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
+ email,
+ email_verified: true,
+ } as never);
+
+ const { res, captured } = makeRes();
+ await callRoute(
+ 'get',
+ '/auth/oidc/callback/login',
+ makeReq({ query: { code: 'c', state } }),
+ res,
+ );
+
+ expect(captured.redirectStatus).toBe(302);
+ expect(captured.redirectUrl).toContain('auth_error=1');
+ });
+});
+
+// ── /auth/oidc/callback/revalidate ──────────────────────────────────
+
+describe('OIDCController revalidate callback', () => {
+ it('returns 400 on invalid state', async () => {
+ const { res, captured } = makeRes();
+ await callRoute(
+ 'get',
+ '/auth/oidc/callback/revalidate',
+ makeReq({ query: { code: 'c', state: 'not-a-token' } }),
+ res,
+ );
+ expect(captured.statusCode).toBe(400);
+ });
+
+ it('returns 400 when the state has the wrong flow', async () => {
+ // verifyState succeeds, but state.flow !== 'revalidate'.
+ const state = oidc().signState({
+ provider: 'custom',
+ flow: 'login',
+ });
+ vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
+ access_token: 'access',
+ id_token: 'id',
+ } as never);
+ vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
+ sub: 'whatever',
+ } as never);
+
+ const { res, captured } = makeRes();
+ await callRoute(
+ 'get',
+ '/auth/oidc/callback/revalidate',
+ makeReq({ query: { code: 'c', state } }),
+ res,
+ );
+ expect(captured.statusCode).toBe(400);
+ });
+
+ it('returns 400 when no account exists for the OIDC sub', async () => {
+ const state = oidc().signState({
+ provider: 'custom',
+ flow: 'revalidate',
+ user_uuid: uuidv4(),
+ });
+ vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
+ access_token: 'access',
+ id_token: 'id',
+ } as never);
+ vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
+ sub: `unlinked-${Math.random().toString(36).slice(2, 8)}`,
+ } as never);
+
+ const { res, captured } = makeRes();
+ await callRoute(
+ 'get',
+ '/auth/oidc/callback/revalidate',
+ makeReq({ query: { code: 'c', state } }),
+ res,
+ );
+ expect(captured.statusCode).toBe(400);
+ });
+});
+
+// ── GET /auth/revalidate-done ───────────────────────────────────────
+
+describe('OIDCController GET /auth/revalidate-done', () => {
+ it('renders the postMessage HTML body with the configured origin', async () => {
+ const { res, captured } = makeRes();
+ await callRoute(
+ 'get',
+ '/auth/revalidate-done',
+ makeReq({}),
+ res,
+ );
+ expect(captured.contentType).toContain('text/html');
+ const body = String(captured.body);
+ expect(body).toContain('puter-revalidate-done');
+ // The configured origin is JSON-stringified into the inline script.
+ expect(body).toContain(JSON.stringify(TEST_ORIGIN));
+ });
+});