1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 22:33:35 +00:00

feat(redirectToVaultIfUnlockedGuard): [Auth/PM-20623] RedirectToVaultIfUnlocked Guard (#15236)

Adds a `redirect-to-vault-if-unlocked.guard.ts` that does the following:
- If there is no active user, allow access to the route
- If the user is specifically Unlocked, redirect the user to /vault
- Otherwise, allow access to the route (fallback/default)
This commit is contained in:
rr-bw
2025-07-17 14:24:53 -07:00
committed by GitHub
parent b4120e0e3f
commit 9ca265c543
6 changed files with 196 additions and 16 deletions

View File

@@ -12,6 +12,7 @@ import {
authGuard,
lockGuard,
redirectGuard,
redirectToVaultIfUnlockedGuard,
tdeDecryptionRequiredGuard,
unauthGuardFn,
} from "@bitwarden/angular/auth/guards";
@@ -454,6 +455,7 @@ const routes: Routes = [
},
{
path: "login-with-device",
canActivate: [redirectToVaultIfUnlockedGuard()],
data: {
pageIcon: DevicesIcon,
pageTitle: {
@@ -502,6 +504,7 @@ const routes: Routes = [
},
{
path: "admin-approval-requested",
canActivate: [redirectToVaultIfUnlockedGuard()],
data: {
pageIcon: DevicesIcon,
pageTitle: {

View File

@@ -4,3 +4,4 @@ export * from "./lock.guard";
export * from "./redirect/redirect.guard";
export * from "./tde-decryption-required.guard";
export * from "./unauth.guard";
export * from "./redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard";

View File

@@ -0,0 +1,19 @@
# RedirectToVaultIfUnlocked Guard
The `redirectToVaultIfUnlocked` redirects the user to `/vault` if they are `Unlocked`. Otherwise, it allows access to the route.
This is particularly useful for routes that can handle BOTH unauthenticated AND authenticated-but-locked users (which makes the `authGuard` unusable on those routes).
<br>
### Special Use Case - Authenticating in the Extension Popout
Imagine a user is going through the Login with Device flow in the Extension pop*out*:
- They open the pop*out* while on `/login-with-device`
- The approve the login from another device
- They are authenticated and routed to `/vault` while in the pop*out*
If the `redirectToVaultIfUnlocked` were NOT applied, if this user now opens the pop*up* they would be shown the `/login-with-device`, not their `/vault`.
But by adding the `redirectToVaultIfUnlocked` to `/login-with-device` we make sure to check if the user has already `Unlocked`, and if so, route them to `/vault` upon opening the pop*up*.

View File

@@ -0,0 +1,98 @@
import { TestBed } from "@angular/core/testing";
import { Router, provideRouter } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { UserId } from "@bitwarden/common/types/guid";
import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked.guard";
describe("redirectToVaultIfUnlockedGuard", () => {
const activeUser: Account = {
id: "userId" as UserId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
};
const setup = (activeUser: Account | null, authStatus: AuthenticationStatus | null) => {
const accountService = mock<AccountService>();
const authService = mock<AuthService>();
accountService.activeAccount$ = new BehaviorSubject<Account | null>(activeUser);
authService.authStatusFor$.mockReturnValue(of(authStatus));
const testBed = TestBed.configureTestingModule({
providers: [
{ provide: AccountService, useValue: accountService },
{ provide: AuthService, useValue: authService },
provideRouter([
{ path: "", component: EmptyComponent },
{ path: "vault", component: EmptyComponent },
{
path: "guarded-route",
component: EmptyComponent,
canActivate: [redirectToVaultIfUnlockedGuard()],
},
]),
],
});
return {
router: testBed.inject(Router),
};
};
it("should be created", () => {
const { router } = setup(null, null);
expect(router).toBeTruthy();
});
it("should redirect to /vault if the user is AuthenticationStatus.Unlocked", async () => {
// Arrange
const { router } = setup(activeUser, AuthenticationStatus.Unlocked);
// Act
await router.navigate(["guarded-route"]);
// Assert
expect(router.url).toBe("/vault");
});
it("should allow navigation to continue to the route if there is no active user", async () => {
// Arrange
const { router } = setup(null, null);
// Act
await router.navigate(["guarded-route"]);
// Assert
expect(router.url).toBe("/guarded-route");
});
it("should allow navigation to continue to the route if the user is AuthenticationStatus.LoggedOut", async () => {
// Arrange
const { router } = setup(null, AuthenticationStatus.LoggedOut);
// Act
await router.navigate(["guarded-route"]);
// Assert
expect(router.url).toBe("/guarded-route");
});
it("should allow navigation to continue to the route if the user is AuthenticationStatus.Locked", async () => {
// Arrange
const { router } = setup(null, AuthenticationStatus.Locked);
// Act
await router.navigate(["guarded-route"]);
// Assert
expect(router.url).toBe("/guarded-route");
});
});

View File

@@ -0,0 +1,36 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
/**
* Redirects the user to `/vault` if they are `Unlocked`. Otherwise, it allows access to the route.
* See ./redirect-to-vault-if-unlocked/README.md for more details.
*/
export function redirectToVaultIfUnlockedGuard(): CanActivateFn {
return async () => {
const accountService = inject(AccountService);
const authService = inject(AuthService);
const router = inject(Router);
const activeUser = await firstValueFrom(accountService.activeAccount$);
// If there is no active user, allow access to the route
if (!activeUser) {
return true;
}
const authStatus = await firstValueFrom(authService.authStatusFor$(activeUser.id));
// If user is Unlocked, redirect to vault
if (authStatus === AuthenticationStatus.Unlocked) {
return router.createUrlTree(["/vault"]);
}
// If user is LoggedOut or Locked, allow access to the route
return true;
};
}

View File

@@ -1,11 +1,22 @@
# Authentication Flows Documentation
# Login via Auth Request Documentation
<br>
**Table of Contents**
> - [Standard Auth Request Flows](#standard-auth-request-flows)
> - [Admin Auth Request Flow](#admin-auth-request-flow)
> - [Summary Table](#summary-table)
> - [State Management](#state-management)
<br>
## 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
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
@@ -14,7 +25,7 @@
### 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
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
@@ -34,9 +45,9 @@ get into this flow:
### 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
2. Navigates to `/login-initiated`
3. Clicks "Approve from your other device"
4. Navigates to /login-with-device which creates a StandardAuthRequest
4. Navigates to `/login-with-device` which creates a `StandardAuthRequest`
5. Receives approval from device with authRequestPublicKey(masterKey)
6. Decrypts masterKey
7. Decrypts userKey
@@ -46,22 +57,24 @@ get into this flow:
### 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
2. Navigates to `/login-initiated`
3. Clicks "Approve from your other device"
4. Navigates to /login-with-device which creates a StandardAuthRequest
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
<br>
## Admin Auth Request Flow
### Flow: Authed SSO TD user requests admin approval
1. SSO TD user authenticates via SSO
2. Navigates to /login-initiated
2. Navigates to `/login-initiated`
3. Clicks "Request admin approval"
4. Navigates to /admin-approval-requested which creates an AdminAuthRequest
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)
@@ -70,21 +83,25 @@ get into this flow:
**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.
<br>
## 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 |
| 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"<br>[`/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.
<br>
## State Management
### View Cache
@@ -102,6 +119,8 @@ The cache is used to:
2. Allow resumption of pending auth requests
3. Enable processing of approved requests after extension close and reopen.
<br>
### Component State Variables
Key state variables maintained during the authentication process:
@@ -149,6 +168,8 @@ protected flow = Flow.StandardAuthRequest
- Affects UI rendering and request handling
- Set based on route and authentication state
<br>
### State Flow Examples
#### Standard Auth Request Cache Flow
@@ -186,6 +207,8 @@ protected flow = Flow.StandardAuthRequest
- Either resumes monitoring or starts new request
- Clears state after successful approval
<br>
### State Cleanup
State cleanup occurs in several scenarios: