diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c5a5e78..fc4aa570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,8 @@ jobs: EVIDENCE_API_URL: ${{ vars.EVIDENCE_API_URL }} EVIDENCE_SELLER_PUBLIC_KEY: ${{ secrets.EVIDENCE_SELLER_PUBLIC_KEY }} EVIDENCE_PAYER_SECRET: ${{ secrets.EVIDENCE_PAYER_SECRET }} + EVIDENCE_SELLER_SECRET: ${{ secrets.EVIDENCE_SELLER_SECRET }} + EVIDENCE_SELLER_SESSION_TOKEN: ${{ secrets.EVIDENCE_SELLER_SESSION_TOKEN }} EVIDENCE_NETWORK: TESTNET steps: @@ -150,5 +152,5 @@ jobs: # secrets are configured (this repo's main-branch pushes, or a PR # from a branch within it). - name: Run Testnet evidence smoke (create -> pay -> verify) - if: ${{ env.EVIDENCE_PAYER_SECRET != '' && env.EVIDENCE_SELLER_PUBLIC_KEY != '' }} + if: ${{ env.EVIDENCE_PAYER_SECRET != '' && env.EVIDENCE_SELLER_PUBLIC_KEY != '' && (env.EVIDENCE_SELLER_SECRET != '' || env.EVIDENCE_SELLER_SESSION_TOKEN != '') }} run: npm run evidence:smoke diff --git a/README.md b/README.md index 7e59e661..a17ce957 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ Quittance supports multi-asset invoicing across native XLM and credit assets suc ### Seller invoice management & cancellation Sellers manage their invoices from the dashboard and detail views: -- **Cancel Pending Invoices**: Sellers can cancel any pending invoice before payment or expiration. Cancellation uses one proof path (issue #517): the request body carries `sellerPublicKey` plus a Freighter signature over the canonical message `cancel:` — the UI signs via `signBlob` before calling `POST /api/invoices/:id/cancel`. Query params and `x-seller-public-key` headers are not accepted as transports, and a value that disagrees with the body returns `400`. A foreign signer returns `403`; a missing or invalid signature returns `401`; only `PENDING` invoices can cancel. Signatures are mandatory in production (`NODE_ENV=production` or `REQUIRE_CANCEL_SIGNATURE=true`); local dev/tests keep the signature optional bypass. +- **Cancel Pending Invoices**: Sellers can cancel a pending invoice using the same signed seller session as their dashboard. Freighter signs a five-minute SEP-10 challenge once; the API returns a bearer session lasting at most one hour. Public keys and legacy `cancel:` blobs cannot authorize cancellation. A missing/expired session returns `401`; a foreign seller or conflicting body/query key returns `403`; only the existing lifecycle's cancellable states can cancel. This applies in every environment. See [seller authentication](./docs/SELLER_AUTH.md) for configuration and key rotation. - **Copy Pay & Share Links**: Direct quick-copy actions with toast feedback for pay URLs and invoice IDs across dashboard cards and detail pages. - **Proof & Receipt Navigation**: One-click jump to verified PDF payment proof and transaction details for all `PAID` invoices. @@ -303,6 +303,7 @@ cd Quittance0 cd backend npm i cp env.mvp.example .env +# Set the seller auth values described in docs/SELLER_AUTH.md first. npm run dev:mvp ``` @@ -390,9 +391,12 @@ cd backend npm run dev # src/server.ts (Postgres) instead of dev:mvp (in-memory) ``` -The dashboard sends the connected wallet on every call: -`GET /api/invoices?sellerPublicKey=G...` and `GET /api/invoices/stats?sellerPublicKey=G...` -both return `400` when the seller key is missing. +The dashboard proves ownership of the connected Freighter account before seller +requests. `GET /api/invoices` and `GET /api/invoices/stats` require +`Authorization: Bearer ` and derive their seller from that verified +session. A public key alone returns `401`; a supplied key that conflicts with +the session returns `403`. [Configure seller authentication](./docs/SELLER_AUTH.md) +before using either the MVP or Postgres dashboard. `POST /api/invoices` is idempotent (issue #514): send an `Idempotency-Key` header (or `idempotencyKey` body field) and a replay returns the original @@ -403,8 +407,9 @@ inside a 2-minute window collapses onto the original row. `GET /api/invoices/:id` serves two shapes from one record (issue #503): anonymous callers — including the `/pay/:id` checkout page — receive the public pay DTO (amount, asset, memo, destination, status, expiry, payment fields only), while -`GET /api/invoices/:id?sellerPublicKey=` returns -the full workspace record with client contact and payer identity. The same +a valid bearer session belonging to the invoice seller returns the full workspace +record with client contact and payer identity. Appending the seller's public key +to the pay URL still returns only the public DTO. The same public shape is embedded in `GET /api/invoices/:id/payment-info` and returned by `POST /api/invoices/:id/verify`. @@ -615,14 +620,15 @@ also runnable locally as `cd backend && npm run evidence:smoke`). That needs a funded Testnet keypair, which is exactly the kind of thing a public repo must not require an external contributor's PR to have: -- The job's final step is gated by `env.EVIDENCE_PAYER_SECRET` and - `env.EVIDENCE_SELLER_PUBLIC_KEY` being non-empty. GitHub never exposes +- The job's final step requires `env.EVIDENCE_PAYER_SECRET`, + `env.EVIDENCE_SELLER_PUBLIC_KEY`, and either `env.EVIDENCE_SELLER_SECRET` + or an unexpired `env.EVIDENCE_SELLER_SESSION_TOKEN` for seller authentication. GitHub never exposes repository secrets to a fork's `pull_request` context at all, so on an external contributor's PR these are always empty and the step is skipped cleanly — it is not possible for a fork PR to fail this job for lacking credentials it was never meant to have. -- Where the repository's own `EVIDENCE_SELLER_PUBLIC_KEY` and - `EVIDENCE_PAYER_SECRET` secrets (and `EVIDENCE_API_URL` repository +- Where the repository's own seller public key, payer secret and seller + authentication credential above (plus the `EVIDENCE_API_URL` repository variable) **are** configured — this repository's own main-branch pushes, or a PR from a branch within it — the step runs for real and its failure does block the job, the same as any other test. diff --git a/backend/env.example.txt b/backend/env.example.txt index 0367ffb4..b39dbd62 100644 --- a/backend/env.example.txt +++ b/backend/env.example.txt @@ -101,6 +101,14 @@ RATE_LIMIT_CANCEL_PER_MIN=10 # Verify concurrency lock → 429 VERIFY_IN_PROGRESS VERIFY_CONCURRENCY_RETRY_AFTER_SECONDS=5 -# Require cryptographic signatures for cancel (production always requires). -REQUIRE_SIGNATURES=false +# Seller ownership is always authenticated, including local development. +# Generate a dedicated Stellar signing key and >=32 random bytes per HMAC key; +# see docs/SELLER_AUTH.md. Never use the seller/payment wallet secret here. +SELLER_AUTH_HOME_DOMAIN=localhost:3000 +SELLER_AUTH_WEB_AUTH_DOMAIN=localhost:3001 +SELLER_AUTH_SIGNING_SECRET= +SELLER_SESSION_ACTIVE_KEY_ID=current +# JSON key-id -> canonical base64 secret; keep old ids during rotation. +SELLER_SESSION_KEYS= +# REQUIRE_SIGNATURES and REQUIRE_CANCEL_SIGNATURE no longer bypass auth. diff --git a/backend/env.mvp.example b/backend/env.mvp.example index 69594946..8410ea74 100644 --- a/backend/env.mvp.example +++ b/backend/env.mvp.example @@ -92,10 +92,16 @@ RATE_LIMIT_CANCEL_PER_MIN=10 # Verify concurrency lock → 429 VERIFY_IN_PROGRESS VERIFY_CONCURRENCY_RETRY_AFTER_SECONDS=5 -# Require cryptographic signatures for sensitive operations (cancel). -# Production always requires signatures. Set to 'true' to test locally. -# Default: false (dev), true (production) -REQUIRE_SIGNATURES=false +# Seller ownership is always authenticated, including local development. +# Generate a dedicated Stellar signing key and >=32 random bytes per HMAC key; +# see docs/SELLER_AUTH.md. Never use the seller/payment wallet secret here. +SELLER_AUTH_HOME_DOMAIN=localhost:3000 +SELLER_AUTH_WEB_AUTH_DOMAIN=localhost:3001 +SELLER_AUTH_SIGNING_SECRET= +SELLER_SESSION_ACTIVE_KEY_ID=current +# JSON key-id -> canonical base64 secret; keep old ids during rotation. +SELLER_SESSION_KEYS= +# REQUIRE_SIGNATURES and REQUIRE_CANCEL_SIGNATURE no longer bypass auth. # Redis connection for verify-cache (optional). Rate limiters use in-memory. # Falls back to in-memory cache if not configured. diff --git a/backend/scripts/evidence-smoke.mjs b/backend/scripts/evidence-smoke.mjs index 9b6f97d6..d468912a 100644 --- a/backend/scripts/evidence-smoke.mjs +++ b/backend/scripts/evidence-smoke.mjs @@ -13,6 +13,7 @@ if (args.includes('--help')) { console.log(`Usage: npm run evidence:smoke -- [--write-evidence] Required: EVIDENCE_API_URL, EVIDENCE_SELLER_PUBLIC_KEY, EVIDENCE_PAYER_SECRET +Seller auth: EVIDENCE_SELLER_SECRET or an unexpired EVIDENCE_SELLER_SESSION_TOKEN For --write-evidence: EVIDENCE_FRONTEND_URL, EVIDENCE_SOURCE_REVISION Optional: EVIDENCE_AMOUNT, EVIDENCE_HORIZON_URL, EVIDENCE_OUTPUT`); process.exit(0); @@ -20,6 +21,9 @@ Optional: EVIDENCE_AMOUNT, EVIDENCE_HORIZON_URL, EVIDENCE_OUTPUT`); async function main() { const config = evidenceConfig(process.env, args); + let sellerSessionToken = process.env.EVIDENCE_SELLER_SESSION_TOKEN; + const sellerSecret = process.env.EVIDENCE_SELLER_SECRET; + if (!sellerSessionToken && !sellerSecret) throw new Error('Seller auth requires EVIDENCE_SELLER_SECRET or EVIDENCE_SELLER_SESSION_TOKEN (docs/SELLER_AUTH.md)'); const payer = StellarSdk.Keypair.fromSecret(config.payerSecret); StellarSdk.Keypair.fromPublicKey(config.sellerPublicKey); if (payer.publicKey() === config.sellerPublicKey) { @@ -32,8 +36,8 @@ async function main() { const send = async (route, options = {}) => { const response = await fetch(config.apiUrl + route, { signal: AbortSignal.timeout(20_000), - headers: { accept: 'application/json', 'content-type': 'application/json' }, ...options, + headers: { accept: 'application/json', 'content-type': 'application/json', ...options.headers }, }); const body = await response.json().catch(() => null); return { ok: response.ok, status: response.status, body }; @@ -57,10 +61,40 @@ async function main() { throw new Error('API readiness did not confirm ready=true'); } + if (!sellerSessionToken) { + const seller = StellarSdk.Keypair.fromSecret(sellerSecret); + if (seller.publicKey() !== config.sellerPublicKey) throw new Error('Evidence seller secret does not match the configured public key'); + const issued = await request('/auth/challenge?account=' + encodeURIComponent(config.sellerPublicKey) + '&network=TESTNET'); + const challenge = issued?.data; + if (challenge?.network !== 'TESTNET' || challenge?.networkPassphrase !== StellarSdk.Networks.TESTNET) { + throw new Error('Seller challenge must use the pinned Testnet passphrase'); + } + const parsed = StellarSdk.WebAuth.readChallengeTx( + challenge.transaction, challenge.serverSigningKey, StellarSdk.Networks.TESTNET, + challenge.homeDomain, challenge.webAuthDomain, + ); + const minTime = Number(parsed.tx.timeBounds?.minTime); + const maxTime = Number(parsed.tx.timeBounds?.maxTime); + if (parsed.clientAccountID !== config.sellerPublicKey || maxTime !== challenge.expiresAt || + maxTime <= Date.now() / 1000 || maxTime - minTime > 300) { + throw new Error('Seller challenge account or lifetime is invalid'); + } + parsed.tx.sign(seller); + const authenticated = await request('/auth/session', { + method: 'POST', body: JSON.stringify({ transaction: parsed.tx.toXDR(), network: 'TESTNET' }), + }); + if (authenticated?.data?.sellerPublicKey !== config.sellerPublicKey || + authenticated?.data?.network !== 'TESTNET' || typeof authenticated?.data?.token !== 'string') { + throw new Error('Seller session does not match the evidence account'); + } + sellerSessionToken = authenticated.data.token; + } + await horizon.loadAccount(config.sellerPublicKey); const payerAccount = await horizon.loadAccount(payer.publicKey()); const created = await request('/invoices', { method: 'POST', + headers: { authorization: `Bearer ${sellerSessionToken}` }, body: JSON.stringify({ amount: Number(config.amount), assetCode: 'XLM', @@ -120,6 +154,7 @@ async function main() { */ const unrelated = await request('/invoices', { method: 'POST', + headers: { authorization: `Bearer ${sellerSessionToken}` }, body: JSON.stringify({ amount: Number(config.amount), assetCode: 'XLM', diff --git a/backend/src/config/runtime.ts b/backend/src/config/runtime.ts index 4a3081c3..dd132cb9 100644 --- a/backend/src/config/runtime.ts +++ b/backend/src/config/runtime.ts @@ -186,7 +186,7 @@ export function corsOptions(env: RuntimeEnvironment = process.env): CorsOptions return { credentials: true, methods: ['GET', 'POST', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Accept', 'X-Request-Id', 'X-Correlation-Id'], + allowedHeaders: ['Content-Type', 'Accept', 'Authorization', 'X-Request-Id', 'X-Correlation-Id'], maxAge: 86400, origin(origin, callback) { // Health checks, curl and server-to-server calls do not carry Origin. diff --git a/backend/src/middleware/seller-session.ts b/backend/src/middleware/seller-session.ts new file mode 100644 index 00000000..f083c459 --- /dev/null +++ b/backend/src/middleware/seller-session.ts @@ -0,0 +1,64 @@ +import type { Request, RequestHandler, Response } from 'express'; +import { SellerSessionError, type SellerSession, type SellerSessionService } from '../services/seller-session.service'; + +export type SellerSessionProvider = () => SellerSessionService; + +export function sendSellerSessionError(res: Response, error: unknown): void { + const failure = error instanceof SellerSessionError + ? error + : new SellerSessionError('AUTH_TOKEN_INVALID', 'Seller session is invalid'); + res.setHeader('Cache-Control', 'no-store'); + res.status(failure.status).json({ success: false, code: failure.code, error: failure.message }); +} + +export function sellerSessionFor(res: Response): SellerSession | undefined { + return res.locals?.sellerSession as SellerSession | undefined; +} + +/** Handler-level guard also keeps direct adapter users from treating a key as proof. */ +export function requireSellerContext(req: Request, res: Response): SellerSession | undefined { + const session = sellerSessionFor(res); + if (!session) { + sendSellerSessionError(res, new SellerSessionError('AUTH_SESSION_REQUIRED', 'A seller session is required')); + return; + } + const keys = [req.query?.sellerPublicKey, req.body?.sellerPublicKey, req.headers?.['x-seller-public-key']]; + if (keys.some(key => key !== undefined && key !== session.sellerPublicKey)) { + sendSellerSessionError(res, new SellerSessionError('AUTH_SELLER_MISMATCH', 'Requested seller does not match the authenticated wallet', 403)); + return; + } + return session; +} + +function attachSession(provider: SellerSessionProvider, required: boolean): RequestHandler { + return (req, res, next) => { + res.vary('Authorization'); + const authorization = req.headers.authorization; + if (authorization === undefined && !required) return next(); + if (typeof authorization !== 'string' || !/^Bearer [A-Za-z0-9_.-]+$/.test(authorization)) { + return sendSellerSessionError(res, new SellerSessionError('AUTH_SESSION_REQUIRED', 'A seller session is required')); + } + try { + const session = provider().verifyToken(authorization.slice(7)); + const declaredKeys = [req.query?.sellerPublicKey, req.body?.sellerPublicKey, req.headers['x-seller-public-key']]; + if (declaredKeys.some(key => key !== undefined && key !== session.sellerPublicKey)) { + throw new SellerSessionError('AUTH_SELLER_MISMATCH', 'Requested seller does not match the authenticated wallet', 403); + } + res.locals.sellerSession = session; + res.setHeader('Cache-Control', 'no-store'); + return next(); + } catch (error) { + return sendSellerSessionError(res, error); + } + }; +} + +/** Applies before seller rate limits and before any storage or mutation work. */ +export function requireSellerSession(provider: SellerSessionProvider): RequestHandler { + return attachSession(provider, true); +} + +/** Anonymous invoice reads remain public; malformed/stale presented tokens fail. */ +export function optionalSellerSession(provider: SellerSessionProvider): RequestHandler { + return attachSession(provider, false); +} diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts new file mode 100644 index 00000000..66f041a1 --- /dev/null +++ b/backend/src/routes/auth.routes.ts @@ -0,0 +1,31 @@ +import { Router } from 'express'; +import { createRateLimiter, getClientIp } from '../middleware/rate-limit'; +import { sendSellerSessionError, type SellerSessionProvider } from '../middleware/seller-session'; + +/** Mounted under /api by the shared invoice router in all three server modes. */ +export function createSellerAuthRouter(provider: SellerSessionProvider, rateLimit: boolean): Router { + const router = Router(); + const challengeLimit = rateLimit ? [createRateLimiter({ + windowMs: 60_000, max: 20, keyGenerator: req => `seller-challenge:${getClientIp(req)}`, + })] : []; + const sessionLimit = rateLimit ? [createRateLimiter({ + windowMs: 60_000, max: 30, keyGenerator: req => `seller-session:${getClientIp(req)}`, + })] : []; + router.get('/auth/challenge', ...challengeLimit, (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + try { + res.json({ success: true, data: provider().issueChallenge(req.query.account, req.query.network) }); + } catch (error) { + sendSellerSessionError(res, error); + } + }); + router.post('/auth/session', ...sessionLimit, (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + try { + res.json({ success: true, data: provider().redeemChallenge(req.body?.transaction, req.body?.network) }); + } catch (error) { + sendSellerSessionError(res, error); + } + }); + return router; +} diff --git a/backend/src/routes/invoice.handlers.ts b/backend/src/routes/invoice.handlers.ts index e9d7e125..972aec78 100644 --- a/backend/src/routes/invoice.handlers.ts +++ b/backend/src/routes/invoice.handlers.ts @@ -9,7 +9,6 @@ import stellarService from '../services/stellar.service'; import { createInvoiceFieldErrors, createInvoiceSchema, - stellarPublicKeySchema, } from '../utils/validation'; import { firstCreateInvoiceMessage } from '../../../shared/invoice-validation'; import { toPublicInvoiceDto } from '../../../shared/invoice'; @@ -56,7 +55,7 @@ import { type VerificationCache, type CachedVerificationBody, } from '../middleware/verify-cache'; -import { verifySellerSignature } from '../utils/signature-verification'; +import { requireSellerContext, sellerSessionFor } from '../middleware/seller-session'; import { classifyHorizonFailure } from '../utils/horizon-client'; import { redactPaymentEventData } from '../utils/payment-event-redaction'; @@ -80,6 +79,7 @@ export interface InvoiceHandlerOptions { /** Optional local-test override. Production always forces simulation off. */ allowSimulate?: boolean; stellar?: TransactionLookup; + /** @deprecated Ignored: every seller action now requires a signed session. */ requireCancelSignature?: boolean; paymentMonitor?: PaymentMonitorWatchRegistry; /** Shared with the route's cache middleware; tests inject a controllable one. */ @@ -191,6 +191,8 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa return { async createInvoice(req: Request, res: Response) { + const session = requireSellerContext(req, res); + if (!session) return; if (cutoverDrainMode()) { const context = resolveLogContext(req); emitEvent('warn', 'invoice.create.rejected', context, { @@ -210,7 +212,7 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa const requestId = context.requestId; const storageMode = storageLabel(storage.mode); try { - const parsed = createInvoiceSchema.safeParse(req.body); + const parsed = createInvoiceSchema.safeParse({ ...req.body, sellerPublicKey: session.sellerPublicKey }); if (!parsed.success) { const fieldErrors = createInvoiceFieldErrors(parsed.error); emitEvent('warn', 'invoice.create.rejected', context, { @@ -253,8 +255,9 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa return sendFailure(res, 400, 'Idempotency-Key header is invalid'); } validatedData.idempotencyKey = headerKey; + } else { + validatedData.idempotencyKey = idempotencyKeyForCreate(validatedData); } - validatedData.idempotencyKey = idempotencyKeyForCreate(validatedData); emitEvent('info', 'invoice.create.started', context, { sellerRef: logReference(validatedData.sellerPublicKey), assetCode: validatedData.assetCode || 'XLM', @@ -307,20 +310,10 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa return sendFailure(res, 404, 'Invoice not found'); } - // #503: two shapes from one record. The workspace (seller) fields — - // customer contact, seller profile, payer identity, settlement - // internals — only leave the server when the caller proves ownership - // by presenting the invoice's own seller key. Everyone else gets the - // public pay DTO. - const sellerKey = req.query.sellerPublicKey; - if (sellerKey !== undefined) { - const parsed = stellarPublicKeySchema.safeParse(sellerKey); - if (!parsed.success) { - return sendFailure(res, 400, 'sellerPublicKey must be a valid Stellar public key'); - } - if (parsed.data === invoice.sellerPublicKey) { - return sendSuccess(res, 200, invoice); - } + // A wallet address in a public pay URL is not proof of ownership. + // Only the verified session's owner receives workspace/customer data. + if (sellerSessionFor(res)?.sellerPublicKey === invoice.sellerPublicKey) { + return sendSuccess(res, 200, invoice); } sendSuccess(res, 200, toPublicInvoiceDto(invoice)); @@ -331,23 +324,17 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa }, async getInvoices(req: Request, res: Response) { + const session = requireSellerContext(req, res); + if (!session) return; try { - const { status, sellerPublicKey, q } = req.query; - - if (!sellerPublicKey) { - return sendFailure(res, 400, 'sellerPublicKey query parameter is required'); - } - const sellerCheck = stellarPublicKeySchema.safeParse(sellerPublicKey); - if (!sellerCheck.success) { - return sendFailure(res, 400, 'sellerPublicKey must be a valid Stellar public key'); - } + const { status, q } = req.query; const limit = toPositiveInt(req.query.limit, 50); const offset = toPositiveInt(req.query.offset, 0); const searchQuery = typeof q === 'string' && q.trim() ? q.trim() : undefined; const invoices = await storage.getInvoicesBySeller( - sellerCheck.data, + session.sellerPublicKey, status as string | undefined, limit, offset, @@ -366,22 +353,19 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa /** * Seller-only audit feed for one invoice (issue #515). The workspace * timeline otherwise shows status timestamps only — a rejected - * underpayment or foreign transaction never appears. The seller key - * scopes the read the same way the cancel proof does: present the - * invoice's own key or get nothing. + * underpayment or foreign transaction never appears. The verified session + * must belong to the invoice's seller; a public key alone grants no access. */ async getPaymentEvents(req: Request, res: Response) { + const session = requireSellerContext(req, res); + if (!session) return; try { const invoice = await storage.getInvoiceById(req.params.id); if (!invoice) { return sendFailure(res, 404, 'Invoice not found'); } - const sellerCheck = stellarPublicKeySchema.safeParse(req.query.sellerPublicKey); - if (!sellerCheck.success) { - return sendFailure(res, 400, 'sellerPublicKey query parameter is required and must be a valid Stellar public key'); - } - if (sellerCheck.data !== invoice.sellerPublicKey) { + if (session.sellerPublicKey !== invoice.sellerPublicKey) { return sendFailure(res, 403, 'Forbidden: not the seller of this invoice'); } @@ -421,99 +405,16 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa }, async cancelInvoice(req: Request, res: Response): Promise { + const session = requireSellerContext(req, res); + if (!session) return; try { - // Issue #517 — one proof path: the seller key, signature and message - // all travel in the JSON body. Query params and headers are legacy - // transports; when they carry a key that disagrees with the body the - // request is ambiguous and fails closed. - const bodyKeyRaw = req.body?.sellerPublicKey; - const queryKeyRaw = req.query?.sellerPublicKey; - const headerKeyRaw = req.headers?.['x-seller-public-key']; - const alternates = [queryKeyRaw, headerKeyRaw] - .flat() - .filter((v): v is string => typeof v === 'string' && v.trim() !== ''); - - let sellerPublicKey: string | undefined; - if (typeof bodyKeyRaw === 'string' && bodyKeyRaw.trim() !== '') { - const parsed = stellarPublicKeySchema.safeParse(bodyKeyRaw); - if (!parsed.success) { - return sendFailure(res, 400, 'Invalid Stellar public key format'); - } - sellerPublicKey = parsed.data; - } - - if (alternates.length > 0) { - if (!sellerPublicKey) { - return sendFailure( - res, - 400, - 'Send sellerPublicKey in the request body; query and header keys are not accepted' - ); - } - for (const alt of alternates) { - const parsed = stellarPublicKeySchema.safeParse(alt); - if (!parsed.success || parsed.data !== sellerPublicKey) { - return sendFailure( - res, - 400, - 'Conflicting sellerPublicKey values between body, query, and header' - ); - } - } - } - - if (!sellerPublicKey) { - return sendFailure( - res, - 401, - 'Unauthorized: sellerPublicKey is required to cancel an invoice' - ); - } - - const signature = - typeof req.body?.signature === 'string' && req.body.signature.trim() !== '' - ? req.body.signature - : undefined; - - // Signature is required in production and whenever the operator opts - // in; the bypass exists for local dev/tests only (documented in - // README — "Cancel authorization"). - const requireSignature = - options.requireCancelSignature ?? - (process.env.REQUIRE_CANCEL_SIGNATURE === 'true' || - process.env.NODE_ENV === 'production'); - - if (requireSignature && !signature) { - res.status(401).json({ - success: false, - code: 'UNAUTHORIZED', - error: 'Cancellation requires a Freighter signature over cancel:', - }); - return; - } - - if (signature) { - const existingInvoice = await storage.getInvoiceById(req.params.id); - if (!existingInvoice) { - return sendFailure(res, 404, 'Invoice not found'); - } - - if (existingInvoice.sellerPublicKey !== sellerPublicKey) { - return sendFailure(res, 403, 'Signer is not the seller of this invoice'); - } - - // One canonical message — the wallet signs exactly `cancel:`. - const isValid = verifySellerSignature(sellerPublicKey, signature, [ - `cancel:${req.params.id}`, - ]); - if (!isValid) { - res.status(401).json({ - success: false, - code: 'INVALID_SIGNATURE', - error: 'Invalid signature for cancellation', - }); - return; - } + // Sessions replace the replayable cancel: blob proof in every mode. + // The storage adapter also enforces ownership inside its mutation. + const sellerPublicKey = session.sellerPublicKey; + const existingInvoice = await storage.getInvoiceById(req.params.id); + if (!existingInvoice) return sendFailure(res, 404, 'Invoice not found'); + if (existingInvoice.sellerPublicKey !== sellerPublicKey) { + return sendFailure(res, 403, 'Forbidden: not the seller of this invoice'); } const invoice = await storage.cancelInvoice(req.params.id, sellerPublicKey); @@ -843,18 +744,10 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa }, async getStats(req: Request, res: Response) { + const session = requireSellerContext(req, res); + if (!session) return; try { - const { sellerPublicKey } = req.query; - - if (!sellerPublicKey) { - return sendFailure(res, 400, 'sellerPublicKey query parameter is required'); - } - const sellerCheck = stellarPublicKeySchema.safeParse(sellerPublicKey); - if (!sellerCheck.success) { - return sendFailure(res, 400, 'sellerPublicKey must be a valid Stellar public key'); - } - - const stats = await storage.getInvoiceStats(sellerCheck.data); + const stats = await storage.getInvoiceStats(session.sellerPublicKey); sendSuccess(res, 200, stats); } catch (error: any) { emitOperationalFailure('invoice.stats'); diff --git a/backend/src/routes/invoice.routes.ts b/backend/src/routes/invoice.routes.ts index 8b062058..b3e46dff 100644 --- a/backend/src/routes/invoice.routes.ts +++ b/backend/src/routes/invoice.routes.ts @@ -1,4 +1,4 @@ -import { Router, Request, Response, NextFunction, RequestHandler } from 'express'; +import { Router, RequestHandler } from 'express'; import { requestCorrelationMiddleware } from '../utils/request-correlation-id'; import { createInvoiceHandlers, InvoiceHandlerOptions } from './invoice.handlers'; import { createProofHandoffHandler } from './proof-handoff'; @@ -13,6 +13,9 @@ import { } from '../middleware/rate-limit'; import { createInvoiceCeilingMiddleware } from '../middleware/invoice-ceiling'; import { createVerifyCacheMiddleware, verificationCache } from '../middleware/verify-cache'; +import { optionalSellerSession, requireSellerSession } from '../middleware/seller-session'; +import { SellerSessionService, sellerSessionsFromEnvironment } from '../services/seller-session.service'; +import { createSellerAuthRouter } from './auth.routes'; export interface InvoiceRouterOptions extends InvoiceHandlerOptions { enableRateLimiting?: boolean; @@ -20,6 +23,8 @@ export interface InvoiceRouterOptions extends InvoiceHandlerOptions { enableCeilingCheck?: boolean; enableVerifyCache?: boolean; invoiceCeiling?: number; + /** Inject one authority for tests or an explicitly configured embedding. */ + sellerSessions?: SellerSessionService; } /** @@ -27,6 +32,8 @@ export interface InvoiceRouterOptions extends InvoiceHandlerOptions { * * Route list is kept identical between server.ts (Postgres) and * server-mvp.ts (in-memory): + * GET /auth/challenge + * POST /auth/session * POST /invoices * GET /invoices/stats * GET /invoices @@ -39,9 +46,9 @@ export interface InvoiceRouterOptions extends InvoiceHandlerOptions { * * Edge middleware order (issue #450) — see also middleware/edge-config.ts: * App: body size → 413 PAYLOAD_TOO_LARGE - * create: ceiling → create rate limits → handler - * list: list rate limit → handler - * cancel: auth pre-check → cancel rate limit → handler + * create: seller session → ceiling → create rate limits → handler + * list: seller session → list rate limit → handler + * cancel: seller session → cancel rate limit → handler * verify: concurrency lock → verify rate limits → replay cache → handler */ export function createInvoiceRouter(options: InvoiceRouterOptions): Router { @@ -53,6 +60,14 @@ export function createInvoiceRouter(options: InvoiceRouterOptions): Router { options.enableRateLimiting ?? (process.env.ENABLE_RATE_LIMITING === 'true' || process.env.NODE_ENV === 'production'); + // Resolve lazily so public checkout/health remain available without exposing + // seller data when auth configuration is missing. All modes share one nonce + // authority per router and the exact same route/middleware contract. + let sellerSessions = options.sellerSessions; + const sessionProvider = () => sellerSessions ??= sellerSessionsFromEnvironment(); + const sellerAuth = requireSellerSession(sessionProvider); + router.use(createSellerAuthRouter(sessionProvider, enableRateLimiting)); + const enableConcurrencyLock = options.enableConcurrencyLock ?? (process.env.ENABLE_VERIFY_CONCURRENCY_LOCK === 'true' || process.env.NODE_ENV === 'production'); @@ -63,8 +78,8 @@ export function createInvoiceRouter(options: InvoiceRouterOptions): Router { process.env.NODE_ENV === 'production' || options.invoiceCeiling !== undefined); - // create order: ceiling (503) → short/long rate limits (429) → handler - const createMiddlewares: RequestHandler[] = []; + // Authenticate before admission work or any write. + const createMiddlewares: RequestHandler[] = [sellerAuth]; if (enableCeilingCheck && options.storage.countInvoices) { createMiddlewares.push( createInvoiceCeilingMiddleware(() => options.storage.countInvoices!(), { @@ -77,44 +92,23 @@ export function createInvoiceRouter(options: InvoiceRouterOptions): Router { } router.post('/invoices', ...createMiddlewares, handlers.createInvoice); - router.get('/invoices/stats', handlers.getStats); + router.get('/invoices/stats', sellerAuth, handlers.getStats); - const getInvoicesMiddlewares: RequestHandler[] = []; + const getInvoicesMiddlewares: RequestHandler[] = [sellerAuth]; if (enableRateLimiting) { getInvoicesMiddlewares.push(createGetInvoicesRateLimiter()); } router.get('/invoices', ...getInvoicesMiddlewares, handlers.getInvoices); - router.get('/invoices/:id', handlers.getInvoice); + router.get('/invoices/:id', optionalSellerSession(sessionProvider), handlers.getInvoice); // GET /invoices/:id/events - seller-scoped audit feed (issue #515) - router.get('/invoices/:id/events', handlers.getPaymentEvents); + router.get('/invoices/:id/events', sellerAuth, handlers.getPaymentEvents); // GET /invoices/:id/payment-info - Payment info (no rate limit, needed for checkout) router.get('/invoices/:id/payment-info', handlers.getPaymentInfo); - const cancelMiddlewares: RequestHandler[] = []; - const cancelAuthPreCheck: RequestHandler = (req: Request, res: Response, next: NextFunction) => { - const requireSig = - options.requireCancelSignature ?? - (process.env.REQUIRE_CANCEL_SIGNATURE === 'true' || - process.env.NODE_ENV === 'production'); - if (requireSig) { - // Body-only contract (issue #517): the handler rejects query/header - // transports itself; this pre-check only enforces signature presence. - const sellerKey = req.body?.sellerPublicKey; - const signature = req.body?.signature; - if (!sellerKey || !signature) { - return res.status(401).json({ - success: false, - code: 'UNAUTHORIZED', - error: 'Cancellation requires seller proof of ownership (signature)', - }); - } - } - next(); - }; - cancelMiddlewares.push(cancelAuthPreCheck); + const cancelMiddlewares: RequestHandler[] = [sellerAuth]; if (enableRateLimiting) { cancelMiddlewares.push(createCancelInvoiceRateLimiter()); } diff --git a/backend/src/services/seller-session.service.ts b/backend/src/services/seller-session.service.ts new file mode 100644 index 00000000..46075ea0 --- /dev/null +++ b/backend/src/services/seller-session.service.ts @@ -0,0 +1,265 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { Keypair, StrKey, Transaction, WebAuth } from '@stellar/stellar-sdk'; +import { + passphraseFor, + resolveStellarNetwork, + type StellarNetwork, +} from '../../../shared/network'; + +const CHALLENGE_TTL_SECONDS = 300; +const SESSION_TTL_SECONDS = 3600; + +export class SellerSessionError extends Error { + constructor(readonly code: string, message: string, readonly status = 401) { + super(message); + this.name = 'SellerSessionError'; + } +} + +export interface SellerSession { + sellerPublicKey: string; + network: StellarNetwork; + expiresAt: number; +} + +export interface SellerSessionOptions { + signingKey: Keypair; + homeDomain: string; + webAuthDomain?: string; + network: StellarNetwork; + activeKeyId: string; + sessionKeys: ReadonlyMap; + challengeTtlSeconds?: number; + sessionTtlSeconds?: number; + maxChallenges?: number; + now?: () => number; +} + +interface ChallengeRecord { + account: string; + expiresAt: number; + redeemed: boolean; +} + +function invalid(code = 'AUTH_TOKEN_INVALID', message = 'Seller session is invalid'): never { + throw new SellerSessionError(code, message); +} + +function domain(value: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 59 || + !/^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?(?::[0-9]{1,5})?$/.test(value)) { + throw new Error('Seller authentication domains must be host names, optionally with a port'); + } + return value.toLowerCase(); +} + +function boundedSeconds(value: number | undefined, maximum: number): number { + const result = value ?? maximum; + if (!Number.isInteger(result) || result < 1 || result > maximum) { + throw new Error('Invalid seller authentication lifetime'); + } + return result; +} + +function encode(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +function decode(value: string): any { + if (!/^[A-Za-z0-9_-]+$/.test(value)) invalid(); + const bytes = Buffer.from(value, 'base64url'); + if (bytes.toString('base64url') !== value) invalid(); + try { + const result = JSON.parse(bytes.toString('utf8')); + if (!result || typeof result !== 'object' || Array.isArray(result)) invalid(); + return result; + } catch { + return invalid(); + } +} + +/** + * SEP-10 challenges prove possession of the exact Freighter account key. + * Challenges never submit a transaction or require a funded account. + * Redemption is synchronous through nonce consumption, preventing two requests + * in this process from exchanging the same challenge for separate sessions. + */ +export class SellerSessionService { + readonly network: StellarNetwork; + readonly networkPassphrase: string; + readonly homeDomain: string; + readonly webAuthDomain: string; + private readonly signingKey: Keypair; + private readonly activeKeyId: string; + private readonly keys: Map; + private readonly challengeTtlSeconds: number; + private readonly sessionTtlSeconds: number; + private readonly maxChallenges: number; + private readonly now: () => number; + private readonly challenges = new Map(); + + constructor(options: SellerSessionOptions) { + this.network = resolveStellarNetwork(options.network); + this.networkPassphrase = passphraseFor(this.network); + this.homeDomain = domain(options.homeDomain); + this.webAuthDomain = domain(options.webAuthDomain ?? options.homeDomain); + if (!options.signingKey.canSign()) throw new Error('Seller auth signing key must sign'); + this.signingKey = options.signingKey; + this.activeKeyId = options.activeKeyId; + this.keys = new Map(); + for (const [kid, key] of options.sessionKeys) { + if (!/^[A-Za-z0-9_-]{1,64}$/.test(kid) || !Buffer.isBuffer(key) || key.length < 32) { + throw new Error('Seller session keys require a key id and at least 32 random bytes'); + } + this.keys.set(kid, Buffer.from(key)); + } + if (!this.keys.has(this.activeKeyId)) throw new Error('Active seller session key is missing'); + this.challengeTtlSeconds = boundedSeconds(options.challengeTtlSeconds, CHALLENGE_TTL_SECONDS); + this.sessionTtlSeconds = boundedSeconds(options.sessionTtlSeconds, SESSION_TTL_SECONDS); + this.maxChallenges = options.maxChallenges ?? 10_000; + if (!Number.isSafeInteger(this.maxChallenges) || this.maxChallenges < 1) { + throw new Error('Invalid seller challenge capacity'); + } + this.now = options.now ?? Date.now; + } + + private requireNetwork(network: unknown): void { + if (network !== undefined && network !== this.network) { + throw new SellerSessionError('AUTH_NETWORK_MISMATCH', 'Seller session network does not match the API network', 403); + } + } + + issueChallenge(account: unknown, network?: unknown) { + this.requireNetwork(network); + if (typeof account !== 'string' || !StrKey.isValidEd25519PublicKey(account) || + account === this.signingKey.publicKey()) { + throw new SellerSessionError('AUTH_ACCOUNT_INVALID', 'A valid seller account is required', 400); + } + const now = Math.floor(this.now() / 1000); + for (const [hash, record] of this.challenges) { + if (record.expiresAt <= now) this.challenges.delete(hash); + } + if (this.challenges.size >= this.maxChallenges) { + throw new SellerSessionError('AUTH_CHALLENGE_LIMIT', 'Challenge capacity reached; retry after expiry', 429); + } + const transaction = WebAuth.buildChallengeTx( + this.signingKey, account, this.homeDomain, this.challengeTtlSeconds, + this.networkPassphrase, this.webAuthDomain, + ); + const tx = new Transaction(transaction, this.networkPassphrase); + const expiresAt = Number(tx.timeBounds!.maxTime); + this.challenges.set(tx.hash().toString('hex'), { account, expiresAt, redeemed: false }); + return { + transaction, + network: this.network, + networkPassphrase: this.networkPassphrase, + serverSigningKey: this.signingKey.publicKey(), + homeDomain: this.homeDomain, + webAuthDomain: this.webAuthDomain, + expiresAt, + }; + } + + redeemChallenge(transaction: unknown, network?: unknown): SellerSession & { token: string } { + this.requireNetwork(network); + if (typeof transaction !== 'string' || transaction.length === 0 || transaction.length > 16_384) { + throw new SellerSessionError('AUTH_CHALLENGE_INVALID', 'A signed challenge transaction is required', 400); + } + let tx: Transaction; + try { + tx = new Transaction(transaction, this.networkPassphrase); + } catch { + return invalid('AUTH_CHALLENGE_INVALID', 'Challenge transaction is invalid'); + } + const record = this.challenges.get(tx.hash().toString('hex')); + if (!record) invalid('AUTH_CHALLENGE_UNKNOWN', 'Challenge was not issued by this session authority'); + const now = Math.floor(this.now() / 1000); + // The SDK permits clock grace; the issued nonce deadline remains strict. + if (now >= record.expiresAt) invalid('AUTH_CHALLENGE_EXPIRED', 'Challenge has expired'); + if (record.redeemed) invalid('AUTH_CHALLENGE_REUSED', 'Challenge has already been redeemed'); + try { + const read = WebAuth.readChallengeTx( + transaction, this.signingKey.publicKey(), this.networkPassphrase, + this.homeDomain, this.webAuthDomain, + ); + if (read.clientAccountID !== record.account || read.memo !== null || + Number(read.tx.timeBounds?.maxTime) !== record.expiresAt) { + invalid('AUTH_CHALLENGE_INVALID', 'Challenge contents do not match the issued request'); + } + const signers = WebAuth.verifyChallengeTxSigners( + transaction, this.signingKey.publicKey(), this.networkPassphrase, + [record.account], this.homeDomain, this.webAuthDomain, + ); + if (signers.length !== 1 || signers[0] !== record.account) { + invalid('AUTH_CHALLENGE_SIGNATURE', 'Challenge must be signed by its seller'); + } + } catch (error) { + if (error instanceof SellerSessionError) throw error; + return invalid('AUTH_CHALLENGE_SIGNATURE', 'Challenge signature or binding is invalid'); + } + record.redeemed = true; + const expiresAt = now + this.sessionTtlSeconds; + const header = encode({ alg: 'HS256', typ: 'JWT', kid: this.activeKeyId }); + const payload = encode({ + v: 1, sub: record.account, network: this.network, + aud: this.homeDomain, iss: this.webAuthDomain, iat: now, exp: expiresAt, + }); + const input = `${header}.${payload}`; + const signature = createHmac('sha256', this.keys.get(this.activeKeyId)!).update(input).digest('base64url'); + return { token: `${input}.${signature}`, sellerPublicKey: record.account, network: this.network, expiresAt }; + } + + verifyToken(token: unknown): SellerSession { + if (typeof token !== 'string' || token.length > 4096) invalid(); + const parts = token.split('.'); + if (parts.length !== 3) invalid(); + const [headerPart, payloadPart, signaturePart] = parts; + const header = decode(headerPart); + if (header.alg !== 'HS256' || header.typ !== 'JWT' || typeof header.kid !== 'string') invalid(); + const key = this.keys.get(header.kid); + if (!key || !/^[A-Za-z0-9_-]{43}$/.test(signaturePart)) invalid(); + const actual = Buffer.from(signaturePart, 'base64url'); + const expected = createHmac('sha256', key).update(`${headerPart}.${payloadPart}`).digest(); + if (actual.length !== expected.length || !timingSafeEqual(actual, expected) || + actual.toString('base64url') !== signaturePart) invalid(); + const payload = decode(payloadPart); + if (payload.network !== this.network) { + throw new SellerSessionError('AUTH_NETWORK_MISMATCH', 'Seller session belongs to another network', 403); + } + const now = Math.floor(this.now() / 1000); + if (payload.v !== 1 || typeof payload.sub !== 'string' || + !StrKey.isValidEd25519PublicKey(payload.sub) || payload.aud !== this.homeDomain || + payload.iss !== this.webAuthDomain || !Number.isSafeInteger(payload.iat) || + !Number.isSafeInteger(payload.exp) || payload.exp <= payload.iat || + payload.exp - payload.iat > SESSION_TTL_SECONDS || payload.iat > now) invalid(); + if (payload.exp <= now) invalid('AUTH_SESSION_EXPIRED', 'Seller session has expired'); + return { sellerPublicKey: payload.sub, network: this.network, expiresAt: payload.exp }; + } +} + +/** No development bypass or payment-key fallback: unconfigured auth fails closed. */ +export function sellerSessionsFromEnvironment(env: Record = process.env): SellerSessionService { + try { + if (!env.SELLER_AUTH_HOME_DOMAIN || !env.SELLER_AUTH_SIGNING_SECRET || + !env.SELLER_SESSION_KEYS || !env.SELLER_SESSION_ACTIVE_KEY_ID) throw new Error('Missing configuration'); + const raw = JSON.parse(env.SELLER_SESSION_KEYS); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Invalid keys'); + const keys = new Map(); + for (const [kid, encoded] of Object.entries(raw)) { + if (typeof encoded !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) throw new Error('Invalid key encoding'); + const key = Buffer.from(encoded, 'base64'); + if (key.toString('base64') !== encoded) throw new Error('Noncanonical key encoding'); + keys.set(kid, key); + } + return new SellerSessionService({ + signingKey: Keypair.fromSecret(env.SELLER_AUTH_SIGNING_SECRET), + homeDomain: env.SELLER_AUTH_HOME_DOMAIN, + webAuthDomain: env.SELLER_AUTH_WEB_AUTH_DOMAIN, + network: resolveStellarNetwork(env.STELLAR_NETWORK), + activeKeyId: env.SELLER_SESSION_ACTIVE_KEY_ID, + sessionKeys: keys, + }); + } catch { + throw new SellerSessionError('AUTH_NOT_CONFIGURED', 'Seller authentication is not configured', 503); + } +} diff --git a/backend/tests/abuse-controls.test.ts b/backend/tests/abuse-controls.test.ts index 9f1b63d2..d725bcd1 100644 --- a/backend/tests/abuse-controls.test.ts +++ b/backend/tests/abuse-controls.test.ts @@ -4,6 +4,7 @@ import http from 'node:http'; import type { AddressInfo } from 'node:net'; import express, { Application } from 'express'; import { Keypair } from '@stellar/stellar-sdk'; +import { sellerAuthEnvironment, sellerAuthHeaders } from './fixtures/seller-auth'; import { createInvoiceRouter } from '../src/routes/invoice.routes'; import { MemoryInvoiceStorage } from '../src/storage/memory-invoice-storage'; import { InvoiceMemoryService } from '../src/services/invoice-memory.service'; @@ -96,7 +97,15 @@ describe('Abuse Controls Suite', () => { const otherKeypair = Keypair.random(); const otherPublicKey = otherKeypair.publicKey(); + function authenticatedRequest( + targetPort: number, method: string, path: string, body?: unknown, + headers: Record = {} + ) { + return request(targetPort, method, path, body, { ...sellerAuthHeaders(sellerPublicKey), ...headers }); + } + before(async () => { + Object.assign(process.env, sellerAuthEnvironment); rawStorage = new MemoryStorage(); const service = new InvoiceMemoryService(rawStorage); invoiceStorage = new MemoryInvoiceStorage(service); @@ -155,7 +164,7 @@ describe('Abuse Controls Suite', () => { const res = await request(port, 'POST', `/api/invoices/${created.id}/cancel`, {}); assert.equal(res.status, 401); assert.equal(res.body.success, false); - assert.equal(res.body.code, 'UNAUTHORIZED'); + assert.equal(res.body.code, 'AUTH_SESSION_REQUIRED'); const check = await invoiceStorage.getInvoiceById(created.id); assert.equal(check?.status, 'PENDING'); @@ -171,14 +180,11 @@ describe('Abuse Controls Suite', () => { expiresAt: new Date(Date.now() + 86400000), }); - const fakeSig = otherKeypair.sign(Buffer.from(`cancel:${created.id}`)).toString('base64'); - const res = await request(port, 'POST', `/api/invoices/${created.id}/cancel`, { + const res = await authenticatedRequest(port, 'POST', `/api/invoices/${created.id}/cancel`, { sellerPublicKey: otherPublicKey, - signature: fakeSig, }); - // Issue #517: a signed request from a non-seller is forbidden, not - // unauthenticated — the signature was valid, the identity is foreign. + // The authenticated seller cannot assert a different body identity. assert.equal(res.status, 403); assert.equal(res.body.success, false); @@ -186,7 +192,7 @@ describe('Abuse Controls Suite', () => { assert.equal(check?.status, 'PENDING'); }); - it('refuses cancellation with correct seller public key but missing signature', async () => { + it('refuses a public key or retired cancel blob without a seller session', async () => { const created = await rawStorage.createInvoice({ id: 'inv-test-cancel-3', sellerPublicKey, @@ -196,19 +202,19 @@ describe('Abuse Controls Suite', () => { expiresAt: new Date(Date.now() + 86400000), }); - const res = await request(port, 'POST', `/api/invoices/${created.id}/cancel`, { - sellerPublicKey, - }); - - assert.equal(res.status, 401); - assert.equal(res.body.success, false); - assert.equal(res.body.code, 'UNAUTHORIZED'); + const signature = sellerKeypair.sign(Buffer.from(`cancel:${created.id}`)).toString('base64'); + for (const body of [{ sellerPublicKey }, { sellerPublicKey, signature }]) { + const res = await request(port, 'POST', `/api/invoices/${created.id}/cancel`, body); + assert.equal(res.status, 401); + assert.equal(res.body.success, false); + assert.equal(res.body.code, 'AUTH_SESSION_REQUIRED'); + } const check = await invoiceStorage.getInvoiceById(created.id); assert.equal(check?.status, 'PENDING'); }); - it('refuses cancellation with invalid cryptographic signature', async () => { + it('refuses cancellation with an invalid session signature', async () => { const created = await rawStorage.createInvoice({ id: 'inv-test-cancel-4', sellerPublicKey, @@ -218,21 +224,21 @@ describe('Abuse Controls Suite', () => { expiresAt: new Date(Date.now() + 86400000), }); - const corruptSig = Buffer.alloc(64, 1).toString('base64'); + const pieces = sellerAuthHeaders(sellerPublicKey).authorization.split('.'); + pieces[2] = (pieces[2][0] === 'A' ? 'B' : 'A') + pieces[2].slice(1); const res = await request(port, 'POST', `/api/invoices/${created.id}/cancel`, { sellerPublicKey, - signature: corruptSig, - }); + }, { authorization: pieces.join('.') }); assert.equal(res.status, 401); assert.equal(res.body.success, false); - assert.equal(res.body.code, 'INVALID_SIGNATURE'); + assert.equal(res.body.code, 'AUTH_TOKEN_INVALID'); const check = await invoiceStorage.getInvoiceById(created.id); assert.equal(check?.status, 'PENDING'); }); - it('successfully cancels invoice with verified ed25519 seller signature', async () => { + it('successfully cancels invoice with a verified seller session', async () => { const created = await rawStorage.createInvoice({ id: 'inv-test-cancel-5', sellerPublicKey, @@ -242,12 +248,8 @@ describe('Abuse Controls Suite', () => { expiresAt: new Date(Date.now() + 86400000), }); - const validSig = sellerKeypair - .sign(Buffer.from(`cancel:${created.id}`)) - .toString('base64'); - const res = await request(port, 'POST', `/api/invoices/${created.id}/cancel`, { + const res = await authenticatedRequest(port, 'POST', `/api/invoices/${created.id}/cancel`, { sellerPublicKey, - signature: validSig, }); assert.equal(res.status, 200); @@ -262,7 +264,7 @@ describe('Abuse Controls Suite', () => { describe('Scenario 4: Creation Rate Limit and Storage Ceiling', () => { it('rate limits invoice creation beyond 5 requests per minute per IP', async () => { for (let i = 0; i < 5; i++) { - const res = await request(port, 'POST', '/api/invoices', { + const res = await authenticatedRequest(port, 'POST', '/api/invoices', { sellerPublicKey, amount: 10, assetCode: 'XLM', @@ -270,7 +272,7 @@ describe('Abuse Controls Suite', () => { assert.equal(res.status, 201, `Request ${i + 1} should succeed`); } - const excessive = await request(port, 'POST', '/api/invoices', { + const excessive = await authenticatedRequest(port, 'POST', '/api/invoices', { sellerPublicKey, amount: 10, assetCode: 'XLM', @@ -284,7 +286,7 @@ describe('Abuse Controls Suite', () => { it('keeps one create budget when an untrusted client rotates forwarding headers', async () => { for (let i = 0; i < 5; i++) { - const res = await request(port, 'POST', '/api/invoices', { + const res = await authenticatedRequest(port, 'POST', '/api/invoices', { sellerPublicKey, amount: 10, assetCode: 'XLM', @@ -295,7 +297,7 @@ describe('Abuse Controls Suite', () => { assert.equal(res.status, 201, `Request ${i + 1} should succeed`); } - const excessive = await request(port, 'POST', '/api/invoices', { + const excessive = await authenticatedRequest(port, 'POST', '/api/invoices', { sellerPublicKey, amount: 10, assetCode: 'XLM', @@ -329,19 +331,19 @@ describe('Abuse Controls Suite', () => { const ceilingPort = (ceilingServer.address() as AddressInfo).port; try { - const res1 = await request(ceilingPort, 'POST', '/api/invoices', { + const res1 = await authenticatedRequest(ceilingPort, 'POST', '/api/invoices', { sellerPublicKey, amount: 1, }); assert.equal(res1.status, 201); - const res2 = await request(ceilingPort, 'POST', '/api/invoices', { + const res2 = await authenticatedRequest(ceilingPort, 'POST', '/api/invoices', { sellerPublicKey, amount: 2, }); assert.equal(res2.status, 201); - const fullRes = await request(ceilingPort, 'POST', '/api/invoices', { + const fullRes = await authenticatedRequest(ceilingPort, 'POST', '/api/invoices', { sellerPublicKey, amount: 3, }); @@ -377,11 +379,11 @@ describe('Abuse Controls Suite', () => { describe('Scenario 5: Listing Rate Limiting', () => { it('rate limits GET /invoices beyond 60 requests per minute', async () => { for (let i = 0; i < 60; i++) { - const res = await request(port, 'GET', `/api/invoices?sellerPublicKey=${sellerPublicKey}`); + const res = await authenticatedRequest(port, 'GET', `/api/invoices?sellerPublicKey=${sellerPublicKey}`); assert.equal(res.status, 200); } - const excessive = await request(port, 'GET', `/api/invoices?sellerPublicKey=${sellerPublicKey}`); + const excessive = await authenticatedRequest(port, 'GET', `/api/invoices?sellerPublicKey=${sellerPublicKey}`); assert.equal(excessive.status, 429); assert.equal(excessive.body.success, false); assert.equal(excessive.body.code, 'RATE_LIMIT_EXCEEDED'); @@ -526,7 +528,7 @@ describe('Abuse Controls Suite', () => { const ceilingPort = (ceilingServer.address() as AddressInfo).port; try { - const first = await request(ceilingPort, 'POST', '/api/invoices', { + const first = await authenticatedRequest(ceilingPort, 'POST', '/api/invoices', { sellerPublicKey, amount: 1, }); @@ -534,7 +536,7 @@ describe('Abuse Controls Suite', () => { // Second create must be the ceiling (503), not a rate-limit 429 — order // is ceiling → rate limits, and we are still inside the create budget. - const full = await request(ceilingPort, 'POST', '/api/invoices', { + const full = await authenticatedRequest(ceilingPort, 'POST', '/api/invoices', { sellerPublicKey, amount: 2, }); diff --git a/backend/tests/create-invoice-validation.test.ts b/backend/tests/create-invoice-validation.test.ts index 79bad4fa..c70f3cf3 100644 --- a/backend/tests/create-invoice-validation.test.ts +++ b/backend/tests/create-invoice-validation.test.ts @@ -1,3 +1,4 @@ +import { sellerSessionLocals } from './fixtures/seller-auth'; /** * Issue #436 - one rule set for the create path. * @@ -34,7 +35,7 @@ function payload(overrides: Record = {}): Record { res.statusCode = code; return res; diff --git a/backend/tests/cutover-export-import.test.ts b/backend/tests/cutover-export-import.test.ts index daa26299..24b661fb 100644 --- a/backend/tests/cutover-export-import.test.ts +++ b/backend/tests/cutover-export-import.test.ts @@ -1,3 +1,4 @@ +import { sellerSessionLocals } from './fixtures/seller-auth'; import assert from 'node:assert/strict'; import { describe, it, beforeEach } from 'node:test'; import { MemoryStorage } from '../src/storage/memory-storage'; @@ -490,6 +491,7 @@ describe('Cutover Export, Validation, and Transactional Import Engine', () => { }, } as any; const resCreate = { + locals: sellerSessionLocals(SELLER_A), status(code: number) { createStatus = code; return this; diff --git a/backend/tests/deploy-smoke.test.ts b/backend/tests/deploy-smoke.test.ts index c1c28cf5..38a46daf 100644 --- a/backend/tests/deploy-smoke.test.ts +++ b/backend/tests/deploy-smoke.test.ts @@ -1,3 +1,4 @@ +import { sellerAuthEnvironment, sellerAuthHeaders } from './fixtures/seller-auth'; import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import type { AddressInfo } from 'node:net'; @@ -10,6 +11,7 @@ describe('deployed MVP smoke contract', () => { let baseUrl: string; before(async () => { + Object.assign(process.env, sellerAuthEnvironment); process.env.NODE_ENV = 'production'; process.env.FRONTEND_URL = 'https://quittance.example'; process.env.FRONTEND_URLS = 'https://preview.quittance.example'; @@ -49,10 +51,10 @@ describe('deployed MVP smoke contract', () => { assert.equal((await denied.json() as any).code, 'CORS_ORIGIN_DENIED'); }); - it('creates and reads an invoice through the public HTTP contract', async () => { + it('creates with a seller session and reads through the public pay contract', async () => { const createdResponse = await fetch(`${baseUrl}/api/invoices`, { method: 'POST', - headers: { 'content-type': 'application/json', origin: 'https://quittance.example' }, + headers: { 'content-type': 'application/json', origin: 'https://quittance.example', ...sellerAuthHeaders(SELLER) }, body: JSON.stringify({ amount: 1, assetCode: 'XLM', diff --git a/backend/tests/fixtures/seller-auth.ts b/backend/tests/fixtures/seller-auth.ts new file mode 100644 index 00000000..32d8cce0 --- /dev/null +++ b/backend/tests/fixtures/seller-auth.ts @@ -0,0 +1,30 @@ +import { createHmac } from 'node:crypto'; +import { Keypair } from '@stellar/stellar-sdk'; + +// Test-only server credentials. Older payment/storage fixtures intentionally +// retain their public account ids; they test behavior after authentication. +// seller-session.test.ts separately proves the real SEP-10 exchange/signatures. +const key = Buffer.alloc(32, 87); +const domain = 'fixture.example.invalid'; +export const sellerAuthEnvironment = { + SELLER_AUTH_HOME_DOMAIN: domain, + SELLER_AUTH_SIGNING_SECRET: Keypair.fromRawEd25519Seed(Buffer.alloc(32, 88)).secret(), + SELLER_SESSION_ACTIVE_KEY_ID: 'fixture', + SELLER_SESSION_KEYS: JSON.stringify({ fixture: key.toString('base64') }), +}; + +export function sellerSessionLocals(sellerPublicKey?: string) { + return sellerPublicKey ? { + sellerSession: { sellerPublicKey, network: 'TESTNET', expiresAt: Math.floor(Date.now() / 1000) + 3600 }, + } : {}; +} + +/** Server-signed fixture token for HTTP suites whose subject is not login. */ +export function sellerAuthHeaders(sellerPublicKey: string): Record { + const now = Math.floor(Date.now() / 1000); + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const header = encode({ alg: 'HS256', typ: 'JWT', kid: 'fixture' }); + const payload = encode({ v: 1, sub: sellerPublicKey, network: 'TESTNET', aud: domain, iss: domain, iat: now, exp: now + 3600 }); + const input = `${header}.${payload}`; + return { authorization: `Bearer ${input}.${createHmac('sha256', key).update(input).digest('base64url')}` }; +} diff --git a/backend/tests/invoice-cancel-payment-race.test.ts b/backend/tests/invoice-cancel-payment-race.test.ts index 376b21f0..1ea69694 100644 --- a/backend/tests/invoice-cancel-payment-race.test.ts +++ b/backend/tests/invoice-cancel-payment-race.test.ts @@ -1,3 +1,4 @@ +import { sellerSessionLocals } from './fixtures/seller-auth'; import assert from 'node:assert/strict'; import { beforeEach, describe, it } from 'node:test'; import type { Request, Response } from 'express'; @@ -32,6 +33,7 @@ interface FakeResponse { function createRes(): FakeResponse & Response { const res: any = { + locals: sellerSessionLocals(SELLER), statusCode: 200, body: undefined, headers: {}, diff --git a/backend/tests/invoice-handlers.test.ts b/backend/tests/invoice-handlers.test.ts index 8a07cce9..7a0f5ad9 100644 --- a/backend/tests/invoice-handlers.test.ts +++ b/backend/tests/invoice-handlers.test.ts @@ -1,3 +1,4 @@ +import { sellerSessionLocals } from './fixtures/seller-auth'; import assert from 'node:assert/strict'; import { describe, it, beforeEach } from 'node:test'; import type { Request, Response } from 'express'; @@ -24,6 +25,7 @@ function createRes(): FakeResponse & Response { const res: any = { statusCode: 200, body: undefined, + setHeader() {}, status(code: number) { res.statusCode = code; return res; @@ -36,12 +38,15 @@ function createRes(): FakeResponse & Response { return res; } -function createReq(init: { body?: any; params?: any; query?: any; headers?: any } = {}): Request { +// Adapter tests start after the auth middleware. Explicit null models an +// anonymous request; the separate mounted auth suite exercises cryptography. +function createReq(init: { body?: any; params?: any; query?: any; headers?: any; sessionSeller?: string | null } = {}): Request { return { body: init.body || {}, params: init.params || {}, query: init.query || {}, headers: init.headers || {}, + fixtureSeller: init.sessionSeller === null ? undefined : init.sessionSeller ?? init.body?.sellerPublicKey ?? init.query?.sellerPublicKey, } as unknown as Request; } @@ -50,6 +55,7 @@ async function call( req: Request ): Promise { const res = createRes(); + res.locals = sellerSessionLocals((req as any).fixtureSeller); await handler(req, res); return res; } @@ -445,18 +451,18 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage assert.equal(res.statusCode, 403); }); - it('requires a valid seller key — 400 when missing or malformed', async () => { + it('requires an authenticated seller context for the event feed', async () => { const invoice = await createInvoice(); const missing = await call( handlers().getPaymentEvents, createReq({ params: { id: invoice.id } }) ); - assert.equal(missing.statusCode, 400); + assert.equal(missing.statusCode, 401); const bad = await call( handlers().getPaymentEvents, - createReq({ params: { id: invoice.id }, query: { sellerPublicKey: 'nope' } }) + createReq({ params: { id: invoice.id }, query: { sellerPublicKey: 'nope' }, sessionSeller: null }) ); - assert.equal(bad.statusCode, 400); + assert.equal(bad.statusCode, 401); }); it('redacts identity-shaped keys from event payloads', async () => { @@ -502,6 +508,23 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage assert.equal(second.body.data.paymentUrl, first.body.data.paymentUrl); }); + it('keeps distinct explicit keys separate for the same invoice intent', async () => { + const body = invoiceBody(); + const first = await call( + handlers().createInvoice, + createReq({ body, headers: { 'idempotency-key': 'form-intent-a' } }) + ); + const second = await call( + handlers().createInvoice, + createReq({ body, headers: { 'idempotency-key': 'form-intent-b' } }) + ); + + assert.equal(first.statusCode, 201); + assert.equal(second.statusCode, 201); + assert.notEqual(second.body.data.invoice.id, first.body.data.invoice.id); + assert.notEqual(second.body.data.invoice.memo, first.body.data.invoice.memo); + }); + it('collapses parallel creates carrying the same key onto one invoice', async () => { const body = invoiceBody(); const [a, b] = await Promise.all([ @@ -652,14 +675,15 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage assert.equal(res.body.data.customerName, 'Client Co'); }); - it('rejects a malformed sellerPublicKey hint with 400', async () => { + it('ignores an unauthenticated malformed seller hint and returns the public shape', async () => { const created = await createInvoice(); const res = await call( handlers().getInvoice, createReq({ params: { id: created.id }, query: { sellerPublicKey: 'not-a-wallet' } }) ); - assert.equal(res.statusCode, 400); + assert.equal(res.statusCode, 200); + assert.equal(res.body.data.customerEmail, undefined); }); it('keeps the payment-info payload on the public shape', async () => { @@ -837,10 +861,10 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage it('rejects an invoice with an invalid seller wallet', async () => { const res = await call( handlers().createInvoice, - createReq({ body: invoiceBody({ sellerPublicKey: 'not-a-wallet' }) }) + createReq({ body: invoiceBody({ sellerPublicKey: 'not-a-wallet' }), sessionSeller: SELLER_A }) ); - assert.equal(res.statusCode, 400); + assert.equal(res.statusCode, 403); assert.equal(res.body.success, false); assert.equal(typeof res.body.error, 'string'); }); @@ -983,11 +1007,11 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage assert.deepEqual(res.body.pagination, { limit: 50, offset: 0, total: 1 }); }); - it('requires a wallet when listing invoices', async () => { + it('requires a verified session when listing invoices', async () => { const res = await call(handlers().getInvoices, createReq()); - assert.equal(res.statusCode, 400); - assert.equal(res.body.error, 'sellerPublicKey query parameter is required'); + assert.equal(res.statusCode, 401); + assert.equal(res.body.code, 'AUTH_SESSION_REQUIRED'); }); it('cancels a pending invoice once', async () => { @@ -1028,24 +1052,23 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage ); assert.equal(res.statusCode, 403); assert.equal(res.body.success, false); - assert.match(res.body.error, /unauthorized/i); + assert.match(res.body.error, /not the seller/i); }); - it('rejects invalid sellerPublicKey format on cancel (400)', async () => { + it('rejects a malformed cancel key that disagrees with the session (403)', async () => { const invoice = await createInvoice(); const res = await call( handlers().cancelInvoice, - createReq({ params: { id: invoice.id }, body: { sellerPublicKey: 'not-a-valid-stellar-key' } }) + createReq({ params: { id: invoice.id }, body: { sellerPublicKey: 'not-a-valid-stellar-key' }, sessionSeller: SELLER_A }) ); - assert.equal(res.statusCode, 400); + assert.equal(res.statusCode, 403); assert.equal(res.body.success, false); }); - // Issue #517 — one proof path: seller key, signature and message all live - // in the JSON body. Query params and headers are legacy transports; a - // disagreeing duplicate fails closed instead of smuggling a second key. - it('rejects cancel when body and query seller keys disagree (400)', async () => { + // A declared body/query/header key cannot replace or disagree with the + // identity installed by the seller-session middleware. + it('rejects cancel when body and query seller keys disagree (403)', async () => { const invoice = await createInvoice(); const res = await call( @@ -1056,11 +1079,11 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage query: { sellerPublicKey: SELLER_B }, }) ); - assert.equal(res.statusCode, 400); - assert.match(res.body.error, /conflicting/i); + assert.equal(res.statusCode, 403); + assert.match(res.body.error, /does not match/i); }); - it('rejects cancel when body and header seller keys disagree (400)', async () => { + it('rejects cancel when body and header seller keys disagree (403)', async () => { const invoice = await createInvoice(); const res = await call( @@ -1071,18 +1094,18 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage headers: { 'x-seller-public-key': SELLER_B }, }) ); - assert.equal(res.statusCode, 400); - assert.match(res.body.error, /conflicting/i); + assert.equal(res.statusCode, 403); + assert.match(res.body.error, /does not match/i); }); - it('rejects a query-only seller key — the body is the one transport (400)', async () => { + it('rejects an unsigned query-only seller key (401)', async () => { const invoice = await createInvoice(); const res = await call( handlers().cancelInvoice, - createReq({ params: { id: invoice.id }, query: { sellerPublicKey: SELLER_A } }) + createReq({ params: { id: invoice.id }, query: { sellerPublicKey: SELLER_A }, sessionSeller: null }) ); - assert.equal(res.statusCode, 400); + assert.equal(res.statusCode, 401); }); it('tolerates a duplicate query key that agrees with the body', async () => { @@ -1100,7 +1123,7 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage assert.equal(res.body.data.status, 'CANCELLED'); }); - it('requires a signature when requireCancelSignature is set (401)', async () => { + it('requires a session even when the legacy signature option is set (401)', async () => { const invoice = await createInvoice(); const res = await call( @@ -1111,18 +1134,17 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage stellar: { getTransaction: async () => transaction }, requireCancelSignature: true, }).cancelInvoice, - createReq({ params: { id: invoice.id }, body: { sellerPublicKey: SELLER_A } }) + createReq({ params: { id: invoice.id }, body: { sellerPublicKey: SELLER_A }, sessionSeller: null }) ); assert.equal(res.statusCode, 401); - assert.equal(res.body.code, 'UNAUTHORIZED'); + assert.equal(res.body.code, 'AUTH_SESSION_REQUIRED'); }); - it('cancels PENDING with a valid cancel: signature', async () => { + it('cancels PENDING with an authenticated owner session', async () => { const { Keypair } = await import('@stellar/stellar-sdk'); const keypair = Keypair.random(); const seller = keypair.publicKey(); const invoice = await createInvoice({ sellerPublicKey: seller }); - const signature = keypair.sign(Buffer.from(`cancel:${invoice.id}`)).toString('base64'); const res = await call( createInvoiceHandlers({ @@ -1134,14 +1156,14 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage }).cancelInvoice, createReq({ params: { id: invoice.id }, - body: { sellerPublicKey: seller, signature }, + body: { sellerPublicKey: seller }, }) ); assert.equal(res.statusCode, 200); assert.equal(res.body.data.status, 'CANCELLED'); }); - it('rejects a signature over a different message (401)', async () => { + it('does not exchange a legacy cancel blob for a session (401)', async () => { const { Keypair } = await import('@stellar/stellar-sdk'); const keypair = Keypair.random(); const seller = keypair.publicKey(); @@ -1160,17 +1182,17 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage createReq({ params: { id: invoice.id }, body: { sellerPublicKey: seller, signature }, + sessionSeller: null, }) ); assert.equal(res.statusCode, 401); - assert.equal(res.body.code, 'INVALID_SIGNATURE'); + assert.equal(res.body.code, 'AUTH_SESSION_REQUIRED'); }); - it('rejects a foreign signer even with a valid signature (403)', async () => { + it('rejects a foreign authenticated seller (403)', async () => { const { Keypair } = await import('@stellar/stellar-sdk'); const foreign = Keypair.random(); const invoice = await createInvoice(); - const signature = foreign.sign(Buffer.from(`cancel:${invoice.id}`)).toString('base64'); const res = await call( createInvoiceHandlers({ @@ -1182,7 +1204,7 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage }).cancelInvoice, createReq({ params: { id: invoice.id }, - body: { sellerPublicKey: foreign.publicKey(), signature }, + body: { sellerPublicKey: foreign.publicKey() }, }) ); assert.equal(res.statusCode, 403); diff --git a/backend/tests/invoice-payment-loop.test.ts b/backend/tests/invoice-payment-loop.test.ts index ca4345cd..d53c37a6 100644 --- a/backend/tests/invoice-payment-loop.test.ts +++ b/backend/tests/invoice-payment-loop.test.ts @@ -29,6 +29,7 @@ import type { Application } from 'express'; import { Account, Keypair, MuxedAccount } from '@stellar/stellar-sdk'; import memoryStorage from '../src/storage/memory-storage'; import { HORIZON_MAX_ATTEMPTS } from '../src/utils/horizon-client'; +import { sellerAuthEnvironment, sellerAuthHeaders } from './fixtures/seller-auth'; const SELLER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; const PAYER = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'; @@ -163,6 +164,7 @@ async function createInvoice(port: number, amount = 25) { }, { 'x-forwarded-for': `203.0.113.${++createRequestSequence}`, + ...sellerAuthHeaders(SELLER), // Each helper call is a distinct create intent; identical bodies inside // the dedupe window would otherwise return the same invoice (#514). 'idempotency-key': `loop-${createRequestSequence}`, @@ -178,6 +180,7 @@ describe('invoice payment loop', () => { let api: http.Server; before(async () => { + Object.assign(process.env, sellerAuthEnvironment); horizon = http.createServer((req, res) => { const reply = horizonResponder(req.url ?? ''); if (reply === null) { @@ -368,7 +371,9 @@ describe('invoice payment loop', () => { const stored = await jsonRequest( port, 'GET', - `/api/invoices/${invoice.id}?sellerPublicKey=${SELLER}` + `/api/invoices/${invoice.id}?sellerPublicKey=${SELLER}`, + undefined, + sellerAuthHeaders(SELLER) ); assert.equal(stored.body.data.payerName, 'Ada Lovelace'); assert.equal(stored.body.data.payerEmail, 'ada@example.com'); @@ -504,7 +509,9 @@ describe('invoice payment loop', () => { const stats = await jsonRequest( port, 'GET', - `/api/invoices/stats?sellerPublicKey=${SELLER}` + `/api/invoices/stats?sellerPublicKey=${SELLER}`, + undefined, + sellerAuthHeaders(SELLER) ); assert.equal(stats.status, 200); diff --git a/backend/tests/invoice-state-machine.test.ts b/backend/tests/invoice-state-machine.test.ts index fbc235c3..5d7dcb94 100644 --- a/backend/tests/invoice-state-machine.test.ts +++ b/backend/tests/invoice-state-machine.test.ts @@ -1,3 +1,4 @@ +import { sellerSessionLocals } from './fixtures/seller-auth'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { @@ -17,7 +18,7 @@ const SELLER_KEY = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; const PAYER_KEY = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'; function createMockResponse(): any { - const res: any = { statusCode: 200, body: null }; + const res: any = { statusCode: 200, body: null, locals: sellerSessionLocals(SELLER_KEY), setHeader() {} }; res.status = (code: number) => { res.statusCode = code; return res; diff --git a/backend/tests/observability.test.ts b/backend/tests/observability.test.ts index 4a48150a..cd146151 100644 --- a/backend/tests/observability.test.ts +++ b/backend/tests/observability.test.ts @@ -1,3 +1,4 @@ +import { sellerSessionLocals } from './fixtures/seller-auth'; import assert from 'node:assert/strict'; import { describe, it, beforeEach, afterEach } from 'node:test'; import type { Request, Response, NextFunction } from 'express'; @@ -32,6 +33,7 @@ interface FakeResponse extends Response { function createRes(): FakeResponse { const res: any = { + locals: sellerSessionLocals(SELLER), statusCode: 200, body: undefined, headers: {}, diff --git a/backend/tests/payment-monitor-runtime.test.ts b/backend/tests/payment-monitor-runtime.test.ts index 56e4d796..8d282bea 100644 --- a/backend/tests/payment-monitor-runtime.test.ts +++ b/backend/tests/payment-monitor-runtime.test.ts @@ -1,3 +1,4 @@ +import { sellerSessionLocals } from './fixtures/seller-auth'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { @@ -541,6 +542,7 @@ describe('PaymentMonitorService Runtime & Multi-Invoice Watch', () => { }; let createdPayload: any; const resCreate: any = { + locals: sellerSessionLocals(SELLER_KEY), status(code: number) { assert.equal(code, 201); return this; @@ -559,6 +561,7 @@ describe('PaymentMonitorService Runtime & Multi-Invoice Watch', () => { body: { sellerPublicKey: SELLER_KEY }, }; const resCancel: any = { + locals: sellerSessionLocals(SELLER_KEY), status(code: number) { assert.equal(code, 200); return this; diff --git a/backend/tests/proof-handoff.test.ts b/backend/tests/proof-handoff.test.ts index 6f7729ef..0160b71a 100644 --- a/backend/tests/proof-handoff.test.ts +++ b/backend/tests/proof-handoff.test.ts @@ -12,6 +12,7 @@ import { requestCorrelationMiddleware } from '../src/utils/request-correlation-i import { requestLoggingMiddleware } from '../src/observability/request-logging'; import { logReference, setLogSink, type StructuredLogRecord } from '../src/observability/log-events'; import { defaultLimiterStore } from '../src/middleware/rate-limit'; +import { sellerAuthEnvironment, sellerAuthHeaders } from './fixtures/seller-auth'; const SELLER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; const PAYER = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'; @@ -30,6 +31,7 @@ describe('mounted browser proof handoff observation', () => { const consoleMethods = { log: console.log, warn: console.warn, error: console.error }; beforeEach(async () => { + Object.assign(process.env, sellerAuthEnvironment); fingerprintKey = process.env.LOG_FINGERPRINT_KEY; process.env.LOG_FINGERPRINT_KEY = 'proof-handoff-test-key'; records = []; @@ -92,7 +94,7 @@ describe('mounted browser proof handoff observation', () => { } it('correlates the HTTP create, verified payment and observed handoff using stored references', async () => { - const created = await request('/invoices', { amount: 25, assetCode: 'XLM', sellerPublicKey: SELLER, expiresInDays: 1 }); + const created = await request('/invoices', { amount: 25, assetCode: 'XLM', sellerPublicKey: SELLER, expiresInDays: 1 }, sellerAuthHeaders(SELLER)); assert.equal(created.status, 201); const invoice = created.body.data.invoice; memo = invoice.memo; diff --git a/backend/tests/runtime-log-privacy.test.ts b/backend/tests/runtime-log-privacy.test.ts index 2e620caf..0eacc91c 100644 --- a/backend/tests/runtime-log-privacy.test.ts +++ b/backend/tests/runtime-log-privacy.test.ts @@ -3,6 +3,7 @@ import { after, before, describe, it } from 'node:test'; import http from 'node:http'; import type { AddressInfo } from 'node:net'; import { format } from 'node:util'; +import { sellerAuthEnvironment, sellerAuthHeaders } from './fixtures/seller-auth'; // Capture every console channel, not just the structured logger's test sink: // plaintext service/cache/SDK diagnostics must obey the same privacy boundary. @@ -37,12 +38,14 @@ function request(method: string, path: string, body?: unknown, options: { port?: number; requestId?: string; raw?: boolean; + sellerPublicKey?: string; } = {}): Promise<{ status: number; body: any; requestId?: string }> { return new Promise((resolve, reject) => { const payload = body === undefined ? undefined : options.raw ? String(body) : JSON.stringify(body); const req = http.request({ host: '127.0.0.1', port: options.port ?? port, method, path, headers: { ...(payload ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {}), ...(options.requestId ? { 'x-request-id': options.requestId } : {}), + ...(options.sellerPublicKey ? sellerAuthHeaders(options.sellerPublicKey) : {}), 'idempotency-key': `log-privacy-${++sequence}`, } }, res => { let raw = ''; @@ -71,6 +74,7 @@ function assertPrivate(output: string, values: string[]): void { describe('runtime log privacy at real HTTP and service boundaries', () => { before(async () => { + Object.assign(process.env, sellerAuthEnvironment); horizonResponder = () => ({ status: 404, body: { status: 404, title: PRIVATE_MARKER } }); horizon = http.createServer((req, res) => { const { status, body } = horizonResponder(req.url ?? ''); @@ -108,7 +112,7 @@ describe('runtime log privacy at real HTTP and service boundaries', () => { const created = await request('POST', '/api/invoices', { amount: 25, assetCode: 'XLM', sellerPublicKey: SELLER, description: PRIVATE_MARKER, customerEmail: PRIVATE_MARKER, - }, { requestId }); + }, { requestId, sellerPublicKey: SELLER }); assert.equal(created.status, 201); const invoice = created.body.data.invoice; horizonResponder = url => { diff --git a/backend/tests/seller-session.test.ts b/backend/tests/seller-session.test.ts new file mode 100644 index 00000000..6d2e5be9 --- /dev/null +++ b/backend/tests/seller-session.test.ts @@ -0,0 +1,274 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import type { Server } from 'node:http'; +import express from 'express'; +import cors from 'cors'; +import { Keypair, Transaction, WebAuth } from '@stellar/stellar-sdk'; +import { SellerSessionError, SellerSessionService, sellerSessionsFromEnvironment } from '../src/services/seller-session.service'; +import { createInvoiceRouter } from '../src/routes/invoice.routes'; +import { corsOptions } from '../src/config/runtime'; +import { MemoryStorage } from '../src/storage/memory-storage'; +import { MemoryInvoiceStorage } from '../src/storage/memory-invoice-storage'; +import { InvoiceMemoryService } from '../src/services/invoice-memory.service'; +import { passphraseFor } from '../../shared/network'; + +const seller = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 1)); +const other = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 2)); +const serverKey = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 3)); +const oldKey = Buffer.alloc(32, 4); +const newKey = Buffer.alloc(32, 5); +const appOrigin = 'https://app.example.invalid'; + +function authority(overrides: Partial[0]> = {}) { + return new SellerSessionService({ + signingKey: serverKey, homeDomain: 'example.invalid', webAuthDomain: 'api.example.invalid', + network: 'TESTNET', activeKeyId: 'old', sessionKeys: new Map([['old', oldKey]]), ...overrides, + }); +} + +function sign(transaction: string, key = seller, network: 'TESTNET' | 'PUBLIC' = 'TESTNET') { + const tx = new Transaction(transaction, passphraseFor(network)); + tx.sign(key); + return tx.toXDR(); +} + +function session(service: SellerSessionService, key = seller) { + const challenge = service.issueChallenge(key.publicKey(), service.network); + return service.redeemChallenge(sign(challenge.transaction, key, service.network), service.network); +} + +function errorCode(code: string, status = 401) { + return (error: unknown) => error instanceof SellerSessionError && error.code === code && error.status === status; +} + +describe('SEP-10 seller sessions', () => { + it('issues a server-signed, network/domain-bound challenge and a one-hour seller session', () => { + const service = authority(); + const challenge = service.issueChallenge(seller.publicKey(), 'TESTNET'); + const parsed = WebAuth.readChallengeTx(challenge.transaction, serverKey.publicKey(), passphraseFor('TESTNET'), 'example.invalid', 'api.example.invalid'); + assert.equal(parsed.clientAccountID, seller.publicKey()); + assert.equal(parsed.tx.sequence, '0'); + assert.equal(Number(parsed.tx.timeBounds!.maxTime) - Number(parsed.tx.timeBounds!.minTime), 300); + assert.equal(challenge.networkPassphrase, passphraseFor('TESTNET')); + const issued = service.redeemChallenge(sign(challenge.transaction), 'TESTNET'); + assert.deepEqual(service.verifyToken(issued.token), { + sellerPublicKey: seller.publicKey(), network: 'TESTNET', expiresAt: issued.expiresAt, + }); + const payload = JSON.parse(Buffer.from(issued.token.split('.')[1], 'base64url').toString()); + assert.equal(payload.exp - payload.iat, 3600); + }); + + it('consumes a nonce once, without consuming it on a bad client signature', () => { + const service = authority(); + const challenge = service.issueChallenge(seller.publicKey()); + assert.throws(() => service.redeemChallenge(sign(challenge.transaction, other)), errorCode('AUTH_CHALLENGE_SIGNATURE')); + assert.throws(() => service.redeemChallenge(challenge.transaction), errorCode('AUTH_CHALLENGE_SIGNATURE')); + const signed = sign(challenge.transaction); + service.redeemChallenge(signed); + assert.throws(() => service.redeemChallenge(signed), errorCode('AUTH_CHALLENGE_REUSED')); + }); + + it('rejects a challenge exactly at five minutes, despite the SDK clock grace', () => { + let now = Date.now(); + const service = authority({ now: () => now }); + const challenge = service.issueChallenge(seller.publicKey()); + now = challenge.expiresAt * 1000; + assert.throws(() => service.redeemChallenge(sign(challenge.transaction)), errorCode('AUTH_CHALLENGE_EXPIRED')); + }); + + it('rejects other networks, foreign authorities, malformed XDR and invalid accounts', () => { + const service = authority(); + assert.throws(() => service.issueChallenge(seller.publicKey(), 'PUBLIC'), errorCode('AUTH_NETWORK_MISMATCH', 403)); + assert.throws(() => service.issueChallenge('not-an-account'), errorCode('AUTH_ACCOUNT_INVALID', 400)); + assert.throws(() => service.redeemChallenge('not-xdr'), errorCode('AUTH_CHALLENGE_INVALID')); + const foreign = authority({ homeDomain: 'other.invalid' }).issueChallenge(seller.publicKey()); + assert.throws(() => service.redeemChallenge(sign(foreign.transaction)), errorCode('AUTH_CHALLENGE_UNKNOWN')); + const challenge = service.issueChallenge(seller.publicKey()); + assert.throws(() => service.redeemChallenge(sign(challenge.transaction, seller, 'PUBLIC')), errorCode('AUTH_CHALLENGE_SIGNATURE')); + assert.throws(() => service.redeemChallenge(sign(challenge.transaction), 'PUBLIC'), errorCode('AUTH_NETWORK_MISMATCH', 403)); + }); + + it('rejects changed, expired, foreign-network and foreign-domain tokens', () => { + let now = Date.now(); + const service = authority({ now: () => now }); + const issued = session(service); + const parts = issued.token.split('.'); + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()); + parts[1] = Buffer.from(JSON.stringify({ ...payload, sub: other.publicKey() })).toString('base64url'); + assert.throws(() => service.verifyToken(parts.join('.')), errorCode('AUTH_TOKEN_INVALID')); + assert.throws(() => service.verifyToken(session(authority({ network: 'PUBLIC' })).token), errorCode('AUTH_NETWORK_MISMATCH', 403)); + assert.throws(() => service.verifyToken(session(authority({ homeDomain: 'other.invalid' })).token), errorCode('AUTH_TOKEN_INVALID')); + now = issued.expiresAt * 1000; + assert.throws(() => service.verifyToken(issued.token), errorCode('AUTH_SESSION_EXPIRED')); + }); + + it('accepts retained key ids during rotation and rejects a retired key', () => { + const old = session(authority()); + const rotated = authority({ activeKeyId: 'new', sessionKeys: new Map([['old', oldKey], ['new', newKey]]) }); + assert.equal(rotated.verifyToken(old.token).sellerPublicKey, seller.publicKey()); + const current = session(rotated); + const newOnly = authority({ activeKeyId: 'new', sessionKeys: new Map([['new', newKey]]) }); + assert.equal(newOnly.verifyToken(current.token).sellerPublicKey, seller.publicKey()); + assert.throws(() => newOnly.verifyToken(old.token), errorCode('AUTH_TOKEN_INVALID')); + }); + + it('bounds nonce memory without evicting a live challenge', () => { + let now = Date.now(); + const service = authority({ maxChallenges: 1, now: () => now }); + const challenge = service.issueChallenge(seller.publicKey()); + assert.throws(() => service.issueChallenge(other.publicKey()), errorCode('AUTH_CHALLENGE_LIMIT', 429)); + service.redeemChallenge(sign(challenge.transaction)); + now = challenge.expiresAt * 1000; + assert.ok(service.issueChallenge(other.publicKey()).transaction); + }); + + it('fails closed without configured secrets and accepts an explicit rotating key ring', () => { + assert.throws(() => sellerSessionsFromEnvironment({ NODE_ENV: 'development', REQUIRE_CANCEL_SIGNATURE: 'false' }), errorCode('AUTH_NOT_CONFIGURED', 503)); + const service = sellerSessionsFromEnvironment({ + SELLER_AUTH_HOME_DOMAIN: 'example.invalid', SELLER_AUTH_WEB_AUTH_DOMAIN: 'api.example.invalid', + SELLER_AUTH_SIGNING_SECRET: serverKey.secret(), SELLER_SESSION_ACTIVE_KEY_ID: 'old', + SELLER_SESSION_KEYS: JSON.stringify({ old: oldKey.toString('base64') }), STELLAR_NETWORK: 'TESTNET', + }); + assert.equal(service.verifyToken(session(service).token).sellerPublicKey, seller.publicKey()); + assert.throws(() => authority({ challengeTtlSeconds: 301 })); + assert.throws(() => authority({ sessionTtlSeconds: 3601 })); + }); +}); + +describe('mounted seller authorization', () => { + let server: Server; + let base: string; + let storage: MemoryInvoiceStorage; + let service: SellerSessionService; + let ownerToken: string; + let otherToken: string; + + beforeEach(async () => { + storage = new MemoryInvoiceStorage(new InvoiceMemoryService(new MemoryStorage())); + service = authority(); + ownerToken = session(service).token; + otherToken = session(service, other).token; + const app = express(); + app.use(cors(corsOptions({ NODE_ENV: 'test', FRONTEND_URL: appOrigin }))); + app.use(express.json({ limit: '32kb' })); + app.use('/api', createInvoiceRouter({ storage, sellerSessions: service, enableRateLimiting: false, enableConcurrencyLock: false, enableCeilingCheck: false })); + app.use((error: any, _req: any, res: any, _next: any) => res.status(error.code === 'CORS_ORIGIN_DENIED' ? 403 : 500).json({ success: false })); + server = app.listen(0, '127.0.0.1'); + await new Promise(resolve => server.once('listening', resolve)); + base = `http://127.0.0.1:${(server.address() as any).port}/api`; + }); + + afterEach(async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + }); + + async function request(path: string, token?: string, body?: unknown) { + const response = await fetch(`${base}${path}`, { + method: body === undefined ? 'GET' : 'POST', + headers: { ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...(token ? { authorization: `Bearer ${token}` } : {}) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + return { status: response.status, headers: response.headers, body: await response.json() as any }; + } + + function seed() { + return storage.createInvoice({ sellerPublicKey: seller.publicKey(), amount: 25, assetCode: 'XLM', expiresInDays: 1, + customerEmail: 'private@example.invalid', customerName: 'Private Customer', sellerEmail: 'owner@example.invalid', metadata: { privateNote: 'internal' } } as any); + } + + it('exchanges through the mounted endpoints and returns a stable replay error', async () => { + const challenge = await request(`/auth/challenge?account=${seller.publicKey()}&network=TESTNET`); + assert.equal(challenge.status, 200); + assert.equal(challenge.headers.get('cache-control'), 'no-store'); + const signed = sign(challenge.body.data.transaction); + const result = await request('/auth/session', undefined, { transaction: signed, network: 'TESTNET' }); + assert.equal(result.status, 200); + assert.equal(result.body.data.sellerPublicKey, seller.publicKey()); + assert.equal((await request('/invoices', result.body.data.token)).status, 200); + const replay = await request('/auth/session', undefined, { transaction: signed, network: 'TESTNET' }); + assert.equal(replay.status, 401); + assert.equal(replay.body.code, 'AUTH_CHALLENGE_REUSED'); + }); + + it('returns 401 before storage work for every protected route, even with a public key or legacy signature', async () => { + const routes: [string, unknown?][] = [ + ['/invoices'], ['/invoices/stats'], ['/invoices/known/events'], + ['/invoices', { sellerPublicKey: seller.publicKey(), amount: 25 }], + ['/invoices/known/cancel', { sellerPublicKey: seller.publicKey(), signature: seller.sign(Buffer.from('cancel:known')).toString('base64') }], + ]; + for (const [path, body] of routes) { + const response = await request(`${path}?sellerPublicKey=${seller.publicKey()}`, undefined, body); + assert.equal(response.status, 401, path); + assert.equal(response.body.code, 'AUTH_SESSION_REQUIRED', path); + } + assert.equal(await storage.countInvoices!(), 0); + }); + + it('keeps pay-link detail public when its query names the actual seller', async () => { + const invoice = await seed(); + for (const token of [undefined, otherToken]) { + const response = await request(`/invoices/${invoice.id}`, token); + assert.equal(response.status, 200); + for (const field of ['customerEmail', 'customerName', 'sellerEmail', 'metadata']) assert.equal(response.body.data[field], undefined); + } + const forged = await request(`/invoices/${invoice.id}?sellerPublicKey=${seller.publicKey()}`); + assert.equal(forged.status, 200); + assert.equal(forged.body.data.customerEmail, undefined); + assert.doesNotMatch(JSON.stringify(forged.body), /private@example|Private Customer|owner@example|privateNote/); + const privateResponse = await request(`/invoices/${invoice.id}`, ownerToken); + assert.equal(privateResponse.body.data.customerEmail, 'private@example.invalid'); + assert.equal(privateResponse.headers.get('cache-control'), 'no-store'); + assert.match(privateResponse.headers.get('vary')!, /Authorization/i); + assert.equal((await request(`/invoices/${invoice.id}`, 'expired.invalid.token')).status, 401); + }); + + it('derives list, stats and create ownership from the verified session', async () => { + await seed(); + await storage.createInvoice({ sellerPublicKey: other.publicKey(), amount: 10, expiresInDays: 1 } as any); + const list = await request('/invoices', ownerToken); + assert.equal(list.status, 200); + assert.equal(list.body.data.length, 1); + assert.equal(list.body.data[0].sellerPublicKey, seller.publicKey()); + const stats = await request('/invoices/stats', otherToken); + assert.equal(stats.body.data[0].total_invoices, 1); + const created = await request('/invoices', ownerToken, { amount: 42, assetCode: 'XLM', expiresInDays: 1 }); + assert.equal(created.status, 201, JSON.stringify(created.body)); + assert.equal(created.body.data.invoice.sellerPublicKey, seller.publicKey()); + }); + + it('rejects mismatched body, query and header keys with 403', async () => { + for (const path of ['/invoices', '/invoices/stats', '/invoices/known/events', '/invoices/known']) { + const response = await request(`${path}?sellerPublicKey=${other.publicKey()}`, ownerToken); + assert.equal(response.status, 403, path); + assert.equal(response.body.code, 'AUTH_SELLER_MISMATCH'); + } + const created = await request('/invoices', ownerToken, { sellerPublicKey: other.publicKey(), amount: 25 }); + assert.equal(created.status, 403); + const header = await fetch(`${base}/invoices`, { headers: { authorization: `Bearer ${ownerToken}`, 'x-seller-public-key': other.publicKey() } }); + assert.equal(header.status, 403); + assert.equal(await storage.countInvoices!(), 0); + }); + + it('protects events and cancellation against a different authenticated seller', async () => { + const invoice = await seed(); + assert.equal((await request(`/invoices/${invoice.id}/events`, otherToken)).status, 403); + assert.equal((await request(`/invoices/${invoice.id}/cancel`, otherToken, {})).status, 403); + assert.equal((await storage.getInvoiceById(invoice.id))!.status, 'PENDING'); + assert.equal((await request(`/invoices/${invoice.id}/events`, ownerToken)).status, 200); + const cancelled = await request(`/invoices/${invoice.id}/cancel`, ownerToken, {}); + assert.equal(cancelled.status, 200); + assert.equal(cancelled.body.data.status, 'CANCELLED'); + }); + + it('allows Authorization preflight only from the configured frontend origin', async () => { + const requestHeaders = { 'access-control-request-method': 'POST', 'access-control-request-headers': 'authorization,content-type' }; + const allowed = await fetch(`${base}/invoices`, { method: 'OPTIONS', headers: { origin: appOrigin, ...requestHeaders } }); + assert.equal(allowed.status, 204); + assert.equal(allowed.headers.get('access-control-allow-origin'), appOrigin); + assert.match(allowed.headers.get('access-control-allow-headers')!, /Authorization/i); + const denied = await fetch(`${base}/invoices`, { method: 'OPTIONS', headers: { origin: 'https://foreign.invalid', ...requestHeaders } }); + assert.equal(denied.status, 403); + assert.equal(denied.headers.get('access-control-allow-origin'), null); + }); +}); diff --git a/backend/tests/stellar-service.test.ts b/backend/tests/stellar-service.test.ts index 772a74e5..5c20ebd4 100644 --- a/backend/tests/stellar-service.test.ts +++ b/backend/tests/stellar-service.test.ts @@ -1,6 +1,25 @@ import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import stellarService from '../src/services/stellar.service'; +import { after, before, describe, it } from 'node:test'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; + +let stellarService: typeof import('../src/services/stellar.service')['default']; +let horizon: http.Server; +before(async () => { + // The CI network is intentionally offline. A missing transaction is a real + // HTTP404, not a connection refusal; exercise that SDK path locally. + horizon = http.createServer((_req, res) => { + res.writeHead(404, { 'content-type': 'application/problem+json' }); + res.end(JSON.stringify({ status: 404, title: 'Resource Missing' })); + }); + await new Promise(resolve => horizon.listen(0, '127.0.0.1', resolve)); + process.env.STELLAR_HORIZON_URL = `http://127.0.0.1:${(horizon.address() as AddressInfo).port}`; + ({ default: stellarService } = await import('../src/services/stellar.service')); +}); +after(async () => { + horizon.closeAllConnections(); + await new Promise(resolve => horizon.close(() => resolve())); +}); describe('StellarService - Horizon Read Consolidation', () => { it('exposes loadAccount, getBalance, verifyPayment, getTransaction, streamPayments, getRecentPayments, sendPayment', () => { diff --git a/backend/tests/wallet-scoped-invoices.test.ts b/backend/tests/wallet-scoped-invoices.test.ts index 4659efef..445b5443 100644 --- a/backend/tests/wallet-scoped-invoices.test.ts +++ b/backend/tests/wallet-scoped-invoices.test.ts @@ -1,3 +1,4 @@ +import { sellerSessionLocals } from './fixtures/seller-auth'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { Request, Response } from 'express'; @@ -194,7 +195,7 @@ describe('wallet-scoped invoice reads', () => { * The cases above drive MemoryInvoiceStorage directly, which proves the filter * exists but not that a request reaches it. These go through the real handlers, * so a request naming another seller cannot be answered even if a caller forgets - * to scope: both endpoints refuse without a seller key, scope with one, and the + * to scope: both endpoints refuse without an authenticated session, scope by it, and the * status filter runs inside that scope. */ interface FakeResponse { @@ -206,6 +207,7 @@ function createRes(): FakeResponse & Response { const res: any = { statusCode: 200, body: undefined, + setHeader() {}, status(code: number) { res.statusCode = code; return res; @@ -218,8 +220,11 @@ function createRes(): FakeResponse & Response { return res; } -function createReq(init: { body?: any; query?: any } = {}): Request { - return { body: init.body || {}, params: {}, query: init.query || {} } as unknown as Request; +function createReq(init: { body?: any; query?: any; sessionSeller?: string | null } = {}): Request { + return { + body: init.body || {}, params: {}, query: init.query || {}, + fixtureSeller: init.sessionSeller === null ? undefined : init.sessionSeller ?? init.body?.sellerPublicKey ?? init.query?.sellerPublicKey, + } as unknown as Request; } async function call( @@ -227,6 +232,7 @@ async function call( req: Request ): Promise { const res = createRes(); + res.locals = sellerSessionLocals((req as any).fixtureSeller); await handler(req, res); return res; } @@ -299,26 +305,26 @@ describe('wallet-scoped invoice endpoints', () => { assert.equal(bob.body.data[0].total_invoices, 1); }); - it('refuses a list or stats request that names no seller', async () => { + it('refuses a list or stats request without a session', async () => { const { handlers } = makeApi(); await seedThroughApi(handlers); const list = await call(handlers.getInvoices as any, createReq({})); const stats = await call(handlers.getStats as any, createReq({})); - assert.equal(list.statusCode, 400); - assert.equal(stats.statusCode, 400); + assert.equal(list.statusCode, 401); + assert.equal(stats.statusCode, 401); }); it('refuses a seller key that is not a Stellar account id', async () => { const { handlers } = makeApi(); await seedThroughApi(handlers); - const list = await call(handlers.getInvoices as any, createReq({ query: { sellerPublicKey: 'nope' } })); - const stats = await call(handlers.getStats as any, createReq({ query: { sellerPublicKey: 'nope' } })); + const list = await call(handlers.getInvoices as any, createReq({ query: { sellerPublicKey: 'nope' }, sessionSeller: ALICE })); + const stats = await call(handlers.getStats as any, createReq({ query: { sellerPublicKey: 'nope' }, sessionSeller: ALICE })); - assert.equal(list.statusCode, 400); - assert.equal(stats.statusCode, 400); + assert.equal(list.statusCode, 403); + assert.equal(stats.statusCode, 403); }); it('filters invoices by search query through the endpoint', async () => { diff --git a/docs/ABUSE-CONTROLS-MIGRATION.md b/docs/ABUSE-CONTROLS-MIGRATION.md index a9d527fb..021611b9 100644 --- a/docs/ABUSE-CONTROLS-MIGRATION.md +++ b/docs/ABUSE-CONTROLS-MIGRATION.md @@ -1,5 +1,10 @@ # Abuse Controls Migration Guide +> Seller authentication update (#586): cancellation and seller reads now require +> the expiring SEP-10 session described in [SELLER_AUTH.md](./SELLER_AUTH.md). +> Public-key-only reads, `cancel:` blob signatures, and the development +> signature bypass in the historical examples below have been removed. + ## Quick Start ### 1. Install Dependencies (if needed) diff --git a/docs/ABUSE-CONTROLS.md b/docs/ABUSE-CONTROLS.md index 1425d5e2..faacb5ee 100644 --- a/docs/ABUSE-CONTROLS.md +++ b/docs/ABUSE-CONTROLS.md @@ -1,5 +1,10 @@ # Abuse controls for the public pay and verify endpoints +> Seller authentication update (#586): cancellation and seller reads now require +> the expiring SEP-10 session described in [SELLER_AUTH.md](./SELLER_AUTH.md). +> Public-key-only reads, `cancel:` blob signatures, and the development +> signature bypass in the historical examples below have been removed. + Status: implemented for the public edge (issues #383 / #450). The ranking below is historical context for *why* the controls exist; the live wiring is documented under "Middleware order" and driven by env (see edge-control diff --git a/docs/SELLER_AUTH.md b/docs/SELLER_AUTH.md new file mode 100644 index 00000000..d56c2cc4 --- /dev/null +++ b/docs/SELLER_AUTH.md @@ -0,0 +1,149 @@ +# Seller wallet sessions + +Seller workspace access requires proof that the caller controls the connected +Stellar account. A public wallet address is an identifier, not a credential. +The same shared router enforces this contract in the in-memory, Postgres and +dual server entrypoints. + +## Configure the API + +Set these backend environment variables before opening the seller dashboard: + +| Variable | Value | +| --- | --- | +| `STELLAR_NETWORK` | `TESTNET` or `PUBLIC`; the passphrase comes from `shared/network.ts` | +| `SELLER_AUTH_HOME_DOMAIN` | Application host, optionally including a port; for local use `localhost:3000` | +| `SELLER_AUTH_WEB_AUTH_DOMAIN` | API host, optionally including a port; defaults to the home domain | +| `SELLER_AUTH_SIGNING_SECRET` | Dedicated Stellar keypair secret used only to sign challenges | +| `SELLER_SESSION_ACTIVE_KEY_ID` | ID of the HMAC key used to issue new sessions | +| `SELLER_SESSION_KEYS` | JSON object mapping key IDs to canonical base64 secrets of at least 32 bytes | + +Generate a dedicated challenge signing key and a session key locally, from the +`backend` directory where the existing Stellar SDK is installed: + +```sh +node -e "process.stdout.write(require('@stellar/stellar-sdk').Keypair.random().secret() + '\n')" +node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64') + '\n')" +``` + +Put those values in backend environment configuration; keep them out of Git, +frontend variables and logs. The challenge signing key is separate from a +seller/payment wallet and does not need funds. For example, the shape of the +key ring is `{"current":""}`. Domains are host names, not URLs; +the home-domain limit is 59 ASCII characters because SEP-10 appends ` auth` to +its 64-byte manage-data name. + +Missing/invalid auth configuration returns `503 AUTH_NOT_CONFIGURED` from the +authentication endpoints. Protected routes without a token still return 401; +public checkout remains available. Development has the same ownership rule as +production; neither `REQUIRE_CANCEL_SIGNATURE=false` nor `REQUIRE_SIGNATURES` +enables a bypass. + +## Browser flow + +1. The connected wallet requests + `GET /api/auth/challenge?account=&network=TESTNET`. +2. The API issues a random, server-signed SEP-10 challenge bound to the account, + configured domains and pinned network passphrase, with a maximum five-minute + lifetime. The response is `{success:true,data:{transaction,network, + networkPassphrase,serverSigningKey,homeDomain,webAuthDomain,expiresAt}}`. +3. The frontend verifies the challenge with `WebAuth.readChallengeTx`, then asks + Freighter to `signTransaction` for that account and passphrase. It checks the + wallet again afterward and refuses a changed transaction or account. This + transaction is never submitted to Stellar. +4. The signed XDR is exchanged at `POST /api/auth/session` with + `{transaction,network}`. The API verifies both signatures using + `WebAuth.verifyChallengeTxSigners`, checks the issued nonce and consumes it + once. Invalid signatures do not consume another user's valid challenge. +5. The response contains `{success:true,data:{token,sellerPublicKey,network, + expiresAt}}`. All `expiresAt` fields are UNIX seconds. The HMAC-signed token + expires within one hour and includes its key ID, seller, network, domain, + issuer and issuance/expiry times. + +The frontend retains the token in memory for the current account and network. +It is never written to localStorage or the persisted wallet store. A synchronous +wallet subscription invalidates tokens and pending authentication on account, +network or connection changes, including an A → B → A switch while a signing +dialog is open. Old in-flight seller requests are aborted and their results +cannot populate the new wallet's workspace. A401 response triggers at most one +new challenge and one retry for the original wallet session. Anonymous pay-page +requests do not open wallet signing dialogs. + +## Endpoint contract + +| Endpoint | Authorization | +| --- | --- | +| `POST /api/invoices` | Required seller session; create identity is derived from it | +| `GET /api/invoices` | Required; list is scoped to the verified seller | +| `GET /api/invoices/stats` | Required; aggregates are scoped to the verified seller | +| `GET /api/invoices/:id/events` | Required; session must own this invoice | +| `POST /api/invoices/:id/cancel` | Required; session must own this invoice | +| `GET /api/invoices/:id` | Anonymous callers get the public DTO; only the owning session gets workspace fields | +| Payment info, verification and proof handoff | Existing public checkout contract | + +Send `Authorization: Bearer ` on seller requests. The configured CORS +origins allow this header alongside the existing content and correlation +headers. Authenticated responses are `Cache-Control: no-store` and vary by +Authorization. A declared body, query or legacy header seller key must match +the session or the API returns 403. A raw +`?sellerPublicKey=` query without a session never reveals +`customerEmail`, customer name or other private workspace fields. + +Authentication errors keep the API's flat envelope: +`{success:false,code:"AUTH_SESSION_EXPIRED",error:"Seller session has expired"}`. +Missing, malformed, expired or replayed credentials return 401; seller/network +mismatches return 403. Challenge admission is bounded (10,000 unexpired records +per authority) and uses the existing IP limiter when edge rate limiting is +enabled; callers must wait after a 429 instead of opening unlimited challenges. + +## Rotation and process topology + +To rotate HMAC keys, add the new ID/secret to `SELLER_SESSION_KEYS`, retain the +old pair and make the new ID active. After every API process is updated and the +last old token's one-hour lifetime has elapsed, remove the old ID. Verification +selects the exact header key ID; a removed or unknown ID is rejected. Restart +processes to apply environment changes. Removing an ID immediately also revokes +all sessions signed with it. + +Challenge nonce storage is bounded and process-local. Route issuance and +redemption to the same API process (sticky routing for this five-minute flow). +Other processes and restarted processes reject unknown challenges, so retrying +requires a new challenge. Session tokens are stateless and work across processes +that share the domain, network and key ring. No challenge or session requires a +database migration. Horizontal deployments needing cross-process challenge +redemption should replace the nonce authority with shared atomic storage before +removing sticky routing; the current implementation fails closed. + +## Cancellation and script migration + +The `cancel:` / `signBlob` proof path has been removed. Cancellation +uses the same session as create, list and stats; no extra cancellation signature +or permissive development fallback exists. Old clients receive401 until they +perform the challenge exchange. The deprecated router option +`requireCancelSignature` is ignored for source compatibility and cannot weaken +session enforcement. + +For `scripts/deploy-smoke.mjs`, provide `DEPLOY_SELLER_SESSION_TOKEN` from a fresh +challenge exchange. The created invoice's seller is derived from the token; the +subsequent read remains anonymous and checks the public pay-link contract. + +The Testnet evidence script accepts `EVIDENCE_SELLER_SECRET` to perform the same +SEP-10 exchange using its configured `EVIDENCE_SELLER_PUBLIC_KEY`, or an unexpired +`EVIDENCE_SELLER_SESSION_TOKEN`. Its payer secret remains separate. It signs a +zero-sequence challenge without submitting it, and sends the resulting bearer +only on invoice creation. Authentication values never enter the public artifact. +The optional evidence CI step is gated on these seller credentials in addition +to its existing payer/API configuration; fork PRs still require no secrets. + +## Acceptance checks + +From `backend`, run `node --import tsx --test tests/seller-session.test.ts` for +the real SDK challenge exchange and mounted Express routes. It covers replay, +strict expiry, signer/network/domain mismatch, HMAC rotation, bounded nonces, +private pay-link fields, protected create/list/stats/events/cancel and CORS. + +The frontend's `seller-session-api.test.js`, `seller-challenge.test.js` and +`wallet-session.test.js` cover real Axios request interception, Freighter's +signing boundary, wallet-switch races, memory-only tokens and one 401 refresh. +Existing payment/storage suites use explicit authenticated fixtures so their +original monetary, lifecycle, redaction and scoping assertions still apply. diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 16567bb2..cebd427b 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { invoiceApi } from '@/lib/api'; import InvoiceCard from '@/components/InvoiceCard'; import WalletConnect from '@/components/WalletConnect'; @@ -14,6 +14,7 @@ import { shouldClearSellerState, walletSessionGate, walletSessionKey, + walletSessionChanged, } from '@/lib/wallet-session'; import Link from 'next/link'; import { Loader2, Plus, TrendingUp, DollarSign, FileText, Download, AlertTriangle } from 'lucide-react'; @@ -38,16 +39,17 @@ import { DASHBOARD_RESULTS_ID, MAIN_CONTENT_ID, describeAmount, statusText } fro import { NETWORK_DISPLAY_NAME } from '@/lib/stellar'; export default function DashboardPage() { - const { publicKey, connected, network, freighterAvailable, isWrongNetwork } = + const { publicKey, connected, network, networkPassphrase, freighterAvailable, isWrongNetwork } = useWalletStore(); // One session for the whole page: the gate, the rows it may show, the stats // it may count and the request it may send all read from this value. - const session = normalizeWalletSession({ + const session = useMemo(() => normalizeWalletSession({ publicKey, connected, network, + networkPassphrase, freighterAvailable, - }); + }), [publicKey, connected, network, networkPassphrase, freighterAvailable]); const gate = walletSessionGate(session, EXPECTED_WALLET_NETWORK); // The key is a string, so the clearing effect below depends on the account // rather than on a fresh session object on every render. @@ -128,19 +130,19 @@ export default function DashboardPage() { invoiceApi.getStats(publicKey), ]); - if (!active) return; + if (!active || walletSessionChanged(session, useWalletStore.getState()).changed) return; setLoaded({ owner: publicKey, invoices: invoicesResult.data, stats: statsResult.data[0] || {}, }); } catch (error) { - if (!active) return; + if (!active || walletSessionChanged(session, useWalletStore.getState()).changed) return; const message = apiErrorMessage(error, 'Failed to load dashboard data'); setLoadError(message); toast.error(message); } finally { - if (active) setLoading(false); + if (active && !walletSessionChanged(session, useWalletStore.getState()).changed) setLoading(false); } })(); @@ -148,7 +150,7 @@ export default function DashboardPage() { return () => { active = false; }; - }, [filter, gate.ready, publicKey, reloadKey, debouncedSearch]); + }, [filter, gate.ready, publicKey, session, reloadKey, debouncedSearch]); const handleInvoiceCancelled = (cancelledId: string) => { // The wallet the user acted in, not whichever one is connected when the diff --git a/frontend/app/invoice/[id]/page.tsx b/frontend/app/invoice/[id]/page.tsx index 43babd47..a3473c76 100644 --- a/frontend/app/invoice/[id]/page.tsx +++ b/frontend/app/invoice/[id]/page.tsx @@ -26,9 +26,10 @@ import InvoiceTimeline from '@/components/InvoiceTimeline'; import PaymentEventsFeed from '@/components/PaymentEventsFeed'; import { invoiceSharePath } from '@/lib/invoice-share-path'; import { shareInvoiceByEmail } from '@/lib/export'; -import { EXPECTED_WALLET_NETWORK, signInvoiceCancelMessage } from '@/lib/stellar'; +import { EXPECTED_WALLET_NETWORK } from '@/lib/stellar'; import { walletGate } from '@/lib/freighter-availability'; import { copyWithFeedback } from '@/lib/clipboard-feedback'; +import { normalizeWalletSession, walletSessionChanged, walletSessionKey } from '@/lib/wallet-session'; export default function InvoiceDetailPage() { const params = useParams(); @@ -36,7 +37,6 @@ export default function InvoiceDetailPage() { const id = params.id as string; const { publicKey, - publicKey: storePublicKey, connected, network, networkPassphrase, @@ -50,7 +50,8 @@ export default function InvoiceDetailPage() { ); const userWallet = gate.ready ? publicKey : null; - const [invoice, setInvoice] = useState(null); + const [loadedInvoice, setInvoice] = useState(null); + const [loadedScope, setLoadedScope] = useState(null); const [paymentInfo, setPaymentInfo] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); @@ -58,26 +59,35 @@ export default function InvoiceDetailPage() { // Cancelling reloads the invoice and swaps the status panel out from under // the button that was just pressed, so focus has to be moved deliberately. const statusPanelRef = useRef(null); + const loadRevision = useRef(0); useEffect(() => { const timer = window.setInterval(() => setLifecycleNow(Date.now()), 30_000); return () => window.clearInterval(timer); }, []); - // Declared before loadInvoice so the loader can present it as the workspace - // credential — the seller-scoped GET returns contact fields only to the - // invoice's own wallet (issue #503). When the wallet connects or switches, - // the load effect re-runs and picks up the richer shape. - const activeWallet = userWallet || (connected ? storePublicKey : null); + const activeWallet = userWallet; + const scope = `${id}:${activeWallet ? walletSessionKey({ publicKey, network }) : 'public'}:${networkPassphrase || ''}`; + // A wallet or route switch hides the previous workspace during this render, + // before the effect has had a chance to clear or replace its stored record. + const invoice = loadedScope === scope ? loadedInvoice : null; const loadInvoice = useCallback(async () => { + const revision = ++loadRevision.current; + const wallet = normalizeWalletSession(useWalletStore.getState()); + const isCurrent = () => revision === loadRevision.current && + !walletSessionChanged(wallet, useWalletStore.getState()).changed; setLoadError(null); + setInvoice(null); + setPaymentInfo(null); + setLoading(true); try { const [invoiceResult, paymentResult] = await Promise.allSettled([ invoiceApi.getById(id, activeWallet), invoiceApi.getPaymentInfo(id), ]); + if (!isCurrent()) return; if (invoiceResult.status === 'rejected') throw invoiceResult.reason; setInvoice(invoiceResult.value.data); if (paymentResult.status === 'fulfilled') { @@ -86,17 +96,22 @@ export default function InvoiceDetailPage() { setLoadError(apiErrorMessage(paymentResult.reason)); } } catch (error) { + if (!isCurrent()) return; const message = apiErrorMessage(error, 'Failed to load invoice'); if (isApiUnavailableError(error)) setLoadError(message); toast.error(message); console.error(error); } finally { - setLoading(false); + if (isCurrent()) { + setLoadedScope(scope); + setLoading(false); + } } - }, [id, activeWallet]); + }, [id, activeWallet, scope]); useEffect(() => { void loadInvoice(); + return () => { loadRevision.current += 1; }; }, [loadInvoice]); /** @@ -161,9 +176,8 @@ export default function InvoiceDetailPage() { const handleCancel = async () => { if (!window.confirm('Cancel this invoice?')) return; try { - // Wallet proves ownership by signing `cancel:` (issue #517). - const proof = await signInvoiceCancelMessage(id); - await invoiceApi.cancel(id, proof.publicKey, proof.signature); + if (!activeWallet) throw new Error('Connect the seller wallet to cancel this invoice.'); + await invoiceApi.cancel(id, activeWallet); toast.success('Invoice cancelled'); await loadInvoice(); /* @@ -180,7 +194,7 @@ export default function InvoiceDetailPage() { } }; - if (loading) { + if (loading || loadedScope !== scope) { return (
` (issue #517); the - // backend verifies the signature against the invoice's seller key. - const proof = await signInvoiceCancelMessage(invoice.id); - await invoiceApi.cancel(invoice.id, proof.publicKey, proof.signature); + if (!userWallet) throw new Error('Connect the seller wallet to cancel this invoice.'); + await invoiceApi.cancel(invoice.id, userWallet); toast.success('Invoice cancelled'); onCancel?.(invoice.id); } catch (error) { diff --git a/frontend/components/InvoiceForm.tsx b/frontend/components/InvoiceForm.tsx index 78aa745e..db883c18 100644 --- a/frontend/components/InvoiceForm.tsx +++ b/frontend/components/InvoiceForm.tsx @@ -10,7 +10,7 @@ import { NETWORK_DISPLAY_NAME } from '@/lib/stellar'; import { showFreighterWrongNetworkPrompt } from './FreighterInstallPrompt'; import AssetLogo from './AssetLogo'; import ApiErrorState from './ApiErrorState'; -import { walletSessionGate } from '@/lib/wallet-session'; +import { normalizeWalletSession, walletSessionChanged, walletSessionGate } from '@/lib/wallet-session'; import { EXPECTED_WALLET_NETWORK } from '@/lib/stellar'; import { showFreighterInstallPrompt } from './FreighterInstallPrompt'; import { parseAmountInput } from '@/lib/parse-amount-input'; @@ -28,7 +28,6 @@ interface InvoiceFormProps { } export default function InvoiceForm({ onSuccess, userWallet }: InvoiceFormProps) { - const { publicKey, connected, network, freighterAvailable } = useWalletStore(); const [loading, setLoading] = useState(false); // The page renders this form only while the wallet gate is ready, so a // Freighter disconnect unmounts it. The draft is read once on mount so the @@ -92,11 +91,12 @@ export default function InvoiceForm({ onSuccess, userWallet }: InvoiceFormProps) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const sellerWallet = userWallet || publicKey || undefined; + const wallet = normalizeWalletSession(useWalletStore.getState()); + const sellerWallet = wallet.publicKey || undefined; // The session module normalises the store first, so a store that claims // connected without a public key cannot enable the submit button. const gate = walletSessionGate( - { freighterAvailable, connected, publicKey: sellerWallet, network }, + wallet, EXPECTED_WALLET_NETWORK ); if (!gate.ready) { @@ -104,6 +104,11 @@ export default function InvoiceForm({ onSuccess, userWallet }: InvoiceFormProps) return; } + if (userWallet && userWallet !== sellerWallet) { + toast.error('The connected wallet changed. Please try again.'); + return; + } + if (isWrongNetwork) { showFreighterWrongNetworkPrompt(NETWORK_DISPLAY_NAME); return; @@ -160,6 +165,7 @@ export default function InvoiceForm({ onSuccess, userWallet }: InvoiceFormProps) idempotencyKey: idempotencyKeyRef.current, }); + if (walletSessionChanged(wallet, useWalletStore.getState()).changed) return; toast.success('Invoice created'); idempotencyKeyRef.current = crypto.randomUUID(); onSuccess?.(result.data); diff --git a/frontend/components/WalletSessionSync.tsx b/frontend/components/WalletSessionSync.tsx index 4a6fa3ca..a0712788 100644 --- a/frontend/components/WalletSessionSync.tsx +++ b/frontend/components/WalletSessionSync.tsx @@ -10,6 +10,7 @@ import { } from '@/lib/stellar'; import { useWalletStore } from '@/lib/store'; import { sessionNetworkMatches, walletGate } from '@/lib/freighter-availability'; +import { walletSessionChanged } from '@/lib/wallet-session'; const SESSION_TOAST_ID = 'wallet-session-sync'; @@ -22,10 +23,12 @@ export default function WalletSessionSync() { useEffect(() => { let active = true; + let revision = 0; const sync = async (announce = false) => { + const requestRevision = ++revision; const session = await readFreighterSession(); - if (!active) return; + if (!active || requestRevision !== revision) return; const changed = previous.current.publicKey !== session.publicKey || @@ -51,14 +54,16 @@ export default function WalletSessionSync() { try { const balances = await getAccountBalance(session.publicKey); - if (!active) return; + if (!active || requestRevision !== revision || + walletSessionChanged(session, useWalletStore.getState()).changed) return; const xlm = balances.find((balance) => balance.assetCode === 'XLM'); syncSession({ - ...session, balance: xlm ? parseFloat(xlm.balance).toFixed(2) : '0.00', }); } catch { - syncSession({ ...session, balance: '—' }); + if (!active || requestRevision !== revision || + walletSessionChanged(session, useWalletStore.getState()).changed) return; + syncSession({ balance: '—' }); } if (announce && changed) { @@ -69,6 +74,7 @@ export default function WalletSessionSync() { void sync(false); const stopWatching = stopFreighterWalletWatcher((session) => { if (!active) return; + revision += 1; const changed = previous.current.publicKey !== session.publicKey || previous.current.network !== session.network; @@ -83,6 +89,7 @@ export default function WalletSessionSync() { return () => { active = false; + revision += 1; window.clearInterval(fallbackPoll); stopWatching(); }; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 83ef715d..4f42d205 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios, { type AxiosRequestConfig } from 'axios'; import { apiErrorMessage, isApiUnavailableError, @@ -8,6 +8,9 @@ import { import { resolveVerificationError } from './verification.js'; import { resolveStellarNetwork } from '@shared/network'; import { createBrowserRequestId } from './request-correlation-id.ts'; +import { useWalletStore } from './store'; +import { createSellerSessionManager, type SellerSessionContext } from './wallet-session'; +import { signSellerChallenge } from '@/lib/stellar'; /** * The API origin, resolved once per build. @@ -36,7 +39,72 @@ const api = axios.create({ }, }); -api.interceptors.request.use((config) => { +type SellerRequestConfig = AxiosRequestConfig & { + __sellerPublicKey?: string; + __sellerContext?: SellerSessionContext; + __sellerToken?: string; + __sellerRetry?: boolean; + __sellerCleanup?: () => void; +}; + +const activeSellerRequests = new Map(); + +export const sellerSessionManager = createSellerSessionManager({ + getWalletSession: useWalletStore.getState, + expectedNetwork: resolveStellarNetwork(process.env.NEXT_PUBLIC_STELLAR_NETWORK), + authenticate: async (wallet, assertCurrent) => { + const challenge = await api.get('/auth/challenge', { + params: { account: wallet.publicKey, network: wallet.network }, + }); + assertCurrent(); + const transaction = await signSellerChallenge(challenge.data.data, wallet.publicKey!); + assertCurrent(); + const response = await api.post('/auth/session', { transaction, network: wallet.network }); + assertCurrent(); + return response.data.data; + }, +}); + +// Zustand subscriptions run in the store update, before the next React effect +// or seller fetch. Tokens and pending issuance are invalidated synchronously. +sellerSessionManager.sync(); +useWalletStore.subscribe(() => { + sellerSessionManager.sync(); + for (const [controller, context] of activeSellerRequests) { + if (!sellerSessionManager.isCurrent(context)) { + controller.abort(); + activeSellerRequests.delete(controller); + } + } +}); + +const forSeller = (sellerPublicKey?: string | null): SellerRequestConfig => ({ + // An absent key must still authenticate against the current wallet for create. + __sellerPublicKey: sellerPublicKey || '', +}); + +api.interceptors.request.use(async (config) => { + const request = config as typeof config & SellerRequestConfig; + if (request.__sellerPublicKey !== undefined) { + const context = request.__sellerContext ?? sellerSessionManager.contextFor(request.__sellerPublicKey); + sellerSessionManager.assertCurrent(context); + const token = await sellerSessionManager.getToken(context); + sellerSessionManager.assertCurrent(context); + request.__sellerContext = context; + request.__sellerToken = token; + config.headers.Authorization = `Bearer ${token}`; + const controller = new AbortController(); + const originalSignal = config.signal; + const abort = () => controller.abort(); + if (originalSignal?.aborted) controller.abort(); + originalSignal?.addEventListener?.('abort', abort); + activeSellerRequests.set(controller, context); + config.signal = controller.signal; + request.__sellerCleanup = () => { + activeSellerRequests.delete(controller); + originalSignal?.removeEventListener?.('abort', abort); + }; + } const headers = config.headers ?? {}; const existing = headers['X-Request-Id'] || @@ -53,9 +121,33 @@ api.interceptors.request.use((config) => { }); api.interceptors.response.use( - (response) => response, - (error) => { - const normalized = toApiError(error); + (response) => { + const request = response.config as SellerRequestConfig; + request.__sellerCleanup?.(); + const context = request.__sellerContext; + if (context) sellerSessionManager.assertCurrent(context); + return response; + }, + async (error) => { + let failure = error; + try { + const request = error?.config as SellerRequestConfig | undefined; + request?.__sellerCleanup?.(); + if (request?.__sellerContext) { + sellerSessionManager.assertCurrent(request.__sellerContext); + if (error?.response?.status === 401) { + sellerSessionManager.invalidate(request.__sellerContext, request.__sellerToken || ''); + if (!request.__sellerRetry) { + // Reuses the original wallet epoch. A switch never retries an old + // seller's request using the new account's token. + return await api.request({ ...request, __sellerRetry: true } as SellerRequestConfig); + } + } + } + } catch (retryError) { + failure = retryError; + } + const normalized = toApiError(failure); console.error('API Error:', normalized.code, normalized.message); return Promise.reject(normalized); } @@ -80,16 +172,16 @@ export const invoiceApi = { const response = await api.post('/invoices', { ...data, assetCode: normalizedAssetCode, - }); + }, forSeller(data.sellerPublicKey)); return response.data; }, getById: async (id: string, sellerPublicKey?: string | null) => { - // Workspace fields (client contact, payer identity) are only returned when - // the caller presents the invoice's own seller key — issue #503. The pay - // page calls this without a key and receives the public pay DTO. + // The public pay page never signs in. A workspace caller earns a session; + // the key is an ownership assertion, not a credential. const response = await api.get(`/invoices/${id}`, { params: sellerPublicKey ? { sellerPublicKey } : undefined, + ...(sellerPublicKey ? forSeller(sellerPublicKey) : {}), }); return response.data; }, @@ -103,7 +195,7 @@ export const invoiceApi = { offset?: number; q?: string; }) => { - const response = await api.get('/invoices', { params }); + const response = await api.get('/invoices', { params, ...forSeller(params.sellerPublicKey) }); return response.data; }, @@ -117,14 +209,14 @@ export const invoiceApi = { getPaymentEvents: async (id: string, sellerPublicKey: string) => { const response = await api.get(`/invoices/${id}/events`, { params: { sellerPublicKey }, + ...forSeller(sellerPublicKey), }); return response.data; }, - // One proof path (issue #517): the seller key and the Freighter signature - // over `cancel:` travel in the request body — never in query or header. - cancel: async (id: string, sellerPublicKey: string, signature?: string) => { - const response = await api.post(`/invoices/${id}/cancel`, { sellerPublicKey, signature }); + // Cancellation uses the same expiring, network-bound seller session. + cancel: async (id: string, sellerPublicKey: string) => { + const response = await api.post(`/invoices/${id}/cancel`, { sellerPublicKey }, forSeller(sellerPublicKey)); return response.data; }, @@ -143,6 +235,7 @@ export const invoiceApi = { getStats: async (sellerPublicKey: string) => { const response = await api.get('/invoices/stats', { params: { sellerPublicKey }, + ...forSeller(sellerPublicKey), }); return response.data; }, diff --git a/frontend/lib/mock-api.ts b/frontend/lib/mock-api.ts index 0ca1a4c6..e4c6bdf1 100644 --- a/frontend/lib/mock-api.ts +++ b/frontend/lib/mock-api.ts @@ -1,11 +1,114 @@ // Mock API - Backend olmadan UI test için import { PUBLIC_INVOICE_FIELDS } from '@shared/invoice'; +import { Keypair, Transaction, WebAuth } from '@stellar/stellar-sdk'; +import { NETWORK_PASSPHRASE, STELLAR_NETWORK, signSellerChallenge } from './stellar'; +import { createSellerSessionManager, type SellerSessionToken } from './wallet-session'; +import { useWalletStore } from './store'; const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); const MIN_EXPIRY_DAYS = 1; const MAX_EXPIRY_DAYS = 30; +// This module simulates both sides for local UI demos only. Its ephemeral key +// and opaque tokens are never a production credential or a persisted account. +const mockSigningKey = Keypair.random(); +const mockDomain = 'quittance.mock'; +const challenges = new Map(); +const sessions = new Map(); +const authError = (code: string, message: string, status = 401): never => { + throw Object.assign(new Error(message), { + code, response: { status, data: { success: false, code, error: message } }, + }); +}; +const publicInvoice = (invoice: any) => Object.fromEntries( + PUBLIC_INVOICE_FIELDS.filter((key) => invoice[key] !== undefined).map((key) => [key, invoice[key]]) +); + +export const mockAuthApi = { + getChallenge: async (account: string, network: string) => { + if (network !== STELLAR_NETWORK) authError('AUTH_NETWORK_MISMATCH', 'Wrong Stellar network.'); + const transaction = WebAuth.buildChallengeTx( + mockSigningKey, account, mockDomain, 300, NETWORK_PASSPHRASE, mockDomain + ); + const tx = new Transaction(transaction, NETWORK_PASSPHRASE); + const expiresAt = Number(tx.timeBounds?.maxTime); + for (const [hash, challenge] of challenges) { + if (challenge.expiresAt * 1000 <= Date.now()) challenges.delete(hash); + } + challenges.set(tx.hash().toString('hex'), { account, expiresAt }); + return { success: true, data: { + transaction, network, networkPassphrase: NETWORK_PASSPHRASE, + serverSigningKey: mockSigningKey.publicKey(), homeDomain: mockDomain, + webAuthDomain: mockDomain, expiresAt, + } }; + }, + createSession: async ({ transaction, network }: { transaction: string; network: string }) => { + if (network !== STELLAR_NETWORK) authError('AUTH_NETWORK_MISMATCH', 'Wrong Stellar network.'); + let hash: string; + try { + hash = new Transaction(transaction, NETWORK_PASSPHRASE).hash().toString('hex'); + } catch { + return authError('AUTH_INVALID_CHALLENGE', 'Invalid seller challenge.'); + } + const challenge = challenges.get(hash); + if (!challenge) return authError('AUTH_CHALLENGE_REPLAYED', 'Challenge is unknown or already used.'); + if (challenge.expiresAt * 1000 <= Date.now()) { + challenges.delete(hash); + return authError('AUTH_CHALLENGE_EXPIRED', 'Seller challenge expired.'); + } + try { + WebAuth.verifyChallengeTxSigners(transaction, mockSigningKey.publicKey(), NETWORK_PASSPHRASE, + [challenge.account], mockDomain, mockDomain); + } catch { + return authError('AUTH_INVALID_SIGNATURE', 'The seller must sign this challenge.'); + } + challenges.delete(hash); + const token = crypto.randomUUID(); + const data = { token, sellerPublicKey: challenge.account, network, expiresAt: Math.floor(Date.now() / 1000) + 3600 }; + sessions.set(token, data); + return { success: true, data }; + }, +}; + +const mockSellerSessions = createSellerSessionManager({ + getWalletSession: useWalletStore.getState, + expectedNetwork: STELLAR_NETWORK, + authenticate: async (wallet, assertCurrent) => { + const challenge = await mockAuthApi.getChallenge(wallet.publicKey!, wallet.network!); + assertCurrent(); + const transaction = await signSellerChallenge(challenge.data, wallet.publicKey!); + assertCurrent(); + const response = await mockAuthApi.createSession({ transaction, network: wallet.network! }); + assertCurrent(); + return response.data; + }, +}); +mockSellerSessions.sync(); +useWalletStore.subscribe(() => mockSellerSessions.sync()); + +async function requireMockSeller(sellerPublicKey?: string | null, delayMs = 500) { + const context = mockSellerSessions.contextFor(sellerPublicKey); + const token = await mockSellerSessions.getToken(context); + await delay(delayMs); + mockSellerSessions.assertCurrent(context); + const session = sessions.get(token); + if (!session || session.expiresAt * 1000 <= Date.now()) { + return authError('AUTH_SESSION_EXPIRED', 'Seller session expired.'); + } + if (sellerPublicKey && sellerPublicKey !== session.sellerPublicKey) { + return authError('AUTH_SELLER_MISMATCH', 'The seller does not match the session.', 403); + } + return session; +} + +function requireMockOwner(invoice: any, session: SellerSessionToken) { + if (!invoice) throw new Error('Invoice not found'); + if (invoice.sellerPublicKey !== session.sellerPublicKey) { + authError('AUTH_SELLER_MISMATCH', 'This invoice belongs to another seller.', 403); + } +} + // Mock invoice data const mockInvoices = [ { @@ -86,7 +189,10 @@ function payableMockInvoice(invoice: any) { export const mockInvoiceApi = { create: async (data: any) => { - await delay(1000); // Simulate network delay + const session = await requireMockSeller(data.sellerPublicKey, 1000); + if (data.network && data.network !== session.network) { + authError('AUTH_NETWORK_MISMATCH', 'Wrong Stellar network.', 403); + } const expiresInDays = data.expiresInDays ?? 7; if (!Number.isInteger(expiresInDays) || expiresInDays < MIN_EXPIRY_DAYS || expiresInDays > MAX_EXPIRY_DAYS) { throw new Error('Invoice expiry must be an integer between 1 and 30 days'); @@ -97,7 +203,8 @@ export const mockInvoiceApi = { ...data, status: 'PENDING', memo: `INV-${Date.now().toString(36).toUpperCase()}-${Math.random().toString(36).substr(2, 4).toUpperCase()}`, - sellerPublicKey: 'GABC123EXAMPLE456789', + sellerPublicKey: session.sellerPublicKey, + network: session.network, createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toISOString(), }; @@ -122,7 +229,8 @@ export const mockInvoiceApi = { }, getById: async (id: string, sellerPublicKey?: string | null) => { - await delay(500); + const session = sellerPublicKey ? await requireMockSeller(sellerPublicKey) : null; + if (!session) await delay(500); expirePendingInvoices(); const invoice = mockInvoices.find(inv => inv.id === id); @@ -130,15 +238,10 @@ export const mockInvoiceApi = { throw new Error('Invoice not found'); } - // Mirrors the backend split (#503): workspace fields only for the seller. - if (sellerPublicKey !== invoice.sellerPublicKey) { - const publicDto: Record = {}; - for (const key of PUBLIC_INVOICE_FIELDS) { - if ((invoice as any)[key] !== undefined) publicDto[key] = (invoice as any)[key]; - } + if (!session || session.sellerPublicKey !== invoice.sellerPublicKey) { return { success: true, - data: publicDto, + data: publicInvoice(invoice), }; } @@ -149,9 +252,9 @@ export const mockInvoiceApi = { }, getAll: async (params?: any) => { - await delay(700); + const session = await requireMockSeller(params?.sellerPublicKey, 700); expirePendingInvoices(); - let filtered = [...mockInvoices]; + let filtered = mockInvoices.filter((invoice) => invoice.sellerPublicKey === session.sellerPublicKey); if (params?.status && params.status !== 'ALL') { filtered = filtered.filter(inv => inv.status === params.status); @@ -190,15 +293,21 @@ export const mockInvoiceApi = { stellarUri: undefined, copyValue: paymentUrl, stellarQrEncodesUri: false, - invoice, + invoice: publicInvoice(invoice), }, }; }, - cancel: async (id: string) => { - await delay(500); + getPaymentEvents: async (id: string, sellerPublicKey: string) => { + const session = await requireMockSeller(sellerPublicKey); + requireMockOwner(mockInvoices.find((invoice) => invoice.id === id), session); + return { success: true, data: [] }; + }, + + cancel: async (id: string, sellerPublicKey: string) => { + const session = await requireMockSeller(sellerPublicKey); const invoice = mockInvoices.find(inv => inv.id === id); - + requireMockOwner(invoice, session); payableMockInvoice(invoice); if (invoice) { invoice.status = 'CANCELLED'; @@ -223,14 +332,15 @@ export const mockInvoiceApi = { return { success: true, - data: invoice, + data: publicInvoice(invoice), }; }, - getStats: async () => { - await delay(500); + getStats: async (sellerPublicKey: string) => { + const session = await requireMockSeller(sellerPublicKey); expirePendingInvoices(); - const revenueByAsset = mockInvoices + const sellerInvoices = mockInvoices.filter((invoice) => invoice.sellerPublicKey === session.sellerPublicKey); + const revenueByAsset = sellerInvoices .filter(invoice => invoice.status === 'PAID') .reduce>((revenue, invoice) => { revenue[invoice.assetCode] = (revenue[invoice.assetCode] || 0) + invoice.amount; @@ -238,11 +348,11 @@ export const mockInvoiceApi = { }, {}); const stats = { - total_invoices: mockInvoices.length, - paid_invoices: mockInvoices.filter(inv => inv.status === 'PAID').length, - pending_invoices: mockInvoices.filter(inv => inv.status === 'PENDING').length, - actionable_invoices: mockInvoices.filter(inv => inv.status === 'PENDING').length, - expired_invoices: mockInvoices.filter(inv => inv.status === 'EXPIRED').length, + total_invoices: sellerInvoices.length, + paid_invoices: sellerInvoices.filter(inv => inv.status === 'PAID').length, + pending_invoices: sellerInvoices.filter(inv => inv.status === 'PENDING').length, + actionable_invoices: sellerInvoices.filter(inv => inv.status === 'PENDING').length, + expired_invoices: sellerInvoices.filter(inv => inv.status === 'EXPIRED').length, revenue_by_asset: revenueByAsset, }; diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index b7bce154..ea97f6bc 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -4,7 +4,6 @@ import { isConnected, getPublicKey, signTransaction, - signBlob, isAllowed, setAllowed, getNetwork, @@ -582,27 +581,88 @@ export const streamPayments = ( return closeHandler; }; -/** - * The one seller-proof message for cancel (issue #517): the connected wallet - * signs exactly `cancel:` — one canonical message on one canonical - * transport (the request body), so a stale query param or header cannot - * smuggle a different seller key past the gate. - */ -export const CANCEL_INVOICE_MESSAGE_PREFIX = 'cancel:'; +export interface SellerChallenge { + transaction: string; + network: string; + networkPassphrase: string; + serverSigningKey: string; + homeDomain: string; + webAuthDomain: string; + expiresAt: number; +} -export const signInvoiceCancelMessage = async ( - invoiceId: string -): Promise<{ publicKey: string; signature: string }> => { - const session = await assertFreighterReady(); - const message = `${CANCEL_INVOICE_MESSAGE_PREFIX}${invoiceId}`; - // ASCII-only message, so btoa is a safe UTF-8→base64 step here. - const signed = await signBlob(btoa(message), { accountToSign: session.publicKey! }); - const signature = readResultString(signed as any, ['signedBlob', 'signature']) || - (typeof signed === 'string' ? signed : null); - if (!signature) { - throw new Error('Freighter did not return a cancel signature'); +/** Sign a server-authenticated SEP-10 challenge; never submit it to Horizon. */ +export const signSellerChallenge = async ( + challenge: SellerChallenge, + expectedPublicKey: string +): Promise => { + const refuse = (code: string, message: string): never => { + throw Object.assign(new Error(message), { + code, response: { status: 401, data: { success: false, code, error: message } }, + }); + }; + if (challenge?.network !== STELLAR_NETWORK || challenge?.networkPassphrase !== NETWORK_PASSPHRASE) { + return refuse('AUTH_NETWORK_MISMATCH', 'The seller challenge is for a different Stellar network.'); + } + let parsed: ReturnType; + try { + if (!challenge.homeDomain || !challenge.webAuthDomain || + !Number.isSafeInteger(challenge.expiresAt)) throw new Error('Invalid challenge metadata'); + parsed = StellarSdk.WebAuth.readChallengeTx( + challenge.transaction, challenge.serverSigningKey, NETWORK_PASSPHRASE, + challenge.homeDomain, challenge.webAuthDomain + ); + const maxTime = Number(parsed.tx.timeBounds?.maxTime); + const minTime = Number(parsed.tx.timeBounds?.minTime); + if (parsed.clientAccountID !== expectedPublicKey || maxTime !== challenge.expiresAt || + maxTime <= Date.now() / 1000 || maxTime - minTime > 300) { + throw new Error('Wrong account or expired challenge'); + } + } catch { + return refuse('AUTH_INVALID_CHALLENGE', 'The server returned an invalid or expired seller challenge.'); + } + + const readReadyWallet = async () => { + try { + return await assertFreighterReady(); + } catch (error) { + return refuse('AUTH_WALLET_REQUIRED', error instanceof Error ? error.message : FREIGHTER_CONNECT_REQUIRED_MESSAGE); + } + }; + const before = await readReadyWallet(); + if (before.publicKey !== expectedPublicKey) { + return refuse('WALLET_SESSION_CHANGED', 'The connected wallet changed. Please try again.'); + } + let signed: Awaited>; + try { + signed = await signTransaction(challenge.transaction, { + networkPassphrase: NETWORK_PASSPHRASE, + accountToSign: expectedPublicKey, + }); + } catch { + return refuse('AUTH_SIGNATURE_REQUIRED', 'Approve the seller sign-in challenge in Freighter to continue.'); + } + const signedTransaction = readResultString(signed, ['signedTxXdr']); + if (!signedTransaction) { + return refuse('AUTH_SIGNATURE_REQUIRED', 'Freighter did not return a signed seller challenge.'); + } + const after = await readReadyWallet(); + if (after.publicKey !== expectedPublicKey) { + return refuse('WALLET_SESSION_CHANGED', 'The connected wallet changed. Please try again.'); + } + try { + const signedTx = StellarSdk.TransactionBuilder.fromXDR(signedTransaction, NETWORK_PASSPHRASE); + if (signedTx.hash().toString('hex') !== parsed.tx.hash().toString('hex')) { + throw new Error('Freighter returned a different transaction'); + } + StellarSdk.WebAuth.verifyChallengeTxSigners( + signedTransaction, challenge.serverSigningKey, NETWORK_PASSPHRASE, + [expectedPublicKey], challenge.homeDomain, challenge.webAuthDomain + ); + } catch { + return refuse('AUTH_INVALID_SIGNATURE', 'Freighter did not sign the expected seller challenge.'); } - return { publicKey: session.publicKey!, signature }; + return signedTransaction; }; /** diff --git a/frontend/lib/wallet-session.d.ts b/frontend/lib/wallet-session.d.ts index 1e27eec9..9eed020f 100644 --- a/frontend/lib/wallet-session.d.ts +++ b/frontend/lib/wallet-session.d.ts @@ -17,6 +17,35 @@ export interface WalletSessionChange { connectionChanged: boolean; } +export interface SellerSessionContext extends WalletSession { + readonly generation: number; +} + +export interface SellerSessionToken { + token: string; + expiresAt: number; + sellerPublicKey: string; + network: string; +} + +export interface SellerSessionManager { + sync(): WalletSession; + contextFor(expectedPublicKey?: string | null): SellerSessionContext; + isCurrent(context: SellerSessionContext): boolean; + assertCurrent(context: SellerSessionContext): void; + getToken(context: SellerSessionContext): Promise; + cachedToken(context: SellerSessionContext): string | null; + invalidate(context: SellerSessionContext, rejectedToken: string): void; + clear(): void; +} + +export function createSellerSessionManager(options: { + getWalletSession: () => Partial; + authenticate: (context: SellerSessionContext, assertCurrent: () => void) => Promise; + expectedNetwork: string; + now?: () => number; +}): SellerSessionManager; + export function normalizeWalletSession(source?: Partial | null): WalletSession; export function walletSessionGate( session?: Partial | null, diff --git a/frontend/lib/wallet-session.js b/frontend/lib/wallet-session.js index 1ca046e1..58425b73 100644 --- a/frontend/lib/wallet-session.js +++ b/frontend/lib/wallet-session.js @@ -9,10 +9,8 @@ * account. The answers drifted, and the drift is what "stale public key" bugs * are made of. * - * This module is the single normaliser. It is pure - no React, no store, no - * Freighter call - so every surface can be tested against the same session - * values, and it delegates the "can this wallet act?" decision to walletGate - * rather than re-deriving it. + * This module has no React, store or Freighter dependency. The normaliser and + * in-memory seller session manager share the same walletGate decision. */ const { walletGate } = require('./freighter-availability'); @@ -109,7 +107,8 @@ function walletSessionChanged(previous, next) { const after = normalizeWalletSession(next); const accountChanged = before.publicKey !== after.publicKey; - const networkChanged = before.network !== after.network; + const networkChanged = before.network !== after.network || + before.networkPassphrase !== after.networkPassphrase; const connectionChanged = before.connected !== after.connected; return { @@ -137,7 +136,113 @@ function shouldClearSellerState(previous, next) { return normalizeWalletSession(previous).publicKey !== after.publicKey; } +function sessionError(code, message, status = 401) { + return Object.assign(new Error(message), { + code, status, response: { status, data: { success: false, code, error: message } }, + }); +} + +/** + * A bearer token belongs to one uninterrupted wallet session, not merely a + * public key. Epochs also fence A -> B -> A switches while Freighter is open. + * Nothing in this manager is written to localStorage or the persisted store. + * + * `sync` must be subscribed synchronously to wallet changes. Each operation + * also reads the wallet itself, so a delayed React effect cannot reuse a token. + * `authenticate` checks assertCurrent between its challenge/sign/redeem awaits. + */ +function createSellerSessionManager({ getWalletSession, authenticate, expectedNetwork, now = Date.now }) { + let scope; + let generation = 0; + let token = null; + let pending = null; + + function sync() { + const wallet = normalizeWalletSession(getWalletSession()); + const nextScope = JSON.stringify([ + wallet.publicKey, wallet.connected, wallet.network, + wallet.networkPassphrase, wallet.freighterAvailable === false, + ]); + if (nextScope !== scope) { + scope = nextScope; + generation += 1; + token = null; + pending = null; + } + return wallet; + } + + function contextFor(expectedPublicKey) { + const wallet = sync(); + const gate = walletSessionGate(wallet, expectedNetwork); + if (!gate.ready) throw sessionError('AUTH_WALLET_REQUIRED', gate.message); + if (expectedPublicKey && expectedPublicKey !== wallet.publicKey) { + throw sessionError('WALLET_SESSION_CHANGED', 'The connected wallet changed. Please try again.', 409); + } + return Object.freeze({ ...wallet, generation, network: expectedNetwork }); + } + + function isCurrent(context) { + const wallet = sync(); + return Boolean(context && context.generation === generation && + context.publicKey === wallet.publicKey && walletSessionGate(wallet, expectedNetwork).ready); + } + + function assertCurrent(context) { + if (!isCurrent(context)) { + throw sessionError('WALLET_SESSION_CHANGED', 'The connected wallet changed. Please try again.', 409); + } + } + + function cachedToken(context) { + assertCurrent(context); + if (token && token.expiresAt * 1000 <= now()) token = null; + return token ? token.token : null; + } + + async function getToken(context) { + const cached = cachedToken(context); + if (cached) return cached; + if (pending) return pending; + + const request = Promise.resolve().then(async () => { + assertCurrent(context); + const issued = await authenticate(context, () => assertCurrent(context)); + assertCurrent(context); + if (!issued || typeof issued.token !== 'string' || !issued.token || + issued.sellerPublicKey !== context.publicKey || issued.network !== expectedNetwork || + !Number.isSafeInteger(issued.expiresAt) || issued.expiresAt * 1000 <= now()) { + throw sessionError('AUTH_INVALID_SESSION', 'The server returned an invalid seller session.'); + } + token = { token: issued.token, expiresAt: issued.expiresAt }; + return token.token; + }); + pending = request; + try { + return await request; + } finally { + // A stale challenge must never clear the new wallet's pending login. + if (pending === request) pending = null; + } + } + + function invalidate(context, rejectedToken) { + assertCurrent(context); + // A late 401 for the previous token must not evict a successful refresh. + if (token && token.token === rejectedToken) token = null; + } + + function clear() { + generation += 1; + token = null; + pending = null; + } + + return { sync, contextFor, isCurrent, assertCurrent, getToken, cachedToken, invalidate, clear }; +} + module.exports = { + createSellerSessionManager, normalizeWalletSession, shouldClearSellerState, walletSessionChanged, diff --git a/frontend/tests/seller-challenge.test.js b/frontend/tests/seller-challenge.test.js new file mode 100644 index 00000000..07112833 --- /dev/null +++ b/frontend/tests/seller-challenge.test.js @@ -0,0 +1,131 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const Module = require('node:module'); +const esbuild = require('esbuild'); +const { Keypair, Networks, Transaction, WebAuth } = require('@stellar/stellar-sdk'); + +const ROOT = path.resolve(__dirname, '..'); +const seller = Keypair.random(); +const other = Keypair.random(); +const serverKey = Keypair.random(); +let app; + +test.before(async () => { + const result = await esbuild.build({ + stdin: { + contents: `export { signSellerChallenge, server } from './lib/stellar'; + export { mockAuthApi, mockInvoiceApi } from './lib/mock-api'; + export { setWallet, setSigner, getCalls } from '@stellar/freighter-api';`, + resolveDir: ROOT, sourcefile: 'seller-challenge-test.ts', loader: 'ts', + }, + absWorkingDir: ROOT, bundle: true, write: false, platform: 'node', format: 'cjs', + external: ['@stellar/stellar-sdk', 'zustand', 'zustand/middleware'], tsconfig: 'tsconfig.json', + define: { 'process.env.NEXT_PUBLIC_STELLAR_NETWORK': '"TESTNET"' }, + plugins: [{ + name: 'freighter-extension-boundary', + setup(build) { + build.onResolve({ filter: /^@stellar\/freighter-api$/ }, () => ({ path: 'freighter', namespace: 'wallet-test' })); + build.onLoad({ filter: /.*/, namespace: 'wallet-test' }, () => ({ + contents: `let wallet; let signer; let calls = []; + export const setWallet = (next) => { wallet = next; }; + export const setSigner = (next) => { signer = next; calls = []; }; + export const getCalls = () => calls; + export const isConnected = async () => true; + export const isAllowed = async () => true; + export const setAllowed = async () => true; + export const getPublicKey = async () => wallet; + export const getAddress = async () => ({ address: wallet }); + export const WatchWalletChanges = undefined; + export const getNetwork = async () => ({ network: 'TESTNET', networkPassphrase: 'Test SDF Network ; September 2015' }); + export const getNetworkDetails = getNetwork; + export const signTransaction = async (xdr, options) => { + calls.push({ xdr, options }); return signer(xdr, options); + };`, loader: 'js', + })); + }, + }], + }); + const compiled = new Module('seller-challenge-test'); + compiled.filename = path.join(ROOT, 'seller-challenge-test.cjs'); + compiled.paths = Module._nodeModulePaths(ROOT); + compiled._compile(result.outputFiles[0].text, compiled.filename); + app = compiled.exports; + app.server.submitTransaction = async () => { throw new Error('An authentication challenge must never be submitted'); }; +}); + +function challengeFor(account = seller.publicKey()) { + const transaction = WebAuth.buildChallengeTx(serverKey, account, 'quittance.test', 300, + Networks.TESTNET, 'api.quittance.test'); + return { + transaction, network: 'TESTNET', networkPassphrase: Networks.TESTNET, + serverSigningKey: serverKey.publicKey(), homeDomain: 'quittance.test', + webAuthDomain: 'api.quittance.test', + expiresAt: Number(new Transaction(transaction, Networks.TESTNET).timeBounds.maxTime), + }; +} + +function sign(xdr, account = seller) { + const transaction = new Transaction(xdr, Networks.TESTNET); + transaction.sign(account); + return transaction.toXDR(); +} + +test('Freighter signs the exact SEP-10 transaction for the pinned account and network', async () => { + const challenge = challengeFor(); + app.setWallet(seller.publicKey()); + app.setSigner((xdr) => sign(xdr)); + const signed = await app.signSellerChallenge(challenge, seller.publicKey()); + assert.deepEqual(app.getCalls(), [{ xdr: challenge.transaction, options: { + networkPassphrase: Networks.TESTNET, accountToSign: seller.publicKey(), + } }]); + assert.deepEqual(WebAuth.verifyChallengeTxSigners(signed, serverKey.publicKey(), Networks.TESTNET, + [seller.publicKey()], 'quittance.test', 'api.quittance.test'), [seller.publicKey()]); +}); + +test('wrong-network, expired and different-account challenges never open Freighter signing', async () => { + app.setWallet(seller.publicKey()); + app.setSigner((xdr) => sign(xdr)); + const challenge = challengeFor(); + await assert.rejects(app.signSellerChallenge({ ...challenge, network: 'PUBLIC' }, seller.publicKey()), + { code: 'AUTH_NETWORK_MISMATCH' }); + await assert.rejects(app.signSellerChallenge({ ...challenge, expiresAt: 1 }, seller.publicKey()), + { code: 'AUTH_INVALID_CHALLENGE' }); + await assert.rejects(app.signSellerChallenge(challengeFor(other.publicKey()), seller.publicKey()), + { code: 'AUTH_INVALID_CHALLENGE' }); + assert.equal(app.getCalls().length, 0); +}); + +test('a wallet switch inside the signing dialog rejects the old account signature', async () => { + app.setWallet(seller.publicKey()); + app.setSigner((xdr) => { + app.setWallet(other.publicKey()); + return sign(xdr); + }); + await assert.rejects(app.signSellerChallenge(challengeFor(), seller.publicKey()), + { code: 'WALLET_SESSION_CHANGED' }); +}); + +test('a disconnected wallet or declined dialog is an authentication error, not an API outage', async () => { + app.setWallet(null); + app.setSigner((xdr) => sign(xdr)); + await assert.rejects(app.signSellerChallenge(challengeFor(), seller.publicKey()), + { code: 'AUTH_WALLET_REQUIRED' }); + app.setWallet(seller.publicKey()); + app.setSigner(() => { throw new Error('User declined'); }); + await assert.rejects(app.signSellerChallenge(challengeFor(), seller.publicKey()), + { code: 'AUTH_SIGNATURE_REQUIRED' }); +}); + +test('the mock accepts a signed challenge once and public pay-link data omits customer email', async () => { + const { data } = await app.mockAuthApi.getChallenge(seller.publicKey(), 'TESTNET'); + const transaction = sign(data.transaction); + const result = await app.mockAuthApi.createSession({ transaction, network: 'TESTNET' }); + assert.equal(result.data.sellerPublicKey, seller.publicKey()); + assert.equal(result.data.network, 'TESTNET'); + await assert.rejects(app.mockAuthApi.createSession({ transaction, network: 'TESTNET' }), + { code: 'AUTH_CHALLENGE_REPLAYED' }); + const invoice = await app.mockInvoiceApi.getById('1'); + assert.equal('customerEmail' in invoice.data, false); + assert.equal('payerPublicKey' in invoice.data, false); +}); diff --git a/frontend/tests/seller-session-api.test.js b/frontend/tests/seller-session-api.test.js new file mode 100644 index 00000000..ba017244 --- /dev/null +++ b/frontend/tests/seller-session-api.test.js @@ -0,0 +1,182 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const Module = require('node:module'); +const esbuild = require('esbuild'); +const { AxiosError } = require('axios'); + +const ROOT = path.resolve(__dirname, '..'); +const ALICE = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; +const BOB = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'; +const TESTNET = 'Test SDF Network ; September 2015'; +let app; +const persisted = new Map(); + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +test.before(async () => { + globalThis.localStorage = { + getItem: (key) => persisted.get(key) ?? null, + setItem: (key, value) => persisted.set(key, value), + removeItem: (key) => persisted.delete(key), + }; + const result = await esbuild.build({ + stdin: { + contents: `export { default as api, invoiceApi, sellerSessionManager } from './lib/api'; + export { useWalletStore } from './lib/store'; + export { setSigner } from '@/lib/stellar';`, + resolveDir: ROOT, sourcefile: 'seller-session-test.ts', loader: 'ts', + }, + absWorkingDir: ROOT, bundle: true, write: false, platform: 'node', format: 'cjs', + external: ['axios', 'zustand', 'zustand/middleware'], tsconfig: 'tsconfig.json', + define: { + 'process.env.NEXT_PUBLIC_STELLAR_NETWORK': '"TESTNET"', + 'process.env.NEXT_PUBLIC_API_URL': '"http://127.0.0.1:3001/api"', + }, + plugins: [{ + name: 'freighter-dialog-only', + setup(build) { + build.onResolve({ filter: /^@\/lib\/stellar$/ }, () => ({ path: 'stellar', namespace: 'wallet-test' })); + build.onLoad({ filter: /.*/, namespace: 'wallet-test' }, () => ({ + contents: `let signer; + export const setSigner = (next) => { signer = next; }; + export const signSellerChallenge = (...args) => signer(...args);`, loader: 'js', + })); + }, + }], + }); + const compiled = new Module('seller-session-api-test'); + compiled.filename = path.join(ROOT, 'seller-session-api-test.cjs'); + compiled.paths = Module._nodeModulePaths(ROOT); + compiled._compile(result.outputFiles[0].text, compiled.filename); + app = compiled.exports; +}); + +function prepare(seller = ALICE) { + persisted.clear(); + app.sellerSessionManager.clear(); + app.useWalletStore.setState({ + publicKey: seller, connected: true, network: 'TESTNET', networkPassphrase: TESTNET, + freighterAvailable: true, balance: '0', + }); + app.setSigner(async (challenge) => `signed:${challenge.transaction}`); + const calls = []; + let issued = 0; + const respond = (config, data) => ({ status: 200, statusText: 'OK', headers: {}, config, data }); + const unauthorized = (config) => { + throw new AxiosError('Expired', 'ERR_BAD_REQUEST', config, undefined, { + status: 401, statusText: 'Unauthorized', headers: {}, config, + data: { success: false, code: 'AUTH_SESSION_EXPIRED', error: 'Seller session expired.' }, + }); + }; + function auth(config) { + calls.push({ url: config.url, method: config.method, token: config.headers.Authorization, signal: config.signal }); + if (config.url === '/auth/challenge') { + return respond(config, { success: true, data: { transaction: config.params.account } }); + } + if (config.url === '/auth/session') { + const body = typeof config.data === 'string' ? JSON.parse(config.data) : config.data; + const sellerPublicKey = body.transaction.replace('signed:', ''); + return respond(config, { success: true, data: { + token: `token-${sellerPublicKey}-${++issued}`, sellerPublicKey, + network: 'TESTNET', expiresAt: Math.floor(Date.now() / 1000) + 3600, + } }); + } + return null; + } + return { calls, respond, unauthorized, auth }; +} + +test('dashboard list and stats earn one bearer session; public pay reads stay anonymous', async () => { + const fixture = prepare(); + app.api.defaults.adapter = async (config) => fixture.auth(config) || fixture.respond(config, { data: [] }); + await Promise.all([app.invoiceApi.getAll({ sellerPublicKey: ALICE }), app.invoiceApi.getStats(ALICE)]); + await app.invoiceApi.getById('public-pay-link'); + assert.equal(fixture.calls.filter((call) => call.url === '/auth/challenge').length, 1); + assert.equal(fixture.calls.filter((call) => call.url === '/auth/session').length, 1); + assert.equal(fixture.calls.find((call) => call.url === '/invoices').token, `Bearer token-${ALICE}-1`); + assert.equal(fixture.calls.find((call) => call.url === '/invoices/stats').token, `Bearer token-${ALICE}-1`); + assert.equal(fixture.calls.find((call) => call.url === '/invoices/public-pay-link').token, undefined); + assert.doesNotMatch([...persisted.values()].join(''), /token-/); +}); + +test('a seller request challenges once after 401 and never loops on a second 401', async () => { + const fixture = prepare(); + let attempts = 0; + app.api.defaults.adapter = async (config) => { + const result = fixture.auth(config); + if (result) return result; + if (++attempts === 1) return fixture.unauthorized(config); + return fixture.respond(config, { data: ['accepted'] }); + }; + assert.deepEqual(await app.invoiceApi.getStats(ALICE), { data: ['accepted'] }); + assert.equal(fixture.calls.filter((call) => call.url === '/auth/challenge').length, 2); + assert.equal(attempts, 2); + + const second = prepare(); + app.api.defaults.adapter = async (config) => second.auth(config) || second.unauthorized(config); + await assert.rejects(app.invoiceApi.getStats(ALICE), { code: 'AUTH_SESSION_EXPIRED' }); + assert.equal(second.calls.filter((call) => call.url === '/auth/challenge').length, 2); + assert.equal(second.calls.filter((call) => call.url === '/invoices/stats').length, 2); +}); + +test('wallet switch while Freighter is signing cannot redeem or fetch for the old seller', async () => { + const fixture = prepare(); + const dialog = deferred(); + const opened = deferred(); + app.setSigner(async () => { opened.resolve(); return dialog.promise; }); + app.api.defaults.adapter = async (config) => fixture.auth(config) || fixture.respond(config, { data: [] }); + const request = app.invoiceApi.getAll({ sellerPublicKey: ALICE }); + await opened.promise; + app.useWalletStore.setState({ publicKey: BOB }); + dialog.resolve(`signed:${ALICE}`); + await assert.rejects(request, { code: 'WALLET_SESSION_CHANGED' }); + assert.deepEqual(fixture.calls.map((call) => call.url), ['/auth/challenge']); + assert.equal(app.sellerSessionManager.cachedToken(app.sellerSessionManager.contextFor(BOB)), null); +}); + +test('wallet switch mid-dashboard aborts the old fetch and its 401 cannot sign in the new account', async () => { + const fixture = prepare(); + const pending = deferred(); + const requested = deferred(); + let oldConfig; + app.api.defaults.adapter = async (config) => { + const result = fixture.auth(config); + if (result) return result; + oldConfig = config; + requested.resolve(); + await pending.promise; + return fixture.unauthorized(config); + }; + const request = app.invoiceApi.getAll({ sellerPublicKey: ALICE }); + await requested.promise; + app.useWalletStore.setState({ publicKey: BOB }); + assert.equal(oldConfig.signal.aborted, true, 'the store update aborts old seller traffic synchronously'); + assert.equal(app.sellerSessionManager.cachedToken(app.sellerSessionManager.contextFor(BOB)), null); + pending.resolve(); + await assert.rejects(request, { code: 'WALLET_SESSION_CHANGED' }); + assert.equal(fixture.calls.filter((call) => call.url === '/auth/challenge').length, 1); +}); + +test('create, workspace detail, audit events and cancel all carry the earned session', async () => { + const fixture = prepare(); + const bodies = []; + app.api.defaults.adapter = async (config) => { + const result = fixture.auth(config); + if (result) return result; + bodies.push(config.data && JSON.parse(config.data)); + return fixture.respond(config, { data: {} }); + }; + await app.invoiceApi.create({ amount: 1, expiresInDays: 7, sellerPublicKey: ALICE }); + await app.invoiceApi.getById('invoice', ALICE); + await app.invoiceApi.getPaymentEvents('invoice', ALICE); + await app.invoiceApi.cancel('invoice', ALICE); + const sellerCalls = fixture.calls.filter((call) => !call.url.startsWith('/auth/')); + assert.equal(sellerCalls.length, 4); + assert.ok(sellerCalls.every((call) => call.token === `Bearer token-${ALICE}-1`)); + assert.deepEqual(bodies.at(-1), { sellerPublicKey: ALICE }, 'cancel has no reusable blob signature'); +}); diff --git a/frontend/tests/support/stubs/stellar.js b/frontend/tests/support/stubs/stellar.js index cb76f933..f899d03d 100644 --- a/frontend/tests/support/stubs/stellar.js +++ b/frontend/tests/support/stubs/stellar.js @@ -52,11 +52,7 @@ export const isWrongNetwork = () => false; export const watchFreighterNetwork = () => () => {}; export const STELLAR_NETWORK = 'TESTNET'; export const STELLAR_PASSPHRASE = 'Test SDF Network ; September 2015'; -export const CANCEL_INVOICE_MESSAGE_PREFIX = 'cancel:'; -export const signInvoiceCancelMessage = async (invoiceId) => ({ - publicKey: null, - signature: null, -}); +export const signSellerChallenge = async () => { throw new Error('No seller challenge fixture configured'); }; const stellarExports = { server, @@ -79,8 +75,7 @@ const stellarExports = { STELLAR_PASSPHRASE, NETWORK_PASSPHRASE, NETWORK_DISPLAY_NAME, - CANCEL_INVOICE_MESSAGE_PREFIX, - signInvoiceCancelMessage, + signSellerChallenge, EXPECTED_WALLET_NETWORK, }; diff --git a/frontend/tests/wallet-session.test.js b/frontend/tests/wallet-session.test.js index f2757781..94e089e2 100644 --- a/frontend/tests/wallet-session.test.js +++ b/frontend/tests/wallet-session.test.js @@ -16,6 +16,7 @@ const { walletSessionChanged, walletSessionGate, walletSessionKey, + createSellerSessionManager, } = require('../lib/wallet-session'); const ALICE = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; @@ -133,3 +134,103 @@ test('a session that has not been read yet clears rather than caches', () => { assert.equal(shouldClearSellerState(session(), null), true); assert.equal(shouldClearSellerState(session(), {}), true); }); + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +function sessionHarness(authenticate) { + let wallet = session(); + let now = 1_000_000; + const manager = createSellerSessionManager({ + getWalletSession: () => wallet, + expectedNetwork: 'TESTNET', + now: () => now, + authenticate, + }); + manager.sync(); + return { + manager, + switchWallet(next) { wallet = session(next); manager.sync(); }, + advance(milliseconds) { now += milliseconds; }, + }; +} + +const issuedFor = (context, token = 'earned-token') => ({ + token, sellerPublicKey: context.publicKey, network: 'TESTNET', expiresAt: 4600, +}); + +test('dashboard list and stats share one sign-in and keep the token only in memory', async () => { + let calls = 0; + const { manager } = sessionHarness(async (context) => { + calls += 1; + return issuedFor(context); + }); + const context = manager.contextFor(ALICE); + assert.deepEqual(await Promise.all([manager.getToken(context), manager.getToken(context)]), + ['earned-token', 'earned-token']); + assert.equal(await manager.getToken(context), 'earned-token'); + assert.equal(calls, 1); + assert.equal('token' in manager.sync(), false, 'the wallet shape never contains the token'); +}); + +test('switching accounts synchronously drops the old token before the next fetch', async () => { + const harness = sessionHarness(async (context) => issuedFor(context, context.publicKey)); + const alice = harness.manager.contextFor(ALICE); + await harness.manager.getToken(alice); + harness.switchWallet({ publicKey: BOB }); + assert.equal(harness.manager.isCurrent(alice), false); + const bob = harness.manager.contextFor(BOB); + assert.equal(harness.manager.cachedToken(bob), null); + await assert.rejects(harness.manager.getToken(alice), { code: 'WALLET_SESSION_CHANGED' }); + assert.equal(await harness.manager.getToken(bob), BOB); +}); + +test('an A to B to A switch cannot publish an earlier challenge result', async () => { + const result = deferred(); + const harness = sessionHarness(() => result.promise); + const alice = harness.manager.contextFor(ALICE); + const oldRequest = harness.manager.getToken(alice); + await Promise.resolve(); + harness.switchWallet({ publicKey: BOB }); + harness.switchWallet({ publicKey: ALICE }); + result.resolve(issuedFor(alice)); + await assert.rejects(oldRequest, { code: 'WALLET_SESSION_CHANGED' }); + assert.equal(harness.manager.cachedToken(harness.manager.contextFor(ALICE)), null); +}); + +test('a passphrase change under the same network name drops authentication', async () => { + const harness = sessionHarness(async (context) => issuedFor(context)); + const alice = harness.manager.contextFor(ALICE); + await harness.manager.getToken(alice); + harness.switchWallet({ networkPassphrase: 'untrusted custom network' }); + assert.equal(harness.manager.isCurrent(alice), false); + assert.throws(() => harness.manager.contextFor(ALICE), { code: 'AUTH_WALLET_REQUIRED' }); + harness.switchWallet({}); + assert.equal(harness.manager.cachedToken(harness.manager.contextFor(ALICE)), null); +}); + +test('expired tokens reauthenticate and a late 401 cannot evict the refreshed token', async () => { + let issued = 0; + const harness = sessionHarness(async (context) => ({ + ...issuedFor(context, `token-${++issued}`), expiresAt: 1010 + issued * 10, + })); + const context = harness.manager.contextFor(ALICE); + assert.equal(await harness.manager.getToken(context), 'token-1'); + harness.advance(20_000); + assert.equal(await harness.manager.getToken(context), 'token-2'); + harness.manager.invalidate(context, 'token-1'); + assert.equal(harness.manager.cachedToken(context), 'token-2'); + assert.equal(issued, 2); +}); + +test('the server cannot cache a token for another wallet or network', async () => { + for (const override of [{ sellerPublicKey: BOB }, { network: 'PUBLIC' }, { expiresAt: 999 }]) { + const { manager } = sessionHarness(async (context) => ({ ...issuedFor(context), ...override })); + const context = manager.contextFor(ALICE); + await assert.rejects(manager.getToken(context), { code: 'AUTH_INVALID_SESSION' }); + assert.equal(manager.cachedToken(context), null); + } +}); diff --git a/scripts/deploy-smoke.mjs b/scripts/deploy-smoke.mjs index a0818b11..fbe2e000 100644 --- a/scripts/deploy-smoke.mjs +++ b/scripts/deploy-smoke.mjs @@ -5,13 +5,16 @@ if (!configured) { } const baseUrl = configured.replace(/\/+$/, ''); -const sellerPublicKey = 'G' + 'A'.repeat(55); +const sellerSessionToken = process.env.DEPLOY_SELLER_SESSION_TOKEN; +if (!sellerSessionToken) { + throw new Error('DEPLOY_SELLER_SESSION_TOKEN is required; obtain a seller session with the signed challenge flow in docs/SELLER_AUTH.md'); +} async function request(path, options = {}) { const response = await fetch(`${baseUrl}${path}`, { signal: AbortSignal.timeout(20_000), - headers: { accept: 'application/json', 'content-type': 'application/json' }, ...options, + headers: { accept: 'application/json', 'content-type': 'application/json', ...options.headers }, }); const body = await response.json().catch(() => null); if (!response.ok) { @@ -30,11 +33,11 @@ if (readiness.status !== 'ready' || readiness.ready !== true) { const created = await request('/invoices', { method: 'POST', + headers: { authorization: `Bearer ${sellerSessionToken}` }, body: JSON.stringify({ amount: 0.0000001, assetCode: 'XLM', description: `Deploy smoke ${new Date().toISOString()}`, - sellerPublicKey, }), }); const invoice = created?.data?.invoice;