diff --git a/Cargo.lock b/Cargo.lock index 903d304ab8..14a5b3cc06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2274,6 +2274,7 @@ dependencies = [ "candid_parser", "canister_tests", "flate2", + "form_urlencoded", "ic-asset-certification", "ic-cdk 0.16.1", "ic-cdk-macros 0.16.0", diff --git a/src/canister_tests/src/api.rs b/src/canister_tests/src/api.rs index a06b5bc4b0..0a797f2ddf 100644 --- a/src/canister_tests/src/api.rs +++ b/src/canister_tests/src/api.rs @@ -1,6 +1,6 @@ use ic_cdk::api::management_canister::main::CanisterId; use internet_identity_interface::http_gateway::{HttpRequest, HttpResponse}; -use pocket_ic::{query_candid, PocketIc, RejectResponse}; +use pocket_ic::{query_candid, update_candid, PocketIc, RejectResponse}; pub mod archive; pub mod internet_identity; @@ -14,3 +14,13 @@ pub fn http_request( ) -> Result { query_candid(env, canister_id, "http_request", (http_request,)).map(|(x,)| x) } + +/// Call `http_request_update` directly, like the HTTP gateway does after +/// `http_request` responds with `upgrade = Some(true)`. +pub fn http_request_update( + env: &PocketIc, + canister_id: CanisterId, + http_request: &HttpRequest, +) -> Result { + update_candid(env, canister_id, "http_request_update", (http_request,)).map(|(x,)| x) +} diff --git a/src/frontend/src/hooks.server.ts b/src/frontend/src/hooks.server.ts index 186b67213a..5132517ca8 100644 --- a/src/frontend/src/hooks.server.ts +++ b/src/frontend/src/hooks.server.ts @@ -3,25 +3,63 @@ import { building } from "$app/environment"; import { localeStore } from "$lib/stores/locale.store"; import { execSync } from "child_process"; +/** Origin of the locally deployed frontend canister, via the replica gateway. */ +const frontendCanisterOrigin = (): string => { + const canisterId = execSync( + "icp canister status internet_identity_frontend --id-only", + ) + .toString() + .trim(); + const port = new URL( + JSON.parse(execSync("icp network status --json").toString()).gateway_url, + ).port; + return `http://${canisterId}.localhost:${port}`; +}; + export const handle: Handle = async ({ event, resolve }) => { + // OpenID providers deliver the OAuth response with `response_mode=form_post`: + // a form POST to `/callback`. In production the frontend canister translates + // it into a certified HTML page that hands the payload to the frontend. The + // hot-reload dev server has no canister in front of it, so forward the POST + // to the deployed canister (always installed when working on OpenID) and + // return its response — dev exercises the real translator + // (`src/internet_identity_frontend/src/callback.rs`) rather than a + // reimplementation. In `NO_HOT_RELOAD` e2e the `replicaForwardPlugin` + // forwards this POST to the canister before SvelteKit sees it. + if ( + !building && + event.request.method === "POST" && + event.url.pathname === "/callback" + ) { + const forwarded = await fetch(`${frontendCanisterOrigin()}/callback`, { + method: "POST", + headers: { + "content-type": + event.request.headers.get("content-type") ?? + "application/x-www-form-urlencoded", + }, + body: await event.request.text(), + }); + // `fetch` has already decoded the body, so drop the headers describing the + // now-absent encoding before handing the response back to the browser. + const headers = new Headers(forwarded.headers); + headers.delete("content-encoding"); + headers.delete("content-length"); + return new Response(await forwarded.text(), { + status: forwarded.status, + headers, + }); + } + const response = await resolve(event); if ( !building && response.headers.get("Content-Type") === "text/html" && response.ok ) { - // Get frontend canister id and then fetch it's HTML - const canisterId = execSync( - "icp canister status internet_identity_frontend --id-only", - ) - .toString() - .trim(); - const port = new URL( - JSON.parse(execSync("icp network status --json").toString()).gateway_url, - ).port; - const canisterResponse = await fetch( - `http://${canisterId}.localhost:${port}`, - ); + // Fetch the frontend canister's HTML to recover its tag, which + // carries the injected canister ID and config. + const canisterResponse = await fetch(frontendCanisterOrigin()); const canisterHtml = await canisterResponse.text(); // Replace the body tag in the dev server HTML with the one from the canister HTML diff --git a/src/frontend/src/lib/utils/openID.test.ts b/src/frontend/src/lib/utils/openID.test.ts index 0a2bf97eaf..5c22b04371 100644 --- a/src/frontend/src/lib/utils/openID.test.ts +++ b/src/frontend/src/lib/utils/openID.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { + CallbackPopupClosedError, + createRedirectURL, findConfig, issuerMatches, extractIssuerTemplateClaims, @@ -10,7 +12,6 @@ import { } from "./openID"; import { OpenIdConfig } from "$lib/generated/internet_identity_types"; import { backendCanisterConfig } from "$lib/globals"; -import { CallbackPopupClosedError } from "../../routes/(new-styling)/callback/utils"; vi.mock("$lib/globals", () => ({ backendCanisterConfig: { @@ -321,13 +322,11 @@ describe("selectAuthScopes", () => { describe("extractIdTokenFromCallback", () => { const STATE = "expected-state"; - const callback = (fragment: string) => - `https://example.id.ai/callback${fragment.length > 0 ? `#${fragment}` : ""}`; it("returns the id_token when state matches and no error is present", () => { expect( extractIdTokenFromCallback( - callback(`state=${STATE}&id_token=eyJhbGciOi.test.token`), + { id_token: "eyJhbGciOi.test.token", state: STATE }, STATE, ), ).toBe("eyJhbGciOi.test.token"); @@ -339,9 +338,12 @@ describe("extractIdTokenFromCallback", () => { let thrown: unknown; try { extractIdTokenFromCallback( - callback( - `state=${STATE}&error=unsupported_response_type&error_description=The+response+type+is+not+supported+by+the+authorization+server.+Configured+response+types%3A+%5Bcode%5D`, - ), + { + state: STATE, + error: "unsupported_response_type", + error_description: + "The response type is not supported by the authorization server. Configured response types: [code]", + }, STATE, ); } catch (e) { @@ -361,7 +363,25 @@ describe("extractIdTokenFromCallback", () => { let thrown: unknown; try { extractIdTokenFromCallback( - callback(`state=${STATE}&error=access_denied`), + { state: STATE, error: "access_denied" }, + STATE, + ); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(OAuthProviderError); + const err = thrown as OAuthProviderError; + expect(err.error).toBe("access_denied"); + expect(err.errorDescription).toBeUndefined(); + }); + + it("normalizes a null error_description to undefined", () => { + // The canister serializes an absent error_description as JSON null + // rather than omitting the key, so the parsed payload carries null. + let thrown: unknown; + try { + extractIdTokenFromCallback( + { state: STATE, error: "access_denied", error_description: null }, STATE, ); } catch (e) { @@ -375,21 +395,45 @@ describe("extractIdTokenFromCallback", () => { it("checks state before surfacing a provider error", () => { // Guards against a forged callback: an attacker who can inject a - // fragment with a legitimate-looking provider error shouldn't be + // payload with a legitimate-looking provider error shouldn't be // able to influence user-facing messaging without passing the CSRF // check first. expect(() => extractIdTokenFromCallback( - callback(`state=attacker-state&error=unsupported_response_type`), + { state: "attacker-state", error: "unsupported_response_type" }, STATE, ), ).toThrow("Invalid state"); }); it("throws 'Invalid state' when state is missing", () => { + expect(() => + extractIdTokenFromCallback({ id_token: "eyJhbGciOi.test.token" }, STATE), + ).toThrow("Invalid state"); + }); + + it("throws 'Invalid state' when the payload is not an object", () => { + // The payload crosses a BroadcastChannel / sessionStorage JSON + // round-trip, so any shape can arrive — including the URL string the + // legacy fragment-era callback page used to post. expect(() => extractIdTokenFromCallback( - callback(`id_token=eyJhbGciOi.test.token`), + `https://example.id.ai/callback#state=${STATE}&id_token=abc`, + STATE, + ), + ).toThrow("Invalid state"); + expect(() => extractIdTokenFromCallback(undefined, STATE)).toThrow( + "Invalid state", + ); + expect(() => extractIdTokenFromCallback(null, STATE)).toThrow( + "Invalid state", + ); + }); + + it("throws 'Invalid state' when state is not a string", () => { + expect(() => + extractIdTokenFromCallback( + { id_token: "eyJhbGciOi.test.token", state: [STATE] }, STATE, ), ).toThrow("Invalid state"); @@ -397,13 +441,40 @@ describe("extractIdTokenFromCallback", () => { it("throws 'No token received' when the provider omits both id_token and error", () => { // Fallback for a spec-violating provider (e.g. pure auth-code flow - // with no error in the fragment — we'd see `code=...` but no - // `id_token=...`). The callback will still have state for our - // CSRF guard to pass. + // with no token in the POST body — we'd see `code` but no + // `id_token`). The payload will still have state for our CSRF + // guard to pass. expect(() => - extractIdTokenFromCallback(callback(`state=${STATE}&code=abc123`), STATE), + extractIdTokenFromCallback({ state: STATE, code: "abc123" }, STATE), ).toThrow("No token received"); }); + + it("throws 'No token received' when id_token is not a string", () => { + expect(() => + extractIdTokenFromCallback({ state: STATE, id_token: 42 }, STATE), + ).toThrow("No token received"); + }); +}); + +describe("createRedirectURL", () => { + it("requests the form_post response mode", () => { + const url = createRedirectURL( + { + clientId: "test-client", + authURL: "https://idp.example.com/authorize", + authScope: "openid profile email", + }, + { nonce: "test-nonce" }, + ); + expect(url.searchParams.get("response_mode")).toBe("form_post"); + expect(url.searchParams.get("response_type")).toBe("code id_token"); + expect(url.searchParams.get("client_id")).toBe("test-client"); + expect(url.searchParams.get("nonce")).toBe("test-nonce"); + expect(url.searchParams.get("state")).not.toBeNull(); + const redirectUri = url.searchParams.get("redirect_uri"); + expect(redirectUri).not.toBeNull(); + expect(new URL(redirectUri ?? "").pathname).toBe("/callback"); + }); }); describe("OAuthProviderError", () => { diff --git a/src/frontend/src/lib/utils/openID.ts b/src/frontend/src/lib/utils/openID.ts index 9cfb7c18de..7d374d909a 100644 --- a/src/frontend/src/lib/utils/openID.ts +++ b/src/frontend/src/lib/utils/openID.ts @@ -5,11 +5,145 @@ import type { import { backendCanisterConfig } from "$lib/globals"; import { fromBase64URL, toBase64URL } from "$lib/utils/utils"; import { Principal } from "@icp-sdk/core/principal"; -import { - CallbackPopupClosedError, - REDIRECT_CALLBACK_PATH, - redirectInPopup, -} from "../../routes/(new-styling)/callback/utils"; +import { z } from "zod"; +const BROADCAST_CHANNEL = "redirect_callback"; +const REDIRECT_CALLBACK_PATH = "/callback"; + +export class CallbackPopupClosedError extends Error {} + +/** + * Payload the canister's POST /callback landing page delivers to the + * frontend — via `BroadcastChannel` in the popup flow, via the + * `ii-openid-callback-data` sessionStorage entry in the same-tab flow. + * Mirrors what the OAuth callback fragment used to carry: either the token + * or the IdP's RFC 6749 error report, plus the CSRF `state` in both cases. + * + * The canister serializes an absent `error_description` as JSON `null` + * rather than omitting the key, so the schema accepts `null` and normalizes + * it to `undefined`. + */ +const CallbackPayloadSchema = z.union([ + z.object({ id_token: z.string(), state: z.string() }), + z.object({ + error: z.string(), + error_description: z + .string() + .nullish() + .transform((value) => value ?? undefined), + state: z.string(), + }), +]); +export type CallbackPayload = z.infer; + +/** + * Lenient per-field view of the payload for {@link extractIdTokenFromCallback}, + * which must read `state` for its CSRF check even on an otherwise-malformed + * payload. Each field independently falls back to `undefined` when absent or + * not a string, and a non-object input falls back to an empty record, so + * `.parse` never throws. + */ +const CallbackFieldsSchema = z + .object({ + state: z.string().optional().catch(undefined), + id_token: z.string().optional().catch(undefined), + error: z.string().optional().catch(undefined), + error_description: z.string().nullish().catch(undefined), + }) + .catch({}); + +/** + * Whether a value posted on the callback channel (or parsed from the + * sessionStorage entry) is a {@link CallbackPayload}. + */ +const isCallbackPayload = (value: unknown): boolean => + CallbackPayloadSchema.safeParse(value).success; + +/** + * Open a popup that round-trips through an OAuth provider and resolves with + * the callback payload delivered by the canister's POST /callback page. + * + * Accepts either: + * - A `string` URL: navigated to immediately. Used by the synchronous flows + * where the redirect URL is known at click time. + * - A `Promise`: the popup is opened to `about:blank` first + * (synchronously, to consume the user-activation token before any + * `await` — Safari blocks `window.open` after an awaited Promise), + * then navigated once the URL resolves. Used for flows that need an + * async step (e.g. SSO two-hop discovery) before the redirect URL is + * known. If the promise rejects, the popup is closed and the outer + * promise rejects with the same error. + * + * The payload is resolved as `unknown`: it crosses a BroadcastChannel, so + * the consumer (`extractIdTokenFromCallback`) revalidates its shape along + * with the CSRF state check. + */ +const redirectInPopup = (url: string | Promise): Promise => { + const width = 500; + const height = 600; + const left = (window.innerWidth - width) / 2 + window.screenX; + const top = (window.innerHeight - height) / 2 + window.screenY; + // For deferred URLs, open about:blank synchronously so we don't lose + // the user-activation token — same-origin (inherited), so we can later + // navigate via `redirectWindow.location.href = ...` even though we'll + // end up on a cross-origin IdP. + const initialUrl = typeof url === "string" ? url : "about:blank"; + const redirectWindow = window.open( + initialUrl, + "_blank", + `width=${width},height=${height},left=${left},top=${top}`, + ); + if (redirectWindow === null) { + throw new CallbackPopupClosedError(); + } + + return new Promise((resolve, reject) => { + const cleanup = () => { + clearInterval(closeInterval); + channel.close(); + redirectWindow?.close(); + window.focus(); + }; + // Periodically check if popup was closed by the user. + // We can't listen for close events due to cross-origin restrictions, + // so we poll every 500ms to detect closure. The interval balances + // responsiveness with resource consumption. + const closeInterval = setInterval(() => { + if (redirectWindow.closed === true) { + cleanup(); + reject(new CallbackPopupClosedError()); + } + }, 500); + // Listen to the popup, we expect a message with the payload of the + // callback, after receiving it we can close the popup and resolve the + // promise. + const channel = new BroadcastChannel(BROADCAST_CHANNEL); + channel.addEventListener("message", (event) => { + const data: unknown = event.data; + if (!isCallbackPayload(data)) { + return; + } + cleanup(); + resolve(data); + }); + + if (typeof url !== "string") { + url.then( + (resolvedUrl) => { + // The user may have closed the popup or the close-poller may have + // already rejected during the await — `closed` covers both. + if (redirectWindow.closed) { + return; + } + redirectWindow.location.href = resolvedUrl; + }, + (error: unknown) => { + cleanup(); + reject(error); + }, + ); + } + }); +}; export interface RequestConfig { // OAuth client ID @@ -94,8 +228,8 @@ export const isOpenIdCancelError = (error: unknown) => { }; /** - * Raised when an OAuth provider redirects back to II with an `error` (and - * optional `error_description`) in the callback fragment — per RFC 6749 + * Raised when an OAuth provider reports an `error` (and optional + * `error_description`) through the callback — per RFC 6749 * §4.1.2.1 / 4.2.2.1. Typical causes are the SSO app being misconfigured: * • `unsupported_response_type` — the Okta/Auth0/etc. app doesn't allow * the hybrid flow we request (`response_type=id_token code`). @@ -137,7 +271,11 @@ export const createRedirectURL = ( // Even though we only need an id token, we're still asking for a code // because some identity providers (AppleID) will throw an error otherwise. authURL.searchParams.set("response_type", "code id_token"); - authURL.searchParams.set("response_mode", "fragment"); + // The IdP POSTs the response to the canister's /callback handler, which + // returns certified HTML that delivers the payload to the frontend. Unlike + // `fragment`, `form_post` works across Okta/Auth0/Apple and never puts the + // id_token in a URL. + authURL.searchParams.set("response_mode", "form_post"); authURL.searchParams.set("client_id", config.clientId); authURL.searchParams.set("redirect_uri", redirectURL.href); authURL.searchParams.set("scope", config.authScope); @@ -157,38 +295,35 @@ export const createRedirectURL = ( }; /** - * Parse the OAuth authorize callback URL and extract the `id_token`. + * Validate the callback payload delivered by the canister's POST /callback + * landing page and extract the `id_token`. * - * Exported so `requestWithPopup` and tests can share a single source of - * truth for how a callback fragment is interpreted. Throws: - * - `Error("Invalid state")` if the callback's `state` doesn't match + * Exported so `requestWithPopup`, `resumeOpenId` and tests can share a + * single source of truth for how a callback payload is interpreted. The + * payload arrives as `unknown` (it crosses a BroadcastChannel or a + * sessionStorage JSON round-trip), so the shape is revalidated here. Throws: + * - `Error("Invalid state")` if the payload's `state` doesn't match * `expectedState` (CSRF guard). - * - `OAuthProviderError` if the callback carries an `error=...` - * fragment (RFC 6749 §4.1.2.1 / 4.2.2.1) — checked BEFORE the - * `id_token` null-check so a misconfigured SSO app surfaces its - * own message instead of a generic "No token received" that looks - * like a bug in II. + * - `OAuthProviderError` if the payload carries an `error` field + * (RFC 6749 §4.1.2.1 / 4.2.2.1) — checked BEFORE the `id_token` + * check so a misconfigured SSO app surfaces its own message instead + * of a generic "No token received" that looks like a bug in II. * - `Error("No token received")` if neither `id_token` nor `error` - * was in the fragment (fallback for spec-violating providers). + * is present (fallback for spec-violating providers). */ export const extractIdTokenFromCallback = ( - callback: string, + callback: unknown, expectedState: string, ): string => { - const callbackURL = new URL(callback); - const searchParams = new URLSearchParams(callbackURL.hash.slice(1)); - if (searchParams.get("state") !== expectedState) { + const { state, id_token, error, error_description } = + CallbackFieldsSchema.parse(callback); + if (state !== expectedState) { throw new Error("Invalid state"); } - const error = searchParams.get("error"); - if (error !== null) { - throw new OAuthProviderError( - error, - searchParams.get("error_description") ?? undefined, - ); + if (error !== undefined) { + throw new OAuthProviderError(error, error_description ?? undefined); } - const id_token = searchParams.get("id_token"); - if (id_token === null) { + if (id_token === undefined) { throw new Error("No token received"); } return id_token; diff --git a/src/frontend/src/routes/(new-styling)/authorize/+page.svelte b/src/frontend/src/routes/(new-styling)/authorize/+page.svelte index f649666d96..9b4b2def11 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/authorize/+page.svelte @@ -52,7 +52,10 @@ DirectOpenIdEvents, directOpenIdFunnel, } from "$lib/utils/analytics/DirectOpenIdFunnel"; - import { createRedirectURL } from "$lib/utils/openID"; + import { + createRedirectURL, + extractIdTokenFromCallback, + } from "$lib/utils/openID"; import { sessionStore } from "$lib/stores/session.store"; import { anonymousActor } from "$lib/globals"; import { discoverSsoConfig } from "$lib/utils/ssoDiscovery"; @@ -221,22 +224,36 @@ /** Process the OpenID callback and authorize. */ const resumeOpenId = async () => { - const searchParams = new URLSearchParams(window.location.hash.slice(1)); + // The canister's POST /callback landing page stashes the payload here + // before navigating to `/authorize?flow=openid-resume` in the same-tab + // flow. Single-use: remove it before anything else can throw. + const storedPayload = sessionStorage.getItem("ii-openid-callback-data"); + sessionStorage.removeItem("ii-openid-callback-data"); window.history.replaceState( undefined, "", window.location.origin + "/authorize", ); - const redirectState = searchParams.get("state"); - const jwt = searchParams.get("id_token"); const openIdAuthorizeState = sessionStorage.getItem( "ii-openid-authorize-state", ); - if ( - openIdAuthorizeState === null || - redirectState !== openIdAuthorizeState || - jwt === null - ) { + // The callback landing page routes on this marker (present = resume + // in-app, absent = deliver to the opener); popups inherit a copy of + // sessionStorage in Chrome, so a stale marker would misroute a later + // popup sign-in from this tab. + sessionStorage.removeItem("ii-openid-authorize-state"); + if (storedPayload === null || openIdAuthorizeState === null) { + return; + } + let jwt: string; + try { + jwt = extractIdTokenFromCallback( + JSON.parse(storedPayload), + openIdAuthorizeState, + ); + } catch { + // A state mismatch, an IdP error report or a missing token falls + // back to the regular flow, matching the fragment-era behavior. return; } const authFlow = new AuthFlow({ trackLastUsed: false }); diff --git a/src/frontend/src/routes/(new-styling)/callback/+page.svelte b/src/frontend/src/routes/(new-styling)/callback/+page.svelte deleted file mode 100644 index b89fbb2552..0000000000 --- a/src/frontend/src/routes/(new-styling)/callback/+page.svelte +++ /dev/null @@ -1,29 +0,0 @@ - diff --git a/src/frontend/src/routes/(new-styling)/callback/utils.ts b/src/frontend/src/routes/(new-styling)/callback/utils.ts deleted file mode 100644 index e1167c0b38..0000000000 --- a/src/frontend/src/routes/(new-styling)/callback/utils.ts +++ /dev/null @@ -1,93 +0,0 @@ -const BROADCAST_CHANNEL = "redirect_callback"; -export const REDIRECT_CALLBACK_PATH = "/callback"; - -export class CallbackPopupClosedError extends Error {} - -/** - * Open a popup that round-trips through an OAuth provider and resolves with - * the callback URL. - * - * Accepts either: - * - A `string` URL: navigated to immediately. Used by the synchronous flows - * where the redirect URL is known at click time. - * - A `Promise`: the popup is opened to `about:blank` first - * (synchronously, to consume the user-activation token before any - * `await` — Safari blocks `window.open` after an awaited Promise), - * then navigated once the URL resolves. Used for flows that need an - * async step (e.g. SSO two-hop discovery) before the redirect URL is - * known. If the promise rejects, the popup is closed and the outer - * promise rejects with the same error. - */ -export const redirectInPopup = ( - url: string | Promise, -): Promise => { - const width = 500; - const height = 600; - const left = (window.innerWidth - width) / 2 + window.screenX; - const top = (window.innerHeight - height) / 2 + window.screenY; - // For deferred URLs, open about:blank synchronously so we don't lose - // the user-activation token — same-origin (inherited), so we can later - // navigate via `redirectWindow.location.href = ...` even though we'll - // end up on a cross-origin IdP. - const initialUrl = typeof url === "string" ? url : "about:blank"; - const redirectWindow = window.open( - initialUrl, - "_blank", - `width=${width},height=${height},left=${left},top=${top}`, - ); - if (redirectWindow === null) { - throw new CallbackPopupClosedError(); - } - - return new Promise((resolve, reject) => { - const cleanup = () => { - clearInterval(closeInterval); - channel.close(); - redirectWindow?.close(); - window.focus(); - }; - // Periodically check if popup was closed by the user. - // We can't listen for close events due to cross-origin restrictions, - // so we poll every 500ms to detect closure. The interval balances - // responsiveness with resource consumption. - const closeInterval = setInterval(() => { - if (redirectWindow.closed === true) { - cleanup(); - reject(new CallbackPopupClosedError()); - } - }, 500); - // Listen to the popup, we expect a message with the url of the callback, - // after receiving it we can close the popup and resolve the promise. - const channel = new BroadcastChannel(BROADCAST_CHANNEL); - channel.addEventListener("message", (event) => { - if (typeof event.data !== "string") { - return; - } - cleanup(); - resolve(event.data); - }); - - if (typeof url !== "string") { - url.then( - (resolvedUrl) => { - // The user may have closed the popup or the close-poller may have - // already rejected during the await — `closed` covers both. - if (redirectWindow.closed) { - return; - } - redirectWindow.location.href = resolvedUrl; - }, - (error: unknown) => { - cleanup(); - reject(error); - }, - ); - } - }); -}; - -export const sendUrlToOpener = (): void => { - const channel = new BroadcastChannel(BROADCAST_CHANNEL); - channel.postMessage(window.location.href); - channel.close(); -}; diff --git a/src/frontend/src/routes/+layout.svelte b/src/frontend/src/routes/+layout.svelte index 4d099fdf1d..75642ba067 100644 --- a/src/frontend/src/routes/+layout.svelte +++ b/src/frontend/src/routes/+layout.svelte @@ -23,8 +23,6 @@ window.location.pathname !== "/self-service" && // Don't redirect if we're visiting webauthn iframe used for migration window.location.pathname !== "/iframe/webauthn" && - // Don't redirect if we're visiting callback used for OpenID flows - window.location.pathname !== "/callback" && // Don't redirect if we're visiting new authorize flow // TODO: Implement redirect with pending ICRC-29 state window.location.pathname !== "/authorize" diff --git a/src/internet_identity_frontend/Cargo.toml b/src/internet_identity_frontend/Cargo.toml index 497588483b..373d7b789c 100644 --- a/src/internet_identity_frontend/Cargo.toml +++ b/src/internet_identity_frontend/Cargo.toml @@ -21,6 +21,7 @@ include_dir.workspace = true # Asset utilities and encoding asset_util.workspace = true base64.workspace = true +form_urlencoded = "1.2" serde_json = { version = "1.0", default-features = false, features = ["std"] } sha2.workspace = true lazy_static.workspace = true diff --git a/src/internet_identity_frontend/internet_identity_frontend.did b/src/internet_identity_frontend/internet_identity_frontend.did index 97dbfd7b59..6b3f7e773f 100644 --- a/src/internet_identity_frontend/internet_identity_frontend.did +++ b/src/internet_identity_frontend/internet_identity_frontend.did @@ -67,4 +67,7 @@ type InternetIdentityFrontendInit = record { service : (InternetIdentityFrontendInit) -> { http_request : (request : HttpRequest) -> (HttpResponse) query; + // Handles the OAuth `response_mode=form_post` callback POSTed by the IdP + // to /callback, upgraded from `http_request` so the response is certified. + http_request_update : (request : HttpRequest) -> (HttpResponse); }; diff --git a/src/internet_identity_frontend/src/callback.rs b/src/internet_identity_frontend/src/callback.rs new file mode 100644 index 0000000000..9e7f6d2a25 --- /dev/null +++ b/src/internet_identity_frontend/src/callback.rs @@ -0,0 +1,527 @@ +//! OAuth `response_mode=form_post` callback translator. +//! +//! The IdP POSTs `{id_token, state}` (or `{error, error_description, state}` +//! per RFC 6749 §4.1.2.1) as a form body to `/callback`. The POST arrives +//! anonymously — the IdP submits the form, not the user's session — so this +//! handler cannot redeem the JWT itself: the salt + nonce + `caller()` +//! binding requires a signed ingress message from the user's session. It is +//! a transport translator only: parse the form body, return certified HTML +//! that hands the payload to the frontend via `BroadcastChannel` (popup +//! flow) or `sessionStorage` (same-tab flow), where the existing JWT +//! redemption flow takes over. +//! +//! The handler runs in update mode (`http_request` upgrades the POST) so the +//! response is certified via consensus — an uncertified dynamic HTML response +//! would be rejected by the HTTP gateway. + +use crate::dynamic_response_headers; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use ic_http_certification::{HeaderField, HttpRequest, HttpResponse, Method, StatusCode}; +use sha2::Digest; +use std::borrow::Cow; + +pub const CALLBACK_PATH: &str = "/callback"; + +/// Upper bound on the accepted form body. The largest legitimate body is an +/// `id_token` near its own cap plus the small `state` and `code` fields; +/// anything bigger is rejected before parsing. +const MAX_BODY_BYTES: usize = 16 * 1024; +const MAX_ID_TOKEN_BYTES: usize = 8192; +const MAX_STATE_BYTES: usize = 64; +const MAX_ERROR_BYTES: usize = 256; +const MAX_ERROR_DESCRIPTION_BYTES: usize = 1024; + +/// The single executable inline script of the landing page. Constant so its +/// CSP hash is constant; the per-request payload lives in a non-executing +/// ` + + +"# + ); + // Scope the CSP to this page instead of inheriting the SPA's permissive + // `script-src 'self' 'unsafe-inline' 'unsafe-eval'` (needed for SvelteKit + // + agent-js wasm). This page runs exactly one known inline script, so + // pinning to its hash with no `unsafe-inline`/`unsafe-eval`/`'self'` + // fallback makes the hash genuinely load-bearing rather than relying on + // the CSP3 rule that a hash makes browsers ignore `unsafe-inline`. + let csp = format!( + "default-src 'none'; script-src '{}'; base-uri 'none'; frame-ancestors 'none'", + callback_script_hash() + ); + html_response(StatusCode::OK, html, csp) +} + +/// Static page for bodies that can't be translated. `reason` is always one +/// of the fixed strings from [`parse_form_post`], never attacker-controlled. +fn render_error_page(reason: &str) -> HttpResponse<'static> { + let html = format!( + r#" + + + +Internet Identity + + +

Sign-in could not be completed: {reason}. Close this window and try again.

+ +"# + ); + // No inline script on the error page, so deny scripts entirely. + html_response( + StatusCode::BAD_REQUEST, + html, + "default-src 'none'; base-uri 'none'; frame-ancestors 'none'".to_string(), + ) +} + +fn html_response( + status_code: StatusCode, + html: String, + content_security_policy: String, +) -> HttpResponse<'static> { + // Take the shared security headers, then swap in this page's own CSP in + // place of the SPA-wide one (see `render_callback_landing`). + let mut headers: Vec = dynamic_response_headers(vec![ + ("content-type".to_string(), "text/html".to_string()), + // The payload is single-use and session-bound; never cache it. + ("cache-control".to_string(), "no-store".to_string()), + ]) + .into_iter() + .filter(|(name, _)| !name.eq_ignore_ascii_case("content-security-policy")) + .collect(); + headers.push(( + "Content-Security-Policy".to_string(), + content_security_policy, + )); + HttpResponse::builder() + .with_status_code(status_code) + .with_headers(headers) + .with_body(html.into_bytes()) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn encode_form(pairs: &[(&str, &str)]) -> Vec { + let mut serializer = form_urlencoded::Serializer::new(String::new()); + for (key, value) in pairs { + serializer.append_pair(key, value); + } + serializer.finish().into_bytes() + } + + const ID_TOKEN: &str = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJ0ZXN0In0.c2lnbmF0dXJl"; + const STATE: &str = "Y2FsbGJhY2stc3RhdGU"; + + #[test] + fn parses_token_payload_and_ignores_unknown_fields() { + let body = encode_form(&[ + ("code", "4/abc-def"), + ("id_token", ID_TOKEN), + ("state", STATE), + ("session_state", "ignored"), + ]); + assert_eq!( + parse_form_post(&body).unwrap(), + CallbackPayload::Token { + id_token: ID_TOKEN.to_string(), + state: STATE.to_string(), + } + ); + } + + #[test] + fn parses_provider_error_payload() { + let body = encode_form(&[ + ("error", "unsupported_response_type"), + ( + "error_description", + "The response type is not supported by the authorization server.", + ), + ("state", STATE), + ]); + assert_eq!( + parse_form_post(&body).unwrap(), + CallbackPayload::ProviderError { + error: "unsupported_response_type".to_string(), + error_description: Some( + "The response type is not supported by the authorization server.".to_string() + ), + state: STATE.to_string(), + } + ); + } + + #[test] + fn parses_provider_error_without_description() { + let body = encode_form(&[("error", "access_denied"), ("state", STATE)]); + assert_eq!( + parse_form_post(&body).unwrap(), + CallbackPayload::ProviderError { + error: "access_denied".to_string(), + error_description: None, + state: STATE.to_string(), + } + ); + } + + #[test] + fn token_wins_over_error_when_both_present() { + // A spec-violating body carrying both: treat as success, matching the + // frontend's order of checking the token first only after the state. + let body = encode_form(&[ + ("id_token", ID_TOKEN), + ("error", "access_denied"), + ("state", STATE), + ]); + assert!(matches!( + parse_form_post(&body).unwrap(), + CallbackPayload::Token { .. } + )); + } + + #[test] + fn first_occurrence_wins_for_duplicate_keys() { + let body = encode_form(&[ + ("id_token", ID_TOKEN), + ("id_token", "second.token.ignored"), + ("state", STATE), + ]); + assert_eq!( + parse_form_post(&body).unwrap(), + CallbackPayload::Token { + id_token: ID_TOKEN.to_string(), + state: STATE.to_string(), + } + ); + } + + #[test] + fn rejects_missing_state() { + let body = encode_form(&[("id_token", ID_TOKEN)]); + assert_eq!(parse_form_post(&body), Err("missing state")); + } + + #[test] + fn rejects_missing_token_and_error() { + let body = encode_form(&[("state", STATE), ("code", "4/abc")]); + assert_eq!(parse_form_post(&body), Err("missing id_token")); + } + + #[test] + fn rejects_invalid_token_charset() { + let body = encode_form(&[("id_token", "ey&".to_string()), + state: STATE.to_string(), + }; + let json = payload_json(&payload); + assert!(!json.contains('<')); + assert!(!json.contains('>')); + assert!(!json.contains('&')); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!( + parsed["error_description"].as_str().unwrap(), + "&" + ); + } + + #[test] + fn payload_json_round_trips_token_payload() { + let payload = CallbackPayload::Token { + id_token: ID_TOKEN.to_string(), + state: STATE.to_string(), + }; + let parsed: serde_json::Value = serde_json::from_str(&payload_json(&payload)).unwrap(); + assert_eq!(parsed["id_token"].as_str().unwrap(), ID_TOKEN); + assert_eq!(parsed["state"].as_str().unwrap(), STATE); + } + + #[test] + fn landing_page_embeds_payload_and_pins_script_hash() { + let response = + handle_form_post_callback(&encode_form(&[("id_token", ID_TOKEN), ("state", STATE)])); + assert_eq!(response.status_code(), StatusCode::OK); + let body = std::str::from_utf8(response.body()).unwrap(); + assert!(body.contains(ID_TOKEN)); + assert!(body.contains(CALLBACK_SCRIPT)); + let csp_headers: Vec<&str> = response + .headers() + .iter() + .filter(|(name, _)| name.eq_ignore_ascii_case("content-security-policy")) + .map(|(_, value)| value.as_str()) + .collect(); + // Exactly one CSP (the SPA-wide one is replaced, not appended), and it + // pins the inline script by hash with no permissive fallback — so the + // hash actually governs execution. + assert_eq!(csp_headers.len(), 1); + let csp = csp_headers[0]; + assert!(csp.contains(&format!("script-src '{}'", callback_script_hash()))); + assert!(!csp.contains("unsafe-inline")); + assert!(!csp.contains("unsafe-eval")); + let cache_control = response + .headers() + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("cache-control")) + .map(|(_, value)| value.as_str()) + .unwrap(); + assert_eq!(cache_control, "no-store"); + } + + #[test] + fn malformed_body_gets_error_page() { + let response = handle_form_post_callback(b"not&a=valid#form"); + assert_eq!(response.status_code(), StatusCode::BAD_REQUEST); + let body = std::str::from_utf8(response.body()).unwrap(); + assert!(body.contains("Sign-in could not be completed")); + assert!(!body.contains("