diff --git a/backend/src/auth/jwt.ts b/backend/src/auth/jwt.ts index 4fd8604..18c9a80 100644 --- a/backend/src/auth/jwt.ts +++ b/backend/src/auth/jwt.ts @@ -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; } diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 77a9dd3..cc3c987 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -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; } diff --git a/backend/src/middleware/nip98.ts b/backend/src/middleware/nip98.ts index 864735b..079ea84 100644 --- a/backend/src/middleware/nip98.ts +++ b/backend/src/middleware/nip98.ts @@ -8,6 +8,7 @@ const AUTH_SCHEME = "Nostr "; export interface NostrAuthPayload { pubkey: string; eventId: string; + method?: "nip98" | "npub"; } declare global { diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 64b2072..d0d3561 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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; diff --git a/backend/src/routes/claim.ts b/backend/src/routes/claim.ts index 807d8c8..41e1595 100644 --- a/backend/src/routes/claim.ts +++ b/backend/src/routes/claim.ts @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2a7edcf..3bac0cf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom"; -import { getToken, getAuthMe, clearToken } from "./api"; +import { getToken, getAuthMe, clearToken, type LoginMethod } from "./api"; import { Header } from "./components/Header"; import { Footer } from "./components/Footer"; import { ClaimWizard } from "./components/ClaimWizard"; @@ -26,19 +26,29 @@ const FaucetSvg = () => ( export default function App() { const [pubkey, setPubkey] = useState(null); + const [loginMethod, setLoginMethod] = useState(null); const [statsRefetchTrigger, setStatsRefetchTrigger] = useState(0); useEffect(() => { const token = getToken(); if (!token) return; getAuthMe() - .then((r) => setPubkey(r.pubkey)) + .then((r) => { + setPubkey(r.pubkey); + setLoginMethod(r.method ?? "nip98"); + }) .catch(() => { clearToken(); setPubkey(null); + setLoginMethod(null); }); }, []); + const handlePubkeyChange = useCallback((pk: string | null, method?: LoginMethod) => { + setPubkey(pk); + setLoginMethod(pk ? (method ?? "nip98") : null); + }, []); + const handleClaimSuccess = useCallback(() => { setStatsRefetchTrigger((t) => t + 1); }, []); @@ -74,7 +84,7 @@ export default function App() {

Sats Faucet

- +