Adds the full devops-documentation OpenSpec change implementation. Separate from docs/developers/ — serves a different audience (operators, not API consumers). docs/devops/: - README.md — index and system overview - architecture.md — components, ports, data flow, Redis key patterns - environment-variables.md — all 7 env vars (required + optional, formats, .env example) - database.md — 4-table schema, indexes, constraints, migration runner - local-development.md — docker-compose setup, health checks, startup, Dockerfile gap noted - security.md — RSA key generation/rotation, CORS, bcrypt, secret storage guidance - operations.md — startup order, graceful shutdown, log reference, troubleshooting QA gates: 48/48 tasks complete. All env vars verified against source. All table names verified against migrations. All ports verified against docker-compose.yml. All internal links resolve. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5.2 KiB
Security
Security configuration for AgentIdP — JWT key management, CORS, and secret storage.
JWT Key Management
AgentIdP uses RS256 (RSA + SHA-256) to sign and verify JWT access tokens. This asymmetric scheme means:
- The private key signs tokens — must be kept secret, known only to the server
- The public key verifies tokens — can be shared with any system that needs to validate tokens
Generate a keypair
Generate a 2048-bit RSA keypair:
# Generate private key
openssl genrsa -out private.pem 2048
# Extract public key
openssl rsa -in private.pem -pubout -out public.pem
Verify the files:
# Confirm private key is valid RSA
openssl rsa -in private.pem -check -noout
# Expected: RSA key ok
# Confirm public key is readable
openssl rsa -in public.pem -pubin -noout -text | head -5
Load keys into environment
Option 1 — Inline in .env (development only)
Encode newlines as \n and wrap in double quotes:
echo "JWT_PRIVATE_KEY=\"$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' private.pem)\"" >> .env
echo "JWT_PUBLIC_KEY=\"$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' public.pem)\"" >> .env
Option 2 — Load from file at runtime (recommended for production)
In the startup script, read the key files and export as environment variables before running the server:
export JWT_PRIVATE_KEY="$(cat /run/secrets/jwt-private.pem)"
export JWT_PUBLIC_KEY="$(cat /run/secrets/jwt-public.pem)"
npm start
With Docker secrets or a secrets manager (Vault, AWS Secrets Manager), mount the key as a file and read it this way.
Key rotation
Rotating the JWT keys invalidates all currently active tokens — every authenticated request will fail until clients re-authenticate. Plan rotation for low-traffic windows.
Rotation procedure:
-
Generate a new RSA keypair:
openssl genrsa -out private-new.pem 2048 openssl rsa -in private-new.pem -pubout -out public-new.pem -
Update
JWT_PRIVATE_KEYandJWT_PUBLIC_KEYin your environment or secrets store. -
Restart the application:
# Graceful restart — send SIGTERM, let in-flight requests complete, then start with new keys kill -SIGTERM <pid> npm start # or docker restart <container> -
All previously issued tokens are now invalid (wrong signature). Clients will receive
401 UNAUTHORIZEDand must callPOST /tokenagain with theirclient_idandclient_secretto get a new token. -
Remove the old key files:
rm private-old.pem public-old.pem
Important: There is no grace period or dual-key support in Phase 1. All tokens issued with the old private key are immediately rejected after rotation. If zero-downtime key rotation is required, it is a Phase 2 feature.
CORS Configuration
Cross-Origin Resource Sharing is configured via the CORS_ORIGIN environment variable.
| Value | Behaviour |
|---|---|
* (default) |
All origins permitted — appropriate for a public API |
https://app.example.ai |
Only the specified origin permitted |
Set in .env:
CORS_ORIGIN=https://app.example.ai
The CORS header is set by the cors middleware applied globally in src/app.ts. Credentials (cookies) are not used — all auth is Bearer token.
For production deployments where the API is only called server-to-server (agent to AgentIdP), setting CORS_ORIGIN to a specific origin or removing browser-facing CORS entirely is recommended.
Client Secret Storage
Client secrets are never stored in plaintext. The flow:
- On credential generation or rotation, AgentIdP generates a random secret string (
sk_live_...) - The plaintext is returned to the caller once only in the API response
- AgentIdP immediately hashes the secret with bcrypt (cost factor from
bcryptjsdefaults) and stores only the hash in thecredentials.secret_hashcolumn - On every
POST /tokencall, the providedclient_secretis verified against the stored hash usingbcrypt.compare()
Implication: If a client loses their client_secret, it cannot be recovered. They must rotate the credential to get a new one.
Secret Storage Guidance
| Environment | Recommendation |
|---|---|
| Local development | .env file, not committed to git |
| CI/CD | Environment variables injected by the CI platform (GitHub Actions secrets, GitLab CI variables, etc.) |
| Production (Docker) | Docker secrets or bind-mounted files from a secrets manager |
| Production (cloud) | AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault (Phase 2) |
Never:
- Commit
.envto version control - Log environment variables
- Pass secrets as command-line arguments (visible in
ps aux) - Store keys in the database
Add .env to .gitignore:
echo ".env" >> .gitignore
echo "*.pem" >> .gitignore
Token Lifetime
JWT access tokens expire after 3600 seconds (1 hour). This is hardcoded in src/utils/jwt.ts. There is no refresh token — clients must re-authenticate via POST /token when the token expires.
The 1-hour lifetime is a balance between security (short-lived tokens limit exposure if stolen) and operational load (clients don't need to authenticate every few minutes).