philiprehberger-jwt_kit 0.1.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 +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE +21 -0
- data/README.md +125 -0
- data/lib/philiprehberger/jwt_kit/configuration.rb +59 -0
- data/lib/philiprehberger/jwt_kit/decoder.rb +97 -0
- data/lib/philiprehberger/jwt_kit/encoder.rb +58 -0
- data/lib/philiprehberger/jwt_kit/revocation.rb +66 -0
- data/lib/philiprehberger/jwt_kit/token_pair.rb +41 -0
- data/lib/philiprehberger/jwt_kit/version.rb +7 -0
- data/lib/philiprehberger/jwt_kit.rb +114 -0
- metadata +60 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 061c721779c41c845521340ab97e7dfe6ce04ece1670eba7ae0d582f86db2dc8
|
|
4
|
+
data.tar.gz: 5c81cf4b22758b7a0351f32185d403b2be2b5f38bbac693ecd057a77605b5230
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8d29b733775f8a814f71dd49ebce2da9d585a0574cbd4d18627a5f70cc4f6b8305df1c6225aabd4482992c1adb2f36ec957a9571cbcedc0fa8e85e8ce48e1a06
|
|
7
|
+
data.tar.gz: c593dbec85796d146aee22d73f7b04313bb3a7cdc2f07c39075eb72b62aa5b8ed1538b6f01c0a0f1d11dcd46fc253935a84a05077b0badf0ee05a043bd52ce4f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-03-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- JWT encoding with HMAC-SHA256/384/512 signing
|
|
14
|
+
- JWT decoding with signature verification
|
|
15
|
+
- Automatic claim management (exp, iat, iss, jti)
|
|
16
|
+
- Expiration and issuer validation
|
|
17
|
+
- Access/refresh token pair generation
|
|
18
|
+
- Token refresh from valid refresh tokens
|
|
19
|
+
- In-memory token revocation with thread-safe store
|
|
20
|
+
- Configurable secret, algorithm, issuer, and expiration
|
|
21
|
+
- Zero external dependencies (uses Ruby's built-in OpenSSL)
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 philiprehberger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# philiprehberger-jwt_kit
|
|
2
|
+
|
|
3
|
+
[](https://github.com/philiprehberger/rb-jwt-kit/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/philiprehberger-jwt_kit)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Opinionated JWT toolkit with encoding, validation, refresh tokens, and revocation
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Ruby >= 3.1
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Add to your Gemfile:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "philiprehberger-jwt_kit"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install directly:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
gem install philiprehberger-jwt_kit
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require "philiprehberger/jwt_kit"
|
|
31
|
+
|
|
32
|
+
Philiprehberger::JwtKit.configure do |c|
|
|
33
|
+
c.secret = "your-secret-key-at-least-32-characters"
|
|
34
|
+
c.issuer = "my-app"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
token = Philiprehberger::JwtKit.encode(user_id: 42)
|
|
38
|
+
payload = Philiprehberger::JwtKit.decode(token)
|
|
39
|
+
payload["user_id"] # => 42
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Configuration
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
Philiprehberger::JwtKit.configure do |c|
|
|
46
|
+
c.secret = "your-secret-key" # Required — HMAC signing key
|
|
47
|
+
c.algorithm = :hs256 # :hs256 (default), :hs384, :hs512
|
|
48
|
+
c.issuer = "my-app" # Optional — sets the `iss` claim
|
|
49
|
+
c.expiration = 3600 # Access token TTL in seconds (default: 1 hour)
|
|
50
|
+
c.refresh_expiration = 86_400 * 7 # Refresh token TTL (default: 1 week)
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Encoding
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
token = Philiprehberger::JwtKit.encode(user_id: 42, role: "admin")
|
|
58
|
+
# => "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHA..."
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Claims `exp`, `iat`, `iss`, and `jti` are added automatically.
|
|
62
|
+
|
|
63
|
+
### Decoding
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
payload = Philiprehberger::JwtKit.decode(token)
|
|
67
|
+
payload["user_id"] # => 42
|
|
68
|
+
payload["exp"] # => 1711036800
|
|
69
|
+
payload["iss"] # => "my-app"
|
|
70
|
+
payload["jti"] # => "a1b2c3d4-..."
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Decoding validates the signature, expiration, and issuer automatically.
|
|
74
|
+
|
|
75
|
+
### Token Pairs
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
access_token, refresh_token = Philiprehberger::JwtKit.token_pair(user_id: 42)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The access token uses the standard expiration. The refresh token uses `refresh_expiration` and includes a `type: "refresh"` claim.
|
|
82
|
+
|
|
83
|
+
### Refresh Tokens
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
new_access_token = Philiprehberger::JwtKit.refresh(refresh_token)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Validates the refresh token, verifies it has `type: "refresh"`, and issues a new access token with the original payload.
|
|
90
|
+
|
|
91
|
+
### Revocation
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
Philiprehberger::JwtKit.revoke(token)
|
|
95
|
+
Philiprehberger::JwtKit.revoked?(token) # => true
|
|
96
|
+
Philiprehberger::JwtKit.decode(token) # => raises RevokedToken
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Revocation uses an in-memory store keyed by JTI. The store is thread-safe.
|
|
100
|
+
|
|
101
|
+
## API
|
|
102
|
+
|
|
103
|
+
| Method | Description |
|
|
104
|
+
|--------|-------------|
|
|
105
|
+
| `JwtKit.configure { \|c\| ... }` | Configure secret, algorithm, issuer, and expiration |
|
|
106
|
+
| `JwtKit.configuration` | Returns the current configuration |
|
|
107
|
+
| `JwtKit.reset_configuration!` | Resets configuration to defaults |
|
|
108
|
+
| `JwtKit.encode(payload)` | Encodes a payload into a signed JWT token |
|
|
109
|
+
| `JwtKit.decode(token)` | Decodes and validates a JWT token |
|
|
110
|
+
| `JwtKit.token_pair(payload)` | Generates an access/refresh token pair |
|
|
111
|
+
| `JwtKit.refresh(refresh_token)` | Issues a new access token from a refresh token |
|
|
112
|
+
| `JwtKit.revoke(token)` | Revokes a token by its JTI |
|
|
113
|
+
| `JwtKit.revoked?(token)` | Checks if a token has been revoked |
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
bundle install
|
|
119
|
+
bundle exec rspec
|
|
120
|
+
bundle exec rubocop
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module JwtKit
|
|
5
|
+
# Configuration singleton for JWT settings.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# Philiprehberger::JwtKit.configure do |c|
|
|
9
|
+
# c.secret = 'my-secret-key'
|
|
10
|
+
# c.algorithm = :hs256
|
|
11
|
+
# c.issuer = 'my-app'
|
|
12
|
+
# c.expiration = 3600
|
|
13
|
+
# end
|
|
14
|
+
class Configuration
|
|
15
|
+
# @return [String, nil] HMAC secret key (required for HS* algorithms)
|
|
16
|
+
attr_accessor :secret
|
|
17
|
+
|
|
18
|
+
# @return [Symbol] signing algorithm (:hs256, :hs384, :hs512)
|
|
19
|
+
attr_accessor :algorithm
|
|
20
|
+
|
|
21
|
+
# @return [String, nil] optional issuer for the `iss` claim
|
|
22
|
+
attr_accessor :issuer
|
|
23
|
+
|
|
24
|
+
# @return [Integer] default TTL in seconds for access tokens
|
|
25
|
+
attr_accessor :expiration
|
|
26
|
+
|
|
27
|
+
# @return [Integer] default TTL in seconds for refresh tokens
|
|
28
|
+
attr_accessor :refresh_expiration
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@secret = nil
|
|
32
|
+
@algorithm = :hs256
|
|
33
|
+
@issuer = nil
|
|
34
|
+
@expiration = 3600
|
|
35
|
+
@refresh_expiration = 86_400 * 7
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the OpenSSL digest algorithm name.
|
|
39
|
+
#
|
|
40
|
+
# @return [String] digest name (e.g. 'SHA256')
|
|
41
|
+
# @raise [Error] if the algorithm is unsupported
|
|
42
|
+
def digest_algorithm
|
|
43
|
+
case @algorithm
|
|
44
|
+
when :hs256 then 'SHA256'
|
|
45
|
+
when :hs384 then 'SHA384'
|
|
46
|
+
when :hs512 then 'SHA512'
|
|
47
|
+
else raise Error, "Unsupported algorithm: #{@algorithm}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns the JWT algorithm header value.
|
|
52
|
+
#
|
|
53
|
+
# @return [String] algorithm name (e.g. 'HS256')
|
|
54
|
+
def algorithm_name
|
|
55
|
+
@algorithm.to_s.upcase
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module JwtKit
|
|
5
|
+
# Decodes and validates JWT tokens.
|
|
6
|
+
module Decoder
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Decodes a JWT token and validates its claims.
|
|
10
|
+
#
|
|
11
|
+
# @param token [String] JWT token string
|
|
12
|
+
# @param config [Configuration] JWT configuration
|
|
13
|
+
# @return [Hash] decoded payload with string keys
|
|
14
|
+
# @raise [DecodeError] if the token format is invalid
|
|
15
|
+
# @raise [InvalidSignature] if the signature does not match
|
|
16
|
+
# @raise [TokenExpired] if the token has expired
|
|
17
|
+
# @raise [InvalidIssuer] if the issuer does not match the configuration
|
|
18
|
+
def decode(token, config)
|
|
19
|
+
raise DecodeError, 'Token must be a string' unless token.is_a?(String)
|
|
20
|
+
|
|
21
|
+
parts = token.split('.')
|
|
22
|
+
raise DecodeError, 'Invalid token format: expected 3 segments' unless parts.length == 3
|
|
23
|
+
|
|
24
|
+
header_segment, payload_segment, signature_segment = parts
|
|
25
|
+
|
|
26
|
+
verify_signature!("#{header_segment}.#{payload_segment}", signature_segment, config)
|
|
27
|
+
|
|
28
|
+
payload = JSON.parse(base64url_decode(payload_segment))
|
|
29
|
+
|
|
30
|
+
validate_expiration!(payload)
|
|
31
|
+
validate_issuer!(payload, config)
|
|
32
|
+
|
|
33
|
+
payload
|
|
34
|
+
rescue JSON::ParserError
|
|
35
|
+
raise DecodeError, 'Invalid token: malformed JSON'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Base64url-decodes a string.
|
|
39
|
+
#
|
|
40
|
+
# @param data [String] base64url-encoded string
|
|
41
|
+
# @return [String] decoded string
|
|
42
|
+
def base64url_decode(data)
|
|
43
|
+
Base64.urlsafe_decode64(data)
|
|
44
|
+
rescue ArgumentError
|
|
45
|
+
raise DecodeError, 'Invalid token: malformed base64'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Verifies the token signature.
|
|
49
|
+
#
|
|
50
|
+
# @param signing_input [String] header.payload string
|
|
51
|
+
# @param signature [String] base64url-encoded signature
|
|
52
|
+
# @param config [Configuration] JWT configuration
|
|
53
|
+
# @raise [InvalidSignature] if the signature does not match
|
|
54
|
+
def verify_signature!(signing_input, signature, config)
|
|
55
|
+
expected = Encoder.sign(signing_input, config)
|
|
56
|
+
raise InvalidSignature, 'Token signature is invalid' unless secure_compare(expected, signature)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Validates the expiration claim.
|
|
60
|
+
#
|
|
61
|
+
# @param payload [Hash] decoded payload
|
|
62
|
+
# @raise [TokenExpired] if the token has expired
|
|
63
|
+
def validate_expiration!(payload)
|
|
64
|
+
exp = payload['exp']
|
|
65
|
+
return unless exp
|
|
66
|
+
|
|
67
|
+
raise TokenExpired, 'Token has expired' if exp.to_i <= Time.now.to_i
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Validates the issuer claim.
|
|
71
|
+
#
|
|
72
|
+
# @param payload [Hash] decoded payload
|
|
73
|
+
# @param config [Configuration] JWT configuration
|
|
74
|
+
# @raise [InvalidIssuer] if the issuer does not match
|
|
75
|
+
def validate_issuer!(payload, config)
|
|
76
|
+
return unless config.issuer
|
|
77
|
+
|
|
78
|
+
raise InvalidIssuer, "Invalid issuer: expected #{config.issuer}" unless payload['iss'] == config.issuer
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Constant-time string comparison to prevent timing attacks.
|
|
82
|
+
#
|
|
83
|
+
# @param a [String] first string
|
|
84
|
+
# @param b [String] second string
|
|
85
|
+
# @return [Boolean] true if the strings are equal
|
|
86
|
+
def secure_compare(a, b)
|
|
87
|
+
return false unless a.bytesize == b.bytesize
|
|
88
|
+
|
|
89
|
+
left = a.unpack('C*')
|
|
90
|
+
right = b.unpack('C*')
|
|
91
|
+
result = 0
|
|
92
|
+
left.each_with_index { |byte, i| result |= byte ^ right[i] }
|
|
93
|
+
result.zero?
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module JwtKit
|
|
5
|
+
# Encodes payloads into signed JWT tokens.
|
|
6
|
+
module Encoder
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Encodes a payload into a JWT token string.
|
|
10
|
+
#
|
|
11
|
+
# @param payload [Hash] custom claims to include in the token
|
|
12
|
+
# @param config [Configuration] JWT configuration
|
|
13
|
+
# @return [String] signed JWT token
|
|
14
|
+
# @raise [Error] if no secret is configured
|
|
15
|
+
def encode(payload, config)
|
|
16
|
+
raise Error, 'Secret is required for encoding' unless config.secret
|
|
17
|
+
|
|
18
|
+
header = { 'alg' => config.algorithm_name, 'typ' => 'JWT' }
|
|
19
|
+
now = Time.now.to_i
|
|
20
|
+
|
|
21
|
+
claims = {
|
|
22
|
+
'exp' => now + config.expiration,
|
|
23
|
+
'iat' => now,
|
|
24
|
+
'jti' => SecureRandom.uuid
|
|
25
|
+
}
|
|
26
|
+
claims['iss'] = config.issuer if config.issuer
|
|
27
|
+
|
|
28
|
+
merged = claims.merge(payload.transform_keys(&:to_s))
|
|
29
|
+
|
|
30
|
+
header_segment = base64url_encode(JSON.generate(header))
|
|
31
|
+
payload_segment = base64url_encode(JSON.generate(merged))
|
|
32
|
+
signing_input = "#{header_segment}.#{payload_segment}"
|
|
33
|
+
signature = sign(signing_input, config)
|
|
34
|
+
|
|
35
|
+
"#{signing_input}.#{signature}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Base64url-encodes a string without padding.
|
|
39
|
+
#
|
|
40
|
+
# @param data [String] data to encode
|
|
41
|
+
# @return [String] base64url-encoded string
|
|
42
|
+
def base64url_encode(data)
|
|
43
|
+
Base64.urlsafe_encode64(data, padding: false)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Signs data using HMAC with the configured algorithm.
|
|
47
|
+
#
|
|
48
|
+
# @param data [String] data to sign
|
|
49
|
+
# @param config [Configuration] JWT configuration
|
|
50
|
+
# @return [String] base64url-encoded signature
|
|
51
|
+
def sign(data, config)
|
|
52
|
+
digest = OpenSSL::Digest.new(config.digest_algorithm)
|
|
53
|
+
signature = OpenSSL::HMAC.digest(digest, config.secret, data)
|
|
54
|
+
base64url_encode(signature)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Philiprehberger
|
|
6
|
+
module JwtKit
|
|
7
|
+
# Token revocation support with an in-memory store.
|
|
8
|
+
module Revocation
|
|
9
|
+
# Thread-safe in-memory revocation store backed by a Set.
|
|
10
|
+
class MemoryStore
|
|
11
|
+
def initialize
|
|
12
|
+
@revoked = Set.new
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Revokes a token by extracting and storing its JTI.
|
|
17
|
+
#
|
|
18
|
+
# @param token [String] JWT token to revoke
|
|
19
|
+
# @return [void]
|
|
20
|
+
def revoke(token)
|
|
21
|
+
jti = extract_jti(token)
|
|
22
|
+
return unless jti
|
|
23
|
+
|
|
24
|
+
@mutex.synchronize { @revoked.add(jti) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Checks whether a token has been revoked.
|
|
28
|
+
#
|
|
29
|
+
# @param token [String] JWT token to check
|
|
30
|
+
# @return [Boolean] true if the token has been revoked
|
|
31
|
+
def revoked?(token)
|
|
32
|
+
jti = extract_jti(token)
|
|
33
|
+
return false unless jti
|
|
34
|
+
|
|
35
|
+
@mutex.synchronize { @revoked.include?(jti) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Clears all revoked tokens.
|
|
39
|
+
#
|
|
40
|
+
# @return [void]
|
|
41
|
+
def clear
|
|
42
|
+
@mutex.synchronize { @revoked.clear }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the number of revoked tokens.
|
|
46
|
+
#
|
|
47
|
+
# @return [Integer]
|
|
48
|
+
def size
|
|
49
|
+
@mutex.synchronize { @revoked.size }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def extract_jti(token)
|
|
55
|
+
parts = token.split('.')
|
|
56
|
+
return nil unless parts.length == 3
|
|
57
|
+
|
|
58
|
+
payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
|
|
59
|
+
payload['jti']
|
|
60
|
+
rescue JSON::ParserError, ArgumentError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module JwtKit
|
|
5
|
+
# Generates and refreshes access/refresh token pairs.
|
|
6
|
+
module TokenPair
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Generates an access token and refresh token pair.
|
|
10
|
+
#
|
|
11
|
+
# @param payload [Hash] custom claims to include in both tokens
|
|
12
|
+
# @param config [Configuration] JWT configuration
|
|
13
|
+
# @return [Array<String>] `[access_token, refresh_token]`
|
|
14
|
+
def generate(payload, config)
|
|
15
|
+
access_token = Encoder.encode(payload, config)
|
|
16
|
+
|
|
17
|
+
refresh_payload = payload.merge('type' => 'refresh')
|
|
18
|
+
original_expiration = config.expiration
|
|
19
|
+
config.expiration = config.refresh_expiration
|
|
20
|
+
refresh_token = Encoder.encode(refresh_payload, config)
|
|
21
|
+
config.expiration = original_expiration
|
|
22
|
+
|
|
23
|
+
[access_token, refresh_token]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Refreshes an access token using a valid refresh token.
|
|
27
|
+
#
|
|
28
|
+
# @param refresh_token [String] a valid refresh token
|
|
29
|
+
# @param config [Configuration] JWT configuration
|
|
30
|
+
# @return [String] new access token
|
|
31
|
+
# @raise [InvalidToken] if the token is not a refresh token
|
|
32
|
+
def refresh(refresh_token, config)
|
|
33
|
+
payload = Decoder.decode(refresh_token, config)
|
|
34
|
+
raise InvalidToken, 'Token is not a refresh token' unless payload['type'] == 'refresh'
|
|
35
|
+
|
|
36
|
+
new_payload = payload.except('exp', 'iat', 'jti', 'iss', 'type')
|
|
37
|
+
Encoder.encode(new_payload, config)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'base64'
|
|
7
|
+
require_relative 'jwt_kit/version'
|
|
8
|
+
require_relative 'jwt_kit/configuration'
|
|
9
|
+
require_relative 'jwt_kit/encoder'
|
|
10
|
+
require_relative 'jwt_kit/decoder'
|
|
11
|
+
require_relative 'jwt_kit/token_pair'
|
|
12
|
+
require_relative 'jwt_kit/revocation'
|
|
13
|
+
|
|
14
|
+
module Philiprehberger
|
|
15
|
+
module JwtKit
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
class DecodeError < Error; end
|
|
18
|
+
class TokenExpired < DecodeError; end
|
|
19
|
+
class InvalidSignature < DecodeError; end
|
|
20
|
+
class InvalidIssuer < DecodeError; end
|
|
21
|
+
class InvalidToken < DecodeError; end
|
|
22
|
+
class RevokedToken < DecodeError; end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Configures JwtKit using a block.
|
|
26
|
+
#
|
|
27
|
+
# @yieldparam config [Configuration] the configuration instance
|
|
28
|
+
# @return [void]
|
|
29
|
+
def configure
|
|
30
|
+
yield(configuration)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns the current configuration.
|
|
34
|
+
#
|
|
35
|
+
# @return [Configuration]
|
|
36
|
+
def configuration
|
|
37
|
+
@configuration ||= Configuration.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Resets the configuration to defaults.
|
|
41
|
+
#
|
|
42
|
+
# @return [Configuration]
|
|
43
|
+
def reset_configuration!
|
|
44
|
+
@configuration = Configuration.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Encodes a payload into a signed JWT token.
|
|
48
|
+
#
|
|
49
|
+
# @param payload [Hash] custom claims
|
|
50
|
+
# @return [String] signed JWT token
|
|
51
|
+
def encode(payload = {})
|
|
52
|
+
Encoder.encode(payload, configuration)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Decodes a JWT token and validates its claims.
|
|
56
|
+
#
|
|
57
|
+
# @param token [String] JWT token
|
|
58
|
+
# @return [Hash] decoded payload
|
|
59
|
+
# @raise [RevokedToken] if the token has been revoked
|
|
60
|
+
def decode(token)
|
|
61
|
+
payload = Decoder.decode(token, configuration)
|
|
62
|
+
raise RevokedToken, 'Token has been revoked' if revocation_store.revoked?(token)
|
|
63
|
+
|
|
64
|
+
payload
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Generates an access/refresh token pair.
|
|
68
|
+
#
|
|
69
|
+
# @param payload [Hash] custom claims
|
|
70
|
+
# @return [Array<String>] `[access_token, refresh_token]`
|
|
71
|
+
def token_pair(payload = {})
|
|
72
|
+
TokenPair.generate(payload, configuration)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Generates a new access token from a refresh token.
|
|
76
|
+
#
|
|
77
|
+
# @param refresh_token [String] valid refresh token
|
|
78
|
+
# @return [String] new access token
|
|
79
|
+
def refresh(refresh_token)
|
|
80
|
+
TokenPair.refresh(refresh_token, configuration)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Revokes a token.
|
|
84
|
+
#
|
|
85
|
+
# @param token [String] JWT token to revoke
|
|
86
|
+
# @return [void]
|
|
87
|
+
def revoke(token)
|
|
88
|
+
revocation_store.revoke(token)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Checks whether a token has been revoked.
|
|
92
|
+
#
|
|
93
|
+
# @param token [String] JWT token
|
|
94
|
+
# @return [Boolean]
|
|
95
|
+
def revoked?(token)
|
|
96
|
+
revocation_store.revoked?(token)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns the revocation store.
|
|
100
|
+
#
|
|
101
|
+
# @return [Revocation::MemoryStore]
|
|
102
|
+
def revocation_store
|
|
103
|
+
@revocation_store ||= Revocation::MemoryStore.new
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Resets the revocation store.
|
|
107
|
+
#
|
|
108
|
+
# @return [Revocation::MemoryStore]
|
|
109
|
+
def reset_revocation_store!
|
|
110
|
+
@revocation_store = Revocation::MemoryStore.new
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: philiprehberger-jwt_kit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Philip Rehberger
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-22 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: A complete JWT toolkit for Ruby. Encode and decode tokens with automatic
|
|
14
|
+
claim management (exp, iat, iss, jti), generate access/refresh token pairs, validate
|
|
15
|
+
expiration and issuer, and revoke tokens — all without external dependencies.
|
|
16
|
+
email:
|
|
17
|
+
- me@philiprehberger.com
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE
|
|
24
|
+
- README.md
|
|
25
|
+
- lib/philiprehberger/jwt_kit.rb
|
|
26
|
+
- lib/philiprehberger/jwt_kit/configuration.rb
|
|
27
|
+
- lib/philiprehberger/jwt_kit/decoder.rb
|
|
28
|
+
- lib/philiprehberger/jwt_kit/encoder.rb
|
|
29
|
+
- lib/philiprehberger/jwt_kit/revocation.rb
|
|
30
|
+
- lib/philiprehberger/jwt_kit/token_pair.rb
|
|
31
|
+
- lib/philiprehberger/jwt_kit/version.rb
|
|
32
|
+
homepage: https://github.com/philiprehberger/rb-jwt-kit
|
|
33
|
+
licenses:
|
|
34
|
+
- MIT
|
|
35
|
+
metadata:
|
|
36
|
+
homepage_uri: https://github.com/philiprehberger/rb-jwt-kit
|
|
37
|
+
source_code_uri: https://github.com/philiprehberger/rb-jwt-kit
|
|
38
|
+
changelog_uri: https://github.com/philiprehberger/rb-jwt-kit/blob/main/CHANGELOG.md
|
|
39
|
+
bug_tracker_uri: https://github.com/philiprehberger/rb-jwt-kit/issues
|
|
40
|
+
rubygems_mfa_required: 'true'
|
|
41
|
+
post_install_message:
|
|
42
|
+
rdoc_options: []
|
|
43
|
+
require_paths:
|
|
44
|
+
- lib
|
|
45
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: 3.1.0
|
|
50
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
55
|
+
requirements: []
|
|
56
|
+
rubygems_version: 3.5.22
|
|
57
|
+
signing_key:
|
|
58
|
+
specification_version: 4
|
|
59
|
+
summary: Opinionated JWT toolkit with encoding, validation, refresh tokens, and revocation
|
|
60
|
+
test_files: []
|