From 4897335686e3d9bf16b6b64d069891d49c4eb825 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 9 Mar 2026 14:26:43 +0000 Subject: [PATCH] chore: add PromptDifficulty type and nostr login planning doc Co-Authored-By: Claude Opus 4.6 --- nostr-login-implementation.md | 700 ++++++++++++++++++++++++++++++++++ 1 file changed, 700 insertions(+) create mode 100644 nostr-login-implementation.md diff --git a/nostr-login-implementation.md b/nostr-login-implementation.md new file mode 100644 index 0000000..bd2c1e9 --- /dev/null +++ b/nostr-login-implementation.md @@ -0,0 +1,700 @@ +# Nostr Login Implementation (NIP-07 + NIP-98) + +This document describes a working Nostr authentication system for a Next.js frontend that talks to a backend API. It uses **NIP-07** (browser extension / remote signer like Amber) for key management and **NIP-98** for HTTP authentication. + +The user never shares their private key with the app. All signing is done by the external signer (browser extension on desktop, Amber on Android, etc.) via the `window.nostr` interface. + +## Dependencies + +``` +nostr-tools ^2.19.3 # NIP-98 token generation +axios ^1.12.2 # HTTP client (can be replaced with fetch) +js-cookie ^3.0.5 # Cookie management for JWT persistence +``` + +## How It Works + +1. User clicks "Login with Nostr" +2. App calls `window.nostr.getPublicKey()` to get the user's pubkey from their signer +3. App builds a NIP-98 signed HTTP auth event (kind 27235) and sends `POST /auth/nostr/session` +4. Backend verifies the NIP-98 signature, returns a JWT +5. JWT is stored in localStorage + cookie, used for subsequent API calls + +On Android with Amber: Amber registers as the `window.nostr` provider in mobile browsers, so the same code works seamlessly - the user just approves the signing request in Amber. + +--- + +## File 1: TypeScript Global Types + +Add this to your global type declarations so TypeScript knows about `window.nostr`: + +```ts +// global.d.ts +type NostrProvider = { + getPublicKey: () => Promise; + signEvent: (event: unknown) => Promise; +}; + +declare global { + interface Window { + nostr?: NostrProvider; + } +} +``` + +--- + +## File 2: Storage Layer + +Manages localStorage persistence of the Nostr pubkey and JWT. Includes automatic expiration checking. + +```ts +// lib/nostr/storage.ts + +const NOSTR_PUBKEY_KEY = 'nostr:pubkey'; +const NOSTR_TOKEN_KEY = 'nostr:token'; + +const isBrowser = () => typeof globalThis !== 'undefined'; + +const decodeTokenPayload = (token: string) => { + const [, payload] = token.split('.'); + if (!payload) return null; + + try { + if (typeof atob === 'function') { + return JSON.parse(atob(payload)); + } + if (typeof Buffer !== 'undefined') { + return JSON.parse(Buffer.from(payload, 'base64').toString()); + } + } catch { + return null; + } + return null; +}; + +const isExpired = (token?: string | null) => { + if (!token || !isBrowser()) return false; + const payload = decodeTokenPayload(token); + if (!payload?.exp) return false; + const now = Math.floor(Date.now() / 1000); + return payload.exp <= now; +}; + +export const getStoredNostrPubkey = () => { + if (!isBrowser()) return null; + return globalThis.localStorage.getItem(NOSTR_PUBKEY_KEY); +}; + +export const persistNostrPubkey = (pubkey: string) => { + if (!isBrowser()) return; + globalThis.localStorage.setItem(NOSTR_PUBKEY_KEY, pubkey); +}; + +export const clearNostrPubkey = () => { + if (!isBrowser()) return; + globalThis.localStorage.removeItem(NOSTR_PUBKEY_KEY); +}; + +export const getStoredNostrToken = () => { + if (!isBrowser()) return; + const token = globalThis.localStorage.getItem(NOSTR_TOKEN_KEY); + if (isExpired(token)) { + globalThis.localStorage.removeItem(NOSTR_TOKEN_KEY); + return; + } + return token ?? undefined; +}; + +export const persistNostrToken = (token: string) => { + if (!isBrowser()) return; + globalThis.localStorage.setItem(NOSTR_TOKEN_KEY, token); +}; + +export const clearNostrSession = () => { + if (!isBrowser()) return; + globalThis.localStorage.removeItem(NOSTR_TOKEN_KEY); + globalThis.localStorage.removeItem(NOSTR_PUBKEY_KEY); +}; + +export const nostrStorageKeys = { + pubkey: NOSTR_PUBKEY_KEY, + token: NOSTR_TOKEN_KEY, +}; +``` + +--- + +## File 3: fetchWithNostr — NIP-98 HTTP Authentication + +This is the core helper. It takes a URL + request options (like `fetch`), builds a NIP-98 auth event, signs it via `window.nostr`, and attaches it as headers. Uses axios under the hood but the pattern works with any HTTP client. + +```ts +// lib/nostr/fetch-with-nostr.ts + +import axios, { AxiosRequestConfig } from 'axios'; +import { nip98 } from 'nostr-tools'; + +const HTTPAuth = 27_235; // NIP-98 kind number + +export class MissingNostrProviderError extends Error { + constructor() { + super('Nostr provider not found. Please install a NIP-07 extension.'); + this.name = 'MissingNostrProviderError'; + } +} + +type BodyValue = BodyInit | null | undefined | Record; + +type NostrProvider = { + getPublicKey: () => Promise; + signEvent: (event: unknown) => Promise; +}; + +const isBrowser = () => typeof globalThis !== 'undefined'; + +const getNostrProvider = (): NostrProvider | undefined => { + if (!isBrowser()) return undefined; + return (globalThis as unknown as { nostr?: NostrProvider }).nostr; +}; + +/** + * Parse the request body into a form suitable for sending + for NIP-98 payload hashing. + */ +const parseBody = (body?: BodyValue) => { + if (!body) + return { + bodyToSend: undefined as BodyInit | null | undefined, + payloadForToken: undefined as unknown, + }; + + if (typeof body === 'string') { + try { + const parsed = JSON.parse(body); + return { bodyToSend: body, payloadForToken: parsed }; + } catch { + return { bodyToSend: body, payloadForToken: body }; + } + } + + if (typeof FormData !== 'undefined' && body instanceof FormData) + return { bodyToSend: body, payloadForToken: undefined }; + if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) + return { bodyToSend: body, payloadForToken: undefined }; + if (typeof Blob !== 'undefined' && body instanceof Blob) + return { bodyToSend: body, payloadForToken: undefined }; + if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) + return { bodyToSend: body as BodyInit, payloadForToken: undefined }; + if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) + return { bodyToSend: body as BodyInit, payloadForToken: undefined }; + + // Plain object — JSON-serialize it + return { bodyToSend: JSON.stringify(body), payloadForToken: body }; +}; + +const encodeBase64 = (value: string) => { + if (typeof btoa === 'function') return btoa(value); + return Buffer.from(value).toString('base64'); +}; + +/** + * Fallback: manually build a NIP-98 token if nostr-tools' nip98.getToken() fails. + * Constructs a kind 27235 event with url, method, and optional payload hash tags. + */ +const buildManualToken = async ( + provider: NostrProvider, + url: string, + method: string, + payload?: unknown, +) => { + const tags: string[][] = [ + ['u', url], + ['method', method], + ]; + + if (payload && typeof payload === 'object') { + try { + tags.push(['payload', nip98.hashPayload(payload)]); + } catch { + // ignore hashing errors + } + } + + const event = { + kind: HTTPAuth, + tags, + content: '', + created_at: Math.round(Date.now() / 1000), + }; + + const signedEvent = await provider.signEvent(event); + return encodeBase64(JSON.stringify(signedEvent)); +}; + +const resolveUrl = (input: RequestInfo | URL) => { + if (typeof input === 'string') { + if (input.startsWith('http://') || input.startsWith('https://')) return input; + const base = + isBrowser() && globalThis.location !== undefined + ? globalThis.location.origin + : 'http://localhost'; + return new URL(input, base).toString(); + } + if (input instanceof URL) return input.toString(); + if (typeof Request !== 'undefined' && input instanceof Request) return input.url; + return String(input); +}; + +const getExistingHeader = ( + headersInit: HeadersInit | undefined, + key: string, +) => { + if (!headersInit) return; + if (headersInit instanceof Headers) + return headersInit.get(key) || headersInit.get(key.toLowerCase()); + if (Array.isArray(headersInit)) { + const found = headersInit.find( + ([k]) => k.toLowerCase() === key.toLowerCase(), + ); + return found?.[1]; + } + const record = headersInit as Record; + const exact = record[key]; + if (exact) return exact; + const lower = Object.entries(record).find( + ([k]) => k.toLowerCase() === key.toLowerCase(), + ); + return lower?.[1]; +}; + +type NostrFetchResponse = { + ok: boolean; + status: number; + statusText: string; + headers: Headers; + json: () => Promise; + text: () => Promise; + data: any; +}; + +/** + * Main entry point: make an HTTP request with NIP-98 auth headers. + * + * Usage: + * const res = await fetchWithNostr('https://api.example.com/auth/nostr/session', { + * method: 'POST', + * }); + * const data = await res.json(); + */ +export const fetchWithNostr = async ( + input: RequestInfo | URL, + init?: RequestInit, +): Promise => { + const provider = getNostrProvider(); + if (!provider) throw new MissingNostrProviderError(); + + const method = ( + init?.method || + (typeof Request !== 'undefined' && input instanceof Request + ? input.method + : 'GET') + ).toUpperCase(); + const url = resolveUrl(input); + const { bodyToSend, payloadForToken } = parseBody(init?.body as BodyValue); + + // Preserve any existing Authorization header (e.g. Bearer JWT for linking) + const existingAuth = getExistingHeader(init?.headers, 'Authorization'); + const headers = new Headers(init?.headers ?? {}); + + if ( + payloadForToken && + typeof payloadForToken === 'object' && + !headers.has('Content-Type') + ) { + headers.set('Content-Type', 'application/json'); + } + + // Generate the NIP-98 token + let nostrToken: string | undefined; + try { + const signFunction = (event: any) => provider.signEvent(event) as any; + nostrToken = await nip98.getToken( + url, + method, + signFunction, + false, + payloadForToken as Record | undefined, + ); + } catch { + // Fallback to manual token building + nostrToken = await buildManualToken( + provider, + url, + method, + payloadForToken, + ); + } + + // Attach nostr auth headers + if (nostrToken) { + headers.set('nostr-authorization', `Nostr ${nostrToken}`); + headers.set('x-nostr-authorization', `Nostr ${nostrToken}`); + } + + const headerObject: Record = Object.fromEntries( + headers.entries(), + ); + + // If there's an existing Bearer token, keep it; otherwise use Nostr token as Authorization + if (existingAuth) { + headerObject.Authorization = existingAuth; + } else if (nostrToken) { + headerObject.Authorization = `Nostr ${nostrToken}`; + } + + if (nostrToken) { + headerObject['nostr-authorization'] = `Nostr ${nostrToken}`; + headerObject['x-nostr-authorization'] = `Nostr ${nostrToken}`; + } + + // Make the request (using axios, but you can swap this for fetch) + const axiosConfig: AxiosRequestConfig = { + url, + method: method as AxiosRequestConfig['method'], + headers: headerObject, + withCredentials: init?.credentials === 'include', + data: bodyToSend as AxiosRequestConfig['data'], + }; + + let response; + try { + response = await axios(axiosConfig); + } catch (error: any) { + if (error?.response) { + response = error.response; + } else { + throw error; + } + } + + const responseHeaders = new Headers(response.headers as any); + + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + data: response.data, + json: async () => response.data, + text: async () => + typeof response.data === 'string' + ? response.data + : JSON.stringify(response.data), + }; +}; +``` + +--- + +## File 4: useNostrAuth Hook + +React hook that manages the full auth lifecycle: connect to signer, exchange for JWT session, persist, disconnect. + +```ts +// hooks/use-nostr-auth.ts + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + fetchWithNostr, + MissingNostrProviderError, +} from '@/lib/nostr/fetch-with-nostr'; +import { + clearNostrSession, + getStoredNostrPubkey, + getStoredNostrToken, + persistNostrPubkey, + persistNostrToken, +} from '@/lib/nostr/storage'; +import Cookies from 'js-cookie'; + +// Replace with your own API service that accepts setAuthToken / uses Bearer headers +import apiService from '@/services/api.service'; + +type ExchangeResult = { + token: string; + response: Record; +}; + +export const useNostrAuth = () => { + const [pubkey, setPubkey] = useState(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Hydrate from localStorage on mount + useEffect(() => { + const storedPubkey = getStoredNostrPubkey(); + const storedToken = getStoredNostrToken(); + + if (storedPubkey) setPubkey(storedPubkey); + if (storedToken) { + setToken(storedToken); + apiService.setAuthToken(storedToken); + } + }, []); + + /** + * Step 1: Ask the signer (extension / Amber) for the user's public key. + */ + const connect = useCallback(async () => { + const provider = + typeof globalThis === 'undefined' + ? undefined + : (globalThis as any).nostr; + if (!provider) throw new MissingNostrProviderError(); + + const publicKey = await provider.getPublicKey(); + persistNostrPubkey(publicKey); + setPubkey(publicKey); + return publicKey; + }, []); + + /** + * Clear all Nostr session state. + */ + const disconnect = useCallback(() => { + clearNostrSession(); + apiService.setAuthToken(undefined); + setPubkey(null); + setToken(null); + setError(null); + }, []); + + /** + * Step 2: Send a NIP-98 signed POST to your backend's session endpoint. + * The backend verifies the signature, creates a user/session, returns a JWT. + */ + const exchangeForSession = useCallback(async (): Promise => { + setIsLoading(true); + setError(null); + try { + if (!pubkey) await connect(); + + const response = await fetchWithNostr( + `${process.env.NEXT_PUBLIC_API_URL}/auth/nostr/session`, + { method: 'POST' }, + ); + + if (!response.ok) { + throw new Error('Unable to create Nostr session.'); + } + + const payload = (await response.json()) as Record; + + // Backend may return the JWT under different field names + const newToken = + (payload?.token as string | undefined) || + (payload?.jwt as string | undefined) || + (payload?.accessToken as string | undefined) || + (payload?.idToken as string | undefined); + + if (!newToken) { + throw new Error('Nostr session did not return a token.'); + } + + persistNostrToken(newToken); + Cookies.set('accessToken', newToken); + apiService.setAuthToken(newToken); + setToken(newToken); + + return { token: newToken, response: payload }; + } catch (caughtError) { + const message = + caughtError instanceof Error + ? caughtError.message + : 'Unable to connect to Nostr provider.'; + setError(message); + throw caughtError; + } finally { + setIsLoading(false); + } + }, [connect, pubkey]); + + const isConnected = useMemo(() => Boolean(pubkey), [pubkey]); + + return { + pubkey, + token, + isConnected, + isLoading, + error, + connect, + disconnect, + exchangeForSession, + fetchWithNostr, + }; +}; +``` + +--- + +## File 5: Auth Service — Nostr-Specific Endpoints + +These are the Nostr-related methods on the auth service for linking/unlinking a pubkey to an existing user account, and the session endpoint for pure Nostr login. + +```ts +// services/auth.service.ts (Nostr-relevant methods only) + +import { fetchWithNostr } from '@/lib/nostr/fetch-with-nostr'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL; + +/** + * Link a Nostr pubkey to an already-authenticated user account. + * Sends both the user's existing JWT (Bearer) AND a NIP-98 signature. + */ +async function linkNostr(pubkey: string, jwt?: string) { + try { + const response = await fetchWithNostr(`${API_URL}/auth/nostr/link`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + body: JSON.stringify({ pubkey }), + }); + + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + return { ok: false, error: payload?.message || 'Failed to link Nostr key' }; + } + + return { ok: true, data: response.data }; + } catch (error: any) { + return { ok: false, error: error?.message || 'Failed to link Nostr key' }; + } +} + +/** + * Remove the linked Nostr pubkey from the current user. + */ +async function unlinkNostr() { + // Uses your normal authenticated API service (Bearer JWT) + return await apiService.post('auth/nostr/unlink', {}); +} +``` + +--- + +## File 6: Auth Context Integration + +How the Nostr token integrates with your app's overall auth state. Key pattern: on app init, check for a stored Nostr token first, try to hydrate the user from it, fall back to other auth methods. + +```ts +// In your auth context / provider: + +import { clearNostrSession, getStoredNostrToken } from '@/lib/nostr/storage'; + +// During token resolution (e.g. in setToken or getToken): +async function resolveToken() { + let token: string | undefined; + + // 1. Try your primary auth method first (e.g. Cognito/Amplify/OAuth) + // token = await getPrimaryAuthToken(); + + // 2. Fall back to stored Nostr token + if (!token) { + const nostrToken = getStoredNostrToken(); + if (nostrToken) { + // Decode and check expiration + const exp = decodeTokenExpiration(nostrToken); // your JWT decode helper + const now = Math.floor(Date.now() / 1000); + if (!exp || exp > now) { + token = nostrToken; + } else { + clearNostrSession(); + } + } + } + + return token; +} + +// During app initialization: +async function initializeAuth() { + const nostrToken = getStoredNostrToken(); + if (nostrToken) { + apiService.setAuthToken(nostrToken); + const user = await authService.getMe(); + if (user) { + setUser(user); + setIsLoggedIn(true); + return; // Nostr session is valid, done + } + // Token was invalid/expired + clearNostrSession(); + } + + // ... fall through to other auth methods +} + +// During logout: +function logout() { + clearNostrSession(); + // ... clear other auth state, cookies, etc. +} +``` + +--- + +## Backend API Contract + +Your backend needs these endpoints: + +### `POST /auth/nostr/session` +- **Auth**: NIP-98 signature only (no Bearer token needed) +- **Headers**: `Authorization: Nostr ` or `nostr-authorization: Nostr ` +- **Backend verifies**: the NIP-98 event signature, that `kind === 27235`, the `u` tag matches the request URL, the `method` tag matches, and `created_at` is recent (within ~60s) +- **Returns**: `{ token: "", ... }` — a JWT with `sub` set to the user's pubkey + +### `POST /auth/nostr/link` +- **Auth**: Both Bearer JWT (existing user session) AND NIP-98 signature +- **Body**: `{ pubkey: "" }` +- **Purpose**: Bind a Nostr pubkey to an existing user account (so they can log in with Nostr next time) + +### `POST /auth/nostr/unlink` +- **Auth**: Bearer JWT only +- **Purpose**: Remove linked Nostr pubkey from the user's account + +### `GET /auth/me` +- **Auth**: Bearer JWT (works with both traditional and Nostr-issued JWTs) +- **Returns**: User object (include `nostrPubkey` field if linked) + +--- + +## UI Components Needed + +These are minimal — implement them in your own design system: + +1. **Provider Detector** — Check `window.nostr` exists on mount, show "detected" / "not detected" status. Link to extension install pages if not found. + +2. **Login Button** — Single button that calls: + ```ts + await connect(); // gets pubkey from signer + await exchangeForSession(); // NIP-98 signed request -> JWT + const user = await getMe(); // fetch user profile with JWT + // redirect to app + ``` + +3. **Status Banner** (optional) — Show connected pubkey (shortened) and a disconnect button. + +4. **Settings: Link/Unlink** (optional) — For users who logged in another way but want to add Nostr as an alternative login method. + +--- + +## Key Concepts + +- **NIP-07**: Standard interface for browser extensions / remote signers. Exposes `window.nostr.getPublicKey()` and `window.nostr.signEvent(event)`. Amber on Android implements this same interface. +- **NIP-98**: HTTP Auth using Nostr events. A kind `27235` event signed by the user, containing the request URL and method in tags. Sent as a base64-encoded header. +- **The private key never touches your app.** All signing happens in the external signer. +- **`nostr-tools`** does the heavy lifting for NIP-98 token generation. The manual fallback (`buildManualToken`) handles edge cases where `nip98.getToken()` fails.