docs(auth): document intentional session-style NIP-98 model #16

Merged
hodlbod merged 1 commits from userAdityaa/caravel:nip-docs into master 2026-04-16 15:40:50 +00:00
5 changed files with 32 additions and 6 deletions
+17 -1
View File
@@ -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=<pubkey>` 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.
+5 -3
View File
@@ -184,9 +184,11 @@ Notes:
## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String>`
- 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.
+3
View File
@@ -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"
+5 -2
View File
@@ -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
+2
View File
@@ -145,6 +145,8 @@ export async function makeAuth(): Promise<string | undefined> {
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]],
})