mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-02 09:58:16 +00:00
fix(auth): bind the OIDC popup-return proof to its purpose, browser, and popup
Tokens under the `oidc-state` scope share one signing key, so the payload is what says which job a token belongs to. The popup-return proof now carries a `purpose` claim, and each verifier accepts exactly one kind of token. The return leg also sets a single-use companion cookie, mirroring the nonce `/start` already uses, and stamps the action its redirect lands on. Redeeming a proof requires the matching cookie — consumed on success only, so a rejected call can't invalidate an in-flight return — and the popup honors only a proof minted for its own action. The cookie is host-only, so `/auth/oidc/verify-popup-return` is also served on the GUI origin and the popup redeems it same-origin. The return leg always lands the popup on `config.origin`, where both the cookie and the route live.
This commit is contained in:
@@ -952,7 +952,6 @@ describe('OIDCController login callback', () => {
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toContain('embedded_in_popup=true');
|
||||
expect(captured.redirectUrl).toContain('oidc_login=true');
|
||||
|
||||
@@ -966,6 +965,15 @@ describe('OIDCController login callback', () => {
|
||||
const user = await server.stores.user.getByEmail(email);
|
||||
expect(user?.uuid).toBeTruthy();
|
||||
expect(proof?.user_uuid).toBe(user!.uuid);
|
||||
|
||||
// Session cookie plus the proof's binding companion.
|
||||
expect(captured.cookies).toHaveLength(2);
|
||||
const binding = captured.cookies.find(
|
||||
(c) => c.name === 'puter_oidc_popup_return',
|
||||
);
|
||||
expect(binding?.value).toBeTruthy();
|
||||
expect(proof?.nonce).toBe(binding!.value);
|
||||
expect(proof?.action).toBe('sign-in');
|
||||
});
|
||||
|
||||
it('uses popup-style error URL (msg_id + opener_origin) when the popup-state user is suspended', async () => {
|
||||
@@ -1017,6 +1025,14 @@ describe('OIDCController login callback', () => {
|
||||
expect(captured.redirectUrl).toContain(
|
||||
`opener_origin=${encodeURIComponent('http://opener.test')}`,
|
||||
);
|
||||
|
||||
// The error leg must stamp the action its own redirect names.
|
||||
const url = new URL(captured.redirectUrl!);
|
||||
const proof = oidc().verifyPopupReturn(
|
||||
url.searchParams.get('opener_state')!,
|
||||
);
|
||||
expect(proof?.oidc_login).toBe(false);
|
||||
expect(proof?.action).toBe(url.searchParams.get('action'));
|
||||
});
|
||||
|
||||
it('links an OIDC identity to an existing CONFIRMED password account via email match', async () => {
|
||||
@@ -1835,19 +1851,34 @@ describe('OIDCController GET /auth/revalidate-done', () => {
|
||||
* attested rather than read.
|
||||
*/
|
||||
describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
|
||||
const redeem = async (opener_state: unknown) => {
|
||||
const NONCE = 'binding-nonce';
|
||||
|
||||
const mint = (payload: Record<string, unknown>) =>
|
||||
server.services.oidc.signPopupReturn({
|
||||
nonce: NONCE,
|
||||
action: 'sign-in',
|
||||
...payload,
|
||||
});
|
||||
|
||||
const redeemWith = (openerState: unknown, nonce: string | null = NONCE) =>
|
||||
makeReq({
|
||||
body: { opener_state: openerState },
|
||||
cookies: nonce === null ? {} : { puter_oidc_popup_return: nonce },
|
||||
});
|
||||
|
||||
const redeem = async (openerState: unknown) => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/auth/oidc/verify-popup-return',
|
||||
makeReq({ body: { opener_state } }),
|
||||
redeemWith(openerState),
|
||||
res,
|
||||
);
|
||||
return captured;
|
||||
};
|
||||
|
||||
it('hands back what a genuine proof attests', async () => {
|
||||
const proof = server.services.oidc.signPopupReturn({
|
||||
const proof = mint({
|
||||
opener_origin: 'https://opener.test',
|
||||
msg_id: '77',
|
||||
oidc_login: true,
|
||||
@@ -1856,6 +1887,7 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
|
||||
expect(captured.body).toEqual({
|
||||
opener_origin: 'https://opener.test',
|
||||
msg_id: '77',
|
||||
action: 'sign-in',
|
||||
oidc_login: true,
|
||||
user_uuid: null,
|
||||
});
|
||||
@@ -1864,7 +1896,7 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
|
||||
it('hands back the account a proof is bound to', async () => {
|
||||
// The popup compares this against its current user to reject a proof
|
||||
// replayed from another account's login.
|
||||
const proof = server.services.oidc.signPopupReturn({
|
||||
const proof = mint({
|
||||
opener_origin: 'https://opener.test',
|
||||
msg_id: '77',
|
||||
oidc_login: true,
|
||||
@@ -1875,9 +1907,13 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
|
||||
});
|
||||
|
||||
it('rejects a proof signed with someone else’s key', async () => {
|
||||
// The whole point: only the server can mint one of these.
|
||||
const forged = jwt.sign(
|
||||
{ opener_origin: 'https://console.puter.com', oidc_login: true },
|
||||
{
|
||||
opener_origin: 'https://console.puter.com',
|
||||
oidc_login: true,
|
||||
purpose: 'popup-return',
|
||||
nonce: NONCE,
|
||||
},
|
||||
'not-the-server-secret',
|
||||
{ keyid: 'v2' },
|
||||
);
|
||||
@@ -1885,7 +1921,7 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
|
||||
callRoute(
|
||||
'post',
|
||||
'/auth/oidc/verify-popup-return',
|
||||
makeReq({ body: { opener_state: forged } }),
|
||||
redeemWith(forged),
|
||||
makeRes().res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
@@ -1896,14 +1932,19 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
|
||||
// redeemed on the very next request, so a stale one is never genuine.
|
||||
const stale = server.services.token.sign(
|
||||
'oidc-state',
|
||||
{ opener_origin: 'https://opener.test', oidc_login: true },
|
||||
{
|
||||
opener_origin: 'https://opener.test',
|
||||
oidc_login: true,
|
||||
purpose: 'popup-return',
|
||||
nonce: NONCE,
|
||||
},
|
||||
{ expiresIn: -600 },
|
||||
);
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/auth/oidc/verify-popup-return',
|
||||
makeReq({ body: { opener_state: stale } }),
|
||||
redeemWith(stale),
|
||||
makeRes().res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
@@ -1915,7 +1956,7 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
|
||||
callRoute(
|
||||
'post',
|
||||
'/auth/oidc/verify-popup-return',
|
||||
makeReq({ body: { opener_state: bad } }),
|
||||
redeemWith(bad),
|
||||
makeRes().res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
@@ -1925,13 +1966,82 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
|
||||
it('reports oidc_login false when the proof does not claim a login', async () => {
|
||||
// The error leg mints one of these: a real return, but nothing was
|
||||
// signed in on it, so it must not suppress the account picker.
|
||||
const proof = server.services.oidc.signPopupReturn({
|
||||
const proof = mint({
|
||||
opener_origin: 'https://opener.test',
|
||||
msg_id: '77',
|
||||
oidc_login: false,
|
||||
});
|
||||
expect((await redeem(proof)).body).toMatchObject({ oidc_login: false });
|
||||
});
|
||||
|
||||
it('rejects an authorization state presented as a proof', async () => {
|
||||
const state = server.services.oidc.signState({
|
||||
provider: 'google',
|
||||
redirect_uri: 'https://puter.test/action/sign-in',
|
||||
embedded_in_popup: true,
|
||||
msg_id: '77',
|
||||
opener_origin: 'https://opener.test',
|
||||
nonce: NONCE,
|
||||
});
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/auth/oidc/verify-popup-return',
|
||||
redeemWith(state),
|
||||
makeRes().res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects a proof redeemed without its binding cookie', async () => {
|
||||
const proof = mint({
|
||||
opener_origin: 'https://opener.test',
|
||||
msg_id: '77',
|
||||
oidc_login: true,
|
||||
});
|
||||
for (const cookie of [null, 'a-different-nonce']) {
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/auth/oidc/verify-popup-return',
|
||||
redeemWith(proof, cookie),
|
||||
makeRes().res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the binding cookie so a proof redeems once', async () => {
|
||||
const proof = mint({
|
||||
opener_origin: 'https://opener.test',
|
||||
msg_id: '77',
|
||||
oidc_login: true,
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/auth/oidc/verify-popup-return',
|
||||
redeemWith(proof),
|
||||
res,
|
||||
);
|
||||
expect(captured.clearedCookies).toContainEqual(
|
||||
expect.objectContaining({ name: 'puter_oidc_popup_return' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the binding cookie in place when redemption fails', async () => {
|
||||
// A junk POST must not consume a cookie an in-flight return needs.
|
||||
const { res, captured } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/auth/oidc/verify-popup-return',
|
||||
redeemWith('not.a.proof'),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(captured.clearedCookies).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── rate-limit scopes ───────────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +38,14 @@ const REVALIDATION_EXPIRY_SEC = 300;
|
||||
const OIDC_NONCE_COOKIE_NAME = 'puter_oidc_nonce';
|
||||
const OIDC_NONCE_EXPIRY_SEC = 600;
|
||||
|
||||
// Single-use, and binds a popup-return proof to the browser that earned it.
|
||||
// Expiry mirrors POPUP_RETURN_EXPIRY_SEC in OIDCService.
|
||||
const OIDC_POPUP_RETURN_COOKIE_NAME = 'puter_oidc_popup_return';
|
||||
const OIDC_POPUP_RETURN_EXPIRY_SEC = 300;
|
||||
|
||||
// The popup branch below hard-codes this return target.
|
||||
const POPUP_RETURN_ACTION = 'sign-in';
|
||||
|
||||
const OIDC_ERROR_REDIRECT_MAP: Record<string, Record<string, string>> = {
|
||||
login: { account_not_found: 'signup', other: 'login' },
|
||||
signup: { account_already_exists: 'login', other: 'signup' },
|
||||
@@ -154,11 +162,8 @@ function buildErrorRedirectUrl(
|
||||
message: string,
|
||||
stateDecoded?: Record<string, unknown>,
|
||||
requestCode?: string,
|
||||
// Signs the popup-return proof. Passed in because this is a module-level
|
||||
// helper with no access to services; omitted by callers that have no
|
||||
// state to attest (the proof is simply absent then, and the popup falls
|
||||
// back to its browser-attested sources).
|
||||
signPopupReturn?: (payload: Record<string, unknown>) => string,
|
||||
// Omitted by callers with no state to attest.
|
||||
mintPopupReturn?: (payload: Record<string, unknown>) => string,
|
||||
): string {
|
||||
const targetFlow =
|
||||
OIDC_ERROR_REDIRECT_MAP[sourceFlow]?.[errorCondition] ?? sourceFlow;
|
||||
@@ -202,13 +207,15 @@ function buildErrorRedirectUrl(
|
||||
// Same reasoning as the success leg: the popup cannot tell a verified
|
||||
// `opener_origin` from a typed one, so attest it. The error leg is a
|
||||
// real return from the provider too — the flow failed, not the hop.
|
||||
if (signPopupReturn) {
|
||||
if (mintPopupReturn) {
|
||||
params.set(
|
||||
'opener_state',
|
||||
signPopupReturn({
|
||||
mintPopupReturn({
|
||||
opener_origin: stateDecoded?.opener_origin ?? null,
|
||||
msg_id: stateDecoded?.msg_id ?? null,
|
||||
oidc_login: false,
|
||||
// The popup checks this against the action it lands on.
|
||||
action: targetFlow,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -267,18 +274,21 @@ export class OIDCController extends PuterController {
|
||||
// A sign-in popup returning from a provider is told the opener's
|
||||
// origin and that a login completed. It cannot check either: the
|
||||
// values arrive as query parameters, and a URL built from a verified
|
||||
// `state` looks exactly like one an attacker typed. The opener's
|
||||
// `state` looks exactly like one anybody can type. The opener's
|
||||
// origin decides which app a token gets minted for, so the popup
|
||||
// redeems the signed proof here instead of believing the raw
|
||||
// parameters.
|
||||
//
|
||||
// Served on the GUI origin too: the binding cookie is host-only.
|
||||
//
|
||||
// Unauthenticated on purpose — it reveals nothing the caller did not
|
||||
// already hand over, and a forged or expired proof yields nothing.
|
||||
// already hand over, and a proof that isn't this browser's yields
|
||||
// nothing.
|
||||
|
||||
router.post(
|
||||
'/auth/oidc/verify-popup-return',
|
||||
{
|
||||
subdomain: 'api',
|
||||
subdomain: ['api', ''],
|
||||
rateLimit: {
|
||||
scope: 'oidc-verify-popup-return',
|
||||
limit: 60,
|
||||
@@ -293,14 +303,25 @@ export class OIDCController extends PuterController {
|
||||
});
|
||||
}
|
||||
const decoded = this.services.oidc.verifyPopupReturn(proof);
|
||||
if (!decoded) {
|
||||
const cookieNonce =
|
||||
req.cookies?.[OIDC_POPUP_RETURN_COOKIE_NAME];
|
||||
|
||||
if (
|
||||
!decoded ||
|
||||
typeof decoded.nonce !== 'string' ||
|
||||
typeof cookieNonce !== 'string' ||
|
||||
!constantTimeEqual(cookieNonce, decoded.nonce)
|
||||
) {
|
||||
throw new HttpError(400, 'Invalid `opener_state`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
// Single-use; a rejected call must not burn the cookie.
|
||||
res.clearCookie(OIDC_POPUP_RETURN_COOKIE_NAME, { path: '/' });
|
||||
res.json({
|
||||
opener_origin: decoded.opener_origin ?? null,
|
||||
msg_id: decoded.msg_id ?? null,
|
||||
action: decoded.action ?? null,
|
||||
oidc_login: decoded.oidc_login === true,
|
||||
user_uuid: decoded.user_uuid ?? null,
|
||||
});
|
||||
@@ -404,7 +425,7 @@ export class OIDCController extends PuterController {
|
||||
: null;
|
||||
|
||||
if (embeddedInPopup && msgId) {
|
||||
appRedirectUri = `${origin}/action/sign-in?embedded_in_popup=true&msg_id=${encodeURIComponent(msgId)}`;
|
||||
appRedirectUri = `${origin}/action/${POPUP_RETURN_ACTION}?embedded_in_popup=true&msg_id=${encodeURIComponent(msgId)}`;
|
||||
if (openerOrigin) {
|
||||
appRedirectUri += `&opener_origin=${encodeURIComponent(openerOrigin)}`;
|
||||
}
|
||||
@@ -522,7 +543,7 @@ export class OIDCController extends PuterController {
|
||||
resolutionErrorCode(resolved.code),
|
||||
stateDecoded,
|
||||
resolved.requestCode,
|
||||
(p) => this.services.oidc.signPopupReturn(p),
|
||||
(p) => this.#mintPopupReturn(res, p),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -541,7 +562,7 @@ export class OIDCController extends PuterController {
|
||||
'account_suspended',
|
||||
stateDecoded,
|
||||
undefined,
|
||||
(p) => this.services.oidc.signPopupReturn(p),
|
||||
(p) => this.#mintPopupReturn(res, p),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -589,7 +610,7 @@ export class OIDCController extends PuterController {
|
||||
resolutionErrorCode(resolved.code),
|
||||
stateDecoded,
|
||||
resolved.requestCode,
|
||||
(p) => this.services.oidc.signPopupReturn(p),
|
||||
(p) => this.#mintPopupReturn(res, p),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -605,7 +626,7 @@ export class OIDCController extends PuterController {
|
||||
'account_suspended',
|
||||
stateDecoded,
|
||||
undefined,
|
||||
(p) => this.services.oidc.signPopupReturn(p),
|
||||
(p) => this.#mintPopupReturn(res, p),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -722,6 +743,23 @@ if (window.opener) {
|
||||
|
||||
// -- Shared helpers ----------------------------------------------
|
||||
|
||||
/** Sign a popup-return proof, bound to this browser and this popup. */
|
||||
#mintPopupReturn(res: Response, payload: Record<string, unknown>): string {
|
||||
const nonce = crypto.randomBytes(32).toString('base64url');
|
||||
res.cookie(OIDC_POPUP_RETURN_COOKIE_NAME, nonce, {
|
||||
// Redeemed same-origin.
|
||||
...sessionCookieFlags(this.config, { crossSite: false }),
|
||||
httpOnly: true,
|
||||
maxAge: OIDC_POPUP_RETURN_EXPIRY_SEC * 1000,
|
||||
path: '/',
|
||||
});
|
||||
return this.services.oidc.signPopupReturn({
|
||||
action: POPUP_RETURN_ACTION,
|
||||
...payload,
|
||||
nonce,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an OIDC callback to a Puter user. In order:
|
||||
*
|
||||
@@ -933,7 +971,7 @@ if (window.opener) {
|
||||
target = appendQueryParam(
|
||||
target,
|
||||
'opener_state',
|
||||
this.services.oidc.signPopupReturn({
|
||||
this.#mintPopupReturn(res, {
|
||||
opener_origin: stateDecoded.opener_origin ?? null,
|
||||
msg_id: stateDecoded.msg_id ?? null,
|
||||
oidc_login: true,
|
||||
|
||||
@@ -636,7 +636,7 @@ describe('OIDCService — state tokens', () => {
|
||||
expect(oidc().verifyState(`${token}x`)).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips a popup-return proof through the same verifier', () => {
|
||||
it('round-trips a popup-return proof', () => {
|
||||
const token = oidc().signPopupReturn({
|
||||
opener_origin: 'https://app.test',
|
||||
logged_in: true,
|
||||
@@ -650,10 +650,37 @@ describe('OIDCService — state tokens', () => {
|
||||
|
||||
it('signs a revalidation token naming the user and purpose', () => {
|
||||
const token = oidc().signRevalidation('user-uuid-1');
|
||||
expect(oidc().verifyState(token)).toMatchObject({
|
||||
user_uuid: 'user-uuid-1',
|
||||
purpose: 'revalidate',
|
||||
});
|
||||
expect(server.services.token.verify('oidc-state', token)).toMatchObject(
|
||||
{
|
||||
user_uuid: 'user-uuid-1',
|
||||
purpose: 'revalidate',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('does not accept a state or a revalidation token as a popup-return proof', () => {
|
||||
expect(
|
||||
oidc().verifyPopupReturn(
|
||||
oidc().signState({
|
||||
provider: 'google',
|
||||
opener_origin: 'https://app.test',
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
oidc().verifyPopupReturn(oidc().signRevalidation('user-uuid-1')),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not accept a popup-return proof or a revalidation token as a state', () => {
|
||||
expect(
|
||||
oidc().verifyState(
|
||||
oidc().signPopupReturn({ opener_origin: 'https://app.test' }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
oidc().verifyState(oidc().signRevalidation('user-uuid-1')),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ const STATE_EXPIRY_SEC = 600; // 10 minutes
|
||||
const POPUP_RETURN_EXPIRY_SEC = 300; // 5 minutes
|
||||
const VALID_OIDC_FLOWS = ['login', 'signup', 'revalidate'] as const;
|
||||
const REVALIDATION_EXPIRY_SEC = 300; // 5 minutes
|
||||
// Every `oidc-state` token shares one signing key; the payload says which job.
|
||||
const POPUP_RETURN_PURPOSE = 'popup-return';
|
||||
|
||||
interface ProviderConfig {
|
||||
client_id: string;
|
||||
@@ -256,17 +258,28 @@ export class OIDCService extends PuterService {
|
||||
* redirects the popup home.
|
||||
*/
|
||||
signPopupReturn(payload: Record<string, unknown>): string {
|
||||
return this.services.token.sign('oidc-state', payload, {
|
||||
expiresIn: POPUP_RETURN_EXPIRY_SEC,
|
||||
});
|
||||
return this.services.token.sign(
|
||||
'oidc-state',
|
||||
{ ...payload, purpose: POPUP_RETURN_PURPOSE },
|
||||
{ expiresIn: POPUP_RETURN_EXPIRY_SEC },
|
||||
);
|
||||
}
|
||||
|
||||
/** Verify a popup-return proof. Returns null on a bad or expired one. */
|
||||
verifyPopupReturn(token: string): Record<string, unknown> | null {
|
||||
return this.verifyState(token);
|
||||
const decoded = this.#verifySigned(token);
|
||||
if (decoded?.purpose !== POPUP_RETURN_PURPOSE) return null;
|
||||
return decoded;
|
||||
}
|
||||
|
||||
verifyState(token: string): Record<string, unknown> | null {
|
||||
const decoded = this.#verifySigned(token);
|
||||
// An authorization state carries no purpose.
|
||||
if (!decoded || decoded.purpose !== undefined) return null;
|
||||
return decoded;
|
||||
}
|
||||
|
||||
#verifySigned(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return this.services.token.verify<Record<string, unknown>>(
|
||||
'oidc-state',
|
||||
|
||||
@@ -1410,6 +1410,7 @@ window.initgui = async function (options) {
|
||||
window.oidcPopupReturn = await verifyOidcPopupReturn(
|
||||
window.url_query_params.get('opener_state'),
|
||||
window.url_query_params.get('msg_id'),
|
||||
action,
|
||||
);
|
||||
window.openerOrigin =
|
||||
window.oidcPopupReturn?.opener_origin || document.referrer;
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
*
|
||||
* The return leg now carries `opener_state`, the same pair re-signed. Only the
|
||||
* server can produce or check that signature, so the popup redeems it here.
|
||||
* A missing, forged, or expired proof yields nothing and the popup falls back
|
||||
* to its browser-attested sources.
|
||||
* A proof that is missing, expired, or not this browser's yields nothing and
|
||||
* the popup falls back to its browser-attested sources.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -45,24 +45,24 @@
|
||||
* @param {string|null|undefined} proof - The `opener_state` query parameter.
|
||||
* @param {string|null|undefined} msgId - The popup's current `msg_id`. A proof
|
||||
* minted for a different one belongs to another flow.
|
||||
* @param {string|null|undefined} action - The popup's current action. A proof
|
||||
* is minted on the sign-in return leg and is good only there.
|
||||
* @returns {Promise<{opener_origin: string|null, oidc_login: boolean, user_uuid: string|null}|null>}
|
||||
* `null` when there is no usable proof. `user_uuid` is the account that
|
||||
* completed OIDC; the caller must confirm it matches the current user before
|
||||
* treating `oidc_login` as consent to skip the account picker.
|
||||
*/
|
||||
export const verifyOidcPopupReturn = async (proof, msgId) => {
|
||||
export const verifyOidcPopupReturn = async (proof, msgId, action) => {
|
||||
if (!proof) return null;
|
||||
|
||||
let attested;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${window.api_origin}/auth/oidc/verify-popup-return`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ opener_state: proof }),
|
||||
},
|
||||
);
|
||||
const resp = await fetch('/auth/oidc/verify-popup-return', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ opener_state: proof }),
|
||||
});
|
||||
// A rejected proof is the expected answer to a crafted link, not an
|
||||
// anomaly — the popup carries on with its attested sources.
|
||||
if (!resp.ok) return null;
|
||||
@@ -85,6 +85,7 @@ export const verifyOidcPopupReturn = async (proof, msgId) => {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (attested.action !== action) return null;
|
||||
|
||||
return {
|
||||
opener_origin: attested.opener_origin,
|
||||
|
||||
@@ -17,21 +17,21 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { verifyOidcPopupReturn } from './popupOidcReturn.js';
|
||||
|
||||
const OPENER = 'https://opener.test';
|
||||
|
||||
/** Stand in for the verify endpoint. */
|
||||
const serverSays = (body, { ok = true } = {}) =>
|
||||
vi.fn(async () => ({ ok, json: async () => body }));
|
||||
vi.fn(async () => ({
|
||||
ok,
|
||||
json: async () => ({ action: 'sign-in', ...body }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.window = { api_origin: 'https://api.test' };
|
||||
});
|
||||
const redeem = (proof, msgId) => verifyOidcPopupReturn(proof, msgId, 'sign-in');
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.window;
|
||||
delete globalThis.fetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -43,9 +43,11 @@ describe('redeeming a proof', () => {
|
||||
msg_id: '7',
|
||||
oidc_login: true,
|
||||
});
|
||||
await expect(verifyOidcPopupReturn('signed.blob.here', '7')).resolves.toEqual(
|
||||
{ opener_origin: OPENER, oidc_login: true, user_uuid: null },
|
||||
);
|
||||
await expect(redeem('signed.blob.here', '7')).resolves.toEqual({
|
||||
opener_origin: OPENER,
|
||||
oidc_login: true,
|
||||
user_uuid: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through the account the proof is bound to', async () => {
|
||||
@@ -55,9 +57,7 @@ describe('redeeming a proof', () => {
|
||||
oidc_login: true,
|
||||
user_uuid: 'user-A',
|
||||
});
|
||||
await expect(
|
||||
verifyOidcPopupReturn('signed.blob.here', '7'),
|
||||
).resolves.toEqual({
|
||||
await expect(redeem('signed.blob.here', '7')).resolves.toEqual({
|
||||
opener_origin: OPENER,
|
||||
oidc_login: true,
|
||||
user_uuid: 'user-A',
|
||||
@@ -65,11 +65,15 @@ describe('redeeming a proof', () => {
|
||||
});
|
||||
|
||||
it('sends the proof to the verify endpoint', async () => {
|
||||
const fetchMock = serverSays({ opener_origin: OPENER, oidc_login: true });
|
||||
const fetchMock = serverSays({
|
||||
opener_origin: OPENER,
|
||||
oidc_login: true,
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
await verifyOidcPopupReturn('signed.blob.here', null);
|
||||
await redeem('signed.blob.here', null);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.test/auth/oidc/verify-popup-return');
|
||||
expect(url).toBe('/auth/oidc/verify-popup-return');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
opener_state: 'signed.blob.here',
|
||||
});
|
||||
@@ -82,9 +86,7 @@ describe('redeeming a proof', () => {
|
||||
opener_origin: OPENER,
|
||||
oidc_login: false,
|
||||
});
|
||||
await expect(
|
||||
verifyOidcPopupReturn('signed.blob.here', null),
|
||||
).resolves.toEqual({
|
||||
await expect(redeem('signed.blob.here', null)).resolves.toEqual({
|
||||
opener_origin: OPENER,
|
||||
oidc_login: false,
|
||||
user_uuid: null,
|
||||
@@ -97,7 +99,7 @@ describe('refusing what the server did not attest', () => {
|
||||
// The attack shape: a crafted link naming an opener, with no OIDC round
|
||||
// trip behind it. Nothing is even asked of the server.
|
||||
globalThis.fetch = serverSays({ opener_origin: OPENER });
|
||||
await expect(verifyOidcPopupReturn(null, '7')).resolves.toBeNull();
|
||||
await expect(redeem(null, '7')).resolves.toBeNull();
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -107,26 +109,38 @@ describe('refusing what the server did not attest', () => {
|
||||
{ message: 'Invalid `opener_state`' },
|
||||
{ ok: false },
|
||||
);
|
||||
await expect(
|
||||
verifyOidcPopupReturn('forged.blob', '7'),
|
||||
).resolves.toBeNull();
|
||||
await expect(redeem('forged.blob', '7')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('yields nothing when the attested payload carries no origin', async () => {
|
||||
globalThis.fetch = serverSays({ opener_origin: null, oidc_login: true });
|
||||
await expect(
|
||||
verifyOidcPopupReturn('signed.blob.here', '7'),
|
||||
).resolves.toBeNull();
|
||||
globalThis.fetch = serverSays({
|
||||
opener_origin: null,
|
||||
oidc_login: true,
|
||||
});
|
||||
await expect(redeem('signed.blob.here', '7')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a proof minted for a different popup flow', async () => {
|
||||
globalThis.fetch = serverSays({
|
||||
opener_origin: OPENER,
|
||||
msg_id: '7',
|
||||
oidc_login: true,
|
||||
});
|
||||
await expect(redeem('signed.blob.here', '8')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a proof minted for a different popup action', async () => {
|
||||
globalThis.fetch = serverSays({
|
||||
opener_origin: OPENER,
|
||||
msg_id: '7',
|
||||
oidc_login: true,
|
||||
});
|
||||
await expect(
|
||||
verifyOidcPopupReturn('signed.blob.here', '8'),
|
||||
verifyOidcPopupReturn(
|
||||
'signed.blob.here',
|
||||
'7',
|
||||
'request-permission',
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
@@ -137,9 +151,7 @@ describe('refusing what the server did not attest', () => {
|
||||
msg_id: 7,
|
||||
oidc_login: true,
|
||||
});
|
||||
await expect(
|
||||
verifyOidcPopupReturn('signed.blob.here', '7'),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(redeem('signed.blob.here', '7')).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('degrades to nothing when the endpoint is unreachable', async () => {
|
||||
@@ -149,8 +161,6 @@ describe('refusing what the server did not attest', () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await expect(
|
||||
verifyOidcPopupReturn('signed.blob.here', '7'),
|
||||
).resolves.toBeNull();
|
||||
await expect(redeem('signed.blob.here', '7')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user