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,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
# Raised when the TokenAuthority configuration is invalid or inconsistent.
|
|
5
|
+
# This typically occurs during initialization or validation when required
|
|
6
|
+
# configuration options are missing or conflicting.
|
|
7
|
+
#
|
|
8
|
+
# @since 0.2.0
|
|
9
|
+
class ConfigurationError < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Raised when the client attempting to use a token or grant doesn't match
|
|
12
|
+
# the client that originally requested it. This prevents token theft and
|
|
13
|
+
# unauthorized client access.
|
|
14
|
+
#
|
|
15
|
+
# @since 0.2.0
|
|
16
|
+
class ClientMismatchError < StandardError; end
|
|
17
|
+
|
|
18
|
+
# Raised when a requested OAuth client cannot be found in the database or
|
|
19
|
+
# via client metadata document resolution.
|
|
20
|
+
#
|
|
21
|
+
# @since 0.2.0
|
|
22
|
+
class ClientNotFoundError < StandardError; end
|
|
23
|
+
|
|
24
|
+
# Raised when an access token fails validation (expired, malformed, or invalid signature).
|
|
25
|
+
#
|
|
26
|
+
# @since 0.2.0
|
|
27
|
+
class InvalidAccessTokenError < StandardError; end
|
|
28
|
+
|
|
29
|
+
# Raised when an authorization grant is invalid, expired, already redeemed,
|
|
30
|
+
# or the PKCE code verifier doesn't match the challenge.
|
|
31
|
+
#
|
|
32
|
+
# Uses I18n for the error message to support internationalization.
|
|
33
|
+
#
|
|
34
|
+
# @since 0.2.0
|
|
35
|
+
class InvalidGrantError < StandardError
|
|
36
|
+
# Returns the localized error message.
|
|
37
|
+
# @return [String] the error message
|
|
38
|
+
def message
|
|
39
|
+
I18n.t("token_authority.errors.invalid_grant")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Raised when a client's redirect URI cannot be parsed or is otherwise invalid.
|
|
44
|
+
# This prevents open redirect vulnerabilities.
|
|
45
|
+
#
|
|
46
|
+
# @since 0.2.0
|
|
47
|
+
class InvalidRedirectUrlError < StandardError; end
|
|
48
|
+
|
|
49
|
+
# Raised when a protected endpoint is accessed without the required Authorization header.
|
|
50
|
+
#
|
|
51
|
+
# @since 0.2.0
|
|
52
|
+
class MissingAuthorizationHeaderError < StandardError; end
|
|
53
|
+
|
|
54
|
+
# Raised when an OAuth session cannot be found by token JTI or session ID.
|
|
55
|
+
#
|
|
56
|
+
# @since 0.2.0
|
|
57
|
+
class OAuthSessionNotFound < StandardError; end
|
|
58
|
+
|
|
59
|
+
# Raised when attempting to use a revoked session.
|
|
60
|
+
# This can indicate a refresh token replay attack where a stolen token
|
|
61
|
+
# is used after the legitimate client has already refreshed.
|
|
62
|
+
#
|
|
63
|
+
# Captures context about both the session being refreshed and the session
|
|
64
|
+
# that was revoked to aid in security auditing.
|
|
65
|
+
#
|
|
66
|
+
# @since 0.2.0
|
|
67
|
+
class RevokedSessionError < StandardError
|
|
68
|
+
# @return [String] the client ID that attempted the refresh
|
|
69
|
+
attr_reader :client_id
|
|
70
|
+
|
|
71
|
+
# @return [Integer] the session ID that was being refreshed
|
|
72
|
+
attr_reader :refreshed_session_id
|
|
73
|
+
|
|
74
|
+
# @return [Integer] the session ID that was revoked
|
|
75
|
+
attr_reader :revoked_session_id
|
|
76
|
+
|
|
77
|
+
# @return [Integer] the user ID associated with the session
|
|
78
|
+
attr_reader :user_id
|
|
79
|
+
|
|
80
|
+
# Creates a new RevokedSessionError with security context.
|
|
81
|
+
#
|
|
82
|
+
# @param client_id [String] the client ID
|
|
83
|
+
# @param refreshed_session_id [Integer] the session being refreshed
|
|
84
|
+
# @param revoked_session_id [Integer] the session that was revoked
|
|
85
|
+
# @param user_id [Integer] the user ID
|
|
86
|
+
def initialize(client_id:, refreshed_session_id:, revoked_session_id:, user_id:)
|
|
87
|
+
super()
|
|
88
|
+
@client_id = client_id
|
|
89
|
+
@refreshed_session_id = refreshed_session_id
|
|
90
|
+
@revoked_session_id = revoked_session_id
|
|
91
|
+
@user_id = user_id
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns the localized error message with context.
|
|
95
|
+
# @return [String] the error message
|
|
96
|
+
def message
|
|
97
|
+
I18n.t("token_authority.errors.revoked_session", client_id:, refreshed_session_id:, revoked_session_id:, user_id:)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Raised for unexpected server-side errors during OAuth flows.
|
|
102
|
+
# This is a catch-all for internal processing errors.
|
|
103
|
+
#
|
|
104
|
+
# @since 0.2.0
|
|
105
|
+
class ServerError < StandardError; end
|
|
106
|
+
|
|
107
|
+
# Raised when an access token is valid but the user is not authorized
|
|
108
|
+
# to access the requested resource.
|
|
109
|
+
#
|
|
110
|
+
# @since 0.2.0
|
|
111
|
+
class UnauthorizedAccessTokenError < StandardError; end
|
|
112
|
+
|
|
113
|
+
# Raised when PKCE code challenge verification fails.
|
|
114
|
+
# This indicates the code_verifier doesn't match the original code_challenge,
|
|
115
|
+
# which could indicate an interception attack.
|
|
116
|
+
#
|
|
117
|
+
# @since 0.2.0
|
|
118
|
+
class UnsuccessfulChallengeError < StandardError; end
|
|
119
|
+
|
|
120
|
+
# Raised when a client requests a grant type that is not supported
|
|
121
|
+
# by the authorization server.
|
|
122
|
+
#
|
|
123
|
+
# @since 0.2.0
|
|
124
|
+
class UnsupportedGrantTypeError < StandardError; end
|
|
125
|
+
|
|
126
|
+
# Raised during client registration (RFC 7591) when one or more
|
|
127
|
+
# redirect URIs are malformed or use an invalid scheme.
|
|
128
|
+
#
|
|
129
|
+
# @since 0.2.0
|
|
130
|
+
class InvalidRedirectUrisError < StandardError
|
|
131
|
+
# Creates a new InvalidRedirectUrisError.
|
|
132
|
+
# @param msg [String] custom error message
|
|
133
|
+
def initialize(msg = "One or more redirect_uris are invalid")
|
|
134
|
+
super
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Raised during client registration (RFC 7591) when the submitted
|
|
139
|
+
# client metadata fails validation.
|
|
140
|
+
#
|
|
141
|
+
# @since 0.2.0
|
|
142
|
+
class InvalidClientMetadataError < StandardError
|
|
143
|
+
# Creates a new InvalidClientMetadataError.
|
|
144
|
+
# @param msg [String] custom error message
|
|
145
|
+
def initialize(msg = "Client metadata is invalid")
|
|
146
|
+
super
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Raised during client registration (RFC 7591) when a software statement
|
|
151
|
+
# JWT cannot be verified or contains invalid claims.
|
|
152
|
+
#
|
|
153
|
+
# @since 0.2.0
|
|
154
|
+
class InvalidSoftwareStatementError < StandardError
|
|
155
|
+
# Creates a new InvalidSoftwareStatementError.
|
|
156
|
+
# @param msg [String] custom error message
|
|
157
|
+
def initialize(msg = "Software statement is invalid or could not be verified")
|
|
158
|
+
super
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Raised during client registration (RFC 7591) when a software statement
|
|
163
|
+
# is valid but not approved for use with this authorization server.
|
|
164
|
+
#
|
|
165
|
+
# @since 0.2.0
|
|
166
|
+
class UnapprovedSoftwareStatementError < StandardError
|
|
167
|
+
# Creates a new UnapprovedSoftwareStatementError.
|
|
168
|
+
# @param msg [String] custom error message
|
|
169
|
+
def initialize(msg = "Software statement is not approved for use with this authorization server")
|
|
170
|
+
super
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Raised during client registration (RFC 7591) when an initial access token
|
|
175
|
+
# is required but missing or fails validation.
|
|
176
|
+
#
|
|
177
|
+
# @since 0.2.0
|
|
178
|
+
class InvalidInitialAccessTokenError < StandardError
|
|
179
|
+
# Creates a new InvalidInitialAccessTokenError.
|
|
180
|
+
# @param msg [String] custom error message
|
|
181
|
+
def initialize(msg = "Initial access token is invalid or missing")
|
|
182
|
+
super
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Raised when a client_id URL for client metadata documents is invalid.
|
|
187
|
+
# URLs must be HTTPS, not contain fragments, and meet other security requirements.
|
|
188
|
+
#
|
|
189
|
+
# @since 0.2.0
|
|
190
|
+
class InvalidClientMetadataDocumentUrlError < StandardError
|
|
191
|
+
# Creates a new InvalidClientMetadataDocumentUrlError.
|
|
192
|
+
# @param msg [String] custom error message
|
|
193
|
+
def initialize(msg = "Client ID URL is invalid")
|
|
194
|
+
super
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Raised when fetching a client metadata document fails due to network errors,
|
|
199
|
+
# timeouts, or HTTP error responses.
|
|
200
|
+
#
|
|
201
|
+
# @since 0.2.0
|
|
202
|
+
class ClientMetadataDocumentFetchError < StandardError
|
|
203
|
+
# Creates a new ClientMetadataDocumentFetchError.
|
|
204
|
+
# @param msg [String] custom error message
|
|
205
|
+
def initialize(msg = "Failed to fetch client metadata document")
|
|
206
|
+
super
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Raised when a fetched client metadata document contains invalid JSON
|
|
211
|
+
# or doesn't meet the required schema.
|
|
212
|
+
#
|
|
213
|
+
# @since 0.2.0
|
|
214
|
+
class InvalidClientMetadataDocumentError < StandardError
|
|
215
|
+
# Creates a new InvalidClientMetadataDocumentError.
|
|
216
|
+
# @param msg [String] custom error message
|
|
217
|
+
def initialize(msg = "Client metadata document is invalid")
|
|
218
|
+
super
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
# Provides instrumentation capabilities using ActiveSupport::Notifications.
|
|
5
|
+
#
|
|
6
|
+
# This module emits performance and timing data that APM tools (New Relic, Datadog,
|
|
7
|
+
# Skylight) can automatically capture. It can be included in classes or extended
|
|
8
|
+
# in modules to add instrumentation to methods.
|
|
9
|
+
#
|
|
10
|
+
# All events are automatically namespaced with "token_authority." prefix to avoid
|
|
11
|
+
# conflicts with other instrumentation in the application.
|
|
12
|
+
#
|
|
13
|
+
# @example Using in a module with class methods
|
|
14
|
+
# module MyModule
|
|
15
|
+
# extend TokenAuthority::Instrumentation
|
|
16
|
+
#
|
|
17
|
+
# def self.process_data
|
|
18
|
+
# instrument("process_data", record_count: 100) do |payload|
|
|
19
|
+
# # Do work here
|
|
20
|
+
# # Can add additional payload data: payload[:rows_processed] = 42
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @example Using in a class with instance methods
|
|
26
|
+
# class MyClass
|
|
27
|
+
# include TokenAuthority::Instrumentation
|
|
28
|
+
#
|
|
29
|
+
# def process_record
|
|
30
|
+
# instrument("process_record") do
|
|
31
|
+
# # Do work here
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# @since 0.2.0
|
|
37
|
+
module Instrumentation
|
|
38
|
+
# The namespace prefix for all instrumentation events.
|
|
39
|
+
# @return [String]
|
|
40
|
+
NAMESPACE = "token_authority"
|
|
41
|
+
|
|
42
|
+
# Wraps a block of code with instrumentation timing and error tracking.
|
|
43
|
+
#
|
|
44
|
+
# If instrumentation is disabled via configuration, the block is executed
|
|
45
|
+
# without emitting notifications. Errors are captured in the payload before
|
|
46
|
+
# being re-raised.
|
|
47
|
+
#
|
|
48
|
+
# @param event_name [String] the event name (will be prefixed with "token_authority.")
|
|
49
|
+
# @param payload [Hash] initial payload data for the event
|
|
50
|
+
#
|
|
51
|
+
# @yield [payload] the block to instrument; the payload can be modified within the block
|
|
52
|
+
# @yieldparam payload [Hash] the mutable event payload
|
|
53
|
+
#
|
|
54
|
+
# @return the result of the yielded block
|
|
55
|
+
#
|
|
56
|
+
# @raise re-raises any exception from the block after capturing it in the payload
|
|
57
|
+
#
|
|
58
|
+
# @example Basic usage
|
|
59
|
+
# instrument("database.query", table: "users") do |payload|
|
|
60
|
+
# result = run_query
|
|
61
|
+
# payload[:rows] = result.count
|
|
62
|
+
# result
|
|
63
|
+
# end
|
|
64
|
+
#
|
|
65
|
+
# @example Error handling
|
|
66
|
+
# instrument("risky_operation") do
|
|
67
|
+
# raise "Something went wrong" # Error is logged to payload before re-raising
|
|
68
|
+
# end
|
|
69
|
+
def instrument(event_name, **payload, &block)
|
|
70
|
+
return yield(payload) unless TokenAuthority.config.instrumentation_enabled
|
|
71
|
+
|
|
72
|
+
ActiveSupport::Notifications.instrument("#{NAMESPACE}.#{event_name}", payload) do |p|
|
|
73
|
+
yield(p)
|
|
74
|
+
rescue => e
|
|
75
|
+
p[:error] = "#{e.class.name}: #{e.message}"
|
|
76
|
+
raise
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
# Subscribes to TokenAuthority instrumentation events and logs them to Rails logger.
|
|
5
|
+
#
|
|
6
|
+
# This subscriber captures all instrumentation events emitted by the TokenAuthority
|
|
7
|
+
# engine and writes them to the Rails log with timing information. This is valuable
|
|
8
|
+
# for development debugging and performance analysis.
|
|
9
|
+
#
|
|
10
|
+
# The subscriber is automatically enabled when `instrumentation_enabled` is true
|
|
11
|
+
# in the configuration. It can also be manually enabled for testing or debugging.
|
|
12
|
+
#
|
|
13
|
+
# Events are logged at INFO level with the format:
|
|
14
|
+
# [TokenAuthority::Instrumentation] event_name (duration_ms) key1=value1 key2=value2
|
|
15
|
+
#
|
|
16
|
+
# @example Automatic subscription via configuration
|
|
17
|
+
# TokenAuthority.configure do |config|
|
|
18
|
+
# config.instrumentation_enabled = true
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example Manual subscription
|
|
22
|
+
# TokenAuthority::InstrumentationLogSubscriber.subscribe!
|
|
23
|
+
#
|
|
24
|
+
# @since 0.2.0
|
|
25
|
+
class InstrumentationLogSubscriber
|
|
26
|
+
class << self
|
|
27
|
+
# Subscribes to all TokenAuthority instrumentation events.
|
|
28
|
+
#
|
|
29
|
+
# Creates a wildcard subscription for all events matching the pattern
|
|
30
|
+
# /^token_authority\./. Each event is logged with its name, duration,
|
|
31
|
+
# and payload data.
|
|
32
|
+
#
|
|
33
|
+
# @return [ActiveSupport::Notifications::Subscription] the subscription object
|
|
34
|
+
#
|
|
35
|
+
# @note This method should only be called once during application initialization
|
|
36
|
+
# to avoid duplicate log entries.
|
|
37
|
+
def subscribe!
|
|
38
|
+
ActiveSupport::Notifications.subscribe(/^token_authority\./) do |name, start, finish, id, payload|
|
|
39
|
+
duration_ms = (finish - start) * 1000
|
|
40
|
+
log_event(name, duration_ms, payload)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Formats and logs an instrumentation event.
|
|
47
|
+
#
|
|
48
|
+
# Exceptions are excluded from the payload output to avoid cluttering logs,
|
|
49
|
+
# as they're typically logged separately by Rails' exception handling.
|
|
50
|
+
#
|
|
51
|
+
# @param name [String] the full event name (e.g., "token_authority.jwt.encode")
|
|
52
|
+
# @param duration_ms [Float] the event duration in milliseconds
|
|
53
|
+
# @param payload [Hash] the event payload data
|
|
54
|
+
#
|
|
55
|
+
# @return [void]
|
|
56
|
+
def log_event(name, duration_ms, payload)
|
|
57
|
+
payload_str = payload.except(:exception).map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
|
|
58
|
+
Rails.logger.info { "[TokenAuthority::Instrumentation] #{name} (#{duration_ms.round(2)}ms) #{payload_str}" }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
|
|
5
|
+
module TokenAuthority
|
|
6
|
+
# Provides JWT encoding and decoding functionality for TokenAuthority.
|
|
7
|
+
#
|
|
8
|
+
# This module wraps the ruby-jwt gem and adds instrumentation for monitoring.
|
|
9
|
+
# All JWTs are signed using HMAC-SHA256 with the configured secret key.
|
|
10
|
+
#
|
|
11
|
+
# Token expiration validation is disabled during decoding to allow the application
|
|
12
|
+
# to handle expired tokens gracefully and provide better error messages.
|
|
13
|
+
#
|
|
14
|
+
# @note This module uses ActiveSupport::Notifications for instrumentation when enabled.
|
|
15
|
+
#
|
|
16
|
+
# @example Encoding a token
|
|
17
|
+
# payload = { user_id: 123, scope: "read write" }
|
|
18
|
+
# token = TokenAuthority::JsonWebToken.encode(payload, 1.hour.from_now)
|
|
19
|
+
#
|
|
20
|
+
# @example Decoding a token
|
|
21
|
+
# payload = TokenAuthority::JsonWebToken.decode(token)
|
|
22
|
+
# user_id = payload[:user_id]
|
|
23
|
+
#
|
|
24
|
+
# @since 0.2.0
|
|
25
|
+
module JsonWebToken
|
|
26
|
+
extend TokenAuthority::Instrumentation
|
|
27
|
+
|
|
28
|
+
# Encodes a payload into a signed JWT.
|
|
29
|
+
#
|
|
30
|
+
# The expiration time is added to the payload before encoding.
|
|
31
|
+
# Emits an instrumentation event with the token size.
|
|
32
|
+
#
|
|
33
|
+
# @param payload [Hash] the JWT claims to encode
|
|
34
|
+
# @param expiration [Time, ActiveSupport::TimeWithZone] when the token expires
|
|
35
|
+
# (default: 30 minutes from now)
|
|
36
|
+
#
|
|
37
|
+
# @return [String] the encoded JWT token
|
|
38
|
+
#
|
|
39
|
+
# @example
|
|
40
|
+
# token = JsonWebToken.encode({ user_id: 42 }, 1.hour.from_now)
|
|
41
|
+
def self.encode(payload, expiration = 30.minutes.from_now)
|
|
42
|
+
payload[:exp] = expiration.to_i
|
|
43
|
+
|
|
44
|
+
instrument("jwt.encode") do |p|
|
|
45
|
+
token = JWT.encode(payload, TokenAuthority.config.secret_key)
|
|
46
|
+
p[:token_size] = token.bytesize
|
|
47
|
+
token
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Decodes and verifies a JWT signature.
|
|
52
|
+
#
|
|
53
|
+
# Expiration validation is intentionally disabled to allow the application
|
|
54
|
+
# to handle expired tokens with custom error messages and logic.
|
|
55
|
+
# Emits an instrumentation event with the token size.
|
|
56
|
+
#
|
|
57
|
+
# @param token [String] the JWT token to decode
|
|
58
|
+
#
|
|
59
|
+
# @return [ActiveSupport::HashWithIndifferentAccess] the decoded JWT claims
|
|
60
|
+
#
|
|
61
|
+
# @raise [JWT::DecodeError] if the token is malformed or signature is invalid
|
|
62
|
+
#
|
|
63
|
+
# @example
|
|
64
|
+
# payload = JsonWebToken.decode(token_string)
|
|
65
|
+
# user_id = payload[:user_id]
|
|
66
|
+
def self.decode(token)
|
|
67
|
+
instrument("jwt.decode", token_size: token.bytesize) do
|
|
68
|
+
(payload,) = JWT.decode(
|
|
69
|
+
token,
|
|
70
|
+
TokenAuthority.config.secret_key,
|
|
71
|
+
true,
|
|
72
|
+
{verify_expiration: false, algorithm: "HS256"}
|
|
73
|
+
)
|
|
74
|
+
ActiveSupport::HashWithIndifferentAccess.new(payload)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
##
|
|
5
|
+
# A Rails.event subscriber that logs TokenAuthority events to Rails.logger.
|
|
6
|
+
#
|
|
7
|
+
# This subscriber only processes events with the "token_authority." prefix,
|
|
8
|
+
# ignoring all other Rails events.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# # Automatically enabled via configuration:
|
|
12
|
+
# TokenAuthority.configure do |config|
|
|
13
|
+
# config.event_logging_rails_logger = true
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# # Or manually subscribe:
|
|
17
|
+
# Rails.event.subscribe(TokenAuthority::LogEventSubscriber.new)
|
|
18
|
+
#
|
|
19
|
+
class LogEventSubscriber
|
|
20
|
+
# @param event [Hash] The event hash from Rails.event
|
|
21
|
+
# - :name [String] Event name (e.g., "token_authority.authorization.request.received")
|
|
22
|
+
# - :payload [Hash] Event-specific data
|
|
23
|
+
# - :context [Hash] Request-level context (request_id, user_id, etc.)
|
|
24
|
+
# - :tags [Hash] Domain tags
|
|
25
|
+
# - :timestamp [Integer] Nanosecond timestamp
|
|
26
|
+
# - :source_location [Hash] File, line, and method info
|
|
27
|
+
def emit(event)
|
|
28
|
+
name = event[:name]
|
|
29
|
+
|
|
30
|
+
# Only log token_authority events
|
|
31
|
+
return unless name.start_with?("token_authority.")
|
|
32
|
+
|
|
33
|
+
payload = event[:payload] || {}
|
|
34
|
+
context = event[:context] || {}
|
|
35
|
+
|
|
36
|
+
# Format payload as key=value pairs
|
|
37
|
+
payload_str = payload.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
|
|
38
|
+
context_str = context.any? ? " context=#{context.inspect}" : ""
|
|
39
|
+
|
|
40
|
+
Rails.logger.info("[TokenAuthority] #{name} #{payload_str}#{context_str}")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
# Provides routing utilities for the TokenAuthority engine.
|
|
5
|
+
#
|
|
6
|
+
# @since 0.2.0
|
|
7
|
+
module Routing
|
|
8
|
+
# Route constraint for matching the grant_type parameter in token requests.
|
|
9
|
+
#
|
|
10
|
+
# This allows routing different grant types (authorization_code, refresh_token)
|
|
11
|
+
# to different controller actions based on the request parameters.
|
|
12
|
+
#
|
|
13
|
+
# @example In routes.rb
|
|
14
|
+
# post "token",
|
|
15
|
+
# to: "sessions#create_from_authorization_code",
|
|
16
|
+
# constraints: GrantTypeConstraint.new("authorization_code")
|
|
17
|
+
#
|
|
18
|
+
# @since 0.2.0
|
|
19
|
+
GrantTypeConstraint = Struct.new(:grant_type) do
|
|
20
|
+
# Determines if the request's grant_type parameter matches the constraint.
|
|
21
|
+
#
|
|
22
|
+
# @param request [ActionDispatch::Request] the Rails request object
|
|
23
|
+
# @return [Boolean] true if the grant_type parameter matches
|
|
24
|
+
def matches?(request)
|
|
25
|
+
request.request_parameters["grant_type"] == grant_type
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Route constraint for matching the token_type_hint parameter in revocation requests.
|
|
30
|
+
#
|
|
31
|
+
# This allows routing access token and refresh token revocations to different
|
|
32
|
+
# controller actions for optimized lookups.
|
|
33
|
+
#
|
|
34
|
+
# @example In routes.rb
|
|
35
|
+
# post "revoke",
|
|
36
|
+
# to: "sessions#revoke_access_token",
|
|
37
|
+
# constraints: TokenTypeHintConstraint.new("access_token")
|
|
38
|
+
#
|
|
39
|
+
# @since 0.2.0
|
|
40
|
+
TokenTypeHintConstraint = Struct.new(:token_type_hint) do
|
|
41
|
+
# Determines if the request's token_type_hint parameter matches the constraint.
|
|
42
|
+
#
|
|
43
|
+
# @param request [ActionDispatch::Request] the Rails request object
|
|
44
|
+
# @return [Boolean] true if the token_type_hint parameter matches
|
|
45
|
+
def matches?(request)
|
|
46
|
+
request.request_parameters["token_type_hint"] == token_type_hint
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Route constraint that checks if dynamic client registration (RFC 7591) is enabled.
|
|
51
|
+
#
|
|
52
|
+
# This prevents registration endpoints from being accessible when the feature
|
|
53
|
+
# is disabled in configuration.
|
|
54
|
+
#
|
|
55
|
+
# @example In routes.rb
|
|
56
|
+
# post "clients",
|
|
57
|
+
# to: "clients#create",
|
|
58
|
+
# constraints: DynamicRegistrationEnabledConstraint.new
|
|
59
|
+
#
|
|
60
|
+
# @since 0.2.0
|
|
61
|
+
DynamicRegistrationEnabledConstraint = Struct.new(:nothing) do
|
|
62
|
+
# Determines if dynamic client registration is enabled in the configuration.
|
|
63
|
+
#
|
|
64
|
+
# @param _request [ActionDispatch::Request] the Rails request object (unused)
|
|
65
|
+
# @return [Boolean] true if RFC 7591 dynamic registration is enabled
|
|
66
|
+
def matches?(_request)
|
|
67
|
+
TokenAuthority.config.rfc_7591_enabled
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionDispatch
|
|
4
|
+
module Routing
|
|
5
|
+
class Mapper
|
|
6
|
+
##
|
|
7
|
+
# Adds all TokenAuthority routes including OAuth 2.0 metadata endpoints
|
|
8
|
+
# (RFC 8414, RFC 9728) and mounts the TokenAuthority engine.
|
|
9
|
+
#
|
|
10
|
+
# Both RFC 8414 and RFC 9728 require the metadata endpoints to be at the
|
|
11
|
+
# root level `/.well-known/` path, not under the engine mount path.
|
|
12
|
+
#
|
|
13
|
+
# @param at [String] the path where TokenAuthority engine is mounted (default: "/oauth")
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# Rails.application.routes.draw do
|
|
17
|
+
# token_authority_routes # Mounts at default "/oauth"
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# @example Custom mount path
|
|
21
|
+
# Rails.application.routes.draw do
|
|
22
|
+
# token_authority_routes(at: "/auth")
|
|
23
|
+
# end
|
|
24
|
+
def token_authority_routes(at: "/oauth")
|
|
25
|
+
# RFC 8414: Authorization Server Metadata
|
|
26
|
+
get "/.well-known/oauth-authorization-server",
|
|
27
|
+
to: "token_authority/metadata#show",
|
|
28
|
+
defaults: {mount_path: at}
|
|
29
|
+
|
|
30
|
+
# RFC 9728: Protected Resource Metadata
|
|
31
|
+
get "/.well-known/oauth-protected-resource",
|
|
32
|
+
to: "token_authority/resource_metadata#show",
|
|
33
|
+
defaults: {mount_path: at}
|
|
34
|
+
|
|
35
|
+
mount TokenAuthority::Engine => at
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/token_authority.rb
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
require "token_authority/version"
|
|
2
2
|
require "token_authority/engine"
|
|
3
|
+
require "token_authority/configuration"
|
|
4
|
+
require "token_authority/errors"
|
|
5
|
+
require "token_authority/instrumentation"
|
|
6
|
+
require "token_authority/json_web_token"
|
|
7
|
+
require "token_authority/routing/constraints"
|
|
8
|
+
require "token_authority/routing/routes"
|
|
3
9
|
|
|
10
|
+
# TokenAuthority is a Rails engine that enables Rails applications to act as their own
|
|
11
|
+
# OAuth 2.1 provider. It provides a complete implementation of the OAuth 2.1 authorization
|
|
12
|
+
# framework with support for PKCE, JWT access tokens (RFC 9068), dynamic client registration
|
|
13
|
+
# (RFC 7591), resource indicators (RFC 8707), and client metadata documents.
|
|
14
|
+
#
|
|
15
|
+
# The engine is designed to integrate alongside existing authentication systems (Devise,
|
|
16
|
+
# custom auth, etc.) and provides mountable OAuth endpoints for authorization, token
|
|
17
|
+
# exchange, and token management.
|
|
18
|
+
#
|
|
19
|
+
# @example Basic configuration
|
|
20
|
+
# TokenAuthority.configure do |config|
|
|
21
|
+
# config.secret_key = Rails.application.credentials.secret_key_base
|
|
22
|
+
# config.rfc_9068_audience_url = "https://api.example.com"
|
|
23
|
+
# config.rfc_9068_issuer_url = "https://example.com"
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# @since 0.2.0
|
|
4
27
|
module TokenAuthority
|
|
5
|
-
#
|
|
28
|
+
# Returns the table name prefix for all TokenAuthority models.
|
|
29
|
+
# This ensures that all database tables are namespaced with 'token_authority_'.
|
|
30
|
+
#
|
|
31
|
+
# @return [String] the table name prefix
|
|
32
|
+
def self.table_name_prefix
|
|
33
|
+
"token_authority_"
|
|
34
|
+
end
|
|
6
35
|
end
|