diff --git a/README.md b/README.md index 074a70a..a81d8a7 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ All options have a default value. However, all of them can be changed in your in * Execute actions emails * Send forgot passsword mail * Client Authorization, create, update, get, delete Resource, Scope, Policy, Permission, Policy Enforcer +* Get list of protocol mappers for a client scope, create/update/get/delete a protocol mapper * Get list of organizations, create/update/get/delete an organization * Get list of members of an organization, add/remove members * Invite new or existing users to an organization @@ -757,6 +758,62 @@ KeycloakAdmin.realm("realm_a").authz_permissions(client.id, 'scope').delete(scop KeycloakAdmin.realm("realm_a").authz_permissions(client.id, 'resource').delete(resource_permission.id) ``` +### Manage Protocol Mappers for a Client Scope + +Protocol mappers allow you to transform tokens and assertions. The following operations are available on the protocol mappers of a given client scope. + +### List protocol mappers for a client scope + +Returns an array of `KeycloakAdmin::ProtocolMapperRepresentation`. + +```ruby +client_scope_id = "7686af34-204c-4515-8122-78d19febbf6e" +KeycloakAdmin.realm("a_realm").client_scope_protocol_mappers(client_scope_id).list +``` + +### Get a protocol mapper by its id + +Returns an instance of `KeycloakAdmin::ProtocolMapperRepresentation`. + +```ruby +client_scope_id = "7686af34-204c-4515-8122-78d19febbf6e" +mapper_id = "95985b21-d884-4bbd-b852-cb8cd365afc2" +KeycloakAdmin.realm("a_realm").client_scope_protocol_mappers(client_scope_id).get(mapper_id) +``` + +### Create a protocol mapper for a client scope + +Takes `mapper_representation` of type `KeycloakAdmin::ProtocolMapperRepresentation`. Returns `true` on success. + +```ruby +client_scope_id = "7686af34-204c-4515-8122-78d19febbf6e" +mapper = KeycloakAdmin::ProtocolMapperRepresentation.new +mapper.name = "my-mapper" +mapper.protocol = "openid-connect" +mapper.protocolMapper = "oidc-usermodel-attribute-mapper" +mapper.config = { "user.attribute" => "locale", "claim.name" => "locale", "jsonType.label" => "String", "id.token.claim" => "true", "access.token.claim" => "true", "userinfo.token.claim" => "true" } +KeycloakAdmin.realm("a_realm").client_scope_protocol_mappers(client_scope_id).create!(mapper) +``` + +### Update a protocol mapper for a client scope + +Takes `mapper_representation` of type `KeycloakAdmin::ProtocolMapperRepresentation` (must include its `id`). Returns `true` on success. + +```ruby +client_scope_id = "7686af34-204c-4515-8122-78d19febbf6e" +mapper = KeycloakAdmin.realm("a_realm").client_scope_protocol_mappers(client_scope_id).get(mapper_id) +mapper.config["claim.name"] = "updated_claim" +KeycloakAdmin.realm("a_realm").client_scope_protocol_mappers(client_scope_id).update(mapper) +``` + +### Delete a protocol mapper from a client scope + +```ruby +client_scope_id = "7686af34-204c-4515-8122-78d19febbf6e" +mapper_id = "95985b21-d884-4bbd-b852-cb8cd365afc2" +KeycloakAdmin.realm("a_realm").client_scope_protocol_mappers(client_scope_id).delete(mapper_id) +``` + ## How to execute library tests From the `keycloak-admin-api` directory: diff --git a/lib/keycloak-admin.rb b/lib/keycloak-admin.rb index c008c8f..c28f57f 100644 --- a/lib/keycloak-admin.rb +++ b/lib/keycloak-admin.rb @@ -16,6 +16,7 @@ require_relative "keycloak-admin/client/configurable_token_client" require_relative "keycloak-admin/client/attack_detection_client" require_relative "keycloak-admin/client/client_authz_scope_client" +require_relative "keycloak-admin/client/client_scope_protocol_mapper_client" require_relative "keycloak-admin/client/client_authz_resource_client" require_relative "keycloak-admin/client/client_authz_policy_client" require_relative "keycloak-admin/client/client_authz_permission_client" diff --git a/lib/keycloak-admin/client/client_scope_protocol_mapper_client.rb b/lib/keycloak-admin/client/client_scope_protocol_mapper_client.rb new file mode 100644 index 0000000..fe51e7c --- /dev/null +++ b/lib/keycloak-admin/client/client_scope_protocol_mapper_client.rb @@ -0,0 +1,62 @@ +module KeycloakAdmin + class ClientScopeProtocolMapperClient < Client + def initialize(configuration, realm_client, client_scope_id) + super(configuration) + + raise ArgumentError.new("realm must be defined") unless realm_client.name_defined? + + @realm_client = realm_client + @client_scope_id = client_scope_id + end + + def list + response = execute_http do + RestClient::Resource.new(protocol_mappers_url, @configuration.rest_client_options).get(headers) + end + + JSON.parse(response).map { |h| ProtocolMapperRepresentation.from_hash(h) } + end + + def get(mapper_id) + response = execute_http do + RestClient::Resource.new(protocol_mappers_url(mapper_id), @configuration.rest_client_options).get(headers) + end + + ProtocolMapperRepresentation.from_hash(JSON.parse(response)) + end + + def create!(mapper_representation) + execute_http do + RestClient::Resource.new(protocol_mappers_url, @configuration.rest_client_options).post( + create_payload(mapper_representation), headers + ) + end + + true + end + + def update(mapper_representation) + execute_http do + RestClient::Resource.new(protocol_mappers_url(mapper_representation.id), @configuration.rest_client_options).put( + create_payload(mapper_representation), headers + ) + end + + true + end + + def delete(mapper_id) + execute_http do + RestClient::Resource.new(protocol_mappers_url(mapper_id), @configuration.rest_client_options).delete(headers) + end + + true + end + + def protocol_mappers_url(mapper_id = nil) + base = "#{@realm_client.realm_admin_url}/client-scopes/#{@client_scope_id}/protocol-mappers/models" + + mapper_id ? "#{base}/#{mapper_id}" : base + end + end +end diff --git a/lib/keycloak-admin/client/realm_client.rb b/lib/keycloak-admin/client/realm_client.rb index 174d787..57f32e1 100644 --- a/lib/keycloak-admin/client/realm_client.rb +++ b/lib/keycloak-admin/client/realm_client.rb @@ -103,6 +103,10 @@ def user(user_id) UserResource.new(@configuration, self, user_id) end + def client_scope_protocol_mappers(client_scope_id) + ClientScopeProtocolMapperClient.new(@configuration, self, client_scope_id) + end + def authz_scopes(client_id, resource_id = nil) ClientAuthzScopeClient.new(@configuration, self, client_id, resource_id) end diff --git a/spec/client/client_scope_protocol_mapper_client_spec.rb b/spec/client/client_scope_protocol_mapper_client_spec.rb new file mode 100644 index 0000000..296188f --- /dev/null +++ b/spec/client/client_scope_protocol_mapper_client_spec.rb @@ -0,0 +1,230 @@ +RSpec.describe KeycloakAdmin::ClientScopeProtocolMapperClient do + let(:realm_name) { "valid-realm" } + let(:client_scope_id) { "valid-scope-id" } + let(:mapper_id) { "valid-mapper-id" } + + let(:mapper_json) do + <<~JSON + {"id":"valid-mapper-id","name":"my-claim","protocol":"openid-connect","protocolMapper":"oidc-hardcoded-claim-mapper","config":{"claim.name":"my_claim","claim.value":"bar","access.token.claim":"true"}} + JSON + end + + let(:audience_mapper_json) do + <<~JSON + {"protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","name":"audience-config-rvw-123","config":{"included.client.audience":"","included.custom.audience":"https://api.example.com","id.token.claim":"false","access.token.claim":"true","lightweight.claim":"false","introspection.token.claim":"true"}} + JSON + end + + describe "#initialize" do + context "when realm_name is defined" do + it "does not raise any error" do + expect { KeycloakAdmin.realm(realm_name).client_scope_protocol_mappers(client_scope_id) }.to_not raise_error + end + end + + context "when realm_name is not defined" do + it "raises an argument error" do + expect { KeycloakAdmin.realm(nil).client_scope_protocol_mappers(client_scope_id) }.to raise_error(ArgumentError) + end + end + end + + describe "#list" do + before(:each) do + @client = KeycloakAdmin.realm(realm_name).client_scope_protocol_mappers(client_scope_id) + stub_token_client + allow_any_instance_of(RestClient::Resource).to receive(:get).and_return stub_response + end + + context "with a hardcoded claim mapper" do + let(:stub_response) { "[#{mapper_json}]" } + + it "returns one mapper" do + expect(@client.list.size).to eq 1 + end + + it "returns the correct mapper attributes" do + expect(@client.list.first).to have_attributes(id: "valid-mapper-id", name: "my-claim", protocol: "openid-connect", protocolMapper: "oidc-hardcoded-claim-mapper") + end + end + + context "with an audience mapper" do + let(:stub_response) { "[#{audience_mapper_json}]" } + + it "returns one mapper" do + expect(@client.list.size).to eq 1 + end + + it "returns the correct mapper attributes" do + expect(@client.list.first).to have_attributes(name: "audience-config-rvw-123", protocol: "openid-connect", protocolMapper: "oidc-audience-mapper") + end + end + + context "with multiple mappers" do + let(:stub_response) { "[#{mapper_json},#{audience_mapper_json}]" } + + it "returns two mappers" do + expect(@client.list.size).to eq 2 + end + + it "includes both mapper names" do + expect(@client.list.map(&:name)).to include("my-claim", "audience-config-rvw-123") + end + end + end + + describe "#get" do + before(:each) do + @client = KeycloakAdmin.realm(realm_name).client_scope_protocol_mappers(client_scope_id) + stub_token_client + allow_any_instance_of(RestClient::Resource).to receive(:get).and_return stub_response + end + + context "with a hardcoded claim mapper" do + let(:stub_response) { mapper_json } + + it "returns the correct id" do + expect(@client.get(mapper_id).id).to eq "valid-mapper-id" + end + + it "returns the correct name" do + expect(@client.get(mapper_id).name).to eq "my-claim" + end + + it "returns the correct protocol" do + expect(@client.get(mapper_id).protocol).to eq "openid-connect" + end + + it "returns the correct protocolMapper" do + expect(@client.get(mapper_id).protocolMapper).to eq "oidc-hardcoded-claim-mapper" + end + end + + context "with an audience mapper" do + let(:stub_response) { audience_mapper_json } + + it "returns the correct name" do + expect(@client.get(mapper_id).name).to eq "audience-config-rvw-123" + end + + it "returns the correct protocol" do + expect(@client.get(mapper_id).protocol).to eq "openid-connect" + end + + it "returns the correct protocolMapper" do + expect(@client.get(mapper_id).protocolMapper).to eq "oidc-audience-mapper" + end + + it "returns the correct config" do + expect(@client.get(mapper_id).config).to include( + "included.custom.audience" => "https://api.example.com", + "access.token.claim" => "true", + "introspection.token.claim" => "true", + "id.token.claim" => "false" + ) + end + end + end + + describe "#create!" do + before(:each) do + @client = KeycloakAdmin.realm(realm_name).client_scope_protocol_mappers(client_scope_id) + stub_token_client + allow_any_instance_of(RestClient::Resource).to receive(:post).and_return stub_response + end + + context "with a hardcoded claim mapper" do + let(:stub_response) { mapper_json } + let(:mapper_representation) do + mapper = KeycloakAdmin::ProtocolMapperRepresentation.new + mapper.name = "my-claim" + mapper.protocol = "openid-connect" + mapper.protocolMapper = "oidc-hardcoded-claim-mapper" + mapper.config = { "claim.name" => "my_claim", "claim.value" => "bar", "access.token.claim" => "true" } + mapper + end + + it "creates successfully" do + expect(@client.create!(mapper_representation)).to be true + end + end + + context "with an audience mapper" do + let(:stub_response) { audience_mapper_json } + let(:mapper_representation) do + mapper = KeycloakAdmin::ProtocolMapperRepresentation.new + mapper.name = "audience-config-rvw-123" + mapper.protocol = "openid-connect" + mapper.protocolMapper = "oidc-audience-mapper" + mapper.config = { + "included.client.audience" => "", + "included.custom.audience" => "https://api.example.com", + "id.token.claim" => "false", + "access.token.claim" => "true", + "lightweight.claim" => "false", + "introspection.token.claim" => "true" + } + mapper + end + + it "creates successfully" do + expect(@client.create!(mapper_representation)).to be true + end + end + end + + describe "#update" do + before(:each) do + @client = KeycloakAdmin.realm(realm_name).client_scope_protocol_mappers(client_scope_id) + stub_token_client + allow_any_instance_of(RestClient::Resource).to receive(:put).and_return "" + end + + context "with a hardcoded claim mapper" do + let(:mapper_representation) { KeycloakAdmin::ProtocolMapperRepresentation.from_hash(JSON.parse(mapper_json)) } + + it "calls put on the mapper url" do + expect_any_instance_of(RestClient::Resource).to receive(:put).with(anything, anything) + @client.update(mapper_representation) + end + end + + context "with an audience mapper" do + let(:mapper_representation) { KeycloakAdmin::ProtocolMapperRepresentation.from_hash(JSON.parse(audience_mapper_json)) } + + it "calls put on the mapper url" do + expect_any_instance_of(RestClient::Resource).to receive(:put).with(anything, anything) + @client.update(mapper_representation) + end + end + end + + describe "#delete" do + before(:each) do + @client = KeycloakAdmin.realm(realm_name).client_scope_protocol_mappers(client_scope_id) + stub_token_client + allow_any_instance_of(RestClient::Resource).to receive(:delete).and_return "" + end + + it "returns true" do + expect(@client.delete(mapper_id)).to eq true + end + end + + describe "#protocol_mappers_url" do + let(:client) { KeycloakAdmin.realm(realm_name).client_scope_protocol_mappers(client_scope_id) } + let(:base_url) { "http://auth.service.io/auth/admin/realms/valid-realm/client-scopes/valid-scope-id/protocol-mappers/models" } + + context "without a mapper_id" do + it "returns the base url" do + expect(client.protocol_mappers_url).to eq base_url + end + end + + context "with a mapper_id" do + it "returns the url with mapper_id appended" do + expect(client.protocol_mappers_url(mapper_id)).to eq "#{base_url}/valid-mapper-id" + end + end + end +end