diff --git a/nftopia-mobile-app/src/services/auth/__tests__/auth.service.test.ts b/nftopia-mobile-app/src/services/auth/__tests__/auth.service.test.ts index c2574f13..f2af7298 100644 --- a/nftopia-mobile-app/src/services/auth/__tests__/auth.service.test.ts +++ b/nftopia-mobile-app/src/services/auth/__tests__/auth.service.test.ts @@ -1,6 +1,6 @@ import { AuthService } from "../auth.service"; import { tokenStorage } from "../tokenStorage"; -import { AuthResponse } from "../types"; +import { EmailAuthResponse } from "../types"; // Tests for AuthService using Jest // tests cover emailLogin, emailRegister, refreshToken, and logout methods @@ -9,7 +9,7 @@ jest.mock("../tokenStorage"); const mockedTokenStorage = tokenStorage as jest.Mocked; -const fakeResponse: AuthResponse = { +const fakeResponse: EmailAuthResponse = { tokens: { accessToken: "access-abc", refreshToken: "refresh-xyz", diff --git a/nftopia-mobile-app/src/services/auth/__tests__/walletAuth.service.test.ts b/nftopia-mobile-app/src/services/auth/__tests__/walletAuth.service.test.ts new file mode 100644 index 00000000..3755157a --- /dev/null +++ b/nftopia-mobile-app/src/services/auth/__tests__/walletAuth.service.test.ts @@ -0,0 +1,419 @@ +import { Keypair } from 'stellar-sdk'; + +jest.mock('expo-secure-store', () => ({ + setItemAsync: jest.fn().mockResolvedValue(undefined), + getItemAsync: jest.fn().mockResolvedValue(null), + deleteItemAsync: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('expo-crypto', () => ({ + CryptoDigestAlgorithm: { SHA256: 'SHA-256' }, + digestStringAsync: jest.fn().mockResolvedValue('mockedhash'), +})); + +jest.mock('stellar-hd-wallet', () => { + const { Keypair: KP } = require('stellar-sdk'); + const mockKeypair = KP.random(); + return { + __esModule: true, + default: { + fromMnemonic: jest.fn().mockReturnValue({ + getKeypair: jest.fn().mockReturnValue(mockKeypair), + }), + }, + }; +}); + +import { WalletAuthService } from '../walletAuth.service'; +import { StellarWalletService } from '../../stellar/wallet.service'; +import { AuthError, AuthErrorCode, AuthResponse, ChallengeResponse, LinkWalletResponse } from '../types'; +import { Wallet } from '../../stellar/types'; +import * as SecureStore from 'expo-secure-store'; + +const VALID_KEYPAIR = Keypair.random(); +const MOCK_WALLET: Wallet = { + publicKey: VALID_KEYPAIR.publicKey(), + secretKey: VALID_KEYPAIR.secret(), +}; + +const MOCK_CHALLENGE: ChallengeResponse = { + sessionId: 'session-abc123', + walletAddress: MOCK_WALLET.publicKey, + nonce: 'nonce-xyz789', + message: 'Sign this message to authenticate: nonce-xyz789', + expiresAt: new Date(Date.now() + 300_000).toISOString(), +}; + +const MOCK_AUTH_RESPONSE: AuthResponse = { + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + user: { + id: 'user-001', + walletAddress: MOCK_WALLET.publicKey, + walletProvider: 'freighter', + }, +}; + +const MOCK_LINK_RESPONSE: LinkWalletResponse = { + success: true, + wallet: { + id: 'wallet-001', + userId: 'user-001', + walletAddress: MOCK_WALLET.publicKey, + isPrimary: false, + }, +}; + +function makeMockWalletService(): jest.Mocked { + return { + signMessage: jest.fn().mockResolvedValue('mock-base64-signature'), + createWallet: jest.fn(), + importFromSecretKey: jest.fn(), + importFromMnemonic: jest.fn(), + getPublicKey: jest.fn(), + isValidSecretKey: jest.fn(), + isValidMnemonic: jest.fn(), + } as unknown as jest.Mocked; +} + +function mockFetchOk(body: unknown): void { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(body), + } as unknown as Response); +} + +function mockFetchError(status: number, message: string): void { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status, + json: jest.fn().mockResolvedValue({ message }), + } as unknown as Response); +} + +describe('WalletAuthService', () => { + let service: WalletAuthService; + let mockWalletService: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockWalletService = makeMockWalletService(); + service = new WalletAuthService(mockWalletService, 'http://test-api.example.com'); + }); + + describe('getChallenge', () => { + it('returns a challenge response for a valid wallet address', async () => { + mockFetchOk(MOCK_CHALLENGE); + + const result = await service.getChallenge(MOCK_WALLET.publicKey); + + expect(result).toEqual(MOCK_CHALLENGE); + expect(global.fetch).toHaveBeenCalledWith( + 'http://test-api.example.com/auth/wallet/challenge', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ walletAddress: MOCK_WALLET.publicKey }), + }), + ); + }); + + it('throws AuthError with CHALLENGE_FAILED on non-ok response', async () => { + mockFetchError(400, 'Invalid wallet address'); + + await expect(service.getChallenge('invalid')).rejects.toThrow(AuthError); + await expect(service.getChallenge('invalid')).rejects.toMatchObject({ + code: AuthErrorCode.CHALLENGE_FAILED, + }); + }); + + it('throws AuthError with NETWORK_ERROR on fetch failure', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Network unreachable')); + + await expect(service.getChallenge(MOCK_WALLET.publicKey)).rejects.toMatchObject({ + code: AuthErrorCode.NETWORK_ERROR, + }); + }); + }); + + describe('authenticate', () => { + it('returns auth response and stores tokens on success', async () => { + mockFetchOk(MOCK_AUTH_RESPONSE); + + const result = await service.authenticate( + MOCK_WALLET.publicKey, + 'mock-signature', + 'mock-nonce', + ); + + expect(result).toEqual(MOCK_AUTH_RESPONSE); + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + 'nftopia_access_token', + 'mock-access-token', + ); + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + 'nftopia_refresh_token', + 'mock-refresh-token', + ); + }); + + it('sends correct request body to verify endpoint', async () => { + mockFetchOk(MOCK_AUTH_RESPONSE); + + await service.authenticate(MOCK_WALLET.publicKey, 'sig-abc', 'nonce-123'); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://test-api.example.com/auth/wallet/verify', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + walletAddress: MOCK_WALLET.publicKey, + signature: 'sig-abc', + nonce: 'nonce-123', + }), + }), + ); + }); + + it('throws AuthError with INVALID_SIGNATURE on 401 response', async () => { + mockFetchError(401, 'Invalid wallet signature'); + + await expect( + service.authenticate(MOCK_WALLET.publicKey, 'bad-sig', 'nonce-123'), + ).rejects.toMatchObject({ code: AuthErrorCode.INVALID_SIGNATURE }); + }); + + it('throws AuthError with AUTHENTICATION_FAILED on non-401 error response', async () => { + mockFetchError(500, 'Internal server error'); + + await expect( + service.authenticate(MOCK_WALLET.publicKey, 'sig', 'nonce'), + ).rejects.toMatchObject({ code: AuthErrorCode.AUTHENTICATION_FAILED }); + }); + + it('throws AuthError with NETWORK_ERROR on fetch failure', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Connection refused')); + + await expect( + service.authenticate(MOCK_WALLET.publicKey, 'sig', 'nonce'), + ).rejects.toMatchObject({ code: AuthErrorCode.NETWORK_ERROR }); + }); + }); + + describe('walletLogin', () => { + it('completes full login flow: challenge → sign → authenticate', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(MOCK_CHALLENGE), + } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(MOCK_AUTH_RESPONSE), + } as unknown as Response); + + const result = await service.walletLogin(MOCK_WALLET); + + expect(result).toEqual(MOCK_AUTH_RESPONSE); + expect(mockWalletService.signMessage).toHaveBeenCalledWith( + MOCK_CHALLENGE.message, + MOCK_WALLET.secretKey, + ); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('stores tokens after successful login', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(MOCK_CHALLENGE), + } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(MOCK_AUTH_RESPONSE), + } as unknown as Response); + + await service.walletLogin(MOCK_WALLET); + + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + 'nftopia_access_token', + MOCK_AUTH_RESPONSE.access_token, + ); + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + 'nftopia_refresh_token', + MOCK_AUTH_RESPONSE.refresh_token, + ); + }); + + it('propagates AuthError if challenge fails', async () => { + mockFetchError(400, 'Invalid wallet address'); + + await expect(service.walletLogin(MOCK_WALLET)).rejects.toMatchObject({ + code: AuthErrorCode.CHALLENGE_FAILED, + }); + expect(mockWalletService.signMessage).not.toHaveBeenCalled(); + }); + + it('propagates AuthError if authentication fails', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(MOCK_CHALLENGE), + } as unknown as Response) + .mockResolvedValueOnce({ + ok: false, + status: 401, + json: jest.fn().mockResolvedValue({ message: 'Invalid signature' }), + } as unknown as Response); + + await expect(service.walletLogin(MOCK_WALLET)).rejects.toMatchObject({ + code: AuthErrorCode.INVALID_SIGNATURE, + }); + }); + }); + + describe('linkWallet', () => { + it('links wallet and returns link response on success', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue('stored-access-token'); + mockFetchOk(MOCK_LINK_RESPONSE); + + const result = await service.linkWallet( + MOCK_WALLET.publicKey, + 'mock-signature', + 'mock-nonce', + ); + + expect(result).toEqual(MOCK_LINK_RESPONSE); + }); + + it('sends Authorization header with stored access token', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue('my-jwt-token'); + mockFetchOk(MOCK_LINK_RESPONSE); + + await service.linkWallet(MOCK_WALLET.publicKey, 'sig', 'nonce'); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://test-api.example.com/auth/wallet/link', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer my-jwt-token', + }), + }), + ); + }); + + it('sends request without Authorization header when no token is stored', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue(null); + mockFetchOk(MOCK_LINK_RESPONSE); + + await service.linkWallet(MOCK_WALLET.publicKey, 'sig', 'nonce'); + + const callHeaders = (global.fetch as jest.Mock).mock.calls[0][1].headers as Record; + expect(callHeaders['Authorization']).toBeUndefined(); + }); + + it('throws AuthError with LINK_FAILED on non-ok response', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue('token'); + mockFetchError(409, 'Wallet already linked to another account'); + + await expect( + service.linkWallet(MOCK_WALLET.publicKey, 'sig', 'nonce'), + ).rejects.toMatchObject({ code: AuthErrorCode.LINK_FAILED }); + }); + + it('throws AuthError with NETWORK_ERROR on fetch failure', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Connection refused')); + + await expect( + service.linkWallet(MOCK_WALLET.publicKey, 'sig', 'nonce'), + ).rejects.toMatchObject({ code: AuthErrorCode.NETWORK_ERROR }); + }); + }); + + describe('unlinkWallet', () => { + it('resolves without error on success', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue('stored-token'); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({}), + } as unknown as Response); + + await expect(service.unlinkWallet(MOCK_WALLET.publicKey)).resolves.toBeUndefined(); + }); + + it('sends DELETE request to unlink endpoint with wallet address', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue('my-jwt-token'); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({}), + } as unknown as Response); + + await service.unlinkWallet(MOCK_WALLET.publicKey); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://test-api.example.com/auth/wallet/unlink', + expect.objectContaining({ + method: 'DELETE', + body: JSON.stringify({ walletAddress: MOCK_WALLET.publicKey }), + }), + ); + }); + + it('sends Authorization header with stored access token', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue('my-jwt-token'); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({}), + } as unknown as Response); + + await service.unlinkWallet(MOCK_WALLET.publicKey); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer my-jwt-token', + }), + }), + ); + }); + + it('throws AuthError with UNLINK_FAILED on non-ok response', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue('token'); + mockFetchError(404, 'Wallet not linked to this account'); + + await expect(service.unlinkWallet(MOCK_WALLET.publicKey)).rejects.toMatchObject({ + code: AuthErrorCode.UNLINK_FAILED, + }); + }); + + it('throws AuthError with NETWORK_ERROR on fetch failure', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Timeout')); + + await expect(service.unlinkWallet(MOCK_WALLET.publicKey)).rejects.toMatchObject({ + code: AuthErrorCode.NETWORK_ERROR, + }); + }); + }); + + describe('AuthError', () => { + it('has correct name and code properties', () => { + const err = new AuthError('Something failed', AuthErrorCode.AUTHENTICATION_FAILED); + expect(err.name).toBe('AuthError'); + expect(err.code).toBe(AuthErrorCode.AUTHENTICATION_FAILED); + expect(err.message).toBe('Something failed'); + expect(err instanceof Error).toBe(true); + }); + }); +}); diff --git a/nftopia-mobile-app/src/services/auth/auth.service.ts b/nftopia-mobile-app/src/services/auth/auth.service.ts index 25b210c5..d3663478 100644 --- a/nftopia-mobile-app/src/services/auth/auth.service.ts +++ b/nftopia-mobile-app/src/services/auth/auth.service.ts @@ -1,5 +1,5 @@ import axios, { AxiosInstance } from "axios"; -import { AuthResponse, AuthError } from "./types"; +import { EmailAuthResponse, ApiAuthError } from "./types"; import { tokenStorage } from "./tokenStorage"; // error handling function @@ -7,10 +7,10 @@ function handleError(error: unknown): never { if (axios.isAxiosError(error)) { const message = error.response?.data?.message ?? error.message; const statusCode = error.response?.status; - const authError: AuthError = { message, statusCode }; + const authError: ApiAuthError = { message, statusCode }; throw authError; } - throw { message: "Something went wrong. Please try again." } as AuthError; + throw { message: "Something went wrong. Please try again." } as ApiAuthError; } // AuthService class for API Calls @@ -25,9 +25,9 @@ export class AuthService { } // Login with email and password - async emailLogin(email: string, password: string): Promise { + async emailLogin(email: string, password: string): Promise { try { - const { data } = await this.api.post( + const { data } = await this.api.post( "/api/v1/auth/email/login", { email, password }, ); @@ -46,9 +46,9 @@ export class AuthService { email: string, password: string, username: string, - ): Promise { + ): Promise { try { - const { data } = await this.api.post( + const { data } = await this.api.post( "/api/v1/auth/email/register", { email, password, username }, ); @@ -63,9 +63,9 @@ export class AuthService { } // Refresh access token using refresh token - async refreshToken(refreshToken: string): Promise { + async refreshToken(refreshToken: string): Promise { try { - const { data } = await this.api.post( + const { data } = await this.api.post( "/api/v1/auth/refresh", { refreshToken }, ); diff --git a/nftopia-mobile-app/src/services/auth/types.ts b/nftopia-mobile-app/src/services/auth/types.ts index 2cceceb0..7d4716fc 100644 --- a/nftopia-mobile-app/src/services/auth/types.ts +++ b/nftopia-mobile-app/src/services/auth/types.ts @@ -1,3 +1,4 @@ +// --- Email auth types --- export interface AuthCredentials { email: string; password: string; @@ -18,12 +19,71 @@ export interface UserProfile { username: string; } -export interface AuthResponse { +// Response shape for email-based auth endpoints (tokens wrapped in object) +export interface EmailAuthResponse { tokens: AuthTokens; user: UserProfile; } -export interface AuthError { +// API-level error shape returned by the backend on failure +export interface ApiAuthError { message: string; statusCode?: number; } + +// --- Wallet auth types --- +export interface User { + id: string; + email?: string; + username?: string; + address?: string; + walletAddress?: string; + walletProvider?: string; +} + +// Matches the backend's actual response shape (snake_case keys) +export interface AuthResponse { + access_token: string; + refresh_token: string; + user: User; +} + +export interface ChallengeResponse { + sessionId: string; + walletAddress: string; + nonce: string; + message: string; + expiresAt: string; +} + +export interface LinkWalletResponse { + success: boolean; + wallet: { + id: string; + userId: string; + walletAddress: string; + walletProvider?: string; + isPrimary: boolean; + }; +} + +export class AuthError extends Error { + constructor( + message: string, + public readonly code: AuthErrorCode, + ) { + super(message); + this.name = 'AuthError'; + } +} + +export enum AuthErrorCode { + CHALLENGE_FAILED = 'CHALLENGE_FAILED', + AUTHENTICATION_FAILED = 'AUTHENTICATION_FAILED', + INVALID_SIGNATURE = 'INVALID_SIGNATURE', + EXPIRED_NONCE = 'EXPIRED_NONCE', + LINK_FAILED = 'LINK_FAILED', + UNLINK_FAILED = 'UNLINK_FAILED', + NETWORK_ERROR = 'NETWORK_ERROR', + TOKEN_STORAGE_ERROR = 'TOKEN_STORAGE_ERROR', +} diff --git a/nftopia-mobile-app/src/services/auth/walletAuth.service.ts b/nftopia-mobile-app/src/services/auth/walletAuth.service.ts new file mode 100644 index 00000000..ebef8d24 --- /dev/null +++ b/nftopia-mobile-app/src/services/auth/walletAuth.service.ts @@ -0,0 +1,178 @@ +import * as SecureStore from 'expo-secure-store'; +import { Wallet } from '../stellar/types'; +import { StellarWalletService } from '../stellar/wallet.service'; +import { + AuthError, + AuthErrorCode, + AuthResponse, + ChallengeResponse, + LinkWalletResponse, +} from './types'; + +const ACCESS_TOKEN_KEY = 'nftopia_access_token'; +const REFRESH_TOKEN_KEY = 'nftopia_refresh_token'; + +export class WalletAuthService { + private readonly walletService: StellarWalletService; + private readonly baseUrl: string; + + constructor(walletService?: StellarWalletService, baseUrl?: string) { + this.walletService = walletService ?? new StellarWalletService(); + this.baseUrl = baseUrl ?? 'http://localhost:3000'; + } + + async getChallenge(walletAddress: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/auth/wallet/challenge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ walletAddress }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})) as { message?: string }; + throw new AuthError( + error.message ?? `Challenge request failed with status ${response.status}`, + AuthErrorCode.CHALLENGE_FAILED, + ); + } + + return response.json() as Promise; + } catch (err) { + if (err instanceof AuthError) throw err; + throw new AuthError( + `Failed to get challenge: ${(err as Error).message}`, + AuthErrorCode.NETWORK_ERROR, + ); + } + } + + async authenticate( + walletAddress: string, + signature: string, + nonce: string, + ): Promise { + try { + const response = await fetch(`${this.baseUrl}/auth/wallet/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ walletAddress, signature, nonce }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})) as { message?: string }; + const message = + error.message ?? `Authentication failed with status ${response.status}`; + const code = + response.status === 401 + ? AuthErrorCode.INVALID_SIGNATURE + : AuthErrorCode.AUTHENTICATION_FAILED; + throw new AuthError(message, code); + } + + const authResponse = (await response.json()) as AuthResponse; + await this._storeTokens(authResponse.access_token, authResponse.refresh_token); + return authResponse; + } catch (err) { + if (err instanceof AuthError) throw err; + throw new AuthError( + `Failed to authenticate: ${(err as Error).message}`, + AuthErrorCode.NETWORK_ERROR, + ); + } + } + + async walletLogin(wallet: Wallet): Promise { + const challenge = await this.getChallenge(wallet.publicKey); + const signature = await this.walletService.signMessage( + challenge.message, + wallet.secretKey, + ); + return this.authenticate(wallet.publicKey, signature, challenge.nonce); + } + + async linkWallet( + walletAddress: string, + signature: string, + nonce: string, + ): Promise { + try { + const accessToken = await this._getAccessToken(); + const response = await fetch(`${this.baseUrl}/auth/wallet/link`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + body: JSON.stringify({ walletAddress, signature, nonce }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})) as { message?: string }; + throw new AuthError( + error.message ?? `Wallet link failed with status ${response.status}`, + AuthErrorCode.LINK_FAILED, + ); + } + + return response.json() as Promise; + } catch (err) { + if (err instanceof AuthError) throw err; + throw new AuthError( + `Failed to link wallet: ${(err as Error).message}`, + AuthErrorCode.NETWORK_ERROR, + ); + } + } + + async unlinkWallet(walletAddress: string): Promise { + try { + const accessToken = await this._getAccessToken(); + const response = await fetch(`${this.baseUrl}/auth/wallet/unlink`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + body: JSON.stringify({ walletAddress }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})) as { message?: string }; + throw new AuthError( + error.message ?? `Wallet unlink failed with status ${response.status}`, + AuthErrorCode.UNLINK_FAILED, + ); + } + } catch (err) { + if (err instanceof AuthError) throw err; + throw new AuthError( + `Failed to unlink wallet: ${(err as Error).message}`, + AuthErrorCode.NETWORK_ERROR, + ); + } + } + + private async _storeTokens( + accessToken: string, + refreshToken: string, + ): Promise { + try { + await SecureStore.setItemAsync(ACCESS_TOKEN_KEY, accessToken); + await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, refreshToken); + } catch (err) { + throw new AuthError( + `Failed to store tokens: ${(err as Error).message}`, + AuthErrorCode.TOKEN_STORAGE_ERROR, + ); + } + } + + private async _getAccessToken(): Promise { + try { + return await SecureStore.getItemAsync(ACCESS_TOKEN_KEY); + } catch { + return null; + } + } +}