Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/identity-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<lowercase identity wallet>:<Solana root wallet>:<environment>:<timestamp>`.
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).
13 changes: 13 additions & 0 deletions packages/identity-service/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
101 changes: 101 additions & 0 deletions packages/identity-service/src/routes/coinflow.js
Original file line number Diff line number Diff line change
@@ -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')
}
})
)
}
195 changes: 195 additions & 0 deletions packages/identity-service/test/coinflowTest.js
Original file line number Diff line number Diff line change
@@ -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)
}
})
})
1 change: 1 addition & 0 deletions packages/identity-service/test/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require('./expressAppTest')
require('./apiHelpersTest')
require('./coinflowTest')
require('./authenticationTest')
require('./relayTest')
require('./configTest')
Expand Down
Loading