authproto 0.3.0.beta1 → 0.3.0.beta2
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/.yardopts +1 -0
- data/AGENTS.md +80 -0
- data/CHANGELOG.md +48 -7
- data/README.md +20 -0
- data/examples/confidential_client/.gitignore +2 -1
- data/examples/confidential_client/Gemfile.lock +5 -1
- data/examples/confidential_client/README.md +2 -2
- data/examples/confidential_client/app.rb +17 -17
- data/examples/confidential_client/config/{client-metadata.example.json → oauth-client-metadata.example.json} +0 -1
- data/examples/confidential_client/scripts/generate_keys.rb +15 -9
- data/examples/minisky_console/Gemfile.lock +2 -2
- data/examples/minisky_console/localhost.rb +8 -25
- data/examples/minisky_console/production.rb +1 -1
- data/lib/authproto/client.rb +150 -201
- data/lib/authproto/dpop/client.rb +12 -60
- data/lib/authproto/dpop/key_manager.rb +17 -17
- data/lib/authproto/dpop/nonce_manager.rb +5 -19
- data/lib/authproto/dpop/proof_generator.rb +27 -23
- data/lib/authproto/errors.rb +1 -1
- data/lib/authproto/http_client.rb +17 -31
- data/lib/authproto/localhost_server.rb +15 -6
- data/lib/authproto/metadata/authorization_server.rb +358 -0
- data/lib/authproto/metadata/client_metadata.rb +574 -0
- data/lib/authproto/metadata/resource_server.rb +137 -0
- data/lib/authproto/{server_metadata.rb → metadata.rb} +7 -7
- data/lib/authproto/minisky.rb +8 -10
- data/lib/authproto/par/client.rb +51 -55
- data/lib/authproto/par/client_assertion.rb +58 -15
- data/lib/authproto/par/request.rb +58 -140
- data/lib/authproto/par/response.rb +9 -10
- data/lib/authproto/par.rb +3 -5
- data/lib/authproto/pkce.rb +29 -106
- data/lib/authproto/serialization/session.rb +11 -11
- data/lib/authproto/serialization/token_set.rb +6 -6
- data/lib/authproto/state/session.rb +27 -31
- data/lib/authproto/state/session_manager.rb +2 -2
- data/lib/authproto/state/token_set.rb +4 -4
- data/lib/authproto/state.rb +3 -3
- data/lib/authproto/token/refresh.rb +35 -43
- data/lib/authproto/validated_uri.rb +57 -0
- data/lib/authproto/version.rb +1 -1
- data/lib/authproto.rb +1 -0
- data/misc/yard_attributes.rb +34 -0
- data/sig/authproto/client.rbs +9 -10
- data/sig/authproto/dpop/client.rbs +1 -8
- data/sig/authproto/dpop/nonce_manager.rbs +0 -1
- data/sig/authproto/dpop/proof_generator.rbs +5 -5
- data/sig/authproto/http_client.rbs +2 -2
- data/sig/authproto/{server_metadata → metadata}/authorization_server.rbs +18 -12
- data/sig/authproto/metadata/client_metadata.rbs +118 -0
- data/sig/authproto/metadata/resource_server.rbs +27 -0
- data/sig/authproto/minisky_oauth.rbs +5 -3
- data/sig/authproto/par/client.rbs +4 -6
- data/sig/authproto/par/client_assertion.rbs +2 -1
- data/sig/authproto/par/request.rbs +5 -19
- data/sig/authproto/par/response.rbs +1 -5
- data/sig/authproto/pkce.rbs +6 -16
- data/sig/authproto/serialization/session.rbs +2 -2
- data/sig/authproto/state/session.rbs +4 -7
- data/sig/authproto/state/session_manager.rbs +2 -2
- data/sig/authproto/token/refresh.rbs +4 -4
- data/sig/authproto/validated_uri.rbs +36 -0
- data/sig/authproto.rbs +5 -3
- metadata +13 -11
- data/lib/authproto/client_metadata.rb +0 -478
- data/lib/authproto/server_metadata/authorization_server.rb +0 -267
- data/lib/authproto/server_metadata/origin_url.rb +0 -71
- data/lib/authproto/server_metadata/resource_server.rb +0 -98
- data/sig/authproto/client_metadata.rbs +0 -113
- data/sig/authproto/server_metadata/origin_url.rbs +0 -21
- data/sig/authproto/server_metadata/resource_server.rbs +0 -27
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
require_relative '../configuration'
|
|
7
|
+
require_relative '../errors'
|
|
8
|
+
require_relative '../http_client'
|
|
9
|
+
require_relative '../metadata'
|
|
10
|
+
require_relative '../validated_uri'
|
|
11
|
+
|
|
12
|
+
module AuthProto
|
|
13
|
+
module Metadata
|
|
14
|
+
|
|
15
|
+
#
|
|
16
|
+
# Handles fetching and validation of AT Protocol OAuth Authorization Server metadata.
|
|
17
|
+
# An Authorization Server in atproto can be either a PDS instance or a separate "entryway" server
|
|
18
|
+
# that handles authentication for multiple PDS instances.
|
|
19
|
+
#
|
|
20
|
+
# The Authorization Server metadata is fetched from the well-known endpoint
|
|
21
|
+
# /.well-known/oauth-authorization-server and must conform to RFC 8414 plus additional
|
|
22
|
+
# requirements specific to the AT Protocol OAuth profile.
|
|
23
|
+
#
|
|
24
|
+
# @example Fetching and validating Authorization Server metadata
|
|
25
|
+
# begin
|
|
26
|
+
# auth_server = AuthProto::Metadata::AuthorizationServer.from_url("https://auth.example.com")
|
|
27
|
+
# puts "Authorization endpoint: #{auth_server.authorization_endpoint}"
|
|
28
|
+
# puts "Supported scopes: #{auth_server.scopes_supported}"
|
|
29
|
+
# rescue AuthProto::InvalidAuthorizationServer => e
|
|
30
|
+
# puts "Failed to validate authorization server: #{e.message}"
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# @see https://atproto.com/specs/oauth#authorization-servers Documentation of Authorization Server requirements
|
|
34
|
+
#
|
|
35
|
+
|
|
36
|
+
class AuthorizationServer
|
|
37
|
+
|
|
38
|
+
# Metadata keys that must be present before capability validation.
|
|
39
|
+
# @return [Array<String>]
|
|
40
|
+
REQUIRED_FIELDS = %w[
|
|
41
|
+
issuer
|
|
42
|
+
authorization_endpoint
|
|
43
|
+
token_endpoint
|
|
44
|
+
response_types_supported
|
|
45
|
+
grant_types_supported
|
|
46
|
+
code_challenge_methods_supported
|
|
47
|
+
token_endpoint_auth_methods_supported
|
|
48
|
+
token_endpoint_auth_signing_alg_values_supported
|
|
49
|
+
scopes_supported
|
|
50
|
+
authorization_response_iss_parameter_supported
|
|
51
|
+
require_pushed_authorization_requests
|
|
52
|
+
pushed_authorization_request_endpoint
|
|
53
|
+
dpop_signing_alg_values_supported
|
|
54
|
+
client_id_metadata_document_supported
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
# Authorization server issuer origin.
|
|
58
|
+
# @return [String]
|
|
59
|
+
attr_reader :issuer
|
|
60
|
+
|
|
61
|
+
# HTTPS browser authorization endpoint.
|
|
62
|
+
# @return [String]
|
|
63
|
+
attr_reader :authorization_endpoint
|
|
64
|
+
|
|
65
|
+
# HTTPS token exchange and refresh endpoint.
|
|
66
|
+
# @return [String]
|
|
67
|
+
attr_reader :token_endpoint
|
|
68
|
+
|
|
69
|
+
# HTTPS PAR endpoint.
|
|
70
|
+
# @return [String]
|
|
71
|
+
attr_reader :pushed_authorization_request_endpoint
|
|
72
|
+
|
|
73
|
+
# Supported response types, including `code`.
|
|
74
|
+
# @return [Array<String>]
|
|
75
|
+
attr_reader :response_types_supported
|
|
76
|
+
|
|
77
|
+
# Supported grant types, including authorization code and refresh token.
|
|
78
|
+
# @return [Array<String>]
|
|
79
|
+
attr_reader :grant_types_supported
|
|
80
|
+
|
|
81
|
+
# Supported PKCE methods, including `S256`.
|
|
82
|
+
# @return [Array<String>]
|
|
83
|
+
attr_reader :code_challenge_methods_supported
|
|
84
|
+
|
|
85
|
+
# Supported client authentication methods.
|
|
86
|
+
# @return [Array<String>]
|
|
87
|
+
attr_reader :token_endpoint_auth_methods_supported
|
|
88
|
+
|
|
89
|
+
# Supported client assertion algorithms.
|
|
90
|
+
# @return [Array<String>]
|
|
91
|
+
attr_reader :token_endpoint_auth_signing_alg_values_supported
|
|
92
|
+
|
|
93
|
+
# Supported scopes, including `atproto`.
|
|
94
|
+
# @return [Array<String>]
|
|
95
|
+
attr_reader :scopes_supported
|
|
96
|
+
|
|
97
|
+
# Supported DPoP algorithms, including `ES256`.
|
|
98
|
+
# @return [Array<String>]
|
|
99
|
+
attr_reader :dpop_signing_alg_values_supported
|
|
100
|
+
|
|
101
|
+
# Validates an authorization server's OAuth metadata.
|
|
102
|
+
#
|
|
103
|
+
# @param metadata [Hash{String => Object}] parsed discovery document with the fields in {REQUIRED_FIELDS} and required Boolean capabilities
|
|
104
|
+
# @raise [InvalidAuthorizationServer] if required capabilities, issuer or endpoint schemes are invalid
|
|
105
|
+
# @raise [URI::InvalidURIError] if a metadata URL cannot be parsed
|
|
106
|
+
#
|
|
107
|
+
def initialize(metadata)
|
|
108
|
+
validate_and_set_metadata(metadata)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Fetches `/.well-known/oauth-authorization-server` using the configured transport.
|
|
112
|
+
# Requires HTTP 200 without redirects and a JSON object served as application/json.
|
|
113
|
+
#
|
|
114
|
+
# The document's issuer must exactly match the requested url.
|
|
115
|
+
#
|
|
116
|
+
# @param url [String] authorization server issuer origin
|
|
117
|
+
# @return [AuthorizationServer] validated discovery metadata
|
|
118
|
+
# @raise [InvalidAuthorizationServer] if HTTP transport fails, JSON is invalid, or metadata/issuer validation fails
|
|
119
|
+
# @raise [HTTPClient::SSRFError] if the transport rejects the initial destination
|
|
120
|
+
# @raise [URI::InvalidURIError] if the issuer or a metadata URI cannot be parsed
|
|
121
|
+
#
|
|
122
|
+
def self.from_url(url)
|
|
123
|
+
validate_source_url(url)
|
|
124
|
+
|
|
125
|
+
response = fetch_metadata(url)
|
|
126
|
+
validate_response(response)
|
|
127
|
+
|
|
128
|
+
metadata = parse_metadata(response[:body])
|
|
129
|
+
validate_issuer_matches(metadata['issuer'], url)
|
|
130
|
+
|
|
131
|
+
new(metadata)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Exports metadata for serialization using symbol keys.
|
|
135
|
+
#
|
|
136
|
+
# Includes issuer/endpoints (String), capability lists (Array<String>), and the
|
|
137
|
+
# three required Boolean flags set to `true`. Use JSON serialization to obtain
|
|
138
|
+
# string keys suitable for passing back to {#initialize}.
|
|
139
|
+
#
|
|
140
|
+
# @return [Hash{Symbol => String, Array<String>, Boolean}] authorization server metadata
|
|
141
|
+
#
|
|
142
|
+
def to_h
|
|
143
|
+
{
|
|
144
|
+
issuer: issuer,
|
|
145
|
+
authorization_endpoint: authorization_endpoint,
|
|
146
|
+
token_endpoint: token_endpoint,
|
|
147
|
+
pushed_authorization_request_endpoint: pushed_authorization_request_endpoint,
|
|
148
|
+
response_types_supported: response_types_supported,
|
|
149
|
+
grant_types_supported: grant_types_supported,
|
|
150
|
+
code_challenge_methods_supported: code_challenge_methods_supported,
|
|
151
|
+
token_endpoint_auth_methods_supported: token_endpoint_auth_methods_supported,
|
|
152
|
+
token_endpoint_auth_signing_alg_values_supported: token_endpoint_auth_signing_alg_values_supported,
|
|
153
|
+
scopes_supported: scopes_supported,
|
|
154
|
+
dpop_signing_alg_values_supported: dpop_signing_alg_values_supported,
|
|
155
|
+
authorization_response_iss_parameter_supported: true,
|
|
156
|
+
require_pushed_authorization_requests: true,
|
|
157
|
+
client_id_metadata_document_supported: true
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def validate_and_set_metadata(data)
|
|
165
|
+
REQUIRED_FIELDS.each do |field|
|
|
166
|
+
raise InvalidAuthorizationServer, "#{field} is required" unless data[field]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
@issuer = validate_issuer(data['issuer'])
|
|
170
|
+
|
|
171
|
+
# Endpoint strings
|
|
172
|
+
@authorization_endpoint = validate_https_url(data['authorization_endpoint'])
|
|
173
|
+
@token_endpoint = validate_https_url(data['token_endpoint'])
|
|
174
|
+
@pushed_authorization_request_endpoint = validate_https_url(data['pushed_authorization_request_endpoint'])
|
|
175
|
+
|
|
176
|
+
# Capability lists
|
|
177
|
+
@response_types_supported = data['response_types_supported']
|
|
178
|
+
@grant_types_supported = data['grant_types_supported']
|
|
179
|
+
@code_challenge_methods_supported = data['code_challenge_methods_supported']
|
|
180
|
+
@token_endpoint_auth_methods_supported = data['token_endpoint_auth_methods_supported']
|
|
181
|
+
@token_endpoint_auth_signing_alg_values_supported = data['token_endpoint_auth_signing_alg_values_supported']
|
|
182
|
+
@scopes_supported = data['scopes_supported']
|
|
183
|
+
@dpop_signing_alg_values_supported = data['dpop_signing_alg_values_supported']
|
|
184
|
+
|
|
185
|
+
validate_response_types
|
|
186
|
+
validate_grant_types
|
|
187
|
+
validate_code_challenge_methods
|
|
188
|
+
validate_token_endpoint_auth_methods
|
|
189
|
+
validate_token_endpoint_auth_signing_algs
|
|
190
|
+
validate_dpop_signing_algs
|
|
191
|
+
validate_scopes
|
|
192
|
+
|
|
193
|
+
# Required boolean fields
|
|
194
|
+
validate_boolean_field(data, 'authorization_response_iss_parameter_supported', true)
|
|
195
|
+
validate_boolean_field(data, 'require_pushed_authorization_requests', true)
|
|
196
|
+
validate_boolean_field(data, 'client_id_metadata_document_supported', true)
|
|
197
|
+
|
|
198
|
+
if data.key?('require_request_uri_registration')
|
|
199
|
+
validate_boolean_field(data, 'require_request_uri_registration', true)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def validate_issuer(issuer)
|
|
204
|
+
uri = ValidatedURI.new(issuer)
|
|
205
|
+
|
|
206
|
+
is_valid = (
|
|
207
|
+
uri.scheme == 'https' && (!uri.specified_port || uri.effective_port != 443) &&
|
|
208
|
+
uri.path.empty? && !uri.query && !uri.fragment && !uri.userinfo
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
raise InvalidAuthorizationServer, "invalid issuer URL format" unless is_valid
|
|
212
|
+
|
|
213
|
+
issuer
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def validate_https_url(url)
|
|
217
|
+
uri = ValidatedURI.new(url)
|
|
218
|
+
raise InvalidAuthorizationServer, "URL must use HTTPS" unless uri.scheme == 'https'
|
|
219
|
+
raise InvalidAuthorizationServer, "Endpoint URL must not contain a fragment" if uri.fragment
|
|
220
|
+
|
|
221
|
+
url
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def validate_response_types
|
|
225
|
+
validate_string_array(@response_types_supported, 'response_types_supported')
|
|
226
|
+
|
|
227
|
+
unless @response_types_supported.include?('code')
|
|
228
|
+
raise InvalidAuthorizationServer, "response_types_supported must include 'code'"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def validate_grant_types
|
|
233
|
+
validate_string_array(@grant_types_supported, 'grant_types_supported')
|
|
234
|
+
|
|
235
|
+
unless %w[authorization_code refresh_token] - @grant_types_supported == []
|
|
236
|
+
raise InvalidAuthorizationServer, "grant types should include 'authorization_code' and 'refresh_token'"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validate_code_challenge_methods
|
|
241
|
+
validate_string_array(@code_challenge_methods_supported, 'code_challenge_methods_supported')
|
|
242
|
+
|
|
243
|
+
unless @code_challenge_methods_supported.include?(PKCE::CHALLENGE_METHOD_S256)
|
|
244
|
+
raise InvalidAuthorizationServer, "code_challenge_methods_supported must include S256"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def validate_token_endpoint_auth_methods
|
|
249
|
+
validate_string_array(@token_endpoint_auth_methods_supported, 'token_endpoint_auth_methods_supported')
|
|
250
|
+
|
|
251
|
+
unless %w[private_key_jwt none] - @token_endpoint_auth_methods_supported == []
|
|
252
|
+
raise InvalidAuthorizationServer, "auth methods must include 'private_key_jwt' and 'none'"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def validate_token_endpoint_auth_signing_algs
|
|
257
|
+
validate_string_array(
|
|
258
|
+
@token_endpoint_auth_signing_alg_values_supported, 'token_endpoint_auth_signing_alg_values_supported'
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
unless @token_endpoint_auth_signing_alg_values_supported.include?('ES256')
|
|
262
|
+
raise InvalidAuthorizationServer, "token_endpoint_auth_signing_alg_values_supported must include ES256"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
if @token_endpoint_auth_signing_alg_values_supported.include?('none')
|
|
266
|
+
raise InvalidAuthorizationServer, "token_endpoint_auth_signing_alg_values_supported must not include 'none'"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def validate_dpop_signing_algs
|
|
271
|
+
validate_string_array(@dpop_signing_alg_values_supported, 'dpop_signing_alg_values_supported')
|
|
272
|
+
|
|
273
|
+
unless @dpop_signing_alg_values_supported.include?('ES256')
|
|
274
|
+
raise InvalidAuthorizationServer, "dpop_signing_alg_values_supported must include ES256"
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def validate_scopes
|
|
279
|
+
validate_string_array(@scopes_supported, 'scopes_supported')
|
|
280
|
+
|
|
281
|
+
unless @scopes_supported.include?('atproto')
|
|
282
|
+
raise InvalidAuthorizationServer, "scopes_supported must include 'atproto'"
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def validate_string_array(values, field)
|
|
287
|
+
unless values.is_a?(Array)
|
|
288
|
+
raise InvalidAuthorizationServer, "#{field} must be an array"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
unless values.all? { |value| value.is_a?(String) }
|
|
292
|
+
raise InvalidAuthorizationServer, "#{field} must contain only strings"
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def validate_boolean_field(metadata, field, required_value)
|
|
297
|
+
unless metadata[field] == required_value
|
|
298
|
+
raise InvalidAuthorizationServer, "#{field} must be #{required_value}"
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
class << self
|
|
303
|
+
private
|
|
304
|
+
|
|
305
|
+
def validate_source_url(url)
|
|
306
|
+
uri = ValidatedURI.new(url)
|
|
307
|
+
|
|
308
|
+
valid = (
|
|
309
|
+
uri.scheme == 'https' && (!uri.specified_port || uri.effective_port != 443) &&
|
|
310
|
+
uri.path.empty? && !uri.userinfo && !uri.query && !uri.fragment
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
raise InvalidAuthorizationServer, "Invalid authorization server URL" unless valid
|
|
314
|
+
rescue URI::InvalidURIError => e
|
|
315
|
+
raise InvalidAuthorizationServer, "Invalid authorization server URL: #{e.message}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def fetch_metadata(url)
|
|
319
|
+
metadata_url = URI.join(url, "/.well-known/oauth-authorization-server")
|
|
320
|
+
|
|
321
|
+
AuthProto.configuration.http_client.get(metadata_url.to_s)
|
|
322
|
+
rescue HTTPClient::HTTPError => e
|
|
323
|
+
raise InvalidAuthorizationServer, "Failed to fetch authorization server metadata: #{e.message}"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def validate_response(response)
|
|
327
|
+
unless response[:status] == 200
|
|
328
|
+
raise InvalidAuthorizationServer, "Authorization server metadata must return HTTP 200"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
content_type = response[:headers]["content-type"].to_s.split(";", 2).first.to_s.strip
|
|
332
|
+
|
|
333
|
+
unless content_type.casecmp?('application/json')
|
|
334
|
+
raise InvalidAuthorizationServer, "Authorization server metadata must use application/json"
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def parse_metadata(body)
|
|
339
|
+
metadata = JSON.parse(body.to_s)
|
|
340
|
+
|
|
341
|
+
unless metadata.is_a?(Hash)
|
|
342
|
+
raise InvalidAuthorizationServer, "Authorization server metadata must be a JSON object"
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
metadata
|
|
346
|
+
rescue JSON::ParserError => e
|
|
347
|
+
raise InvalidAuthorizationServer, "Invalid JSON in authorization server metadata: #{e.message}"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def validate_issuer_matches(metadata_issuer, source_url)
|
|
351
|
+
return if metadata_issuer == source_url
|
|
352
|
+
|
|
353
|
+
raise InvalidAuthorizationServer, "issuer mismatch: #{metadata_issuer} != #{source_url}"
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|