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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +199 -7
- data/app/controllers/concerns/token_authority/client_authentication.rb +141 -0
- data/app/controllers/concerns/token_authority/controller_event_logging.rb +98 -0
- data/app/controllers/concerns/token_authority/initial_access_token_authentication.rb +35 -0
- data/app/controllers/concerns/token_authority/token_authentication.rb +128 -0
- data/app/controllers/token_authority/authorization_grants_controller.rb +119 -0
- data/app/controllers/token_authority/authorizations_controller.rb +105 -0
- data/app/controllers/token_authority/clients_controller.rb +99 -0
- data/app/controllers/token_authority/metadata_controller.rb +12 -0
- data/app/controllers/token_authority/resource_metadata_controller.rb +12 -0
- data/app/controllers/token_authority/sessions_controller.rb +228 -0
- data/app/helpers/token_authority/authorization_grants_helper.rb +27 -0
- data/app/models/concerns/token_authority/claim_validatable.rb +95 -0
- data/app/models/concerns/token_authority/event_logging.rb +144 -0
- data/app/models/concerns/token_authority/resourceable.rb +111 -0
- data/app/models/concerns/token_authority/scopeable.rb +105 -0
- data/app/models/concerns/token_authority/session_creatable.rb +101 -0
- data/app/models/token_authority/access_token.rb +127 -0
- data/app/models/token_authority/access_token_request.rb +193 -0
- data/app/models/token_authority/authorization_grant.rb +119 -0
- data/app/models/token_authority/authorization_request.rb +276 -0
- data/app/models/token_authority/authorization_server_metadata.rb +101 -0
- data/app/models/token_authority/client.rb +263 -0
- data/app/models/token_authority/client_id_resolver.rb +114 -0
- data/app/models/token_authority/client_metadata_document.rb +164 -0
- data/app/models/token_authority/client_metadata_document_cache.rb +33 -0
- data/app/models/token_authority/client_metadata_document_fetcher.rb +266 -0
- data/app/models/token_authority/client_registration_request.rb +214 -0
- data/app/models/token_authority/client_registration_response.rb +58 -0
- data/app/models/token_authority/jwks_cache.rb +37 -0
- data/app/models/token_authority/jwks_fetcher.rb +70 -0
- data/app/models/token_authority/protected_resource_metadata.rb +74 -0
- data/app/models/token_authority/refresh_token.rb +110 -0
- data/app/models/token_authority/refresh_token_request.rb +116 -0
- data/app/models/token_authority/session.rb +193 -0
- data/app/models/token_authority/software_statement.rb +70 -0
- data/app/views/token_authority/authorization_grants/new.html.erb +25 -0
- data/app/views/token_authority/client_error.html.erb +8 -0
- data/config/locales/token_authority.en.yml +248 -0
- data/config/routes.rb +29 -0
- data/lib/generators/token_authority/install/install_generator.rb +61 -0
- data/lib/generators/token_authority/install/templates/create_token_authority_tables.rb.erb +116 -0
- data/lib/generators/token_authority/install/templates/token_authority.rb +247 -0
- data/lib/token_authority/configuration.rb +397 -0
- data/lib/token_authority/engine.rb +34 -0
- data/lib/token_authority/errors.rb +221 -0
- data/lib/token_authority/instrumentation.rb +80 -0
- data/lib/token_authority/instrumentation_log_subscriber.rb +62 -0
- data/lib/token_authority/json_web_token.rb +78 -0
- data/lib/token_authority/log_event_subscriber.rb +43 -0
- data/lib/token_authority/routing/constraints.rb +71 -0
- data/lib/token_authority/routing/routes.rb +39 -0
- data/lib/token_authority/version.rb +4 -1
- data/lib/token_authority.rb +30 -1
- metadata +65 -5
- data/app/assets/stylesheets/token_authority/application.css +0 -15
- data/app/controllers/token_authority/application_controller.rb +0 -4
- data/app/helpers/token_authority/application_helper.rb +0 -4
- 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
|