token_authority 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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +199 -7
  4. data/app/controllers/concerns/token_authority/client_authentication.rb +141 -0
  5. data/app/controllers/concerns/token_authority/controller_event_logging.rb +98 -0
  6. data/app/controllers/concerns/token_authority/initial_access_token_authentication.rb +35 -0
  7. data/app/controllers/concerns/token_authority/token_authentication.rb +128 -0
  8. data/app/controllers/token_authority/authorization_grants_controller.rb +119 -0
  9. data/app/controllers/token_authority/authorizations_controller.rb +105 -0
  10. data/app/controllers/token_authority/clients_controller.rb +99 -0
  11. data/app/controllers/token_authority/metadata_controller.rb +12 -0
  12. data/app/controllers/token_authority/resource_metadata_controller.rb +12 -0
  13. data/app/controllers/token_authority/sessions_controller.rb +228 -0
  14. data/app/helpers/token_authority/authorization_grants_helper.rb +27 -0
  15. data/app/models/concerns/token_authority/claim_validatable.rb +95 -0
  16. data/app/models/concerns/token_authority/event_logging.rb +144 -0
  17. data/app/models/concerns/token_authority/resourceable.rb +111 -0
  18. data/app/models/concerns/token_authority/scopeable.rb +105 -0
  19. data/app/models/concerns/token_authority/session_creatable.rb +101 -0
  20. data/app/models/token_authority/access_token.rb +127 -0
  21. data/app/models/token_authority/access_token_request.rb +193 -0
  22. data/app/models/token_authority/authorization_grant.rb +119 -0
  23. data/app/models/token_authority/authorization_request.rb +276 -0
  24. data/app/models/token_authority/authorization_server_metadata.rb +101 -0
  25. data/app/models/token_authority/client.rb +263 -0
  26. data/app/models/token_authority/client_id_resolver.rb +114 -0
  27. data/app/models/token_authority/client_metadata_document.rb +164 -0
  28. data/app/models/token_authority/client_metadata_document_cache.rb +33 -0
  29. data/app/models/token_authority/client_metadata_document_fetcher.rb +266 -0
  30. data/app/models/token_authority/client_registration_request.rb +214 -0
  31. data/app/models/token_authority/client_registration_response.rb +58 -0
  32. data/app/models/token_authority/jwks_cache.rb +37 -0
  33. data/app/models/token_authority/jwks_fetcher.rb +70 -0
  34. data/app/models/token_authority/protected_resource_metadata.rb +74 -0
  35. data/app/models/token_authority/refresh_token.rb +110 -0
  36. data/app/models/token_authority/refresh_token_request.rb +116 -0
  37. data/app/models/token_authority/session.rb +193 -0
  38. data/app/models/token_authority/software_statement.rb +70 -0
  39. data/app/views/token_authority/authorization_grants/new.html.erb +25 -0
  40. data/app/views/token_authority/client_error.html.erb +8 -0
  41. data/config/locales/token_authority.en.yml +248 -0
  42. data/config/routes.rb +29 -0
  43. data/lib/generators/token_authority/install/install_generator.rb +61 -0
  44. data/lib/generators/token_authority/install/templates/create_token_authority_tables.rb.erb +116 -0
  45. data/lib/generators/token_authority/install/templates/token_authority.rb +247 -0
  46. data/lib/token_authority/configuration.rb +397 -0
  47. data/lib/token_authority/engine.rb +34 -0
  48. data/lib/token_authority/errors.rb +221 -0
  49. data/lib/token_authority/instrumentation.rb +80 -0
  50. data/lib/token_authority/instrumentation_log_subscriber.rb +62 -0
  51. data/lib/token_authority/json_web_token.rb +78 -0
  52. data/lib/token_authority/log_event_subscriber.rb +43 -0
  53. data/lib/token_authority/routing/constraints.rb +71 -0
  54. data/lib/token_authority/routing/routes.rb +39 -0
  55. data/lib/token_authority/version.rb +4 -1
  56. data/lib/token_authority.rb +30 -1
  57. metadata +65 -5
  58. data/app/assets/stylesheets/token_authority/application.css +0 -15
  59. data/app/controllers/token_authority/application_controller.rb +0 -4
  60. data/app/helpers/token_authority/application_helper.rb +0 -4
  61. data/app/views/layouts/token_authority/application.html.erb +0 -17
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenAuthority
4
+ # Represents an OAuth 2.1 client application.
5
+ #
6
+ # Clients can be either public (like mobile or SPA applications) or confidential
7
+ # (like server-side applications). Public clients use PKCE for security, while
8
+ # confidential clients can authenticate using client secrets or JWT assertions.
9
+ #
10
+ # This model handles client registration, authentication, and secure generation
11
+ # of client secrets using HMAC. Secrets are derived from a stored UUID rather
12
+ # than being stored directly, allowing secret rotation via the configuration's
13
+ # secret_key.
14
+ #
15
+ # @example Creating a public client (mobile app)
16
+ # client = TokenAuthority::Client.create!(
17
+ # name: "Mobile App",
18
+ # redirect_uris: ["myapp://oauth/callback"],
19
+ # token_endpoint_auth_method: "none"
20
+ # )
21
+ #
22
+ # @example Creating a confidential client (server app)
23
+ # client = TokenAuthority::Client.create!(
24
+ # name: "Server App",
25
+ # redirect_uris: ["https://app.example.com/oauth/callback"],
26
+ # token_endpoint_auth_method: "client_secret_basic"
27
+ # )
28
+ # # Store client.client_secret securely
29
+ #
30
+ # @since 0.2.0
31
+ class Client < ApplicationRecord
32
+ CLIENT_TYPE_ENUM_VALUES = {public: "public", confidential: "confidential"}.freeze
33
+ SUPPORTED_AUTH_METHODS = %w[none client_secret_basic client_secret_post client_secret_jwt private_key_jwt].freeze
34
+
35
+ enum :client_type, CLIENT_TYPE_ENUM_VALUES, suffix: true
36
+
37
+ validates :name, presence: true, length: {minimum: 3, maximum: 255}
38
+ validates :access_token_duration, numericality: {only_integer: true, greater_than: 0}
39
+ validates :refresh_token_duration, numericality: {only_integer: true, greater_than: 0}
40
+ validates :redirect_uris, presence: true
41
+ validates :token_endpoint_auth_method, inclusion: {in: SUPPORTED_AUTH_METHODS}
42
+ validate :redirect_uris_are_valid_uris
43
+ validate :jwks_required_for_private_key_jwt
44
+
45
+ before_validation :set_default_durations, on: :create
46
+ before_validation :set_client_type_from_auth_method, on: :create
47
+ before_validation :set_default_grant_and_response_types, on: :create
48
+ before_create :generate_client_secret_id
49
+ before_create :generate_public_id
50
+ before_create :set_client_id_issued_at
51
+ before_create :set_client_secret_expiration
52
+
53
+ # Creates a new authorization grant for this client and the specified user.
54
+ #
55
+ # The grant represents the user's consent to allow this client access.
56
+ # PKCE challenge parameters should be included for public clients.
57
+ #
58
+ # @param user [User] the user granting authorization
59
+ # @param challenge_params [Hash] PKCE parameters (code_challenge, code_challenge_method)
60
+ #
61
+ # @return [TokenAuthority::AuthorizationGrant] the created authorization grant
62
+ #
63
+ # @example
64
+ # grant = client.new_authorization_grant(
65
+ # user: current_user,
66
+ # challenge_params: {
67
+ # code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
68
+ # code_challenge_method: "S256"
69
+ # }
70
+ # )
71
+ def new_authorization_grant(user:, challenge_params: {})
72
+ TokenAuthority::AuthorizationGrant.create(token_authority_client: self, user:, **challenge_params)
73
+ end
74
+
75
+ # Creates a new authorization request object for validation.
76
+ #
77
+ # This service object validates all OAuth authorization parameters against
78
+ # the client's configuration and OAuth 2.1 requirements.
79
+ #
80
+ # @param client_id [String] the client identifier
81
+ # @param code_challenge [String] the PKCE code challenge
82
+ # @param code_challenge_method [String] the PKCE method (S256)
83
+ # @param redirect_uri [String] the callback URI
84
+ # @param response_type [String] the response type (code)
85
+ # @param state [String] the state parameter for CSRF protection
86
+ # @param resources [Array<String>] resource indicators (RFC 8707)
87
+ # @param scope [Array<String>] requested scopes
88
+ #
89
+ # @return [TokenAuthority::AuthorizationRequest] the validation object
90
+ def new_authorization_request(client_id:, code_challenge:, code_challenge_method:, redirect_uri:, response_type:, state:, resources: [], scope: [])
91
+ TokenAuthority::AuthorizationRequest.new(
92
+ token_authority_client: self,
93
+ client_id:,
94
+ state:,
95
+ code_challenge:,
96
+ code_challenge_method:,
97
+ redirect_uri:,
98
+ response_type:,
99
+ resources:,
100
+ scope:
101
+ )
102
+ end
103
+
104
+ # Builds a redirect URL with query parameters.
105
+ #
106
+ # Used to redirect back to the client application with authorization codes
107
+ # or error information.
108
+ #
109
+ # @param params [Hash] query parameters to append
110
+ #
111
+ # @return [String] the complete redirect URL
112
+ #
113
+ # @raise [TokenAuthority::InvalidRedirectUrlError] if the URI cannot be parsed
114
+ #
115
+ # @example
116
+ # url = client.url_for_redirect(params: { code: "abc123", state: "xyz" })
117
+ def url_for_redirect(params:)
118
+ uri = URI(primary_redirect_uri)
119
+ params_for_query = params.collect { |key, value| [key.to_s, value] }
120
+ encoded_params = URI.encode_www_form(params_for_query)
121
+ uri.query = encoded_params
122
+ uri.to_s
123
+ rescue URI::InvalidURIError, ArgumentError, NoMethodError => error
124
+ raise TokenAuthority::InvalidRedirectUrlError, error.message
125
+ end
126
+
127
+ # Returns the client secret for confidential clients.
128
+ #
129
+ # The secret is derived from the client_secret_id using HMAC-SHA256 with
130
+ # the application's secret_key. This allows secret rotation by changing
131
+ # the secret_key without modifying the database.
132
+ #
133
+ # @return [String, nil] the client secret, or nil for public clients
134
+ #
135
+ # @note This secret should only be displayed once during client creation
136
+ # and must be stored securely by the client application.
137
+ def client_secret
138
+ return nil if client_type == "public" || client_secret_id.blank?
139
+
140
+ generate_client_secret_for(client_secret_id)
141
+ end
142
+
143
+ # Authenticates a client using the provided secret.
144
+ #
145
+ # Uses constant-time comparison to prevent timing attacks that could be
146
+ # used to guess the client secret character by character.
147
+ #
148
+ # @param provided_secret [String] the secret to verify
149
+ #
150
+ # @return [Boolean] true if the secret is valid
151
+ #
152
+ # @example
153
+ # if client.authenticate_with_secret(params[:client_secret])
154
+ # # Client authenticated successfully
155
+ # end
156
+ def authenticate_with_secret(provided_secret)
157
+ return false if client_type == "public" || client_secret_id.blank?
158
+ return false if provided_secret.blank?
159
+
160
+ # Use secure comparison to prevent timing attacks
161
+ ActiveSupport::SecurityUtils.secure_compare(
162
+ client_secret,
163
+ provided_secret
164
+ )
165
+ end
166
+
167
+ # Checks if a redirect URI is registered for this client.
168
+ #
169
+ # @param uri [String] the redirect URI to check
170
+ #
171
+ # @return [Boolean] true if the URI is registered
172
+ def redirect_uri_registered?(uri)
173
+ redirect_uris&.include?(uri)
174
+ end
175
+
176
+ # Returns the primary (first) redirect URI.
177
+ #
178
+ # Used as the default redirect URI when one is not explicitly specified
179
+ # by the client in the authorization request.
180
+ #
181
+ # @return [String, nil] the primary redirect URI
182
+ def primary_redirect_uri
183
+ redirect_uris&.first
184
+ end
185
+
186
+ # Indicates whether this is a URL-based client from a client metadata document.
187
+ #
188
+ # Always returns false for registered Client records. URL-based clients are
189
+ # represented by the ClientMetadataDocument class instead.
190
+ #
191
+ # @return [Boolean] false
192
+ #
193
+ # @see TokenAuthority::ClientMetadataDocument#url_based?
194
+ def url_based?
195
+ false
196
+ end
197
+
198
+ private
199
+
200
+ def redirect_uris_are_valid_uris
201
+ return if redirect_uris.blank?
202
+
203
+ redirect_uris.each do |uri|
204
+ parsed_uri = URI.parse(uri)
205
+ unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
206
+ errors.add(:redirect_uris, :invalid_http_scheme)
207
+ break
208
+ end
209
+ rescue URI::InvalidURIError
210
+ errors.add(:redirect_uris, :invalid_uri)
211
+ break
212
+ end
213
+ end
214
+
215
+ def jwks_required_for_private_key_jwt
216
+ return unless token_endpoint_auth_method == "private_key_jwt"
217
+ return if jwks.present? || jwks_uri.present?
218
+
219
+ errors.add(:base, :jwks_required_for_private_key_jwt)
220
+ end
221
+
222
+ def set_client_type_from_auth_method
223
+ return if client_type.present?
224
+
225
+ self.client_type = (token_endpoint_auth_method == "none") ? "public" : "confidential"
226
+ end
227
+
228
+ def set_default_grant_and_response_types
229
+ self.grant_types ||= ["authorization_code"]
230
+ self.response_types ||= ["code"]
231
+ end
232
+
233
+ def generate_client_secret_id
234
+ return if client_type == "public"
235
+
236
+ self.client_secret_id = SecureRandom.uuid
237
+ end
238
+
239
+ def generate_public_id
240
+ self.public_id = SecureRandom.uuid
241
+ end
242
+
243
+ def set_default_durations
244
+ self.access_token_duration ||= TokenAuthority.config.rfc_9068_default_access_token_duration
245
+ self.refresh_token_duration ||= TokenAuthority.config.rfc_9068_default_refresh_token_duration
246
+ end
247
+
248
+ def set_client_id_issued_at
249
+ self.client_id_issued_at = Time.current
250
+ end
251
+
252
+ def set_client_secret_expiration
253
+ return if client_type == "public"
254
+ return unless TokenAuthority.config.rfc_7591_client_secret_expiration
255
+
256
+ self.client_secret_expires_at = Time.current + TokenAuthority.config.rfc_7591_client_secret_expiration
257
+ end
258
+
259
+ def generate_client_secret_for(secret_id)
260
+ OpenSSL::HMAC.hexdigest("SHA256", TokenAuthority.config.secret_key, secret_id)
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenAuthority
4
+ # Resolves a client_id to the appropriate client representation.
5
+ #
6
+ # This service object handles both traditional registered clients (identified by UUID)
7
+ # and URL-based clients that use client metadata documents per
8
+ # draft-ietf-oauth-client-id-metadata-document.
9
+ #
10
+ # The resolver determines which type of client is being used based on the format
11
+ # of the client_id:
12
+ # - HTTPS URLs are resolved as URL-based clients (fetching metadata documents)
13
+ # - UUIDs are resolved as registered clients (database lookups)
14
+ #
15
+ # URL-based client support can be disabled via configuration, in which case only
16
+ # registered clients are supported.
17
+ #
18
+ # @example Resolving a registered client
19
+ # client = ClientIdResolver.resolve("550e8400-e29b-41d4-a716-446655440000")
20
+ # client.class # => TokenAuthority::Client
21
+ #
22
+ # @example Resolving a URL-based client
23
+ # client = ClientIdResolver.resolve("https://client.example.com/.well-known/oauth-client")
24
+ # client.class # => TokenAuthority::ClientMetadataDocument
25
+ #
26
+ # @since 0.2.0
27
+ class ClientIdResolver
28
+ extend TokenAuthority::Instrumentation
29
+
30
+ class << self
31
+ # Resolves a client_id to either a Client or ClientMetadataDocument.
32
+ #
33
+ # Emits instrumentation events with the client type for monitoring.
34
+ #
35
+ # @param client_id [String] the client identifier (UUID or HTTPS URL)
36
+ #
37
+ # @return [TokenAuthority::Client, TokenAuthority::ClientMetadataDocument, nil]
38
+ # the resolved client, or nil if client_id is blank
39
+ #
40
+ # @raise [TokenAuthority::ClientNotFoundError] if the client cannot be found
41
+ # or the metadata document cannot be fetched
42
+ #
43
+ # @example
44
+ # client = ClientIdResolver.resolve(params[:client_id])
45
+ # if client.public_client_type?
46
+ # # Handle public client
47
+ # end
48
+ def resolve(client_id)
49
+ instrument("client.resolve") do |payload|
50
+ return nil if client_id.blank?
51
+
52
+ # Check if it's a URL-based client_id
53
+ if url_based_client_id?(client_id)
54
+ payload[:client_type] = "url_based"
55
+ resolve_url_based(client_id)
56
+ else
57
+ payload[:client_type] = "registered"
58
+ resolve_uuid_based(client_id)
59
+ end
60
+ end
61
+ end
62
+
63
+ # Checks if a client_id represents a URL-based client.
64
+ #
65
+ # URL-based clients are identified by HTTPS URLs and are only supported
66
+ # when client_metadata_document_enabled is true in the configuration.
67
+ #
68
+ # @param client_id [String] the client identifier to check
69
+ #
70
+ # @return [Boolean] true if this is a URL-based client_id
71
+ def url_based_client_id?(client_id)
72
+ return false if client_id.blank?
73
+ return false unless TokenAuthority.config.client_metadata_document_enabled
74
+
75
+ # Check if it looks like a URL
76
+ client_id.start_with?("https://")
77
+ end
78
+
79
+ private
80
+
81
+ # Resolves a URL-based client by fetching its metadata document.
82
+ #
83
+ # @param client_id [String] the HTTPS URL of the client
84
+ #
85
+ # @return [TokenAuthority::ClientMetadataDocument] the metadata document
86
+ #
87
+ # @raise [TokenAuthority::ClientNotFoundError] if fetching or parsing fails
88
+ # @api private
89
+ def resolve_url_based(client_id)
90
+ # Validate and fetch the metadata document
91
+ metadata = ClientMetadataDocumentFetcher.fetch(client_id)
92
+ ClientMetadataDocument.new(metadata)
93
+ rescue InvalidClientMetadataDocumentUrlError, ClientMetadataDocumentFetchError, InvalidClientMetadataDocumentError
94
+ # If URL-based resolution fails, raise ClientNotFoundError for consistent error handling
95
+ raise ClientNotFoundError
96
+ end
97
+
98
+ # Resolves a UUID-based registered client from the database.
99
+ #
100
+ # @param client_id [String] the UUID of the client
101
+ #
102
+ # @return [TokenAuthority::Client] the registered client
103
+ #
104
+ # @raise [TokenAuthority::ClientNotFoundError] if no client is found
105
+ # @api private
106
+ def resolve_uuid_based(client_id)
107
+ client = TokenAuthority::Client.find_by(public_id: client_id)
108
+ raise ClientNotFoundError if client.nil?
109
+
110
+ client
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenAuthority
4
+ ##
5
+ # Represents a client identified by a URL-based client_id (Client Metadata Document spec).
6
+ # Implements the same interface as Client for use in OAuth flows.
7
+ class ClientMetadataDocument
8
+ attr_reader :metadata
9
+
10
+ def initialize(metadata)
11
+ @metadata = metadata.with_indifferent_access
12
+ end
13
+
14
+ # The client_id is the URL itself
15
+ def public_id
16
+ metadata[:client_id]
17
+ end
18
+
19
+ # URL-based clients are always public (per spec)
20
+ def client_type
21
+ "public"
22
+ end
23
+
24
+ def public_client_type?
25
+ true
26
+ end
27
+
28
+ def confidential_client_type?
29
+ false
30
+ end
31
+
32
+ def name
33
+ metadata[:client_name] || metadata[:client_id]
34
+ end
35
+
36
+ def redirect_uris
37
+ metadata[:redirect_uris] || []
38
+ end
39
+
40
+ def redirect_uri_registered?(uri)
41
+ redirect_uris.include?(uri)
42
+ end
43
+
44
+ def primary_redirect_uri
45
+ redirect_uris.first
46
+ end
47
+
48
+ # URL-based clients use default token durations from config
49
+ def access_token_duration
50
+ TokenAuthority.config.rfc_9068_default_access_token_duration
51
+ end
52
+
53
+ def refresh_token_duration
54
+ TokenAuthority.config.rfc_9068_default_refresh_token_duration
55
+ end
56
+
57
+ # URL-based clients cannot have secrets
58
+ def client_secret
59
+ nil
60
+ end
61
+
62
+ def client_secret_id
63
+ nil
64
+ end
65
+
66
+ def authenticate_with_secret(_provided_secret)
67
+ false
68
+ end
69
+
70
+ # URL-based clients always use "none" for token endpoint auth
71
+ def token_endpoint_auth_method
72
+ "none"
73
+ end
74
+
75
+ def grant_types
76
+ metadata[:grant_types] || ["authorization_code"]
77
+ end
78
+
79
+ def response_types
80
+ metadata[:response_types] || ["code"]
81
+ end
82
+
83
+ def scope
84
+ metadata[:scope]
85
+ end
86
+
87
+ # Human-readable metadata
88
+ def client_uri
89
+ metadata[:client_uri]
90
+ end
91
+
92
+ def logo_uri
93
+ metadata[:logo_uri]
94
+ end
95
+
96
+ def tos_uri
97
+ metadata[:tos_uri]
98
+ end
99
+
100
+ def policy_uri
101
+ metadata[:policy_uri]
102
+ end
103
+
104
+ def contacts
105
+ metadata[:contacts]
106
+ end
107
+
108
+ # Technical metadata
109
+ def jwks_uri
110
+ metadata[:jwks_uri]
111
+ end
112
+
113
+ def jwks
114
+ metadata[:jwks]
115
+ end
116
+
117
+ def software_id
118
+ metadata[:software_id]
119
+ end
120
+
121
+ def software_version
122
+ metadata[:software_version]
123
+ end
124
+
125
+ # Creates a new authorization grant for this URL-based client
126
+ def new_authorization_grant(user:, challenge_params: {})
127
+ TokenAuthority::AuthorizationGrant.create(
128
+ client_id_url: public_id,
129
+ user:,
130
+ **challenge_params
131
+ )
132
+ end
133
+
134
+ # Creates a new authorization request for this URL-based client
135
+ def new_authorization_request(client_id:, code_challenge:, code_challenge_method:, redirect_uri:, response_type:, state:, resources: [], scope: [])
136
+ TokenAuthority::AuthorizationRequest.new(
137
+ token_authority_client: self,
138
+ client_id:,
139
+ state:,
140
+ code_challenge:,
141
+ code_challenge_method:,
142
+ redirect_uri:,
143
+ response_type:,
144
+ resources:,
145
+ scope:
146
+ )
147
+ end
148
+
149
+ def url_for_redirect(params:)
150
+ uri = URI(primary_redirect_uri)
151
+ params_for_query = params.collect { |key, value| [key.to_s, value] }
152
+ encoded_params = URI.encode_www_form(params_for_query)
153
+ uri.query = encoded_params
154
+ uri.to_s
155
+ rescue URI::InvalidURIError, ArgumentError, NoMethodError => error
156
+ raise TokenAuthority::InvalidRedirectUrlError, error.message
157
+ end
158
+
159
+ # Check if this is a URL-based client (always true for this class)
160
+ def url_based?
161
+ true
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenAuthority
4
+ ##
5
+ # Stores cached client metadata documents fetched from remote URIs
6
+ class ClientMetadataDocumentCache < ApplicationRecord
7
+ validates :uri_hash, presence: true, uniqueness: true
8
+ validates :uri, presence: true
9
+ validates :metadata, presence: true
10
+ validates :expires_at, presence: true
11
+
12
+ scope :expired, -> { where("expires_at <= ?", Time.current) }
13
+ scope :valid, -> { where("expires_at > ?", Time.current) }
14
+
15
+ class << self
16
+ def find_by_uri(uri)
17
+ find_by(uri_hash: hash_uri(uri))
18
+ end
19
+
20
+ def hash_uri(uri)
21
+ Digest::SHA256.hexdigest(uri)
22
+ end
23
+
24
+ def cleanup_expired!
25
+ expired.delete_all
26
+ end
27
+ end
28
+
29
+ def expired?
30
+ expires_at <= Time.current
31
+ end
32
+ end
33
+ end