diff --git a/packages/common/src/hooks/index.ts b/packages/common/src/hooks/index.ts index 25ff3b75afd..75c98f99866 100644 --- a/packages/common/src/hooks/index.ts +++ b/packages/common/src/hooks/index.ts @@ -31,6 +31,7 @@ export * from './useCollectionMetadata' export * from './useCollectionDogEar' export * from './useCollectionAccessTypeLabel' export * from './useCoinflowAdapter' +export * from './useCoinflowSessionKey' export * from './useChangePasswordFormConfiguration' export * from './useChangeEmailFormConfiguration' export * from './useCanSendChatBlast' diff --git a/packages/common/src/hooks/useCoinflowAdapter.ts b/packages/common/src/hooks/useCoinflowAdapter.ts index 976f546fbc3..a11c11402a5 100644 --- a/packages/common/src/hooks/useCoinflowAdapter.ts +++ b/packages/common/src/hooks/useCoinflowAdapter.ts @@ -21,6 +21,8 @@ import { } from '~/store' type CoinflowAdapter = { + accountWallet: string | null | undefined + signerWallet: string | null | undefined wallet: { publicKey: PublicKey sendTransaction: ( @@ -41,10 +43,11 @@ export const useCoinflowWithdrawalAdapter = () => { } = useAppContext() const [adapter, setAdapter] = useState(null) const { data: walletAddresses } = useWalletAddresses() - const { currentUser } = walletAddresses ?? {} + const { currentUser, web3User } = walletAddresses ?? {} const { audiusSdk, solanaWalletService } = useQueryContext() useEffect(() => { + let canceled = false const initWallet = async () => { const wallet = await solanaWalletService.getKeypair() const sdk = await audiusSdk() @@ -57,7 +60,10 @@ export const useCoinflowWithdrawalAdapter = () => { return } + if (canceled) return setAdapter({ + accountWallet: currentUser, + signerWallet: web3User, connection, wallet: { publicKey: wallet.publicKey, @@ -111,9 +117,23 @@ export const useCoinflowWithdrawalAdapter = () => { }) } initWallet() - }, [audiusBackend, currentUser, solanaWalletService, make, track, audiusSdk]) + return () => { + canceled = true + } + }, [ + audiusBackend, + currentUser, + web3User, + solanaWalletService, + make, + track, + audiusSdk + ]) - return adapter + return adapter?.accountWallet === currentUser && + adapter?.signerWallet === web3User + ? adapter + : null } /** An adapter for signing and sending unmodified Coinflow transactions. Will partialSign with the @@ -128,11 +148,14 @@ export const useCoinflowAdapter = ({ onFailure: () => void }) => { const { audiusBackend } = useAppContext() + const { data: walletAddresses } = useWalletAddresses() + const { currentUser, web3User } = walletAddresses ?? {} const [adapter, setAdapter] = useState(null) const { audiusSdk, solanaWalletService } = useQueryContext() const dispatch = useDispatch() useEffect(() => { + let canceled = false const initWallet = async () => { const wallet = await solanaWalletService.getKeypair() const sdk = await audiusSdk() @@ -144,7 +167,10 @@ export const useCoinflowAdapter = ({ return } + if (canceled) return setAdapter({ + accountWallet: currentUser, + signerWallet: web3User, connection, wallet: { publicKey: wallet.publicKey, @@ -192,7 +218,12 @@ export const useCoinflowAdapter = ({ }) } initWallet() + return () => { + canceled = true + } }, [ + currentUser, + web3User, audiusBackend, solanaWalletService, audiusSdk, @@ -201,5 +232,8 @@ export const useCoinflowAdapter = ({ onFailure ]) - return adapter + return adapter?.accountWallet === currentUser && + adapter?.signerWallet === web3User + ? adapter + : null } diff --git a/packages/common/src/hooks/useCoinflowSessionKey.test.tsx b/packages/common/src/hooks/useCoinflowSessionKey.test.tsx new file mode 100644 index 00000000000..b63c6ca6e2f --- /dev/null +++ b/packages/common/src/hooks/useCoinflowSessionKey.test.tsx @@ -0,0 +1,186 @@ +// @vitest-environment jsdom +import { PropsWithChildren } from 'react' + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, cleanup, renderHook } from '@testing-library/react' +import nacl from 'tweetnacl' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useCoinflowSessionKey } from './useCoinflowSessionKey' + +const mocks = vi.hoisted(() => ({ + addresses: { currentUser: '0xabc', web3User: '0xabc' } as { + currentUser: string | null + web3User: string | null + }, + getKeypair: vi.fn(), + createSession: vi.fn() +})) + +vi.mock('~/api', () => ({ + useWalletAddresses: () => ({ data: mocks.addresses }) +})) +vi.mock('~/api/tan-query/utils', () => ({ + useQueryContext: () => ({ + identityService: { + identityServiceEndpoint: 'https://identity.example', + createCoinflowSessionKey: mocks.createSession + }, + solanaWalletService: { getKeypair: mocks.getKeypair } + }) +})) + +const keys = nacl.sign.keyPair.fromSeed(new Uint8Array(32).fill(1)) +const minutes = (value: number) => value * 60 * 1000 +const session = (key: string) => ({ key, expiresAt: Date.now() + minutes(30) }) +let client: QueryClient + +function Wrapper({ children }: PropsWithChildren) { + return {children} +} + +const flush = async (ms = 10) => { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms) + }) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-28T12:00:00Z')) + vi.clearAllMocks() + mocks.addresses = { currentUser: '0xabc', web3User: '0xabc' } + mocks.getKeypair.mockResolvedValue({ + publicKey: { toBase58: () => 'wallet-a' }, + secretKey: keys.secretKey + }) + mocks.createSession.mockImplementation(async () => session('key-a')) + client = new QueryClient() +}) + +afterEach(() => { + cleanup() + client.clear() + vi.useRealTimers() +}) + +describe('useCoinflowSessionKey', () => { + const props = { + wallet: 'wallet-a', + environment: 'sandbox' as const, + enabled: true + } + + it('waits until opened, then signs proof for the active identity and wallet', async () => { + const { result, rerender } = renderHook(useCoinflowSessionKey, { + wrapper: Wrapper, + initialProps: { ...props, enabled: false } + }) + await flush() + expect(mocks.createSession).not.toHaveBeenCalled() + expect(result.current.sessionKey).toBeUndefined() + rerender(props) + await flush() + expect(result.current.sessionKey).toBe('key-a') + const proof = mocks.createSession.mock.calls[0][0] + const message = `Audius Coinflow session:0xabc:wallet-a:sandbox:${proof.timestamp}` + expect( + nacl.sign.detached.verify( + new Uint8Array(Buffer.from(message)), + new Uint8Array(Buffer.from(proof.signature, 'base64')), + keys.publicKey + ) + ).toBe(true) + }) + + it('refreshes before expiry and stops using the old key if refresh fails', async () => { + const { result } = renderHook(useCoinflowSessionKey, { + wrapper: Wrapper, + initialProps: props + }) + await flush() + expect(result.current.sessionKey).toBe('key-a') + mocks.createSession.mockRejectedValue(new Error('Network error')) + await flush(minutes(25) + 2000) + expect(mocks.createSession.mock.calls.length).toBeGreaterThanOrEqual(3) + expect(result.current.sessionKey).toBe('key-a') + await flush(minutes(4)) + expect(result.current.sessionKey).toBeUndefined() + expect(result.current.isError).toBe(true) + mocks.createSession.mockResolvedValue(session('key-refreshed')) + await act(async () => { + await result.current.retry() + }) + await flush() + expect(result.current.sessionKey).toBe('key-refreshed') + }) + + it('does not reuse a key across account changes or logout', async () => { + const { result, rerender } = renderHook(useCoinflowSessionKey, { + wrapper: Wrapper, + initialProps: props + }) + await flush() + expect(result.current.sessionKey).toBe('key-a') + mocks.addresses = { currentUser: '0xdef', web3User: '0xdef' } + mocks.createSession.mockImplementation(async () => session('key-b')) + rerender(props) + expect(result.current.sessionKey).toBeUndefined() + await flush() + expect(result.current.sessionKey).toBe('key-b') + mocks.addresses = { currentUser: null, web3User: null } + rerender(props) + expect(result.current.sessionKey).toBeUndefined() + }) + + it('ignores late session responses after a wallet switch', async () => { + let resolveOld!: (value: { key: string; expiresAt: number }) => void + mocks.createSession.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOld = resolve + }) + ) + const { result, rerender } = renderHook(useCoinflowSessionKey, { + wrapper: Wrapper, + initialProps: props + }) + await flush() + mocks.getKeypair.mockResolvedValue({ + publicKey: { toBase58: () => 'wallet-b' }, + secretKey: keys.secretKey + }) + mocks.createSession.mockResolvedValue(session('key-b')) + rerender({ ...props, wallet: 'wallet-b' }) + await flush() + expect(result.current.sessionKey).toBe('key-b') + resolveOld(session('key-a')) + await flush() + expect(result.current.sessionKey).toBe('key-b') + }) + + it('rejects an adapter whose wallet no longer matches the signing wallet', async () => { + const { result } = renderHook(useCoinflowSessionKey, { + wrapper: Wrapper, + initialProps: { ...props, wallet: 'stale-wallet' } + }) + await flush(2000) + expect(mocks.createSession).not.toHaveBeenCalled() + expect(result.current.sessionKey).toBeUndefined() + expect(result.current.isError).toBe(true) + }) + + it('discards invalid or already expired credentials', async () => { + mocks.createSession.mockResolvedValue({ + key: 'expired', + expiresAt: Date.now() + }) + const { result } = renderHook(useCoinflowSessionKey, { + wrapper: Wrapper, + initialProps: props + }) + await flush(2000) + expect(result.current.sessionKey).toBeUndefined() + expect(result.current.isError).toBe(true) + }) +}) diff --git a/packages/common/src/hooks/useCoinflowSessionKey.ts b/packages/common/src/hooks/useCoinflowSessionKey.ts new file mode 100644 index 00000000000..bcaa5ec9812 --- /dev/null +++ b/packages/common/src/hooks/useCoinflowSessionKey.ts @@ -0,0 +1,98 @@ +import { useEffect, useReducer } from 'react' + +import { useQuery } from '@tanstack/react-query' +import nacl from 'tweetnacl' + +import { useWalletAddresses } from '~/api' +import { useQueryContext } from '~/api/tan-query/utils' + +const REFRESH_INTERVAL_MS = 25 * 60 * 1000 +const EXPIRY_BUFFER_MS = 60 * 1000 + +/** Keep Coinflow credentials in memory and scoped to the active wallet. */ +export const useCoinflowSessionKey = ({ + wallet, + environment, + enabled +}: { + wallet: string | undefined + environment: 'prod' | 'sandbox' + enabled: boolean +}) => { + const { identityService, solanaWalletService } = useQueryContext() + const { data: addresses } = useWalletAddresses() + const identityWallet = addresses?.web3User ?? addresses?.currentUser + const [, render] = useReducer((value: number) => value + 1, 0) + const isEnabled = enabled && !!wallet && !!identityWallet + + const query = useQuery({ + queryKey: [ + 'coinflowSessionKey', + identityService.identityServiceEndpoint, + environment, + addresses?.currentUser, + identityWallet, + wallet + ], + queryFn: async () => { + const keypair = await solanaWalletService.getKeypair() + if ( + !wallet || + !identityWallet || + !keypair || + keypair.publicKey.toBase58() !== wallet + ) { + throw new Error('Coinflow wallet is unavailable') + } + const timestamp = Date.now() + const message = `Audius Coinflow session:${identityWallet.toLowerCase()}:${wallet}:${environment}:${timestamp}` + const signature = Buffer.from( + nacl.sign.detached( + new Uint8Array(Buffer.from(message)), + keypair.secretKey + ) + ).toString('base64') + const session = await identityService.createCoinflowSessionKey({ + wallet, + signature, + timestamp, + environment + }) + if ( + !session.key || + !Number.isFinite(session.expiresAt) || + session.expiresAt <= Date.now() + EXPIRY_BUFFER_MS + ) { + throw new Error('Coinflow session is invalid or expired') + } + return session + }, + enabled: isEnabled, + staleTime: REFRESH_INTERVAL_MS, + gcTime: 0, + retry: 1, + refetchInterval: isEnabled ? REFRESH_INTERVAL_MS : false + }) + + const expiresAt = query.data?.expiresAt + useEffect(() => { + if (!isEnabled || !expiresAt) return + // If refresh fails, stop using the previous key before it expires. + const timer = setTimeout( + render, + Math.max(0, expiresAt - Date.now() - EXPIRY_BUFFER_MS) + ) + return () => clearTimeout(timer) + }, [expiresAt, isEnabled]) + + return { + sessionKey: + isEnabled && expiresAt && expiresAt > Date.now() + EXPIRY_BUFFER_MS + ? query.data?.key + : undefined, + isPending: query.isPending, + isError: query.isError, + isFetching: query.isFetching, + retry: query.refetch + } +} diff --git a/packages/common/src/services/auth/identity.ts b/packages/common/src/services/auth/identity.ts index 877ca250fdf..90694e9f2c3 100644 --- a/packages/common/src/services/auth/identity.ts +++ b/packages/common/src/services/auth/identity.ts @@ -337,6 +337,21 @@ export class IdentityService { }) } + async createCoinflowSessionKey(data: { + wallet: string + signature: string + timestamp: number + environment: 'prod' | 'sandbox' + }) { + return await this._makeRequest<{ key: string; expiresAt: number }>({ + url: '/coinflow/session-key', + method: 'post', + headers: await this.getAuthHeaders(), + data, + timeout: 15000 + }) + } + async createPersonaSessionToken() { const headers = await this.getAuthHeaders() diff --git a/packages/common/vitest.config.ts b/packages/common/vitest.config.ts index 354a527efcc..29b1f522eb8 100644 --- a/packages/common/vitest.config.ts +++ b/packages/common/vitest.config.ts @@ -1,10 +1,12 @@ +import { fileURLToPath } from 'node:url' + import { defineConfig } from 'vitest/config' export default defineConfig(() => { return { resolve: { alias: { - '~': '/src' + '~': fileURLToPath(new URL('./src', import.meta.url)) } } } diff --git a/packages/identity-service/README.md b/packages/identity-service/README.md index 424fbea1dbc..698b823e817 100644 --- a/packages/identity-service/README.md +++ b/packages/identity-service/README.md @@ -3,3 +3,42 @@ The identity service maintains all the identity aspects of the Audius ecosystem such as storing encrypted auth ciphertexts, doing Twitter oauth and relay transactions on behalf of users Read [the wiki](https://github.com/AudiusProject/apps/wiki/Identity-Service:-Overview) for more info. + +## Coinflow session authentication + +Coinflow purchase and withdrawal components obtain their session key from +`POST /coinflow/session-key`. The endpoint requires identity-service auth headers +and a fresh Ed25519 ownership proof from the Solana root wallet. The root wallet +is different from the public `spl_wallet` user bank address. No database migration +is required. + +Configure these server-side environment variables before deploying the clients: + +- `coinflowApiKey`: the secret Coinflow merchant API key. Use the key for the + merchant configured in the clients (`audius` in development, `tikilabs` in + production). Do not put this key in web/mobile environment files. +- `coinflowEnvironment`: `sandbox` (default) or `prod`, matching the clients. + +Deploy the configured identity service before the web and mobile updates. +Existing mobile installations also need the client update; a backend deployment +alone does not migrate them. No Coinflow SDK upgrade is needed. + +The client sends `{ wallet, environment, timestamp, signature }`, with a Unix +millisecond timestamp and base64 Ed25519 signature over the UTF-8 string +`Audius Coinflow session::::`. +The proof is valid for five minutes and is bound to the authenticated identity. +The endpoint returns `{ key, expiresAt }` with `Cache-Control: no-store`. + +Session keys are kept in memory, scoped by identity, wallet, and environment, +and refreshed after 25 minutes. The components stop using an unrefreshed key +one minute before its documented 30-minute expiry. Upstream errors are sanitized +so merchant credentials are not logged or returned. + +Before production rollout, verify purchase and withdrawal on web, iOS, and +Android in sandbox, including guest checkout, session refresh, failed refresh +and retry, logout, and account switching. Verify that the embedded Coinflow +requests use session authentication. Confirm completion with Coinflow only after +production rollout and verification. + +References: [session-key API](https://docs.coinflow.cash/api-reference/api-reference/authentication/get-session-key), +[session lifetime](https://docs.coinflow.cash/guides/payouts/implementation-methods/bank-authentication-ui). diff --git a/packages/identity-service/src/config.js b/packages/identity-service/src/config.js index c53c9187e60..e60517fb9cb 100644 --- a/packages/identity-service/src/config.js +++ b/packages/identity-service/src/config.js @@ -485,6 +485,19 @@ const config = convict({ env: 'hCaptchaSecret', default: '' }, + coinflowApiKey: { + doc: 'Coinflow merchant API key. Must match the client merchant ID and environment.', + format: String, + env: 'coinflowApiKey', + sensitive: true, + default: '' + }, + coinflowEnvironment: { + doc: 'Coinflow API environment', + format: ['sandbox', 'prod'], + env: 'coinflowEnvironment', + default: 'sandbox' + }, plaidClientId: { doc: 'Plaid client ID', format: String, diff --git a/packages/identity-service/src/routes/coinflow.js b/packages/identity-service/src/routes/coinflow.js new file mode 100644 index 00000000000..b013e3756b7 --- /dev/null +++ b/packages/identity-service/src/routes/coinflow.js @@ -0,0 +1,101 @@ +const { createPublicKey, verify } = require('crypto') +const { PublicKey } = require('@solana/web3.js') +const axios = require('axios') +const axiosHttpAdapter = require('axios/lib/adapters/http') + +const config = require('../config') +const authMiddleware = require('../authMiddleware') +const { + handleResponse, + successResponse, + errorResponseBadRequest, + errorResponseForbidden, + errorResponseServerError +} = require('../apiHelpers') + +const PROOF_MAX_AGE_MS = 5 * 60 * 1000 +const SESSION_DURATION_MS = 30 * 60 * 1000 +// ASN.1 SubjectPublicKeyInfo prefix for an Ed25519 public key. +const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') + +module.exports = function (app) { + app.post( + '/coinflow/session-key', + authMiddleware, + handleResponse(async (req, res) => { + res.set('Cache-Control', 'no-store') + const { wallet, signature, timestamp, environment } = req.body + if ( + typeof wallet !== 'string' || + typeof signature !== 'string' || + !Number.isSafeInteger(timestamp) || + timestamp > Date.now() + 30000 || + Date.now() - timestamp > PROOF_MAX_AGE_MS || + environment !== config.get('coinflowEnvironment') + ) { + return errorResponseBadRequest('Invalid Coinflow session request') + } + + // The root wallet is derived locally and is not the public spl_wallet + // (user bank). Require proof of ownership, bound to this identity and + // environment, rather than trusting a caller-supplied wallet address. + const message = `Audius Coinflow session:${req.user.walletAddress.toLowerCase()}:${wallet}:${environment}:${timestamp}` + try { + const publicKey = createPublicKey({ + key: Buffer.concat([ + ED25519_SPKI_PREFIX, + new PublicKey(wallet).toBuffer() + ]), + format: 'der', + type: 'spki' + }) + const signatureBytes = Buffer.from(signature, 'base64') + if ( + signatureBytes.length !== 64 || + !verify(null, Buffer.from(message), publicKey, signatureBytes) + ) { + return errorResponseForbidden('Invalid wallet ownership proof') + } + } catch { + return errorResponseForbidden('Invalid wallet ownership proof') + } + + const apiKey = config.get('coinflowApiKey') + if (!apiKey) { + return errorResponseServerError('Coinflow is not configured') + } + const baseUrl = + environment === 'prod' + ? 'https://api.coinflow.cash' + : 'https://api-sandbox.coinflow.cash' + const requestedAt = Date.now() + try { + const response = await axios({ + adapter: axiosHttpAdapter, + method: 'GET', + url: `${baseUrl}/api/auth/session-key`, + timeout: 10000, + headers: { + Authorization: apiKey, + 'x-coinflow-auth-wallet': wallet, + 'x-coinflow-auth-blockchain': 'solana' + } + }) + if (typeof response.data?.key !== 'string' || !response.data.key) { + throw new Error('Missing session key') + } + return successResponse({ + key: response.data.key, + expiresAt: requestedAt + SESSION_DURATION_MS + }) + } catch (error) { + // Axios errors include the merchant key in their request config. + req.logger.error( + { status: error.response?.status }, + 'Failed to create Coinflow session' + ) + return errorResponseServerError('Could not create Coinflow session') + } + }) + ) +} diff --git a/packages/identity-service/test/coinflowTest.js b/packages/identity-service/test/coinflowTest.js new file mode 100644 index 00000000000..859944ddd7f --- /dev/null +++ b/packages/identity-service/test/coinflowTest.js @@ -0,0 +1,195 @@ +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const vm = require('vm') +const { Keypair } = require('@solana/web3.js') +const { createPrivateKey, sign } = require('crypto') + +// Load the route with isolated service boundaries so these tests do not need +// Postgres, Redis, a merchant key, or a live payment provider. +function loadModule(filename, dependencies) { + const module = { exports: {} } + vm.runInNewContext( + fs.readFileSync(filename, 'utf8'), + { + module, + exports: module.exports, + require: (name) => dependencies[name] ?? require(name), + Buffer, + Date + }, + { filename } + ) + return module.exports +} + +const apiHelpers = loadModule(path.join(__dirname, '../src/apiHelpers.js'), { + './logging': { requestNotExcludedFromLogging: () => true } +}) + +const PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex') + +describe('Coinflow session authentication', function () { + let handler, authMiddleware, middleware, settings, calls, logs, response + const keypair = Keypair.generate() + const wallet = keypair.publicKey.toBase58() + const identity = '0x1234567890123456789012345678901234567890' + const privateKey = createPrivateKey({ + key: Buffer.concat([ + PKCS8_PREFIX, + Buffer.from(keypair.secretKey.slice(0, 32)) + ]), + type: 'pkcs8', + format: 'der' + }) + + beforeEach(() => { + calls = [] + logs = [] + settings = { + coinflowApiKey: 'merchant-secret', + coinflowEnvironment: 'sandbox' + } + response = { data: { key: 'session-secret' } } + authMiddleware = () => {} + const register = loadModule( + path.join(__dirname, '../src/routes/coinflow.js'), + { + '../config': { get: (key) => settings[key] }, + '../authMiddleware': authMiddleware, + '../apiHelpers': { ...apiHelpers, handleResponse: (fn) => fn }, + axios: async (request) => { + calls.push(request) + if (response instanceof Error) throw response + return response + }, + 'axios/lib/adapters/http': () => {} + } + ) + register({ + post: (route, auth, fn) => { + assert.strictEqual(route, '/coinflow/session-key') + middleware = auth + handler = fn + } + }) + }) + + function request(overrides = {}, userWallet = identity) { + const timestamp = Date.now() + const environment = settings.coinflowEnvironment + const message = `Audius Coinflow session:${identity}:${wallet}:${environment}:${timestamp}` + return { + body: { + wallet, + timestamp, + environment, + signature: sign(null, Buffer.from(message), privateKey).toString( + 'base64' + ), + ...overrides + }, + user: { walletAddress: userWallet }, + logger: { error: (...args) => logs.push(args) } + } + } + + async function invoke(req = request()) { + const headers = {} + const result = await handler(req, { + set: (key, value) => { + headers[key] = value + } + }) + assert.strictEqual(headers['Cache-Control'], 'no-store') + return result + } + + it('requires identity authentication and uses the verified root wallet in the upstream request', async () => { + assert.strictEqual(middleware, authMiddleware) + const before = Date.now() + const result = await invoke() + assert.strictEqual(result.statusCode, 200) + assert.strictEqual(result.object.key, 'session-secret') + assert(result.object.expiresAt >= before + 30 * 60 * 1000) + assert.strictEqual(calls.length, 1) + assert.strictEqual( + calls[0].url, + 'https://api-sandbox.coinflow.cash/api/auth/session-key' + ) + assert.strictEqual(calls[0].method, 'GET') + assert.strictEqual(calls[0].headers.Authorization, 'merchant-secret') + assert.strictEqual(calls[0].headers['x-coinflow-auth-wallet'], wallet) + assert.strictEqual(calls[0].headers['x-coinflow-auth-blockchain'], 'solana') + assert.strictEqual(calls[0].timeout, 10000) + }) + + it('uses the production API only when configured for production', async () => { + settings.coinflowEnvironment = 'prod' + assert.strictEqual((await invoke()).statusCode, 200) + assert.strictEqual( + calls[0].url, + 'https://api.coinflow.cash/api/auth/session-key' + ) + }) + + it('rejects another wallet, another identity, and tampered signatures', async () => { + for (const req of [ + request({ wallet: Keypair.generate().publicKey.toBase58() }), + request({}, '0x9999999999999999999999999999999999999999'), + request({ signature: Buffer.alloc(64).toString('base64') }), + request({ wallet: 'not-a-solana-wallet' }), + request({ signature: '' }) + ]) { + assert.strictEqual((await invoke(req)).statusCode, 403) + } + assert.strictEqual(calls.length, 0) + }) + + it('rejects stale, future, missing, or wrong-environment proofs', async () => { + for (const overrides of [ + { timestamp: Date.now() - 6 * 60 * 1000 }, + { timestamp: Date.now() + 60 * 1000 }, + { timestamp: undefined }, + { timestamp: '123' }, + { environment: 'prod' }, + { wallet: undefined }, + { signature: undefined } + ]) { + assert.strictEqual((await invoke(request(overrides))).statusCode, 400) + } + assert.strictEqual(calls.length, 0) + }) + + it('rejects changing the environment even when the new environment is configured', async () => { + const req = request() + settings.coinflowEnvironment = 'prod' + req.body.environment = 'prod' + assert.strictEqual((await invoke(req)).statusCode, 403) + assert.strictEqual(calls.length, 0) + }) + + it('fails closed without a configured merchant key', async () => { + settings.coinflowApiKey = '' + assert.strictEqual((await invoke()).statusCode, 500) + assert.strictEqual(calls.length, 0) + }) + + it('does not expose merchant credentials or upstream error bodies', async () => { + response = new Error('merchant-secret') + response.config = { headers: { Authorization: 'merchant-secret' } } + response.response = { status: 401, data: 'session-secret' } + const result = await invoke() + assert.strictEqual(result.statusCode, 500) + const output = JSON.stringify({ result, logs }) + assert(!output.includes('merchant-secret')) + assert(!output.includes('session-secret')) + }) + + it('rejects malformed upstream responses', async () => { + for (const data of [{}, { key: '' }, { key: 123 }, null]) { + response = { data } + assert.strictEqual((await invoke()).statusCode, 500) + } + }) +}) diff --git a/packages/identity-service/test/index.ts b/packages/identity-service/test/index.ts index 400cf4c849d..657853b6a47 100644 --- a/packages/identity-service/test/index.ts +++ b/packages/identity-service/test/index.ts @@ -1,5 +1,6 @@ require('./expressAppTest') require('./apiHelpersTest') +require('./coinflowTest') require('./authenticationTest') require('./relayTest') require('./configTest') diff --git a/packages/mobile/src/components/coinflow-onramp-drawer/CoinflowOnrampDrawer.tsx b/packages/mobile/src/components/coinflow-onramp-drawer/CoinflowOnrampDrawer.tsx index 77e0c4ad265..d531fe1b4ee 100644 --- a/packages/mobile/src/components/coinflow-onramp-drawer/CoinflowOnrampDrawer.tsx +++ b/packages/mobile/src/components/coinflow-onramp-drawer/CoinflowOnrampDrawer.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react' -import { useCoinflowAdapter } from '@audius/common/hooks' +import { useCoinflowAdapter, useCoinflowSessionKey } from '@audius/common/hooks' import { coinflowModalUIActions, useCoinflowOnrampModal @@ -19,6 +19,8 @@ import { spacing } from 'app/styles/spacing' import { useThemeColors } from 'app/utils/theme' import { zIndex } from 'app/utils/zIndex' +import { CoinflowSessionStatus } from './CoinflowSessionStatus' + const MODAL_NAME = 'CoinflowOnramp' const { ENVIRONMENT } = env @@ -102,7 +104,12 @@ export const CoinflowOnrampDrawer = () => { onFailure: handleClose }) const deviceId = getCoinflowDeviceId() - const showContent = isOpen && adapter + const { sessionKey, isError, isFetching, retry } = useCoinflowSessionKey({ + wallet: adapter?.wallet.publicKey.toBase58(), + environment: IS_PRODUCTION ? 'prod' : 'sandbox', + enabled: isOpen + }) + const showContent = isOpen && adapter && sessionKey return ( { > {showContent ? ( { blockchain='solana' subtotal={{ cents: amount * 100, currency: Currency.USD }} /> + ) : isOpen ? ( + retry()} + /> ) : null} ) diff --git a/packages/mobile/src/components/coinflow-onramp-drawer/CoinflowSessionStatus.tsx b/packages/mobile/src/components/coinflow-onramp-drawer/CoinflowSessionStatus.tsx new file mode 100644 index 00000000000..0142a5230d4 --- /dev/null +++ b/packages/mobile/src/components/coinflow-onramp-drawer/CoinflowSessionStatus.tsx @@ -0,0 +1,27 @@ +import { Button, Flex, LoadingSpinner, Text } from '@audius/harmony-native' + +export const CoinflowSessionStatus = ({ + isError, + isFetching, + onRetry +}: { + isError: boolean + isFetching: boolean + onRetry: () => void +}) => ( + + {isError ? ( + <> + Unable to connect to Coinflow. Please try again. + + + ) : ( + <> + + Connecting to Coinflow... + + )} + +) diff --git a/packages/mobile/src/components/coinflow-withdraw-drawer/CoinflowWithdrawDrawer.tsx b/packages/mobile/src/components/coinflow-withdraw-drawer/CoinflowWithdrawDrawer.tsx index fb652479f47..2e7a529a4a4 100644 --- a/packages/mobile/src/components/coinflow-withdraw-drawer/CoinflowWithdrawDrawer.tsx +++ b/packages/mobile/src/components/coinflow-withdraw-drawer/CoinflowWithdrawDrawer.tsx @@ -1,6 +1,9 @@ import { useCallback } from 'react' -import { useCoinflowWithdrawalAdapter } from '@audius/common/hooks' +import { + useCoinflowWithdrawalAdapter, + useCoinflowSessionKey +} from '@audius/common/hooks' import { useCoinflowWithdrawModal, withdrawUSDCActions, @@ -12,6 +15,7 @@ import { CoinflowWithdraw } from '@coinflowlabs/react-native' import { useDispatch, useSelector } from 'react-redux' import { Flex, IconButton, IconCloseAlt } from '@audius/harmony-native' +import { CoinflowSessionStatus } from 'app/components/coinflow-onramp-drawer/CoinflowSessionStatus' import { AppDrawer } from 'app/components/drawer' import { env } from 'app/services/env' import { zIndex } from 'app/utils/zIndex' @@ -57,7 +61,12 @@ export const CoinflowWithdrawDrawer = () => { onClose() }, [dispatch, onClose]) - const showContent = isOpen && adapter && amount !== undefined + const { sessionKey, isError, isFetching, retry } = useCoinflowSessionKey({ + wallet: adapter?.wallet.publicKey.toBase58(), + environment: IS_PRODUCTION ? 'prod' : 'sandbox', + enabled: isOpen + }) + const showContent = isOpen && adapter && amount !== undefined && sessionKey return ( { > {showContent ? ( { env={IS_PRODUCTION ? 'prod' : 'sandbox'} blockchain='solana' /> + ) : isOpen ? ( + retry()} + /> ) : null} ) diff --git a/packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.tsx b/packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.tsx index 4509251c22f..0c8da23bac9 100644 --- a/packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.tsx +++ b/packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react' -import { useCoinflowAdapter } from '@audius/common/hooks' +import { useCoinflowAdapter, useCoinflowSessionKey } from '@audius/common/hooks' import { coinflowModalUIActions, useCoinflowOnrampModal @@ -16,6 +16,7 @@ import { isElectron } from 'utils/clientUtil' import zIndex from 'utils/zIndex' import styles from './CoinflowOnrampModal.module.css' +import { CoinflowSessionStatus } from './CoinflowSessionStatus' const { transactionSucceeded, transactionCanceled } = coinflowModalUIActions @@ -62,7 +63,12 @@ export const CoinflowOnrampModal = NiceModal.create(() => { onSuccess: handleSuccess, onFailure: handleClose }) - const showContent = isOpen && adapter + const { sessionKey, isError, isFetching, retry } = useCoinflowSessionKey({ + wallet: adapter?.wallet.publicKey.toBase58(), + environment: IS_PRODUCTION ? 'prod' : 'sandbox', + enabled: isOpen + }) + const showContent = isOpen && adapter && sessionKey return ( { > {showContent ? ( { blockchain='solana' subtotal={{ cents: amount * 100, currency: Currency.USD }} /> + ) : isOpen ? ( + retry()} + /> ) : null} ) diff --git a/packages/web/src/components/coinflow-onramp-modal/CoinflowSessionStatus.tsx b/packages/web/src/components/coinflow-onramp-modal/CoinflowSessionStatus.tsx new file mode 100644 index 00000000000..85cae83de06 --- /dev/null +++ b/packages/web/src/components/coinflow-onramp-modal/CoinflowSessionStatus.tsx @@ -0,0 +1,27 @@ +import { Button, Flex, LoadingSpinner, Text } from '@audius/harmony' + +export const CoinflowSessionStatus = ({ + isError, + isFetching, + onRetry +}: { + isError: boolean + isFetching: boolean + onRetry: () => void +}) => ( + + {isError ? ( + <> + Unable to connect to Coinflow. Please try again. + + + ) : ( + <> + + Connecting to Coinflow... + + )} + +) diff --git a/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.tsx b/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.tsx index f79f3fb9e16..45a7897cb62 100644 --- a/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.tsx +++ b/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.tsx @@ -1,6 +1,9 @@ import { useCallback } from 'react' -import { useCoinflowWithdrawalAdapter } from '@audius/common/hooks' +import { + useCoinflowWithdrawalAdapter, + useCoinflowSessionKey +} from '@audius/common/hooks' import { withdrawUSDCActions, withdrawUSDCSelectors @@ -9,6 +12,7 @@ import { CoinflowWithdraw, OnSuccessMethod } from '@coinflowlabs/react' import NiceModal, { useModal } from '@ebay/nice-modal-react' import { useDispatch, useSelector } from 'react-redux' +import { CoinflowSessionStatus } from 'components/coinflow-onramp-modal/CoinflowSessionStatus' import ModalDrawer from 'components/modal-drawer/ModalDrawer' import { env } from 'services/env' import zIndex from 'utils/zIndex' @@ -62,7 +66,12 @@ export const CoinflowWithdrawModal = NiceModal.create(() => { [dispatch, onClose] ) - const showContent = isOpen && adapter && amount !== undefined + const { sessionKey, isError, isFetching, retry } = useCoinflowSessionKey({ + wallet: adapter?.wallet.publicKey.toBase58(), + environment: IS_PRODUCTION ? 'prod' : 'sandbox', + enabled: isOpen + }) + const showContent = isOpen && adapter && amount !== undefined && sessionKey return ( { > {showContent ? ( { env={IS_PRODUCTION ? 'prod' : 'sandbox'} blockchain='solana' /> + ) : isOpen ? ( + retry()} + /> ) : null} )