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:
Michaël
2026-02-27 17:56:15 -03:00
parent 5b516f02cb
commit 9fff2d427f
12 changed files with 127 additions and 44 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -8,6 +8,7 @@ const AUTH_SCHEME = "Nostr ";
export interface NostrAuthPayload {
pubkey: string;
eventId: string;
method?: "nip98" | "npub";
}
declare global {

View File

@@ -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;

View File

@@ -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",

View File

@@ -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<string | null>(null);
const [loginMethod, setLoginMethod] = useState<LoginMethod | null>(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() {
<FaucetSvg />
<h1>Sats Faucet</h1>
</div>
<ClaimWizard pubkey={pubkey} onPubkeyChange={setPubkey} onClaimSuccess={handleClaimSuccess} />
<ClaimWizard pubkey={pubkey} loginMethod={loginMethod} onPubkeyChange={handlePubkeyChange} onClaimSuccess={handleClaimSuccess} />
</main>
<aside className="sidebar sidebar-right">
<StatsSection refetchTrigger={statsRefetchTrigger} />

View File

@@ -231,8 +231,18 @@ export async function postAuthLogin(): Promise<{ token: string; pubkey: string }
return requestWithNip98<{ token: string; pubkey: string }>("POST", "/auth/login", {});
}
export type LoginMethod = "nip98" | "npub";
/** Login with just an npub (no signature). Restricted: cannot change Lightning address. */
export async function postAuthLoginNpub(npub: string): Promise<{ token: string; pubkey: string; method: LoginMethod }> {
return request<{ token: string; pubkey: string; method: LoginMethod }>("/auth/login-npub", {
method: "POST",
body: JSON.stringify({ npub: npub.trim() }),
});
}
/** Get current user from session (Bearer). */
export async function getAuthMe(): Promise<{ pubkey: string }> {
export async function getAuthMe(): Promise<{ pubkey: string; method: LoginMethod }> {
const token = getToken();
if (!token) throw new Error("Not logged in");
const url = apiUrl("/auth/me");
@@ -241,7 +251,7 @@ export async function getAuthMe(): Promise<{ pubkey: string }> {
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message ?? "Session invalid");
return data as { pubkey: string };
return data as { pubkey: string; method: LoginMethod };
}
export async function getConfig(): Promise<FaucetConfig> {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { postUserRefreshProfile, type UserProfile } from "../api";
import { postUserRefreshProfile, type UserProfile, type LoginMethod } from "../api";
import { useClaimFlow } from "../hooks/useClaimFlow";
import { StepIndicator } from "./StepIndicator";
import { ConnectStep } from "./ConnectStep";
@@ -25,11 +25,12 @@ function getWizardStep(
interface ClaimWizardProps {
pubkey: string | null;
onPubkeyChange: (pk: string | null) => void;
loginMethod: LoginMethod | null;
onPubkeyChange: (pk: string | null, method?: LoginMethod) => void;
onClaimSuccess?: () => void;
}
export function ClaimWizard({ pubkey, onPubkeyChange, onClaimSuccess }: ClaimWizardProps) {
export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSuccess }: ClaimWizardProps) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [lightningAddress, setLightningAddress] = useState("");
const [lightningAddressTouched, setLightningAddressTouched] = useState(false);
@@ -133,7 +134,7 @@ export function ClaimWizard({ pubkey, onPubkeyChange, onClaimSuccess }: ClaimWiz
<ConnectStep
pubkey={pubkey}
displayName={profile?.name}
onConnect={(pk) => onPubkeyChange(pk)}
onConnect={(pk, method) => onPubkeyChange(pk, method)}
onDisconnect={handleDisconnect}
/>
)}
@@ -146,6 +147,7 @@ export function ClaimWizard({ pubkey, onPubkeyChange, onClaimSuccess }: ClaimWiz
setLightningAddressTouched={setLightningAddressTouched}
invalid={lightningAddressInvalid}
fromProfile={fromProfile}
readOnly={loginMethod === "npub"}
loading={claim.loading === "quote"}
eligibilityProgressStep={claim.eligibilityProgressStep}
denial={claim.denial}

View File

@@ -1,12 +1,12 @@
import React from "react";
import { clearToken } from "../api";
import { clearToken, type LoginMethod } from "../api";
import { nip19 } from "nostr-tools";
import { LoginModal } from "./LoginModal";
interface Props {
pubkey: string | null;
displayName?: string | null;
onConnect: (pubkey: string) => void;
onConnect: (pubkey: string, method?: LoginMethod) => void;
onDisconnect: () => void;
}
@@ -63,7 +63,7 @@ export function ConnectNostr({ pubkey, displayName, onConnect, onDisconnect }: P
<LoginModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSuccess={onConnect}
onSuccess={(pubkey, method) => onConnect(pubkey, method)}
/>
</>
);

View File

@@ -1,11 +1,11 @@
import { useEffect, useState } from "react";
import { ConnectNostr } from "./ConnectNostr";
import { getConfig, type FaucetConfig } from "../api";
import { getConfig, type FaucetConfig, type LoginMethod } from "../api";
interface ConnectStepProps {
pubkey: string | null;
displayName?: string | null;
onConnect: (pubkey: string) => void;
onConnect: (pubkey: string, method?: LoginMethod) => void;
onDisconnect: () => void;
}

View File

@@ -10,6 +10,7 @@ interface EligibilityStepProps {
setLightningAddressTouched: (t: boolean) => void;
invalid: boolean;
fromProfile: boolean;
readOnly?: boolean;
loading: boolean;
eligibilityProgressStep: number | null;
denial: DenialState | null;
@@ -27,6 +28,7 @@ export function EligibilityStep({
setLightningAddressTouched,
invalid,
fromProfile,
readOnly,
loading,
eligibilityProgressStep,
denial,
@@ -38,27 +40,37 @@ export function EligibilityStep({
const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim());
const showProfileCard = fromProfile && !editing;
const noAddressForNpub = readOnly && !lightningAddress.trim();
return (
<div className="claim-wizard-step claim-wizard-step-eligibility">
<h3 className="claim-wizard-step-title">Check eligibility</h3>
<p className="claim-wizard-step-desc">
Enter your Lightning address. We'll verify cooldown and calculate your payout.
{readOnly
? "Logged in with public key \u2014 payout goes to your profile\u2019s Lightning address."
: "Enter your Lightning address. We\u2019ll verify cooldown and calculate your payout."}
</p>
{showProfileCard ? (
{noAddressForNpub ? (
<p className="claim-wizard-input-hint" role="alert">
No Lightning address found in your Nostr profile. Log in with nsec or extension to enter one manually.
</p>
) : showProfileCard ? (
<div className="claim-wizard-profile-card">
<div className="claim-wizard-profile-card-icon" aria-hidden>&#9889;</div>
<div className="claim-wizard-profile-card-body">
<span className="claim-wizard-profile-card-label">Lightning address from profile</span>
<span className="claim-wizard-profile-card-address">{lightningAddress}</span>
</div>
<button
type="button"
className="btn-secondary claim-wizard-profile-card-edit"
onClick={() => setEditing(true)}
>
Edit
</button>
{!readOnly && (
<button
type="button"
className="btn-secondary claim-wizard-profile-card-edit"
onClick={() => setEditing(true)}
>
Edit
</button>
)}
</div>
) : (
<div className="claim-wizard-address-row">
@@ -70,7 +82,7 @@ export function EligibilityStep({
onChange={(e) => onLightningAddressChange(e.target.value)}
onBlur={() => setLightningAddressTouched(true)}
placeholder="you@wallet.com"
disabled={loading}
disabled={loading || readOnly}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? "wizard-lightning-hint" : undefined}
/>
@@ -82,7 +94,7 @@ export function EligibilityStep({
</p>
)}
{loading ? (
{noAddressForNpub ? null : loading ? (
<div className="claim-wizard-progress" role="status" aria-live="polite">
<div className="claim-wizard-progress-bar" />
<p className="claim-wizard-progress-text">

View File

@@ -4,10 +4,12 @@ import { BunkerSigner, parseBunkerInput } from "nostr-tools/nip46";
import {
postAuthLoginWithSigner,
postAuthLogin,
postAuthLoginNpub,
postUserRefreshProfile,
setToken,
hasNostr,
type ApiError,
type LoginMethod,
} from "../api";
import { Modal } from "./Modal";
import { RemoteSignerQR } from "./RemoteSignerQR";
@@ -29,7 +31,7 @@ function getDefaultRemoteSignerMode(): RemoteSignerMode {
interface Props {
open: boolean;
onClose: () => void;
onSuccess: (pubkey: string) => void;
onSuccess: (pubkey: string, method: LoginMethod) => void;
}
export function LoginModal({ open, onClose, onSuccess }: Props) {
@@ -58,16 +60,15 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
};
const runLogin = useCallback(
async (sign: () => Promise<{ token: string; pubkey: string }>) => {
async (sign: () => Promise<{ token: string; pubkey: string; method?: LoginMethod }>) => {
setLoading(true);
setError(null);
setPasteInlineError(null);
try {
const { token, pubkey } = await sign();
const { token, pubkey, method } = await sign();
setToken(token);
// Trigger profile fetch so backend has token before ClaimFlow effect runs (helps remote signer login)
postUserRefreshProfile().catch(() => {});
onSuccess(pubkey);
onSuccess(pubkey, method ?? "nip98");
handleClose();
} catch (e) {
const msg = e instanceof Error ? e.message : (e as ApiError)?.message ?? "Login failed";
@@ -146,7 +147,7 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
try {
const decoded = nip19.decode(raw);
if (decoded.type === "npub") {
setError("Npub (public key) cannot sign. Paste your nsec to log in and claim, or use Extension / Remote signer.");
await runLogin(() => postAuthLoginNpub(raw));
return;
}
if (decoded.type !== "nsec") {
@@ -295,7 +296,7 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
{tab === "nsec" && (
<div className="login-method">
<p className="login-method-desc">
Paste your <strong>nsec</strong> to sign in (kept in this tab only). Npub alone cannot sign.
Paste your <strong>nsec</strong> for full access, or <strong>npub</strong> to claim using your profile's Lightning address.
</p>
<textarea
className="login-modal-textarea"
@@ -311,7 +312,7 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
onClick={handleNsecNpub}
disabled={loading}
>
{loading ? "Signing in…" : "Login with nsec"}
{loading ? "Signing in…" : "Login"}
</button>
</div>
)}