diff --git a/extensions/bitwarden/CHANGELOG.md b/extensions/bitwarden/CHANGELOG.md index fd4eec4f2000f..7f6f8ad73d3b0 100644 --- a/extensions/bitwarden/CHANGELOG.md +++ b/extensions/bitwarden/CHANGELOG.md @@ -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 diff --git a/extensions/bitwarden/src/api/bitwarden.ts b/extensions/bitwarden/src/api/bitwarden.ts index a1517dcd27da5..c4066d436432e 100644 --- a/extensions/bitwarden/src/api/bitwarden.ts +++ b/extensions/bitwarden/src/api/bitwarden.ts @@ -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"; @@ -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; - private tempSessionToken: string | undefined; + private tempSessionToken?: string; + private callbacks: ActionCallbacks = {}; lockReason: string | undefined; cliPath: string; @@ -41,23 +42,28 @@ export class Bitwarden { })(); } - setSessionToken(token: string) { + setActionCallback(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 { await this.initPromise; return this; } @@ -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; } @@ -131,11 +144,32 @@ export class Bitwarden { async login(): Promise { await this.exec(["login", "--apikey"]); await this.clearLockReason(); + await this.callbacks.login?.(); } async logout(): Promise { await this.exec(["logout"]); this.clearSessionToken(); + await this.callbacks.logout?.(); + } + + async lock(reason?: string, shouldCheckVaultStatus?: boolean): Promise { + 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 { + 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 { @@ -156,17 +190,6 @@ export class Bitwarden { return stdout; } - async unlock(password: string): Promise { - const { stdout: sessionToken } = await this.exec(["unlock", password, "--raw"]); - await this.clearLockReason(); - return sessionToken; - } - - async lock(reason?: string): Promise { - if (reason) await this.setLockReason(reason); - await this.exec(["lock"]); - } - async status(): Promise { const { stdout } = await this.exec(["status"]); return JSON.parse(stdout); @@ -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 = { @@ -199,6 +226,13 @@ type Env = { BW_SESSION?: string; }; +type ActionCallbacks = { + login?: () => MaybePromise; + logout?: () => MaybePromise; + lock?: (reason?: string) => MaybePromise; + unlock?: (password: string, sessionToken: string) => MaybePromise; +}; + type ExecProps = { abortController?: AbortController; skipLastActivityUpdate?: boolean; diff --git a/extensions/bitwarden/src/components/RootErrorBoundary.tsx b/extensions/bitwarden/src/components/RootErrorBoundary.tsx index e1fdedcb270f1..a8760f763a019 100644 --- a/extensions/bitwarden/src/components/RootErrorBoundary.tsx +++ b/extensions/bitwarden/src/components/RootErrorBoundary.tsx @@ -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; @@ -23,7 +23,7 @@ export default class RootErrorBoundary extends Component { } 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 { diff --git a/extensions/bitwarden/src/components/UnlockForm.tsx b/extensions/bitwarden/src/components/UnlockForm.tsx index 9394fb96d91f7..99a5137ae86eb 100644 --- a/extensions/bitwarden/src/components/UnlockForm.tsx +++ b/extensions/bitwarden/src/components/UnlockForm.tsx @@ -8,13 +8,12 @@ import useVaultMessages from "~/utils/hooks/useVaultMessages"; import { getLabelForTimeoutPreference } from "~/utils/preferences"; export type UnlockFormProps = { - onUnlock: (password: string) => Promise; 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); @@ -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) { @@ -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); diff --git a/extensions/bitwarden/src/components/searchVault/actions/CommonActions.tsx b/extensions/bitwarden/src/components/searchVault/actions/CommonActions.tsx index bddaa69993378..a1eaea7b34453 100644 --- a/extensions/bitwarden/src/components/searchVault/actions/CommonActions.tsx +++ b/extensions/bitwarden/src/components/searchVault/actions/CommonActions.tsx @@ -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(); }; diff --git a/extensions/bitwarden/src/context/session/session.tsx b/extensions/bitwarden/src/context/session/session.tsx index 8d1c5866d3cb4..045d82ee32d98 100644 --- a/extensions/bitwarden/src/context/session/session.tsx +++ b/extensions/bitwarden/src/context/session/session.tsx @@ -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 = { @@ -15,8 +17,6 @@ export type Session = { isLocked: boolean; isAuthenticated: boolean; confirmMasterPassword: (password: string) => Promise; - lock: (reason?: string) => Promise; - logout: () => Promise; }; export const SessionContext = createContext(null); @@ -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" }); @@ -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 ; @@ -120,7 +91,7 @@ export function SessionProvider(props: SessionProviderProps) { const children = state.token ? props.children : null; return ( - {showUnlockForm && props.unlock ? : children} + {showUnlockForm && props.unlock ? : children} ); } diff --git a/extensions/bitwarden/src/context/vault.tsx b/extensions/bitwarden/src/context/vault.tsx index 62179222cd45f..47d67064f5736 100644 --- a/extensions/bitwarden/src/context/vault.tsx +++ b/extensions/bitwarden/src/context/vault.tsx @@ -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; @@ -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 }); @@ -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); } } diff --git a/extensions/bitwarden/src/types/general.ts b/extensions/bitwarden/src/types/general.ts index 8cda39b980943..17695ad11f117 100644 --- a/extensions/bitwarden/src/types/general.ts +++ b/extensions/bitwarden/src/types/general.ts @@ -1,7 +1,5 @@ /* Put types that you feel like they still don't deserve a file of their own here */ -import { ERROR_TYPES } from "~/utils/errors"; - // This should be updated with the command "name" values in the package.json file export type CommandName = "search" | "generate-password" | "generate-password-quick"; @@ -11,5 +9,3 @@ export type VaultState = { status: VaultStatus; serverUrl: string | null; }; - -export type ErrorType = (typeof ERROR_TYPES)[keyof typeof ERROR_TYPES]; diff --git a/extensions/bitwarden/src/types/global.d.ts b/extensions/bitwarden/src/types/global.d.ts index f789099df17a9..1b1d03d91d17a 100644 --- a/extensions/bitwarden/src/types/global.d.ts +++ b/extensions/bitwarden/src/types/global.d.ts @@ -24,6 +24,7 @@ declare global { } type RecordOfAny = Record; type RecursiveNonOptional = { [K in keyof T]-?: RecursiveNonOptional }; + type MaybePromise = T | Promise; } export {}; diff --git a/extensions/bitwarden/src/utils/errors.ts b/extensions/bitwarden/src/utils/errors.ts index 0457b21f33936..8a6e735a05b75 100644 --- a/extensions/bitwarden/src/utils/errors.ts +++ b/extensions/bitwarden/src/utils/errors.ts @@ -1,18 +1,37 @@ -export const ERROR_TYPES = { - CLINotFound: "CLINotFound", - FailedToLoadVaultItemsError: "FailedToLoadVaultItemsError", -} as const; +export class ManuallyThrownError extends Error { + constructor(message: string) { + super(message); + } +} -export class CLINotFoundError extends Error { +export class DisplayableError extends ManuallyThrownError { constructor(message: string) { super(message); - this.name = ERROR_TYPES.CLINotFound; } } -export class FailedToLoadVaultItemsError extends Error { +export class CLINotFoundError extends DisplayableError { + constructor(message: string) { + super(message ?? "Bitwarden CLI not found"); + this.name = "CLINotFoundError"; + } +} + +export class FailedToLoadVaultItemsError extends ManuallyThrownError { constructor(message?: string) { super(message ?? "Failed to load vault items"); - this.name = ERROR_TYPES.CLINotFound; + this.name = "FailedToLoadVaultItemsError"; } } + +export class VaultIsLockedError extends DisplayableError { + constructor(message?: string) { + super(message ?? "Vault is locked"); + this.name = "VaultIsLockedError"; + } +} + +export function getDisplayableErrorMessage(error: any) { + if (error instanceof DisplayableError) return error.message; + return undefined; +} diff --git a/extensions/bitwarden/src/utils/hooks/useOnceEffect.ts b/extensions/bitwarden/src/utils/hooks/useOnceEffect.ts index 15235c99030aa..b82cb7fbef098 100644 --- a/extensions/bitwarden/src/utils/hooks/useOnceEffect.ts +++ b/extensions/bitwarden/src/utils/hooks/useOnceEffect.ts @@ -1,15 +1,28 @@ import { EffectCallback, useEffect, useRef } from "react"; -function useOnceEffect(effect: EffectCallback, condition?: any) { - const ref = useRef(false); +type AsyncEffectCallback = () => Promise; +type Effect = EffectCallback | AsyncEffectCallback; + +/** `useEffect` that only runs once after the `condition` is met */ +function useOnceEffect(effect: Effect, condition?: any) { + const hasRun = useRef(false); useEffect(() => { - if (ref.current) return; + if (hasRun.current) return; if (!condition) return; - ref.current = true; + hasRun.current = true; + if (isAsyncFunction(effect)) { + void effect(); + return undefined; + } + return effect(); }, [condition]); } +function isAsyncFunction(fn: Effect): fn is AsyncEffectCallback { + return fn.constructor.name === "AsyncFunction"; +} + export default useOnceEffect;