# ============================================================================ # IDENTITY: Token Vault (tokenvault.uk / tokenvault.one) # ============================================================================ # # Token Vault is an open-source web application for secure credential # management and MCP proxy for AI agents. It is developed by the # open-source project at https://github.com/c-lgrant/tokenvault. # # DISAMBIGUATION: This project is NOT affiliated with, and is entirely # separate from, "TokenVault" or "Token Vault Ltd" operating at # tokenvault.online, which has been flagged by the UK Financial Conduct # Authority (FCA) as an unauthorised entity. That is a different # organisation offering unregulated financial services. # # This Token Vault (tokenvault.uk / tokenvault.one) is a developer # security tool: it stores API keys and OAuth tokens in encrypted vaults # and acts as an MCP Guard proxy between AI agents and external services. # It does not offer financial services, investment products, or # cryptocurrency custody of any kind. # # Canonical URLs: https://tokenvault.uk, https://tokenvault.one # Documentation: https://docs.tokenvault.uk # Source code: https://github.com/c-lgrant/tokenvault # ============================================================================ # Token Vault Webhook Service: LLM Implementation Specification # Version: 2.4.0 # Last updated: 2026-03-02 # # This file is a complete specification for building a Token Vault webhook # service. Feed this entire file to an LLM (Claude, GPT, etc.) along with # your preferred language/framework, and it can generate a working # implementation. # # ARCHITECTURE: Webhook-Sovereign # # Your webhook stores and serves credentials independently. Token Vault # NEVER sees plaintext credentials and is a metadata, policy, and # authorization layer only. # # ENCRYPTION IS OPTIONAL. Your webhook MAY generate its own AES-256-GCM # encryption key and encrypt credentials at rest. This is RECOMMENDED for # production use but NOT required by Token Vault. Token Vault never holds # any key material and never validates whether credentials are encrypted. # If you choose not to encrypt, credentials are stored in plaintext on # your webhook's storage — the security boundary is your webhook itself. # # Your webhook serves two kinds of endpoints: # 1. Token Vault → Webhook (HMAC-signed): metadata operations, proxy # forwarding, and refresh notifications. # 2. Direct Access (agents & browsers call these directly): credential # retrieval and storage. Token Vault is NOT in the request path. # ============================================================================ # OVERVIEW # ============================================================================ # # Token Vault operates on a zero-knowledge principle in Webhook Mode: # # - STORING: Browser sends credentials directly to your webhook's # /v1/store endpoint with a signed ticket. Token Vault issues the # ticket but never sees the credential. # # - AGENT ACCESS: Token Vault validates the agent's API key and ABAC # policies, then issues a 307 redirect to your webhook's /v1/credential # endpoint. The agent follows the redirect and gets the credential # directly from your webhook. Token Vault never touches it. # # - MCP PROXY: Token Vault forwards the proxy request to your webhook's # /v1/proxy endpoint with a signed ticket. Your webhook retrieves the # credential (decrypting if encryption is enabled), injects it into # the upstream request, and returns the response. Token Vault sees # the upstream response but never the credential. # # - REFRESH (notify-only): Token Vault notifies your webhook via # /v1/refresh-notify with provider hints. Your webhook handles the # entire OAuth refresh cycle independently. Use this for custom OAuth # providers where you configure your own client credentials on the # webhook. # # - REFRESH (TV-mediated): For Token Vault's built-in OAuth providers # (Google, GitHub), TV can use /v1/refresh to retrieve the refresh # token, perform the OAuth refresh itself using its own client_secret, # and send new tokens back for your webhook to store (encrypting # if encryption is enabled). # This is the only flow where TV briefly handles credentials in transit # (they are never stored). Requires the "tv-refresh" capability. # The webhook is the killswitch: remove it and TV cannot access # any credentials. # # Your webhook is called by Token Vault in Webhook Mode (also known as # "User-Hosted" mode). In this mode, your webhook stores all credential # data and optionally encrypts it with its own key. # ============================================================================ # ENDPOINTS # ============================================================================ # # Token Vault → Webhook (HMAC-signed): # POST /v1/exchange : One-time code exchange, establishes HMAC secret # GET|POST /v1/health : Health check (GET unauthenticated, POST HMAC-signed) # POST /v1/storage : Metadata CRUD (tokens, proxies, audit, config) # POST /v1/proxy : MCP proxy, credential injection into upstream # POST /v1/refresh-notify : Token refresh notification (webhook handles refresh) # # Token Vault → Webhook (HMAC-signed, TV RECEIVES CREDENTIALS): # POST /v1/refresh : TV-mediated refresh (get refresh token + update) # THIS IS THE ONLY ENDPOINT WHERE TV SEES CREDENTIALS. # Requires "tv-refresh" capability. # # Direct Access (agents & browsers, NOT called by Token Vault): # GET /v1/credential : Agent 307 redirect, return credential # POST /v1/store : Browser-direct, store credential # # Optional Helpers (not called by Token Vault, for webhook operators): # GET /v1/register-url : Generate one-time registration URL (CLI/API) # GET /bind : Browser HTML page for one-click binding # # ============================================================================ # CAPABILITY → ENDPOINT MAPPING # ============================================================================ # # Capabilities are declared in /v1/exchange and /v1/health responses. # Each capability requires specific endpoints to be implemented. # Core endpoints (exchange, health) are always required. # # ALWAYS REQUIRED (not tied to a capability): # POST /v1/exchange — Binding flow (one-time setup) # GET|POST /v1/health — Health checks and status # # "storage" capability: # POST /v1/storage — Metadata CRUD for tokens, proxies, audit, config # Required for: dashboard token listing, proxy config management, audit logs # # "credential" capability: # GET|POST /v1/credential — Agent/browser direct credential retrieval # Requires: "storage" (to read token documents) # Required for: agent 307-redirect credential access, browser token reveal # # "store" capability: # POST /v1/store — Browser-direct credential storage # Requires: "storage" (to persist token documents) # Required for: browser-direct token creation (zero-knowledge store) # # "proxy" capability: # POST /v1/proxy — MCP proxy credential injection + upstream call # Requires: "storage" (to read token documents and proxy configs) # Required for: MCP Guard proxy (AI agents calling upstream APIs) # # "refresh" capability: # POST /v1/refresh-notify — Webhook-owned token refresh (notify-only) # Requires: "storage" (to read/update token documents) # Required for: custom OAuth providers where webhook owns client credentials # # "tv-refresh" capability: # POST /v1/refresh — TV-mediated two-phase refresh (get + update) # Requires: "storage" (to read/update token documents) # Required for: built-in OAuth providers (Google, GitHub) where TV owns # the client_secret. This is the ONLY flow where TV sees # credential material. # # MINIMAL IMPLEMENTATION: exchange + health + storage + credential + store # → Supports: token storage, agent access, browser reveal # # FULL IMPLEMENTATION: all capabilities # → Supports: everything including MCP proxy and both refresh modes # ============================================================================ # PERSISTENT STATE YOUR WEBHOOK MUST STORE # ============================================================================ # # After /v1/exchange, your webhook must securely persist: # # hmac_secret: bytes - 256-bit HMAC-SHA256 shared secret (base64 during delivery) # webhook_id: str - Your webhook's unique ID (returned during exchange) # is_configured: bool - Set to true after successful exchange # encryption_key: bytes - OPTIONAL. Your own 256-bit AES-256-GCM key # (generated locally). Only needed if you choose # to encrypt credentials at rest. Omit to store # credentials in plaintext. # # Storage collections (your webhook's KV store): # # tokens: dict - Token documents (keyed by service name). # Encrypted if encryption is enabled, plaintext otherwise. # proxy_configs: dict - MCP proxy configurations (keyed by proxy ID) # audit: list - Append-only audit event log (keyed by timestamp) # vault_config: dict - Vault settings (single key: "settings") # ============================================================================ # REQUEST AUTHENTICATION (HMAC) # ============================================================================ # # Every request from Token Vault (except /v1/exchange) includes these headers: # # X-TokenVault-Signature: sha256= # X-TokenVault-Timestamp: # X-TokenVault-Request-Id: req_<12-char hex> # Content-Type: application/json # # HMAC SIGNATURE VERIFICATION: # # signing_payload = f"{timestamp}.{raw_json_body}" # expected = HMAC-SHA256(hmac_secret, signing_payload.encode("utf-8")) # signature_valid = constant_time_compare(expected.hexdigest(), header_signature[7:]) # # Note: header_signature starts with "sha256=", strip that prefix. # # IMPORTANT: JSON SERIALIZATION: # Token Vault serializes the request body as compact JSON with NO whitespace: # json.dumps(body, separators=(",", ":")) # Your HMAC verification MUST use the raw bytes received (do NOT re-serialize # or pretty-print). If you re-serialize for verification, use the same compact # format: no spaces after colons or commas. # # REPLAY PREVENTION: # - Reject requests where |current_time - timestamp| > 300 seconds (5 minutes) # - Track request IDs to reject duplicates # ============================================================================ # TICKET AUTHENTICATION (for /v1/credential, /v1/store, /v1/proxy) # ============================================================================ # # The direct-access endpoints and the proxy endpoint use signed tickets # instead of (or in addition to) HMAC headers. Tickets are self- # authenticating; only Token Vault (which shares the HMAC secret) could # have generated them. # # TICKET FORMAT: # {base64url(json_payload)}.{hex_hmac_signature} # # VERIFICATION: # 1. Split on ".": first part is base64url payload, second is hex signature # 2. Verify: HMAC-SHA256(hmac_secret, payload_b64_string) == provided_signature # (use constant-time comparison) # 3. Base64url-decode the payload (add back "=" padding if needed) and JSON-parse # 4. Check exp > current_time (reject expired tickets) # 5. Optionally track nonce to prevent replay (recommended) # # TICKET PAYLOAD FIELDS: # # { # "sub": "user-id", // vault owner's user ID (for audit) # "svc": "github", // service name # "pur": "agent_credential" | "user_reveal" | "store" | "proxy", # "aid": "agent-id", // optional, present for agent requests # "pid": "proxy-id", // optional, present for proxy requests # "iat": 1708300000, // issued at (Unix seconds) # "exp": 1708300060, // expires at (Unix seconds, 60s default TTL) # "nonce": "hex-string" // 32-char hex nonce for replay prevention # } # # IMPORTANT: Tickets do NOT contain any key material. If encryption is # enabled, your webhook uses its own key; it does not need anything from # Token Vault to access credentials. # ============================================================================ # ENDPOINT: POST /v1/exchange # ============================================================================ # # Called ONCE during the webhook binding flow. Your webhook receives a # one-time code and returns its HMAC secret. This is the only endpoint # called without HMAC authentication; the code itself is the auth. # # Flow: # 1. User visits your webhook's /bind page # 2. Your webhook generates a one-time code (UUID, 5-minute TTL) # 3. Redirects to Token Vault with the code # 4. Token Vault calls POST /v1/exchange with the code # 5. Your webhook validates the code and returns the HMAC secret # 6. Both sides now share the HMAC secret for all future requests # # Request body: # { # "code": "550e8400-e29b-41d4-a716-446655440000" # } # # Your webhook MUST: # 1. Validate the one-time code (must be unused, within 5-minute TTL) # 2. Generate a 256-bit HMAC secret (if not already generated) # 3. OPTIONAL: Generate your own 256-bit AES-256-GCM encryption key # (stored locally). Only needed if you want encryption at rest. # 4. Mark the code as used # 5. Set is_configured = true # 6. Return the HMAC secret, webhook ID, version, and capabilities # # Response (200): # { # "hmacSecret": "", # "webhookId": "wh_abc123", # "version": "1.0.0", # "capabilities": ["storage", "credential", "proxy", "refresh", "tv-refresh", "store"] # } # # The "version" field is YOUR webhook's implementation version (use any # semver string you like, e.g. "1.0.0"). It is not the spec version. # # If the code is expired, return 410 (Gone): # { # "error": "code_expired", # "message": "Registration code expired or not found" # } # # If the code has already been used, return 410 (Gone): # { # "error": "code_used", # "message": "Registration code already used" # } # # CAPABILITIES (include only the ones your webhook implements): # "storage" : Supports /v1/storage (metadata CRUD) # "credential" : Supports /v1/credential (agent direct access) # "proxy" : Supports /v1/proxy (MCP proxy credential injection) # "refresh" : Supports /v1/refresh-notify (webhook-owned token refresh) # "tv-refresh" : Supports /v1/refresh (TV-mediated two-phase refresh) # "store" : Supports /v1/store (browser-direct storage) # ============================================================================ # ENDPOINT: GET|POST /v1/health # ============================================================================ # # Called on-demand and periodically. # # AUTHENTICATION: # - POST requests: HMAC-authenticated (standard X-TokenVault-* headers). # Token Vault uses POST for server-to-server health checks. # - GET requests: No authentication required. Useful for external # monitoring, load balancer health checks, and the /bind status page. # # POST request body: # { # "requestId": "req_abc123def456" # } # # Response (200): # { # "status": "healthy", # "version": "1.0.0", # "keyConfigured": true, # "capabilities": ["storage", "credential", "proxy", "refresh", "tv-refresh", "store"], # "uptime": 86400, # "tokenCount": 5 # } # # Fields: # status: "healthy", "degraded", or "unreachable" # version: Your webhook's implementation version string (any semver) # keyConfigured: Whether an encryption key has been generated (boolean). # false is valid — it means credentials are stored in # plaintext (encryption is optional). # capabilities: List of supported endpoint capabilities: # "storage" : Supports /v1/storage (metadata CRUD) # "credential" : Supports /v1/credential (agent direct access) # "proxy" : Supports /v1/proxy (MCP proxy credential injection) # "refresh" : Supports /v1/refresh-notify (notify-only refresh) # "tv-refresh" : Supports /v1/refresh (TV-mediated two-phase refresh) # "store" : Supports /v1/store (browser-direct storage) # uptime: Seconds since the webhook process started # tokenCount: Number of tokens currently stored # ============================================================================ # ENDPOINT: POST /v1/storage # ============================================================================ # # Called for METADATA OPERATIONS ONLY. Token Vault uses this endpoint to # list tokens, manage proxy configurations, write audit events, and store # vault settings. This endpoint NEVER returns plaintext credentials; # credential access goes through /v1/credential (direct) or /v1/proxy. # HMAC-authenticated. # # A single endpoint handles get, list, set, and delete via the "operation" # field across four collections. # # COLLECTIONS: # "tokens" : Encrypted token documents (list returns metadata only) # "proxy_configs" : MCP proxy configurations (keyed by proxy ID) # "audit" : Audit event log (keyed by timestamp, newest-first) # "vault_config" : Vault settings (single key: "settings") # # --- LIST (tokens, metadata only) --- # Request: # { # "requestId": "req_list_tokens456", # "operation": "list", # "collection": "tokens" # } # Response (200): # { # "requestId": "req_list_tokens456", # "items": [ # { # "key": "github", # "meta": { # "serviceName": "github", # "tokenType": "JWT", # "expiryTime": 1720003600000, # "createdAt": "2026-02-01T10:00:00Z" # } # } # ] # } # # IMPORTANT: Each item in the list MUST include "key" and a "meta" object # with at least "serviceName". The meta comes from the stored document's # unencrypted metadata, NOT the decrypted credential. # # --- LIST with OPTIONS (pagination + filtering, all collections) --- # The "options" field is OPTIONAL. Old webhooks that ignore it continue # to return all items and Token Vault filters in-memory. # # Request: # { # "requestId": "req_list_audit789", # "operation": "list", # "collection": "audit", # "options": { # "limit": 50, # "after": "2026-02-15T10:30:00Z", # "filters": { # "event_type": "SECRET_ACCESS", # "source": "agent" # } # } # } # # options fields: # limit (int) : Max items to return (default: all, max: 200) # after (str) : Cursor — return items AFTER this value. For audit, # this is a timestamp (items older than the cursor). # For tokens, this could be a serviceName cursor. # filters (dict) : Key-value pairs matched against item data fields. # All filters are AND-ed. # # Response (200) — with optional "pagination": # { # "requestId": "req_list_audit789", # "items": [ ... ], # "pagination": { # "hasMore": true, # "nextCursor": "2026-02-14T08:00:00Z", # "totalCount": 1234 # } # } # # pagination fields (all optional): # hasMore (bool) : true if more items exist beyond the returned page # nextCursor (str) : cursor value to pass as "after" for the next page # totalCount (int) : total number of matching items (optional, may be omitted) # # If "pagination" is absent in the response, Token Vault assumes no more pages. # # --- GET --- # Request: # { # "requestId": "req_get_config123", # "operation": "get", # "collection": "vault_config", # "key": "settings" # } # Response (200): # { # "requestId": "req_get_config123", # "data": { ... } // The stored document, or null if not found # } # # --- SET --- # Request: # { # "requestId": "req_set_proxy789", # "operation": "set", # "collection": "proxy_configs", # "key": "proxy-abc123", # "data": { # "name": "GitHub MCP", # "upstreamUrl": "https://api.github.com/mcp", # "serviceName": "github", # "headerTemplates": { "Authorization": "Bearer ${TOKEN}" } # } # } # Response (200): # { # "requestId": "req_set_proxy789", # "status": "ok" # } # # --- DELETE --- # Request: # { # "requestId": "req_delete_token000", # "operation": "delete", # "collection": "tokens", # "key": "github" # } # Response (200): # { # "requestId": "req_delete_token000", # "status": "ok" # } # # --- LIST_BATCH (multiple collections in one round trip) --- # Request: # { # "requestId": "req_batch_abc123", # "operation": "list_batch", # "collections": ["tokens", "audit"] # } # Response (200): # { # "requestId": "req_batch_abc123", # "results": { # "tokens": { # "items": [ # { "key": "github", "meta": { "serviceName": "github", ... } } # ] # }, # "audit": { # "items": [ # { "key": "2026-02-15T10:30:00Z", "data": { ... } } # ] # } # } # } # # NOTE: The "collection" field is ignored for list_batch; use "collections" # (an array) instead. Each collection in the results uses the same format # as an individual "list" response. Unknown collection names are skipped. # ============================================================================ # AUDIT EVENTS (collection: "audit") # ============================================================================ # # Token Vault sends audit events to your webhook's /v1/storage endpoint # using the "audit" collection. These are written with operation "set" # (keyed by timestamp) and read back with operation "list". # # Your webhook stores these events and returns them when listed. # Token Vault reads audit events back to display in the dashboard UI. # # --- SET (write an audit event) --- # Request: # { # "requestId": "req_audit_abc123", # "operation": "set", # "collection": "audit", # "key": "2026-02-15T10:30:00Z", # "data": { # "event_type": "AGENT_CREDENTIAL_ACCESS", # "source": "agent", # "service_name": "github", # "agent_id": "agent-abc123", # "client_ip": "203.0.113.42", # "zero_knowledge": true, # "timestamp": "2026-02-15T10:30:00Z" # } # } # # --- LIST (read audit events) --- # Request: # { # "requestId": "req_audit_list456", # "operation": "list", # "collection": "audit" # } # Response (200): # { # "requestId": "req_audit_list456", # "items": [ # { # "key": "2026-02-15T10:30:00Z", # "data": { # "event_type": "AGENT_CREDENTIAL_ACCESS", # "source": "agent", # "service_name": "github", # "zero_knowledge": true, # "timestamp": "2026-02-15T10:30:00Z" # } # } # ] # } # # IMPORTANT: Return items sorted newest-first by timestamp. # # AUDIT EVENT TYPES: # # SECRET_ACCESS : A token was read (direct user access or proxy) # Fields: source ("direct"|"agent"|"proxy"), service_name, client_ip, # user_agent, proxy_id?, proxy_name?, geo_country?, http_method?, # token_type?, request_path?, upstream_url?, response_status?, # duration_ms? # # AGENT_CREDENTIAL_ACCESS: An agent retrieved a credential via 307 redirect # Fields: source ("agent"), service_name, agent_id, agent_name, # client_ip, user_agent, zero_knowledge, geo_country?, # http_method?, token_type? # # TOKEN_REFRESH : A token was refreshed # Fields: source ("direct"), service_name, refresh_mode ("server"|"webhook"), # geo_country? # # POLICY_DENIED : An access request was blocked by an ABAC policy # Fields: source (entity_type), entity_type ("agent"|"proxy"|"token"), # entity_id, service_name?, policy_name, rule_type, reason, # client_ip, user_agent, geo_country?, http_method? # # COMMON OPTIONAL FIELDS (present when available): # geo_country (str) : ISO 3166-1 alpha-2 country code from CF-IPCountry # http_method (str) : HTTP method of the triggering request (GET, POST) # token_type (str) : Token type (JWT, OAuth, Certificate, SSHKey, etc.) # request_path (str) : URL path of the proxy request (proxy events only) # upstream_url (str) : Full upstream URL called (proxy events only) # response_status (int) : HTTP status from the upstream response (proxy only) # duration_ms (int) : Round-trip time to upstream in ms (proxy only) # ============================================================================ # ENDPOINT: POST /v1/proxy # ============================================================================ # # Called when an AI agent makes a request through the MCP proxy. Token Vault # validates the proxy key and ABAC policies, then forwards the request to # your webhook with a signed proxy ticket. Your webhook retrieves the # credential (decrypting it if encryption is enabled), injects it into the # upstream request headers (replacing ${TOKEN} placeholders), makes the # upstream HTTP call, and returns the response. Token Vault never sees the # credential. HMAC-authenticated + ticket-authenticated. # # Request body: # { # "requestId": "req_proxy_abc123", # "ticket": "", # "service": "github", # "upstream": { # "url": "https://api.github.com/mcp", # "method": "POST", # "headers": { # "Content-Type": "application/json" # }, # "body": "" # }, # "headerTemplates": { # "Authorization": "Bearer ${TOKEN}" # } # } # # Your webhook MUST: # 1. Verify HMAC signature (standard header auth) # 2. Verify the proxy ticket (HMAC, expiry, nonce, purpose = "proxy") # 3. Read the credential for the given service from storage # 4. If encryption is enabled, decrypt the credential using your key # 5. Replace ${TOKEN} in headerTemplates with the access token # 6. Make the HTTP request to upstream.url with the merged headers # 7. Return the upstream response as a raw HTTP passthrough # # Response: # Your webhook returns the upstream HTTP response directly: same status # code, same Content-Type, same body. Token Vault streams this response # back to the agent transparently. Include an X-Upstream-Status header # with the upstream's numeric status code for observability. # # Example: if the upstream returns 200 with JSON, your webhook returns # HTTP 200 with the same JSON body and Content-Type header. # # This is NOT a JSON envelope. The response body IS the upstream body. # ============================================================================ # ENDPOINT: POST /v1/refresh-notify (NOTIFY-ONLY REFRESH) # ============================================================================ # # Called when Token Vault detects a token approaching expiry. Your webhook # owns the credential and handles the refresh independently. Token Vault # sends metadata (service name, provider hints) but NEVER the credential. # HMAC-authenticated. Zero-knowledge: TV does not see any credentials. # # Use this for CUSTOM OAuth providers where your webhook has its own # client credentials. For Token Vault's built-in providers (Google, GitHub), # TV uses /v1/refresh instead (see below). # # Request body: # { # "requestId": "req_refresh_abc123", # "service": "my-custom-api", # "reason": "token_expiring", # "expiresAt": "2026-02-17T15:30:00Z", # "refreshHint": { # "provider": "my-custom-api", # "tokenUrl": "https://auth.example.com/oauth/token", # "clientId": "your_client_id" # } # } # # NOTE: Token Vault does NOT send clientSecret. Your webhook must have its # own OAuth client credentials configured locally. The refreshHint only # contains the provider name, token URL, and client ID as hints. # # To configure your webhook for custom provider refresh: # 1. Set environment variables or a config file on your webhook with # the OAuth client_id and client_secret for each custom provider # 2. When this endpoint is called, look up the credentials by provider # name or service name # 3. Read the stored refresh token (decrypting if encryption is enabled) # and call the provider's token endpoint with your own client credentials # # Your webhook MUST: # 1. Verify HMAC signature # 2. Read the token for the given service from your storage # 3. If encryption is enabled, decrypt the refresh token using your key # 4. Look up your own client credentials for this provider # 5. Call the OAuth provider's token endpoint: # # POST {refreshHint.tokenUrl} # Content-Type: application/x-www-form-urlencoded # # grant_type=refresh_token # &client_id={your_client_id} # &client_secret={your_client_secret} # &refresh_token={refresh_token} # # 6. Store the new tokens (encrypting if encryption is enabled) # 7. Return acknowledgement # # Response (200): # { # "requestId": "req_refresh_abc123", # "status": "refreshed", # "newExpiresAt": "2026-02-17T16:30:00Z" # } # # Valid status values: # "refreshed" : Token was refreshed synchronously; newExpiresAt is set # "acknowledged" : Notification received; webhook will refresh later # "no_token" : No token stored for the requested service # "no_refresh_token" : Token exists but has no refresh token # "refresh_failed" : OAuth provider returned an error during refresh # "error" : Unexpected internal error during refresh # ============================================================================ # ENDPOINT: POST /v1/refresh (TV-MEDIATED REFRESH) # ============================================================================ # # *** THIS IS THE ONLY ENDPOINT WHERE TV RECEIVES CREDENTIAL MATERIAL. *** # # Called when Token Vault needs to refresh a token for one of its built-in # OAuth providers (Google, GitHub). Unlike /v1/refresh-notify where the # webhook handles everything, this endpoint enables a two-phase flow where # TV performs the OAuth refresh using its own client_secret. # # HMAC-authenticated. Requires "tv-refresh" capability. # # The webhook acts as a killswitch: if the webhook is removed or offline, # TV cannot access any credentials and cannot refresh. # # Your webhook opts in by including "tv-refresh" in the capabilities array # returned from /v1/exchange and /v1/health. If omitted, TV falls back to # /v1/refresh-notify. # # --- Phase 1: GET REFRESH TOKEN (action: "get") --- # # Token Vault asks for the plaintext refresh token. # # Request body: # { # "requestId": "req_refresh_abc123", # "action": "get", # "service": "google" # } # # Response (200): # { # "requestId": "req_refresh_abc123", # "status": "ok", # "refreshToken": "1//0abc_plaintext_refresh_token...", # "meta": { # "serviceName": "google", # "tokenType": "JWT", # "expiryTime": 1720003600000, # "createdAt": "2026-02-01T10:00:00Z", # "hasRefreshToken": true # } # } # # Your webhook MUST: # 1. Verify HMAC signature # 2. Read the token for the given service from your storage # 3. If encryption is enabled, decrypt the refresh token using your key # 4. Return the plaintext refresh token and token metadata # # Status values: # "ok" : Refresh token retrieved and returned # "no_token" : No token stored for the requested service # "no_refresh_token" : Token exists but has no refresh token # # --- Phase 2: UPDATE WITH NEW TOKENS (action: "update") --- # # After TV refreshes with the OAuth provider, it sends new tokens back. # # Request body: # { # "requestId": "req_refresh_abc123", # "action": "update", # "service": "google", # "tokens": { # "accessToken": "ya29.new_access_token...", # "refreshToken": "1//0abc_new_or_same_refresh_token...", # "expiryTime": 1720007200000 # } # } # # Your webhook MUST: # 1. Verify HMAC signature # 2. If encryption is enabled, encrypt the new accessToken and # refreshToken with your key. Otherwise store as plaintext. # 3. Update the stored token document, preserving existing metadata # (createdAt, tokenType) and updating expiryTime and updatedAt # 4. Return acknowledgement # # Response (200): # { # "requestId": "req_refresh_abc123", # "status": "updated", # "newExpiresAt": "2026-07-03T17:00:00+00:00" # } # # Status values: # "updated" : Tokens stored successfully # "no_token" : No existing token document (nothing to update) # # SECURITY NOTES: # - TV receives the refresh token briefly, uses it to call the OAuth # provider, receives new tokens, sends them to the webhook, and # discards all credential material. TV does NOT persist any tokens. # - The webhook remains the killswitch: removing the webhook prevents # TV from accessing any credential material. # - Your webhook opts in by reporting "tv-refresh" in capabilities. # Omit this capability to stay fully zero-knowledge. # ============================================================================ # ENDPOINT: GET /v1/credential (DIRECT ACCESS: agent 307 redirect) # ============================================================================ # # Called DIRECTLY by agents and browsers, NOT by Token Vault's backend. # This is the zero-knowledge credential access endpoint. When an agent # requests a credential, Token Vault validates auth and ABAC policies, # then returns a 307 redirect to this endpoint with a signed ticket. # The agent follows the redirect and gets the credential directly from # your webhook. Token Vault never sees the credential. # # Also accepts POST with a JSON body for browser-based access. # # AUTHENTICATION: This endpoint does NOT use the standard X-TokenVault-* # HMAC headers. Instead, the request contains a self-authenticating # ticket signed by Token Vault using the shared HMAC secret. Your webhook # verifies the ticket signature. # # CALLERS: # - Agents: Token Vault returns a 307 redirect when agents call # GET /api/agents/credentials?service=. Most HTTP clients # follow redirects automatically, making it transparent. The redirect # URL includes ticket and service as query parameters. # - Browsers: The Token Vault dashboard fetches a ticket via API, then # calls this endpoint directly using fetch(). # # --- GET request (from 307 redirect) --- # GET /v1/credential?ticket=&service=github # # --- POST request (browser) --- # { # "ticket": "", # "service": "github" # } # # Your webhook MUST: # 1. Extract ticket and service from query params (GET) or JSON body (POST) # 2. Split the ticket on ".": payload_b64 and hex_signature # 3. Verify: HMAC-SHA256(hmac_secret, payload_b64) == hex_signature # (use constant-time comparison) # 4. Base64url-decode and JSON-parse the payload # 5. Check exp > current_time (reject expired tickets) # 6. Check svc matches the requested service # 7. Optionally track nonce to prevent replay (recommended) # 8. Read the token from your storage (key = service name) # 9. If encryption is enabled, decrypt sensitive fields using your key # 10. Return the credential # # Response (200): # { # "token": { # "accessToken": "ghp_actual_plaintext_token...", # "refreshToken": "ghr_actual_refresh_token...", # "serviceName": "github", # "tokenType": "JWT", # "createdAt": "2026-02-01T10:00:00Z" # } # } # # Error responses: # 401 - ticket_invalid: HMAC signature verification failed # 401 - ticket_expired: Ticket exp is in the past # 404 - token_not_found: No token stored for the requested service # # CORS REQUIREMENTS: # Browser-based requests come from the Token Vault frontend origin. # Your webhook MUST return CORS headers on /v1/credential: # # Access-Control-Allow-Origin: https://tokenvault.uk (or your TV origin) # Access-Control-Allow-Methods: GET, POST, OPTIONS # Access-Control-Allow-Headers: Content-Type # # Handle OPTIONS preflight requests by returning 204 with these headers. # # SECURITY NOTES: # - The ticket is self-authenticating: only Token Vault (which shares the # HMAC secret) could have generated it. # - No key material is included in the ticket. If encryption is enabled, # your webhook uses its own key to decrypt credentials. # - The Authorization header is stripped by HTTP clients on cross-domain # redirects, so agents' API keys are never sent to the webhook. # - Tickets are short-lived (60s) and should be single-use where possible. # ============================================================================ # ENDPOINT: POST /v1/store (DIRECT ACCESS: browser-direct storage) # ============================================================================ # # Called DIRECTLY by the browser, NOT by Token Vault's backend. When a # user adds a token through the dashboard, Token Vault issues a store # ticket. The browser sends the plaintext credential directly to your # webhook with this ticket. Your webhook verifies the ticket, optionally # encrypts the credential with its own AES-256-GCM key, and persists it. # Token Vault never sees the plaintext credential. # # AUTHENTICATION: Signed ticket in the JSON body (not HMAC headers). # # Request body: # { # "ticket": "", # "service": "github", # "tokenData": { # "accessToken": "ghp_abc123...", # "refreshToken": "ghr_xyz789...", # "tokenType": "JWT", # "expiresAt": "2026-02-17T15:30:00Z" # } # } # # Your webhook MUST: # 1. Verify the ticket (HMAC signature, expiry, nonce, purpose = "store") # 2. Verify that the ticket's svc field matches the service parameter # 3. Extract the credential data from tokenData # 4. If encryption is enabled, encrypt sensitive fields (accessToken, # refreshToken) with your AES-256-GCM key. Otherwise store as-is. # 5. Persist the token document (with plaintext metadata alongside # encrypted or plaintext credential fields) # 6. Return metadata (not the credential) in the response # # Response (200): # { # "status": "stored", # "service": "github", # "meta": { # "serviceName": "github", # "tokenType": "JWT", # "createdAt": "2026-02-15T10:00:00Z" # } # } # # CORS REQUIREMENTS: # Same as /v1/credential: this endpoint is called from the browser. # Return CORS headers and handle OPTIONS preflight requests. # ============================================================================ # TOKEN DOCUMENT FORMAT # ============================================================================ # # Your webhook stores token documents with metadata in plaintext. # Credential fields (accessToken, refreshToken) are either encrypted # with AES-256-GCM or stored in plaintext, depending on whether you # have enabled encryption. # # --- PLAINTEXT FORMAT (no encryption) --- # # { # "v": 1, // Schema version # "alg": "none", // No encryption # "fields": { # "accessToken": "ghp_abc123...", // Plaintext # "refreshToken": "ghr_xyz789..." // Plaintext # }, # "meta": { // Plaintext metadata # "serviceName": "github", # "tokenType": "JWT", // "JWT", "PlainText" # "createdAt": "2026-02-01T10:00:00Z", # "expiryTime": 1720003600000, // ms since epoch, optional # "hasRefreshToken": true // bool # } # } # # --- ENCRYPTED FORMAT (AES-256-GCM, recommended) --- # # { # "v": 1, // Schema version # "alg": "AES-256-GCM", // Algorithm identifier # "fields": { # "accessToken": "", # "refreshToken": "" # }, # "meta": { // Plaintext metadata # "serviceName": "github", # "tokenType": "JWT", // "JWT", "PlainText" # "createdAt": "2026-02-01T10:00:00Z", # "expiryTime": 1720003600000, // ms since epoch, optional # "hasRefreshToken": true // bool # } # } # # The "alg" field tells your webhook how to read credential fields: # "none" → fields contain plaintext strings # "AES-256-GCM" → fields contain base64-encoded encrypted blobs # # AES-256-GCM DETAILS (only when encryption is enabled): # Key: 32 bytes (your webhook's own key, generated during /v1/exchange) # IV: 12 bytes (randomly generated per encryption, prepended to ciphertext) # Tag: 16 bytes (appended to ciphertext by AES-GCM) # AAD: None (no additional authenticated data) # # To decrypt: # raw = base64_decode(encrypted_field_value) # iv = raw[0:12] # ciphertext_with_tag = raw[12:] # plaintext = AES-GCM-Decrypt(key, iv, ciphertext_with_tag, aad=None) # # To encrypt: # iv = random_bytes(12) # ciphertext_with_tag = AES-GCM-Encrypt(key, iv, plaintext.encode("utf-8"), aad=None) # result = base64_encode(iv + ciphertext_with_tag) # ============================================================================ # ERROR RESPONSES # ============================================================================ # # Return these HTTP status codes with JSON error bodies: # # 400: invalid_request # Malformed request or missing required fields. # { "error": "invalid_request", "message": "Missing required field: service" } # # 401: auth_failed # HMAC signature is invalid. Token Vault will NOT retry. # { "error": "auth_failed", "message": "HMAC signature invalid" } # # 401: ticket_invalid # Ticket HMAC signature verification failed. # { "error": "ticket_invalid", "message": "Ticket signature mismatch" } # # 401: ticket_expired # Ticket exp is in the past. # { "error": "ticket_expired", "message": "Ticket has expired" } # # 403: setup_required # Webhook has not been configured yet (no /v1/exchange completed). # { "error": "setup_required", "message": "Complete /v1/exchange first" } # # 404: token_not_found # No token stored for the requested service. # { "error": "token_not_found", "message": "No token found for service: github" } # # 410: code_expired # Exchange code has expired or was not found. # { "error": "code_expired", "message": "Registration code expired or not found" } # # 410: code_used # Exchange code has already been used. # { "error": "code_used", "message": "Registration code already used" } # # 500: internal_error # Unexpected server error (encryption failure, storage I/O, etc.). # { "error": "internal_error", "message": "Token storage failed:
" } # # 502: upstream_error # The upstream service returned an error during proxy forwarding. # { "error": "upstream_error", "message": "Upstream request failed:
" } # # 502: provider_error # The OAuth provider returned an error during refresh. # { "error": "provider_error", "message": "Google returned 401: invalid_grant" } # # 504: upstream_timeout # The upstream service timed out during proxy forwarding. # { "error": "upstream_timeout", "message": "Upstream request timed out" } # # 504: provider_timeout # The OAuth provider timed out during refresh. # { "error": "provider_timeout", "message": "OAuth provider did not respond in 10s" } # ============================================================================ # RETRY POLICY (from Token Vault's side) # ============================================================================ # # Token Vault calls your webhook with: # Timeout: 10 seconds per request (30 seconds for /v1/proxy) # Retries: 2 retries (3 total attempts) # Backoff: 1 second after first failure, 3 seconds after second # No retry: on 4xx errors (400, 401, 403, 410) # Retry: on 5xx errors and network timeouts only # ============================================================================ # SECURITY REQUIREMENTS # ============================================================================ # # 1. HTTPS only (TLS 1.2+) # 2. Store HMAC secret in a secrets manager or encrypted storage. Never # store in plaintext config files or environment variables. # 3. If encryption is enabled, store the encryption key with the same care # as the HMAC secret. # 4. Validate HMAC on EVERY request from Token Vault (except /v1/exchange) # 5. Validate ticket signature on EVERY direct-access request # 6. Reject timestamps > 5 minutes old # 7. Track request IDs and ticket nonces to prevent replay # 8. If encryption is enabled, never expose your encryption key to any # external system. # 9. Log all incoming requests for your own audit trail # 10. /v1/exchange must be one-time only: reject after code is used # 11. Return CORS headers only on /v1/credential and /v1/store # ============================================================================ # OPTIONAL HELPER ENDPOINTS (not called by Token Vault) # ============================================================================ # # These endpoints are convenience helpers for webhook operators. Token Vault # does NOT call them. They are used by the webhook's own UI and CLI tools. # You may implement them or skip them entirely. # # --- GET /v1/register-url --- # # Generates a one-time registration URL that the operator can open in a # browser to complete the webhook-bind flow. Useful for headless/CLI setup. # # Response (200): # { # "registrationUrl": "https://tokenvault.uk/vault/webhook-bind?code=...&webhook_url=...&hmac_hash=...", # "code": "550e8400-e29b-41d4-a716-446655440000", # "expiresIn": 300, # "webhookUrl": "https://your-webhook.example.com" # } # # No authentication required (local access only). # # --- GET /bind --- # # A browser-friendly HTML page for one-click webhook binding. Shows: # - If already bound: connection status, webhook ID, token count, uptime # - If not bound: a "Connect to TokenVault" button that generates a # one-time code and redirects to the Token Vault frontend # - ?force=1 query param to re-bind even if already connected # # No authentication required (meant for local browser access). # ============================================================================ # EXAMPLE: MINIMAL IMPLEMENTATION STRUCTURE # ============================================================================ # # Your webhook needs: # # 1. A web server (Express, FastAPI, Gin, Actix, etc.) # 2. A secrets store for the HMAC secret (and encryption key, if enabled) # 3. A KV store for token documents, proxy configs, audit events # 4. An AES-256-GCM library (only if encryption is enabled) # 5. An HTTP client for OAuth provider calls and upstream proxy requests # # Pseudocode structure: # # middleware: verify_hmac(request) → 401 if invalid # middleware: check_configured() → 403 if not configured # middleware: check_replay(request_id, timestamp) → 400 if replay # # POST /v1/exchange: # validate_code(request.code) or return 410 # hmac_secret = generate_secret(32_bytes) # if ENCRYPTION_ENABLED: # encryption_key = generate_secret(32_bytes) # store_secrets(hmac_secret, encryption_key?) # mark_code_used(request.code) # return { hmacSecret: base64(hmac_secret), webhookId: ..., capabilities: [...] } # # POST /v1/health: # return { status: "healthy", version: "1.0.0", capabilities: [...] } # # POST /v1/storage: # switch request.operation: # "get": return { data: kv_store.get(collection, key) } # "list": return { items: kv_store.list(collection) } // metadata only # "list_batch": return { results: { col: list(col) for col in collections } } # "set": kv_store.set(collection, key, data); return { status: "ok" } # "delete": kv_store.delete(collection, key); return { status: "ok" } # # POST /v1/proxy: # verify_hmac(request) # verify_ticket(request.ticket, purpose="proxy") # doc = kv_store.get("tokens", request.service) # if doc.alg == "AES-256-GCM": # access_token = aes_gcm_decrypt(encryption_key, doc.fields.accessToken) # else: # access_token = doc.fields.accessToken // plaintext # headers = apply_templates(request.headerTemplates, access_token) # response = http_request(request.upstream.url, request.upstream.method, headers, body) # return raw_http_response(response.status, response.headers, response.body) // passthrough # # POST /v1/refresh-notify: # doc = kv_store.get("tokens", request.service) # if doc.alg == "AES-256-GCM": # refresh_token = aes_gcm_decrypt(encryption_key, doc.fields.refreshToken) # else: # refresh_token = doc.fields.refreshToken // plaintext # new_tokens = call_oauth_provider(refresh_token, request.refreshHint) # new_doc = maybe_encrypt_document(encryption_key, new_tokens) // respects alg setting # kv_store.set("tokens", request.service, new_doc) # return { status: "refreshed" } # # GET /v1/credential: # NO HMAC header auth, ticket only # ticket = request.query.ticket # service = request.query.service # payload = verify_ticket(ticket, hmac_secret) # if payload.svc != service: return 400 # doc = kv_store.get("tokens", service) # if not doc: return 404 # fields = {} # for field_name, value in doc.fields: # if doc.alg == "AES-256-GCM": # fields[field_name] = aes_gcm_decrypt(encryption_key, value) # else: # fields[field_name] = value // plaintext # token = { **doc.meta, **fields } # set_cors_headers(response) # return { token: token } # # POST /v1/store: # NO HMAC header auth, ticket only # payload = verify_ticket(request.ticket, hmac_secret, purpose="store") # if payload.svc != request.service: return 400 # doc = build_token_document(encryption_key, request.tokenData) // encrypt or plaintext # kv_store.set("tokens", request.service, doc) # set_cors_headers(response) # return { status: "stored", service: request.service, meta: doc.meta } # ============================================================================ # IMPLEMENTATION NOTES # ============================================================================ # # 1. ENCRYPTION IS OPTIONAL. If enabled, your webhook generates and owns # its own encryption key — Token Vault never holds any key material. # If disabled, credentials are stored in plaintext on your webhook. # Either way, Token Vault never sees plaintext credentials. # # 2. /v1/storage list operations on the "tokens" collection return metadata # only. Token Vault uses this to populate the dashboard, and it never # needs (or receives) plaintext credentials through this endpoint. # # 3. /v1/credential is called directly by agents following a 307 redirect # from Token Vault. The agent's Authorization header is stripped by HTTP # clients on cross-domain redirects, so agent API keys never reach your # webhook. # # 4. /v1/store is called directly by the browser when the user adds a token # in the dashboard. Token Vault issues a store ticket but never sees the # credential the browser sends to your webhook. # # 5. /v1/proxy receives both HMAC headers (from Token Vault) and a signed # ticket. Verify both. The ticket authorizes your webhook to retrieve a # specific credential for a specific proxy request. # # 6. JSON body serialization: Token Vault uses compact JSON (no whitespace) # for HMAC signing. Your verification must use the raw received bytes, # not re-serialized JSON. # # 7. hasRefreshToken in token document meta: Set this boolean when storing # so list_tokens can indicate refresh capability without reading # credential fields. # # 8. CORS: Only /v1/credential and /v1/store need CORS headers. All other # endpoints are called server-to-server by Token Vault's backend. # # 9. Use the "alg" field in token documents to determine whether to # encrypt/decrypt: "AES-256-GCM" means encrypted, "none" means plaintext. # This allows a webhook to support both modes or migrate between them. # # 10. The reference implementation is available at: # https://github.com/c-lgrant/tvault/tree/main/examples/webhook-ngrok # ============================================================================ # DEPLOYMENT NOTES # ============================================================================ # # - Deploy to any platform that supports HTTPS (Cloudflare Workers, AWS # Lambda, Google Cloud Run, Fly.io, a VPS with nginx + Let's Encrypt, etc.) # - Use a persistent storage backend (SQLite, Redis, PostgreSQL, or even # the filesystem) for the token KV store # - Minimum resource requirements are very low. The webhook handles # lightweight operations (optional crypto, occasional OAuth refreshes). # - Token Vault validates webhook URLs: must be HTTPS, public IP, no # private ranges (10.x, 172.16.x, 192.168.x, 127.x), port 443 or >1024 # - For local development, use a tunnel service (ngrok, Cloudflare Tunnel) # to expose your webhook with a public HTTPS URL