Skip to content

Commit

Permalink
Refactor finding rpId for login (#2751)
Browse files Browse the repository at this point in the history
* Refactor finding rpId for login

* Add tests

* Fix build
  • Loading branch information
lmuntaner authored Dec 17, 2024
1 parent 66edea7 commit 5e77adc
Show file tree
Hide file tree
Showing 4 changed files with 158 additions and 26 deletions.
132 changes: 126 additions & 6 deletions src/frontend/src/utils/iiConnection.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
);
}
});
});
25 changes: 23 additions & 2 deletions src/frontend/src/utils/iiConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";

/*
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -369,13 +376,24 @@ export class Connection {
userNumber: bigint,
credentials: CredentialData[]
): Promise<LoginSuccess | WebAuthnFailed | AuthFail> => {
// 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
Expand Down Expand Up @@ -520,6 +538,9 @@ export class Connection {
createActor = async (
identity?: SignIdentity
): Promise<ActorSubclass<_SERVICE>> => {
if (this.overrideActor !== undefined) {
return this.overrideActor;
}
const agent = await HttpAgent.create({
identity,
host: inferHost(),
Expand Down
25 changes: 8 additions & 17 deletions src/frontend/src/utils/multiWebAuthnIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) => ({
Expand All @@ -85,7 +76,7 @@ export class MultiWebAuthnIdentity extends SignIdentity {
})),
challenge: blob,
userVerification: "discouraged",
rpId,
rpId: this.rpId,
},
})) as PublicKeyCredential;

Expand Down
2 changes: 1 addition & 1 deletion src/showcase/src/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class MockAuthenticatedConnection extends AuthenticatedConnection {
constructor() {
super(
"12345",
MultiWebAuthnIdentity.fromCredentials([]),
MultiWebAuthnIdentity.fromCredentials([], undefined),
mockDelegationIdentity,
BigInt(12345),
mockActor
Expand Down

0 comments on commit 5e77adc

Please sign in to comment.