1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00:00

feat(device-approval-persistence): [PM-19380] Device Approval Persistence (#13958)

* feat(device-approval-persistence): [PM-19380] Device Approval Persistence - Added lookup on standard auth requests.

* fix(device-approval-persistence): [PM-19380] Device Approval Persistence - Fixed issue with null value trying to be parsed from the fromJSON function.




---------

Co-authored-by: Todd Martin <tmartin@bitwarden.com>
This commit is contained in:
Patrick-Pimentel-Bitwarden
2025-04-04 15:44:48 -04:00
committed by GitHub
parent b385dd430e
commit 1af8fe2012
8 changed files with 605 additions and 357 deletions

View File

@@ -0,0 +1,203 @@
# Authentication Flows Documentation
## Standard Auth Request Flows
### Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory
1. Unauthed user clicks "Login with device"
2. Navigates to /login-with-device which creates a StandardAuthRequest
3. Receives approval from a device with authRequestPublicKey(masterKey)
4. Decrypts masterKey
5. Decrypts userKey
6. Proceeds to vault
### Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory
1. Unauthed user clicks "Login with device"
2. Navigates to /login-with-device which creates a StandardAuthRequest
3. Receives approval from a device with authRequestPublicKey(userKey)
4. Decrypts userKey
5. Proceeds to vault
**Note:** This flow is an uncommon scenario and relates to TDE off-boarding. The following describes how a user could
get into this flow:
1. An SSO TD user logs into a device via an Admin auth request approval, therefore this device does NOT have a masterKey
in memory
2. The org admin:
- Changes the member decryption options from "Trusted devices" to "Master password" AND
- Turns off the "Require single sign-on authentication" policy
3. On another device, the user clicks "Login with device", which they can do because the org no longer requires SSO
4. The user approves from the device they had previously logged into with SSO TD, which does NOT have a masterKey in
memory
### Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory
1. SSO TD user authenticates via SSO
2. Navigates to /login-initiated
3. Clicks "Approve from your other device"
4. Navigates to /login-with-device which creates a StandardAuthRequest
5. Receives approval from device with authRequestPublicKey(masterKey)
6. Decrypts masterKey
7. Decrypts userKey
8. Establishes trust (if required)
9. Proceeds to vault
### Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory
1. SSO TD user authenticates via SSO
2. Navigates to /login-initiated
3. Clicks "Approve from your other device"
4. Navigates to /login-with-device which creates a StandardAuthRequest
5. Receives approval from device with authRequestPublicKey(userKey)
6. Decrypts userKey
7. Establishes trust (if required)
8. Proceeds to vault
## Admin Auth Request Flow
### Flow: Authed SSO TD user requests admin approval
1. SSO TD user authenticates via SSO
2. Navigates to /login-initiated
3. Clicks "Request admin approval"
4. Navigates to /admin-approval-requested which creates an AdminAuthRequest
5. Receives approval from device with authRequestPublicKey(userKey)
6. Decrypts userKey
7. Establishes trust (if required)
8. Proceeds to vault
**Note:** TDE users are required to be enrolled in admin account recovery, which gives the admin access to the user's
userKey. This is how admins are able to send over the authRequestPublicKey(userKey) to the user to allow them to unlock.
## Summary Table
| Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory\* |
| --------------- | ----------- | --------------------------------------------------- | ------------------------- | ------------------------------------------------- |
| Standard Flow 1 | unauthed | "Login with device" [/login] | /login-with-device | yes |
| Standard Flow 2 | unauthed | "Login with device" [/login] | /login-with-device | no |
| Standard Flow 3 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | yes |
| Standard Flow 4 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | no |
| Admin Flow | authed | "Request admin approval" [/login-initiated] | /admin-approval-requested | NA - admin requests always send encrypted userKey |
**Note:** The phrase "in memory" here is important. It is possible for a user to have a master password for their
account, but not have a masterKey IN MEMORY for a specific device. For example, if a user registers an account with a
master password, then joins an SSO TD org, then logs in to a device via SSO and admin auth request, they are now logged
into that device but that device does not have masterKey IN MEMORY.
## State Management
### View Cache
The component uses `LoginViaAuthRequestCacheService` to manage persistent state across extension close and reopen.
This cache stores:
- Auth Request ID
- Private Key
- Access Code
The cache is used to:
1. Preserve authentication state during extension close
2. Allow resumption of pending auth requests
3. Enable processing of approved requests after extension close and reopen.
### Component State Variables
Key state variables maintained during the authentication process:
#### Authentication Keys
```
private authRequestKeyPair: {
publicKey: Uint8Array | undefined;
privateKey: Uint8Array | undefined;
} | undefined
```
- Stores the RSA key pair used for secure communication
- Generated during auth request initialization
- Required for decrypting approved auth responses
#### Access Code
```
private accessCode: string | undefined
```
- 25-character generated password
- Used for retrieving auth responses when user is not authenticated
- Required for standard auth flows
#### Authentication Status
```
private authStatus: AuthenticationStatus | undefined
```
- Tracks whether user is authenticated via SSO
- Determines available flows and API endpoints
- Affects navigation paths (`/login` vs `/login-initiated`)
#### Flow Control
```
protected flow = Flow.StandardAuthRequest
```
- Determines current authentication flow (Standard vs Admin)
- Affects UI rendering and request handling
- Set based on route and authentication state
### State Flow Examples
#### Standard Auth Request Cache Flow
1. User initiates login with device
2. Component generates auth request and keys
3. Cache service stores:
```
cacheLoginView(
authRequestResponse.id,
authRequestKeyPair.privateKey,
accessCode
)
```
4. On page refresh/revisit:
- Component retrieves cached view
- Reestablishes connection using cached credentials
- Continues monitoring for approval
#### Admin Auth Request State Flow
1. User requests admin approval
2. Component stores admin request in `AuthRequestService`:
```
setAdminAuthRequest(
new AdminAuthRequestStorable({
id: authRequestResponse.id,
privateKey: authRequestKeyPair.privateKey
}),
userId
)
```
3. On subsequent visits:
- Component checks for existing admin requests
- Either resumes monitoring or starts new request
- Clears state after successful approval
### State Cleanup
State cleanup occurs in several scenarios:
- Component destruction (`ngOnDestroy`)
- Successful authentication
- Request denial or timeout
- Manual navigation away
Key cleanup actions:
1. Hub connection termination
2. Cache clearance
3. Admin request state removal
4. Key pair disposal

View File

@@ -1,57 +1,65 @@
<div class="tw-text-center">
<ng-container *ngIf="flow === Flow.StandardAuthRequest">
<p *ngIf="clientType !== ClientType.Web">
{{ "notificationSentDevicePart1" | i18n }}
<a
bitLink
linkType="primary"
class="tw-cursor-pointer"
[href]="deviceManagementUrl"
target="_blank"
rel="noreferrer"
>{{ "notificationSentDeviceAnchor" | i18n }}</a
>. {{ "notificationSentDevicePart2" | i18n }}
</p>
<p *ngIf="clientType === ClientType.Web">
{{ "notificationSentDeviceComplete" | i18n }}
</p>
<ng-container *ngIf="loading">
<div class="tw-flex tw-items-center tw-justify-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
</div>
</ng-container>
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
<ng-container *ngIf="!loading">
<div class="tw-text-center">
<ng-container *ngIf="flow === Flow.StandardAuthRequest">
<p *ngIf="clientType !== ClientType.Web">
{{ "notificationSentDevicePart1" | i18n }}
<a
bitLink
linkType="primary"
class="tw-cursor-pointer"
[href]="deviceManagementUrl"
target="_blank"
rel="noreferrer"
>{{ "notificationSentDeviceAnchor" | i18n }}</a
>. {{ "notificationSentDevicePart2" | i18n }}
</p>
<p *ngIf="clientType === ClientType.Web">
{{ "notificationSentDeviceComplete" | i18n }}
</p>
<button
*ngIf="showResendNotification"
type="button"
bitButton
block
buttonType="secondary"
class="tw-mt-4"
(click)="startStandardAuthRequestLogin(true)"
>
{{ "resendNotification" | i18n }}
</button>
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
<div *ngIf="clientType !== ClientType.Browser" class="tw-mt-4">
<span>{{ "needAnotherOptionV1" | i18n }}</span
>&nbsp;
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
"viewAllLogInOptions" | i18n
}}</a>
</div>
</ng-container>
<button
*ngIf="showResendNotification"
type="button"
bitButton
block
buttonType="secondary"
class="tw-mt-4"
(click)="handleNewStandardAuthRequestLogin()"
>
{{ "resendNotification" | i18n }}
</button>
<ng-container *ngIf="flow === Flow.AdminAuthRequest">
<p>{{ "youWillBeNotifiedOnceTheRequestIsApproved" | i18n }}</p>
<div *ngIf="clientType !== ClientType.Browser" class="tw-mt-4">
<span>{{ "needAnotherOptionV1" | i18n }}</span
>&nbsp;
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
"viewAllLogInOptions" | i18n
}}</a>
</div>
</ng-container>
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
<ng-container *ngIf="flow === Flow.AdminAuthRequest">
<p>{{ "youWillBeNotifiedOnceTheRequestIsApproved" | i18n }}</p>
<div class="tw-mt-4">
<span>{{ "troubleLoggingIn" | i18n }}</span
>&nbsp;
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
"viewAllLogInOptions" | i18n
}}</a>
</div>
</ng-container>
</div>
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
<div class="tw-mt-4">
<span>{{ "troubleLoggingIn" | i18n }}</span
>&nbsp;
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
"viewAllLogInOptions" | i18n
}}</a>
</div>
</ng-container>
</div>
</ng-container>

View File

@@ -62,12 +62,13 @@ const matchOptions: IsActiveMatchOptions = {
providers: [{ provide: LoginViaAuthRequestCacheService }],
})
export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
private authRequest: AuthRequest | undefined = undefined;
private authRequestKeyPair:
| { publicKey: Uint8Array | undefined; privateKey: Uint8Array | undefined }
| undefined = undefined;
private accessCode: string | undefined = undefined;
private authStatus: AuthenticationStatus | undefined = undefined;
private showResendNotificationTimeoutSeconds = 12;
protected loading = true;
protected backToRoute = "/login";
protected clientType: ClientType;
@@ -110,13 +111,14 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
this.authRequestService.authRequestPushNotification$
.pipe(takeUntilDestroyed())
.subscribe((requestId) => {
this.verifyAndHandleApprovedAuthReq(requestId).catch((e: Error) => {
this.loading = true;
this.handleExistingAuthRequestLogin(requestId).catch((e: Error) => {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("error"),
message: e.message,
});
this.loading = false;
this.logService.error("Failed to use approved auth request: " + e.message);
});
});
@@ -149,24 +151,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
} else {
await this.initStandardAuthRequestFlow();
}
this.loading = false;
}
private async initAdminAuthRequestFlow(): Promise<void> {
this.flow = Flow.AdminAuthRequest;
// Get email from state for admin auth requests because it is available and also
// prevents it from being lost on refresh as the loginEmailService email does not persist.
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
if (!this.email) {
await this.handleMissingEmail();
return;
}
// We only allow a single admin approval request to be active at a time
// so we must check state to see if we have an existing one or not
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (!userId) {
this.logService.error(
@@ -175,12 +165,13 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
const existingAdminAuthRequest = await this.authRequestService.getAdminAuthRequest(userId);
// [Admin Request Flow State Management] Check cached auth request
const existingAdminAuthRequest = await this.reloadCachedAdminAuthRequest(userId);
if (existingAdminAuthRequest) {
await this.handleExistingAdminAuthRequest(existingAdminAuthRequest, userId);
await this.handleExistingAdminAuthRequestLogin(existingAdminAuthRequest, userId);
} else {
await this.startAdminAuthRequestLogin();
await this.handleNewAdminAuthRequestLogin();
}
}
@@ -194,7 +185,24 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
await this.startStandardAuthRequestLogin();
// [Standard Flow State Management] Check cached auth request
const cachedAuthRequest: LoginViaAuthRequestView | null =
this.loginViaAuthRequestCacheService.getCachedLoginViaAuthRequestView();
if (cachedAuthRequest) {
this.logService.info("Found cached auth request.");
if (!cachedAuthRequest.id) {
this.logService.error(
"No id on the cached auth request when in the standard auth request flow.",
);
return;
}
await this.reloadCachedStandardAuthRequest(cachedAuthRequest);
await this.handleExistingAuthRequestLogin(cachedAuthRequest.id);
} else {
await this.handleNewStandardAuthRequestLogin();
}
}
private async handleMissingEmail(): Promise<void> {
@@ -212,11 +220,17 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
this.loginViaAuthRequestCacheService.clearCacheLoginView();
}
private async startAdminAuthRequestLogin(): Promise<void> {
private async handleNewAdminAuthRequestLogin(): Promise<void> {
try {
await this.buildAuthRequest(AuthRequestType.AdminApproval);
if (!this.email) {
this.logService.error("No email when starting admin auth request login.");
return;
}
if (!this.authRequest) {
// At this point we know there is no
const authRequest = await this.buildAuthRequest(this.email, AuthRequestType.AdminApproval);
if (!authRequest) {
this.logService.error("Auth request failed to build.");
return;
}
@@ -226,9 +240,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
const authRequestResponse = await this.authRequestApiService.postAdminAuthRequest(
this.authRequest as AuthRequest,
);
const authRequestResponse =
await this.authRequestApiService.postAdminAuthRequest(authRequest);
const adminAuthReqStorable = new AdminAuthRequestStorable({
id: authRequestResponse.id,
privateKey: this.authRequestKeyPair.privateKey,
@@ -253,104 +267,154 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
}
}
protected async startStandardAuthRequestLogin(
clearCachedRequest: boolean = false,
/**
* We only allow a single admin approval request to be active at a time
* so we can check to see if it's stored in state with the state service
* provider.
* @param userId
* @protected
*/
protected async reloadCachedAdminAuthRequest(
userId: UserId,
): Promise<AdminAuthRequestStorable | null> {
// Get email from state for admin auth requests because it is available and also
// prevents it from being lost on refresh as the loginEmailService email does not persist.
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
if (!this.email) {
await this.handleMissingEmail();
return null;
}
return await this.authRequestService.getAdminAuthRequest(userId);
}
/**
* Restores a cached authentication request into the component's state.
*
* This function checks for the presence of a cached authentication request and,
* if available, updates the component's state with the necessary details to
* continue processing the request. It ensures that the user's email and the
* private key from the cached request are available.
*
* The private key is converted from Base64 to an ArrayBuffer, and a fingerprint
* phrase is derived to verify the request's integrity. The function then sets
* the authentication request key pair in the component's state, preparing it
* to handle any responses or approvals.
*
* @param cachedAuthRequest The request to load into the component state
* @returns Promise to await for completion
*/
protected async reloadCachedStandardAuthRequest(
cachedAuthRequest: LoginViaAuthRequestView,
): Promise<void> {
if (cachedAuthRequest) {
if (!this.email) {
this.logService.error(
"Email not defined when trying to reload cached standard auth request.",
);
return;
}
if (!cachedAuthRequest.privateKey) {
this.logService.error(
"No private key on the cached auth request when trying to reload cached standard auth request.",
);
return;
}
if (!cachedAuthRequest.accessCode) {
this.logService.error(
"No access code on the cached auth request when trying to reload cached standard auth request.",
);
return;
}
const privateKey = Utils.fromB64ToArray(cachedAuthRequest.privateKey);
// Re-derive the user's fingerprint phrase
// It is important to not use the server's public key here as it could have been compromised via MITM
const derivedPublicKeyArrayBuffer =
await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
derivedPublicKeyArrayBuffer,
);
// We don't need the public key for handling the authentication request because
// the handleExistingAuthRequestLogin function will receive the public key back
// from the looked up auth request, and all we need is to make sure that
// we can use the cached private key that is associated with it.
this.authRequestKeyPair = {
privateKey: privateKey,
publicKey: undefined,
};
this.accessCode = cachedAuthRequest.accessCode;
}
}
protected async handleNewStandardAuthRequestLogin(): Promise<void> {
this.showResendNotification = false;
if (await this.configService.getFeatureFlag(FeatureFlag.PM9112_DeviceApprovalPersistence)) {
// Used for manually refreshing the auth request when clicking the resend auth request
// on the ui.
if (clearCachedRequest) {
this.loginViaAuthRequestCacheService.clearCacheLoginView();
try {
if (!this.email) {
this.logService.error("Email not defined when starting standard auth request login.");
return;
}
try {
const loginAuthRequestView: LoginViaAuthRequestView | null =
this.loginViaAuthRequestCacheService.getCachedLoginViaAuthRequestView();
const authRequest = await this.buildAuthRequest(
this.email,
AuthRequestType.AuthenticateAndUnlock,
);
if (!loginAuthRequestView) {
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
// I tried several ways to get the IDE/linter to play nice with checking for null values
// in less code / more efficiently, but it struggles to identify code paths that
// are more complicated than this.
if (!this.authRequest) {
this.logService.error("AuthRequest failed to initialize from buildAuthRequest.");
return;
}
if (!this.fingerprintPhrase) {
this.logService.error("FingerprintPhrase failed to initialize from buildAuthRequest.");
return;
}
if (!this.authRequestKeyPair) {
this.logService.error("KeyPair failed to initialize from buildAuthRequest.");
return;
}
const authRequestResponse: AuthRequestResponse =
await this.authRequestApiService.postAuthRequest(this.authRequest);
this.loginViaAuthRequestCacheService.cacheLoginView(
this.authRequest,
authRequestResponse,
this.fingerprintPhrase,
this.authRequestKeyPair,
);
if (authRequestResponse.id) {
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
}
} else {
// Grab the cached information and store it back in component state.
// We don't need the public key for handling the authentication request because
// the verifyAndHandleApprovedAuthReq function will receive the public key back
// from the looked up auth request and all we need is to make sure that
// we can use the cached private key that is associated with it.
this.authRequest = loginAuthRequestView.authRequest;
this.fingerprintPhrase = loginAuthRequestView.fingerprintPhrase;
this.authRequestKeyPair = {
privateKey: loginAuthRequestView.privateKey
? Utils.fromB64ToArray(loginAuthRequestView.privateKey)
: undefined,
publicKey: undefined,
};
if (!loginAuthRequestView.authRequestResponse) {
this.logService.error("No cached auth request response.");
return;
}
if (loginAuthRequestView.authRequestResponse.id) {
await this.anonymousHubService.createHubConnection(
loginAuthRequestView.authRequestResponse.id,
);
}
}
} catch (e) {
this.logService.error(e);
// I tried several ways to get the IDE/linter to play nice with checking for null values
// in less code / more efficiently, but it struggles to identify code paths that
// are more complicated than this.
if (!authRequest) {
this.logService.error("AuthRequest failed to initialize from buildAuthRequest.");
return;
}
} else {
try {
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
if (!this.authRequest) {
this.logService.error("No auth request found.");
if (!this.fingerprintPhrase) {
this.logService.error("FingerprintPhrase failed to initialize from buildAuthRequest.");
return;
}
if (!this.authRequestKeyPair) {
this.logService.error("KeyPair failed to initialize from buildAuthRequest.");
return;
}
const authRequestResponse: AuthRequestResponse =
await this.authRequestApiService.postAuthRequest(authRequest);
if (await this.configService.getFeatureFlag(FeatureFlag.PM9112_DeviceApprovalPersistence)) {
if (!this.authRequestKeyPair.privateKey) {
this.logService.error("No private key when trying to cache the login view.");
return;
}
const authRequestResponse = await this.authRequestApiService.postAuthRequest(
this.authRequest,
);
if (authRequestResponse.id) {
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
if (!this.accessCode) {
this.logService.error("No access code when trying to cache the login view.");
return;
}
} catch (e) {
this.logService.error(e);
this.loginViaAuthRequestCacheService.cacheLoginView(
authRequestResponse.id,
this.authRequestKeyPair.privateKey,
this.accessCode,
);
}
if (authRequestResponse.id) {
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
}
} catch (e) {
this.logService.error(e);
}
setTimeout(() => {
@@ -358,7 +422,10 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
}, this.showResendNotificationTimeoutSeconds * 1000);
}
private async buildAuthRequest(authRequestType: AuthRequestType): Promise<void> {
private async buildAuthRequest(
email: string,
authRequestType: AuthRequestType,
): Promise<AuthRequest> {
const authRequestKeyPairArray = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
this.authRequestKeyPair = {
@@ -369,36 +436,27 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
const deviceIdentifier = await this.appIdService.getAppId();
if (!this.authRequestKeyPair.publicKey) {
this.logService.error("AuthRequest public key not set to value in building auth request.");
return;
const errorMessage = "No public key when building an auth request.";
this.logService.error(errorMessage);
throw new Error(errorMessage);
}
const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
const accessCode = await this.passwordGenerationService.generatePassword({
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
email,
this.authRequestKeyPair.publicKey,
);
this.accessCode = await this.passwordGenerationService.generatePassword({
type: "password",
length: 25,
});
if (!this.email) {
this.logService.error("Email not defined when building auth request.");
return;
}
const b64PublicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
this.authRequestKeyPair.publicKey,
);
this.authRequest = new AuthRequest(
this.email,
deviceIdentifier,
publicKey,
authRequestType,
accessCode,
);
return new AuthRequest(email, deviceIdentifier, b64PublicKey, authRequestType, this.accessCode);
}
private async handleExistingAdminAuthRequest(
private async handleExistingAdminAuthRequestLogin(
adminAuthRequestStorable: AdminAuthRequestStorable,
userId: UserId,
): Promise<void> {
@@ -414,7 +472,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
);
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
return await this.clearExistingAdminAuthRequestAndStartNewRequest(userId);
}
this.logService.error(error);
return;
@@ -422,28 +480,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
// Request doesn't exist anymore
if (!adminAuthRequestResponse) {
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
return await this.clearExistingAdminAuthRequestAndStartNewRequest(userId);
}
// Re-derive the user's fingerprint phrase
// It is important to not use the server's public key here as it could have been compromised via MITM
const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
adminAuthRequestStorable.privateKey,
);
if (!this.email) {
this.logService.error("Email not defined when handling an existing an admin auth request.");
return;
}
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
derivedPublicKeyArrayBuffer,
);
// Request denied
if (adminAuthRequestResponse.isAnswered && !adminAuthRequestResponse.requestApproved) {
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
return await this.clearExistingAdminAuthRequestAndStartNewRequest(userId);
}
// Request approved
@@ -455,6 +497,22 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
);
}
if (!this.email) {
this.logService.error("Email not defined when handling an existing an admin auth request.");
return;
}
// Re-derive the user's fingerprint phrase
// It is important to not use the server's public key here as it could have been compromised via MITM
const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
adminAuthRequestStorable.privateKey,
);
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
derivedPublicKeyArrayBuffer,
);
// Request still pending response from admin set keypair and create hub connection
// so that any approvals will be received via push notification
this.authRequestKeyPair = {
@@ -464,117 +522,99 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
await this.anonymousHubService.createHubConnection(adminAuthRequestStorable.id);
}
private async verifyAndHandleApprovedAuthReq(requestId: string): Promise<void> {
/**
* ***********************************
* Standard Auth Request Flows
* ***********************************
*
* Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory.
*
* Unauthed user clicks "Login with device" > navigates to /login-with-device which creates a StandardAuthRequest
* > receives approval from a device with authRequestPublicKey(masterKey) > decrypts masterKey > decrypts userKey > proceed to vault
*
* Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory.
*
* Unauthed user clicks "Login with device" > navigates to /login-with-device which creates a StandardAuthRequest
* > receives approval from a device with authRequestPublicKey(userKey) > decrypts userKey > proceeds to vault
*
* Note: this flow is an uncommon scenario and relates to TDE off-boarding. The following describes how a user could get into this flow:
* 1) An SSO TD user logs into a device via an Admin auth request approval, therefore this device does NOT have a masterKey in memory.
* 2) The org admin...
* (2a) Changes the member decryption options from "Trusted devices" to "Master password" AND
* (2b) Turns off the "Require single sign-on authentication" policy
* 3) On another device, the user clicks "Login with device", which they can do because the org no longer requires SSO.
* 4) The user approves from the device they had previously logged into with SSO TD, which does NOT have a masterKey in memory (see step 1 above).
*
* Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory.
*
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Approve from your other device"
* > navigates to /login-with-device which creates a StandardAuthRequest > receives approval from device with authRequestPublicKey(masterKey)
* > decrypts masterKey > decrypts userKey > establishes trust (if required) > proceeds to vault
*
* Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory.
*
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Approve from your other device"
* > navigates to /login-with-device which creates a StandardAuthRequest > receives approval from device with authRequestPublicKey(userKey)
* > decrypts userKey > establishes trust (if required) > proceeds to vault
*
* ***********************************
* Admin Auth Request Flow
* ***********************************
*
* Flow: Authed SSO TD user requests admin approval.
*
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Request admin approval"
* > navigates to /admin-approval-requested which creates an AdminAuthRequest > receives approval from device with authRequestPublicKey(userKey)
* > decrypts userKey > establishes trust (if required) > proceeds to vault
*
* Note: TDE users are required to be enrolled in admin password reset, which gives the admin access to the user's userKey.
* This is how admins are able to send over the authRequestPublicKey(userKey) to the user to allow them to unlock.
*
*
* Summary Table
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
* | Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory (see note 1) |
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
* | Standard Flow 1 | unauthed | "Login with device" [/login] | /login-with-device | yes |
* | Standard Flow 2 | unauthed | "Login with device" [/login] | /login-with-device | no |
* | Standard Flow 3 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | yes |
* | Standard Flow 4 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | no |
* | Admin Flow | authed | "Request admin approval" [/login-initiated] | /admin-approval-requested | NA - admin requests always send encrypted userKey |
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
* * Note 1: The phrase "in memory" here is important. It is possible for a user to have a master password for their account, but not have a masterKey IN MEMORY for
* a specific device. For example, if a user registers an account with a master password, then joins an SSO TD org, then logs in to a device via SSO and
* admin auth request, they are now logged into that device but that device does not have masterKey IN MEMORY.
*/
/**
* This is used for trying to get the auth request back out of state.
* @param requestId
* @private
*/
private async retrieveAuthRequest(requestId: string): Promise<AuthRequestResponse> {
let authRequestResponse: AuthRequestResponse | undefined = undefined;
try {
// There are two cases here, the first being
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
// Get the response based on whether we've authenticated or not. We need to call a different API method
// based on whether we have a token or need to use the accessCode.
if (userHasAuthenticatedViaSSO) {
// Get the auth request from the server
// User is authenticated, therefore the endpoint does not require an access code.
const authRequestResponse = await this.authRequestApiService.getAuthRequest(requestId);
if (authRequestResponse.requestApproved) {
// Handles Standard Flows 3-4 and Admin Flow
await this.handleAuthenticatedFlows(authRequestResponse);
}
authRequestResponse = await this.authRequestApiService.getAuthRequest(requestId);
} else {
if (!this.authRequest) {
this.logService.error("No auth request defined when handling approved auth request.");
return;
if (!this.accessCode) {
const errorMessage = "No access code available when handling approved auth request.";
this.logService.error(errorMessage);
throw new Error(errorMessage);
}
// Get the auth request from the server
// User is unauthenticated, therefore the endpoint requires an access code for user verification.
const authRequestResponse = await this.authRequestApiService.getAuthResponse(
authRequestResponse = await this.authRequestApiService.getAuthResponse(
requestId,
this.authRequest.accessCode,
this.accessCode,
);
}
} catch (error) {
// If the request no longer exists, we treat it as if it's been answered (and denied).
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
authRequestResponse = undefined;
} else {
this.logService.error(error);
}
}
if (authRequestResponse.requestApproved) {
// Handles Standard Flows 1-2
await this.handleUnauthenticatedFlows(authRequestResponse, requestId);
if (authRequestResponse === undefined) {
throw new Error("Auth request response not generated");
}
return authRequestResponse;
}
/**
* Determines if the Auth Request has been approved, deleted or denied, and handles
* the response accordingly.
* @param requestId The ID of the Auth Request to process
* @returns A boolean indicating whether the Auth Request was successfully processed
*/
private async handleExistingAuthRequestLogin(requestId: string): Promise<void> {
this.showResendNotification = false;
try {
const authRequestResponse = await this.retrieveAuthRequest(requestId);
// Request doesn't exist anymore, so we'll clear the cache and start a new request.
if (!authRequestResponse) {
return await this.clearExistingStandardAuthRequestAndStartNewRequest();
}
// Request denied, so we'll clear the cache and start a new request.
if (authRequestResponse.isAnswered && !authRequestResponse.requestApproved) {
return await this.clearExistingStandardAuthRequestAndStartNewRequest();
}
// Request approved, so we'll log the user in.
if (authRequestResponse.requestApproved) {
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
if (userHasAuthenticatedViaSSO) {
// [Standard Flow 3-4] Handle authenticated SSO TD user flows
return await this.handleAuthenticatedFlows(authRequestResponse);
} else {
// [Standard Flow 1-2] Handle unauthenticated user flows
return await this.handleUnauthenticatedFlows(authRequestResponse, requestId);
}
}
// At this point, we know that the request is still pending, so we'll start a hub connection to listen for a response.
await this.anonymousHubService.createHubConnection(requestId);
} catch (error) {
if (error instanceof ErrorResponse) {
await this.router.navigate([this.backToRoute]);
this.validationService.showError(error);
return;
}
this.logService.error(error);
} finally {
// Manually clean out the cache to make sure sensitive
// data does not persist longer than it needs to.
this.loginViaAuthRequestCacheService.clearCacheLoginView();
}
setTimeout(() => {
this.showResendNotification = true;
}, this.showResendNotificationTimeoutSeconds * 1000);
}
private async handleAuthenticatedFlows(authRequestResponse: AuthRequestResponse) {
// [Standard Flow 3-4] Handle authenticated SSO TD user flows
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (!userId) {
this.logService.error(
@@ -599,6 +639,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
authRequestResponse: AuthRequestResponse,
requestId: string,
) {
// [Standard Flow 1-2] Handle unauthenticated user flows
const authRequestLoginCredentials = await this.buildAuthRequestLoginCredentials(
requestId,
authRequestResponse,
@@ -609,6 +650,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
// Clear the cached auth request from state since we're using it to log in.
this.loginViaAuthRequestCacheService.clearCacheLoginView();
// Note: keys are set by AuthRequestLoginStrategy success handling
const authResult = await this.loginStrategyService.logIn(authRequestLoginCredentials);
@@ -621,21 +665,20 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
userId: UserId,
): Promise<void> {
/**
* See verifyAndHandleApprovedAuthReq() for flow details.
*
* [Flow Type Detection]
* We determine the type of `key` based on the presence or absence of `masterPasswordHash`:
* - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)]
* - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
* - If `masterPasswordHash` exists: Standard Flow 1 or 3 (device has masterKey)
* - If no `masterPasswordHash`: Standard Flow 2, 4, or Admin Flow (device sends userKey)
*/
if (authRequestResponse.masterPasswordHash) {
// ...in Standard Auth Request Flow 3
// [Standard Flow 1 or 3] Device has masterKey
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
authRequestResponse,
privateKey,
userId,
);
} else {
// ...in Standard Auth Request Flow 4 or Admin Auth Request Flow
// [Standard Flow 2, 4, or Admin Flow] Device sends userKey
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
authRequestResponse,
privateKey,
@@ -643,15 +686,20 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
);
}
// [Admin Flow Cleanup] Clear one-time use admin auth request
// clear the admin auth request from state so it cannot be used again (it's a one time use)
// TODO: this should eventually be enforced via deleting this on the server once it is used
await this.authRequestService.clearAdminAuthRequest(userId);
// [Standard Flow Cleanup] Clear the cached auth request from state
this.loginViaAuthRequestCacheService.clearCacheLoginView();
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("loginApproved"),
});
// [Device Trust] Establish trust if required
// Now that we have a decrypted user key in memory, we can check if we
// need to establish trust on the current device
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
@@ -686,9 +734,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
if (!this.authRequest) {
if (!this.accessCode) {
this.logService.error(
"AuthRequest not defined when building auth request login credentials.",
"Access code not defined when building auth request login credentials.",
);
return;
}
@@ -711,7 +759,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return new AuthRequestLoginCredentials(
this.email,
this.authRequest.accessCode,
this.accessCode,
requestId,
null, // no userKey
masterKey,
@@ -725,7 +773,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
);
return new AuthRequestLoginCredentials(
this.email,
this.authRequest.accessCode,
this.accessCode,
requestId,
userKey,
null, // no masterKey
@@ -734,12 +782,20 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
}
}
private async handleExistingAdminAuthReqDeletedOrDenied(userId: UserId) {
private async clearExistingAdminAuthRequestAndStartNewRequest(userId: UserId) {
// clear the admin auth request from state
await this.authRequestService.clearAdminAuthRequest(userId);
// start new auth request
await this.startAdminAuthRequestLogin();
await this.handleNewAdminAuthRequestLogin();
}
private async clearExistingStandardAuthRequestAndStartNewRequest(): Promise<void> {
// clear the auth request from state
this.loginViaAuthRequestCacheService.clearCacheLoginView();
// start new auth request
await this.handleNewStandardAuthRequestLogin();
}
private async handlePostLoginNavigation(loginResponse: AuthResult) {

View File

@@ -2,11 +2,9 @@ import { signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { AuthRequestType } from "@bitwarden/common/auth/enums/auth-request-type";
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { LoginViaAuthRequestCacheService } from "./default-login-via-auth-request-cache.service";
@@ -39,12 +37,12 @@ describe("LoginViaAuthRequestCache", () => {
});
it("`getCachedLoginViaAuthRequestView` returns the cached data", async () => {
cacheSignal.set({ ...buildAuthenticMockAuthView() });
cacheSignal.set({ ...buildMockState() });
service = testBed.inject(LoginViaAuthRequestCacheService);
await service.init();
expect(service.getCachedLoginViaAuthRequestView()).toEqual({
...buildAuthenticMockAuthView(),
...buildMockState(),
});
});
@@ -54,20 +52,19 @@ describe("LoginViaAuthRequestCache", () => {
const parameters = buildAuthenticMockAuthView();
service.cacheLoginView(
parameters.authRequest,
parameters.authRequestResponse,
parameters.fingerprintPhrase,
{ publicKey: new Uint8Array(), privateKey: new Uint8Array() },
);
service.cacheLoginView(parameters.id, parameters.privateKey, parameters.accessCode);
expect(cacheSignal.set).toHaveBeenCalledWith(parameters);
expect(cacheSignal.set).toHaveBeenCalledWith({
id: parameters.id,
privateKey: Utils.fromBufferToB64(parameters.privateKey),
accessCode: parameters.accessCode,
});
});
});
describe("feature disabled", () => {
beforeEach(async () => {
cacheSignal.set({ ...buildAuthenticMockAuthView() } as LoginViaAuthRequestView);
cacheSignal.set({ ...buildMockState() } as LoginViaAuthRequestView);
getFeatureFlag.mockResolvedValue(false);
cacheSetMock.mockClear();
@@ -82,12 +79,7 @@ describe("LoginViaAuthRequestCache", () => {
it("does not update the signal value", () => {
const params = buildAuthenticMockAuthView();
service.cacheLoginView(
params.authRequest,
params.authRequestResponse,
params.fingerprintPhrase,
{ publicKey: new Uint8Array(), privateKey: new Uint8Array() },
);
service.cacheLoginView(params.id, params.privateKey, params.accessCode);
expect(cacheSignal.set).not.toHaveBeenCalled();
});
@@ -95,17 +87,17 @@ describe("LoginViaAuthRequestCache", () => {
const buildAuthenticMockAuthView = () => {
return {
fingerprintPhrase: "",
privateKey: "",
publicKey: "",
authRequest: new AuthRequest(
"test@gmail.com",
"deviceIdentifier",
"publicKey",
AuthRequestType.Unlock,
"accessCode",
),
authRequestResponse: new AuthRequestResponse({}),
id: "testId",
privateKey: new Uint8Array(),
accessCode: "testAccessCode",
};
};
const buildMockState = () => {
return {
id: "testId",
privateKey: Utils.fromBufferToB64(new Uint8Array()),
accessCode: "testAccessCode",
};
};
});

View File

@@ -1,8 +1,6 @@
import { inject, Injectable, WritableSignal } from "@angular/core";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -45,12 +43,7 @@ export class LoginViaAuthRequestCacheService {
/**
* Update the cache with the new LoginView.
*/
cacheLoginView(
authRequest: AuthRequest,
authRequestResponse: AuthRequestResponse,
fingerprintPhrase: string,
keys: { privateKey: Uint8Array | undefined; publicKey: Uint8Array | undefined },
): void {
cacheLoginView(id: string, privateKey: Uint8Array, accessCode: string): void {
if (!this.featureEnabled) {
return;
}
@@ -59,11 +52,9 @@ export class LoginViaAuthRequestCacheService {
// data can be properly formed when json-ified. If not done, they are not stored properly and
// will not be parsable by the cryptography library after coming out of storage.
this.defaultLoginViaAuthRequestCache.set({
authRequest,
authRequestResponse,
fingerprintPhrase,
privateKey: keys.privateKey ? Utils.fromBufferToB64(keys.privateKey.buffer) : undefined,
publicKey: keys.publicKey ? Utils.fromBufferToB64(keys.publicKey.buffer) : undefined,
id: id,
privateKey: Utils.fromBufferToB64(privateKey.buffer),
accessCode: accessCode,
} as LoginViaAuthRequestView);
}