1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-22 11:13:46 +00:00

Merge branch 'master' into ac/ac-1139/deprecate-custom-collection-perm

This commit is contained in:
Rui Tome
2023-11-27 12:32:34 +00:00
364 changed files with 23724 additions and 1953 deletions

View File

@@ -38,6 +38,7 @@ import { EmailRequest } from "../auth/models/request/email.request";
import { PasswordTokenRequest } from "../auth/models/request/identity-token/password-token.request";
import { SsoTokenRequest } from "../auth/models/request/identity-token/sso-token.request";
import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-api-token.request";
import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token/webauthn-login-token.request";
import { KeyConnectorUserKeyRequest } from "../auth/models/request/key-connector-user-key.request";
import { PasswordHintRequest } from "../auth/models/request/password-hint.request";
import { PasswordRequest } from "../auth/models/request/password.request";
@@ -144,7 +145,11 @@ export abstract class ApiService {
) => Promise<any>;
postIdentityToken: (
request: PasswordTokenRequest | SsoTokenRequest | UserApiTokenRequest
request:
| PasswordTokenRequest
| SsoTokenRequest
| UserApiTokenRequest
| WebAuthnLoginTokenRequest
) => Promise<IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse>;
refreshIdentityToken: () => Promise<any>;

View File

@@ -10,5 +10,7 @@ export abstract class SettingsService {
getEquivalentDomains: (url: string) => Set<string>;
setDisableFavicon: (value: boolean) => Promise<any>;
getDisableFavicon: () => boolean;
setAutoFillOverlayVisibility: (value: number) => Promise<void>;
getAutoFillOverlayVisibility: () => Promise<number>;
clear: (userId?: string) => Promise<void>;
}

View File

@@ -9,34 +9,87 @@ import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-p
import { PolicyResponse } from "../../models/response/policy.response";
export abstract class PolicyService {
/**
* All {@link Policy} objects for the active user (from sync data).
* May include policies that are disabled or otherwise do not apply to the user.
* @see {@link get$} or {@link policyAppliesToActiveUser$} if you want to know when a policy applies to a user.
*/
policies$: Observable<Policy[]>;
/**
* @returns the first {@link Policy} found that applies to the active user.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* @param policyType the {@link PolicyType} to search for
* @param policyFilter Optional predicate to apply when filtering policies
*/
get$: (policyType: PolicyType, policyFilter?: (policy: Policy) => boolean) => Observable<Policy>;
masterPasswordPolicyOptions$: (policies?: Policy[]) => Observable<MasterPasswordPolicyOptions>;
/**
* All {@link Policy} objects for the specified user (from sync data).
* May include policies that are disabled or otherwise do not apply to the user.
* @see {@link policyAppliesToUser} if you want to know when a policy applies to the user.
* @deprecated Use {@link policies$} instead
*/
getAll: (type?: PolicyType, userId?: string) => Promise<Policy[]>;
/**
* @returns true if the {@link PolicyType} applies to the current user, otherwise false.
* @remarks A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
*/
policyAppliesToActiveUser$: (
policyType: PolicyType,
policyFilter?: (policy: Policy) => boolean
) => Observable<boolean>;
/**
* @deprecated Do not call this, use the policies$ observable collection
* @returns true if the {@link PolicyType} applies to the specified user, otherwise false.
* @remarks A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* @see {@link policyAppliesToActiveUser$} if you only want to know about the current user.
*/
getAll: (type?: PolicyType, userId?: string) => Promise<Policy[]>;
evaluateMasterPassword: (
passwordStrength: number,
newPassword: string,
enforcedPolicyOptions?: MasterPasswordPolicyOptions
) => boolean;
getResetPasswordPolicyOptions: (
policies: Policy[],
orgId: string
) => [ResetPasswordPolicyOptions, boolean];
mapPolicyFromResponse: (policyResponse: PolicyResponse) => Policy;
mapPoliciesFromToken: (policiesResponse: ListResponse<PolicyResponse>) => Policy[];
policyAppliesToUser: (
policyType: PolicyType,
policyFilter?: (policy: Policy) => boolean,
userId?: string
) => Promise<boolean>;
// Policy specific interfaces
/**
* Combines all Master Password policies that apply to the user.
* @returns a set of options which represent the minimum Master Password settings that the user must
* comply with in order to comply with **all** Master Password policies.
*/
masterPasswordPolicyOptions$: (policies?: Policy[]) => Observable<MasterPasswordPolicyOptions>;
/**
* Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user.
*/
evaluateMasterPassword: (
passwordStrength: number,
newPassword: string,
enforcedPolicyOptions?: MasterPasswordPolicyOptions
) => boolean;
/**
* @returns Reset Password policy options for the specified organization and a boolean indicating whether the policy
* is enabled
*/
getResetPasswordPolicyOptions: (
policies: Policy[],
orgId: string
) => [ResetPasswordPolicyOptions, boolean];
// Helpers
/**
* Instantiates {@link Policy} objects from {@link PolicyResponse} objects.
*/
mapPolicyFromResponse: (policyResponse: PolicyResponse) => Policy;
/**
* Instantiates {@link Policy} objects from {@link ListResponse<PolicyResponse>} objects.
*/
mapPoliciesFromToken: (policiesResponse: ListResponse<PolicyResponse>) => Policy[];
}
export abstract class InternalPolicyService extends PolicyService {

View File

@@ -270,6 +270,10 @@ export class Organization {
return this.providerId != null || this.providerName != null;
}
get hasReseller() {
return this.hasProvider && this.providerType === ProviderType.Reseller;
}
get canAccessSecretsManager() {
return this.useSecretsManager && this.accessSecretsManager;
}

View File

@@ -7,6 +7,11 @@ export class Policy extends Domain {
organizationId: string;
type: PolicyType;
data: any;
/**
* Warning: a user can be exempt from a policy even if the policy is enabled.
* @see {@link PolicyService} has methods to tell you whether a policy applies to a user.
*/
enabled: boolean;
constructor(obj?: PolicyData) {

View File

@@ -42,11 +42,6 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
.subscribe();
}
/**
* Returns the first policy found that applies to the active user
* @param policyType Policy type to search for
* @param policyFilter Additional filter to apply to the policy
*/
get$(policyType: PolicyType, policyFilter?: (policy: Policy) => boolean): Observable<Policy> {
return this.policies$.pipe(
concatMap(async (policies) => {
@@ -64,9 +59,6 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
);
}
/**
* @deprecated Do not call this, use the policies$ observable collection
*/
async getAll(type?: PolicyType, userId?: string): Promise<Policy[]> {
let response: Policy[] = [];
const decryptedPolicies = await this.stateService.getDecryptedPolicies({ userId: userId });

View File

@@ -3,12 +3,20 @@ import { Observable } from "rxjs";
import { UserId } from "../../types/guid";
import { AuthenticationStatus } from "../enums/authentication-status";
/**
* Holds information about an account for use in the AccountService
* if more information is added, be sure to update the equality method.
*/
export type AccountInfo = {
status: AuthenticationStatus;
email: string;
name: string | undefined;
};
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
return a.status == b.status && a.email == b.email && a.name == b.name;
}
export abstract class AccountService {
accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>;

View File

@@ -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,

View File

@@ -0,0 +1,5 @@
import { CredentialAssertionOptionsResponse } from "../../services/webauthn-login/response/credential-assertion-options.response";
export class WebAuthnLoginApiServiceAbstraction {
getCredentialAssertionOptions: () => Promise<CredentialAssertionOptionsResponse>;
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -3,4 +3,5 @@ export enum AuthenticationType {
Sso = 1,
UserApi = 2,
AuthRequest = 3,
WebAuthn = 4,
}

View File

@@ -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(

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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
) {}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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")
);
}
}
}

View File

@@ -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"));
}
}
}

View File

@@ -0,0 +1,5 @@
import { AssertionOptionsResponse } from "../../../services/webauthn-login/response/assertion-options.response";
export class WebAuthnLoginCredentialAssertionOptionsView {
constructor(readonly options: AssertionOptionsResponse, readonly token: string) {}
}

View File

@@ -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
) {}
}

View File

@@ -1,9 +1,15 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { trackEmissions } from "../../../spec/utils";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import {
ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID,
GlobalState,
GlobalStateProvider,
} from "../../platform/state";
import { UserId } from "../../types/guid";
import { AccountInfo } from "../abstractions/account.service";
import { AuthenticationStatus } from "../enums/authentication-status";
@@ -13,6 +19,11 @@ import { AccountServiceImplementation } from "./account.service";
describe("accountService", () => {
let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>;
let globalStateProvider: MockProxy<GlobalStateProvider>;
let accountsState: MockProxy<GlobalState<Record<UserId, AccountInfo>>>;
let accountsSubject: BehaviorSubject<Record<UserId, AccountInfo>>;
let activeAccountIdState: MockProxy<GlobalState<UserId>>;
let activeAccountIdSubject: BehaviorSubject<UserId>;
let sut: AccountServiceImplementation;
const userId = "userId" as UserId;
function userInfo(status: AuthenticationStatus): AccountInfo {
@@ -20,10 +31,29 @@ describe("accountService", () => {
}
beforeEach(() => {
messagingService = mock<MessagingService>();
logService = mock<LogService>();
messagingService = mock();
logService = mock();
globalStateProvider = mock();
accountsState = mock();
activeAccountIdState = mock();
sut = new AccountServiceImplementation(messagingService, logService);
accountsSubject = new BehaviorSubject<Record<UserId, AccountInfo>>(null);
accountsState.state$ = accountsSubject.asObservable();
activeAccountIdSubject = new BehaviorSubject<UserId>(null);
activeAccountIdState.state$ = activeAccountIdSubject.asObservable();
globalStateProvider.get.mockImplementation((keyDefinition) => {
switch (keyDefinition) {
case ACCOUNT_ACCOUNTS:
return accountsState;
case ACCOUNT_ACTIVE_ACCOUNT_ID:
return activeAccountIdState;
default:
throw new Error("Unknown key definition");
}
});
sut = new AccountServiceImplementation(messagingService, logService, globalStateProvider);
});
afterEach(() => {
@@ -39,8 +69,8 @@ describe("accountService", () => {
it("should emit the active account and status", async () => {
const emissions = trackEmissions(sut.activeAccount$);
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
sut.switchAccount(userId);
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
activeAccountIdSubject.next(userId);
expect(emissions).toEqual([
undefined, // initial value
@@ -48,9 +78,21 @@ describe("accountService", () => {
]);
});
it("should update the status if the account status changes", async () => {
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
activeAccountIdSubject.next(userId);
const emissions = trackEmissions(sut.activeAccount$);
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Locked) });
expect(emissions).toEqual([
{ id: userId, ...userInfo(AuthenticationStatus.Unlocked) },
{ id: userId, ...userInfo(AuthenticationStatus.Locked) },
]);
});
it("should remember the last emitted value", async () => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
sut.switchAccount(userId);
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
activeAccountIdSubject.next(userId);
expect(await firstValueFrom(sut.activeAccount$)).toEqual({
id: userId,
@@ -59,77 +101,98 @@ describe("accountService", () => {
});
});
describe("accounts$", () => {
it("should maintain an accounts cache", async () => {
expect(await firstValueFrom(sut.accounts$)).toEqual({});
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
expect(await firstValueFrom(sut.accounts$)).toEqual({
[userId]: userInfo(AuthenticationStatus.Unlocked),
});
});
});
describe("addAccount", () => {
it("should emit the new account", () => {
const emissions = trackEmissions(sut.accounts$);
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
expect(emissions).toEqual([
{}, // initial value
{ [userId]: userInfo(AuthenticationStatus.Unlocked) },
]);
expect(accountsState.update).toHaveBeenCalledTimes(1);
const callback = accountsState.update.mock.calls[0][0];
expect(callback({}, null)).toEqual({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
});
describe("setAccountName", () => {
beforeEach(() => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
it("should emit the updated account", () => {
const emissions = trackEmissions(sut.accounts$);
it("should update the account", async () => {
sut.setAccountName(userId, "new name");
expect(emissions).toEqual([
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "name" } },
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "new name" } },
]);
const callback = accountsState.update.mock.calls[0][0];
expect(callback(accountsSubject.value, null)).toEqual({
[userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "new name" },
});
});
it("should not update if the name is the same", async () => {
sut.setAccountName(userId, "name");
const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
expect(callback(accountsSubject.value, null)).toBe(false);
});
});
describe("setAccountEmail", () => {
beforeEach(() => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
it("should emit the updated account", () => {
const emissions = trackEmissions(sut.accounts$);
it("should update the account", () => {
sut.setAccountEmail(userId, "new email");
expect(emissions).toEqual([
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "email" } },
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "new email" } },
]);
const callback = accountsState.update.mock.calls[0][0];
expect(callback(accountsSubject.value, null)).toEqual({
[userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "new email" },
});
});
it("should not update if the email is the same", () => {
sut.setAccountEmail(userId, "email");
const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
expect(callback(accountsSubject.value, null)).toBe(false);
});
});
describe("setAccountStatus", () => {
beforeEach(() => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
it("should not emit if the status is the same", async () => {
const emissions = trackEmissions(sut.accounts$);
sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
it("should update the account", () => {
sut.setAccountStatus(userId, AuthenticationStatus.Locked);
expect(emissions).toEqual([{ userId: userInfo(AuthenticationStatus.Unlocked) }]);
});
const callback = accountsState.update.mock.calls[0][0];
it("should maintain an accounts cache", async () => {
expect(await firstValueFrom(sut.accounts$)).toEqual({
[userId]: userInfo(AuthenticationStatus.Unlocked),
expect(callback(accountsSubject.value, null)).toEqual({
[userId]: {
...userInfo(AuthenticationStatus.Unlocked),
status: AuthenticationStatus.Locked,
},
});
});
it("should emit if the status is different", () => {
const emissions = trackEmissions(sut.accounts$);
sut.setAccountStatus(userId, AuthenticationStatus.Locked);
it("should not update if the status is the same", () => {
sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
expect(emissions).toEqual([
{ userId: userInfo(AuthenticationStatus.Unlocked) }, // initial value from beforeEach
{ userId: userInfo(AuthenticationStatus.Locked) },
]);
const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
expect(callback(accountsSubject.value, null)).toBe(false);
});
it("should emit logout if the status is logged out", () => {
@@ -148,34 +211,20 @@ describe("accountService", () => {
});
describe("switchAccount", () => {
let emissions: { id: string; status: AuthenticationStatus }[];
beforeEach(() => {
emissions = [];
sut.activeAccount$.subscribe((value) => emissions.push(value));
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
it("should emit undefined if no account is provided", () => {
sut.switchAccount(undefined);
expect(emissions).toEqual([undefined]);
sut.switchAccount(null);
const callback = activeAccountIdState.update.mock.calls[0][0];
expect(callback(userId, accountsSubject.value)).toBeUndefined();
});
it("should emit the active account and status", () => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
sut.switchAccount(userId);
sut.setAccountStatus(userId, AuthenticationStatus.Locked);
sut.switchAccount(undefined);
sut.switchAccount(undefined);
expect(emissions).toEqual([
undefined, // initial value
{ id: userId, ...userInfo(AuthenticationStatus.Unlocked) },
{ id: userId, ...userInfo(AuthenticationStatus.Locked) },
]);
});
it("should throw if switched to an unknown account", () => {
expect(() => sut.switchAccount(userId)).toThrowError("Account does not exist");
it("should throw if the account does not exist", () => {
sut.switchAccount("unknown" as UserId);
const callback = activeAccountIdState.update.mock.calls[0][0];
expect(() => callback(userId, accountsSubject.value)).toThrowError("Account does not exist");
});
});
});

View File

@@ -1,50 +1,80 @@
import {
BehaviorSubject,
Subject,
combineLatestWith,
map,
distinctUntilChanged,
shareReplay,
} from "rxjs";
import { Subject, combineLatestWith, map, distinctUntilChanged, shareReplay } from "rxjs";
import { Jsonify } from "type-fest";
import { AccountInfo, InternalAccountService } from "../../auth/abstractions/account.service";
import {
AccountInfo,
InternalAccountService,
accountInfoEqual,
} from "../../auth/abstractions/account.service";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import {
ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID,
GlobalState,
GlobalStateProvider,
} from "../../platform/state";
import { UserId } from "../../types/guid";
import { AuthenticationStatus } from "../enums/authentication-status";
export function AccountsDeserializer(
accounts: Jsonify<Record<UserId, AccountInfo> | null>
): Record<UserId, AccountInfo> {
if (accounts == null) {
return {};
}
return accounts;
}
export class AccountServiceImplementation implements InternalAccountService {
private accounts = new BehaviorSubject<Record<UserId, AccountInfo>>({});
private activeAccountId = new BehaviorSubject<UserId | undefined>(undefined);
private lock = new Subject<UserId>();
private logout = new Subject<UserId>();
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
private activeAccountIdState: GlobalState<UserId | undefined>;
accounts$ = this.accounts.asObservable();
activeAccount$ = this.activeAccountId.pipe(
combineLatestWith(this.accounts$),
map(([id, accounts]) => (id ? { id, ...accounts[id] } : undefined)),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false })
);
accounts$;
activeAccount$;
accountLock$ = this.lock.asObservable();
accountLogout$ = this.logout.asObservable();
constructor(private messagingService: MessagingService, private logService: LogService) {}
constructor(
private messagingService: MessagingService,
private logService: LogService,
private globalStateProvider: GlobalStateProvider
) {
this.accountsState = this.globalStateProvider.get(ACCOUNT_ACCOUNTS);
this.activeAccountIdState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID);
this.accounts$ = this.accountsState.state$.pipe(
map((accounts) => (accounts == null ? {} : accounts))
);
this.activeAccount$ = this.activeAccountIdState.state$.pipe(
combineLatestWith(this.accounts$),
map(([id, accounts]) => (id ? { id, ...accounts[id] } : undefined)),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false })
);
}
addAccount(userId: UserId, accountData: AccountInfo): void {
this.accounts.value[userId] = accountData;
this.accounts.next(this.accounts.value);
this.accountsState.update((accounts) => {
accounts ||= {};
accounts[userId] = accountData;
return accounts;
});
}
setAccountName(userId: UserId, name: string): void {
this.setAccountInfo(userId, { ...this.accounts.value[userId], name });
this.setAccountInfo(userId, { name });
}
setAccountEmail(userId: UserId, email: string): void {
this.setAccountInfo(userId, { ...this.accounts.value[userId], email });
this.setAccountInfo(userId, { email });
}
setAccountStatus(userId: UserId, status: AuthenticationStatus): void {
this.setAccountInfo(userId, { ...this.accounts.value[userId], status });
this.setAccountInfo(userId, { status });
if (status === AuthenticationStatus.LoggedOut) {
this.logout.next(userId);
@@ -54,16 +84,22 @@ export class AccountServiceImplementation implements InternalAccountService {
}
switchAccount(userId: UserId) {
if (userId == null) {
// indicates no account is active
this.activeAccountId.next(undefined);
return;
}
this.activeAccountIdState.update(
(_, accounts) => {
if (userId == null) {
// indicates no account is active
return undefined;
}
if (this.accounts.value[userId] == null) {
throw new Error("Account does not exist");
}
this.activeAccountId.next(userId);
if (accounts?.[userId] == null) {
throw new Error("Account does not exist");
}
return userId;
},
{
combineLatestWith: this.accounts$,
}
);
}
// TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow
@@ -76,18 +112,26 @@ export class AccountServiceImplementation implements InternalAccountService {
}
}
private setAccountInfo(userId: UserId, accountInfo: AccountInfo) {
if (this.accounts.value[userId] == null) {
throw new Error("Account does not exist");
private setAccountInfo(userId: UserId, update: Partial<AccountInfo>) {
function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
return { ...oldAccountInfo, ...update };
}
this.accountsState.update(
(accounts) => {
accounts[userId] = newAccountInfo(accounts[userId]);
return accounts;
},
{
// Avoid unnecessary updates
// TODO: Faster comparison, maybe include a hash on the objects?
shouldUpdate: (accounts) => {
if (accounts?.[userId] == null) {
throw new Error("Account does not exist");
}
// Avoid unnecessary updates
// TODO: Faster comparison, maybe include a hash on the objects?
if (JSON.stringify(this.accounts.value[userId]) === JSON.stringify(accountInfo)) {
return;
}
this.accounts.value[userId] = accountInfo;
this.accounts.next(this.accounts.value);
return !accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
},
}
);
}
}

View File

@@ -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();

View File

@@ -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),
};
}
}

View File

@@ -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
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}

View File

@@ -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);
}
}

View File

@@ -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
);
}

View File

@@ -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;
}
}

View File

@@ -1,10 +1,10 @@
export enum FeatureFlag {
DisplayEuEnvironmentFlag = "display-eu-environment",
DisplayLowKdfIterationWarningFlag = "display-kdf-iteration-warning",
Fido2VaultCredentials = "fido2-vault-credentials",
TrustedDeviceEncryption = "trusted-device-encryption",
PasswordlessLogin = "passwordless-login",
AutofillV2 = "autofill-v2",
AutofillOverlay = "autofill-overlay",
BrowserFilelessImport = "browser-fileless-import",
ItemShare = "item-share",
FlexibleCollections = "flexible-collections",

View File

@@ -19,6 +19,7 @@ export class Fido2CredentialExport {
req.keyValue = "keyValue";
req.rpId = "rpId";
req.userHandle = "userHandle";
req.userName = "userName";
req.counter = "counter";
req.rpName = "rpName";
req.userDisplayName = "userDisplayName";
@@ -41,6 +42,7 @@ export class Fido2CredentialExport {
view.keyValue = req.keyValue;
view.rpId = req.rpId;
view.userHandle = req.userHandle;
view.userName = req.userName;
view.counter = parseInt(req.counter);
view.rpName = req.rpName;
view.userDisplayName = req.userDisplayName;
@@ -63,6 +65,7 @@ export class Fido2CredentialExport {
domain.keyValue = req.keyValue != null ? new EncString(req.keyValue) : null;
domain.rpId = req.rpId != null ? new EncString(req.rpId) : null;
domain.userHandle = req.userHandle != null ? new EncString(req.userHandle) : null;
domain.userName = req.userName != null ? new EncString(req.userName) : null;
domain.counter = req.counter != null ? new EncString(req.counter) : null;
domain.rpName = req.rpName != null ? new EncString(req.rpName) : null;
domain.userDisplayName =
@@ -79,6 +82,7 @@ export class Fido2CredentialExport {
keyValue: string;
rpId: string;
userHandle: string;
userName: string;
counter: string;
rpName: string;
userDisplayName: string;
@@ -103,6 +107,7 @@ export class Fido2CredentialExport {
this.keyValue = o.keyValue;
this.rpId = o.rpId;
this.userHandle = o.userHandle;
this.userName = o.userName;
this.counter = String(o.counter);
this.rpName = o.rpName;
this.userDisplayName = o.userDisplayName;
@@ -115,6 +120,7 @@ export class Fido2CredentialExport {
this.keyValue = o.keyValue?.encryptedString;
this.rpId = o.rpId?.encryptedString;
this.userHandle = o.userHandle?.encryptedString;
this.userName = o.userName?.encryptedString;
this.counter = o.counter?.encryptedString;
this.rpName = o.rpName?.encryptedString;
this.userDisplayName = o.userDisplayName?.encryptedString;

View File

@@ -243,6 +243,8 @@ export abstract class StateService<T extends Account = Account> {
value: boolean,
options?: StorageOptions
) => Promise<void>;
getEnablePasskeys: (options?: StorageOptions) => Promise<boolean>;
setEnablePasskeys: (value: boolean, options?: StorageOptions) => Promise<void>;
getDisableContextMenuItem: (options?: StorageOptions) => Promise<boolean>;
setDisableContextMenuItem: (value: boolean, options?: StorageOptions) => Promise<void>;
/**
@@ -285,6 +287,8 @@ export abstract class StateService<T extends Account = Account> {
setEmailVerified: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableAlwaysOnTop: (options?: StorageOptions) => Promise<boolean>;
setEnableAlwaysOnTop: (value: boolean, options?: StorageOptions) => Promise<void>;
getAutoFillOverlayVisibility: (options?: StorageOptions) => Promise<number>;
setAutoFillOverlayVisibility: (value: number, options?: StorageOptions) => Promise<void>;
getEnableAutoFillOnPageLoad: (options?: StorageOptions) => Promise<boolean>;
setEnableAutoFillOnPageLoad: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
@@ -428,8 +432,6 @@ export abstract class StateService<T extends Account = Account> {
setOpenAtLogin: (value: boolean, options?: StorageOptions) => Promise<void>;
getOrganizationInvitation: (options?: StorageOptions) => Promise<any>;
setOrganizationInvitation: (value: any, options?: StorageOptions) => Promise<void>;
getEmergencyAccessInvitation: (options?: StorageOptions) => Promise<any>;
setEmergencyAccessInvitation: (value: any, options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this directly, use OrganizationService
*/
@@ -530,4 +532,17 @@ export abstract class StateService<T extends Account = Account> {
value: Record<string, Record<string, boolean>>,
options?: StorageOptions
) => Promise<void>;
/**
* fetches string value of URL user tried to navigate to while unauthenticated.
* @param options Defines the storage options for the URL; Defaults to session Storage.
* @returns route called prior to successful login.
*/
getDeepLinkRedirectUrl: (options?: StorageOptions) => Promise<string>;
/**
* Store URL in session storage by default, but can be configured. Developed to handle
* unauthN interrupted navigation.
* @param url URL of route
* @param options Defines the storage options for the URL; Defaults to session Storage.
*/
setDeepLinkRedirectUrl: (url: string, options?: StorageOptions) => Promise<void>;
}

View File

@@ -8,14 +8,17 @@ export type StorageUpdate = {
updateType: StorageUpdateType;
};
export abstract class AbstractStorageService {
abstract get valuesRequireDeserialization(): boolean;
export interface ObservableStorageService {
/**
* Provides an {@link Observable} that represents a stream of updates that
* have happened in this storage service or in the storage this service provides
* an interface to.
*/
abstract get updates$(): Observable<StorageUpdate>;
get updates$(): Observable<StorageUpdate>;
}
export abstract class AbstractStorageService {
abstract get valuesRequireDeserialization(): boolean;
abstract get<T>(key: string, options?: StorageOptions): Promise<T>;
abstract has(key: string, options?: StorageOptions): Promise<boolean>;
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;

View File

@@ -244,6 +244,243 @@ describe("Utils Service", () => {
});
});
function runInBothEnvironments(testName: string, testFunc: () => void): void {
const environments = [
{ isNode: true, name: "Node environment" },
{ isNode: false, name: "non-Node environment" },
];
environments.forEach((env) => {
it(`${testName} in ${env.name}`, () => {
Utils.isNode = env.isNode;
testFunc();
});
});
}
const asciiHelloWorldArray = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100];
const b64HelloWorldString = "aGVsbG8gd29ybGQ=";
describe("fromBufferToB64(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should convert an ArrayBuffer to a b64 string", () => {
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;
const b64String = Utils.fromBufferToB64(buffer);
expect(b64String).toBe(b64HelloWorldString);
});
runInBothEnvironments("should return an empty string for an empty ArrayBuffer", () => {
const buffer = new Uint8Array([]).buffer;
const b64String = Utils.fromBufferToB64(buffer);
expect(b64String).toBe("");
});
runInBothEnvironments("should return null for null input", () => {
const b64String = Utils.fromBufferToB64(null);
expect(b64String).toBeNull();
});
});
describe("fromB64ToArray(...)", () => {
runInBothEnvironments("should convert a b64 string to an Uint8Array", () => {
const expectedArray = new Uint8Array(asciiHelloWorldArray);
const resultArray = Utils.fromB64ToArray(b64HelloWorldString);
expect(resultArray).toEqual(expectedArray);
});
runInBothEnvironments("should return null for null input", () => {
const expectedArray = Utils.fromB64ToArray(null);
expect(expectedArray).toBeNull();
});
// Hmmm... this passes in browser but not in node
// as node doesn't throw an error for invalid base64 strings.
// It instead produces a buffer with the bytes that could be decoded
// and ignores the rest after an invalid character.
// https://github.com/nodejs/node/issues/8569
// This could be mitigated with a regex check before decoding...
// runInBothEnvironments("should throw an error for invalid base64 string", () => {
// const invalidB64String = "invalid base64";
// expect(() => {
// Utils.fromB64ToArrayBuffer(invalidB64String);
// }).toThrow();
// });
});
describe("Base64 and ArrayBuffer round trip conversions", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments(
"should correctly round trip convert from ArrayBuffer to base64 and back",
() => {
// Start with a known ArrayBuffer
const originalArray = new Uint8Array(asciiHelloWorldArray);
const originalBuffer = originalArray.buffer;
// Convert ArrayBuffer to a base64 string
const b64String = Utils.fromBufferToB64(originalBuffer);
// Convert that base64 string back to an ArrayBuffer
const roundTrippedBuffer = Utils.fromB64ToArray(b64String).buffer;
const roundTrippedArray = new Uint8Array(roundTrippedBuffer);
// Compare the original ArrayBuffer with the round-tripped ArrayBuffer
expect(roundTrippedArray).toEqual(originalArray);
}
);
runInBothEnvironments(
"should correctly round trip convert from base64 to ArrayBuffer and back",
() => {
// Convert known base64 string to ArrayBuffer
const bufferFromB64 = Utils.fromB64ToArray(b64HelloWorldString).buffer;
// Convert the ArrayBuffer back to a base64 string
const roundTrippedB64String = Utils.fromBufferToB64(bufferFromB64);
// Compare the original base64 string with the round-tripped base64 string
expect(roundTrippedB64String).toBe(b64HelloWorldString);
}
);
});
describe("fromBufferToHex(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
/**
* Creates a string that represents a sequence of hexadecimal byte values in ascending order.
* Each byte value corresponds to its position in the sequence.
*
* @param {number} length - The number of bytes to include in the string.
* @return {string} A string of hexadecimal byte values in sequential order.
*
* @example
* // Returns '000102030405060708090a0b0c0d0e0f101112...ff'
* createSequentialHexByteString(256);
*/
function createSequentialHexByteString(length: number) {
let sequentialHexString = "";
for (let i = 0; i < length; i++) {
// Convert the number to a hex string and pad with leading zeros if necessary
const hexByte = i.toString(16).padStart(2, "0");
sequentialHexString += hexByte;
}
return sequentialHexString;
}
runInBothEnvironments("should convert an ArrayBuffer to a hex string", () => {
const buffer = new Uint8Array([0, 1, 10, 16, 255]).buffer;
const hexString = Utils.fromBufferToHex(buffer);
expect(hexString).toBe("00010a10ff");
});
runInBothEnvironments("should handle an empty buffer", () => {
const buffer = new ArrayBuffer(0);
const hexString = Utils.fromBufferToHex(buffer);
expect(hexString).toBe("");
});
runInBothEnvironments(
"should correctly convert a large buffer containing a repeating sequence of all 256 unique byte values to hex",
() => {
const largeBuffer = new Uint8Array(1024).map((_, index) => index % 256).buffer;
const hexString = Utils.fromBufferToHex(largeBuffer);
const expectedHexString = createSequentialHexByteString(256).repeat(4);
expect(hexString).toBe(expectedHexString);
}
);
runInBothEnvironments("should correctly convert a buffer with a single byte to hex", () => {
const singleByteBuffer = new Uint8Array([0xab]).buffer;
const hexString = Utils.fromBufferToHex(singleByteBuffer);
expect(hexString).toBe("ab");
});
runInBothEnvironments(
"should correctly convert a buffer with an odd number of bytes to hex",
() => {
const oddByteBuffer = new Uint8Array([0x01, 0x23, 0x45, 0x67, 0x89]).buffer;
const hexString = Utils.fromBufferToHex(oddByteBuffer);
expect(hexString).toBe("0123456789");
}
);
});
describe("hexStringToArrayBuffer(...)", () => {
test("should convert a hex string to an ArrayBuffer correctly", () => {
const hexString = "ff0a1b"; // Arbitrary hex string
const expectedResult = new Uint8Array([255, 10, 27]).buffer;
const result = Utils.hexStringToArrayBuffer(hexString);
expect(new Uint8Array(result)).toEqual(new Uint8Array(expectedResult));
});
test("should throw an error if the hex string length is not even", () => {
const hexString = "abc"; // Odd number of characters
expect(() => {
Utils.hexStringToArrayBuffer(hexString);
}).toThrow("HexString has to be an even length");
});
test("should convert a hex string representing zero to an ArrayBuffer correctly", () => {
const hexString = "00";
const expectedResult = new Uint8Array([0]).buffer;
const result = Utils.hexStringToArrayBuffer(hexString);
expect(new Uint8Array(result)).toEqual(new Uint8Array(expectedResult));
});
test("should handle an empty hex string", () => {
const hexString = "";
const expectedResult = new ArrayBuffer(0);
const result = Utils.hexStringToArrayBuffer(hexString);
expect(result).toEqual(expectedResult);
});
test("should convert a long hex string to an ArrayBuffer correctly", () => {
const hexString = "0102030405060708090a0b0c0d0e0f";
const expectedResult = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
.buffer;
const result = Utils.hexStringToArrayBuffer(hexString);
expect(new Uint8Array(result)).toEqual(new Uint8Array(expectedResult));
});
});
describe("ArrayBuffer and Hex string round trip conversions", () => {
runInBothEnvironments(
"should allow round-trip conversion from ArrayBuffer to hex and back",
() => {
const originalBuffer = new Uint8Array([10, 20, 30, 40, 255]).buffer; // arbitrary buffer
const hexString = Utils.fromBufferToHex(originalBuffer);
const roundTripBuffer = Utils.hexStringToArrayBuffer(hexString);
expect(new Uint8Array(roundTripBuffer)).toEqual(new Uint8Array(originalBuffer));
}
);
runInBothEnvironments(
"should allow round-trip conversion from hex to ArrayBuffer and back",
() => {
const hexString = "0a141e28ff"; // arbitrary hex string
const bufferFromHex = Utils.hexStringToArrayBuffer(hexString);
const roundTripHexString = Utils.fromBufferToHex(bufferFromHex);
expect(roundTripHexString).toBe(hexString);
}
);
});
describe("mapToRecord", () => {
it("should handle null", () => {
expect(Utils.mapToRecord(null)).toEqual(null);

View File

@@ -170,6 +170,43 @@ export class Utils {
}
}
/**
* Converts a hex string to an ArrayBuffer.
* Note: this doesn't need any Node specific code as parseInt() / ArrayBuffer / Uint8Array
* work the same in Node and the browser.
* @param {string} hexString - A string of hexadecimal characters.
* @returns {ArrayBuffer} The ArrayBuffer representation of the hex string.
*/
static hexStringToArrayBuffer(hexString: string): ArrayBuffer {
// Check if the hexString has an even length, as each hex digit represents half a byte (4 bits),
// and it takes two hex digits to represent a full byte (8 bits).
if (hexString.length % 2 !== 0) {
throw "HexString has to be an even length";
}
// Create an ArrayBuffer with a length that is half the length of the hex string,
// because each pair of hex digits will become a single byte.
const arrayBuffer = new ArrayBuffer(hexString.length / 2);
// Create a Uint8Array view on top of the ArrayBuffer (each position represents a byte)
// as ArrayBuffers cannot be edited directly.
const uint8Array = new Uint8Array(arrayBuffer);
// Loop through the bytes
for (let i = 0; i < uint8Array.length; i++) {
// Extract two hex characters (1 byte)
const hexByte = hexString.substr(i * 2, 2);
// Convert hexByte into a decimal value from base 16. (ex: ff --> 255)
const byteValue = parseInt(hexByte, 16);
// Place the byte value into the uint8Array
uint8Array[i] = byteValue;
}
return arrayBuffer;
}
static fromUrlB64ToB64(urlB64Str: string): string {
let output = urlB64Str.replace(/-/g, "+").replace(/_/g, "/");
switch (output.length % 4) {

View File

@@ -7,7 +7,6 @@ export class GlobalState {
installedVersion?: string;
locale?: string;
organizationInvitation?: any;
emergencyAccessInvitation?: any;
ssoCodeVerifier?: string;
ssoOrganizationIdentifier?: string;
ssoState?: string;
@@ -37,7 +36,10 @@ export class GlobalState {
enableDuckDuckGoBrowserIntegration?: boolean;
region?: string;
neverDomains?: { [id: string]: unknown };
enablePasskeys?: boolean;
disableAddLoginNotification?: boolean;
disableChangedPasswordNotification?: boolean;
disableContextMenuItem?: boolean;
autoFillOverlayVisibility?: number;
deepLinkRedirectUrl?: string;
}

View File

@@ -3,7 +3,7 @@ import { Subject } from "rxjs";
import { AbstractMemoryStorageService, StorageUpdate } from "../abstractions/storage.service";
export class MemoryStorageService extends AbstractMemoryStorageService {
private store = new Map<string, unknown>();
protected store = new Map<string, unknown>();
private updatesSubject = new Subject<StorageUpdate>();
get valuesRequireDeserialization(): boolean {

View File

@@ -1,6 +1,7 @@
import { BehaviorSubject, concatMap } from "rxjs";
import { Jsonify, JsonValue } from "type-fest";
import { AutofillOverlayVisibility } from "../../../../../apps/browser/src/autofill/utils/autofill-overlay.enum";
import { EncryptedOrganizationKeyData } from "../../admin-console/models/data/encrypted-organization-key.data";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { PolicyData } from "../../admin-console/models/data/policy.data";
@@ -1212,6 +1213,24 @@ export class StateService<
);
}
async getEnablePasskeys(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.enablePasskeys ?? true
);
}
async setEnablePasskeys(value: boolean, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
globals.enablePasskeys = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
}
async getDisableContextMenuItem(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@@ -1526,6 +1545,27 @@ export class StateService<
);
}
async getAutoFillOverlayVisibility(options?: StorageOptions): Promise<number> {
return (
(
await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
)
)?.autoFillOverlayVisibility ?? AutofillOverlayVisibility.OnFieldFocus
);
}
async setAutoFillOverlayVisibility(value: number, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
globals.autoFillOverlayVisibility = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
}
async getEnableAutoFillOnPageLoad(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@@ -2364,23 +2404,6 @@ export class StateService<
);
}
async getEmergencyAccessInvitation(options?: StorageOptions): Promise<any> {
return (
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.emergencyAccessInvitation;
}
async setEmergencyAccessInvitation(value: any, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
globals.emergencyAccessInvitation = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
}
/**
* @deprecated Do not call this directly, use OrganizationService
*/
@@ -2862,6 +2885,23 @@ export class StateService<
);
}
async getDeepLinkRedirectUrl(options?: StorageOptions): Promise<string> {
return (
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.deepLinkRedirectUrl;
}
async setDeepLinkRedirectUrl(url: string, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
globals.deepLinkRedirectUrl = url;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
}
protected async getGlobals(options: StorageOptions): Promise<TGlobalState> {
let globals: TGlobalState;
if (this.useMemory(options.storageLocation)) {

View File

@@ -1,6 +1,7 @@
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { GlobalState } from "../global-state";
import { GlobalStateProvider } from "../global-state.provider";
@@ -13,8 +14,8 @@ export class DefaultGlobalStateProvider implements GlobalStateProvider {
private globalStateCache: Record<string, GlobalState<unknown>> = {};
constructor(
private memoryStorage: AbstractMemoryStorageService,
private diskStorage: AbstractStorageService
private memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
private diskStorage: AbstractStorageService & ObservableStorageService
) {}
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {

View File

@@ -10,7 +10,10 @@ import {
timeout,
} from "rxjs";
import { AbstractStorageService } from "../../abstractions/storage.service";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { GlobalState } from "../global-state";
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
@@ -29,7 +32,7 @@ export class DefaultGlobalState<T> implements GlobalState<T> {
constructor(
private keyDefinition: KeyDefinition<T>,
private chosenLocation: AbstractStorageService
private chosenLocation: AbstractStorageService & ObservableStorageService
) {
this.storageKey = globalKeyBuilder(this.keyDefinition);

View File

@@ -3,6 +3,7 @@ import { EncryptService } from "../../abstractions/encrypt.service";
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { KeyDefinition } from "../key-definition";
import { StorageLocation } from "../state-definition";
@@ -17,8 +18,8 @@ export class DefaultUserStateProvider implements UserStateProvider {
constructor(
protected accountService: AccountService,
protected encryptService: EncryptService,
protected memoryStorage: AbstractMemoryStorageService,
protected diskStorage: AbstractStorageService
protected memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
protected diskStorage: AbstractStorageService & ObservableStorageService
) {}
get<T>(keyDefinition: KeyDefinition<T>): UserState<T> {

View File

@@ -15,7 +15,10 @@ import {
import { AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import { EncryptService } from "../../abstractions/encrypt.service";
import { AbstractStorageService } from "../../abstractions/storage.service";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { DerivedUserState } from "../derived-user-state";
import { KeyDefinition, userKeyBuilder } from "../key-definition";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
@@ -40,7 +43,7 @@ export class DefaultUserState<T> implements UserState<T> {
protected keyDefinition: KeyDefinition<T>,
private accountService: AccountService,
private encryptService: EncryptService,
private chosenStorageLocation: AbstractStorageService
private chosenStorageLocation: AbstractStorageService & ObservableStorageService
) {
this.formattedKey$ = this.accountService.activeAccount$.pipe(
map((account) =>

View File

@@ -1,3 +1,7 @@
export { DerivedUserState } from "./derived-user-state";
export { DefaultGlobalStateProvider } from "./implementations/default-global-state.provider";
export { DefaultUserStateProvider } from "./implementations/default-user-state.provider";
export { GlobalState } from "./global-state";
export { GlobalStateProvider } from "./global-state.provider";
export { UserState } from "./user-state";
export { UserStateProvider } from "./user-state.provider";
export * from "./key-definitions";

View File

@@ -0,0 +1,18 @@
import { AccountInfo } from "../../auth/abstractions/account.service";
import { AccountsDeserializer } from "../../auth/services/account.service";
import { UserId } from "../../types/guid";
import { KeyDefinition } from "./key-definition";
import { StateDefinition } from "./state-definition";
const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
export const ACCOUNT_ACCOUNTS = new KeyDefinition<Record<UserId, AccountInfo>>(
ACCOUNT_MEMORY,
"accounts",
{
deserializer: (obj) => AccountsDeserializer(obj),
}
);
export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_MEMORY, "activeAccountId", {
deserializer: (id: UserId) => id,
});

View File

@@ -42,6 +42,7 @@ import { PasswordTokenRequest } from "../auth/models/request/identity-token/pass
import { SsoTokenRequest } from "../auth/models/request/identity-token/sso-token.request";
import { TokenTwoFactorRequest } from "../auth/models/request/identity-token/token-two-factor.request";
import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-api-token.request";
import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token/webauthn-login-token.request";
import { KeyConnectorUserKeyRequest } from "../auth/models/request/key-connector-user-key.request";
import { PasswordHintRequest } from "../auth/models/request/password-hint.request";
import { PasswordRequest } from "../auth/models/request/password.request";
@@ -180,7 +181,11 @@ export class ApiService implements ApiServiceAbstraction {
// Auth APIs
async postIdentityToken(
request: UserApiTokenRequest | PasswordTokenRequest | SsoTokenRequest
request:
| UserApiTokenRequest
| PasswordTokenRequest
| SsoTokenRequest
| WebAuthnLoginTokenRequest
): Promise<IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse> {
const headers = new Headers({
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",

View File

@@ -74,6 +74,14 @@ export class SettingsService implements SettingsServiceAbstraction {
return this._disableFavicon.getValue();
}
async setAutoFillOverlayVisibility(value: number): Promise<void> {
return await this.stateService.setAutoFillOverlayVisibility(value);
}
async getAutoFillOverlayVisibility(): Promise<number> {
return await this.stateService.getAutoFillOverlayVisibility();
}
async clear(userId?: string): Promise<void> {
if (userId == null || userId == (await this.stateService.getUserId())) {
this._settings.next({});

View File

@@ -103,6 +103,7 @@ export interface CreateCredentialParams {
user: {
id: string; // b64 encoded
displayName: string;
name: string;
};
/** Forwarded to user interface */
fallbackSupported: boolean;

View File

@@ -8,6 +8,7 @@ export class Fido2CredentialApi extends BaseResponse {
keyValue: string;
rpId: string;
userHandle: string;
userName: string;
counter: string;
rpName: string;
userDisplayName: string;
@@ -27,6 +28,7 @@ export class Fido2CredentialApi extends BaseResponse {
this.keyValue = this.getResponseProperty("keyValue");
this.rpId = this.getResponseProperty("RpId");
this.userHandle = this.getResponseProperty("UserHandle");
this.userName = this.getResponseProperty("UserName");
this.counter = this.getResponseProperty("Counter");
this.rpName = this.getResponseProperty("RpName");
this.userDisplayName = this.getResponseProperty("UserDisplayName");

View File

@@ -0,0 +1,85 @@
import { Utils } from "../../platform/misc/utils";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
export function buildCipherIcon(
iconsServerUrl: string,
cipher: CipherView,
isFaviconDisabled: boolean
) {
const imageEnabled = !isFaviconDisabled;
let icon;
let image;
let fallbackImage = "";
const cardIcons: Record<string, string> = {
Visa: "card-visa",
Mastercard: "card-mastercard",
Amex: "card-amex",
Discover: "card-discover",
"Diners Club": "card-diners-club",
JCB: "card-jcb",
Maestro: "card-maestro",
UnionPay: "card-union-pay",
RuPay: "card-ru-pay",
};
switch (cipher.type) {
case CipherType.Login:
icon = "bwi-globe";
if (cipher.login.uri) {
let hostnameUri = cipher.login.uri;
let isWebsite = false;
if (hostnameUri.indexOf("androidapp://") === 0) {
icon = "bwi-android";
image = null;
} else if (hostnameUri.indexOf("iosapp://") === 0) {
icon = "bwi-apple";
image = null;
} else if (
imageEnabled &&
hostnameUri.indexOf("://") === -1 &&
hostnameUri.indexOf(".") > -1
) {
hostnameUri = `http://${hostnameUri}`;
isWebsite = true;
} else if (imageEnabled) {
isWebsite = hostnameUri.indexOf("http") === 0 && hostnameUri.indexOf(".") > -1;
}
if (imageEnabled && isWebsite) {
try {
image = `${iconsServerUrl}/${Utils.getHostname(hostnameUri)}/icon.png`;
fallbackImage = "images/bwi-globe.png";
} catch (e) {
// Ignore error since the fallback icon will be shown if image is null.
}
}
} else {
image = null;
}
break;
case CipherType.SecureNote:
icon = "bwi-sticky-note";
break;
case CipherType.Card:
icon = "bwi-credit-card";
if (imageEnabled && cipher.card.brand in cardIcons) {
icon = `credit-card-icon ${cardIcons[cipher.card.brand]}`;
}
break;
case CipherType.Identity:
icon = "bwi-id-card";
break;
default:
break;
}
return {
imageEnabled,
image,
fallbackImage,
icon,
};
}

View File

@@ -8,6 +8,7 @@ export class Fido2CredentialData {
keyValue: string;
rpId: string;
userHandle: string;
userName: string;
counter: string;
rpName: string;
userDisplayName: string;
@@ -26,6 +27,7 @@ export class Fido2CredentialData {
this.keyValue = data.keyValue;
this.rpId = data.rpId;
this.userHandle = data.userHandle;
this.userName = data.userName;
this.counter = data.counter;
this.rpName = data.rpName;
this.userDisplayName = data.userDisplayName;

View File

@@ -25,6 +25,7 @@ describe("Fido2Credential", () => {
keyValue: null,
rpId: null,
userHandle: null,
userName: null,
rpName: null,
userDisplayName: null,
counter: null,
@@ -42,6 +43,7 @@ describe("Fido2Credential", () => {
keyValue: "keyValue",
rpId: "rpId",
userHandle: "userHandle",
userName: "userName",
counter: "counter",
rpName: "rpName",
userDisplayName: "userDisplayName",
@@ -58,6 +60,7 @@ describe("Fido2Credential", () => {
keyValue: { encryptedString: "keyValue", encryptionType: 0 },
rpId: { encryptedString: "rpId", encryptionType: 0 },
userHandle: { encryptedString: "userHandle", encryptionType: 0 },
userName: { encryptedString: "userName", encryptionType: 0 },
counter: { encryptedString: "counter", encryptionType: 0 },
rpName: { encryptedString: "rpName", encryptionType: 0 },
userDisplayName: { encryptedString: "userDisplayName", encryptionType: 0 },
@@ -85,6 +88,7 @@ describe("Fido2Credential", () => {
credential.keyValue = mockEnc("keyValue");
credential.rpId = mockEnc("rpId");
credential.userHandle = mockEnc("userHandle");
credential.userName = mockEnc("userName");
credential.counter = mockEnc("2");
credential.rpName = mockEnc("rpName");
credential.userDisplayName = mockEnc("userDisplayName");
@@ -101,6 +105,7 @@ describe("Fido2Credential", () => {
keyValue: "keyValue",
rpId: "rpId",
userHandle: "userHandle",
userName: "userName",
rpName: "rpName",
userDisplayName: "userDisplayName",
counter: 2,
@@ -120,6 +125,7 @@ describe("Fido2Credential", () => {
keyValue: "keyValue",
rpId: "rpId",
userHandle: "userHandle",
userName: "userName",
counter: "2",
rpName: "rpName",
userDisplayName: "userDisplayName",
@@ -144,6 +150,7 @@ describe("Fido2Credential", () => {
credential.keyValue = createEncryptedEncString("keyValue");
credential.rpId = createEncryptedEncString("rpId");
credential.userHandle = createEncryptedEncString("userHandle");
credential.userName = createEncryptedEncString("userName");
credential.counter = createEncryptedEncString("2");
credential.rpName = createEncryptedEncString("rpName");
credential.userDisplayName = createEncryptedEncString("userDisplayName");

View File

@@ -14,6 +14,7 @@ export class Fido2Credential extends Domain {
keyValue: EncString;
rpId: EncString;
userHandle: EncString;
userName: EncString;
counter: EncString;
rpName: EncString;
userDisplayName: EncString;
@@ -37,6 +38,7 @@ export class Fido2Credential extends Domain {
keyValue: null,
rpId: null,
userHandle: null,
userName: null,
counter: null,
rpName: null,
userDisplayName: null,
@@ -58,6 +60,7 @@ export class Fido2Credential extends Domain {
keyValue: null,
rpId: null,
userHandle: null,
userName: null,
rpName: null,
userDisplayName: null,
discoverable: null,
@@ -102,6 +105,7 @@ export class Fido2Credential extends Domain {
keyValue: null,
rpId: null,
userHandle: null,
userName: null,
counter: null,
rpName: null,
userDisplayName: null,
@@ -122,6 +126,7 @@ export class Fido2Credential extends Domain {
const keyValue = EncString.fromJSON(obj.keyValue);
const rpId = EncString.fromJSON(obj.rpId);
const userHandle = EncString.fromJSON(obj.userHandle);
const userName = EncString.fromJSON(obj.userName);
const counter = EncString.fromJSON(obj.counter);
const rpName = EncString.fromJSON(obj.rpName);
const userDisplayName = EncString.fromJSON(obj.userDisplayName);
@@ -136,6 +141,7 @@ export class Fido2Credential extends Domain {
keyValue,
rpId,
userHandle,
userName,
counter,
rpName,
userDisplayName,

View File

@@ -135,6 +135,7 @@ describe("Login DTO", () => {
keyValue: "keyValue" as EncryptedString,
rpId: "rpId" as EncryptedString,
userHandle: "userHandle" as EncryptedString,
userName: "userName" as EncryptedString,
counter: "counter" as EncryptedString,
rpName: "rpName" as EncryptedString,
userDisplayName: "userDisplayName" as EncryptedString,
@@ -159,6 +160,7 @@ describe("Login DTO", () => {
keyValue: "keyValue_fromJSON",
rpId: "rpId_fromJSON",
userHandle: "userHandle_fromJSON",
userName: "userName_fromJSON",
counter: "counter_fromJSON",
rpName: "rpName_fromJSON",
userDisplayName: "userDisplayName_fromJSON",
@@ -185,6 +187,7 @@ function initializeFido2Credential<T extends Fido2CredentialLike>(key: T): T {
key.keyValue = "keyValue";
key.rpId = "rpId";
key.userHandle = "userHandle";
key.userName = "userName";
key.counter = "counter";
key.rpName = "rpName";
key.userDisplayName = "userDisplayName";
@@ -202,6 +205,7 @@ function encryptFido2Credential(key: Fido2CredentialLike): Fido2Credential {
encrypted.keyValue = { encryptedString: key.keyValue, encryptionType: 0 } as EncString;
encrypted.rpId = { encryptedString: key.rpId, encryptionType: 0 } as EncString;
encrypted.userHandle = { encryptedString: key.userHandle, encryptionType: 0 } as EncString;
encrypted.userName = { encryptedString: key.userName, encryptionType: 0 } as EncString;
encrypted.counter = { encryptedString: key.counter, encryptionType: 0 } as EncString;
encrypted.rpName = { encryptedString: key.rpName, encryptionType: 0 } as EncString;
encrypted.userDisplayName = {

View File

@@ -81,6 +81,7 @@ export class CipherRequest {
keyApi.rpName = key.rpName != null ? key.rpName.encryptedString : null;
keyApi.counter = key.counter != null ? key.counter.encryptedString : null;
keyApi.userHandle = key.userHandle != null ? key.userHandle.encryptedString : null;
keyApi.userName = key.userName != null ? key.userName.encryptedString : null;
keyApi.userDisplayName =
key.userDisplayName != null ? key.userDisplayName.encryptedString : null;
keyApi.discoverable =

View File

@@ -84,7 +84,7 @@ export class CipherView implements View, InitializerMetadata {
}
get subTitle(): string {
return this.item.subTitle;
return this.item?.subTitle;
}
get hasPasswordHistory(): boolean {
@@ -124,7 +124,7 @@ export class CipherView implements View, InitializerMetadata {
}
get linkedFieldOptions() {
return this.item.linkedFieldOptions;
return this.item?.linkedFieldOptions;
}
linkedFieldValue(id: LinkedIdType) {

View File

@@ -10,6 +10,7 @@ export class Fido2CredentialView extends ItemView {
keyValue: string;
rpId: string;
userHandle: string;
userName: string;
counter: number;
rpName: string;
userDisplayName: string;

View File

@@ -1158,6 +1158,7 @@ export class CipherService implements CipherServiceAbstraction {
rpId: null,
rpName: null,
userHandle: null,
userName: null,
userDisplayName: null,
origin: null,
},

View File

@@ -247,6 +247,7 @@ describe("FidoAuthenticatorService", () => {
rpId: params.rpEntity.id,
rpName: params.rpEntity.name,
userHandle: Fido2Utils.bufferToString(params.userEntity.id),
userName: params.userEntity.name,
counter: 0,
userDisplayName: params.userEntity.displayName,
discoverable: false,
@@ -796,6 +797,7 @@ function createCipherView(
fido2CredentialView.counter = fido2Credential.counter ?? 0;
fido2CredentialView.userHandle =
fido2Credential.userHandle ?? Fido2Utils.bufferToString(randomBytes(16));
fido2CredentialView.userName = fido2Credential.userName;
fido2CredentialView.keyAlgorithm = fido2Credential.keyAlgorithm ?? "ECDSA";
fido2CredentialView.keyCurve = fido2Credential.keyCurve ?? "P-256";
fido2CredentialView.discoverable = fido2Credential.discoverable ?? true;

View File

@@ -401,6 +401,7 @@ async function createKeyView(
fido2Credential.keyValue = Fido2Utils.bufferToString(pkcs8Key);
fido2Credential.rpId = params.rpEntity.id;
fido2Credential.userHandle = Fido2Utils.bufferToString(params.userEntity.id);
fido2Credential.userName = params.userEntity.name;
fido2Credential.counter = 0;
fido2Credential.rpName = params.rpEntity.name;
fido2Credential.userDisplayName = params.userEntity.displayName;

View File

@@ -40,6 +40,7 @@ describe("FidoAuthenticatorService", () => {
client = new Fido2ClientService(authenticator, configService, authService, stateService);
configService.getFeatureFlag.mockResolvedValue(true);
stateService.getEnablePasskeys.mockResolvedValue(true);
tab = { id: 123, windowId: 456 } as chrome.tabs.Tab;
});
@@ -58,7 +59,7 @@ describe("FidoAuthenticatorService", () => {
// Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError.
it("should throw error if user.id is too small", async () => {
const params = createParams({ user: { id: "", displayName: "name" } });
const params = createParams({ user: { id: "", displayName: "displayName", name: "name" } });
const result = async () => await client.createCredential(params, tab);
@@ -70,7 +71,8 @@ describe("FidoAuthenticatorService", () => {
const params = createParams({
user: {
id: "YWJzb2x1dGVseS13YXktd2F5LXRvby1sYXJnZS1iYXNlNjQtZW5jb2RlZC11c2VyLWlkLWJpbmFyeS1zZXF1ZW5jZQ",
displayName: "name",
displayName: "displayName",
name: "name",
},
});
@@ -228,6 +230,16 @@ describe("FidoAuthenticatorService", () => {
await rejects.toThrow(FallbackRequestedError);
});
it("should throw FallbackRequestedError if passkeys state is not enabled", async () => {
const params = createParams();
stateService.getEnablePasskeys.mockResolvedValue(false);
const result = async () => await client.createCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toThrow(FallbackRequestedError);
});
it("should throw FallbackRequestedError if user is logged out", async () => {
const params = createParams();
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut);
@@ -261,6 +273,7 @@ describe("FidoAuthenticatorService", () => {
user: params.user ?? {
id: "YmFzZTY0LWVuY29kZWQtdXNlci1pZA",
displayName: "User Name",
name: "name",
},
fallbackSupported: params.fallbackSupported ?? false,
timeout: params.timeout,
@@ -387,6 +400,16 @@ describe("FidoAuthenticatorService", () => {
await rejects.toThrow(FallbackRequestedError);
});
it("should throw FallbackRequestedError if passkeys state is not enabled", async () => {
const params = createParams();
stateService.getEnablePasskeys.mockResolvedValue(false);
const result = async () => await client.assertCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toThrow(FallbackRequestedError);
});
it("should throw FallbackRequestedError if user is logged out", async () => {
const params = createParams();
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut);

View File

@@ -46,7 +46,11 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
) {}
async isFido2FeatureEnabled(): Promise<boolean> {
return await this.configService.getFeatureFlag<boolean>(FeatureFlag.Fido2VaultCredentials);
const featureFlagEnabled = await this.configService.getFeatureFlag<boolean>(
FeatureFlag.Fido2VaultCredentials
);
const userEnabledPasskeys = await this.stateService.getEnablePasskeys();
return featureFlagEnabled && userEnabledPasskeys;
}
async createCredential(
@@ -196,7 +200,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
authData: Fido2Utils.bufferToString(makeCredentialResult.authData),
clientDataJSON: Fido2Utils.bufferToString(clientDataJSONBytes),
publicKeyAlgorithm: makeCredentialResult.publicKeyAlgorithm,
transports: ["internal"],
transports: params.rp.id === "google.com" ? ["internal", "usb"] : ["internal"],
};
}
@@ -395,6 +399,7 @@ function mapToMakeCredentialParams({
userEntity: {
id: Fido2Utils.stringToBuffer(params.user.id),
displayName: params.user.displayName,
name: params.user.name,
},
fallbackSupported: params.fallbackSupported,
};