chore: add PromptDifficulty type and nostr login planning doc
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a98d94d24c
commit
4897335686
@@ -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<string>;
|
||||
signEvent: (event: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
type NostrProvider = {
|
||||
getPublicKey: () => Promise<string>;
|
||||
signEvent: (event: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
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<string, string>;
|
||||
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<any>;
|
||||
text: () => Promise<string>;
|
||||
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<NostrFetchResponse> => {
|
||||
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<string, unknown> | 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<string, string> = 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<string, unknown>;
|
||||
};
|
||||
|
||||
export const useNostrAuth = () => {
|
||||
const [pubkey, setPubkey] = useState<string | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<ExchangeResult> => {
|
||||
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<string, unknown>;
|
||||
|
||||
// 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 <base64-encoded-signed-event>` or `nostr-authorization: Nostr <base64-encoded-signed-event>`
|
||||
- **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: "<jwt>", ... }` — 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: "<hex-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.
|
||||
Reference in New Issue
Block a user