Add public key (npub) login support
- Backend: POST /auth/login-npub endpoint, JWT carries login method - Backend: Enforce Lightning address lock for npub logins in claim/quote - Frontend: LoginModal supports npub via new endpoint, clear copy - Frontend: Track loginMethod, lock LN address editing for npub users - Handle missing Lightning address for npub users with helpful message Made-with: Cursor
This commit is contained in:
@@ -4,15 +4,17 @@ import { config } from "../config.js";
|
||||
const HEADER = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
||||
const SEP = ".";
|
||||
|
||||
export function signJwt(pubkey: string): string {
|
||||
export type LoginMethod = "nip98" | "npub";
|
||||
|
||||
export function signJwt(pubkey: string, method: LoginMethod = "nip98"): string {
|
||||
const exp = Math.floor(Date.now() / 1000) + config.jwtExpiresInSeconds;
|
||||
const payload = Buffer.from(JSON.stringify({ pubkey, exp })).toString("base64url");
|
||||
const payload = Buffer.from(JSON.stringify({ pubkey, exp, method })).toString("base64url");
|
||||
const message = `${HEADER}${SEP}${payload}`;
|
||||
const sig = createHmac("sha256", config.jwtSecret).update(message).digest("base64url");
|
||||
return `${message}${SEP}${sig}`;
|
||||
}
|
||||
|
||||
export function verifyJwt(token: string): { pubkey: string } | null {
|
||||
export function verifyJwt(token: string): { pubkey: string; method: LoginMethod } | null {
|
||||
try {
|
||||
const parts = token.split(SEP);
|
||||
if (parts.length !== 3) return null;
|
||||
@@ -22,10 +24,11 @@ export function verifyJwt(token: string): { pubkey: string } | null {
|
||||
if (sigB64 !== expected) return null;
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(payloadB64, "base64url").toString("utf-8")
|
||||
) as { pubkey?: string; exp?: number };
|
||||
) as { pubkey?: string; exp?: number; method?: string };
|
||||
if (!payload.pubkey || typeof payload.pubkey !== "string") return null;
|
||||
if (!payload.exp || payload.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return { pubkey: payload.pubkey };
|
||||
const method: LoginMethod = payload.method === "npub" ? "npub" : "nip98";
|
||||
return { pubkey: payload.pubkey, method };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { nip98Auth } from "./nip98.js";
|
||||
const BEARER_PREFIX = "Bearer ";
|
||||
|
||||
/**
|
||||
* Accept either Bearer JWT or NIP-98. Sets req.nostr = { pubkey }.
|
||||
* Accept either Bearer JWT or NIP-98. Sets req.nostr = { pubkey, method }.
|
||||
*/
|
||||
export function authOrNip98(req: Request, res: Response, next: NextFunction): void {
|
||||
const auth = req.headers.authorization;
|
||||
@@ -13,7 +13,7 @@ export function authOrNip98(req: Request, res: Response, next: NextFunction): vo
|
||||
const token = auth.slice(BEARER_PREFIX.length).trim();
|
||||
const payload = verifyJwt(token);
|
||||
if (payload) {
|
||||
req.nostr = { pubkey: payload.pubkey, eventId: "" };
|
||||
req.nostr = { pubkey: payload.pubkey, eventId: "", method: payload.method };
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const AUTH_SCHEME = "Nostr ";
|
||||
export interface NostrAuthPayload {
|
||||
pubkey: string;
|
||||
eventId: string;
|
||||
method?: "nip98" | "npub";
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { nip98Auth } from "../middleware/nip98.js";
|
||||
import { signJwt, verifyJwt } from "../auth/jwt.js";
|
||||
|
||||
@@ -7,8 +8,29 @@ const router = Router();
|
||||
/** Sign in with NIP-98 once; returns a JWT for subsequent requests. */
|
||||
router.post("/login", nip98Auth, (req: Request, res: Response) => {
|
||||
const pubkey = req.nostr!.pubkey;
|
||||
const token = signJwt(pubkey);
|
||||
res.json({ token, pubkey });
|
||||
const token = signJwt(pubkey, "nip98");
|
||||
res.json({ token, pubkey, method: "nip98" });
|
||||
});
|
||||
|
||||
/** Sign in with just an npub (no signature). Limited: cannot change Lightning address. */
|
||||
router.post("/login-npub", (req: Request, res: Response) => {
|
||||
const raw = typeof req.body?.npub === "string" ? req.body.npub.trim() : "";
|
||||
if (!raw) {
|
||||
res.status(400).json({ code: "invalid_npub", message: "npub is required." });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const decoded = nip19.decode(raw);
|
||||
if (decoded.type !== "npub") {
|
||||
res.status(400).json({ code: "invalid_npub", message: "Expected an npub-encoded public key." });
|
||||
return;
|
||||
}
|
||||
const pubkey = decoded.data as string;
|
||||
const token = signJwt(pubkey, "npub");
|
||||
res.json({ token, pubkey, method: "npub" });
|
||||
} catch {
|
||||
res.status(400).json({ code: "invalid_npub", message: "Invalid npub format." });
|
||||
}
|
||||
});
|
||||
|
||||
/** Return current user from JWT (Bearer only). Used to restore session. */
|
||||
@@ -23,7 +45,7 @@ router.get("/me", (req: Request, res: Response) => {
|
||||
res.status(401).json({ code: "invalid_token", message: "Invalid or expired token." });
|
||||
return;
|
||||
}
|
||||
res.json({ pubkey: payload.pubkey });
|
||||
res.json({ pubkey: payload.pubkey, method: payload.method });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -27,7 +27,29 @@ function parseLightningAddress(body: unknown): string | null {
|
||||
router.post("/quote", authOrNip98, async (req: Request, res: Response) => {
|
||||
const pubkey = req.nostr!.pubkey;
|
||||
const ipHash = req.ipHash!;
|
||||
const lightningAddress = parseLightningAddress(req.body);
|
||||
let lightningAddress = parseLightningAddress(req.body);
|
||||
|
||||
if (req.nostr!.method === "npub") {
|
||||
const db = getDb();
|
||||
const user = await db.getUser(pubkey);
|
||||
const profileAddr = user?.lightning_address?.trim() || null;
|
||||
if (!profileAddr) {
|
||||
res.status(400).json({
|
||||
code: "no_profile_address",
|
||||
message: "No Lightning address found in your Nostr profile. Log in with nsec or extension to enter one manually.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (lightningAddress && lightningAddress !== profileAddr) {
|
||||
res.status(403).json({
|
||||
code: "address_locked",
|
||||
message: "Public-key login restricts payouts to your profile Lightning address.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
lightningAddress = profileAddr;
|
||||
}
|
||||
|
||||
if (!lightningAddress) {
|
||||
res.status(400).json({
|
||||
code: "invalid_lightning_address",
|
||||
|
||||
Reference in New Issue
Block a user