mirror of
https://github.com/bitwarden/browser
synced 2025-12-18 09:13:33 +00:00
Auth/PM-2041 - Finish adding FIDO2 Authentication + Decryption to Web Client (#6798)
* PM-2041 - (1) Bring over WebauthnApiService + required models from existing #5493 PR (2) Per discussion with Andreas, remove unnecessary methods from WebauthnApiService * PM-2041 - Rename responses folder to response to match rest of codebase * PM-2041 - Recreate BaseLoginViaWebAuthnComponent and then web implementation of it. * PM-2041 - Web routing module - add LoginViaWebAuthnComponent and associated route "login-with-passkey" * PM-2041 - InjectionTokens - add new navigator credentials injection token which provides the CredentialsContainer interface of the Credential Management API and exposes methods to request credentials and notify the user agent when events such as successful sign in or sign out happen * PM-2041 - Rename WebauthnApiService & abstraction to WebAuthnLoginApiService * PM-2041 - Rename WebauthnLoginApiService to WebAuthnAdminApiService * PM-2041 - Bring over first draft of webauthn-login.service + abstraction; register on jslib-services.module. * PM-2041 - Bring over web & base login component changes to add login with passkey button if feature flag enabled. * PM-2041 - WebAuthnAdminApi - update list of TODOs based on conversation with Andreas * PM-2041 - Login.module - cleanup todo after conversation w/ Andreas * PM-2041 - Move utils out of web and into common auth/utils and renamed to webauthn-utils * PM-2041 - Update userDecryptionOptions to support new webauthn prf decryption option * PM-2041 - (1) Recreate webauthn-login service with updated logic (2) Move files from webauthn to webauthn-login (3) Recreate webauthn-login.strategy with updated logic * PM-2041 - Remove completed TODO * PM-2041 - Fix login-via-webauthn component imports + fix name (missing n) * PM-2041 - Missed this change when renaming LoginViaWebAuthComponent to LoginViaWebAuthnComponent * PM-2041 - Add WebAuthnLoginApiService to jslib-services.module * PM-2041 - Remove unused param from WebAuthnLoginApiServiceAbstraction as we aren't supporting non-discoverable passkeys for MVP * PM-2041 - WebAuthnLoginApiService - remove email and target correct endpoint for getCredentialAssertionOptions(...) call * PM-2041 - WebAuthnLoginStrategy - (1) Remove unused dep (2) Add safeguard checks to setUserKey(...) logic similar to SSO login strategy * PM-2041 - BaseLoginViaWebAuthnComponent - Rewrite authenticate logic to use new methods on webAuthnLoginService * PM-2041 - UserDecryptionOptionsResponse - update naming of webAuthn options object to match server response * PM-2041 - WebAuthnLoginAssertionResponseRequest - (1) clean up TODO (2) Fix response property name to match server * PM-2041 - WebAuthnTokenRequest - must stringify device response b/c sending as form data * PM-2041 - AuthService - Add WebAuthnLoginCredentials and WebAuthnLoginStrategy support to auth service * PM-2041 - WIP tests for WebAuthnLoginService * PM-2041 - UserDecryptionOptions - Rename WebAuthnPrfOptions to singular WebAuthnPrfOption to match server * PM-2041 - Add TODO in login comp * PM-2041 - (1) Update WebAuthnLoginService.assertCredential(...) to add a check to ensure we cannot leak PRF credentials to the BW server by mistake (2) Add credential to view names for clarity (3) Add JS doc style comments to WebAuthnLoginServiceAbstraction * PM-2041 - Login.component.html - (1) Center passkey login button (2) Use correct user passkey icon * PM-2041 - Utils + tests - (1) Add new hexStringToArrayBuffer(...) method (2) Add tests for existing fromBufferToHex(...) (3) Add tests for new hexStringToArrayBuffer(...) method * PM-2041 - Fix broken import * PM-2041 - WebAuthnLoginResponseRequest - Adjust warning to be correct * PM-2041 - Webauthn-utils - createSymmetricKeyFromPrf(...) - add return type * PM-2041 - WebAuthnLoginService spec file - good progress on figuring out how to test passkey assertion process. Tests are passing, but need to add more setup logic around the MockAuthenticatorAssertionResponse in order to be able to confirm the output is correct. * PM-2041 - Utils + Utils Spec file changes - (1) Add new fromB64ToArrayBuffer(...) method (2) Add tests for existing fromBufferToB64(...) (3) Add tests for new fromB64ToArrayBuffer(...) method (4) Add round trip conversion tests in both directions * PM-2041 - Utils.spec - update round trip conversion tests between hex string and array buffer. * PM-2041 - WebAuthnLoginService.spec - assertCredential(...) happy path test passing * PM-2041 - WebAuthnLoginAssertionResponseRequest - Add interface * PM-2041 - WebAuthnLoginAssertionResponseRequest data should be UrlB64 strings per discussion w/ Andreas * PM-2041 - WebAuthnLoginService Spec file - Per feedback, reverse approaches to generating test data (go from array buffer to b64 strings vs the reverse) to avoid using math.random which can introduce test inconsistency * PM-2041 - Finish testing assertCredential(...) * PM-2041 - WebAuthnLoginService tests completed - tested logIn method * PM-2041 - Login html - add "or" between standard email login and passkey login * PM-2041 - WebAuthnLoginStrategy test start * PM-2041 - After rebase - BaseLoginViaWebAuthnComponent - Must rename ForceResetPasswordReason to ForceSetPasswordReason + refactor post login routing logic to match other auth owned flows. * PM-2401 - Desktop - login comp - fix desktop build * PM-2041 - Browser - login comp - fix build issue * PM-2401 - WIP on webauthn-login.strategy testing * PM-2401 - Finish testing webauthn login strategy * PM-2041 - WebAuthnAdminApiService renamed to WebAuthnLoginAdminApiService * PM-2041 - Remove unnecessary comment * PM-2041 - Per PR feedback, remove noMargin and just add mb-3 * PM-2041 - Per PR feedback, remove unused 2FA and remember email logic (2FA isn't supported right now and we aren't using non-discoverable credentials so we aren't using a user entered email) * PM-2401 - BaseLoginViaWebAuthnComponent - improve error handling to allow users to retry w/ another passkey * PM-2401 - Per PR feedback, provide translated message to cover all invalid passkey scenarios. * PM-2401 - WebAuthnLoginService - per PR feedback, remove unnecessary from * PM-2041 - WebAuthnLoginCredentialAssertionView - per PR feedback, use actual key type * PM-2401 - Per PR feedback, remove WebAuthnLoginStrategy constructor as it is identical to its super class constructor * PM-2041 - WebAuthnLoginService tests - use first value from to improve tests * PM-2401 - Fix WebAuthnLoginService build issue after changing SymmetricCryptoKey to PrfKey * PM-2041 - WebAuthnLoginServiceAbstraction remove incorrect undefined from getCredentialAssertionOptions() abstraction * PM-2041 - Refacor WebAuthn login service tests based on PR feedback * PM-2041 - Per PR feedback, remove NAVIGATOR_CREDENTIALS injection token and just use WINDOW directly for WebAuthnLoginService * PM-2041 - WebAuthnLoginServiceAbstraction - per PR feedback, improve assertCredential jsdocs with return info * PM-2041 - Per PR feedback, update WebAuthnLoginStrategy logInTwoFactor(...) to return an exception if attempted to be called. * PM-2041 - WebAuthnLoginResponseRequest - per PR feedback, replace fromBufferToB64(...) with fromBufferToUrlB64(...) * PM-2041 - AssertionOptionsResponse - use doc comment per PR feedback * PM-2041 - Per PR feedback, adjust location of helpers and mocks in WebAuthnLoginStrategy test file * PM-2041 - Adjust WebAuthnLoginService tests to take the WebAuthnLoginResponseRequest change to use fromBufferToUrlB64(...) into account to get tests to pass again * PM-2041 - WebAuthnLoginStrategy - adjust test name to match convention per PR feedback * PM-2041 - More test tweaks - (1) Rename method (2) Support strict * PM-2041 - Per PR feedback, AssertionOptionsResponse constructor should null check allowCredentials b/c it is optional * PM-2041 - Per PR Feedback, remove duplicated fromB64ToArrayBuffer(...) from utils and update tests. * PM-2041 - Per PR feedback, rename WebAuthnTokenRequest to WebAuthnLoginTokenRequest * PM-2041 - Per discussion with product and Andreas, add 2FA transition handling just in case we add server support in the future. * feat: stretch PRF key (#6927) * feat: stretch PRF key includes necessary utils -> service refactors * feat: add tests * [PM-2041] feat: assertion-options `POST` -> `GET` * [PM-2041] chore: remove unused properties * [PM-2041] fix: set private key * [PM-2041] feat: remove all 2FA related fields * [PM-2041] chore: clean up 2FA comments * [PM-2041] chore: document `webauthn-login-prf-crypto.service.abstraction.ts` * [PM-2041] chore: document webauthn login services --------- Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com> Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
PasswordLoginCredentials,
|
||||
SsoLoginCredentials,
|
||||
AuthRequestLoginCredentials,
|
||||
WebAuthnLoginCredentials,
|
||||
} from "../models/domain/login-credentials";
|
||||
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request";
|
||||
import { AuthRequestResponse } from "../models/response/auth-request.response";
|
||||
@@ -26,6 +27,7 @@ export abstract class AuthService {
|
||||
| PasswordLoginCredentials
|
||||
| SsoLoginCredentials
|
||||
| AuthRequestLoginCredentials
|
||||
| WebAuthnLoginCredentials
|
||||
) => Promise<AuthResult>;
|
||||
logInTwoFactor: (
|
||||
twoFactor: TokenTwoFactorRequest,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CredentialAssertionOptionsResponse } from "../../services/webauthn-login/response/credential-assertion-options.response";
|
||||
|
||||
export class WebAuthnLoginApiServiceAbstraction {
|
||||
getCredentialAssertionOptions: () => Promise<CredentialAssertionOptionsResponse>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PrfKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
/**
|
||||
* Contains methods for all crypto operations specific to the WebAuthn login flow.
|
||||
*/
|
||||
export abstract class WebAuthnLoginPrfCryptoServiceAbstraction {
|
||||
/**
|
||||
* Get the salt used to generate the PRF-output used when logging in with WebAuthn.
|
||||
*/
|
||||
getLoginWithPrfSalt: () => Promise<ArrayBuffer>;
|
||||
|
||||
/**
|
||||
* Create a symmetric key from the PRF-output by stretching it.
|
||||
* This should be used as `ExternalKey` with `RotateableKeySet`.
|
||||
*/
|
||||
createSymmetricKeyFromPrf: (prf: ArrayBuffer) => Promise<PrfKey>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { AuthResult } from "../../models/domain/auth-result";
|
||||
import { WebAuthnLoginCredentialAssertionOptionsView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion-options.view";
|
||||
import { WebAuthnLoginCredentialAssertionView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion.view";
|
||||
|
||||
/**
|
||||
* Service for logging in with WebAuthnLogin credentials.
|
||||
*/
|
||||
export abstract class WebAuthnLoginServiceAbstraction {
|
||||
/**
|
||||
* An Observable that emits a boolean indicating whether the WebAuthn login feature is enabled.
|
||||
*/
|
||||
readonly enabled$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Gets the credential assertion options needed for initiating the WebAuthn
|
||||
* authentication process. It should provide the challenge and other data
|
||||
* (whether FIDO2 user verification is required, the relying party id, timeout duration for the process to complete, etc.)
|
||||
* for the authenticator.
|
||||
*/
|
||||
getCredentialAssertionOptions: () => Promise<WebAuthnLoginCredentialAssertionOptionsView>;
|
||||
|
||||
/**
|
||||
* Asserts the credential. This involves user interaction with the authenticator
|
||||
* to sign a challenge with a private key (proving ownership of the private key).
|
||||
* This will trigger the browsers WebAuthn API to assert a credential. A PRF-output might
|
||||
* be included in the response if the authenticator supports it.
|
||||
*
|
||||
* @param {WebAuthnLoginCredentialAssertionOptionsView} credentialAssertionOptions - The options provided by the
|
||||
* `getCredentialAssertionOptions` method, including the challenge and other data.
|
||||
* @returns {WebAuthnLoginCredentialAssertionView} The assertion obtained from the authenticator.
|
||||
* If the assertion is not successfully obtained, it returns undefined.
|
||||
*/
|
||||
assertCredential: (
|
||||
credentialAssertionOptions: WebAuthnLoginCredentialAssertionOptionsView
|
||||
) => Promise<WebAuthnLoginCredentialAssertionView | undefined>;
|
||||
|
||||
/**
|
||||
* Logs the user in using the assertion obtained from the authenticator.
|
||||
* It completes the authentication process if the assertion is successfully validated server side:
|
||||
* the server verifies the signed challenge with the corresponding public key.
|
||||
*
|
||||
* @param {WebAuthnLoginCredentialAssertionView} assertion - The assertion obtained from the authenticator
|
||||
* that needs to be validated for login.
|
||||
*/
|
||||
logIn: (assertion: WebAuthnLoginCredentialAssertionView) => Promise<AuthResult>;
|
||||
}
|
||||
@@ -3,4 +3,5 @@ export enum AuthenticationType {
|
||||
Sso = 1,
|
||||
UserApi = 2,
|
||||
AuthRequest = 3,
|
||||
WebAuthn = 4,
|
||||
}
|
||||
|
||||
@@ -24,12 +24,14 @@ import {
|
||||
PasswordLoginCredentials,
|
||||
SsoLoginCredentials,
|
||||
UserApiLoginCredentials,
|
||||
WebAuthnLoginCredentials,
|
||||
} from "../models/domain/login-credentials";
|
||||
import { DeviceRequest } from "../models/request/identity-token/device.request";
|
||||
import { PasswordTokenRequest } from "../models/request/identity-token/password-token.request";
|
||||
import { SsoTokenRequest } from "../models/request/identity-token/sso-token.request";
|
||||
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request";
|
||||
import { UserApiTokenRequest } from "../models/request/identity-token/user-api-token.request";
|
||||
import { WebAuthnLoginTokenRequest } from "../models/request/identity-token/webauthn-login-token.request";
|
||||
import { IdentityCaptchaResponse } from "../models/response/identity-captcha.response";
|
||||
import { IdentityTokenResponse } from "../models/response/identity-token.response";
|
||||
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
|
||||
@@ -37,7 +39,11 @@ import { IdentityTwoFactorResponse } from "../models/response/identity-two-facto
|
||||
type IdentityResponse = IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse;
|
||||
|
||||
export abstract class LoginStrategy {
|
||||
protected abstract tokenRequest: UserApiTokenRequest | PasswordTokenRequest | SsoTokenRequest;
|
||||
protected abstract tokenRequest:
|
||||
| UserApiTokenRequest
|
||||
| PasswordTokenRequest
|
||||
| SsoTokenRequest
|
||||
| WebAuthnLoginTokenRequest;
|
||||
protected captchaBypassToken: string = null;
|
||||
|
||||
constructor(
|
||||
@@ -58,6 +64,7 @@ export abstract class LoginStrategy {
|
||||
| PasswordLoginCredentials
|
||||
| SsoLoginCredentials
|
||||
| AuthRequestLoginCredentials
|
||||
| WebAuthnLoginCredentials
|
||||
): Promise<AuthResult>;
|
||||
|
||||
async logInTwoFactor(
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { AppIdService } from "../../platform/abstractions/app-id.service";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import {
|
||||
PrfKey,
|
||||
SymmetricCryptoKey,
|
||||
UserKey,
|
||||
} from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { TokenService } from "../abstractions/token.service";
|
||||
import { TwoFactorService } from "../abstractions/two-factor.service";
|
||||
import { AuthResult } from "../models/domain/auth-result";
|
||||
import { WebAuthnLoginCredentials } from "../models/domain/login-credentials";
|
||||
import { IdentityTokenResponse } from "../models/response/identity-token.response";
|
||||
import { IUserDecryptionOptionsServerResponse } from "../models/response/user-decryption-options/user-decryption-options.response";
|
||||
import { WebAuthnLoginAssertionResponseRequest } from "../services/webauthn-login/request/webauthn-login-assertion-response.request";
|
||||
|
||||
import { identityTokenResponseFactory } from "./login.strategy.spec";
|
||||
import { WebAuthnLoginStrategy } from "./webauthn-login.strategy";
|
||||
|
||||
describe("WebAuthnLoginStrategy", () => {
|
||||
let cryptoService!: MockProxy<CryptoService>;
|
||||
let apiService!: MockProxy<ApiService>;
|
||||
let tokenService!: MockProxy<TokenService>;
|
||||
let appIdService!: MockProxy<AppIdService>;
|
||||
let platformUtilsService!: MockProxy<PlatformUtilsService>;
|
||||
let messagingService!: MockProxy<MessagingService>;
|
||||
let logService!: MockProxy<LogService>;
|
||||
let stateService!: MockProxy<StateService>;
|
||||
let twoFactorService!: MockProxy<TwoFactorService>;
|
||||
|
||||
let webAuthnLoginStrategy!: WebAuthnLoginStrategy;
|
||||
|
||||
const token = "mockToken";
|
||||
const deviceId = Utils.newGuid();
|
||||
|
||||
let webAuthnCredentials!: WebAuthnLoginCredentials;
|
||||
|
||||
let originalPublicKeyCredential!: PublicKeyCredential | any;
|
||||
let originalAuthenticatorAssertionResponse!: AuthenticatorAssertionResponse | any;
|
||||
|
||||
beforeAll(() => {
|
||||
// Save off the original classes so we can restore them after all tests are done if they exist
|
||||
originalPublicKeyCredential = global.PublicKeyCredential;
|
||||
originalAuthenticatorAssertionResponse = global.AuthenticatorAssertionResponse;
|
||||
|
||||
// We must do this to make the mocked classes available for all the
|
||||
// assertCredential(...) tests.
|
||||
global.PublicKeyCredential = MockPublicKeyCredential;
|
||||
global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
cryptoService = mock<CryptoService>();
|
||||
apiService = mock<ApiService>();
|
||||
tokenService = mock<TokenService>();
|
||||
appIdService = mock<AppIdService>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
messagingService = mock<MessagingService>();
|
||||
logService = mock<LogService>();
|
||||
stateService = mock<StateService>();
|
||||
twoFactorService = mock<TwoFactorService>();
|
||||
|
||||
tokenService.getTwoFactorToken.mockResolvedValue(null);
|
||||
appIdService.getAppId.mockResolvedValue(deviceId);
|
||||
tokenService.decodeToken.mockResolvedValue({});
|
||||
|
||||
webAuthnLoginStrategy = new WebAuthnLoginStrategy(
|
||||
cryptoService,
|
||||
apiService,
|
||||
tokenService,
|
||||
appIdService,
|
||||
platformUtilsService,
|
||||
messagingService,
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService
|
||||
);
|
||||
|
||||
// Create credentials
|
||||
const publicKeyCredential = new MockPublicKeyCredential();
|
||||
const deviceResponse = new WebAuthnLoginAssertionResponseRequest(publicKeyCredential);
|
||||
const prfKey = new SymmetricCryptoKey(randomBytes(32)) as PrfKey;
|
||||
webAuthnCredentials = new WebAuthnLoginCredentials(token, deviceResponse, prfKey);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore global after all tests are done
|
||||
global.PublicKeyCredential = originalPublicKeyCredential;
|
||||
global.AuthenticatorAssertionResponse = originalAuthenticatorAssertionResponse;
|
||||
});
|
||||
|
||||
const mockEncPrfPrivateKey =
|
||||
"2.eh465OrUcluL9UpnCOUTAg==|2HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=";
|
||||
|
||||
const mockEncUserKey =
|
||||
"4.Xht6K9GA9jKcSNy4TaIvdj7f9+WsgQycs/HdkrJi33aC//roKkjf3UTGpdzFLxVP3WhyOVGyo9f2Jymf1MFPdpg7AuMnpGJlcrWLDbnPjOJo4x5gUwwBUmy3nFw6+wamyS1LRmrBPcv56yKpf80k5Q3hUrum8q9YS9m2I10vklX/TaB1YML0yo+K1feWUxg8vIx+vloxhUdkkysvcV5xU3R+AgYLrwvJS8TLL7Ug/P5HxinCaIroRrNe8xcv84vyVnzPFdXe0cfZ0cpcrm586LwfEXP2seeldO/bC51Uk/mudeSALJURPC64f5ch2cOvk48GOTapGnssCqr6ky5yFw==";
|
||||
|
||||
const userDecryptionOptsServerResponseWithWebAuthnPrfOption: IUserDecryptionOptionsServerResponse =
|
||||
{
|
||||
HasMasterPassword: true,
|
||||
WebAuthnPrfOption: {
|
||||
EncryptedPrivateKey: mockEncPrfPrivateKey,
|
||||
EncryptedUserKey: mockEncUserKey,
|
||||
},
|
||||
};
|
||||
|
||||
const mockIdTokenResponseWithModifiedWebAuthnPrfOption = (key: string, value: any) => {
|
||||
const userDecryptionOpts: IUserDecryptionOptionsServerResponse = {
|
||||
...userDecryptionOptsServerResponseWithWebAuthnPrfOption,
|
||||
WebAuthnPrfOption: {
|
||||
...userDecryptionOptsServerResponseWithWebAuthnPrfOption.WebAuthnPrfOption,
|
||||
[key]: value,
|
||||
},
|
||||
};
|
||||
return identityTokenResponseFactory(null, userDecryptionOpts);
|
||||
};
|
||||
|
||||
it("returns successful authResult when api service returns valid credentials", async () => {
|
||||
// Arrange
|
||||
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
|
||||
null,
|
||||
userDecryptionOptsServerResponseWithWebAuthnPrfOption
|
||||
);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
|
||||
|
||||
// Act
|
||||
const authResult = await webAuthnLoginStrategy.logIn(webAuthnCredentials);
|
||||
|
||||
// Assert
|
||||
expect(apiService.postIdentityToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
// webauthn specific info
|
||||
token: webAuthnCredentials.token,
|
||||
deviceResponse: webAuthnCredentials.deviceResponse,
|
||||
// standard info
|
||||
device: expect.objectContaining({
|
||||
identifier: deviceId,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
expect(authResult).toBeInstanceOf(AuthResult);
|
||||
expect(authResult).toMatchObject({
|
||||
captchaSiteKey: "",
|
||||
forcePasswordReset: 0,
|
||||
resetMasterPassword: false,
|
||||
twoFactorProviders: null,
|
||||
requiresTwoFactor: false,
|
||||
requiresCaptcha: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("decrypts and sets user key when webAuthn PRF decryption option exists with valid PRF key and enc key data", async () => {
|
||||
// Arrange
|
||||
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
|
||||
null,
|
||||
userDecryptionOptsServerResponseWithWebAuthnPrfOption
|
||||
);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
|
||||
|
||||
const mockPrfPrivateKey: Uint8Array = randomBytes(32);
|
||||
const mockUserKeyArray: Uint8Array = randomBytes(32);
|
||||
const mockUserKey = new SymmetricCryptoKey(mockUserKeyArray) as UserKey;
|
||||
|
||||
cryptoService.decryptToBytes.mockResolvedValue(mockPrfPrivateKey);
|
||||
cryptoService.rsaDecrypt.mockResolvedValue(mockUserKeyArray);
|
||||
|
||||
// Act
|
||||
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
|
||||
|
||||
// // Assert
|
||||
expect(cryptoService.decryptToBytes).toHaveBeenCalledTimes(1);
|
||||
expect(cryptoService.decryptToBytes).toHaveBeenCalledWith(
|
||||
idTokenResponse.userDecryptionOptions.webAuthnPrfOption.encryptedPrivateKey,
|
||||
webAuthnCredentials.prfKey
|
||||
);
|
||||
expect(cryptoService.rsaDecrypt).toHaveBeenCalledTimes(1);
|
||||
expect(cryptoService.rsaDecrypt).toHaveBeenCalledWith(
|
||||
idTokenResponse.userDecryptionOptions.webAuthnPrfOption.encryptedUserKey.encryptedString,
|
||||
mockPrfPrivateKey
|
||||
);
|
||||
expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockUserKey);
|
||||
expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(idTokenResponse.privateKey);
|
||||
|
||||
// Master key and private key should not be set
|
||||
expect(cryptoService.setMasterKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not try to set the user key when prfKey is missing", async () => {
|
||||
// Arrange
|
||||
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
|
||||
null,
|
||||
userDecryptionOptsServerResponseWithWebAuthnPrfOption
|
||||
);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
|
||||
|
||||
// Remove PRF key
|
||||
webAuthnCredentials.prfKey = null;
|
||||
|
||||
// Act
|
||||
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
|
||||
|
||||
// Assert
|
||||
expect(cryptoService.decryptToBytes).not.toHaveBeenCalled();
|
||||
expect(cryptoService.rsaDecrypt).not.toHaveBeenCalled();
|
||||
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
valueName: "encPrfPrivateKey",
|
||||
},
|
||||
{
|
||||
valueName: "encUserKey",
|
||||
},
|
||||
])("given webAuthn PRF decryption option has missing encrypted key data", ({ valueName }) => {
|
||||
it(`does not set the user key when ${valueName} is missing`, async () => {
|
||||
// Arrange
|
||||
const idTokenResponse = mockIdTokenResponseWithModifiedWebAuthnPrfOption(valueName, null);
|
||||
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
|
||||
|
||||
// Act
|
||||
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
|
||||
|
||||
// Assert
|
||||
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set the user key when the PRF encrypted private key decryption fails", async () => {
|
||||
// Arrange
|
||||
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
|
||||
null,
|
||||
userDecryptionOptsServerResponseWithWebAuthnPrfOption
|
||||
);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
|
||||
|
||||
cryptoService.decryptToBytes.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
|
||||
|
||||
// Assert
|
||||
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not set the user key when the encrypted user key decryption fails", async () => {
|
||||
// Arrange
|
||||
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
|
||||
null,
|
||||
userDecryptionOptsServerResponseWithWebAuthnPrfOption
|
||||
);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
|
||||
|
||||
cryptoService.rsaDecrypt.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
|
||||
|
||||
// Assert
|
||||
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Helpers and mocks
|
||||
function randomBytes(length: number): Uint8Array {
|
||||
return new Uint8Array(Array.from({ length }, (_, k) => k % 255));
|
||||
}
|
||||
|
||||
// AuthenticatorAssertionResponse && PublicKeyCredential are only available in secure contexts
|
||||
// so we need to mock them and assign them to the global object to make them available
|
||||
// for the tests
|
||||
class MockAuthenticatorAssertionResponse implements AuthenticatorAssertionResponse {
|
||||
clientDataJSON: ArrayBuffer = randomBytes(32).buffer;
|
||||
authenticatorData: ArrayBuffer = randomBytes(196).buffer;
|
||||
signature: ArrayBuffer = randomBytes(72).buffer;
|
||||
userHandle: ArrayBuffer = randomBytes(16).buffer;
|
||||
|
||||
clientDataJSONB64Str = Utils.fromBufferToUrlB64(this.clientDataJSON);
|
||||
authenticatorDataB64Str = Utils.fromBufferToUrlB64(this.authenticatorData);
|
||||
signatureB64Str = Utils.fromBufferToUrlB64(this.signature);
|
||||
userHandleB64Str = Utils.fromBufferToUrlB64(this.userHandle);
|
||||
}
|
||||
|
||||
class MockPublicKeyCredential implements PublicKeyCredential {
|
||||
authenticatorAttachment = "cross-platform";
|
||||
id = "mockCredentialId";
|
||||
type = "public-key";
|
||||
rawId: ArrayBuffer = randomBytes(32).buffer;
|
||||
rawIdB64Str = Utils.fromBufferToB64(this.rawId);
|
||||
|
||||
response: MockAuthenticatorAssertionResponse = new MockAuthenticatorAssertionResponse();
|
||||
|
||||
// Use random 64 character hex string (32 bytes - matters for symmetric key creation)
|
||||
// to represent the prf key binary data and convert to ArrayBuffer
|
||||
// Creating the array buffer from a known hex value allows us to
|
||||
// assert on the value in tests
|
||||
private prfKeyArrayBuffer: ArrayBuffer = Utils.hexStringToArrayBuffer(
|
||||
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
);
|
||||
|
||||
getClientExtensionResults(): any {
|
||||
return {
|
||||
prf: {
|
||||
results: {
|
||||
first: this.prfKeyArrayBuffer,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static isConditionalMediationAvailable(): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
static isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { SymmetricCryptoKey, UserKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { AuthResult } from "../models/domain/auth-result";
|
||||
import { WebAuthnLoginCredentials } from "../models/domain/login-credentials";
|
||||
import { WebAuthnLoginTokenRequest } from "../models/request/identity-token/webauthn-login-token.request";
|
||||
import { IdentityTokenResponse } from "../models/response/identity-token.response";
|
||||
|
||||
import { LoginStrategy } from "./login.strategy";
|
||||
|
||||
export class WebAuthnLoginStrategy extends LoginStrategy {
|
||||
tokenRequest: WebAuthnLoginTokenRequest;
|
||||
private credentials: WebAuthnLoginCredentials;
|
||||
|
||||
protected override async setMasterKey() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
protected override async setUserKey(idTokenResponse: IdentityTokenResponse) {
|
||||
const userDecryptionOptions = idTokenResponse?.userDecryptionOptions;
|
||||
|
||||
if (userDecryptionOptions?.webAuthnPrfOption) {
|
||||
const webAuthnPrfOption = idTokenResponse.userDecryptionOptions?.webAuthnPrfOption;
|
||||
|
||||
// confirm we still have the prf key
|
||||
if (!this.credentials.prfKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// decrypt prf encrypted private key
|
||||
const privateKey = await this.cryptoService.decryptToBytes(
|
||||
webAuthnPrfOption.encryptedPrivateKey,
|
||||
this.credentials.prfKey
|
||||
);
|
||||
|
||||
// decrypt user key with private key
|
||||
const userKey = await this.cryptoService.rsaDecrypt(
|
||||
webAuthnPrfOption.encryptedUserKey.encryptedString,
|
||||
privateKey
|
||||
);
|
||||
|
||||
if (userKey) {
|
||||
await this.cryptoService.setUserKey(new SymmetricCryptoKey(userKey) as UserKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override async setPrivateKey(response: IdentityTokenResponse): Promise<void> {
|
||||
await this.cryptoService.setPrivateKey(
|
||||
response.privateKey ?? (await this.createKeyPairForOldAccount())
|
||||
);
|
||||
}
|
||||
|
||||
async logInTwoFactor(): Promise<AuthResult> {
|
||||
throw new Error("2FA not supported yet for WebAuthn Login.");
|
||||
}
|
||||
|
||||
async logIn(credentials: WebAuthnLoginCredentials) {
|
||||
this.credentials = credentials;
|
||||
|
||||
this.tokenRequest = new WebAuthnLoginTokenRequest(
|
||||
credentials.token,
|
||||
credentials.deviceResponse,
|
||||
await this.buildDeviceRequest()
|
||||
);
|
||||
|
||||
const [authResult] = await this.startLogIn();
|
||||
return authResult;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { MasterKey, UserKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import {
|
||||
MasterKey,
|
||||
UserKey,
|
||||
SymmetricCryptoKey,
|
||||
} from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { AuthenticationType } from "../../enums/authentication-type";
|
||||
import { WebAuthnLoginAssertionResponseRequest } from "../../services/webauthn-login/request/webauthn-login-assertion-response.request";
|
||||
import { TokenTwoFactorRequest } from "../request/identity-token/token-two-factor.request";
|
||||
|
||||
export class PasswordLoginCredentials {
|
||||
@@ -44,3 +49,13 @@ export class AuthRequestLoginCredentials {
|
||||
public twoFactor?: TokenTwoFactorRequest
|
||||
) {}
|
||||
}
|
||||
|
||||
export class WebAuthnLoginCredentials {
|
||||
readonly type = AuthenticationType.WebAuthn;
|
||||
|
||||
constructor(
|
||||
public token: string,
|
||||
public deviceResponse: WebAuthnLoginAssertionResponseRequest,
|
||||
public prfKey?: SymmetricCryptoKey
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export abstract class TokenRequest {
|
||||
protected device?: DeviceRequest;
|
||||
protected authRequest: string;
|
||||
|
||||
constructor(protected twoFactor: TokenTwoFactorRequest, device?: DeviceRequest) {
|
||||
constructor(protected twoFactor?: TokenTwoFactorRequest, device?: DeviceRequest) {
|
||||
this.device = device != null ? device : null;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export abstract class TokenRequest {
|
||||
// Implemented in subclass if required
|
||||
}
|
||||
|
||||
setTwoFactor(twoFactor: TokenTwoFactorRequest) {
|
||||
setTwoFactor(twoFactor: TokenTwoFactorRequest | undefined) {
|
||||
this.twoFactor = twoFactor;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { WebAuthnLoginAssertionResponseRequest } from "../../../services/webauthn-login/request/webauthn-login-assertion-response.request";
|
||||
|
||||
import { DeviceRequest } from "./device.request";
|
||||
import { TokenRequest } from "./token.request";
|
||||
|
||||
export class WebAuthnLoginTokenRequest extends TokenRequest {
|
||||
constructor(
|
||||
public token: string,
|
||||
public deviceResponse: WebAuthnLoginAssertionResponseRequest,
|
||||
device?: DeviceRequest
|
||||
) {
|
||||
super(undefined, device);
|
||||
}
|
||||
|
||||
toIdentityToken(clientId: string) {
|
||||
const obj = super.toIdentityToken(clientId);
|
||||
|
||||
obj.grant_type = "webauthn";
|
||||
obj.token = this.token;
|
||||
// must be a string b/c sending as form encoded data
|
||||
obj.deviceResponse = JSON.stringify(this.deviceResponse);
|
||||
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
@@ -8,17 +8,23 @@ import {
|
||||
ITrustedDeviceUserDecryptionOptionServerResponse,
|
||||
TrustedDeviceUserDecryptionOptionResponse,
|
||||
} from "./trusted-device-user-decryption-option.response";
|
||||
import {
|
||||
IWebAuthnPrfDecryptionOptionServerResponse,
|
||||
WebAuthnPrfDecryptionOptionResponse,
|
||||
} from "./webauthn-prf-decryption-option.response";
|
||||
|
||||
export interface IUserDecryptionOptionsServerResponse {
|
||||
HasMasterPassword: boolean;
|
||||
TrustedDeviceOption?: ITrustedDeviceUserDecryptionOptionServerResponse;
|
||||
KeyConnectorOption?: IKeyConnectorUserDecryptionOptionServerResponse;
|
||||
WebAuthnPrfOption?: IWebAuthnPrfDecryptionOptionServerResponse;
|
||||
}
|
||||
|
||||
export class UserDecryptionOptionsResponse extends BaseResponse {
|
||||
hasMasterPassword: boolean;
|
||||
trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse;
|
||||
keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse;
|
||||
webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse;
|
||||
|
||||
constructor(response: IUserDecryptionOptionsServerResponse) {
|
||||
super(response);
|
||||
@@ -35,5 +41,10 @@ export class UserDecryptionOptionsResponse extends BaseResponse {
|
||||
this.getResponseProperty("KeyConnectorOption")
|
||||
);
|
||||
}
|
||||
if (response.WebAuthnPrfOption) {
|
||||
this.webAuthnPrfOption = new WebAuthnPrfDecryptionOptionResponse(
|
||||
this.getResponseProperty("WebAuthnPrfOption")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
import { EncString } from "../../../../platform/models/domain/enc-string";
|
||||
|
||||
export interface IWebAuthnPrfDecryptionOptionServerResponse {
|
||||
EncryptedPrivateKey: string;
|
||||
EncryptedUserKey: string;
|
||||
}
|
||||
|
||||
export class WebAuthnPrfDecryptionOptionResponse extends BaseResponse {
|
||||
encryptedPrivateKey: EncString;
|
||||
encryptedUserKey: EncString;
|
||||
|
||||
constructor(response: IWebAuthnPrfDecryptionOptionServerResponse) {
|
||||
super(response);
|
||||
if (response.EncryptedPrivateKey) {
|
||||
this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey"));
|
||||
}
|
||||
if (response.EncryptedUserKey) {
|
||||
this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AssertionOptionsResponse } from "../../../services/webauthn-login/response/assertion-options.response";
|
||||
|
||||
export class WebAuthnLoginCredentialAssertionOptionsView {
|
||||
constructor(readonly options: AssertionOptionsResponse, readonly token: string) {}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PrfKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { WebAuthnLoginAssertionResponseRequest } from "../../../services/webauthn-login/request/webauthn-login-assertion-response.request";
|
||||
|
||||
export class WebAuthnLoginCredentialAssertionView {
|
||||
constructor(
|
||||
readonly token: string,
|
||||
readonly deviceResponse: WebAuthnLoginAssertionResponseRequest,
|
||||
readonly prfKey?: PrfKey
|
||||
) {}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { AuthRequestLoginStrategy } from "../login-strategies/auth-request-login
|
||||
import { PasswordLoginStrategy } from "../login-strategies/password-login.strategy";
|
||||
import { SsoLoginStrategy } from "../login-strategies/sso-login.strategy";
|
||||
import { UserApiLoginStrategy } from "../login-strategies/user-api-login.strategy";
|
||||
import { WebAuthnLoginStrategy } from "../login-strategies/webauthn-login.strategy";
|
||||
import { AuthResult } from "../models/domain/auth-result";
|
||||
import { KdfConfig } from "../models/domain/kdf-config";
|
||||
import {
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
PasswordLoginCredentials,
|
||||
SsoLoginCredentials,
|
||||
UserApiLoginCredentials,
|
||||
WebAuthnLoginCredentials,
|
||||
} from "../models/domain/login-credentials";
|
||||
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request";
|
||||
import { PasswordlessAuthRequest } from "../models/request/passwordless-auth.request";
|
||||
@@ -85,7 +87,8 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
| UserApiLoginStrategy
|
||||
| PasswordLoginStrategy
|
||||
| SsoLoginStrategy
|
||||
| AuthRequestLoginStrategy;
|
||||
| AuthRequestLoginStrategy
|
||||
| WebAuthnLoginStrategy;
|
||||
private sessionTimeout: any;
|
||||
|
||||
private pushNotificationSubject = new Subject<string>();
|
||||
@@ -116,6 +119,7 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
| PasswordLoginCredentials
|
||||
| SsoLoginCredentials
|
||||
| AuthRequestLoginCredentials
|
||||
| WebAuthnLoginCredentials
|
||||
): Promise<AuthResult> {
|
||||
this.clearState();
|
||||
|
||||
@@ -123,7 +127,8 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
| UserApiLoginStrategy
|
||||
| PasswordLoginStrategy
|
||||
| SsoLoginStrategy
|
||||
| AuthRequestLoginStrategy;
|
||||
| AuthRequestLoginStrategy
|
||||
| WebAuthnLoginStrategy;
|
||||
|
||||
switch (credentials.type) {
|
||||
case AuthenticationType.Password:
|
||||
@@ -188,6 +193,19 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
this.deviceTrustCryptoService
|
||||
);
|
||||
break;
|
||||
case AuthenticationType.WebAuthn:
|
||||
strategy = new WebAuthnLoginStrategy(
|
||||
this.cryptoService,
|
||||
this.apiService,
|
||||
this.tokenService,
|
||||
this.appIdService,
|
||||
this.platformUtilsService,
|
||||
this.messagingService,
|
||||
this.logService,
|
||||
this.stateService,
|
||||
this.twoFactorService
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const result = await strategy.logIn(credentials as any);
|
||||
@@ -353,6 +371,7 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
| PasswordLoginStrategy
|
||||
| SsoLoginStrategy
|
||||
| AuthRequestLoginStrategy
|
||||
| WebAuthnLoginStrategy
|
||||
) {
|
||||
this.logInStrategy = strategy;
|
||||
this.startSessionTimeout();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
|
||||
import { WebAuthnLoginResponseRequest } from "./webauthn-login-response.request";
|
||||
|
||||
// base 64 strings
|
||||
export interface WebAuthnLoginAssertionResponseData {
|
||||
authenticatorData: string;
|
||||
signature: string;
|
||||
clientDataJSON: string;
|
||||
userHandle: string;
|
||||
}
|
||||
|
||||
export class WebAuthnLoginAssertionResponseRequest extends WebAuthnLoginResponseRequest {
|
||||
response: WebAuthnLoginAssertionResponseData;
|
||||
|
||||
constructor(credential: PublicKeyCredential) {
|
||||
super(credential);
|
||||
|
||||
if (!(credential.response instanceof AuthenticatorAssertionResponse)) {
|
||||
throw new Error("Invalid authenticator response");
|
||||
}
|
||||
|
||||
this.response = {
|
||||
authenticatorData: Utils.fromBufferToUrlB64(credential.response.authenticatorData),
|
||||
signature: Utils.fromBufferToUrlB64(credential.response.signature),
|
||||
clientDataJSON: Utils.fromBufferToUrlB64(credential.response.clientDataJSON),
|
||||
userHandle: Utils.fromBufferToUrlB64(credential.response.userHandle),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
|
||||
export abstract class WebAuthnLoginResponseRequest {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
extensions: Record<string, unknown>;
|
||||
|
||||
constructor(credential: PublicKeyCredential) {
|
||||
this.id = credential.id;
|
||||
this.rawId = Utils.fromBufferToUrlB64(credential.rawId);
|
||||
this.type = credential.type;
|
||||
|
||||
// WARNING: do not add PRF information here by mapping
|
||||
// credential.getClientExtensionResults() into the extensions property,
|
||||
// as it will be sent to the server (leaking credentials).
|
||||
this.extensions = {}; // Extensions are handled client-side
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
|
||||
export class AssertionOptionsResponse
|
||||
extends BaseResponse
|
||||
implements PublicKeyCredentialRequestOptions
|
||||
{
|
||||
/** A list of credentials that the authenticator is allowed to use; only used for non-discoverable flow */
|
||||
allowCredentials?: PublicKeyCredentialDescriptor[];
|
||||
challenge: BufferSource;
|
||||
extensions?: AuthenticationExtensionsClientInputs;
|
||||
rpId?: string;
|
||||
timeout?: number;
|
||||
userVerification?: UserVerificationRequirement;
|
||||
|
||||
constructor(response: unknown) {
|
||||
super(response);
|
||||
this.allowCredentials = this.getResponseProperty("allowCredentials")?.map((c: any) => ({
|
||||
...c,
|
||||
id: Utils.fromUrlB64ToArray(c.id).buffer,
|
||||
}));
|
||||
this.challenge = Utils.fromUrlB64ToArray(this.getResponseProperty("challenge"));
|
||||
this.extensions = this.getResponseProperty("extensions");
|
||||
this.rpId = this.getResponseProperty("rpId");
|
||||
this.timeout = this.getResponseProperty("timeout");
|
||||
this.userVerification = this.getResponseProperty("userVerification");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
|
||||
import { AssertionOptionsResponse } from "./assertion-options.response";
|
||||
|
||||
export class CredentialAssertionOptionsResponse extends BaseResponse {
|
||||
options: AssertionOptionsResponse;
|
||||
token: string;
|
||||
|
||||
constructor(response: unknown) {
|
||||
super(response);
|
||||
this.options = new AssertionOptionsResponse(this.getResponseProperty("options"));
|
||||
this.token = this.getResponseProperty("token");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { EnvironmentService } from "../../../platform/abstractions/environment.service";
|
||||
import { WebAuthnLoginApiServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-api.service.abstraction";
|
||||
|
||||
import { CredentialAssertionOptionsResponse } from "./response/credential-assertion-options.response";
|
||||
|
||||
export class WebAuthnLoginApiService implements WebAuthnLoginApiServiceAbstraction {
|
||||
constructor(private apiService: ApiService, private environmentService: EnvironmentService) {}
|
||||
|
||||
async getCredentialAssertionOptions(): Promise<CredentialAssertionOptionsResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
`/accounts/webauthn/assertion-options`,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
this.environmentService.getIdentityUrl()
|
||||
);
|
||||
return new CredentialAssertionOptionsResponse(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
|
||||
|
||||
import { WebAuthnLoginPrfCryptoService } from "./webauthn-login-prf-crypto.service";
|
||||
|
||||
describe("WebAuthnLoginPrfCryptoService", () => {
|
||||
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
|
||||
let service: WebAuthnLoginPrfCryptoService;
|
||||
|
||||
beforeEach(() => {
|
||||
cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
service = new WebAuthnLoginPrfCryptoService(cryptoFunctionService);
|
||||
});
|
||||
|
||||
describe("createSymmetricKeyFromPrf", () => {
|
||||
it("should stretch the key to 64 bytes when given a key with 32 bytes", async () => {
|
||||
cryptoFunctionService.hkdfExpand.mockImplementation((key, salt, length) =>
|
||||
Promise.resolve(randomBytes(length))
|
||||
);
|
||||
|
||||
const result = await service.createSymmetricKeyFromPrf(randomBytes(32));
|
||||
|
||||
expect(result.key.length).toBe(64);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** This is a fake function that always returns the same byte sequence */
|
||||
function randomBytes(length: number) {
|
||||
return new Uint8Array(Array.from({ length }, (_, k) => k % 255));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
|
||||
import { PrfKey, SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
|
||||
|
||||
const LoginWithPrfSalt = "passwordless-login";
|
||||
|
||||
export class WebAuthnLoginPrfCryptoService implements WebAuthnLoginPrfCryptoServiceAbstraction {
|
||||
constructor(private cryptoFunctionService: CryptoFunctionService) {}
|
||||
|
||||
async getLoginWithPrfSalt(): Promise<ArrayBuffer> {
|
||||
return await this.cryptoFunctionService.hash(LoginWithPrfSalt, "sha256");
|
||||
}
|
||||
|
||||
async createSymmetricKeyFromPrf(prf: ArrayBuffer): Promise<PrfKey> {
|
||||
return (await this.stretchKey(new Uint8Array(prf))) as PrfKey;
|
||||
}
|
||||
|
||||
private async stretchKey(key: Uint8Array): Promise<SymmetricCryptoKey> {
|
||||
const newKey = new Uint8Array(64);
|
||||
const encKey = await this.cryptoFunctionService.hkdfExpand(key, "enc", 32, "sha256");
|
||||
const macKey = await this.cryptoFunctionService.hkdfExpand(key, "mac", 32, "sha256");
|
||||
newKey.set(new Uint8Array(encKey));
|
||||
newKey.set(new Uint8Array(macKey), 32);
|
||||
return new SymmetricCryptoKey(newKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { PrfKey, SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { AuthService } from "../../abstractions/auth.service";
|
||||
import { WebAuthnLoginApiServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-api.service.abstraction";
|
||||
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
|
||||
import { AuthResult } from "../../models/domain/auth-result";
|
||||
import { WebAuthnLoginCredentials } from "../../models/domain/login-credentials";
|
||||
import { WebAuthnLoginCredentialAssertionOptionsView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion-options.view";
|
||||
import { WebAuthnLoginCredentialAssertionView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion.view";
|
||||
|
||||
import { WebAuthnLoginAssertionResponseRequest } from "./request/webauthn-login-assertion-response.request";
|
||||
import { CredentialAssertionOptionsResponse } from "./response/credential-assertion-options.response";
|
||||
import { WebAuthnLoginService } from "./webauthn-login.service";
|
||||
|
||||
describe("WebAuthnLoginService", () => {
|
||||
let webAuthnLoginService: WebAuthnLoginService;
|
||||
|
||||
const webAuthnLoginApiService = mock<WebAuthnLoginApiServiceAbstraction>();
|
||||
const authService = mock<AuthService>();
|
||||
const configService = mock<ConfigServiceAbstraction>();
|
||||
const webAuthnLoginPrfCryptoService = mock<WebAuthnLoginPrfCryptoServiceAbstraction>();
|
||||
const navigatorCredentials = mock<CredentialsContainer>();
|
||||
const logService = mock<LogService>();
|
||||
|
||||
let originalPublicKeyCredential!: PublicKeyCredential | any;
|
||||
let originalAuthenticatorAssertionResponse!: AuthenticatorAssertionResponse | any;
|
||||
let originalNavigator!: Navigator;
|
||||
|
||||
beforeAll(() => {
|
||||
// Save off the original classes so we can restore them after all tests are done if they exist
|
||||
originalPublicKeyCredential = global.PublicKeyCredential;
|
||||
originalAuthenticatorAssertionResponse = global.AuthenticatorAssertionResponse;
|
||||
|
||||
// We must do this to make the mocked classes available for all the
|
||||
// assertCredential(...) tests.
|
||||
global.PublicKeyCredential = MockPublicKeyCredential;
|
||||
global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse;
|
||||
|
||||
// Save the original navigator
|
||||
originalNavigator = global.window.navigator;
|
||||
|
||||
// Mock the window.navigator with mocked CredentialsContainer
|
||||
Object.defineProperty(global.window, "navigator", {
|
||||
value: {
|
||||
...originalNavigator,
|
||||
credentials: navigatorCredentials,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore global after all tests are done
|
||||
global.PublicKeyCredential = originalPublicKeyCredential;
|
||||
global.AuthenticatorAssertionResponse = originalAuthenticatorAssertionResponse;
|
||||
|
||||
// Restore the original navigator
|
||||
Object.defineProperty(global.window, "navigator", {
|
||||
value: originalNavigator,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
function createWebAuthnLoginService(config: { featureEnabled: boolean }): WebAuthnLoginService {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(config.featureEnabled));
|
||||
return new WebAuthnLoginService(
|
||||
webAuthnLoginApiService,
|
||||
authService,
|
||||
configService,
|
||||
webAuthnLoginPrfCryptoService,
|
||||
window,
|
||||
logService
|
||||
);
|
||||
}
|
||||
|
||||
it("instantiates", () => {
|
||||
webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true });
|
||||
expect(webAuthnLoginService).not.toBeFalsy();
|
||||
});
|
||||
|
||||
describe("enabled$", () => {
|
||||
it("should emit true when feature flag for PasswordlessLogin is enabled", async () => {
|
||||
// Arrange
|
||||
const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true });
|
||||
|
||||
// Act & Assert
|
||||
const result = await firstValueFrom(webAuthnLoginService.enabled$);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should emit false when feature flag for PasswordlessLogin is disabled", async () => {
|
||||
// Arrange
|
||||
const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: false });
|
||||
|
||||
// Act & Assert
|
||||
const result = await firstValueFrom(webAuthnLoginService.enabled$);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCredentialAssertionOptions()", () => {
|
||||
it("webAuthnLoginService returns WebAuthnLoginCredentialAssertionOptionsView when getCredentialAssertionOptions is called with the feature enabled", async () => {
|
||||
// Arrange
|
||||
const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true });
|
||||
|
||||
const challenge = "6CG3jqMCVASJVXySMi9KWw";
|
||||
const token = "BWWebAuthnLoginAssertionOptions_CfDJ_2KBN892w";
|
||||
const timeout = 60000;
|
||||
const rpId = "localhost";
|
||||
const allowCredentials = [] as PublicKeyCredentialDescriptor[];
|
||||
const userVerification = "required";
|
||||
const objectName = "webAuthnLoginAssertionOptions";
|
||||
|
||||
const mockedCredentialAssertionOptionsServerResponse = {
|
||||
options: {
|
||||
challenge: challenge,
|
||||
timeout: timeout,
|
||||
rpId: rpId,
|
||||
allowCredentials: allowCredentials,
|
||||
userVerification: userVerification,
|
||||
status: "ok",
|
||||
errorMessage: "",
|
||||
},
|
||||
token: token,
|
||||
object: objectName,
|
||||
};
|
||||
|
||||
const mockedCredentialAssertionOptionsResponse = new CredentialAssertionOptionsResponse(
|
||||
mockedCredentialAssertionOptionsServerResponse
|
||||
);
|
||||
|
||||
webAuthnLoginApiService.getCredentialAssertionOptions.mockResolvedValue(
|
||||
mockedCredentialAssertionOptionsResponse
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await webAuthnLoginService.getCredentialAssertionOptions();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeInstanceOf(WebAuthnLoginCredentialAssertionOptionsView);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertCredential(...)", () => {
|
||||
it("should assert the credential and return WebAuthnLoginAssertionView on success", async () => {
|
||||
// Arrange
|
||||
const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true });
|
||||
const credentialAssertionOptions = buildCredentialAssertionOptions();
|
||||
|
||||
// Mock webAuthnUtils functions
|
||||
const expectedSaltHex = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||
const saltArrayBuffer = Utils.hexStringToArrayBuffer(expectedSaltHex);
|
||||
|
||||
const publicKeyCredential = new MockPublicKeyCredential();
|
||||
const prfResult: ArrayBuffer =
|
||||
publicKeyCredential.getClientExtensionResults().prf?.results?.first;
|
||||
const prfKey = new SymmetricCryptoKey(new Uint8Array(prfResult)) as PrfKey;
|
||||
|
||||
webAuthnLoginPrfCryptoService.getLoginWithPrfSalt.mockResolvedValue(saltArrayBuffer);
|
||||
webAuthnLoginPrfCryptoService.createSymmetricKeyFromPrf.mockResolvedValue(prfKey);
|
||||
|
||||
// Mock implementations
|
||||
navigatorCredentials.get.mockResolvedValue(publicKeyCredential);
|
||||
|
||||
// Act
|
||||
const result = await webAuthnLoginService.assertCredential(credentialAssertionOptions);
|
||||
|
||||
// Assert
|
||||
|
||||
expect(webAuthnLoginPrfCryptoService.getLoginWithPrfSalt).toHaveBeenCalled();
|
||||
|
||||
expect(navigatorCredentials.get).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
publicKey: expect.objectContaining({
|
||||
...credentialAssertionOptions.options,
|
||||
extensions: expect.objectContaining({
|
||||
prf: expect.objectContaining({
|
||||
eval: expect.objectContaining({
|
||||
first: saltArrayBuffer,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
expect(webAuthnLoginPrfCryptoService.createSymmetricKeyFromPrf).toHaveBeenCalledWith(
|
||||
prfResult
|
||||
);
|
||||
|
||||
expect(result).toBeInstanceOf(WebAuthnLoginCredentialAssertionView);
|
||||
expect(result.token).toEqual(credentialAssertionOptions.token);
|
||||
|
||||
expect(result.deviceResponse).toBeInstanceOf(WebAuthnLoginAssertionResponseRequest);
|
||||
expect(result.deviceResponse.id).toEqual(publicKeyCredential.id);
|
||||
expect(result.deviceResponse.rawId).toEqual(publicKeyCredential.rawIdB64Str);
|
||||
expect(result.deviceResponse.type).toEqual(publicKeyCredential.type);
|
||||
// extensions being empty could change in the future but for now it is expected
|
||||
expect(result.deviceResponse.extensions).toEqual({});
|
||||
// but it should never contain any PRF information
|
||||
expect("prf" in result.deviceResponse.extensions).toBe(false);
|
||||
|
||||
expect(result.deviceResponse.response).toEqual({
|
||||
authenticatorData: publicKeyCredential.response.authenticatorDataB64Str,
|
||||
clientDataJSON: publicKeyCredential.response.clientDataJSONB64Str,
|
||||
signature: publicKeyCredential.response.signatureB64Str,
|
||||
userHandle: publicKeyCredential.response.userHandleB64Str,
|
||||
});
|
||||
|
||||
expect(result.prfKey).toEqual(prfKey);
|
||||
});
|
||||
|
||||
it("should return undefined on non-PublicKeyCredential browser response", async () => {
|
||||
// Arrange
|
||||
const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true });
|
||||
const credentialAssertionOptions = buildCredentialAssertionOptions();
|
||||
|
||||
// Mock the navigatorCredentials.get to return null
|
||||
navigatorCredentials.get.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await webAuthnLoginService.assertCredential(credentialAssertionOptions);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should log an error and return undefined when navigatorCredentials.get throws an error", async () => {
|
||||
// Arrange
|
||||
const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true });
|
||||
const credentialAssertionOptions = buildCredentialAssertionOptions();
|
||||
|
||||
// Mock navigatorCredentials.get to throw an error
|
||||
const errorMessage = "Simulated error";
|
||||
navigatorCredentials.get.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
// Spy on logService.error
|
||||
const logServiceErrorSpy = jest.spyOn(logService, "error");
|
||||
|
||||
// Act
|
||||
const result = await webAuthnLoginService.assertCredential(credentialAssertionOptions);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
expect(logServiceErrorSpy).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
});
|
||||
|
||||
describe("logIn(...)", () => {
|
||||
function buildWebAuthnLoginCredentialAssertionView(): WebAuthnLoginCredentialAssertionView {
|
||||
const publicKeyCredential = new MockPublicKeyCredential();
|
||||
|
||||
const deviceResponse = new WebAuthnLoginAssertionResponseRequest(publicKeyCredential);
|
||||
|
||||
const prfKey = new SymmetricCryptoKey(randomBytes(32)) as PrfKey;
|
||||
|
||||
return new WebAuthnLoginCredentialAssertionView("mockToken", deviceResponse, prfKey);
|
||||
}
|
||||
|
||||
it("should accept an assertion with a signed challenge and use it to try and login", async () => {
|
||||
// Arrange
|
||||
const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true });
|
||||
const assertion = buildWebAuthnLoginCredentialAssertionView();
|
||||
const mockAuthResult: AuthResult = new AuthResult();
|
||||
|
||||
jest.spyOn(authService, "logIn").mockResolvedValue(mockAuthResult);
|
||||
|
||||
// Act
|
||||
const result = await webAuthnLoginService.logIn(assertion);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockAuthResult);
|
||||
|
||||
const callArguments = authService.logIn.mock.calls[0];
|
||||
expect(callArguments[0]).toBeInstanceOf(WebAuthnLoginCredentials);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Test helpers
|
||||
function randomBytes(length: number): Uint8Array {
|
||||
return new Uint8Array(Array.from({ length }, (_, k) => k % 255));
|
||||
}
|
||||
|
||||
// AuthenticatorAssertionResponse && PublicKeyCredential are only available in secure contexts
|
||||
// so we need to mock them and assign them to the global object to make them available
|
||||
// for the tests
|
||||
class MockAuthenticatorAssertionResponse implements AuthenticatorAssertionResponse {
|
||||
clientDataJSON: ArrayBuffer = randomBytes(32).buffer;
|
||||
authenticatorData: ArrayBuffer = randomBytes(196).buffer;
|
||||
signature: ArrayBuffer = randomBytes(72).buffer;
|
||||
userHandle: ArrayBuffer = randomBytes(16).buffer;
|
||||
|
||||
clientDataJSONB64Str = Utils.fromBufferToUrlB64(this.clientDataJSON);
|
||||
authenticatorDataB64Str = Utils.fromBufferToUrlB64(this.authenticatorData);
|
||||
signatureB64Str = Utils.fromBufferToUrlB64(this.signature);
|
||||
userHandleB64Str = Utils.fromBufferToUrlB64(this.userHandle);
|
||||
}
|
||||
|
||||
class MockPublicKeyCredential implements PublicKeyCredential {
|
||||
authenticatorAttachment = "cross-platform";
|
||||
id = "mockCredentialId";
|
||||
type = "public-key";
|
||||
rawId: ArrayBuffer = randomBytes(32).buffer;
|
||||
rawIdB64Str = Utils.fromBufferToUrlB64(this.rawId);
|
||||
|
||||
response: MockAuthenticatorAssertionResponse = new MockAuthenticatorAssertionResponse();
|
||||
|
||||
// Use random 64 character hex string (32 bytes - matters for symmetric key creation)
|
||||
// to represent the prf key binary data and convert to ArrayBuffer
|
||||
// Creating the array buffer from a known hex value allows us to
|
||||
// assert on the value in tests
|
||||
private prfKeyArrayBuffer: ArrayBuffer = Utils.hexStringToArrayBuffer(
|
||||
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
);
|
||||
|
||||
getClientExtensionResults(): any {
|
||||
return {
|
||||
prf: {
|
||||
results: {
|
||||
first: this.prfKeyArrayBuffer,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static isConditionalMediationAvailable(): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
static isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCredentialAssertionOptions(): WebAuthnLoginCredentialAssertionOptionsView {
|
||||
// Mock credential assertion options
|
||||
const challenge = "6CG3jqMCVASJVXySMi9KWw";
|
||||
const token = "BWWebAuthnLoginAssertionOptions_CfDJ_2KBN892w";
|
||||
const timeout = 60000;
|
||||
const rpId = "localhost";
|
||||
const allowCredentials = [] as PublicKeyCredentialDescriptor[];
|
||||
const userVerification = "required";
|
||||
const objectName = "webAuthnLoginAssertionOptions";
|
||||
|
||||
const credentialAssertionOptionsServerResponse = {
|
||||
options: {
|
||||
challenge: challenge,
|
||||
timeout: timeout,
|
||||
rpId: rpId,
|
||||
allowCredentials: allowCredentials,
|
||||
userVerification: userVerification,
|
||||
status: "ok",
|
||||
errorMessage: "",
|
||||
},
|
||||
token: token,
|
||||
object: objectName,
|
||||
};
|
||||
|
||||
const credentialAssertionOptionsResponse = new CredentialAssertionOptionsResponse(
|
||||
credentialAssertionOptionsServerResponse
|
||||
);
|
||||
|
||||
return new WebAuthnLoginCredentialAssertionOptionsView(
|
||||
credentialAssertionOptionsResponse.options,
|
||||
credentialAssertionOptionsResponse.token
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { PrfKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { AuthService } from "../../abstractions/auth.service";
|
||||
import { WebAuthnLoginApiServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-api.service.abstraction";
|
||||
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
|
||||
import { WebAuthnLoginServiceAbstraction } from "../../abstractions/webauthn/webauthn-login.service.abstraction";
|
||||
import { AuthResult } from "../../models/domain/auth-result";
|
||||
import { WebAuthnLoginCredentials } from "../../models/domain/login-credentials";
|
||||
import { WebAuthnLoginCredentialAssertionOptionsView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion-options.view";
|
||||
import { WebAuthnLoginCredentialAssertionView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion.view";
|
||||
|
||||
import { WebAuthnLoginAssertionResponseRequest } from "./request/webauthn-login-assertion-response.request";
|
||||
|
||||
export class WebAuthnLoginService implements WebAuthnLoginServiceAbstraction {
|
||||
readonly enabled$: Observable<boolean>;
|
||||
|
||||
private navigatorCredentials: CredentialsContainer;
|
||||
|
||||
constructor(
|
||||
private webAuthnLoginApiService: WebAuthnLoginApiServiceAbstraction,
|
||||
private authService: AuthService,
|
||||
private configService: ConfigServiceAbstraction,
|
||||
private webAuthnLoginPrfCryptoService: WebAuthnLoginPrfCryptoServiceAbstraction,
|
||||
private window: Window,
|
||||
private logService?: LogService
|
||||
) {
|
||||
this.enabled$ = this.configService.getFeatureFlag$(FeatureFlag.PasswordlessLogin, false);
|
||||
this.navigatorCredentials = this.window.navigator.credentials;
|
||||
}
|
||||
|
||||
async getCredentialAssertionOptions(): Promise<WebAuthnLoginCredentialAssertionOptionsView> {
|
||||
const response = await this.webAuthnLoginApiService.getCredentialAssertionOptions();
|
||||
return new WebAuthnLoginCredentialAssertionOptionsView(response.options, response.token);
|
||||
}
|
||||
|
||||
async assertCredential(
|
||||
credentialAssertionOptions: WebAuthnLoginCredentialAssertionOptionsView
|
||||
): Promise<WebAuthnLoginCredentialAssertionView> {
|
||||
const nativeOptions: CredentialRequestOptions = {
|
||||
publicKey: credentialAssertionOptions.options,
|
||||
};
|
||||
// TODO: Remove `any` when typescript typings add support for PRF
|
||||
nativeOptions.publicKey.extensions = {
|
||||
prf: { eval: { first: await this.webAuthnLoginPrfCryptoService.getLoginWithPrfSalt() } },
|
||||
} as any;
|
||||
|
||||
try {
|
||||
const response = await this.navigatorCredentials.get(nativeOptions);
|
||||
if (!(response instanceof PublicKeyCredential)) {
|
||||
return undefined;
|
||||
}
|
||||
// TODO: Remove `any` when typescript typings add support for PRF
|
||||
const prfResult = (response.getClientExtensionResults() as any).prf?.results?.first;
|
||||
let symmetricPrfKey: PrfKey | undefined;
|
||||
if (prfResult != undefined) {
|
||||
symmetricPrfKey = await this.webAuthnLoginPrfCryptoService.createSymmetricKeyFromPrf(
|
||||
prfResult
|
||||
);
|
||||
}
|
||||
|
||||
const deviceResponse = new WebAuthnLoginAssertionResponseRequest(response);
|
||||
|
||||
// Verify that we aren't going to send PRF information to the server in any case.
|
||||
// Note: this will only happen if a dev has done something wrong.
|
||||
if ("prf" in deviceResponse.extensions) {
|
||||
throw new Error("PRF information is not allowed to be sent to the server.");
|
||||
}
|
||||
|
||||
return new WebAuthnLoginCredentialAssertionView(
|
||||
credentialAssertionOptions.token,
|
||||
deviceResponse,
|
||||
symmetricPrfKey
|
||||
);
|
||||
} catch (error) {
|
||||
this.logService?.error(error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async logIn(assertion: WebAuthnLoginCredentialAssertionView): Promise<AuthResult> {
|
||||
const credential = new WebAuthnLoginCredentials(
|
||||
assertion.token,
|
||||
assertion.deviceResponse,
|
||||
assertion.prfKey
|
||||
);
|
||||
const result = await this.authService.logIn(credential);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user