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,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
|