From b3748fc19027f65e093b1f401860471cdd85c9ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Wed, 1 Jul 2026 14:11:20 +0200 Subject: [PATCH 1/3] feat: add audience mapper to Keycloak clients RHBK 26.6.2+ rejects token introspection when the calling client is not in the token's aud claim. Previously, Zync created OIDC clients without an audience mapper, so tokens only contained aud: "account". This caused introspection to fail with "Client '' is not in the token audience". Add an oidc-audience-mapper protocol mapper to every client created or updated by Zync. The mapper injects the client's own ID into the aud claim of access tokens, satisfying RHBK's new validation requirement while leaving ID tokens unchanged. Assisted-by: Claude Code --- app/adapters/keycloak_adapter.rb | 16 ++++++++++++++++ test/adapters/keycloak_adapter_test.rb | 13 +++++++++++++ 2 files changed, 29 insertions(+) diff --git a/app/adapters/keycloak_adapter.rb b/app/adapters/keycloak_adapter.rb index 1126a6ed..d438831c 100644 --- a/app/adapters/keycloak_adapter.rb +++ b/app/adapters/keycloak_adapter.rb @@ -54,6 +54,7 @@ def to_h redirectUris: [ redirect_url ].compact, attributes: { '3scale' => true }, enabled: enabled?, + protocolMappers: [audience_mapper], **oidc_configuration, **self.class.attributes, } @@ -79,6 +80,21 @@ def enabled? def self.attributes Rails.application.config.x.keycloak.deep_symbolize_keys.dig(:attributes) || Hash.new end + + private + + def audience_mapper + { + name: 'audience-mapper', + protocol: 'openid-connect', + protocolMapper: 'oidc-audience-mapper', + config: { + 'included.client.audience' => id, + 'id.token.claim' => 'false', + 'access.token.claim' => 'true', + }, + } + end end def self.build_client(attributes) diff --git a/test/adapters/keycloak_adapter_test.rb b/test/adapters/keycloak_adapter_test.rb index e565b854..437137dd 100644 --- a/test/adapters/keycloak_adapter_test.rb +++ b/test/adapters/keycloak_adapter_test.rb @@ -102,6 +102,19 @@ class KeycloakAdapterTest < ActiveSupport::TestCase assert_equal client.to_h.to_json, client.to_json end + test 'audience mapper is always included' do + client = KeycloakAdapter::Client.new(id: 'my-client-id') + mappers = client.to_h[:protocolMappers] + + assert_equal 1, mappers.length + mapper = mappers.first + assert_equal 'audience-mapper', mapper[:name] + assert_equal 'oidc-audience-mapper', mapper[:protocolMapper] + assert_equal 'my-client-id', mapper[:config]['included.client.audience'] + assert_equal 'true', mapper[:config]['access.token.claim'] + assert_equal 'false', mapper[:config]['id.token.claim'] + end + test 'oauth flows' do keycloak = { clientId: "client_id", implicitFlowEnabled: true, serviceAccountsEnabled: true } From 8be746d3a01f3983bdfbcb9bfff0ac68449e5f98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Fri, 3 Jul 2026 12:30:46 +0200 Subject: [PATCH 2/3] test: update stubs for audience mapper The audience mapper feature added protocolMappers to the client payload sent to Keycloak. WebMock stubs in integration tests were matching on exact request bodies without this field, causing 5 test failures. Update all affected stubs to include the audience mapper in their expected request bodies. Assisted-by: Claude Code --- test/integration/data_model_test.rb | 20 +++++++++++++------ .../integration/keycloak_service_test.rb | 3 +++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/test/integration/data_model_test.rb b/test/integration/data_model_test.rb index 746d0c1a..abd5e7a1 100644 --- a/test/integration/data_model_test.rb +++ b/test/integration/data_model_test.rb @@ -91,9 +91,11 @@ def teardown to_return(status: 404) perform_enqueued_jobs do + audience_mapper_foo = [{ name: 'audience-mapper', protocol: 'openid-connect', protocolMapper: 'oidc-audience-mapper', config: { 'included.client.audience' => 'foo', 'id.token.claim' => 'false', 'access.token.claim' => 'true' } }] + stub_request(:put, 'http://example.com/auth/realm/master/clients-registrations/default/foo'). with( - body: '{"name":null,"description":null,"clientId":"foo","secret":"bar","redirectUris":[],"attributes":{"3scale":true},"enabled":null}', + body: { name: nil, description: nil, clientId: 'foo', secret: 'bar', redirectUris: [], attributes: { '3scale' => true }, enabled: nil, protocolMappers: audience_mapper_foo }.to_json, headers: { 'Authorization'=>'Bearer token', 'Content-Type'=>'application/json', @@ -102,7 +104,7 @@ def teardown stub_request(:put, 'http://example.com/auth/realm/master/clients-registrations/default/foo'). with( - body: '{"name":"new-name","description":null,"clientId":"foo","secret":"bar","redirectUris":[],"attributes":{"3scale":true},"enabled":null}', + body: { name: 'new-name', description: nil, clientId: 'foo', secret: 'bar', redirectUris: [], attributes: { '3scale' => true }, enabled: nil, protocolMappers: audience_mapper_foo }.to_json, headers: { 'Authorization'=>'Bearer token', 'Content-Type'=>'application/json', @@ -144,8 +146,10 @@ def teardown stub_oauth_access_token(keycloak) + audience_mapper_foo = [{ name: 'audience-mapper', protocol: 'openid-connect', protocolMapper: 'oidc-audience-mapper', config: { 'included.client.audience' => 'foo', 'id.token.claim' => 'false', 'access.token.claim' => 'true' } }] + stub_request(:put, "http://example.com/clients-registrations/default/foo"). - with(body: '{"name":null,"description":null,"clientId":"foo","secret":"bar","redirectUris":[],"attributes":{"3scale":true},"enabled":null}'). + with(body: { name: nil, description: nil, clientId: 'foo', secret: 'bar', redirectUris: [], attributes: { '3scale' => true }, enabled: nil, protocolMappers: audience_mapper_foo }.to_json). to_return(status: 200) stub_request(:get, "#{tenant.endpoint}/admin/api/applications/find.json?application_id=2"). @@ -180,8 +184,10 @@ def teardown stub_oauth_access_token(keycloak) + audience_mapper_foo = [{ name: 'audience-mapper', protocol: 'openid-connect', protocolMapper: 'oidc-audience-mapper', config: { 'included.client.audience' => 'foo', 'id.token.claim' => 'false', 'access.token.claim' => 'true' } }] + stub_request(:put, "http://example.com/clients-registrations/default/foo"). - with(body: '{"name":null,"description":null,"clientId":"foo","secret":"bar","redirectUris":[],"attributes":{"3scale":true},"enabled":null}'). + with(body: { name: nil, description: nil, clientId: 'foo', secret: 'bar', redirectUris: [], attributes: { '3scale' => true }, enabled: nil, protocolMappers: audience_mapper_foo }.to_json). to_return(status: 200) put_notification(type: 'Application', id: 1, service_id: service.to_param, tenant_id: tenant.to_param) @@ -238,11 +244,13 @@ def teardown stub_oauth_access_token(keycloak1) stub_oauth_access_token(keycloak2) + audience_mapper_foo = [{ name: 'audience-mapper', protocol: 'openid-connect', protocolMapper: 'oidc-audience-mapper', config: { 'included.client.audience' => 'foo', 'id.token.claim' => 'false', 'access.token.claim' => 'true' } }] + stub_request(:put, "http://example.com/clients-registrations/default/foo"). - with(body: '{"name":null,"description":null,"clientId":"foo","secret":"secret-service-one","redirectUris":[],"attributes":{"3scale":true},"enabled":null}'). + with(body: { name: nil, description: nil, clientId: 'foo', secret: 'secret-service-one', redirectUris: [], attributes: { '3scale' => true }, enabled: nil, protocolMappers: audience_mapper_foo }.to_json). to_return(status: 200) stub_request(:put, "http://second.example.com/clients-registrations/default/foo"). - with(body: '{"name":null,"description":null,"clientId":"foo","secret":"secret-service-two","redirectUris":[],"attributes":{"3scale":true},"enabled":null}'). + with(body: { name: nil, description: nil, clientId: 'foo', secret: 'secret-service-two', redirectUris: [], attributes: { '3scale' => true }, enabled: nil, protocolMappers: audience_mapper_foo }.to_json). to_return(status: 200) put_notification(type: 'Application', id: 1, service_id: service1.to_param, tenant_id: tenant.to_param) diff --git a/test/services/integration/keycloak_service_test.rb b/test/services/integration/keycloak_service_test.rb index 7e89e691..4fe69ba7 100644 --- a/test/services/integration/keycloak_service_test.rb +++ b/test/services/integration/keycloak_service_test.rb @@ -39,6 +39,8 @@ def test_new test 'client auth flows attributes' do entry = entries(:client) + audience_mapper = [{ name: 'audience-mapper', protocol: 'openid-connect', protocolMapper: 'oidc-audience-mapper', config: { 'included.client.audience' => 'two_id', 'id.token.claim' => 'false', 'access.token.claim' => 'true' } }] + stub_request(:put, "http://example.com/clients-registrations/default/two_id"). with( body: { @@ -46,6 +48,7 @@ def test_new clientId: "two_id", secret: "two_secret", redirectUris: ["http://example.com"], attributes: {'3scale' => true}, enabled: true, + protocolMappers: audience_mapper, standardFlowEnabled: true, implicitFlowEnabled: true, serviceAccountsEnabled: true, directAccessGrantsEnabled: true, }.to_json, From ddf7f91e9c64b2e549a71fd3f25ca2ee05aea9c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Thu, 9 Jul 2026 12:16:10 +0200 Subject: [PATCH 3/3] test: add coverage for create/update with mapper Added explicit test coverage for both create (POST) and update (PUT) paths through persist() to verify the audience mapper is included in the request body. The create path is triggered when update fails with 404 (client doesn't exist). Both tests stub the full request body with hardcoded fields including protocolMappers, ensuring the mapper is present and correctly configured. Also renamed the existing auth flows test to clarify it covers the update path specifically. Assisted-by: Claude Code --- .../integration/keycloak_service_test.rb | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/test/services/integration/keycloak_service_test.rb b/test/services/integration/keycloak_service_test.rb index 4fe69ba7..ea4f786b 100644 --- a/test/services/integration/keycloak_service_test.rb +++ b/test/services/integration/keycloak_service_test.rb @@ -36,7 +36,35 @@ def test_new end end - test 'client auth flows attributes' do + test 'client create sends audience mapper' do + entry = entries(:client) + + audience_mapper = [{ name: 'audience-mapper', protocol: 'openid-connect', protocolMapper: 'oidc-audience-mapper', config: { 'included.client.audience' => 'two_id', 'id.token.claim' => 'false', 'access.token.claim' => 'true' } }] + + stub_request(:put, "http://example.com/clients-registrations/default/two_id"). + to_return(status: 404) + + stub_request(:post, "http://example.com/clients-registrations/default"). + with( + body: { + name: "client name", description: "client description", + clientId: "two_id", secret: "two_secret", + redirectUris: ["http://example.com"], attributes: {'3scale' => true}, + enabled: true, + protocolMappers: audience_mapper, + standardFlowEnabled: true, implicitFlowEnabled: true, + serviceAccountsEnabled: true, directAccessGrantsEnabled: true, + }.to_json, + ).to_return(status: 200, body: { clientId: 'two_id' }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + subject.tap do |service| + service.adapter.authentication = 'foobar' + service.call(entry) + end + end + + test 'client update sends audience mapper' do entry = entries(:client) audience_mapper = [{ name: 'audience-mapper', protocol: 'openid-connect', protocolMapper: 'oidc-audience-mapper', config: { 'included.client.audience' => 'two_id', 'id.token.claim' => 'false', 'access.token.claim' => 'true' } }]