mirror of
https://github.com/bitwarden/browser
synced 2026-01-05 18:13:26 +00:00
[PM-2014] Passkey registration (#5396)
* [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
This commit is contained in:
15
apps/web/src/app/auth/core/core.module.ts
Normal file
15
apps/web/src/app/auth/core/core.module.ts
Normal file
@@ -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
apps/web/src/app/auth/core/index.ts
Normal file
2
apps/web/src/app/auth/core/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./services";
|
||||
export * from "./core.module";
|
||||
1
apps/web/src/app/auth/core/services/index.ts
Normal file
1
apps/web/src/app/auth/core/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./webauthn-login";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./webauthn-login.service";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class WebauthnCredentialView {
|
||||
id: string;
|
||||
name: string;
|
||||
prfSupport: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user