clavis 0.7.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.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/.actrc +4 -0
  3. data/.cursor/rules/ruby-gem.mdc +49 -0
  4. data/.gemignore +6 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +88 -0
  7. data/.vscode/settings.json +22 -0
  8. data/CHANGELOG.md +127 -0
  9. data/CODE_OF_CONDUCT.md +3 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +838 -0
  12. data/Rakefile +341 -0
  13. data/UPGRADE.md +57 -0
  14. data/app/assets/stylesheets/clavis.css +133 -0
  15. data/app/controllers/clavis/auth_controller.rb +133 -0
  16. data/config/database.yml +16 -0
  17. data/config/routes.rb +49 -0
  18. data/docs/SECURITY.md +340 -0
  19. data/docs/TESTING.md +78 -0
  20. data/docs/integration.md +272 -0
  21. data/error_handling.md +355 -0
  22. data/file_structure.md +221 -0
  23. data/gemfiles/rails_80.gemfile +17 -0
  24. data/gemfiles/rails_80.gemfile.lock +286 -0
  25. data/implementation_plan.md +523 -0
  26. data/lib/clavis/configuration.rb +196 -0
  27. data/lib/clavis/controllers/concerns/authentication.rb +232 -0
  28. data/lib/clavis/controllers/concerns/session_management.rb +117 -0
  29. data/lib/clavis/engine.rb +191 -0
  30. data/lib/clavis/errors.rb +205 -0
  31. data/lib/clavis/logging.rb +116 -0
  32. data/lib/clavis/models/concerns/oauth_authenticatable.rb +169 -0
  33. data/lib/clavis/oauth_identity.rb +174 -0
  34. data/lib/clavis/providers/apple.rb +135 -0
  35. data/lib/clavis/providers/base.rb +432 -0
  36. data/lib/clavis/providers/custom_provider_example.rb +57 -0
  37. data/lib/clavis/providers/facebook.rb +84 -0
  38. data/lib/clavis/providers/generic.rb +63 -0
  39. data/lib/clavis/providers/github.rb +87 -0
  40. data/lib/clavis/providers/google.rb +98 -0
  41. data/lib/clavis/providers/microsoft.rb +57 -0
  42. data/lib/clavis/security/csrf_protection.rb +79 -0
  43. data/lib/clavis/security/https_enforcer.rb +90 -0
  44. data/lib/clavis/security/input_validator.rb +192 -0
  45. data/lib/clavis/security/parameter_filter.rb +64 -0
  46. data/lib/clavis/security/rate_limiter.rb +109 -0
  47. data/lib/clavis/security/redirect_uri_validator.rb +124 -0
  48. data/lib/clavis/security/session_manager.rb +220 -0
  49. data/lib/clavis/security/token_storage.rb +114 -0
  50. data/lib/clavis/user_info_normalizer.rb +74 -0
  51. data/lib/clavis/utils/nonce_store.rb +14 -0
  52. data/lib/clavis/utils/secure_token.rb +17 -0
  53. data/lib/clavis/utils/state_store.rb +18 -0
  54. data/lib/clavis/version.rb +6 -0
  55. data/lib/clavis/view_helpers.rb +260 -0
  56. data/lib/clavis.rb +132 -0
  57. data/lib/generators/clavis/controller/controller_generator.rb +48 -0
  58. data/lib/generators/clavis/controller/templates/controller.rb.tt +137 -0
  59. data/lib/generators/clavis/controller/templates/views/login.html.erb.tt +145 -0
  60. data/lib/generators/clavis/install_generator.rb +182 -0
  61. data/lib/generators/clavis/templates/add_oauth_to_users.rb +28 -0
  62. data/lib/generators/clavis/templates/clavis.css +133 -0
  63. data/lib/generators/clavis/templates/initializer.rb +47 -0
  64. data/lib/generators/clavis/templates/initializer.rb.tt +76 -0
  65. data/lib/generators/clavis/templates/migration.rb +18 -0
  66. data/lib/generators/clavis/templates/migration.rb.tt +16 -0
  67. data/lib/generators/clavis/user_method/user_method_generator.rb +219 -0
  68. data/lib/tasks/provider_verification.rake +77 -0
  69. data/llms.md +487 -0
  70. data/log/development.log +20 -0
  71. data/log/test.log +0 -0
  72. data/sig/clavis.rbs +4 -0
  73. data/testing_plan.md +710 -0
  74. metadata +258 -0
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clavis
4
+ # Base error class for all Clavis errors
5
+ class Error < StandardError; end
6
+
7
+ # Configuration errors
8
+ class ConfigurationError < Error; end
9
+
10
+ class MissingConfiguration < ConfigurationError
11
+ def initialize(missing_config)
12
+ super("Missing configuration: #{missing_config}")
13
+ end
14
+ end
15
+
16
+ class InvalidConfiguration < ConfigurationError
17
+ def initialize(config_name, message)
18
+ super("Invalid configuration for #{config_name}: #{message}")
19
+ end
20
+ end
21
+
22
+ # Provider errors
23
+ class ProviderError < Error; end
24
+
25
+ class UnsupportedProvider < ProviderError
26
+ def initialize(provider)
27
+ super("Unsupported provider: #{provider}")
28
+ end
29
+ end
30
+
31
+ class ProviderAPIError < ProviderError
32
+ def initialize(provider, message)
33
+ super("API error from #{provider}: #{message}")
34
+ end
35
+ end
36
+
37
+ class ProviderNotConfigured < ProviderError
38
+ def initialize(provider_or_message)
39
+ if provider_or_message.is_a?(String) && provider_or_message.include?("not configured")
40
+ # Already a detailed message
41
+ super
42
+ else
43
+ # Just a provider name, create a detailed message
44
+ provider = provider_or_message.to_s
45
+ message = "Provider '#{provider}' is not properly configured. " \
46
+ "Please check your configuration in config/initializers/clavis.rb.\n" \
47
+ "Required fields for #{provider} provider: client_id, client_secret, and redirect_uri.\n" \
48
+ "Example configuration:\n" \
49
+ "Clavis.configure do |config|\n " \
50
+ "config.providers = {\n " \
51
+ "#{provider}: {\n " \
52
+ "client_id: 'your_client_id',\n " \
53
+ "client_secret: 'your_client_secret',\n " \
54
+ "redirect_uri: 'https://your-app.com/auth/#{provider}/callback'\n " \
55
+ "}\n " \
56
+ "}\n" \
57
+ "end"
58
+ super(message)
59
+ end
60
+ end
61
+ end
62
+
63
+ # OAuth errors
64
+ class OAuthError < Error
65
+ def initialize(message = "OAuth error")
66
+ super
67
+ end
68
+ end
69
+
70
+ # Authorization errors
71
+ class AuthorizationError < Error; end
72
+
73
+ class AuthorizationDenied < AuthorizationError
74
+ def initialize(provider = nil)
75
+ message = provider ? "User denied authorization for #{provider}" : "User denied authorization"
76
+ super(message)
77
+ end
78
+ end
79
+
80
+ class InvalidState < AuthorizationError
81
+ def initialize
82
+ super("Invalid state parameter in callback")
83
+ end
84
+ end
85
+
86
+ class MissingState < AuthorizationError
87
+ def initialize
88
+ super("Missing state parameter in callback")
89
+ end
90
+ end
91
+
92
+ class InvalidNonce < AuthorizationError
93
+ def initialize
94
+ super("Invalid nonce in ID token")
95
+ end
96
+ end
97
+
98
+ class MissingNonce < AuthorizationError
99
+ def initialize
100
+ super("Missing nonce in ID token or session")
101
+ end
102
+ end
103
+
104
+ class InvalidRedirectUri < AuthorizationError
105
+ def initialize(uri)
106
+ super("Invalid redirect URI: #{uri}")
107
+ end
108
+ end
109
+
110
+ # Authentication errors
111
+ class AuthenticationError < Error
112
+ def initialize(message = "Authentication failed")
113
+ super
114
+ end
115
+ end
116
+
117
+ # Token errors
118
+ class TokenError < Error; end
119
+
120
+ class InvalidToken < TokenError
121
+ def initialize(message = "Invalid token")
122
+ super
123
+ end
124
+ end
125
+
126
+ class InvalidAccessToken < TokenError
127
+ def initialize
128
+ super("Invalid access token")
129
+ end
130
+ end
131
+
132
+ class InvalidGrant < TokenError
133
+ def initialize(message = "Invalid grant")
134
+ super
135
+ end
136
+ end
137
+
138
+ class ExpiredToken < TokenError
139
+ def initialize
140
+ super("Token has expired")
141
+ end
142
+ end
143
+
144
+ # Client errors
145
+ class InvalidClient < TokenError
146
+ def initialize(message = "Invalid client credentials")
147
+ super
148
+ end
149
+ end
150
+
151
+ class UnauthorizedClient < TokenError
152
+ def initialize(message = "The client is not authorized to use this grant type")
153
+ super
154
+ end
155
+ end
156
+
157
+ class UnsupportedGrantType < TokenError
158
+ def initialize(message = "The grant type is not supported by the authorization server")
159
+ super
160
+ end
161
+ end
162
+
163
+ class InvalidScope < TokenError
164
+ def initialize(message = "The requested scope is invalid or unknown")
165
+ super
166
+ end
167
+ end
168
+
169
+ class InsufficientScope < TokenError
170
+ def initialize(message = "The token does not have the required scopes")
171
+ super
172
+ end
173
+ end
174
+
175
+ # Operation errors
176
+ class UnsupportedOperation < Error
177
+ def initialize(message)
178
+ super("Unsupported operation: #{message}")
179
+ end
180
+ end
181
+
182
+ # User errors
183
+ class UserError < Error; end
184
+
185
+ class UserCreationFailed < UserError
186
+ def initialize(message = "Failed to create user")
187
+ super
188
+ end
189
+ end
190
+
191
+ class UserNotFound < UserError
192
+ def initialize
193
+ super("User not found")
194
+ end
195
+ end
196
+
197
+ # View errors
198
+ class ViewError < Error; end
199
+
200
+ class InvalidButton < ViewError
201
+ def initialize(provider)
202
+ super("Invalid button provider: #{provider}")
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Clavis
6
+ module Logging
7
+ class << self
8
+ def logger
9
+ @logger ||= defined?(Rails) ? Rails.logger : Logger.new($stdout)
10
+ end
11
+
12
+ attr_writer :logger
13
+
14
+ # Log informational messages
15
+ # @param message [String] The message to log
16
+ # @param level [Symbol] The log level (:info, :debug, etc.)
17
+ def log_info(message)
18
+ return unless logger
19
+ return if !message || message.empty?
20
+
21
+ # Sanitize any potentially sensitive data
22
+ sanitized_message = filter_sensitive_data(message)
23
+ logger.info("[Clavis] #{sanitized_message}")
24
+ end
25
+
26
+ # Log error messages
27
+ # @param error [Exception, String] The error to log
28
+ def log_error(error)
29
+ return unless logger
30
+
31
+ case error
32
+ when Exception
33
+ logger.error("[Clavis] #{error.class.name}: #{error.message}")
34
+ logger.debug("[Clavis] #{error.backtrace.join("\n")}") if error.backtrace
35
+ when String
36
+ logger.error("[Clavis] Error: #{error}")
37
+ end
38
+ end
39
+
40
+ # Log security warnings - always logged at WARN level
41
+ # @param message [String] The message to log
42
+ def security_warning(message)
43
+ return unless logger
44
+ return if !message || message.empty?
45
+
46
+ # Sanitize any potentially sensitive data
47
+ sanitized_message = filter_sensitive_data(message)
48
+ logger.warn("[Clavis Security Warning] #{sanitized_message}")
49
+ end
50
+
51
+ # The following methods are provided as aliases or for backwards compatibility
52
+
53
+ def log(message, level = :info)
54
+ return unless logger
55
+ return if !message || message.empty?
56
+
57
+ # Sanitize any potentially sensitive data
58
+ sanitized_message = filter_sensitive_data(message)
59
+ logger.send(level, "[Clavis] #{sanitized_message}")
60
+ end
61
+
62
+ # Older method signatures maintained for compatibility
63
+ def log_token_refresh(provider, success, message = nil)
64
+ log("Token refresh for #{provider}: #{success ? "success" : "failed"}#{message ? " - #{message}" : ""}")
65
+ end
66
+
67
+ def log_token_exchange(provider, success, details = nil)
68
+ log("Token exchange for #{provider}: #{success ? "success" : "failed"}#{details ? " - #{details}" : ""}")
69
+ end
70
+
71
+ def log_userinfo_request(provider, success, details = nil)
72
+ log("Userinfo request for #{provider}: #{success ? "success" : "failed"}#{details ? " - #{details}" : ""}")
73
+ end
74
+
75
+ def log_authorization_request(provider, params)
76
+ sanitized_params = filter_sensitive_data(params.to_s)
77
+ log("Authorization request for #{provider}: #{sanitized_params}")
78
+ end
79
+
80
+ def log_authorization_callback(provider, success)
81
+ log("Authorization callback for #{provider}: #{success ? "success" : "failed"}")
82
+ end
83
+
84
+ private
85
+
86
+ # Filter potentially sensitive data from log messages
87
+ def filter_sensitive_data(message)
88
+ return message unless message.is_a?(String)
89
+
90
+ # List of patterns to filter out
91
+ patterns = [
92
+ /client_secret=([^&\s]+)/i,
93
+ /access_token=([^&\s]+)/i,
94
+ /refresh_token=([^&\s]+)/i,
95
+ /id_token=([^&\s]+)/i,
96
+ /code=([^&\s]+)/i,
97
+ /password=([^&\s]+)/i,
98
+ /secret=([^&\s]+)/i,
99
+ /api_key=([^&\s]+)/i,
100
+ /key=([^&\s]+)/i,
101
+ /"token":\s*"([^"]+)"/i,
102
+ /"refresh_token":\s*"([^"]+)"/i,
103
+ /"id_token":\s*"([^"]+)"/i,
104
+ /"code":\s*"([^"]+)"/i
105
+ ]
106
+
107
+ filtered_message = message.dup
108
+ patterns.each do |pattern|
109
+ filtered_message.gsub!(pattern, '\0=[FILTERED]')
110
+ end
111
+
112
+ filtered_message
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Clavis
6
+ module Models
7
+ module Concerns
8
+ # This module adds OAuth authentication capabilities to a model
9
+ # It's typically included in the User model
10
+ module OauthAuthenticatable
11
+ extend ActiveSupport::Concern
12
+
13
+ included do
14
+ # Setup relationships
15
+ has_many :oauth_identities,
16
+ class_name: "Clavis::OauthIdentity",
17
+ as: :authenticatable,
18
+ dependent: :destroy
19
+ end
20
+
21
+ # Get the primary OAuth identity for this user
22
+ #
23
+ # @return [Clavis::OauthIdentity, nil] The primary OAuth identity
24
+ def oauth_identity
25
+ oauth_identities.first
26
+ end
27
+
28
+ # Determines if this user was created via OAuth
29
+ #
30
+ # @return [Boolean] True if the user has any OAuth identities or oauth_user flag is true
31
+ def oauth_user?
32
+ return true if respond_to?(:oauth_user) && oauth_user
33
+
34
+ # Handle both Array and ActiveRecord collection
35
+ if oauth_identities.respond_to?(:exists?)
36
+ oauth_identities.exists?
37
+ else
38
+ oauth_identities.any?
39
+ end
40
+ end
41
+
42
+ # Get the identity for a specific provider
43
+ #
44
+ # @param provider [String, Symbol] The provider name
45
+ # @return [Clavis::OauthIdentity, nil] The OAuth identity for the provider or nil
46
+ def oauth_identity_for(provider)
47
+ oauth_identities.find_by(provider: provider)
48
+ end
49
+
50
+ # Get the email from the OAuth identity
51
+ #
52
+ # @return [String, nil] The email from the OAuth identity or nil
53
+ def oauth_email
54
+ return nil unless oauth_identity&.auth_data
55
+
56
+ # First check standardized data
57
+ email = extract_standardized_value(oauth_identity.auth_data, "email")
58
+
59
+ # Fall back to raw auth data
60
+ email ||= oauth_identity.auth_data["email"] || oauth_identity.auth_data[:email]
61
+ email
62
+ end
63
+
64
+ # Get the name from the OAuth identity
65
+ #
66
+ # @return [String, nil] The name from the OAuth identity or nil
67
+ def oauth_name
68
+ return nil unless oauth_identity&.auth_data
69
+
70
+ # First check standardized data
71
+ name = extract_standardized_value(oauth_identity.auth_data, "name")
72
+
73
+ # Fall back to raw auth data
74
+ name ||= oauth_identity.auth_data["name"] || oauth_identity.auth_data[:name]
75
+ name
76
+ end
77
+
78
+ # Get the profile picture URL from the OAuth identity
79
+ #
80
+ # @return [String, nil] The profile picture URL from the OAuth identity or nil
81
+ def oauth_avatar_url
82
+ return nil unless oauth_identity&.auth_data
83
+
84
+ # First check standardized data
85
+ avatar = extract_standardized_value(oauth_identity.auth_data, "avatar_url")
86
+
87
+ # Fall back to various possible fields in raw auth data
88
+ avatar ||= oauth_identity.auth_data["image"] || oauth_identity.auth_data[:image]
89
+ avatar ||= oauth_identity.auth_data["picture"] || oauth_identity.auth_data[:picture]
90
+ avatar ||= oauth_identity.auth_data["avatar_url"] || oauth_identity.auth_data[:avatar_url]
91
+
92
+ avatar
93
+ end
94
+
95
+ # Get the access token for the primary identity
96
+ #
97
+ # @return [String, nil] The access token or nil
98
+ def oauth_token
99
+ oauth_identity&.token
100
+ end
101
+
102
+ # Get the refresh token for the primary identity
103
+ #
104
+ # @return [String, nil] The refresh token or nil
105
+ def oauth_refresh_token
106
+ oauth_identity&.refresh_token
107
+ end
108
+
109
+ # Check if the token for the primary identity has expired
110
+ #
111
+ # @return [Boolean] True if the token has expired, false otherwise or if expires_at is nil
112
+ def oauth_token_expired?
113
+ return false unless oauth_identity&.expires_at
114
+
115
+ # Convert to Time object if it's an Integer
116
+ expires_at = if oauth_identity.expires_at.is_a?(Integer)
117
+ Time.at(oauth_identity.expires_at)
118
+ else
119
+ oauth_identity.expires_at
120
+ end
121
+ expires_at < Time.now
122
+ end
123
+
124
+ # Refresh the access token for the primary identity
125
+ #
126
+ # @return [String, nil] The new access token or nil if refresh failed or not supported
127
+ def refresh_oauth_token
128
+ return nil unless oauth_identity&.refresh_token.present?
129
+
130
+ provider = oauth_identity.provider
131
+ provider_instance = Clavis.provider(provider)
132
+ return nil unless provider_instance.respond_to?(:refresh_token)
133
+
134
+ # Refresh the token
135
+ new_tokens = provider_instance.refresh_token(oauth_identity.refresh_token)
136
+
137
+ # Update the identity with the new tokens
138
+ oauth_identity.token = new_tokens[:access_token] || new_tokens[:token]
139
+ oauth_identity.refresh_token = new_tokens[:refresh_token] if new_tokens[:refresh_token].present?
140
+ oauth_identity.expires_at = new_tokens[:expires_at] if new_tokens[:expires_at].present?
141
+ oauth_identity.save!
142
+
143
+ # Return the new token
144
+ oauth_identity.token
145
+ end
146
+
147
+ private
148
+
149
+ # Extract a value from standardized data, supporting both string and symbol keys
150
+ def extract_standardized_value(auth_data, key)
151
+ # Try string keys first
152
+ if auth_data.key?("standardized") && auth_data["standardized"].is_a?(Hash)
153
+ value = auth_data["standardized"][key]
154
+ return value if value
155
+ end
156
+
157
+ # Then try symbol keys
158
+ if auth_data.key?(:standardized) && auth_data[:standardized].is_a?(Hash)
159
+ value = auth_data[:standardized][key.to_sym]
160
+ return value if value
161
+ end
162
+
163
+ # No standardized value found
164
+ nil
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clavis
4
+ if defined?(ActiveRecord::Base)
5
+ class OauthIdentity < defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base
6
+ belongs_to :authenticatable, polymorphic: true
7
+
8
+ validates :provider, presence: true
9
+ validates :uid, presence: true
10
+ validates :uid, uniqueness: { scope: :provider }
11
+
12
+ # Rails 8.0+ serialization
13
+ serialize :auth_data
14
+
15
+ # Alias for authenticatable to support User model assignment
16
+ def user
17
+ authenticatable
18
+ end
19
+
20
+ # Alias for authenticatable= to support User model assignment
21
+ def user=(user)
22
+ self.authenticatable = user
23
+ self.authenticatable_type = user.class.name
24
+ end
25
+
26
+ # Override token getter to decrypt the token
27
+ def token
28
+ Clavis::Security::TokenStorage.decrypt(self[:token])
29
+ end
30
+
31
+ # Override token setter to encrypt the token
32
+ def token=(value)
33
+ encrypted_token = Clavis::Security::TokenStorage.encrypt(value)
34
+ self[:token] = encrypted_token
35
+ end
36
+
37
+ # Override refresh_token getter to decrypt the token
38
+ def refresh_token
39
+ Clavis::Security::TokenStorage.decrypt(self[:refresh_token])
40
+ end
41
+
42
+ # Override refresh_token setter to encrypt the token
43
+ def refresh_token=(value)
44
+ encrypted_token = Clavis::Security::TokenStorage.encrypt(value)
45
+ self[:refresh_token] = encrypted_token
46
+ end
47
+
48
+ def token_expired?
49
+ expires_at.present? && expires_at < Time.current
50
+ end
51
+
52
+ def token_valid?
53
+ token.present? && !token_expired?
54
+ end
55
+
56
+ def ensure_fresh_token
57
+ return token unless token_expired?
58
+ return nil unless refresh_token && !refresh_token.empty?
59
+
60
+ begin
61
+ provider_instance = Clavis.provider(
62
+ provider.to_sym,
63
+ redirect_uri: Clavis.configuration.providers.dig(provider.to_sym, :redirect_uri)
64
+ )
65
+
66
+ new_tokens = provider_instance.refresh_token(refresh_token)
67
+
68
+ self.token = new_tokens[:access_token]
69
+ self.refresh_token = new_tokens[:refresh_token] || refresh_token
70
+ self.expires_at = new_tokens[:expires_at] ? Time.at(new_tokens[:expires_at]) : nil
71
+
72
+ token
73
+ rescue Clavis::UnsupportedOperation => e
74
+ Clavis::Logging.log_token_refresh(provider, false, e.message)
75
+ token
76
+ rescue Clavis::Error => e
77
+ Clavis::Logging.log_error(e)
78
+ nil
79
+ end
80
+ end
81
+
82
+ # Store standardized user info in the auth_data field
83
+ def store_standardized_user_info!
84
+ return unless auth_data.present?
85
+
86
+ normalized = Clavis::UserInfoNormalizer.normalize(provider, auth_data)
87
+
88
+ # Store the normalized data in auth_data under a standardized key
89
+ self.auth_data = auth_data.merge("standardized" => normalized)
90
+ save! if persisted?
91
+ end
92
+ end
93
+ else
94
+ # Stub class for environments where ActiveRecord is not available
95
+ class OauthIdentity
96
+ attr_accessor :provider, :uid, :token, :refresh_token, :expires_at, :authenticatable, :updated_at
97
+
98
+ # Custom accessor for auth_data to ensure it's always initialized as a hash
99
+ def auth_data
100
+ @auth_data ||= {}
101
+ end
102
+
103
+ def auth_data=(value)
104
+ @auth_data = value || {}
105
+ end
106
+
107
+ def initialize(attributes = {})
108
+ @provider = attributes[:provider]
109
+ @uid = attributes[:uid]
110
+ @token = attributes[:token]
111
+ @refresh_token = attributes[:refresh_token]
112
+ @expires_at = attributes[:expires_at]
113
+ self.auth_data = attributes[:auth_data]
114
+ @authenticatable = attributes[:authenticatable]
115
+ @persisted = attributes[:persisted] || false
116
+ @updated_at = attributes[:updated_at] || Time.now
117
+ end
118
+
119
+ def token_expired?
120
+ !expires_at.nil? && expires_at < Time.now
121
+ end
122
+
123
+ def token_valid?
124
+ !token.nil? && !token.empty? && !token_expired?
125
+ end
126
+
127
+ def persisted?
128
+ @persisted
129
+ end
130
+
131
+ def save!
132
+ @persisted = true
133
+ self
134
+ end
135
+
136
+ def ensure_fresh_token
137
+ return token unless token_expired?
138
+ return nil unless refresh_token && !refresh_token.empty?
139
+
140
+ begin
141
+ provider_instance = Clavis.provider(
142
+ provider.to_sym,
143
+ redirect_uri: Clavis.configuration.providers.dig(provider.to_sym, :redirect_uri)
144
+ )
145
+
146
+ new_tokens = provider_instance.refresh_token(refresh_token)
147
+
148
+ self.token = new_tokens[:access_token]
149
+ self.refresh_token = new_tokens[:refresh_token] || refresh_token
150
+ self.expires_at = new_tokens[:expires_at] ? Time.at(new_tokens[:expires_at]) : nil
151
+
152
+ token
153
+ rescue Clavis::UnsupportedOperation => e
154
+ Clavis::Logging.log_token_refresh(provider, false, e.message)
155
+ token
156
+ rescue Clavis::Error => e
157
+ Clavis::Logging.log_error(e)
158
+ nil
159
+ end
160
+ end
161
+
162
+ # Store standardized user info in the auth_data field
163
+ def store_standardized_user_info!
164
+ return unless auth_data.present?
165
+
166
+ normalized = Clavis::UserInfoNormalizer.normalize(provider, auth_data)
167
+
168
+ # Store the normalized data in auth_data under a standardized key
169
+ self.auth_data = auth_data.merge("standardized" => normalized)
170
+ save! if persisted?
171
+ end
172
+ end
173
+ end
174
+ end