mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
Refactor JWT authStore
This commit is contained in:
+20
-4
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user