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)
|
// event.kind — any (27235 recommended per NIP-98, but not enforced)
|
||||||
|
|
||||||
router.post("/identity/verify", async (req: Request, res: Response) => {
|
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") {
|
if (!event || typeof event !== "object") {
|
||||||
res.status(400).json({ error: "Body must include 'event' (Nostr signed event)" });
|
res.status(400).json({ error: "Body must include 'event' (Nostr signed event)" });
|
||||||
return;
|
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 ──────────────────────────────────────────────
|
// ── Validate event structure ──────────────────────────────────────────────
|
||||||
const ev = event as Record<string, unknown>;
|
const ev = event as Record<string, unknown>;
|
||||||
const pubkey = ev["pubkey"];
|
const pubkey = ev["pubkey"];
|
||||||
@@ -69,6 +78,12 @@ router.post("/identity/verify", async (req: Request, res: Response) => {
|
|||||||
return;
|
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) {
|
if (typeof content !== "string" || content.trim().length === 0) {
|
||||||
res.status(400).json({ error: "event.content must be the nonce string" });
|
res.status(400).json({ error: "event.content must be the nonce string" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -266,13 +266,20 @@ async function advanceJob(job: Job): Promise<Job | null> {
|
|||||||
|
|
||||||
// ── Resolve Nostr pubkey from token header or body ────────────────────────────
|
// ── 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 header = req.headers["x-nostr-token"];
|
||||||
const bodyToken = req.body?.nostr_token;
|
const bodyToken = req.body?.nostr_token;
|
||||||
const raw = typeof header === "string" ? header : (typeof bodyToken === "string" ? bodyToken : null);
|
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());
|
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) => {
|
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;
|
const { request } = parseResult.data;
|
||||||
|
|
||||||
// Optionally bind a Nostr identity — ensure row exists before FK insert
|
// 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);
|
if (nostrPubkey) await trustService.getOrCreate(nostrPubkey);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -138,13 +138,20 @@ async function advanceTopup(session: Session): Promise<Session> {
|
|||||||
|
|
||||||
// ── Resolve Nostr pubkey from token header or body ────────────────────────────
|
// ── 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 header = req.headers["x-nostr-token"];
|
||||||
const bodyToken = req.body?.nostr_token;
|
const bodyToken = req.body?.nostr_token;
|
||||||
const raw = typeof header === "string" ? header : (typeof bodyToken === "string" ? bodyToken : null);
|
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());
|
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 ─────────────────────────────────────────────────────────────
|
// ── 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
|
// 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);
|
if (nostrPubkey) await trustService.getOrCreate(nostrPubkey);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user