Skip to content

Scoped Per-User API Keys for Programmatic Access #374

Description

@devsimze

Problem Statement

The only way to call the API as a user is with a session Bearer JWT — obtained by signing a wallet challenge (src/controllers/auth-controller.ts), short-lived, refresh-rotated, and tied to a Session row. That is right for an interactive app and wrong for everything else: a user who wants to run a script, a bot, or a backtest harness against their own account has to either automate wallet signing or babysit refresh tokens. There is an AdminApiKey model with role + scopes[] for operators, but nothing equivalent for end users. This issue adds scoped per-user API keys: long-lived, hashed-at-rest credentials with a permission scope narrower than a full session, per-key rate limits, IP allowlisting, and revocation — so programmatic access is first-class and safely bounded.

Current State

  • src/middleware/authenticate.ts — canonical auth: Bearer → JWT verify (JwtAdapter) → live Session row → not expired → user active. Sets req.user.
  • src/controllers/auth-controller.ts — challenge/verify/refresh/logout; refresh-token rotation (Add refresh token rotation for JWT sessions #214); closeUserSockets on logout.
  • src/middleware/adminAuth.ts + AdminApiKey (name, role, scopes[], hash (bcrypt), tokenPrefix (sha256: lookup key), expiresAt, revokedAt, lastUsedAt) + AdminAuditLog — the exact pattern to mirror for users.
  • src/middleware/rateLimiter.ts — HTTP rate limiting; src/middleware/subAccount.ts — sub-account permission checks.
  • prisma/schema.prismaUser, Session, AdminApiKey.
  • docs/API_REFERENCE.md, docs/api-versioning.md.

Proposed Solution

1. Model

model UserApiKey {
  id           String   @id @default(uuid())
  userId       String
  name         String
  scopes       String[] // e.g. "portfolio:read", "transactions:read", "deposit:write", "alerts:manage"
  hash         String   // bcrypt of the raw secret
  tokenPrefix  String   // "sha256:<hex>" deterministic lookup, narrows before bcrypt (same as AdminApiKey)
  ipAllowlist  String[] // optional CID--/IP list; empty = any
  rateLimitPerMin Int?  // optional per-key override
  lastUsedAt   DateTime?
  lastUsedIp   String?
  expiresAt    DateTime?
  revokedAt    DateTime?
  createdAt    DateTime @default(now())
  @@index([userId])
  @@index([tokenPrefix])
}
  • Raw key format: nwk_<keyId>_<secret> (prefix makes it greppable in leaked-secret scanners and lets us do the tokenPrefix lookup). Shown once on creation.

2. Auth middleware

  • Extend the auth layer: if the Authorization header is Bearer nwk_…, route to authenticateApiKey instead of the JWT path:
    • tokenPrefix lookup → bcrypt compare → not revoked/expired → user active → IP in ipAllowlist (if set) → set req.user plus req.authScopes and req.authKind = 'api_key'.
  • A requireScope('deposit:write') middleware gates routes. Session JWTs implicitly have all user scopes (req.authScopes = ['*']); API keys have only their granted set. Existing routes get a scope annotation (a central map, so it's reviewable in one place).
  • Money-movement scopes (deposit:write, withdraw:write) are opt-in per key and off by default; a key with no write scopes is read-only.

3. Guardrails

4. Management + docs

  • POST/GET/DELETE /api/v1/keys — create (returns secret once), list (metadata only, never the secret), revoke. POST /api/v1/keys/:id/rotate.
  • Every create/revoke/rotate emits a security.api_key_changed real-time + email event (ties into the email-channel + session-management issues) — the user always knows when a key appears.
  • GET /api/v1/keys/:id/usage — recent request counts, last IP, scope-denied attempts.
  • docs/API_KEYS.md — scope catalog, key format, rotation, the withdrawal opt-in; docs/API_REFERENCE.md auth section updated.

Edge Cases & Failure Modes

  • Leaked key: revocation is immediate (checked every request, no caching beyond a short TTL that's documented); secret scanners can match the nwk_ prefix; usage endpoint shows unfamiliar IPs.
  • Scope escalation attempt: a key calling a route outside its scopes gets 403 insufficient_scope with the required scope named; the attempt is logged and counted (feeds anomaly review).
  • IP allowlist + legitimate IP change: documented; the user edits the allowlist (a session-auth action, not an API-key action — a key cannot widen its own allowlist).
  • Key with withdraw:write + a AML Transaction Monitoring & Sanctions Screening Pipeline #321 freeze: freeze wins; the withdrawal is halted at the outbox exactly as a session withdrawal would be.
  • Expired key: 401 key_expired (distinct from revoked/invalid) so tooling can tell the difference.
  • Clock skew on expiresAt: evaluated server-side; small grace, documented.
  • Sub-account: an API key belongs to one User; to act on a child it needs both the relevant scope and the parent's SubAccount permission for that child — the API-key path reuses src/middleware/subAccount.ts, no separate logic.
  • Bulk key creation abuse: cap active keys per user (config); creating past the cap is 409.

Security & Privacy Considerations

  • Secrets hashed with bcrypt at rest; tokenPrefix is a SHA-256 lookup accelerator, not the secret; the raw secret is returned exactly once and never logged.
  • Read-only by default; write and especially withdrawal scopes are explicit opt-ins with a confirmation and a platform kill-switch.
  • API-key requests are subject to every downstream control a session is (rate limits, sub-account perms, approval workflows, compliance freeze) — the key is an authentication method, not a bypass.
  • Key lifecycle events notify the user out-of-band so an attacker-created key is visible.
  • keys management endpoints require session auth (an API key cannot create or escalate another API key).

Out of Scope

  • OAuth2 / third-party app authorization (this is the account owner's own key).
  • Fine-grained resource-level scopes (per-position, per-goal) — v1 is action+resource-type scopes.
  • HMAC request signing (Bearer secret over TLS for v1).
  • Machine-to-machine key exchange / JWKS.

Suggested Implementation Plan

  1. UserApiKey model + migration/rollback; raw key format + tokenPrefix derivation (mirror AdminApiKey).
  2. authenticateApiKey middleware + requireScope + a central route→scope map; wire into the auth entry point alongside the JWT path.
  3. Withdrawal opt-in + platform kill-switch + per-key rate limit; reuse subAccount.ts for delegated access.
  4. POST/GET/DELETE /api/v1/keys + rotate + :id/usage; session-auth-only; active-key cap.
  5. security.api_key_changed events (real-time + email); async lastUsedAt/IP updates.
  6. docs/API_KEYS.md + docs/API_REFERENCE.md + docs/openapi.yaml; metrics (auth by kind, scope-denied count).

Acceptance Criteria

  • UserApiKey with named scopes, bcrypt hash + tokenPrefix lookup, optional IP allowlist and per-key rate limit, expiry, and revocation — secret shown once
  • Authorization: Bearer nwk_… authenticates via a dedicated path setting req.authScopes; requireScope(...) gates routes from a central route→scope map; sessions keep ['*']
  • Keys are read-only by default; withdraw:write requires an explicit per-key opt-in and honors a platform kill-switch; API-key requests are still subject to rate limits, sub-account perms, Approval Workflows & Multi-Signature Governance for High-Value Transactions #314 approvals, and AML Transaction Monitoring & Sanctions Screening Pipeline #321 freeze
  • Revocation takes effect within a documented short TTL; expired vs. revoked vs. invalid return distinct errors; scope violations return 403 insufficient_scope and are logged
  • keys management endpoints require session auth (a key cannot mint or escalate a key); active-key count is capped per user
  • Every key create/revoke/rotate notifies the user via real-time + email; :id/usage shows recent IPs and scope-denied attempts
  • docs/API_KEYS.md + docs/API_REFERENCE.md + docs/openapi.yaml updated; unit + integration tests green

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Stellar WaveIssues in the Stellar wave program

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions