Refactor JWT authStore

This commit is contained in:
minibits-cash
2025-08-08 23:53:00 +02:00
parent 97ee8abd4f
commit 7522562db0
13 changed files with 381 additions and 386 deletions
+20 -4
View File
@@ -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)
}
+177 -17
View File
@@ -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,
}
})
+7 -26
View File
@@ -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)
+8 -2
View File
@@ -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) {
+7 -1
View File
@@ -29,7 +29,7 @@ export const RecoverWalletAddressScreen = observer(function RecoverWalletAddress
},
})
const { walletProfileStore, userSettingsStore, walletStore } = useStores()
const { walletProfileStore, userSettingsStore, walletStore, authStore } = useStores()
const mnemonicInputRef = useRef<TextInput>(null)
const seedRef = useRef<Uint8Array | null>(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,
+8 -1
View File
@@ -55,7 +55,8 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
userSettingsStore,
transactionsStore,
walletProfileStore,
walletStore
walletStore,
authStore
} = useStores()
const mnemonicInputRef = useRef<TextInput>(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) {
+3 -3
View File
@@ -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}`,
},
+9 -7
View File
@@ -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'))
+4 -4
View File
@@ -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
+10 -1
View File
@@ -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,
-292
View File
@@ -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<JwtTokens> {
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<string> {
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<JwtTokens> {
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<void> {
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<Record<string, string>> => {
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
}
+89 -28
View File
@@ -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<string, unknown>
headers?: Record<string, string>
jwtAuthRequired?: boolean
jwtAccessToken?: string
}
type MinibitsRequestOptions = MinibitsRequestArgs & Omit<RequestInit, 'body' | 'headers' | 'method'>
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<string, string>
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<string, string> => {
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,
+39
View File
@@ -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
}
}