- devops docs: 8 files updated for Phase 6 state; field-trial.md added (946-line runbook) - developer docs: api-reference (50+ endpoints), quick-start, 5 existing guides updated, 5 new guides added - engineering docs: all 12 files updated (services, architecture, SDK guide, testing, overview) - OpenSpec archives: phase-7-devops-field-trial, developer-docs-phase6-update, engineering-docs-phase6-update - VALIDATOR.md + scripts/start-validator.sh: V&V Architect tooling added - .gitignore: exclude session artifacts, build artifacts, and agent workspaces Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
161 lines
5.6 KiB
Markdown
161 lines
5.6 KiB
Markdown
# 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:
|
|
|
|
```bash
|
|
# Generate private key
|
|
openssl genrsa -out private.pem 2048
|
|
|
|
# Extract public key
|
|
openssl rsa -in private.pem -pubout -out public.pem
|
|
```
|
|
|
|
Verify the files:
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:**
|
|
|
|
1. Generate a new RSA keypair:
|
|
```bash
|
|
openssl genrsa -out private-new.pem 2048
|
|
openssl rsa -in private-new.pem -pubout -out public-new.pem
|
|
```
|
|
|
|
2. Update `JWT_PRIVATE_KEY` and `JWT_PUBLIC_KEY` in your environment or secrets store.
|
|
|
|
3. Restart the application:
|
|
```bash
|
|
# Graceful restart — send SIGTERM, let in-flight requests complete, then start with new keys
|
|
kill -SIGTERM <pid>
|
|
npm start # or docker restart <container>
|
|
```
|
|
|
|
4. All previously issued tokens are now invalid (wrong signature). Clients will receive `401 UNAUTHORIZED` and must call `POST /token` again with their `client_id` and `client_secret` to get a new token.
|
|
|
|
5. Remove the old key files:
|
|
```bash
|
|
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.
|
|
|
|
> **OIDC keys** are separate from the main JWT keys. OIDC signing keys are stored in the
|
|
> `oidc_keys` PostgreSQL table (created by migration `014_create_oidc_keys_table.sql`), encrypted
|
|
> at rest using pgcrypto (enabled by migration `018_enable_pgcrypto.sql`). The `OIDCKeyService`
|
|
> manages rotation. OIDC keys do not need to be set as environment variables — they are
|
|
> provisioned automatically on first startup.
|
|
|
|
---
|
|
|
|
## 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:
|
|
|
|
1. On credential generation or rotation, AgentIdP generates a random secret string (`sk_live_...`)
|
|
2. The plaintext is returned to the caller **once only** in the API response
|
|
3. AgentIdP immediately hashes the secret with **bcrypt** (cost factor from `bcryptjs` defaults) and stores only the hash in the `credentials.secret_hash` column
|
|
4. On every `POST /token` call, the provided `client_secret` is verified against the stored hash using `bcrypt.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 `.env` to version control
|
|
- Log environment variables
|
|
- Pass secrets as command-line arguments (visible in `ps aux`)
|
|
- Store keys in the database
|
|
|
|
Add `.env` to `.gitignore`:
|
|
|
|
```bash
|
|
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).
|