mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-22 05:06:10 +00:00
change cors auth path (#3464)
* change cors auth path * undo oidc ref changes * scary OIDC state changes * fix: bad cors signin * puterjs changes --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
This commit is contained in:
co-authored by
Daniel Salazar
parent
f1bc065730
commit
08d1708378
@@ -6921,3 +6921,162 @@ describe('AuthController auth_id preservation on reauth', () => {
|
||||
).rejects.toMatchObject({ statusCode: 429 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Popup sign-in relay (/login/wait + /login/set) ──────────────────
|
||||
|
||||
/**
|
||||
* The relay stands in for the popup's `postMessage` hand-off on
|
||||
* cross-origin-isolated openers, where COOP has severed `window.opener`.
|
||||
* postMessage is audience-bound for free (it posts with `targetOrigin`);
|
||||
* these tests pin the equivalent binding on the server-side path, since the
|
||||
* session id is a link-borne value and not a secret.
|
||||
*/
|
||||
describe('AuthController.loginWait audience binding', () => {
|
||||
const OPENER = 'https://opener.test';
|
||||
|
||||
/** Mint a real app-under-user token for `origin`, as the popup would. */
|
||||
const mintAppToken = async (actor: Actor, origin: string) => {
|
||||
const res = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGetUserAppToken(makeReq({ origin }, { actor }), res),
|
||||
);
|
||||
return (res.body as { token: string }).token;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a wait, then relay `token` into it. The handler resolves the
|
||||
* origin (async, DB-backed) before subscribing, so the emit is retried
|
||||
* until the wait settles rather than fired after a fixed sleep.
|
||||
*/
|
||||
const waitWithRelay = async (
|
||||
session: string,
|
||||
headers: Record<string, unknown>,
|
||||
token: string | null,
|
||||
) => {
|
||||
const res = makeRes();
|
||||
const waiting = controller.loginWait(makeReq({ session }, { headers }), res);
|
||||
const settled = waiting.then(
|
||||
() => 'ok' as const,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
|
||||
if (token !== null) {
|
||||
let done = false;
|
||||
settled.then(() => {
|
||||
done = true;
|
||||
});
|
||||
for (let i = 0; i < 100 && !done; i++) {
|
||||
await controller.loginSet(
|
||||
makeReq({ session, auth_token: token }),
|
||||
makeRes(),
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
}
|
||||
return { res, outcome: await settled };
|
||||
};
|
||||
|
||||
it('returns the token when the caller Origin matches the app it was minted for', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
const token = await mintAppToken(actor, OPENER);
|
||||
|
||||
const { res, outcome } = await waitWithRelay(
|
||||
uuidv4(),
|
||||
{ origin: OPENER },
|
||||
token,
|
||||
);
|
||||
expect(outcome).toBe('ok');
|
||||
expect((res.body as { auth_token: string }).auth_token).toBe(token);
|
||||
});
|
||||
|
||||
it('withholds a token minted for a different app from a mismatched Origin', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
// The attack: the popup was talked into minting for OPENER, but the
|
||||
// party holding the session id is somewhere else entirely.
|
||||
const token = await mintAppToken(actor, OPENER);
|
||||
|
||||
const { res, outcome } = await waitWithRelay(
|
||||
uuidv4(),
|
||||
{ origin: 'https://evil.test' },
|
||||
token,
|
||||
);
|
||||
// Same 408 the empty path returns — a mismatched caller must not be
|
||||
// able to tell "nothing arrived" from "something arrived for someone
|
||||
// else".
|
||||
expect(outcome).toMatchObject({ statusCode: 408 });
|
||||
expect(res.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a caller that sends no Origin header', async () => {
|
||||
// curl and any server-side fetch land here. Without this the session
|
||||
// id alone — which travels in a link — would be enough to collect.
|
||||
await expect(
|
||||
controller.loginWait(makeReq({ session: uuidv4() }, {}), makeRes()),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('rejects the opaque "null" origin', async () => {
|
||||
// Sandboxed iframes and file:// documents both serialise to "null",
|
||||
// so honouring it would make two unrelated opaque origins equal.
|
||||
await expect(
|
||||
controller.loginWait(
|
||||
makeReq({ session: uuidv4() }, { headers: { origin: 'null' } }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('still rejects a malformed session id before looking at Origin', async () => {
|
||||
await expect(
|
||||
controller.loginWait(
|
||||
makeReq(
|
||||
{ session: 'not-a-uuid' },
|
||||
{ headers: { origin: OPENER } },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('withholds a token that is not an app-under-user token', async () => {
|
||||
// A session/GUI token relayed through here would sign the opener in
|
||||
// as the user outright, not as the app.
|
||||
const username = `relay_${uniq()}`;
|
||||
const loginRes = makeRes();
|
||||
await controller.handleSignup(
|
||||
makeReq({
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
loginRes,
|
||||
);
|
||||
const guiToken = (loginRes.body as { token: string }).token;
|
||||
|
||||
const { res, outcome } = await waitWithRelay(
|
||||
uuidv4(),
|
||||
{ origin: OPENER },
|
||||
guiToken,
|
||||
);
|
||||
expect(outcome).toMatchObject({ statusCode: 408 });
|
||||
expect(res.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('withholds a token with a valid shape but a forged signature', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
const real = await mintAppToken(actor, OPENER);
|
||||
const forged = jwt.sign(
|
||||
jwt.decode(real) as object,
|
||||
'not-the-server-secret',
|
||||
{ keyid: 'v2' },
|
||||
);
|
||||
|
||||
const { res, outcome } = await waitWithRelay(
|
||||
uuidv4(),
|
||||
{ origin: OPENER },
|
||||
forged,
|
||||
);
|
||||
expect(outcome).toMatchObject({ statusCode: 408 });
|
||||
expect(res.body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,6 +133,31 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
// Browser-only gate, same rule as `handleMigrateToken` below. The
|
||||
// session id is client-chosen and travels in a link, so it is not a
|
||||
// secret — the `Origin` header is what actually says who is asking,
|
||||
// and only a browser is prevented from lying about it. A caller with
|
||||
// no `Origin` (curl, a server-side fetch) could otherwise collect a
|
||||
// token minted for someone else's app just by knowing the id.
|
||||
//
|
||||
// `"null"` is rejected too: sandboxed iframes and `file://` documents
|
||||
// serialise their opaque origin that way, and two *unrelated* opaque
|
||||
// origins would compare equal to each other.
|
||||
const reqOrigin = req.headers.origin;
|
||||
if (!reqOrigin || reqOrigin === 'null') {
|
||||
throw new HttpError(403, 'Origin not allowed', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
|
||||
// The app identity this caller is allowed to collect a token for,
|
||||
// derived from the browser-attested header rather than anything in
|
||||
// the request body — so no client, honest or not, can influence the
|
||||
// comparison made after the token arrives.
|
||||
const expectedAppUid =
|
||||
await this.services.auth.appUidFromOrigin(reqOrigin);
|
||||
|
||||
const { resolve, promise } = Promise.withResolvers<void>();
|
||||
|
||||
let token: string | null = null;
|
||||
@@ -153,12 +178,56 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
// Audience check. The postMessage hand-off this relay stands in for
|
||||
// is origin-bound for free — it posts with `targetOrigin`, so a page
|
||||
// can only ever receive a token minted for *itself*. Delivering
|
||||
// server-side dropped that binding; this restores it. Without it a
|
||||
// popup talked into minting for app X (see `trustsOpenerOriginParam`
|
||||
// in the GUI) hands X's token to whoever holds the session id.
|
||||
if (!this.#tokenIsForApp(token, expectedAppUid)) {
|
||||
// Deliberately the same 408 the no-token path returns: a caller
|
||||
// learns only that nothing arrived for them, not that a token
|
||||
// for a different app went past.
|
||||
throw new HttpError(408, 'Request timeout.', {
|
||||
legacyCode: 'request_timeout',
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
auth_token: token,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a relayed token is an app-under-user token minted for
|
||||
* `expectedAppUid`. Verifies the signature — an unverified decode would let
|
||||
* a caller relay a token whose claims it wrote itself.
|
||||
*/
|
||||
#tokenIsForApp(token: string, expectedAppUid: string): boolean {
|
||||
try {
|
||||
const payload = this.services.token.verify<{
|
||||
type?: string;
|
||||
app_uid?: string;
|
||||
}>('auth', token);
|
||||
return (
|
||||
payload.type === 'app-under-user' &&
|
||||
!!payload.app_uid &&
|
||||
payload.app_uid === expectedAppUid
|
||||
);
|
||||
} catch {
|
||||
// Malformed, expired, or signed with a key we don't hold.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@Post('/login/set', {
|
||||
subdomain: ['api'],
|
||||
// Unauthenticated fan-out to every `/login/wait` listener on the
|
||||
// session id. A legitimate popup posts here exactly once per sign-in,
|
||||
// so a generous per-IP cap costs honest traffic nothing while denying
|
||||
// an attacker unbounded attempts to land a token on a guessed id.
|
||||
rateLimit: [
|
||||
{ scope: 'login-set', limit: 60, window: 15 * 60_000, key: 'ip' },
|
||||
],
|
||||
})
|
||||
async loginSet(req: Request, res: Response) {
|
||||
const { session, auth_token } = req.body;
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
@@ -1594,3 +1595,98 @@ describe('OIDCController GET /auth/revalidate-done', () => {
|
||||
expect(body).toContain(JSON.stringify(TEST_ORIGIN));
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /auth/oidc/verify-popup-return ─────────────────────────────
|
||||
|
||||
/**
|
||||
* The proof exists because the popup return leg states two things the popup
|
||||
* cannot check — the opener's origin and that a login completed — and a URL
|
||||
* built from a verified `state` is byte-identical to one anybody can type.
|
||||
* The opener's origin picks the app a token is minted for, so it has to be
|
||||
* attested rather than read.
|
||||
*/
|
||||
describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
|
||||
const redeem = async (opener_state: unknown) => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/auth/oidc/verify-popup-return',
|
||||
makeReq({ body: { opener_state } }),
|
||||
res,
|
||||
);
|
||||
return captured;
|
||||
};
|
||||
|
||||
it('hands back what a genuine proof attests', async () => {
|
||||
const proof = server.services.oidc.signPopupReturn({
|
||||
opener_origin: 'https://opener.test',
|
||||
msg_id: '77',
|
||||
oidc_login: true,
|
||||
});
|
||||
const captured = await redeem(proof);
|
||||
expect(captured.body).toEqual({
|
||||
opener_origin: 'https://opener.test',
|
||||
msg_id: '77',
|
||||
oidc_login: true,
|
||||
});
|
||||
});
|
||||
|
||||
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 },
|
||||
'not-the-server-secret',
|
||||
{ keyid: 'v2' },
|
||||
);
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/auth/oidc/verify-popup-return',
|
||||
makeReq({ body: { opener_state: forged } }),
|
||||
makeRes().res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects an expired proof', async () => {
|
||||
// Comfortably past `TokenService`'s 30s clock tolerance — the proof is
|
||||
// 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 },
|
||||
{ expiresIn: -600 },
|
||||
);
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/auth/oidc/verify-popup-return',
|
||||
makeReq({ body: { opener_state: stale } }),
|
||||
makeRes().res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects a missing or non-string proof', async () => {
|
||||
for (const bad of [undefined, null, '', 42, {}]) {
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/auth/oidc/verify-popup-return',
|
||||
makeReq({ body: { opener_state: bad } }),
|
||||
makeRes().res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
}
|
||||
});
|
||||
|
||||
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({
|
||||
opener_origin: 'https://opener.test',
|
||||
msg_id: '77',
|
||||
oidc_login: false,
|
||||
});
|
||||
expect((await redeem(proof)).body).toMatchObject({ oidc_login: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,11 @@ 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,
|
||||
): string {
|
||||
const targetFlow =
|
||||
OIDC_ERROR_REDIRECT_MAP[sourceFlow]?.[errorCondition] ?? sourceFlow;
|
||||
@@ -100,6 +105,19 @@ function buildErrorRedirectUrl(
|
||||
if (stateDecoded?.opener_origin) {
|
||||
params.set('opener_origin', String(stateDecoded.opener_origin));
|
||||
}
|
||||
// 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) {
|
||||
params.set(
|
||||
'opener_state',
|
||||
signPopupReturn({
|
||||
opener_origin: stateDecoded?.opener_origin ?? null,
|
||||
msg_id: stateDecoded?.msg_id ?? null,
|
||||
oidc_login: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
params = new URLSearchParams({
|
||||
action: targetFlow,
|
||||
@@ -146,6 +164,51 @@ function isSameOrigin(target: string, origin: string): boolean {
|
||||
*/
|
||||
export class OIDCController extends PuterController {
|
||||
registerRoutes(router: PuterRouter): void {
|
||||
// -- POST /auth/oidc/verify-popup-return ---------------------
|
||||
// Public — hand back the facts a popup-return proof attests to.
|
||||
//
|
||||
// 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
|
||||
// origin decides which app a token gets minted for, so the popup
|
||||
// redeems the signed proof here instead of believing the raw
|
||||
// parameters.
|
||||
//
|
||||
// Unauthenticated on purpose — it reveals nothing the caller did not
|
||||
// already hand over, and a forged or expired proof yields nothing.
|
||||
|
||||
router.post(
|
||||
'/auth/oidc/verify-popup-return',
|
||||
{
|
||||
subdomain: 'api',
|
||||
rateLimit: {
|
||||
scope: 'oidc-verify-popup-return',
|
||||
limit: 60,
|
||||
window: 60_000,
|
||||
},
|
||||
},
|
||||
async (req: Request, res: Response) => {
|
||||
const proof = req.body?.opener_state;
|
||||
if (typeof proof !== 'string' || !proof) {
|
||||
throw new HttpError(400, 'Missing `opener_state`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const decoded = this.services.oidc.verifyPopupReturn(proof);
|
||||
if (!decoded) {
|
||||
throw new HttpError(400, 'Invalid `opener_state`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
opener_origin: decoded.opener_origin ?? null,
|
||||
msg_id: decoded.msg_id ?? null,
|
||||
oidc_login: decoded.oidc_login === true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// -- GET /auth/oidc/providers --------------------------------
|
||||
// Public — list enabled provider IDs for the frontend.
|
||||
|
||||
@@ -344,6 +407,7 @@ export class OIDCController extends PuterController {
|
||||
resolved.code ?? 'unauthorized',
|
||||
stateDecoded,
|
||||
resolved.requestCode,
|
||||
(p) => this.services.oidc.signPopupReturn(p),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -361,6 +425,8 @@ export class OIDCController extends PuterController {
|
||||
'other',
|
||||
'account_suspended',
|
||||
stateDecoded,
|
||||
undefined,
|
||||
(p) => this.services.oidc.signPopupReturn(p),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -407,6 +473,7 @@ export class OIDCController extends PuterController {
|
||||
resolved.code ?? 'unauthorized',
|
||||
stateDecoded,
|
||||
resolved.requestCode,
|
||||
(p) => this.services.oidc.signPopupReturn(p),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -421,6 +488,8 @@ export class OIDCController extends PuterController {
|
||||
'other',
|
||||
'account_suspended',
|
||||
stateDecoded,
|
||||
undefined,
|
||||
(p) => this.services.oidc.signPopupReturn(p),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -700,6 +769,22 @@ if (window.opener) {
|
||||
|
||||
if (stateDecoded.embedded_in_popup) {
|
||||
target = appendQueryParam(target, 'oidc_login', 'true');
|
||||
// `opener_origin` and `oidc_login` reach the popup as bare query
|
||||
// parameters, which say nothing about where they came from: the
|
||||
// URL a verified state produces is byte-identical to one anybody
|
||||
// can type. The popup treats the opener's origin as the app
|
||||
// identity to mint a token for, so it needs the integrity this
|
||||
// state already carries — re-signed here, at the one point where
|
||||
// the round trip is known to have actually happened.
|
||||
target = appendQueryParam(
|
||||
target,
|
||||
'opener_state',
|
||||
this.services.oidc.signPopupReturn({
|
||||
opener_origin: stateDecoded.opener_origin ?? null,
|
||||
msg_id: stateDecoded.msg_id ?? null,
|
||||
oidc_login: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (extraQueryParams) {
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { LayerInstances } from '../../types';
|
||||
@@ -42,6 +43,9 @@ const MICROSOFT_SCOPES = 'openid email profile';
|
||||
// admin-editable and only attested via the opt-in `xms_edov` claim.
|
||||
const MICROSOFT_CONSUMER_TENANT = '9188040d-6c67-4c5b-b112-36a304b66dad';
|
||||
const STATE_EXPIRY_SEC = 600; // 10 minutes
|
||||
// The popup redeems this on the request the provider redirects it into, so it
|
||||
// only has to outlive one hop.
|
||||
const POPUP_RETURN_EXPIRY_SEC = 300; // 5 minutes
|
||||
const VALID_OIDC_FLOWS = ['login', 'signup', 'revalidate'] as const;
|
||||
const REVALIDATION_EXPIRY_SEC = 300; // 5 minutes
|
||||
|
||||
@@ -75,7 +79,8 @@ interface OIDCUserInfo {
|
||||
* Delegates to TokenService for JWT state signing, AuthService for session
|
||||
* creation, UserStore for user creation.
|
||||
*
|
||||
* Config shape: `config.oidc.providers.<providerId>.{ client_id, client_secret, ... }`
|
||||
* Config shape: `config.oidc.providers.<providerId>.{ client_id, client_secret,
|
||||
* ... }`
|
||||
*/
|
||||
export class OIDCService extends PuterService {
|
||||
declare protected services: LayerInstances<typeof puterServices>;
|
||||
@@ -237,6 +242,30 @@ export class OIDCService extends PuterService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the facts a popup needs back from an OIDC round trip.
|
||||
*
|
||||
* The return URL states the opener's origin and that a login completed.
|
||||
* Both come out of a verified `state`, but they reach the popup as bare
|
||||
* query parameters — and the popup treats the opener's origin as the app
|
||||
* identity it mints a token for. Since a URL says nothing about who wrote
|
||||
* it, that pair is re-signed here so the popup can tell a real return leg
|
||||
* from a crafted link.
|
||||
*
|
||||
* Short-lived: this is consumed on the very next request, as the provider
|
||||
* redirects the popup home.
|
||||
*/
|
||||
signPopupReturn(payload: Record<string, unknown>): string {
|
||||
return this.services.token.sign('oidc-state', payload, {
|
||||
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);
|
||||
}
|
||||
|
||||
verifyState(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return this.services.token.verify<Record<string, unknown>>(
|
||||
@@ -367,8 +396,8 @@ export class OIDCService extends PuterService {
|
||||
* Find an existing Puter user by the email claimed by the OIDC provider.
|
||||
*
|
||||
* Matches on both the raw `email` column and the canonical `clean_email`
|
||||
* column so that `Foo.Bar+tag@gmail.com` (OIDC) resolves to an account
|
||||
* that signed up as `foobar@gmail.com`. Primary email is preferred over a
|
||||
* column so that `Foo.Bar+tag@gmail.com` (OIDC) resolves to an account that
|
||||
* signed up as `foobar@gmail.com`. Primary email is preferred over a
|
||||
* clean_email collision.
|
||||
*/
|
||||
async findUserByEmail(email: string): Promise<UserRow | null> {
|
||||
@@ -382,9 +411,9 @@ export class OIDCService extends PuterService {
|
||||
* Link an OIDC provider to an existing user. Use when the `sub` wasn't
|
||||
* linked yet but we matched the user by email.
|
||||
*
|
||||
* Does NOT touch the password column — a user who originally signed up
|
||||
* with a password keeps password login. Does mark `email_confirmed` if
|
||||
* the provider verified the email and the row wasn't already confirmed.
|
||||
* Does NOT touch the password column — a user who originally signed up with
|
||||
* a password keeps password login. Does mark `email_confirmed` if the
|
||||
* provider verified the email and the row wasn't already confirmed.
|
||||
*/
|
||||
async linkProviderToUser(
|
||||
userId: number,
|
||||
@@ -421,8 +450,8 @@ export class OIDCService extends PuterService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Puter user from OIDC claims and link the provider.
|
||||
* Returns `{ success, user, error? }`.
|
||||
* Create a new Puter user from OIDC claims and link the provider. Returns
|
||||
* `{ success, user, error? }`.
|
||||
*/
|
||||
async createUserFromOIDC(
|
||||
providerId: string,
|
||||
@@ -434,8 +463,8 @@ export class OIDCService extends PuterService {
|
||||
error?: string;
|
||||
code?: string;
|
||||
/**
|
||||
* Support-correlation id for a vetoed signup (the abuse trail id).
|
||||
* Safe to show the user; the veto reason in `error` is not.
|
||||
* Support-correlation id for a vetoed signup (the abuse trail id). Safe
|
||||
* to show the user; the veto reason in `error` is not.
|
||||
*/
|
||||
requestCode?: string;
|
||||
}> {
|
||||
@@ -737,9 +766,9 @@ export class OIDCService extends PuterService {
|
||||
|
||||
/**
|
||||
* Verify an id_token against the provider's JWKS and return its claims.
|
||||
* Used for providers without a userinfo endpoint (e.g. Apple). Delegates
|
||||
* to the standalone verifier, passing this service's JWKS cache so keys
|
||||
* are reused across calls. See {@link verifyOidcIdToken} for semantics.
|
||||
* Used for providers without a userinfo endpoint (e.g. Apple). Delegates to
|
||||
* the standalone verifier, passing this service's JWKS cache so keys are
|
||||
* reused across calls. See {@link verifyOidcIdToken} for semantics.
|
||||
*/
|
||||
async #verifyIdToken(
|
||||
idToken: string,
|
||||
|
||||
+119
-20
@@ -59,10 +59,8 @@ import { ThemeService } from './services/ThemeService.js';
|
||||
// silently resolve to the factory — use `window.privacy_aware_path` instead.
|
||||
import { privacy_aware_path as privacy_aware_path_factory } from './util/desktop.js';
|
||||
import { resolveAPIOrigin } from './util/apiOrigin.js';
|
||||
import {
|
||||
deliversTokenToOpener,
|
||||
trustsOpenerOriginParam,
|
||||
} from './util/popupAuth.js';
|
||||
import { deliversTokenToOpener } from './util/popupAuth.js';
|
||||
import { verifyOidcPopupReturn } from './util/popupOidcReturn.js';
|
||||
|
||||
const postAuthActions = async (action) => {
|
||||
// Set when a popup's user-app token exchange fails. The exchange is what
|
||||
@@ -189,9 +187,64 @@ const postAuthActions = async (action) => {
|
||||
let isolated = window.url_query_params.get("cross_origin_isolated") === 'true'
|
||||
&& deliversTokenToOpener(action);
|
||||
let session = window.url_query_params.get('signin_session');
|
||||
|
||||
// Signing the opener in is something the user has to have asked for.
|
||||
// The gates upstream record that decision — picking an account,
|
||||
// finishing signup, or already holding a token for this opener — and
|
||||
// a first visit that mints a throwaway temp user has no existing
|
||||
// account to hand over. Without this check the hand-off below runs
|
||||
// unconditionally, so a popup that showed the user nothing still
|
||||
// ended in a token: dismissing the account picker skipped only the
|
||||
// early exchange, not the delivery.
|
||||
//
|
||||
// Scoped to the popups whose whole purpose is signing in. The
|
||||
// file-picker actions also reach the hand-off, but they answer for
|
||||
// themselves — they have their own dialogs and never show an account
|
||||
// picker, so requiring one here would just break them.
|
||||
const is_signin_popup = !action || action === 'sign-in';
|
||||
const consented =
|
||||
window.popup_signin_consent ||
|
||||
(window.attempt_temp_user_creation && window.first_visit_ever);
|
||||
if (is_signin_popup && !consented) {
|
||||
console.error(
|
||||
'popup sign-in was not consented to; not delivering a token',
|
||||
);
|
||||
if (isolated) {
|
||||
window.close();
|
||||
window.open('', '_self').close();
|
||||
return;
|
||||
}
|
||||
window.opener?.postMessage({
|
||||
msg: 'puter.token',
|
||||
success: false,
|
||||
token: null,
|
||||
msg_id: msg_id,
|
||||
}, window.openerOrigin);
|
||||
window.close();
|
||||
window.open('', '_self').close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isolated) {
|
||||
try {
|
||||
const data = await window.getUserAppToken(new URL(window.openerOrigin).origin);
|
||||
// Same two failure modes the postMessage path below guards
|
||||
// against: `getUserAppToken` reports a network failure by
|
||||
// returning null, and an HTTP failure (a blocked origin, an
|
||||
// unparseable origin, a 5xx) by returning the parsed *error*
|
||||
// body — truthy, but carrying no token. Without this check the
|
||||
// missing token is handed to `/login/set`, which rejects it as
|
||||
// a 400, and every distinct cause — including the ones that
|
||||
// only occur on a deployment with a populated origin blocklist
|
||||
// — collapses into the same unattributable alert below.
|
||||
if ( ! data?.token ) {
|
||||
const detail = data?.code
|
||||
? `${data.code}: ${data.message ?? ''}`
|
||||
: 'no response';
|
||||
throw new Error(
|
||||
`user-app token exchange returned no token (${detail})`,
|
||||
);
|
||||
}
|
||||
const resp = await fetch(`${window.api_origin}/login/set`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -1088,19 +1141,26 @@ window.initgui = async function (options) {
|
||||
if (window.embedded_in_popup) {
|
||||
$('body').addClass('embedded-in-popup');
|
||||
|
||||
// determine the origin of the opener (preserved across OIDC redirect via URL param, else referrer or messaging)
|
||||
// A permission prompt is the exception: there the opener's origin names
|
||||
// the requester on the dialog and picks the app the grant is written to,
|
||||
// so it may only come from a source the browser vouches for. See
|
||||
// util/popupAuth.js.
|
||||
const openerOriginFromUrl = trustsOpenerOriginParam(action)
|
||||
? window.url_query_params.get('opener_origin')
|
||||
: null;
|
||||
if (openerOriginFromUrl) {
|
||||
window.openerOrigin = openerOriginFromUrl;
|
||||
} else {
|
||||
window.openerOrigin = document.referrer;
|
||||
}
|
||||
// Determine the origin of the opener. This is the one assignment that
|
||||
// matters: the token exchange, `checkUserSiteRelationship`,
|
||||
// `getAppUIDFromOrigin` and both `postMessage` targets all read
|
||||
// `window.openerOrigin`, so every one of them is only as trustworthy
|
||||
// as this line.
|
||||
//
|
||||
// An OIDC redirect drops `document.referrer` — it returns the popup
|
||||
// with the *provider* as referrer — so the opener's origin has to
|
||||
// survive the hop. It does, inside the signed `state`, but the backend
|
||||
// used to flatten it into a bare `opener_origin` parameter: a URL
|
||||
// built from a verified state is byte-identical to one anybody can
|
||||
// type, and the popup believed both. Now the return leg carries a
|
||||
// signed proof, redeemed here for the value the server actually
|
||||
// attested. Everything else falls back to browser-attested sources.
|
||||
window.oidcPopupReturn = await verifyOidcPopupReturn(
|
||||
window.url_query_params.get('opener_state'),
|
||||
window.url_query_params.get('msg_id'),
|
||||
);
|
||||
window.openerOrigin =
|
||||
window.oidcPopupReturn?.opener_origin || document.referrer;
|
||||
if (!window.openerOrigin) {
|
||||
try {
|
||||
window.openerOrigin = await requestOpenerOrigin();
|
||||
@@ -1158,10 +1218,19 @@ window.initgui = async function (options) {
|
||||
},
|
||||
})
|
||||
) {
|
||||
// Completing signup in a sign-in popup is the user asking to
|
||||
// be signed in to the opener.
|
||||
window.popup_signin_consent = true;
|
||||
await window.getUserAppToken(window.openerOrigin);
|
||||
}
|
||||
} else if (
|
||||
action === 'sign-in' &&
|
||||
// An action-less popup is a sign-in popup — `postAuthActions`
|
||||
// already treats it as one when it decides to close the window,
|
||||
// and it ends in the same token hand-off. It has to reach the
|
||||
// same account picker too: leaving it out meant the one popup
|
||||
// shape that shows the user nothing was also the one that minted
|
||||
// a token for the opener without being asked.
|
||||
(action === 'sign-in' || !action) &&
|
||||
window.is_auth() &&
|
||||
!(window.attempt_temp_user_creation && window.first_visit_ever)
|
||||
) {
|
||||
@@ -1180,8 +1249,13 @@ window.initgui = async function (options) {
|
||||
console.error("error in 'sign-in' flow", e);
|
||||
}
|
||||
|
||||
if (window.url_query_params.get('oidc_login') === 'true') {
|
||||
// OIDC login just completed in popup — skip session list and finish the flow
|
||||
// An OIDC login that just completed may skip the account picker —
|
||||
// the user chose their account at the provider moments ago. That
|
||||
// comes from the same signed proof as the opener's origin, rather
|
||||
// than the `oidc_login` query parameter it used to be read from:
|
||||
// as a bare parameter anyone could write it, and it suppresses the
|
||||
// one prompt standing between a link and a token.
|
||||
if (window.oidcPopupReturn?.oidc_login) {
|
||||
picked_a_user_for_sdk_login = true;
|
||||
await window.getUserAppToken(window.openerOrigin);
|
||||
} else {
|
||||
@@ -1197,6 +1271,12 @@ window.initgui = async function (options) {
|
||||
await window.getUserAppToken(window.openerOrigin);
|
||||
}
|
||||
}
|
||||
// Picking an account here *is* the consent to sign the opener in.
|
||||
// `postAuthActions` runs later and unconditionally, so it needs to
|
||||
// know whether that decision was ever made — dismissing the picker
|
||||
// has to mean the opener gets nothing, not just that the early
|
||||
// token exchange was skipped.
|
||||
window.popup_signin_consent = !!picked_a_user_for_sdk_login;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1341,6 +1421,18 @@ window.initgui = async function (options) {
|
||||
has_head: false,
|
||||
cover_page: true,
|
||||
});
|
||||
if (picked_a_user_for_sdk_login) {
|
||||
window.popup_signin_consent = true;
|
||||
}
|
||||
}
|
||||
|
||||
// An opener the user has already signed in to before does not need to
|
||||
// be re-approved on every visit — that grant is what
|
||||
// `checkUserSiteRelationship` reports. This is also what keeps the
|
||||
// file-picker and permission popups, which never show an account
|
||||
// picker, from being blocked by the gate in `postAuthActions`.
|
||||
if (window.userAppToken) {
|
||||
window.popup_signin_consent = true;
|
||||
}
|
||||
}
|
||||
// -------------------------------------------------------------------------------------
|
||||
@@ -2048,6 +2140,13 @@ window.initgui = async function (options) {
|
||||
// `login` event handler
|
||||
// --------------------------------------------------------------------------------------
|
||||
$(document).on('login', async (e) => {
|
||||
// Reaching this in a popup means the user just entered credentials in
|
||||
// a window the opener asked for — that is the consent `postAuthActions`
|
||||
// looks for. The account-picker gate upstream never runs on this path:
|
||||
// it only applies to a popup that was already signed in at boot.
|
||||
if (window.embedded_in_popup) {
|
||||
window.popup_signin_consent = true;
|
||||
}
|
||||
// close all windows
|
||||
$('.window').close();
|
||||
|
||||
|
||||
@@ -51,37 +51,31 @@ const NON_AUTH_POPUP_ACTIONS = new Set(['request-permission']);
|
||||
export const deliversTokenToOpener = (action) =>
|
||||
!NON_AUTH_POPUP_ACTIONS.has(action);
|
||||
|
||||
/**
|
||||
* Popup actions where the opener's origin *is* the requester's identity, rather
|
||||
* than just the address an answer is sent back to.
|
||||
/*
|
||||
* On the `opener_origin` URL parameter, which this module used to gate.
|
||||
*
|
||||
* The opener's origin is the requester's identity twice over: it is the name a
|
||||
* dialog attributes the request to, and it is what the server resolves into
|
||||
* the app a token is minted for and a grant written against. It only ever
|
||||
* appeared in the URL to survive an OIDC redirect, which drops the rest of the
|
||||
* query and returns the popup with the *provider* as its referrer.
|
||||
*
|
||||
* The gate here was a denylist of one action (`request-permission`), so it fell
|
||||
* open on exactly the case it most needed to catch: a popup URL with no
|
||||
* `action` at all was trusted, and an action-less popup is also the one shape
|
||||
* that renders no consent UI. Any site could then have a token minted in
|
||||
* another app's name with a single navigation. The reasoning behind the
|
||||
* denylist rested on a mistaken premise — that the OIDC redirect "drops
|
||||
* `action`", so no other flow could return through one. It does not; the
|
||||
* return path is hard-coded to `/action/sign-in`.
|
||||
*
|
||||
* No action believes the raw parameter now, so there is nothing left to gate.
|
||||
* The OIDC round trip carries the value in the signed `state` it always did,
|
||||
* and the return leg re-signs it as `opener_state` for the popup to redeem —
|
||||
* see util/popupOidcReturn.js. That leaves only sources the browser or the
|
||||
* server vouches for: `document.referrer`, the `requestOrigin` handshake, and
|
||||
* that proof.
|
||||
*/
|
||||
const OPENER_IS_THE_REQUESTER_ACTIONS = new Set(['request-permission']);
|
||||
|
||||
/**
|
||||
* Whether a popup running `action` may take its opener's origin from the
|
||||
* `opener_origin` URL parameter.
|
||||
*
|
||||
* That parameter exists so a sign-in popup can carry the opener's origin across
|
||||
* an OIDC redirect, which drops the rest of the query. It is chosen by whoever
|
||||
* built the link, though, and for a permission prompt the opener's origin is the
|
||||
* requester's identity twice over: it is the name the dialog attributes the
|
||||
* request to, and it is what the server resolves into the app the grant is
|
||||
* written against. Honouring a link-supplied one would let any site prompt in
|
||||
* another app's name and commit the user's grant to it — the same hole that
|
||||
* `app_uid` was removed from this URL to close.
|
||||
*
|
||||
* So a permission popup takes only a browser-attested origin: `document.referrer`
|
||||
* or the opener's own reply to the `requestOrigin` handshake. Nothing is lost —
|
||||
* the SDK never sends this parameter, and the OIDC redirect it exists for drops
|
||||
* `action` too, so no permission flow can reach here through one.
|
||||
*
|
||||
* @param {string|null|undefined} action - The popup's `action`, as parsed from
|
||||
* the URL (`/action/<name>` or `?action=<name>`); undefined for a plain
|
||||
* sign-in popup.
|
||||
* @returns {boolean} `true` if `opener_origin` may be believed.
|
||||
*/
|
||||
export const trustsOpenerOriginParam = (action) =>
|
||||
!OPENER_IS_THE_REQUESTER_ACTIONS.has(action);
|
||||
|
||||
/**
|
||||
* Whether a popup running `action` may offer federated (OIDC) sign-in.
|
||||
@@ -99,10 +93,10 @@ export const trustsOpenerOriginParam = (action) =>
|
||||
* Nothing in the returned URL says what the popup was originally for, so the
|
||||
* popup cannot re-establish it. Restoring the action through the redirect is
|
||||
* also not enough on its own: the returning navigation's referrer is the
|
||||
* provider, not the opener, so `trustsOpenerOriginParam`'s browser-attested
|
||||
* origin would have to come from the `requestOrigin` handshake instead. Until
|
||||
* that exists, a popup whose purpose cannot survive the round trip does not
|
||||
* offer the round trip. Email sign-in stays in the window and works normally.
|
||||
* provider, not the opener, so the prompt would have to re-attest its opener
|
||||
* from the `requestOrigin` handshake or the `opener_state` proof. Until that
|
||||
* exists, a popup whose purpose cannot survive the round trip does not offer
|
||||
* the round trip. Email sign-in stays in the window and works normally.
|
||||
*
|
||||
* @param {string|null|undefined} action - The popup's `action`, as parsed from
|
||||
* the URL (`/action/<name>` or `?action=<name>`); undefined for a plain
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as popupAuth from './popupAuth.js';
|
||||
import {
|
||||
deliversTokenToOpener,
|
||||
offersFederatedSignInInPopup,
|
||||
trustsOpenerOriginParam,
|
||||
} from './popupAuth.js';
|
||||
|
||||
describe('deliversTokenToOpener', () => {
|
||||
@@ -68,28 +68,15 @@ describe('offersFederatedSignInInPopup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('trustsOpenerOriginParam', () => {
|
||||
it('disbelieves a link-supplied origin on a permission prompt', () => {
|
||||
// The opener's origin is the requester's identity there: it is the name
|
||||
// the dialog shows and the app the server writes the grant to. Taking it
|
||||
// from the link would let any site prompt in another app's name — the
|
||||
// same hole `app_uid` was removed from this URL to close.
|
||||
expect(trustsOpenerOriginParam('request-permission')).toBe(false);
|
||||
});
|
||||
|
||||
it('believes it for the flows the parameter exists to carry', () => {
|
||||
// It survives an OIDC redirect, which drops the rest of the query.
|
||||
// `undefined` is a plain sign-in popup, which carries no action.
|
||||
for ( const action of [
|
||||
undefined,
|
||||
'sign-in',
|
||||
'login',
|
||||
'signup',
|
||||
'show-open-file-picker',
|
||||
'show-directory-picker',
|
||||
'show-save-file-picker',
|
||||
] ) {
|
||||
expect(trustsOpenerOriginParam(action)).toBe(true);
|
||||
}
|
||||
describe('the retired opener_origin gate', () => {
|
||||
it('is gone, because no action believes the raw parameter now', () => {
|
||||
// It used to allow `opener_origin` for every action but
|
||||
// `request-permission`. Being a denylist it fell open on `undefined` —
|
||||
// a popup with no action, which is also the one shape that renders no
|
||||
// consent UI — so any site could have a token minted in another app's
|
||||
// name with a single navigation. The OIDC round trip the parameter
|
||||
// existed for now redeems a signed proof instead; see
|
||||
// util/popupOidcReturn.js.
|
||||
expect(popupAuth.trustsOpenerOriginParam).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Redeeming a sign-in popup's OIDC return proof.
|
||||
*
|
||||
* A popup that hands control to an identity provider comes back needing two
|
||||
* facts it cannot establish for itself: who its opener is, and that a login
|
||||
* really did just complete. `document.referrer` on that navigation is the
|
||||
* provider, so neither is recoverable locally.
|
||||
*
|
||||
* Both facts do survive the round trip — they travel inside the `state` the
|
||||
* backend signs and verifies (`OIDCService.signState`/`verifyState`). The
|
||||
* problem was the last hop: the backend used to flatten them into plain
|
||||
* `opener_origin` and `oidc_login` query parameters. A URL produced by a
|
||||
* verified state is byte-identical to one an attacker types, so the popup had
|
||||
* no way to tell them apart — and the opener's origin is what picks the app a
|
||||
* token gets minted for.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Redeem an `opener_state` proof for the facts the server attested.
|
||||
*
|
||||
* @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.
|
||||
* @returns {Promise<{opener_origin: string|null, oidc_login: boolean}|null>}
|
||||
* `null` when there is no usable proof.
|
||||
*/
|
||||
export const verifyOidcPopupReturn = async (proof, msgId) => {
|
||||
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 }),
|
||||
},
|
||||
);
|
||||
// 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;
|
||||
attested = await resp.json();
|
||||
} catch (e) {
|
||||
// The popup can still sign in via referrer/handshake, so a failure to
|
||||
// reach the server here must not take it down.
|
||||
console.error('could not verify the OIDC popup return proof', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!attested?.opener_origin) return null;
|
||||
// The popup carries its `msg_id` through the round trip, so a mismatch
|
||||
// means this proof was minted for a different flow. Compared as strings:
|
||||
// the SDK generates a number, the URL yields text.
|
||||
if (
|
||||
attested.msg_id != null &&
|
||||
msgId != null &&
|
||||
String(attested.msg_id) !== String(msgId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
opener_origin: attested.opener_origin,
|
||||
oidc_login: attested.oidc_login === true,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, 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 }));
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.window = { api_origin: 'https://api.test' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.window;
|
||||
delete globalThis.fetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('redeeming a proof', () => {
|
||||
it('returns the origin the server attested', async () => {
|
||||
globalThis.fetch = serverSays({
|
||||
opener_origin: OPENER,
|
||||
msg_id: '7',
|
||||
oidc_login: true,
|
||||
});
|
||||
await expect(verifyOidcPopupReturn('signed.blob.here', '7')).resolves.toEqual(
|
||||
{ opener_origin: OPENER, oidc_login: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the proof to the verify endpoint', async () => {
|
||||
const fetchMock = serverSays({ opener_origin: OPENER, oidc_login: true });
|
||||
globalThis.fetch = fetchMock;
|
||||
await verifyOidcPopupReturn('signed.blob.here', null);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.test/auth/oidc/verify-popup-return');
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
opener_state: 'signed.blob.here',
|
||||
});
|
||||
});
|
||||
|
||||
it('carries oidc_login=false through rather than defaulting it true', async () => {
|
||||
// The error leg is a real return too, but no login completed on it —
|
||||
// so it must not suppress the account picker.
|
||||
globalThis.fetch = serverSays({
|
||||
opener_origin: OPENER,
|
||||
oidc_login: false,
|
||||
});
|
||||
await expect(
|
||||
verifyOidcPopupReturn('signed.blob.here', null),
|
||||
).resolves.toEqual({ opener_origin: OPENER, oidc_login: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('refusing what the server did not attest', () => {
|
||||
it('yields nothing when there is no proof at all', async () => {
|
||||
// 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();
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('yields nothing when the server rejects the proof', async () => {
|
||||
// Forged or expired: the endpoint answers 400.
|
||||
globalThis.fetch = serverSays(
|
||||
{ message: 'Invalid `opener_state`' },
|
||||
{ ok: false },
|
||||
);
|
||||
await expect(
|
||||
verifyOidcPopupReturn('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();
|
||||
});
|
||||
|
||||
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(
|
||||
verifyOidcPopupReturn('signed.blob.here', '8'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('still matches when msg_id differs only by type', async () => {
|
||||
// The SDK generates a number; the URL yields text.
|
||||
globalThis.fetch = serverSays({
|
||||
opener_origin: OPENER,
|
||||
msg_id: 7,
|
||||
oidc_login: true,
|
||||
});
|
||||
await expect(
|
||||
verifyOidcPopupReturn('signed.blob.here', '7'),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('degrades to nothing when the endpoint is unreachable', async () => {
|
||||
// The popup can still sign in from referrer/handshake, so a network
|
||||
// failure must not take it down.
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await expect(
|
||||
verifyOidcPopupReturn('signed.blob.here', '7'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,13 @@ class Auth extends PuterModule {
|
||||
* Rejects with `{ error: 'popup_blocked' }` if the browser blocked the
|
||||
* popup, or `{ error: 'auth_window_closed' }` if the user closed it.
|
||||
*
|
||||
* @type {(options?: { attempt_temp_user_creation?: boolean }) => Promise<SignInResult>}
|
||||
* `request_auth` asks the popup to let the user re-pick their account even
|
||||
* when this site already holds a token for them — the GUI otherwise skips
|
||||
* that prompt for a site it has seen before. Implicit auth (a `puter.*`
|
||||
* call that finds no token) sets it, which is the behaviour its own popup
|
||||
* used to carry as `?request_auth=true`.
|
||||
*
|
||||
* @type {(options?: { attempt_temp_user_creation?: boolean, request_auth?: boolean }) => Promise<SignInResult>}
|
||||
*/
|
||||
signIn = (options) => {
|
||||
options = options || {};
|
||||
@@ -35,7 +41,7 @@ class Auth extends PuterModule {
|
||||
return new Promise((resolve, reject) => {
|
||||
const signinsession = crypto.randomUUID();
|
||||
const msg_id = this.#messageID++;
|
||||
const url = `${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? `&cross_origin_isolated=true&signin_session=${signinsession}` : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}`;
|
||||
const url = `${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? `&cross_origin_isolated=true&signin_session=${signinsession}` : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}${options.request_auth ? '&request_auth=true' : ''}`;
|
||||
|
||||
// Guards against settling the promise more than once across the
|
||||
// message, popup-closed, and dialog-cancel code paths.
|
||||
|
||||
@@ -1859,16 +1859,52 @@ class UI extends EventListener {
|
||||
puter.puterAuthState.isPromptOpen = true;
|
||||
puter.puterAuthState.authGranted = null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if ( ! puter.authToken ) {
|
||||
const puterDialog = new PuterDialog(resolve, reject);
|
||||
document.body.appendChild(puterDialog);
|
||||
puterDialog.open();
|
||||
} else {
|
||||
// If authToken is already present, resolve immediately
|
||||
resolve();
|
||||
// Hand off to `signIn()` rather than opening a second sign-in popup of
|
||||
// our own. It is the same flow with the parts this one never grew:
|
||||
// it opens the popup directly when a user gesture is available (and
|
||||
// falls back to the consent dialog to obtain one when not), notices the
|
||||
// user closing the popup, and — on a cross-origin-isolated page, where
|
||||
// COOP severs `window.opener` so no `puter.token` message can ever come
|
||||
// back — collects the token from the `/login/set` → `/login/wait` relay
|
||||
// instead. Without that last part implicit auth could not complete at
|
||||
// all on an isolated page: every `puter.ai.chat()` / `puter.fs.*` call
|
||||
// opened a popup that had no way to return anything, while an explicit
|
||||
// `puter.auth.signIn()` worked.
|
||||
//
|
||||
// `signIn` adopts the token itself, so all that is left here is
|
||||
// settling the shared prompt state and anything queued behind it.
|
||||
const settle = (granted) => {
|
||||
puter.puterAuthState.authGranted = granted;
|
||||
puter.puterAuthState.isPromptOpen = false;
|
||||
const resolver = puter.puterAuthState.resolver;
|
||||
puter.puterAuthState.resolver = null;
|
||||
if ( resolver ) {
|
||||
if ( granted ) {
|
||||
resolver.resolve();
|
||||
} else {
|
||||
resolver.reject();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// `request_auth` keeps the one behaviour the popup this replaced had
|
||||
// that a plain `signIn()` does not: with more than one account signed
|
||||
// in, the user gets to re-pick even if this site already holds a token
|
||||
// for them.
|
||||
return puter.auth.signIn({ request_auth: true }).then(
|
||||
() => {
|
||||
settle(true);
|
||||
if ( puter.onAuth && typeof puter.onAuth === 'function' ) {
|
||||
puter.getUser().then((user) => {
|
||||
puter.onAuth(user);
|
||||
});
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
settle(false);
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<script src="./apps.test.js"></script>
|
||||
<script src="./os.test.js"></script>
|
||||
<script src="./perms.test.js"></script>
|
||||
<script src="./signin.test.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
@@ -746,6 +747,7 @@
|
||||
{ key: 'apps', label: 'Apps', tests: window.appsTests || [] },
|
||||
{ key: 'os', label: 'OS', tests: window.osTests || [] },
|
||||
{ key: 'perms', label: 'Permissions', tests: window.permsTests || [] },
|
||||
{ key: 'signin', label: 'Cross-Origin Sign-In', tests: window.signinTests || [] },
|
||||
];
|
||||
window.extraGroups = extraGroups;
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/* eslint-disable */
|
||||
// TODO: Make these more compatible with eslint
|
||||
//
|
||||
// Cross-origin sign-in. These only mean anything when this page is served from
|
||||
// an origin that ISN'T the Puter GUI — `npm run test` in src/puter-js serves it
|
||||
// on http://localhost:8080 while the GUI is on http://puter.localhost:4100, so
|
||||
// `puter.env` is 'web' and `signIn()` goes through a real popup on the GUI
|
||||
// origin. Opened from the GUI's own origin they prove nothing; the first test
|
||||
// checks that and bails.
|
||||
//
|
||||
// Hand-run these ONE AT A TIME: three of the four open a popup and need you to
|
||||
// complete (or dismiss) a sign-in, so they can't run unattended.
|
||||
//
|
||||
// Two shapes exist and they deliver the token by completely different means:
|
||||
//
|
||||
// - default — popup posts `puter.token` back via postMessage.
|
||||
// - cross-origin-isolated — COOP severs `window.opener`, so the popup can't
|
||||
// post anything. It POSTs the token to `/login/set` instead and the SDK
|
||||
// collects it by long-polling `/login/wait`. This is the path that broke:
|
||||
// every failure in it used to surface as the popup alerting "Couldn't sign
|
||||
// you in. Please try again." with no cause anywhere.
|
||||
window.signinTests = [
|
||||
{
|
||||
name: "testSignInIsCrossOrigin",
|
||||
description: "Harness sanity check: this page is cross-origin to the GUI (env=web)",
|
||||
test: async function() {
|
||||
try {
|
||||
const guiOrigin = puter.defaultGUIOrigin;
|
||||
assert(typeof guiOrigin === 'string' && guiOrigin.length > 0,
|
||||
"puter.defaultGUIOrigin is unset — the SDK has no popup target");
|
||||
assert(window.location.origin !== guiOrigin,
|
||||
`this page is served from the GUI origin (${guiOrigin}); serve it elsewhere ` +
|
||||
`(cd src/puter-js && npm run test) or these tests prove nothing`);
|
||||
assert(puter.env === 'web',
|
||||
`expected env 'web' for a third-party page, got '${puter.env}'`);
|
||||
pass(`testSignInIsCrossOrigin passed: page=${window.location.origin} gui=${guiOrigin} api=${puter.APIOrigin}`);
|
||||
} catch (error) {
|
||||
fail("testSignInIsCrossOrigin failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testSignInPostMessage",
|
||||
description: "[interactive] signIn() resolves with a token over the default postMessage path",
|
||||
test: async function() {
|
||||
try {
|
||||
const result = await puter.auth.signIn();
|
||||
assert(result && result.success === true,
|
||||
"signIn did not resolve with success:true — got " + JSON.stringify(result));
|
||||
assert(typeof result.token === 'string' && result.token.length > 0,
|
||||
"signIn resolved without a token");
|
||||
// The SDK is supposed to adopt the token, not just hand it back.
|
||||
assert(puter.authToken === result.token,
|
||||
"signIn resolved but puter.authToken was not set to the returned token");
|
||||
assert(puter.auth.isSignedIn(), "isSignedIn() is false after a successful signIn");
|
||||
pass("testSignInPostMessage passed as " + (result.username ?? '(unknown user)'));
|
||||
} catch (error) {
|
||||
// `auth_window_closed` means the popup was dismissed rather
|
||||
// than completed — that's an aborted run, not a product bug,
|
||||
// so say so instead of reporting a failure the code caused.
|
||||
if (error && error.error === 'auth_window_closed') {
|
||||
fail("testSignInPostMessage was not completed: the popup was closed before sign-in finished. Re-run and complete it.", error);
|
||||
}
|
||||
if (error && error.error === 'popup_blocked') {
|
||||
fail("testSignInPostMessage could not run: the browser blocked the popup. Allow popups for this origin.", error);
|
||||
}
|
||||
fail("testSignInPostMessage failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testSignInCrossOriginIsolatedRelay",
|
||||
description: "[interactive] signIn() resolves via the /login/set + /login/wait relay (forces the cross-origin-isolated path)",
|
||||
test: async function() {
|
||||
// `http-server` sends no COOP/COEP, so `window.crossOriginIsolated`
|
||||
// is false here and the SDK would take the postMessage path. Shadow
|
||||
// the getter to force the relay branch — the same trick the
|
||||
// Playwright specs use for `navigator.userActivation`. The GUI side
|
||||
// is driven by the `cross_origin_isolated=true` URL parameter the
|
||||
// SDK adds off this flag, so the popup genuinely runs its isolated
|
||||
// branch and really does POST to `/login/set`.
|
||||
//
|
||||
// This does NOT reproduce COOP severing `window.opener`, so it
|
||||
// exercises the relay's delivery, not the reason the relay exists.
|
||||
const hadOwn = Object.prototype.hasOwnProperty.call(window, 'crossOriginIsolated');
|
||||
const ownDescriptor = hadOwn
|
||||
? Object.getOwnPropertyDescriptor(window, 'crossOriginIsolated')
|
||||
: null;
|
||||
let relayObserved = false;
|
||||
// The relay long-poll goes out through the SDK's `fetchUrl`, which
|
||||
// is an XHR-based replacement for fetch — hooking only `window.fetch`
|
||||
// sees nothing. Both are hooked so this keeps working if the SDK's
|
||||
// transport changes.
|
||||
const realFetch = window.fetch;
|
||||
const realXhrOpen = XMLHttpRequest.prototype.open;
|
||||
const noteUrl = (url) => {
|
||||
if (typeof url === 'string' && url.includes('/login/wait')) relayObserved = true;
|
||||
};
|
||||
|
||||
try {
|
||||
Object.defineProperty(window, 'crossOriginIsolated', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
writable: false,
|
||||
});
|
||||
assert(window.crossOriginIsolated === true,
|
||||
"could not shadow window.crossOriginIsolated — this browser won't let the isolated path be forced");
|
||||
|
||||
// Watch for the long-poll so a pass can't be claimed by the
|
||||
// postMessage path quietly handling it instead.
|
||||
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
||||
try { noteUrl(url); } catch (e) { /* never let instrumentation break the call */ }
|
||||
return realXhrOpen.call(this, method, url, ...rest);
|
||||
};
|
||||
window.fetch = function(resource, ...rest) {
|
||||
try {
|
||||
noteUrl(typeof resource === 'string' ? resource : resource?.url);
|
||||
} catch (e) { /* never let instrumentation break the call */ }
|
||||
return realFetch.call(this, resource, ...rest);
|
||||
};
|
||||
|
||||
const result = await puter.auth.signIn();
|
||||
|
||||
assert(result && result.success === true,
|
||||
"signIn did not resolve with success:true — got " + JSON.stringify(result));
|
||||
assert(typeof result.token === 'string' && result.token.length > 0,
|
||||
"signIn resolved without a token");
|
||||
assert(puter.authToken === result.token,
|
||||
"signIn resolved but puter.authToken was not set to the returned token");
|
||||
assert(relayObserved,
|
||||
"no /login/wait request was seen — the token did not come through the relay, " +
|
||||
"so the isolated path was not actually exercised");
|
||||
pass("testSignInCrossOriginIsolatedRelay passed: token arrived via /login/wait");
|
||||
} catch (error) {
|
||||
if (error && error.error === 'auth_window_closed') {
|
||||
fail("testSignInCrossOriginIsolatedRelay was not completed: the popup was closed before sign-in finished. Re-run and complete it.", error);
|
||||
}
|
||||
if (error && error.error === 'popup_blocked') {
|
||||
fail("testSignInCrossOriginIsolatedRelay could not run: the browser blocked the popup. Allow popups for this origin.", error);
|
||||
}
|
||||
// The isolated path has no popup-closed watchdog, so a failure
|
||||
// on the GUI side never reaches this promise — it just never
|
||||
// settles and the harness times out. If the popup showed
|
||||
// "Couldn't sign you in", the cause is in ITS console, not here.
|
||||
fail("testSignInCrossOriginIsolatedRelay failed (if the popup alerted \"Couldn't sign you in\", check the popup's console for the real cause):", error);
|
||||
} finally {
|
||||
window.fetch = realFetch;
|
||||
XMLHttpRequest.prototype.open = realXhrOpen;
|
||||
if (hadOwn && ownDescriptor) {
|
||||
Object.defineProperty(window, 'crossOriginIsolated', ownDescriptor);
|
||||
} else {
|
||||
// Drop the shadow so the real (prototype) getter is visible again.
|
||||
delete window.crossOriginIsolated;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testSignInRelayContract",
|
||||
description: "Non-interactive: /login/wait and /login/set reject a session id that isn't a UUID",
|
||||
test: async function() {
|
||||
// The two halves of the relay, checked without a popup. Only the
|
||||
// input contract is asserted, because what a *valid* session id
|
||||
// returns is deliberately different before and after the audience
|
||||
// check that binds a relayed token to the collecting origin: a
|
||||
// caller that isn't the app the token was minted for gets the same
|
||||
// 408 as "nothing arrived". Asserting a successful round-trip here
|
||||
// would therefore start failing the moment that lands, so don't.
|
||||
try {
|
||||
const post = async (path, body) => {
|
||||
const resp = await fetch(`${puter.APIOrigin}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return resp;
|
||||
};
|
||||
|
||||
const waitBad = await post('/login/wait', { session: 'not-a-uuid' });
|
||||
assert(waitBad.status === 400,
|
||||
`/login/wait should reject a non-UUID session with 400, got ${waitBad.status}`);
|
||||
|
||||
const waitMissing = await post('/login/wait', {});
|
||||
assert(waitMissing.status === 400,
|
||||
`/login/wait should reject a missing session with 400, got ${waitMissing.status}`);
|
||||
|
||||
// A guessable/attacker-chosen id is the whole reason the id is
|
||||
// validated and the relay is origin-bound; a non-UUID must
|
||||
// never open a channel.
|
||||
const setBad = await post('/login/set', { session: 'not-a-uuid', auth_token: 'x' });
|
||||
assert(setBad.status === 400,
|
||||
`/login/set should reject a non-UUID session with 400, got ${setBad.status}`);
|
||||
|
||||
const setNoToken = await post('/login/set', { session: crypto.randomUUID() });
|
||||
assert(setNoToken.status === 400,
|
||||
`/login/set should reject a missing auth_token with 400, got ${setNoToken.status}`);
|
||||
|
||||
pass("testSignInRelayContract passed: both relay endpoints validate their input");
|
||||
} catch (error) {
|
||||
fail("testSignInRelayContract failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testSignInConsentDialogWithoutGesture",
|
||||
description: "[interactive] with no user activation, signIn() shows the consent dialog instead of being popup-blocked",
|
||||
test: async function() {
|
||||
// Clicking "Run Test" grants user activation, and the SDK opens the
|
||||
// popup directly when it has one. Wait it out first: activation
|
||||
// expires after a few seconds, and the no-gesture path is the one
|
||||
// that has to put up a consent dialog so the popup can be opened
|
||||
// from a click on THAT — otherwise the browser blocks it.
|
||||
try {
|
||||
if (!navigator.userActivation) {
|
||||
fail("testSignInConsentDialogWithoutGesture skipped: this browser has no navigator.userActivation, so the no-gesture path can't be identified");
|
||||
}
|
||||
|
||||
// Don't touch the page while this runs, or activation returns.
|
||||
const deadline = Date.now() + 15000;
|
||||
while (navigator.userActivation.isActive && Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
assert(!navigator.userActivation.isActive,
|
||||
"user activation never went idle — don't interact with the page while this test waits");
|
||||
|
||||
const settled = puter.auth.signIn().then(
|
||||
(v) => ({ outcome: 'resolved', value: v }),
|
||||
(e) => ({ outcome: 'rejected', error: e }),
|
||||
);
|
||||
|
||||
// The dialog is a <puter-dialog> custom element with its
|
||||
// markup in a shadow root.
|
||||
let host = null;
|
||||
const dialogDeadline = Date.now() + 5000;
|
||||
while (!host && Date.now() < dialogDeadline) {
|
||||
host = [...document.querySelectorAll('*')].find(
|
||||
(el) => el.shadowRoot && el.shadowRoot.querySelector('#launch-auth-popup'),
|
||||
) ?? null;
|
||||
if (!host) await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
assert(host, "no consent dialog appeared — without a gesture the popup would just be blocked");
|
||||
|
||||
pass("testSignInConsentDialogWithoutGesture passed: consent dialog shown; " +
|
||||
"click Continue to finish signing in, or Cancel to reject (either is fine — the dialog is what this test asserts)");
|
||||
|
||||
// Don't leave the promise dangling as an unhandled rejection
|
||||
// if the dialog is cancelled.
|
||||
settled.catch(() => {});
|
||||
} catch (error) {
|
||||
fail("testSignInConsentDialogWithoutGesture failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,180 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { FIXTURE_URL } from '../helpers/testApp.js';
|
||||
|
||||
const FIXTURE = FIXTURE_URL.replace(
|
||||
'menubar-contextmenu.html',
|
||||
'request-permission.html',
|
||||
);
|
||||
|
||||
/**
|
||||
* A sign-in popup ends by handing the opener a user-app token. On a
|
||||
* cross-origin-isolated opener that hand-off goes through `/login/set`, which
|
||||
* the unauthenticated `/login/wait` then serves to whoever holds the session
|
||||
* id — so unlike the `postMessage` route it is not origin-bound by the
|
||||
* browser, and the popup's own gates are what stand between a link and a
|
||||
* token.
|
||||
*
|
||||
* These cover the two ways a link used to get past those gates: naming an
|
||||
* opener in the URL, and asserting in the URL that a login already happened.
|
||||
*/
|
||||
test.describe('popup sign-in cannot be driven from a link', () => {
|
||||
/**
|
||||
* Start a `/login/wait` long poll on the page, open `popupUrl`, and report
|
||||
* whether a token ever came back. Absence of a leak reads as 'waiting'.
|
||||
*/
|
||||
const probe = async (page, session, popupUrl) => {
|
||||
await page.goto('/');
|
||||
await page.waitForFunction(() => !!window.puter?.authToken, null, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.goto(FIXTURE);
|
||||
await page.locator('body.ready').waitFor({ timeout: 60_000 });
|
||||
|
||||
const [popup] = await Promise.all([
|
||||
page.waitForEvent('popup'),
|
||||
page.evaluate(
|
||||
({ s, u }) => {
|
||||
window.__leaked = 'waiting';
|
||||
(async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
const r = await fetch(
|
||||
`${puter.APIOrigin}/login/wait`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ session: s }),
|
||||
},
|
||||
);
|
||||
if (r.ok && (await r.json())?.auth_token) {
|
||||
window.__leaked = 'leaked';
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
/* keep waiting */
|
||||
}
|
||||
}
|
||||
})();
|
||||
window.open(
|
||||
u.replace('__GUI__', puter.defaultGUIOrigin),
|
||||
'signin-probe',
|
||||
'width=600,height=700',
|
||||
);
|
||||
},
|
||||
{ s: session, u: popupUrl },
|
||||
),
|
||||
]);
|
||||
return popup;
|
||||
};
|
||||
|
||||
test('an action-less popup does not mint and relay a token on its own', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The reported zero-click. A popup URL with no `action` missed the
|
||||
// account picker (which keyed off `action === 'sign-in'`) and, on a
|
||||
// single-session account, the multi-account picker too — so it showed
|
||||
// the user nothing and still ended in a token on the relay.
|
||||
const session = '11111111-2222-4333-8444-666666666666';
|
||||
const popup = await probe(
|
||||
page,
|
||||
session,
|
||||
'__GUI__/?embedded_in_popup=true&cross_origin_isolated=true' +
|
||||
`&signin_session=${session}&msg_id=88`,
|
||||
);
|
||||
|
||||
// The user is asked, rather than the popup deciding for them.
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(await page.evaluate(() => window.__leaked)).toBe('waiting');
|
||||
});
|
||||
|
||||
test('`opener_origin` cannot name the app a sign-in token is minted for', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The opener's origin picks the app identity the token belongs to.
|
||||
// Taken from the link, it let any site have a token minted in another
|
||||
// app's name; it now comes only from sources the browser vouches for.
|
||||
const session = '11111111-2222-4333-8444-777777777777';
|
||||
const popup = await probe(
|
||||
page,
|
||||
session,
|
||||
'__GUI__/?embedded_in_popup=true&cross_origin_isolated=true' +
|
||||
`&signin_session=${session}&msg_id=89` +
|
||||
'&opener_origin=https%3A%2F%2Fconsole.puter.com',
|
||||
);
|
||||
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.waitForTimeout(5000);
|
||||
expect(await page.evaluate(() => window.__leaked)).toBe('waiting');
|
||||
});
|
||||
|
||||
test('`oidc_login` in the URL does not skip the account picker', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The backend appends this on a genuine OIDC return leg, but as a bare
|
||||
// query parameter anyone can write it — and it suppressed the picker
|
||||
// outright. Both it and the opener's origin now come from the signed
|
||||
// `opener_state` proof, which only the server can mint.
|
||||
const session = '11111111-2222-4333-8444-888888888888';
|
||||
const popup = await probe(
|
||||
page,
|
||||
session,
|
||||
'__GUI__/action/sign-in?embedded_in_popup=true' +
|
||||
'&cross_origin_isolated=true&oidc_login=true' +
|
||||
`&signin_session=${session}&msg_id=90`,
|
||||
);
|
||||
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(await page.evaluate(() => window.__leaked)).toBe('waiting');
|
||||
});
|
||||
|
||||
test('a forged opener_state is not believed', async ({ page }) => {
|
||||
// Only the server holds the signing key, so a made-up proof is refused
|
||||
// by /auth/oidc/verify-popup-return and the popup falls back to its
|
||||
// browser-attested opener.
|
||||
const session = '11111111-2222-4333-8444-aaaaaaaaaaaa';
|
||||
const popup = await probe(
|
||||
page,
|
||||
session,
|
||||
'__GUI__/action/sign-in?embedded_in_popup=true' +
|
||||
'&cross_origin_isolated=true&oidc_login=true' +
|
||||
'&opener_state=not.a.real.proof' +
|
||||
`&signin_session=${session}&msg_id=92`,
|
||||
);
|
||||
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(await page.evaluate(() => window.__leaked)).toBe('waiting');
|
||||
});
|
||||
|
||||
test('dismissing the account picker leaves the opener with no token', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The picker used to gate only the early token exchange; the delivery
|
||||
// in `postAuthActions` ran regardless, so declining still signed the
|
||||
// site in.
|
||||
const session = '11111111-2222-4333-8444-999999999999';
|
||||
const popup = await probe(
|
||||
page,
|
||||
session,
|
||||
'__GUI__/action/sign-in?embedded_in_popup=true' +
|
||||
'&cross_origin_isolated=true' +
|
||||
`&signin_session=${session}&msg_id=91`,
|
||||
);
|
||||
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await popup.close();
|
||||
await page.waitForTimeout(5000);
|
||||
expect(await page.evaluate(() => window.__leaked)).toBe('waiting');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user