Skip to content

Commit

Permalink
Allow custom domains to use alternative origins (#2399)
Browse files Browse the repository at this point in the history
* Allow custom domains to use alternative origins

This PR lifts the restriction on the alternative origins feature to only
allow canister id based subdomains of `ic0.app` and `icp0.io`.

The canister id resolution is now the same for the session authorization
flow as it is for verifiable credentials.

The following restriction is still in place: the alternative origins file
needs to be served from a canister accessible on the `icp0.io` domain.

Allowances are made for local development using `localhost`, `0.0.0.0`,
`127.0.0.1` and custom HTTP gateways.

This PR also adds a unit test suite for the derivation origin validation.

* Improve comment

* Fix backticks

* Make resolveCanisterId a default parameter
  • Loading branch information
frederikrothenberger committed Apr 5, 2024
1 parent 1e1d474 commit cdf3ffd
Show file tree
Hide file tree
Showing 7 changed files with 362 additions and 103 deletions.
18 changes: 5 additions & 13 deletions docs/ii-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ This section describes the Internet Identity Service from the point of view of a

- the `allowPinAuthentication` (EXPERIMENTAL), if present, indicates whether or not the Identity Provider should allow the user to authenticate and/or register using a temporary key/PIN identity. Authenticating dapps may want to prevent users from using Temporary keys/PIN identities because Temporary keys/PIN identities are less secure than Passkeys (webauthn credentials) and because Temporary keys/PIN identities generally only live in a browser database (which may get cleared by the browser/OS).

- the `derivationOrigin`, if present, indicates an origin that should be used for principal derivation instead of the client origin. Values must match the following regular expression: `^https:\/\/[\w-]+(\.raw)?\.(ic0\.app|icp0\.io)$`. Internet Identity will only accept values that are also listed in the HTTP resource `https://<canister_id>.ic0.app/.well-known/ii-alternative-origins` of the corresponding canister (see [Alternative Frontend Origins](#alternative-frontend-origins)).
- the `derivationOrigin`, if present, indicates an origin that should be used for principal derivation instead of the client origin. Internet Identity will only accept values that are also listed in the HTTP resource `/.well-known/ii-alternative-origins` of the corresponding canister (see [Alternative Frontend Origins](#alternative-frontend-origins)).


6. Now the client application window expects a message back, with data `event`.
Expand Down Expand Up @@ -220,12 +220,8 @@ The Internet Identity frontend will use `event.origin` as the "Frontend URL" to

## Alternative Frontend Origins


To allow flexibility regarding the canister frontend URL, the client may choose to provide the canonical canister frontend URL (`https://<canister_id>.ic0.app` or `https://<canister_id>.raw.ic0.app`) as the `derivationOrigin` (see [Client authentication protocol](#client-authentication-protocol)). This means that Internet Identity will issue the same principals to the frontend (which uses a different origin) as it would if it were using one of the canonical URLs.

:::note
This feature is also available for `https://<canister id>.icp0.io` (resp. `https://<canister id>.raw.icp0.io`).
:::
To allow flexibility regarding the canister frontend URL, the client may choose to provide another frontend URL as the `derivationOrigin` (see [Client authentication protocol](#client-authentication-protocol)). This means that Internet Identity will issue the same principals to the frontend (which uses a different origin) as it would if it were using the `derivationOrigin` directly.
This feature works for all [custom domains](https://internetcomputer.org/docs/current/developer-docs/web-apps/custom-domains/using-custom-domains) backed by canisters.

:::caution
This feature is intended to allow more flexibility with respect to the origins of a _single_ service. Do _not_ use this feature to allow _third party_ services to use the same principals. Only add origins you fully control to `/.well-known/ii-alternative-origins` and never set origins you do not control as `derivationOrigin`!
Expand All @@ -235,11 +231,7 @@ This feature is intended to allow more flexibility with respect to the origins o
`https://<canister_id>.ic0.app` and `https://<canister_id>.raw.ic0.app` do _not_ issue the same principals by default . However, this feature can also be used to map `https://<canister_id>.raw.ic0.app` to `https://<canister_id>.ic0.app` principals or vice versa.
:::

:::note
In general, Internet Identity only allows alternative origins of the form `<canister id>.ic0.app` or `<canister id>.icp0.io`. There is one exception: `nns.ic0.app`, which is treated as `<nns-dapp canister id>.icp0.io`.
:::

In order for Internet Identity to accept the `derivationOrigin` the corresponding canister must list the frontend origin in the JSON object served on the URL `https://<canister_id>.ic0.app/.well-known/ii-alternative-origins` (i.e. the canister _must_ implement the `http_request` query call as specified [here](https://github.com/dfinity/interface-spec/blob/master/spec/index.adoc#the-http-gateway-protocol)).
In order for Internet Identity to accept the `derivationOrigin` the corresponding canister must list the frontend origin in the JSON object served on the URL `https://<canister_id>.icp0.io/.well-known/ii-alternative-origins` (i.e. the canister _must_ implement the `http_request` query call as specified [here](https://github.com/dfinity/interface-spec/blob/master/spec/index.adoc#the-http-gateway-protocol)).


### JSON Schema {#alternative-frontend-origins-schema}
Expand All @@ -249,7 +241,7 @@ In order for Internet Identity to accept the `derivationOrigin` the correspondin
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "II Alternative Origins Principal Derivation Origins",
"description": "An object containing the alternative frontend origins of the given canister, which are allowed to use a canonical canister URL (https://<canister_id>.ic0.app or https://<canister_id>.raw.ic0.app) for principal derivation.",
"description": "An object containing the alternative frontend origins of the given canister, which are allowed to use the URL this document is hosted on for principal derivation.",
"type": "object",
"properties": {
"alternativeOrigins": {
Expand Down
8 changes: 4 additions & 4 deletions src/frontend/src/flows/authorize/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,10 @@ const authenticate = async (
const i18n = new I18n();
const copy = i18n.i18n(copyJson);

const validationResult = await validateDerivationOrigin(
authContext.requestOrigin,
authContext.authRequest.derivationOrigin
);
const validationResult = await validateDerivationOrigin({
requestOrigin: authContext.requestOrigin,
derivationOrigin: authContext.authRequest.derivationOrigin,
});

if (validationResult.result === "invalid") {
await displayError({
Expand Down
13 changes: 8 additions & 5 deletions src/frontend/src/flows/verifiableCredentials/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ const verifyCredentials = async ({
// Verify that principals may be issued to RP using the specified
// derivation origin
const validRpDerivationOrigin = await withLoader(() =>
validateDerivationOrigin(rpOrigin_, rpDerivationOrigin)
validateDerivationOrigin({
requestOrigin: rpOrigin_,
derivationOrigin: rpDerivationOrigin,
})
);
if (validRpDerivationOrigin.result === "invalid") {
return abortedCredentials({ reason: "bad_derivation_origin_rp" });
Expand Down Expand Up @@ -307,10 +310,10 @@ const getValidatedIssuerDerivationOrigin = async ({
}
derivationOriginResult.kind satisfies "origin";

const validationResult = await validateDerivationOrigin(
issuerOrigin,
derivationOriginResult.origin
);
const validationResult = await validateDerivationOrigin({
requestOrigin: issuerOrigin,
derivationOrigin: derivationOriginResult.origin,
});
if (validationResult.result === "invalid") {
console.error(
`Invalid derivation origin ${derivationOriginResult.origin} for issuer ${issuerOrigin}: ${validationResult.message}`
Expand Down
34 changes: 34 additions & 0 deletions src/frontend/src/test-e2e/alternativeOrigin/endpointFormat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,37 @@ test("Should fetch /.well-known/ii-alternative-origins using the non-raw url", a
await niceDemoAppView.waitForAuthenticated();
});
}, 300_000);

test("Should allow arbitrary URL as derivation origin", async () => {
await runInBrowser(async (browser: WebdriverIO.Browser) => {
const authenticatorId1 = await addVirtualAuthenticator(browser);
await browser.url(II_URL);
const userNumber = await FLOWS.registerNewIdentityWelcomeView(browser);
await FLOWS.addRecoveryMechanismSeedPhrase(browser);
const credentials = await getWebAuthnCredentials(browser, authenticatorId1);
expect(credentials).toHaveLength(1);

const niceDemoAppView = new DemoAppView(browser);
await niceDemoAppView.open(TEST_APP_CANONICAL_URL, II_URL);
await niceDemoAppView.waitForDisplay();
await niceDemoAppView.updateAlternativeOrigins(
`{"alternativeOrigins":["${TEST_APP_CANONICAL_URL}"]}`,
"certified"
);
await niceDemoAppView.setDerivationOrigin(TEST_APP_NICE_URL);
expect(await niceDemoAppView.getPrincipal()).toBe("");
await niceDemoAppView.signin();

const authenticatorId3 = await switchToPopup(browser);
await addWebAuthnCredential(
browser,
authenticatorId3,
credentials[0],
originToRelyingPartyId(II_URL)
);
const authenticateView = new AuthenticateView(browser);
await authenticateView.waitForDisplay();
await authenticateView.pickAnchor(userNumber);
await niceDemoAppView.waitForAuthenticated();
});
}, 300_000);
37 changes: 0 additions & 37 deletions src/frontend/src/test-e2e/alternativeOrigin/ingressFormat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,40 +49,3 @@ test("Should not issue delegation when derivationOrigin is missing from /.well-k
);
});
}, 300_000);

test("Should not issue delegation when derivationOrigin is malformed", async () => {
await runInBrowser(async (browser: WebdriverIO.Browser) => {
const authenticatorId1 = await addVirtualAuthenticator(browser);
await browser.url(II_URL);
await FLOWS.registerNewIdentityWelcomeView(browser);
await FLOWS.addRecoveryMechanismSeedPhrase(browser);
const credentials = await getWebAuthnCredentials(browser, authenticatorId1);
expect(credentials).toHaveLength(1);

const niceDemoAppView = new DemoAppView(browser);
await niceDemoAppView.open(TEST_APP_NICE_URL, II_URL);
await niceDemoAppView.waitForDisplay();
await niceDemoAppView.resetAlternativeOrigins();
await niceDemoAppView.setDerivationOrigin(
"https://some-random-disallowed-url.com"
);
expect(await niceDemoAppView.getPrincipal()).toBe("");
await niceDemoAppView.signin();

const authenticatorId3 = await switchToPopup(browser);
await addWebAuthnCredential(
browser,
authenticatorId3,
credentials[0],
originToRelyingPartyId(II_URL)
);
const errorView = new ErrorView(browser);
await errorView.waitForDisplay();
expect(await errorView.getErrorMessage()).toEqual(
`"https://some-random-disallowed-url.com" is not a valid derivation origin for "${TEST_APP_NICE_URL}"`
);
expect(await errorView.getErrorDetail()).toEqual(
"derivationOrigin does not match regex /^https:\\/\\/([\\w-]+)(?:\\.raw)?\\.(?:ic0\\.app|icp0\\.io)$/"
);
});
}, 300_000);
228 changes: 228 additions & 0 deletions src/frontend/src/utils/validateDerivationOrigin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { validateDerivationOrigin } from "$src/utils/validateDerivationOrigin";
import { Principal } from "@dfinity/principal";
import { expect } from "vitest";

const FETCH_OPTS = {
redirect: "error",
headers: {
Accept: "application/json",
},
credentials: "omit",
};
const TEST_CANISTER_ID = Principal.fromText("bkyz2-fmaaa-aaaaa-qaaaq-cai");

test("should validate no derivation origin", async () => {
const result = await validateDerivationOrigin({
requestOrigin: "https://example.com",
resolveCanisterId: () => Promise.reject("unused in this test"),
});
expect(result).toEqual({ result: "valid" });
});

test("should validate same derivation origin", async () => {
const result = await validateDerivationOrigin({
requestOrigin: "https://example.com",
derivationOrigin: "https://example.com",
resolveCanisterId: () => Promise.reject("unused in this test"),
});
expect(result).toEqual({ result: "valid" });
});

test("should fetch alternative origins file from expected URL", async () => {
const testCases = [
{
iiUrl: "https://identity.ic0.app",
fetchUrl: `https://${TEST_CANISTER_ID}.icp0.io/.well-known/ii-alternative-origins`,
},
{
iiUrl: "https://identity.raw.ic0.app",
fetchUrl: `https://${TEST_CANISTER_ID}.icp0.io/.well-known/ii-alternative-origins`,
},
{
iiUrl: "https://identity.icp0.io",
fetchUrl: `https://${TEST_CANISTER_ID}.icp0.io/.well-known/ii-alternative-origins`,
},
{
iiUrl: "https://identity.internetcomputer.org",
fetchUrl: `https://${TEST_CANISTER_ID}.icp0.io/.well-known/ii-alternative-origins`,
},
{
iiUrl: "http://222ew-7aaaa-aaaar-akaia-cai.localhost",
fetchUrl: `http://localhost/.well-known/ii-alternative-origins?canisterId=${TEST_CANISTER_ID}`,
},
{
iiUrl: "http://222ew-7aaaa-aaaar-akaia-cai.localhost:4943",
fetchUrl: `http://localhost:4943/.well-known/ii-alternative-origins?canisterId=${TEST_CANISTER_ID}`,
},
{
iiUrl: "https://222ew-7aaaa-aaaar-akaia-cai.localhost",
fetchUrl: `https://localhost/.well-known/ii-alternative-origins?canisterId=${TEST_CANISTER_ID}`,
},
{
iiUrl: "http://localhost/?canisterId=222ew-7aaaa-aaaar-akaia-cai",
fetchUrl: `http://localhost/.well-known/ii-alternative-origins?canisterId=${TEST_CANISTER_ID}`,
},
{
iiUrl: "https://localhost/?canisterId=222ew-7aaaa-aaaar-akaia-cai",
fetchUrl: `https://localhost/.well-known/ii-alternative-origins?canisterId=${TEST_CANISTER_ID}`,
},
{
iiUrl: "http://0.0.0.0/?canisterId=222ew-7aaaa-aaaar-akaia-cai",
fetchUrl: `http://0.0.0.0/.well-known/ii-alternative-origins?canisterId=${TEST_CANISTER_ID}`,
},
{
iiUrl: "https://0.0.0.0/?canisterId=222ew-7aaaa-aaaar-akaia-cai",
fetchUrl: `https://0.0.0.0/.well-known/ii-alternative-origins?canisterId=${TEST_CANISTER_ID}`,
},
{
iiUrl: "https://0.0.0.0:1234/?canisterId=222ew-7aaaa-aaaar-akaia-cai",
fetchUrl: `https://0.0.0.0:1234/.well-known/ii-alternative-origins?canisterId=${TEST_CANISTER_ID}`,
},
{
iiUrl: "http://127.0.0.1/?canisterId=222ew-7aaaa-aaaar-akaia-cai",
fetchUrl: `http://127.0.0.1/.well-known/ii-alternative-origins?canisterId=${TEST_CANISTER_ID}`,
},
{
iiUrl: "https://127.0.0.1/?canisterId=222ew-7aaaa-aaaar-akaia-cai",
fetchUrl: `https://127.0.0.1/.well-known/ii-alternative-origins?canisterId=${TEST_CANISTER_ID}`,
},
{
iiUrl:
"https://totally-custom.com/?canisterId=222ew-7aaaa-aaaar-akaia-cai",
fetchUrl: `https://totally-custom.com/.well-known/ii-alternative-origins?canisterId=${TEST_CANISTER_ID}`,
},
{
iiUrl:
"http://totally-custom.com:8080/?canisterId=222ew-7aaaa-aaaar-akaia-cai",
fetchUrl: `https://totally-custom.com:8080/.well-known/ii-alternative-origins?canisterId=${TEST_CANISTER_ID}`,
},
];

for (const { iiUrl, fetchUrl } of testCases) {
const fetchMock = setupMocks({
iiUrl,
response: Response.json({
alternativeOrigins: ["https://example.com"],
}),
});

const result = await validateDerivationOrigin({
requestOrigin: "https://example.com",
derivationOrigin: "https://some-url.com", // different from requestOrigin so that we need to fetch the alternative origins
resolveCanisterId: () => Promise.resolve({ ok: TEST_CANISTER_ID }),
});

expect(result).toEqual({ result: "valid" });
expect(fetchMock).toHaveBeenLastCalledWith(fetchUrl, FETCH_OPTS);
}
});

test("should fetch alternative origins file using non-raw URL", async () => {
const fetchMock = setupMocks({
iiUrl: "https://identity.ic0.app",
response: Response.json({
alternativeOrigins: [`https://${TEST_CANISTER_ID}.raw.ic0.app`],
}),
});

const result = await validateDerivationOrigin({
requestOrigin: `https://${TEST_CANISTER_ID}.raw.ic0.app`,
derivationOrigin: "https://some-url.com", // different from requestOrigin so that we need to fetch the alternative origins
resolveCanisterId: () => Promise.resolve({ ok: TEST_CANISTER_ID }),
});

expect(result).toEqual({ result: "valid" });
expect(fetchMock).toHaveBeenLastCalledWith(
`https://${TEST_CANISTER_ID}.icp0.io/.well-known/ii-alternative-origins`,
FETCH_OPTS
);
});

test("should not validate if canister id resolution fails", async () => {
const result = await validateDerivationOrigin({
requestOrigin: "https://example.com",
derivationOrigin: "https://derivation.com",
resolveCanisterId: () => Promise.resolve("not_found"),
});
expect(result.result).toBe("invalid");
});

test("should not validate if origin not allowed", async () => {
setupMocks({
iiUrl: "https://identity.ic0.app",
response: Response.json({
alternativeOrigins: ["https://not-example.com"],
}),
});

const result = await validateDerivationOrigin({
requestOrigin: "https://example.com",
derivationOrigin: "https://some-url.com", // different from requestOrigin so that we need to fetch the alternative origins
resolveCanisterId: () => Promise.resolve({ ok: TEST_CANISTER_ID }),
});

expect(result.result).toBe("invalid");
});

test("should not validate if alternative origins file malformed", async () => {
setupMocks({
iiUrl: "https://identity.ic0.app",
response: Response.json({
notAlternativeOrigins: ["https://example.com"],
}),
});

const result = await validateDerivationOrigin({
requestOrigin: "https://example.com",
derivationOrigin: "https://some-url.com", // different from requestOrigin so that we need to fetch the alternative origins
resolveCanisterId: () => Promise.resolve({ ok: TEST_CANISTER_ID }),
});

expect(result.result).toBe("invalid");
});

test("should not validate on alternative origins redirect", async () => {
setupMocks({
iiUrl: "https://identity.ic0.app",
response: Response.redirect("https://some-evil-url.com"),
});

const result = await validateDerivationOrigin({
requestOrigin: "https://example.com",
derivationOrigin: "https://some-url.com", // different from requestOrigin so that we need to fetch the alternative origins
resolveCanisterId: () => Promise.resolve({ ok: TEST_CANISTER_ID }),
});

expect(result.result).toBe("invalid");
});

test("should not validate on alternative origins error", async () => {
setupMocks({
iiUrl: "https://identity.ic0.app",
response: new Response(undefined, { status: 404 }),
});

const result = await validateDerivationOrigin({
requestOrigin: "https://example.com",
derivationOrigin: "https://some-url.com", // different from requestOrigin so that we need to fetch the alternative origins
resolveCanisterId: () => Promise.resolve({ ok: TEST_CANISTER_ID }),
});

expect(result.result).toBe("invalid");
});

const setupMocks = ({
iiUrl,
response,
}: {
iiUrl: string;
response: Response;
}) => {
// eslint-disable-next-line
// @ts-ignore
vi.spyOn(window, "location", "get").mockReturnValue(new URL(iiUrl));
const fetchMock = vi.fn();
global.fetch = fetchMock;
fetchMock.mockReturnValueOnce(Promise.resolve(response));
return fetchMock;
};

0 comments on commit cdf3ffd

Please sign in to comment.