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,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenAuthority
4
+ # Provides JWT claim validation for token models.
5
+ #
6
+ # This concern adds ActiveModel validation for standard JWT claims (aud, exp, iat,
7
+ # iss, jti) and integrates with the Session model to automatically update session
8
+ # status when tokens fail validation.
9
+ #
10
+ # When validation fails for revocable claims (aud, iss, user_id), the associated
11
+ # session is marked as revoked. When validation fails for expirable claims (exp),
12
+ # the session is marked as expired.
13
+ #
14
+ # @example Including in a token model
15
+ # class MyToken
16
+ # include TokenAuthority::ClaimValidatable
17
+ # end
18
+ #
19
+ # @since 0.2.0
20
+ module ClaimValidatable
21
+ extend ActiveSupport::Concern
22
+
23
+ # JWT claims that should trigger session revocation when invalid.
24
+ # These represent security violations like wrong audience or issuer.
25
+ REVOCABLE_CLAIMS = %i[aud iss user_id].freeze
26
+
27
+ # JWT claims that should trigger session expiration when invalid.
28
+ # Currently only includes the exp (expiration time) claim.
29
+ EXPIRABLE_CLAIMS = %i[exp].freeze
30
+
31
+ included do
32
+ include ActiveModel::Model
33
+ include ActiveModel::Validations
34
+ include ActiveModel::Validations::Callbacks
35
+
36
+ attr_accessor :aud, :exp, :iat, :iss, :jti
37
+
38
+ validates :jti, presence: true
39
+
40
+ validates :aud, presence: true, format: {with: /\A#{TokenAuthority.config.rfc_9068_audience_url}*/}
41
+
42
+ validates :exp, presence: true
43
+ validate do
44
+ next if exp.blank?
45
+
46
+ errors.add(:exp, :expired) if Time.zone.now > Time.zone.at(exp)
47
+ end
48
+
49
+ validates :iss, presence: true, format: {with: /\A#{TokenAuthority.config.rfc_9068_issuer_url}\z/}
50
+
51
+ after_validation :expire_token_authority_session, if: :errors_for_expirable_claims?
52
+ after_validation :revoke_token_authority_session, if: :errors_for_revocable_claims?
53
+ end
54
+
55
+ private
56
+
57
+ def token_authority_session
58
+ return @token_authority_session if defined?(@token_authority_session)
59
+
60
+ @token_authority_session = TokenAuthority::Session.find_by(query_params_for_token_authority_session)
61
+ end
62
+
63
+ def query_params_for_token_authority_session
64
+ {key_for_jti_query => jti}
65
+ end
66
+
67
+ def key_for_jti_query
68
+ :"#{self.class.name.demodulize.underscore}_jti"
69
+ end
70
+
71
+ def errors_for_revocable_claims?
72
+ return false if skip_token_authority_session_update?
73
+
74
+ errors.attribute_names.intersect?(REVOCABLE_CLAIMS)
75
+ end
76
+
77
+ def revoke_token_authority_session
78
+ token_authority_session.update(status: "revoked")
79
+ end
80
+
81
+ def errors_for_expirable_claims?
82
+ return false if skip_token_authority_session_update?
83
+
84
+ errors.attribute_names.intersect?(EXPIRABLE_CLAIMS)
85
+ end
86
+
87
+ def expire_token_authority_session
88
+ token_authority_session.update(status: "expired")
89
+ end
90
+
91
+ def skip_token_authority_session_update?
92
+ errors.blank? || errors.include?(:jti)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenAuthority
4
+ # Provides structured event logging for model classes.
5
+ #
6
+ # This concern integrates with Rails 8.1+ event logging (Rails.event) to emit
7
+ # structured, machine-readable events for security auditing, monitoring, and debugging.
8
+ # All events are automatically namespaced and timestamped.
9
+ #
10
+ # Two event levels are supported:
11
+ # - Production events (notify_event): Always emitted when event_logging_enabled is true
12
+ # - Debug events (debug_event): Only emitted when both event_logging_enabled and
13
+ # event_logging_debug_events are true
14
+ #
15
+ # Events can be consumed by Rails event subscribers for logging, metrics, or
16
+ # integration with external monitoring systems.
17
+ #
18
+ # @example Using in a model
19
+ # class Session < ApplicationRecord
20
+ # include TokenAuthority::EventLogging
21
+ #
22
+ # def revoke
23
+ # notify_event("security.session.revoked",
24
+ # session_id: id,
25
+ # client_id: client.public_id,
26
+ # reason: "user_logout")
27
+ # end
28
+ # end
29
+ #
30
+ # @example Using debug events
31
+ # debug_event("validation.pkce.started",
32
+ # code_challenge: challenge,
33
+ # challenge_method: "S256")
34
+ #
35
+ # @since 0.2.0
36
+ module EventLogging
37
+ extend ActiveSupport::Concern
38
+
39
+ included do
40
+ class_attribute :_event_logging_enabled, default: true
41
+ end
42
+
43
+ class_methods do
44
+ # Emits a production-level event.
45
+ #
46
+ # Events are namespaced with "token_authority." and include a timestamp.
47
+ # Only emitted when event logging is enabled in configuration.
48
+ #
49
+ # @param event_name [String] the event name (will be prefixed)
50
+ # @param request_id [String, nil] optional request ID for correlation
51
+ # @param payload [Hash] additional event data
52
+ #
53
+ # @return [void]
54
+ #
55
+ # @example
56
+ # notify_event("client.lookup.completed",
57
+ # client_id: "abc123",
58
+ # lookup_duration_ms: 42)
59
+ def notify_event(event_name, request_id: nil, **payload)
60
+ return unless event_logging_enabled?
61
+
62
+ full_payload = build_payload(payload, request_id: request_id)
63
+ Rails.event.notify("token_authority.#{event_name}", **full_payload)
64
+ end
65
+
66
+ # Emits a debug-level event.
67
+ #
68
+ # Debug events are only emitted when both event_logging_enabled and
69
+ # event_logging_debug_events are true in configuration. Use for verbose
70
+ # logging that aids in troubleshooting but would be too noisy in production.
71
+ #
72
+ # @param event_name [String] the event name (will be prefixed)
73
+ # @param request_id [String, nil] optional request ID for correlation
74
+ # @param payload [Hash] additional event data
75
+ #
76
+ # @return [void]
77
+ #
78
+ # @example
79
+ # debug_event("validation.pkce.challenge_computed",
80
+ # code_verifier_length: 128,
81
+ # challenge_method: "S256")
82
+ def debug_event(event_name, request_id: nil, **payload)
83
+ return unless event_logging_enabled?
84
+ return unless debug_events_enabled?
85
+
86
+ full_payload = build_payload(payload, request_id: request_id)
87
+ Rails.event.debug("token_authority.#{event_name}", **full_payload)
88
+ end
89
+
90
+ private
91
+
92
+ # Checks if event logging is enabled.
93
+ # @return [Boolean]
94
+ # @api private
95
+ def event_logging_enabled?
96
+ _event_logging_enabled && TokenAuthority.config.event_logging_enabled
97
+ end
98
+
99
+ # Checks if debug events are enabled.
100
+ # @return [Boolean]
101
+ # @api private
102
+ def debug_events_enabled?
103
+ TokenAuthority.config.event_logging_debug_events
104
+ end
105
+
106
+ # Builds the complete event payload with timestamp and optional request_id.
107
+ # @param payload [Hash] the base payload
108
+ # @param request_id [String, nil] optional request ID
109
+ # @return [Hash] the enriched payload
110
+ # @api private
111
+ def build_payload(payload, request_id: nil)
112
+ base = {timestamp: Time.current.iso8601(6)}
113
+ base[:request_id] = request_id if request_id.present?
114
+ base.merge(payload)
115
+ end
116
+ end
117
+
118
+ # Emits a production-level event from instance methods.
119
+ #
120
+ # Delegates to the class method. See class method documentation for details.
121
+ #
122
+ # @param event_name [String] the event name (will be prefixed)
123
+ # @param request_id [String, nil] optional request ID for correlation
124
+ # @param payload [Hash] additional event data
125
+ #
126
+ # @return [void]
127
+ def notify_event(event_name, request_id: nil, **payload)
128
+ self.class.notify_event(event_name, request_id: request_id, **payload)
129
+ end
130
+
131
+ # Emits a debug-level event from instance methods.
132
+ #
133
+ # Delegates to the class method. See class method documentation for details.
134
+ #
135
+ # @param event_name [String] the event name (will be prefixed)
136
+ # @param request_id [String, nil] optional request ID for correlation
137
+ # @param payload [Hash] additional event data
138
+ #
139
+ # @return [void]
140
+ def debug_event(event_name, request_id: nil, **payload)
141
+ self.class.debug_event(event_name, request_id: request_id, **payload)
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenAuthority
4
+ # Provides RFC 8707 resource indicator handling for authorization requests and tokens.
5
+ #
6
+ # Resource indicators allow clients to specify which protected resources (APIs)
7
+ # they want access to. This concern handles validation of resource URIs according
8
+ # to RFC 8707 requirements and checking resources against the configured allowed
9
+ # resource list.
10
+ #
11
+ # Resource URIs must:
12
+ # - Be absolute URIs with http or https scheme
13
+ # - Include a host
14
+ # - Not contain a fragment
15
+ #
16
+ # @example Using in a model
17
+ # class AuthorizationRequest
18
+ # include TokenAuthority::Resourceable
19
+ # end
20
+ #
21
+ # request.resources = ["https://api.example.com", "https://files.example.com"]
22
+ # request.resources # => ["https://api.example.com", "https://files.example.com"]
23
+ #
24
+ # @see https://www.rfc-editor.org/rfc/rfc8707.html RFC 8707: Resource Indicators for OAuth 2.0
25
+ # @since 0.2.0
26
+ module Resourceable
27
+ extend ActiveSupport::Concern
28
+
29
+ # Returns the resource indicators as an array.
30
+ #
31
+ # @return [Array<String>] the resource URIs
32
+ def resources
33
+ @resources ||= []
34
+ end
35
+
36
+ # Sets the resource indicators from an array or array-like value.
37
+ #
38
+ # Nil or empty values result in an empty array.
39
+ #
40
+ # @param value [Array<String>, String, nil] the resource URIs to set
41
+ #
42
+ # @example
43
+ # obj.resources = ["https://api.example.com"]
44
+ # obj.resources # => ["https://api.example.com"]
45
+ def resources=(value)
46
+ @resources = Array(value).presence || []
47
+ end
48
+
49
+ private
50
+
51
+ # Validates that a single resource URI meets RFC 8707 requirements.
52
+ #
53
+ # @param uri [String] the resource URI to validate
54
+ #
55
+ # @return [Boolean] true if the URI is valid
56
+ # @api private
57
+ def valid_resource_uri?(uri)
58
+ return false if uri.blank?
59
+
60
+ parsed_uri = URI.parse(uri)
61
+
62
+ # Must be absolute URI with http/https scheme
63
+ return false unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
64
+
65
+ # Must not have a fragment
66
+ return false if parsed_uri.fragment.present?
67
+
68
+ # Must have a host
69
+ return false if parsed_uri.host.blank?
70
+
71
+ true
72
+ rescue URI::InvalidURIError
73
+ false
74
+ end
75
+
76
+ # Validates that all resource URIs meet RFC 8707 requirements.
77
+ #
78
+ # @return [Boolean] true if all URIs are valid
79
+ # @api private
80
+ def valid_resource_uris?
81
+ resources.all? { |uri| valid_resource_uri?(uri) }
82
+ end
83
+
84
+ # Checks if all requested resources are in the allowed resources list.
85
+ #
86
+ # Returns true if resource indicators are not enabled in configuration.
87
+ #
88
+ # @return [Boolean] true if all resources are allowed
89
+ # @api private
90
+ def allowed_resources?
91
+ return true unless TokenAuthority.config.rfc_8707_enabled?
92
+
93
+ resources.all? { |uri| TokenAuthority.config.rfc_8707_resources.key?(uri) }
94
+ end
95
+
96
+ # Checks if the current resources are a subset of the granted resources.
97
+ #
98
+ # Used during token refresh to ensure the new token doesn't request
99
+ # access to more resources than the original grant.
100
+ #
101
+ # @param granted [Array<String>, nil] the originally granted resources
102
+ #
103
+ # @return [Boolean] true if resources are a subset of granted resources
104
+ # @api private
105
+ def resources_subset_of?(granted)
106
+ return true if granted.blank? || resources.blank?
107
+
108
+ (resources - granted).empty?
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenAuthority
4
+ # Provides OAuth scope handling for authorization requests and tokens.
5
+ #
6
+ # This concern handles parsing space-delimited scope strings per OAuth 2.1,
7
+ # validation of scope tokens per RFC 6749, and checking scopes against the
8
+ # configured allowed scopes.
9
+ #
10
+ # Scopes are stored internally as arrays but can be set from either strings
11
+ # or arrays. The scope setter automatically splits space-delimited strings
12
+ # into individual scope tokens.
13
+ #
14
+ # @example Using in a model
15
+ # class AuthorizationRequest
16
+ # include TokenAuthority::Scopeable
17
+ # end
18
+ #
19
+ # request.scope = "read write admin"
20
+ # request.scope # => ["read", "write", "admin"]
21
+ # request.scope_as_string # => "read write admin"
22
+ #
23
+ # @since 0.2.0
24
+ module Scopeable
25
+ extend ActiveSupport::Concern
26
+
27
+ # Regular expression for valid scope tokens per RFC 6749 Section 3.3.
28
+ # Scope tokens must not contain whitespace, double quotes, or backslashes.
29
+ # Allowed characters are printable ASCII excluding space, double-quote, and backslash.
30
+ VALID_SCOPE_TOKEN = /\A[\x21\x23-\x5B\x5D-\x7E]+\z/
31
+
32
+ # Returns the scopes as an array.
33
+ #
34
+ # @return [Array<String>] the scope tokens
35
+ def scope
36
+ @scope ||= []
37
+ end
38
+
39
+ # Sets the scopes from a string or array.
40
+ #
41
+ # String values are split on whitespace into individual tokens.
42
+ # Array values are used directly. Other values result in an empty array.
43
+ #
44
+ # @param value [String, Array<String>, nil] the scopes to set
45
+ #
46
+ # @example Setting from a string
47
+ # obj.scope = "read write"
48
+ # obj.scope # => ["read", "write"]
49
+ #
50
+ # @example Setting from an array
51
+ # obj.scope = ["read", "write"]
52
+ # obj.scope # => ["read", "write"]
53
+ def scope=(value)
54
+ @scope = case value
55
+ when String then value.split(/\s+/).reject(&:blank?)
56
+ when Array then value
57
+ else []
58
+ end
59
+ end
60
+
61
+ # Returns the scopes as a space-delimited string.
62
+ #
63
+ # This format is used in OAuth responses and JWT scope claims.
64
+ #
65
+ # @return [String] space-delimited scope string
66
+ def scope_as_string
67
+ scope.join(" ")
68
+ end
69
+
70
+ private
71
+
72
+ # Validates that all scope tokens match the RFC 6749 format.
73
+ #
74
+ # @return [Boolean] true if all tokens are valid
75
+ # @api private
76
+ def valid_scope_tokens?
77
+ scope.all? { |s| VALID_SCOPE_TOKEN.match?(s) }
78
+ end
79
+
80
+ # Checks if all requested scopes are in the allowed scopes list.
81
+ #
82
+ # Returns true if scopes are not enabled in configuration.
83
+ #
84
+ # @return [Boolean] true if all scopes are allowed
85
+ # @api private
86
+ def allowed_scopes?
87
+ return true unless TokenAuthority.config.scopes_enabled?
88
+ scope.all? { |s| TokenAuthority.config.scopes.key?(s) }
89
+ end
90
+
91
+ # Checks if the current scopes are a subset of the granted scopes.
92
+ #
93
+ # Used during token refresh to ensure the new token doesn't request
94
+ # more privileges than the original grant.
95
+ #
96
+ # @param granted [Array<String>, nil] the originally granted scopes
97
+ #
98
+ # @return [Boolean] true if scopes are a subset of granted scopes
99
+ # @api private
100
+ def scopes_subset_of?(granted)
101
+ return true if granted.blank? || scope.blank?
102
+ (scope - Array(granted)).empty?
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenAuthority
4
+ # Provides session creation functionality for authorization grants and token refreshes.
5
+ #
6
+ # This concern encapsulates the complex logic of creating a new OAuth session with
7
+ # access and refresh token pairs. It handles:
8
+ # - Generating access and refresh tokens with appropriate lifetimes
9
+ # - Creating the session record with JTI references
10
+ # - Yielding to allow the caller to update related records (e.g., marking grant as redeemed)
11
+ # - Returning a TokenContainer with all token data
12
+ #
13
+ # Used by both AuthorizationGrant (when redeeming) and Session (when refreshing).
14
+ #
15
+ # @example In a model
16
+ # class AuthorizationGrant < ApplicationRecord
17
+ # include TokenAuthority::SessionCreatable
18
+ #
19
+ # def redeem(resources: [], scopes: [])
20
+ # create_token_authority_session(grant: self, resources:, scopes:) do
21
+ # update(redeemed: true)
22
+ # end
23
+ # end
24
+ # end
25
+ #
26
+ # @since 0.2.0
27
+ module SessionCreatable
28
+ extend ActiveSupport::Concern
29
+ include TokenAuthority::Instrumentation
30
+
31
+ # Container for token response data.
32
+ #
33
+ # @!attribute [r] access_token
34
+ # The encoded JWT access token string.
35
+ # @return [String]
36
+ #
37
+ # @!attribute [r] refresh_token
38
+ # The encoded JWT refresh token string.
39
+ # @return [String]
40
+ #
41
+ # @!attribute [r] expiration
42
+ # The Unix timestamp when the access token expires.
43
+ # @return [Integer]
44
+ #
45
+ # @!attribute [r] scope
46
+ # Space-delimited scope string, or nil if no scopes.
47
+ # @return [String, nil]
48
+ #
49
+ # @!attribute [r] token_authority_session
50
+ # The created Session record.
51
+ # @return [TokenAuthority::Session]
52
+ TokenContainer = Data.define(:access_token, :refresh_token, :expiration, :scope, :token_authority_session)
53
+
54
+ private
55
+
56
+ # Creates a new token session with access and refresh tokens.
57
+ #
58
+ # Generates token pairs based on the client's configured lifetimes, creates
59
+ # a Session record, and yields to allow the caller to perform additional
60
+ # actions (like marking a grant as redeemed).
61
+ #
62
+ # Emits instrumentation events for monitoring session creation performance.
63
+ #
64
+ # @param grant [TokenAuthority::AuthorizationGrant] the authorization grant
65
+ # @param resources [Array<String>] resource indicators for the tokens
66
+ # @param scopes [Array<String>] scope tokens for the tokens
67
+ #
68
+ # @yield allows the caller to update related records within the session creation
69
+ #
70
+ # @return [TokenContainer] container with access_token, refresh_token, expiration,
71
+ # scope, and token_authority_session
72
+ #
73
+ # @raise [TokenAuthority::ServerError] if session creation fails validation
74
+ #
75
+ # @api private
76
+ def create_token_authority_session(grant:, resources: [], scopes: [])
77
+ client = grant.resolved_client
78
+
79
+ instrument("session.create") do
80
+ access_token_expiration = client.access_token_duration.seconds.from_now.to_i
81
+ access_token = TokenAuthority::AccessToken.default(user_id:, exp: access_token_expiration, resources:, scopes:)
82
+
83
+ refresh_token_expiration = client.refresh_token_duration.seconds.from_now.to_i
84
+ refresh_token = TokenAuthority::RefreshToken.default(exp: refresh_token_expiration, resources:, scopes:)
85
+
86
+ token_authority_session = grant.token_authority_sessions.new(
87
+ access_token_jti: access_token.jti, refresh_token_jti: refresh_token.jti
88
+ )
89
+
90
+ if token_authority_session.save
91
+ yield
92
+ scope = scopes.any? ? scopes.join(" ") : nil
93
+ TokenContainer[access_token.to_encoded_token, refresh_token.to_encoded_token, access_token.exp, scope, token_authority_session]
94
+ else
95
+ errors = token_authority_session.errors.full_messages.join(", ")
96
+ raise TokenAuthority::ServerError, I18n.t("token_authority.errors.oauth_session_failure", errors:)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenAuthority
4
+ # Represents an OAuth 2.1 access token with JWT format per RFC 9068.
5
+ #
6
+ # Access tokens are short-lived bearer tokens that grant access to protected
7
+ # resources. They are not persisted in the database; instead, only their JTI
8
+ # (JWT ID) is stored in the Session model for revocation lookups.
9
+ #
10
+ # The tokens follow RFC 9068 JWT Profile for OAuth Access Tokens, including
11
+ # standard claims (iss, aud, exp, iat, jti) plus custom claims like user_id
12
+ # and scope.
13
+ #
14
+ # This is an ActiveModel object (not ActiveRecord) that provides validation
15
+ # and serialization of JWT claims without database persistence.
16
+ #
17
+ # @example Creating a new access token
18
+ # token = TokenAuthority::AccessToken.default(
19
+ # exp: 5.minutes.from_now,
20
+ # user_id: 42,
21
+ # resources: ["https://api.example.com"],
22
+ # scopes: ["read", "write"]
23
+ # )
24
+ # jwt_string = token.to_encoded_token
25
+ #
26
+ # @example Decoding and validating a token
27
+ # token = TokenAuthority::AccessToken.from_token(jwt_string)
28
+ # token.valid? # => true/false
29
+ #
30
+ # @since 0.2.0
31
+ class AccessToken
32
+ include TokenAuthority::ClaimValidatable
33
+
34
+ # @!attribute [rw] user_id
35
+ # The ID of the user this token grants access for.
36
+ # @return [Integer]
37
+ attr_accessor :user_id
38
+
39
+ # @!attribute [rw] scope
40
+ # Space-separated list of OAuth scopes granted to this token.
41
+ # @return [String, nil]
42
+ attr_accessor :scope
43
+
44
+ validates :user_id, presence: true, comparison: {equal_to: :user_id_from_token_authority_session}
45
+
46
+ # Creates a new access token with default claims per RFC 9068.
47
+ #
48
+ # The audience (aud) claim is set from resource indicators if provided,
49
+ # otherwise falls back to the configured default audience URL.
50
+ #
51
+ # @param exp [Time, Integer] token expiration time
52
+ # @param user_id [Integer] the user ID
53
+ # @param resources [Array<String>] resource indicators (RFC 8707)
54
+ # @param scopes [Array<String>] OAuth scopes to include
55
+ #
56
+ # @return [TokenAuthority::AccessToken] the new token instance
57
+ #
58
+ # @example
59
+ # token = AccessToken.default(
60
+ # exp: 5.minutes.from_now,
61
+ # user_id: 123,
62
+ # resources: ["https://api.example.com"],
63
+ # scopes: ["read", "write"]
64
+ # )
65
+ def self.default(exp:, user_id:, resources: [], scopes: [])
66
+ # Use resources for aud claim if provided, otherwise fall back to config
67
+ aud = if resources.any?
68
+ (resources.size == 1) ? resources.first : resources
69
+ else
70
+ TokenAuthority.config.rfc_9068_audience_url
71
+ end
72
+
73
+ scope_claim = scopes.any? ? scopes.join(" ") : nil
74
+
75
+ new(
76
+ aud:,
77
+ exp:,
78
+ iat: Time.zone.now.to_i,
79
+ iss: TokenAuthority.config.rfc_9068_issuer_url,
80
+ jti: SecureRandom.uuid,
81
+ user_id:,
82
+ scope: scope_claim
83
+ )
84
+ end
85
+
86
+ # Decodes a JWT string into an AccessToken instance.
87
+ #
88
+ # @param token [String] the JWT-encoded access token
89
+ #
90
+ # @return [TokenAuthority::AccessToken] the decoded token instance
91
+ #
92
+ # @raise [JWT::DecodeError] if the token is malformed or signature is invalid
93
+ #
94
+ # @example
95
+ # token = AccessToken.from_token(jwt_string)
96
+ # user_id = token.user_id
97
+ def self.from_token(token)
98
+ new(TokenAuthority::JsonWebToken.decode(token))
99
+ end
100
+
101
+ # Converts the token to a hash of JWT claims.
102
+ #
103
+ # Nil values are omitted from the hash to produce a minimal JWT payload.
104
+ #
105
+ # @return [Hash] the JWT claims
106
+ def to_h
107
+ {aud:, exp:, iat:, iss:, jti:, user_id:, scope:}.compact
108
+ end
109
+
110
+ # Encodes the token as a signed JWT string.
111
+ #
112
+ # @return [String] the JWT-encoded access token
113
+ def to_encoded_token
114
+ TokenAuthority::JsonWebToken.encode(to_h, exp)
115
+ end
116
+
117
+ private
118
+
119
+ # Returns the user_id from the associated session for validation.
120
+ #
121
+ # @return [Integer, nil]
122
+ # @api private
123
+ def user_id_from_token_authority_session
124
+ token_authority_session&.user_id
125
+ end
126
+ end
127
+ end