A Security Token Service implementing the Phantom Token Pattern for secure session management and internal JWT issuance.
Specification: ID052 - Session Service (Janus)
- Overview
- Architecture
- How It Works
- Components
- JWKS & Key Rotation
- Configuration
- Getting Started
- API Reference
- Security
The Secure Token Service (STS) is a Policy Enforcement Point (PEP) and token translation service that sits between external clients and internal microservices. It implements the Phantom Token Pattern and OAuth2 token exchange to provide:
- Session Management: Exchanges OIDC tokens for opaque HttpOnly cookies
- Token Translation: Converts opaque session IDs to signed internal JWTs
- M2M Token Exchange: Swaps upstream IdP client credentials access tokens (e.g., Ory Hydra) for signed internal STS JWTs with TTL clamping and synthetic machine identities
- JWT Issuance: Mints ES256-signed JWTs for service-to-service communication
- Key Management: PostgreSQL-backed JWKS with atomic key rotation
- Session Storage: Valkey/Redis-based session persistence
graph LR
A[Browser/Client] -->|1. OIDC Login| B[Janus STS]
B -->|2. HttpOnly Cookie| A
A -->|3. Cookie + Request| C[API Gateway]
C -->|4. gRPC: ExchangeSession| B
B -->|5. Internal JWT| C
C -->|6. JWT in Header| D[Microservices]
D -->|7. Verify with JWKS| B
B -.->|Session Data| E[(Valkey)]
B -.->|JWKS Keys| F[(PostgreSQL)]
style B fill:#4285f4,stroke:#333,stroke-width:3px,color:#fff
style E fill:#dc143c,stroke:#333,stroke-width:2px,color:#fff
style F fill:#336791,stroke:#333,stroke-width:2px,color:#fff
graph TB
subgraph "External Zone"
Browser[Browser/Client]
end
subgraph "Janus STS (PEP)"
HTTP[HTTP Server<br/>Port 8080]
GRPC[gRPC Server<br/>Port 9090]
KM[Key Manager]
SM[Session Manager]
HTTP --> SM
GRPC --> SM
GRPC --> KM
end
subgraph "Storage Layer"
Valkey[(Valkey<br/>Sessions)]
Postgres[(PostgreSQL<br/>JWKS)]
end
subgraph "Internal Services"
MS1[Microservice A]
MS2[Microservice B]
MS3[Microservice N]
end
Browser -->|OIDC Flow| HTTP
Browser -->|Requests with Cookie| MS1
MS1 -->|gRPC: ExchangeSession| GRPC
MS2 -->|gRPC: ExchangeSession| GRPC
MS3 -->|GET /.well-known/jwks.json| HTTP
SM -.->|Read/Write Sessions| Valkey
KM -.->|Read JWKS| Postgres
style HTTP fill:#4285f4,stroke:#333,stroke-width:2px,color:#fff
style GRPC fill:#0f9d58,stroke:#333,stroke-width:2px,color:#fff
style KM fill:#f4b400,stroke:#333,stroke-width:2px
style SM fill:#db4437,stroke:#333,stroke-width:2px,color:#fff
sequenceDiagram
participant Browser
participant Janus
participant OIDC as OIDC Provider<br/>(e.g., Kratos)
participant Valkey
Browser->>Janus: GET /auth/login?return_to=/dashboard
Janus->>Janus: Generate state & nonce
Janus->>Browser: Set encrypted cookies (state, nonce)
Janus->>Browser: 302 Redirect to OIDC Provider
Browser->>OIDC: User authenticates
OIDC->>Browser: 302 Redirect with auth code
Browser->>Janus: GET /auth/callback?code=...&state=...
Janus->>Janus: Validate state
Janus->>OIDC: Exchange code for tokens
OIDC->>Janus: ID Token + Access Token
Janus->>Janus: Verify ID Token (nonce, signature)
Janus->>Valkey: Store session (ID → tokens + user info)
Valkey-->>Janus: OK
Janus->>Browser: Set HttpOnly Cookie (session_id)
Janus->>Browser: 302 Redirect to /dashboard
sequenceDiagram
participant Client as Browser
participant Gateway as API Gateway
participant Janus as Janus STS
participant Valkey
participant Postgres
participant Service as Microservice
Client->>Gateway: Request + Cookie (session_id)
Gateway->>Janus: gRPC: ExchangeSession(session_id)
Janus->>Valkey: Get session by ID
Valkey-->>Janus: Session data (user_id, upstream_token, ...)
Janus->>Postgres: GetLatestActiveKey()
Postgres-->>Janus: Private key + KID
Janus->>Janus: Mint JWT (ES256)<br/>Claims: sub, iss, aud, exp, custom
Janus-->>Gateway: Internal JWT + expires_in
Gateway->>Service: Request + Authorization: Bearer <JWT>
Service->>Janus: GET /.well-known/jwks.json (cached)
Janus->>Postgres: GetAllPublicKeys()
Postgres-->>Janus: Active + retired keys
Janus-->>Service: JWK Set
Service->>Service: Verify JWT signature using JWKS
Service-->>Gateway: Response
Gateway-->>Client: Response
sequenceDiagram
participant Admin
participant CLI as rotate-key CLI
participant Postgres
Admin->>CLI: ./bin/sts rotate-key
CLI->>CLI: Generate new ECDSA P-256 key pair
CLI->>CLI: Create JSONB entry<br/>(kid, public_pem, private_pem)
CLI->>Postgres: BEGIN TRANSACTION
CLI->>Postgres: UPDATE hydra_jwk SET sid='public.retired'<br/>WHERE sid='public'
CLI->>Postgres: INSERT new key with sid='public'
CLI->>Postgres: COMMIT
Postgres-->>CLI: Success
CLI->>Postgres: GetLatestActiveKey() [verify]
Postgres-->>CLI: New key
CLI-->>Admin: ✓ Rotation complete<br/>New KID: janus-key-xyz<br/>Old keys moved to retired set
Note over Postgres: JWKS endpoint now returns:<br/>- 1 active key (signing)<br/>- N retired keys (verification)
Endpoints:
GET /auth/login- Initiates authentication flow (supports?provider=oidc|openid)GET /auth/callback- OIDC callback handlerGET /auth/openid/callback- Ubuntu One OpenID 2.0 callback handlerPOST /auth/logout- Session terminationGET /auth/sessions- List user sessionsGET /.well-known/jwks.json- Public JWKS endpoint (all active + retired keys)
Service: sts.v1.SecurityTokenService
Features:
- Reflection Enabled: Service discovery via gRPC reflection (no proto files needed for clients)
- Cookie-Based Authentication: Accepts encoded session cookies for enhanced security
- Session-to-JWT exchange (Phantom Token Pattern)
- M2M token exchange for OAuth2 client credentials access tokens (with TTL clamping and synthetic identity)
- Bulk user session revocation
Methods:
ExchangeSession(session_cookie) → internal_jwt- Decodes session cookie and exchanges for JWTExchangeToken(token) → internal_jwt- Validates upstream IdP access token (e.g., Ory Hydra) against pre-cached JWKS, clamps TTL, and mints internal JWTRevokeUserSessions(user_id) → success- Bulk session revocation
Session Cookie Flow:
- Client receives HttpOnly session cookie from HTTP authentication endpoint
- Client sends encoded cookie to gRPC service
- Service decodes cookie to extract session ID
- Service retrieves session from Valkey
- Service mints internal JWT with custom claims
- Client uses JWT internally
M2M Token Exchange Flow:
- Client authenticates via upstream IdP (e.g., Ory Hydra Client Credentials flow) to obtain access token
- Client or API Gateway sends raw access token to
ExchangeTokenRPC - STS validates token against preemptively cached upstream JWKS (RS256)
- STS validates expiration (rejects if remaining lifetime < 60s) and clamps internal TTL to upstream validity
- STS mints internal ES256 JWT with
sub: <client_id>and syntheticemail: <client_id>@serviceaccount.local - Downstream microservices verify STS JWT using STS JWKS (
/.well-known/jwks.json)
Usage with grpcurl:
# List all services (reflection)
grpcurl -plaintext localhost:9090 list
# Describe a service
grpcurl -plaintext localhost:9090 describe sts.v1.SecurityTokenService
# Exchange session (requires encoded cookie)
grpcurl -plaintext -d '{"session_cookie": "encoded-cookie-value"}' \
localhost:9090 sts.v1.SecurityTokenService/ExchangeSession
# Exchange upstream IdP access token (M2M)
grpcurl -plaintext -d '{"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."}' \
localhost:9090 sts.v1.SecurityTokenService/ExchangeToken
# Revoke user sessions
grpcurl -plaintext -d '{"user_id": "user-123"}' \
localhost:9090 sts.v1.SecurityTokenService/RevokeUserSessionsTesting: See internal/grpc/server_test.go for comprehensive unit tests
Responsibilities:
- Generate ECDSA P-256 key pairs on first boot
- Fetch latest active key from PostgreSQL for signing
- Mint ES256-signed JWTs with custom claims
- Provide JWKS for verification
Key Change: MintToken() always fetches the latest active key from the database before signing, ensuring zero-downtime rotation.
Implementation: Valkey/Redis-backed storage
Session Data:
type Session struct {
ID string
UserID string
UpstreamToken string // Encrypted OIDC access token
IDTokenRaw string // Original ID token
ExpiresAt time.Time
CreatedAt time.Time
}Database: PostgreSQL with JSONB storage
Methods:
GetLatestActiveKey()- Fetch newest key from"public"setGetAllPublicKeys()- Return all keys from"public"+"public.retired"setsRotateKey(newKey)- Atomic transaction: retire old keys, activate new keySaveKey(key)- Store new key with JSONB dataDeleteKey(sid, kid)- Remove key (cleanup)
CREATE TABLE hydra_jwk (
sid VARCHAR(255) NOT NULL, -- "public" or "public.retired"
kid VARCHAR(255) NOT NULL, -- Key ID (UUID-based)
version INTEGER NOT NULL DEFAULT 0,
keydata JSONB NOT NULL, -- Complete JWK + PEM data
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (sid, kid)
);
CREATE INDEX hydra_jwk_idx_id ON hydra_jwk (sid);
CREATE INDEX hydra_jwk_kid_idx ON hydra_jwk USING GIN (keydata);{
"kty": "EC",
"kid": "janus-key-a1b2c3d4",
"use": "sig",
"alg": "ES256",
"private_pem": "-----BEGIN EC PRIVATE KEY-----\n...",
"public_pem": "-----BEGIN PUBLIC KEY-----\n..."
}Automated rotation (recommended schedule: every 90 days):
./bin/sts rotate-keyWhat happens:
- Generates new ECDSA P-256 key pair
- Moves all active keys to
"public.retired"set - Inserts new key into
"public"set - Both keys available via JWKS endpoint during rotation period
- JWTs minted with new key immediately
- Old JWTs still valid until expiry (verifiable with retired keys)
Zero Downtime: Services continue to verify old JWTs while new tokens use the new key.
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
postgres://localhost:5432/sts?sslmode=disable |
PostgreSQL connection for JWKS storage |
CACHE_ADDR |
localhost:6379 |
Valkey/Redis address for sessions |
CACHE_PASSWORD |
"" |
Cache authentication password |
CACHE_DB |
0 |
Cache database number |
HTTP_PORT |
8080 |
HTTP server port |
GRPC_PORT |
9090 |
gRPC server port |
JWT_ISSUER |
session-service |
JWT iss claim |
JWT_AUDIENCE |
internal-services |
JWT aud claim |
JWT_EXPIRY |
3600 |
JWT expiry in seconds |
DEFAULT_AUTH_PROVIDER |
oidc |
Default provider when provider query parameter is omitted (oidc or openid) |
OIDC_PROVIDER_URL |
- | OIDC provider discovery URL |
OIDC_CLIENT_ID |
- | OAuth2 client ID |
OIDC_CLIENT_SECRET |
- | OAuth2 client secret |
OIDC_REDIRECT_URL |
http://localhost:8080/auth/callback |
OAuth2 redirect URI |
UBUNTU_ONE_OPENID_URL |
https://login.ubuntu.com/+openid |
Ubuntu One OpenID 2.0 endpoint |
UBUNTU_ONE_REALM |
http://localhost:8080 |
OpenID trust realm matching service URL |
ALLOWED_RETURN_TO_HOSTS |
- | Comma-separated list of additional trusted hosts allowed for return_to redirection |
COOKIE_HASH_KEY |
- | 64-byte hex key for HMAC (required) |
COOKIE_BLOCK_KEY |
- | 32-byte hex key for AES (required) |
HYDRA_JWKS_URL |
- | Upstream Hydra JWKS URL for M2M verification (defaults to ${OIDC_PROVIDER_URL}/.well-known/jwks.json) |
JWKS_CACHE_TTL |
600 |
Preemptive JWKS cache refresh TTL in seconds |
This project uses .env files for configuration:
.env.example: Template with placeholder values (tracked in git).env.local: Local development secrets (git-ignored, do NOT commit).env.secrets/.env.*.local: Alternative patterns for secrets (all git-ignored)
Workflow:
# 1. Copy template
cp .env.example .env.local
# 2. Edit with your values
nano .env.local
# 3. Docker Compose automatically loads .env.local
docker-compose up -dSecurity: Ensure .env.local is in .gitignore and never commit credentials to version control.
- Go 1.26+ (for local development)
- Docker & Docker Compose (for containerized deployment)
- Kubernetes cluster (for Skaffold deployment)
- Skaffold (optional, for Kubernetes development)
- PostgreSQL 16+ (provided via Docker Compose or external)
- Valkey 9+ or Redis 7+ (provided via Docker Compose or external)
Docker Compose provides the fastest way to run all services locally.
# Clone the repository
git clone https://github.com/canonical/secure-token-service
cd secure-token-service
# Copy the environment template
cp .env.example .env.local
# Edit .env.local with your OIDC provider credentials
nano .env.localImportant Variables to Update in .env.local:
OIDC_PROVIDER_URL: Your OIDC provider's discovery URL (e.g.,https://accounts.google.com)OIDC_CLIENT_ID: OAuth2 client ID from your providerOIDC_CLIENT_SECRET: OAuth2 client secret from your providerOIDC_SCOPES: Comma-separated scopes (default:openid,profile,email,offline_access)OIDC_REDIRECT_URL: Your application's callback URLDEFAULT_AUTH_PROVIDER: Primary authentication provider (oidcoropenid, default:oidc)UBUNTU_ONE_OPENID_URL: Ubuntu One OpenID 2.0 endpoint (default:https://login.ubuntu.com/+openid)UBUNTU_ONE_REALM: Trust root realm for OpenID matching STS base URL (default:http://localhost:8080)
Security Note: .env.local is git-ignored and contains sensitive credentials. Never commit it to version control.
# Start all services (PostgreSQL, Valkey, STS)
docker-compose up -d
# View logs
docker-compose logs -f secure-token-service
# Check service health
docker-compose psImportant: Migrations must be run manually before first use.
# Run migrations inside the container
docker-compose exec secure-token-service ./bin/sts migrate
# Or run locally if you have access to the database
export DATABASE_URL="postgres://postgres:password@localhost:5432/sts?sslmode=disable"
./bin/sts migrate# Check JWKS endpoint
curl http://localhost:8080/.well-known/jwks.json
# Check gRPC server (requires grpcurl)
grpcurl -plaintext localhost:9090 list
# View PostgreSQL JWKS table
docker-compose exec postgres psql -U postgres -d sts -c "SELECT sid, kid, created_at FROM hydra_jwk;"# Enter the container
docker-compose exec secure-token-service sh
# Rotate the JWKS
./bin/sts rotate-key
# Exit container
exit# Stop all services
docker-compose down
# Stop and remove volumes (CAUTION: destroys data)
docker-compose down -vServices Available:
- HTTP Server:
http://localhost:8080 - gRPC Server:
localhost:9090 - PostgreSQL:
localhost:5432(user:postgres, password:password, database:sts) - Valkey:
localhost:6379
Skaffold provides continuous development workflow with Kubernetes.
# Install Skaffold
curl -Lo skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64
chmod +x skaffold
sudo mv skaffold /usr/local/bin
# Verify Kubernetes cluster access
kubectl cluster-info
# Create namespace
kubectl create namespace sts-dev
kubectl config set-context --current --namespace=sts-devIf your container image is hosted on GitHub Container Registry (GHCR) as a private image, create an image pull secret:
# Create a GitHub Personal Access Token (PAT) with read:packages scope
# Go to: https://github.com/settings/tokens/new
# Select scope: read:packages
# Create the image pull secret
kubectl create secret docker-registry ghcr-credentials \
--docker-server=ghcr.io \
--docker-username=YOUR_GITHUB_USERNAME \
--docker-password=YOUR_GITHUB_PAT \
--docker-email=YOUR_EMAIL \
--namespace=sts-dev
# Verify secret was created
kubectl get secret ghcr-credentials -n sts-devAlternative: Using a YAML manifest (see k8s/ghcr-secret.yaml):
# Encode your Docker config
cat > /tmp/docker-config.json << EOF
{
"auths": {
"ghcr.io": {
"username": "YOUR_GITHUB_USERNAME",
"password": "YOUR_GITHUB_PAT",
"email": "YOUR_EMAIL"
}
}
}
EOF
# Base64 encode it
DOCKER_CONFIG=$(cat /tmp/docker-config.json | base64 -w0)
# Create secret YAML
cat > k8s/ghcr-secret.yaml << EOF
apiVersion: v1
kind: Secret
metadata:
name: ghcr-credentials
namespace: sts-dev
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: ${DOCKER_CONFIG}
EOF
# Apply the secret
kubectl apply -f k8s/ghcr-secret.yaml
# Clean up temporary file
rm /tmp/docker-config.jsonNote: The service account (k8s/serviceaccount.yaml) is already configured to reference this secret via imagePullSecrets.
Update Kubernetes secrets with your OIDC configuration:
# Edit k8s/secret.yaml with your credentials
nano k8s/secret.yaml
# Base64 encode your values
echo -n "your-oidc-client-id" | base64
echo -n "your-oidc-client-secret" | base64
echo -n "your-cookie-hash-key" | base64
echo -n "your-cookie-block-key" | base64# Deploy with default profile (PostgreSQL + Valkey + STS)
skaffold dev
# Deploy with Istio profile (includes service mesh)
skaffold dev -p istio
# Build and deploy for production
skaffold runWhat Skaffold Does:
- Builds Docker image from Dockerfile
- Deploys PostgreSQL via Helm chart (Bitnami) with persistent storage
- Deploys Valkey via Helm chart (Bitnami)
- Applies Kubernetes manifests from
k8s/directory - Watches for code changes and automatically rebuilds/redeploys
- Streams logs from all pods
Database Credentials (configured in k8s/secret.yaml):
- Username:
stsuser - Password:
stspassword(⚠️ CHANGE IN PRODUCTION!) - Database:
sts - Service:
postgresql:5432 - Connection String:
postgres://stsuser:stspassword@postgresql:5432/sts?sslmode=disable
Important: After deployment, run migrations before the service can function:
# Wait for PostgreSQL to be ready
kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=postgresql --timeout=120s
# Run migrations
kubectl exec -it deployment/secure-token-service -- ./bin/sts migrate
# Verify migration
kubectl exec -it deployment/secure-token-service -- sh -c \
'psql "$DATABASE_URL" -c "SELECT sid, kid, created_at FROM hydra_jwk;"'# Port-forward HTTP server
kubectl port-forward svc/secure-token-service 8080:8080
# Port-forward gRPC server
kubectl port-forward svc/secure-token-service 9090:9090
# Access JWKS endpoint
curl http://localhost:8080/.well-known/jwks.json# View STS logs
kubectl logs -f deployment/secure-token-service
# View Valkey logs
kubectl logs -f deployment/valkey
# View all logs
skaffold dev --tail# Delete resources
skaffold delete
# Or manually
kubectl delete -f k8s/
helm uninstall valkeyIstio Profile Features:
- Ambient mesh mode (sidecarless)
- Gateway for HTTP traffic exposure
- Internal gRPC service mesh
- Mutual TLS between services
Run the service natively on your machine for development.
# Install Go dependencies
make install-deps
# Install PostgreSQL (Ubuntu/Debian)
sudo apt-get install postgresql postgresql-contrib
# Install Valkey (or use Docker)
docker run -d -p 6379:6379 --name valkey valkey/valkey:9-alpine
# Install development tools
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest# Start PostgreSQL service
sudo systemctl start postgresql
# Create database and user
sudo -u postgres psql << EOF
CREATE DATABASE sts;
CREATE USER stsuser WITH PASSWORD 'stspass';
GRANT ALL PRIVILEGES ON DATABASE sts TO stsuser;
EOFImportant: Run migrations before first start.
# Set database URL
export DATABASE_URL=postgres://stsuser:stspass@localhost:5432/sts?sslmode=disable
# Build the binary
make build
# Run migrations
./bin/sts migrateCreate a .env file or export variables:
# Create .env file
cat > .env << 'EOF'
# Database
DATABASE_URL=postgres://stsuser:stspass@localhost:5432/sts?sslmode=disable
# Cache
CACHE_ADDR=localhost:6379
CACHE_PASSWORD=
CACHE_DB=0
# Servers
HTTP_PORT=8080
GRPC_PORT=9090
# JWT
JWT_ISSUER=session-service
JWT_AUDIENCE=internal-services
JWT_EXPIRY=3600
# OIDC (update these!)
OIDC_PROVIDER_URL=https://your-oidc-provider.com
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_REDIRECT_URL=http://localhost:8080/auth/callback
# Cookies (generate secure random keys!)
COOKIE_HASH_KEY=$(openssl rand -hex 64)
COOKIE_BLOCK_KEY=$(openssl rand -hex 32)
EOF
# Load environment variables
export $(cat .env | xargs)# Build the binary for the host OS/architecture (outputs bin/app)
make build
# Build binaries for all supported architectures (linux/amd64, linux/arm64, linux/riscv64)
make build-all
# Build the binary for a specific target architecture
GOARCH=arm64 make build
# Pack and load a Rock container image for a supported image architecture (amd64 or arm64)
IMAGE=secure-token-service:dev ARCH=amd64 ./build.sh
# Run the service
./bin/app serve
# Or combine
make runMulti-Architecture Notes:
riscv64is supported at the Go binary level (make build-all,GOOS=linux GOARCH=riscv64 make build), whereas Rock container images are built and published only foramd64andarm64.- Multi-architecture release candidate promotion (
<version>-candidate$\rightarrow$ <version>andstable) requiresskopeo copy --allin the centralized workflow (canonical/identity-team#156). Verify published or promoted multi-architecture manifest lists with:docker manifest inspect ghcr.io/canonical/secure-token-service:<tag>
# Run tests
make test
# Run tests with coverage
make test-coverage
# Format code
make fmt
# Lint code
make lint
# Generate protobuf (if modified)
make proto
# Rotate JWKS
./bin/sts rotate-key# In separate terminals:
# Terminal 1: Run service
./bin/sts serve
# Terminal 2: Test endpoints
curl http://localhost:8080/.well-known/jwks.json
# Install grpcurl
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
# List gRPC services
grpcurl -plaintext localhost:9090 list
# Test session exchange (requires valid session_id)
grpcurl -plaintext -d '{"session_id": "test-session"}' \
localhost:9090 sts.v1.SecurityTokenService/ExchangeSession# Run with debug logging
LOG_LEVEL=debug ./bin/sts serve
# Run with Delve debugger
dlv debug ./cmd/sts -- serve
# Profile CPU usage
go tool pprof http://localhost:6060/debug/pprof/profile
# View test coverage
make test-coverage
open coverage.htmlRequired: Must be run before first use and when upgrading.
Option 1: Manual Execution (Quick)
# Docker Compose
docker-compose exec secure-token-service ./bin/sts migrate
# Kubernetes - Direct execution
kubectl exec -it deployment/secure-token-service -- ./bin/sts migrate
# Local
./bin/sts migrate
# With custom database URL
./bin/sts migrate --database-url="postgres://user:pass@host:5432/db"Option 2: Using Kubernetes CronJob Template
The migration CronJob (k8s/migration-cronjob.yaml) provides a managed way to run migrations:
# Run migration immediately (create Job from CronJob)
kubectl create job sts-migration-manual --from=cronjob/sts-migration
# Watch the migration job
kubectl logs -f job/sts-migration-manual
# Check job status
kubectl get jobs -l component=migration
# Enable automatic scheduled runs (edit schedule as needed)
kubectl patch cronjob sts-migration -p '{"spec":{"suspend":false}}'
# View CronJob status
kubectl get cronjob sts-migrationCronJob Features:
- ✓ Waits for PostgreSQL to be ready (init container)
- ✓ Idempotent migrations (safe to run multiple times)
- ✓ Suspended by default (manual trigger only)
- ✓ Configurable schedule (default: 2 AM daily)
- ✓ Automatic cleanup of old jobs
- ✓ Uses same credentials as main service
Scheduling Options:
# Run daily at 2 AM UTC (default)
schedule: "0 2 * * *"
# Run every 6 hours
schedule: "0 */6 * * *"
# Run weekly on Sunday at midnight
schedule: "0 0 * * 0"
# Run on the 1st of every month
schedule: "0 0 1 * *"# Docker Compose
docker-compose exec secure-token-service ./bin/sts rotate-key
# Kubernetes
kubectl exec -it deployment/secure-token-service -- ./bin/sts rotate-key
# Local
./bin/sts rotate-key# Docker Compose
docker-compose exec postgres psql -U postgres -d sts
# Kubernetes (if PostgreSQL is deployed)
kubectl exec -it postgres-0 -- psql -U postgres -d sts
# Local
psql -U stsuser -d sts
# Useful queries
SELECT sid, kid, created_at FROM hydra_jwk ORDER BY created_at DESC;
SELECT * FROM hydra_jwk WHERE sid = 'public';# Valkey CLI (Docker Compose)
docker-compose exec valkey valkey-cli
> KEYS session:*
> FLUSHDB
# Kubernetes
kubectl exec -it deployment/valkey -- valkey-cli FLUSHDB# Docker Compose: Edit docker-compose.yml then
docker-compose up -d --force-recreate secure-token-service
# Kubernetes: Edit k8s/secret.yaml or k8s/deployment.yaml then
kubectl apply -f k8s/
# Local: Edit .env and reload
export $(cat .env | xargs)
./bin/sts serve# Check logs
docker-compose logs secure-token-service
kubectl logs -f deployment/secure-token-service
journalctl -u sts -f
# Common issues:
# 1. PostgreSQL not ready - wait for health check
# 2. Valkey connection failed - check CACHE_ADDR
# 3. Missing OIDC config - update environment variables# Check if initial key was generated
psql -U postgres -d sts -c "SELECT * FROM hydra_jwk;"
# If empty, service didn't start properly - check logs
# Should see: "Database migrations completed" and "Key manager initialized"# Verify port is listening
netstat -tlnp | grep 9090
lsof -i :9090
# Check firewall
sudo ufw status
sudo ufw allow 9090# Check Valkey connection
docker-compose exec valkey valkey-cli ping
# Verify session exists
valkey-cli GET session:your-session-id
# Check logs for authentication errorsThe STS binary provides several commands for managing the service:
# Start the HTTP and gRPC servers
./bin/sts serve
# Run database migrations (required before first use)
./bin/sts migrate [--database-url=<url>]
# Rotate JWKS signing keys
./bin/sts rotate-key
# Show help for all commands
./bin/sts --help
# Show help for specific command
./bin/sts <command> --helpProto Definition: api/proto/v1/sts.proto
Exchanges an opaque session ID for an internal JWT.
Request:
message ExchangeRequest {
string session_id = 1;
}Response:
message ExchangeResponse {
string access_token = 1; // ES256-signed JWT
int64 expires_in = 2; // Seconds until expiration
}JWT Claims:
{
"iss": "session-service",
"sub": "user-uuid",
"aud": "internal-services",
"iat": 1612137600,
"exp": 1612141200,
"email": "user@example.com",
"upstream_token": "..."
}Exchanges an upstream IdP access token (issued via OAuth2 client credentials grant, e.g., Ory Hydra) for an internal STS JWT.
Request:
message ExchangeTokenRequest {
string token = 1; // Raw upstream IdP access token (JWT, max 64 KB)
}Response:
message ExchangeResponse {
string access_token = 1; // ES256-signed JWT
int64 expires_in = 2; // Seconds until expiration (clamped to upstream TTL)
}JWT Claims:
{
"iss": "session-service",
"sub": "client-id",
"aud": "internal-services",
"iat": 1728409400,
"exp": 1728410000,
"email": "client-id@serviceaccount.local"
}Security & Validation Rules:
- Max Token Size: Rejects tokens exceeding 64 KB (
INVALID_ARGUMENT). - Claim Validation: Upstream
sub/client_idmust be non-empty strings,<= 256chars, without control characters or newlines (INVALID_ARGUMENT). - Expiration Policy: Tokens with remaining validity under 60 seconds are rejected (
UNAUTHENTICATED). - Clamping: Internal token lifetime is clamped to
min(configured_expiry, remaining_upstream_ttl). - Clock Skew: Tolerates up to 5 seconds of clock skew leeway.
Revokes all sessions for a specific user.
Request:
message RevokeUserRequest {
string user_id = 1;
}Response:
message RevokeUserResponse {
bool success = 1;
}Returns the JSON Web Key Set containing all public keys (active + retired) for JWT verification.
Response:
{
"keys": [
{
"kty": "EC",
"use": "sig",
"alg": "ES256",
"kid": "janus-key-a1b2c3d4",
"crv": "P-256",
"x": "<base64url-encoded-x-coordinate>",
"y": "<base64url-encoded-y-coordinate>"
},
{
"kty": "EC",
"use": "sig",
"alg": "ES256",
"kid": "janus-key-retired-xyz",
"crv": "P-256",
"x": "<base64url-encoded-x-coordinate>",
"y": "<base64url-encoded-y-coordinate>"
}
]
}Caching: Services should cache JWKS with a TTL of 1 hour and refresh on verification failures.
- Session Hijacking: Mitigated by HttpOnly cookies, SameSite=Strict, Secure flag
- Token Leakage: Internal JWTs never exposed to browser; opaque session IDs only
- Replay Attacks: Short-lived JWTs (1 hour default), session revocation support
- Key Compromise: Key rotation support, retired keys for verification only
- XSS Attacks: HttpOnly cookies prevent JavaScript access to session tokens
- Open Redirects: Mitigated by strict
return_toparameter validation allowing only path-relative URLs, same-origin targets, and explicitly configuredALLOWED_RETURN_TO_HOSTS. Unsafe targets return HTTP 400 on login, and fallback safely to/on callback.
- Network Isolation: Deploy Janus in trusted network, restrict gRPC access
- TLS Everywhere: Use mTLS for gRPC, HTTPS for HTTP endpoints
- Key Rotation: Rotate JWKS every 90 days (automated via cron)
- Session Expiry: Align JWT expiry with upstream token expiry
- Audit Logging: Log all session creation, exchange, and revocation events
- Rate Limiting: Implement rate limits on
/auth/callbackand gRPC endpoints
- Configure strong
COOKIE_HASH_KEYandCOOKIE_BLOCK_KEY(cryptographically random) - Enable PostgreSQL SSL mode (
sslmode=require) - Enable Valkey/Redis AUTH and TLS
- Set up automated JWKS rotation (cron job every 90 days)
- Configure gRPC authentication interceptor
- Implement network policies (restrict gRPC to authorized services)
- Enable audit logging for session operations
- Set up monitoring and alerting for failed authentications
- Configure backup and disaster recovery for PostgreSQL
- Review and harden OIDC scopes and claims
# Run all tests with parallelization (max 10 concurrent tests)
make test
# Run tests without long-running tests (e.g., TTL expiry tests)
make test-short
# Run only integration tests (requires Docker/Podman)
make test-integration
# Run tests with coverage report
make test
# Coverage report generated at ./coverage.htmlTests are configured to run in parallel with a maximum of 10 concurrent tests:
-p 10: Maximum 10 test packages run simultaneously-parallel 10: Maximum 10 tests per package run simultaneously
This limit prevents resource exhaustion from too many concurrent Docker containers during integration testing.
Why 10? This balances:
- Performance: Faster test execution through parallelization
- Resource Usage: Prevents overwhelming Docker with container creation
- Reliability: Reduces flaky tests from resource contention
Integration tests use testcontainers-go for PostgreSQL and Valkey:
- Container Names: Based on test names for predictability (e.g.,
sts-auth-pg-testnewkeymanager) - Automatic Cleanup: Containers are automatically removed after test completion via
defer terminate() - Isolation: Each test gets its own container instance
Packages with Container Tests:
internal/auth: PostgreSQL + Valkey for key management and cachinginternal/session: Valkey for session storageinternal/db: PostgreSQL for JWKS repository
Prerequisites:
- Docker or Podman running and accessible
- Go 1.26+
For Podman users, set the DOCKER_HOST environment variable:
# Set DOCKER_HOST for Podman
export DOCKER_HOST=unix://${XDG_RUNTIME_DIR}/podman/podman.sock
# Verify Podman is accessible
podman ps
# Run tests with Podman
make test
# Or pass directly to make
make test DOCKER_HOST=unix://${XDG_RUNTIME_DIR}/podman/podman.sockRunning tests:
# Install dependencies and generate mocks
make mocks
# Run all tests
make test
# Run specific package tests
go test -v ./internal/auth/...
go test -v ./internal/session/...
go test -v ./internal/db/...| Package | Type | Dependencies | Description |
|---|---|---|---|
internal/http |
Unit | Mocks | HTTP endpoint handlers |
internal/grpc |
Unit | Mocks | gRPC service methods |
internal/auth |
Integration | PostgreSQL, Valkey | Key management, JWT signing, caching |
internal/session |
Integration | Valkey | Session storage operations |
internal/db |
Integration | PostgreSQL | JWKS repository CRUD |
internal/cookie |
Unit | None | Cookie encoding/decoding |
When adding new tests that use containers:
-
Use
t.Parallel()to enable parallel execution:func TestYourFeature(t *testing.T) { t.Parallel() // Add this as first line // ... test code }
-
Container names are automatic: The test helper functions automatically generate unique container names based on
t.Name() -
Always use
deferfor cleanup:repo, container := setupTestPostgres(t) defer func() { if err := container.Terminate(context.Background()); err != nil { t.Logf("Failed to terminate container: %v", err) } }()
Docker connection issues:
# Check Docker is running
docker ps
# For Podman users, set DOCKER_HOST environment variable
export DOCKER_HOST=unix://${XDG_RUNTIME_DIR}/podman/podman.sock
# Verify Podman socket
podman info
# Alternative: use systemd user socket
export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sockTest containers not cleaning up:
# List running test containers
docker ps -a | grep sts-
# Manual cleanup if needed
docker rm -f $(docker ps -a -q --filter "name=sts-")Parallel test failures:
# Run tests sequentially to diagnose
go test -v -p 1 -parallel 1 ./...The project includes comprehensive integration tests that use testcontainers to spin up real PostgreSQL and Valkey instances.
- Docker or Podman: Tests require a container runtime
- Go 1.26+: Latest Go version
- Sufficient resources (2GB+ RAM recommended)
If using Podman instead of Docker, set the DOCKER_HOST environment variable:
export DOCKER_HOST=unix://${XDG_RUNTIME_DIR}/podman/podman.sock
# Verify Podman is accessible
podman psNote: testcontainers will automatically detect and use Podman when DOCKER_HOST is set.
# For Podman users - set this first
export DOCKER_HOST=unix://${XDG_RUNTIME_DIR}/podman/podman.sock
# Run all tests
go test ./... -v
# Run auth package tests (KeyManager, JWKS caching)
go test ./internal/auth -v
# Run specific test
go test ./internal/auth -v -run TestKeyManager_MintToken
# Run without TTL expiry test (saves time)
go test ./internal/auth -v -short
# Run with coverage
go test ./... -cover -coverprofile=coverage.out
go tool cover -html=coverage.out- ✅ KeyManager initialization
- ✅ KeyManager with caching enabled/disabled
- ✅ Token minting with custom claims
- ✅ JWT verification
- ✅ Public key retrieval
- ✅ JWK retrieval with metadata
- ✅ GetAllJWKS for multiple keys
- ✅ Cache MISS scenario (cold cache)
- ✅ Cache HIT scenario (warm cache)
- ✅ Cache invalidation
- ✅ Cache TTL expiry
- ✅ Caching disabled (nil client)
- ✅ Graceful fallback on cache unavailability
# GitHub Actions example
- name: Setup Docker
uses: docker/setup-dockerd@v1
- name: Run tests
run: go test ./... -v# GitHub Actions with Podman
- name: Setup Podman
run: |
sudo apt-get update
sudo apt-get install -y podman
- name: Run tests
env:
DOCKER_HOST: unix:///run/podman/podman.sock
run: go test ./... -v- name: Run unit tests only
run: go test ./... -v -shortError: "checked path: $XDG_RUNTIME_DIR"
- Container runtime (Docker/Podman) is not running or not accessible
- Docker:
sudo systemctl start dockeror start Docker Desktop - Podman: Set
DOCKER_HOST=unix://${XDG_RUNTIME_DIR}/podman/podman.sock
Error: "Failed to start PostgreSQL container"
- Container daemon not running
- Insufficient resources
- Solution: Check runtime status and allocate more resources
Tests timing out
- Container pulling images for first time
- Solution: Pull images manually:
# Docker docker pull postgres:16-alpine docker pull valkey/valkey:7.2-alpine # Podman podman pull postgres:16-alpine podman pull valkey/valkey:7.2-alpine
The service uses standardized error responses with machine-readable error codes.
JSON:
{
"code": "SESSION_NOT_FOUND",
"message": "Session not found"
}Common Error Codes:
| Code | HTTP Status | Description |
|---|---|---|
INVALID_STATE |
400 | Invalid OIDC state parameter |
SESSION_NOT_FOUND |
401 | Session doesn't exist or expired |
JWT_MINTING_FAILED |
500 | Failed to mint JWT |
JWKS_RETRIEVAL_FAILED |
500 | Failed to retrieve JWKS |
See also: internal/errors/errors.go for full error package implementation.
Mocks are generated via annotations, not committed to the repository.
# Install mockgen
go install go.uber.org/mock/mockgen@latest
# Generate all mocks
go generate ./...//go:generate mockgen -destination=mocks/mock_interfaces.go -package=mocks github.com/canonical/secure-token-service/internal/PACKAGE InterfaceName
type InterfaceName interface {
Method(ctx context.Context) error
}Generated mocks are placed in mocks/ subdirectories (gitignored).
AGPL-3.0 - See LICENSE file for details.
Contributions are welcome! Please see CONTRIBUTING.md for guidelines.