From 4c76d6ae237dc394a62dcf4fac55891c38159dc2 Mon Sep 17 00:00:00 2001 From: Thebora Kompanioni Date: Sun, 8 Oct 2023 15:08:52 +0200 Subject: [PATCH] feat: new authentication mechanism (access/refresh token) (#665) * dev(auth): add method postToken to JmWalletApi * refactor: remove type ServiceInfoUpdate * dev(auth): add Single- and RefreshTokenContext types * Revert "config: use joinmarket v0.9.10 (#664)" Commit was temporarily till the new auth mechanism can be handled. This reverts commit c31ba67bfbfe18e2ab89e66a5f9fb2373c3c3a43. * dev(auth): save auth context to session * dev(auth): refresh token periodically * dev(auth): maintain CurrentWallet reference when renewing auth token * refactor: server.version must be present * dev(auth): lower token refresh interval in dev mode * dev(auth): add distinct method to parse auth props --- .../joinmarket/latest/Dockerfile | 2 +- src/components/App.tsx | 12 +- src/components/BitcoinQR.jsx | 2 +- .../CoinjoinPreconditionViolationAlert.tsx | 4 +- src/components/CreateWallet.jsx | 9 +- src/components/EarnReport.tsx | 1 - src/components/ImportWallet.tsx | 20 ++-- src/components/LogOverlay.tsx | 3 +- src/components/ToggleSwitch.tsx | 4 +- src/components/Wallets.jsx | 5 +- src/components/Wallets.test.jsx | 12 +- src/components/jar_details/DisplayBranch.tsx | 6 +- src/constants/config.ts | 2 + src/constants/features.ts | 2 +- src/context/ServiceConfigContext.tsx | 5 +- src/context/ServiceInfoContext.tsx | 61 +++++----- src/context/WalletContext.tsx | 106 +++++++++++++++--- src/index.tsx | 9 +- src/libs/JmWalletApi.ts | 82 ++++++++++++-- src/session.ts | 10 +- src/testUtils.tsx | 12 +- 21 files changed, 254 insertions(+), 115 deletions(-) diff --git a/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile b/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile index 941b31582..f875f186a 100644 --- a/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile +++ b/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile @@ -9,7 +9,7 @@ RUN apt-get update \ ENV REPO https://github.com/JoinMarket-Org/joinmarket-clientserver ENV REPO_BRANCH master -ENV REPO_REF v0.9.10 +ENV REPO_REF master WORKDIR /src RUN git clone "$REPO" . --depth=10 --branch "$REPO_BRANCH" && git checkout "$REPO_REF" diff --git a/src/components/App.tsx b/src/components/App.tsx index 44d0934be..310ad0328 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -19,6 +19,7 @@ import { CurrentWallet, useCurrentWallet, useSetCurrentWallet, + useClearCurrentWallet, useReloadCurrentWalletInfo, } from '../context/WalletContext' import { clearSession, setSession } from '../session' @@ -44,6 +45,7 @@ export default function App() { const settings = useSettings() const currentWallet = useCurrentWallet() const setCurrentWallet = useSetCurrentWallet() + const clearCurrentWallet = useClearCurrentWallet() const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() const serviceInfo = useServiceInfo() const sessionConnectionError = useSessionConnectionError() @@ -51,17 +53,17 @@ export default function App() { const isReloadingWalletInfo = useMemo(() => reloadingWalletInfoCounter > 0, [reloadingWalletInfoCounter]) const startWallet = useCallback( - (name: Api.WalletName, token: Api.ApiToken) => { - setSession({ name, token }) - setCurrentWallet({ name, token }) + (name: Api.WalletName, auth: Api.ApiAuthContext) => { + setSession({ name, auth }) + setCurrentWallet({ name, token: auth.token }) }, [setCurrentWallet], ) const stopWallet = useCallback(() => { + clearCurrentWallet() clearSession() - setCurrentWallet(null) - }, [setCurrentWallet]) + }, [clearCurrentWallet]) const reloadWalletInfo = useCallback( (delay: Milliseconds) => { diff --git a/src/components/BitcoinQR.jsx b/src/components/BitcoinQR.jsx index d2e902c3e..4d082d257 100644 --- a/src/components/BitcoinQR.jsx +++ b/src/components/BitcoinQR.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import QRCode from 'qrcode' import { satsToBtc } from '../utils' diff --git a/src/components/CoinjoinPreconditionViolationAlert.tsx b/src/components/CoinjoinPreconditionViolationAlert.tsx index d8b3bfbaf..bafee07d8 100644 --- a/src/components/CoinjoinPreconditionViolationAlert.tsx +++ b/src/components/CoinjoinPreconditionViolationAlert.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef } from 'react' +import { Ref, forwardRef } from 'react' import * as rb from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { useSettings } from '../context/SettingsContext' @@ -14,7 +14,7 @@ interface CoinjoinPreconditionViolationAlertProps { } export const CoinjoinPreconditionViolationAlert = forwardRef( - ({ summary, i18nPrefix = '' }: CoinjoinPreconditionViolationAlertProps, ref: React.Ref) => { + ({ summary, i18nPrefix = '' }: CoinjoinPreconditionViolationAlertProps, ref: Ref) => { const { t } = useTranslation() const settings = useSettings() diff --git a/src/components/CreateWallet.jsx b/src/components/CreateWallet.jsx index b69ff2a36..784d3e490 100644 --- a/src/components/CreateWallet.jsx +++ b/src/components/CreateWallet.jsx @@ -99,8 +99,9 @@ export default function CreateWallet({ parentRoute, startWallet }) { const res = await Api.postWalletCreate({}, { walletname: walletName, password }) const body = await (res.ok ? res.json() : Api.Helper.throwError(res)) - const { seedphrase, token, walletname: createdWalletFileName } = body - setCreatedWallet({ walletFileName: createdWalletFileName, seedphrase, password, token }) + const { seedphrase, walletname: createdWalletFileName } = body + const auth = Api.Helper.parseAuthProps(body) + setCreatedWallet({ walletFileName: createdWalletFileName, seedphrase, password, auth }) } catch (e) { const message = t('create_wallet.error_creating_failed', { reason: e.message || 'Unknown reason', @@ -112,9 +113,9 @@ export default function CreateWallet({ parentRoute, startWallet }) { ) const walletConfirmed = useCallback(() => { - if (createdWallet?.walletFileName && createdWallet?.token) { + if (createdWallet?.walletFileName && createdWallet?.auth) { setAlert(null) - startWallet(createdWallet.walletFileName, createdWallet.token) + startWallet(createdWallet.walletFileName, createdWallet.auth) navigate(routes.wallet) } else { setAlert({ variant: 'danger', message: t('create_wallet.alert_confirmation_failed') }) diff --git a/src/components/EarnReport.tsx b/src/components/EarnReport.tsx index 34a5f41cb..9bf6ad9c9 100644 --- a/src/components/EarnReport.tsx +++ b/src/components/EarnReport.tsx @@ -7,7 +7,6 @@ import { useTheme } from '@table-library/react-table-library/theme' import * as rb from 'react-bootstrap' import { useTranslation } from 'react-i18next' import * as Api from '../libs/JmWalletApi' -// @ts-ignore import { useSettings } from '../context/SettingsContext' import Balance from './Balance' import Sprite from './Sprite' diff --git a/src/components/ImportWallet.tsx b/src/components/ImportWallet.tsx index 04751c1d3..d6cfc1b90 100644 --- a/src/components/ImportWallet.tsx +++ b/src/components/ImportWallet.tsx @@ -374,7 +374,7 @@ enum ImportWalletSteps { interface ImportWalletProps { parentRoute: Route - startWallet: (name: Api.WalletName, token: Api.ApiToken) => void + startWallet: (name: Api.WalletName, auth: Api.ApiAuthContext) => void } export default function ImportWallet({ parentRoute, startWallet }: ImportWalletProps) { @@ -388,9 +388,9 @@ export default function ImportWallet({ parentRoute, startWallet }: ImportWalletP const [alert, setAlert] = useState() const [createWalletFormValues, setCreateWalletFormValues] = useState() const [importDetailsFormValues, setImportDetailsFormValues] = useState() - const [recoveredWallet, setRecoveredWallet] = useState<{ walletFileName: Api.WalletName; token: Api.ApiToken }>() + const [recoveredWallet, setRecoveredWallet] = useState<{ walletFileName: Api.WalletName; auth: Api.ApiAuthContext }>() - const isRecovered = useMemo(() => !!recoveredWallet?.walletFileName && recoveredWallet?.token, [recoveredWallet]) + const isRecovered = useMemo(() => !!recoveredWallet?.walletFileName && recoveredWallet?.auth, [recoveredWallet]) const canRecover = useMemo( () => !isRecovered && !serviceInfo?.walletName && !serviceInfo?.rescanning, [isRecovered, serviceInfo], @@ -441,13 +441,14 @@ export default function ImportWallet({ parentRoute, startWallet }: ImportWalletP const recoverBody = await (recoverResponse.ok ? recoverResponse.json() : Api.Helper.throwError(recoverResponse)) const { walletname: importedWalletFileName } = recoverBody - setRecoveredWallet({ walletFileName: importedWalletFileName, token: recoverBody.token }) + let auth: Api.ApiAuthContext = Api.Helper.parseAuthProps(recoverBody) + setRecoveredWallet({ walletFileName: importedWalletFileName, auth }) // Step #2: update the gaplimit config value if necessary const originalGaplimit = await refreshConfigValues({ signal, keys: [JM_GAPLIMIT_CONFIGKEY], - wallet: { name: importedWalletFileName, token: recoverBody.token }, + wallet: { name: importedWalletFileName, token: auth.token }, }) .then((it) => it[JM_GAPLIMIT_CONFIGKEY.section] || {}) .then((it) => parseInt(it[JM_GAPLIMIT_CONFIGKEY.field] || String(JM_GAPLIMIT_DEFAULT), 10)) @@ -465,16 +466,17 @@ export default function ImportWallet({ parentRoute, startWallet }: ImportWalletP value: String(gaplimit), }, ], - wallet: { name: importedWalletFileName, token: recoverBody.token }, + wallet: { name: importedWalletFileName, token: auth.token }, }) } // Step #3: lock and unlock the wallet (for new addresses to be imported) - const lockResponse = await Api.getWalletLock({ walletName: importedWalletFileName, token: recoverBody.token }) + const lockResponse = await Api.getWalletLock({ walletName: importedWalletFileName, token: auth.token }) if (!lockResponse.ok) await Api.Helper.throwError(lockResponse) const unlockResponse = await Api.postWalletUnlock({ walletName: importedWalletFileName }, { password }) const unlockBody = await (unlockResponse.ok ? unlockResponse.json() : Api.Helper.throwError(unlockResponse)) + auth = Api.Helper.parseAuthProps(unlockBody) // Step #4: reset `gaplimit´ to previous value if necessary if (gaplimitUpdateNecessary) { @@ -487,7 +489,7 @@ export default function ImportWallet({ parentRoute, startWallet }: ImportWalletP value: String(originalGaplimit), }, ], - wallet: { name: importedWalletFileName, token: unlockBody.token }, + wallet: { name: importedWalletFileName, token: auth.token }, }) } @@ -508,7 +510,7 @@ export default function ImportWallet({ parentRoute, startWallet }: ImportWalletP }) } - startWallet(importedWalletFileName, unlockBody.token) + startWallet(importedWalletFileName, auth) navigate(routes.wallet) } catch (e: any) { if (signal.aborted) return diff --git a/src/components/LogOverlay.tsx b/src/components/LogOverlay.tsx index 4ffb4571e..29e30efb6 100644 --- a/src/components/LogOverlay.tsx +++ b/src/components/LogOverlay.tsx @@ -1,9 +1,8 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import * as rb from 'react-bootstrap' import { useTranslation } from 'react-i18next' import { Helper as ApiHelper } from '../libs/JmWalletApi' import { fetchLog } from '../libs/JamApi' -// @ts-ignore import { useSettings } from '../context/SettingsContext' import { CurrentWallet } from '../context/WalletContext' import Sprite from './Sprite' diff --git a/src/components/ToggleSwitch.tsx b/src/components/ToggleSwitch.tsx index 2f83f9a69..de6f2b5c3 100644 --- a/src/components/ToggleSwitch.tsx +++ b/src/components/ToggleSwitch.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import { ChangeEvent } from 'react' import styles from './ToggleSwitch.module.css' interface ToggleSwitchProps { @@ -16,7 +16,7 @@ export default function ToggleSwitch({ toggledOn, disabled = false, }: ToggleSwitchProps) { - const onChange = (e: React.ChangeEvent) => { + const onChange = (e: ChangeEvent) => { e.stopPropagation() onToggle(e.currentTarget.checked) } diff --git a/src/components/Wallets.jsx b/src/components/Wallets.jsx index 37fe6e99d..c9c9ea954 100644 --- a/src/components/Wallets.jsx +++ b/src/components/Wallets.jsx @@ -65,8 +65,9 @@ export default function Wallets({ currentWallet, startWallet, stopWallet }) { setUnlockWalletName(undefined) - const { walletname: unlockedWalletName, token } = body - startWallet(unlockedWalletName, token) + const auth = Api.Helper.parseAuthProps(body) + + startWallet(body.walletname, auth) navigate(routes.wallet) } catch (e) { const message = e.message.replace('Wallet', walletName) diff --git a/src/components/Wallets.test.jsx b/src/components/Wallets.test.jsx index 0e75c8c1c..6c1f8f5f6 100644 --- a/src/components/Wallets.test.jsx +++ b/src/components/Wallets.test.jsx @@ -202,7 +202,12 @@ describe('', () => { }) apiMock.postWalletUnlock.mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ walletname: dummyWalletName, token: dummyToken }), + json: () => + Promise.resolve({ + walletname: dummyWalletName, + token: dummyToken, + refresh_token: dummyToken, + }), }) await act(async () => setup({})) @@ -223,7 +228,10 @@ describe('', () => { await waitFor(() => screen.findByText('wallets.wallet_preview.button_unlock')) }) - expect(mockStartWallet).toHaveBeenCalledWith(dummyWalletName, dummyToken) + expect(mockStartWallet).toHaveBeenCalledWith(dummyWalletName, { + token: dummyToken, + refresh_token: dummyToken, + }) expect(mockedNavigate).toHaveBeenCalledWith('/wallet') }) diff --git a/src/components/jar_details/DisplayBranch.tsx b/src/components/jar_details/DisplayBranch.tsx index 99c6a4202..65da72bd2 100644 --- a/src/components/jar_details/DisplayBranch.tsx +++ b/src/components/jar_details/DisplayBranch.tsx @@ -1,9 +1,7 @@ -import React from 'react' +import { ReactNode } from 'react' import * as rb from 'react-bootstrap' import { useTranslation } from 'react-i18next' -// @ts-ignore import Balance from '../Balance' -// @ts-ignore import { useSettings } from '../../context/SettingsContext' import { Branch, BranchEntry } from '../../context/WalletContext' import styles from './DisplayBranch.module.css' @@ -28,7 +26,7 @@ const toSimpleStatus = (value: string) => { return value.substring(0, indexOfBracket).trim() } -const toLabelNode = (simpleStatus: string): React.ReactNode => { +const toLabelNode = (simpleStatus: string): ReactNode => { if (simpleStatus === 'new') return {simpleStatus} if (simpleStatus === 'used') return {simpleStatus} diff --git a/src/constants/config.ts b/src/constants/config.ts index 632e5dde9..c67165669 100644 --- a/src/constants/config.ts +++ b/src/constants/config.ts @@ -17,3 +17,5 @@ export const JM_MINIMUM_MAKERS_DEFAULT = 4 export const CJ_STATE_TAKER_RUNNING = 0 export const CJ_STATE_MAKER_RUNNING = 1 export const CJ_STATE_NONE_RUNNING = 2 + +export const JM_API_AUTH_TOKEN_EXPIRY: Milliseconds = Math.round(0.5 * 60 * 60 * 1_000) diff --git a/src/constants/features.ts b/src/constants/features.ts index d0ea91144..236fc6c39 100644 --- a/src/constants/features.ts +++ b/src/constants/features.ts @@ -22,5 +22,5 @@ const __isFeatureEnabled = (name: Feature, version: SemVer): boolean => { } export const isFeatureEnabled = (name: Feature, serviceInfo: ServiceInfo): boolean => { - return !!serviceInfo.server?.version && __isFeatureEnabled(name, serviceInfo.server.version) + return !!serviceInfo.server && __isFeatureEnabled(name, serviceInfo.server.version) } diff --git a/src/context/ServiceConfigContext.tsx b/src/context/ServiceConfigContext.tsx index 73a38c54f..f7a547404 100644 --- a/src/context/ServiceConfigContext.tsx +++ b/src/context/ServiceConfigContext.tsx @@ -1,5 +1,4 @@ -import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react' -// @ts-ignore +import { PropsWithChildren, createContext, useCallback, useContext, useEffect, useRef } from 'react' import { CurrentWallet, useCurrentWallet } from './WalletContext' import * as Api from '../libs/JmWalletApi' @@ -105,7 +104,7 @@ export interface ServiceConfigContextEntry { const ServiceConfigContext = createContext(undefined) -const ServiceConfigProvider = ({ children }: React.PropsWithChildren<{}>) => { +const ServiceConfigProvider = ({ children }: PropsWithChildren<{}>) => { const currentWallet = useCurrentWallet() const serviceConfig = useRef(null) diff --git a/src/context/ServiceInfoContext.tsx b/src/context/ServiceInfoContext.tsx index 6c6355ebd..d84c0de08 100644 --- a/src/context/ServiceInfoContext.tsx +++ b/src/context/ServiceInfoContext.tsx @@ -1,6 +1,15 @@ -import React, { createContext, useCallback, useContext, useReducer, useState, useEffect, useRef } from 'react' -// @ts-ignore -import { useCurrentWallet, useSetCurrentWallet } from './WalletContext' +import { + PropsWithChildren, + Dispatch, + createContext, + useCallback, + useContext, + useReducer, + useState, + useEffect, + useRef, +} from 'react' +import { useCurrentWallet, useClearCurrentWallet } from './WalletContext' // @ts-ignore import { useWebsocket } from './WebsocketContext' import { clearSession } from '../session' @@ -9,8 +18,8 @@ import { toSemVer, UNKNOWN_VERSION } from '../utils' import * as Api from '../libs/JmWalletApi' -// interval in milliseconds for periodic session requests -const SESSION_REQUEST_INTERVAL = 10_000 +// interval for periodic session requests +const SESSION_REQUEST_INTERVAL: Milliseconds = 10_000 type AmountFraction = number type AmountCounterparties = number @@ -71,7 +80,7 @@ type SessionInfo = { } type ServerInfo = { server?: { - version?: SemVer + version: SemVer } } @@ -81,31 +90,25 @@ type ServiceInfo = SessionFlag & RescanBlockchainInProgressFlag & SessionInfo & ServerInfo -type ServiceInfoUpdate = - | ServiceInfo - | MakerRunningFlag - | CoinjoinInProgressFlag - | RescanBlockchainInProgressFlag - | ServerInfo interface ServiceInfoContextEntry { serviceInfo: ServiceInfo | null reloadServiceInfo: ({ signal }: { signal: AbortSignal }) => Promise - dispatchServiceInfo: React.Dispatch + dispatchServiceInfo: Dispatch> connectionError?: Error } const ServiceInfoContext = createContext(undefined) -const ServiceInfoProvider = ({ children }: React.PropsWithChildren<{}>) => { +const ServiceInfoProvider = ({ children }: PropsWithChildren<{}>) => { const currentWallet = useCurrentWallet() - const setCurrentWallet = useSetCurrentWallet() + const clearCurrentWallet = useClearCurrentWallet() const websocket = useWebsocket() const fetchSessionInProgress = useRef | null>(null) const [serviceInfo, dispatchServiceInfo] = useReducer( - (state: ServiceInfo | null, obj: ServiceInfoUpdate) => ({ ...state, ...obj }) as ServiceInfo | null, + (state: ServiceInfo | null, obj: Partial) => ({ ...state, ...obj }) as ServiceInfo | null, null, ) const [connectionError, setConnectionError] = useState() @@ -115,20 +118,12 @@ const ServiceInfoProvider = ({ children }: React.PropsWithChildren<{}>) => { Api.getGetinfo({ signal: abortCtrl.signal }) .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res))) - .then((data: JmGetInfoData) => { - dispatchServiceInfo({ - server: { - version: toSemVer(data.version), - }, - }) - }) - .catch((err) => { - const notFound = err.response.status === 404 - if (notFound) { + .then((data: JmGetInfoData) => toSemVer(data.version)) + .catch((_) => UNKNOWN_VERSION) + .then((version) => { + if (!abortCtrl.signal.aborted) { dispatchServiceInfo({ - server: { - version: UNKNOWN_VERSION, - }, + server: { version }, }) } }) @@ -143,14 +138,14 @@ const ServiceInfoProvider = ({ children }: React.PropsWithChildren<{}>) => { // Just reset the wallet info, not the session storage (token), // as the connection might be down shortly and auth information // is still valid most of the time. - setCurrentWallet(null) + clearCurrentWallet() } - }, [connectionError, setCurrentWallet]) + }, [connectionError, clearCurrentWallet]) const reloadServiceInfo = useCallback( async ({ signal }: { signal: AbortSignal }) => { const resetWalletAndClearSession = () => { - setCurrentWallet(null) + clearCurrentWallet() clearSession() } @@ -222,7 +217,7 @@ const ServiceInfoProvider = ({ children }: React.PropsWithChildren<{}>) => { throw err }) }, - [currentWallet, setCurrentWallet], + [currentWallet, clearCurrentWallet], ) useEffect(() => { diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx index f3423f8fa..f0f43f60a 100644 --- a/src/context/WalletContext.tsx +++ b/src/context/WalletContext.tsx @@ -1,16 +1,40 @@ import { createContext, useEffect, useCallback, useState, useContext, PropsWithChildren, useMemo } from 'react' -import { getSession } from '../session' +import { getSession, setSession } from '../session' import * as fb from '../components/fb/utils' import * as Api from '../libs/JmWalletApi' import { WalletBalanceSummary, toBalanceSummary } from './BalanceSummary' +import { JM_API_AUTH_TOKEN_EXPIRY } from '../constants/config' +import { isDevMode } from '../constants/debugFeatures' + +const API_AUTH_TOKEN_RENEW_INTERVAL: Milliseconds = isDevMode() + ? 60 * 1_000 + : Math.round(JM_API_AUTH_TOKEN_EXPIRY * 0.75) export interface CurrentWallet { name: Api.WalletName token: Api.ApiToken } +class CurrentWalletImpl implements CurrentWallet { + readonly name: Api.WalletName + #token: Api.ApiToken + + constructor(name: Api.WalletName, token: Api.ApiToken) { + this.name = name + this.#token = token + } + + get token() { + return this.#token + } + + updateToken(token: Api.ApiToken) { + this.#token = token + } +} + // TODO: move these interfaces to JmWalletApi, once distinct types are used as return value instead of plain "Response" export type Utxo = { address: Api.BitcoinAddress @@ -110,9 +134,10 @@ export interface WalletInfo { data: CombinedRawWalletData } -interface WalletContextEntry { - currentWallet: CurrentWallet | null - setCurrentWallet: React.Dispatch> +interface WalletContextEntry { + currentWallet: T | null + setCurrentWallet: (currentWallet: CurrentWallet) => void + clearCurrentWallet: () => void currentWalletInfo: WalletInfo | undefined reloadCurrentWalletInfo: { reloadAll: ({ signal }: { signal: AbortSignal }) => Promise @@ -149,16 +174,11 @@ const toFidelityBondSummary = (res: UtxosResponse): FidenlityBondSummary => { } } -const WalletContext = createContext(undefined) +const WalletContext = createContext | undefined>(undefined) -const restoreWalletFromSession = (): CurrentWallet | null => { +const restoreWalletFromSession = (): CurrentWalletImpl | null => { const session = getSession() - return session && session.name && session.token - ? { - name: session.name, - token: session.token, - } - : null + return session?.name && session?.auth?.token ? new CurrentWalletImpl(session.name, session.auth.token) : null } export const groupByJar = (utxos: Utxos): UtxosByJar => { @@ -188,7 +208,18 @@ const toWalletInfo = (data: CombinedRawWalletData): WalletInfo => { const toCombinedRawData = (utxos: UtxosResponse, display: WalletDisplayResponse) => ({ utxos, display }) const WalletProvider = ({ children }: PropsWithChildren) => { - const [currentWallet, setCurrentWallet] = useState(restoreWalletFromSession()) + const [currentWallet, setCurrentWalletOrNull] = useState(restoreWalletFromSession) + + const setCurrentWallet = useCallback( + (wallet: CurrentWallet) => { + setCurrentWalletOrNull(new CurrentWalletImpl(wallet.name, wallet.token)) + }, + [setCurrentWalletOrNull], + ) + + const clearCurrentWallet = useCallback(() => { + setCurrentWalletOrNull(null) + }, [setCurrentWalletOrNull]) const [utxoResponse, setUtxoResponse] = useState() const [displayResponse, setDisplayResponse] = useState() @@ -276,11 +307,49 @@ const WalletProvider = ({ children }: PropsWithChildren) => { } }, [currentWallet]) + useEffect(() => { + if (!currentWallet) return + + const abortCtrl = new AbortController() + + const renewToken = () => { + const session = getSession() + if (!session?.auth?.refresh_token) { + console.warn('Cannot renew auth token - no refresh_token available.') + return + } + + Api.postToken( + { token: session.auth.token, signal: abortCtrl.signal }, + { + grant_type: 'refresh_token', + refresh_token: session.auth.refresh_token, + }, + ) + .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res))) + .then((body) => { + const auth = Api.Helper.parseAuthProps(body) + + setSession({ name: currentWallet.name, auth }) + currentWallet.updateToken(auth.token) + console.debug('Successfully renewed auth token.') + }) + .catch((err) => console.error(err)) + } + + const interval = setInterval(renewToken, API_AUTH_TOKEN_RENEW_INTERVAL) + return () => { + clearInterval(interval) + abortCtrl.abort() + } + }, [currentWallet]) + return ( ) => { ) } -const useCurrentWallet = () => { +const useCurrentWallet = (): CurrentWallet | null => { const context = useContext(WalletContext) if (context === undefined) { throw new Error('useCurrentWallet must be used within a WalletProvider') @@ -306,6 +375,14 @@ const useSetCurrentWallet = () => { return context.setCurrentWallet } +const useClearCurrentWallet = () => { + const context = useContext(WalletContext) + if (context === undefined) { + throw new Error('useClearCurrentWallet must be used within a WalletProvider') + } + return context.clearCurrentWallet +} + const useCurrentWalletInfo = () => { const context = useContext(WalletContext) if (context === undefined) { @@ -327,6 +404,7 @@ export { WalletProvider, useCurrentWallet, useSetCurrentWallet, + useClearCurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo, } diff --git a/src/index.tsx b/src/index.tsx index cd9270cc3..fe113b939 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,8 +1,7 @@ -import React from 'react' +import { StrictMode } from 'react' import ReactDOM from 'react-dom' -// @ts-ignore + import App from './components/App' -// @ts-ignore import { SettingsProvider } from './context/SettingsContext' // @ts-ignore import { WebsocketProvider } from './context/WebsocketContext' @@ -15,7 +14,7 @@ import './index.css' import './i18n/config' ReactDOM.render( - + @@ -27,6 +26,6 @@ ReactDOM.render( - , + , document.getElementById('root'), ) diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts index 3d201fb0b..f87e4052c 100644 --- a/src/libs/JmWalletApi.ts +++ b/src/libs/JmWalletApi.ts @@ -12,16 +12,33 @@ */ const basePath = () => `${window.JM.PUBLIC_PATH}/api` -export type ApiToken = string -export type WalletName = `${string}.jmdat` +type ApiToken = string +type WalletName = `${string}.jmdat` -export type Mixdepth = number -export type AmountSats = number // TODO: should be BigInt! Remove once every caller migrated to TypeScript. -export type BitcoinAddress = string +type Mixdepth = number +type AmountSats = number // TODO: should be BigInt! Remove once every caller migrated to TypeScript. +type BitcoinAddress = string type Vout = number -export type TxId = string -export type UtxoId = `${TxId}:${Vout}` +type TxId = string +type UtxoId = `${TxId}:${Vout}` + +// for JM versions <0.9.11 +type SingleTokenAuthContext = { + token: ApiToken + refresh_token: undefined +} + +// for JM versions >=0.9.11 +type RefreshTokenAuthContext = { + token: ApiToken + token_type: string // "bearer" + expires_in: Seconds // 1800 + scope: string + refresh_token: ApiToken +} + +type ApiAuthContext = SingleTokenAuthContext | RefreshTokenAuthContext type WithWalletName = { walletName: WalletName @@ -33,7 +50,7 @@ type WithMixdepth = { type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' type YYYY = `2${Digit}${Digit}${Digit}` type MM = '01' | '02' | '03' | '04' | '05' | '06' | '07' | '08' | '09' | '10' | '11' | '12' -export type Lockdate = `${YYYY}-${MM}` +type Lockdate = `${YYYY}-${MM}` type WithLockdate = { lockdate: Lockdate } @@ -49,7 +66,7 @@ interface AuthApiRequestContext extends ApiRequestContext { token: ApiToken } -export type WalletRequestContext = AuthApiRequestContext & WithWalletName +type WalletRequestContext = AuthApiRequestContext & WithWalletName interface ApiError { message: string @@ -57,6 +74,11 @@ interface ApiError { type WalletType = 'sw-fb' +interface TokenRequest { + grant_type: 'refresh_token' | string + refresh_token: string +} + interface CreateWalletRequest { walletname: WalletName | string password: string @@ -115,12 +137,12 @@ interface ConfigGetRequest { field: string } -export interface StartSchedulerRequest { +interface StartSchedulerRequest { destination_addresses: BitcoinAddress[] tumbler_options?: TumblerOptions } -export interface TumblerOptions { +interface TumblerOptions { restart?: boolean schedulefile?: string addrcount?: number @@ -202,11 +224,25 @@ const Helper = (() => { return { 'x-jm-authorization': `Bearer ${token}` } } + // Simple helper method to parse auth properties. + // TODO: This can be removed when the API methods + // return typed responses (see #670) + const parseAuthProps = (body: any): ApiAuthContext => { + return { + token: body.token, + token_type: body.token_type, + expires_in: body.expires_in, + scope: body.scope, + refresh_token: body.refresh_token, + } + } + return { throwError, throwResolved, extractErrorMessage, buildAuthHeader, + parseAuthProps, } })() @@ -223,6 +259,15 @@ const getSession = async ({ token, signal }: ApiRequestContext & { token?: ApiTo }) } +const postToken = async ({ signal, token }: AuthApiRequestContext, req: TokenRequest) => { + return await fetch(`${basePath()}/v1/token`, { + headers: { ...Helper.buildAuthHeader(token) }, + method: 'POST', + body: JSON.stringify(req), + signal, + }) +} + const getAddressNew = async ({ token, signal, walletName, mixdepth }: WalletRequestContext & WithMixdepth) => { return await fetch(`${basePath()}/v1/wallet/${encodeURIComponent(walletName)}/address/new/${mixdepth}`, { headers: { ...Helper.buildAuthHeader(token) }, @@ -461,7 +506,7 @@ const getRescanBlockchain = async ({ }) } -export class JmApiError extends Error { +class JmApiError extends Error { public response: Response constructor(response: Response, message: string) { @@ -472,6 +517,7 @@ export class JmApiError extends Error { export { getGetinfo, + postToken, postMakerStart, getMakerStop, getSession, @@ -496,4 +542,16 @@ export { getSchedule, getRescanBlockchain, Helper, + JmApiError, + ApiAuthContext, + StartSchedulerRequest, + WalletRequestContext, + ApiToken, + WalletName, + Lockdate, + TxId, + UtxoId, + Mixdepth, + AmountSats, + BitcoinAddress, } diff --git a/src/session.ts b/src/session.ts index 6a2cdf4e3..dacaf43f7 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,19 +1,19 @@ -import { WalletName, ApiToken } from './libs/JmWalletApi' +import { WalletName, ApiAuthContext } from './libs/JmWalletApi' const SESSION_KEY = 'joinmarket' export interface SessionItem { name: WalletName - token: ApiToken + auth: ApiAuthContext } export const setSession = (session: SessionItem) => sessionStorage.setItem(SESSION_KEY, JSON.stringify(session)) export const getSession = (): SessionItem | null => { const json = sessionStorage.getItem(SESSION_KEY) - const { name, token }: any = (json && JSON.parse(json)) || {} - if (name && token) { - return { name, token } + const { name, auth }: any = (json && JSON.parse(json)) || {} + if (name && auth?.token) { + return { name, auth } } else { clearSession() return null diff --git a/src/testUtils.tsx b/src/testUtils.tsx index 5377a6631..9dd81d03f 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -1,18 +1,17 @@ -import React from 'react' +import { StrictMode, ReactElement } from 'react' import { render, RenderOptions } from '@testing-library/react' import { I18nextProvider } from 'react-i18next' import { WalletProvider } from './context/WalletContext' import { ServiceInfoProvider } from './context/ServiceInfoContext' import { ServiceConfigProvider } from './context/ServiceConfigContext' -// @ts-ignore import { SettingsProvider } from './context/SettingsContext' // @ts-ignore import { WebsocketProvider } from './context/WebsocketContext' import i18n from './i18n/testConfig' -const AllTheProviders = ({ children }: { children: React.ReactElement }) => { +const AllTheProviders = ({ children }: { children: ReactElement }) => { return ( - + @@ -24,12 +23,11 @@ const AllTheProviders = ({ children }: { children: React.ReactElement }) => { - + ) } -const customRender = (ui: React.ReactElement, options?: RenderOptions) => - render(ui, { wrapper: AllTheProviders, ...options }) +const customRender = (ui: ReactElement, options?: RenderOptions) => render(ui, { wrapper: AllTheProviders, ...options }) // re-export everything export * from '@testing-library/react'