kinde_sdk 1.4.0 → 1.6.0

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: 4393bedaa94f9f37bcdf785519f34d2a563a812953e9a79172b386dc70b1bdf1
4
- data.tar.gz: c6ed0c2101f8bdde09679b1354f92106180ac9842c79899ff4991af80e207006
3
+ metadata.gz: 83f3ec0e5e4b6f61d409b0a6c6314459917c94fa6109440fda5634e7d2e747f6
4
+ data.tar.gz: 35dd34872266b28076bf0a9a89f5bb624d6a314d4067fc359a49f901403ef7a2
5
5
  SHA512:
6
- metadata.gz: 3a66add0908092ef798854740ebabaa033e21565386dd6724915c2a494bc393c2ad8f52b04e29d26c92df93ffb2fbc133d5749f3e514e98e3e015a2246bc4f22
7
- data.tar.gz: 70a0439d8b50580546a9e2d4cad90a32eda4bbe276425653be46fd38975918a8aaaab9842261e690dfb7df8962995cb23a064c8629f540e28acd6c334799c348
6
+ metadata.gz: 58130b81b2d67b3c2ef8c4f34b6d87157016270b08af9d19d77bb67cfae9b27b32937bbd8b90891f0590b9ad7a7d4cdb0443cf4a2281983567c1960415f60bb8
7
+ data.tar.gz: fa2f18f0219dbe76661f9a6809deeb0a1e5fcfde13ea300533d3be8c342c97902143392e1c9991e3b94ad5bfda319eefb042f21de27405e091d7b6fbb2d34c75
@@ -9,6 +9,7 @@
9
9
  # Generated by: https://openapi-generator.tech
10
10
  # OpenAPI Generator version: 7.0.0-SNAPSHOT
11
11
  #
12
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
12
13
 
13
14
  # load the gem
14
15
  require 'kinde_api'
@@ -1,4 +1,4 @@
1
- require "kinde_api"
1
+ require_relative '../../kinde_api/lib/kinde_api'
2
2
 
3
3
  module KindeSdk
4
4
  class Client
@@ -10,6 +10,11 @@ module KindeSdk
10
10
  attr_accessor :authorize_url
11
11
  attr_accessor :token_url
12
12
 
13
+ attr_accessor :jwks_url
14
+ attr_accessor :jwks
15
+ attr_accessor :expected_issuer
16
+ attr_accessor :expected_audience
17
+
13
18
  attr_accessor :logger
14
19
  attr_accessor :debugging
15
20
  attr_accessor :oauth_client
@@ -19,6 +24,9 @@ module KindeSdk
19
24
  def initialize
20
25
  @authorize_url = '/oauth2/auth'
21
26
  @token_url = '/oauth2/token'
27
+ @jwks_url = '/.well-known/jwks.json'
28
+ @expected_audience = nil
29
+ @expected_issuer = nil
22
30
  @debugging = false
23
31
  @logger = defined?(Rails) ? Rails.logger : Logger.new(STDOUT)
24
32
  @scope = 'openid offline email profile'
@@ -0,0 +1,5 @@
1
+ module KindeSdk
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace KindeSdk # Optional, for mountable engines
4
+ end
5
+ end
@@ -1,3 +1,3 @@
1
1
  module KindeSdk
2
- VERSION = "1.4.0"
2
+ VERSION = "1.6.0"
3
3
  end
data/lib/kinde_sdk.rb CHANGED
@@ -1,20 +1,28 @@
1
1
  require "logger"
2
+ require "rails"
2
3
  require "kinde_sdk/version"
3
4
  require "kinde_sdk/configuration"
4
5
  require "kinde_sdk/client/feature_flags"
5
6
  require "kinde_sdk/client/permissions"
6
- require "kinde_sdk/controllers/auth_controller"
7
7
  require "kinde_sdk/client"
8
8
  require 'securerandom'
9
9
  require 'oauth2'
10
10
  require 'pkce_challenge'
11
11
  require 'faraday/follow_redirects'
12
12
  require 'uri'
13
+ require 'httparty'
14
+ require 'jwt'
15
+ require 'openssl'
16
+ require 'base64'
13
17
 
14
18
  module KindeSdk
15
19
  class << self
16
20
  attr_accessor :config
17
21
 
22
+ if defined?(Rails)
23
+ require "kinde_sdk/engine"
24
+ end
25
+
18
26
  def configure
19
27
  if block_given?
20
28
  yield(Configuration.default)
@@ -102,6 +110,8 @@ module KindeSdk
102
110
  #
103
111
  # @return [KindeSdk::Client]
104
112
  def client(tokens_hash)
113
+ validate_jwt_token(tokens_hash)
114
+
105
115
  sdk_api_client = api_client(tokens_hash[:access_token] || tokens_hash["access_token"])
106
116
  KindeSdk::Client.new(sdk_api_client, tokens_hash, @config.auto_refresh_tokens)
107
117
  end
@@ -135,6 +145,8 @@ module KindeSdk
135
145
  audience: "#{@config.domain}/api",
136
146
  domain: @config.domain
137
147
  )
148
+ validate_jwt_token(hash)
149
+
138
150
  OAuth2::AccessToken.from_hash(@config.oauth_client(
139
151
  client_id: client_id,
140
152
  client_secret: client_secret,
@@ -150,6 +162,8 @@ module KindeSdk
150
162
  audience: "#{@config.domain}/api",
151
163
  domain: @config.domain
152
164
  )
165
+ validate_jwt_token(hash)
166
+
153
167
  OAuth2::AccessToken.from_hash(@config.oauth_client(
154
168
  client_id: client_id,
155
169
  client_secret: client_secret,
@@ -182,5 +196,62 @@ module KindeSdk
182
196
  rescue URI::InvalidURIError
183
197
  default_scheme
184
198
  end
199
+
200
+
201
+ def validate_jwt_token(token_hash)
202
+ token_hash.each do |key, token|
203
+ next unless %w[access_token id_token].include?(key.to_s.downcase)
204
+ begin
205
+ jwt_validation(token, "#{@config.domain}#{@config.jwks_url}", @config.expected_issuer, @config.expected_audience)
206
+ rescue JWT::DecodeError
207
+ Rails.logger.error("Invalid JWT token: #{key}")
208
+ raise JWT::DecodeError, "Invalid #{key.to_s.capitalize.gsub('_', ' ')}"
209
+ end
210
+ end
211
+ end
212
+
213
+
214
+ # Method to validate a JWT token with caching for JWKS
215
+ def jwt_validation(jwt_token, jwks_url, expected_issuer, expected_audience)
216
+ @cached_jwks ||= fetch_jwks(jwks_url)
217
+
218
+ begin
219
+ validate_token(jwt_token, @cached_jwks, expected_issuer, expected_audience)
220
+ rescue JWT::DecodeError, StandardError
221
+ # If validation fails, fetch JWKS again and retry validation
222
+ @cached_jwks = fetch_jwks(jwks_url)
223
+ validate_token(jwt_token, @cached_jwks, expected_issuer, expected_audience)
224
+ end
225
+ end
226
+
227
+ private
228
+
229
+ # Fetch JWKS from the URL
230
+ def fetch_jwks(jwks_url)
231
+ jwks_response = HTTParty.get(jwks_url)
232
+ JSON.parse(jwks_response.body)
233
+ end
234
+
235
+ # Validate the JWT token using the provided JWKS
236
+ def validate_token(jwt_token, jwks_hash, expected_issuer, expected_audience)
237
+ # Decode token header to get 'kid'
238
+ decoded_token = JWT.decode(jwt_token, nil, false) # [payload, header]
239
+ header = decoded_token[1]
240
+ kid = header['kid']
241
+
242
+ # Find the matching JWK
243
+ jwks = JWT::JWK::Set.new(jwks_hash)
244
+ jwks.filter! {|key| key[:use] == 'sig' }
245
+ algorithms = jwks.map { |key| key[:alg] }.compact.uniq
246
+ payload, _header = JWT.decode(jwt_token, nil, true, algorithms: algorithms, jwks: jwks)
247
+ { valid: true, payload: payload }
248
+ rescue JWT::DecodeError => e
249
+ Rails.logger.error("Token validation failed: #{e.message}")
250
+ raise JWT::DecodeError, "Token validation failed: #{e.message}"
251
+ rescue StandardError => e
252
+ Rails.logger.error("Unexpected error: #{e.message}")
253
+ raise StandardError, "Unexpected error: #{e.message}"
254
+ end
255
+
185
256
  end
186
257
  end
@@ -1,4 +1,8 @@
1
1
  require 'spec_helper'
2
+ require 'jwt'
3
+ require 'openssl'
4
+ require 'webmock/rspec'
5
+
2
6
 
3
7
  describe KindeSdk do
4
8
  let(:domain) { "http://example.com" }
@@ -8,6 +12,13 @@ describe KindeSdk do
8
12
  let(:logout_url) { "http://localhost/logout-callback" }
9
13
  let(:auto_refresh_tokens) { true }
10
14
 
15
+ let(:optional_parameters) { { kid: 'my-kid', use: 'sig', alg: 'RS512' } }
16
+ let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
17
+ let(:jwk) { JWT::JWK.new(rsa_key, optional_parameters) }
18
+ let(:payload) { { data: 'data' } }
19
+ let(:token) { JWT.encode(payload, jwk.signing_key, jwk[:alg], kid: jwk[:kid]) }
20
+ let(:jwks_hash) { JWT::JWK::Set.new(jwk).export }
21
+
11
22
  before do
12
23
  KindeSdk.configure do |c|
13
24
  c.domain = domain
@@ -74,7 +85,19 @@ describe KindeSdk do
74
85
  )
75
86
  .to_return(
76
87
  status: 200,
77
- body: { "access_token": "eyJ", "id_token": "test", "refresh_token": "test","expires_in": 86399, "scope": "", "token_type": "bearer" }.to_json,
88
+ body: { "access_token" => "eyJ", "id_token" => "test", "refresh_token" => "test", "expires_in" => 86399, "scope" => "", "token_type" => "bearer" }.to_json,
89
+ headers: { "content-type" => "application/json;charset=UTF-8" }
90
+ )
91
+ stub_request(:get, "#{domain}/.well-known/jwks.json")
92
+ .with(
93
+ headers: {
94
+ 'Accept'=>'*/*',
95
+ 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
96
+ 'User-Agent'=>'Ruby'
97
+ })
98
+ .to_return(
99
+ status: 200,
100
+ body: jwks_hash.to_json,
78
101
  headers: { "content-type" => "application/json;charset=UTF-8" }
79
102
  )
80
103
  end
@@ -123,13 +146,13 @@ describe KindeSdk do
123
146
  let(:hash_to_encode) do
124
147
  { "aud" => [],
125
148
  "azp" => "19ebb687cd2f405c9f2daf645a8db895",
126
- "exp" => 1679600554,
127
149
  "feature_flags" => {
128
150
  "asd" => { "t" => "b", "v" => true },
129
151
  "eeeeee" => { "t" => "i", "v" => 111 },
130
152
  "qqq" => { "t" => "s", "v" => "aa" }
131
153
  },
132
- "iat" => 1679514154,
154
+ "iat": Time.now.to_i, # Issued at: current time
155
+ "exp": Time.now.to_i + 3600, # Expiration time: 1 hour from now
133
156
  "iss" => "https://example.kinde.com",
134
157
  "jti" => "22c48b2c-da46-4661-a7ff-425c23eceab5",
135
158
  "org_code" => "org_cb4544175bc",
@@ -137,9 +160,23 @@ describe KindeSdk do
137
160
  "scp" => ["openid", "offline"],
138
161
  "sub" => "kp:b17adf719f7d4b87b611d1a88a09fd15" }
139
162
  end
140
- let(:token) { JWT.encode(hash_to_encode, nil, "none") }
163
+ before do
164
+ stub_request(:get, "#{domain}/.well-known/jwks.json")
165
+ .with(
166
+ headers: {
167
+ 'Accept'=>'*/*',
168
+ 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
169
+ 'User-Agent'=>'Ruby'
170
+ })
171
+ .to_return(
172
+ status: 200,
173
+ body: jwks_hash.to_json,
174
+ headers: { "content-type" => "application/json;charset=UTF-8" }
175
+ )
176
+ end
177
+ let(:token) { JWT.encode(hash_to_encode, jwk.signing_key, jwk[:alg], kid: jwk[:kid]) }
141
178
  let(:expires_at) { Time.now.to_i + 10000000 }
142
- let(:client) { described_class.client({ "access_token": token, "expires_at": expires_at }) }
179
+ let(:client) { described_class.client({ access_token: token, expires_at: expires_at }) }
143
180
 
144
181
  context "with feature flags" do
145
182
  it "returns existing flags", :aggregate_failures do
@@ -229,3 +266,5 @@ describe KindeSdk do
229
266
  end
230
267
  end
231
268
  end
269
+
270
+
data/spec/spec_helper.rb CHANGED
@@ -1,3 +1,6 @@
1
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__) # For kinde_sdk
2
+ $LOAD_PATH.unshift File.expand_path('../kinde_api/lib', __dir__) # For kinde_api
3
+
1
4
  require "kinde_sdk"
2
5
  require "webmock/rspec"
3
6
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kinde_sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kinde Australia Pty Ltd
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-03-20 00:00:00.000000000 Z
11
+ date: 2025-03-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: typhoeus
@@ -72,6 +72,48 @@ dependencies:
72
72
  - - ">="
73
73
  - !ruby/object:Gem::Version
74
74
  version: '0'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rails
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 7.0.4
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 7.0.4
89
+ - !ruby/object:Gem::Dependency
90
+ name: httparty
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: 0.19.0
96
+ type: :runtime
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: 0.19.0
103
+ - !ruby/object:Gem::Dependency
104
+ name: jwt
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '2.2'
110
+ type: :runtime
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '2.2'
75
117
  - !ruby/object:Gem::Dependency
76
118
  name: rspec
77
119
  requirement: !ruby/object:Gem::Requirement
@@ -422,7 +464,7 @@ files:
422
464
  - lib/kinde_sdk/client/feature_flags.rb
423
465
  - lib/kinde_sdk/client/permissions.rb
424
466
  - lib/kinde_sdk/configuration.rb
425
- - lib/kinde_sdk/controllers/auth_controller.rb
467
+ - lib/kinde_sdk/engine.rb
426
468
  - lib/kinde_sdk/version.rb
427
469
  - spec/kinde_sdk_spec.rb
428
470
  - spec/spec_helper.rb
@@ -1,152 +0,0 @@
1
- require 'action_controller'
2
- require 'uri'
3
- require 'cgi'
4
- require 'net/http'
5
- require 'json'
6
- require 'jwt'
7
-
8
- module KindeSdk
9
- class AuthController < ActionController::Base
10
- # Add before_action to validate nonce in callback
11
- before_action :validate_state, only: :callback
12
-
13
- def auth
14
- # Generate a secure random nonce
15
- nonce = SecureRandom.urlsafe_base64(16)
16
-
17
- # Call KindeSdk.auth_url with nonce
18
- auth_data = KindeSdk.auth_url(nonce: nonce)
19
-
20
- # Store in session
21
- session[:code_verifier] = auth_data[:code_verifier] if auth_data[:code_verifier].present?
22
- session[:auth_nonce] = nonce
23
- session[:auth_state] = {
24
- requested_at: Time.current.to_i,
25
- redirect_url: auth_data[:url]
26
- }
27
-
28
- redirect_to auth_data[:url], allow_other_host: true
29
- end
30
-
31
- def callback
32
- tokens = KindeSdk.fetch_tokens(
33
- params[:code],
34
- code_verifier: KindeSdk.config.pkce_enabled ? session[:code_verifier] : nil
35
- ).slice(:access_token, :id_token, :refresh_token, :expires_at)
36
-
37
-
38
- # Validate nonce in ID token
39
- id_token = tokens[:id_token]
40
- issuer = KindeSdk.config.domain
41
- client_id = KindeSdk.config.client_id
42
- original_nonce = session[:auth_nonce]
43
- unless validate_nonce(id_token, original_nonce, issuer, client_id)
44
- Rails.logger.warn("Nonce validation failed")
45
- redirect_to "/", alert: "Invalid authentication nonce"
46
- return
47
- end
48
-
49
- # Store tokens and user in session
50
- session[:kinde_auth] = OAuth2::AccessToken.from_hash(KindeSdk.config.oauth_client, tokens).to_hash
51
- .slice(:access_token, :refresh_token, :expires_at)
52
- session[:kinde_user] = KindeSdk.client(tokens).oauth.get_user.to_hash
53
-
54
- # Clear nonce and state after successful authentication
55
- session.delete(:auth_nonce)
56
- session.delete(:auth_state)
57
- session.delete(:code_verifier)
58
- redirect_to "/"
59
- rescue StandardError => e
60
- Rails.logger.error("Authentication callback failed: #{e.message}")
61
- redirect_to "/", alert: "Authentication failed"
62
- end
63
-
64
- def client_credentials_auth
65
- result = KindeSdk.client_credentials_access(
66
- client_id: ENV["KINDE_MANAGEMENT_CLIENT_ID"],
67
- client_secret: ENV["KINDE_MANAGEMENT_CLIENT_SECRET"]
68
- )
69
-
70
- if result["error"].present?
71
- Rails.logger.error("Client credentials auth failed: #{result['error']}")
72
- raise result["error"]
73
- end
74
-
75
- $redis.set("kinde_m2m_token", result["access_token"], ex: result["expires_in"].to_i)
76
- redirect_to mgmt_path
77
- end
78
-
79
- def logout
80
- redirect_to KindeSdk.logout_url, allow_other_host: true
81
- end
82
-
83
- def logout_callback
84
- reset_session
85
- redirect_to "/"
86
- end
87
-
88
- private
89
-
90
- def validate_state
91
- # Check if nonce and state exist in session
92
- unless session[:auth_nonce] && session[:auth_state]
93
- Rails.logger.warn("Missing session state or nonce")
94
- redirect_to "/", alert: "Invalid authentication state"
95
- return
96
- end
97
-
98
- # Verify nonce returned matches stored nonce
99
- returned_state = params[:state]
100
- stored_state = session[:auth_state]
101
- stored_url = stored_state["redirect_url"]
102
-
103
- # Extract the state from the stored redirect_url
104
- parsed_url = URI.parse(stored_url)
105
- query_params = CGI.parse(parsed_url.query || "")
106
- stored_state_from_url = query_params["state"]&.first
107
-
108
- # Verify returned state matches the state extracted from the redirect_url
109
- unless returned_state.present? && returned_state == stored_state_from_url
110
- Rails.logger.warn("State validation failed: returned=#{returned_state}, expected=#{stored_state_from_url}")
111
- redirect_to "/", alert: "Invalid authentication state"
112
- return
113
- end
114
-
115
- # Optional: Check state age (e.g., expires after 15 minutes)
116
- if Time.current.to_i - stored_state["requested_at"] > 900
117
- Rails.logger.warn("Authentication state expired")
118
- redirect_to "/", alert: "Authentication session expired"
119
- return
120
- end
121
- end
122
-
123
-
124
- def validate_nonce(id_token, original_nonce, issuer, client_id)
125
- jwks_uri = URI.parse("#{issuer}/.well-known/jwks.json")
126
- jwks_response = Net::HTTP.get(jwks_uri)
127
- jwks = JSON.parse(jwks_response)
128
-
129
- decoded_token = JWT.decode(
130
- id_token,
131
- nil,
132
- true,
133
- algorithm: 'RS256',
134
- iss: issuer,
135
- aud: client_id,
136
- verify_iss: true,
137
- verify_aud: true,
138
- jwks: { keys: jwks['keys'] }
139
- )
140
-
141
- payload = decoded_token[0]
142
- nonce_from_token = payload['nonce']
143
-
144
- nonce_from_token == original_nonce
145
- rescue StandardError => e
146
- Rails.logger.error("Nonce validation error: #{e.message}")
147
- false
148
- end
149
-
150
-
151
- end
152
- end