Medici Protocol

Authentication

How the Medici Protocol authenticates users and services against Canton via Keycloak.

Overview

The Medici Protocol uses Keycloak as its identity provider (IdP), issuing RS256 JWTs (RSA-signed JSON Web Tokens). These tokens authenticate against the Canton JSON API v2, which resolves rights from the Canton user whose id matches the token's sub claim.

Two authentication flows are supported:

FlowGrant TypeUse CaseToken Type
Service Account client_credentials Automated agents, bots, backend services Client-bound RS256 JWT
User Auth authorization_code (PKCE) Interactive users in the web frontend User-bound RS256 JWT

Token Format

Every token is a standard JWT with three base64url-encoded segments:

header.payload.signature
# eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...rest-of-token...

Header specifies RS256 (RSA with SHA-256):

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "abc123-...-xyz789"
}

Payload carries claims the Canton participant uses for authorization:

{
  "iss": "https://keycloak.dev.medici.loan/realms/AppUser",
  "sub": "2f6e0f1a-...-c4d8b3e9",
  "aud": ["account", "canton"],
  "exp": 1719600000,
  "iat": 1719599700,
  "azp": "medici-app",
  "scope": "openid profile"
}

Service Accounts (client_credentials)

Service accounts are for non-interactive clients: the Ledger Service, agents, bots, and any backend process. They authenticate directly with a client_id and client_secret — no user interaction, no redirects.

Obtaining a Token

curl -X POST \
  "https://keycloak.dev.medici.loan/realms/AppUser/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=medici-app" \
  -d "client_secret="

Auto-Refresh

The Ledger Service maintains a central token cache with automatic refresh. Tokens are valid for 5 minutes. When a 401 or 403 is received, the cache refreshes and retries once. Clients talking to Canton directly must implement the same retry logic.

User Auth (OAuth2 PKCE)

Interactive users authenticate through the web frontend using the authorization_code grant with PKCE (Proof Key for Code Exchange). The flow:

  1. Frontend generates a code_verifier and code_challenge (S256)
  2. User is redirected to Keycloak's login page
  3. After login, Keycloak returns an authorization_code to the redirect URI
  4. Frontend exchanges the code + verifier for an access token + refresh token
  5. User identity is the token's sub claim — a Canton user must exist with that id
# Step 1: Generate PKCE challenge (in the browser or via script)
# code_verifier = random 43-128 chars
# code_challenge = base64url(sha256(code_verifier))

# Step 2: Redirect user to Keycloak
open "https://keycloak.dev.medici.loan/realms/AppUser/protocol/openid-connect/auth\
?response_type=code\
&client_id=medici-app\
&redirect_uri=https://app.dev.medici.loan/callback\
&code_challenge=<challenge>\
&code_challenge_method=S256\
&scope=openid+profile"

# Step 3: Keycloak redirects to your callback with ?code=...

# Step 4: Exchange code for token
curl -X POST \
  "https://keycloak.dev.medici.loan/realms/AppUser/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "client_id=medici-app" \
  -d "code=<authorization-code>" \
  -d "code_verifier=<code-verifier>" \
  -d "redirect_uri=https://app.dev.medici.loan/callback"

Required Claims

Canton resolves authorization from these JWT claims. Every token must carry them. Missing or incorrect claims produce 401 or 403 responses.

ClaimRequiredPurpose
subYes Subject identifier. Canton looks up the user whose id equals this value. This is the canonical identity binding — not actAs, not azp.
issYes Issuer URL. Must match the IdP config's issuer exactly, or Canton routes the token to the wrong (default) IdP and auth fails.
audYes Audience. Must include the expected audience string (usually account or canton).
scopeYes OpenID Connect scope. Must include at least openid.
expYes Expiration time (epoch seconds). Tokens last 5 minutes. Expired tokens get 401.

Canton does NOT read actAs or readAs from the token. Authorization (which parties a user can act/read as) is stored in Canton's user record (canActAs / canReadAs rights), granted at provisioning time.

The 5 Canonical Auth Rules

These rules are the result of hard-won experience integrating Canton with Keycloak. Violating any of them produces cryptic errors. The rules are summarized here; for full diagnosis see the canton-auth-doctor skill in the repo.

Rule 1 — Token issuer must be the public URL. Mint tokens from https://keycloak.<env>.medici.loan (the public ingress URL), not from the in-cluster service URL. If iss in the token does not match the Canton IdP config's issuer, the token is routed to the default IdP and auth fails with 401.
Rule 2 — JWKS must be fetched over in-cluster HTTP. The Canton participant's IdP config jwks_url must use the internal cluster DNS name (http://keycloak.<ns>.svc.cluster.local:8080/...). The JVM validates TLS on this fetch; a public ingress URL with an untrusted cert causes JwtException: null and all RS256 tokens are rejected.
Rule 3 — Set identityProviderId for IdP-scoped admin calls. Party management and user management operations require the IdP id when the token comes from a non-default IdP. Without it, you get PERMISSION_DENIED. The HS256 god token (default IdP) hides this requirement — it does not exist on mainnet.
Rule 4 — A party belongs to the IdP of the token that allocated it. You cannot re-allocate or manage a party cross-IdP. The allocating token's IdP is permanently bound to the party.
Rule 5 — Canton resolves rights from sub, not actAs. A well-formed RS256 token 403s if its sub has no Canton user with the needed canActAs/canReadAs rights. Provisioning = Keycloak client/user plus a Canton user keyed by sub with rights granted.

Provisioning Lifecycle

Full provisioning for a new user or service account has two sides:

  1. Keycloak side: Create the client (service account) or user. Configure mappers to populate the required claims (sub, scope, aud).
  2. Canton side: Create a Canton user with id == sub. Allocate a party (or set primaryParty for per-user identities). Grant canActAs and canReadAs rights for the needed parties.
# Provisioning example: create a Canton user and grant rights
# 1. Allocate party
curl -s -X POST \
  "https://canton.dev.medici.loan/v2/parties" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"partyHint":"my-service","identityProviderId":"keycloak-appuser"}' \
  | jq

# Response: { "partyDetails": { "party": "my-service::1220abcd..." } }

# 2. Create a Canton user with id == token sub
curl -s -X POST \
  "https://canton.dev.medici.loan/v2/users" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "user": {
      "id": "",
      "primaryParty": "my-service::1220abcd...",
      "identityProviderId": "keycloak-appuser"
    },
    "rights": [{
      "canActAs": ["my-service::1220abcd..."],
      "canReadAs": ["my-service::1220abcd..."]
    }]
  }' | jq

Common Errors

HTTPErrorLikely CauseFix
401 UNAUTHENTICATED Token iss does not match IdP config issuer Mint token from the public Keycloak URL, not the in-cluster URL
401 No Key ID found JWKS fetch failed — participant cannot reach Keycloak Ensure jwks_url uses in-cluster HTTP, not HTTPS public ingress
401 JwtException: null TLS validation failed on JWKS fetch Use in-cluster HTTP URL for jwks_url; check cert trust store
401 Token expired Token older than 5 minutes Refresh token; implement auto-refresh with retry
403 PERMISSION_DENIED Token sub has no Canton user with needed rights Create Canton user with matching id; grant canActAs/canReadAs
403 PERMISSION_DENIED Missing identityProviderId on IdP-scoped call Add ?identity-provider-id=keycloak-appuser query param; or include in JSON body
403 PERMISSION_DENIED Party belongs to a different IdP than your token Re-allocate party with correct IdP token (Rule 4)
400 Bad Request from token endpoint Keycloak client not configured for this grant type Enable client_credentials or authorization_code for the client
401 TEMPLATES_OR_INTERFACES_NOT_FOUND Package content hash is stale (DAR was rebuilt) Use upgrade-transparent package-name form: #option-index-tracker-v3

Identity Provider Configuration

The Canton participant is configured with a Keycloak IdP via the auth-setup Job. Two endpoints matter:

SettingValueWhy
issuer https://keycloak.<env>.medici.loan/realms/AppUser Must match token iss — use public URL (Rule 1)
jwks_url http://keycloak.<ns>.svc.cluster.local:8080/realms/AppUser/protocol/openid-connect/certs Must fetch over in-cluster HTTP (Rule 2)
Dev-only HS256 bootstrap: The Splice dev quickstart provides an HS256 "god token" with disableAuth=true. This token bypasses all auth checks and belongs to the default IdP. It is a dev-only artifact — it does not exist on mainnet. Do not build logic that depends on it.

curl Quick Reference

# Service account token
curl -s -X POST \
  "https://keycloak.dev.medici.loan/realms/AppUser/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=medici-app" \
  -d "client_secret=$CLIENT_SECRET" | jq -r '.access_token'

# Use token against Canton
TOKEN=$(...)
curl -s -X POST \
  "https://canton.dev.medici.loan/v2/state/active-contracts" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"activeAtOffset":"","templateIds":["#option-index-tracker-v3:OptionIndex.Oracle:PublishedPrice"],"includeCreatedEventBlob":false}' | jq

# Use token against Ledger Service (recommended)
curl -s \
  -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8080/api/v1/prices?ticker=BTC/USDC" | jq