Skip to content

Token Exchange

This document describes the token exchange implementation that allows Keycloak and Entra tokens to be exchanged for enriched governance platform tokens. The examples below use Keycloak because it has a checked-in local compose setup.

The token exchange pattern implements RFC 8693 (OAuth 2.0 Token Exchange) to allow clients to exchange their IDP access tokens for governance platform tokens that include enriched claims such as organization memberships, project assignments, and DID keys.

┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Client │────▶│ Keycloak │ │ Auth Service │
└─────────────┘ └──────────────┘ └─────────────────┘
│ │ │
│ 1. Authenticate │ │
│────────────────────▶│ │
│ │ │
│ 2. Access Token │ │
│◀────────────────────│ │
│ │ │
│ 3. Token Exchange Request │
│───────────────────────────────────────────▶│
│ │ │
│ │ 4. Validate Token │
│ │◀─────────────────────│
│ │ │
│ 5. Enriched Token │ │
│◀───────────────────────────────────────────│

1. Keycloak Token Validation (auth-service/backend/pkg/idp/providers/keycloak/token_validation.go)

Section titled “1. Keycloak Token Validation (auth-service/backend/pkg/idp/providers/keycloak/token_validation.go)”
  • Validates Keycloak JWT tokens using Keycloak’s JWKS endpoint
  • Caches public keys for performance
  • Extracts user claims from validated tokens

2. Token Issuer (auth-service/backend/pkg/services/token_issuer.go)

Section titled “2. Token Issuer (auth-service/backend/pkg/services/token_issuer.go)”
  • Issues new JWT tokens with enriched claims
  • Manages signing keys (generates or loads from environment)
  • Provides JWKS endpoint for token validation

3. Token Exchange Handler (auth-service/backend/pkg/handlers/token_exchange.go)

Section titled “3. Token Exchange Handler (auth-service/backend/pkg/handlers/token_exchange.go)”
  • Implements the token exchange endpoint
  • Orchestrates validation, user lookup, and token issuance
  • Enriches tokens with governance platform claims

4. Claims Builder (auth-service/backend/pkg/claims/claims.go)

Section titled “4. Claims Builder (auth-service/backend/pkg/claims/claims.go)”
  • Shared functions for building organization and project claims
  • Consistent claim structure across different auth flows
POST /api/v1/auth/token
Content-Type: application/json
{
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": "<keycloak_access_token>",
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token"
}
Response:
{
"access_token": "<enriched_token>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<refresh_token>",
"refresh_expires_in": 2592000,
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}

POST /api/v1/auth/token-exchange is still registered for backward compatibility and routes to the same handler. Both routes are only registered when IDP_PROVIDER is keycloak or entra; Auth0 and generic OIDC deployments do not expose the token-exchange endpoint and rely on the provider-issued access token directly.

GET /api/v1/auth/.well-known/jwks.json
Response:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "auth-service-12345678",
"alg": "RS256",
"n": "...",
"e": "AQAB"
}
]
}
Terminal window
# Keycloak Configuration
IDP_PROVIDER=keycloak
IDP_ISSUER=http://localhost:8081/realms/governance
IDP_CLIENT_ID=governance-platform-backend
IDP_CLIENT_SECRET=backend-secret
IDP_KEYCLOAK_REALM=governance
IDP_KEYCLOAK_ADMIN_URL=http://localhost:8081
# Token Issuer Configuration
AUTH_SERVICE_URL=http://localhost:8080/api/v1/auth
AUTH_SERVICE_PRIVATE_KEY=<PEM_ENCODED_RSA_KEY>
AUTH_SERVICE_KEY_ID=<CUSTOM_KEY_ID>
AUTH_SERVICE_KEY_PATH=/path/to/key.pem
{
"sub": "f:12345678-1234-1234-1234-123456789012:johndoe",
"email": "john@example.com",
"name": "John Doe",
"preferred_username": "johndoe",
"groups": ["project-123", "admin"],
"exp": 1234567890,
"iat": 1234567890
}
{
"sub": "12345678-1234-1234-1234-123456789012",
"email": "john@example.com",
"name": "John Doe",
"preferred_username": "johndoe",
"app_organizations": [
{
"id": 1,
"roles": ["organization_owner"],
"name": "Example Org",
"auth0_org_id": "org_123"
}
],
"app_projects": [
{
"id": 123,
"roles": ["project_owner"]
}
],
"user_context": {
"total_organizations": 1,
"total_projects": 1,
"has_admin_access": true
},
"did_key_id": "did:key:z6Mk...",
"groups": ["project-123", "admin"],
"idp_provider": "keycloak",
"idp_user_id": "f:12345678-1234-1234-1234-123456789012:johndoe",
"https://governance.eqtylab.io/organization_id": 1,
"organization_id": "1",
"iss": "http://localhost:8080/api/v1/auth",
"aud": "http://localhost:8080/api/v1/auth",
"exp": 1234567890,
"iat": 1234567890,
"nbf": 1234567890,
"jti": "87654321-4321-4321-4321-210987654321"
}
async function exchangeKeycloakToken(keycloakToken: string): Promise<string> {
const response = await fetch("https://auth-service/api/v1/auth/token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token: keycloakToken,
subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
}),
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.statusText}`);
}
const data = await response.json();
return data.access_token;
}
type TokenExchangeRequest struct {
GrantType string `json:"grant_type"`
SubjectToken string `json:"subject_token"`
SubjectTokenType string `json:"subject_token_type"`
}
type TokenExchangeResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
RefreshExpiresIn int `json:"refresh_expires_in"`
IssuedTokenType string `json:"issued_token_type"`
}
func exchangeToken(keycloakToken string) (string, error) {
reqBody := TokenExchangeRequest{
GrantType: "urn:ietf:params:oauth:grant-type:token-exchange",
SubjectToken: keycloakToken,
SubjectTokenType: "urn:ietf:params:oauth:token-type:access_token",
}
jsonBody, _ := json.Marshal(reqBody)
resp, err := http.Post(
"https://auth-service/api/v1/auth/token",
"application/json",
bytes.NewBuffer(jsonBody),
)
if err != nil {
return "", err
}
defer resp.Body.Close()
var tokenResp TokenExchangeResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", err
}
return tokenResp.AccessToken, nil
}

For local testing, obtain a Keycloak access token, POST it to /api/v1/auth/token, then verify the returned access token against /api/v1/auth/.well-known/jwks.json.

  1. Token Validation: Incoming IDP tokens are validated against the configured provider’s JWKS
  2. Token Expiration: Enriched tokens respect the remaining lifetime of the original IDP token
  3. Key Management: Private keys for token signing can be provided via environment or auto-generated
  4. HTTPS: Always use HTTPS in production for token exchange
  5. Token Scope: Enriched tokens are only valid for the governance platform services
  • Verify Keycloak configuration in environment variables
  • Check auth service logs for validation errors
  • Ensure the incoming IDP token is not expired
  • Verify user exists in governance platform database
  • Check organization/project memberships
  • Review auth service logs for enrichment errors
  • Token exchange adds ~50-100ms latency
  • Consider implementing client-side token caching
  • Monitor JWKS cache hit rates