verikloak 0.1.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.
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+
5
+ module Verikloak
6
+ # Verifies JWT tokens using a JWKS key set.
7
+ #
8
+ # This class validates a JWT's signature and standard claims (`iss`, `aud`, `exp`, `nbf`, etc.)
9
+ # using the appropriate RSA public key selected by the JWT's `kid` header.
10
+ # Only `RS256`-signed tokens with RSA JWKs are supported.
11
+ # It also supports a configurable clock skew (`leeway`) to account for minor time drift.
12
+ #
13
+ # @example
14
+ # decoder = Verikloak::TokenDecoder.new(
15
+ # jwks: jwks_keys,
16
+ # issuer: "https://keycloak.example.com/realms/myrealm",
17
+ # audience: "my-client-id",
18
+ # leeway: 30 # allow 30 seconds clock skew
19
+ # )
20
+ # payload = decoder.decode!(token)
21
+ # puts payload["sub"]
22
+ #
23
+ class TokenDecoder
24
+ # Default clock skew tolerance in seconds.
25
+ DEFAULT_LEEWAY = 60
26
+
27
+ # Initializes the decoder with a JWKS and verification criteria.
28
+ #
29
+ # @param jwks [Array<Hash>] List of JWKs from the discovery document.
30
+ # @param issuer [String] Expected `iss` value in the token.
31
+ # @param audience [String] Expected `aud` value in the token.
32
+ # @param leeway [Integer] Clock skew tolerance in seconds (optional).
33
+ def initialize(jwks:, issuer:, audience:, leeway: DEFAULT_LEEWAY)
34
+ @jwks = jwks
35
+ @issuer = issuer
36
+ @audience = audience
37
+ @leeway = leeway
38
+
39
+ # Build a kid-indexed hash for O(1) JWK lookup
40
+ @jwk_by_kid = {}
41
+ Array(@jwks).each do |j|
42
+ kid_key = fetch_indifferent(j, 'kid')
43
+ @jwk_by_kid[kid_key] = j if kid_key
44
+ end
45
+ end
46
+
47
+ # Decodes and verifies a JWT.
48
+ #
49
+ # @param token [String] The JWT string to verify.
50
+ # @return [Hash] The decoded payload (claims).
51
+ # @raise [TokenDecoderError] If verification fails. Possible error codes:
52
+ # - invalid_token
53
+ # - expired_token
54
+ # - not_yet_valid
55
+ # - invalid_issuer
56
+ # - invalid_audience
57
+ # - invalid_signature
58
+ # - unsupported_algorithm
59
+ def decode!(token)
60
+ with_error_handling do
61
+ # Extract header without verifying signature (payload is ignored here).
62
+ header = JWT.decode(token, nil, false).last
63
+ validate_header(header) # check alg and kid present
64
+ jwk = find_key_by_kid(header) # locate JWK by kid
65
+ public_key = rsa_key_from_jwk(jwk) # import RSA public key
66
+ payload = decode_with_public_key(token, public_key) # verify signature & claims
67
+ payload
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # Validates the JWT header.
74
+ #
75
+ # Rules:
76
+ # - Algorithm must be exactly 'RS256'
77
+ # - 'kid' must be present
78
+ #
79
+ # @param header [Hash]
80
+ # @raise [TokenDecoderError] If the algorithm is not RS256 or 'kid' is missing.
81
+ def validate_header(header)
82
+ alg = fetch_indifferent(header, 'alg')
83
+ unless alg.is_a?(String) && alg == 'RS256'
84
+ raise TokenDecoderError.new('Missing or unsupported algorithm',
85
+ code: 'unsupported_algorithm')
86
+ end
87
+
88
+ kid = fetch_indifferent(header, 'kid')
89
+ raise TokenDecoderError.new("JWT header missing 'kid'", code: 'invalid_token') unless kid
90
+ end
91
+
92
+ # Finds the JWK matching the kid in the JWT header.
93
+ #
94
+ # @param header [Hash]
95
+ # @return [Hash] The matching JWK.
96
+ # @raise [TokenDecoderError] If key not found or unsupported type.
97
+ def find_key_by_kid(header)
98
+ kid = fetch_indifferent(header, 'kid')
99
+ jwk = @jwk_by_kid[kid]
100
+
101
+ raise TokenDecoderError.new("Key with kid=#{kid} not found in JWKS", code: 'invalid_token') unless jwk
102
+
103
+ jwk
104
+ end
105
+
106
+ # Decodes and verifies the token using the given public key and decode options.
107
+ #
108
+ # @param token [String] JWT to verify.
109
+ # @param public_key [OpenSSL::PKey::RSA] Public key for verification.
110
+ # @return [Hash] Verified claims (payload).
111
+ def decode_with_public_key(token, public_key)
112
+ payload, = JWT.decode(token, public_key, true, jwt_decode_options)
113
+ payload
114
+ end
115
+
116
+ # Returns the verification options passed to JWT.decode.
117
+ #
118
+ # Enforces:
119
+ # - Allowed signature algorithms (RS256)
120
+ # - Issuer and audience validation
121
+ # - Expiration (`exp`) and not-before (`nbf`) checks
122
+ # - Clock skew tolerance via `leeway`
123
+ #
124
+ # @return [Hash]
125
+ def jwt_decode_options
126
+ {
127
+ # Specify allowed algorithms explicitly as an array to prevent header tampering
128
+ algorithms: ['RS256'],
129
+ iss: @issuer,
130
+ verify_iss: true,
131
+ aud: @audience,
132
+ verify_aud: true,
133
+ verify_iat: true,
134
+ verify_expiration: true,
135
+ verify_not_before: true,
136
+ leeway: @leeway # allow clock skew tolerance
137
+ }
138
+ end
139
+
140
+ # Imports an OpenSSL::PKey::RSA public key from the given JWK.
141
+ #
142
+ # @param jwk [Hash] JWK hash containing 'n', 'e', etc.
143
+ # @return [OpenSSL::PKey::RSA]
144
+ # @raise [TokenDecoderError] If import fails.
145
+ def rsa_key_from_jwk(jwk)
146
+ normalized = jwk.transform_keys(&:to_s)
147
+
148
+ # Pre-validate minimal RSA JWK requirements for stable error behavior
149
+ kty = normalized['kty']
150
+ n = normalized['n']
151
+ e = normalized['e']
152
+
153
+ unless kty == 'RSA'
154
+ raise TokenDecoderError.new("Unsupported key type '#{kty}'. Only RSA is supported", code: 'invalid_token')
155
+ end
156
+
157
+ # Accept only non-empty String for n/e to avoid nil/empty/incorrect types
158
+ unless n.is_a?(String) && !n.empty? && e.is_a?(String) && !e.empty?
159
+ raise TokenDecoderError.new('Failed to import JWK: missing required parameter(s)', code: 'invalid_token')
160
+ end
161
+
162
+ # Try importing — any failure is wrapped consistently
163
+ JWT::JWK::RSA.import(normalized).public_key
164
+ rescue StandardError => e
165
+ raise TokenDecoderError.new("Failed to import JWK: #{e.message}", code: 'invalid_token')
166
+ end
167
+
168
+ # Fetches a value from a hash, allowing indifferent access by string or symbol keys.
169
+ #
170
+ # @param hash [Hash] The hash to look up the key in. (Non-hash values return nil.)
171
+ # @param key [String, Symbol] The key to retrieve, as a string or symbol.
172
+ # @return [Object, nil] The value associated with the key, or nil if not found or if the input is not a Hash.
173
+ def fetch_indifferent(hash, key)
174
+ return nil unless hash.is_a?(Hash)
175
+
176
+ hash[key] || hash[key.to_s] || hash[key.to_sym]
177
+ end
178
+
179
+ # Wraps decoding logic with structured error handling.
180
+ #
181
+ # @yield Executes the core decoding steps.
182
+ # @return [Hash] Decoded payload.
183
+ # @raise [TokenDecoderError] On verification failure.
184
+ def with_error_handling
185
+ yield
186
+ rescue TokenDecoderError => e
187
+ # Pass through our own structured errors without rewrapping
188
+ raise e
189
+ rescue *jwt_errors => e
190
+ code = case e
191
+ when JWT::ExpiredSignature then 'expired_token'
192
+ when JWT::ImmatureSignature then 'not_yet_valid'
193
+ when JWT::InvalidIssuerError then 'invalid_issuer'
194
+ when JWT::InvalidAudError then 'invalid_audience'
195
+ when defined?(JWT::VerificationError) && e.is_a?(JWT::VerificationError) then 'invalid_signature'
196
+ when defined?(JWT::IncorrectAlgorithm) && e.is_a?(JWT::IncorrectAlgorithm) then 'unsupported_algorithm'
197
+ else 'invalid_token'
198
+ end
199
+ raise TokenDecoderError.new(jwt_error_message(e), code: code)
200
+ rescue StandardError => e
201
+ raise TokenDecoderError.new("Unexpected token verification error: #{e.message}", code: 'invalid_token')
202
+ end
203
+
204
+ # JWT-related exceptions to catch and rewrap.
205
+ #
206
+ # @return [Array<Class>]
207
+ def jwt_errors
208
+ [
209
+ JWT::ExpiredSignature,
210
+ JWT::ImmatureSignature,
211
+ JWT::InvalidIssuerError,
212
+ JWT::InvalidAudError,
213
+ (JWT::InvalidIatError if defined?(JWT::InvalidIatError)),
214
+ (JWT::VerificationError if defined?(JWT::VerificationError)),
215
+ (JWT::IncorrectAlgorithm if defined?(JWT::IncorrectAlgorithm)),
216
+ JWT::DecodeError
217
+ ].compact
218
+ end
219
+
220
+ # Maps JWT exception classes to user-friendly messages.
221
+ #
222
+ # @param error [Exception]
223
+ # @return [String]
224
+ def jwt_error_message(error)
225
+ {
226
+ JWT::ExpiredSignature => 'Token has expired',
227
+ JWT::ImmatureSignature => 'Token is not yet valid (nbf in the future)',
228
+ JWT::InvalidIssuerError => 'Invalid issuer (iss claim)',
229
+ JWT::InvalidAudError => 'Invalid audience (aud claim)',
230
+ JWT::InvalidIatError => 'Invalid issued-at (iat) claim'
231
+ }.fetch(error.class) { fallback_jwt_error_message(error) }
232
+ end
233
+
234
+ # Fallback for unexpected JWT errors.
235
+ #
236
+ # @param error [Exception]
237
+ # @return [String]
238
+ def fallback_jwt_error_message(error)
239
+ if error.is_a?(JWT::DecodeError)
240
+ "JWT decode failed: #{error.message}"
241
+ else
242
+ "JWT verification failed: #{error.message}"
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verikloak
4
+ # Defines the current version of the Verikloak gem.
5
+ VERSION = '0.1.1'
6
+ end
data/lib/verikloak.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Main entry point for the Verikloak gem.
4
+ # This file requires all core components so that they can be loaded
5
+ # by simply requiring 'verikloak'.
6
+ require 'verikloak/version'
7
+ require 'verikloak/errors'
8
+ require 'verikloak/discovery'
9
+ require 'verikloak/jwks_cache'
10
+ require 'verikloak/token_decoder'
11
+ require 'verikloak/middleware'
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: verikloak
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - taiyaky
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '2.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: json
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '2.6'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '2.6'
46
+ - !ruby/object:Gem::Dependency
47
+ name: jwt
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '2.7'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '2.7'
60
+ description: |
61
+ Verikloak is a lightweight Ruby gem that provides JWT access token verification middleware
62
+ for Rack-based applications, including Rails API mode. It uses OpenID Connect discovery
63
+ and JWKS to securely validate tokens issued by Keycloak.
64
+ executables: []
65
+ extensions: []
66
+ extra_rdoc_files: []
67
+ files:
68
+ - CHANGELOG.md
69
+ - LICENSE
70
+ - README.md
71
+ - lib/verikloak.rb
72
+ - lib/verikloak/discovery.rb
73
+ - lib/verikloak/errors.rb
74
+ - lib/verikloak/jwks_cache.rb
75
+ - lib/verikloak/middleware.rb
76
+ - lib/verikloak/token_decoder.rb
77
+ - lib/verikloak/version.rb
78
+ homepage: https://github.com/taiyaky/verikloak
79
+ licenses:
80
+ - MIT
81
+ metadata:
82
+ source_code_uri: https://github.com/taiyaky/verikloak
83
+ changelog_uri: https://github.com/taiyaky/verikloak/blob/main/CHANGELOG.md
84
+ bug_tracker_uri: https://github.com/taiyaky/verikloak/issues
85
+ documentation_uri: https://rubydoc.info/gems/verikloak/0.1.1
86
+ rubygems_mfa_required: 'true'
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '3.0'
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubygems_version: 3.6.9
102
+ specification_version: 4
103
+ summary: Rack middleware for verifying Keycloak JWTs via OpenID Connect
104
+ test_files: []