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:
| Flow | Grant Type | Use Case | Token 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:
- Frontend generates a
code_verifierandcode_challenge(S256) - User is redirected to Keycloak's login page
- After login, Keycloak returns an
authorization_codeto the redirect URI - Frontend exchanges the code + verifier for an access token + refresh token
- User identity is the token's
subclaim — 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.
| Claim | Required | Purpose |
|---|---|---|
sub | Yes | Subject identifier. Canton looks up the user whose id equals this value.
This is the canonical identity binding — not actAs,
not azp. |
iss | Yes | Issuer URL. Must match the IdP config's issuer exactly, or Canton routes
the token to the wrong (default) IdP and auth fails. |
aud | Yes | Audience. Must include the expected audience string (usually account or
canton). |
scope | Yes | OpenID Connect scope. Must include at least openid. |
exp | Yes | 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.
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.
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.
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.
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:
- Keycloak side: Create the client (service account) or user. Configure
mappers to populate the required claims (
sub,scope,aud). - Canton side: Create a Canton user with
id == sub. Allocate a party (or setprimaryPartyfor per-user identities). GrantcanActAsandcanReadAsrights 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
| HTTP | Error | Likely Cause | Fix |
|---|---|---|---|
| 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:
| Setting | Value | Why |
|---|---|---|
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) |
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