change cors auth path (#3464)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

* 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:
Neal Shah
2026-07-31 01:55:01 -04:00
committed by GitHub
co-authored by Daniel Salazar
parent f1bc065730
commit 08d1708378
15 changed files with 1334 additions and 111 deletions
@@ -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 elses 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) {
+51 -22
View File
@@ -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
View File
@@ -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();
+28 -34
View File
@@ -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
+11 -24
View File
@@ -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();
});
});
+91
View File
@@ -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,
};
};
+136
View File
@@ -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();
});
});
+8 -2
View File
@@ -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.
+45 -9
View File
@@ -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;
},
);
};
/**
+2
View File
@@ -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;
+254
View File
@@ -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');
});
});