Skip to content

feat: self-service token minting via NIP-98 HTTP Auth#37

Merged
tlongwell-block merged 1 commit intomainfrom
feat/self-service-token-minting
Mar 12, 2026
Merged

feat: self-service token minting via NIP-98 HTTP Auth#37
tlongwell-block merged 1 commit intomainfrom
feat/self-service-token-minting

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

@tlongwell-block tlongwell-block commented Mar 12, 2026

Summary

Allow users to mint their own API auth tokens using their Nostr keypair, eliminating the need for admin CLI access (sprout-admin mint-token). Self-sovereign identity means self-sovereign token management.

New Endpoints

Method Path Auth Purpose
POST /api/tokens NIP-98 (bootstrap) or Bearer Mint a new token
GET /api/tokens Bearer List own tokens
DELETE /api/tokens/{id} Bearer Revoke one token
DELETE /api/tokens Bearer Revoke all tokens (panic button)

Security Model

  • NIP-98 (kind:27235) HTTP Auth for bootstrap — stateless, no WebSocket session needed
  • NIP-98 payload hash requiredPOST /api/tokens requires the payload tag to cryptographically bind the request body to the signed event (prevents body substitution)
  • NIP-98 scoped to mint onlyextract_auth_context() explicitly rejects NIP-98 on all non-token endpoints with nip98_not_supported
  • Scope whitelist: 7 self-mintable scopes; admin scopes (admin:channels, admin:users, jobs:*, subscriptions:*) remain CLI-only
  • Scope escalation prevention: Bearer-minted tokens must be ⊆ caller's own scopes and channel_ids
  • Rate limiting: configurable per-pubkey-per-hour limit (default 50, override with SPROUT_MINT_RATE_LIMIT), bounded moka LRU cache (100k entry cap)
  • Token limit: 10 active tokens per pubkey (atomic conditional INSERT — no TOCTOU race)
  • Distinct error types: token_revoked vs token_expired vs invalid_token
  • Input sanitization: scope and channel_ids deduplication before storage

Agent Ownership

Self-minted tokens automatically set agent_owner_pubkey = caller_pubkey in the users table — same ownership semantics as sprout-admin mint-token --owner-pubkey.

Scope Enforcement

All REST endpoints now enforce scopes via RestAuthContext + require_scope(). Migrated 61 call sites across 15 handler files from the old extract_auth_pubkey() to the new extract_auth_context() which returns scopes, auth method, token ID, and channel_ids. Token-level channel_ids restrictions are enforced universally across all channel-scoped handlers.

Implementation

Crate What Changed
sprout-auth NIP-98 verifier (320 lines, 14 unit tests), Scope::all_known(), all_non_admin(), is_self_mintable(), new error variants
sprout-db 5 new token DB functions (conditional INSERT, list, revoke, revoke-all, get-including-revoked), migration
sprout-relay Token CRUD handlers, RestAuthContext, configurable MintRateLimiter, debounced last_used_at, channel access checks, NIP-98 scoped to mint endpoint only
sprout-test-client 20 e2e integration tests

Configuration

Env Var Default Description
SPROUT_MINT_RATE_LIMIT 50 Max token mints per pubkey per hour
SPROUT_REQUIRE_AUTH_TOKEN false Must be true for production

Test Results

  • Unit tests: all pass, 0 failures
  • Integration tests: 100/100 (48 REST + 18 WS + 14 MCP + 20 Token E2E)
  • Live token test: mint → create channel → send message → read → self-mint child → escalation blocked → revoke → revoked token rejected ✅
  • Code quality: cargo fmt, cargo clippy -D warnings clean
  • Crossfire reviewed: Codex 9/10 APPROVE, Opus 10/10 APPROVE, Gemini 10/10 APPROVE

E2E Test Coverage (20 tests)

  • NIP-98 bootstrap minting (with payload hash verification)
  • Bearer token minting (with scope escalation prevention)
  • Scope validation (admin rejected, unknown rejected, empty rejected, duplicates deduplicated)
  • Token listing and revocation (single, bulk, already-revoked 409, nonexistent 404)
  • Rate limiting (configurable, reads SPROUT_MINT_RATE_LIMIT)
  • NIP-98 negative cases (missing payload tag, wrong payload hash, rejected for non-mint endpoints)
  • Input validation (long names, invalid expiry)

Migration

20260317000001_self_mint_token.sql — adds created_by_self_mint boolean column to api_tokens table. Non-breaking, backward compatible.

@tlongwell-block tlongwell-block force-pushed the feat/self-service-token-minting branch 2 times, most recently from 5588646 to a3e35f3 Compare March 12, 2026 13:27
Allow users to mint their own API auth tokens using their Nostr keypair,
eliminating the need for admin CLI access. Tokens are created via a new
POST /api/tokens endpoint that accepts NIP-98 (kind:27235) HTTP Auth for
bootstrap (no existing token required) or Bearer token for delegation.

## New Endpoints

- POST   /api/tokens      — mint a new token (NIP-98 or Bearer)
- GET    /api/tokens      — list own tokens (Bearer required)
- DELETE /api/tokens/{id}  — revoke one token
- DELETE /api/tokens       — revoke all tokens (panic button)

## Security Model

- **Scope whitelist**: 7 self-mintable scopes; admin scopes (admin:channels,
  admin:users, jobs:*, subscriptions:*) remain CLI-only
- **Scope escalation prevention**: Bearer-minted tokens must be a subset of
  the caller's own scopes and channel_ids
- **NIP-98 payload hash required**: POST /api/tokens requires the payload tag
  to cryptographically bind the request body to the signed event
- **Rate limiting**: 5 mints/hr/pubkey via bounded moka LRU cache (100k cap)
- **Token limit**: 10 active tokens per pubkey (atomic conditional INSERT)
- **Distinct error types**: token_revoked vs token_expired vs invalid_token

## Scope Enforcement

All REST endpoints now enforce scopes via RestAuthContext + require_scope().
Migrated 61 call sites across 15 handler files from the old extract_auth_pubkey()
to the new extract_auth_context() which returns scopes, auth method, token ID,
and channel_ids. Token-level channel_ids restrictions are enforced universally
across all channel-scoped handlers.

## Implementation

- sprout-auth: NIP-98 verifier (319 lines, 14 unit tests), scope helpers
  (all_known, all_non_admin, is_self_mintable), new error variants
- sprout-db: 5 new token DB functions (conditional INSERT, list, revoke,
  revoke-all, get-including-revoked), migration adding created_by_self_mint
- sprout-relay: token CRUD handlers (755 lines), RestAuthContext struct,
  MintRateLimiter, debounced last_used_at tracking, channel access checks
- sprout-test-client: 17 e2e integration tests (824 lines) covering NIP-98
  auth, Bearer auth, scope validation, rate limiting, revocation, escalation
  prevention, and NIP-98 negative cases

## Test Results

- Unit tests: 504+ passed, 0 failed
- Integration tests: 97/97 (48 REST + 18 WS + 14 MCP + 17 Token E2E)
- Crossfire reviewed: Codex 8/10 APPROVE, Opus 9/10 APPROVE

Spec: PLANS/SPROUT_SELF_MINT_SPEC.md
Research: RESEARCH/SPROUT_SELF_SERVICE_TOKEN_MINTING.md
@tlongwell-block tlongwell-block force-pushed the feat/self-service-token-minting branch from a3e35f3 to 3c15e6f Compare March 12, 2026 14:08
@tlongwell-block tlongwell-block merged commit f84da74 into main Mar 12, 2026
8 checks passed
@tlongwell-block tlongwell-block deleted the feat/self-service-token-minting branch March 12, 2026 14:11
tlongwell-block added a commit that referenced this pull request Mar 12, 2026
* origin/main:
  feat: self-service token minting via NIP-98 HTTP Auth (#37)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant