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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
26 changes: 16 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<invoiceId>` — 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:<invoiceId>` 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.

Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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 <session>` 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
Expand All @@ -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=<the invoice's own seller key>` 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`.

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions backend/env.example.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.

14 changes: 10 additions & 4 deletions backend/env.mvp.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 36 additions & 1 deletion backend/scripts/evidence-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ 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);
}

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) {
Expand All @@ -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 };
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion backend/src/config/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 64 additions & 0 deletions backend/src/middleware/seller-session.ts
Original file line number Diff line number Diff line change
@@ -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);
}
31 changes: 31 additions & 0 deletions backend/src/routes/auth.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading