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:
Juan Castro
2026-09-23 17:21:35 -04:00
parent bc0f4dc279
commit d73933b8a5
7 changed files with 282 additions and 82 deletions
@@ -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 ───────────────────────────────────────────────
+55 -17
View File
@@ -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,
+32 -5
View File
@@ -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();
});
});
+17 -4
View File
@@ -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',
+1
View File
@@ -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;
+12 -11
View File
@@ -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,
+43 -33
View File
@@ -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();
});
});