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
17 changes: 17 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,20 @@
# PostgreSQL connection string for Prisma
# Example: postgres://username:password@localhost:5432/stellar_bounty
DATABASE_URL=postgresql://username:password@localhost:5432/stellar_bounty

# SEP-10 Wallet Authentication
# Server signing keypair for issuing SEP-10 challenge transactions.
# Generate with: stellar keys generate --network testnet
# Required in production. Defaults to a deterministic test keypair in development.
SERVER_SIGNING_SECRET=SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

# HMAC secret for signing issued JWTs.
# If not set, derived from SERVER_SIGNING_SECRET. Set explicitly in production.
JWT_SECRET=replace-with-a-long-random-secret-string

# Home domain used in SEP-10 ManageData operation keys (e.g. your-domain.com)
HOME_DOMAIN=stellar-bounty-board.local

# Web auth domain (defaults to HOME_DOMAIN when not set)
# WEB_AUTH_DOMAIN=auth.your-domain.com

22 changes: 12 additions & 10 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,9 @@ import {
captureRawBody,
createGitHubWebhookSignatureMiddleware,
} from './webhooks/signatureVerification';
import {
createBountyCreationSignatureMiddleware,
createStellarSignatureAuthMiddleware,
} from './middleware/auth';
import { createBountyCreationSignatureMiddleware } from './middleware/auth';
import { requireSep10Auth } from './middleware/sep10Session';
import { authRouter } from './routes/auth';
import { idempotencyMiddleware } from './middleware/idempotency';
import { requireJsonContentType } from './middleware/contentType';
import { readLimiter, mutationLimiter } from './utils';
Expand Down Expand Up @@ -170,6 +169,9 @@ app.use(readLimiter);
const swaggerDoc = generateOpenApiDocument();
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDoc));

// SEP-10 wallet authentication
app.use('/api/auth', authRouter);

function parseId(raw: string | string[] | undefined): string {
return bountyIdSchema.parse(Array.isArray(raw) ? raw[0] : raw);
}
Expand Down Expand Up @@ -610,7 +612,7 @@ app.post(
mutationLimiter,
requireJsonContentType,
idempotencyMiddleware,
createStellarSignatureAuthMiddleware(),
requireSep10Auth(),
validateBody(maintainerActionSchema),
async (req: Request, res: Response) => {
try {
Expand All @@ -631,7 +633,7 @@ app.post(
'/api/bounties/:id/refund',
mutationLimiter,
idempotencyMiddleware,
createStellarSignatureAuthMiddleware(),
requireSep10Auth(),
validateBody(maintainerActionSchema),
async (req: Request, res: Response) => {
try {
Expand All @@ -652,7 +654,7 @@ app.post(
'/api/bounties/:id/cancel',
mutationLimiter,
idempotencyMiddleware,
createStellarSignatureAuthMiddleware(),
requireSep10Auth(),
async (req: Request, res: Response) => {
const parsedBody = maintainerActionSchema.safeParse(req.body);

Expand All @@ -678,7 +680,7 @@ app.post(
app.post(
'/api/bounties/:id/dispute',
mutationLimiter,
createStellarSignatureAuthMiddleware(),
requireSep10Auth(),
validateBody(disputeBountySchema),
async (req: Request, res: Response) => {
try {
Expand Down Expand Up @@ -719,7 +721,7 @@ app.patch(
'/api/bounties/:id/notes',
mutationLimiter,
requireJsonContentType,
createStellarSignatureAuthMiddleware(),
requireSep10Auth(),
validateBody(updateNotesSchema),
async (req: Request, res: Response) => {
try {
Expand All @@ -740,7 +742,7 @@ app.post(
'/api/bounties/:id/extend-deadline',
mutationLimiter,
idempotencyMiddleware,
createStellarSignatureAuthMiddleware(),
requireSep10Auth(),
async (req: Request, res: Response) => {
const parsedBody = extendDeadlineSchema.safeParse(req.body);

Expand Down
59 changes: 59 additions & 0 deletions backend/src/middleware/sep10Session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* SEP-10 JWT session middleware.
*
* Validates the `Authorization: Bearer <jwt>` header issued by POST /api/auth/verify.
* On success, attaches the authenticated Stellar address to `req.signerPublicKey`
* so downstream handlers have a verified identity without trusting raw address claims
* in the request body.
*
* Usage:
* import { requireSep10Auth } from "./middleware/sep10Session";
* app.post("/api/bounties/:id/release", requireSep10Auth(), ...);
*/

import type { RequestHandler } from "express";
import { verifyJwt } from "../services/sep10Auth";
import { logger } from "../logger";

/**
* Returns a middleware that enforces a valid SEP-10 session JWT.
*
* In `NODE_ENV=test` the middleware is bypassed — `req.signerPublicKey` must
* be set by the test itself if the handler needs it. This mirrors the
* behaviour of the legacy `createStellarSignatureAuthMiddleware`.
*/
export function requireSep10Auth(): RequestHandler {
return (req, res, next) => {
// Allow tests to skip real JWT verification.
if (process.env.NODE_ENV === "test") {
next();
return;
}

const authHeader = req.headers.authorization;
if (!authHeader) {
res.status(401).json({ error: "Missing Authorization header. Use Bearer <token>." });
return;
}

const parts = authHeader.split(" ");
if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") {
res.status(401).json({ error: "Invalid Authorization header format. Use: Bearer <token>." });
return;
}

const token = parts[1];

try {
const payload = verifyJwt(token);
// Attach the verified Stellar address for use by route handlers.
req.signerPublicKey = payload.sub;
logger.debug({ accountId: payload.sub }, "SEP-10 session verified");
next();
} catch (err) {
const message = err instanceof Error ? err.message : "Token verification failed.";
logger.warn({ err: message }, "SEP-10 session rejected");
res.status(401).json({ error: message });
}
};
}
118 changes: 118 additions & 0 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* SEP-10 authentication routes.
*
* GET /api/auth/challenge?account=G... — issue a SEP-10 challenge transaction
* POST /api/auth/verify — verify a signed challenge and receive a JWT
*/

import { Router, Request, Response } from "express";
import { z } from "zod";
import { buildChallenge, verifyChallenge } from "../services/sep10Auth";
import { logger } from "../logger";

export const authRouter = Router();

// ---------------------------------------------------------------------------
// Input validation schemas
// ---------------------------------------------------------------------------

const challengeQuerySchema = z.object({
account: z
.string({ required_error: "account query parameter is required." })
.regex(/^[GM][A-Z2-7]{54,55}$/, "account must be a valid Stellar public key (G...) or muxed account (M...)."),
});

const verifyBodySchema = z.object({
transaction: z
.string({ required_error: "transaction is required." })
.min(1, "transaction must not be empty."),
account: z
.string({ required_error: "account is required." })
.regex(/^[GM][A-Z2-7]{54,55}$/, "account must be a valid Stellar public key (G...) or muxed account (M...)."),
});

// ---------------------------------------------------------------------------
// GET /api/auth/challenge
// ---------------------------------------------------------------------------

/**
* Issues a SEP-10 challenge transaction for the given Stellar account.
*
* Query parameters:
* - account (required): Stellar public key G... or muxed account M... of the wallet
*
* Response 200:
* {
* "transaction": "<base64 XDR>",
* "network_passphrase": "<network passphrase>"
* }
*/
authRouter.get("/challenge", (req: Request, res: Response): void => {
const parsed = challengeQuerySchema.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({
error: parsed.error.errors.map((e) => e.message).join("; "),
});
return;
}

try {
const result = buildChallenge(parsed.data.account);
res.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to build challenge.";
logger.error({ err }, "SEP-10 challenge build failed");
res.status(500).json({ error: message });
}
});

// ---------------------------------------------------------------------------
// POST /api/auth/verify
// ---------------------------------------------------------------------------

/**
* Verifies a signed SEP-10 challenge transaction and issues a JWT.
*
* Request body:
* {
* "transaction": "<base64 XDR of signed challenge>",
* "account": "G... or M... wallet address"
* }
*
* Response 200:
* {
* "token": "<JWT>",
* "account": "<Stellar public key>"
* }
*
* Error responses:
* 400 — malformed request or invalid challenge structure
* 401 — signature invalid, wrong signer, expired challenge, or replay
*/
authRouter.post("/verify", (req: Request, res: Response): void => {
const parsed = verifyBodySchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
error: parsed.error.errors.map((e) => e.message).join("; "),
});
return;
}

const { transaction, account } = parsed.data;

try {
const result = verifyChallenge(transaction, account);
res.json({ token: result.token, account: result.accountId });
} catch (err) {
const message = err instanceof Error ? err.message : "Challenge verification failed.";
logger.warn({ account, err: message }, "SEP-10 verify rejected");

// Differentiate structural errors (400) from auth failures (401).
const isStructural =
message.includes("Invalid SEP-10 challenge") ||
message.includes("Malformed");
const statusCode = isStructural ? 400 : 401;

res.status(statusCode).json({ error: message });
}
});
Loading
Loading