authproto 0.3.0.beta1
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 +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +31 -0
- data/.tm_properties +1 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +52 -0
- data/MIT-LICENSE.txt +21 -0
- data/README.md +304 -0
- data/Rakefile +8 -0
- data/SECURITY.md +16 -0
- data/examples/confidential_client/.gitignore +5 -0
- data/examples/confidential_client/Gemfile +14 -0
- data/examples/confidential_client/Gemfile.lock +88 -0
- data/examples/confidential_client/README.md +226 -0
- data/examples/confidential_client/app.rb +290 -0
- data/examples/confidential_client/config/client-metadata.example.json +25 -0
- data/examples/confidential_client/config.ru +4 -0
- data/examples/confidential_client/public/styles.css +70 -0
- data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
- data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
- data/examples/confidential_client/scripts/generate_keys.rb +15 -0
- data/examples/confidential_client/views/authorized.erb +46 -0
- data/examples/confidential_client/views/index.erb +44 -0
- data/examples/confidential_client/views/layout.erb +11 -0
- data/examples/minisky_console/.gitignore +2 -0
- data/examples/minisky_console/Gemfile +12 -0
- data/examples/minisky_console/Gemfile.lock +131 -0
- data/examples/minisky_console/README.md +112 -0
- data/examples/minisky_console/app.rb +50 -0
- data/examples/minisky_console/bin/console +6 -0
- data/examples/minisky_console/config.ru +5 -0
- data/examples/minisky_console/localhost.rb +53 -0
- data/examples/minisky_console/production.rb +32 -0
- data/lib/authproto/client.rb +583 -0
- data/lib/authproto/client_metadata.rb +478 -0
- data/lib/authproto/configuration.rb +139 -0
- data/lib/authproto/dpop/client.rb +161 -0
- data/lib/authproto/dpop/key_manager.rb +223 -0
- data/lib/authproto/dpop/nonce_manager.rb +145 -0
- data/lib/authproto/dpop/proof_generator.rb +124 -0
- data/lib/authproto/dpop/stored_nonce.rb +37 -0
- data/lib/authproto/encryption.rb +250 -0
- data/lib/authproto/errors.rb +75 -0
- data/lib/authproto/http_client.rb +320 -0
- data/lib/authproto/insecure_http_client.rb +24 -0
- data/lib/authproto/localhost_server.rb +274 -0
- data/lib/authproto/minisky.rb +273 -0
- data/lib/authproto/par/client.rb +240 -0
- data/lib/authproto/par/client_assertion.rb +69 -0
- data/lib/authproto/par/request.rb +243 -0
- data/lib/authproto/par/response.rb +43 -0
- data/lib/authproto/par.rb +48 -0
- data/lib/authproto/pkce.rb +125 -0
- data/lib/authproto/serialization/base.rb +258 -0
- data/lib/authproto/serialization/dpop_key.rb +38 -0
- data/lib/authproto/serialization/session.rb +88 -0
- data/lib/authproto/serialization/stored_nonce.rb +46 -0
- data/lib/authproto/serialization/token_set.rb +54 -0
- data/lib/authproto/server_metadata/authorization_server.rb +267 -0
- data/lib/authproto/server_metadata/origin_url.rb +71 -0
- data/lib/authproto/server_metadata/resource_server.rb +98 -0
- data/lib/authproto/server_metadata.rb +28 -0
- data/lib/authproto/state/session.rb +168 -0
- data/lib/authproto/state/session_manager.rb +162 -0
- data/lib/authproto/state/token_set.rb +101 -0
- data/lib/authproto/state.rb +61 -0
- data/lib/authproto/storage/interface.rb +121 -0
- data/lib/authproto/storage/key_builder.rb +76 -0
- data/lib/authproto/storage/memory.rb +222 -0
- data/lib/authproto/storage/redis.rb +156 -0
- data/lib/authproto/storage/sqlite.rb +281 -0
- data/lib/authproto/token/refresh.rb +414 -0
- data/lib/authproto/version.rb +8 -0
- data/lib/authproto.rb +18 -0
- data/sig/authproto/client.rbs +43 -0
- data/sig/authproto/client_metadata.rbs +113 -0
- data/sig/authproto/dependencies.rbs +19 -0
- data/sig/authproto/dpop/client.rbs +23 -0
- data/sig/authproto/dpop/key_manager.rbs +26 -0
- data/sig/authproto/dpop/nonce_manager.rbs +23 -0
- data/sig/authproto/dpop/proof_generator.rbs +40 -0
- data/sig/authproto/dpop/stored_nonce.rbs +10 -0
- data/sig/authproto/encryption.rbs +42 -0
- data/sig/authproto/http_client.rbs +45 -0
- data/sig/authproto/insecure_http_client.rbs +8 -0
- data/sig/authproto/localhost_server.rbs +30 -0
- data/sig/authproto/minisky_oauth.rbs +22 -0
- data/sig/authproto/par/client.rbs +25 -0
- data/sig/authproto/par/client_assertion.rbs +10 -0
- data/sig/authproto/par/request.rbs +73 -0
- data/sig/authproto/par/response.rbs +17 -0
- data/sig/authproto/par.rbs +7 -0
- data/sig/authproto/pkce.rbs +24 -0
- data/sig/authproto/serialization/base.rbs +36 -0
- data/sig/authproto/serialization/dpop_key.rbs +14 -0
- data/sig/authproto/serialization/session.rbs +19 -0
- data/sig/authproto/serialization/stored_nonce.rbs +14 -0
- data/sig/authproto/serialization/token_set.rbs +14 -0
- data/sig/authproto/server_metadata/authorization_server.rbs +71 -0
- data/sig/authproto/server_metadata/origin_url.rbs +21 -0
- data/sig/authproto/server_metadata/resource_server.rbs +27 -0
- data/sig/authproto/state/session.rbs +28 -0
- data/sig/authproto/state/session_manager.rbs +15 -0
- data/sig/authproto/state/token_set.rbs +40 -0
- data/sig/authproto/state.rbs +6 -0
- data/sig/authproto/storage/interface.rbs +20 -0
- data/sig/authproto/storage/key_builder.rbs +16 -0
- data/sig/authproto/storage/memory.rbs +22 -0
- data/sig/authproto/storage/redis.rbs +19 -0
- data/sig/authproto/storage/redis_client.rbs +11 -0
- data/sig/authproto/storage/sqlite.rbs +26 -0
- data/sig/authproto/token/refresh.rbs +47 -0
- data/sig/authproto/version.rbs +3 -0
- data/sig/authproto.rbs +54 -0
- metadata +200 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
require_relative 'configuration'
|
|
7
|
+
require_relative 'errors'
|
|
8
|
+
|
|
9
|
+
module AuthProto
|
|
10
|
+
|
|
11
|
+
#
|
|
12
|
+
# Supported OAuth application types used by {ClientMetadata}.
|
|
13
|
+
#
|
|
14
|
+
|
|
15
|
+
module ApplicationType
|
|
16
|
+
|
|
17
|
+
# Application type for browser-based OAuth client applications
|
|
18
|
+
WEB = "web"
|
|
19
|
+
|
|
20
|
+
# Application type for native desktop or mobile OAuth client applications
|
|
21
|
+
NATIVE = "native"
|
|
22
|
+
|
|
23
|
+
# Supported application type values.
|
|
24
|
+
# @return [Array<String>]
|
|
25
|
+
ALL = [WEB, NATIVE].freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#
|
|
29
|
+
# Handles validation and management of AT Protocol OAuth client metadata according to
|
|
30
|
+
# the specification. This includes required fields like client_id and redirect URIs,
|
|
31
|
+
# optional metadata like client name and logo, and authentication configuration for
|
|
32
|
+
# confidential clients. Validates that all fields conform to the protocol's requirements,
|
|
33
|
+
# including:
|
|
34
|
+
#
|
|
35
|
+
# - Application type (web/native) validation and redirect URI rules
|
|
36
|
+
# - Required scopes and grant types
|
|
37
|
+
# - JWKS configuration for confidential clients
|
|
38
|
+
# - DPoP binding requirements
|
|
39
|
+
# - URI scheme and format validation
|
|
40
|
+
#
|
|
41
|
+
|
|
42
|
+
class ClientMetadata
|
|
43
|
+
|
|
44
|
+
# Client authentication method for confidential clients
|
|
45
|
+
AUTH_METHOD_PRIVATE_KEY_JWT = "private_key_jwt"
|
|
46
|
+
|
|
47
|
+
# Client authentication method value for public clients
|
|
48
|
+
AUTH_METHOD_NONE = "none"
|
|
49
|
+
|
|
50
|
+
# RFC 6749 scope-token characters, separated by single ASCII spaces.
|
|
51
|
+
SCOPE_PATTERN = /\A[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*\z/
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# -- required fields --
|
|
55
|
+
|
|
56
|
+
# Client metadata URL or localhost client identifier. **Required**.
|
|
57
|
+
# @return [String]
|
|
58
|
+
attr_reader :client_id
|
|
59
|
+
|
|
60
|
+
# List of allowed grant types. ATProto requires `authorization_code` and supports `refresh_token`. **Required**.
|
|
61
|
+
# @return [Array<String>]
|
|
62
|
+
attr_reader :grant_types
|
|
63
|
+
|
|
64
|
+
# List of supported response types. In ATProto, it must include `code`. **Required**.
|
|
65
|
+
# @return [Array<String>]
|
|
66
|
+
attr_reader :response_types
|
|
67
|
+
|
|
68
|
+
# List of registered OAuth callback URIs. **Required**.
|
|
69
|
+
# @return [Array<String>]
|
|
70
|
+
attr_reader :redirect_uris
|
|
71
|
+
|
|
72
|
+
# Space-separated list of supported OAuth scopes. Must include at least `atproto`. **Required**.
|
|
73
|
+
# @return [String]
|
|
74
|
+
attr_reader :scope
|
|
75
|
+
|
|
76
|
+
# Client authentication method: `none` for public clients, or `private_key_jwt` for confidential ones.
|
|
77
|
+
# Not technically required, but the reference PDS sets it to `client_secret_basic` if not set,
|
|
78
|
+
# which we don't support.
|
|
79
|
+
# @return [String]
|
|
80
|
+
attr_reader :token_endpoint_auth_method
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# -- optional fields --
|
|
84
|
+
|
|
85
|
+
# Application type: `web` (default) or `native`.
|
|
86
|
+
# @return [String]
|
|
87
|
+
attr_reader :application_type
|
|
88
|
+
|
|
89
|
+
# Public name of the client application that is presented to the user, if supplied.
|
|
90
|
+
# @return [String, nil]
|
|
91
|
+
attr_reader :client_name
|
|
92
|
+
|
|
93
|
+
# URL of the client application's home page, if supplied.
|
|
94
|
+
# @return [String, nil]
|
|
95
|
+
attr_reader :client_uri
|
|
96
|
+
|
|
97
|
+
# URL to the client application's logo image, if supplied.
|
|
98
|
+
# @return [String, nil]
|
|
99
|
+
attr_reader :logo_uri
|
|
100
|
+
|
|
101
|
+
# URL to human-readable terms of service of the client, if supplied.
|
|
102
|
+
# @return [String, nil]
|
|
103
|
+
attr_reader :tos_uri
|
|
104
|
+
|
|
105
|
+
# URL to human-readable privacy policy of the client, if supplied.
|
|
106
|
+
# @return [String, nil]
|
|
107
|
+
attr_reader :policy_uri
|
|
108
|
+
|
|
109
|
+
# Inline set of JWK keys for confidential clients, if supplied.
|
|
110
|
+
# @return [Hash{String => Object}, nil]
|
|
111
|
+
attr_reader :jwks
|
|
112
|
+
|
|
113
|
+
# HTTPS URL to a set of JWK keys for confidential clients, if supplied.
|
|
114
|
+
# @return [String, nil]
|
|
115
|
+
attr_reader :jwks_uri
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Validates string-keyed client metadata and exposes its fields.
|
|
119
|
+
#
|
|
120
|
+
# Requires `client_id`, `grant_types`, `response_types`, `redirect_uris`, `scope`,
|
|
121
|
+
# `token_endpoint_auth_method`, and `dpop_bound_access_tokens: true`.
|
|
122
|
+
# `application_type` defaults to `web`.
|
|
123
|
+
# Confidential clients using `private_key_jwt` require `ES256` and a JWK set or URI.
|
|
124
|
+
#
|
|
125
|
+
# @param metadata [Hash{String => Object}] parsed OAuth client metadata
|
|
126
|
+
# @raise [InvalidClientMetadata] if a required field or supported value is invalid
|
|
127
|
+
# @raise [URI::InvalidURIError] if a metadata URI cannot be parsed
|
|
128
|
+
#
|
|
129
|
+
def initialize(metadata)
|
|
130
|
+
validate_and_set_metadata(metadata)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Fetches and validates a client metadata document using the configured transport.
|
|
134
|
+
#
|
|
135
|
+
# Requires HTTP 200 and application/json, without following redirects.
|
|
136
|
+
# The document's `client_id` must equal the requested URL.
|
|
137
|
+
#
|
|
138
|
+
# @param url [String] HTTPS metadata URL, or localhost URL for development
|
|
139
|
+
# @return [ClientMetadata] validated metadata
|
|
140
|
+
# @raise [InvalidClientMetadata] if metadata is missing, invalid/mismatched, not JSON, or transport isn't configured
|
|
141
|
+
# @raise [HTTPClient::HTTPError, HTTPClient::SSRFError] if the transport rejects/fails the request
|
|
142
|
+
# @raise [URI::InvalidURIError] if a URL cannot be parsed
|
|
143
|
+
#
|
|
144
|
+
def self.from_url(url)
|
|
145
|
+
uri = URI(url)
|
|
146
|
+
|
|
147
|
+
unless uri.scheme == 'https' || (uri.scheme == 'http' && uri.host == 'localhost')
|
|
148
|
+
raise InvalidClientMetadata, "client_id must use HTTPS except for localhost"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
raise InvalidClientMetadata, "HTTP client not configured" unless AuthProto.configuration.http_client
|
|
152
|
+
|
|
153
|
+
response = AuthProto.configuration.http_client.get(url, follow_redirects: false)
|
|
154
|
+
|
|
155
|
+
unless response[:status] == 200
|
|
156
|
+
raise InvalidClientMetadata, "expected HTTP 200 response for client metadata request, got #{response[:status]}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
content_type = (response[:headers] || {}).find { |name, _| name.downcase == 'content-type' }&.last
|
|
160
|
+
|
|
161
|
+
unless content_type.to_s.split(';', 2).first.to_s.strip.downcase == 'application/json'
|
|
162
|
+
raise InvalidClientMetadata, "client metadata Content-Type must be application/json"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
raise InvalidClientMetadata, "client metadata request returned response with no body" if response[:body].nil?
|
|
166
|
+
|
|
167
|
+
metadata = JSON.parse(response[:body])
|
|
168
|
+
raise InvalidClientMetadata, "metadata must be a hash" unless metadata.is_a?(Hash)
|
|
169
|
+
|
|
170
|
+
if metadata['client_id'] != url
|
|
171
|
+
raise InvalidClientMetadata, "client_id mismatch: #{metadata['client_id']} != #{url}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
self.new(metadata)
|
|
175
|
+
rescue JSON::ParserError => e
|
|
176
|
+
raise InvalidClientMetadata, "Invalid JSON in client metadata: #{e.message}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Determines if the client is confidential (has authentication keys).
|
|
180
|
+
# @return [Boolean] true if client is confidential.
|
|
181
|
+
#
|
|
182
|
+
def confidential?
|
|
183
|
+
token_endpoint_auth_method == AUTH_METHOD_PRIVATE_KEY_JWT
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def validate_and_set_metadata(metadata)
|
|
190
|
+
raise InvalidClientMetadata, "metadata must be a hash" unless metadata.is_a?(Hash)
|
|
191
|
+
|
|
192
|
+
# Optional (defaults to "web"), but must be set before validating redirect URIs
|
|
193
|
+
@application_type = validate_application_type(metadata["application_type"])
|
|
194
|
+
|
|
195
|
+
# Required fields
|
|
196
|
+
@client_id = validate_client_id(metadata["client_id"])
|
|
197
|
+
@grant_types = validate_grant_types(metadata["grant_types"])
|
|
198
|
+
@response_types = validate_response_types(metadata["response_types"])
|
|
199
|
+
@redirect_uris = validate_redirect_uris(metadata["redirect_uris"])
|
|
200
|
+
@scope = validate_scope(metadata["scope"])
|
|
201
|
+
|
|
202
|
+
# Check dpop_bound_access_tokens
|
|
203
|
+
validate_dpop(metadata)
|
|
204
|
+
|
|
205
|
+
# Optional fields
|
|
206
|
+
@client_name = validate_client_name(metadata["client_name"])
|
|
207
|
+
@client_uri = validate_client_uri(metadata["client_uri"])
|
|
208
|
+
@logo_uri = validate_https_uri(metadata["logo_uri"])
|
|
209
|
+
@tos_uri = validate_https_uri(metadata["tos_uri"])
|
|
210
|
+
@policy_uri = validate_https_uri(metadata["policy_uri"])
|
|
211
|
+
|
|
212
|
+
# Check token_endpoint_auth_method and jwks
|
|
213
|
+
validate_auth_methods(metadata)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# -- Required field validations --
|
|
218
|
+
|
|
219
|
+
def validate_client_id(client_id)
|
|
220
|
+
raise InvalidClientMetadata, "client_id is required" unless client_id
|
|
221
|
+
raise InvalidClientMetadata, "client_id must be a string" unless client_id.is_a?(String)
|
|
222
|
+
|
|
223
|
+
unless valid_https_client_id?(client_id) || valid_localhost_client_id?(client_id)
|
|
224
|
+
raise InvalidClientMetadata, "client_id must be HTTPS or localhost HTTP URL"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
client_id
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def valid_https_client_id?(client_id)
|
|
231
|
+
uri = URI(client_id)
|
|
232
|
+
userinfo, port = URI.split(client_id).values_at(1, 3)
|
|
233
|
+
|
|
234
|
+
uri.scheme == 'https' && uri.host.to_s != '' && port.nil? && userinfo.nil? && !uri.fragment
|
|
235
|
+
rescue URI::InvalidURIError
|
|
236
|
+
false
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def valid_localhost_client_id?(client_id)
|
|
240
|
+
uri = URI(client_id)
|
|
241
|
+
userinfo, port = URI.split(client_id).values_at(1, 3)
|
|
242
|
+
|
|
243
|
+
uri.scheme == 'http' && uri.host == 'localhost' && port.nil? && userinfo.nil? && !uri.fragment &&
|
|
244
|
+
uri.path.to_s.chomp('/').empty?
|
|
245
|
+
rescue URI::InvalidURIError
|
|
246
|
+
false
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def validate_grant_types(grant_types)
|
|
250
|
+
raise InvalidClientMetadata, "grant_types is required" unless grant_types
|
|
251
|
+
raise InvalidClientMetadata, "grant_types must be an array" unless grant_types.is_a?(Array)
|
|
252
|
+
|
|
253
|
+
unless grant_types.include?('authorization_code')
|
|
254
|
+
raise InvalidClientMetadata, "grant_types must include authorization_code"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
valid_types = %w[authorization_code refresh_token]
|
|
258
|
+
|
|
259
|
+
grant_types.each do |grant|
|
|
260
|
+
raise InvalidClientMetadata, "unsupported grant type" unless valid_types.include?(grant)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
grant_types
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def validate_response_types(response_types)
|
|
267
|
+
raise InvalidClientMetadata, "response_types is required" unless response_types
|
|
268
|
+
raise InvalidClientMetadata, "response_types must be an array" unless response_types.is_a?(Array)
|
|
269
|
+
raise InvalidClientMetadata, "response_types must include 'code'" unless response_types.include?("code")
|
|
270
|
+
|
|
271
|
+
unless response_types.all? { |type| type.is_a?(String) }
|
|
272
|
+
raise InvalidClientMetadata, "response_types must contain only strings"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
response_types
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def validate_redirect_uris(uris)
|
|
279
|
+
raise InvalidClientMetadata, "redirect_uris is required" unless uris
|
|
280
|
+
raise InvalidClientMetadata, "redirect_uris must be an array" unless uris.is_a?(Array)
|
|
281
|
+
raise InvalidClientMetadata, "redirect_uris must not be empty" if uris.empty?
|
|
282
|
+
|
|
283
|
+
uris.each { |uri| validate_redirect_uri(uri) }
|
|
284
|
+
uris
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def validate_redirect_uri(value)
|
|
288
|
+
raise InvalidClientMetadata, "redirect_uris entries must be strings" unless value.is_a?(String)
|
|
289
|
+
|
|
290
|
+
uri = URI(value)
|
|
291
|
+
userinfo, port = URI.split(value).values_at(1, 3)
|
|
292
|
+
|
|
293
|
+
raise InvalidClientMetadata, "redirect URI must be absolute" unless uri.absolute?
|
|
294
|
+
raise InvalidClientMetadata, "redirect URI must not contain credentials" unless userinfo.nil?
|
|
295
|
+
raise InvalidClientMetadata, "redirect URI must not contain a fragment" unless uri.fragment.nil?
|
|
296
|
+
|
|
297
|
+
case application_type
|
|
298
|
+
when ApplicationType::WEB
|
|
299
|
+
validate_https_redirect_uri(uri, port)
|
|
300
|
+
when ApplicationType::NATIVE
|
|
301
|
+
validate_native_redirect_uri(uri, port)
|
|
302
|
+
end
|
|
303
|
+
rescue URI::InvalidURIError => e
|
|
304
|
+
raise InvalidClientMetadata, "Invalid redirect URI: #{e.message}"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def validate_https_redirect_uri(uri, port)
|
|
308
|
+
unless uri.scheme == 'https' && !uri.host.to_s.empty?
|
|
309
|
+
raise InvalidClientMetadata, "redirect URI must use HTTPS with a hostname"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
if port && uri.port == 443
|
|
313
|
+
raise InvalidClientMetadata, "HTTPS redirect URI must not specify the default port"
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def validate_redirect_uri_origin(uri)
|
|
318
|
+
client_uri = URI(@client_id)
|
|
319
|
+
|
|
320
|
+
unless uri.origin.downcase == client_uri.origin.downcase
|
|
321
|
+
raise InvalidClientMetadata, "redirect URI must match client_id origin"
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def validate_native_redirect_uri(uri, port)
|
|
326
|
+
case uri.scheme
|
|
327
|
+
when 'https'
|
|
328
|
+
validate_https_redirect_uri(uri, port)
|
|
329
|
+
validate_redirect_uri_origin(uri)
|
|
330
|
+
when 'http'
|
|
331
|
+
unless ["127.0.0.1", "[::1]"].include?(uri.host)
|
|
332
|
+
raise InvalidClientMetadata, "HTTP redirect URIs for native clients must use loopback IP"
|
|
333
|
+
end
|
|
334
|
+
else
|
|
335
|
+
validate_custom_scheme(uri)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def validate_custom_scheme(uri)
|
|
340
|
+
reversed_host = URI(@client_id).host.downcase.split('.').reverse.join('.')
|
|
341
|
+
|
|
342
|
+
unless uri.scheme.include?('.') && uri.scheme == reversed_host
|
|
343
|
+
raise InvalidClientMetadata, "custom scheme must match reversed client_id domain"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
unless uri.host.nil? && uri.path && uri.path.start_with?('/')
|
|
347
|
+
raise InvalidClientMetadata, "custom scheme redirect URI must use a single slash after the scheme"
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def validate_scope(scope)
|
|
352
|
+
raise InvalidClientMetadata, "scope is required" unless scope
|
|
353
|
+
raise InvalidClientMetadata, "scope must be a string" unless scope.is_a?(String)
|
|
354
|
+
raise InvalidClientMetadata, "scope must contain valid tokens separated by single spaces" unless SCOPE_PATTERN.match?(scope)
|
|
355
|
+
|
|
356
|
+
scope_values = scope.split(' ')
|
|
357
|
+
raise InvalidClientMetadata, "atproto scope is required" unless scope_values.include?("atproto")
|
|
358
|
+
|
|
359
|
+
scope
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def validate_dpop(metadata)
|
|
363
|
+
unless metadata["dpop_bound_access_tokens"] == true
|
|
364
|
+
raise InvalidClientMetadata, "dpop_bound_access_tokens must be true"
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# -- Optional field validations --
|
|
370
|
+
|
|
371
|
+
def validate_application_type(type)
|
|
372
|
+
if ApplicationType::ALL.include?(type)
|
|
373
|
+
type
|
|
374
|
+
elsif type.nil?
|
|
375
|
+
ApplicationType::WEB
|
|
376
|
+
else
|
|
377
|
+
raise InvalidClientMetadata, "application_type must be 'web' or 'native'"
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def validate_client_name(name)
|
|
382
|
+
return if name.nil?
|
|
383
|
+
raise InvalidClientMetadata, "client_name must be a string, if provided" unless name.is_a?(String)
|
|
384
|
+
|
|
385
|
+
name
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def validate_client_uri(uri)
|
|
389
|
+
return if uri.nil?
|
|
390
|
+
raise InvalidClientMetadata, "client_uri must be a string" unless uri.is_a?(String)
|
|
391
|
+
|
|
392
|
+
parsed = URI(uri)
|
|
393
|
+
|
|
394
|
+
unless %w[http https].include?(parsed.scheme)
|
|
395
|
+
raise InvalidClientMetadata, "client_uri must use HTTP or HTTPS"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
unless URI(uri).origin == URI(@client_id).origin
|
|
399
|
+
raise InvalidClientMetadata, "client_uri must have the same hostname as client_id"
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
uri
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def validate_https_uri(uri)
|
|
406
|
+
return if uri.nil?
|
|
407
|
+
|
|
408
|
+
raise InvalidClientMetadata, "URI must be a string" unless uri.is_a?(String)
|
|
409
|
+
|
|
410
|
+
parsed = URI(uri)
|
|
411
|
+
raise InvalidClientMetadata, "URI must use HTTPS" unless parsed.scheme == "https" && !parsed.host.to_s.empty?
|
|
412
|
+
|
|
413
|
+
uri
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def validate_jwks(jwks)
|
|
417
|
+
raise InvalidClientMetadata, "jwks must be a hash" unless jwks.is_a?(Hash)
|
|
418
|
+
raise InvalidClientMetadata, "jwks must have a 'keys' array" unless jwks['keys'].is_a?(Array)
|
|
419
|
+
|
|
420
|
+
jwks['keys'].each_with_index do |key, index|
|
|
421
|
+
raise InvalidClientMetadata, "jwks.keys.#{index} must be a hash" unless key.is_a?(Hash)
|
|
422
|
+
|
|
423
|
+
if key.key?('key_ops') && !key['key_ops'].is_a?(Array)
|
|
424
|
+
raise InvalidClientMetadata, "jwks.keys.#{index}.key_ops must be an array"
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
has_key_use_sig = (key['use'] == 'sig')
|
|
428
|
+
has_key_ops_sign = (key['key_ops']&.include?('sign'))
|
|
429
|
+
|
|
430
|
+
if !has_key_use_sig && !has_key_ops_sign
|
|
431
|
+
raise InvalidClientMetadata, "jwks.keys.#{index} must have use='sig' or key_ops including 'sign'"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
raise InvalidClientMetadata, "jwks.keys.#{index} must have a 'kid' field" unless key['kid']
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# -- Auth fields validations --
|
|
440
|
+
|
|
441
|
+
def validate_auth_methods(metadata)
|
|
442
|
+
@token_endpoint_auth_method = metadata["token_endpoint_auth_method"]
|
|
443
|
+
@token_endpoint_auth_signing_alg = metadata["token_endpoint_auth_signing_alg"]
|
|
444
|
+
|
|
445
|
+
raise InvalidClientMetadata, "token_endpoint_auth_method is required" if @token_endpoint_auth_method.nil?
|
|
446
|
+
|
|
447
|
+
case @token_endpoint_auth_method
|
|
448
|
+
when AUTH_METHOD_NONE
|
|
449
|
+
validate_auth_public(metadata)
|
|
450
|
+
when AUTH_METHOD_PRIVATE_KEY_JWT
|
|
451
|
+
validate_auth_confidential(metadata)
|
|
452
|
+
else
|
|
453
|
+
raise InvalidClientMetadata, "unsupported token_endpoint_auth_method"
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def validate_auth_public(metadata)
|
|
458
|
+
unless @token_endpoint_auth_signing_alg.nil?
|
|
459
|
+
raise InvalidClientMetadata, "token_endpoint_auth_signing_alg must be unset for public clients"
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def validate_auth_confidential(metadata)
|
|
464
|
+
unless @token_endpoint_auth_signing_alg == "ES256"
|
|
465
|
+
raise InvalidClientMetadata, "token_endpoint_auth_signing_alg must be ES256"
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
@jwks = metadata["jwks"]
|
|
469
|
+
@jwks_uri = metadata["jwks_uri"]
|
|
470
|
+
|
|
471
|
+
raise InvalidClientMetadata, "cannot use both jwks and jwks_uri" if @jwks && @jwks_uri
|
|
472
|
+
raise InvalidClientMetadata, "confidential clients must provide jwks or jwks_uri" unless @jwks || @jwks_uri
|
|
473
|
+
|
|
474
|
+
validate_jwks(@jwks) if @jwks
|
|
475
|
+
validate_https_uri(@jwks_uri) if @jwks_uri
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
require_relative 'storage/interface'
|
|
7
|
+
|
|
8
|
+
module AuthProto
|
|
9
|
+
class << self
|
|
10
|
+
|
|
11
|
+
# Global settings object, replaced without validation.
|
|
12
|
+
#
|
|
13
|
+
# @param value [Configuration] replacement global settings
|
|
14
|
+
# @return [Configuration]
|
|
15
|
+
#
|
|
16
|
+
attr_writer :configuration
|
|
17
|
+
|
|
18
|
+
# Returns the process-wide configuration, creating defaults on first access.
|
|
19
|
+
#
|
|
20
|
+
# The default storage is in memory; `http_client` is initially `nil` and must be
|
|
21
|
+
# configured before discovery or OAuth requests.
|
|
22
|
+
#
|
|
23
|
+
# @return [Configuration] current global settings
|
|
24
|
+
#
|
|
25
|
+
def configuration
|
|
26
|
+
@configuration ||= Configuration.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Yields the global configuration, validates it, and returns it.
|
|
30
|
+
#
|
|
31
|
+
# Changes remain on the global object even if validation fails.
|
|
32
|
+
#
|
|
33
|
+
# @yield [config] block that changes settings
|
|
34
|
+
# @yieldparam config [Configuration] mutable global settings
|
|
35
|
+
# @yieldreturn [Object] ignored
|
|
36
|
+
# @return [Configuration] validated global settings
|
|
37
|
+
# @raise [ConfigurationError] if the storage or HTTP client is invalid
|
|
38
|
+
# @example Configure the default transport
|
|
39
|
+
# AuthProto.configure do |config|
|
|
40
|
+
# config.http_client = AuthProto::HTTPClient.new
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
def configure
|
|
44
|
+
yield(configuration)
|
|
45
|
+
configuration.validate
|
|
46
|
+
configuration
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Gets the configured storage backend
|
|
50
|
+
# @return [Storage::Interface] The configured storage implementation
|
|
51
|
+
def storage
|
|
52
|
+
configuration.storage
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Replaces the global configuration with a fresh set of defaults.
|
|
56
|
+
# Existing client instances and their dependencies are not rebuilt.
|
|
57
|
+
#
|
|
58
|
+
# @return [Configuration] new global settings
|
|
59
|
+
#
|
|
60
|
+
def reset_configuration
|
|
61
|
+
@configuration = Configuration.new
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Raised when global storage or HTTP transport settings are invalid.
|
|
66
|
+
class ConfigurationError < Error; end
|
|
67
|
+
|
|
68
|
+
#
|
|
69
|
+
# Configuration class for global AuthProto settings
|
|
70
|
+
#
|
|
71
|
+
|
|
72
|
+
class Configuration
|
|
73
|
+
|
|
74
|
+
# Configured default token lifetime in seconds (300); currently unused by token exchange.
|
|
75
|
+
# @return [Integer]
|
|
76
|
+
attr_accessor :default_token_lifetime
|
|
77
|
+
|
|
78
|
+
# Configured default nonce lifetime in seconds (300); nonce managers currently use their own TTL.
|
|
79
|
+
# @return [Integer]
|
|
80
|
+
attr_accessor :dpop_nonce_lifetime
|
|
81
|
+
|
|
82
|
+
# Reserved encryption setting; serializers currently create their own service.
|
|
83
|
+
# @return [Object, nil]
|
|
84
|
+
attr_accessor :encryption
|
|
85
|
+
|
|
86
|
+
# Transport used for discovery, PAR and token requests; initially `nil`.
|
|
87
|
+
# @return [#get, #post, nil]
|
|
88
|
+
attr_accessor :http_client
|
|
89
|
+
|
|
90
|
+
# Logger used for diagnostics; defaults to standard output.
|
|
91
|
+
# @return [Logger]
|
|
92
|
+
attr_accessor :logger
|
|
93
|
+
|
|
94
|
+
# Storage backend; defaults to a new {Storage::Memory}.
|
|
95
|
+
# @return [Storage::Interface]
|
|
96
|
+
attr_accessor :storage
|
|
97
|
+
|
|
98
|
+
# Creates default settings with in-memory storage and no HTTP transport.
|
|
99
|
+
#
|
|
100
|
+
def initialize
|
|
101
|
+
@default_token_lifetime = 300 # 5 minutes in seconds
|
|
102
|
+
@dpop_nonce_lifetime = 300 # 5 minutes in seconds
|
|
103
|
+
@encryption = nil
|
|
104
|
+
@http_client = nil
|
|
105
|
+
@logger = Logger.new($stdout)
|
|
106
|
+
@storage = AuthProto::Storage::Memory.new
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Checks that storage implements the interface and the transport supports GET/POST.
|
|
110
|
+
#
|
|
111
|
+
# A `nil` HTTP client is accepted, but cannot perform OAuth or discovery requests.
|
|
112
|
+
#
|
|
113
|
+
# @return [true] if validation succeeds
|
|
114
|
+
# @raise [ConfigurationError] if storage is absent/invalid or transport methods are missing
|
|
115
|
+
#
|
|
116
|
+
def validate
|
|
117
|
+
validate_storage
|
|
118
|
+
validate_http_client
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def validate_storage
|
|
126
|
+
raise ConfigurationError, "Storage must be configured" if @storage.nil?
|
|
127
|
+
return if @storage.is_a?(AuthProto::Storage::Interface)
|
|
128
|
+
|
|
129
|
+
raise ConfigurationError, "Storage must implement Storage::Interface"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def validate_http_client
|
|
133
|
+
return if @http_client.nil? # Allow nil for testing
|
|
134
|
+
return if @http_client.respond_to?(:get) && @http_client.respond_to?(:post)
|
|
135
|
+
|
|
136
|
+
raise ConfigurationError, "HTTP client must implement get and post methods"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|