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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +49 -0
- data/LICENSE +21 -0
- data/README.md +324 -0
- data/lib/verikloak/discovery.rb +246 -0
- data/lib/verikloak/errors.rb +66 -0
- data/lib/verikloak/jwks_cache.rb +242 -0
- data/lib/verikloak/middleware.rb +322 -0
- data/lib/verikloak/token_decoder.rb +246 -0
- data/lib/verikloak/version.rb +6 -0
- data/lib/verikloak.rb +11 -0
- metadata +104 -0
|
@@ -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
|
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: []
|