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,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
|