From 4e1bf0fc664043a922ccd266c1c608d43eb639a1 Mon Sep 17 00:00:00 2001 From: gix-bot <107688624+gix-bot@users.noreply.github.com> Date: Tue, 17 Dec 2024 11:26:24 +0100 Subject: [PATCH 01/14] Update dapps list (#2750) Update dapps Co-authored-by: gix-bot --- src/frontend/src/flows/dappsExplorer/dapps.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/frontend/src/flows/dappsExplorer/dapps.json b/src/frontend/src/flows/dappsExplorer/dapps.json index b838b09322..3b8f44f02c 100644 --- a/src/frontend/src/flows/dappsExplorer/dapps.json +++ b/src/frontend/src/flows/dappsExplorer/dapps.json @@ -1,4 +1,10 @@ [ + { + "name": "DecideAI", + "oneLiner": "A fullstack decentralized AI platform", + "website": "https://decideai.xyz", + "logo": "decideai_logo.png" + }, { "name": "Bitfinity EVM", "website": "https://bitfinity.network/", @@ -119,12 +125,6 @@ "website": "https://boomdao.xyz/", "logo": "boom-dao-logo.png" }, - { - "name": "DecideAI", - "oneLiner": "A fullstack decentralized AI platform", - "website": "https://decideai.xyz", - "logo": "decideai_logo.png" - }, { "name": "AutoRoyale", "website": "https://cm6iy-sqaaa-aaaam-abmxq-cai.icp0.io/", From 66edea7dea1c25156134d209dc54a74c5cb5b1ec Mon Sep 17 00:00:00 2001 From: Andri Schatz Date: Tue, 17 Dec 2024 12:05:06 +0100 Subject: [PATCH 02/14] Add tests to make sure .well-known routes are not cached (#2749) * create tests to check headers on .well-known routes * add config to .well-known/webauthn test --- .../tests/integration/http.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/internet_identity/tests/integration/http.rs b/src/internet_identity/tests/integration/http.rs index d6493678ad..4fb94e9ac3 100644 --- a/src/internet_identity/tests/integration/http.rs +++ b/src/internet_identity/tests/integration/http.rs @@ -517,6 +517,99 @@ fn should_set_cache_control_for_icons() -> Result<(), CallError> { Ok(()) } +#[test] +fn must_not_cache_well_known_ic_domains() -> Result<(), CallError> { + const CERTIFICATION_VERSION: u16 = 2; + let env = env(); + let canister_id = install_ii_canister(&env, II_WASM.clone()); + + // Get index page + let well_known_request = HttpRequest { + method: "GET".to_string(), + url: "/.well-known/ic-domains".to_string(), + headers: vec![], + body: ByteBuf::new(), + certificate_version: Some(CERTIFICATION_VERSION), + }; + let well_known_response = http_request(&env, canister_id, &well_known_request)?; + + assert_eq!(well_known_response.status_code, 200); + println!("{:?}", well_known_response.headers); + assert!( + !well_known_response // Make sure we have no cache-control headers whatsoever on the response + .headers + .clone() + .into_iter() + .map(|headers| headers.0) // Get only the key + .collect::>() + .contains(&"Cache-Control".to_string()) + ); + + let result = verify_response_certification( + &env, + canister_id, + well_known_request, + well_known_response, + CERTIFICATION_VERSION, + ); + assert_eq!(result.verification_version, CERTIFICATION_VERSION); + + Ok(()) +} + +#[test] +fn must_not_cache_well_known_webauthn() -> Result<(), CallError> { + const CERTIFICATION_VERSION: u16 = 2; + let env = env(); + let related_origins: Vec = [ + "https://identity.internetcomputer.org".to_string(), + "https://identity.ic0.app".to_string(), + ] + .to_vec(); + let config = InternetIdentityInit { + assigned_user_number_range: None, + archive_config: None, + canister_creation_cycles_cost: None, + register_rate_limit: None, + captcha_config: None, + related_origins: Some(related_origins.clone()), + }; + let canister_id = install_ii_canister_with_arg(&env, II_WASM.clone(), Some(config)); + + // Get index page + let well_known_request = HttpRequest { + method: "GET".to_string(), + url: "/.well-known/webauthn".to_string(), + headers: vec![], + body: ByteBuf::new(), + certificate_version: Some(CERTIFICATION_VERSION), + }; + let well_known_response = http_request(&env, canister_id, &well_known_request)?; + + assert_eq!(well_known_response.status_code, 200); + println!("{:?}", well_known_response.headers); + assert!( + !well_known_response // Make sure we have no cache-control headers whatsoever on the response + .headers + .clone() + .into_iter() + .map(|headers| headers.0) // Get only the key + .collect::>() + .contains(&"Cache-Control".to_string()) + ); + + let result = verify_response_certification( + &env, + canister_id, + well_known_request, + well_known_response, + CERTIFICATION_VERSION, + ); + assert_eq!(result.verification_version, CERTIFICATION_VERSION); + + Ok(()) +} + /// Verifies that expected metrics are available via the HTTP endpoint. #[test] fn ii_canister_serves_http_metrics() -> Result<(), CallError> { From 5e77adcf3edd282b50fa4246d066b6c6c910e523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lloren=C3=A7=20Muntaner?= Date: Tue, 17 Dec 2024 12:43:50 +0100 Subject: [PATCH 03/14] Refactor finding rpId for login (#2751) * Refactor finding rpId for login * Add tests * Fix build --- src/frontend/src/utils/iiConnection.test.ts | 132 +++++++++++++++++- src/frontend/src/utils/iiConnection.ts | 25 +++- .../src/utils/multiWebAuthnIdentity.ts | 25 ++-- src/showcase/src/flows.ts | 2 +- 4 files changed, 158 insertions(+), 26 deletions(-) diff --git a/src/frontend/src/utils/iiConnection.test.ts b/src/frontend/src/utils/iiConnection.test.ts index 552e136640..f1a541d3d9 100644 --- a/src/frontend/src/utils/iiConnection.test.ts +++ b/src/frontend/src/utils/iiConnection.test.ts @@ -1,13 +1,30 @@ -import { MetadataMapV2, _SERVICE } from "$generated/internet_identity_types"; +import { + DeviceData, + MetadataMapV2, + _SERVICE, +} from "$generated/internet_identity_types"; +import { DOMAIN_COMPATIBILITY } from "$src/featureFlags"; import { IdentityMetadata, RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS, } from "$src/repositories/identityMetadata"; -import { ActorSubclass } from "@dfinity/agent"; -import { DelegationIdentity } from "@dfinity/identity"; -import { AuthenticatedConnection } from "./iiConnection"; +import { ActorSubclass, DerEncodedPublicKey, Signature } from "@dfinity/agent"; +import { DelegationIdentity, WebAuthnIdentity } from "@dfinity/identity"; +import { CredentialData, convertToCredentialData } from "./credential-devices"; +import { AuthenticatedConnection, Connection } from "./iiConnection"; import { MultiWebAuthnIdentity } from "./multiWebAuthnIdentity"; +const mockDevice: DeviceData = { + alias: "mockDevice", + metadata: [], + origin: [], + protection: { protected: null }, + pubkey: new Uint8Array(), + key_type: { platform: null }, + purpose: { authentication: null }, + credential_id: [], +}; + const mockDelegationIdentity = { getDelegation() { return { @@ -37,17 +54,22 @@ const mockActor = { return { Ok: { metadata: mockRawMetadata } }; }), identity_metadata_replace: vi.fn().mockResolvedValue({ Ok: null }), + lookup: vi.fn().mockResolvedValue([mockDevice]), } as unknown as ActorSubclass<_SERVICE>; beforeEach(() => { infoResponse = undefined; vi.clearAllMocks(); + vi.stubGlobal("location", { + origin: "https://identity.internetcomputer.org", + }); + DOMAIN_COMPATIBILITY.reset(); }); test("initializes identity metadata repository", async () => { const connection = new AuthenticatedConnection( "12345", - MultiWebAuthnIdentity.fromCredentials([]), + MultiWebAuthnIdentity.fromCredentials([], undefined), mockDelegationIdentity, BigInt(1234), mockActor @@ -62,7 +84,7 @@ test("commits changes on identity metadata", async () => { const userNumber = BigInt(1234); const connection = new AuthenticatedConnection( "12345", - MultiWebAuthnIdentity.fromCredentials([]), + MultiWebAuthnIdentity.fromCredentials([], undefined), mockDelegationIdentity, userNumber, mockActor @@ -88,3 +110,101 @@ test("commits changes on identity metadata", async () => { ], ]); }); + +describe("Connection.login", () => { + beforeEach(() => { + vi.spyOn(MultiWebAuthnIdentity, "fromCredentials").mockImplementation( + () => { + const mockIdentity = { + getPublicKey: () => { + return { + toDer: () => new ArrayBuffer(0) as DerEncodedPublicKey, + toRaw: () => new ArrayBuffer(0), + rawKey: () => new ArrayBuffer(0), + derKey: () => new ArrayBuffer(0) as DerEncodedPublicKey, + }; + }, + } as unknown as WebAuthnIdentity; + class MockMultiWebAuthnIdentity extends MultiWebAuthnIdentity { + static fromCredentials( + credentials: CredentialData[], + rpId: string | undefined + ) { + return new MockMultiWebAuthnIdentity(credentials, rpId); + } + override sign() { + this._actualIdentity = mockIdentity; + return Promise.resolve(new ArrayBuffer(0) as Signature); + } + } + return MockMultiWebAuthnIdentity.fromCredentials([], undefined); + } + ); + }); + + it("login returns authenticated connection with expected rpID", async () => { + DOMAIN_COMPATIBILITY.set(true); + vi.stubGlobal("navigator", { + // Supports RoR + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + }); + const connection = new Connection("aaaaa-aa", mockActor); + + const loginResult = await connection.login(BigInt(12345)); + + expect(loginResult.kind).toBe("loginSuccess"); + if (loginResult.kind === "loginSuccess") { + expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToCredentialData(mockDevice)], + "identity.ic0.app" + ); + } + }); + + it("login returns authenticated connection without rpID if flag is not enabled", async () => { + DOMAIN_COMPATIBILITY.set(false); + vi.stubGlobal("navigator", { + // Supports RoR + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + }); + const connection = new Connection("aaaaa-aa", mockActor); + + const loginResult = await connection.login(BigInt(12345)); + + expect(loginResult.kind).toBe("loginSuccess"); + if (loginResult.kind === "loginSuccess") { + expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToCredentialData(mockDevice)], + undefined + ); + } + }); + + it("login returns authenticated connection without rpID if browser doesn't support it", async () => { + DOMAIN_COMPATIBILITY.set(true); + vi.stubGlobal("navigator", { + // Supports RoR + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + }); + const connection = new Connection("aaaaa-aa", mockActor); + + const loginResult = await connection.login(BigInt(12345)); + + expect(loginResult.kind).toBe("loginSuccess"); + if (loginResult.kind === "loginSuccess") { + expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToCredentialData(mockDevice)], + undefined + ); + } + }); +}); diff --git a/src/frontend/src/utils/iiConnection.ts b/src/frontend/src/utils/iiConnection.ts index 28ddfc7731..877816319a 100644 --- a/src/frontend/src/utils/iiConnection.ts +++ b/src/frontend/src/utils/iiConnection.ts @@ -28,6 +28,7 @@ import { VerifyTentativeDeviceResponse, } from "$generated/internet_identity_types"; import { fromMnemonicWithoutValidation } from "$src/crypto/ed25519"; +import { DOMAIN_COMPATIBILITY } from "$src/featureFlags"; import { features } from "$src/features"; import { IdentityMetadata, @@ -50,8 +51,10 @@ import { import { Principal } from "@dfinity/principal"; import { isNullish, nonNullish } from "@dfinity/utils"; import { convertToCredentialData, CredentialData } from "./credential-devices"; +import { findWebAuthnRpId, relatedDomains } from "./findWebAuthnRpId"; import { MultiWebAuthnIdentity } from "./multiWebAuthnIdentity"; import { isRecoveryDevice, RecoveryDevice } from "./recoveryDevice"; +import { supportsWebauthRoR } from "./userAgent"; import { isWebAuthnCancel } from "./webAuthnErrorUtils"; /* @@ -134,7 +137,11 @@ export interface IIWebAuthnIdentity extends SignIdentity { } export class Connection { - public constructor(readonly canisterId: string) {} + public constructor( + readonly canisterId: string, + // Used for testing purposes + readonly overrideActor?: ActorSubclass<_SERVICE> + ) {} identity_registration_start = async ({ tempIdentity, @@ -369,13 +376,24 @@ export class Connection { userNumber: bigint, credentials: CredentialData[] ): Promise => { + // TODO: Filter out the credentials from the used rpIDs. + const rpId = + DOMAIN_COMPATIBILITY.isEnabled() && + supportsWebauthRoR(window.navigator.userAgent) + ? findWebAuthnRpId( + window.location.origin, + credentials, + relatedDomains() + ) + : undefined; + /* Recover the Identity (i.e. key pair) used when creating the anchor. * If the "DUMMY_AUTH" feature is set, we use a dummy identity, the same identity * that is used in the register flow. */ const identity = features.DUMMY_AUTH ? new DummyIdentity() - : MultiWebAuthnIdentity.fromCredentials(credentials); + : MultiWebAuthnIdentity.fromCredentials(credentials, rpId); let delegationIdentity: DelegationIdentity; // Here we expect a webauth exception if the user canceled the webauthn prompt (triggered by @@ -520,6 +538,9 @@ export class Connection { createActor = async ( identity?: SignIdentity ): Promise> => { + if (this.overrideActor !== undefined) { + return this.overrideActor; + } const agent = await HttpAgent.create({ identity, host: inferHost(), diff --git a/src/frontend/src/utils/multiWebAuthnIdentity.ts b/src/frontend/src/utils/multiWebAuthnIdentity.ts index d97a273f90..90f93a4838 100644 --- a/src/frontend/src/utils/multiWebAuthnIdentity.ts +++ b/src/frontend/src/utils/multiWebAuthnIdentity.ts @@ -7,15 +7,12 @@ * then we know which one the user is actually using * - It doesn't support creating credentials; use `WebAuthnIdentity` for that */ -import { DOMAIN_COMPATIBILITY } from "$src/featureFlags"; import { PublicKey, Signature, SignIdentity } from "@dfinity/agent"; import { DER_COSE_OID, unwrapDER, WebAuthnIdentity } from "@dfinity/identity"; import { isNullish } from "@dfinity/utils"; import borc from "borc"; import { CredentialData } from "./credential-devices"; -import { findWebAuthnRpId, relatedDomains } from "./findWebAuthnRpId"; import { bufferEqual } from "./iiConnection"; -import { supportsWebauthRoR } from "./userAgent"; /** * A SignIdentity that uses `navigator.credentials`. See https://webauthn.guide/ for @@ -27,15 +24,19 @@ export class MultiWebAuthnIdentity extends SignIdentity { * @param json - json to parse */ public static fromCredentials( - credentialData: CredentialData[] + credentialData: CredentialData[], + rpId: string | undefined ): MultiWebAuthnIdentity { - return new this(credentialData); + return new this(credentialData, rpId); } /* Set after the first `sign`, see `sign()` for more info. */ protected _actualIdentity?: WebAuthnIdentity; - protected constructor(readonly credentialData: CredentialData[]) { + protected constructor( + readonly credentialData: CredentialData[], + readonly rpId: string | undefined + ) { super(); this._actualIdentity = undefined; } @@ -67,16 +68,6 @@ export class MultiWebAuthnIdentity extends SignIdentity { return this._actualIdentity.sign(blob); } - const rpId = - DOMAIN_COMPATIBILITY.isEnabled() && - supportsWebauthRoR(window.navigator.userAgent) - ? findWebAuthnRpId( - window.location.origin, - this.credentialData, - relatedDomains() - ) - : undefined; - const result = (await navigator.credentials.get({ publicKey: { allowCredentials: this.credentialData.map((cd) => ({ @@ -85,7 +76,7 @@ export class MultiWebAuthnIdentity extends SignIdentity { })), challenge: blob, userVerification: "discouraged", - rpId, + rpId: this.rpId, }, })) as PublicKeyCredential; diff --git a/src/showcase/src/flows.ts b/src/showcase/src/flows.ts index 18758ef0ba..8724cb89f0 100644 --- a/src/showcase/src/flows.ts +++ b/src/showcase/src/flows.ts @@ -48,7 +48,7 @@ class MockAuthenticatedConnection extends AuthenticatedConnection { constructor() { super( "12345", - MultiWebAuthnIdentity.fromCredentials([]), + MultiWebAuthnIdentity.fromCredentials([], undefined), mockDelegationIdentity, BigInt(12345), mockActor From 2705d64fde4ab6468d0b23521f89f9ae09845461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lloren=C3=A7=20Muntaner?= Date: Wed, 18 Dec 2024 11:07:04 +0100 Subject: [PATCH 04/14] Add bot approved files policy (#2752) --- .github/repo_policies/BOT_APPROVED_FILES | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .github/repo_policies/BOT_APPROVED_FILES diff --git a/.github/repo_policies/BOT_APPROVED_FILES b/.github/repo_policies/BOT_APPROVED_FILES new file mode 100644 index 0000000000..b1b5d6b48b --- /dev/null +++ b/.github/repo_policies/BOT_APPROVED_FILES @@ -0,0 +1,3 @@ +# List of approved files that can be changed by a bot via an automated PR +# This is to increase security and prevent accidentally updating files that shouldn't be changed by a bot +src/frontend/src/flows/dappsExplorer/dapps.json \ No newline at end of file From e3e48d05346e4654e4e24c47313d7a6352d8b86c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lloren=C3=A7=20Muntaner?= Date: Wed, 18 Dec 2024 11:40:21 +0100 Subject: [PATCH 05/14] Retry with differen rp id (#2753) * Retry with differen rp id * Improve tests --- .../src/utils/findWebAuthnRpId.test.ts | 106 ++++++++ src/frontend/src/utils/findWebAuthnRpId.ts | 37 +++ src/frontend/src/utils/iiConnection.test.ts | 256 +++++++++++++++--- src/frontend/src/utils/iiConnection.ts | 47 +++- 4 files changed, 390 insertions(+), 56 deletions(-) diff --git a/src/frontend/src/utils/findWebAuthnRpId.test.ts b/src/frontend/src/utils/findWebAuthnRpId.test.ts index 6cc8d5cdec..cb7c486d14 100644 --- a/src/frontend/src/utils/findWebAuthnRpId.test.ts +++ b/src/frontend/src/utils/findWebAuthnRpId.test.ts @@ -2,6 +2,7 @@ import { CredentialData } from "./credential-devices"; import { BETA_DOMAINS, PROD_DOMAINS, + excludeCredentialsFromOrigins, findWebAuthnRpId, } from "./findWebAuthnRpId"; @@ -165,3 +166,108 @@ describe("findWebAuthnRpId", () => { ); }); }); + +describe("excludeCredentialsFromOrigins", () => { + const mockDeviceData = (origin?: string): CredentialData => ({ + origin, + credentialId: new ArrayBuffer(1), + pubkey: new ArrayBuffer(1), + }); + + test("excludes credentials from specified origins", () => { + const credentials = [ + mockDeviceData("https://identity.ic0.app"), + mockDeviceData("https://identity.internetcomputer.org"), + mockDeviceData("https://identity.icp0.io"), + ]; + const originsToExclude = new Set(["https://identity.ic0.app"]); + const currentOrigin = "https://identity.internetcomputer.org"; + + const result = excludeCredentialsFromOrigins( + credentials, + originsToExclude, + currentOrigin + ); + + expect(result).toHaveLength(2); + expect(result).toEqual([ + mockDeviceData("https://identity.internetcomputer.org"), + mockDeviceData("https://identity.icp0.io"), + ]); + }); + + test("treats undefined credential origins as DEFAULT_DOMAIN", () => { + const credentials = [ + mockDeviceData(undefined), // Should be treated as DEFAULT_DOMAIN + mockDeviceData("https://identity.internetcomputer.org"), + ]; + const originsToExclude = new Set(["https://identity.ic0.app"]); // Should match DEFAULT_DOMAIN + const currentOrigin = "https://identity.internetcomputer.org"; + + const result = excludeCredentialsFromOrigins( + credentials, + originsToExclude, + currentOrigin + ); + + expect(result).toHaveLength(1); + expect(result).toEqual([ + mockDeviceData("https://identity.internetcomputer.org"), + ]); + }); + + test("treats undefined origins in exclusion set as currentOrigin", () => { + const credentials = [ + mockDeviceData("https://identity.ic0.app"), + mockDeviceData("https://identity.internetcomputer.org"), + ]; + const originsToExclude = new Set([undefined]); // Should be treated as currentOrigin + const currentOrigin = "https://identity.internetcomputer.org"; + + const result = excludeCredentialsFromOrigins( + credentials, + originsToExclude, + currentOrigin + ); + + expect(result).toHaveLength(1); + expect(result).toEqual([mockDeviceData("https://identity.ic0.app")]); + }); + + test("returns empty array when all credentials are excluded", () => { + const credentials = [ + mockDeviceData("https://identity.ic0.app"), + mockDeviceData("https://identity.internetcomputer.org"), + ]; + const originsToExclude = new Set([ + "https://identity.ic0.app", + "https://identity.internetcomputer.org", + ]); + const currentOrigin = "https://identity.ic0.app"; + + const result = excludeCredentialsFromOrigins( + credentials, + originsToExclude, + currentOrigin + ); + + expect(result).toHaveLength(0); + }); + + test("returns all credentials when no origins to exclude", () => { + const credentials = [ + mockDeviceData("https://identity.ic0.app"), + mockDeviceData("https://identity.internetcomputer.org"), + ]; + const originsToExclude = new Set(); + const currentOrigin = "https://identity.ic0.app"; + + const result = excludeCredentialsFromOrigins( + credentials, + originsToExclude, + currentOrigin + ); + + expect(result).toEqual(credentials); + }); +}); diff --git a/src/frontend/src/utils/findWebAuthnRpId.ts b/src/frontend/src/utils/findWebAuthnRpId.ts index a3163d9706..c23a77897e 100644 --- a/src/frontend/src/utils/findWebAuthnRpId.ts +++ b/src/frontend/src/utils/findWebAuthnRpId.ts @@ -30,6 +30,43 @@ export const relatedDomains = (): string[] => { return []; }; +export const hasCredentialsFromMultipleOrigins = ( + credentials: CredentialData[] +): boolean => + new Set(credentials.map(({ origin }) => origin ?? DEFAULT_DOMAIN)).size > 1; + +/** + * Filters out credentials from specific origins. + * + * This function takes a list of credentials and removes any that match the provided origins. + * If a credential has no origin (undefined), it is treated as if it had the `DEFAULT_DOMAIN`. + * Two origins match if they have the same hostname (domain). + * + * @param credentials - List of credential devices to filter + * @param origins - Set of origins to exclude (undefined values are treated as `currentOrigin`) + * @param currentOrigin - The current origin to use when comparing against undefined origins + * @returns Filtered list of credentials, excluding those from the specified origins + */ +export const excludeCredentialsFromOrigins = ( + credentials: CredentialData[], + origins: Set, + currentOrigin: string +): CredentialData[] => { + if (origins.size === 0) { + return credentials; + } + // Change `undefined` to the current origin. + const originsToExclude = Array.from(origins).map( + (origin) => origin ?? currentOrigin + ); + return credentials.filter( + (credential) => + originsToExclude.filter((originToExclude) => + sameDomain(credential.origin ?? DEFAULT_DOMAIN, originToExclude) + ).length === 0 + ); +}; + const sameDomain = (url1: string, url2: string): boolean => new URL(url1).hostname === new URL(url2).hostname; diff --git a/src/frontend/src/utils/iiConnection.test.ts b/src/frontend/src/utils/iiConnection.test.ts index f1a541d3d9..4addc3c9f5 100644 --- a/src/frontend/src/utils/iiConnection.test.ts +++ b/src/frontend/src/utils/iiConnection.test.ts @@ -14,16 +14,17 @@ import { CredentialData, convertToCredentialData } from "./credential-devices"; import { AuthenticatedConnection, Connection } from "./iiConnection"; import { MultiWebAuthnIdentity } from "./multiWebAuthnIdentity"; -const mockDevice: DeviceData = { +const createMockDevice = (origin?: string): DeviceData => ({ alias: "mockDevice", metadata: [], - origin: [], + origin: origin !== undefined ? [origin] : [], protection: { protected: null }, pubkey: new Uint8Array(), key_type: { platform: null }, purpose: { authentication: null }, credential_id: [], -}; +}); +const mockDevice = createMockDevice(); const mockDelegationIdentity = { getDelegation() { @@ -57,11 +58,13 @@ const mockActor = { lookup: vi.fn().mockResolvedValue([mockDevice]), } as unknown as ActorSubclass<_SERVICE>; +const currentOrigin = "https://identity.internetcomputer.org"; + beforeEach(() => { infoResponse = undefined; vi.clearAllMocks(); vi.stubGlobal("location", { - origin: "https://identity.internetcomputer.org", + origin: currentOrigin, }); DOMAIN_COMPATIBILITY.reset(); }); @@ -112,7 +115,9 @@ test("commits changes on identity metadata", async () => { }); describe("Connection.login", () => { + let failSign = false; beforeEach(() => { + failSign = false; vi.spyOn(MultiWebAuthnIdentity, "fromCredentials").mockImplementation( () => { const mockIdentity = { @@ -133,6 +138,9 @@ describe("Connection.login", () => { return new MockMultiWebAuthnIdentity(credentials, rpId); } override sign() { + if (failSign) { + throw new DOMException("Error test", "NotAllowedError"); + } this._actualIdentity = mockIdentity; return Promise.resolve(new ArrayBuffer(0) as Signature); } @@ -142,69 +150,227 @@ describe("Connection.login", () => { ); }); - it("login returns authenticated connection with expected rpID", async () => { - DOMAIN_COMPATIBILITY.set(true); - vi.stubGlobal("navigator", { - // Supports RoR - userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + describe("domains compatibility flag enabled and browser support", () => { + beforeEach(() => { + DOMAIN_COMPATIBILITY.set(true); + vi.stubGlobal("navigator", { + // Supports RoR + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + }); }); - const connection = new Connection("aaaaa-aa", mockActor); - const loginResult = await connection.login(BigInt(12345)); + it("login returns authenticated connection with expected rpID", async () => { + const connection = new Connection("aaaaa-aa", mockActor); + + const loginResult = await connection.login(BigInt(12345)); + + expect(loginResult.kind).toBe("loginSuccess"); + if (loginResult.kind === "loginSuccess") { + expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToCredentialData(mockDevice)], + "identity.ic0.app" + ); + } + }); + + it("connection exludes rpId when user cancels", async () => { + // This one would fail because it's not the device the user is using at the moment. + const currentOriginDevice: DeviceData = createMockDevice(currentOrigin); + const currentOriginCredentialData = + convertToCredentialData(currentOriginDevice); + const currentDevice: DeviceData = createMockDevice(); + const currentDeviceCredentialData = + convertToCredentialData(currentDevice); + const mockActor = { + identity_info: vi.fn().mockResolvedValue({ Ok: { metadata: [] } }), + lookup: vi.fn().mockResolvedValue([currentOriginDevice, currentDevice]), + } as unknown as ActorSubclass<_SERVICE>; + const connection = new Connection("aaaaa-aa", mockActor); + + failSign = true; + const firstLoginResult = await connection.login(BigInt(12345)); - expect(loginResult.kind).toBe("loginSuccess"); - if (loginResult.kind === "loginSuccess") { - expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(firstLoginResult.kind).toBe("webAuthnFailed"); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( - [convertToCredentialData(mockDevice)], - "identity.ic0.app" + expect.arrayContaining([ + currentOriginCredentialData, + currentDeviceCredentialData, + ]), + undefined ); - } - }); - it("login returns authenticated connection without rpID if flag is not enabled", async () => { - DOMAIN_COMPATIBILITY.set(false); - vi.stubGlobal("navigator", { - // Supports RoR - userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + failSign = false; + const secondLoginResult = await connection.login(BigInt(12345)); + + expect(secondLoginResult.kind).toBe("loginSuccess"); + if (secondLoginResult.kind === "loginSuccess") { + expect(secondLoginResult.connection).toBeInstanceOf( + AuthenticatedConnection + ); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(2); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([currentDeviceCredentialData]), + "identity.ic0.app" + ); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenNthCalledWith( + 2, + expect.not.arrayContaining([currentOriginCredentialData]), + "identity.ic0.app" + ); + } }); - const connection = new Connection("aaaaa-aa", mockActor); - const loginResult = await connection.login(BigInt(12345)); + it("connection doesn't exclude rpId if user has only one domain", async () => { + const currentOriginDevice: DeviceData = createMockDevice(currentOrigin); + const currentOriginCredentialData = + convertToCredentialData(currentOriginDevice); + const currentOriginDevice2: DeviceData = createMockDevice(currentOrigin); + const currentOriginCredentialData2 = + convertToCredentialData(currentOriginDevice2); + const mockActor = { + identity_info: vi.fn().mockResolvedValue({ Ok: { metadata: [] } }), + lookup: vi + .fn() + .mockResolvedValue([currentOriginDevice, currentOriginDevice2]), + } as unknown as ActorSubclass<_SERVICE>; + const connection = new Connection("aaaaa-aa", mockActor); + + failSign = true; + const firstLoginResult = await connection.login(BigInt(12345)); - expect(loginResult.kind).toBe("loginSuccess"); - if (loginResult.kind === "loginSuccess") { - expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(firstLoginResult.kind).toBe("webAuthnFailed"); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( - [convertToCredentialData(mockDevice)], + expect.arrayContaining([ + currentOriginCredentialData, + currentOriginCredentialData2, + ]), undefined ); - } + + failSign = false; + const secondLoginResult = await connection.login(BigInt(12345)); + + expect(secondLoginResult.kind).toBe("loginSuccess"); + if (secondLoginResult.kind === "loginSuccess") { + expect(secondLoginResult.connection).toBeInstanceOf( + AuthenticatedConnection + ); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(2); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([ + currentOriginCredentialData, + currentOriginCredentialData2, + ]), + undefined + ); + } + }); }); - it("login returns authenticated connection without rpID if browser doesn't support it", async () => { - DOMAIN_COMPATIBILITY.set(true); - vi.stubGlobal("navigator", { - // Supports RoR - userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + describe("domains compatibility flag enabled and browser doesn't support", () => { + beforeEach(() => { + DOMAIN_COMPATIBILITY.set(true); + vi.stubGlobal("navigator", { + // Does NOT Supports RoR + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + }); }); - const connection = new Connection("aaaaa-aa", mockActor); - const loginResult = await connection.login(BigInt(12345)); + it("login returns authenticated connection without rpID if browser doesn't support it", async () => { + const connection = new Connection("aaaaa-aa", mockActor); - expect(loginResult.kind).toBe("loginSuccess"); - if (loginResult.kind === "loginSuccess") { - expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + const loginResult = await connection.login(BigInt(12345)); + + expect(loginResult.kind).toBe("loginSuccess"); + if (loginResult.kind === "loginSuccess") { + expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToCredentialData(mockDevice)], + undefined + ); + } + }); + }); + + describe("domains compatibility flag disabled", () => { + beforeEach(() => { + DOMAIN_COMPATIBILITY.set(false); + vi.stubGlobal("navigator", { + // Supports RoR + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + }); + }); + + it("login returns authenticated connection without rpID if flag is not enabled", async () => { + const connection = new Connection("aaaaa-aa", mockActor); + + const loginResult = await connection.login(BigInt(12345)); + + expect(loginResult.kind).toBe("loginSuccess"); + if (loginResult.kind === "loginSuccess") { + expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToCredentialData(mockDevice)], + undefined + ); + } + }); + + it("connection does not exlud rpId when user cancels", async () => { + const currentOriginDevice: DeviceData = createMockDevice(currentOrigin); + const currentOriginCredentialData = + convertToCredentialData(currentOriginDevice); + const currentDevice: DeviceData = createMockDevice(); + const currentDeviceCredentialData = + convertToCredentialData(currentDevice); + const mockActor = { + identity_info: vi.fn().mockResolvedValue({ Ok: { metadata: [] } }), + lookup: vi.fn().mockResolvedValue([currentOriginDevice, currentDevice]), + } as unknown as ActorSubclass<_SERVICE>; + const connection = new Connection("aaaaa-aa", mockActor); + + failSign = true; + const firstLoginResult = await connection.login(BigInt(12345)); + + expect(firstLoginResult.kind).toBe("webAuthnFailed"); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( - [convertToCredentialData(mockDevice)], + expect.arrayContaining([ + currentOriginCredentialData, + currentDeviceCredentialData, + ]), undefined ); - } + + failSign = false; + const secondLoginResult = await connection.login(BigInt(12345)); + + expect(secondLoginResult.kind).toBe("loginSuccess"); + if (secondLoginResult.kind === "loginSuccess") { + expect(secondLoginResult.connection).toBeInstanceOf( + AuthenticatedConnection + ); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(2); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([ + currentDeviceCredentialData, + currentOriginCredentialData, + ]), + undefined + ); + } + }); }); }); diff --git a/src/frontend/src/utils/iiConnection.ts b/src/frontend/src/utils/iiConnection.ts index 877816319a..9813d4e257 100644 --- a/src/frontend/src/utils/iiConnection.ts +++ b/src/frontend/src/utils/iiConnection.ts @@ -51,7 +51,12 @@ import { import { Principal } from "@dfinity/principal"; import { isNullish, nonNullish } from "@dfinity/utils"; import { convertToCredentialData, CredentialData } from "./credential-devices"; -import { findWebAuthnRpId, relatedDomains } from "./findWebAuthnRpId"; +import { + excludeCredentialsFromOrigins, + findWebAuthnRpId, + hasCredentialsFromMultipleOrigins, + relatedDomains, +} from "./findWebAuthnRpId"; import { MultiWebAuthnIdentity } from "./multiWebAuthnIdentity"; import { isRecoveryDevice, RecoveryDevice } from "./recoveryDevice"; import { supportsWebauthRoR } from "./userAgent"; @@ -137,6 +142,12 @@ export interface IIWebAuthnIdentity extends SignIdentity { } export class Connection { + // The rpID is used to get the passkey from the browser's WebAuthn API. + // Using different rpIDs allows us to have compatibility across multiple domains. + // However, when one RP ID is used and the user cancels, it must be because the user is in a device + // registered in another domain. In this case, we must try the other rpID. + // Map> + private _cancelledRpIds: Map> = new Map(); public constructor( readonly canisterId: string, // Used for testing purposes @@ -376,16 +387,19 @@ export class Connection { userNumber: bigint, credentials: CredentialData[] ): Promise => { - // TODO: Filter out the credentials from the used rpIDs. - const rpId = + const cancelledRpIds = this._cancelledRpIds.get(userNumber) ?? new Set(); + const currentOrigin = window.location.origin; + const dynamicRPIdEnabled = DOMAIN_COMPATIBILITY.isEnabled() && - supportsWebauthRoR(window.navigator.userAgent) - ? findWebAuthnRpId( - window.location.origin, - credentials, - relatedDomains() - ) - : undefined; + supportsWebauthRoR(window.navigator.userAgent); + const filteredCredentials = excludeCredentialsFromOrigins( + credentials, + cancelledRpIds, + currentOrigin + ); + const rpId = dynamicRPIdEnabled + ? findWebAuthnRpId(currentOrigin, filteredCredentials, relatedDomains()) + : undefined; /* Recover the Identity (i.e. key pair) used when creating the anchor. * If the "DUMMY_AUTH" feature is set, we use a dummy identity, the same identity @@ -393,7 +407,7 @@ export class Connection { */ const identity = features.DUMMY_AUTH ? new DummyIdentity() - : MultiWebAuthnIdentity.fromCredentials(credentials, rpId); + : MultiWebAuthnIdentity.fromCredentials(filteredCredentials, rpId); let delegationIdentity: DelegationIdentity; // Here we expect a webauth exception if the user canceled the webauthn prompt (triggered by @@ -402,6 +416,17 @@ export class Connection { delegationIdentity = await this.requestFEDelegation(identity); } catch (e: unknown) { if (isWebAuthnCancel(e)) { + // We only want to cache cancelled rpids if there can be multiple rpids. + if ( + dynamicRPIdEnabled && + hasCredentialsFromMultipleOrigins(credentials) + ) { + if (this._cancelledRpIds.has(userNumber)) { + this._cancelledRpIds.get(userNumber)?.add(rpId); + } else { + this._cancelledRpIds.set(userNumber, new Set([rpId])); + } + } return { kind: "webAuthnFailed" }; } From 84a0cab16dd376156c95ca5f65b61e2c0c4139ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lloren=C3=A7=20Muntaner?= Date: Wed, 18 Dec 2024 13:43:32 +0100 Subject: [PATCH 06/14] Add nice UX when passkey is not found in that RP ID (#2754) * Add nice UX when passkey is not found in that RP ID * Fix test --- .../src/components/authenticateBox/index.ts | 37 +++++- .../src/components/infoToast/copy.json | 7 ++ .../src/components/infoToast/index.ts | 16 +++ .../src/flows/recovery/recoverWith/device.ts | 20 ++++ src/frontend/src/utils/iiConnection.test.ts | 106 +++++++++++++++++- src/frontend/src/utils/iiConnection.ts | 12 +- 6 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 src/frontend/src/components/infoToast/copy.json create mode 100644 src/frontend/src/components/infoToast/index.ts diff --git a/src/frontend/src/components/authenticateBox/index.ts b/src/frontend/src/components/authenticateBox/index.ts index 5a29edd310..09cd29d81e 100644 --- a/src/frontend/src/components/authenticateBox/index.ts +++ b/src/frontend/src/components/authenticateBox/index.ts @@ -33,6 +33,7 @@ import { InvalidCaller, LoginSuccess, NoRegistrationFlow, + PossiblyWrongRPID, RateLimitExceeded, RegisterNoSpace, UnexpectedCall, @@ -50,6 +51,8 @@ import { import { DerEncodedPublicKey } from "@dfinity/agent"; import { isNullish, nonNullish } from "@dfinity/utils"; import { TemplateResult, html, render } from "lit-html"; +import { infoToastTemplate } from "../infoToast"; +import infoToastCopy from "../infoToast/copy.json"; /** Template used for rendering specific authentication screens. See `authnScreens` below * for meaning of "firstTime", "useExisting" and "pick". */ @@ -189,7 +192,12 @@ export const authenticateBoxFlow = async ({ loginPasskey: ( userNumber: bigint ) => Promise< - LoginSuccess | AuthFail | WebAuthnFailed | UnknownUser | ApiError + | LoginSuccess + | AuthFail + | WebAuthnFailed + | PossiblyWrongRPID + | UnknownUser + | ApiError >; loginPinIdentityMaterial: ({ userNumber, @@ -218,6 +226,7 @@ export const authenticateBoxFlow = async ({ newAnchor: boolean; authnMethod: "pin" | "passkey" | "recovery"; }) + | PossiblyWrongRPID | FlowError | { tag: "canceled" } | { tag: "deviceAdded" } @@ -267,6 +276,7 @@ export const authenticateBoxFlow = async ({ newAnchor: boolean; authnMethod: "pin" | "passkey" | "recovery"; }) + | PossiblyWrongRPID | FlowError | { tag: "canceled" } | { tag: "deviceAdded" } @@ -345,7 +355,7 @@ export type FlowError = | RegisterNoSpace; export const handleLoginFlowResult = async ( - result: (LoginSuccess & E) | FlowError + result: (LoginSuccess & E) | PossiblyWrongRPID | FlowError ): Promise< ({ userNumber: bigint; connection: AuthenticatedConnection } & E) | undefined > => { @@ -354,6 +364,21 @@ export const handleLoginFlowResult = async ( return result; } + if (result.kind === "possiblyWrongRPID") { + const i18n = new I18n(); + const copy = i18n.i18n(infoToastCopy); + toast.info( + infoToastTemplate({ + title: copy.title_possibly_wrong_rp_id, + messages: [ + copy.message_possibly_wrong_rp_id_1, + copy.message_possibly_wrong_rp_id_2, + ], + }) + ); + return undefined; + } + result satisfies FlowError; toast.error(flowErrorToastTemplate(result)); @@ -657,7 +682,12 @@ const useIdentityFlow = async ({ loginPasskey: ( userNumber: bigint ) => Promise< - LoginSuccess | AuthFail | WebAuthnFailed | UnknownUser | ApiError + | LoginSuccess + | AuthFail + | WebAuthnFailed + | PossiblyWrongRPID + | UnknownUser + | ApiError >; allowPinLogin: boolean; verifyPinValidity: (opts: { @@ -680,6 +710,7 @@ const useIdentityFlow = async ({ }) | AuthFail | WebAuthnFailed + | PossiblyWrongRPID | UnknownUser | ApiError | BadPin diff --git a/src/frontend/src/components/infoToast/copy.json b/src/frontend/src/components/infoToast/copy.json new file mode 100644 index 0000000000..49509599a8 --- /dev/null +++ b/src/frontend/src/components/infoToast/copy.json @@ -0,0 +1,7 @@ +{ + "en": { + "title_possibly_wrong_rp_id": "Please try again", + "message_possibly_wrong_rp_id_1": "The wrong domain was set for the passkey and the browser couldn't find it.", + "message_possibly_wrong_rp_id_2": "Try again and another domain will be used." + } +} diff --git a/src/frontend/src/components/infoToast/index.ts b/src/frontend/src/components/infoToast/index.ts new file mode 100644 index 0000000000..62d36b1513 --- /dev/null +++ b/src/frontend/src/components/infoToast/index.ts @@ -0,0 +1,16 @@ +import { DynamicKey } from "$src/i18n"; +import { html } from "lit-html"; + +export const infoToastTemplate = ({ + title, + messages, +}: { + title: string | DynamicKey; + messages: string[] | DynamicKey[]; +}) => html` +

${title}

+ ${messages.map( + (message) => + html`

${message}

` + )} +`; diff --git a/src/frontend/src/flows/recovery/recoverWith/device.ts b/src/frontend/src/flows/recovery/recoverWith/device.ts index 5cea032d8b..62e152e8dc 100644 --- a/src/frontend/src/flows/recovery/recoverWith/device.ts +++ b/src/frontend/src/flows/recovery/recoverWith/device.ts @@ -1,11 +1,15 @@ import { CredentialId, DeviceData } from "$generated/internet_identity_types"; +import { infoToastTemplate } from "$src/components/infoToast"; +import infoToastCopy from "$src/components/infoToast/copy.json"; import { promptUserNumberTemplate } from "$src/components/promptUserNumber"; import { toast } from "$src/components/toast"; +import { I18n } from "$src/i18n"; import { convertToCredentialData } from "$src/utils/credential-devices"; import { AuthFail, Connection, LoginSuccess, + PossiblyWrongRPID, WebAuthnFailed, } from "$src/utils/iiConnection"; import { renderPage } from "$src/utils/lit-html"; @@ -50,6 +54,21 @@ export const recoverWithDevice = ({ } if (result.kind !== "loginSuccess") { + if (result.kind === "possiblyWrongRPID") { + const i18n = new I18n(); + const copy = i18n.i18n(infoToastCopy); + toast.info( + infoToastTemplate({ + title: copy.title_possibly_wrong_rp_id, + messages: [ + copy.message_possibly_wrong_rp_id_1, + copy.message_possibly_wrong_rp_id_2, + ], + }) + ); + return; + } + result satisfies AuthFail | WebAuthnFailed; toast.error("Could not authenticate using the device"); return; @@ -72,6 +91,7 @@ const attemptRecovery = async ({ }): Promise< | LoginSuccess | WebAuthnFailed + | PossiblyWrongRPID | AuthFail | { kind: "noRecovery" } | { kind: "tooManyRecovery" } diff --git a/src/frontend/src/utils/iiConnection.test.ts b/src/frontend/src/utils/iiConnection.test.ts index 4addc3c9f5..324bf0da0f 100644 --- a/src/frontend/src/utils/iiConnection.test.ts +++ b/src/frontend/src/utils/iiConnection.test.ts @@ -176,7 +176,7 @@ describe("Connection.login", () => { } }); - it("connection exludes rpId when user cancels", async () => { + it("connection excludes rpId when user cancels", async () => { // This one would fail because it's not the device the user is using at the moment. const currentOriginDevice: DeviceData = createMockDevice(currentOrigin); const currentOriginCredentialData = @@ -193,7 +193,7 @@ describe("Connection.login", () => { failSign = true; const firstLoginResult = await connection.login(BigInt(12345)); - expect(firstLoginResult.kind).toBe("webAuthnFailed"); + expect(firstLoginResult.kind).toBe("possiblyWrongRPID"); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( expect.arrayContaining([ @@ -327,7 +327,107 @@ describe("Connection.login", () => { } }); - it("connection does not exlud rpId when user cancels", async () => { + it("connection does not exclude rpId when user cancels", async () => { + const currentOriginDevice: DeviceData = createMockDevice(currentOrigin); + const currentOriginCredentialData = + convertToCredentialData(currentOriginDevice); + const currentDevice: DeviceData = createMockDevice(); + const currentDeviceCredentialData = + convertToCredentialData(currentDevice); + const mockActor = { + identity_info: vi.fn().mockResolvedValue({ Ok: { metadata: [] } }), + lookup: vi.fn().mockResolvedValue([currentOriginDevice, currentDevice]), + } as unknown as ActorSubclass<_SERVICE>; + const connection = new Connection("aaaaa-aa", mockActor); + + failSign = true; + const firstLoginResult = await connection.login(BigInt(12345)); + + expect(firstLoginResult.kind).toBe("webAuthnFailed"); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + expect.arrayContaining([ + currentOriginCredentialData, + currentDeviceCredentialData, + ]), + undefined + ); + + failSign = false; + const secondLoginResult = await connection.login(BigInt(12345)); + + expect(secondLoginResult.kind).toBe("loginSuccess"); + if (secondLoginResult.kind === "loginSuccess") { + expect(secondLoginResult.connection).toBeInstanceOf( + AuthenticatedConnection + ); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(2); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([ + currentDeviceCredentialData, + currentOriginCredentialData, + ]), + undefined + ); + } + }); + }); + + describe("domains compatibility flag enabled and browser doesn't support", () => { + beforeEach(() => { + DOMAIN_COMPATIBILITY.set(true); + vi.stubGlobal("navigator", { + // Does NOT Supports RoR + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + }); + }); + + it("login returns authenticated connection without rpID if browser doesn't support it", async () => { + const connection = new Connection("aaaaa-aa", mockActor); + + const loginResult = await connection.login(BigInt(12345)); + + expect(loginResult.kind).toBe("loginSuccess"); + if (loginResult.kind === "loginSuccess") { + expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToCredentialData(mockDevice)], + undefined + ); + } + }); + }); + + describe("domains compatibility flag disabled", () => { + beforeEach(() => { + DOMAIN_COMPATIBILITY.set(false); + vi.stubGlobal("navigator", { + // Supports RoR + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + }); + }); + + it("login returns authenticated connection without rpID if flag is not enabled", async () => { + const connection = new Connection("aaaaa-aa", mockActor); + + const loginResult = await connection.login(BigInt(12345)); + + expect(loginResult.kind).toBe("loginSuccess"); + if (loginResult.kind === "loginSuccess") { + expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToCredentialData(mockDevice)], + undefined + ); + } + }); + + it("connection does not exclude rpId when user cancels", async () => { const currentOriginDevice: DeviceData = createMockDevice(currentOrigin); const currentOriginCredentialData = convertToCredentialData(currentOriginDevice); diff --git a/src/frontend/src/utils/iiConnection.ts b/src/frontend/src/utils/iiConnection.ts index 9813d4e257..518d885814 100644 --- a/src/frontend/src/utils/iiConnection.ts +++ b/src/frontend/src/utils/iiConnection.ts @@ -124,6 +124,7 @@ export type RegisterNoSpace = { kind: "registerNoSpace" }; export type NoSeedPhrase = { kind: "noSeedPhrase" }; export type SeedPhraseFail = { kind: "seedPhraseFail" }; export type WebAuthnFailed = { kind: "webAuthnFailed" }; +export type PossiblyWrongRPID = { kind: "possiblyWrongRPID" }; export type InvalidAuthnMethod = { kind: "invalidAuthnMethod"; message: string; @@ -360,7 +361,12 @@ export class Connection { login = async ( userNumber: bigint ): Promise< - LoginSuccess | AuthFail | WebAuthnFailed | UnknownUser | ApiError + | LoginSuccess + | AuthFail + | WebAuthnFailed + | PossiblyWrongRPID + | UnknownUser + | ApiError > => { let devices: Omit[]; try { @@ -386,7 +392,7 @@ export class Connection { fromWebauthnCredentials = async ( userNumber: bigint, credentials: CredentialData[] - ): Promise => { + ): Promise => { const cancelledRpIds = this._cancelledRpIds.get(userNumber) ?? new Set(); const currentOrigin = window.location.origin; const dynamicRPIdEnabled = @@ -426,6 +432,8 @@ export class Connection { } else { this._cancelledRpIds.set(userNumber, new Set([rpId])); } + // We want to user to retry again and a new RP ID will be used. + return { kind: "possiblyWrongRPID" }; } return { kind: "webAuthnFailed" }; } From aaf88ffdfdb2b11b70bea12e5a14f4a864ba1215 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 09:40:50 +0100 Subject: [PATCH 07/14] Bump astro from 4.16.1 to 4.16.17 (#2756) Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 4.16.1 to 4.16.17. - [Release notes](https://github.com/withastro/astro/releases) - [Changelog](https://github.com/withastro/astro/blob/astro@4.16.17/packages/astro/CHANGELOG.md) - [Commits](https://github.com/withastro/astro/commits/astro@4.16.17/packages/astro) --- updated-dependencies: - dependency-name: astro dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 562 ++++++++++++++++++++-------------------------- package.json | 2 +- 2 files changed, 246 insertions(+), 318 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9cd9016d0e..815cf019cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "@typescript-eslint/parser": "6.7.5", "@vitejs/plugin-basic-ssl": "^1.1.0", "@wdio/globals": "^8.39.0", - "astro": "4.16.1", + "astro": "4.16.17", "eslint": "8.51.0", "fake-indexeddb": "^4.0.2", "lit-analyzer": "^2.0.2", @@ -314,12 +314,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz", - "integrity": "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "dev": true, "dependencies": { - "@babel/highlight": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", "picocolors": "^1.0.0" }, "engines": { @@ -327,30 +328,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.8.tgz", - "integrity": "sha512-ZsysZyXY4Tlx+Q53XdnOFmqwfB9QDTHYxaZYajWRoBLuLEAwI2UIbtxOjWh/cFaa9IKUlcB+DDuoskLuKu56JA==", + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz", + "integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.8.tgz", - "integrity": "sha512-Oixnb+DzmRT30qu9d3tJSQkxuygWm32DFykT4bRoORPa9hZ/L4KhVB/XiRm6KG+roIEM7DBQlmg27kw2HZkdZg==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", + "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.25.7", - "@babel/generator": "^7.25.7", - "@babel/helper-compilation-targets": "^7.25.7", - "@babel/helper-module-transforms": "^7.25.7", - "@babel/helpers": "^7.25.7", - "@babel/parser": "^7.25.8", - "@babel/template": "^7.25.7", - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.8", + "@babel/code-frame": "^7.26.0", + "@babel/generator": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.0", + "@babel/parser": "^7.26.0", + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.26.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -374,12 +375,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.7.tgz", - "integrity": "sha512-5Dqpl5fyV9pIAD62yK9P7fcA768uVPUyrQmqpqstHWgMma4feF1x/oFysBCVZLY5wJ2GkMUCdsNDnGZrPoR6rA==", + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", + "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", "dev": true, "dependencies": { - "@babel/types": "^7.25.7", + "@babel/parser": "^7.26.3", + "@babel/types": "^7.26.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -389,25 +391,25 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.7.tgz", - "integrity": "sha512-4xwU8StnqnlIhhioZf1tqnVWeQ9pvH/ujS8hRfw/WOza+/a+1qv69BWNy+oY231maTCWgKWhfBU7kDpsds6zAA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "dev": true, "dependencies": { - "@babel/types": "^7.25.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.7.tgz", - "integrity": "sha512-DniTEax0sv6isaw6qSQSfV4gVRNtw2rte8HHM45t9ZR0xILaufBRNkpMifCRiAPyvL4ACD6v0gfCwCmtOQaV4A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", + "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.25.7", - "@babel/helper-validator-option": "^7.25.7", + "@babel/compat-data": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -426,28 +428,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.7.tgz", - "integrity": "sha512-o0xCgpNmRohmnoWKQ0Ij8IdddjyBFE4T2kagL/x6M3+4zUgc+4qTOUBoNe4XxDskt1HPKO007ZPiMgLDq2s7Kw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", "dev": true, "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.7.tgz", - "integrity": "sha512-k/6f8dKG3yDz/qCwSM+RKovjMix563SLxQFo0UhRNo239SP6n9u5/eLtKD6EAjwta2JHJ49CsD8pms2HdNiMMQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.25.7", - "@babel/helper-simple-access": "^7.25.7", - "@babel/helper-validator-identifier": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -457,160 +458,61 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.7.tgz", - "integrity": "sha512-eaPZai0PiqCi09pPs3pAFfl/zYgGaE6IdXtYvmf0qlcDTd3WCtO7JWCcRd64e0EQrcYgiHibEZnOGsSY4QSgaw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-simple-access": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.7.tgz", - "integrity": "sha512-FPGAkJmyoChQeM+ruBGIDyrT2tKfZJO8NcxdC+CWNJi7N8/rZpSxK7yvBJ5O/nF1gfu5KzN7VKG3YVSLFfRSxQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", + "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", "dev": true, - "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" - }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.7.tgz", - "integrity": "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz", - "integrity": "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.7.tgz", - "integrity": "sha512-ytbPLsm+GjArDYXJ8Ydr1c/KJuutjF2besPNbIZnZ6MKUxi/uTA22t2ymmA4WFjZFpjiAMO0xuuJPqK2nvDVfQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.7.tgz", - "integrity": "sha512-Sv6pASx7Esm38KQpF/U/OXLwPPrdGHNKoeblRxgZRLXnAtnkEe4ptJPDtAZM7fBLadbc1Q07kQpSiGQ0Jg6tRA==", - "dev": true, - "dependencies": { - "@babel/template": "^7.25.7", - "@babel/types": "^7.25.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz", - "integrity": "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", + "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.25.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.0" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/parser": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.8.tgz", - "integrity": "sha512-HcttkxzdPucv3nNFmfOOMfFf64KgdJVqm1KaCm25dPGMLElo9nsLvXeJECQg8UzPuBGLyTSA0ZzqCtDSzKTEoQ==", + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", + "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", "dev": true, "dependencies": { - "@babel/types": "^7.25.8" + "@babel/types": "^7.26.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -620,12 +522,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.7.tgz", - "integrity": "sha512-ruZOnKO+ajVL/MVx+PwNBPOkrnXTXoWMtte1MBpegfCArhqOe3Bj52avVj1huLLxNKYKXYaSxZ2F+woK1ekXfw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", + "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -635,16 +537,16 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.7.tgz", - "integrity": "sha512-vILAg5nwGlR9EXE8JIOX4NHXd49lrYbN8hnjffDtoULwpL9hUx/N55nqh2qd0q6FyNDfjl9V79ecKGvFbcSA0Q==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.9.tgz", + "integrity": "sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.7", - "@babel/helper-module-imports": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/plugin-syntax-jsx": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -693,30 +595,30 @@ } }, "node_modules/@babel/template": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", - "integrity": "sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.25.7", - "@babel/parser": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.7.tgz", - "integrity": "sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==", + "version": "7.26.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", + "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.25.7", - "@babel/generator": "^7.25.7", - "@babel/parser": "^7.25.7", - "@babel/template": "^7.25.7", - "@babel/types": "^7.25.7", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.3", + "@babel/parser": "^7.26.3", + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.3", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -725,14 +627,13 @@ } }, "node_modules/@babel/types": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.8.tgz", - "integrity": "sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg==", + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", + "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.25.7", - "@babel/helper-validator-identifier": "^7.25.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1994,14 +1895,14 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.2.tgz", - "integrity": "sha512-/FIdS3PyZ39bjZlwqFnWqCOVnW7o963LtKMwQOD0NhQqw22gSr2YY1afu3FxRip4ZCZNsD5jq6Aaz6QV3D/Njw==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", "dev": true, "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", - "picomatch": "^2.3.1" + "picomatch": "^4.0.2" }, "engines": { "node": ">=14.0.0" @@ -2021,6 +1922,18 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", @@ -2246,44 +2159,44 @@ ] }, "node_modules/@shikijs/core": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.22.0.tgz", - "integrity": "sha512-S8sMe4q71TJAW+qG93s5VaiihujRK6rqDFqBnxqvga/3LvqHEnxqBIOPkt//IdXVtHkQWKu4nOQNk0uBGicU7Q==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.24.2.tgz", + "integrity": "sha512-BpbNUSKIwbKrRRA+BQj0BEWSw+8kOPKDJevWeSE/xIqGX7K0xrCZQ9kK0nnEQyrzsUoka1l81ZtJ2mGaCA32HQ==", "dev": true, "dependencies": { - "@shikijs/engine-javascript": "1.22.0", - "@shikijs/engine-oniguruma": "1.22.0", - "@shikijs/types": "1.22.0", + "@shikijs/engine-javascript": "1.24.2", + "@shikijs/engine-oniguruma": "1.24.2", + "@shikijs/types": "1.24.2", "@shikijs/vscode-textmate": "^9.3.0", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.3" } }, "node_modules/@shikijs/engine-javascript": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.22.0.tgz", - "integrity": "sha512-AeEtF4Gcck2dwBqCFUKYfsCq0s+eEbCEbkUuFou53NZ0sTGnJnJ/05KHQFZxpii5HMXbocV9URYVowOP2wH5kw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.24.2.tgz", + "integrity": "sha512-EqsmYBJdLEwEiO4H+oExz34a5GhhnVp+jH9Q/XjPjmBPc6TE/x4/gD0X3i0EbkKKNqXYHHJTJUpOLRQNkEzS9Q==", "dev": true, "dependencies": { - "@shikijs/types": "1.22.0", + "@shikijs/types": "1.24.2", "@shikijs/vscode-textmate": "^9.3.0", - "oniguruma-to-js": "0.4.3" + "oniguruma-to-es": "0.7.0" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.22.0.tgz", - "integrity": "sha512-5iBVjhu/DYs1HB0BKsRRFipRrD7rqjxlWTj4F2Pf+nQSPqc3kcyqFFeZXnBMzDf0HdqaFVvhDRAGiYNvyLP+Mw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.24.2.tgz", + "integrity": "sha512-ZN6k//aDNWRJs1uKB12pturKHh7GejKugowOFGAuG7TxDRLod1Bd5JhpOikOiFqPmKjKEPtEA6mRCf7q3ulDyQ==", "dev": true, "dependencies": { - "@shikijs/types": "1.22.0", + "@shikijs/types": "1.24.2", "@shikijs/vscode-textmate": "^9.3.0" } }, "node_modules/@shikijs/types": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.22.0.tgz", - "integrity": "sha512-Fw/Nr7FGFhlQqHfxzZY8Cwtwk5E9nKDUgeLjZgt3UuhcM3yJR9xj3ZGNravZZok8XmEZMiYkSMTPlPkULB8nww==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.24.2.tgz", + "integrity": "sha512-bdeWZiDtajGLG9BudI0AHet0b6e7FbR0EsE4jpGaI0YwHm/XJunI9+3uZnzFtX65gsyJ6ngCIWUfA4NWRPnBkQ==", "dev": true, "dependencies": { "@shikijs/vscode-textmate": "^9.3.0", @@ -2291,9 +2204,9 @@ } }, "node_modules/@shikijs/vscode-textmate": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-9.3.0.tgz", - "integrity": "sha512-jn7/7ky30idSkd/O5yDBfAnVt+JJpepofP/POZ1iMOxK59cOfqIgg/Dj0eFsjOTMw+4ycJN0uhZH/Eb0bs/EUA==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-9.3.1.tgz", + "integrity": "sha512-79QfK1393x9Ho60QFyLti+QfdJzRQCVLFb97kOIV7Eo9vQU/roINgk7m24uv0a7AUvN//RDH36FLjjK48v0s9g==", "dev": true }, "node_modules/@sinclair/typebox": { @@ -3269,9 +3182,9 @@ } }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -3609,27 +3522,27 @@ } }, "node_modules/astro": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/astro/-/astro-4.16.1.tgz", - "integrity": "sha512-ZeZd+L147HHgHmvoSkve7KM3EutV+hY0mOCa4PwARHEFAAh+omo4MUNoTWsFkfq7ozTgR0PCXQwslrZduoWHNg==", + "version": "4.16.17", + "resolved": "https://registry.npmjs.org/astro/-/astro-4.16.17.tgz", + "integrity": "sha512-OuD+BP7U6OqQLKtZ/FJkU2S+TOlifxS/OKUbZOb5p6y+LLBa1J3zHRJrIl7DUSq6eXY+9wSWwbJpD9JS+lqhxA==", "dev": true, "dependencies": { "@astrojs/compiler": "^2.10.3", "@astrojs/internal-helpers": "0.4.1", "@astrojs/markdown-remark": "5.3.0", "@astrojs/telemetry": "3.1.0", - "@babel/core": "^7.25.7", - "@babel/plugin-transform-react-jsx": "^7.25.7", - "@babel/types": "^7.25.7", + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx": "^7.25.9", + "@babel/types": "^7.26.0", "@oslojs/encoding": "^1.1.0", - "@rollup/pluginutils": "^5.1.2", + "@rollup/pluginutils": "^5.1.3", "@types/babel__core": "^7.20.5", "@types/cookie": "^0.6.0", - "acorn": "^8.12.1", + "acorn": "^8.14.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", - "ci-info": "^4.0.0", + "ci-info": "^4.1.0", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^0.7.2", @@ -3651,30 +3564,30 @@ "http-cache-semantics": "^4.1.1", "js-yaml": "^4.1.0", "kleur": "^4.1.5", - "magic-string": "^0.30.11", + "magic-string": "^0.30.14", "magicast": "^0.3.5", "micromatch": "^4.0.8", "mrmime": "^2.0.0", "neotraverse": "^0.6.18", - "ora": "^8.1.0", + "ora": "^8.1.1", "p-limit": "^6.1.0", "p-queue": "^8.0.1", "preferred-pm": "^4.0.0", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.6.3", - "shiki": "^1.22.0", - "tinyexec": "^0.3.0", - "tsconfck": "^3.1.3", + "shiki": "^1.23.1", + "tinyexec": "^0.3.1", + "tsconfck": "^3.1.4", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3", - "vite": "^5.4.8", - "vitefu": "^1.0.2", + "vite": "^5.4.11", + "vitefu": "^1.0.4", "which-pm": "^3.0.0", - "xxhash-wasm": "^1.0.2", + "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "zod": "^3.23.8", - "zod-to-json-schema": "^3.23.3", + "zod-to-json-schema": "^3.23.5", "zod-to-ts": "^1.2.0" }, "bin": { @@ -3690,9 +3603,9 @@ } }, "node_modules/astro/node_modules/ci-info": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", - "integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.1.0.tgz", + "integrity": "sha512-HutrvTNsF48wnxkzERIXOe5/mlcfFcbfCmwcg6CJnizbSue78AbDt+1cgl26zwn61WFxhcPykPfZrbqjGmBb4A==", "dev": true, "funding": [ { @@ -4161,9 +4074,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.0.tgz", - "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==", + "version": "4.24.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz", + "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==", "dev": true, "funding": [ { @@ -4180,10 +4093,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001663", - "electron-to-chromium": "^1.5.28", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.0" + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" @@ -4320,9 +4233,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001668", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001668.tgz", - "integrity": "sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw==", + "version": "1.0.30001689", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001689.tgz", + "integrity": "sha512-CmeR2VBycfa+5/jOfnp/NpWPGd06nf1XYiefUvhXFfZE4GkRc9jv+eGPS4nT558WS/8lYCzV8SlANCIPvbWP1g==", "dev": true, "funding": [ { @@ -5627,9 +5540,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.37", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.37.tgz", - "integrity": "sha512-u7000ZB/X0K78TaQqXZ5ktoR7J79B9US7IkE4zyvcILYwOGY2Tx9GRPYstn7HmuPcMxZ+BDGqIsyLpZQi9ufPw==", + "version": "1.5.74", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.74.tgz", + "integrity": "sha512-ck3//9RC+6oss/1Bh9tiAVFy5vfSKbRHAFh7Z3/eTRkEqJeWgymloShB17Vg3Z4nmDNp35vAd1BZ6CMW4Wt6Iw==", "dev": true }, "node_modules/emmet": { @@ -5647,6 +5560,12 @@ "dev": true, "license": "MIT" }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true + }, "node_modules/end-of-stream": { "version": "1.4.4", "dev": true, @@ -7594,9 +7513,9 @@ } }, "node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "bin": { "jsesc": "bin/jsesc" @@ -8195,9 +8114,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.12", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", - "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" @@ -9325,9 +9244,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "dev": true }, "node_modules/normalize-path": { @@ -9407,22 +9326,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/oniguruma-to-js": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/oniguruma-to-js/-/oniguruma-to-js-0.4.3.tgz", - "integrity": "sha512-X0jWUcAlxORhOqqBREgPMgnshB7ZGYszBNspP+tS9hPD3l13CdaXcHbgImoHUHlrvGx/7AvFEkTRhAGYh+jzjQ==", + "node_modules/oniguruma-to-es": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-0.7.0.tgz", + "integrity": "sha512-HRaRh09cE0gRS3+wi2zxekB+I5L8C/gN60S+vb11eADHUaB/q4u8wGGOX3GvwvitG8ixaeycZfeoyruKQzUgNg==", "dev": true, "dependencies": { - "regex": "^4.3.2" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" + "emoji-regex-xs": "^1.0.0", + "regex": "^5.0.2", + "regex-recursion": "^4.3.0" } }, "node_modules/ora": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.1.0.tgz", - "integrity": "sha512-GQEkNkH/GHOhPFXcqZs3IDahXEQcQxsSjEkK4KvEEST4t7eNzoMjxTzef+EZ+JluDEV+Raoi3WQ2CflnRdSVnQ==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.1.1.tgz", + "integrity": "sha512-YWielGi1XzG1UTvOaCFaNgEnuhZVMSHYkW/FQ7UX8O26PtlpdM84c0f7wLPlkvx2RfiQmnzd61d/MGxmpQeJPw==", "dev": true, "dependencies": { "chalk": "^5.3.0", @@ -10506,9 +10424,27 @@ "license": "MIT" }, "node_modules/regex": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/regex/-/regex-4.3.3.tgz", - "integrity": "sha512-r/AadFO7owAq1QJVeZ/nq9jNS1vyZt+6t1p/E59B56Rn2GCya+gr1KSyOzNL/er+r+B7phv5jG2xU2Nz1YkmJg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/regex/-/regex-5.0.2.tgz", + "integrity": "sha512-/pczGbKIQgfTMRV0XjABvc5RzLqQmwqxLHdQao2RTXPk+pmTXB2P0IaUHYdYyk412YLwUIkaeMd5T+RzVgTqnQ==", + "dev": true, + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-4.3.0.tgz", + "integrity": "sha512-5LcLnizwjcQ2ALfOj95MjcatxyqF5RPySx9yT+PaXu3Gox2vyAtLDjHB8NTJLtMGkvyau6nI3CfpwFCjPUIs/A==", + "dev": true, + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", "dev": true }, "node_modules/rehype": { @@ -11038,15 +10974,15 @@ } }, "node_modules/shiki": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.22.0.tgz", - "integrity": "sha512-/t5LlhNs+UOKQCYBtl5ZsH/Vclz73GIqT2yQsCBygr8L/ppTdmpL4w3kPLoZJbMKVWtoG77Ue1feOjZfDxvMkw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.24.2.tgz", + "integrity": "sha512-TR1fi6mkRrzW+SKT5G6uKuc32Dj2EEa7Kj0k8kGqiBINb+C1TiflVOiT9ta6GqOJtC4fraxO5SLUaKBcSY38Fg==", "dev": true, "dependencies": { - "@shikijs/core": "1.22.0", - "@shikijs/engine-javascript": "1.22.0", - "@shikijs/engine-oniguruma": "1.22.0", - "@shikijs/types": "1.22.0", + "@shikijs/core": "1.24.2", + "@shikijs/engine-javascript": "1.24.2", + "@shikijs/engine-oniguruma": "1.24.2", + "@shikijs/types": "1.24.2", "@shikijs/vscode-textmate": "^9.3.0", "@types/hast": "^3.0.4" } @@ -11478,9 +11414,9 @@ "dev": true }, "node_modules/tinyexec": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", - "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.1.tgz", + "integrity": "sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==", "dev": true }, "node_modules/tinypool": { @@ -11501,14 +11437,6 @@ "node": ">=14.0.0" } }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -12156,9 +12084,9 @@ } }, "node_modules/vite": { - "version": "5.4.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.9.tgz", - "integrity": "sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg==", + "version": "5.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", + "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", "dev": true, "dependencies": { "esbuild": "^0.21.3", @@ -12295,12 +12223,12 @@ } }, "node_modules/vitefu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.3.tgz", - "integrity": "sha512-iKKfOMBHob2WxEJbqbJjHAkmYgvFDPhuqrO82om83S8RLk+17FtyMBfcyeH8GqD0ihShtkMW/zzJgiA51hCNCQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.4.tgz", + "integrity": "sha512-y6zEE3PQf6uu/Mt6DTJ9ih+kyJLr4XcSgHR2zUkM8SWDhuixEJxfJ6CZGMHh1Ec3vPLoEA0IHU5oWzVqw8ulow==", "dev": true, "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0-beta.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "peerDependenciesMeta": { "vite": { @@ -13065,9 +12993,9 @@ "peer": true }, "node_modules/xxhash-wasm": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.0.2.tgz", - "integrity": "sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", "dev": true }, "node_modules/y18n": { @@ -13186,20 +13114,20 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-to-json-schema": { - "version": "3.23.3", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.23.3.tgz", - "integrity": "sha512-TYWChTxKQbRJp5ST22o/Irt9KC5nj7CdBKYB/AosCRdj/wxEMvv4NNaj9XVUHDOIp53ZxArGhnw5HMZziPFjog==", + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.1.tgz", + "integrity": "sha512-3h08nf3Vw3Wl3PK+q3ow/lIil81IT2Oa7YpQyUUDsEWbXveMesdfK1xBd2RhCkynwZndAxixji/7SYJJowr62w==", "dev": true, "peerDependencies": { - "zod": "^3.23.3" + "zod": "^3.24.1" } }, "node_modules/zod-to-ts": { diff --git a/package.json b/package.json index a6ee072c3d..75d38faf60 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@typescript-eslint/parser": "6.7.5", "@vitejs/plugin-basic-ssl": "^1.1.0", "@wdio/globals": "^8.39.0", - "astro": "4.16.1", + "astro": "4.16.17", "eslint": "8.51.0", "fake-indexeddb": "^4.0.2", "lit-analyzer": "^2.0.2", From a867c7f2b8c7ca903027e1bdb2f09b53a0c91ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lloren=C3=A7=20Muntaner?= Date: Thu, 19 Dec 2024 10:23:09 +0100 Subject: [PATCH 08/14] Append https:// in front of RP ID when excluding devices (#2755) * Convert cancelled rp id to full urls * Fix issue with `undefined` rpId --- src/frontend/src/utils/findWebAuthnRpId.test.ts | 8 ++++---- src/frontend/src/utils/findWebAuthnRpId.ts | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/frontend/src/utils/findWebAuthnRpId.test.ts b/src/frontend/src/utils/findWebAuthnRpId.test.ts index cb7c486d14..7f71345e70 100644 --- a/src/frontend/src/utils/findWebAuthnRpId.test.ts +++ b/src/frontend/src/utils/findWebAuthnRpId.test.ts @@ -180,7 +180,7 @@ describe("excludeCredentialsFromOrigins", () => { mockDeviceData("https://identity.internetcomputer.org"), mockDeviceData("https://identity.icp0.io"), ]; - const originsToExclude = new Set(["https://identity.ic0.app"]); + const originsToExclude = new Set(["identity.ic0.app"]); const currentOrigin = "https://identity.internetcomputer.org"; const result = excludeCredentialsFromOrigins( @@ -201,7 +201,7 @@ describe("excludeCredentialsFromOrigins", () => { mockDeviceData(undefined), // Should be treated as DEFAULT_DOMAIN mockDeviceData("https://identity.internetcomputer.org"), ]; - const originsToExclude = new Set(["https://identity.ic0.app"]); // Should match DEFAULT_DOMAIN + const originsToExclude = new Set(["identity.ic0.app"]); // Should match DEFAULT_DOMAIN const currentOrigin = "https://identity.internetcomputer.org"; const result = excludeCredentialsFromOrigins( @@ -240,8 +240,8 @@ describe("excludeCredentialsFromOrigins", () => { mockDeviceData("https://identity.internetcomputer.org"), ]; const originsToExclude = new Set([ - "https://identity.ic0.app", - "https://identity.internetcomputer.org", + "identity.ic0.app", + "identity.internetcomputer.org", ]); const currentOrigin = "https://identity.ic0.app"; diff --git a/src/frontend/src/utils/findWebAuthnRpId.ts b/src/frontend/src/utils/findWebAuthnRpId.ts index c23a77897e..215d73bad6 100644 --- a/src/frontend/src/utils/findWebAuthnRpId.ts +++ b/src/frontend/src/utils/findWebAuthnRpId.ts @@ -43,21 +43,21 @@ export const hasCredentialsFromMultipleOrigins = ( * Two origins match if they have the same hostname (domain). * * @param credentials - List of credential devices to filter - * @param origins - Set of origins to exclude (undefined values are treated as `currentOrigin`) + * @param rpIds - Set of origins to exclude (undefined values are treated as `currentOrigin`) * @param currentOrigin - The current origin to use when comparing against undefined origins * @returns Filtered list of credentials, excluding those from the specified origins */ export const excludeCredentialsFromOrigins = ( credentials: CredentialData[], - origins: Set, + rpIds: Set, currentOrigin: string ): CredentialData[] => { - if (origins.size === 0) { + if (rpIds.size === 0) { return credentials; } // Change `undefined` to the current origin. - const originsToExclude = Array.from(origins).map( - (origin) => origin ?? currentOrigin + const originsToExclude = Array.from(rpIds).map((origin) => + origin === undefined ? currentOrigin : `https://${origin}` ); return credentials.filter( (credential) => From 49d61c5d6c28081e0eab97119a33f581cc117ac2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 20:06:57 +0100 Subject: [PATCH 09/14] Bump astro from 4.16.17 to 4.16.18 (#2758) Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 4.16.17 to 4.16.18. - [Release notes](https://github.com/withastro/astro/releases) - [Changelog](https://github.com/withastro/astro/blob/astro@4.16.18/packages/astro/CHANGELOG.md) - [Commits](https://github.com/withastro/astro/commits/astro@4.16.18/packages/astro) --- updated-dependencies: - dependency-name: astro dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 815cf019cc..bf55dbaaaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "@typescript-eslint/parser": "6.7.5", "@vitejs/plugin-basic-ssl": "^1.1.0", "@wdio/globals": "^8.39.0", - "astro": "4.16.17", + "astro": "4.16.18", "eslint": "8.51.0", "fake-indexeddb": "^4.0.2", "lit-analyzer": "^2.0.2", @@ -3522,9 +3522,9 @@ } }, "node_modules/astro": { - "version": "4.16.17", - "resolved": "https://registry.npmjs.org/astro/-/astro-4.16.17.tgz", - "integrity": "sha512-OuD+BP7U6OqQLKtZ/FJkU2S+TOlifxS/OKUbZOb5p6y+LLBa1J3zHRJrIl7DUSq6eXY+9wSWwbJpD9JS+lqhxA==", + "version": "4.16.18", + "resolved": "https://registry.npmjs.org/astro/-/astro-4.16.18.tgz", + "integrity": "sha512-G7zfwJt9BDHEZwlaLNvjbInIw2hPryyD654314KV/XT34pJU6SfN1S+mWa8RAkALcZNJnJXCJmT3JXLQStD3Lw==", "dev": true, "dependencies": { "@astrojs/compiler": "^2.10.3", diff --git a/package.json b/package.json index 75d38faf60..bb595442cf 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@typescript-eslint/parser": "6.7.5", "@vitejs/plugin-basic-ssl": "^1.1.0", "@wdio/globals": "^8.39.0", - "astro": "4.16.17", + "astro": "4.16.18", "eslint": "8.51.0", "fake-indexeddb": "^4.0.2", "lit-analyzer": "^2.0.2", From 2fe168380fd33bed3ec7f2001c7e660ab87aea4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lloren=C3=A7=20Muntaner?= Date: Fri, 20 Dec 2024 12:43:30 +0100 Subject: [PATCH 10/14] UI Page to add current device to the current origin (#2757) * UI Page to replace current device's origin * Rename and change copy * Rename field * Update src/frontend/src/flows/addDevice/addCurrentDevice.json Co-authored-by: sea-snake <104725312+sea-snake@users.noreply.github.com> * Update src/frontend/src/flows/addDevice/addCurrentDevice.ts Co-authored-by: sea-snake <104725312+sea-snake@users.noreply.github.com> --------- Co-authored-by: sea-snake <104725312+sea-snake@users.noreply.github.com> --- .../src/flows/addDevice/addCurrentDevice.json | 9 ++++ .../src/flows/addDevice/addCurrentDevice.ts | 46 +++++++++++++++++++ src/showcase/src/pages/addCurrentDevice.astro | 17 +++++++ 3 files changed, 72 insertions(+) create mode 100644 src/frontend/src/flows/addDevice/addCurrentDevice.json create mode 100644 src/frontend/src/flows/addDevice/addCurrentDevice.ts create mode 100644 src/showcase/src/pages/addCurrentDevice.astro diff --git a/src/frontend/src/flows/addDevice/addCurrentDevice.json b/src/frontend/src/flows/addDevice/addCurrentDevice.json new file mode 100644 index 0000000000..fa4173ed87 --- /dev/null +++ b/src/frontend/src/flows/addDevice/addCurrentDevice.json @@ -0,0 +1,9 @@ +{ + "en": { + "title": "Add Current Device", + "paragraph": "Please add the current device to your identity to improve the user experience with Internet Identity.", + "skip": "Skip", + "add": "Add", + "label_icon": "Recommended Action" + } +} diff --git a/src/frontend/src/flows/addDevice/addCurrentDevice.ts b/src/frontend/src/flows/addDevice/addCurrentDevice.ts new file mode 100644 index 0000000000..2ebf99d9fe --- /dev/null +++ b/src/frontend/src/flows/addDevice/addCurrentDevice.ts @@ -0,0 +1,46 @@ +import { infoScreenTemplate } from "$src/components/infoScreen"; +import { I18n } from "$src/i18n"; +import { renderPage } from "$src/utils/lit-html"; +import { TemplateResult } from "lit-html"; +import copyJson from "./addCurrentDevice.json"; + +const addCurrentDeviceTemplate = ({ + add, + skip, + i18n, +}: { + add: () => void; + skip: () => void; + i18n: I18n; +}): TemplateResult => { + const copy = i18n.i18n(copyJson); + + return infoScreenTemplate({ + cancel: skip, + cancelText: copy.skip, + next: add, + nextText: copy.add, + title: copy.title, + paragraph: copy.paragraph, + scrollToTop: true, + icon: "info", + pageId: "add-current-device", + label: copy.label_icon, + }); +}; + +export const addCurrentDevicePage = renderPage(addCurrentDeviceTemplate); + +// Prompt the user to add the current device (with the current origin). +// Adding the current device to the current origin improves the UX of the user when they come back to this origin. +export const addCurrentDevice = (): Promise<{ + action: "skip" | "add-current-device"; +}> => { + return new Promise((resolve) => + addCurrentDevicePage({ + i18n: new I18n(), + add: () => resolve({ action: "add-current-device" }), + skip: () => resolve({ action: "skip" }), + }) + ); +}; diff --git a/src/showcase/src/pages/addCurrentDevice.astro b/src/showcase/src/pages/addCurrentDevice.astro new file mode 100644 index 0000000000..d58310a023 --- /dev/null +++ b/src/showcase/src/pages/addCurrentDevice.astro @@ -0,0 +1,17 @@ +--- +import Screen from "../layouts/Screen.astro"; +--- + + + + From edb52b872839d4c16d31c12f33c636503a1a0556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lloren=C3=A7=20Muntaner?= Date: Fri, 20 Dec 2024 15:33:46 +0100 Subject: [PATCH 11/14] Add new field to LoginSuccess to show add current device screen (#2759) * Add new field to LoginSuccess to show add current device screen * Add field to current tests --- src/frontend/src/components/authenticateBox/index.ts | 1 + src/frontend/src/utils/iiConnection.test.ts | 9 +++++++++ src/frontend/src/utils/iiConnection.ts | 5 +++++ src/showcase/src/flows.ts | 4 ++++ 4 files changed, 19 insertions(+) diff --git a/src/frontend/src/components/authenticateBox/index.ts b/src/frontend/src/components/authenticateBox/index.ts index 09cd29d81e..be078f9aaa 100644 --- a/src/frontend/src/components/authenticateBox/index.ts +++ b/src/frontend/src/components/authenticateBox/index.ts @@ -89,6 +89,7 @@ export const authenticateBox = async ({ connection: AuthenticatedConnection; newAnchor: boolean; authnMethod: "pin" | "passkey" | "recovery"; + showAddCurrentDevice: boolean; }> => { const promptAuth = async (autoSelectIdentity?: bigint) => authenticateBoxFlow({ diff --git a/src/frontend/src/utils/iiConnection.test.ts b/src/frontend/src/utils/iiConnection.test.ts index 324bf0da0f..48d9cdea5c 100644 --- a/src/frontend/src/utils/iiConnection.test.ts +++ b/src/frontend/src/utils/iiConnection.test.ts @@ -168,6 +168,7 @@ describe("Connection.login", () => { expect(loginResult.kind).toBe("loginSuccess"); if (loginResult.kind === "loginSuccess") { expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(loginResult.showAddCurrentDevice).toBe(false); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( [convertToCredentialData(mockDevice)], @@ -208,6 +209,7 @@ describe("Connection.login", () => { expect(secondLoginResult.kind).toBe("loginSuccess"); if (secondLoginResult.kind === "loginSuccess") { + expect(secondLoginResult.showAddCurrentDevice).toBe(true); expect(secondLoginResult.connection).toBeInstanceOf( AuthenticatedConnection ); @@ -258,6 +260,7 @@ describe("Connection.login", () => { expect(secondLoginResult.kind).toBe("loginSuccess"); if (secondLoginResult.kind === "loginSuccess") { + expect(secondLoginResult.showAddCurrentDevice).toBe(false); expect(secondLoginResult.connection).toBeInstanceOf( AuthenticatedConnection ); @@ -291,6 +294,7 @@ describe("Connection.login", () => { expect(loginResult.kind).toBe("loginSuccess"); if (loginResult.kind === "loginSuccess") { + expect(loginResult.showAddCurrentDevice).toBe(false); expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( @@ -318,6 +322,7 @@ describe("Connection.login", () => { expect(loginResult.kind).toBe("loginSuccess"); if (loginResult.kind === "loginSuccess") { + expect(loginResult.showAddCurrentDevice).toBe(false); expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( @@ -358,6 +363,7 @@ describe("Connection.login", () => { expect(secondLoginResult.kind).toBe("loginSuccess"); if (secondLoginResult.kind === "loginSuccess") { + expect(secondLoginResult.showAddCurrentDevice).toBe(false); expect(secondLoginResult.connection).toBeInstanceOf( AuthenticatedConnection ); @@ -391,6 +397,7 @@ describe("Connection.login", () => { expect(loginResult.kind).toBe("loginSuccess"); if (loginResult.kind === "loginSuccess") { + expect(loginResult.showAddCurrentDevice).toBe(false); expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( @@ -419,6 +426,7 @@ describe("Connection.login", () => { expect(loginResult.kind).toBe("loginSuccess"); if (loginResult.kind === "loginSuccess") { expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(loginResult.showAddCurrentDevice).toBe(false); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( [convertToCredentialData(mockDevice)], @@ -461,6 +469,7 @@ describe("Connection.login", () => { expect(secondLoginResult.connection).toBeInstanceOf( AuthenticatedConnection ); + expect(secondLoginResult.showAddCurrentDevice).toBe(false); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(2); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenNthCalledWith( 2, diff --git a/src/frontend/src/utils/iiConnection.ts b/src/frontend/src/utils/iiConnection.ts index 518d885814..a441468cfa 100644 --- a/src/frontend/src/utils/iiConnection.ts +++ b/src/frontend/src/utils/iiConnection.ts @@ -94,6 +94,7 @@ export type LoginSuccess = { kind: "loginSuccess"; connection: AuthenticatedConnection; userNumber: bigint; + showAddCurrentDevice: boolean; }; export type RegFlowNextStep = @@ -323,6 +324,7 @@ export class Connection { actor ), userNumber, + showAddCurrentDevice: false, }; } @@ -460,6 +462,7 @@ export class Connection { kind: "loginSuccess", userNumber, connection, + showAddCurrentDevice: cancelledRpIds.size > 0, }; }; fromIdentity = async ( @@ -480,6 +483,7 @@ export class Connection { kind: "loginSuccess", userNumber, connection, + showAddCurrentDevice: false, }; }; @@ -520,6 +524,7 @@ export class Connection { userNumber, actor ), + showAddCurrentDevice: false, }; }; diff --git a/src/showcase/src/flows.ts b/src/showcase/src/flows.ts index 8724cb89f0..d4cf68167d 100644 --- a/src/showcase/src/flows.ts +++ b/src/showcase/src/flows.ts @@ -92,6 +92,7 @@ const registerFlowOpts: RegisterFlowOpts = { kind: "loginSuccess", userNumber: BigInt(12356), connection: mockConnection, + showAddCurrentDevice: false, }; }, registrationAllowed: true, @@ -122,6 +123,7 @@ export const iiFlows: Record void> = { kind: "loginSuccess", userNumber: BigInt(1234), connection: mockConnection, + showAddCurrentDevice: false, }); }, allowPinLogin: true, @@ -138,6 +140,7 @@ export const iiFlows: Record void> = { kind: "loginSuccess", userNumber: BigInt(1234), connection: mockConnection, + showAddCurrentDevice: false, }); }, recover: () => { @@ -146,6 +149,7 @@ export const iiFlows: Record void> = { kind: "loginSuccess", userNumber: BigInt(1234), connection: mockConnection, + showAddCurrentDevice: false, }); }, retrievePinIdentityMaterial: ({ userNumber }) => { From 1e6cf696867ac39eb3c2a79bf90863da1753582b Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Mon, 30 Dec 2024 11:19:31 +0100 Subject: [PATCH 12/14] Fix authentication with (older) identities that have devices without a (valid) credential id. (#2760) * Fix authentication when (older) identities have devices without a credential id. * Rename fn to explicitly indicate it converts the device to valid credential data. --- .../src/flows/recovery/recoverWith/device.ts | 13 +-- src/frontend/src/utils/credential-devices.ts | 26 ++++-- src/frontend/src/utils/iiConnection.test.ts | 81 +++++++++++++++---- src/frontend/src/utils/iiConnection.ts | 7 +- 4 files changed, 95 insertions(+), 32 deletions(-) diff --git a/src/frontend/src/flows/recovery/recoverWith/device.ts b/src/frontend/src/flows/recovery/recoverWith/device.ts index 62e152e8dc..cba8326d96 100644 --- a/src/frontend/src/flows/recovery/recoverWith/device.ts +++ b/src/frontend/src/flows/recovery/recoverWith/device.ts @@ -1,10 +1,9 @@ -import { CredentialId, DeviceData } from "$generated/internet_identity_types"; import { infoToastTemplate } from "$src/components/infoToast"; import infoToastCopy from "$src/components/infoToast/copy.json"; import { promptUserNumberTemplate } from "$src/components/promptUserNumber"; import { toast } from "$src/components/toast"; import { I18n } from "$src/i18n"; -import { convertToCredentialData } from "$src/utils/credential-devices"; +import { convertToValidCredentialData } from "$src/utils/credential-devices"; import { AuthFail, Connection, @@ -13,6 +12,7 @@ import { WebAuthnFailed, } from "$src/utils/iiConnection"; import { renderPage } from "$src/utils/lit-html"; +import { nonNullish } from "@dfinity/utils"; export const recoverWithDeviceTemplate = ({ next, @@ -110,14 +110,9 @@ const attemptRecovery = async ({ return { kind: "tooManyRecovery" }; } - const hasCredentialId = ( - device: Omit - ): device is Omit & { credential_id: [CredentialId] } => - device.credential_id.length === 1; - const credentialData = recoveryCredentials - .filter(hasCredentialId) - .map(convertToCredentialData); + .map(convertToValidCredentialData) + .filter(nonNullish); return await connection.fromWebauthnCredentials(userNumber, credentialData); }; diff --git a/src/frontend/src/utils/credential-devices.ts b/src/frontend/src/utils/credential-devices.ts index ee5c5a4f7e..21a1c9dce4 100644 --- a/src/frontend/src/utils/credential-devices.ts +++ b/src/frontend/src/utils/credential-devices.ts @@ -11,10 +11,24 @@ export type CredentialData = { const derFromPubkey = (pubkey: DeviceKey): DerEncodedPublicKey => new Uint8Array(pubkey).buffer as DerEncodedPublicKey; -export const convertToCredentialData = ( +export const convertToValidCredentialData = ( device: Omit -): CredentialData => ({ - credentialId: Buffer.from(device.credential_id[0] ?? []), - pubkey: derFromPubkey(device.pubkey), - origin: device.origin[0], -}); +): CredentialData | undefined => { + // In certain cases, e.g. Chrome on Windows 10, an invalid credential id is + // not ignored but instead will result in a WebAuthn error that prevents a + // user from authenticating with any of their registered devices. + // + // Instead of throwing an error, we return `undefined` to make sure that the + // device will be filtered out to unblock the user from authenticating. + if ( + device.credential_id.length !== 1 || + device.credential_id[0].length === 0 + ) { + return; + } + return { + credentialId: Buffer.from(device.credential_id[0]), + pubkey: derFromPubkey(device.pubkey), + origin: device.origin[0], + }; +}; diff --git a/src/frontend/src/utils/iiConnection.test.ts b/src/frontend/src/utils/iiConnection.test.ts index 48d9cdea5c..2ac6a1768c 100644 --- a/src/frontend/src/utils/iiConnection.test.ts +++ b/src/frontend/src/utils/iiConnection.test.ts @@ -10,7 +10,10 @@ import { } from "$src/repositories/identityMetadata"; import { ActorSubclass, DerEncodedPublicKey, Signature } from "@dfinity/agent"; import { DelegationIdentity, WebAuthnIdentity } from "@dfinity/identity"; -import { CredentialData, convertToCredentialData } from "./credential-devices"; +import { + CredentialData, + convertToValidCredentialData, +} from "./credential-devices"; import { AuthenticatedConnection, Connection } from "./iiConnection"; import { MultiWebAuthnIdentity } from "./multiWebAuthnIdentity"; @@ -22,7 +25,7 @@ const createMockDevice = (origin?: string): DeviceData => ({ pubkey: new Uint8Array(), key_type: { platform: null }, purpose: { authentication: null }, - credential_id: [], + credential_id: [Uint8Array.from([0, 0, 0, 0, 0])], }); const mockDevice = createMockDevice(); @@ -171,7 +174,7 @@ describe("Connection.login", () => { expect(loginResult.showAddCurrentDevice).toBe(false); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( - [convertToCredentialData(mockDevice)], + [convertToValidCredentialData(mockDevice)], "identity.ic0.app" ); } @@ -181,10 +184,10 @@ describe("Connection.login", () => { // This one would fail because it's not the device the user is using at the moment. const currentOriginDevice: DeviceData = createMockDevice(currentOrigin); const currentOriginCredentialData = - convertToCredentialData(currentOriginDevice); + convertToValidCredentialData(currentOriginDevice); const currentDevice: DeviceData = createMockDevice(); const currentDeviceCredentialData = - convertToCredentialData(currentDevice); + convertToValidCredentialData(currentDevice); const mockActor = { identity_info: vi.fn().mockResolvedValue({ Ok: { metadata: [] } }), lookup: vi.fn().mockResolvedValue([currentOriginDevice, currentDevice]), @@ -230,10 +233,10 @@ describe("Connection.login", () => { it("connection doesn't exclude rpId if user has only one domain", async () => { const currentOriginDevice: DeviceData = createMockDevice(currentOrigin); const currentOriginCredentialData = - convertToCredentialData(currentOriginDevice); + convertToValidCredentialData(currentOriginDevice); const currentOriginDevice2: DeviceData = createMockDevice(currentOrigin); const currentOriginCredentialData2 = - convertToCredentialData(currentOriginDevice2); + convertToValidCredentialData(currentOriginDevice2); const mockActor = { identity_info: vi.fn().mockResolvedValue({ Ok: { metadata: [] } }), lookup: vi @@ -298,7 +301,7 @@ describe("Connection.login", () => { expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( - [convertToCredentialData(mockDevice)], + [convertToValidCredentialData(mockDevice)], undefined ); } @@ -326,7 +329,7 @@ describe("Connection.login", () => { expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( - [convertToCredentialData(mockDevice)], + [convertToValidCredentialData(mockDevice)], undefined ); } @@ -335,10 +338,10 @@ describe("Connection.login", () => { it("connection does not exclude rpId when user cancels", async () => { const currentOriginDevice: DeviceData = createMockDevice(currentOrigin); const currentOriginCredentialData = - convertToCredentialData(currentOriginDevice); + convertToValidCredentialData(currentOriginDevice); const currentDevice: DeviceData = createMockDevice(); const currentDeviceCredentialData = - convertToCredentialData(currentDevice); + convertToValidCredentialData(currentDevice); const mockActor = { identity_info: vi.fn().mockResolvedValue({ Ok: { metadata: [] } }), lookup: vi.fn().mockResolvedValue([currentOriginDevice, currentDevice]), @@ -401,7 +404,7 @@ describe("Connection.login", () => { expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( - [convertToCredentialData(mockDevice)], + [convertToValidCredentialData(mockDevice)], undefined ); } @@ -429,7 +432,7 @@ describe("Connection.login", () => { expect(loginResult.showAddCurrentDevice).toBe(false); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( - [convertToCredentialData(mockDevice)], + [convertToValidCredentialData(mockDevice)], undefined ); } @@ -438,10 +441,10 @@ describe("Connection.login", () => { it("connection does not exclude rpId when user cancels", async () => { const currentOriginDevice: DeviceData = createMockDevice(currentOrigin); const currentOriginCredentialData = - convertToCredentialData(currentOriginDevice); + convertToValidCredentialData(currentOriginDevice); const currentDevice: DeviceData = createMockDevice(); const currentDeviceCredentialData = - convertToCredentialData(currentDevice); + convertToValidCredentialData(currentDevice); const mockActor = { identity_info: vi.fn().mockResolvedValue({ Ok: { metadata: [] } }), lookup: vi.fn().mockResolvedValue([currentOriginDevice, currentDevice]), @@ -482,4 +485,52 @@ describe("Connection.login", () => { } }); }); + + describe("when a device credential id is missing", () => { + it("connection does not use this device to authenticate", async () => { + const deviceWithCredentialId: DeviceData = createMockDevice(); + const deviceWithoutCredentialId: DeviceData = createMockDevice(); + deviceWithoutCredentialId.credential_id = []; + const mockActor = { + identity_info: vi.fn().mockResolvedValue({ Ok: { metadata: [] } }), + lookup: vi + .fn() + .mockResolvedValue([ + deviceWithCredentialId, + deviceWithoutCredentialId, + ]), + } as unknown as ActorSubclass<_SERVICE>; + const connection = new Connection("aaaaa-aa", mockActor); + await connection.login(BigInt(12345)); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToValidCredentialData(deviceWithCredentialId)], + undefined + ); + }); + }); + + describe("when device credential id is invalid", () => { + it("connection does not use this device to authenticate", async () => { + const deviceValidCredentialId: DeviceData = createMockDevice(); + const deviceInvalidCredentialId: DeviceData = createMockDevice(); + deviceInvalidCredentialId.credential_id = [Uint8Array.from([])]; + const mockActor = { + identity_info: vi.fn().mockResolvedValue({ Ok: { metadata: [] } }), + lookup: vi + .fn() + .mockResolvedValue([ + deviceValidCredentialId, + deviceInvalidCredentialId, + ]), + } as unknown as ActorSubclass<_SERVICE>; + const connection = new Connection("aaaaa-aa", mockActor); + await connection.login(BigInt(12345)); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToValidCredentialData(deviceValidCredentialId)], + undefined + ); + }); + }); }); diff --git a/src/frontend/src/utils/iiConnection.ts b/src/frontend/src/utils/iiConnection.ts index a441468cfa..d8eeaefad4 100644 --- a/src/frontend/src/utils/iiConnection.ts +++ b/src/frontend/src/utils/iiConnection.ts @@ -50,7 +50,10 @@ import { } from "@dfinity/identity"; import { Principal } from "@dfinity/principal"; import { isNullish, nonNullish } from "@dfinity/utils"; -import { convertToCredentialData, CredentialData } from "./credential-devices"; +import { + convertToValidCredentialData, + CredentialData, +} from "./credential-devices"; import { excludeCredentialsFromOrigins, findWebAuthnRpId, @@ -387,7 +390,7 @@ export class Connection { return this.fromWebauthnCredentials( userNumber, - devices.map(convertToCredentialData) + devices.map(convertToValidCredentialData).filter(nonNullish) ); }; From 771294c71f1dd8a8cf9b119110dc7b5a98565dbb Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Tue, 31 Dec 2024 12:52:10 +0100 Subject: [PATCH 13/14] Implement mock openID actor methods (#2761) * Implement mock openID actor methods * Update with changes from other branch * Update with changes from other branch --- src/frontend/src/utils/iiConnection.ts | 28 ++++++++++- src/frontend/src/utils/mockOpenID.ts | 66 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 src/frontend/src/utils/mockOpenID.ts diff --git a/src/frontend/src/utils/iiConnection.ts b/src/frontend/src/utils/iiConnection.ts index d8eeaefad4..d84ec06af9 100644 --- a/src/frontend/src/utils/iiConnection.ts +++ b/src/frontend/src/utils/iiConnection.ts @@ -34,6 +34,7 @@ import { IdentityMetadata, IdentityMetadataRepository, } from "$src/repositories/identityMetadata"; +import { JWT, MockOpenID, OpenIDCredential, Salt } from "$src/utils/mockOpenID"; import { diagnosticInfo, unknownToString } from "$src/utils/utils"; import { Actor, @@ -153,6 +154,8 @@ export class Connection { // registered in another domain. In this case, we must try the other rpID. // Map> private _cancelledRpIds: Map> = new Map(); + protected _mockOpenID = new MockOpenID(); + public constructor( readonly canisterId: string, // Used for testing purposes @@ -615,6 +618,7 @@ export class Connection { export class AuthenticatedConnection extends Connection { private metadataRepository: IdentityMetadataRepository; + public constructor( public canisterId: string, public identity: SignIdentity, @@ -662,9 +666,15 @@ export class AuthenticatedConnection extends Connection { return this.actor; } - getAnchorInfo = async (): Promise => { + getAnchorInfo = async (): Promise< + IdentityAnchorInfo & { credentials: OpenIDCredential[] } + > => { const actor = await this.getActor(); - return await actor.get_anchor_info(this.userNumber); + const anchorInfo = await actor.get_anchor_info(this.userNumber); + const mockedAnchorInfo = await this._mockOpenID.get_anchor_info( + this.userNumber + ); + return { ...anchorInfo, ...mockedAnchorInfo }; }; getPrincipal = async ({ @@ -906,6 +916,20 @@ export class AuthenticatedConnection extends Connection { console.error("Unknown error", err); return { error: "internal_error" }; }; + + addJWT = async (jwt: JWT, salt: Salt): Promise => { + const result = await this._mockOpenID.add_jwt(this.userNumber, jwt, salt); + if ("Err" in result) { + throw new Error(result.Err); + } + }; + + removeJWT = async (iss: string, sub: string): Promise => { + const result = await this._mockOpenID.remove_jwt(this.userNumber, iss, sub); + if ("Err" in result) { + throw new Error(result.Err); + } + }; } // Reads the "origin" used to infer what domain a FIDO device is available on. diff --git a/src/frontend/src/utils/mockOpenID.ts b/src/frontend/src/utils/mockOpenID.ts new file mode 100644 index 0000000000..9e698d1e77 --- /dev/null +++ b/src/frontend/src/utils/mockOpenID.ts @@ -0,0 +1,66 @@ +import { MetadataMapV2, UserNumber } from "$generated/internet_identity_types"; +import { Principal } from "@dfinity/principal"; + +export type JWT = string; +export type Salt = Uint8Array; + +export interface OpenIDCredential { + iss: string; + sub: string; + aud: string; + principal: Principal; + last_usage_timestamp: bigint; + metadata: MetadataMapV2; +} + +// Mocked implementation of new or existing actor methods with only additional data +export class MockOpenID { + #credentials: OpenIDCredential[] = []; + + add_jwt( + _userNumber: UserNumber, + jwt: JWT, + _salt: Salt + ): Promise<{ Ok: null } | { Err: string }> { + const [_header, body, _signature] = jwt.split("."); + const { iss, sub, aud, email, name, picture } = JSON.parse(atob(body)); + if ( + this.#credentials.find( + (credential) => credential.iss === iss && credential.sub === sub + ) + ) { + return Promise.resolve({ Err: "This account has already been linked" }); + } + this.#credentials.push({ + iss, + sub, + aud, + principal: Principal.anonymous(), + last_usage_timestamp: BigInt(Date.now()) * BigInt(1000000), + metadata: [ + ["email", { String: email }], + ["name", { String: name }], + ["picture", { String: picture }], + ], + }); + return Promise.resolve({ Ok: null }); + } + + remove_jwt( + _userNumber: UserNumber, + iss: string, + sub: string + ): Promise<{ Ok: null } | { Err: string }> { + const index = this.#credentials.findIndex( + (credential) => credential.iss === iss && credential.sub === sub + ); + this.#credentials.splice(index, 1); + return Promise.resolve({ Ok: null }); + } + + get_anchor_info(_userNumber: UserNumber): Promise<{ + credentials: OpenIDCredential[]; + }> { + return Promise.resolve({ credentials: this.#credentials }); + } +} From 0d59af95e08e58fa87661d756246470ffb60bcd3 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Fri, 3 Jan 2025 15:10:28 +0100 Subject: [PATCH 14/14] Implement OpenID add/remove accounts in identity management (#2762) * Implement mock openID actor methods * Implement (un)link account on manage page. * Move Google client id to env variable. * Move Google client id to env variable. * Update CSP in test. * Update CSP in test. * Update CSP in test. * Add OpenID to showcase * Add OpenID to showcase * Apply prettier to json * Fix showcase * Fix showcase * Move callback path to shared constant. --- package.json | 4 +- src/canister_tests/src/framework.rs | 2 +- src/frontend/src/components/icons.ts | 26 +++ src/frontend/src/environment.ts | 2 + src/frontend/src/featureFlags/index.ts | 4 +- src/frontend/src/flows/manage/index.ts | 82 +++++++- .../flows/manage/linkedAccountsSection.json | 14 ++ .../src/flows/manage/linkedAccountsSection.ts | 139 +++++++++++++ src/frontend/src/flows/redirect.ts | 45 ++++ src/frontend/src/index.ts | 4 + src/frontend/src/styles/main.css | 39 +++- src/frontend/src/utils/i18n.ts | 5 + src/frontend/src/utils/iiConnection.ts | 10 +- src/frontend/src/utils/mockOpenID.ts | 23 +-- src/frontend/src/utils/openID.ts | 192 ++++++++++++++++++ src/frontend/test-setup.ts | 1 + src/internet_identity/src/http.rs | 2 +- src/showcase/src/constants.ts | 3 + src/showcase/src/pages/displayManage.astro | 7 + .../displayManageCredentialsMultiple.astro | 88 ++++++++ .../displayManageCredentialsSingle.astro | 76 +++++++ .../src/pages/displayManageSingle.astro | 7 + .../src/pages/displayManageTempKey.astro | 7 + vite.config.ts | 3 + 24 files changed, 747 insertions(+), 38 deletions(-) create mode 100644 src/frontend/src/flows/manage/linkedAccountsSection.json create mode 100644 src/frontend/src/flows/manage/linkedAccountsSection.ts create mode 100644 src/frontend/src/flows/redirect.ts create mode 100644 src/frontend/src/utils/openID.ts create mode 100644 src/showcase/src/pages/displayManageCredentialsMultiple.astro create mode 100644 src/showcase/src/pages/displayManageCredentialsSingle.astro diff --git a/package.json b/package.json index bb595442cf..87dd45436d 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "private": true, "license": "SEE LICENSE IN LICENSE.md", "scripts": { - "dev": "II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=1 vite", - "host": "II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=1 vite --host", + "dev": "II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=1 II_OPENID_GOOGLE_CLIENT_ID=45431994619-cbbfgtn7o0pp0dpfcg2l66bc4rcg7qbu.apps.googleusercontent.com vite", + "host": "II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=1 II_OPENID_GOOGLE_CLIENT_ID=45431994619-cbbfgtn7o0pp0dpfcg2l66bc4rcg7qbu.apps.googleusercontent.com vite --host", "showcase": "astro dev --root ./src/showcase", "build": "tsc --noEmit && vite build", "check": "tsc --project ./tsconfig.all.json --noEmit", diff --git a/src/canister_tests/src/framework.rs b/src/canister_tests/src/framework.rs index 0bc05dc661..e980a61d2a 100644 --- a/src/canister_tests/src/framework.rs +++ b/src/canister_tests/src/framework.rs @@ -433,7 +433,7 @@ xr-spatial-tracking=()", let rgx = Regex::new( "^default-src 'none';\ connect-src 'self' https:;\ -img-src 'self' data:;\ +img-src 'self' data: https://\\*.googleusercontent.com;\ script-src 'strict-dynamic' ('[^']+' )*'unsafe-inline' 'unsafe-eval' https:;\ base-uri 'none';\ form-action 'none';\ diff --git a/src/frontend/src/components/icons.ts b/src/frontend/src/components/icons.ts index f82f1829d6..0c8be576f7 100644 --- a/src/frontend/src/components/icons.ts +++ b/src/frontend/src/components/icons.ts @@ -445,3 +445,29 @@ export const cypherIcon = html` /> `; + +export const googleIcon = html` + + + + + + +`; diff --git a/src/frontend/src/environment.ts b/src/frontend/src/environment.ts index ed36419d65..1b98538d67 100644 --- a/src/frontend/src/environment.ts +++ b/src/frontend/src/environment.ts @@ -5,3 +5,5 @@ export const VERSION = import.meta.env.II_VERSION ?? ""; export const FETCH_ROOT_KEY = import.meta.env.II_FETCH_ROOT_KEY === "1"; export const DUMMY_AUTH = import.meta.env.II_DUMMY_AUTH === "1"; export const DUMMY_CAPTCHA = import.meta.env.II_DUMMY_CAPTCHA === "1"; +export const II_OPENID_GOOGLE_CLIENT_ID = import.meta.env + .II_OPENID_GOOGLE_CLIENT_ID; diff --git a/src/frontend/src/featureFlags/index.ts b/src/frontend/src/featureFlags/index.ts index 0b0c8d8aa4..14cd4c9b91 100644 --- a/src/frontend/src/featureFlags/index.ts +++ b/src/frontend/src/featureFlags/index.ts @@ -1,6 +1,7 @@ // Feature flags with default values const FEATURE_FLAGS_WITH_DEFAULTS = { DOMAIN_COMPATIBILITY: false, + OPENID_AUTHENTICATION: false, } as const satisfies Record; const LOCALSTORAGE_FEATURE_FLAGS_PREFIX = "ii-localstorage-feature-flags__"; @@ -63,4 +64,5 @@ const initializedFeatureFlags = Object.fromEntries( window.__featureFlags = initializedFeatureFlags; // Export initialized feature flags as named exports -export const { DOMAIN_COMPATIBILITY } = initializedFeatureFlags; +export const { DOMAIN_COMPATIBILITY, OPENID_AUTHENTICATION } = + initializedFeatureFlags; diff --git a/src/frontend/src/flows/manage/index.ts b/src/frontend/src/flows/manage/index.ts index c05f94d57e..e647d662d7 100644 --- a/src/frontend/src/flows/manage/index.ts +++ b/src/frontend/src/flows/manage/index.ts @@ -15,10 +15,13 @@ import { logoutSection } from "$src/components/logout"; import { mainWindow } from "$src/components/mainWindow"; import { toast } from "$src/components/toast"; import { ENABLE_PIN_QUERY_PARAM_KEY, LEGACY_II_URL } from "$src/config"; +import { OPENID_AUTHENTICATION } from "$src/featureFlags"; import { addDevice } from "$src/flows/addDevice/manage/addDevice"; import { dappsExplorer } from "$src/flows/dappsExplorer"; import { KnownDapp, getDapps } from "$src/flows/dappsExplorer/dapps"; import { dappsHeader, dappsTeaser } from "$src/flows/dappsExplorer/teaser"; +import { linkedAccountsSection } from "$src/flows/manage/linkedAccountsSection"; +import copyJson from "$src/flows/manage/linkedAccountsSection.json"; import { TempKeyWarningAction, tempKeyWarningBox, @@ -29,6 +32,14 @@ import { setupKey, setupPhrase } from "$src/flows/recovery/setupRecovery"; import { I18n } from "$src/i18n"; import { AuthenticatedConnection, Connection } from "$src/utils/iiConnection"; import { TemplateElement, renderPage } from "$src/utils/lit-html"; +import { OpenIDCredential } from "$src/utils/mockOpenID"; +import { + GOOGLE_REQUEST_CONFIG, + createAnonymousNonce, + decodeJWT, + isPermissionError, + requestJWT, +} from "$src/utils/openID"; import { PreLoadImage } from "$src/utils/preLoadImage"; import { isProtected, @@ -147,6 +158,9 @@ const displayManageTemplate = ({ onAddDevice, addRecoveryPhrase, addRecoveryKey, + credentials, + onLinkAccount, + onUnlinkAccount, dapps, exploreDapps, identityBackground, @@ -157,6 +171,9 @@ const displayManageTemplate = ({ onAddDevice: () => void; addRecoveryPhrase: () => void; addRecoveryKey: () => void; + credentials: OpenIDCredential[]; + onLinkAccount: () => void; + onUnlinkAccount: (credential: OpenIDCredential) => void; dapps: KnownDapp[]; exploreDapps: () => void; identityBackground: PreLoadImage; @@ -182,6 +199,14 @@ const displayManageTemplate = ({ onAddDevice, warnNoPasskeys, })} + ${OPENID_AUTHENTICATION.isEnabled() + ? linkedAccountsSection({ + credentials, + onLinkAccount, + onUnlinkAccount, + hasOtherAuthMethods: authenticators.length > 0, + }) + : ""} ${recoveryMethodsSection({ recoveries, addRecoveryPhrase, addRecoveryKey })}