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.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +31 -0
  4. data/.tm_properties +1 -0
  5. data/.yardopts +4 -0
  6. data/CHANGELOG.md +52 -0
  7. data/MIT-LICENSE.txt +21 -0
  8. data/README.md +304 -0
  9. data/Rakefile +8 -0
  10. data/SECURITY.md +16 -0
  11. data/examples/confidential_client/.gitignore +5 -0
  12. data/examples/confidential_client/Gemfile +14 -0
  13. data/examples/confidential_client/Gemfile.lock +88 -0
  14. data/examples/confidential_client/README.md +226 -0
  15. data/examples/confidential_client/app.rb +290 -0
  16. data/examples/confidential_client/config/client-metadata.example.json +25 -0
  17. data/examples/confidential_client/config.ru +4 -0
  18. data/examples/confidential_client/public/styles.css +70 -0
  19. data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
  20. data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
  21. data/examples/confidential_client/scripts/generate_keys.rb +15 -0
  22. data/examples/confidential_client/views/authorized.erb +46 -0
  23. data/examples/confidential_client/views/index.erb +44 -0
  24. data/examples/confidential_client/views/layout.erb +11 -0
  25. data/examples/minisky_console/.gitignore +2 -0
  26. data/examples/minisky_console/Gemfile +12 -0
  27. data/examples/minisky_console/Gemfile.lock +131 -0
  28. data/examples/minisky_console/README.md +112 -0
  29. data/examples/minisky_console/app.rb +50 -0
  30. data/examples/minisky_console/bin/console +6 -0
  31. data/examples/minisky_console/config.ru +5 -0
  32. data/examples/minisky_console/localhost.rb +53 -0
  33. data/examples/minisky_console/production.rb +32 -0
  34. data/lib/authproto/client.rb +583 -0
  35. data/lib/authproto/client_metadata.rb +478 -0
  36. data/lib/authproto/configuration.rb +139 -0
  37. data/lib/authproto/dpop/client.rb +161 -0
  38. data/lib/authproto/dpop/key_manager.rb +223 -0
  39. data/lib/authproto/dpop/nonce_manager.rb +145 -0
  40. data/lib/authproto/dpop/proof_generator.rb +124 -0
  41. data/lib/authproto/dpop/stored_nonce.rb +37 -0
  42. data/lib/authproto/encryption.rb +250 -0
  43. data/lib/authproto/errors.rb +75 -0
  44. data/lib/authproto/http_client.rb +320 -0
  45. data/lib/authproto/insecure_http_client.rb +24 -0
  46. data/lib/authproto/localhost_server.rb +274 -0
  47. data/lib/authproto/minisky.rb +273 -0
  48. data/lib/authproto/par/client.rb +240 -0
  49. data/lib/authproto/par/client_assertion.rb +69 -0
  50. data/lib/authproto/par/request.rb +243 -0
  51. data/lib/authproto/par/response.rb +43 -0
  52. data/lib/authproto/par.rb +48 -0
  53. data/lib/authproto/pkce.rb +125 -0
  54. data/lib/authproto/serialization/base.rb +258 -0
  55. data/lib/authproto/serialization/dpop_key.rb +38 -0
  56. data/lib/authproto/serialization/session.rb +88 -0
  57. data/lib/authproto/serialization/stored_nonce.rb +46 -0
  58. data/lib/authproto/serialization/token_set.rb +54 -0
  59. data/lib/authproto/server_metadata/authorization_server.rb +267 -0
  60. data/lib/authproto/server_metadata/origin_url.rb +71 -0
  61. data/lib/authproto/server_metadata/resource_server.rb +98 -0
  62. data/lib/authproto/server_metadata.rb +28 -0
  63. data/lib/authproto/state/session.rb +168 -0
  64. data/lib/authproto/state/session_manager.rb +162 -0
  65. data/lib/authproto/state/token_set.rb +101 -0
  66. data/lib/authproto/state.rb +61 -0
  67. data/lib/authproto/storage/interface.rb +121 -0
  68. data/lib/authproto/storage/key_builder.rb +76 -0
  69. data/lib/authproto/storage/memory.rb +222 -0
  70. data/lib/authproto/storage/redis.rb +156 -0
  71. data/lib/authproto/storage/sqlite.rb +281 -0
  72. data/lib/authproto/token/refresh.rb +414 -0
  73. data/lib/authproto/version.rb +8 -0
  74. data/lib/authproto.rb +18 -0
  75. data/sig/authproto/client.rbs +43 -0
  76. data/sig/authproto/client_metadata.rbs +113 -0
  77. data/sig/authproto/dependencies.rbs +19 -0
  78. data/sig/authproto/dpop/client.rbs +23 -0
  79. data/sig/authproto/dpop/key_manager.rbs +26 -0
  80. data/sig/authproto/dpop/nonce_manager.rbs +23 -0
  81. data/sig/authproto/dpop/proof_generator.rbs +40 -0
  82. data/sig/authproto/dpop/stored_nonce.rbs +10 -0
  83. data/sig/authproto/encryption.rbs +42 -0
  84. data/sig/authproto/http_client.rbs +45 -0
  85. data/sig/authproto/insecure_http_client.rbs +8 -0
  86. data/sig/authproto/localhost_server.rbs +30 -0
  87. data/sig/authproto/minisky_oauth.rbs +22 -0
  88. data/sig/authproto/par/client.rbs +25 -0
  89. data/sig/authproto/par/client_assertion.rbs +10 -0
  90. data/sig/authproto/par/request.rbs +73 -0
  91. data/sig/authproto/par/response.rbs +17 -0
  92. data/sig/authproto/par.rbs +7 -0
  93. data/sig/authproto/pkce.rbs +24 -0
  94. data/sig/authproto/serialization/base.rbs +36 -0
  95. data/sig/authproto/serialization/dpop_key.rbs +14 -0
  96. data/sig/authproto/serialization/session.rbs +19 -0
  97. data/sig/authproto/serialization/stored_nonce.rbs +14 -0
  98. data/sig/authproto/serialization/token_set.rbs +14 -0
  99. data/sig/authproto/server_metadata/authorization_server.rbs +71 -0
  100. data/sig/authproto/server_metadata/origin_url.rbs +21 -0
  101. data/sig/authproto/server_metadata/resource_server.rbs +27 -0
  102. data/sig/authproto/state/session.rbs +28 -0
  103. data/sig/authproto/state/session_manager.rbs +15 -0
  104. data/sig/authproto/state/token_set.rbs +40 -0
  105. data/sig/authproto/state.rbs +6 -0
  106. data/sig/authproto/storage/interface.rbs +20 -0
  107. data/sig/authproto/storage/key_builder.rbs +16 -0
  108. data/sig/authproto/storage/memory.rbs +22 -0
  109. data/sig/authproto/storage/redis.rbs +19 -0
  110. data/sig/authproto/storage/redis_client.rbs +11 -0
  111. data/sig/authproto/storage/sqlite.rbs +26 -0
  112. data/sig/authproto/token/refresh.rbs +47 -0
  113. data/sig/authproto/version.rbs +3 -0
  114. data/sig/authproto.rbs +54 -0
  115. 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