Skip to content

Commit 965ef04

Browse files
lmuntaneraterga
andauthored
Push id.ai credentials at the end for 1.0 RP ID calculation (#3345)
* Push id.ai credentials at the end for 1.0 RP ID calculation * Fix lint * Add test to iiConnection * Refactor and improve comments * Move helper inside function * Fix test * slightly generalize unit tests * prettier --------- Co-authored-by: Arshavir Ter-Gabrielyan <[email protected]>
1 parent 14f10bc commit 965ef04

File tree

3 files changed

+177
-22
lines changed

3 files changed

+177
-22
lines changed

src/frontend/src/lib/utils/findWebAuthnFlows.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,22 @@ import { LEGACY_II_URL } from "$lib/config";
22
import { CredentialData } from "./credential-devices";
33
import { findWebAuthnFlows } from "./findWebAuthnFlows";
44

5+
vi.mock("$lib/globals", () => ({
6+
canisterConfig: {
7+
new_flow_origins: [["https://id.ai"]],
8+
},
9+
}));
10+
511
describe("findWebAuthnFlows", () => {
12+
const newOrigin = "https://id.ai";
13+
const newOriginRpId = new URL(newOrigin).hostname;
614
const currentOrigin = "https://identity.internetcomputer.org";
715
const nonCurrentOrigin1 = "https://identity.ic0.app";
816
const nonCurrentOrigin1RpId = new URL(nonCurrentOrigin1).hostname;
917
const nonCurrentOrigin2 = "https://identity.icp0.io";
1018
const nonCurrentOrigin2RpId = new URL(nonCurrentOrigin2).hostname;
1119
const relatedOrigins = [
20+
newOrigin,
1221
"https://identity.ic0.app",
1322
"https://identity.internetcomputer.org",
1423
"https://identity.icp0.io",
@@ -149,4 +158,77 @@ describe("findWebAuthnFlows", () => {
149158
{ useIframe: true, rpId: nonCurrentOrigin1RpId },
150159
]);
151160
});
161+
162+
it("pushes RP IDs from new_flow_origins to the end while preserving relative order for some permutations", () => {
163+
const test_cases = [
164+
{
165+
label: "newOrigin first, 1st currentOrigin before nonCurrentOrigin1",
166+
devices: [
167+
createMockCredential(newOrigin),
168+
createMockCredential(currentOrigin),
169+
createMockCredential(nonCurrentOrigin1),
170+
createMockCredential(currentOrigin),
171+
],
172+
expectedOrder: [
173+
{ useIframe: false, rpId: undefined },
174+
{ useIframe: true, rpId: nonCurrentOrigin1RpId },
175+
{ useIframe: true, rpId: newOriginRpId },
176+
],
177+
},
178+
{
179+
label: "newOrigin first, 1st currentOrigin after nonCurrentOrigin1",
180+
devices: [
181+
createMockCredential(newOrigin),
182+
createMockCredential(nonCurrentOrigin1),
183+
createMockCredential(currentOrigin),
184+
createMockCredential(currentOrigin),
185+
],
186+
expectedOrder: [
187+
{ useIframe: true, rpId: nonCurrentOrigin1RpId },
188+
{ useIframe: false, rpId: undefined },
189+
{ useIframe: true, rpId: newOriginRpId },
190+
],
191+
},
192+
{
193+
label: "newOrigin last, 1st currentOrigin after nonCurrentOrigin1",
194+
devices: [
195+
createMockCredential(nonCurrentOrigin1),
196+
createMockCredential(currentOrigin),
197+
createMockCredential(currentOrigin),
198+
createMockCredential(newOrigin),
199+
],
200+
expectedOrder: [
201+
{ useIframe: true, rpId: nonCurrentOrigin1RpId },
202+
{ useIframe: false, rpId: undefined },
203+
{ useIframe: true, rpId: newOriginRpId },
204+
],
205+
},
206+
{
207+
label: "triplicate currentOrigin",
208+
devices: [
209+
createMockCredential(newOrigin),
210+
createMockCredential(currentOrigin),
211+
createMockCredential(nonCurrentOrigin1),
212+
createMockCredential(currentOrigin),
213+
createMockCredential(currentOrigin),
214+
],
215+
expectedOrder: [
216+
{ useIframe: false, rpId: undefined },
217+
{ useIframe: true, rpId: nonCurrentOrigin1RpId },
218+
{ useIframe: true, rpId: newOriginRpId },
219+
],
220+
},
221+
];
222+
223+
for (const { label, devices, expectedOrder } of test_cases) {
224+
const result = findWebAuthnFlows({
225+
supportsRor: true, // irrelevant for this test
226+
devices,
227+
currentOrigin,
228+
relatedOrigins: [currentOrigin, nonCurrentOrigin1, newOrigin],
229+
});
230+
231+
expect(result, label).toEqual(expectedOrder);
232+
}
233+
});
152234
});

src/frontend/src/lib/utils/findWebAuthnFlows.ts

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { II_LEGACY_ORIGIN } from "$lib/legacy/constants";
22
import { isNullish, nonNullish } from "@dfinity/utils";
33
import { CredentialData } from "./credential-devices";
4+
import { canisterConfig } from "$lib/globals";
5+
import { isSameOrigin } from "./urlUtils";
46

57
export type WebAuthnFlow = {
68
useIframe: boolean;
@@ -25,7 +27,13 @@ type Parameters = {
2527
*
2628
* Logic:
2729
* - To calculate the RP IDs, we look for all RP IDs within the devices
28-
* - At the moment, we only use non-iframe if the RP ID matches the current origin. to avoid bad UX, if the RP ID doesn't match the current origin, the iframe will be used.
30+
* - We sort the devices to move the devices registered on the new flow origins to the end
31+
* - The rest of the order we keep as is because it's the order by last used (recently used first) returned by the backend
32+
* We do this because during the upgrade flow a new passkey is created and it will be used to authenticate in 1.0. This was initially considered a feature, not a bug.
33+
* But users don't know where passkeys are stored.
34+
* Therefore, the passkey that they use to authenticate in 1.0 is not in the same place where they upgraded.
35+
* Which triggers a new UX for the user that confuses them because they were used to a different UX.
36+
* - We only use non-iframe if the RP ID matches the current origin. to avoid bad UX, if the RP ID doesn't match the current origin, the iframe will be used.
2937
*
3038
* @param {Parameters} params - The parameters to find the webauthn steps.
3139
* @returns {WebAuthnFlow[]} The ordered steps to try to perform the webauthn authentication.
@@ -35,32 +43,58 @@ export const findWebAuthnFlows = ({
3543
currentOrigin,
3644
relatedOrigins,
3745
}: Parameters): WebAuthnFlow[] => {
46+
// We need the helpers inside so that when `canisterConfig` is accessed, it already exists.
47+
// The devices are expected to be ordered by recently used already
48+
// Move devices registered on the new flow origins to the end using toSorted (preserving relative order within groups)
49+
const newFlowOrigins = canisterConfig.new_flow_origins[0] ?? [];
50+
const isInNewFlow = (credentialData: CredentialData): boolean => {
51+
const origin = credentialData.origin ?? II_LEGACY_ORIGIN;
52+
return newFlowOrigins.some((o) => isSameOrigin(o, origin));
53+
};
54+
const sortNewFlowOriginsToEnd = (
55+
a: CredentialData,
56+
b: CredentialData,
57+
): number => {
58+
const aIn = isInNewFlow(a);
59+
const bIn = isInNewFlow(b);
60+
// Keep the order if both are in the new flow or both are not
61+
if (aIn === bIn) return 0;
62+
// Move the one that is in the new flow to the end
63+
return aIn ? 1 : -1;
64+
};
65+
3866
const currentRpId = new URL(currentOrigin).hostname;
3967
const relatedRpIds = relatedOrigins.map(
4068
(relatedOrigin) => new URL(relatedOrigin).hostname,
4169
);
4270

43-
// The devices are expected to be ordered by recently used already
44-
const orderedDeviceRpIds = [
45-
...new Set(
46-
devices
47-
// Device origin to RP ID (hostname)
48-
.map((device) =>
49-
device.origin === currentOrigin ||
50-
(currentOrigin === II_LEGACY_ORIGIN && isNullish(device.origin))
51-
? undefined
52-
: new URL(device.origin ?? II_LEGACY_ORIGIN).hostname,
53-
)
54-
// Filter out RP IDs that are not within `relatedRpIds`
55-
.filter((rpId) => isNullish(rpId) || relatedRpIds.includes(rpId)),
56-
),
57-
];
58-
59-
// Create steps from `deviceRpIds`, currently that's one step per RP ID
60-
const steps: WebAuthnFlow[] = orderedDeviceRpIds.map((rpId) => ({
61-
rpId,
62-
useIframe: nonNullish(rpId) && rpId !== currentRpId,
63-
}));
71+
// Sort devices in place
72+
devices.sort(sortNewFlowOriginsToEnd);
73+
// Create steps from `devices`, currently that's one step per RP ID
74+
const steps: WebAuthnFlow[] = devices
75+
// Device origin to RP ID (hostname)
76+
.map((device: CredentialData) =>
77+
device.origin === currentOrigin ||
78+
(currentOrigin === II_LEGACY_ORIGIN && isNullish(device.origin))
79+
? undefined
80+
: new URL(device.origin ?? II_LEGACY_ORIGIN).hostname,
81+
)
82+
// Filter out RP IDs that are not within `relatedRpIds`
83+
.filter(
84+
(rpId: string | undefined) =>
85+
isNullish(rpId) || relatedRpIds.includes(rpId),
86+
)
87+
// Remove duplicates
88+
.reduce((rpIds: Array<string | undefined>, rpId: string | undefined) => {
89+
if (rpIds.includes(rpId)) {
90+
return rpIds;
91+
}
92+
return [...rpIds, rpId];
93+
}, [])
94+
.map((rpId) => ({
95+
rpId,
96+
useIframe: nonNullish(rpId) && rpId !== currentRpId,
97+
}));
6498

6599
// If there are no steps, add a default step.
66100
if (steps.length === 0) {

src/frontend/src/lib/utils/iiConnection.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ const DEFAULT_INIT: InternetIdentityInit = {
8585
feature_flag_enable_generic_open_id_fe: [],
8686
};
8787

88+
vi.mock("$lib/globals", () => ({
89+
canisterConfig: {
90+
new_flow_origins: [["https://id.ai"]],
91+
},
92+
}));
93+
8894
const mockActor = {
8995
identity_info: vi.fn().mockImplementation(async () => {
9096
// The `await` is necessary to make sure that the `getterResponse` is set before the test continues.
@@ -232,6 +238,39 @@ describe("Connection.login", () => {
232238
}
233239
});
234240

241+
it("login returns authenticated connection with expected rpID if new flow origins are enabled", async () => {
242+
const newOriginDevice = createMockDevice("https://id.ai");
243+
const mockActor = {
244+
identity_info: vi.fn().mockImplementation(async () => {
245+
// The `await` is necessary to make sure that the `getterResponse` is set before the test continues.
246+
infoResponse = await mockRawMetadata;
247+
return { Ok: { metadata: mockRawMetadata } };
248+
}),
249+
identity_metadata_replace: vi.fn().mockResolvedValue({ Ok: null }),
250+
// The order here matters, the first device is the one that would be used normally.
251+
// But we changed to push the new_flow_origins to the end.
252+
lookup: vi.fn().mockResolvedValue([newOriginDevice, mockDevice]),
253+
} as unknown as ActorSubclass<_SERVICE>;
254+
const connection = new Connection("aaaaa-aa", DEFAULT_INIT, mockActor);
255+
256+
const loginResult = await connection.login(BigInt(12345));
257+
258+
expect(loginResult.kind).toBe("loginSuccess");
259+
if (loginResult.kind === "loginSuccess") {
260+
expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection);
261+
expect(loginResult.showAddCurrentDevice).toBe(false);
262+
expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1);
263+
expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith(
264+
[
265+
convertToValidCredentialData(mockDevice),
266+
convertToValidCredentialData(newOriginDevice),
267+
],
268+
"identity.ic0.app",
269+
true,
270+
);
271+
}
272+
});
273+
235274
it("login returns undefined RP ID if no related origins are in the config", async () => {
236275
const config: InternetIdentityInit = {
237276
...DEFAULT_INIT,

0 commit comments

Comments
 (0)