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+) |
/me |
GET | Returns the username of the token; 400 when authentication is disabled |
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.
sapporo reads auth_config.json once at startup. To apply a change, such as a new user or a new secret_key, restart sapporo.
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_hash. A username is 1 to 128 characters from letters, digits,_,-,.and@; the same rule applies to the username taken from an external IdP tokenexternal_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
POST /token issues a token only while authentication is enabled. It returns 400 when authentication is disabled, and in external mode with a public client.
A token stays valid until it expires, because sapporo cannot revoke a single token. To invalidate all tokens at once, change secret_key and restart sapporo. Keep expires_delta_hours short, so that a leaked token can be used only for a short time.
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: 3 attempts in total, with 0.5 s and then 1.0 s between them.
sapporo verifies only the signature and the expiry of a token. A token that the IdP has revoked therefore stays valid in sapporo until it expires. Keep the access token lifespan of the IdP short.
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:
When authentication is enabled in sapporo mode, sapporo refuses to start with a weak secret key, in debug mode as well. A key is weak when it is shorter than 32 characters or is a well-known value, such as the secret_key of the bundled auth_config.json. Use a generated secret key.
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):
While SAPPORO_ALLOW_INSECURE_IDP is set, sapporo logs a warning at startup.