From 7522562db0aad07f127372aee2d6803d7d95bd2e Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Fri, 8 Aug 2025 23:53:00 +0200 Subject: [PATCH] Refactor JWT authStore --- src/App.tsx | 24 +- src/models/AuthStore.ts | 194 ++++++++++++-- src/models/WalletProfileStore.ts | 33 +-- src/screens/ImportBackupScreen.tsx | 10 +- src/screens/RecoverWalletAddressScreen.tsx | 8 +- src/screens/SeedRecoveryScreen.tsx | 9 +- src/screens/SettingsScreen.tsx | 6 +- src/screens/UpdateScreen.tsx | 16 +- src/screens/WalletScreen.tsx | 8 +- src/screens/WelcomeScreen.tsx | 11 +- src/services/authService.ts | 292 --------------------- src/services/minibitsService.ts | 117 +++++++-- src/utils/authUtils.ts | 39 +++ 13 files changed, 381 insertions(+), 386 deletions(-) delete mode 100644 src/services/authService.ts create mode 100644 src/utils/authUtils.ts diff --git a/src/App.tsx b/src/App.tsx index 35869759..c7417cd1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,16 +17,16 @@ import { import RNExitApp from 'react-native-exit-app' import {AppNavigator} from './navigation' import {useInitialRootStore, useStores} from './models' -import {KeyChain} from './services' +import {KeyChain, WalletKeys} from './services' import {ErrorBoundary} from './screens/ErrorScreen/ErrorBoundary' import Config from './config' import {log} from './services' -import {Env} from './utils/envtypes' import AppError from './utils/AppError' import { Image, TextStyle, View, Platform, UIManager } from 'react-native' import { spacing, typography } from './theme' import { displayName } from '../app.json' import { Text } from './components/Text' +import useIsInternetReachable from './utils/useIsInternetReachable' /* Set default size ratio for styling */ setSizeMattersBaseWidth(375) @@ -52,8 +52,9 @@ if (!__DEV__) { function App() { - const {userSettingsStore, relaysStore} = useStores() - const [isAuthenticated, setIsAuthenticated] = useState(false) + const {userSettingsStore, relaysStore, authStore, walletStore, walletProfileStore} = useStores() + const [isAuthenticated, setIsAuthenticated] = useState(false) + const isInternetReachable = useIsInternetReachable() const {rehydrated} = useInitialRootStore(async() => { log.trace('[useInitialRootStore]', 'Root store rehydrated') @@ -85,6 +86,21 @@ function App() { setIsAuthenticated(true) } + // reenroll device for JWT authentication if refresh token expired + if(authStore.isRefreshokenExpired && isInternetReachable) { + log.trace('[useInitialRootStore]', 'Re-enrolling device for JWT authentication') + try { + const walletKeys: WalletKeys = await walletStore.getCachedWalletKeys() + const deviceId = walletProfileStore.device + + await authStore.logout() + await authStore.enrollDevice(walletKeys.NOSTR, deviceId) + } catch (e: any) { + log.error('[useInitialRootStore]', 'Failed to re-enroll device', {message: e.message}) + + } + } + if(userSettingsStore.theme !== userSettingsStore.nextTheme) { userSettingsStore.setTheme(userSettingsStore.nextTheme) } diff --git a/src/models/AuthStore.ts b/src/models/AuthStore.ts index 9902ef78..cc0f09ab 100644 --- a/src/models/AuthStore.ts +++ b/src/models/AuthStore.ts @@ -1,6 +1,10 @@ import {Instance, SnapshotOut, types, flow} from 'mobx-state-tree' -import {JwtTokens, KeyChain, log} from '../services' +import {JwtTokens, KeyChain, log, MinibitsClient, NostrEvent, NostrKeyPair, NostrUnsignedEvent} from '../services' import AppError, { Err } from '../utils/AppError' +import { MINIBITS_SERVER_API_HOST } from '@env' +import { finalizeEvent, getEventHash, verifyEvent } from 'nostr-tools/pure' +import { hexToBytes } from '@noble/hashes/utils' +import { decodeJwtExpiry } from '../utils/authUtils' export type AuthState = { accessToken: string | null @@ -10,14 +14,26 @@ export type AuthState = { isAuthenticated: boolean } +export interface AuthChallengeResponse { + challenge: string + expiresAt: number + createdAt: number +} + +export interface VerifyChallengeResponse { + accessToken: string + refreshToken: string, + pubkey: string, + deviceId: string +} + export const AuthStoreModel = types .model('AuthStore') .props({ accessToken: types.maybeNull(types.string), refreshToken: types.maybeNull(types.string), accessTokenExpiresAt: types.maybeNull(types.number), - refreshTokenExpiresAt: types.maybeNull(types.number), - deviceId: types.maybeNull(types.string), + refreshTokenExpiresAt: types.maybeNull(types.number), }) .views(self => ({ get isAuthenticated(): boolean { @@ -46,9 +62,7 @@ export const AuthStoreModel = types } })) .actions(self => ({ - setTokens: flow(function* setTokens( - tokens: JwtTokens - ) { + setTokens: flow(function* setTokens(tokens: JwtTokens) { try { log.trace('[setTokens] Saving JWT tokens', {tokens}) @@ -67,7 +81,6 @@ export const AuthStoreModel = types throw e } }), - clearTokens: flow(function* clearTokens() { try { log.trace('[clearTokens] Clearing tokens') @@ -86,14 +99,7 @@ export const AuthStoreModel = types log.error('[clearTokens] Failed to clear tokens', e) throw e } - }), - - setDeviceId: (deviceId: string) => { - log.trace('[AuthStore.setDeviceId]', {deviceId}) - self.deviceId = deviceId - return deviceId - }, - + }), loadTokensFromKeyChain: flow(function* loadTokensFromKeyChain() { try { log.trace('[AuthStore.loadTokensFromKeyChain] Loading tokens from the KeyChain') @@ -114,13 +120,167 @@ export const AuthStoreModel = types log.error('[AuthStore.loadTokensFromKeyChain] Failed to load tokens', e) // Don't throw here, just log the error as missing tokens is not critical } + }), + + })) + .actions(self => ({ + refreshToken: flow(function* refreshToken() { + try { + const {tokens} = self + + if (!tokens || !tokens.refreshToken) { + throw new AppError(Err.AUTH_ERROR, 'No refresh token available. Please re-authenticate.') + } + + log.trace('[refreshTokens] Refreshing tokens') + + const refreshUrl = `${MINIBITS_SERVER_API_HOST}/auth/refresh` + const refreshBody = { + refreshToken: tokens.refreshToken + } + + const newTokens: JwtTokens = yield MinibitsClient.fetchApi(refreshUrl, { + method: 'POST', + body: refreshBody, + jwtAuthRequired: false + }) + + log.info('[refreshTokens] Tokens refreshed successfully', {newTokens}) + + // Store new tokens securely + const jwtTokens: JwtTokens = { + accessToken: newTokens.accessToken, + refreshToken: newTokens.refreshToken, + accessTokenExpiresAt: decodeJwtExpiry(newTokens.accessToken) || 0, + refreshTokenExpiresAt: decodeJwtExpiry(newTokens.refreshToken) || 0 + } + + self.setTokens(jwtTokens) + return jwtTokens + + } catch (e: any) { + log.error('[refreshTokens] Failed to refresh tokens', e) + + // If refresh fails, clear stored tokens + yield self.clearTokens() + + throw new AppError(Err.AUTH_ERROR, `Token refresh failed: ${e.message}`, e) + } + }), + })) + .actions(self => ({ + enrollDevice: flow(function* enrollDevice(nostrKeys: NostrKeyPair, deviceId?: string | null) { + try { + log.trace('[enrollDevice] Starting device enrollment', { nostrKeys, deviceId }) + + // Step 1: Get challenge from server + const challengeResponse: AuthChallengeResponse = yield MinibitsClient.getAuthChallenge(nostrKeys.publicKey, deviceId) + + log.trace('[enrollDevice] Received challenge', { challenge: challengeResponse.challenge }) + + // Step 2: Sign the challenge with Nostr private key + let authUnsignedEvent: NostrUnsignedEvent = { + kind: 22242, + pubkey: nostrKeys.publicKey, + tags: [['relay', process.env.MINIBITS_RELAY_URL as string], ['challenge', challengeResponse.challenge]], + content: '', + created_at: challengeResponse.createdAt + } + + let authEvent = {...authUnsignedEvent} as unknown as NostrEvent + + authEvent.id = getEventHash(authUnsignedEvent) + + const privateKeyBytes = hexToBytes(nostrKeys.privateKey) + const signedEvent = finalizeEvent(authEvent, privateKeyBytes) + + if (!verifyEvent(signedEvent)) { + throw new AppError(Err.VALIDATION_ERROR, 'Failed to sign authentication challenge') + } + + log.trace('[enrollDevice] Signed challenge event', { signedEvent }) + + // Step 3: Verify the signed event and get JWT tokens + const verifyChallengeResponse: VerifyChallengeResponse = yield MinibitsClient.verifyAuthChallenge( + nostrKeys.publicKey, + challengeResponse.challenge, + signedEvent.sig, + deviceId + ) + + log.trace('[enrollDevice] Device enrolled successfully') + + // Store tokens securely + const jwtTokens: JwtTokens = { + accessToken: verifyChallengeResponse.accessToken, + refreshToken: verifyChallengeResponse.refreshToken, + accessTokenExpiresAt: decodeJwtExpiry(verifyChallengeResponse.accessToken) || 0, + refreshTokenExpiresAt: decodeJwtExpiry(verifyChallengeResponse.refreshToken) || 0 + } + + yield self.setTokens(jwtTokens) + + return jwtTokens + + } catch (e: any) { + // Throw error to satisfy return type + throw new AppError(Err.AUTH_ERROR, 'Failed to enroll device and obtain tokens', e) + } + }), + getValidAccessToken: flow(function* getValidAccessToken() { + try { + // If access token is valid, return it + if (self.isAuthenticated) { + log.trace('[getValidAccessToken] Access token is valid') + return self.accessToken + } + + // If access token is expired, refresh tokens + log.trace('[getValidAccessToken] Access token is expired, refreshing tokens') + const newTokens = yield self.refreshToken() + + return newTokens.accessToken + + } catch (e: any) { + log.error('[getValidAccessToken] Failed to get valid access token', e) + throw e + } + }), + logout: flow(function* logout() { + try { + const {tokens} = self + + if (tokens && tokens.refreshToken) { + // Call server logout endpoint to invalidate refresh token + try { + yield MinibitsClient.logout(tokens.refreshToken) + + log.info('[logout] Server logout successful') + } catch (e: any) { + // Log but don't throw - we still want to clear local tokens + log.warn('[logout] Server logout failed, clearing local tokens anyway', e) + } + } + + // Always clear local tokens + yield self.clearTokens() + log.info('[logout] Local tokens cleared') + + } catch (e: any) { + log.error('[logout] Logout failed', e) + // Still try to clear local tokens even if there's an error + yield self.clearTokens() + throw new AppError(Err.AUTH_ERROR, `Logout failed: ${e.message}`, e) + } }) - })).postProcessSnapshot((snapshot) => { // NOT persisted outside of KeyChain + + })) + .postProcessSnapshot((snapshot) => { // NOT persisted outside of KeyChain except device return { accessToken: null, refreshToken: null, expiresAt: null, - deviceId: null, + device: snapshot.deviceId || null, } }) diff --git a/src/models/WalletProfileStore.ts b/src/models/WalletProfileStore.ts index 453be7ed..c26963c1 100644 --- a/src/models/WalletProfileStore.ts +++ b/src/models/WalletProfileStore.ts @@ -1,6 +1,6 @@ import {Instance, SnapshotOut, types, flow} from 'mobx-state-tree' import { Metadata } from 'nostr-tools/kinds' -import {AuthService, KeyChain, MinibitsClient, NostrClient, NostrUnsignedEvent, WalletTask} from '../services' +import {KeyChain, MinibitsClient, NostrClient, NostrKeyPair, NostrUnsignedEvent, WalletTask} from '../services' import {log} from '../services/logService' import { Err } from '../utils/AppError' import { getRandomUsername } from '../utils/usernames' @@ -108,21 +108,15 @@ export const WalletProfileStoreModel = types }) })) .actions(self => ({ - create: flow(function* create(publicKey: string, walletId: string, seedHash: string) { + create: flow(function* create(nostrPublicKey: string, walletId: string, seedHash: string) { let profileRecord: WalletProfileRecord - log.trace('[create]', {seedHash, publicKey, walletId}) + log.trace('[create]', {seedHash, nostrPublicKey, walletId}) try { - // First, enroll device for JWT authentication after successful profile creation - yield AuthService.enrollDevice( - publicKey, - self.device - ) - // Use retrieved jwt token to authenticate and creates new profile. If all params equal existing one, it is returned - profileRecord = yield MinibitsClient.createWalletProfile(publicKey, walletId, seedHash) + profileRecord = yield MinibitsClient.createWalletProfile(nostrPublicKey, walletId, seedHash) self.hydrate(profileRecord) log.info('[create]', 'Wallet profile saved in WalletProfileStore', {self}) @@ -132,19 +126,12 @@ export const WalletProfileStoreModel = types if(e.name.includes(Err.ALREADY_EXISTS_ERROR)) { // recreate walletId + default name const name = getRandomUsername() - - // Enroll device for JWT authentication - yield AuthService.enrollDevice( - publicKey, - self.device - ) // attempt to create new unique profile again - profileRecord = yield MinibitsClient.createWalletProfile(publicKey, name, seedHash) + profileRecord = yield MinibitsClient.createWalletProfile(nostrPublicKey, name, seedHash) log.error('[create]', 'Profile reset executed to resolve duplicate walletId on the server.', {caller: 'create', walletId, newWalletId: name}) self.hydrate(profileRecord) - return self } throw e @@ -203,16 +190,10 @@ export const WalletProfileStoreModel = types log.info('[updateNip05]', 'Wallet nip05 updated in the WalletProfileStore', {self}) return self }), - recover: flow(function* recover(publicKey: string, walletId: string, seedHash: string) { - - // First re-enroll device for JWT authentication after successful profile recovery - yield AuthService.enrollDevice( - publicKey, - self.device - ) + recover: flow(function* recover(nostrPublicKey: string, walletId: string, seedHash: string) { let profileRecord: WalletProfileRecord = yield MinibitsClient.recoverProfile( - publicKey, walletId, seedHash + nostrPublicKey, walletId, seedHash ) self.hydrate(profileRecord) diff --git a/src/screens/ImportBackupScreen.tsx b/src/screens/ImportBackupScreen.tsx index 6ca29bba..73fb2361 100644 --- a/src/screens/ImportBackupScreen.tsx +++ b/src/screens/ImportBackupScreen.tsx @@ -20,7 +20,7 @@ import { } from '../components' import {useHeader} from '../utils/useHeader' import AppError, { Err } from '../utils/AppError' -import { Database, KeyChain, log, MinibitsClient } from '../services' +import { AuthService, Database, KeyChain, log, MinibitsClient } from '../services' import Clipboard from '@react-native-clipboard/clipboard' import { rootStoreInstance, useStores } from '../models' import {MnemonicInput} from './Recovery/MnemonicInput' @@ -284,7 +284,13 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } mnemonic } - keys.SEED = seed + keys.SEED = seed + + await AuthService.logout() + await AuthService.enrollDevice( + keys.NOSTR, + walletProfileStore.device + ) if(isNewProfileNeeded) { diff --git a/src/screens/RecoverWalletAddressScreen.tsx b/src/screens/RecoverWalletAddressScreen.tsx index 523ba440..5337149c 100644 --- a/src/screens/RecoverWalletAddressScreen.tsx +++ b/src/screens/RecoverWalletAddressScreen.tsx @@ -29,7 +29,7 @@ export const RecoverWalletAddressScreen = observer(function RecoverWalletAddress }, }) - const { walletProfileStore, userSettingsStore, walletStore } = useStores() + const { walletProfileStore, userSettingsStore, walletStore, authStore } = useStores() const mnemonicInputRef = useRef(null) const seedRef = useRef(null) @@ -112,6 +112,12 @@ export const RecoverWalletAddressScreen = observer(function RecoverWalletAddress log.trace('[onCompleteAddress] Wallet keys', { keys }) + await authStore.logout() + await authStore.enrollDevice( + keys.NOSTR, + walletProfileStore.device + ) + await walletProfileStore.recover( keys.NOSTR.publicKey, keys.walletId, diff --git a/src/screens/SeedRecoveryScreen.tsx b/src/screens/SeedRecoveryScreen.tsx index 40481a90..35566313 100644 --- a/src/screens/SeedRecoveryScreen.tsx +++ b/src/screens/SeedRecoveryScreen.tsx @@ -55,7 +55,8 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route } userSettingsStore, transactionsStore, walletProfileStore, - walletStore + walletStore, + authStore } = useStores() const mnemonicInputRef = useRef(null) @@ -504,6 +505,12 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route } } keys.SEED = seed + + await authStore.logout() + await authStore.enrollDevice( + keys.NOSTR, + walletProfileStore.device + ) if(isNewProfileNeeded) { diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 915dec0f..5e95a5b3 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -54,9 +54,9 @@ export const SettingsScreen = observer(function SettingsScreen({ route }: Props) const checkForUpdate = async () => { try { const updateInfo = await HotUpdater.checkForUpdate({ - source: getUpdateSource(HOT_UPDATER_URL, { - updateStrategy: "appVersion", - }), + source: getUpdateSource(HOT_UPDATER_URL, { + updateStrategy: "appVersion", + }), requestHeaders: { Authorization: `Bearer ${HOT_UPDATER_API_KEY}`, }, diff --git a/src/screens/UpdateScreen.tsx b/src/screens/UpdateScreen.tsx index 5c5dc324..fb98d21b 100644 --- a/src/screens/UpdateScreen.tsx +++ b/src/screens/UpdateScreen.tsx @@ -26,7 +26,7 @@ import { log } from '../services' import {Env} from '../utils/envtypes' import { CommonActions, StaticScreenProps, useNavigation } from '@react-navigation/native' import { translate } from '../i18n' -import { HotUpdater, useHotUpdaterStore } from '@hot-updater/react-native' +import { getUpdateSource, HotUpdater, useHotUpdaterStore } from '@hot-updater/react-native' import { ResultModalInfo } from './Wallet/ResultModalInfo' @@ -89,12 +89,14 @@ export const UpdateScreen = observer(function UpdateScreen({ route }: Props) { const handleUpdate = async function () { try { setIsUpdateModalVisible(true) - const updateInfo = await HotUpdater.checkForUpdate({ - source: HOT_UPDATER_URL, - requestHeaders: { - Authorization: `Bearer ${HOT_UPDATER_API_KEY}`, - }, - }) + const updateInfo = await HotUpdater.checkForUpdate({ + source: getUpdateSource(HOT_UPDATER_URL, { + updateStrategy: "appVersion", + }), + requestHeaders: { + Authorization: `Bearer ${HOT_UPDATER_API_KEY}`, + }, + }) if (!updateInfo) { throw new AppError(Err.NETWORK_ERROR, translate('updateScreen_couldNotRetrieveUpdate')) diff --git a/src/screens/WalletScreen.tsx b/src/screens/WalletScreen.tsx index 143010b8..983f10e1 100644 --- a/src/screens/WalletScreen.tsx +++ b/src/screens/WalletScreen.tsx @@ -111,15 +111,15 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) { const checkForUpdate = async () => { try { const updateInfo = await HotUpdater.checkForUpdate({ - source: getUpdateSource(HOT_UPDATER_URL, { - updateStrategy: "appVersion", - }), + source: getUpdateSource(HOT_UPDATER_URL, { + updateStrategy: "appVersion", + }), requestHeaders: { Authorization: `Bearer ${HOT_UPDATER_API_KEY}`, }, }) - log.debug('[checkForUpdate]', {updateInfo}) + log.trace('[checkForUpdate]', {updateInfo}) if (!updateInfo) { return diff --git a/src/screens/WelcomeScreen.tsx b/src/screens/WelcomeScreen.tsx index b3dc864b..8cdc4b48 100644 --- a/src/screens/WelcomeScreen.tsx +++ b/src/screens/WelcomeScreen.tsx @@ -84,6 +84,7 @@ export const WelcomeScreen = function ({ route }: Props) { }) const { + authStore, userSettingsStore, relaysStore, walletProfileStore, @@ -112,7 +113,15 @@ export const WelcomeScreen = function ({ route }: Props) { const keys = KeyChain.generateWalletKeys() - setStatusMessage(translate('welcomeScreen_creatingProfile')) + setStatusMessage(translate('welcomeScreen_creatingProfile')) + + // First, enroll device for JWT authentication then create profile + await authStore.logout() + + await authStore.enrollDevice( + keys.NOSTR, + walletProfileStore.device + ) await walletProfileStore.create( keys.NOSTR.publicKey, diff --git a/src/services/authService.ts b/src/services/authService.ts deleted file mode 100644 index a162e255..00000000 --- a/src/services/authService.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { JS_BUNDLE_VERSION, MINIBITS_SERVER_API_HOST, MINIBITS_SERVER_API_KEY } from '@env' -import { bytesToHex, hexToBytes } from '@noble/hashes/utils' -import { finalizeEvent, getEventHash, validateEvent } from 'nostr-tools/pure' -import { NostrClient, NostrEvent, NostrUnsignedEvent } from './nostrService' -import { KeyChain, JwtTokens, NostrKeyPair, WalletKeys } from './keyChain' -import { MinibitsClient } from './minibitsService' -import { log } from './logService' -import AppError, { Err } from '../utils/AppError' -import { rootStoreInstance } from '../models' - - -export interface AuthChallengeResponse { - challenge: string - expiresAt: number - createdAt: number -} - -export interface VerifyChallengeResponse { - accessToken: string - refreshToken: string, - pubkey: string, - deviceId: string -} - -const { walletProfileStore, walletStore, authStore } = rootStoreInstance - -/** - * Enrolls a new device by signing a challenge - */ -const enrollDevice = async function (pubkey: string, deviceId?: string | null): Promise { - try { - log.trace('[enrollDevice] Starting device enrollment', { pubkey, deviceId }) - - // Step 1: Get challenge from server - const challengeUrl = `${MINIBITS_SERVER_API_HOST}/auth/challenge` - const challengeBody = { pubkey, deviceId } - - const challengeResponse = await MinibitsClient.fetchApi(challengeUrl, { - method: 'POST', - body: challengeBody, - jwtAuthRequired: false - }) as AuthChallengeResponse - - log.trace('[enrollDevice] Received challenge', { challenge: challengeResponse.challenge }) - - // Step 2: Sign the challenge with Nostr private key - let authUnsignedEvent: NostrUnsignedEvent = { - kind: 22242, - pubkey, - tags: [['relay', process.env.MINIBITS_RELAY_URL as string], ['challenge', challengeResponse.challenge]], - content: '', - created_at: challengeResponse.createdAt - } - - let authEvent = {...authUnsignedEvent} as unknown as NostrEvent - - const walletKeys: WalletKeys = await walletStore.getCachedWalletKeys() - const nostrKeys = walletKeys.NOSTR - - authEvent.id = getEventHash(authUnsignedEvent) - - const privateKeyBytes = hexToBytes(nostrKeys.privateKey) - const signedEvent = finalizeEvent(authEvent, privateKeyBytes) - - if (!validateEvent(signedEvent)) { - throw new AppError(Err.VALIDATION_ERROR, 'Failed to sign authentication challenge') - } - - log.trace('[enrollDevice] Signed challenge event', { signedEvent }) - - // Step 3: Verify the signed event and get JWT tokens - const verifyUrl = `${MINIBITS_SERVER_API_HOST}/auth/verify` - const verifyBody = { - pubkey, - challenge: challengeResponse.challenge, - signature: signedEvent.sig, - deviceId, - } - - const verifyChallengeResponse = await MinibitsClient.fetchApi(verifyUrl, { - method: 'POST', - body: verifyBody, - jwtAuthRequired: false - }) as VerifyChallengeResponse - - log.info('[enrollDevice] Device enrolled successfully') - - // Store tokens securely - const jwtTokens: JwtTokens = { - accessToken: verifyChallengeResponse.accessToken, - refreshToken: verifyChallengeResponse.refreshToken, - accessTokenExpiresAt: decodeJwtExpiry(verifyChallengeResponse.accessToken) || 0, - refreshTokenExpiresAt: decodeJwtExpiry(verifyChallengeResponse.refreshToken) || 0 - } - - authStore.setTokens(jwtTokens) - - return jwtTokens - - } catch (e: any) { - log.error('[enrollDevice] Failed to load tokens', e) - // Throw error to satisfy return type - throw new AppError(Err.AUTH_ERROR, 'Failed to enroll device and obtain tokens', e) - } -} - -/** - * Returns a valid access token, automatically refreshing if expired - */ -const getValidAccessToken = async function (pubkey: string, deviceId?: string | null): Promise { - try { - - let tokens = authStore.tokens - - if (!tokens) { - // try to re-enroll device if no tokens found - tokens = await enrollDevice(pubkey, deviceId) - log.trace('[getValidAccessToken] Re-enrolled device and obtained new tokens', { tokens }) - } - - // Check if token is expired (with 1 minute buffer) - const now = Math.floor(Date.now() / 1000) - const bufferTime = 1 * 60 // 1 min - - if (tokens && tokens.accessTokenExpiresAt <= (now + bufferTime)) { - log.trace('[getValidAccessToken] Token expired, refreshing') - const newTokens = await refreshTokens() - return newTokens.accessToken - } - - return tokens.accessToken - - } catch (e: any) { - log.error('[getValidAccessToken] Failed to get valid access token', e) - throw e - } -} - -/** - * Calls /auth/refresh endpoint to get new token pair - */ -const refreshTokens = async function (): Promise { - try { - - const {tokens} = authStore - - if (!tokens || !tokens.refreshToken) { - throw new AppError(Err.AUTH_ERROR, 'No refresh token available. Please re-authenticate.') - } - - log.trace('[refreshTokens] Refreshing tokens') - - const refreshUrl = `${MINIBITS_SERVER_API_HOST}/auth/refresh` - const refreshBody = { - refreshToken: tokens.refreshToken - } - - const newTokens = await MinibitsClient.fetchApi(refreshUrl, { - method: 'POST', - body: refreshBody, - jwtAuthRequired: false - }) as JwtTokens - - log.info('[refreshTokens] Tokens refreshed successfully') - - // Store new tokens securely - const jwtTokens: JwtTokens = { - accessToken: newTokens.accessToken, - refreshToken: newTokens.refreshToken, - accessTokenExpiresAt: decodeJwtExpiry(newTokens.accessToken) || 0, - refreshTokenExpiresAt: decodeJwtExpiry(newTokens.refreshToken) || 0 - } - - await authStore.setTokens(jwtTokens) - return jwtTokens - - } catch (e: any) { - log.error('[refreshTokens] Failed to refresh tokens', e) - - // If refresh fails, clear stored tokens - await authStore.clearTokens() - - throw new AppError(Err.AUTH_ERROR, `Token refresh failed: ${e.message}`, e) - } -} - - -function base64urlDecode(base64url: string) { - // Replace base64url characters with base64 standard ones - let base64 = base64url - .replace(/-/g, '+') // Replace '-' with '+' - .replace(/_/g, '/') // Replace '_' with '/' - .replace(/[^A-Za-z0-9+/=]/g, ''); // Clean up any other characters - - // Pad the base64 string to make it a multiple of 4 if necessary - while (base64.length % 4) { - base64 += '='; - } - - // Decode base64 to a string - const decodedString = atob(base64); - return decodedString; -} - - - - -export const decodeJwtExpiry = (token: string): number | null => { - try { - // Split the token into its parts: header, payload, and signature - const [, payload] = token.split('.') - - if (!payload) { - throw new Error('Invalid JWT token format') - } - - // Decode the payload from Base64URL - const decodedPayload = JSON.parse(base64urlDecode(payload)) - - // Return the `exp` field (expiry time in seconds since epoch) - return decodedPayload.exp || null - } catch (error) { - console.error('Failed to decode JWT token:', error) - return null - } -} - - - - -/** - * Calls /auth/logout endpoint and clears local tokens - */ -const logout = async function (): Promise { - try { - const {tokens} = authStore - - if (tokens && tokens.refreshToken) { - // Call server logout endpoint to invalidate refresh token - try { - const logoutUrl = `${MINIBITS_SERVER_API_HOST}/auth/logout` - const logoutBody = { - refreshToken: tokens.refreshToken - } - - await MinibitsClient.fetchApi(logoutUrl, { - method: 'POST', - body: logoutBody, - jwtAuthRequired: false - }) - - log.info('[logout] Server logout successful') - } catch (e: any) { - // Log but don't throw - we still want to clear local tokens - log.warn('[logout] Server logout failed, clearing local tokens anyway', e) - } - } - - // Always clear local tokens - await authStore.clearTokens() - log.info('[logout] Local tokens cleared') - - } catch (e: any) { - log.error('[logout] Logout failed', e) - // Still try to clear local tokens even if there's an error - await authStore.clearTokens() - throw new AppError(Err.AUTH_ERROR, `Logout failed: ${e.message}`, e) - } -} - - -const getAuthenticatedHeaders = async (): Promise> => { - - const {pubkey, device} = walletProfileStore - - const accessToken = await AuthService.getValidAccessToken(pubkey, device) - return { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Accept-encoding': 'gzip, deflate', - 'Authorization': `Bearer ${accessToken}`, - 'User-Agent': `Minibits/${JS_BUNDLE_VERSION}` - } -} - -export const AuthService = { - enrollDevice, - getValidAccessToken, - refreshTokens, - logout, - getAuthenticatedHeaders -} \ No newline at end of file diff --git a/src/services/minibitsService.ts b/src/services/minibitsService.ts index b9bea5a8..4b63020c 100644 --- a/src/services/minibitsService.ts +++ b/src/services/minibitsService.ts @@ -7,7 +7,9 @@ import { } from '@env' import { WalletProfileRecord } from "../models/WalletProfileStore" import { CurrencyCode } from "./wallet/currency" -import { AuthService } from "./authService" +import { rootStoreInstance } from "../models" +import { JwtTokens } from "./keyChain" +import { AuthChallengeResponse, VerifyChallengeResponse } from "../models/AuthStore" // refresh // refresh // refresh type MinibitsRequestArgs = { @@ -15,10 +17,79 @@ type MinibitsRequestArgs = { body?: Record headers?: Record jwtAuthRequired?: boolean + jwtAccessToken?: string } type MinibitsRequestOptions = MinibitsRequestArgs & Omit +const { authStore } = rootStoreInstance + + +const getAuthChallenge = async function (pubkey: string, deviceId?: string | null) { + const challengeUrl = `${MINIBITS_SERVER_API_HOST}/auth/challenge` + const challengeBody = { pubkey, deviceId } + + const challengeResponse: AuthChallengeResponse = await fetchApi(challengeUrl, { + method: 'POST', + body: challengeBody, + jwtAuthRequired: false + }) + + return challengeResponse +} + + +const verifyAuthChallenge = async function (pubkey: string, challenge: string, signature: string, deviceId?: string | null) { + const verifyUrl = `${MINIBITS_SERVER_API_HOST}/auth/verify` + const verifyBody = { + pubkey, + challenge, + signature, + deviceId, + } + + const verifyChallengeResponse: VerifyChallengeResponse = await fetchApi(verifyUrl, { + method: 'POST', + body: verifyBody, + jwtAuthRequired: false + }) + + return verifyChallengeResponse +} + + +const refreshTokens = async function (refreshToken: string) { + const refreshUrl = `${MINIBITS_SERVER_API_HOST}/auth/refresh` + const refreshBody = { + refreshToken + } + + const newTokens: JwtTokens = await fetchApi(refreshUrl, { + method: 'POST', + body: refreshBody, + jwtAuthRequired: false + }) + + return newTokens +} + + +const logout = async function (refreshToken: string) { + const logoutUrl = `${MINIBITS_SERVER_API_HOST}/auth/logout` + const logoutBody = { + refreshToken + } + + await MinibitsClient.fetchApi(logoutUrl, { + method: 'POST', + body: logoutBody, + jwtAuthRequired: false + }) + + return +} + + const getRandomPictures = async function () { const url = MINIBITS_SERVER_API_HOST + '/profile' const method = 'GET' @@ -284,12 +355,13 @@ const fetchApi = async (url: string, options: MinibitsRequestOptions, timeout = const controller = new AbortController() const body = options.body ? JSON.stringify(options.body) : undefined - const jwtAuthRequired = options.jwtAuthRequired || false + const jwtAuthRequired = options.jwtAuthRequired let headers: Record if (jwtAuthRequired) { - headers = await AuthService.getAuthenticatedHeaders() + const jwtAccessToken = await authStore.getValidAccessToken() + headers = getAuthenticatedHeaders(jwtAccessToken) } else { headers = getPublicHeaders() } @@ -308,31 +380,6 @@ const fetchApi = async (url: string, options: MinibitsRequestOptions, timeout = } let response = await makeRequest() - - // Handle 401 responses by attempting token refresh once - if (response.status === 401 && jwtAuthRequired) { - try { - log.trace('[fetchApi] Got 401, attempting token refresh') - await AuthService.refreshTokens() - - // Update headers with new token and retry - headers = await AuthService.getAuthenticatedHeaders() - response = await makeRequest() - - log.trace('[fetchApi] Request retried successfully after token refresh') - } catch (refreshError: any) { - log.error('[fetchApi] Token refresh failed, logging out', refreshError) - - // If refresh fails, logout and throw the original 401 error - try { - await AuthService.logout() - } catch (logoutError: any) { - log.error('[fetchApi] Logout failed', logoutError) - } - - throw new AppError(Err.AUTH_ERROR, 'Authentication failed. Please log in again.', {caller: 'fetchApi', status: 401, url}) - } - } const responseJson = await response.json() as any @@ -370,8 +417,22 @@ const getPublicHeaders = () => { } } +const getAuthenticatedHeaders = (accessToken: string): Record => { + return { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Accept-encoding': 'gzip, deflate', + 'Authorization': `Bearer ${accessToken}`, + 'User-Agent': `Minibits/${JS_BUNDLE_VERSION}` + } +} + export const MinibitsClient = { + getAuthChallenge, + verifyAuthChallenge, + refreshTokens, + logout, getWalletProfile, createWalletProfile, updateWalletProfile, diff --git a/src/utils/authUtils.ts b/src/utils/authUtils.ts new file mode 100644 index 00000000..090b5f75 --- /dev/null +++ b/src/utils/authUtils.ts @@ -0,0 +1,39 @@ +function base64urlDecode(base64url: string) { + // Replace base64url characters with base64 standard ones + let base64 = base64url + .replace(/-/g, '+') // Replace '-' with '+' + .replace(/_/g, '/') // Replace '_' with '/' + .replace(/[^A-Za-z0-9+/=]/g, ''); // Clean up any other characters + + // Pad the base64 string to make it a multiple of 4 if necessary + while (base64.length % 4) { + base64 += '='; + } + + // Decode base64 to a string + const decodedString = atob(base64); + return decodedString; +} + + + + +export const decodeJwtExpiry = (token: string): number | null => { + try { + // Split the token into its parts: header, payload, and signature + const [, payload] = token.split('.') + + if (!payload) { + throw new Error('Invalid JWT token format') + } + + // Decode the payload from Base64URL + const decodedPayload = JSON.parse(base64urlDecode(payload)) + + // Return the `exp` field (expiry time in seconds since epoch) + return decodedPayload.exp || null + } catch (error) { + console.error('Failed to decode JWT token:', error) + return null + } +} \ No newline at end of file