fix(#26): tighten token handling and verify API contract

- resolveNostrPubkey() now returns { pubkey, rejected } instead of string|null
  so invalid/expired tokens return 401 instead of silently falling to anonymous
- POST /sessions and POST /jobs: return 401 if nostr_token header/body is
  present but invalid or expired
- POST /identity/verify: now accepts optional top-level 'pubkey' field alongside
  'event'; asserts pubkey matches event.pubkey if both are provided — aligns
  API contract with { pubkey, event } spec shape and hardens against mismatch
This commit is contained in:
Replit Agent
2026-03-19 16:15:55 +00:00
parent 96d5915ada
commit 99ede5792e
3 changed files with 48 additions and 9 deletions

View File

@@ -52,13 +52,22 @@ router.post("/identity/challenge", (_req: Request, res: Response) => {
// event.kind — any (27235 recommended per NIP-98, but not enforced)
router.post("/identity/verify", async (req: Request, res: Response) => {
const { event } = req.body as { event?: unknown };
// Accept both { event } and { pubkey, event } shapes (pubkey is optional but asserted if present)
const { event, pubkey: explicitPubkey } = req.body as { event?: unknown; pubkey?: unknown };
if (!event || typeof event !== "object") {
res.status(400).json({ error: "Body must include 'event' (Nostr signed event)" });
return;
}
// If caller provided a top-level pubkey, validate it matches event.pubkey
if (explicitPubkey !== undefined) {
if (typeof explicitPubkey !== "string" || !/^[0-9a-f]{64}$/.test(explicitPubkey)) {
res.status(400).json({ error: "top-level 'pubkey' must be a 64-char hex string" });
return;
}
}
// ── Validate event structure ──────────────────────────────────────────────
const ev = event as Record<string, unknown>;
const pubkey = ev["pubkey"];
@@ -69,6 +78,12 @@ router.post("/identity/verify", async (req: Request, res: Response) => {
return;
}
// Assert top-level pubkey matches event.pubkey if both are provided
if (typeof explicitPubkey === "string" && explicitPubkey !== pubkey) {
res.status(400).json({ error: "top-level 'pubkey' does not match event.pubkey" });
return;
}
if (typeof content !== "string" || content.trim().length === 0) {
res.status(400).json({ error: "event.content must be the nonce string" });
return;

View File

@@ -266,13 +266,20 @@ async function advanceJob(job: Job): Promise<Job | null> {
// ── Resolve Nostr pubkey from token header or body ────────────────────────────
function resolveNostrPubkey(req: Request): string | null {
/** Resolves a Nostr token from the request.
* Returns `{ pubkey, rejected }` where:
* rejected=false, pubkey=null → no token supplied (anonymous)
* rejected=true, pubkey=null → token supplied but invalid/expired → caller should 401
* rejected=false, pubkey=str → valid token
*/
function resolveNostrPubkey(req: Request): { pubkey: string | null; rejected: boolean } {
const header = req.headers["x-nostr-token"];
const bodyToken = req.body?.nostr_token;
const raw = typeof header === "string" ? header : (typeof bodyToken === "string" ? bodyToken : null);
if (!raw) return null;
if (!raw) return { pubkey: null, rejected: false };
const parsed = trustService.verifyToken(raw.trim());
return parsed?.pubkey ?? null;
if (!parsed) return { pubkey: null, rejected: true };
return { pubkey: parsed.pubkey, rejected: false };
}
router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => {
@@ -288,7 +295,12 @@ router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => {
const { request } = parseResult.data;
// Optionally bind a Nostr identity — ensure row exists before FK insert
const nostrPubkey = resolveNostrPubkey(req);
const tokenResult = resolveNostrPubkey(req);
if (tokenResult.rejected) {
res.status(401).json({ error: "Invalid or expired nostr_token" });
return;
}
const nostrPubkey = tokenResult.pubkey;
if (nostrPubkey) await trustService.getOrCreate(nostrPubkey);
try {

View File

@@ -138,13 +138,20 @@ async function advanceTopup(session: Session): Promise<Session> {
// ── Resolve Nostr pubkey from token header or body ────────────────────────────
function resolveNostrPubkey(req: Request): string | null {
/** Resolves a Nostr token from the request.
* Returns `{ pubkey, rejected }` where:
* rejected=false, pubkey=null → no token supplied (anonymous)
* rejected=true, pubkey=null → token supplied but invalid/expired → caller should 401
* rejected=false, pubkey=str → valid token
*/
function resolveNostrPubkey(req: Request): { pubkey: string | null; rejected: boolean } {
const header = req.headers["x-nostr-token"];
const bodyToken = req.body?.nostr_token;
const raw = typeof header === "string" ? header : (typeof bodyToken === "string" ? bodyToken : null);
if (!raw) return null;
if (!raw) return { pubkey: null, rejected: false };
const parsed = trustService.verifyToken(raw.trim());
return parsed?.pubkey ?? null;
if (!parsed) return { pubkey: null, rejected: true };
return { pubkey: parsed.pubkey, rejected: false };
}
// ── POST /sessions ─────────────────────────────────────────────────────────────
@@ -161,7 +168,12 @@ router.post("/sessions", sessionsLimiter, async (req: Request, res: Response) =>
}
// Optionally bind a Nostr identity — ensure row exists before FK insert
const nostrPubkey = resolveNostrPubkey(req);
const tokenResult = resolveNostrPubkey(req);
if (tokenResult.rejected) {
res.status(401).json({ error: "Invalid or expired nostr_token" });
return;
}
const nostrPubkey = tokenResult.pubkey;
if (nostrPubkey) await trustService.getOrCreate(nostrPubkey);
try {