Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bitwarden] Improve locked vault handling and separate concerns #6387

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions extensions/bitwarden/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Bitwarden Changelog

## [Fix] - 2023-05-08

- Hopefully fixed a bug that caused the extnesion to keep logging out right after logging in.

## [Improvement] - 2023-05-04

- Use session token on every bitwarden cli command
Expand Down
70 changes: 52 additions & 18 deletions extensions/bitwarden/src/api/bitwarden.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { environment, getPreferenceValues, LocalStorage, showToast, Toast } from "@raycast/api";
import { execa, ExecaChildProcess, ExecaError } from "execa";
import { execa, ExecaChildProcess, ExecaError, ExecaReturnValue } from "execa";
import { existsSync } from "fs";
import { dirname } from "path/posix";
import { LOCAL_STORAGE_KEY, DEFAULT_SERVER_URL } from "~/constants/general";
Expand All @@ -9,12 +9,13 @@ import { PasswordGeneratorOptions } from "~/types/passwords";
import { Folder, Item } from "~/types/vault";
import { getPasswordGeneratingArgs } from "~/utils/passwords";
import { getServerUrlPreference } from "~/utils/preferences";
import { CLINotFoundError } from "~/utils/errors";
import { CLINotFoundError, VaultIsLockedError } from "~/utils/errors";

export class Bitwarden {
private env: Env;
private initPromise: Promise<void>;
private tempSessionToken: string | undefined;
private tempSessionToken?: string;
private callbacks: ActionCallbacks = {};
lockReason: string | undefined;
cliPath: string;

Expand All @@ -41,23 +42,28 @@ export class Bitwarden {
})();
}

setSessionToken(token: string) {
setActionCallback<TAction extends keyof ActionCallbacks>(action: TAction, callback: ActionCallbacks[TAction]): this {
this.callbacks[action] = callback;
return this;
}

setSessionToken(token: string): void {
this.env = {
...this.env,
BW_SESSION: token,
};
}

clearSessionToken() {
clearSessionToken(): void {
delete this.env.BW_SESSION;
}

withSession(token: string) {
withSession(token: string): this {
this.tempSessionToken = token;
return this;
}

async initialize() {
async initialize(): Promise<this> {
await this.initPromise;
return this;
}
Expand Down Expand Up @@ -121,6 +127,13 @@ export class Bitwarden {
if (this.tempSessionToken) {
this.tempSessionToken = undefined;
}
if (this.isPromptWaitingForMasterPassword(result)) {
/* since we have the session token in the env, the password
should not be requested, unless the vault is locked */
await this.lock();
throw new VaultIsLockedError();
}

return result;
}

Expand All @@ -131,11 +144,32 @@ export class Bitwarden {
async login(): Promise<void> {
await this.exec(["login", "--apikey"]);
await this.clearLockReason();
await this.callbacks.login?.();
}

async logout(): Promise<void> {
await this.exec(["logout"]);
this.clearSessionToken();
await this.callbacks.logout?.();
}

async lock(reason?: string, shouldCheckVaultStatus?: boolean): Promise<void> {
if (shouldCheckVaultStatus) {
const isAuthenticated = (await this.status()).status !== "unauthenticated";
if (!isAuthenticated) return;
}

if (reason) await this.setLockReason(reason);
await this.exec(["lock"]);
await this.callbacks.lock?.(reason);
}

async unlock(password: string): Promise<string> {
const { stdout: sessionToken } = await this.exec(["unlock", password, "--raw"]);
this.setSessionToken(sessionToken);
await this.clearLockReason();
await this.callbacks.unlock?.(password, sessionToken);
return sessionToken;
}

async listItems(): Promise<Item[]> {
Expand All @@ -156,17 +190,6 @@ export class Bitwarden {
return stdout;
}

async unlock(password: string): Promise<string> {
const { stdout: sessionToken } = await this.exec(["unlock", password, "--raw"]);
await this.clearLockReason();
return sessionToken;
}

async lock(reason?: string): Promise<void> {
if (reason) await this.setLockReason(reason);
await this.exec(["lock"]);
}

async status(): Promise<VaultState> {
const { stdout } = await this.exec(["status"]);
return JSON.parse(stdout);
Expand All @@ -188,6 +211,10 @@ export class Bitwarden {
const { stdout } = await this.exec(["generate", ...args], { abortController });
return stdout;
}

private isPromptWaitingForMasterPassword(result: ExecaReturnValue): boolean {
return !!(result.stderr && result.stderr.includes("Master password"));
}
}

type Env = {
Expand All @@ -199,6 +226,13 @@ type Env = {
BW_SESSION?: string;
};

type ActionCallbacks = {
login?: () => MaybePromise<void>;
logout?: () => MaybePromise<void>;
lock?: (reason?: string) => MaybePromise<void>;
unlock?: (password: string, sessionToken: string) => MaybePromise<void>;
};

type ExecProps = {
abortController?: AbortController;
skipLastActivityUpdate?: boolean;
Expand Down
4 changes: 2 additions & 2 deletions extensions/bitwarden/src/components/RootErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { environment, showToast, Toast } from "@raycast/api";
import { Component, ErrorInfo, ReactNode } from "react";
import TroubleshootingGuide from "~/components/TroubleshootingGuide";
import { ERROR_TYPES } from "~/utils/errors";
import { ManuallyThrownError } from "~/utils/errors";

type Props = {
children?: ReactNode;
Expand All @@ -23,7 +23,7 @@ export default class RootErrorBoundary extends Component<Props, State> {
}

async componentDidCatch(error: Error, errorInfo: ErrorInfo) {
if (error.name in ERROR_TYPES) {
if (error instanceof ManuallyThrownError) {
this.setState((state) => ({ ...state, hasError: true, error: error.message }));
await showToast(Toast.Style.Failure, error.message);
} else {
Expand Down
7 changes: 3 additions & 4 deletions extensions/bitwarden/src/components/UnlockForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@ import useVaultMessages from "~/utils/hooks/useVaultMessages";
import { getLabelForTimeoutPreference } from "~/utils/preferences";

export type UnlockFormProps = {
onUnlock: (password: string) => Promise<void>;
lockReason?: string;
};

/** Form for unlocking or logging in to the Bitwarden vault. */
const UnlockForm = (props: UnlockFormProps) => {
const { onUnlock, lockReason: lockReasonProp } = props;
const { lockReason: lockReasonProp } = props;

const bitwarden = useBitwarden();
const [isLoading, setLoading] = useState(false);
Expand All @@ -31,7 +30,7 @@ const UnlockForm = (props: UnlockFormProps) => {
setUnlockError(undefined);

const state = await bitwarden.status();
if (state.status == "unauthenticated") {
if (state.status === "unauthenticated") {
try {
await bitwarden.login();
} catch (error) {
Expand All @@ -46,7 +45,7 @@ const UnlockForm = (props: UnlockFormProps) => {
}
}

await onUnlock(password);
await bitwarden.unlock(password);
await toast.hide();
} catch (error) {
const { displayableError = "Please check your credentials", treatedError } = getUsefulError(error, password);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { Action, Color, Icon, showToast, Toast } from "@raycast/api";
import { useSession } from "~/context/session";
import { useBitwarden } from "~/context/bitwarden";
import { useVault } from "~/context/vault";

function SearchCommonActions() {
const vault = useVault();
const session = useSession();
const bitwarden = useBitwarden();

const handleLockVault = async () => {
const toast = await showToast(Toast.Style.Animated, "Locking Vault...", "Please wait");
await session.lock("Manually locked by the user");
await bitwarden.lock("Manually locked by the user");
await toast.hide();
};

const handleLogoutVault = async () => {
const toast = await showToast({ title: "Logging Out...", style: Toast.Style.Animated });
await session.logout();
await bitwarden.logout();
await toast.hide();
};

Expand Down
61 changes: 16 additions & 45 deletions extensions/bitwarden/src/context/session/session.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { List } from "@raycast/api";
import { createContext, PropsWithChildren, useContext, useEffect, useMemo, useRef } from "react";
import { createContext, PropsWithChildren, useContext, useMemo } from "react";
import UnlockForm from "~/components/UnlockForm";
import { useBitwarden } from "~/context/bitwarden";
import { useSessionReducer } from "~/context/session/reducer";
import { getSavedSession, Storage } from "~/context/session/utils";
import { Cache } from "~/utils/cache";
import { captureException } from "~/utils/development";
import { VaultIsLockedError } from "~/utils/errors";
import useOnceEffect from "~/utils/hooks/useOnceEffect";
import { hashMasterPasswordForReprompting } from "~/utils/passwords";

export type Session = {
Expand All @@ -15,8 +17,6 @@ export type Session = {
isLocked: boolean;
isAuthenticated: boolean;
confirmMasterPassword: (password: string) => Promise<boolean>;
lock: (reason?: string) => Promise<void>;
logout: () => Promise<void>;
};

export const SessionContext = createContext<Session | null>(null);
Expand All @@ -32,64 +32,37 @@ export type SessionProviderProps = PropsWithChildren<{
export function SessionProvider(props: SessionProviderProps) {
const bitwarden = useBitwarden();
const [state, dispatch] = useSessionReducer();
const isInitialized = useRef(false);

useEffect(() => {
if (!bitwarden || isInitialized.current) return;
const initialize = async () => {
await loadSavedSession();
isInitialized.current = true;
};
void initialize();
}, [bitwarden]);

useEffect(() => {
// check if the vault is locked or unauthenticated in the background
if (!state.token) return;
const checkVaultStatus = async () => {
const status = await bitwarden.checkLockStatus();
if (status === "unauthenticated") return await handleLogout();
if (status === "locked") return await handleLock();
};
void checkVaultStatus();
}, [state.token]);

async function loadSavedSession() {
useOnceEffect(async () => {
try {
bitwarden
.setActionCallback("lock", handleLock)
.setActionCallback("unlock", handleUnlock)
.setActionCallback("logout", handleLogout);

const restoredSession = await getSavedSession();
if (restoredSession.token) bitwarden.setSessionToken(restoredSession.token);
dispatch({ type: "loadSavedState", ...restoredSession });
if (restoredSession.shouldLockVault) await handleLock(restoredSession.lockReason, true);
if (restoredSession.shouldLockVault) await bitwarden.lock(restoredSession.lockReason, true);
} catch (error) {
await handleLock();
if (!(error instanceof VaultIsLockedError)) await bitwarden.lock();
dispatch({ type: "failedLoadSavedState" });
captureException("Failed to load saved session state", error);
}
}
}, bitwarden);

async function handleUnlock(password: string) {
const token = await bitwarden.unlock(password);
async function handleUnlock(password: string, token: string) {
const passwordHash = await hashMasterPasswordForReprompting(password);
await Storage.saveSession(token, passwordHash);
bitwarden.setSessionToken(token);
dispatch({ type: "unlock", token, passwordHash });
}

async function handleLock(reason?: string, shouldCheckVaultStatus = false) {
if (shouldCheckVaultStatus) {
const { status } = await bitwarden.status();
if (status !== "unauthenticated") {
await bitwarden.lock(reason);
}
} else {
await bitwarden.lock(reason);
}
async function handleLock(reason?: string) {
await Storage.clearSession();
dispatch({ type: "lock", lockReason: reason });
}

async function handleLogout() {
await bitwarden.logout();
await Storage.clearSession();
Cache.clear();
dispatch({ type: "logout" });
Expand All @@ -107,11 +80,9 @@ export function SessionProvider(props: SessionProviderProps) {
isAuthenticated: state.isAuthenticated,
isLocked: state.isLocked,
active: !state.isLoading && state.isAuthenticated && !state.isLocked,
lock: handleLock,
logout: handleLogout,
confirmMasterPassword,
}),
[state, handleLock, handleLogout, confirmMasterPassword]
[state, confirmMasterPassword]
);

if (state.isLoading) return <List isLoading />;
Expand All @@ -120,7 +91,7 @@ export function SessionProvider(props: SessionProviderProps) {
const children = state.token ? props.children : null;
return (
<SessionContext.Provider value={contextValue}>
{showUnlockForm && props.unlock ? <UnlockForm onUnlock={handleUnlock} lockReason={state.lockReason} /> : children}
{showUnlockForm && props.unlock ? <UnlockForm lockReason={state.lockReason} /> : children}
</SessionContext.Provider>
);
}
Expand Down
11 changes: 6 additions & 5 deletions extensions/bitwarden/src/context/vault.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useSession } from "~/context/session";
import { Folder, Item, Vault } from "~/types/vault";
import { captureException } from "~/utils/development";
import useVaultCaching from "~/components/searchVault/utils/useVaultCaching";
import { FailedToLoadVaultItemsError } from "~/utils/errors";
import { FailedToLoadVaultItemsError, getDisplayableErrorMessage } from "~/utils/errors";

export type VaultState = Vault & {
isLoading: boolean;
Expand Down Expand Up @@ -58,7 +58,7 @@ export const VaultProvider = ({ children }: PropsWithChildren) => {
publishItems(items);
cacheVault(items, folders);
} catch (error) {
await showToast(Toast.Style.Failure, "Failed to load updated vault");
await showToast(Toast.Style.Failure, "Failed to load vault items", getDisplayableErrorMessage(error));
captureException("Failed to load vault items", error);
} finally {
setState({ isLoading: false });
Expand All @@ -71,10 +71,11 @@ export const VaultProvider = ({ children }: PropsWithChildren) => {
await bitwarden.sync();
await loadItems();
await toast.hide();
} catch {
await session.logout();
} catch (error) {
await bitwarden.logout();
toast.style = Toast.Style.Failure;
toast.message = "Failed to sync. Please try logging in again.";
toast.title = "Failed to sync. Please try logging in again.";
toast.message = getDisplayableErrorMessage(error);
}
}

Expand Down
Loading