From 08d17083784a1c99e8470edf3e4f8fb60b239684 Mon Sep 17 00:00:00 2001 From: Neal Shah <30693865+ProgrammerIn-wonderland@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:55:01 -0400 Subject: [PATCH] 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 --- .../controllers/auth/AuthController.test.ts | 159 +++++++++++ .../controllers/auth/AuthController.ts | 69 +++++ .../controllers/oidc/OIDCController.test.ts | 96 +++++++ .../controllers/oidc/OIDCController.ts | 85 ++++++ src/backend/services/auth/OIDCService.ts | 73 +++-- src/gui/src/initgui.js | 139 ++++++++-- src/gui/src/util/popupAuth.js | 62 ++--- src/gui/src/util/popupAuth.test.js | 35 +-- src/gui/src/util/popupOidcReturn.js | 91 +++++++ src/gui/src/util/popupOidcReturn.test.js | 136 ++++++++++ src/puter-js/src/modules/Auth.js | 10 +- src/puter-js/src/modules/UI.js | 54 +++- src/puter-js/test/index.html | 2 + src/puter-js/test/signin.test.js | 254 ++++++++++++++++++ .../tests/e2e/specs/popupSignIn.spec.js | 180 +++++++++++++ 15 files changed, 1334 insertions(+), 111 deletions(-) create mode 100644 src/gui/src/util/popupOidcReturn.js create mode 100644 src/gui/src/util/popupOidcReturn.test.js create mode 100644 src/puter-js/test/signin.test.js create mode 100644 src/puter-js/tests/e2e/specs/popupSignIn.spec.js diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 91e502211..3d9c055f8 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -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, + 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(); + }); +}); diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 6a9cdaa25..8adbffdab 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -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(); 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; diff --git a/src/backend/controllers/oidc/OIDCController.test.ts b/src/backend/controllers/oidc/OIDCController.test.ts index 9dd6d0831..5d4a9dbcb 100644 --- a/src/backend/controllers/oidc/OIDCController.test.ts +++ b/src/backend/controllers/oidc/OIDCController.test.ts @@ -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 }); + }); +}); diff --git a/src/backend/controllers/oidc/OIDCController.ts b/src/backend/controllers/oidc/OIDCController.ts index 784360e37..4b7a8f5ac 100644 --- a/src/backend/controllers/oidc/OIDCController.ts +++ b/src/backend/controllers/oidc/OIDCController.ts @@ -66,6 +66,11 @@ function buildErrorRedirectUrl( message: string, stateDecoded?: Record, 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, ): 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) { diff --git a/src/backend/services/auth/OIDCService.ts b/src/backend/services/auth/OIDCService.ts index 6a9c65c2f..e4dbe8bc0 100644 --- a/src/backend/services/auth/OIDCService.ts +++ b/src/backend/services/auth/OIDCService.ts @@ -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 . + * 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..{ client_id, client_secret, ... }` + * Config shape: `config.oidc.providers..{ client_id, client_secret, + * ... }` */ export class OIDCService extends PuterService { declare protected services: LayerInstances; @@ -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 { + 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 | null { + return this.verifyState(token); + } + verifyState(token: string): Record | null { try { return this.services.token.verify>( @@ -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 { @@ -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, diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index eeafcccb9..0380d8499 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -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(); diff --git a/src/gui/src/util/popupAuth.js b/src/gui/src/util/popupAuth.js index 26aa81897..c8612942e 100644 --- a/src/gui/src/util/popupAuth.js +++ b/src/gui/src/util/popupAuth.js @@ -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/` or `?action=`); 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/` or `?action=`); undefined for a plain diff --git a/src/gui/src/util/popupAuth.test.js b/src/gui/src/util/popupAuth.test.js index b3c4d911b..3287b71a7 100644 --- a/src/gui/src/util/popupAuth.test.js +++ b/src/gui/src/util/popupAuth.test.js @@ -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(); }); }); diff --git a/src/gui/src/util/popupOidcReturn.js b/src/gui/src/util/popupOidcReturn.js new file mode 100644 index 000000000..bc100f153 --- /dev/null +++ b/src/gui/src/util/popupOidcReturn.js @@ -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 . + */ + +/** + * 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, + }; +}; diff --git a/src/gui/src/util/popupOidcReturn.test.js b/src/gui/src/util/popupOidcReturn.test.js new file mode 100644 index 000000000..c82b9cab7 --- /dev/null +++ b/src/gui/src/util/popupOidcReturn.test.js @@ -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 . + */ + +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(); + }); +}); diff --git a/src/puter-js/src/modules/Auth.js b/src/puter-js/src/modules/Auth.js index 0b7e7a623..eff497d2f 100644 --- a/src/puter-js/src/modules/Auth.js +++ b/src/puter-js/src/modules/Auth.js @@ -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} + * `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} */ 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. diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index 3b716e78c..bc8ed926e 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -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; + }, + ); }; /** diff --git a/src/puter-js/test/index.html b/src/puter-js/test/index.html index 79526fa39..cd45277af 100644 --- a/src/puter-js/test/index.html +++ b/src/puter-js/test/index.html @@ -13,6 +13,7 @@ +