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,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ require_relative '../configuration'
8
+ require_relative '../par'
9
+ require_relative 'response'
10
+
11
+ module AuthProto
12
+ module PAR
13
+
14
+ #
15
+ # Client for making Pushed Authorization Requests (PAR) according to RFC 9126.
16
+ # Handles submitting authorization parameters to the PAR endpoint and building
17
+ # the subsequent authorization URL.
18
+ #
19
+ # In AT Protocol OAuth, all authorization requests must first go through PAR.
20
+ # This means instead of sending authorization parameters directly to the
21
+ # authorization endpoint, clients:
22
+ # 1. Submit parameters to the PAR endpoint via POST
23
+ # 2. Receive a request_uri in response
24
+ # 3. Use only the request_uri and client_id in the authorization redirect
25
+ #
26
+ # @example Basic PAR flow
27
+ # client = AuthProto::PAR::Client.new(
28
+ # endpoint: "https://auth.example.com/par",
29
+ # dpop_client: AuthProto::DPoP::Client.new
30
+ # )
31
+ #
32
+ # # Create and submit PAR request using builder pattern
33
+ # request = AuthProto::PAR::Request.build do |config|
34
+ # config.client_id = "https://app.example.com/oauth-client-metadata.json"
35
+ # config.redirect_uri = "https://app.example.com/callback"
36
+ # config.code_challenge = "abc123..."
37
+ # config.code_challenge_method = "S256"
38
+ # config.state = "xyz789..."
39
+ # config.scope = "atproto"
40
+ # end
41
+ #
42
+ # response = client.submit(request)
43
+ #
44
+ # # Build authorization URL using response
45
+ # auth_url = client.authorization_url(
46
+ # authorize_endpoint: "https://auth.example.com/authorize",
47
+ # request_uri: response.request_uri,
48
+ # client_id: request.client_id
49
+ # )
50
+ #
51
+ # @example With client authentication (confidential clients)
52
+ # request = AuthProto::PAR::Request.build do |config|
53
+ # # ... basic parameters ...
54
+ # config.client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
55
+ # config.client_assertion = jwt_token
56
+ # end
57
+ #
58
+ # All requests use HTTPS and include a fresh DPoP proof from the supplied
59
+ # DPoP client. Nonce challenges are retried once with a new proof. The client
60
+ # validates PAR responses before returning a request URI.
61
+ #
62
+
63
+ class Client
64
+
65
+ # HTTPS pushed authorization request endpoint.
66
+ # @return [String]
67
+ attr_reader :endpoint
68
+
69
+ # DPoP client used to sign requests.
70
+ # @return [DPoP::Client]
71
+ attr_reader :dpop_client
72
+
73
+ # Nonce manager belonging to the DPoP client.
74
+ # @return [DPoP::NonceManager]
75
+ attr_reader :nonce_manager
76
+
77
+ # Creates a PAR transport using the global HTTP client and supplied DPoP client.
78
+ #
79
+ # @param endpoint [String] HTTPS PAR endpoint
80
+ # @param dpop_client [DPoP::Client] proof generator and nonce tracker
81
+ # @raise [Error] if the endpoint is malformed or does not use HTTPS
82
+ #
83
+ def initialize(endpoint:, dpop_client:)
84
+ @endpoint = endpoint
85
+ @dpop_client = dpop_client
86
+ @nonce_manager = dpop_client.nonce_manager
87
+ validate_endpoint
88
+ end
89
+
90
+ # Submits a PAR request, handling DPoP nonce requirements
91
+ #
92
+ # @param request [Request] The request to submit
93
+ # @return [Response] The PAR response
94
+ # @raise [Error] if request fails
95
+ #
96
+ def submit(request)
97
+ # Try the initial request
98
+ response = make_request(request)
99
+
100
+ return process_response(response) if response[:status] == 201
101
+
102
+ # Handle DPoP nonce requirement
103
+ if requires_nonce?(response)
104
+ nonce = extract_nonce(response)
105
+ store_nonce(nonce)
106
+
107
+ # Get stored nonce to verify
108
+ nonce_manager.get(server_origin)
109
+
110
+ # Generate new proof with nonce and retry
111
+ response = make_request(request)
112
+ return process_response(response) if response[:status] == 201
113
+ end
114
+
115
+ handle_error_response(response)
116
+ rescue StandardError => e
117
+ raise Error, "PAR request failed: #{e.message}"
118
+ end
119
+
120
+ # Reads a nonce from a transport response hash.
121
+ #
122
+ # @param response [Hash{Symbol => Object}] response containing a `:headers` hash with a `DPoP-Nonce` header
123
+ # @return [String] server-provided nonce
124
+ # @raise [Error] if no supported nonce header is present
125
+ #
126
+ def extract_nonce(response)
127
+ # Try all possible header key formats
128
+ headers = response[:headers]
129
+ nonce = headers["DPoP-Nonce"] ||
130
+ headers["dpop-nonce"] ||
131
+ headers["Dpop-Nonce"]
132
+
133
+ raise Error, "No DPoP nonce provided in response" unless nonce
134
+
135
+ nonce
136
+ end
137
+
138
+ # Builds the browser URL using the client ID and returned PAR request URI.
139
+ #
140
+ # The endpoint's existing query is replaced by these two parameters.
141
+ #
142
+ # @param authorize_endpoint [String] authorization endpoint URL
143
+ # @param request_uri [String] request URI from {Response#request_uri}
144
+ # @param client_id [String] OAuth client ID
145
+ # @return [String] browser authorization URL
146
+ # @raise [URI::InvalidURIError] if the authorization endpoint cannot be parsed
147
+ #
148
+ def authorization_url(authorize_endpoint:, request_uri:, client_id:)
149
+ uri = URI(authorize_endpoint)
150
+ uri.query = encode_params(
151
+ "request_uri" => request_uri,
152
+ "client_id" => client_id
153
+ )
154
+ uri.to_s
155
+ end
156
+
157
+ private
158
+
159
+ def validate_endpoint
160
+ uri = URI(@endpoint)
161
+ raise Error, "endpoint must be HTTPS" unless uri.scheme == "https"
162
+ rescue URI::InvalidURIError => e
163
+ raise Error, "invalid endpoint URL: #{e.message}"
164
+ end
165
+
166
+ def make_request(request)
167
+ # Generate DPoP proof for this request
168
+ proof = dpop_client.generate_proof(
169
+ http_method: "POST",
170
+ http_uri: endpoint,
171
+ nonce: nonce_manager.get(server_origin)
172
+ )
173
+
174
+ # Build headers including DPoP proof
175
+ headers = build_headers(request, proof)
176
+
177
+ # Make the request
178
+ AuthProto.configuration.http_client.post(
179
+ endpoint,
180
+ body: request.to_form,
181
+ headers: headers
182
+ )
183
+ end
184
+
185
+ def build_headers(request, dpop_proof)
186
+ {
187
+ "Content-Type" => "application/x-www-form-urlencoded",
188
+ "DPoP" => dpop_proof
189
+ }
190
+ end
191
+
192
+ def requires_nonce?(response)
193
+ body = JSON.parse(response[:body])
194
+ body["error"] == "use_dpop_nonce"
195
+ rescue JSON::ParserError
196
+ false
197
+ end
198
+
199
+ def store_nonce(nonce)
200
+ nonce_manager.update(nonce: nonce, server_url: server_origin)
201
+ end
202
+
203
+ def server_origin
204
+ uri = URI(@endpoint)
205
+ "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != uri.default_port}"
206
+ end
207
+
208
+ def handle_error_response(response)
209
+ begin
210
+ error_data = JSON.parse(response[:body])
211
+ error_message = error_data["error_description"] || error_data["error"] || "Unknown error"
212
+ rescue JSON::ParserError
213
+ error_message = "Invalid response from server"
214
+ end
215
+
216
+ raise Error, "PAR request failed: #{error_message} (status: #{response[:status]})"
217
+ end
218
+
219
+ def process_response(response)
220
+ raise Error, "unexpected response status: #{response[:status]}" unless response[:status] == 201
221
+
222
+ begin
223
+ data = JSON.parse(response[:body])
224
+ Response.new(
225
+ request_uri: data["request_uri"],
226
+ expires_in: data["expires_in"]
227
+ )
228
+ rescue JSON::ParserError => e
229
+ raise Error, "invalid JSON response: #{e.message}"
230
+ rescue StandardError => e
231
+ raise Error, "failed to process response: #{e.message}"
232
+ end
233
+ end
234
+
235
+ def encode_params(params)
236
+ params.map { |k, v| "#{CGI.escape(k)}=#{CGI.escape(v.to_s)}" }.join("&")
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+ require 'securerandom'
5
+
6
+ require_relative '../errors'
7
+
8
+ module AuthProto
9
+ module PAR
10
+
11
+ #
12
+ # Generates client authentication JWTs according to RFC 7523 for AT Protocol OAuth.
13
+ # Creates signed assertions using ES256 with required claims including iss/sub (client_id),
14
+ # aud (authorization server issuer), jti (unique ID), and iat/exp (timing claims).
15
+ #
16
+
17
+ class ClientAssertion
18
+
19
+ # Raised when client assertion key import or JWT signing fails.
20
+ class Error < AuthProto::Error; end
21
+
22
+ # Creates an ES256 client assertion signer.
23
+ #
24
+ # @param client_id [String] client ID used in `iss` and `sub`
25
+ # @param signing_key [JOSE::JWK] private EC P-256 signing key, including its `kid`
26
+ #
27
+ def initialize(client_id:, signing_key:)
28
+ @client_id = client_id
29
+ @signing_key = signing_key
30
+ end
31
+
32
+ # Signs a short-lived client authentication JWT with ES256.
33
+ #
34
+ # Includes `iss`, `sub`, `aud`, a unique `jti`, and `iat`/`exp` times.
35
+ #
36
+ # @param audience [String] authorization server issuer URL used as the assertion audience
37
+ # @param lifetime [Integer] validity in seconds; defaults to five minutes
38
+ # @return [String] compact signed client assertion JWT
39
+ # @raise [Error] if JWK import or JWT signing fails
40
+ #
41
+ def generate_jwt(audience:, lifetime: 300)
42
+ now = Time.now.to_i
43
+
44
+ payload = {
45
+ # Required claims
46
+ iss: @client_id, # Issuer is client_id
47
+ sub: @client_id, # Subject is client_id
48
+ aud: audience, # Audience is the authorization server issuer
49
+ jti: SecureRandom.uuid, # Unique identifier
50
+ exp: now + lifetime, # Expiration time
51
+ iat: now # Issued at time
52
+ }
53
+
54
+ # Header specifying ES256 algorithm for signing
55
+ header = {
56
+ alg: "ES256",
57
+ typ: "JWT",
58
+ kid: @signing_key.fields["kid"]
59
+ }
60
+
61
+ # Sign and return the JWT
62
+ key = JWT::JWK.import(@signing_key.to_map.to_h)
63
+ JWT.encode(payload, key, "ES256", header)
64
+ rescue JWT::Error, ArgumentError => e
65
+ raise Error, "Failed to generate client assertion: #{e.message}"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ require_relative '../par'
6
+
7
+ module AuthProto
8
+ module PAR
9
+
10
+ #
11
+ # Represents a pushed authorization request
12
+ #
13
+
14
+ class Request
15
+
16
+ #
17
+ # Configuration for request parameters
18
+ #
19
+
20
+ class Configuration
21
+
22
+ # OAuth client identifier.
23
+ # @return [String, nil]
24
+ attr_accessor :client_id
25
+
26
+ # Registered OAuth callback URI.
27
+ # @return [String, nil]
28
+ attr_accessor :redirect_uri
29
+
30
+ # S256 PKCE challenge.
31
+ # @return [String, nil]
32
+ attr_accessor :code_challenge
33
+
34
+ # PKCE method; must be `S256`.
35
+ # @return [String, nil]
36
+ attr_accessor :code_challenge_method
37
+
38
+ # Random state token to validate the callback.
39
+ # @return [String, nil]
40
+ attr_accessor :state
41
+
42
+ # Space-separated scopes including `atproto`.
43
+ # @return [String, nil]
44
+ attr_accessor :scope
45
+
46
+ # Account handle to prefill at the authorization server.
47
+ # @return [String, nil]
48
+ attr_accessor :login_hint
49
+
50
+ # Optional authorization nonce parameter.
51
+ # @return [String, nil]
52
+ attr_accessor :nonce
53
+
54
+ # Optional proof value retained on the request; {Client#submit} generates its own HTTP proof.
55
+ # @return [String, nil]
56
+ attr_accessor :dpop_proof
57
+
58
+ # JWT bearer assertion type for confidential clients.
59
+ # @return [String, nil]
60
+ attr_accessor :client_assertion_type
61
+
62
+ # Signed client authentication JWT for confidential clients.
63
+ # @return [String, nil]
64
+ attr_accessor :client_assertion
65
+ end
66
+
67
+ # OAuth response type; always `code`.
68
+ # @return [String]
69
+ attr_reader :response_type
70
+
71
+ # OAuth client identifier.
72
+ # @return [String]
73
+ attr_reader :client_id
74
+
75
+ # Registered OAuth callback URI.
76
+ # @return [String]
77
+ attr_reader :redirect_uri
78
+
79
+ # S256 PKCE challenge.
80
+ # @return [String]
81
+ attr_reader :code_challenge
82
+
83
+ # PKCE method; must be `S256`.
84
+ # @return [String]
85
+ attr_reader :code_challenge_method
86
+
87
+ # Random state token to validate the callback.
88
+ # @return [String]
89
+ attr_reader :state
90
+
91
+ # Space-separated scopes including `atproto`.
92
+ # @return [String]
93
+ attr_reader :scope
94
+
95
+ # Account handle to prefill at the authorization server.
96
+ # @return [String, nil]
97
+ attr_reader :login_hint
98
+
99
+ # Optional authorization nonce parameter.
100
+ # @return [String, nil]
101
+ attr_reader :nonce
102
+
103
+ # Optional proof value retained on the request; {Client#submit} generates its own HTTP proof.
104
+ # @return [String, nil]
105
+ attr_reader :dpop_proof
106
+
107
+ # JWT bearer assertion type for confidential clients.
108
+ # @return [String, nil]
109
+ attr_reader :client_assertion_type
110
+
111
+ # Signed client authentication JWT for confidential clients.
112
+ # @return [String, nil]
113
+ attr_reader :client_assertion
114
+
115
+ # Builds a validated PAR request by yielding a mutable parameter configuration.
116
+ #
117
+ # @yield [config] block that sets the request parameters
118
+ # @yieldparam config [Configuration] mutable request settings
119
+ # @yieldreturn [Object] ignored
120
+ # @return [Request] validated pushed authorization request
121
+ # @raise [Error] if required parameters, PKCE, scope or client authentication are invalid
122
+ #
123
+ def self.build
124
+ config = Configuration.new
125
+ yield(config)
126
+ new(config)
127
+ end
128
+
129
+ # Copies and validates a PAR parameter configuration.
130
+ #
131
+ # @param config [Configuration] populated request settings
132
+ # @raise [Error] if required values are missing or authentication/PKCE/scope values are invalid
133
+ #
134
+ def initialize(config)
135
+ # Required parameters
136
+ @response_type = "code" # Always "code" for AT Protocol OAuth
137
+ @client_id = config.client_id
138
+ @redirect_uri = config.redirect_uri
139
+ @code_challenge = config.code_challenge
140
+ @code_challenge_method = config.code_challenge_method
141
+ @state = config.state
142
+ @scope = config.scope
143
+
144
+ # Optional parameters
145
+ @login_hint = config.login_hint
146
+ @nonce = config.nonce
147
+ @dpop_proof = config.dpop_proof
148
+
149
+ # Client authentication
150
+ @client_assertion_type = config.client_assertion_type
151
+ @client_assertion = config.client_assertion
152
+
153
+ validate_request
154
+ end
155
+
156
+ # Converts request to form-encoded parameters
157
+ # @return [String] Form-encoded request body
158
+ def to_form
159
+ encode_params(build_params)
160
+ end
161
+
162
+ private
163
+
164
+ def build_params
165
+ params = {
166
+ "response_type" => response_type,
167
+ "client_id" => client_id,
168
+ "redirect_uri" => redirect_uri,
169
+ "code_challenge" => code_challenge,
170
+ "code_challenge_method" => code_challenge_method,
171
+ "state" => state,
172
+ "scope" => scope
173
+ }
174
+
175
+ add_optional_params(params)
176
+ add_client_auth_params(params)
177
+ params
178
+ end
179
+
180
+ def add_optional_params(params)
181
+ params["login_hint"] = login_hint if login_hint
182
+ params["nonce"] = nonce if nonce
183
+ end
184
+
185
+ def add_client_auth_params(params)
186
+ return unless client_assertion
187
+
188
+ params["client_assertion_type"] = CLIENT_ASSERTION_TYPE
189
+ params["client_assertion"] = client_assertion
190
+ end
191
+
192
+ def validate_request
193
+ validate_required_params
194
+ validate_response_type
195
+ validate_code_challenge_method
196
+ validate_scope
197
+ validate_client_auth
198
+ end
199
+
200
+ def validate_required_params
201
+ %i[client_id redirect_uri code_challenge code_challenge_method state scope].each do |param|
202
+ value = send(param)
203
+ raise Error, "#{param} is required" if value.nil? || value.empty?
204
+ end
205
+ end
206
+
207
+ def validate_response_type
208
+ return if response_type == "code"
209
+
210
+ raise Error, "response_type must be 'code'"
211
+ end
212
+
213
+ def validate_code_challenge_method
214
+ return if code_challenge_method == "S256"
215
+
216
+ raise Error, "code_challenge_method must be 'S256'"
217
+ end
218
+
219
+ def validate_scope
220
+ scopes = scope.split
221
+ raise Error, "atproto scope is required" unless scopes.include?("atproto")
222
+ end
223
+
224
+ def validate_client_auth
225
+ # If either auth parameter is present, both must be present and valid
226
+ has_assertion = !client_assertion.nil?
227
+ has_type = !client_assertion_type.nil?
228
+
229
+ return unless has_assertion || has_type
230
+ unless client_assertion_type == CLIENT_ASSERTION_TYPE
231
+ raise Error, "client_assertion_type must be #{CLIENT_ASSERTION_TYPE}"
232
+ end
233
+
234
+ raise Error, "client_assertion required with client_assertion_type" if client_assertion.nil?
235
+ raise Error, "client_assertion_type required with client_assertion" if client_assertion_type.nil?
236
+ end
237
+
238
+ def encode_params(params)
239
+ params.map { |k, v| "#{CGI.escape(k)}=#{CGI.escape(v.to_s)}" }.join("&")
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../par'
4
+
5
+ module AuthProto
6
+ module PAR
7
+
8
+ #
9
+ # Represents a PAR response
10
+ #
11
+
12
+ class Response
13
+
14
+ # Opaque PAR request URI used in the browser authorization URL.
15
+ # @return [String]
16
+ attr_reader :request_uri
17
+
18
+ # Lifetime of the request URI in seconds.
19
+ # @return [Integer]
20
+ attr_reader :expires_in
21
+
22
+ # Creates and validates a PAR response.
23
+ #
24
+ # @param request_uri [String] nonempty request URI issued by the authorization server
25
+ # @param expires_in [Integer, String] positive lifetime in seconds; converted with `to_i`
26
+ # @raise [Error] if the URI is empty or expiry is not positive
27
+ #
28
+ def initialize(request_uri:, expires_in:)
29
+ @request_uri = request_uri
30
+ @expires_in = expires_in.to_i
31
+ validate_response
32
+ end
33
+
34
+
35
+ private
36
+
37
+ def validate_response
38
+ raise Error, "request_uri is required" if request_uri.nil? || request_uri.empty?
39
+ raise Error, "expires_in must be positive" unless expires_in.positive?
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module AuthProto
6
+
7
+ #
8
+ # Handles creation and processing of Pushed Authorization Requests (PAR)
9
+ # according to RFC 9126 and AT Protocol OAuth requirements.
10
+ #
11
+ # PAR is mandatory in AT Protocol OAuth. Before redirecting a user to the
12
+ # authorization endpoint, clients must first submit all authorization parameters
13
+ # via HTTP POST to the PAR endpoint. Only the returned request_uri and client_id
14
+ # are then included in the authorization redirect.
15
+ #
16
+ # @example Basic PAR request
17
+ # par = AuthProto::PAR::Client.new(
18
+ # endpoint: "https://auth.example.com/par",
19
+ # dpop_client: AuthProto::DPoP::Client.new
20
+ # )
21
+ #
22
+ # request = AuthProto::PAR::Request.build do |config|
23
+ # config.client_id = "https://app.example.com/oauth-client-metadata.json"
24
+ # config.redirect_uri = "https://app.example.com/callback"
25
+ # config.code_challenge = "abc123..."
26
+ # config.code_challenge_method = "S256"
27
+ # config.state = "xyz789..."
28
+ # config.scope = "atproto"
29
+ # end
30
+ #
31
+ # response = par.submit(request)
32
+ # auth_url = par.authorization_url(
33
+ # authorize_endpoint: "https://auth.example.com/authorize",
34
+ # request_uri: response.request_uri,
35
+ # client_id: request.client_id
36
+ # )
37
+ #
38
+
39
+ module PAR
40
+
41
+ # Error raised for PAR-related issues
42
+ class Error < AuthProto::Error; end
43
+
44
+ # OAuth JWT bearer client assertion type URN.
45
+ # @return [String]
46
+ CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
47
+ end
48
+ end