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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user