You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
modelUserApiKey {idString@id@default(uuid())userIdStringnameStringscopesString[]// e.g. "portfolio:read", "transactions:read", "deposit:write", "alerts:manage"hashString// bcrypt of the raw secrettokenPrefixString// "sha256:<hex>" deterministic lookup, narrows before bcrypt (same as AdminApiKey)ipAllowlistString[]// optional CID--/IP list; empty = anyrateLimitPerMinInt?// optional per-key overridelastUsedAtDateTime?lastUsedIpString?expiresAtDateTime?revokedAtDateTime?createdAtDateTime@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.userplusreq.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.
Per-key rate limit (default stricter than a session); 429 with the key id in the log.
lastUsedAt/lastUsedIp updated async (not on the hot path) for anomaly review.
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).
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
UserApiKey model + migration/rollback; raw key format + tokenPrefix derivation (mirror AdminApiKey).
authenticateApiKey middleware + requireScope + a central route→scope map; wire into the auth entry point alongside the JWT path.
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 ['*']
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
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 aSessionrow. 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 anAdminApiKeymodel withrole+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) → liveSessionrow → not expired → user active. Setsreq.user.src/controllers/auth-controller.ts— challenge/verify/refresh/logout; refresh-token rotation (Add refresh token rotation for JWT sessions #214);closeUserSocketson 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.prisma—User,Session,AdminApiKey.docs/API_REFERENCE.md,docs/api-versioning.md.Proposed Solution
1. Model
nwk_<keyId>_<secret>(prefix makes it greppable in leaked-secret scanners and lets us do thetokenPrefixlookup). Shown once on creation.2. Auth middleware
Authorizationheader isBearer nwk_…, route toauthenticateApiKeyinstead of the JWT path:tokenPrefixlookup → bcrypt compare → not revoked/expired → user active → IP inipAllowlist(if set) → setreq.userplusreq.authScopesandreq.authKind = 'api_key'.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).deposit:write,withdraw:write) are opt-in per key and off by default; a key with no write scopes is read-only.3. Guardrails
withdraw:writeon an API key additionally requires the key to have been created with an explicitallowWithdrawals: trueconfirmation and (config) may be disabled platform-wide — a leaked read key must never be able to drain an account, and even a write key is subject to approval workflows (Approval Workflows & Multi-Signature Governance for High-Value Transactions #314) and compliance freeze (AML Transaction Monitoring & Sanctions Screening Pipeline #321) exactly like a session.429with the key id in the log.lastUsedAt/lastUsedIpupdated async (not on the hot path) for anomaly review.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.security.api_key_changedreal-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.mdauth section updated.Edge Cases & Failure Modes
nwk_prefix; usage endpoint shows unfamiliar IPs.403 insufficient_scopewith the required scope named; the attempt is logged and counted (feeds anomaly review).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.401 key_expired(distinct from revoked/invalid) so tooling can tell the difference.expiresAt: evaluated server-side; small grace, documented.User; to act on a child it needs both the relevant scope and the parent'sSubAccountpermission for that child — the API-key path reusessrc/middleware/subAccount.ts, no separate logic.409.Security & Privacy Considerations
tokenPrefixis a SHA-256 lookup accelerator, not the secret; the raw secret is returned exactly once and never logged.keysmanagement endpoints require session auth (an API key cannot create or escalate another API key).Out of Scope
Suggested Implementation Plan
UserApiKeymodel + migration/rollback; raw key format +tokenPrefixderivation (mirrorAdminApiKey).authenticateApiKeymiddleware +requireScope+ a central route→scope map; wire into the auth entry point alongside the JWT path.subAccount.tsfor delegated access.POST/GET/DELETE /api/v1/keys+rotate+:id/usage; session-auth-only; active-key cap.security.api_key_changedevents (real-time + email); asynclastUsedAt/IP updates.docs/API_KEYS.md+docs/API_REFERENCE.md+docs/openapi.yaml; metrics (auth by kind, scope-denied count).Acceptance Criteria
UserApiKeywith named scopes, bcrypt hash +tokenPrefixlookup, optional IP allowlist and per-key rate limit, expiry, and revocation — secret shown onceAuthorization: Bearer nwk_…authenticates via a dedicated path settingreq.authScopes;requireScope(...)gates routes from a central route→scope map; sessions keep['*']withdraw:writerequires 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 freeze403 insufficient_scopeand are loggedkeysmanagement endpoints require session auth (a key cannot mint or escalate a key); active-key count is capped per user:id/usageshows recent IPs and scope-denied attemptsdocs/API_KEYS.md+docs/API_REFERENCE.md+docs/openapi.yamlupdated; unit + integration tests green