keycloak-admin 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c198fd53c7034b75c805c4479b73eec2dec2159da57e6cebd8056742ad40cd6
4
- data.tar.gz: f2532b1773d60f633644bb5dd9c0c361933037db228ec871ac92aea73f4baf0b
3
+ metadata.gz: 4a9c09a6765355416960bbc5c4564ad5dd5f526f65093b43d46ac10fc39d0ae7
4
+ data.tar.gz: b2dd1762448fad5a096b94adc3d18b30640235fe02efa1e77269797bfa3f9464
5
5
  SHA512:
6
- metadata.gz: 553e781914529764e787e455918d878e7ce49902e4f75b679f66a6559d59a70684058f655e81f7de23bd3f83e9f884a26c3bad3a9d2c962600de66249176409a
7
- data.tar.gz: 626a03f798614205ffe9218b76f31d8fd625810980c33f49d12ce322e79e5e47977104873863ea36b955b79efb68aa995af3f63efa4aad2beef77b38dba85527
6
+ metadata.gz: 3274b863ef8b7d295b82d5dd87a6d3340ac96ac08c1f38c822ad531b204f4fd5755704a905ccc308821e199d17cc8c73959c3dc1d39a5a13079ce133f1ed4854
7
+ data.tar.gz: 7c8d7b5f54eb523f27eaaa83893d8b5190d4647ed6d7424a6ac1b55168ffa02fb426171b424330db86d976e00c84e1644d3bab3cf4fd398d1eb9cf4deff2e679
@@ -0,0 +1,112 @@
1
+ name: Release
2
+
3
+ # Publishing is triggered by pushing a version tag, e.g.:
4
+ # git tag -a v1.2.0 -m "Version 1.2.0" && git push origin v1.2.0
5
+ #
6
+ # No RubyGems API key is stored in this repository: the gem is pushed through
7
+ # RubyGems' Trusted Publishing, which exchanges a short-lived GitHub OIDC token
8
+ # for a scoped RubyGems credential. This requires a trusted publisher declared on
9
+ # https://rubygems.org/gems/keycloak-admin pointing to this repository, this
10
+ # workflow file name and the 'release' environment.
11
+ on:
12
+ push:
13
+ tags: [ "v*" ]
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ release:
20
+ name: Push gem to RubyGems.org
21
+ # Pinned rather than 'ubuntu-latest', for the same reason as in ci.yml.
22
+ runs-on: ubuntu-24.04
23
+
24
+ environment: release
25
+
26
+ permissions:
27
+ contents: write # 'rake release' pushes the version tag when it does not exist yet
28
+ id-token: write # mandatory to request the OIDC token used by trusted publishing
29
+
30
+ steps:
31
+ # The test suite includes an integration spec that talks to a real Keycloak
32
+ # (spec/integration/client_authorization_spec.rb, gated on ENV["GITHUB_ACTIONS"],
33
+ # which GitHub Actions sets on every run regardless of workflow). Keycloak is
34
+ # provisioned here the same way ci.yml does it, so 'bundle exec rspec' below is a
35
+ # real, complete gate rather than skipping that spec for lack of a server.
36
+ - name: Start Keycloak
37
+ run: |
38
+ docker run -d --name keycloak -p 8080:8080 \
39
+ -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
40
+ -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
41
+ -e KC_HOSTNAME=http://localhost:8080 \
42
+ -e KC_HOSTNAME_ADMIN=http://localhost:8080 \
43
+ -e KC_HTTP_ENABLED=true \
44
+ -e KC_DB=dev-file \
45
+ quay.io/keycloak/keycloak:26.7.0 \
46
+ start-dev
47
+
48
+ - name: Wait for Keycloak to be ready
49
+ run: |
50
+ for i in $(seq 1 30); do
51
+ if curl --silent --fail http://localhost:8080/realms/master > /dev/null; then
52
+ echo "Keycloak is ready"
53
+ exit 0
54
+ fi
55
+ sleep 5
56
+ done
57
+ echo "Keycloak did not become ready in time"
58
+ docker logs keycloak
59
+ exit 1
60
+
61
+ - name: create realm
62
+ run: |
63
+ TOKEN=$(curl --silent --location --request POST "http://localhost:8080/realms/master/protocol/openid-connect/token" \
64
+ --header 'Content-Type: application/x-www-form-urlencoded' \
65
+ --data-urlencode 'grant_type=password' \
66
+ --data-urlencode 'username=admin' \
67
+ --data-urlencode 'password=admin' \
68
+ --data-urlencode 'client_id=admin-cli' | jq -r '.access_token')
69
+
70
+ curl --silent --show-error -L -X POST "http://localhost:8080/admin/realms" \
71
+ --header "Content-Type: application/json" \
72
+ --header "Authorization: Bearer ${TOKEN}" \
73
+ --data '{"realm":"dummy","enabled":true}'
74
+
75
+ curl --silent --show-error --request POST 'http://localhost:8080/admin/realms/dummy/clients' \
76
+ --header 'Authorization: Bearer '$TOKEN \
77
+ --header 'Content-Type: application/json' \
78
+ --data-raw '{
79
+ "clientId":"dummy-client",
80
+ "enabled":true,
81
+ "consentRequired": false,
82
+ "attributes":{},
83
+ "serviceAccountsEnabled": true,
84
+ "protocol":"openid-connect",
85
+ "publicClient":false,
86
+ "authorizationServicesEnabled": true,
87
+ "clientAuthenticatorType":"client-secret",
88
+ "redirectUris":["http://localhost:8180/demo"]
89
+ }'
90
+
91
+ - uses: actions/checkout@v4
92
+
93
+ - name: Set up Ruby
94
+ uses: ruby/setup-ruby@v1
95
+ with:
96
+ ruby-version: '3.4'
97
+ bundler-cache: true
98
+
99
+ - name: Check that the tag matches KeycloakAdmin::VERSION
100
+ run: |
101
+ tag="${GITHUB_REF_NAME#v}"
102
+ version="$(ruby -Ilib -r keycloak-admin/version -e 'print KeycloakAdmin::VERSION')"
103
+ if [ "$tag" != "$version" ]; then
104
+ echo "::error::Tag '${GITHUB_REF_NAME}' does not match KeycloakAdmin::VERSION '${version}'"
105
+ exit 1
106
+ fi
107
+
108
+ - name: Run tests
109
+ run: bundle exec rspec
110
+
111
+ # Builds the gem, pushes it to RubyGems.org and waits for it to be available.
112
+ - uses: rubygems/release-gem@v1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.0.1] - 2026-07-31
9
+
10
+ * [Feature] Requests are now logged through `config.logger` (method, URL, and response status) via Faraday's `:logger` middleware.
11
+ * [Feature] The access token is now cached and reused (tracking `expires_in`) instead of being fetched before nearly every call. Previously, caching lived on the `Client` instance, but a fresh `Client` subclass is created for almost every call (e.g. `KeycloakAdmin.realm(x).users` builds a new `UserClient`), so the cache was rarely hit in practice; it now lives on the shared `Configuration`.
12
+ * Publish the gem to RubyGems from Github Actions when a `v*` tag is pushed, using RubyGems' Trusted Publishing
13
+
8
14
  ## [2.0.0] - 2026-07-31
9
15
 
10
16
  * [Breaking] Replaced `rest-client` with `Faraday` as the underlying HTTP library. This should be transparent for callers of this gem's own API, but `config.rest_client_options` is renamed to `config.faraday_options` and its shape changes from rest-client's flat hash to Faraday's connection options (e.g. `{ timeout: 5 }` becomes `{ request: { timeout: 5 } }`, `{ verify_ssl: false }` becomes `{ ssl: { verify: false } }`). See the `Configuration` section of the README.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- keycloak-admin (2.0.0)
4
+ keycloak-admin (2.0.1)
5
5
  base64
6
6
  faraday (~> 2.0)
7
7
  http-cookie (~> 1.0, >= 1.0.3)
@@ -25,6 +25,7 @@ GEM
25
25
  logger (1.7.0)
26
26
  net-http (0.9.1)
27
27
  uri (>= 0.11.1)
28
+ rake (13.4.2)
28
29
  rspec (3.13.2)
29
30
  rspec-core (~> 3.13.0)
30
31
  rspec-expectations (~> 3.13.0)
@@ -46,6 +47,7 @@ PLATFORMS
46
47
  DEPENDENCIES
47
48
  byebug (= 12.0.0)
48
49
  keycloak-admin!
50
+ rake (>= 13.0)
49
51
  rspec (= 3.13.2)
50
52
 
51
53
  BUNDLED WITH
data/README.md CHANGED
@@ -16,7 +16,7 @@ This gem *does not* require Rails.
16
16
  For example, using `bundle`, add this line to your Gemfile.
17
17
 
18
18
  ```ruby
19
- gem "keycloak-admin", "2.0.0"
19
+ gem "keycloak-admin", "2.0.1"
20
20
  ```
21
21
 
22
22
  ## Login
@@ -885,3 +885,19 @@ From the `keycloak-admin-api` directory:
885
885
  $ docker build . -t keycloak-admin:test
886
886
  $ docker run -v `pwd`:/usr/src/app/ keycloak-admin:test rspec spec
887
887
  ```
888
+
889
+ ## How to release a new version
890
+
891
+ Releases are published to [RubyGems](https://rubygems.org/gems/keycloak-admin) by GitHub Actions
892
+ (`.github/workflows/release.yml`), through [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/):
893
+ no API key is stored in this repository, the workflow exchanges a short-lived GitHub OIDC token for a
894
+ scoped RubyGems credential.
895
+
896
+ 1. Update `KeycloakAdmin::VERSION` in `lib/keycloak-admin/version.rb` and the `CHANGELOG.md`
897
+ 2. Commit and push these changes to `main`
898
+ 3. Tag the commit and push the tag:
899
+
900
+ ```
901
+ $ git tag -a v2.0.1 -m "Version 2.0.1"
902
+ $ git push origin v2.0.1
903
+ ```
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -22,4 +22,5 @@ Gem::Specification.new do |spec|
22
22
  spec.add_dependency "base64"
23
23
  spec.add_development_dependency "rspec", "3.13.2"
24
24
  spec.add_development_dependency "byebug", "12.0.0"
25
+ spec.add_development_dependency "rake", ">= 13.0"
25
26
  end
@@ -9,8 +9,11 @@ module KeycloakAdmin
9
9
  @configuration.server_url&.sub(/\/+\z/, "")
10
10
  end
11
11
 
12
+ # Cached on @configuration, not on this instance, since a new Client subclass is
13
+ # created for nearly every call (e.g. KeycloakAdmin.realm(x).users creates a fresh
14
+ # UserClient) - caching here alone would fetch a new token on almost every request.
12
15
  def current_token
13
- @current_token ||= KeycloakAdmin.create_client(@configuration, @configuration.client_realm_name).token.get
16
+ @configuration.cached_token || @configuration.cache_token(fetch_token)
14
17
  end
15
18
 
16
19
  def headers
@@ -49,8 +52,12 @@ module KeycloakAdmin
49
52
 
50
53
  private
51
54
 
55
+ def fetch_token
56
+ KeycloakAdmin.create_client(@configuration, @configuration.client_realm_name).token.get
57
+ end
58
+
52
59
  def resource(url)
53
- Resource.new(url, @configuration.faraday_options)
60
+ Resource.new(url, @configuration.faraday_options, @configuration.logger)
54
61
  end
55
62
 
56
63
  def http_error(response)
@@ -25,7 +25,8 @@ module KeycloakAdmin
25
25
  Authorization: "Bearer #{user_access_token}",
26
26
  content_type: :json,
27
27
  accept: :json
28
- }
28
+ },
29
+ logger: @configuration.logger
29
30
  )
30
31
  )
31
32
  end
@@ -118,7 +118,8 @@ module KeycloakAdmin
118
118
  url: url,
119
119
  method: :delete,
120
120
  payload: create_payload([role_representation]),
121
- headers: headers
121
+ headers: headers,
122
+ logger: @configuration.logger
122
123
  )
123
124
  )
124
125
  end
@@ -0,0 +1,98 @@
1
+ require "faraday"
2
+ require "uri"
3
+
4
+ module KeycloakAdmin
5
+ # Thin facade over Faraday that mirrors RestClient::Resource/RestClient::Request's call
6
+ # shape, so every client class can build/send requests without depending on Faraday's API
7
+ # directly.
8
+ class Resource
9
+ MIME_TYPES = { json: "application/json" }.freeze
10
+
11
+ def self.execute(options)
12
+ options = options.dup
13
+ method = options.delete(:method)
14
+ url = options.delete(:url)
15
+ payload = options.delete(:payload)
16
+ headers = options.delete(:headers) || {}
17
+ logger = options.delete(:logger)
18
+ new(url, options, logger).send(:request, method, payload, headers)
19
+ end
20
+
21
+ def self.put(url, payload, headers = {}, logger = nil)
22
+ new(url, {}, logger).put(payload, headers)
23
+ end
24
+
25
+ def initialize(url, options = {}, logger = nil)
26
+ @url = url
27
+ @options = options || {}
28
+ @logger = logger
29
+ end
30
+
31
+ def get(headers = {})
32
+ request(:get, nil, headers)
33
+ end
34
+
35
+ def delete(headers = {})
36
+ request(:delete, nil, headers)
37
+ end
38
+
39
+ def post(payload, headers = {})
40
+ request(:post, payload, headers)
41
+ end
42
+
43
+ def put(payload, headers = {})
44
+ request(:put, payload, headers)
45
+ end
46
+
47
+ private
48
+
49
+ def request(method, payload, headers)
50
+ headers = headers || {}
51
+ query = headers[:params]
52
+ send_headers = build_headers(headers.reject { |key, _| key == :params })
53
+ body = encode_payload(payload)
54
+
55
+ # RestClient::Payload::UrlEncoded always stamped this Content-Type for Hash payloads,
56
+ # regardless of what the caller's headers said; token retrieval relies on it and never
57
+ # sets content_type itself.
58
+ if payload.is_a?(Hash) && !send_headers.key?("Content-Type")
59
+ send_headers["Content-Type"] = "application/x-www-form-urlencoded"
60
+ end
61
+
62
+ faraday_response = connection.public_send(method) do |req|
63
+ req.params.update(query) if query
64
+ send_headers.each { |name, value| req.headers[name] = value }
65
+ req.body = body unless body.nil?
66
+ end
67
+ Response.new(faraday_response)
68
+ end
69
+
70
+ def connection
71
+ Faraday.new(url: @url, **@options) do |f|
72
+ f.response :raise_error
73
+ # Added after :raise_error so it logs the raw response (status/duration) before
74
+ # :raise_error can raise, including on failures. Faraday's logger middleware logs
75
+ # headers by default, which would print the bearer token on every call; headers: false
76
+ # keeps this to method/url/status only. Bodies are already off by Faraday's own default.
77
+ f.response :logger, @logger, headers: false if @logger
78
+ f.adapter Faraday.default_adapter
79
+ end
80
+ end
81
+
82
+ def encode_payload(payload)
83
+ return nil if payload.nil?
84
+ payload.is_a?(Hash) ? URI.encode_www_form(payload) : payload.to_s
85
+ end
86
+
87
+ def build_headers(headers)
88
+ headers.each_with_object({}) do |(key, value), result|
89
+ name = key.is_a?(Symbol) ? humanize(key) : key.to_s
90
+ result[name] = value.is_a?(Symbol) ? MIME_TYPES.fetch(value, value.to_s) : value
91
+ end
92
+ end
93
+
94
+ def humanize(key)
95
+ key.to_s.split("_").map { |word| word.capitalize }.join("-")
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,36 @@
1
+ module KeycloakAdmin
2
+ # Wraps a Faraday::Response the way RestClient::Response used to: as the body string
3
+ # itself (RestClient::Response < String), so existing call sites that do JSON.parse(response)
4
+ # or response.to_i keep working, while .body/.headers/.status/.code/.reason_phrase stay
5
+ # available for call sites that need response metadata.
6
+ class Response < String
7
+ attr_reader :status, :reason_phrase, :headers
8
+
9
+ def initialize(faraday_response)
10
+ super(faraday_response.body.to_s)
11
+ @status = faraday_response.status
12
+ @reason_phrase = faraday_response.reason_phrase
13
+ @headers = symbolize_headers(faraday_response.headers)
14
+ end
15
+
16
+ alias_method :code, :status
17
+ alias_method :body, :to_s
18
+
19
+ private
20
+
21
+ def symbolize_headers(faraday_headers)
22
+ faraday_headers.each_with_object({}) do |(key, value), result|
23
+ symbol_key = key.downcase.tr("-", "_").to_sym
24
+ result[symbol_key] = symbol_key == :set_cookie ? split_set_cookie(value) : value
25
+ end
26
+ end
27
+
28
+ # Net::HTTP (used by Faraday's default adapter) joins repeated Set-Cookie header lines with
29
+ # ", " (see Net::HTTPHeader#each_header), losing the one-header-per-cookie structure RestClient
30
+ # exposed as an Array. Re-split on cookie boundaries rather than on every comma, since a comma
31
+ # can also appear inside a cookie's own "Expires=Wed, 09 Jun 2027 ..." attribute.
32
+ def split_set_cookie(value)
33
+ value.split(/,(?=\s*[^;\s]+=)/).map(&:strip)
34
+ end
35
+ end
36
+ end
@@ -27,7 +27,8 @@ module KeycloakAdmin
27
27
  method: :delete,
28
28
  url: realm_level_url,
29
29
  payload: create_payload(role_representation_list),
30
- headers: headers
30
+ headers: headers,
31
+ logger: @configuration.logger
31
32
  )
32
33
  )
33
34
  end
@@ -28,7 +28,8 @@ module KeycloakAdmin
28
28
  method: :put,
29
29
  url: users_url(user_id),
30
30
  payload: create_payload(user_representation_body),
31
- headers: headers
31
+ headers: headers,
32
+ logger: @configuration.logger
32
33
  )
33
34
  )
34
35
  end
@@ -39,7 +40,8 @@ module KeycloakAdmin
39
40
  method: :put,
40
41
  url: "#{users_url(user_id)}/groups/#{group_id}",
41
42
  payload: create_payload({}),
42
- headers: headers
43
+ headers: headers,
44
+ logger: @configuration.logger
43
45
  )
44
46
  )
45
47
  end
@@ -49,7 +51,8 @@ module KeycloakAdmin
49
51
  @configuration.faraday_options.merge(
50
52
  method: :delete,
51
53
  url: "#{users_url(user_id)}/groups/#{group_id}",
52
- headers: headers
54
+ headers: headers,
55
+ logger: @configuration.logger
53
56
  )
54
57
  )
55
58
  end
@@ -116,7 +119,8 @@ module KeycloakAdmin
116
119
  method: :put,
117
120
  url: reset_password_url(user_id),
118
121
  payload: { type: "password", value: new_password, temporary: false }.to_json,
119
- headers: headers
122
+ headers: headers,
123
+ logger: @configuration.logger
120
124
  )
121
125
  )
122
126
  end
@@ -140,7 +144,7 @@ module KeycloakAdmin
140
144
  lifespan_param = lifespan.nil? ? "" : "&lifespan=#{lifespan.seconds}"
141
145
  redirect_uri_param = redirect_uri.nil? ? "" : "&redirect_uri=#{redirect_uri}"
142
146
  client_id_param = client_id.nil? ? "" : "client_id=#{client_id}"
143
- Resource.put("#{execute_actions_email_url(user_id)}?#{client_id_param}#{redirect_uri_param}#{lifespan_param}", create_payload(actions), headers)
147
+ Resource.put("#{execute_actions_email_url(user_id)}?#{client_id_param}#{redirect_uri_param}#{lifespan_param}", create_payload(actions), headers, @configuration.logger)
144
148
  end
145
149
  user_id
146
150
  end
@@ -153,7 +157,8 @@ module KeycloakAdmin
153
157
  method: :post,
154
158
  url: impersonation.impersonation_url,
155
159
  payload: impersonation.body.to_json,
156
- headers: impersonation.headers
160
+ headers: impersonation.headers,
161
+ logger: @configuration.logger
157
162
  )
158
163
  )
159
164
  end
@@ -177,7 +182,8 @@ module KeycloakAdmin
177
182
  @configuration.faraday_options.merge(
178
183
  method: :post,
179
184
  url: logout_url(user_id),
180
- headers: headers
185
+ headers: headers,
186
+ logger: @configuration.logger
181
187
  )
182
188
  )
183
189
  end
@@ -200,7 +206,8 @@ module KeycloakAdmin
200
206
  method: :post,
201
207
  url: federated_identity_url(user_id, idp_id),
202
208
  payload: fed_id_rep.to_json,
203
- headers: headers
209
+ headers: headers,
210
+ logger: @configuration.logger
204
211
  )
205
212
  )
206
213
  end
@@ -4,6 +4,28 @@ module KeycloakAdmin
4
4
  class Configuration
5
5
  attr_accessor :server_url, :server_domain, :client_id, :client_secret, :client_realm_name, :use_service_account, :username, :password, :logger, :faraday_options
6
6
 
7
+ # Treat a cached token as expired this many seconds before its real expiry, so it
8
+ # cannot go stale between the check below and the request that relies on it.
9
+ TOKEN_EXPIRY_SAFETY_MARGIN_SECONDS = 10
10
+
11
+ # Returns the cached TokenRepresentation, or nil if there is none or it is (about to be) expired.
12
+ def cached_token
13
+ return nil if @token_expires_at.nil? || Time.now >= @token_expires_at
14
+ @cached_token
15
+ end
16
+
17
+ def cache_token(token_representation)
18
+ return token_representation unless token_representation.expires_in.is_a?(Numeric)
19
+ @cached_token = token_representation
20
+ @token_expires_at = Time.now + token_representation.expires_in - TOKEN_EXPIRY_SAFETY_MARGIN_SECONDS
21
+ token_representation
22
+ end
23
+
24
+ def clear_cached_token!
25
+ @cached_token = nil
26
+ @token_expires_at = nil
27
+ end
28
+
7
29
  def body_for_token_retrieval
8
30
  if use_service_account
9
31
  body_for_service_account
@@ -1,3 +1,3 @@
1
1
  module KeycloakAdmin
2
- VERSION = "2.0.0"
2
+ VERSION = "2.0.1"
3
3
  end
@@ -86,7 +86,7 @@ RSpec.describe KeycloakAdmin::ClientClient do
86
86
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
87
87
 
88
88
  expect(KeycloakAdmin::Resource).to receive(:new).with(
89
- "http://auth.service.io/auth/admin/realms/valid-realm/clients", faraday_options).and_call_original
89
+ "http://auth.service.io/auth/admin/realms/valid-realm/clients", faraday_options, anything).and_call_original
90
90
 
91
91
  clients = @client_client.list
92
92
  expect(clients.length).to eq 1
@@ -126,7 +126,7 @@ RSpec.describe KeycloakAdmin::ClientClient do
126
126
  faraday_options = {timeout: 10}
127
127
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
128
128
  expect(KeycloakAdmin::Resource).to receive(:new).with(
129
- "http://auth.service.io/auth/admin/realms/valid-realm/clients/95b45037-3980-404c-ba12-784fa1baf2c2", faraday_options).and_call_original
129
+ "http://auth.service.io/auth/admin/realms/valid-realm/clients/95b45037-3980-404c-ba12-784fa1baf2c2", faraday_options, anything).and_call_original
130
130
  @client_client.delete("95b45037-3980-404c-ba12-784fa1baf2c2")
131
131
  end
132
132
  end
@@ -37,7 +37,8 @@ RSpec.describe KeycloakAdmin::ClientRoleMappingsClient do
37
37
 
38
38
  expect(KeycloakAdmin::Resource).to receive(:new).with(
39
39
  "http://auth.service.io/auth/admin/realms/valid-realm/users/test_user/role-mappings/clients/test_client/available",
40
- faraday_options
40
+ faraday_options,
41
+ anything
41
42
  ).and_call_original
42
43
 
43
44
  roles = @client_role_mappings_client.list_available
@@ -74,7 +75,7 @@ RSpec.describe KeycloakAdmin::ClientRoleMappingsClient do
74
75
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
75
76
 
76
77
  expect(KeycloakAdmin::Resource).to receive(:new).with(
77
- "http://auth.service.io/auth/admin/realms/valid-realm/users/test_user/role-mappings/clients/test_client", faraday_options).and_call_original
78
+ "http://auth.service.io/auth/admin/realms/valid-realm/users/test_user/role-mappings/clients/test_client", faraday_options, anything).and_call_original
78
79
 
79
80
  @client_role_mappings_client.save(role_list)
80
81
  end
@@ -23,6 +23,59 @@ RSpec.describe KeycloakAdmin::Client do
23
23
  end
24
24
  end
25
25
 
26
+ describe "#current_token" do
27
+ def token(expires_in)
28
+ KeycloakAdmin::TokenRepresentation.new(
29
+ "access_token", "bearer", expires_in, "refresh_token",
30
+ "refresh_expires_in", "id_token", "not_before_policy", "session_state"
31
+ )
32
+ end
33
+
34
+ it "fetches once and reuses the cached token across Client instances sharing the same configuration" do
35
+ configuration = KeycloakAdmin.config
36
+ configuration.clear_cached_token!
37
+ allow_any_instance_of(KeycloakAdmin::TokenClient).to receive(:get).and_return(token(3600))
38
+
39
+ first = KeycloakAdmin::Client.new(configuration).current_token
40
+ second = KeycloakAdmin::Client.new(configuration).current_token
41
+
42
+ expect(first).to be second
43
+ expect(second).to be second
44
+ end
45
+
46
+ it "fetches a new token once the cached one has expired" do
47
+ configuration = KeycloakAdmin.config
48
+ configuration.clear_cached_token!
49
+ # allow_any_instance_of's sequential and_return(a, b) tracks call count per instance,
50
+ # and a fresh TokenClient is built on every fetch, so a plain external counter is used
51
+ # instead to force the first fetch to be pre-expired and the second one not to be.
52
+ call_count = 0
53
+ allow_any_instance_of(KeycloakAdmin::TokenClient).to receive(:get) do
54
+ call_count += 1
55
+ call_count == 1 ? token(5) : token(3600)
56
+ end
57
+
58
+ expired_immediately = KeycloakAdmin::Client.new(configuration).current_token
59
+ refreshed = KeycloakAdmin::Client.new(configuration).current_token
60
+
61
+ expect(expired_immediately).to_not be refreshed
62
+ expect(call_count).to eq 2
63
+ end
64
+ end
65
+
66
+ describe "#resource" do
67
+ it "builds a Resource with the configuration's faraday_options and logger" do
68
+ configuration = KeycloakAdmin::Configuration.new
69
+ configuration.faraday_options = { timeout: 5 }
70
+ configuration.logger = Logger.new(IO::NULL)
71
+ client = KeycloakAdmin::Client.new(configuration)
72
+
73
+ expect(KeycloakAdmin::Resource).to receive(:new).with("http://x", { timeout: 5 }, configuration.logger)
74
+
75
+ client.send(:resource, "http://x")
76
+ end
77
+ end
78
+
26
79
  describe "#execute_http" do
27
80
  let(:realm_name) { "valid-realm" }
28
81
  before(:each) do
@@ -43,7 +43,7 @@ RSpec.describe KeycloakAdmin::GroupClient do
43
43
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
44
44
 
45
45
  expect(KeycloakAdmin::Resource).to receive(:new).with(
46
- "http://auth.service.io/auth/admin/realms/valid-realm/groups/test_group_id", faraday_options).and_call_original
46
+ "http://auth.service.io/auth/admin/realms/valid-realm/groups/test_group_id", faraday_options, anything).and_call_original
47
47
 
48
48
  group = @group_client.get("test_group_id")
49
49
  expect(group.id).to eq "test_group_id"
@@ -72,7 +72,7 @@ RSpec.describe KeycloakAdmin::GroupClient do
72
72
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
73
73
 
74
74
  expect(KeycloakAdmin::Resource).to receive(:new).with(
75
- "http://auth.service.io/auth/admin/realms/valid-realm/groups", faraday_options).and_call_original
75
+ "http://auth.service.io/auth/admin/realms/valid-realm/groups", faraday_options, anything).and_call_original
76
76
 
77
77
  groups = @group_client.list
78
78
  expect(groups.length).to eq 1
@@ -102,7 +102,7 @@ RSpec.describe KeycloakAdmin::GroupClient do
102
102
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
103
103
 
104
104
  expect(KeycloakAdmin::Resource).to receive(:new).with(
105
- "http://auth.service.io/auth/admin/realms/valid-realm/groups/parent_group_id/children", faraday_options).and_call_original
105
+ "http://auth.service.io/auth/admin/realms/valid-realm/groups/parent_group_id/children", faraday_options, anything).and_call_original
106
106
 
107
107
  groups = @group_client.children("parent_group_id")
108
108
  expect(groups.length).to eq 1
@@ -142,7 +142,7 @@ RSpec.describe KeycloakAdmin::GroupClient do
142
142
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
143
143
 
144
144
  expect(KeycloakAdmin::Resource).to receive(:new).with(
145
- "http://auth.service.io/auth/admin/realms/valid-realm/groups", faraday_options).and_call_original
145
+ "http://auth.service.io/auth/admin/realms/valid-realm/groups", faraday_options, anything).and_call_original
146
146
 
147
147
  @group_client.save(group)
148
148
  end
@@ -172,7 +172,7 @@ RSpec.describe KeycloakAdmin::GroupClient do
172
172
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
173
173
 
174
174
  expect(KeycloakAdmin::Resource).to receive(:new).with(
175
- "http://auth.service.io/auth/admin/realms/valid-realm/groups/test_group_id", faraday_options).and_call_original
175
+ "http://auth.service.io/auth/admin/realms/valid-realm/groups/test_group_id", faraday_options, anything).and_call_original
176
176
 
177
177
  @group_client.save(group)
178
178
  end
@@ -250,7 +250,7 @@ RSpec.describe KeycloakAdmin::GroupClient do
250
250
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
251
251
 
252
252
  expect(KeycloakAdmin::Resource).to receive(:new).with(
253
- "http://auth.service.io/auth/admin/realms/valid-realm/groups/test_group_id", faraday_options).and_raise("error")
253
+ "http://auth.service.io/auth/admin/realms/valid-realm/groups/test_group_id", faraday_options, anything).and_raise("error")
254
254
 
255
255
  expect { @group_client.delete("test_group_id") }.to raise_error("error")
256
256
  end
@@ -82,7 +82,7 @@ RSpec.describe KeycloakAdmin::IdentityProviderClient do
82
82
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
83
83
 
84
84
  expect(KeycloakAdmin::Resource).to receive(:new).with(
85
- "http://auth.service.io/auth/admin/realms/valid-realm/identity-provider/instances", faraday_options).and_call_original
85
+ "http://auth.service.io/auth/admin/realms/valid-realm/identity-provider/instances", faraday_options, anything).and_call_original
86
86
 
87
87
  identity_providers = @identity_provider_client.list
88
88
  expect(identity_providers.length).to eq 1
@@ -57,7 +57,7 @@ RSpec.describe KeycloakAdmin::OrganizationClient do
57
57
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
58
58
 
59
59
  expect(KeycloakAdmin::Resource).to receive(:new).with(
60
- "http://auth.service.io/auth/admin/realms/valid-realm/organizations?briefRepresentation=true", faraday_options).and_call_original
60
+ "http://auth.service.io/auth/admin/realms/valid-realm/organizations?briefRepresentation=true", faraday_options, anything).and_call_original
61
61
 
62
62
  organizations = @organization_client.list
63
63
  expect(organizations.length).to eq 1
@@ -193,7 +193,7 @@ RSpec.describe KeycloakAdmin::OrganizationClient do
193
193
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
194
194
 
195
195
  expect(KeycloakAdmin::Resource).to receive(:new).with(
196
- "http://auth.service.io/auth/admin/realms/valid-realm/organizations/2904e1a1-e5f4-4143-8725-003e54cc8b58", faraday_options).and_raise("error")
196
+ "http://auth.service.io/auth/admin/realms/valid-realm/organizations/2904e1a1-e5f4-4143-8725-003e54cc8b58", faraday_options, anything).and_raise("error")
197
197
 
198
198
  expect { @organization_client.delete("2904e1a1-e5f4-4143-8725-003e54cc8b58") }.to raise_error("error")
199
199
  end
@@ -336,7 +336,7 @@ RSpec.describe KeycloakAdmin::OrganizationClient do
336
336
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
337
337
 
338
338
  expect(KeycloakAdmin::Resource).to receive(:new).with(
339
- "http://auth.service.io/auth/admin/realms/valid-realm/organizations/8f6e474e-e688-4bec-99ba-5dc862594f4b", faraday_options).and_call_original
339
+ "http://auth.service.io/auth/admin/realms/valid-realm/organizations/8f6e474e-e688-4bec-99ba-5dc862594f4b", faraday_options, anything).and_call_original
340
340
 
341
341
  organization = @organization_client.get("8f6e474e-e688-4bec-99ba-5dc862594f4b")
342
342
  expect(organization).to be
@@ -64,7 +64,7 @@ RSpec.describe KeycloakAdmin::RealmClient do
64
64
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
65
65
 
66
66
  expect(KeycloakAdmin::Resource).to receive(:new).with(
67
- "http://auth.service.io/auth/admin/realms", faraday_options).and_call_original
67
+ "http://auth.service.io/auth/admin/realms", faraday_options, anything).and_call_original
68
68
 
69
69
  realms = @realm_client.list
70
70
  expect(realms.length).to eq 1
@@ -91,7 +91,7 @@ RSpec.describe KeycloakAdmin::RealmClient do
91
91
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
92
92
 
93
93
  expect(KeycloakAdmin::Resource).to receive(:new).with(
94
- "http://auth.service.io/auth/admin/realms/valid-realm", faraday_options).and_call_original
94
+ "http://auth.service.io/auth/admin/realms/valid-realm", faraday_options, anything).and_call_original
95
95
 
96
96
  expect(@realm_client.delete).to be_truthy
97
97
  end
@@ -121,7 +121,7 @@ RSpec.describe KeycloakAdmin::RealmClient do
121
121
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
122
122
 
123
123
  expect(KeycloakAdmin::Resource).to receive(:new).with(
124
- "http://auth.service.io/auth/admin/realms", faraday_options).and_call_original
124
+ "http://auth.service.io/auth/admin/realms", faraday_options, anything).and_call_original
125
125
 
126
126
  @realm_client.save(realm)
127
127
  end
@@ -147,7 +147,7 @@ RSpec.describe KeycloakAdmin::RealmClient do
147
147
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
148
148
 
149
149
  expect(KeycloakAdmin::Resource).to receive(:new).with(
150
- "http://auth.service.io/auth/admin/realms/valid-realm", faraday_options).and_call_original
150
+ "http://auth.service.io/auth/admin/realms/valid-realm", faraday_options, anything).and_call_original
151
151
 
152
152
  @realm_client.update(realm_json)
153
153
  end
@@ -0,0 +1,223 @@
1
+ RSpec.describe KeycloakAdmin::Resource do
2
+ # A real, mutable stand-in for the Faraday::Request builder yielded by
3
+ # connection.get/post/put/delete { |req| ... }.
4
+ FakeRequest = Struct.new(:params, :headers) do
5
+ attr_accessor :body
6
+ end
7
+
8
+ let(:fake_request) { FakeRequest.new({}, {}) }
9
+ let(:fake_response) { double(status: 200, body: "ok", headers: {}, reason_phrase: "OK") }
10
+
11
+ def stub_connection(resource, verb)
12
+ connection = double
13
+ allow(connection).to receive(verb) do |&block|
14
+ block.call(fake_request) if block
15
+ fake_response
16
+ end
17
+ allow(resource).to receive(:connection).and_return(connection)
18
+ connection
19
+ end
20
+
21
+ describe "#connection (the real Faraday connection, not stubbed)" do
22
+ def middleware_classes(resource)
23
+ resource.send(:connection).builder.handlers
24
+ end
25
+
26
+ it "does not register the logger middleware when no logger is configured" do
27
+ resource = KeycloakAdmin::Resource.new("http://example.com")
28
+ expect(middleware_classes(resource)).to_not include(Faraday::Response::Logger)
29
+ end
30
+
31
+ it "registers the logger middleware when a logger is configured" do
32
+ resource = KeycloakAdmin::Resource.new("http://example.com", {}, Logger.new(IO::NULL))
33
+ expect(middleware_classes(resource)).to include(Faraday::Response::Logger)
34
+ end
35
+
36
+ it "orders :raise_error before :logger, so a response is logged even when it raises" do
37
+ resource = KeycloakAdmin::Resource.new("http://example.com", {}, Logger.new(IO::NULL))
38
+ handlers = middleware_classes(resource)
39
+ expect(handlers.index(Faraday::Response::RaiseError)).to be < handlers.index(Faraday::Response::Logger)
40
+ end
41
+
42
+ it "logs method/url/status but never headers, since Faraday logs headers by default and one of them is the bearer token" do
43
+ log_output = StringIO.new
44
+ logger = Logger.new(log_output)
45
+ stubs = Faraday::Adapter::Test::Stubs.new
46
+ stubs.get(//) { [200, {}, "ok"] }
47
+
48
+ resource = KeycloakAdmin::Resource.new("http://example.com/x", {}, logger)
49
+ allow(resource).to receive(:connection) do
50
+ Faraday.new(url: "http://example.com/x") do |f|
51
+ f.response :raise_error
52
+ f.response :logger, logger, headers: false
53
+ f.adapter :test, stubs
54
+ end
55
+ end
56
+
57
+ resource.get(Authorization: "Bearer super-secret-token")
58
+
59
+ expect(log_output.string).to include("request: GET")
60
+ expect(log_output.string).to include("response: Status 200")
61
+ expect(log_output.string).to_not include("super-secret-token")
62
+ expect(log_output.string).to_not include("Authorization")
63
+ end
64
+ end
65
+
66
+ describe "#get" do
67
+ it "humanizes symbol header keys into HTTP header names" do
68
+ resource = KeycloakAdmin::Resource.new("http://example.com/users")
69
+ stub_connection(resource, :get)
70
+
71
+ resource.get(Authorization: "Bearer token", content_type: :json, accept: :json)
72
+
73
+ expect(fake_request.headers).to eq(
74
+ "Authorization" => "Bearer token",
75
+ "Content-Type" => "application/json",
76
+ "Accept" => "application/json"
77
+ )
78
+ end
79
+
80
+ it "extracts :params into the query string instead of sending it as a header" do
81
+ resource = KeycloakAdmin::Resource.new("http://example.com/users")
82
+ stub_connection(resource, :get)
83
+
84
+ resource.get(Authorization: "Bearer token", params: { search: "jean" })
85
+
86
+ expect(fake_request.params).to eq(search: "jean")
87
+ expect(fake_request.headers).to eq("Authorization" => "Bearer token")
88
+ end
89
+
90
+ it "passes a string content_type through unchanged" do
91
+ resource = KeycloakAdmin::Resource.new("http://example.com/users")
92
+ stub_connection(resource, :get)
93
+
94
+ resource.get(content_type: "application/x-www-form-urlencoded")
95
+
96
+ expect(fake_request.headers["Content-Type"]).to eq "application/x-www-form-urlencoded"
97
+ end
98
+
99
+ it "does not set a body" do
100
+ resource = KeycloakAdmin::Resource.new("http://example.com/users")
101
+ stub_connection(resource, :get)
102
+
103
+ resource.get({})
104
+
105
+ expect(fake_request.body).to be_nil
106
+ end
107
+
108
+ it "wraps the Faraday response in a KeycloakAdmin::Response" do
109
+ resource = KeycloakAdmin::Resource.new("http://example.com/users")
110
+ stub_connection(resource, :get)
111
+
112
+ expect(resource.get({})).to be_a KeycloakAdmin::Response
113
+ end
114
+ end
115
+
116
+ describe "#post" do
117
+ it "sends a pre-serialized string payload as the body unchanged" do
118
+ resource = KeycloakAdmin::Resource.new("http://example.com/users")
119
+ stub_connection(resource, :post)
120
+
121
+ resource.post('{"username":"jean"}', content_type: :json)
122
+
123
+ expect(fake_request.body).to eq '{"username":"jean"}'
124
+ expect(fake_request.headers["Content-Type"]).to eq "application/json"
125
+ end
126
+
127
+ it "form-encodes a Hash payload" do
128
+ resource = KeycloakAdmin::Resource.new("http://example.com/users")
129
+ stub_connection(resource, :post)
130
+
131
+ resource.post({ email: "a@b.com", firstName: "Jean Yves" }, {})
132
+
133
+ expect(fake_request.body).to eq "email=a%40b.com&firstName=Jean+Yves"
134
+ end
135
+
136
+ it "stamps Content-Type: application/x-www-form-urlencoded for a Hash payload when none was set" do
137
+ resource = KeycloakAdmin::Resource.new("http://example.com/users")
138
+ stub_connection(resource, :post)
139
+
140
+ resource.post({ id: "42" }, {})
141
+
142
+ expect(fake_request.headers["Content-Type"]).to eq "application/x-www-form-urlencoded"
143
+ end
144
+
145
+ it "does not override an explicit Content-Type for a Hash payload" do
146
+ resource = KeycloakAdmin::Resource.new("http://example.com/users")
147
+ stub_connection(resource, :post)
148
+
149
+ resource.post({ id: "42" }, content_type: "application/vnd.custom+form")
150
+
151
+ expect(fake_request.headers["Content-Type"]).to eq "application/vnd.custom+form"
152
+ end
153
+ end
154
+
155
+ describe "#delete" do
156
+ it "sends no body and only the given headers" do
157
+ resource = KeycloakAdmin::Resource.new("http://example.com/users/1")
158
+ stub_connection(resource, :delete)
159
+
160
+ resource.delete(Authorization: "Bearer token")
161
+
162
+ expect(fake_request.body).to be_nil
163
+ expect(fake_request.headers).to eq("Authorization" => "Bearer token")
164
+ end
165
+ end
166
+
167
+ describe ".execute (RestClient::Request.execute equivalent)" do
168
+ it "dispatches an arbitrary method with a payload, including DELETE with a body" do
169
+ resource_instance = KeycloakAdmin::Resource.new("http://example.com/mappings")
170
+ allow(KeycloakAdmin::Resource).to receive(:new).and_return(resource_instance)
171
+ stub_connection(resource_instance, :delete)
172
+
173
+ KeycloakAdmin::Resource.execute(
174
+ method: :delete,
175
+ url: "http://example.com/mappings",
176
+ payload: '[{"id":"1"}]',
177
+ headers: { content_type: :json }
178
+ )
179
+
180
+ expect(fake_request.body).to eq '[{"id":"1"}]'
181
+ expect(fake_request.headers["Content-Type"]).to eq "application/json"
182
+ end
183
+
184
+ it "forwards non-method/url/payload/headers keys as connection options" do
185
+ resource_instance = KeycloakAdmin::Resource.new("http://example.com/x")
186
+ expected_options = { timeout: 5 }
187
+ stub_connection(resource_instance, :get)
188
+ expect(KeycloakAdmin::Resource).to receive(:new).with("http://example.com/x", expected_options, nil).and_return(resource_instance)
189
+
190
+ KeycloakAdmin::Resource.execute(method: :get, url: "http://example.com/x", headers: {}, timeout: 5)
191
+ end
192
+
193
+ it "extracts :logger and passes it positionally instead of leaving it in connection options" do
194
+ resource_instance = KeycloakAdmin::Resource.new("http://example.com/x")
195
+ logger = Logger.new(IO::NULL)
196
+ stub_connection(resource_instance, :get)
197
+ expect(KeycloakAdmin::Resource).to receive(:new).with("http://example.com/x", {}, logger).and_return(resource_instance)
198
+
199
+ KeycloakAdmin::Resource.execute(method: :get, url: "http://example.com/x", headers: {}, logger: logger)
200
+ end
201
+ end
202
+
203
+ describe ".put (RestClient.put shorthand)" do
204
+ it "posts without requiring any connection options" do
205
+ resource_instance = KeycloakAdmin::Resource.new("http://example.com/x")
206
+ allow(KeycloakAdmin::Resource).to receive(:new).and_return(resource_instance)
207
+ stub_connection(resource_instance, :put)
208
+
209
+ KeycloakAdmin::Resource.put("http://example.com/x", "body", {})
210
+
211
+ expect(fake_request.body).to eq "body"
212
+ end
213
+
214
+ it "forwards the given logger to the underlying Resource" do
215
+ resource_instance = KeycloakAdmin::Resource.new("http://example.com/x")
216
+ logger = Logger.new(IO::NULL)
217
+ stub_connection(resource_instance, :put)
218
+ expect(KeycloakAdmin::Resource).to receive(:new).with("http://example.com/x", {}, logger).and_return(resource_instance)
219
+
220
+ KeycloakAdmin::Resource.put("http://example.com/x", "body", {}, logger)
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,68 @@
1
+ RSpec.describe KeycloakAdmin::Response do
2
+ def fake_faraday_response(status: 200, body: "", headers: {}, reason_phrase: "OK")
3
+ double(status: status, body: body, headers: headers, reason_phrase: reason_phrase)
4
+ end
5
+
6
+ describe "body-as-string behavior (RestClient::Response was a String subclass)" do
7
+ it "behaves like the raw body string" do
8
+ response = KeycloakAdmin::Response.new(fake_faraday_response(body: '{"a":1}'))
9
+ expect(response).to eq '{"a":1}'
10
+ expect(JSON.parse(response)).to eq({ "a" => 1 })
11
+ end
12
+
13
+ it "supports String methods relied on by callers, e.g. #to_i" do
14
+ response = KeycloakAdmin::Response.new(fake_faraday_response(body: "42"))
15
+ expect(response.to_i).to eq 42
16
+ end
17
+
18
+ it "exposes #body returning the same content" do
19
+ response = KeycloakAdmin::Response.new(fake_faraday_response(body: "hello"))
20
+ expect(response.body).to eq "hello"
21
+ end
22
+ end
23
+
24
+ describe "#status / #code / #reason_phrase" do
25
+ it "exposes the HTTP status" do
26
+ response = KeycloakAdmin::Response.new(fake_faraday_response(status: 201))
27
+ expect(response.status).to eq 201
28
+ expect(response.code).to eq 201
29
+ end
30
+
31
+ it "exposes the reason phrase" do
32
+ response = KeycloakAdmin::Response.new(fake_faraday_response(reason_phrase: "Created"))
33
+ expect(response.reason_phrase).to eq "Created"
34
+ end
35
+ end
36
+
37
+ describe "#headers" do
38
+ it "symbolizes header names the way RestClient did (downcased, dash to underscore)" do
39
+ response = KeycloakAdmin::Response.new(fake_faraday_response(headers: { "Location" => "/users/42", "Content-Type" => "application/json" }))
40
+ expect(response.headers[:location]).to eq "/users/42"
41
+ expect(response.headers[:content_type]).to eq "application/json"
42
+ end
43
+
44
+ it "splits a single Set-Cookie header into a one-element array" do
45
+ response = KeycloakAdmin::Response.new(fake_faraday_response(headers: { "Set-Cookie" => "a=1; Path=/" }))
46
+ expect(response.headers[:set_cookie]).to eq ["a=1; Path=/"]
47
+ end
48
+
49
+ it "splits multiple Set-Cookie headers joined by Faraday's net_http adapter" do
50
+ joined = "KEYCLOAK_SESSION=abc; Path=/, KEYCLOAK_IDENTITY=def; Path=/, AUTH_SESSION_ID=ghi; Path=/"
51
+ response = KeycloakAdmin::Response.new(fake_faraday_response(headers: { "Set-Cookie" => joined }))
52
+ expect(response.headers[:set_cookie]).to eq [
53
+ "KEYCLOAK_SESSION=abc; Path=/",
54
+ "KEYCLOAK_IDENTITY=def; Path=/",
55
+ "AUTH_SESSION_ID=ghi; Path=/"
56
+ ]
57
+ end
58
+
59
+ it "does not split on a comma embedded in a cookie's own Expires attribute" do
60
+ joined = "a=1; Expires=Wed, 09 Jun 2027 10:18:14 GMT; Path=/, b=2; Path=/"
61
+ response = KeycloakAdmin::Response.new(fake_faraday_response(headers: { "Set-Cookie" => joined }))
62
+ expect(response.headers[:set_cookie]).to eq [
63
+ "a=1; Expires=Wed, 09 Jun 2027 10:18:14 GMT; Path=/",
64
+ "b=2; Path=/"
65
+ ]
66
+ end
67
+ end
68
+ end
@@ -39,7 +39,7 @@ RSpec.describe KeycloakAdmin::RoleClient do
39
39
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
40
40
 
41
41
  expect(KeycloakAdmin::Resource).to receive(:new).with(
42
- "http://auth.service.io/auth/admin/realms/valid-realm/roles", faraday_options).and_call_original
42
+ "http://auth.service.io/auth/admin/realms/valid-realm/roles", faraday_options, anything).and_call_original
43
43
 
44
44
  roles = @role_client.list
45
45
  expect(roles.length).to eq 1
@@ -71,7 +71,7 @@ RSpec.describe KeycloakAdmin::RoleClient do
71
71
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
72
72
 
73
73
  expect(KeycloakAdmin::Resource).to receive(:new).with(
74
- "http://auth.service.io/auth/admin/realms/valid-realm/roles", faraday_options).and_call_original
74
+ "http://auth.service.io/auth/admin/realms/valid-realm/roles", faraday_options, anything).and_call_original
75
75
 
76
76
  @role_client.save(role)
77
77
  end
@@ -60,7 +60,7 @@ RSpec.describe KeycloakAdmin::RoleMapperClient do
60
60
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
61
61
 
62
62
  expect(KeycloakAdmin::Resource).to receive(:new).with(
63
- "http://auth.service.io/auth/admin/realms/valid-realm/users/test_user/role-mappings/realm", faraday_options).and_call_original
63
+ "http://auth.service.io/auth/admin/realms/valid-realm/users/test_user/role-mappings/realm", faraday_options, anything).and_call_original
64
64
 
65
65
  @role_mapper_client.save_realm_level(role_list)
66
66
  end
@@ -54,7 +54,7 @@ RSpec.describe KeycloakAdmin::TokenClient do
54
54
  stub_post
55
55
 
56
56
  expect(KeycloakAdmin::Resource).to receive(:new).with(
57
- "http://auth.service.io/auth/realms/valid-realm/protocol/openid-connect/token", faraday_options).and_call_original
57
+ "http://auth.service.io/auth/realms/valid-realm/protocol/openid-connect/token", faraday_options, anything).and_call_original
58
58
 
59
59
  @token_client.get
60
60
  end
@@ -145,7 +145,7 @@ RSpec.describe KeycloakAdmin::TokenClient do
145
145
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
146
146
 
147
147
  expect(KeycloakAdmin::Resource).to receive(:new).with(
148
- "http://auth.service.io/auth/admin/realms/valid-realm/users", faraday_options).and_call_original
148
+ "http://auth.service.io/auth/admin/realms/valid-realm/users", faraday_options, anything).and_call_original
149
149
 
150
150
  expect(@user_client.save(user)).to eq user
151
151
  end
@@ -171,7 +171,7 @@ RSpec.describe KeycloakAdmin::TokenClient do
171
171
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
172
172
 
173
173
  expect(KeycloakAdmin::Resource).to receive(:new).with(
174
- "http://auth.service.io/auth/admin/realms/valid-realm/users/test_user_id", faraday_options).and_call_original
174
+ "http://auth.service.io/auth/admin/realms/valid-realm/users/test_user_id", faraday_options, anything).and_call_original
175
175
 
176
176
  user = @user_client.get('test_user_id')
177
177
  expect(user.username).to eq 'test_username'
@@ -217,7 +217,7 @@ RSpec.describe KeycloakAdmin::TokenClient do
217
217
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
218
218
 
219
219
  expect(KeycloakAdmin::Resource).to receive(:new).with(
220
- "http://auth.service.io/auth/admin/realms/valid-realm/users", faraday_options).and_call_original
220
+ "http://auth.service.io/auth/admin/realms/valid-realm/users", faraday_options, anything).and_call_original
221
221
 
222
222
  users = @user_client.search("test_username")
223
223
  expect(users.length).to eq 1
@@ -250,7 +250,7 @@ RSpec.describe KeycloakAdmin::TokenClient do
250
250
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
251
251
 
252
252
  expect(KeycloakAdmin::Resource).to receive(:new).with(
253
- "http://auth.service.io/auth/admin/realms/valid-realm/users", faraday_options).and_call_original
253
+ "http://auth.service.io/auth/admin/realms/valid-realm/users", faraday_options, anything).and_call_original
254
254
 
255
255
  users = @user_client.list
256
256
  expect(users.length).to eq 1
@@ -277,7 +277,7 @@ RSpec.describe KeycloakAdmin::TokenClient do
277
277
  allow_any_instance_of(KeycloakAdmin::Configuration).to receive(:faraday_options).and_return faraday_options
278
278
 
279
279
  expect(KeycloakAdmin::Resource).to receive(:new).with(
280
- "http://auth.service.io/auth/admin/realms/valid-realm/users/test_user_id", faraday_options).and_call_original
280
+ "http://auth.service.io/auth/admin/realms/valid-realm/users/test_user_id", faraday_options, anything).and_call_original
281
281
 
282
282
  @user_client.delete('test_user_id')
283
283
  end
@@ -68,6 +68,43 @@ RSpec.describe KeycloakAdmin::RealmClient do
68
68
  end
69
69
  end
70
70
 
71
+ describe "#cached_token / #cache_token / #clear_cached_token!" do
72
+ def token(expires_in)
73
+ KeycloakAdmin::TokenRepresentation.new(
74
+ "access_token", "bearer", expires_in, "refresh_token",
75
+ "refresh_expires_in", "id_token", "not_before_policy", "session_state"
76
+ )
77
+ end
78
+
79
+ it "has no cached token initially" do
80
+ expect(@configuration.cached_token).to be_nil
81
+ end
82
+
83
+ it "returns the token it was given, while it is still within its expiry" do
84
+ cached = @configuration.cache_token(token(3600))
85
+ expect(cached.access_token).to eq "access_token"
86
+ expect(@configuration.cached_token).to eq cached
87
+ end
88
+
89
+ it "treats a token as expired a bit before its real expiry, not just after" do
90
+ # expires_in: 5, minus the 10s safety margin, is already in the past the moment it is cached.
91
+ @configuration.cache_token(token(5))
92
+ expect(@configuration.cached_token).to be_nil
93
+ end
94
+
95
+ it "does not cache a token with a non-numeric expires_in, but still returns it" do
96
+ returned = @configuration.cache_token(token("not-a-number"))
97
+ expect(returned.access_token).to eq "access_token"
98
+ expect(@configuration.cached_token).to be_nil
99
+ end
100
+
101
+ it "clears the cache" do
102
+ @configuration.cache_token(token(3600))
103
+ @configuration.clear_cached_token!
104
+ expect(@configuration.cached_token).to be_nil
105
+ end
106
+ end
107
+
71
108
  describe "#body_for_token_retrieval" do
72
109
  before(:each) do
73
110
  @body = @configuration.body_for_token_retrieval
data/spec/spec_helper.rb CHANGED
@@ -19,11 +19,18 @@ RSpec.configure do |config|
19
19
  end
20
20
 
21
21
  configure
22
+
23
+ # KeycloakAdmin.config is a global set up once for the whole suite (see 'configure'
24
+ # above), not per example, so a token cached in one example would otherwise leak into
25
+ # the next one and silently skip its stub_token_client expectations.
26
+ config.before(:each) do
27
+ KeycloakAdmin.config.clear_cached_token!
28
+ end
22
29
  end
23
30
 
24
31
  def stub_token_client
25
32
  allow_any_instance_of(KeycloakAdmin::TokenClient).to receive(:get).and_return KeycloakAdmin::TokenRepresentation.new(
26
- "test_access_token", "token_type", "expires_in", "refresh_token",
33
+ "test_access_token", "token_type", 3600, "refresh_token",
27
34
  "refresh_expires_in", "id_token", "not_before_policy", "session_state"
28
35
  )
29
36
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keycloak-admin
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lorent Lempereur
@@ -85,6 +85,20 @@ dependencies:
85
85
  - - '='
86
86
  - !ruby/object:Gem::Version
87
87
  version: 12.0.0
88
+ - !ruby/object:Gem::Dependency
89
+ name: rake
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '13.0'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '13.0'
88
102
  description: Keycloak Admin REST API client written in Ruby
89
103
  email:
90
104
  - lorent.lempereur.dev@gmail.com
@@ -94,6 +108,7 @@ extra_rdoc_files: []
94
108
  files:
95
109
  - ".github/workflows/Dockerfile"
96
110
  - ".github/workflows/ci.yml"
111
+ - ".github/workflows/release.yml"
97
112
  - ".gitignore"
98
113
  - ".rspec"
99
114
  - CHANGELOG.md
@@ -102,6 +117,7 @@ files:
102
117
  - Gemfile.lock
103
118
  - MIT-LICENSE
104
119
  - README.md
120
+ - Rakefile
105
121
  - bin/console
106
122
  - keycloak-admin.gemspec
107
123
  - lib/keycloak-admin.rb
@@ -121,6 +137,8 @@ files:
121
137
  - lib/keycloak-admin/client/identity_provider_client.rb
122
138
  - lib/keycloak-admin/client/organization_client.rb
123
139
  - lib/keycloak-admin/client/realm_client.rb
140
+ - lib/keycloak-admin/client/resource.rb
141
+ - lib/keycloak-admin/client/response.rb
124
142
  - lib/keycloak-admin/client/role_client.rb
125
143
  - lib/keycloak-admin/client/role_mapper_client.rb
126
144
  - lib/keycloak-admin/client/token_client.rb
@@ -171,6 +189,8 @@ files:
171
189
  - spec/client/identity_provider_client_spec.rb
172
190
  - spec/client/organization_client_spec.rb
173
191
  - spec/client/realm_client_spec.rb
192
+ - spec/client/resource_spec.rb
193
+ - spec/client/response_spec.rb
174
194
  - spec/client/role_client_spec.rb
175
195
  - spec/client/role_mapper_client_spec.rb
176
196
  - spec/client/token_client_spec.rb
@@ -216,7 +236,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
216
236
  - !ruby/object:Gem::Version
217
237
  version: '0'
218
238
  requirements: []
219
- rubygems_version: 4.0.17
239
+ rubygems_version: 3.6.9
220
240
  specification_version: 4
221
241
  summary: Keycloak Admin REST API client written in Ruby
222
242
  test_files: []