Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion src/canister_tests/src/api.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,3 +14,13 @@ pub fn http_request(
) -> Result<HttpResponse, RejectResponse> {
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<HttpResponse, RejectResponse> {
update_candid(env, canister_id, "http_request_update", (http_request,)).map(|(x,)| x)
}
62 changes: 50 additions & 12 deletions src/frontend/src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body> 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
Expand Down
101 changes: 86 additions & 15 deletions src/frontend/src/lib/utils/openID.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
CallbackPopupClosedError,
createRedirectURL,
findConfig,
issuerMatches,
extractIssuerTemplateClaims,
Expand All @@ -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: {
Expand Down Expand Up @@ -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");
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -375,35 +395,86 @@ 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");
});

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", () => {
Expand Down
Loading
Loading