tokenzen 0.1.0 → 0.2.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: 2f4dde7ef56bd6c8ed1d4b23b43f4256b17dfce96f9a0b8a0272e294db5fbf30
4
- data.tar.gz: c8fbddcc5c83663e8635063fc67dd0d48b085166ce1c8b8f13f3f5c01266bbe5
3
+ metadata.gz: 82f6a97feb3cf2582a57f606802d2978ecb8b7e59a761f5ca11b243235ec2665
4
+ data.tar.gz: d4bce0dc31a14c18d299b1bbfa02841d0be1271174dff28c994e0e72e3385e5a
5
5
  SHA512:
6
- metadata.gz: 13f223728ffc56a0609a1834b09c1e1bb4b5dc0d6d4a7a426a221d9d42bd316a52cea0135ef11d9430943057ae43ec67c2b5e8c98a624a624e3209e9b389100e
7
- data.tar.gz: 4b38a201f3a87dd92e0c8c68d4eed112ff497f960c10cab5ebea806843e430b21d1c93f85247974649956bd8915701d1971c96ad442e9d461af19b928a367adb
6
+ metadata.gz: 45e06ea452280b363a8552d6498331f7281a754307eddf72231f67b3d0983a462593ba7ca8fcbf5a884a66eea092e938c620bf02e91b5c4d1341b63cf8ee6fa5
7
+ data.tar.gz: 47f09407cbea9d9ed77f82a9e2aedc39ecf959fc9ef956ee442476b3266a66d8eaa6c6951550fe7d70d4f0e71c4f5617f8be38b053a88da75c2953e591650181
data/README.md CHANGED
@@ -1,57 +1,52 @@
1
1
  Tokenzen
2
2
  =======
3
3
 
4
- Tokenzen is a lightweight, model-agnostic token authentication toolkit for Rails.
4
+ Tokenzen is a lightweight, session-based token authentication toolkit for Rails.
5
5
 
6
- It provides secure, polymorphic access token management for any ActiveRecord model — not just User.
6
+ It provides secure, encrypted access + refresh token management for any ActiveRecord model — not just User.
7
7
 
8
8
  Tokenzen is designed to be:
9
9
  - Model agnostic
10
10
  - Multi-model compatible
11
+ - Multi-device aware
12
+ - Session-limited
13
+ - Secure refresh-rotation enabled
11
14
  - Cache-backed and scalable
12
- - Configurable
13
15
  - Lightweight
14
16
  - Easy to integrate
15
17
 
16
18
  ========================================
17
19
  FEATURES
18
20
  =========
19
-
20
21
  - Works with any model (Admin, Customer, Account, etc.)
21
- - Access and refresh token generation
22
- - Token revocation (logout)
23
- - Multi-device support
24
- - Automatic token rotation on refresh token change
25
- - Token validation
26
- - Refresh access token using refresh token
22
+ - Access + Refresh token generation
23
+ - Secure refresh token rotation (replay-safe)
24
+ - Multi-device login support
25
+ - Configurable max session limit
26
+ - Automatic session revocation on password change
27
+ - Logout from all devices
28
+ - Token validation via class-level API
27
29
  - Configurable expiration
28
- - AES-256 encryption of tokens
30
+ - AES-256-GCM encryption of tokens
29
31
  - Rails auto-loading via Railtie
30
32
 
31
33
  ========================================
32
34
  INSTALLATION
33
35
  ============
34
-
35
36
  Add this to your application's Gemfile:
36
-
37
37
  gem "tokenzen"
38
38
 
39
39
  Then run:
40
-
41
40
  bundle install
42
41
 
43
42
  Or install manually:
44
-
45
43
  gem install tokenzen
46
44
 
47
45
  ========================================
48
46
  BASIC USAGE
49
47
  ===========
50
-
51
48
  Include Tokenzen in any ActiveRecord model.
52
49
 
53
- Example:
54
-
55
50
  class Admin < ApplicationRecord
56
51
  include Tokenzen::Authenticatable
57
52
  tokenzen
@@ -67,42 +62,39 @@ You can use Tokenzen in multiple models at the same time:
67
62
  ========================================
68
63
  GENERATE ACCESS + REFRESH TOKEN
69
64
  ===============================
70
-
71
65
  admin = Admin.first
72
66
  tokens = admin.generate_tokens
73
- # tokens => { access_token: "...", refresh_token: "..." }
67
+ # tokens => { access_token: "...", refresh_token: "..." }
74
68
 
75
- This generates secure encrypted tokens and stores them in cache with separate expiries.
69
+ This:
70
+ - Creates a new session
71
+ - Enforces max session limit
72
+ - Stores tokens in cache
73
+ - Encrypts them using AES-256
74
+ - Returns encrypted tokens
76
75
 
77
76
  ========================================
78
- LOGIN / VALIDATE TOKENS
77
+ AUTHENTICATE ACCESS TOKEN
79
78
  =======================
80
-
81
- Use the class-level `login` method to validate a token pair:
82
-
83
- record = Admin.login(tokens[:access_token], tokens[:refresh_token])
84
- # returns Admin instance if valid, nil if invalid
85
-
86
79
  You can also validate token pair from an instance:
87
80
 
88
- valid = admin.validate_tokens(tokens[:access_token])
89
- # => ActiveRecord or nil
81
+ admin = Admin.validate_token(access_token)
82
+ # => Admin record or nil
90
83
 
91
84
  ========================================
92
- REFRESH ACCESS TOKEN
85
+ REFRESH / ROTATE TOKENS
93
86
  ====================
94
-
95
87
  Use the refresh token to generate a new access token:
96
88
 
97
- new_tokens = Admin.refresh_access(tokens[:refresh_token])
98
- # returns { access_token: "...", refresh_token: "..." }
89
+ new_tokens = Admin.rotate_tokens(refresh_token)
90
+ # returns { access_token: "...", refresh_token: "..." }
99
91
 
100
92
  ========================================
101
93
  LOGOUT / CLEAR ALL TOKENS
102
94
  =========================
103
-
104
- admin.logout
105
- # clears all access and refresh tokens for this record
95
+ admin.logout(access_token)-> delete current session
96
+ admin.logout_all
97
+ # clears all access and refresh tokens for this record
106
98
 
107
99
  ========================================
108
100
  CONFIGURATION
@@ -115,6 +107,7 @@ Create an initializer:
115
107
  Tokenzen.configure do |config|
116
108
  config.access_token_expiry = 2.days
117
109
  config.refresh_token_expiry = 2.months
110
+ config.max_sessions = 3
118
111
  config.secret_key = ENV["TOKENZEN_SECRET_KEY"] || Rails.application.secret_key_base
119
112
  end
120
113
 
@@ -123,44 +116,42 @@ The gem automatically encrypts all tokens using AES-256 with this secret key.
123
116
  ========================================
124
117
  HOW IT WORKS
125
118
  =============
126
-
127
- When a token is issued:
128
-
129
- - A secure random key is generated for access and refresh tokens.
130
- - Tokens are stored in cache along with model name and record ID.
131
- - Tokens are encrypted using AES-256.
132
- - Tokens are tracked per record for easy revocation.
119
+ When a user logs in:
120
+ - A new session_id is created.
121
+ - Secure random keys are generated for access + refresh tokens.
122
+ - Tokens are stored in cache (Redis recommended).
123
+ - Tokens are encrypted using AES-256-GCM.
124
+ - Sessions are tracked per model record.
125
+ - Oldest session is removed if max_sessions limit is reached.
133
126
 
134
127
  Stored payload example:
135
128
 
136
129
  {
137
130
  "model" => "Admin",
138
131
  "id" => 1,
139
- "type" => "access" # or "refresh"
132
+ "type" => "access" # or "refresh",
133
+ "session_id" => "uuid"
140
134
  }
141
135
 
142
136
  This allows Tokenzen to work with any ActiveRecord model automatically.
143
137
 
144
138
  ========================================
145
- TOKEN ROTATION
139
+ SESSION MANAGEMENT
146
140
  ===============
141
+ Tokenzen supports:
142
+ - Multiple devices per user
143
+ - Configurable max session limit
144
+ - Automatic removal of oldest session when limit exceeded
145
+ - Full session revocation
147
146
 
148
- If your model includes:
149
-
150
- - refresh_token
151
- - password_digest
152
-
153
- Tokenzen will automatically:
154
-
155
- - Rotate the refresh token when password changes
156
- - Clear all access tokens when refresh token changes
147
+ Example:
148
+ If max_sessions = 3
157
149
 
158
- This behavior is optional and safely guarded.
150
+ Logging in from 4th device will revoke the oldest session.
159
151
 
160
152
  ========================================
161
153
  PRODUCTION RECOMMENDATION
162
154
  ==========================
163
-
164
155
  Use Redis as your cache store for production environments:
165
156
 
166
157
  config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] }
@@ -178,27 +169,28 @@ REQUIREMENTS
178
169
  ========================================
179
170
  SECURITY NOTES
180
171
  ================
181
-
182
- - Tokens are encrypted using AES-256
183
- - Tokens are stored in cache (Redis recommended)
184
- - Tokens are namespaced per model
185
- - Clearing tokens invalidates all sessions instantly
172
+ - Tokens encrypted using AES-256-GCM
173
+ - Refresh tokens rotate on use
174
+ - Old refresh tokens invalidated immediately
175
+ - Sessions revocable instantly
176
+ - No tokens stored in database
177
+ - No fingerprint/device binding required
178
+ - Replay attack resistant refresh flow
186
179
 
187
180
  ========================================
188
181
  ROADMAP
189
182
  ========
190
-
191
- - Controller helpers (current_resource)
183
+ - Per-device logout
184
+ - Session listing API
185
+ - Sliding expiration
186
+ - Controller helpers
192
187
  - Rack middleware
193
- - Stateless JWT mode
194
- - Device/session tracking
195
- - Revokable single-session tokens
196
- - OAuth support
188
+ - Optional JWT mode
189
+ - OAuth compatibility layer
197
190
 
198
191
  ========================================
199
192
  CONTRIBUTING
200
193
  ==============
201
-
202
194
  Bug reports and pull requests are welcome at:
203
195
 
204
196
  https://github.com/stndrk/tokenzen
@@ -2,92 +2,133 @@
2
2
 
3
3
  module Tokenzen
4
4
  module Authenticatable
5
- def self.included(base)
6
- base.extend(ClassMethods)
5
+ extend ActiveSupport::Concern
6
+
7
+ # ============================================================
8
+ # CALLBACKS
9
+ # ============================================================
10
+ included do
11
+ after_commit :_tokenzen_revoke_on_password_change, on: :update
12
+ after_commit :_tokenzen_revoke_all_on_destroy, on: :destroy
7
13
  end
8
14
 
9
- module ClassMethods
10
- def tokenzen(options = {})
11
- include Tokenzen::Authenticatable::InstanceMethods
15
+ # ============================================================
16
+ # CLASS METHODS
17
+ # ============================================================
18
+ class_methods do
12
19
 
13
- class_attribute :tokenzen_options
14
- self.tokenzen_options = options
15
-
16
- before_save :_tokenzen_rotate_refresh_token
17
- around_save :_tokenzen_handle_refresh_rotation
18
- after_destroy :_tokenzen_clear_tokens
20
+ # Enable token features in model
21
+ def tokenzen
22
+ include InstanceMethods unless included_modules.include?(InstanceMethods)
19
23
  end
20
- end
21
24
 
22
- module InstanceMethods
23
- attr_reader :current_access_token, :current_refresh_token
25
+ # -------------------------------
26
+ # VALIDATE ACCESS TOKEN
27
+ # -------------------------------
28
+ def validate_token(access_token)
29
+ return if access_token.blank?
24
30
 
25
- # Generate access + refresh token
26
- def generate_tokens(access_expiry: nil, refresh_expiry: nil)
27
- tokens = TokenStore.issue_tokens(self, access_expiry: access_expiry, refresh_expiry: refresh_expiry)
28
- @current_access_token = tokens[:access_token]
29
- @current_refresh_token = tokens[:refresh_token]
30
- tokens
31
+ TokenStore.authenticate(access_token)
32
+ rescue
33
+ nil
31
34
  end
32
35
 
33
- # Logout: clear all tokens
34
- def logout
35
- clear_all_access_tokens
36
- end
36
+ # -------------------------------
37
+ # ROTATE REFRESH TOKEN
38
+ # -------------------------------
39
+ def rotate_tokens(refresh_token)
40
+ return if refresh_token.blank?
37
41
 
38
- # Clear all tokens
39
- def clear_all_access_tokens
40
- TokenStore.clear_tokens(self)
41
- @current_access_token = nil
42
- @current_refresh_token = nil
42
+ TokenStore.renew_tokens(refresh_token)
43
+ rescue
44
+ nil
43
45
  end
44
46
 
45
- # Validate tokens for this record
46
- def validate_tokens(access_token)
47
- TokenStore.fetch_record_from_access(access_token)
47
+ # -------------------------------
48
+ # LOGOUT CURRENT SESSION
49
+ # -------------------------------
50
+ def logout(access_token)
51
+ return if access_token.blank?
52
+
53
+ TokenStore.revoke_current_session(access_token)
54
+ rescue
55
+ nil
48
56
  end
57
+ end
49
58
 
50
- # =============================
51
- # Class-level login using token pair
52
- # =============================
53
- #
54
- # Returns the record if tokens valid, else nil
55
- #
56
- def self.login(access_token, refresh_token = nil)
57
- record = TokenStore.fetch_record_from_access(access_token)
58
- return nil unless record
59
-
60
- if refresh_token
61
- refresh_record = TokenStore.fetch_record_from_refresh(refresh_token)
62
- return nil unless refresh_record == record
63
- end
59
+ # ============================================================
60
+ # INSTANCE METHODS
61
+ # ============================================================
62
+ module InstanceMethods
64
63
 
65
- record
64
+ attr_reader :current_access_token, :current_refresh_token
65
+
66
+ # -------------------------------
67
+ # LOGIN / ISSUE TOKENS
68
+ # -------------------------------
69
+ def generate_tokens
70
+ raise "Record must be persisted" unless persisted?
71
+
72
+ tokens = TokenStore.issue_tokens(self)
73
+
74
+ assign_tokens(tokens) if tokens.present?
75
+
76
+ tokens
77
+ rescue
78
+ nil
66
79
  end
67
80
 
68
- # Refresh access token using refresh token
69
- def self.refresh_access(refresh_token)
70
- TokenStore.refresh_access_token(refresh_token)
81
+ # -------------------------------
82
+ # LOGOUT ALL DEVICES
83
+ # -------------------------------
84
+ def logout_all
85
+ return unless persisted?
86
+
87
+ TokenStore.revoke_all(self)
88
+ rescue
89
+ nil
71
90
  end
72
91
 
73
92
  private
74
93
 
75
- def _tokenzen_rotate_refresh_token
76
- return unless respond_to?(:password_digest_changed?) && password_digest_changed?
77
- self.refresh_token = JwtProcessor.encode(model: self.class.name, id: id) if respond_to?(:refresh_token=)
94
+ # ============================================================
95
+ # PASSWORD CHANGE DETECTION
96
+ # ============================================================
97
+ def _tokenzen_revoke_on_password_change
98
+ return unless _password_changed?
99
+
100
+ TokenStore.revoke_all(self)
101
+ rescue
102
+ nil
78
103
  end
79
104
 
80
- def _tokenzen_handle_refresh_rotation
81
- changed = respond_to?(:refresh_token_changed?) && refresh_token_changed?
82
- yield
83
- return unless changed
84
- clear_all_access_tokens
85
- generate_tokens
105
+ # Support both has_secure_password and custom columns
106
+ def _password_changed?
107
+ if respond_to?(:saved_change_to_password_digest?)
108
+ saved_change_to_password_digest?
109
+ elsif respond_to?(:saved_change_to_encrypted_password?)
110
+ saved_change_to_encrypted_password?
111
+ else
112
+ false
113
+ end
86
114
  end
87
115
 
88
- def _tokenzen_clear_tokens
89
- clear_all_access_tokens
116
+ # ============================================================
117
+ # DESTROY CALLBACK
118
+ # ============================================================
119
+ def _tokenzen_revoke_all_on_destroy
120
+ TokenStore.revoke_all(self)
121
+ rescue
122
+ nil
123
+ end
124
+
125
+ # ============================================================
126
+ # ASSIGN TOKENS TO INSTANCE
127
+ # ============================================================
128
+ def assign_tokens(tokens)
129
+ @current_access_token = tokens[:access_token]
130
+ @current_refresh_token = tokens[:refresh_token]
90
131
  end
91
132
  end
92
133
  end
93
- end
134
+ end
@@ -1,52 +1,80 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support"
4
- require "active_support/message_encryptor"
5
- require "active_support/key_generator"
3
+ require "openssl"
4
+ require "base64"
5
+ require "digest"
6
6
 
7
7
  module Tokenzen
8
8
  class Configuration
9
- attr_accessor :cache_store,
10
- :access_token_expiry,
11
- :refresh_token_expiry,
12
- :secret_key
9
+ class << self
10
+ attr_accessor :cache_store,
11
+ :access_token_expiry,
12
+ :refresh_token_expiry,
13
+ :secret_key,
14
+ :max_sessions
13
15
 
14
- def initialize
15
- @cache_store = Rails.cache
16
- @access_token_expiry = 2.days
17
- @refresh_token_expiry = 2.months
16
+ # ================================
17
+ # Default Setup
18
+ # ================================
19
+ def setup!
20
+ @cache_store ||= defined?(Rails) ? Rails.cache : {}
21
+ @access_token_expiry ||= 172800 # 2 days in seconds
22
+ @refresh_token_expiry ||= 5_184_000 # 60 days
23
+ @max_sessions = 3
24
+ @secret_key ||= default_secret_key
25
+ end
18
26
 
19
- @secret_key = default_secret_key
20
- end
27
+ # ================================
28
+ # AES-256-GCM Encryption
29
+ # ================================
30
+ def encrypt(value)
31
+ setup!
21
32
 
22
- # Public encrypt method
23
- def encrypt(value)
24
- message_encryptor.encrypt_and_sign(value)
25
- end
33
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
34
+ cipher.encrypt
35
+ cipher.key = derived_key
36
+ iv = cipher.random_iv
26
37
 
27
- # Public decrypt method
28
- def decrypt(value)
29
- message_encryptor.decrypt_and_verify(value)
30
- end
38
+ encrypted = cipher.update(value.to_s) + cipher.final
39
+ tag = cipher.auth_tag
40
+
41
+ Base64.strict_encode64(iv + tag + encrypted)
42
+ end
31
43
 
32
- private
44
+ def decrypt(value)
45
+ setup!
33
46
 
34
- def message_encryptor
35
- @message_encryptor ||= begin
36
- key_len = ActiveSupport::MessageEncryptor.key_len
37
- generator = ActiveSupport::KeyGenerator.new(@secret_key)
38
- key = generator.generate_key("tokenzen", key_len)
47
+ decoded = Base64.strict_decode64(value)
39
48
 
40
- ActiveSupport::MessageEncryptor.new(key)
49
+ iv = decoded[0..11]
50
+ tag = decoded[12..27]
51
+ encrypted = decoded[28..-1]
52
+
53
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
54
+ cipher.decrypt
55
+ cipher.key = derived_key
56
+ cipher.iv = iv
57
+ cipher.auth_tag = tag
58
+
59
+ cipher.update(encrypted) + cipher.final
60
+ rescue OpenSSL::Cipher::CipherError, ArgumentError
61
+ nil
62
+ end
63
+
64
+ private
65
+
66
+ def derived_key
67
+ Digest::SHA256.digest(secret_key)
41
68
  end
42
- end
43
69
 
44
- def default_secret_key
45
- if defined?(Rails) && Rails.application.respond_to?(:secret_key_base)
46
- Rails.application.secret_key_base
47
- else
48
- ENV["TOKENZEN_SECRET_KEY"] || raise("TOKENZEN_SECRET_KEY not set in ENV and Rails.secret_key_base not found")
70
+ def default_secret_key
71
+ if defined?(Rails) && Rails.application.respond_to?(:secret_key_base)
72
+ Rails.application.secret_key_base
73
+ else
74
+ ENV["TOKENZEN_SECRET_KEY"] ||
75
+ raise("TOKENZEN_SECRET_KEY not set and Rails.secret_key_base not found")
76
+ end
49
77
  end
50
78
  end
51
79
  end
52
- end
80
+ end
@@ -1,102 +1,282 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Tokenzen
4
6
  class TokenStore
5
7
  class << self
6
8
 
7
- # Generate both tokens and store in cache
8
- def issue_tokens(record, access_expiry: nil, refresh_expiry: nil)
9
- access_expiry ||= Tokenzen.configuration.access_token_expiry
10
- refresh_expiry ||= Tokenzen.configuration.refresh_token_expiry
9
+ # ============================================================
10
+ # ISSUE TOKENS (LOGIN / NEW DEVICE SESSION)
11
+ # ============================================================
12
+ def issue_tokens(record)
13
+ raise ArgumentError, "Record must be persisted" unless record&.id
11
14
 
12
- access_key = SecureRandom.hex(32)
13
- refresh_key = SecureRandom.hex(32)
15
+ enforce_session_limit(record)
14
16
 
15
- access_payload = {
16
- "model" => record.class.name,
17
- "id" => record.id,
18
- "type" => "access"
19
- }
17
+ session_id = SecureRandom.uuid
20
18
 
21
- refresh_payload = {
22
- "model" => record.class.name,
23
- "id" => record.id,
24
- "type" => "refresh"
25
- }
19
+ access_raw = SecureRandom.hex(64)
20
+ refresh_raw = SecureRandom.hex(64)
21
+
22
+ access_payload = build_payload(record, session_id, "access")
23
+ refresh_payload = build_payload(record, session_id, "refresh")
26
24
 
27
- cache.write(access_key, access_payload, expires_in: access_expiry)
28
- cache.write(refresh_key, refresh_payload, expires_in: refresh_expiry)
25
+ write_token(access_raw, access_payload, config.access_token_expiry)
26
+ write_token(refresh_raw, refresh_payload, config.refresh_token_expiry)
29
27
 
30
- register_token(record, access_key)
31
- register_token(record, refresh_key)
28
+ store_session_mapping(record, session_id, access_raw, refresh_raw)
29
+
30
+ register_session(record, session_id)
32
31
 
33
32
  {
34
- access_token: encrypt(access_key),
35
- refresh_token: encrypt(refresh_key)
33
+ access_token: encrypt(access_raw),
34
+ refresh_token: encrypt(refresh_raw)
36
35
  }
37
36
  end
38
37
 
39
- # Fetch record using access token
40
- def fetch_record_from_access(token)
41
- raw_key = decrypt(token)
42
- payload = cache.read(raw_key)
43
- return nil unless payload && payload["type"] == "access"
38
+ # ============================================================
39
+ # AUTHENTICATE ACCESS TOKEN
40
+ # ============================================================
41
+ def authenticate(encrypted_token)
42
+ raw = safe_decrypt(encrypted_token)
43
+ return unless raw
44
44
 
45
- payload["model"].constantize.find_by(id: payload["id"])
45
+ payload = safe_read(raw)
46
+ return unless valid_payload?(payload, "access")
47
+
48
+ record = safe_find_record(payload)
49
+ return unless record
50
+
51
+ # ensure session still valid
52
+ return unless session_exists?(record, payload["session_id"])
53
+
54
+ record
46
55
  end
47
56
 
48
- # Fetch record using refresh token
49
- def fetch_record_from_refresh(token)
50
- raw_key = decrypt(token)
51
- payload = cache.read(raw_key)
52
- return nil unless payload && payload["type"] == "refresh"
57
+ # ============================================================
58
+ # REFRESH TOKEN (ROTATION)
59
+ # ============================================================
60
+ def renew_tokens(encrypted_refresh_token)
61
+ raw = safe_decrypt(encrypted_refresh_token)
62
+ return unless raw
53
63
 
54
- payload["model"].constantize.find_by(id: payload["id"])
64
+ payload = safe_read(raw)
65
+ return unless valid_payload?(payload, "refresh")
66
+
67
+ record = safe_find_record(payload)
68
+ return unless record
69
+
70
+ session_id = payload["session_id"]
71
+
72
+ return unless session_exists?(record, session_id)
73
+
74
+ # delete old refresh token immediately (rotation)
75
+ cache.delete(raw)
76
+
77
+ revoke_session(record, session_id)
78
+
79
+ issue_tokens(record)
55
80
  end
56
81
 
57
- # Refresh access token using refresh token
58
- def refresh_access_token(refresh_token)
59
- record = fetch_record_from_refresh(refresh_token)
82
+ # ============================================================
83
+ # LOGOUT CURRENT SESSION (THIS DEVICE ONLY)
84
+ # ============================================================
85
+ def revoke_current_session(encrypted_access_token)
86
+ raw = safe_decrypt(encrypted_access_token)
87
+ return unless raw
88
+
89
+ payload = safe_read(raw)
90
+ return unless valid_payload?(payload, "access")
91
+
92
+ record = safe_find_record(payload)
60
93
  return unless record
61
94
 
62
- tokens = issue_tokens(record)
63
- clear_tokens(record) # clear old tokens
64
- tokens
95
+ revoke_session(record, payload["session_id"])
65
96
  end
66
97
 
67
- # Clear all tokens for a record
68
- def clear_tokens(record)
69
- tokens_for(record).each { |key| cache.delete(key) }
70
- cache.write(register_key(record), [], expires_in: Tokenzen.configuration.refresh_token_expiry)
98
+ # ============================================================
99
+ # LOGOUT ALL SESSIONS
100
+ # ============================================================
101
+ def revoke_all(record)
102
+ return unless record&.id
103
+
104
+ sessions(record).dup.each do |session_id|
105
+ revoke_session(record, session_id)
106
+ end
107
+
108
+ cache.delete(user_key(record))
71
109
  end
72
110
 
111
+ # ============================================================
112
+ # PRIVATE
113
+ # ============================================================
73
114
  private
74
115
 
75
- def register_token(record, raw_key)
76
- list = tokens_for(record)
77
- list << raw_key
78
- cache.write(register_key(record), list, expires_in: Tokenzen.configuration.refresh_token_expiry)
116
+ # -------------------------------
117
+ # TOKEN WRITE
118
+ # -------------------------------
119
+ def write_token(raw_key, payload, expiry)
120
+ cache.write(raw_key, payload, expires_in: expiry)
121
+ rescue => _e
122
+ nil
123
+ end
124
+
125
+ # -------------------------------
126
+ # BUILD PAYLOAD
127
+ # -------------------------------
128
+ def build_payload(record, session_id, type)
129
+ {
130
+ "model" => record.class.name,
131
+ "id" => record.id,
132
+ "type" => type,
133
+ "session_id" => session_id
134
+ }
135
+ end
136
+
137
+ # -------------------------------
138
+ # SAFE CACHE READ
139
+ # -------------------------------
140
+ def safe_read(key)
141
+ cache.read(key)
142
+ rescue
143
+ nil
144
+ end
145
+
146
+ # -------------------------------
147
+ # SAFE DECRYPT
148
+ # -------------------------------
149
+ def safe_decrypt(token)
150
+ decrypt(token)
151
+ rescue
152
+ nil
153
+ end
154
+
155
+ # -------------------------------
156
+ # VALIDATE PAYLOAD
157
+ # -------------------------------
158
+ def valid_payload?(payload, expected_type)
159
+ payload &&
160
+ payload["type"] == expected_type &&
161
+ payload["model"].present? &&
162
+ payload["id"].present? &&
163
+ payload["session_id"].present?
164
+ end
165
+
166
+ # -------------------------------
167
+ # SAFE FIND RECORD
168
+ # -------------------------------
169
+ def safe_find_record(payload)
170
+ payload["model"].constantize.find_by(id: payload["id"])
171
+ rescue
172
+ nil
173
+ end
174
+
175
+ # -------------------------------
176
+ # SESSION LIMIT ENFORCEMENT
177
+ # -------------------------------
178
+ def enforce_session_limit(record)
179
+ list = sessions(record)
180
+
181
+ return if list.size < config.max_sessions
182
+
183
+ oldest = list.first
184
+ revoke_session(record, oldest)
185
+ end
186
+
187
+ # -------------------------------
188
+ # REGISTER SESSION
189
+ # -------------------------------
190
+ def register_session(record, session_id)
191
+ list = sessions(record)
192
+ list << session_id
193
+
194
+ cache.write(
195
+ user_key(record),
196
+ list,
197
+ expires_in: config.refresh_token_expiry
198
+ )
199
+ rescue
200
+ nil
201
+ end
202
+
203
+ # -------------------------------
204
+ # STORE SESSION TOKEN MAPPING
205
+ # -------------------------------
206
+ def store_session_mapping(record, session_id, access_raw, refresh_raw)
207
+ cache.write(
208
+ session_token_key(record, session_id),
209
+ { access: access_raw, refresh: refresh_raw },
210
+ expires_in: config.refresh_token_expiry
211
+ )
212
+ rescue
213
+ nil
214
+ end
215
+
216
+ # -------------------------------
217
+ # REVOKE ONE SESSION
218
+ # -------------------------------
219
+ def revoke_session(record, session_id)
220
+ session_data = cache.read(session_token_key(record, session_id))
221
+ return unless session_data
222
+
223
+ cache.delete(session_data[:access])
224
+ cache.delete(session_data[:refresh])
225
+ cache.delete(session_token_key(record, session_id))
226
+
227
+ list = sessions(record)
228
+ list.delete(session_id)
229
+
230
+ cache.write(user_key(record), list)
231
+ rescue
232
+ nil
233
+ end
234
+
235
+ # -------------------------------
236
+ # CHECK SESSION EXISTS
237
+ # -------------------------------
238
+ def session_exists?(record, session_id)
239
+ sessions(record).include?(session_id)
240
+ end
241
+
242
+ # -------------------------------
243
+ # FETCH SESSIONS
244
+ # -------------------------------
245
+ def sessions(record)
246
+ cache.read(user_key(record)) || []
247
+ rescue
248
+ []
249
+ end
250
+
251
+ # -------------------------------
252
+ # CACHE KEYS
253
+ # -------------------------------
254
+ def user_key(record)
255
+ "tokenzen:#{record.class.name}:#{record.id}:sessions"
79
256
  end
80
257
 
81
- def tokens_for(record)
82
- cache.read(register_key(record)) || []
258
+ def session_token_key(record, session_id)
259
+ "tokenzen:#{record.class.name}:#{record.id}:#{session_id}"
83
260
  end
84
261
 
85
- def register_key(record)
86
- "tokenzen:#{record.class.name}:#{record.id}:tokens"
262
+ # -------------------------------
263
+ # HELPERS
264
+ # -------------------------------
265
+ def config
266
+ Tokenzen.configuration
87
267
  end
88
268
 
89
269
  def cache
90
- Tokenzen.configuration.cache_store
270
+ config.cache_store
91
271
  end
92
272
 
93
273
  def encrypt(value)
94
- Tokenzen.configuration.encrypt(value)
274
+ config.encrypt(value)
95
275
  end
96
276
 
97
277
  def decrypt(value)
98
- Tokenzen.configuration.decrypt(value)
278
+ config.decrypt(value)
99
279
  end
100
280
  end
101
281
  end
102
- end
282
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tokenzen
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tokenzen
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Satendra
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-16 00:00:00.000000000 Z
11
+ date: 2026-02-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -52,7 +52,6 @@ files:
52
52
  - lib/tokenzen.rb
53
53
  - lib/tokenzen/authenticatable.rb
54
54
  - lib/tokenzen/configuration.rb
55
- - lib/tokenzen/model.rb
56
55
  - lib/tokenzen/railtie.rb
57
56
  - lib/tokenzen/token_store.rb
58
57
  - lib/tokenzen/version.rb
@@ -1,46 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Tokenzen
4
- module Model
5
- extend ActiveSupport::Concern
6
-
7
- included do
8
- attr_reader :current_access_token
9
-
10
- before_save :renew_refresh_token_on_password_change
11
- around_save :handle_refresh_token_rotation
12
- after_destroy :clear_all_access_tokens
13
- end
14
-
15
- # PUBLIC API
16
- def generate_access_token
17
- token = Tokenzen::TokenStore.write(id)
18
- return unless token
19
-
20
- Tokenzen::TokenStore.store_user_token(id, token)
21
- @current_access_token = token
22
- end
23
-
24
- def clear_all_access_tokens
25
- Tokenzen::TokenStore.clear_user_tokens(id)
26
- @current_access_token = nil
27
- end
28
-
29
- private
30
-
31
- def renew_refresh_token_on_password_change
32
- return unless respond_to?(:password_digest_changed?) && password_digest_changed?
33
-
34
- self.refresh_token = JwtProcessor.encode(id: id)
35
- end
36
-
37
- def handle_refresh_token_rotation
38
- changed = respond_to?(:refresh_token_changed?) && refresh_token_changed?
39
- yield
40
- return unless changed
41
-
42
- clear_all_access_tokens
43
- generate_access_token
44
- end
45
- end
46
- end