Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ jobs:
build-multiarch:
name: Build & Push (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
needs: [test-chart, test, test-e2e]
# TEMP: skip all gating to speed up image build (revert before merge)
strategy:
matrix:
include:
Expand Down
138 changes: 138 additions & 0 deletions internal/controller/reconcilers/auth/providers/keycloak.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log"
)

const (
// standardTokenExchangeAttr is the Keycloak client attribute that enables
// Standard Token Exchange (V2, RFC 8693) on the requesting client.
standardTokenExchangeAttr = "standard.token.exchange.enabled"

// attrTrue is the literal "true" used as the value for boolean Keycloak
// client attributes (the admin API serializes them as strings).
attrTrue = "true"
)

// KeycloakProvider implements the OIDCProvider interface for Keycloak.
type KeycloakProvider struct {
Client client.Client
Expand Down Expand Up @@ -331,6 +341,26 @@ func (p *KeycloakProvider) ConfigureTokenExchange(ctx context.Context, nebariApp
}
internalID := gocloak.PString(existingClient.ID)

// Enable Keycloak Standard Token Exchange (V2, RFC 8693) on each peer
// client. In Keycloak 26.2+ TOKEN_EXCHANGE_STANDARD_V2 is enabled by
// default and routes the urn:ietf:params:oauth:grant-type:token-exchange
// grant through V2, which checks the requesting client's
// `standard.token.exchange.enabled` attribute. Without this, the legacy
// V1 fine-grained-authz wiring below is silently ignored and every
// exchange returns 403 access_denied "Client not allowed to exchange".
if err := p.enableStandardTokenExchangeOnPeers(ctx, kcClient, token, peerClientIDs); err != nil {
return fmt.Errorf("failed to enable standard token exchange on peers: %w", err)
}

// V2 also requires the target client to be in the requesting client's
// scope tree, otherwise Keycloak rejects with
// 400 invalid_request "Requested audience not available: <target>".
// Add an oidc-audience-mapper on each peer client targeting this client's
// clientId so requests for `audience=<target>` resolve.
if err := p.addAudienceMapperOnPeers(ctx, kcClient, token, peerClientIDs, clientID); err != nil {
return fmt.Errorf("failed to add audience mapper on peers: %w", err)
}

// Step 1: Enable management permissions on the target client.
// Keycloak auto-creates scope permissions (including token-exchange) on
// the realm-management client's authorization resource server.
Expand Down Expand Up @@ -457,6 +487,114 @@ func (p *KeycloakProvider) ConfigureTokenExchange(ctx context.Context, nebariApp
return nil
}

// enableStandardTokenExchangeOnPeers sets the `standard.token.exchange.enabled`
// client attribute to "true" on each peer client. Required by Keycloak Standard
// Token Exchange V2 (RFC 8693). Idempotent: skips peers that are already
// enabled. Missing peer clients are skipped with a log entry rather than
// returning an error so token-exchange wiring stays best-effort across the
// realm.
func (p *KeycloakProvider) enableStandardTokenExchangeOnPeers(
ctx context.Context,
kcClient *gocloak.GoCloak,
token *gocloak.JWT,
peerClientIDs []string,
) error {
logger := log.FromContext(ctx)
for _, peerID := range peerClientIDs {
peer, err := p.findClient(ctx, kcClient, token, peerID)
if err != nil {
return fmt.Errorf("failed to look up peer client %s: %w", peerID, err)
}
if peer == nil {
logger.Info("Peer client not found, skipping standard token exchange enablement", "peer", peerID)
continue
}
attrs := map[string]string{}
if peer.Attributes != nil {
attrs = *peer.Attributes
}
if attrs[standardTokenExchangeAttr] == attrTrue {
continue
}
attrs[standardTokenExchangeAttr] = attrTrue
peer.Attributes = &attrs
if err := kcClient.UpdateClient(ctx, token.AccessToken, p.Config.Realm, *peer); err != nil {
return fmt.Errorf("failed to enable standard token exchange on peer %s: %w", peerID, err)
}
logger.Info("Standard token exchange enabled on peer", "peer", peerID)
}
return nil
}

// addAudienceMapperOnPeers ensures each peer client carries an
// oidc-audience-mapper protocol mapper pointing to the target client's
// clientId. Required by Keycloak Standard Token Exchange V2 alongside the
// `standard.token.exchange.enabled` attribute: V2 only resolves an
// `audience=<target>` parameter when the target appears in the requesting
// client's scope tree, otherwise the exchange returns
// `400 invalid_request "Requested audience not available"`.
//
// The mapper is named `<targetClientID>-audience` so the operator can
// idempotently detect and skip an existing mapper on subsequent reconciles.
func (p *KeycloakProvider) addAudienceMapperOnPeers(
ctx context.Context,
kcClient *gocloak.GoCloak,
token *gocloak.JWT,
peerClientIDs []string,
targetClientID string,
) error {
logger := log.FromContext(ctx)
mapperName := targetClientID + "-audience"
for _, peerID := range peerClientIDs {
peer, err := p.findClient(ctx, kcClient, token, peerID)
if err != nil {
return fmt.Errorf("failed to look up peer client %s: %w", peerID, err)
}
if peer == nil {
logger.Info("Peer client not found, skipping audience mapper", "peer", peerID)
continue
}
peerInternalID := gocloak.PString(peer.ID)

// Skip if an audience mapper for this target already exists. The peer
// client representation from findClient carries the full list of
// protocol mappers; no extra round-trip needed.
exists := false
if peer.ProtocolMappers != nil {
for _, m := range *peer.ProtocolMappers {
if m.Name != nil && *m.Name == mapperName {
exists = true
break
}
}
}
if exists {
continue
}

mapper := gocloak.ProtocolMapperRepresentation{
Name: gocloak.StringP(mapperName),
Protocol: gocloak.StringP("openid-connect"),
ProtocolMapper: gocloak.StringP("oidc-audience-mapper"),
Config: &map[string]string{
"included.client.audience": targetClientID,
"id.token.claim": attrTrue,
"access.token.claim": attrTrue,
},
}
if _, err := kcClient.CreateClientProtocolMapper(ctx, token.AccessToken, p.Config.Realm, peerInternalID, mapper); err != nil {
// Tolerate races where another reconcile created it first.
if !strings.Contains(err.Error(), "409") {
return fmt.Errorf("failed to create audience mapper on peer %s: %w", peerID, err)
}
logger.Info("Audience mapper already exists on peer", "peer", peerID, "audience", targetClientID)
continue
}
logger.Info("Audience mapper added on peer", "peer", peerID, "audience", targetClientID)
}
return nil
}

// CleanupTokenExchange disables management permissions on the client, which
// causes Keycloak to automatically remove the auto-created scope permissions
// (including token-exchange) from realm-management. Peer client policies are
Expand Down
Loading
Loading