-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[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
- Loading branch information
Showing
34 changed files
with
1,088 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * from "./services"; | ||
export * from "./core.module"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./webauthn-login"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./webauthn-login.service"; |
18 changes: 18 additions & 0 deletions
18
apps/web/src/app/auth/core/services/webauthn-login/request/save-credential.request.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
27 changes: 27 additions & 0 deletions
27
.../auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
}; | ||
} | ||
} |
19 changes: 19 additions & 0 deletions
19
...uth/core/services/webauthn-login/request/webauthn-login-authenticator-response.request.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
22 changes: 22 additions & 0 deletions
22
...ore/services/webauthn-login/response/webauthn-login-credential-create-options.response.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
} | ||
} |
17 changes: 17 additions & 0 deletions
17
.../src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
} | ||
} |
40 changes: 40 additions & 0 deletions
40
apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-api.service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
63 changes: 63 additions & 0 deletions
63
apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
109 changes: 109 additions & 0 deletions
109
apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} |
5 changes: 5 additions & 0 deletions
5
apps/web/src/app/auth/core/views/credential-create-options.view.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) {} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
export class WebauthnCredentialView { | ||
id: string; | ||
name: string; | ||
prfSupport: boolean; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * from "./auth.module"; | ||
export * from "./core"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.