diff --git a/backend/README.md b/backend/README.md index 0989c15..01e41da 100644 --- a/backend/README.md +++ b/backend/README.md @@ -60,7 +60,13 @@ See [spec](spec) for more details ## API Routes -All routes are NIP-98 protected. +Most API routes are NIP-98 protected. + +Public exceptions: + +- `GET /plans` +- `GET /plans/:id` +- `POST /stripe/webhook` (validated with Stripe signatures instead) - `GET /identity` — get auth identity (`pubkey`, `is_admin`) - `GET /tenants` — list tenants (admin) @@ -73,3 +79,13 @@ All routes are NIP-98 protected. - `PUT /relays/:id` — update relay (admin or relay tenant) - `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant) - `GET /invoices` — list invoices (`?tenant=` allowed for admin only) + +## API Auth Model + +Caravel intentionally uses a session-style variant of NIP-98 for client-to-backend API auth. + +- Frontend signs one kind `27235` event with `u = VITE_API_URL` and caches that header for about 10 minutes. +- Backend verifies event kind, signature, and that `u` contains configured `HOST`. +- Backend intentionally does not bind auth to exact request URL/method/query, and does not enforce payload hash, timestamp freshness window, or replay cache. +- Goal: reduce repeated wallet signing prompts and avoid cookie-based sessions. +- Tradeoff: this is weaker request-intent binding than strict NIP-98 semantics. diff --git a/backend/spec/api.md b/backend/spec/api.md index 831efd9..872d0a2 100644 --- a/backend/spec/api.md +++ b/backend/spec/api.md @@ -184,9 +184,11 @@ Notes: ## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result` - Parses `Authorization` header -- Validates event kind and signature using `nostr_sdk` -- Validates event `u` against `HOST` (not the request path. Non-standard, but correct) -- Does not validate `method` tag +- Validates event kind (`27235`) and signature using `nostr_sdk` +- Validates event `u` contains configured `HOST` +- Intentionally does **not** enforce exact request URL/method/query matching +- Intentionally does **not** validate `payload` tag/hash, `created_at` freshness window, or replay nonce/cache +- This is a deliberate session-style tradeoff to reduce repeated signer prompts in the client - Returns pubkey if header all checks pass Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Use `nostr_sdk` functionality where possible. diff --git a/backend/src/api.rs b/backend/src/api.rs index 6f68808..ec98a46 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -209,6 +209,9 @@ impl Api { return Err(ApiError::Unauthorized(anyhow!("missing u tag"))); }; + // Intentional session-style variant of NIP-98 for Caravel API auth. + // We validate signer identity plus host affinity, and do not bind to exact + // request URL/method or maintain replay state here. if !self.host.is_empty() && !got_u.contains(&self.host) { return Err(ApiError::Unauthorized(anyhow!( "authorization host mismatch" diff --git a/frontend/README.md b/frontend/README.md index f4802ee..5d2d67c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -51,8 +51,11 @@ npm run preview ## Authentication -- Tenant requests use NIP-98 tokens derived from the logged-in user -- Admin routes require a pubkey listed in `PLATFORM_ADMIN_PUBKEYS` on the backend +- Tenant requests use an intentional session-style variant of NIP-98: + - The client signs one kind `27235` event with `u = VITE_API_URL`. + - The resulting `Authorization` header is cached for about 10 minutes to avoid repeated signer prompts. + - The backend validates signer identity + host affinity rather than exact URL/method binding per request. +- Admin routes require a pubkey listed in `ADMINS` on the backend. ## Routes diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6d26250..4c23b6c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -145,6 +145,8 @@ export async function makeAuth(): Promise { kind: 27235, content: "", created_at: Math.floor(now / 1000), + // Intentional session-style auth: sign the API base URL once, then reuse + // the header briefly to avoid prompting the signer on every request. tags: [["u", API_URL]], })