Skip to content

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 authentication
  • idp_provider: sapporo (local) or external (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 with username and password_hash. A username is 1 to 128 characters from letters, digits, _, -, . and @; the same rule applies to the username taken from an external IdP token
  • external_config:
  • idp_url: External IdP URL (must use HTTPS in production)
  • jwt_audience: Expected JWT audience claim
  • client_mode: confidential or public
  • client_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 iss claim in the JWT is validated against the issuer field from the IdP's OIDC Discovery metadata.
  • JWKS key rotation: When a JWT's kid header 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):

sapporo-cli hash-password --password "your_password"

Secret Key Generation

Generate a cryptographically secure secret key:

sapporo-cli generate-secret
# Output: Secret key: <43-character secure random string>

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):

export SAPPORO_ALLOW_INSECURE_IDP=true

While SAPPORO_ALLOW_INSECURE_IDP is set, sapporo logs a warning at startup.