Authentication¶
Overview¶
The sapporo-service supports JWT-based authentication with two modes:
- sapporo mode: Built-in authentication with local user management. The service manages users, hashes passwords with Argon2, and issues/verifies JWTs using a local secret key. Suitable for standalone deployments.
- external mode: Delegates authentication to an external OpenID Connect Identity Provider (e.g., Keycloak). The service only verifies JWTs using the IdP's JWKS endpoint. Suitable for organizations with existing identity infrastructure.
When authentication is enabled, each run is associated with a username, ensuring users can only access their own runs.
Protected Endpoints¶
| Endpoint | Method | Note |
|---|---|---|
/service-info |
GET | Optional: provides user-specific counts when authenticated |
/runs |
GET | |
/runs |
POST | |
/runs/{run_id} |
GET | |
/runs/{run_id}/cancel |
POST | |
/runs/{run_id}/status |
GET | |
/runs/{run_id}/outputs |
GET | |
/runs/{run_id}/outputs/{path} |
GET | |
/runs/{run_id}/ro-crate |
GET | |
/runs/{run_id} |
DELETE | |
/runs |
DELETE | Bulk delete (sapporo 2.1.0+) |
auth_config.json¶
Configure authentication via auth_config.json:
The built-in default (sapporo/auth_config.json) ships with authentication disabled:
{
"auth_enabled": false,
"idp_provider": "sapporo",
"sapporo_auth_config": {
"secret_key": "sapporo_secret_key_please_change_this",
"expires_delta_hours": 24,
"users": [
{
"username": "sapporo-dev-user",
"password_hash": "$argon2id$v=19$m=65536,t=3,p=4$..."
}
]
},
"external_config": {
"idp_url": "http://sapporo-keycloak-dev:8080/realms/sapporo-dev",
"jwt_audience": "account",
"client_mode": "public",
"client_id": "sapporo-service-dev",
"client_secret": "example-client-secret"
}
}
To enable authentication, set auth_enabled to true and configure the appropriate mode. Override the location using --auth-config or SAPPORO_AUTH_CONFIG.
Configuration Fields¶
auth_enabled: Enable/disable authenticationidp_provider:sapporo(local) orexternal(IdP like Keycloak)sapporo_auth_config:secret_key: JWT signing key (must be strong, see Secret Key Generation)expires_delta_hours: JWT expiration time in hours (default: 24, max: 168)users: List of users withusernameandpassword_hashexternal_config:idp_url: External IdP URL (must use HTTPS in production)jwt_audience: Expected JWT audience claimclient_mode:confidentialorpublicclient_id/client_secret: OAuth2 credentials for confidential mode
Sapporo Mode¶
For local authentication:
# Start the service
sapporo
# Get JWT token
TOKEN=$(curl -s -X POST \
-H "Content-Type: multipart/form-data" \
-F "username=user1" \
-F "password=yourpassword" \
localhost:1122/token | jq -r '.access_token')
# Verify token
curl -X GET -H "Authorization: Bearer $TOKEN" localhost:1122/me
# Access protected endpoints
curl -X GET -H "Authorization: Bearer $TOKEN" localhost:1122/runs
External Mode¶
In external mode, integrate with an IdP like Keycloak. Users authenticate with the IdP, which issues JWTs that the sapporo-service verifies.
Security Considerations¶
The external mode enforces the following security measures when verifying JWTs issued by the IdP:
- Algorithm restriction: Only RS256, RS384, and RS512 are accepted. HMAC-based algorithms (e.g., HS256) are rejected to prevent key confusion attacks.
- Issuer verification: The
issclaim in the JWT is validated against theissuerfield from the IdP's OIDC Discovery metadata. - JWKS key rotation: When a JWT's
kidheader does not match any cached key, the JWKS is re-fetched from the IdP. If the key is still not found after refresh, the token is rejected. - TTL-based caching: OIDC Discovery metadata is cached for 1 hour. JWKS is cached for 5 minutes. This ensures timely pickup of key rotations while reducing load on the IdP.
- HTTP timeout: All HTTP requests to the IdP use a 10-second timeout to prevent hanging.
- Retry with exponential backoff: Transient HTTP errors when fetching metadata or JWKS are retried up to 3 times with exponential backoff (0.5s, 1.0s, 2.0s).
Keycloak Development Setup¶
A pre-configured Keycloak realm is provided for development and testing. The realm is automatically imported on first start via keycloak/realm-export.json.
# Start Keycloak (realm is auto-imported)
docker compose -f compose.keycloak.dev.yml up -d
# Wait for healthcheck to pass
docker compose -f compose.keycloak.dev.yml ps
# Start sapporo with external auth
export SAPPORO_ALLOW_INSECURE_IDP=true
sapporo --auth-config auth_config.json --debug
Keycloak admin console: http://localhost:8080 (sapporo-admin / sapporo-admin-password)
Pre-configured Clients¶
| Client ID | Type | Secret | Use case |
|---|---|---|---|
sapporo-service-dev |
public | N/A | Frontend direct authentication |
sapporo-service-dev-confidential |
confidential | sapporo-dev-client-secret |
Server-to-server authentication |
Both clients have directAccessGrantsEnabled: true (Resource Owner Password Grant) for testing convenience.
Test Users¶
| Username | Password |
|---|---|
test-user |
test-user-password |
test-user-2 |
test-user-2-password |
auth_config.json Examples¶
Public mode (frontend obtains tokens directly from Keycloak):
{
"auth_enabled": true,
"idp_provider": "external",
"sapporo_auth_config": {
"secret_key": "unused",
"expires_delta_hours": 24,
"users": []
},
"external_config": {
"idp_url": "http://localhost:8080/realms/sapporo-dev",
"jwt_audience": "account",
"client_mode": "public",
"client_id": "sapporo-service-dev",
"client_secret": null
}
}
Confidential mode (sapporo proxies token requests to Keycloak):
{
"auth_enabled": true,
"idp_provider": "external",
"sapporo_auth_config": {
"secret_key": "unused",
"expires_delta_hours": 24,
"users": []
},
"external_config": {
"idp_url": "http://localhost:8080/realms/sapporo-dev",
"jwt_audience": "account",
"client_mode": "confidential",
"client_id": "sapporo-service-dev-confidential",
"client_secret": "sapporo-dev-client-secret"
}
}
CLI Utilities¶
Password Hashing¶
All passwords are stored as Argon2 hashes. Generate password hashes using the CLI:
sapporo-cli hash-password
# Follow the prompts to enter and confirm your password
# Output: Password hash: $argon2id$v=19$m=65536,t=3,p=4$...
Or with an inline password (not recommended for interactive use):
Secret Key Generation¶
Generate a cryptographically secure secret key:
In production mode (non-debug), weak secret keys are rejected. Always use a generated secret key in production deployments.
HTTPS Requirement¶
When using external identity providers, HTTPS is required by default. This prevents token interception during authentication flows.
To allow HTTP connections during development (not recommended for production):