Skip to content
7 changes: 3 additions & 4 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ services:
- ${KEYS_DIR:-./keys}/localhost.crt:/etc/x509/tls/localhost.crt
- ${KEYS_DIR:-./keys}/localhost.key:/etc/x509/tls/localhost.key
- ${KEYS_DIR:-./keys}/ca.jks:/truststore/truststore.jks
image: keycloak/keycloak:25.0
image: quay.io/keycloak/keycloak:26.4.0
restart: always
command:
- "start-dev"
Expand All @@ -27,10 +27,9 @@ services:
KC_HTTP_PORT: "8888"
KC_HTTPS_PORT: "8443"
KC_HTTP_MANAGEMENT_PORT: "9001"
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: changeme
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: changeme
#KC_HOSTNAME_URL: http://localhost:8888/auth
KC_FEATURES: "preview,token-exchange"
KC_HEALTH_ENABLED: "true"
KC_HTTPS_KEY_STORE_PASSWORD: "password"
KC_HTTPS_KEY_STORE_FILE: "/truststore/truststore.jks"
Expand Down
2 changes: 1 addition & 1 deletion go.work
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
go 1.25.5

toolchain go1.25.9
toolchain go1.25.11

use (
./examples
Expand Down
242 changes: 122 additions & 120 deletions lib/fixtures/keycloak.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const (
kcErrNone = 0
kcErrUnknown = -1

standardTokenExchangeEnabledAttribute = "standard.token.exchange.enabled"
dpopBoundAccessTokensAttribute = "dpop.bound.access.tokens" //nolint:gosec // Keycloak client attribute name, not a credential.
keycloakBoolTrue = "true"

// Token refresh constants
defaultTokenBufferSeconds = 120 // 2 minutes before expiration
defaultFallbackExpiryMinutes = 5 // Fallback when token doesn't provide ExpiresIn
Expand Down Expand Up @@ -101,6 +105,29 @@ func SetupKeycloak(ctx context.Context, kcConnectParams KeycloakConnectParams) e
}

func SetupKeycloakWithConfig(ctx context.Context, kcConnectParams KeycloakConnectParams, tmConfig *TokenManagerConfig) error {
return setupKeycloakWithConfig(ctx, kcConnectParams, tmConfig, keycloakSetupOptions{
includeCustomDPoPMapper: true,
includeCertExchange: true,
})
}

func SetupStandardKeycloak(ctx context.Context, kcConnectParams KeycloakConnectParams) error {
return SetupStandardKeycloakWithConfig(ctx, kcConnectParams, nil)
}

func SetupStandardKeycloakWithConfig(ctx context.Context, kcConnectParams KeycloakConnectParams, tmConfig *TokenManagerConfig) error {
return setupKeycloakWithConfig(ctx, kcConnectParams, tmConfig, keycloakSetupOptions{
includeCustomDPoPMapper: false,
includeCertExchange: false,
})
}

type keycloakSetupOptions struct {
includeCustomDPoPMapper bool
includeCertExchange bool
}

func setupKeycloakWithConfig(ctx context.Context, kcConnectParams KeycloakConnectParams, tmConfig *TokenManagerConfig, options keycloakSetupOptions) error {
// Create TokenManager
tm, err := NewTokenManager(ctx, &kcConnectParams, tmConfig)
if err != nil {
Expand Down Expand Up @@ -172,31 +199,7 @@ func SetupKeycloakWithConfig(ctx context.Context, kcConnectParams KeycloakConnec
opentdfAuthorizationClientID := "tdf-authorization-svc"
realmMangementClientName := "realm-management"

protocolMappers := []gocloak.ProtocolMapperRepresentation{
{
Name: gocloak.StringP("audience-mapper"),
Protocol: gocloak.StringP("openid-connect"),
ProtocolMapper: gocloak.StringP("oidc-audience-mapper"),
Config: &map[string]string{
"included.client.audience": kcConnectParams.Audience,
"included.custom.audience": "custom_audience",
"access.token.claim": "true",
"id.token.claim": "true",
},
},
{
Name: gocloak.StringP("dpop-mapper"),
Protocol: gocloak.StringP("openid-connect"),
ProtocolMapper: gocloak.StringP("virtru-oidc-protocolmapper"),
Config: &map[string]string{
"claim.name": "tdf_claims",
"client.dpop": "true",
"tdf_claims.enabled": "true",
"access.token.claim": "true",
"client.publickey": "X-VirtruPubKey",
},
},
}
protocolMappers := defaultProtocolMappers(kcConnectParams.Audience, options.includeCustomDPoPMapper)

// Create Roles
roles := []string{opentdfAdminRoleName, opentdfStandardRoleName, testingOnlyRoleName}
Expand Down Expand Up @@ -243,15 +246,19 @@ func SetupKeycloakWithConfig(ctx context.Context, kcConnectParams KeycloakConnec
}

// Create OpenTDF Client
_, err = createClient(ctx, tm, &kcConnectParams, gocloak.Client{
opentdfClient := gocloak.Client{
ClientID: gocloak.StringP(opentdfClientID),
Enabled: gocloak.BoolP(true),
Name: gocloak.StringP(opentdfClientID),
ServiceAccountsEnabled: gocloak.BoolP(true),
ClientAuthenticatorType: gocloak.StringP("client-secret"),
Secret: gocloak.StringP("secret"),
ProtocolMappers: &protocolMappers,
}, []gocloak.Role{*opentdfAdminRole}, nil)
}
if !options.includeCustomDPoPMapper {
opentdfClient = withDPoPBoundAccessTokens(opentdfClient)
}
_, err = createClient(ctx, tm, &kcConnectParams, opentdfClient, []gocloak.Role{*opentdfAdminRole}, nil)
if err != nil {
return err
}
Expand Down Expand Up @@ -285,7 +292,7 @@ func SetupKeycloakWithConfig(ctx context.Context, kcConnectParams KeycloakConnec
}

// Create TDF SDK Client
sdkNumericID, err := createClient(ctx, tm, &kcConnectParams, gocloak.Client{
opentdfSdkClient := gocloak.Client{
ClientID: gocloak.StringP(opentdfSdkClientID),
Enabled: gocloak.BoolP(true),
// OptionalClientScopes: &[]string{"testscope"},
Expand All @@ -295,7 +302,11 @@ func SetupKeycloakWithConfig(ctx context.Context, kcConnectParams KeycloakConnec
Secret: gocloak.StringP("secret"),
DirectAccessGrantsEnabled: gocloak.BoolP(true),
ProtocolMappers: &protocolMappers,
}, []gocloak.Role{*opentdfStandardRole, *testingOnlyRole}, nil)
}
if !options.includeCustomDPoPMapper {
opentdfSdkClient = withDPoPBoundAccessTokens(opentdfSdkClient)
}
sdkNumericID, err := createClient(ctx, tm, &kcConnectParams, opentdfSdkClient, []gocloak.Role{*opentdfStandardRole, *testingOnlyRole}, nil)
if err != nil {
return err
}
Expand Down Expand Up @@ -368,17 +379,50 @@ func SetupKeycloakWithConfig(ctx context.Context, kcConnectParams KeycloakConnec
panic("Oh no!, failed to create user :(")
}

// Create token exchange opentdf->opentdf sdk
if err := createTokenExchange(ctx, &kcConnectParams, opentdfClientID, opentdfSdkClientID); err != nil {
// Enable standard token exchange for the requester client.
if err := createTokenExchange(ctx, &kcConnectParams, opentdfClientID); err != nil {
return err
}
if err := createCertExchange(ctx, &kcConnectParams, "x509-auth-flow", opentdfSdkClientID); err != nil {
return err
if options.includeCertExchange {
if err := createCertExchange(ctx, &kcConnectParams, "x509-auth-flow", opentdfSdkClientID); err != nil {
return err
}
}

return nil
}

func defaultProtocolMappers(audience string, includeCustomDPoPMapper bool) []gocloak.ProtocolMapperRepresentation {
protocolMappers := []gocloak.ProtocolMapperRepresentation{
{
Name: gocloak.StringP("audience-mapper"),
Protocol: gocloak.StringP("openid-connect"),
ProtocolMapper: gocloak.StringP("oidc-audience-mapper"),
Config: &map[string]string{
"included.client.audience": audience,
"included.custom.audience": "custom_audience",
"access.token.claim": "true",
"id.token.claim": "true",
},
},
}
if includeCustomDPoPMapper {
protocolMappers = append(protocolMappers, gocloak.ProtocolMapperRepresentation{
Name: gocloak.StringP("dpop-mapper"),
Protocol: gocloak.StringP("openid-connect"),
ProtocolMapper: gocloak.StringP("virtru-oidc-protocolmapper"),
Config: &map[string]string{
"claim.name": "tdf_claims",
"client.dpop": "true",
"tdf_claims.enabled": "true",
"access.token.claim": "true",
"client.publickey": "X-VirtruPubKey",
},
})
}
return protocolMappers
}

func SetupCustomKeycloak(ctx context.Context, kcParams KeycloakConnectParams, keycloakData KeycloakData) error {
return SetupCustomKeycloakWithConfig(ctx, kcParams, keycloakData, nil)
}
Expand Down Expand Up @@ -511,7 +555,7 @@ func SetupCustomKeycloakWithConfig(ctx context.Context, kcParams KeycloakConnect
// create token exchanges
if realmToCreate.TokenExchanges != nil {
for _, tokenExchange := range realmToCreate.TokenExchanges {
err := createTokenExchangeWithTokenManager(ctx, &kcConnectParams, tm, tokenExchange.StartClientID, tokenExchange.TargetClientID)
err := createTokenExchangeWithTokenManager(ctx, &kcConnectParams, tm, tokenExchange.StartClientID)
if err != nil {
return err
}
Expand Down Expand Up @@ -1059,121 +1103,79 @@ func getIDOfClient(ctx context.Context, tm *TokenManager, connectParams *Keycloa
return clientID, nil
}

func createTokenExchange(ctx context.Context, connectParams *KeycloakConnectParams, startClientID string, targetClientID string) error {
func createTokenExchange(ctx context.Context, connectParams *KeycloakConnectParams, startClientID string) error {
// Create TokenManager and delegate to TokenManager version
tm, err := NewTokenManager(ctx, connectParams, nil)
if err != nil {
return fmt.Errorf("failed to create token manager: %w", err)
}
return createTokenExchangeWithTokenManager(ctx, connectParams, tm, startClientID, targetClientID)
return createTokenExchangeWithTokenManager(ctx, connectParams, tm, startClientID)
}

func createTokenExchangeWithTokenManager(ctx context.Context, connectParams *KeycloakConnectParams, tm *TokenManager, startClientID string, targetClientID string) error {
func createTokenExchangeWithTokenManager(ctx context.Context, connectParams *KeycloakConnectParams, tm *TokenManager, startClientID string) error {
// Get fresh token
token, err := tm.GetToken(ctx)
if err != nil {
return fmt.Errorf("failed to get token: %w", err)
}
client := tm.GetClient()

// Step 1- enable permissions for target client
idForTargetClientID, err := getIDOfClient(ctx, tm, connectParams, &targetClientID)
requesterClients, err := client.GetClients(ctx, token.AccessToken, connectParams.Realm, gocloak.GetClientsParams{
ClientID: gocloak.StringP(startClientID),
})
if err != nil {
return err
return fmt.Errorf("error getting token exchange requester client %q: %w", startClientID, err)
}
enabled := true
mgmtPermissionsRepr, err := client.UpdateClientManagementPermissions(ctx, token.AccessToken,
connectParams.Realm, *idForTargetClientID,
gocloak.ManagementPermissionRepresentation{Enabled: &enabled})
if err != nil {
slog.Error("error creating management permissions", slog.Any("error", err))
return err
if len(requesterClients) == 0 {
return fmt.Errorf("token exchange requester client %q not found", startClientID)
}
tokenExchangePolicyPermissionResourceID := mgmtPermissionsRepr.Resource
scopePermissions := *mgmtPermissionsRepr.ScopePermissions
tokenExchangePolicyScopePermissionID := scopePermissions["token-exchange"]
slog.Debug("creating management permission",
slog.String("resource", *tokenExchangePolicyPermissionResourceID),
slog.String("scope_permission_id", tokenExchangePolicyScopePermissionID))

slog.Debug("step 2 - get realm mgmt client id")
realmMangementClientName := "realm-management"
realmManagementClientID, err := getIDOfClient(ctx, tm, connectParams, &realmMangementClientName)
requesterClient, err := exactClientByClientID(requesterClients, startClientID)
if err != nil {
return err
}
slog.Debug("client information",
slog.String("client_name", realmMangementClientName),
slog.String("client_id", *realmManagementClientID))

slog.Debug("step 3 - add policy for token exchange")
policyType := "client"
policyName := fmt.Sprintf("%s-%s-exchange-policy", targetClientID, startClientID)
realmMgmtExchangePolicyRepresentation := gocloak.PolicyRepresentation{
Logic: gocloak.POSITIVE,
Name: &policyName,
Type: &policyType,
requesterClient = withStandardTokenExchangeEnabled(requesterClient)
if err := client.UpdateClient(ctx, token.AccessToken, connectParams.Realm, requesterClient); err != nil {
return fmt.Errorf("error enabling standard token exchange for requester client %q: %w", startClientID, err)
}
policyClients := []string{startClientID}
realmMgmtExchangePolicyRepresentation.Clients = &policyClients

realmMgmtPolicy, err := client.CreatePolicy(ctx, token.AccessToken, connectParams.Realm,
*realmManagementClientID, realmMgmtExchangePolicyRepresentation)
if err != nil {
switch kcErrCode(err) {
case http.StatusConflict:
//nolint:sloglint // allow existing emojis
slog.Warn("⏭️ policy already exists; skipping remainder of token exchange creation", slog.String("policy", *realmMgmtExchangePolicyRepresentation.Name))
return nil
default:
slog.Error("error creating realm management policy", slog.Any("error", err))
return err
slog.Info("enabled standard token exchange for client",
slog.String("requester_client_id", startClientID))

return nil
}

func exactClientByClientID(clients []*gocloak.Client, clientID string) (gocloak.Client, error) {
for _, client := range clients {
if client == nil || client.ClientID == nil {
continue
}
if *client.ClientID == clientID {
return *client, nil
}
}
tokenExchangePolicyID := realmMgmtPolicy.ID
//nolint:sloglint // allow existing emojis
slog.Info("✅ created token exchange policy", slog.String("policy_id", *tokenExchangePolicyID))
return gocloak.Client{}, fmt.Errorf("token exchange requester client %q not found by exact clientID match", clientID)
}

slog.Debug("step 4 - get token exchange scope identifier")
resourceRep, err := client.GetResource(ctx, token.AccessToken, connectParams.Realm, *realmManagementClientID, *tokenExchangePolicyPermissionResourceID)
if err != nil {
slog.Error("error getting resource", slog.Any("error", err))
return err
}
var tokenExchangeScopeID *string
tokenExchangeScopeID = nil
for _, scope := range *resourceRep.Scopes {
if *scope.Name == "token-exchange" {
tokenExchangeScopeID = scope.ID
func withStandardTokenExchangeEnabled(client gocloak.Client) gocloak.Client {
return withClientAttribute(client, standardTokenExchangeEnabledAttribute, keycloakBoolTrue)
}

func withDPoPBoundAccessTokens(client gocloak.Client) gocloak.Client {
return withClientAttribute(client, dpopBoundAccessTokensAttribute, keycloakBoolTrue)
}

func withClientAttribute(client gocloak.Client, key, value string) gocloak.Client {
attributes := make(map[string]string)
if client.Attributes != nil {
for existingKey, existingValue := range *client.Attributes {
attributes[existingKey] = existingValue
}
}
if tokenExchangeScopeID == nil {
return errors.New("no token exchange scope found")
}
slog.Debug("token exchange scope information",
slog.String("scope_id", *tokenExchangeScopeID))

clientPermissionName := "token-exchange.permission.client." + *idForTargetClientID
clientType := "Scope"
clientPermissionResources := []string{*tokenExchangePolicyPermissionResourceID}
clientPermissionPolicies := []string{*tokenExchangePolicyID}
clientPermissionScopes := []string{*tokenExchangeScopeID}
permissionScopePolicyRepresentation := gocloak.PolicyRepresentation{
ID: &tokenExchangePolicyScopePermissionID,
Name: &clientPermissionName,
Type: &clientType,
Logic: gocloak.POSITIVE,
DecisionStrategy: gocloak.UNANIMOUS,
Resources: &clientPermissionResources,
Policies: &clientPermissionPolicies,
Scopes: &clientPermissionScopes,
}
if err := client.UpdatePermissionScope(ctx, token.AccessToken, connectParams.Realm,
*realmManagementClientID, tokenExchangePolicyScopePermissionID, permissionScopePolicyRepresentation); err != nil {
slog.Error("error creating permission scope", slog.Any("error", err))
return err
}
return nil
attributes[key] = value
client.Attributes = &attributes
return client
}

func createCertExchange(ctx context.Context, connectParams *KeycloakConnectParams, topLevelFlowName, clientID string) error {
Expand Down
Loading
Loading