Skip to content

Commit

Permalink
[PM-2014] Passkey registration (#5396)
Browse files Browse the repository at this point in the history
* [PM-2014] feat: scaffold new fido2 login component and module

* [PM-1024] feat: add content to login settings component

* [PM-1024] feat: add badge and button aria label

* [PM-2014] feat: create new dialog

* feat: add ability to remove form field bottom margin

(cherry picked from commit 05925ff77ed47f3865c2aecade8271390d9e2fa6)

* [PM-2014] feat: disable dialog close button

* [PM-2014] feat: implement mocked failing wizard flow

* [PM-2014] feat: add icons and other content

* [PM-2014] feat: change wording to "creating" password

* [PM-2014] feat: add new auth and auth core modules

* [PM-2014] chore: move fido2-login-settings to auth module

* [PM-2014] chore: expose using barrel files

* [PM-2014] feat: fetch webauthn challenge

* [PM-2014] chore: refactor api logic into new api service and move ui logic into existing service

* [PM-2014] feat: add tests for new credential options

* [PM-2014] feat: return undefined when credential creation fails

* [PM-2014] feat: implement credential creation

* [PM-2014] feat: add passkey naming ui

* [PM-2014] feat: add support for creation token

* [PM-2014] feat: implement credential saving

* [PM-2014] feat: Basic list of credentials

* [PM-2014] feat: improve async data loading

* [PM-2014] feat: finish up list UI

* [PM-2014] fix: loading state not being set properly

* [PM-2014] feat: improve aria labels

* [PM-2014] feat: show toast on passkey saved

* [PM-2014] feat: add delete dialog

* [PM-2014] feat: implement deletion without user verification

* [PM-2014] feat: add user verification to delete

* [PM-2014] feat: change to danger button

* [PM-2014] feat: show `save` if passkeys already exist

* [PM-2014] feat: add passkey limit

* [PM-2014] feat: improve error on delete

* [PM-2014] feat: add support for feature flag

* [PM-2014] feat: update copy

* [PM-2014] feat: reduce remove button margin

* [PM-2014] feat: refactor submit method

* [PM-2014] feat: autofocus fields

* [PM-2014] fix: move error handling to components

After discussing it with Jake we decided that following convention was best.

* [PM-2014] feat: change toast depending on existing passkeys

* [PM-2014] chore: rename everything from `fido2` to `webauthn`

* [PM-2014] fix: `CoreAuthModule` duplicate import

* [PM-2014] feat: change to new figma design `Encryption not supported`

* [PM-2014] fix: add missing href

* [PM-2014] fix: misaligned badge

* [PM-2014] chore: remove whitespace

* [PM-2014] fix: dialog close bug

* [PM-2014] fix: badge alignment not applying properly

* [PM-2014] fix: remove redundant align class

* [PM-2014] chore: move CoreAuthModule to AuthModule

* [PM-2014] feat: create new settings module

* [PM-2014] feat: move change password component to settings module

* [PM-2014] chore: tweak loose components recommendation

* [PM-2014] fix: remove deprecated pattern

* [PM-2014] chore: rename everything to `WebauthnLogin` to follow new naming scheme

* [PM-2014] chore: document requests and responses

* [PM-2014] fix: remove `undefined`

* [PM-2014] fix: clarify webauthn login service

* [PM-2014] fix: use `getCredentials$()`

* [PM-2014] fix: badge alignment using important statement

* [PM-2014] fix: remove sm billing flag

* [PM-2014] fix: `CoreAuthModule` double import

* [PM-2014] fix: unimported component (issue due to conflict with master)

* [PM-2014] fix: unawaited promise bug
  • Loading branch information
coroiu authored Oct 10, 2023
1 parent b2aa33f commit 725ee08
Show file tree
Hide file tree
Showing 34 changed files with 1,088 additions and 10 deletions.
12 changes: 12 additions & 0 deletions apps/web/src/app/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { NgModule } from "@angular/core";

import { CoreAuthModule } from "./core";
import { SettingsModule } from "./settings/settings.module";

@NgModule({
imports: [CoreAuthModule, SettingsModule],
declarations: [],
providers: [],
exports: [SettingsModule],
})
export class AuthModule {}
15 changes: 15 additions & 0 deletions apps/web/src/app/auth/core/core.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NgModule, Optional, SkipSelf } from "@angular/core";

import { WebauthnLoginApiService } from "./services/webauthn-login/webauthn-login-api.service";
import { WebauthnLoginService } from "./services/webauthn-login/webauthn-login.service";

@NgModule({
providers: [WebauthnLoginService, WebauthnLoginApiService],
})
export class CoreAuthModule {
constructor(@Optional() @SkipSelf() parentModule?: CoreAuthModule) {
if (parentModule) {
throw new Error("CoreAuthModule is already loaded. Import it in AuthModule only");
}
}
}
2 changes: 2 additions & 0 deletions apps/web/src/app/auth/core/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./services";
export * from "./core.module";
1 change: 1 addition & 0 deletions apps/web/src/app/auth/core/services/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./webauthn-login";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./webauthn-login.service";
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { WebauthnLoginAttestationResponseRequest } from "./webauthn-login-attestation-response.request";

/**
* Request sent to the server to save a newly created webauthn login credential.
*/
export class SaveCredentialRequest {
/** The response recieved from the authenticator. This contains the public key */
deviceResponse: WebauthnLoginAttestationResponseRequest;

/** Nickname chosen by the user to identify this credential */
name: string;

/**
* Token required by the server to complete the creation.
* It contains encrypted information that the server needs to verify the credential.
*/
token: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Utils } from "@bitwarden/common/platform/misc/utils";

import { WebauthnLoginAuthenticatorResponseRequest } from "./webauthn-login-authenticator-response.request";

/**
* The response recieved from an authentiator after a successful attestation.
* This request is used to save newly created webauthn login credentials to the server.
*/
export class WebauthnLoginAttestationResponseRequest extends WebauthnLoginAuthenticatorResponseRequest {
response: {
attestationObject: string;
clientDataJson: string;
};

constructor(credential: PublicKeyCredential) {
super(credential);

if (!(credential.response instanceof AuthenticatorAttestationResponse)) {
throw new Error("Invalid authenticator response");
}

this.response = {
attestationObject: Utils.fromBufferToB64(credential.response.attestationObject),
clientDataJson: Utils.fromBufferToB64(credential.response.clientDataJSON),
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Utils } from "@bitwarden/common/platform/misc/utils";

/**
* An abstract class that represents responses recieved from the webauthn authenticator.
* It contains data that is commonly returned during different types of authenticator interactions.
*/
export abstract class WebauthnLoginAuthenticatorResponseRequest {
id: string;
rawId: string;
type: string;
extensions: Record<string, unknown>;

constructor(credential: PublicKeyCredential) {
this.id = credential.id;
this.rawId = Utils.fromBufferToB64(credential.rawId);
this.type = credential.type;
this.extensions = {}; // Extensions are handled client-side
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ChallengeResponse } from "@bitwarden/common/auth/models/response/two-factor-web-authn.response";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";

/**
* Options provided by the server to be used during attestation (i.e. creation of a new webauthn credential)
*/
export class WebauthnLoginCredentialCreateOptionsResponse extends BaseResponse {
/** Options to be provided to the webauthn authenticator */
options: ChallengeResponse;

/**
* Contains an encrypted version of the {@link options}.
* Used by the server to validate the attestation response of newly created credentials.
*/
token: string;

constructor(response: unknown) {
super(response);
this.options = new ChallengeResponse(this.getResponseProperty("options"));
this.token = this.getResponseProperty("token");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";

/**
* A webauthn login credential recieved from the server.
*/
export class WebauthnLoginCredentialResponse extends BaseResponse {
id: string;
name: string;
prfSupport: boolean;

constructor(response: unknown) {
super(response);
this.id = this.getResponseProperty("id");
this.name = this.getResponseProperty("name");
this.prfSupport = this.getResponseProperty("prfSupport");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Injectable } from "@angular/core";

import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { Verification } from "@bitwarden/common/types/verification";

import { SaveCredentialRequest } from "./request/save-credential.request";
import { WebauthnLoginCredentialCreateOptionsResponse } from "./response/webauthn-login-credential-create-options.response";
import { WebauthnLoginCredentialResponse } from "./response/webauthn-login-credential.response";

@Injectable()
export class WebauthnLoginApiService {
constructor(
private apiService: ApiService,
private userVerificationService: UserVerificationService
) {}

async getCredentialCreateOptions(
verification: Verification
): Promise<WebauthnLoginCredentialCreateOptionsResponse> {
const request = await this.userVerificationService.buildRequest(verification);
const response = await this.apiService.send("POST", "/webauthn/options", request, true, true);
return new WebauthnLoginCredentialCreateOptionsResponse(response);
}

async saveCredential(request: SaveCredentialRequest): Promise<boolean> {
await this.apiService.send("POST", "/webauthn", request, true, true);
return true;
}

getCredentials(): Promise<ListResponse<WebauthnLoginCredentialResponse>> {
return this.apiService.send("GET", "/webauthn", null, true, true);
}

async deleteCredential(credentialId: string, verification: Verification): Promise<void> {
const request = await this.userVerificationService.buildRequest(verification);
await this.apiService.send("POST", `/webauthn/${credentialId}/delete`, request, true, true);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { mock, MockProxy } from "jest-mock-extended";

import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";

import { WebauthnLoginApiService } from "./webauthn-login-api.service";
import { WebauthnLoginService } from "./webauthn-login.service";

describe("WebauthnService", () => {
let apiService!: MockProxy<WebauthnLoginApiService>;
let credentials: MockProxy<CredentialsContainer>;
let webauthnService!: WebauthnLoginService;

beforeAll(() => {
// Polyfill missing class
window.PublicKeyCredential = class {} as any;
window.AuthenticatorAttestationResponse = class {} as any;
apiService = mock<WebauthnLoginApiService>();
credentials = mock<CredentialsContainer>();
webauthnService = new WebauthnLoginService(apiService, credentials);
});

describe("createCredential", () => {
it("should return undefined when navigator.credentials throws", async () => {
credentials.create.mockRejectedValue(new Error("Mocked error"));
const options = createCredentialCreateOptions();

const result = await webauthnService.createCredential(options);

expect(result).toBeUndefined();
});

it("should return credential when navigator.credentials does not throw", async () => {
const credential = createDeviceResponse();
credentials.create.mockResolvedValue(credential as PublicKeyCredential);
const options = createCredentialCreateOptions();

const result = await webauthnService.createCredential(options);

expect(result).toBe(credential);
});
});
});

function createCredentialCreateOptions(): CredentialCreateOptionsView {
return new CredentialCreateOptionsView(Symbol() as any, Symbol() as any);
}

function createDeviceResponse(): PublicKeyCredential {
const credential = {
id: "dGVzdA==",
rawId: new Uint8Array([0x74, 0x65, 0x73, 0x74]),
type: "public-key",
response: {
attestationObject: new Uint8Array([0, 0, 0]),
clientDataJSON: "eyJ0ZXN0IjoidGVzdCJ9",
},
} as any;

Object.setPrototypeOf(credential, PublicKeyCredential.prototype);
Object.setPrototypeOf(credential.response, AuthenticatorAttestationResponse.prototype);

return credential;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Injectable, Optional } from "@angular/core";
import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs";

import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Verification } from "@bitwarden/common/types/verification";

import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
import { WebauthnCredentialView } from "../../views/webauth-credential.view";

import { SaveCredentialRequest } from "./request/save-credential.request";
import { WebauthnLoginAttestationResponseRequest } from "./request/webauthn-login-attestation-response.request";
import { WebauthnLoginApiService } from "./webauthn-login-api.service";

@Injectable()
export class WebauthnLoginService {
private navigatorCredentials: CredentialsContainer;
private _refresh$ = new BehaviorSubject<void>(undefined);
private _loading$ = new BehaviorSubject<boolean>(true);
private readonly credentials$ = this._refresh$.pipe(
tap(() => this._loading$.next(true)),
switchMap(() => this.fetchCredentials$()),
tap(() => this._loading$.next(false)),
shareReplay({ bufferSize: 1, refCount: true })
);

readonly loading$ = this._loading$.asObservable();

constructor(
private apiService: WebauthnLoginApiService,
@Optional() navigatorCredentials?: CredentialsContainer,
@Optional() private logService?: LogService
) {
// Default parameters don't work when used with Angular DI
this.navigatorCredentials = navigatorCredentials ?? navigator.credentials;
}

async getCredentialCreateOptions(
verification: Verification
): Promise<CredentialCreateOptionsView> {
const response = await this.apiService.getCredentialCreateOptions(verification);
return new CredentialCreateOptionsView(response.options, response.token);
}

async createCredential(
credentialOptions: CredentialCreateOptionsView
): Promise<PublicKeyCredential | undefined> {
const nativeOptions: CredentialCreationOptions = {
publicKey: credentialOptions.options,
};

try {
const response = await this.navigatorCredentials.create(nativeOptions);
if (!(response instanceof PublicKeyCredential)) {
return undefined;
}
return response;
} catch (error) {
this.logService?.error(error);
return undefined;
}
}

async saveCredential(
credentialOptions: CredentialCreateOptionsView,
deviceResponse: PublicKeyCredential,
name: string
) {
const request = new SaveCredentialRequest();
request.deviceResponse = new WebauthnLoginAttestationResponseRequest(deviceResponse);
request.token = credentialOptions.token;
request.name = name;
await this.apiService.saveCredential(request);
this.refresh();
}

/**
* List of webauthn credentials saved on the server.
*
* **Note:**
* - Subscribing might trigger a network request if the credentials haven't been fetched yet.
* - The observable is shared and will not create unnecessary duplicate requests.
* - The observable will automatically re-fetch if the user adds or removes a credential.
* - The observable is lazy and will only fetch credentials when subscribed to.
* - Don't subscribe to this in the constructor of a long-running service, as it will keep the observable alive.
*/
getCredentials$(): Observable<WebauthnCredentialView[]> {
return this.credentials$;
}

getCredential$(credentialId: string): Observable<WebauthnCredentialView> {
return this.credentials$.pipe(
map((credentials) => credentials.find((c) => c.id === credentialId)),
filter((c) => c !== undefined)
);
}

async deleteCredential(credentialId: string, verification: Verification): Promise<void> {
await this.apiService.deleteCredential(credentialId, verification);
this.refresh();
}

private fetchCredentials$(): Observable<WebauthnCredentialView[]> {
return from(this.apiService.getCredentials()).pipe(map((response) => response.data));
}

private refresh() {
this._refresh$.next();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ChallengeResponse } from "@bitwarden/common/auth/models/response/two-factor-web-authn.response";

export class CredentialCreateOptionsView {
constructor(readonly options: ChallengeResponse, readonly token: string) {}
}
5 changes: 5 additions & 0 deletions apps/web/src/app/auth/core/views/webauth-credential.view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class WebauthnCredentialView {
id: string;
name: string;
prfSupport: boolean;
}
2 changes: 2 additions & 0 deletions apps/web/src/app/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./auth.module";
export * from "./core";
13 changes: 12 additions & 1 deletion apps/web/src/app/auth/settings/change-password.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ <h1>{{ "changeMasterPassword" | i18n }}</h1>
<auth-password-callout [policy]="enforcedPolicyOptions" *ngIf="enforcedPolicyOptions">
</auth-password-callout>

<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate autocomplete="off">
<form
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
autocomplete="off"
class="tw-mb-14"
>
<div class="row">
<div class="col-6">
<div class="form-group">
Expand Down Expand Up @@ -118,3 +125,7 @@ <h1>{{ "changeMasterPassword" | i18n }}</h1>
{{ "changeMasterPassword" | i18n }}
</button>
</form>

<app-webauthn-login-settings
*ngIf="showWebauthnLoginSettings$ | async"
></app-webauthn-login-settings>
Loading

0 comments on commit 725ee08

Please sign in to comment.