Skip to content

Commit 289a44a

Browse files
authored
fix: OIDC state (#3370)
1 parent 827e9bc commit 289a44a

2 files changed

Lines changed: 240 additions & 3 deletions

File tree

src/backend/controllers/oidc/OIDCController.test.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ interface CapturedResponse {
9292
redirectUrl?: string;
9393
headers: Record<string, string>;
9494
cookies: Array<{ name: string; value: string; opts?: unknown }>;
95+
clearedCookies: Array<{ name: string; opts?: unknown }>;
9596
contentType?: string;
9697
}
9798

@@ -100,13 +101,15 @@ const makeReq = (init: {
100101
query?: Record<string, unknown>;
101102
params?: Record<string, unknown>;
102103
headers?: Record<string, string>;
104+
cookies?: Record<string, string>;
103105
method?: string;
104106
}): Request => {
105107
return {
106108
body: init.body ?? {},
107109
query: init.query ?? {},
108110
params: init.params ?? {},
109111
headers: init.headers ?? {},
112+
cookies: init.cookies ?? {},
110113
method: init.method ?? 'GET',
111114
} as unknown as Request;
112115
};
@@ -117,6 +120,7 @@ const makeRes = () => {
117120
body: undefined,
118121
headers: {},
119122
cookies: [],
123+
clearedCookies: [],
120124
};
121125
const res = {
122126
json: vi.fn((value: unknown) => {
@@ -155,6 +159,10 @@ const makeRes = () => {
155159
captured.cookies.push({ name, value, opts });
156160
return res;
157161
}),
162+
clearCookie: vi.fn((name: string, opts?: unknown) => {
163+
captured.clearedCookies.push({ name, opts });
164+
return res;
165+
}),
158166
type: vi.fn(() => res),
159167
};
160168
return { res: res as unknown as Response, captured };
@@ -767,6 +775,177 @@ describe('OIDCController login callback', () => {
767775
});
768776
});
769777

778+
// -- Browser binding / login-CSRF --------------------------------------
779+
780+
describe('OIDCController browser binding', () => {
781+
const NONCE_COOKIE = 'puter_oidc_nonce';
782+
783+
const stubIdP = (sub: string, email: string) => {
784+
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
785+
access_token: 'access',
786+
id_token: 'id',
787+
} as never);
788+
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
789+
sub,
790+
email,
791+
email_verified: true,
792+
} as never);
793+
};
794+
795+
it('/start sets an HttpOnly nonce cookie matching the nonce embedded in state', async () => {
796+
const { res, captured } = makeRes();
797+
await callRoute(
798+
'get',
799+
'/auth/oidc/:provider/start',
800+
makeReq({ params: { provider: 'custom' } }),
801+
res,
802+
);
803+
804+
const nonceCookie = captured.cookies.find(
805+
(c) => c.name === NONCE_COOKIE,
806+
);
807+
expect(nonceCookie).toBeTruthy();
808+
expect(nonceCookie?.value).toBeTruthy();
809+
expect((nonceCookie?.opts as { httpOnly?: boolean })?.httpOnly).toBe(
810+
true,
811+
);
812+
813+
// The cookie value must equal the nonce baked into the signed state.
814+
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
815+
'state',
816+
);
817+
const decoded = oidc().verifyState(state!);
818+
expect(decoded?.nonce).toBe(nonceCookie?.value);
819+
});
820+
821+
it('completes login when the nonce cookie matches the state nonce', async () => {
822+
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
823+
const email = `bind-${Math.random().toString(36).slice(2, 8)}@test.local`;
824+
const nonce = 'browser-nonce-match';
825+
const state = oidc().signState({
826+
provider: 'custom',
827+
redirect_uri: TEST_ORIGIN + '/',
828+
nonce,
829+
});
830+
stubIdP(sub, email);
831+
832+
const { res, captured } = makeRes();
833+
await callRoute(
834+
'get',
835+
'/auth/oidc/callback/login',
836+
makeReq({
837+
query: { code: 'c', state },
838+
cookies: { [NONCE_COOKIE]: nonce },
839+
}),
840+
res,
841+
);
842+
843+
// Session cookie issued; single-use nonce cookie cleared.
844+
expect(captured.cookies).toHaveLength(1);
845+
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
846+
expect(
847+
captured.clearedCookies.some((c) => c.name === NONCE_COOKIE),
848+
).toBe(true);
849+
});
850+
851+
it('rejects login (no session cookie) when the nonce cookie is absent — the login-CSRF case', async () => {
852+
const state = oidc().signState({
853+
provider: 'custom',
854+
redirect_uri: TEST_ORIGIN + '/',
855+
nonce: 'attacker-flow-nonce',
856+
});
857+
// If enforcement were missing, this would resolve a user and set a
858+
// session cookie for the victim's browser. It must not get that far.
859+
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
860+
861+
const { res, captured } = makeRes();
862+
await callRoute(
863+
'get',
864+
'/auth/oidc/callback/login',
865+
// Victim's browser has no nonce cookie for the attacker's flow.
866+
makeReq({ query: { code: 'c', state }, cookies: {} }),
867+
res,
868+
);
869+
870+
expect(captured.redirectStatus).toBe(302);
871+
expect(captured.redirectUrl).toContain('auth_error=1');
872+
expect(captured.cookies).toHaveLength(0);
873+
// We bail before ever exchanging the code.
874+
expect(exchangeSpy).not.toHaveBeenCalled();
875+
});
876+
877+
it('rejects login when the nonce cookie does not match the state nonce', async () => {
878+
const state = oidc().signState({
879+
provider: 'custom',
880+
redirect_uri: TEST_ORIGIN + '/',
881+
nonce: 'expected-nonce',
882+
});
883+
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
884+
885+
const { res, captured } = makeRes();
886+
await callRoute(
887+
'get',
888+
'/auth/oidc/callback/login',
889+
makeReq({
890+
query: { code: 'c', state },
891+
cookies: { [NONCE_COOKIE]: 'a-different-nonce' },
892+
}),
893+
res,
894+
);
895+
896+
expect(captured.redirectUrl).toContain('auth_error=1');
897+
expect(captured.cookies).toHaveLength(0);
898+
expect(exchangeSpy).not.toHaveBeenCalled();
899+
});
900+
901+
it('rejects the revalidate callback (400) when the nonce cookie is missing', async () => {
902+
const state = oidc().signState({
903+
provider: 'custom',
904+
flow: 'revalidate',
905+
user_uuid: uuidv4(),
906+
nonce: 'reval-nonce',
907+
});
908+
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
909+
910+
const { res, captured } = makeRes();
911+
await callRoute(
912+
'get',
913+
'/auth/oidc/callback/revalidate',
914+
makeReq({ query: { code: 'c', state }, cookies: {} }),
915+
res,
916+
);
917+
918+
expect(captured.statusCode).toBe(400);
919+
expect(exchangeSpy).not.toHaveBeenCalled();
920+
});
921+
922+
it('lets legacy nonce-less state through (deploy grace) without touching the nonce cookie', async () => {
923+
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
924+
const email = `legacy-${Math.random().toString(36).slice(2, 8)}@test.local`;
925+
// No `nonce` field — mimics a state signed before this shipped.
926+
const state = oidc().signState({
927+
provider: 'custom',
928+
redirect_uri: TEST_ORIGIN + '/',
929+
});
930+
stubIdP(sub, email);
931+
932+
const { res, captured } = makeRes();
933+
await callRoute(
934+
'get',
935+
'/auth/oidc/callback/login',
936+
makeReq({ query: { code: 'c', state }, cookies: {} }),
937+
res,
938+
);
939+
940+
// Proceeds as before; no nonce cookie is cleared for legacy states.
941+
expect(captured.cookies).toHaveLength(1);
942+
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
943+
expect(
944+
captured.clearedCookies.some((c) => c.name === NONCE_COOKIE),
945+
).toBe(false);
946+
});
947+
});
948+
770949
// ── /auth/oidc/callback/signup ──────────────────────────────────────
771950

772951
describe('OIDCController signup callback', () => {

src/backend/controllers/oidc/OIDCController.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
* along with this program. If not, see <https://www.gnu.org/licenses/>.
1818
*/
1919

20+
import crypto from 'node:crypto';
2021
import type { Request, Response } from 'express';
2122
import { HttpError } from '../../core/http/HttpError.js';
2223
import type { PuterRouter } from '../../core/http/PuterRouter.js';
@@ -26,6 +27,12 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js';
2627
const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
2728
const REVALIDATION_EXPIRY_SEC = 300;
2829

30+
// Companion cookie that binds an OIDC flow to the browser that started it.
31+
// Expiry mirrors STATE_EXPIRY_SEC in OIDCService — the state and its
32+
// browser-binding cookie must expire together.
33+
const OIDC_NONCE_COOKIE_NAME = 'puter_oidc_nonce';
34+
const OIDC_NONCE_EXPIRY_SEC = 600;
35+
2936
const OIDC_ERROR_REDIRECT_MAP: Record<string, Record<string, string>> = {
3037
login: { account_not_found: 'signup', other: 'login' },
3138
signup: { account_already_exists: 'login', other: 'signup' },
@@ -73,6 +80,14 @@ function appendQueryParam(url: string, key: string, value: string): string {
7380
return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
7481
}
7582

83+
/** Length-safe constant-time string compare (never throws on mismatch). */
84+
function constantTimeEqual(a: string, b: string): boolean {
85+
const ba = Buffer.from(a);
86+
const bb = Buffer.from(b);
87+
if (ba.length !== bb.length) return false;
88+
return crypto.timingSafeEqual(ba, bb);
89+
}
90+
7691
/**
7792
* True iff `target` parses as a URL whose origin equals `origin`. Used to
7893
* clamp OIDC redirect targets — `startsWith` would accept
@@ -211,6 +226,17 @@ export class OIDCController extends PuterController {
211226
statePayload.flow = 'revalidate';
212227
}
213228

229+
// Bind this flow to the initiating browser: a single-use
230+
// nonce lives both in the signed `state` and in an HttpOnly
231+
// companion cookie. The callback requires them to match, so a
232+
// `state` captured from an attacker's own flow can't be
233+
// replayed in a victim's browser (login-CSRF / session
234+
// fixation).
235+
const browserNonce = crypto
236+
.randomBytes(32)
237+
.toString('base64url');
238+
statePayload.nonce = browserNonce;
239+
214240
const state = this.services.oidc.signState(statePayload);
215241
const url = await this.services.oidc.getAuthorizationUrl(
216242
provider,
@@ -224,6 +250,15 @@ export class OIDCController extends PuterController {
224250
{ legacyCode: 'internal_error' },
225251
);
226252

253+
res.cookie(OIDC_NONCE_COOKIE_NAME, browserNonce, {
254+
// Same flags as the session cookie: SameSite=None;Secure
255+
// on HTTPS so the cookie survives Apple's cross-site
256+
// form_post callback; Lax on plain-HTTP self-host.
257+
...sessionCookieFlags(this.config),
258+
httpOnly: true,
259+
maxAge: OIDC_NONCE_EXPIRY_SEC * 1000,
260+
path: '/',
261+
});
227262
res.redirect(302, url);
228263
},
229264
);
@@ -237,7 +272,7 @@ export class OIDCController extends PuterController {
237272

238273
const loginCb = async (req: Request, res: Response) => {
239274
const origin = this.config.origin ?? '';
240-
const result = await this.#processCallback(req, 'login');
275+
const result = await this.#processCallback(req, res, 'login');
241276
if ('error' in result) {
242277
console.warn(`OIDC login callback error: ${result.error}`);
243278
return res.redirect(
@@ -300,7 +335,7 @@ export class OIDCController extends PuterController {
300335

301336
const signupCb = async (req: Request, res: Response) => {
302337
const origin = this.config.origin ?? '';
303-
const result = await this.#processCallback(req, 'signup');
338+
const result = await this.#processCallback(req, res, 'signup');
304339
if ('error' in result) {
305340
return res.redirect(
306341
302,
@@ -365,7 +400,7 @@ export class OIDCController extends PuterController {
365400
req: Request,
366401
res: Response,
367402
): Promise<void> => {
368-
const result = await this.#processCallback(req, 'revalidate');
403+
const result = await this.#processCallback(req, res, 'revalidate');
369404
if ('error' in result) {
370405
res.status(400).send(result.error);
371406
return;
@@ -517,6 +552,7 @@ if (window.opener) {
517552

518553
async #processCallback(
519554
req: Request,
555+
res: Response,
520556
flow: string,
521557
): Promise<
522558
| { error: string }
@@ -540,6 +576,28 @@ if (window.opener) {
540576
if (!stateDecoded || !stateDecoded.provider)
541577
return { error: 'Invalid or expired state.' };
542578

579+
// Enforce the browser binding set at /start. Every state minted by
580+
// the current /start carries a nonce, so this covers all live flows.
581+
// States signed before this shipped have no nonce and pass through
582+
// until they expire (STATE_EXPIRY, 10 min) so in-flight logins don't
583+
// break on deploy — a caller can't forge a nonce-less state because
584+
// /start always adds one and the state is server-signed.
585+
const expectedNonce =
586+
typeof stateDecoded.nonce === 'string' ? stateDecoded.nonce : '';
587+
if (expectedNonce) {
588+
const cookieNonce = req.cookies?.[OIDC_NONCE_COOKIE_NAME];
589+
// Single-use: drop the cookie regardless of the outcome.
590+
res.clearCookie(OIDC_NONCE_COOKIE_NAME, { path: '/' });
591+
if (
592+
typeof cookieNonce !== 'string' ||
593+
!constantTimeEqual(cookieNonce, expectedNonce)
594+
) {
595+
return {
596+
error: 'This sign-in could not be verified for your browser. Please start again.',
597+
};
598+
}
599+
}
600+
543601
const provider = String(stateDecoded.provider);
544602
const callbackUrl = this.services.oidc.getCallbackUrl(flow);
545603
if (!callbackUrl) return { error: 'Invalid flow.' };

0 commit comments

Comments
 (0)