{"openapi":"3.0.0","info":{"title":"Circles Authentication Service","version":"0.1.0","description":"## Authentication\n\nTwo paths — both produce the same JWT:\n\n**New user (first time):**\n1. `POST /challenge` → get SIWE message\n2. Sign with wallet (EOA or Safe)\n3. `POST /verify` → JWT\n4. `POST /passkey/register/options` + `/register/verify` → register passkey for future logins\n\n**Returning user (has passkey):**\n1. `POST /passkey/authenticate/options` → get WebAuthn challenge\n2. User taps passkey (biometric / device prompt)\n3. `POST /passkey/authenticate/verify` → JWT\n\nThe recommended flow: SIWE once to onboard, passkey for every session after.\nAll JWTs are identical regardless of auth method — verify via JWKS at `/.well-known/jwks.json`.\n\n---\n\n**Other auth methods:**\n- **Passkey Account** — walletless onboarding, derives a Safe from a passkey. Experimental.\n- **Service Auth** — backend-to-backend API keys. Not for end users."},"servers":[{"url":"","description":"Production (via reverse proxy or DO App Platform)"},{"url":"http://localhost:3001","description":"Local development"}],"tags":[{"name":"SIWE","description":"First-time authentication — wallet signature. POST /challenge → sign with wallet → POST /verify → JWT. Works with EOA and Safe wallets (ERC-1271). After the first login, register a passkey (see Passkey section) so the user never needs to interact with their wallet again."},{"name":"Passkey","description":"Primary authentication for returning users. After initial wallet-based login (SIWE), register a passkey. All future logins use the passkey — no wallet popup, no gas, instant. Produces the same JWT as SIWE. Registration requires a valid JWT (from SIWE). Authentication does not."},{"name":"JWKS","description":"Public key for JWT verification. Backends call GET /.well-known/jwks.json to validate tokens. Cache ~10 min."},{"name":"Token Exchange","description":"Exchange an external JWT (from a trusted issuer like Gnosis App) for a Circles JWT. Stateless, single-endpoint flow: POST /exchange with external token → get Circles JWT. Supports multi-audience: request one token valid for referrals + marketplace + defender in a single call. Issuer trust is configured server-side via TRUSTED_ISSUERS env var."},{"name":"Passkey Account","description":"Experimental — walletless onboarding via passkey. Derives a Safe address from the passkey public key. Not required for integration."},{"name":"Service Auth","description":"Backend-to-backend API key management. Not for end-user auth."},{"name":"Health","description":"Liveness and readiness probes."}],"components":{"schemas":{},"parameters":{}},"paths":{"/auth/health":{"get":{"tags":["Health"],"summary":"Basic health check","description":"Simple health check returning service info. Legacy endpoint for compatibility.","responses":{"200":{"description":"Service is running","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"service":{"type":"string","enum":["circles-auth-service"]},"version":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}},"required":["status","service","version","timestamp"]}}}}}}},"/auth/health/live":{"get":{"tags":["Health"],"summary":"Liveness probe","description":"Kubernetes liveness probe. Checks if the process can respond. No dependency checks - those belong in readiness.","responses":{"200":{"description":"Process is alive","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"timestamp":{"type":"string","format":"date-time"}},"required":["status","timestamp"]}}}}}}},"/auth/health/ready":{"get":{"tags":["Health"],"summary":"Readiness probe","description":"Kubernetes readiness probe. Checks if the service can serve traffic. Verifies database connectivity.","responses":{"200":{"description":"Service is ready to serve traffic","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok","degraded","error"]},"timestamp":{"type":"string","format":"date-time"},"checks":{"type":"object","properties":{"database":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"latencyMs":{"type":"number"},"error":{"type":"string"},"pool":{"type":"object","properties":{"max":{"type":"number"},"idleTimeoutSeconds":{"type":"number","nullable":true}},"required":["max","idleTimeoutSeconds"]}},"required":["status"]},"rpc":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"url":{"type":"string"},"latencyMs":{"type":"number"},"blockNumber":{"type":"number"},"error":{"type":"string"}},"required":["status","url"]},"tokenService":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"initialized":{"type":"boolean"}},"required":["status","initialized"]}},"required":["database","rpc"]}},"required":["status","timestamp","checks"]}}}},"503":{"description":"Service is not ready","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok","degraded","error"]},"timestamp":{"type":"string","format":"date-time"},"checks":{"type":"object","properties":{"database":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"latencyMs":{"type":"number"},"error":{"type":"string"},"pool":{"type":"object","properties":{"max":{"type":"number"},"idleTimeoutSeconds":{"type":"number","nullable":true}},"required":["max","idleTimeoutSeconds"]}},"required":["status"]},"rpc":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"url":{"type":"string"},"latencyMs":{"type":"number"},"blockNumber":{"type":"number"},"error":{"type":"string"}},"required":["status","url"]},"tokenService":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"initialized":{"type":"boolean"}},"required":["status","initialized"]}},"required":["database","rpc"]}},"required":["status","timestamp","checks"]}}}}}}},"/health":{"get":{"tags":["Health"],"summary":"Basic health check","description":"Simple health check returning service info. Legacy endpoint for compatibility.","responses":{"200":{"description":"Service is running","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"service":{"type":"string","enum":["circles-auth-service"]},"version":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}},"required":["status","service","version","timestamp"]}}}}}}},"/health/live":{"get":{"tags":["Health"],"summary":"Liveness probe","description":"Kubernetes liveness probe. Checks if the process can respond. No dependency checks - those belong in readiness.","responses":{"200":{"description":"Process is alive","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"timestamp":{"type":"string","format":"date-time"}},"required":["status","timestamp"]}}}}}}},"/health/ready":{"get":{"tags":["Health"],"summary":"Readiness probe","description":"Kubernetes readiness probe. Checks if the service can serve traffic. Verifies database connectivity.","responses":{"200":{"description":"Service is ready to serve traffic","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok","degraded","error"]},"timestamp":{"type":"string","format":"date-time"},"checks":{"type":"object","properties":{"database":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"latencyMs":{"type":"number"},"error":{"type":"string"},"pool":{"type":"object","properties":{"max":{"type":"number"},"idleTimeoutSeconds":{"type":"number","nullable":true}},"required":["max","idleTimeoutSeconds"]}},"required":["status"]},"rpc":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"url":{"type":"string"},"latencyMs":{"type":"number"},"blockNumber":{"type":"number"},"error":{"type":"string"}},"required":["status","url"]},"tokenService":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"initialized":{"type":"boolean"}},"required":["status","initialized"]}},"required":["database","rpc"]}},"required":["status","timestamp","checks"]}}}},"503":{"description":"Service is not ready","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok","degraded","error"]},"timestamp":{"type":"string","format":"date-time"},"checks":{"type":"object","properties":{"database":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"latencyMs":{"type":"number"},"error":{"type":"string"},"pool":{"type":"object","properties":{"max":{"type":"number"},"idleTimeoutSeconds":{"type":"number","nullable":true}},"required":["max","idleTimeoutSeconds"]}},"required":["status"]},"rpc":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"url":{"type":"string"},"latencyMs":{"type":"number"},"blockNumber":{"type":"number"},"error":{"type":"string"}},"required":["status","url"]},"tokenService":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"]},"initialized":{"type":"boolean"}},"required":["status","initialized"]}},"required":["database","rpc"]}},"required":["status","timestamp","checks"]}}}}}}},"/auth/challenge":{"post":{"tags":["SIWE"],"summary":"Generate authentication challenge","description":"Generate a SIWE (Sign-In with Ethereum) challenge message. The client signs this message with their wallet and submits the signature to /verify. Supports EOA wallets and ERC-1271 smart contract wallets (Safe).\n\nKey parameters:\n- `audience` — sets the JWT `aud` claim and TTL. Available: `circles-api` (default, 1h), `referrals-api` (7d), `market-api` (7d), `defender-api` (30m).\n- `statement` — optional text shown in the wallet signing popup. UI-only, not validated by the backend.\n\nFor first-time users: after verifying, register a passkey via /passkey/register so the user can skip wallet signing on future visits.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"address":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Ethereum address to authenticate","example":"0x1234567890123456789012345678901234567890"},"chainId":{"type":"integer","minimum":0,"exclusiveMinimum":true,"description":"EVM chain ID. Embedded in the SIWE message and the JWT `chainId` claim. Defaults to 100 (Gnosis Chain). Only set this if authenticating for a different chain.","example":100},"statement":{"type":"string","maxLength":256,"description":"**UI-only. Not validated by the backend.** This text is shown to the user in the wallet signing popup (the EIP-4361 'statement' line). The frontend sets this to whatever makes sense for the context. Defaults to 'Sign in to Circles' if omitted. Has zero effect on the JWT or authentication — purely for the user to read before signing.","example":"Sign in to Circles"},"audience":{"anyOf":[{"type":"string","maxLength":64},{"type":"array","items":{"type":"string","maxLength":64},"maxItems":5}],"description":"Controls which backend(s) accept the JWT and how long it lives. Single string or array of up to 5 audiences. Sets the JWT `aud` claim — the target backend rejects tokens with a wrong audience. If omitted, defaults to 'circles-api' (1h TTL). When multiple audiences are given, the shortest TTL wins. Options: 'referrals-api' → invitation-backend, 7 day TTL. 'market-api' → market API, 7 day TTL. 'defender-api' → Circles Defender game, 30 min TTL. Omit or 'circles-api' → default, 1h TTL.","example":"referrals-api"}},"required":["address"]}}}},"responses":{"200":{"description":"Challenge created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"challengeId":{"type":"string","format":"uuid","description":"Unique challenge identifier. Pass this to POST /verify along with the signature. Single-use — consumed on verification."},"message":{"type":"string","description":"The EIP-4361 SIWE message the user must sign. Pass this string as-is to: eth_sign (EOA), Safe SDK signMessage/signBytes (Safe wallet), or miniapp-sdk signMessage (Cometh/4337 passkey). Do NOT modify it — the backend verifies the exact string. Contains: domain, address, statement, URI, chainId, nonce, timestamps."},"nonce":{"type":"string","description":"Random nonce embedded in the message. For replay protection — each challenge gets a unique nonce."},"expiresAt":{"type":"string","format":"date-time","description":"Challenge expiry (default: 10 minutes from creation). After this time, /verify will reject the signature and you must request a new challenge."}},"required":["challengeId","message","nonce","expiresAt"]}}}},"400":{"description":"Bad request - invalid address or parameters","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/auth/verify":{"post":{"tags":["SIWE"],"summary":"Verify signature and issue token","description":"Verify a wallet signature against a previously issued challenge and issue a JWT. Supports EOA and ERC-1271 (Safe) signatures. After receiving the JWT, call POST /passkey/register/options to set up passwordless login for returning sessions.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"challengeId":{"type":"string","format":"uuid","description":"The challengeId returned by POST /challenge. Links this signature to the original challenge. Single-use — cannot be reused after verification.","example":"550e8400-e29b-41d4-a716-446655440000"},"signature":{"type":"string","pattern":"^0x[a-fA-F0-9]+$","description":"Hex-encoded signature of the SIWE message. Supported signing methods: (1) EOA: eth_sign or personal_sign. (2) Safe SDK: signBytes() / signMessage() via Safe Protocol Kit — verified via isValidSignature(bytes). (3) Cometh Connect / ERC-4337 passkeys: account.signMessage() via viem — verified via isValidSignature(bytes32) with EIP-191 hash. (4) WalletConnect: Safe Transaction Service lookup. Must be 0x-prefixed hex.","example":"0x1234..."}},"required":["challengeId","signature"]}}}},"responses":{"200":{"description":"Signature verified, token issued","content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","description":"RS256 JWT. Verify via JWKS at /.well-known/jwks.json. Claims: sub (address@chainId), addr, chainId, aud, iat, exp. Same token format regardless of auth method (SIWE, passkey, or passkey-account)."},"address":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Authenticated wallet address (checksummed, lowercase in JWT)"},"chainId":{"type":"number","description":"Chain ID from the SIWE message (default 100 = Gnosis Chain)"},"expiresIn":{"type":"number","description":"Token lifetime in seconds. Depends on the `audience` you set in /challenge: no audience / 'circles-api' → 3600 (1h), 'referrals-api' → 604800 (7d), 'market-api' → 604800 (7d)."},"verificationMethod":{"type":"string","enum":["eoa","erc1271-bytes32","erc1271-eip191","erc1271-bytes","safe-owner"],"description":"How the signature was verified. Tried in order until one succeeds: 'eoa' = ecrecover (EOA wallet), 'erc1271-bytes' = on-chain isValidSignature(bytes) call (Safe wallets — most common), 'erc1271-bytes32' = isValidSignature(bytes32) variant, 'erc1271-eip191' = isValidSignature(bytes32) with EIP-191 personal message hash (Cometh/4337 passkey flow), 'safe-owner' = ecrecover matched a Safe owner address."}},"required":["token","address","chainId","expiresIn","verificationMethod"]}}}},"401":{"description":"Unauthorized - invalid signature, expired or used challenge","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/challenge":{"post":{"tags":["SIWE"],"summary":"Generate authentication challenge","description":"Generate a SIWE (Sign-In with Ethereum) challenge message. The client signs this message with their wallet and submits the signature to /verify. Supports EOA wallets and ERC-1271 smart contract wallets (Safe).\n\nKey parameters:\n- `audience` — sets the JWT `aud` claim and TTL. Available: `circles-api` (default, 1h), `referrals-api` (7d), `market-api` (7d), `defender-api` (30m).\n- `statement` — optional text shown in the wallet signing popup. UI-only, not validated by the backend.\n\nFor first-time users: after verifying, register a passkey via /passkey/register so the user can skip wallet signing on future visits.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"address":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Ethereum address to authenticate","example":"0x1234567890123456789012345678901234567890"},"chainId":{"type":"integer","minimum":0,"exclusiveMinimum":true,"description":"EVM chain ID. Embedded in the SIWE message and the JWT `chainId` claim. Defaults to 100 (Gnosis Chain). Only set this if authenticating for a different chain.","example":100},"statement":{"type":"string","maxLength":256,"description":"**UI-only. Not validated by the backend.** This text is shown to the user in the wallet signing popup (the EIP-4361 'statement' line). The frontend sets this to whatever makes sense for the context. Defaults to 'Sign in to Circles' if omitted. Has zero effect on the JWT or authentication — purely for the user to read before signing.","example":"Sign in to Circles"},"audience":{"anyOf":[{"type":"string","maxLength":64},{"type":"array","items":{"type":"string","maxLength":64},"maxItems":5}],"description":"Controls which backend(s) accept the JWT and how long it lives. Single string or array of up to 5 audiences. Sets the JWT `aud` claim — the target backend rejects tokens with a wrong audience. If omitted, defaults to 'circles-api' (1h TTL). When multiple audiences are given, the shortest TTL wins. Options: 'referrals-api' → invitation-backend, 7 day TTL. 'market-api' → market API, 7 day TTL. 'defender-api' → Circles Defender game, 30 min TTL. Omit or 'circles-api' → default, 1h TTL.","example":"referrals-api"}},"required":["address"]}}}},"responses":{"200":{"description":"Challenge created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"challengeId":{"type":"string","format":"uuid","description":"Unique challenge identifier. Pass this to POST /verify along with the signature. Single-use — consumed on verification."},"message":{"type":"string","description":"The EIP-4361 SIWE message the user must sign. Pass this string as-is to: eth_sign (EOA), Safe SDK signMessage/signBytes (Safe wallet), or miniapp-sdk signMessage (Cometh/4337 passkey). Do NOT modify it — the backend verifies the exact string. Contains: domain, address, statement, URI, chainId, nonce, timestamps."},"nonce":{"type":"string","description":"Random nonce embedded in the message. For replay protection — each challenge gets a unique nonce."},"expiresAt":{"type":"string","format":"date-time","description":"Challenge expiry (default: 10 minutes from creation). After this time, /verify will reject the signature and you must request a new challenge."}},"required":["challengeId","message","nonce","expiresAt"]}}}},"400":{"description":"Bad request - invalid address or parameters","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/verify":{"post":{"tags":["SIWE"],"summary":"Verify signature and issue token","description":"Verify a wallet signature against a previously issued challenge and issue a JWT. Supports EOA and ERC-1271 (Safe) signatures. After receiving the JWT, call POST /passkey/register/options to set up passwordless login for returning sessions.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"challengeId":{"type":"string","format":"uuid","description":"The challengeId returned by POST /challenge. Links this signature to the original challenge. Single-use — cannot be reused after verification.","example":"550e8400-e29b-41d4-a716-446655440000"},"signature":{"type":"string","pattern":"^0x[a-fA-F0-9]+$","description":"Hex-encoded signature of the SIWE message. Supported signing methods: (1) EOA: eth_sign or personal_sign. (2) Safe SDK: signBytes() / signMessage() via Safe Protocol Kit — verified via isValidSignature(bytes). (3) Cometh Connect / ERC-4337 passkeys: account.signMessage() via viem — verified via isValidSignature(bytes32) with EIP-191 hash. (4) WalletConnect: Safe Transaction Service lookup. Must be 0x-prefixed hex.","example":"0x1234..."}},"required":["challengeId","signature"]}}}},"responses":{"200":{"description":"Signature verified, token issued","content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","description":"RS256 JWT. Verify via JWKS at /.well-known/jwks.json. Claims: sub (address@chainId), addr, chainId, aud, iat, exp. Same token format regardless of auth method (SIWE, passkey, or passkey-account)."},"address":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Authenticated wallet address (checksummed, lowercase in JWT)"},"chainId":{"type":"number","description":"Chain ID from the SIWE message (default 100 = Gnosis Chain)"},"expiresIn":{"type":"number","description":"Token lifetime in seconds. Depends on the `audience` you set in /challenge: no audience / 'circles-api' → 3600 (1h), 'referrals-api' → 604800 (7d), 'market-api' → 604800 (7d)."},"verificationMethod":{"type":"string","enum":["eoa","erc1271-bytes32","erc1271-eip191","erc1271-bytes","safe-owner"],"description":"How the signature was verified. Tried in order until one succeeds: 'eoa' = ecrecover (EOA wallet), 'erc1271-bytes' = on-chain isValidSignature(bytes) call (Safe wallets — most common), 'erc1271-bytes32' = isValidSignature(bytes32) variant, 'erc1271-eip191' = isValidSignature(bytes32) with EIP-191 personal message hash (Cometh/4337 passkey flow), 'safe-owner' = ecrecover matched a Safe owner address."}},"required":["token","address","chainId","expiresIn","verificationMethod"]}}}},"401":{"description":"Unauthorized - invalid signature, expired or used challenge","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/auth/passkey/register/options":{"post":{"tags":["Passkey"],"summary":"Get passkey registration options","description":"Get WebAuthn registration options to create a new passkey. Requires a valid JWT (from SIWE or existing passkey session). One-time setup — after registration, the user can authenticate via /authenticate/options + /authenticate/verify without touching their wallet.","parameters":[{"schema":{"type":"string","description":"JWT from SIWE /verify or passkey /authenticate/verify. Format: `Bearer <token>`. Required for /register/*, /list, and DELETE endpoints. Not required for /authenticate/* (those endpoints issue new tokens).","example":"Bearer eyJhbGciOiJSUzI1NiIs..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Registration options generated","content":{"application/json":{"schema":{"type":"object","properties":{"options":{"nullable":true,"description":"WebAuthn PublicKeyCredentialCreationOptions. Pass directly to navigator.credentials.create({ publicKey: options }) in the browser. Contains rpId, user info, challenge, supported algorithms, and attestation preferences."},"challenge":{"type":"string","description":"Server-side challenge identifier. Pass this value back in the /register/verify request body. This is NOT the WebAuthn challenge bytes — the server maps this ID to the stored challenge internally. Single-use, expires in 5 minutes."}},"required":["challenge"]}}}},"401":{"description":"Unauthorized - missing or invalid JWT","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/auth/passkey/register/verify":{"post":{"tags":["Passkey"],"summary":"Verify passkey registration","description":"Verify the WebAuthn registration response and store the credential. Requires prior authentication via SIWE.","parameters":[{"schema":{"type":"string","description":"JWT from SIWE /verify or passkey /authenticate/verify. Format: `Bearer <token>`. Required for /register/*, /list, and DELETE endpoints. Not required for /authenticate/* (those endpoints issue new tokens).","example":"Bearer eyJhbGciOiJSUzI1NiIs..."},"required":false,"name":"authorization","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"response":{"nullable":true,"description":"The full response object from navigator.credentials.create(). Contains attestationObject, clientDataJSON, and transports. Pass the entire credential response as-is — do not extract individual fields."}}}}}},"responses":{"200":{"description":"Passkey registered successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","description":"Whether the passkey was registered successfully."},"credentialId":{"type":"string","description":"Base64url-encoded credential ID of the newly registered passkey. Use this to identify or delete the passkey later via DELETE /{credentialId}."}},"required":["success"]}}}},"400":{"description":"Bad request - verification failed","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"401":{"description":"Unauthorized - missing or invalid JWT","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/auth/passkey/authenticate/options":{"post":{"tags":["Passkey"],"summary":"Get passkey authentication options","description":"Get WebAuthn authentication options for passkey login. This is the primary login method for returning users — no wallet interaction needed. Pass the returned options to navigator.credentials.get() in the browser. No prior authentication required.\n\nAccepts optional `audience` to set JWT `aud` claim and TTL: `circles-api` (default, 1h), `referrals-api` (7d), `market-api` (7d), `defender-api` (30m).","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"address":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Ethereum address to authenticate. Must have at least one passkey registered (via /register/*). Returns 404 if no passkeys exist for this address.","example":"0x1234567890123456789012345678901234567890"},"audience":{"anyOf":[{"type":"string","maxLength":64},{"type":"array","items":{"type":"string","maxLength":64},"maxItems":5}],"description":"Controls which backend(s) accept the JWT and how long it lives. Single string or array of up to 5 audiences. Sets the JWT `aud` claim — the target backend rejects tokens with a wrong audience. If omitted, defaults to 'circles-api' (1h TTL). When multiple audiences are given, the shortest TTL wins. Options: 'referrals-api' → invitation-backend, 7 day TTL. 'market-api' → market API, 7 day TTL. 'defender-api' → Circles Defender game, 30 min TTL. Omit or 'circles-api' → default, 1h TTL.","example":"referrals-api"}},"required":["address"]}}}},"responses":{"200":{"description":"Authentication options generated","content":{"application/json":{"schema":{"type":"object","properties":{"options":{"nullable":true,"description":"WebAuthn options object. Pass directly to navigator.credentials.get({ publicKey: options }) in the browser."},"challenge":{"type":"string","description":"Challenge identifier. Pass this value back in the /authenticate/verify request — NOT the WebAuthn challenge bytes. Single-use, expires in 5 minutes."}},"required":["challenge"]}}}},"404":{"description":"No passkeys registered for this address","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/auth/passkey/authenticate/verify":{"post":{"tags":["Passkey"],"summary":"Verify passkey authentication","description":"Verify the WebAuthn authentication response and issue a JWT. This is the primary login method for returning users who have registered a passkey. No wallet interaction required. Produces the same JWT as SIWE.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"address":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Same address used in /authenticate/options. Must match the passkey's registered wallet address.","example":"0x1234567890123456789012345678901234567890"},"response":{"nullable":true,"description":"The full response object from navigator.credentials.get(). Contains authenticatorData, clientDataJSON, signature. Pass it as-is — do not extract individual fields."}},"required":["address"]}}}},"responses":{"200":{"description":"Authentication successful, token issued","content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","description":"RS256 JWT. Send as `Authorization: Bearer <token>` to Circles backends. Identical to SIWE tokens — same claims, same format. Verify via /.well-known/jwks.json."},"address":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Authenticated wallet address (lowercase). Same address that was registered via SIWE."},"chainId":{"type":"number","description":"Chain ID (default 100 = Gnosis Chain)."},"expiresIn":{"type":"number","description":"Token lifetime in seconds. Depends on the `audience` set in /authenticate/options: no audience / 'circles-api' → 3600 (1h), 'referrals-api' → 604800 (7d), 'market-api' → 604800 (7d)."}},"required":["token","address","chainId","expiresIn"]}}}},"401":{"description":"Unauthorized - verification failed","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/auth/passkey/list":{"get":{"tags":["Passkey"],"summary":"List user's passkeys","description":"Get a list of all passkeys registered for the authenticated user.","parameters":[{"schema":{"type":"string","description":"JWT from SIWE /verify or passkey /authenticate/verify. Format: `Bearer <token>`. Required for /register/*, /list, and DELETE endpoints. Not required for /authenticate/* (those endpoints issue new tokens).","example":"Bearer eyJhbGciOiJSUzI1NiIs..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"List of passkeys","content":{"application/json":{"schema":{"type":"object","properties":{"passkeys":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Internal database row ID (UUID). Not used in WebAuthn flows."},"credentialId":{"type":"string","description":"Base64url-encoded WebAuthn credential ID. Use this for DELETE /{credentialId} and to identify which passkey the user is authenticating with."},"deviceType":{"type":"string","nullable":true,"description":"Device type reported by the authenticator: 'singleDevice' (hardware key like YubiKey) or 'multiDevice' (synced passkey, e.g. iCloud Keychain, Google Password Manager). Null if the authenticator didn't report this."},"backedUp":{"type":"boolean","nullable":true,"description":"Whether the credential is backed up / synced across devices. true = synced passkey (survives device loss), false = single-device credential. Null if unknown."},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when the passkey was registered."},"lastUsedAt":{"type":"string","nullable":true,"format":"date-time","description":"ISO 8601 timestamp of last successful authentication with this passkey. Null if never used for auth."}},"required":["id","credentialId","deviceType","backedUp","createdAt","lastUsedAt"]}}},"required":["passkeys"]}}}},"401":{"description":"Unauthorized - missing or invalid JWT","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/auth/passkey/{credentialId}":{"delete":{"tags":["Passkey"],"summary":"Delete a passkey","description":"Remove a passkey from the authenticated user's account.","parameters":[{"schema":{"type":"string","description":"Base64url-encoded credential ID of the passkey to remove. Get this from the /list endpoint. Must belong to the authenticated user."},"required":true,"name":"credentialId","in":"path"},{"schema":{"type":"string","description":"JWT from SIWE /verify or passkey /authenticate/verify. Format: `Bearer <token>`. Required for /register/*, /list, and DELETE endpoints. Not required for /authenticate/* (those endpoints issue new tokens).","example":"Bearer eyJhbGciOiJSUzI1NiIs..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Passkey deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","description":"Whether the passkey was deleted successfully."}},"required":["success"]}}}},"401":{"description":"Unauthorized - missing or invalid JWT","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"404":{"description":"Passkey not found or belongs to different user","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/passkey/register/options":{"post":{"tags":["Passkey"],"summary":"Get passkey registration options","description":"Get WebAuthn registration options to create a new passkey. Requires a valid JWT (from SIWE or existing passkey session). One-time setup — after registration, the user can authenticate via /authenticate/options + /authenticate/verify without touching their wallet.","parameters":[{"schema":{"type":"string","description":"JWT from SIWE /verify or passkey /authenticate/verify. Format: `Bearer <token>`. Required for /register/*, /list, and DELETE endpoints. Not required for /authenticate/* (those endpoints issue new tokens).","example":"Bearer eyJhbGciOiJSUzI1NiIs..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Registration options generated","content":{"application/json":{"schema":{"type":"object","properties":{"options":{"nullable":true,"description":"WebAuthn PublicKeyCredentialCreationOptions. Pass directly to navigator.credentials.create({ publicKey: options }) in the browser. Contains rpId, user info, challenge, supported algorithms, and attestation preferences."},"challenge":{"type":"string","description":"Server-side challenge identifier. Pass this value back in the /register/verify request body. This is NOT the WebAuthn challenge bytes — the server maps this ID to the stored challenge internally. Single-use, expires in 5 minutes."}},"required":["challenge"]}}}},"401":{"description":"Unauthorized - missing or invalid JWT","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/passkey/register/verify":{"post":{"tags":["Passkey"],"summary":"Verify passkey registration","description":"Verify the WebAuthn registration response and store the credential. Requires prior authentication via SIWE.","parameters":[{"schema":{"type":"string","description":"JWT from SIWE /verify or passkey /authenticate/verify. Format: `Bearer <token>`. Required for /register/*, /list, and DELETE endpoints. Not required for /authenticate/* (those endpoints issue new tokens).","example":"Bearer eyJhbGciOiJSUzI1NiIs..."},"required":false,"name":"authorization","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"response":{"nullable":true,"description":"The full response object from navigator.credentials.create(). Contains attestationObject, clientDataJSON, and transports. Pass the entire credential response as-is — do not extract individual fields."}}}}}},"responses":{"200":{"description":"Passkey registered successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","description":"Whether the passkey was registered successfully."},"credentialId":{"type":"string","description":"Base64url-encoded credential ID of the newly registered passkey. Use this to identify or delete the passkey later via DELETE /{credentialId}."}},"required":["success"]}}}},"400":{"description":"Bad request - verification failed","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"401":{"description":"Unauthorized - missing or invalid JWT","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/passkey/authenticate/options":{"post":{"tags":["Passkey"],"summary":"Get passkey authentication options","description":"Get WebAuthn authentication options for passkey login. This is the primary login method for returning users — no wallet interaction needed. Pass the returned options to navigator.credentials.get() in the browser. No prior authentication required.\n\nAccepts optional `audience` to set JWT `aud` claim and TTL: `circles-api` (default, 1h), `referrals-api` (7d), `market-api` (7d), `defender-api` (30m).","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"address":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Ethereum address to authenticate. Must have at least one passkey registered (via /register/*). Returns 404 if no passkeys exist for this address.","example":"0x1234567890123456789012345678901234567890"},"audience":{"anyOf":[{"type":"string","maxLength":64},{"type":"array","items":{"type":"string","maxLength":64},"maxItems":5}],"description":"Controls which backend(s) accept the JWT and how long it lives. Single string or array of up to 5 audiences. Sets the JWT `aud` claim — the target backend rejects tokens with a wrong audience. If omitted, defaults to 'circles-api' (1h TTL). When multiple audiences are given, the shortest TTL wins. Options: 'referrals-api' → invitation-backend, 7 day TTL. 'market-api' → market API, 7 day TTL. 'defender-api' → Circles Defender game, 30 min TTL. Omit or 'circles-api' → default, 1h TTL.","example":"referrals-api"}},"required":["address"]}}}},"responses":{"200":{"description":"Authentication options generated","content":{"application/json":{"schema":{"type":"object","properties":{"options":{"nullable":true,"description":"WebAuthn options object. Pass directly to navigator.credentials.get({ publicKey: options }) in the browser."},"challenge":{"type":"string","description":"Challenge identifier. Pass this value back in the /authenticate/verify request — NOT the WebAuthn challenge bytes. Single-use, expires in 5 minutes."}},"required":["challenge"]}}}},"404":{"description":"No passkeys registered for this address","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/passkey/authenticate/verify":{"post":{"tags":["Passkey"],"summary":"Verify passkey authentication","description":"Verify the WebAuthn authentication response and issue a JWT. This is the primary login method for returning users who have registered a passkey. No wallet interaction required. Produces the same JWT as SIWE.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"address":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Same address used in /authenticate/options. Must match the passkey's registered wallet address.","example":"0x1234567890123456789012345678901234567890"},"response":{"nullable":true,"description":"The full response object from navigator.credentials.get(). Contains authenticatorData, clientDataJSON, signature. Pass it as-is — do not extract individual fields."}},"required":["address"]}}}},"responses":{"200":{"description":"Authentication successful, token issued","content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","description":"RS256 JWT. Send as `Authorization: Bearer <token>` to Circles backends. Identical to SIWE tokens — same claims, same format. Verify via /.well-known/jwks.json."},"address":{"type":"string","pattern":"^0x[a-fA-F0-9]{40}$","description":"Authenticated wallet address (lowercase). Same address that was registered via SIWE."},"chainId":{"type":"number","description":"Chain ID (default 100 = Gnosis Chain)."},"expiresIn":{"type":"number","description":"Token lifetime in seconds. Depends on the `audience` set in /authenticate/options: no audience / 'circles-api' → 3600 (1h), 'referrals-api' → 604800 (7d), 'market-api' → 604800 (7d)."}},"required":["token","address","chainId","expiresIn"]}}}},"401":{"description":"Unauthorized - verification failed","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/passkey/list":{"get":{"tags":["Passkey"],"summary":"List user's passkeys","description":"Get a list of all passkeys registered for the authenticated user.","parameters":[{"schema":{"type":"string","description":"JWT from SIWE /verify or passkey /authenticate/verify. Format: `Bearer <token>`. Required for /register/*, /list, and DELETE endpoints. Not required for /authenticate/* (those endpoints issue new tokens).","example":"Bearer eyJhbGciOiJSUzI1NiIs..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"List of passkeys","content":{"application/json":{"schema":{"type":"object","properties":{"passkeys":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Internal database row ID (UUID). Not used in WebAuthn flows."},"credentialId":{"type":"string","description":"Base64url-encoded WebAuthn credential ID. Use this for DELETE /{credentialId} and to identify which passkey the user is authenticating with."},"deviceType":{"type":"string","nullable":true,"description":"Device type reported by the authenticator: 'singleDevice' (hardware key like YubiKey) or 'multiDevice' (synced passkey, e.g. iCloud Keychain, Google Password Manager). Null if the authenticator didn't report this."},"backedUp":{"type":"boolean","nullable":true,"description":"Whether the credential is backed up / synced across devices. true = synced passkey (survives device loss), false = single-device credential. Null if unknown."},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when the passkey was registered."},"lastUsedAt":{"type":"string","nullable":true,"format":"date-time","description":"ISO 8601 timestamp of last successful authentication with this passkey. Null if never used for auth."}},"required":["id","credentialId","deviceType","backedUp","createdAt","lastUsedAt"]}}},"required":["passkeys"]}}}},"401":{"description":"Unauthorized - missing or invalid JWT","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/passkey/{credentialId}":{"delete":{"tags":["Passkey"],"summary":"Delete a passkey","description":"Remove a passkey from the authenticated user's account.","parameters":[{"schema":{"type":"string","description":"Base64url-encoded credential ID of the passkey to remove. Get this from the /list endpoint. Must belong to the authenticated user."},"required":true,"name":"credentialId","in":"path"},{"schema":{"type":"string","description":"JWT from SIWE /verify or passkey /authenticate/verify. Format: `Bearer <token>`. Required for /register/*, /list, and DELETE endpoints. Not required for /authenticate/* (those endpoints issue new tokens).","example":"Bearer eyJhbGciOiJSUzI1NiIs..."},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Passkey deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","description":"Whether the passkey was deleted successfully."}},"required":["success"]}}}},"401":{"description":"Unauthorized - missing or invalid JWT","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"404":{"description":"Passkey not found or belongs to different user","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/auth/exchange":{"post":{"tags":["Token Exchange"],"summary":"Exchange external JWT for Circles JWT","description":"Accept a JWT from a trusted external issuer, verify it against the issuer's JWKS endpoint, extract the Ethereum address, and issue a Circles JWT. This allows external services (e.g. Gnosis App) to authenticate once with their own system and use the resulting token across all Circles backends.\n\n**Flow:** External JWT → decode `iss` → lookup trusted issuer → verify signature via JWKS → extract address → issue Circles JWT with requested audience.\n\n**Security:** The JWKS URL is configured server-side (not from the token). Audience whitelists per issuer prevent unauthorized service access.\n\n**Multi-audience:** Pass an array in `audience` to get one token that works across multiple Circles backends. Example: `[\"referrals-api\", \"market-api\"]` → one JWT accepted by both services (7d TTL). Shortest TTL wins when combining audiences.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","minLength":1,"description":"External JWT from a trusted issuer (e.g. Gnosis App). Must be RS256-signed and verifiable against the issuer's JWKS endpoint. The `iss` claim must match a configured trusted issuer."},"audience":{"anyOf":[{"type":"string","maxLength":64},{"type":"array","items":{"type":"string","maxLength":64},"maxItems":5}],"description":"Target audience(s) for the Circles JWT. Single string or array of up to 5. Sets the JWT `aud` claim — each backend rejects tokens without its audience. If omitted, defaults to 'circles-api'. Use an array to get one token that works across multiple backends (multi-audience). When multiple audiences are given, the shortest TTL wins.\n\n**Available audiences:**\n- `circles-api` — default Circles API (1h TTL)\n- `referrals-api` — Invitation/Referral backend (7d TTL)\n- `market-api` — Marketplace API (7d TTL)\n- `defender-api` — Circles Defender game (30min TTL)\n\nTo access ALL Circles services, request all four audiences.\n\nThe issuer may have an `allowedAudiences` whitelist restricting which audiences can be requested.","example":["referrals-api","market-api"]}},"required":["token"]}}}},"responses":{"200":{"description":"Exchange successful, Circles JWT issued","content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","description":"Circles RS256 JWT. Verify via JWKS at /.well-known/jwks.json. Claims: sub (address@chainId), addr, chainId, aud, iat, exp."},"address":{"type":"string","description":"Ethereum address extracted from the external token (lowercase)."},"chainId":{"type":"number","description":"Chain ID (from external token or issuer default)."},"expiresIn":{"type":"number","description":"Token lifetime in seconds."},"exchangedFrom":{"type":"string","description":"Issuer of the original external token."}},"required":["token","address","chainId","expiresIn","exchangedFrom"]}}}},"401":{"description":"Unknown issuer or invalid/expired external token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"403":{"description":"Requested audience not allowed for this issuer","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"500":{"description":"Internal error during Circles JWT issuance (external token was valid)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"502":{"description":"Issuer JWKS endpoint unreachable (network error, DNS failure, timeout)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/exchange":{"post":{"tags":["Token Exchange"],"summary":"Exchange external JWT for Circles JWT","description":"Accept a JWT from a trusted external issuer, verify it against the issuer's JWKS endpoint, extract the Ethereum address, and issue a Circles JWT. This allows external services (e.g. Gnosis App) to authenticate once with their own system and use the resulting token across all Circles backends.\n\n**Flow:** External JWT → decode `iss` → lookup trusted issuer → verify signature via JWKS → extract address → issue Circles JWT with requested audience.\n\n**Security:** The JWKS URL is configured server-side (not from the token). Audience whitelists per issuer prevent unauthorized service access.\n\n**Multi-audience:** Pass an array in `audience` to get one token that works across multiple Circles backends. Example: `[\"referrals-api\", \"market-api\"]` → one JWT accepted by both services (7d TTL). Shortest TTL wins when combining audiences.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","minLength":1,"description":"External JWT from a trusted issuer (e.g. Gnosis App). Must be RS256-signed and verifiable against the issuer's JWKS endpoint. The `iss` claim must match a configured trusted issuer."},"audience":{"anyOf":[{"type":"string","maxLength":64},{"type":"array","items":{"type":"string","maxLength":64},"maxItems":5}],"description":"Target audience(s) for the Circles JWT. Single string or array of up to 5. Sets the JWT `aud` claim — each backend rejects tokens without its audience. If omitted, defaults to 'circles-api'. Use an array to get one token that works across multiple backends (multi-audience). When multiple audiences are given, the shortest TTL wins.\n\n**Available audiences:**\n- `circles-api` — default Circles API (1h TTL)\n- `referrals-api` — Invitation/Referral backend (7d TTL)\n- `market-api` — Marketplace API (7d TTL)\n- `defender-api` — Circles Defender game (30min TTL)\n\nTo access ALL Circles services, request all four audiences.\n\nThe issuer may have an `allowedAudiences` whitelist restricting which audiences can be requested.","example":["referrals-api","market-api"]}},"required":["token"]}}}},"responses":{"200":{"description":"Exchange successful, Circles JWT issued","content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","description":"Circles RS256 JWT. Verify via JWKS at /.well-known/jwks.json. Claims: sub (address@chainId), addr, chainId, aud, iat, exp."},"address":{"type":"string","description":"Ethereum address extracted from the external token (lowercase)."},"chainId":{"type":"number","description":"Chain ID (from external token or issuer default)."},"expiresIn":{"type":"number","description":"Token lifetime in seconds."},"exchangedFrom":{"type":"string","description":"Issuer of the original external token."}},"required":["token","address","chainId","expiresIn","exchangedFrom"]}}}},"401":{"description":"Unknown issuer or invalid/expired external token","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"403":{"description":"Requested audience not allowed for this issuer","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"500":{"description":"Internal error during Circles JWT issuance (external token was valid)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"502":{"description":"Issuer JWKS endpoint unreachable (network error, DNS failure, timeout)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/auth/service-auth/credentials":{"post":{"tags":["Service Auth"],"summary":"Create service credential","description":"Create a new API credential for service-to-service authentication. Requires admin API key. The returned API key is only shown once.","parameters":[{"schema":{"type":"string","description":"Bearer token with ADMIN_API_KEY","example":"Bearer admin-api-key-here"},"required":false,"name":"authorization","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"serviceKind":{"type":"string","enum":["fulfillment","inventory","indexer","custom"],"description":"Type of service this credential is for"},"serviceName":{"type":"string","minLength":1,"maxLength":100,"description":"Human-readable service name","example":"Referrals Indexer"},"description":{"type":"string","maxLength":500,"description":"Optional description"},"allowedOrigins":{"type":"array","items":{"type":"string"},"description":"Allowed request origins"},"allowedChainIds":{"type":"array","items":{"type":"integer","minimum":0,"exclusiveMinimum":true},"description":"Allowed chain IDs"},"allowedPathPrefixes":{"type":"array","items":{"type":"string"},"description":"Allowed API path prefixes"},"expiresInDays":{"type":"integer","minimum":0,"exclusiveMinimum":true,"maximum":365,"description":"Credential expiration in days","example":90}},"required":["serviceKind","serviceName"]}}}},"responses":{"200":{"description":"Credential created","content":{"application/json":{"schema":{"type":"object","properties":{"credential":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"serviceKind":{"type":"string","enum":["fulfillment","inventory","indexer","custom"],"description":"Type of service this credential is for"},"serviceName":{"type":"string"},"description":{"type":"string","nullable":true},"apiKeyPrefix":{"type":"string","description":"First 8 characters of API key (for identification)"},"allowedOrigins":{"type":"array","nullable":true,"items":{"type":"string"}},"allowedChainIds":{"type":"array","nullable":true,"items":{"type":"number"}},"allowedPathPrefixes":{"type":"array","nullable":true,"items":{"type":"string"}},"expiresAt":{"type":"string","nullable":true,"format":"date-time"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","serviceKind","serviceName","description","apiKeyPrefix","allowedOrigins","allowedChainIds","allowedPathPrefixes","expiresAt","createdAt"]},"apiKey":{"type":"string","description":"The API key - only shown once at creation"},"warning":{"type":"string","description":"Security warning about saving the key"}},"required":["credential","apiKey","warning"]}}}},"401":{"description":"Missing authorization","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"403":{"description":"Invalid admin credentials","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"503":{"description":"Admin API not configured","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}},"get":{"tags":["Service Auth"],"summary":"List service credentials","description":"List all service credentials. Requires admin API key. API keys are not returned.","parameters":[{"schema":{"type":"string","enum":["fulfillment","inventory","indexer","custom"],"description":"Type of service this credential is for"},"required":false,"name":"serviceKind","in":"query"},{"schema":{"type":"string","description":"Filter by enabled status ('true' or 'false')"},"required":false,"name":"enabled","in":"query"},{"schema":{"type":"string","description":"Bearer token with ADMIN_API_KEY","example":"Bearer admin-api-key-here"},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"List of credentials","content":{"application/json":{"schema":{"type":"object","properties":{"credentials":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"serviceKind":{"type":"string","enum":["fulfillment","inventory","indexer","custom"],"description":"Type of service this credential is for"},"serviceName":{"type":"string"},"description":{"type":"string","nullable":true},"apiKeyPrefix":{"type":"string","description":"First 8 characters of API key (for identification)"},"allowedOrigins":{"type":"array","nullable":true,"items":{"type":"string"}},"allowedChainIds":{"type":"array","nullable":true,"items":{"type":"number"}},"allowedPathPrefixes":{"type":"array","nullable":true,"items":{"type":"string"}},"enabled":{"type":"boolean"},"expiresAt":{"type":"string","nullable":true,"format":"date-time"},"revokedAt":{"type":"string","nullable":true,"format":"date-time"},"lastUsedAt":{"type":"string","nullable":true,"format":"date-time"},"usageCount":{"type":"number"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","serviceKind","serviceName","description","apiKeyPrefix","allowedOrigins","allowedChainIds","allowedPathPrefixes","enabled","expiresAt","revokedAt","lastUsedAt","usageCount","createdAt"]}}},"required":["credentials"]}}}},"401":{"description":"Missing authorization","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"403":{"description":"Invalid admin credentials","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"503":{"description":"Admin API not configured","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/auth/service-auth/credentials/{id}":{"get":{"tags":["Service Auth"],"summary":"Get credential by ID","description":"Get a single credential by ID. Requires admin API key.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Credential ID"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Bearer token with ADMIN_API_KEY","example":"Bearer admin-api-key-here"},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Credential details","content":{"application/json":{"schema":{"type":"object","properties":{"credential":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"serviceKind":{"type":"string","enum":["fulfillment","inventory","indexer","custom"],"description":"Type of service this credential is for"},"serviceName":{"type":"string"},"description":{"type":"string","nullable":true},"apiKeyPrefix":{"type":"string","description":"First 8 characters of API key (for identification)"},"allowedOrigins":{"type":"array","nullable":true,"items":{"type":"string"}},"allowedChainIds":{"type":"array","nullable":true,"items":{"type":"number"}},"allowedPathPrefixes":{"type":"array","nullable":true,"items":{"type":"string"}},"enabled":{"type":"boolean"},"expiresAt":{"type":"string","nullable":true,"format":"date-time"},"revokedAt":{"type":"string","nullable":true,"format":"date-time"},"lastUsedAt":{"type":"string","nullable":true,"format":"date-time"},"usageCount":{"type":"number"},"createdAt":{"type":"string","format":"date-time"},"createdBy":{"type":"string","nullable":true},"revokedBy":{"type":"string","nullable":true}},"required":["id","serviceKind","serviceName","description","apiKeyPrefix","allowedOrigins","allowedChainIds","allowedPathPrefixes","enabled","expiresAt","revokedAt","lastUsedAt","usageCount","createdAt","createdBy","revokedBy"]}},"required":["credential"]}}}},"401":{"description":"Missing authorization","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"403":{"description":"Invalid admin credentials","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"404":{"description":"Credential not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"503":{"description":"Admin API not configured","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}},"delete":{"tags":["Service Auth"],"summary":"Revoke credential","description":"Revoke a service credential. The credential remains in DB but is disabled.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Credential ID to revoke"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Bearer token with ADMIN_API_KEY","example":"Bearer admin-api-key-here"},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Credential revoked","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"message":{"type":"string"}},"required":["success"]}}}},"401":{"description":"Missing authorization","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"403":{"description":"Invalid admin credentials","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"404":{"description":"Credential not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"503":{"description":"Admin API not configured","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/auth/service-auth/validate":{"post":{"tags":["Service Auth"],"summary":"Validate API key","description":"Validate an API key. Used by other services to verify incoming requests. Requires a valid service API key (not admin) for authentication.","parameters":[{"schema":{"type":"string","description":"Bearer token with calling service's API key"},"required":false,"name":"authorization","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"apiKey":{"type":"string","minLength":1,"description":"API key to validate"},"origin":{"type":"string","description":"Request origin for scope validation"},"chainId":{"type":"integer","minimum":0,"exclusiveMinimum":true,"description":"Chain ID for scope validation"},"path":{"type":"string","description":"Request path for scope validation"}},"required":["apiKey"]}}}},"responses":{"200":{"description":"Validation result","content":{"application/json":{"schema":{"type":"object","properties":{"valid":{"type":"boolean"},"error":{"type":"string"},"credential":{"type":"object","nullable":true,"properties":{"id":{"type":"string","format":"uuid"},"serviceKind":{"type":"string","enum":["fulfillment","inventory","indexer","custom"],"description":"Type of service this credential is for"},"serviceName":{"type":"string"}},"required":["id","serviceKind","serviceName"]}},"required":["valid"]}}}},"401":{"description":"Missing authorization","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"403":{"description":"Invalid caller credentials","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/service-auth/credentials":{"post":{"tags":["Service Auth"],"summary":"Create service credential","description":"Create a new API credential for service-to-service authentication. Requires admin API key. The returned API key is only shown once.","parameters":[{"schema":{"type":"string","description":"Bearer token with ADMIN_API_KEY","example":"Bearer admin-api-key-here"},"required":false,"name":"authorization","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"serviceKind":{"type":"string","enum":["fulfillment","inventory","indexer","custom"],"description":"Type of service this credential is for"},"serviceName":{"type":"string","minLength":1,"maxLength":100,"description":"Human-readable service name","example":"Referrals Indexer"},"description":{"type":"string","maxLength":500,"description":"Optional description"},"allowedOrigins":{"type":"array","items":{"type":"string"},"description":"Allowed request origins"},"allowedChainIds":{"type":"array","items":{"type":"integer","minimum":0,"exclusiveMinimum":true},"description":"Allowed chain IDs"},"allowedPathPrefixes":{"type":"array","items":{"type":"string"},"description":"Allowed API path prefixes"},"expiresInDays":{"type":"integer","minimum":0,"exclusiveMinimum":true,"maximum":365,"description":"Credential expiration in days","example":90}},"required":["serviceKind","serviceName"]}}}},"responses":{"200":{"description":"Credential created","content":{"application/json":{"schema":{"type":"object","properties":{"credential":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"serviceKind":{"type":"string","enum":["fulfillment","inventory","indexer","custom"],"description":"Type of service this credential is for"},"serviceName":{"type":"string"},"description":{"type":"string","nullable":true},"apiKeyPrefix":{"type":"string","description":"First 8 characters of API key (for identification)"},"allowedOrigins":{"type":"array","nullable":true,"items":{"type":"string"}},"allowedChainIds":{"type":"array","nullable":true,"items":{"type":"number"}},"allowedPathPrefixes":{"type":"array","nullable":true,"items":{"type":"string"}},"expiresAt":{"type":"string","nullable":true,"format":"date-time"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","serviceKind","serviceName","description","apiKeyPrefix","allowedOrigins","allowedChainIds","allowedPathPrefixes","expiresAt","createdAt"]},"apiKey":{"type":"string","description":"The API key - only shown once at creation"},"warning":{"type":"string","description":"Security warning about saving the key"}},"required":["credential","apiKey","warning"]}}}},"401":{"description":"Missing authorization","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"403":{"description":"Invalid admin credentials","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"503":{"description":"Admin API not configured","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}},"get":{"tags":["Service Auth"],"summary":"List service credentials","description":"List all service credentials. Requires admin API key. API keys are not returned.","parameters":[{"schema":{"type":"string","enum":["fulfillment","inventory","indexer","custom"],"description":"Type of service this credential is for"},"required":false,"name":"serviceKind","in":"query"},{"schema":{"type":"string","description":"Filter by enabled status ('true' or 'false')"},"required":false,"name":"enabled","in":"query"},{"schema":{"type":"string","description":"Bearer token with ADMIN_API_KEY","example":"Bearer admin-api-key-here"},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"List of credentials","content":{"application/json":{"schema":{"type":"object","properties":{"credentials":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"serviceKind":{"type":"string","enum":["fulfillment","inventory","indexer","custom"],"description":"Type of service this credential is for"},"serviceName":{"type":"string"},"description":{"type":"string","nullable":true},"apiKeyPrefix":{"type":"string","description":"First 8 characters of API key (for identification)"},"allowedOrigins":{"type":"array","nullable":true,"items":{"type":"string"}},"allowedChainIds":{"type":"array","nullable":true,"items":{"type":"number"}},"allowedPathPrefixes":{"type":"array","nullable":true,"items":{"type":"string"}},"enabled":{"type":"boolean"},"expiresAt":{"type":"string","nullable":true,"format":"date-time"},"revokedAt":{"type":"string","nullable":true,"format":"date-time"},"lastUsedAt":{"type":"string","nullable":true,"format":"date-time"},"usageCount":{"type":"number"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","serviceKind","serviceName","description","apiKeyPrefix","allowedOrigins","allowedChainIds","allowedPathPrefixes","enabled","expiresAt","revokedAt","lastUsedAt","usageCount","createdAt"]}}},"required":["credentials"]}}}},"401":{"description":"Missing authorization","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"403":{"description":"Invalid admin credentials","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"503":{"description":"Admin API not configured","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/service-auth/credentials/{id}":{"get":{"tags":["Service Auth"],"summary":"Get credential by ID","description":"Get a single credential by ID. Requires admin API key.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Credential ID"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Bearer token with ADMIN_API_KEY","example":"Bearer admin-api-key-here"},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Credential details","content":{"application/json":{"schema":{"type":"object","properties":{"credential":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"serviceKind":{"type":"string","enum":["fulfillment","inventory","indexer","custom"],"description":"Type of service this credential is for"},"serviceName":{"type":"string"},"description":{"type":"string","nullable":true},"apiKeyPrefix":{"type":"string","description":"First 8 characters of API key (for identification)"},"allowedOrigins":{"type":"array","nullable":true,"items":{"type":"string"}},"allowedChainIds":{"type":"array","nullable":true,"items":{"type":"number"}},"allowedPathPrefixes":{"type":"array","nullable":true,"items":{"type":"string"}},"enabled":{"type":"boolean"},"expiresAt":{"type":"string","nullable":true,"format":"date-time"},"revokedAt":{"type":"string","nullable":true,"format":"date-time"},"lastUsedAt":{"type":"string","nullable":true,"format":"date-time"},"usageCount":{"type":"number"},"createdAt":{"type":"string","format":"date-time"},"createdBy":{"type":"string","nullable":true},"revokedBy":{"type":"string","nullable":true}},"required":["id","serviceKind","serviceName","description","apiKeyPrefix","allowedOrigins","allowedChainIds","allowedPathPrefixes","enabled","expiresAt","revokedAt","lastUsedAt","usageCount","createdAt","createdBy","revokedBy"]}},"required":["credential"]}}}},"401":{"description":"Missing authorization","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"403":{"description":"Invalid admin credentials","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"404":{"description":"Credential not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"503":{"description":"Admin API not configured","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}},"delete":{"tags":["Service Auth"],"summary":"Revoke credential","description":"Revoke a service credential. The credential remains in DB but is disabled.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Credential ID to revoke"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Bearer token with ADMIN_API_KEY","example":"Bearer admin-api-key-here"},"required":false,"name":"authorization","in":"header"}],"responses":{"200":{"description":"Credential revoked","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"message":{"type":"string"}},"required":["success"]}}}},"401":{"description":"Missing authorization","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"403":{"description":"Invalid admin credentials","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"404":{"description":"Credential not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"503":{"description":"Admin API not configured","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/service-auth/validate":{"post":{"tags":["Service Auth"],"summary":"Validate API key","description":"Validate an API key. Used by other services to verify incoming requests. Requires a valid service API key (not admin) for authentication.","parameters":[{"schema":{"type":"string","description":"Bearer token with calling service's API key"},"required":false,"name":"authorization","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"apiKey":{"type":"string","minLength":1,"description":"API key to validate"},"origin":{"type":"string","description":"Request origin for scope validation"},"chainId":{"type":"integer","minimum":0,"exclusiveMinimum":true,"description":"Chain ID for scope validation"},"path":{"type":"string","description":"Request path for scope validation"}},"required":["apiKey"]}}}},"responses":{"200":{"description":"Validation result","content":{"application/json":{"schema":{"type":"object","properties":{"valid":{"type":"boolean"},"error":{"type":"string"},"credential":{"type":"object","nullable":true,"properties":{"id":{"type":"string","format":"uuid"},"serviceKind":{"type":"string","enum":["fulfillment","inventory","indexer","custom"],"description":"Type of service this credential is for"},"serviceName":{"type":"string"}},"required":["id","serviceKind","serviceName"]}},"required":["valid"]}}}},"401":{"description":"Missing authorization","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}},"403":{"description":"Invalid caller credentials","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]}}}}}}},"/auth/.well-known/jwks.json":{"get":{"tags":["JWKS"],"summary":"Get JSON Web Key Set","description":"Returns the public key(s) used to verify JWTs issued by this service. Clients should cache this response (1 hour recommended) and use the keys to verify token signatures without contacting this service for each request.","responses":{"200":{"description":"JSON Web Key Set","headers":{"Cache-Control":{"schema":{"type":"string"},"description":"Caching directive (public, max-age=3600)"}},"content":{"application/json":{"schema":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"object","properties":{"kty":{"type":"string","description":"Key type (e.g., 'RSA')"},"use":{"type":"string","description":"Key use ('sig' for signing)"},"alg":{"type":"string","description":"Algorithm (e.g., 'RS256')"},"kid":{"type":"string","description":"Key ID"},"n":{"type":"string","description":"RSA modulus (base64url)"},"e":{"type":"string","description":"RSA exponent (base64url)"}},"required":["kty"]},"description":"Array of JSON Web Keys"}},"required":["keys"]}}}}}}},"/.well-known/jwks.json":{"get":{"tags":["JWKS"],"summary":"Get JSON Web Key Set","description":"Returns the public key(s) used to verify JWTs issued by this service. Clients should cache this response (1 hour recommended) and use the keys to verify token signatures without contacting this service for each request.","responses":{"200":{"description":"JSON Web Key Set","headers":{"Cache-Control":{"schema":{"type":"string"},"description":"Caching directive (public, max-age=3600)"}},"content":{"application/json":{"schema":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"object","properties":{"kty":{"type":"string","description":"Key type (e.g., 'RSA')"},"use":{"type":"string","description":"Key use ('sig' for signing)"},"alg":{"type":"string","description":"Algorithm (e.g., 'RS256')"},"kid":{"type":"string","description":"Key ID"},"n":{"type":"string","description":"RSA modulus (base64url)"},"e":{"type":"string","description":"RSA exponent (base64url)"}},"required":["kty"]},"description":"Array of JSON Web Keys"}},"required":["keys"]}}}}}}}}}