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: keycloak/keycloak:26.2
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
112 changes: 24 additions & 88 deletions lib/fixtures/keycloak.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
kcErrNone = 0
kcErrUnknown = -1

standardTokenExchangeEnabledAttribute = "standard.token.exchange.enabled"

// Token refresh constants
defaultTokenBufferSeconds = 120 // 2 minutes before expiration
defaultFallbackExpiryMinutes = 5 // Fallback when token doesn't provide ExpiresIn
Expand Down Expand Up @@ -1076,104 +1078,38 @@
}
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)
if err != nil {
return err
requesterClient := withStandardTokenExchangeEnabled(*requesterClients[0])
Comment thread
jp-ayyappan marked this conversation as resolved.
Outdated
if err := client.UpdateClient(ctx, token.AccessToken, connectParams.Realm, requesterClient); err != nil {
return fmt.Errorf("error enabling standard token exchange for requester client %q targeting %q: %w", startClientID, targetClientID, 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,
}
policyClients := []string{startClientID}
realmMgmtExchangePolicyRepresentation.Clients = &policyClients
slog.Info("enabled standard token exchange for client",
slog.String("requester_client_id", startClientID),
slog.String("target_client_id", targetClientID))

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
}
}
tokenExchangePolicyID := realmMgmtPolicy.ID
//nolint:sloglint // allow existing emojis
slog.Info("✅ created token exchange policy", slog.String("policy_id", *tokenExchangePolicyID))
return nil
}

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 {
attributes := make(map[string]string)
if client.Attributes != nil {
for key, value := range *client.Attributes {
attributes[key] = value
}
}
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[standardTokenExchangeEnabledAttribute] = "true"

Check failure on line 1110 in lib/fixtures/keycloak.go

View workflow job for this annotation

GitHub Actions / go (lib/fixtures)

string `true` has 3 occurrences, make it a constant (goconst)
client.Attributes = &attributes
return client
}

func createCertExchange(ctx context.Context, connectParams *KeycloakConnectParams, topLevelFlowName, clientID string) error {
Expand Down
42 changes: 42 additions & 0 deletions lib/fixtures/keycloak_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package fixtures

import (
"testing"

"github.com/Nerzal/gocloak/v13"
)

func TestWithStandardTokenExchangeEnabled(t *testing.T) {
existingAttributes := map[string]string{
"client.secret.creation.time": "12345",
}
client := gocloak.Client{
Attributes: &existingAttributes,
}

updatedClient := withStandardTokenExchangeEnabled(client)

if updatedClient.Attributes == nil {
t.Fatal("expected attributes to be set")
}
if got := (*updatedClient.Attributes)[standardTokenExchangeEnabledAttribute]; got != "true" {
t.Fatalf("expected %s to be true, got %q", standardTokenExchangeEnabledAttribute, got)
}
if got := (*updatedClient.Attributes)["client.secret.creation.time"]; got != "12345" {
t.Fatalf("expected existing attribute to be preserved, got %q", got)
}
if _, ok := existingAttributes[standardTokenExchangeEnabledAttribute]; ok {
t.Fatal("expected original attributes map to remain unchanged")
}
}

func TestWithStandardTokenExchangeEnabledInitializesAttributes(t *testing.T) {
updatedClient := withStandardTokenExchangeEnabled(gocloak.Client{})

if updatedClient.Attributes == nil {
t.Fatal("expected attributes to be initialized")
}
if got := (*updatedClient.Attributes)[standardTokenExchangeEnabledAttribute]; got != "true" {
t.Fatalf("expected %s to be true, got %q", standardTokenExchangeEnabledAttribute, got)
}
}
4 changes: 2 additions & 2 deletions service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ For convenience, the `make toolcheck` script checks if you have the necessary de

### Provisioning Custom Keycloak and Policy Data

To provision a custom Keycloak setup, create a yaml following the format of [the sample Keycloak config](service/cmd/keycloak_data.yaml). You can create different realms with separate users, clients, roles, and groups. Run the provisioning with `go run ./service provision keycloak -f <path-to-your-yaml-file>`.
To provision a custom Keycloak setup, create a yaml following the format of [the sample Keycloak config](service/cmd/keycloak_data.yaml). You can create different realms with separate users, clients, roles, and groups. The `token_exchanges` entries enable Keycloak standard token exchange on the requester clients. Run the provisioning with `go run ./service provision keycloak -f <path-to-your-yaml-file>`.

## Developing a new platform service

Expand Down Expand Up @@ -132,4 +132,4 @@ services:
myservice:
enabled: true
foo: bar
```
```
8 changes: 4 additions & 4 deletions test/integration/oauth/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,10 +509,10 @@ func extractDPoPToken(r *http.Request, t *testing.T) jwt.Token {

func setupKeycloak(ctx context.Context, t *testing.T) (tc.Container, string, string) {
containerReq := tc.ContainerRequest{
Image: "ghcr.io/opentdf/keycloak:sha-8a6d35a",
Image: "keycloak/keycloak:26.2",
ExposedPorts: []string{"8082/tcp", "8083/tcp"},
Cmd: []string{
"start-dev", "--http-port=8082", "--https-port=8083", "--features=preview", "--verbose",
"start-dev", "--http-port=8082", "--https-port=8083", "--verbose",
"-Djavax.net.ssl.trustStorePassword=password", "-Djavax.net.ssl.HostnameVerifier=AllowAll",
"-Djavax.net.debug=ssl",
"-Djavax.net.ssl.trustStore=/truststore/truststore.jks",
Expand All @@ -524,8 +524,8 @@ func setupKeycloak(ctx context.Context, t *testing.T) (tc.Container, string, str
{HostFilePath: "testdata/localhost.key", ContainerFilePath: "/etc/x509/tls/localhost.key", FileMode: int64(0o600)},
},
Env: map[string]string{
"KEYCLOAK_ADMIN": "admin",
"KEYCLOAK_ADMIN_PASSWORD": "admin",
"KC_BOOTSTRAP_ADMIN_USERNAME": "admin",
"KC_BOOTSTRAP_ADMIN_PASSWORD": "admin",
"KC_HTTPS_KEY_STORE_PASSWORD": "password",
"KC_HTTPS_KEY_STORE_FILE": "/truststore/truststore.jks",
"KC_HTTPS_CERTIFICATE_FILE": "/etc/x509/tls/localhost.crt",
Expand Down
12 changes: 0 additions & 12 deletions test/start-up-with-containers/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -297,18 +297,6 @@ runs:
run: |
yq e '.server.auth.dpop.enforce = true' -i opentdf.yaml
working-directory: otdf-test-platform
- name: Overlay DPoP-capable Keycloak (26.2)
# The default docker-compose pins Keycloak 25 so downstream consumers stay on
# it; DPoP testing needs Keycloak 26.2 plus the admin-fine-grained-authz:v1
# feature flag, overlaid only when the nonce challenge flow is enabled.
shell: bash
if: ${{ inputs.dpop-challenge-enabled == 'true' }}
run: |
yq e '
(.services.keycloak.image = "keycloak/keycloak:26.2")
| (.services.keycloak.environment.KC_FEATURES = "preview,token-exchange,admin-fine-grained-authz:v1")
' -i docker-compose.yaml
working-directory: otdf-test-platform
- name: Configure logging
shell: bash
env:
Expand Down
Loading