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,267 @@
|
|
|
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 '../server_metadata'
|
|
10
|
+
require_relative 'origin_url'
|
|
11
|
+
|
|
12
|
+
module AuthProto
|
|
13
|
+
module ServerMetadata
|
|
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::ServerMetadata::AuthorizationServer.from_issuer("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
|
+
dpop_signing_alg_values_supported
|
|
51
|
+
pushed_authorization_request_endpoint
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
# Authorization server issuer origin.
|
|
55
|
+
# @return [String]
|
|
56
|
+
attr_reader :issuer
|
|
57
|
+
|
|
58
|
+
# HTTPS browser authorization endpoint.
|
|
59
|
+
# @return [String]
|
|
60
|
+
attr_reader :authorization_endpoint
|
|
61
|
+
|
|
62
|
+
# HTTPS token exchange and refresh endpoint.
|
|
63
|
+
# @return [String]
|
|
64
|
+
attr_reader :token_endpoint
|
|
65
|
+
|
|
66
|
+
# HTTPS PAR endpoint.
|
|
67
|
+
# @return [String]
|
|
68
|
+
attr_reader :pushed_authorization_request_endpoint
|
|
69
|
+
|
|
70
|
+
# Supported response types, including `code`.
|
|
71
|
+
# @return [Array<String>]
|
|
72
|
+
attr_reader :response_types_supported
|
|
73
|
+
|
|
74
|
+
# Supported grant types, including authorization code and refresh token.
|
|
75
|
+
# @return [Array<String>]
|
|
76
|
+
attr_reader :grant_types_supported
|
|
77
|
+
|
|
78
|
+
# Supported PKCE methods, including `S256`.
|
|
79
|
+
# @return [Array<String>]
|
|
80
|
+
attr_reader :code_challenge_methods_supported
|
|
81
|
+
|
|
82
|
+
# Supported client authentication methods.
|
|
83
|
+
# @return [Array<String>]
|
|
84
|
+
attr_reader :token_endpoint_auth_methods_supported
|
|
85
|
+
|
|
86
|
+
# Supported client assertion algorithms.
|
|
87
|
+
# @return [Array<String>]
|
|
88
|
+
attr_reader :token_endpoint_auth_signing_alg_values_supported
|
|
89
|
+
|
|
90
|
+
# Supported scopes, including `atproto`.
|
|
91
|
+
# @return [Array<String>]
|
|
92
|
+
attr_reader :scopes_supported
|
|
93
|
+
|
|
94
|
+
# Supported DPoP algorithms, including `ES256`.
|
|
95
|
+
# @return [Array<String>]
|
|
96
|
+
attr_reader :dpop_signing_alg_values_supported
|
|
97
|
+
|
|
98
|
+
# Validates an authorization server's OAuth metadata.
|
|
99
|
+
#
|
|
100
|
+
# @param metadata [Hash{String => Object}] parsed discovery document with the fields in {REQUIRED_FIELDS} and required Boolean capabilities
|
|
101
|
+
# @raise [InvalidAuthorizationServer] if required capabilities, issuer or endpoint schemes are invalid
|
|
102
|
+
# @raise [URI::InvalidURIError] if a metadata URL cannot be parsed
|
|
103
|
+
#
|
|
104
|
+
def initialize(metadata)
|
|
105
|
+
validate_and_set_metadata(metadata)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Fetches `/.well-known/oauth-authorization-server` using the configured transport.
|
|
109
|
+
#
|
|
110
|
+
# The document's issuer must exactly match the requested issuer.
|
|
111
|
+
#
|
|
112
|
+
# @param issuer [String] authorization server issuer origin
|
|
113
|
+
# @return [AuthorizationServer] validated discovery metadata
|
|
114
|
+
# @raise [InvalidAuthorizationServer] if HTTP transport fails, JSON is invalid, or metadata/issuer validation fails
|
|
115
|
+
# @raise [HTTPClient::SSRFError] if the transport rejects the initial destination
|
|
116
|
+
# @raise [URI::InvalidURIError] if the issuer or a metadata URI cannot be parsed
|
|
117
|
+
#
|
|
118
|
+
def self.from_issuer(issuer)
|
|
119
|
+
response = fetch_metadata(issuer)
|
|
120
|
+
metadata = parse_metadata(response[:body])
|
|
121
|
+
validate_issuer(metadata["issuer"], issuer)
|
|
122
|
+
new(metadata)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Exports metadata for serialization using symbol keys.
|
|
126
|
+
#
|
|
127
|
+
# Includes issuer/endpoints (String), capability lists (Array<String>), and the
|
|
128
|
+
# three required Boolean flags set to `true`. Use JSON serialization to obtain
|
|
129
|
+
# string keys suitable for passing back to {#initialize}.
|
|
130
|
+
#
|
|
131
|
+
# @return [Hash{Symbol => String, Array<String>, Boolean}] authorization server metadata
|
|
132
|
+
#
|
|
133
|
+
def to_h
|
|
134
|
+
{
|
|
135
|
+
issuer: issuer,
|
|
136
|
+
authorization_endpoint: authorization_endpoint,
|
|
137
|
+
token_endpoint: token_endpoint,
|
|
138
|
+
pushed_authorization_request_endpoint: pushed_authorization_request_endpoint,
|
|
139
|
+
response_types_supported: response_types_supported,
|
|
140
|
+
grant_types_supported: grant_types_supported,
|
|
141
|
+
code_challenge_methods_supported: code_challenge_methods_supported,
|
|
142
|
+
token_endpoint_auth_methods_supported: token_endpoint_auth_methods_supported,
|
|
143
|
+
token_endpoint_auth_signing_alg_values_supported: token_endpoint_auth_signing_alg_values_supported,
|
|
144
|
+
scopes_supported: scopes_supported,
|
|
145
|
+
dpop_signing_alg_values_supported: dpop_signing_alg_values_supported,
|
|
146
|
+
authorization_response_iss_parameter_supported: true,
|
|
147
|
+
require_pushed_authorization_requests: true,
|
|
148
|
+
client_id_metadata_document_supported: true
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def validate_and_set_metadata(metadata)
|
|
155
|
+
REQUIRED_FIELDS.each do |field|
|
|
156
|
+
raise InvalidAuthorizationServer, "#{field} is required" unless metadata[field]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
@issuer = validate_issuer(metadata["issuer"])
|
|
160
|
+
@authorization_endpoint = validate_https_url(metadata["authorization_endpoint"])
|
|
161
|
+
@token_endpoint = validate_https_url(metadata["token_endpoint"])
|
|
162
|
+
@pushed_authorization_request_endpoint = validate_https_url(metadata["pushed_authorization_request_endpoint"])
|
|
163
|
+
|
|
164
|
+
validate_response_types(metadata["response_types_supported"])
|
|
165
|
+
validate_grant_types(metadata["grant_types_supported"])
|
|
166
|
+
validate_code_challenge_methods(metadata["code_challenge_methods_supported"])
|
|
167
|
+
validate_token_endpoint_auth_methods(metadata["token_endpoint_auth_methods_supported"])
|
|
168
|
+
validate_token_endpoint_auth_signing_algs(metadata["token_endpoint_auth_signing_alg_values_supported"])
|
|
169
|
+
validate_dpop_signing_algs(metadata["dpop_signing_alg_values_supported"])
|
|
170
|
+
validate_scopes(metadata["scopes_supported"])
|
|
171
|
+
|
|
172
|
+
# Store validated values
|
|
173
|
+
@response_types_supported = metadata["response_types_supported"]
|
|
174
|
+
@grant_types_supported = metadata["grant_types_supported"]
|
|
175
|
+
@code_challenge_methods_supported = metadata["code_challenge_methods_supported"]
|
|
176
|
+
@token_endpoint_auth_methods_supported = metadata["token_endpoint_auth_methods_supported"]
|
|
177
|
+
@token_endpoint_auth_signing_alg_values_supported = metadata["token_endpoint_auth_signing_alg_values_supported"]
|
|
178
|
+
@scopes_supported = metadata["scopes_supported"]
|
|
179
|
+
@dpop_signing_alg_values_supported = metadata["dpop_signing_alg_values_supported"]
|
|
180
|
+
|
|
181
|
+
# Required boolean fields
|
|
182
|
+
validate_boolean_field(metadata, "authorization_response_iss_parameter_supported", true)
|
|
183
|
+
validate_boolean_field(metadata, "require_pushed_authorization_requests", true)
|
|
184
|
+
validate_boolean_field(metadata, "client_id_metadata_document_supported", true)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def validate_issuer(issuer)
|
|
188
|
+
is_valid = OriginURL.new(issuer).valid?
|
|
189
|
+
raise InvalidAuthorizationServer, "invalid issuer URL format" unless is_valid
|
|
190
|
+
|
|
191
|
+
issuer
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def validate_https_url(url)
|
|
195
|
+
uri = URI(url)
|
|
196
|
+
raise InvalidAuthorizationServer, "URL must use HTTPS" unless uri.scheme == "https"
|
|
197
|
+
|
|
198
|
+
url
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def validate_response_types(types)
|
|
202
|
+
raise InvalidAuthorizationServer, "must support 'code' response type" unless types.include?("code")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def validate_grant_types(types)
|
|
206
|
+
required = %w[authorization_code refresh_token]
|
|
207
|
+
missing = required - types
|
|
208
|
+
raise InvalidAuthorizationServer, "missing grant types: #{missing.join(", ")}" if missing.any?
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def validate_code_challenge_methods(methods)
|
|
212
|
+
raise InvalidAuthorizationServer, "must support S256 PKCE" unless methods.include?("S256")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def validate_token_endpoint_auth_methods(methods)
|
|
216
|
+
required = %w[private_key_jwt none]
|
|
217
|
+
missing = required - methods
|
|
218
|
+
raise InvalidAuthorizationServer, "missing auth methods: #{missing.join(", ")}" if missing.any?
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def validate_token_endpoint_auth_signing_algs(algs)
|
|
222
|
+
raise InvalidAuthorizationServer, "must support ES256" unless algs.include?("ES256")
|
|
223
|
+
raise InvalidAuthorizationServer, "must not allow 'none'" if algs.include?("none")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def validate_dpop_signing_algs(algs)
|
|
227
|
+
raise InvalidAuthorizationServer, "must support ES256 for DPoP" unless algs.include?("ES256")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def validate_scopes(scopes)
|
|
231
|
+
required = %w[atproto]
|
|
232
|
+
missing = required - scopes
|
|
233
|
+
raise InvalidAuthorizationServer, "missing scopes: #{missing.join(", ")}" if missing.any?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def validate_boolean_field(metadata, field, required_value)
|
|
237
|
+
actual = metadata[field]
|
|
238
|
+
return if actual == required_value
|
|
239
|
+
|
|
240
|
+
raise InvalidAuthorizationServer, "#{field} must be #{required_value}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
class << self
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
def fetch_metadata(issuer)
|
|
247
|
+
metadata_url = URI.join(issuer, "/.well-known/oauth-authorization-server")
|
|
248
|
+
AuthProto.configuration.http_client.get(metadata_url.to_s)
|
|
249
|
+
rescue HTTPClient::HTTPError => e
|
|
250
|
+
raise InvalidAuthorizationServer, "Failed to fetch authorization server metadata: #{e.message}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def parse_metadata(body)
|
|
254
|
+
JSON.parse(body)
|
|
255
|
+
rescue JSON::ParserError => e
|
|
256
|
+
raise InvalidAuthorizationServer, "Invalid JSON in authorization server metadata: #{e.message}"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def validate_issuer(metadata_issuer, request_issuer)
|
|
260
|
+
return if metadata_issuer == request_issuer
|
|
261
|
+
|
|
262
|
+
raise InvalidAuthorizationServer, "issuer mismatch: #{metadata_issuer} != #{request_issuer}"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
require_relative '../server_metadata'
|
|
6
|
+
|
|
7
|
+
module AuthProto
|
|
8
|
+
module ServerMetadata
|
|
9
|
+
|
|
10
|
+
#
|
|
11
|
+
# The `OriginURL` class provides validation logic for URLs that must conform
|
|
12
|
+
# to the AT Protocol OAuth "simple origin URL" requirements. These requirements
|
|
13
|
+
# are common between Resource and Authorization Servers and ensure that the URL
|
|
14
|
+
# is valid and secure for use in the protocol. This class validates that the URL:
|
|
15
|
+
# - Uses the HTTPS scheme.
|
|
16
|
+
# - Points to the root path (either an empty path or "/").
|
|
17
|
+
# - Does not include a query string or fragment.
|
|
18
|
+
# - Does not include user or password credentials.
|
|
19
|
+
# - May include a non-default port but disallows the default HTTPS port (443).
|
|
20
|
+
#
|
|
21
|
+
# This model centralizes the URL validation logic to promote reusability and
|
|
22
|
+
# consistency between different server classes.
|
|
23
|
+
#
|
|
24
|
+
|
|
25
|
+
class OriginURL
|
|
26
|
+
|
|
27
|
+
# Original URL string supplied by the caller.
|
|
28
|
+
# @return [String]
|
|
29
|
+
attr_reader :url
|
|
30
|
+
|
|
31
|
+
# Parsed URI used for origin validation.
|
|
32
|
+
# @return [URI::Generic]
|
|
33
|
+
attr_reader :uri
|
|
34
|
+
|
|
35
|
+
# Parses a candidate server origin; call {#valid?} to check origin restrictions.
|
|
36
|
+
#
|
|
37
|
+
# @param url [String] origin URL to parse
|
|
38
|
+
# @raise [URI::InvalidURIError] if the input cannot be parsed as a URI
|
|
39
|
+
#
|
|
40
|
+
def initialize(url)
|
|
41
|
+
@url = url
|
|
42
|
+
@uri = URI(url)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Determines if a URL conforms to AT Protocol OAuth "simple origin URL" requirements
|
|
46
|
+
# @return [Boolean] true if the URL is a valid origin URL
|
|
47
|
+
def valid?
|
|
48
|
+
https_scheme? &&
|
|
49
|
+
root_path? &&
|
|
50
|
+
!uri.query &&
|
|
51
|
+
!uri.fragment &&
|
|
52
|
+
!uri.userinfo &&
|
|
53
|
+
(!explicit_port? || uri.port != 443)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def https_scheme?
|
|
59
|
+
uri.scheme == "https"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def root_path?
|
|
63
|
+
uri.path.empty? || uri.path == "/"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def explicit_port?
|
|
67
|
+
url.match?(/:\d+/)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
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 '../server_metadata'
|
|
10
|
+
require_relative 'origin_url'
|
|
11
|
+
|
|
12
|
+
module AuthProto
|
|
13
|
+
module ServerMetadata
|
|
14
|
+
|
|
15
|
+
#
|
|
16
|
+
# This class represents a Resource Server (PDS) and is responsible for
|
|
17
|
+
# validating and managing its metadata. It ensures that the authorization
|
|
18
|
+
# server URLs provided are valid and compliant with expected standards.
|
|
19
|
+
# The class also includes functionality to fetch and parse metadata from a
|
|
20
|
+
# remote URL, raising specific errors for invalid or malformed metadata.
|
|
21
|
+
#
|
|
22
|
+
|
|
23
|
+
class ResourceServer
|
|
24
|
+
|
|
25
|
+
# Authorization server origins; validation requires exactly one entry.
|
|
26
|
+
# @return [Array<String>]
|
|
27
|
+
attr_reader :authorization_servers
|
|
28
|
+
|
|
29
|
+
# Validates resource server metadata and its single authorization server origin.
|
|
30
|
+
#
|
|
31
|
+
# @param metadata [Hash{String => Object}] discovery document containing `authorization_servers`
|
|
32
|
+
# @raise [InvalidAuthorizationServer] if the list is missing or does not contain one valid origin
|
|
33
|
+
# @raise [URI::InvalidURIError] if the authorization server URI cannot be parsed
|
|
34
|
+
#
|
|
35
|
+
def initialize(metadata)
|
|
36
|
+
@authorization_servers = validate_authorization_servers(metadata["authorization_servers"])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Fetches `/.well-known/oauth-protected-resource` using the configured transport.
|
|
40
|
+
#
|
|
41
|
+
# @param url [String] resource server/PDS base URL
|
|
42
|
+
# @return [ResourceServer] validated resource server metadata
|
|
43
|
+
# @raise [InvalidAuthorizationServer] if HTTP transport, JSON parsing or metadata validation fails
|
|
44
|
+
# @raise [HTTPClient::SSRFError] if the transport rejects the initial destination
|
|
45
|
+
# @raise [URI::InvalidURIError] if the PDS or authorization server URL cannot be parsed
|
|
46
|
+
#
|
|
47
|
+
def self.from_url(url)
|
|
48
|
+
response = fetch_metadata(url)
|
|
49
|
+
new(parse_metadata(response[:body]))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def validate_authorization_servers(servers)
|
|
56
|
+
ensure_servers_exist(servers)
|
|
57
|
+
ensure_exactly_one_server(servers)
|
|
58
|
+
validate_server_url_format(servers.first)
|
|
59
|
+
servers
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def ensure_servers_exist(servers)
|
|
63
|
+
return if servers.is_a?(Array)
|
|
64
|
+
|
|
65
|
+
raise InvalidAuthorizationServer, "authorization_servers missing"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def ensure_exactly_one_server(servers)
|
|
69
|
+
return if servers.size == 1
|
|
70
|
+
|
|
71
|
+
raise InvalidAuthorizationServer, "must have exactly one authorization server"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_server_url_format(server_url)
|
|
75
|
+
return if OriginURL.new(server_url).valid?
|
|
76
|
+
|
|
77
|
+
raise InvalidAuthorizationServer, "invalid authorization server URL format for #{server_url}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class << self
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def fetch_metadata(url)
|
|
84
|
+
metadata_url = URI.join(url, "/.well-known/oauth-protected-resource")
|
|
85
|
+
AuthProto.configuration.http_client.get(metadata_url.to_s)
|
|
86
|
+
rescue HTTPClient::HTTPError => e
|
|
87
|
+
raise InvalidAuthorizationServer, "Failed to fetch resource server metadata: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse_metadata(body)
|
|
91
|
+
JSON.parse(body)
|
|
92
|
+
rescue JSON::ParserError => e
|
|
93
|
+
raise InvalidAuthorizationServer, "Invalid JSON in resource server metadata: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuthProto
|
|
4
|
+
|
|
5
|
+
#
|
|
6
|
+
# Provides functionality for fetching and validating AT Protocol OAuth server metadata
|
|
7
|
+
# from both Resource Servers (PDS instances) and Authorization Servers (PDS/entryway).
|
|
8
|
+
#
|
|
9
|
+
# The flow for resolving an account's authorization server is:
|
|
10
|
+
# 1. Start with PDS URL
|
|
11
|
+
# 2. Fetch Resource Server metadata from /.well-known/oauth-protected-resource
|
|
12
|
+
# 3. Get Authorization Server URL from authorization_servers array
|
|
13
|
+
# 4. Fetch Authorization Server metadata from /.well-known/oauth-authorization-server
|
|
14
|
+
#
|
|
15
|
+
# @example Resolving authorization server from PDS URL
|
|
16
|
+
# resource_server = AuthProto::ServerMetadata::ResourceServer.from_url("https://pds.example.com")
|
|
17
|
+
# auth_server_url = resource_server.authorization_servers.first
|
|
18
|
+
# auth_server = AuthProto::ServerMetadata::AuthorizationServer.from_issuer(auth_server_url)
|
|
19
|
+
#
|
|
20
|
+
# The module includes three main classes:
|
|
21
|
+
# - {ResourceServer} - Handles PDS metadata validation and authorization server discovery
|
|
22
|
+
# - {AuthorizationServer} - Handles authorization server metadata validation
|
|
23
|
+
# - {OriginURL} - Validates URLs conform to AT Protocol's "simple origin URL" requirements
|
|
24
|
+
#
|
|
25
|
+
|
|
26
|
+
module ServerMetadata
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'monitor'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
require_relative '../pkce'
|
|
7
|
+
require_relative '../state'
|
|
8
|
+
|
|
9
|
+
module AuthProto
|
|
10
|
+
module State
|
|
11
|
+
|
|
12
|
+
#
|
|
13
|
+
# Tracks state for an OAuth authorization flow session
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
class Session
|
|
17
|
+
include MonitorMixin
|
|
18
|
+
|
|
19
|
+
# Unique identifier used to retrieve this session.
|
|
20
|
+
# @return [String]
|
|
21
|
+
attr_reader :session_id
|
|
22
|
+
|
|
23
|
+
# Random OAuth state token for callback validation.
|
|
24
|
+
# @return [String]
|
|
25
|
+
attr_reader :state_token
|
|
26
|
+
|
|
27
|
+
# OAuth client identifier.
|
|
28
|
+
# @return [String]
|
|
29
|
+
attr_reader :client_id
|
|
30
|
+
|
|
31
|
+
# Space-separated requested scopes.
|
|
32
|
+
# @return [String]
|
|
33
|
+
attr_reader :scope
|
|
34
|
+
|
|
35
|
+
# Secret PKCE verifier used for code exchange.
|
|
36
|
+
# @return [String]
|
|
37
|
+
attr_reader :pkce_verifier
|
|
38
|
+
|
|
39
|
+
# S256 PKCE challenge sent in PAR.
|
|
40
|
+
# @return [String]
|
|
41
|
+
attr_reader :pkce_challenge
|
|
42
|
+
|
|
43
|
+
# Bound authorization server, once discovered.
|
|
44
|
+
# @return [ServerMetadata::AuthorizationServer, nil]
|
|
45
|
+
attr_reader :auth_server
|
|
46
|
+
|
|
47
|
+
# Bound account DID, once resolved.
|
|
48
|
+
# @return [String, nil]
|
|
49
|
+
attr_reader :did
|
|
50
|
+
|
|
51
|
+
# Most recently assigned tokens, if any.
|
|
52
|
+
# @return [TokenSet, nil]
|
|
53
|
+
attr_reader :tokens
|
|
54
|
+
|
|
55
|
+
# Creates session identifiers, a state token, and a PKCE verifier/challenge pair.
|
|
56
|
+
#
|
|
57
|
+
# @param client_id [String] OAuth client ID
|
|
58
|
+
# @param scope [String] requested space-separated scopes
|
|
59
|
+
# @param auth_server [ServerMetadata::AuthorizationServer, nil] pre-resolved authorization server
|
|
60
|
+
# @param did [String, nil] pre-resolved account DID
|
|
61
|
+
# @raise [PKCE::Error] if PKCE generation fails
|
|
62
|
+
#
|
|
63
|
+
def initialize(client_id:, scope:, auth_server: nil, did: nil)
|
|
64
|
+
super() # Initialize MonitorMixin
|
|
65
|
+
|
|
66
|
+
@session_id = SecureRandom.uuid
|
|
67
|
+
@state_token = SecureRandom.urlsafe_base64(32)
|
|
68
|
+
@client_id = client_id
|
|
69
|
+
@scope = scope
|
|
70
|
+
@auth_server = auth_server
|
|
71
|
+
@did = did
|
|
72
|
+
|
|
73
|
+
# Generate PKCE values
|
|
74
|
+
@pkce_verifier = PKCE.generate_verifier
|
|
75
|
+
@pkce_challenge = PKCE.generate_challenge(@pkce_verifier)
|
|
76
|
+
|
|
77
|
+
@tokens = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Binds this session to an authorization server.
|
|
81
|
+
#
|
|
82
|
+
# @param server [ServerMetadata::AuthorizationServer] resolved authorization server
|
|
83
|
+
# @return [ServerMetadata::AuthorizationServer] assigned server when called as a method
|
|
84
|
+
# @raise [SessionError] if already bound to a server with a different issuer
|
|
85
|
+
#
|
|
86
|
+
def authorization_server=(server)
|
|
87
|
+
synchronize do
|
|
88
|
+
if @auth_server && @auth_server.issuer != server.issuer
|
|
89
|
+
raise SessionError, "Session already bound to different authorization server"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
@auth_server = server
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Binds this session to an account DID.
|
|
97
|
+
#
|
|
98
|
+
# @param did [String] resolved DID
|
|
99
|
+
# @return [String] assigned DID
|
|
100
|
+
# @raise [SessionError] if already bound to a different DID
|
|
101
|
+
#
|
|
102
|
+
def did=(did)
|
|
103
|
+
synchronize do
|
|
104
|
+
raise SessionError, "Session already bound to different DID" if @did && @did != did
|
|
105
|
+
|
|
106
|
+
@did = did
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Stores tokens in this session object and binds an unbound session to their subject.
|
|
111
|
+
#
|
|
112
|
+
# Call {SessionManager#update_session} to persist the changed session.
|
|
113
|
+
#
|
|
114
|
+
# @param tokens [TokenSet] replacement token set
|
|
115
|
+
# @return [String] existing or newly bound session DID when called as a method
|
|
116
|
+
# @raise [SessionError] if token subject conflicts with the existing session DID
|
|
117
|
+
#
|
|
118
|
+
def tokens=(tokens)
|
|
119
|
+
synchronize do
|
|
120
|
+
raise SessionError, "Token subject doesn't match session DID" if @did && tokens.sub != @did
|
|
121
|
+
|
|
122
|
+
@tokens = tokens
|
|
123
|
+
@did ||= tokens.sub
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Whether this session has valid access tokens
|
|
128
|
+
# @return [Boolean]
|
|
129
|
+
def authorized?
|
|
130
|
+
synchronize do
|
|
131
|
+
!@tokens.nil? && !@tokens.expired?
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Whether this session can refresh its tokens
|
|
136
|
+
# @return [Boolean]
|
|
137
|
+
def renewable?
|
|
138
|
+
synchronize do
|
|
139
|
+
!@tokens.nil? && @tokens.renewable?
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Compares a supplied state token with the session token in constant time.
|
|
144
|
+
#
|
|
145
|
+
# @param state [String, nil] callback state token
|
|
146
|
+
# @return [Boolean] whether it matches; `false` for `nil` or a different length
|
|
147
|
+
#
|
|
148
|
+
def validate_state(state)
|
|
149
|
+
return false unless state
|
|
150
|
+
|
|
151
|
+
# Use secure comparison to prevent timing attacks
|
|
152
|
+
secure_compare(@state_token, state)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def secure_compare(str1, str2)
|
|
158
|
+
return false unless str1.bytesize == str2.bytesize
|
|
159
|
+
|
|
160
|
+
left = str1.unpack("C*")
|
|
161
|
+
right = str2.unpack("C*")
|
|
162
|
+
result = 0
|
|
163
|
+
left.zip(right) { |x, y| result |= x ^ y }
|
|
164
|
+
result.zero?
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|