philiprehberger-jwt_kit 0.3.2 → 0.5.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: 8b513b3bc5d568092e0a8088d423e2beade5a1b57b00486ba67f9730b9aceb6c
4
- data.tar.gz: 07dcb4ce8c3ece9f4880c04e8367467ba3e5eb0da24bb753c577cc124e598a92
3
+ metadata.gz: 4cd6d9f8d91d59046e8427bb209a93e9220a3ff0398ef1c84758a9396fae2eb7
4
+ data.tar.gz: 9fc834f733af1020130928f51ee99ff24d94f468c64ab86a638d7859dbba658e
5
5
  SHA512:
6
- metadata.gz: 597007459eeba4fb603b8b3322eead718e04a320594fb63ba1908e90a146e03f71f110ad5656d62b1d2a8dfaed9e74d4a03737373b1861744af723f1bed6c906
7
- data.tar.gz: c93b1e5bd2d9c76bccdeff6bd1f3e797d3aa047f725d13c76b36d4ad346e06da50c3a7fe542f6bead0cf6dc93b0835ed5c9234dbe1c9a2a6be147dcf5aee7242
6
+ metadata.gz: 2751b52e541a834214dee62b4eaa136798d7f244900c206e063738dae3fd2ae90a2c350916ca82dcf714558028eeecb96ea4ac269a02ede7d6bb7c7c5116b7f2
7
+ data.tar.gz: 44de4708a12b6bf7b0317a98db9a05d3375a5083791cd126458b9f63f716942a054a4de5327a6a13c918fe1421936a72f5e19451c402f9dbb75070d234169b47
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-04-26
11
+
12
+ ### Added
13
+ - Lifecycle callbacks on `Configuration`: `on_encode`, `on_decode`, `on_refresh`, `on_revoke` for audit logging and metrics without monkey-patching
14
+
15
+ ## [0.4.0] - 2026-04-15
16
+
17
+ ### Added
18
+ - `JwtKit.expired?(token)` — check a token's `exp` claim without verifying the signature
19
+
10
20
  ## [0.3.2] - 2026-04-09
11
21
 
12
22
  ### Fixed
data/README.md CHANGED
@@ -108,6 +108,15 @@ result[:header] # => {"alg"=>"HS256", "typ"=>"JWT"}
108
108
  result[:payload] # => {"user_id"=>42, "exp"=>..., "iat"=>..., "jti"=>...}
109
109
  ```
110
110
 
111
+ ### Expiration Check
112
+
113
+ Check whether a token's `exp` claim is in the past without verifying the signature:
114
+
115
+ ```ruby
116
+ Philiprehberger::JwtKit.expired?(token) # => false
117
+ # Use to decide whether to refresh before the authoritative decode
118
+ ```
119
+
111
120
  ### Audience Validation
112
121
 
113
122
  ```ruby
@@ -171,6 +180,34 @@ Replace the default in-memory store with any object that responds to `#revoke`,
171
180
  Philiprehberger::JwtKit.revocation_store = MyRedisRevocationStore.new
172
181
  ```
173
182
 
183
+ ### Lifecycle Callbacks
184
+
185
+ Hook into encode, decode, refresh, and revoke without monkey-patching. Useful for audit logging, metrics, and tracing:
186
+
187
+ ```ruby
188
+ Philiprehberger::JwtKit.configure do |c|
189
+ c.secret = 'your-secret-key'
190
+
191
+ c.on_encode do |token, payload|
192
+ Metrics.increment('jwt.encoded', tags: { iss: payload['iss'] })
193
+ end
194
+
195
+ c.on_decode do |payload|
196
+ Audit.log('jwt.decoded', user_id: payload['user_id'], jti: payload['jti'])
197
+ end
198
+
199
+ c.on_refresh do |new_token|
200
+ Metrics.increment('jwt.refreshed')
201
+ end
202
+
203
+ c.on_revoke do |jti|
204
+ Audit.log('jwt.revoked', jti: jti)
205
+ end
206
+ end
207
+ ```
208
+
209
+ Callbacks fire only after a successful operation. Exceptions raised inside a callback are swallowed so they cannot break the calling JWT operation.
210
+
174
211
  ## API
175
212
 
176
213
  | Method | Description |
@@ -186,8 +223,13 @@ Philiprehberger::JwtKit.revocation_store = MyRedisRevocationStore.new
186
223
  | `JwtKit.revoke(token)` | Revokes a token by its JTI |
187
224
  | `JwtKit.revoked?(token)` | Checks if a token has been revoked |
188
225
  | `JwtKit.peek(token)` | Decode header and payload without signature verification |
226
+ | `JwtKit.expired?(token)` | Check `exp` claim without verifying the signature |
189
227
  | `JwtKit.revocation_store=` | Set a custom revocation store |
190
228
  | `MemoryStore#cleanup!(max_age:)` | Remove revocation entries older than max_age seconds |
229
+ | `Configuration#on_encode { \|token, payload\| ... }` | Register a callback fired after a successful encode |
230
+ | `Configuration#on_decode { \|payload\| ... }` | Register a callback fired after a successful decode |
231
+ | `Configuration#on_refresh { \|new_token\| ... }` | Register a callback fired after a successful refresh |
232
+ | `Configuration#on_revoke { \|jti\| ... }` | Register a callback fired after a successful revoke |
191
233
 
192
234
  ## Development
193
235
 
@@ -41,6 +41,92 @@ module Philiprehberger
41
41
  @expiration = 3600
42
42
  @refresh_expiration = 86_400 * 7
43
43
  @secrets = nil
44
+ @on_encode = nil
45
+ @on_decode = nil
46
+ @on_refresh = nil
47
+ @on_revoke = nil
48
+ end
49
+
50
+ # Registers a callback fired after a successful encode.
51
+ #
52
+ # @yieldparam token [String] the encoded JWT token
53
+ # @yieldparam payload [Hash] the merged payload that was encoded
54
+ # @return [Proc] the stored callback
55
+ def on_encode(&block)
56
+ @on_encode = block
57
+ end
58
+
59
+ # Registers a callback fired after a successful decode.
60
+ #
61
+ # @yieldparam payload [Hash] the decoded payload
62
+ # @return [Proc] the stored callback
63
+ def on_decode(&block)
64
+ @on_decode = block
65
+ end
66
+
67
+ # Registers a callback fired after a successful refresh.
68
+ #
69
+ # @yieldparam new_token [String] the newly issued access token
70
+ # @return [Proc] the stored callback
71
+ def on_refresh(&block)
72
+ @on_refresh = block
73
+ end
74
+
75
+ # Registers a callback fired after a successful revoke.
76
+ #
77
+ # @yieldparam jti [String, nil] the revoked token's JTI
78
+ # @return [Proc] the stored callback
79
+ def on_revoke(&block)
80
+ @on_revoke = block
81
+ end
82
+
83
+ # Invokes the on_encode callback if registered. Errors raised by the callback are swallowed.
84
+ #
85
+ # @param token [String]
86
+ # @param payload [Hash]
87
+ # @return [void]
88
+ def fire_on_encode(token, payload)
89
+ return unless @on_encode
90
+
91
+ @on_encode.call(token, payload)
92
+ rescue StandardError
93
+ nil
94
+ end
95
+
96
+ # Invokes the on_decode callback if registered. Errors raised by the callback are swallowed.
97
+ #
98
+ # @param payload [Hash]
99
+ # @return [void]
100
+ def fire_on_decode(payload)
101
+ return unless @on_decode
102
+
103
+ @on_decode.call(payload)
104
+ rescue StandardError
105
+ nil
106
+ end
107
+
108
+ # Invokes the on_refresh callback if registered. Errors raised by the callback are swallowed.
109
+ #
110
+ # @param new_token [String]
111
+ # @return [void]
112
+ def fire_on_refresh(new_token)
113
+ return unless @on_refresh
114
+
115
+ @on_refresh.call(new_token)
116
+ rescue StandardError
117
+ nil
118
+ end
119
+
120
+ # Invokes the on_revoke callback if registered. Errors raised by the callback are swallowed.
121
+ #
122
+ # @param jti [String, nil]
123
+ # @return [void]
124
+ def fire_on_revoke(jti)
125
+ return unless @on_revoke
126
+
127
+ @on_revoke.call(jti)
128
+ rescue StandardError
129
+ nil
44
130
  end
45
131
 
46
132
  # Returns the OpenSSL digest algorithm name.
@@ -33,6 +33,7 @@ module Philiprehberger
33
33
  validate_issuer!(payload, config)
34
34
  validate_audience!(payload, config)
35
35
 
36
+ config.fire_on_decode(payload)
36
37
  payload
37
38
  rescue JSON::ParserError
38
39
  raise DecodeError, 'Invalid token: malformed JSON'
@@ -43,7 +43,9 @@ module Philiprehberger
43
43
  signing_input = "#{header_segment}.#{payload_segment}"
44
44
  signature = sign(signing_input, config, secret: signing_secret)
45
45
 
46
- "#{signing_input}.#{signature}"
46
+ token = "#{signing_input}.#{signature}"
47
+ config.fire_on_encode(token, merged)
48
+ token
47
49
  end
48
50
 
49
51
  # Base64url-encodes a string without padding.
@@ -4,6 +4,20 @@ module Philiprehberger
4
4
  module JwtKit
5
5
  # Token revocation support with an in-memory store.
6
6
  module Revocation
7
+ # Extracts the JTI claim from a JWT token without verifying its signature.
8
+ #
9
+ # @param token [String] JWT token
10
+ # @return [String, nil] the JTI, or nil if the token is malformed
11
+ def self.extract_jti(token)
12
+ parts = token.split('.')
13
+ return nil unless parts.length == 3
14
+
15
+ payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
16
+ payload['jti']
17
+ rescue JSON::ParserError, ArgumentError
18
+ nil
19
+ end
20
+
7
21
  # Thread-safe in-memory revocation store backed by a Hash.
8
22
  class MemoryStore
9
23
  def initialize
@@ -60,13 +74,7 @@ module Philiprehberger
60
74
  private
61
75
 
62
76
  def extract_jti(token)
63
- parts = token.split('.')
64
- return nil unless parts.length == 3
65
-
66
- payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
67
- payload['jti']
68
- rescue JSON::ParserError, ArgumentError
69
- nil
77
+ Revocation.extract_jti(token)
70
78
  end
71
79
  end
72
80
  end
@@ -34,7 +34,9 @@ module Philiprehberger
34
34
  raise InvalidToken, 'Token is not a refresh token' unless payload['type'] == 'refresh'
35
35
 
36
36
  new_payload = payload.except('exp', 'nbf', 'iat', 'jti', 'iss', 'aud', 'type')
37
- Encoder.encode(new_payload, config)
37
+ new_token = Encoder.encode(new_payload, config)
38
+ config.fire_on_refresh(new_token)
39
+ new_token
38
40
  end
39
41
  end
40
42
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module JwtKit
5
- VERSION = '0.3.2'
5
+ VERSION = '0.5.0'
6
6
  end
7
7
  end
@@ -57,6 +57,22 @@ module Philiprehberger
57
57
  { valid: false, payload: nil, error: e.message }
58
58
  end
59
59
 
60
+ # Checks whether a token's `exp` claim is in the past without verifying the signature.
61
+ # Useful for proactive refresh decisions. Returns `true` for malformed tokens or when
62
+ # `exp` is missing.
63
+ #
64
+ # @param token [String] JWT token
65
+ # @return [Boolean]
66
+ def expired?(token)
67
+ payload = peek(token)[:payload]
68
+ exp = payload['exp']
69
+ return true unless exp.is_a?(Numeric)
70
+
71
+ Time.now.to_i >= exp
72
+ rescue DecodeError
73
+ true
74
+ end
75
+
60
76
  # Encodes a payload into a signed JWT token.
61
77
  #
62
78
  # @param payload [Hash] custom claims
@@ -109,6 +125,8 @@ module Philiprehberger
109
125
  # @return [void]
110
126
  def revoke(token)
111
127
  revocation_store.revoke(token)
128
+ jti = Revocation.extract_jti(token)
129
+ configuration.fire_on_revoke(jti)
112
130
  end
113
131
 
114
132
  # Checks whether a token has been revoked.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-jwt_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-09 00:00:00.000000000 Z
11
+ date: 2026-04-27 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A complete JWT toolkit for Ruby. Encode and decode tokens with automatic
14
14
  claim management (exp, iat, iss, jti), generate access/refresh token pairs, validate