atproto_auth 0.0.1
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/.rubocop.yml +16 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +179 -0
- data/Rakefile +16 -0
- data/examples/confidential_client/Gemfile +12 -0
- data/examples/confidential_client/Gemfile.lock +84 -0
- data/examples/confidential_client/README.md +110 -0
- data/examples/confidential_client/app.rb +136 -0
- data/examples/confidential_client/config/client-metadata.json +25 -0
- data/examples/confidential_client/config.ru +4 -0
- data/examples/confidential_client/public/client-metadata.json +24 -0
- data/examples/confidential_client/public/styles.css +70 -0
- data/examples/confidential_client/scripts/generate_keys.rb +15 -0
- data/examples/confidential_client/views/authorized.erb +29 -0
- data/examples/confidential_client/views/index.erb +44 -0
- data/examples/confidential_client/views/layout.erb +11 -0
- data/lib/atproto_auth/client.rb +410 -0
- data/lib/atproto_auth/client_metadata.rb +264 -0
- data/lib/atproto_auth/configuration.rb +17 -0
- data/lib/atproto_auth/dpop/client.rb +122 -0
- data/lib/atproto_auth/dpop/key_manager.rb +235 -0
- data/lib/atproto_auth/dpop/nonce_manager.rb +138 -0
- data/lib/atproto_auth/dpop/proof_generator.rb +112 -0
- data/lib/atproto_auth/errors.rb +47 -0
- data/lib/atproto_auth/http_client.rb +227 -0
- data/lib/atproto_auth/identity/document.rb +104 -0
- data/lib/atproto_auth/identity/resolver.rb +221 -0
- data/lib/atproto_auth/identity.rb +24 -0
- data/lib/atproto_auth/par/client.rb +203 -0
- data/lib/atproto_auth/par/client_assertion.rb +50 -0
- data/lib/atproto_auth/par/request.rb +140 -0
- data/lib/atproto_auth/par/response.rb +23 -0
- data/lib/atproto_auth/par.rb +40 -0
- data/lib/atproto_auth/pkce.rb +105 -0
- data/lib/atproto_auth/server_metadata/authorization_server.rb +175 -0
- data/lib/atproto_auth/server_metadata/origin_url.rb +51 -0
- data/lib/atproto_auth/server_metadata/resource_server.rb +71 -0
- data/lib/atproto_auth/server_metadata.rb +24 -0
- data/lib/atproto_auth/state/session.rb +117 -0
- data/lib/atproto_auth/state/session_manager.rb +75 -0
- data/lib/atproto_auth/state/token_set.rb +68 -0
- data/lib/atproto_auth/state.rb +54 -0
- data/lib/atproto_auth/version.rb +5 -0
- data/lib/atproto_auth.rb +56 -0
- data/sig/atproto_auth/client_metadata.rbs +95 -0
- data/sig/atproto_auth/dpop/client.rbs +38 -0
- data/sig/atproto_auth/dpop/key_manager.rbs +33 -0
- data/sig/atproto_auth/dpop/nonce_manager.rbs +48 -0
- data/sig/atproto_auth/dpop/proof_generator.rbs +42 -0
- data/sig/atproto_auth/http_client.rbs +58 -0
- data/sig/atproto_auth/identity/document.rbs +31 -0
- data/sig/atproto_auth/identity/resolver.rbs +41 -0
- data/sig/atproto_auth/par/client.rbs +31 -0
- data/sig/atproto_auth/par/request.rbs +73 -0
- data/sig/atproto_auth/par/response.rbs +17 -0
- data/sig/atproto_auth/pkce.rbs +24 -0
- data/sig/atproto_auth/server_metadata/authorization_server.rbs +69 -0
- data/sig/atproto_auth/server_metadata/origin_url.rbs +21 -0
- data/sig/atproto_auth/server_metadata/resource_server.rbs +27 -0
- data/sig/atproto_auth/state/session.rbs +50 -0
- data/sig/atproto_auth/state/session_manager.rbs +26 -0
- data/sig/atproto_auth/state/token_set.rbs +40 -0
- data/sig/atproto_auth/version.rbs +3 -0
- data/sig/atproto_auth.rbs +39 -0
- metadata +142 -0
@@ -0,0 +1,221 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "resolv"
|
4
|
+
|
5
|
+
module AtprotoAuth
|
6
|
+
module Identity
|
7
|
+
# Resolves and validates AT Protocol identities
|
8
|
+
class Resolver
|
9
|
+
PLC_DIRECTORY_URL = "https://plc.directory"
|
10
|
+
DID_PLC_PREFIX = "did:plc:"
|
11
|
+
HANDLE_REGEX = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
|
12
|
+
|
13
|
+
# Creates a new Identity resolver
|
14
|
+
# @param plc_directory [String] Optional custom PLC directory URL
|
15
|
+
def initialize(plc_directory: nil)
|
16
|
+
@plc_directory = plc_directory || PLC_DIRECTORY_URL
|
17
|
+
end
|
18
|
+
|
19
|
+
# Resolves a handle to a DID Document
|
20
|
+
# @param handle [String] The handle to resolve (with or without @ prefix)
|
21
|
+
# @return [Hash] Resolution result with :did, :document, and :pds keys
|
22
|
+
# @raise [ResolutionError] if resolution fails
|
23
|
+
def resolve_handle(handle)
|
24
|
+
validate_handle!(handle)
|
25
|
+
normalized = normalize_handle(handle)
|
26
|
+
|
27
|
+
# First try DNS-based resolution
|
28
|
+
did = resolve_handle_dns(normalized)
|
29
|
+
return get_did_info(did) if did
|
30
|
+
|
31
|
+
# Fall back to HTTP resolution via a known PDS
|
32
|
+
resolve_handle_http(normalized)
|
33
|
+
rescue StandardError => e
|
34
|
+
raise ResolutionError, "Failed to resolve handle #{handle}: #{e.message}"
|
35
|
+
end
|
36
|
+
|
37
|
+
# Fetches and parses DID Document
|
38
|
+
# @param did [String] The DID to resolve
|
39
|
+
# @return [Hash] Resolution result with :did, :document, and :pds keys
|
40
|
+
# @raise [ResolutionError] if resolution fails
|
41
|
+
def get_did_info(did)
|
42
|
+
validate_did!(did)
|
43
|
+
|
44
|
+
# Fetch and parse DID document
|
45
|
+
doc_data = fetch_did_document(did)
|
46
|
+
document = Document.new(doc_data)
|
47
|
+
|
48
|
+
# Validate PDS URL format
|
49
|
+
validate_pds_url!(document.pds)
|
50
|
+
|
51
|
+
{ did: did, document: document, pds: document.pds }
|
52
|
+
rescue DocumentError => e
|
53
|
+
raise ResolutionError, "Invalid DID document: #{e.message}"
|
54
|
+
rescue StandardError => e
|
55
|
+
raise ResolutionError, "Failed to resolve DID #{did}: #{e.message}"
|
56
|
+
end
|
57
|
+
|
58
|
+
# Verifies that a PDS hosts a given DID
|
59
|
+
# @param did [String] The DID to verify
|
60
|
+
# @param pds_url [String] The PDS URL to check
|
61
|
+
# @return [Boolean] true if verification succeeds
|
62
|
+
# @raise [ValidationError] if verification fails
|
63
|
+
def verify_pds_binding(did, pds_url)
|
64
|
+
info = get_did_info(did)
|
65
|
+
normalize_url(info[:pds]) == normalize_url(pds_url)
|
66
|
+
rescue StandardError => e
|
67
|
+
raise ValidationError, "Failed to verify PDS binding: #{e.message}"
|
68
|
+
end
|
69
|
+
|
70
|
+
# Verifies that an auth server (issuer) is authorized for a DID
|
71
|
+
# @param did [String] The DID to verify
|
72
|
+
# @param issuer [String] The issuer URL to verify
|
73
|
+
# @return [Boolean] true if verification succeeds
|
74
|
+
# @raise [ValidationError] if verification fails
|
75
|
+
def verify_issuer_binding(did, issuer)
|
76
|
+
# Get PDS location from DID
|
77
|
+
info = get_did_info(did)
|
78
|
+
pds_url = info[:pds]
|
79
|
+
|
80
|
+
# Fetch resource server metadata to find auth server
|
81
|
+
resource_server = ServerMetadata::ResourceServer.from_url(pds_url)
|
82
|
+
auth_server_url = resource_server.authorization_servers.first
|
83
|
+
|
84
|
+
# Compare normalized URLs
|
85
|
+
normalize_url(auth_server_url) == normalize_url(issuer)
|
86
|
+
rescue StandardError => e
|
87
|
+
raise ValidationError, "Failed to verify issuer binding: #{e.message}"
|
88
|
+
end
|
89
|
+
|
90
|
+
# Verifies that a handle belongs to a DID
|
91
|
+
# @param handle [String] Handle to verify
|
92
|
+
# @param did [String] DID to check against
|
93
|
+
# @return [Boolean] true if verification succeeds
|
94
|
+
# @raise [ValidationError] if verification fails
|
95
|
+
def verify_handle_binding(handle, did)
|
96
|
+
info = get_did_info(did)
|
97
|
+
info[:document].has_handle?(handle)
|
98
|
+
rescue StandardError => e
|
99
|
+
raise ValidationError, "Failed to verify handle binding: #{e.message}"
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
def validate_handle!(handle)
|
105
|
+
normalized = normalize_handle(handle)
|
106
|
+
return if normalized.match?(HANDLE_REGEX)
|
107
|
+
|
108
|
+
raise ResolutionError, "Invalid handle format: #{handle}"
|
109
|
+
end
|
110
|
+
|
111
|
+
def validate_did!(did)
|
112
|
+
return if did.start_with?(DID_PLC_PREFIX)
|
113
|
+
|
114
|
+
raise ResolutionError, "Invalid DID format (must be did:plc:): #{did}"
|
115
|
+
end
|
116
|
+
|
117
|
+
def normalize_handle(handle)
|
118
|
+
normalized = handle.start_with?("@") ? handle[1..] : handle
|
119
|
+
normalized.downcase
|
120
|
+
end
|
121
|
+
|
122
|
+
def resolve_handle_dns(handle) # rubocop:disable Metrics/CyclomaticComplexity
|
123
|
+
domain = extract_domain(handle)
|
124
|
+
return nil unless domain
|
125
|
+
|
126
|
+
txt_records = fetch_txt_records("_atproto.#{domain}")
|
127
|
+
return nil unless txt_records&.any?
|
128
|
+
|
129
|
+
# Look for did= entries in TXT records
|
130
|
+
txt_records.each do |record|
|
131
|
+
next unless record.start_with?("did=")
|
132
|
+
|
133
|
+
did = record.delete_prefix("did=").strip
|
134
|
+
return did if valid_did?(did)
|
135
|
+
end
|
136
|
+
|
137
|
+
nil
|
138
|
+
rescue Resolv::ResolvError, Resolv::ResolvTimeout => e
|
139
|
+
logger.debug("DNS resolution failed for #{handle}: #{e.message}")
|
140
|
+
nil # Gracefully fall back to HTTP resolution
|
141
|
+
end
|
142
|
+
|
143
|
+
def extract_domain(handle)
|
144
|
+
# Remove @ prefix if present
|
145
|
+
handle = handle[1..] if handle.start_with?("@")
|
146
|
+
|
147
|
+
# Handle could be user.domain.com or domain.com format
|
148
|
+
# We just need the domain portion
|
149
|
+
if handle.count(".") == 1
|
150
|
+
handle
|
151
|
+
else
|
152
|
+
handle.split(".", 2)[1]
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def fetch_txt_records(domain)
|
157
|
+
resolver = Resolv::DNS.new
|
158
|
+
resolver.timeouts = 3 # 3 second timeout
|
159
|
+
|
160
|
+
records = resolver.getresources(
|
161
|
+
domain,
|
162
|
+
Resolv::DNS::Resource::IN::TXT
|
163
|
+
).map { |r| r.strings.join(" ") }
|
164
|
+
|
165
|
+
resolver.close
|
166
|
+
records
|
167
|
+
end
|
168
|
+
|
169
|
+
def valid_did?(did)
|
170
|
+
did.start_with?(DID_PLC_PREFIX) && did.length > DID_PLC_PREFIX.length
|
171
|
+
end
|
172
|
+
|
173
|
+
def resolve_handle_http(handle)
|
174
|
+
# Build resolution URL
|
175
|
+
uri = URI("https://#{handle}/.well-known/atproto-did")
|
176
|
+
|
177
|
+
# Make HTTP request
|
178
|
+
response = AtprotoAuth.configuration.http_client.get(uri.to_s)
|
179
|
+
did = response[:body].strip
|
180
|
+
|
181
|
+
validate_did!(did)
|
182
|
+
get_did_info(did)
|
183
|
+
end
|
184
|
+
|
185
|
+
def fetch_did_document(did)
|
186
|
+
# Fetch document from PLC directory
|
187
|
+
uri = URI.join(@plc_directory, "/#{did}")
|
188
|
+
response = AtprotoAuth.configuration.http_client.get(uri.to_s)
|
189
|
+
JSON.parse(response[:body])
|
190
|
+
end
|
191
|
+
|
192
|
+
def validate_pds_url!(url)
|
193
|
+
uri = URI(url)
|
194
|
+
return if uri.is_a?(URI::HTTPS)
|
195
|
+
|
196
|
+
raise ResolutionError, "PDS URL must use HTTPS"
|
197
|
+
end
|
198
|
+
|
199
|
+
def normalize_url(url)
|
200
|
+
uri = URI(url)
|
201
|
+
|
202
|
+
# Remove default ports
|
203
|
+
uri.port = nil if (uri.scheme == "https" && uri.port == 443) ||
|
204
|
+
(uri.scheme == "http" && uri.port == 80)
|
205
|
+
|
206
|
+
# Ensure no trailing slash
|
207
|
+
uri.path = uri.path.chomp("/")
|
208
|
+
|
209
|
+
# Remove any query or fragment
|
210
|
+
uri.query = nil
|
211
|
+
uri.fragment = nil
|
212
|
+
|
213
|
+
uri.to_s
|
214
|
+
end
|
215
|
+
|
216
|
+
def logger
|
217
|
+
@logger ||= AtprotoAuth.configuration.logger || Logger.new($stdout)
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AtprotoAuth
|
4
|
+
# Handles identity resolution, verification and management for AT Protocol OAuth.
|
5
|
+
# This module provides functionality to resolve handles to DIDs, verify identity
|
6
|
+
# documents, validate PDS locations, and verify authorization server bindings.
|
7
|
+
#
|
8
|
+
# The module consists of three main components:
|
9
|
+
#
|
10
|
+
# 1. {Document} - Represents and validates AT Protocol DID documents,
|
11
|
+
# handling extraction of crucial service endpoints and verification.
|
12
|
+
#
|
13
|
+
# 2. {Resolver} - Handles resolution of handles to DIDs and fetching of
|
14
|
+
# DID documents, with support for both DNS and HTTP-based resolution.
|
15
|
+
#
|
16
|
+
# 3. {Error} classes - Structured error hierarchy for handling different
|
17
|
+
# types of identity-related failures.
|
18
|
+
module Identity
|
19
|
+
class Error < Error; end
|
20
|
+
class ResolutionError < Error; end
|
21
|
+
class ValidationError < Error; end
|
22
|
+
class DocumentError < Error; end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,203 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AtprotoAuth
|
4
|
+
module PAR
|
5
|
+
# Client for making Pushed Authorization Requests (PAR) according to RFC 9126.
|
6
|
+
# Handles submitting authorization parameters to the PAR endpoint and building
|
7
|
+
# the subsequent authorization URL.
|
8
|
+
#
|
9
|
+
# In AT Protocol OAuth, all authorization requests must first go through PAR.
|
10
|
+
# This means instead of sending authorization parameters directly to the
|
11
|
+
# authorization endpoint, clients:
|
12
|
+
# 1. Submit parameters to the PAR endpoint via POST
|
13
|
+
# 2. Receive a request_uri in response
|
14
|
+
# 3. Use only the request_uri and client_id in the authorization redirect
|
15
|
+
#
|
16
|
+
# @example Basic PAR flow
|
17
|
+
# client = AtprotoAuth::PAR::Client.new(
|
18
|
+
# endpoint: "https://auth.example.com/par"
|
19
|
+
# )
|
20
|
+
#
|
21
|
+
# # Create and submit PAR request using builder pattern
|
22
|
+
# request = AtprotoAuth::PAR::Request.build do |config|
|
23
|
+
# config.client_id = "https://app.example.com/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 = client.submit(request)
|
32
|
+
#
|
33
|
+
# # Build authorization URL using response
|
34
|
+
# auth_url = client.authorization_url(
|
35
|
+
# authorize_endpoint: "https://auth.example.com/authorize",
|
36
|
+
# request_uri: response.request_uri,
|
37
|
+
# client_id: request.client_id
|
38
|
+
# )
|
39
|
+
#
|
40
|
+
# @example With client authentication (confidential clients)
|
41
|
+
# request = AtprotoAuth::PAR::Request.build do |config|
|
42
|
+
# # ... basic parameters ...
|
43
|
+
# config.client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
44
|
+
# config.client_assertion = jwt_token
|
45
|
+
# end
|
46
|
+
#
|
47
|
+
# @example With DPoP proof
|
48
|
+
# request = AtprotoAuth::PAR::Request.build do |config|
|
49
|
+
# # ... basic parameters ...
|
50
|
+
# config.dpop_proof = dpop_proof_jwt
|
51
|
+
# end
|
52
|
+
#
|
53
|
+
# All requests are made using HTTPS and include proper content-type headers.
|
54
|
+
# DPoP proofs can be included for enhanced security. The client validates
|
55
|
+
# all responses and provides clear error messages for any failures.
|
56
|
+
class Client
|
57
|
+
attr_reader :endpoint, :dpop_client, :nonce_manager
|
58
|
+
|
59
|
+
def initialize(endpoint:, dpop_client:)
|
60
|
+
@endpoint = endpoint
|
61
|
+
@dpop_client = dpop_client
|
62
|
+
@nonce_manager = dpop_client.nonce_manager
|
63
|
+
validate_endpoint!
|
64
|
+
end
|
65
|
+
|
66
|
+
# Submits a PAR request, handling DPoP nonce requirements
|
67
|
+
# @param request [Request] The request to submit
|
68
|
+
# @return [Response] The PAR response
|
69
|
+
# @raise [Error] if request fails
|
70
|
+
def submit(request)
|
71
|
+
# Try the initial request
|
72
|
+
response = make_request(request)
|
73
|
+
|
74
|
+
return process_response(response) if response[:status] == 201
|
75
|
+
|
76
|
+
# Handle DPoP nonce requirement
|
77
|
+
if requires_nonce?(response)
|
78
|
+
nonce = extract_nonce(response)
|
79
|
+
store_nonce(nonce)
|
80
|
+
|
81
|
+
# Get stored nonce to verify
|
82
|
+
nonce_manager.get(server_origin)
|
83
|
+
|
84
|
+
# Generate new proof with nonce and retry
|
85
|
+
response = make_request(request)
|
86
|
+
return process_response(response) if response[:status] == 201
|
87
|
+
end
|
88
|
+
|
89
|
+
handle_error_response(response)
|
90
|
+
rescue StandardError => e
|
91
|
+
raise Error, "PAR request failed: #{e.message}"
|
92
|
+
end
|
93
|
+
|
94
|
+
def extract_nonce(response)
|
95
|
+
# Try all possible header key formats
|
96
|
+
headers = response[:headers]
|
97
|
+
nonce = headers["DPoP-Nonce"] ||
|
98
|
+
headers["dpop-nonce"] ||
|
99
|
+
headers["Dpop-Nonce"]
|
100
|
+
|
101
|
+
raise Error, "No DPoP nonce provided in response" unless nonce
|
102
|
+
|
103
|
+
nonce
|
104
|
+
end
|
105
|
+
|
106
|
+
# Builds authorization URL from PAR response
|
107
|
+
# @param authorize_endpoint [String] Authorization endpoint URL
|
108
|
+
# @param request_uri [String] PAR request_uri
|
109
|
+
# @param client_id [String] OAuth client_id
|
110
|
+
# @return [String] Authorization URL
|
111
|
+
def authorization_url(authorize_endpoint:, request_uri:, client_id:)
|
112
|
+
uri = URI(authorize_endpoint)
|
113
|
+
uri.query = encode_params(
|
114
|
+
"request_uri" => request_uri,
|
115
|
+
"client_id" => client_id
|
116
|
+
)
|
117
|
+
uri.to_s
|
118
|
+
end
|
119
|
+
|
120
|
+
private
|
121
|
+
|
122
|
+
def validate_endpoint!
|
123
|
+
uri = URI(@endpoint)
|
124
|
+
raise Error, "endpoint must be HTTPS" unless uri.scheme == "https"
|
125
|
+
rescue URI::InvalidURIError => e
|
126
|
+
raise Error, "invalid endpoint URL: #{e.message}"
|
127
|
+
end
|
128
|
+
|
129
|
+
def make_request(request)
|
130
|
+
# Generate DPoP proof for this request
|
131
|
+
proof = dpop_client.generate_proof(
|
132
|
+
http_method: "POST",
|
133
|
+
http_uri: endpoint,
|
134
|
+
nonce: nonce_manager.get(server_origin)
|
135
|
+
)
|
136
|
+
|
137
|
+
# Build headers including DPoP proof
|
138
|
+
headers = build_headers(request, proof)
|
139
|
+
|
140
|
+
# Make the request
|
141
|
+
AtprotoAuth.configuration.http_client.post(
|
142
|
+
endpoint,
|
143
|
+
body: request.to_form,
|
144
|
+
headers: headers
|
145
|
+
)
|
146
|
+
end
|
147
|
+
|
148
|
+
def build_headers(_request, dpop_proof)
|
149
|
+
{
|
150
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
151
|
+
"DPoP" => dpop_proof
|
152
|
+
}
|
153
|
+
end
|
154
|
+
|
155
|
+
def requires_nonce?(response)
|
156
|
+
body = JSON.parse(response[:body])
|
157
|
+
body["error"] == "use_dpop_nonce"
|
158
|
+
rescue JSON::ParserError
|
159
|
+
false
|
160
|
+
end
|
161
|
+
|
162
|
+
def store_nonce(nonce)
|
163
|
+
nonce_manager.update(nonce: nonce, server_url: server_origin)
|
164
|
+
end
|
165
|
+
|
166
|
+
def server_origin
|
167
|
+
uri = URI(@endpoint)
|
168
|
+
"#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != uri.default_port}"
|
169
|
+
end
|
170
|
+
|
171
|
+
def handle_error_response(response)
|
172
|
+
begin
|
173
|
+
error_data = JSON.parse(response[:body])
|
174
|
+
error_message = error_data["error_description"] || error_data["error"] || "Unknown error"
|
175
|
+
rescue JSON::ParserError
|
176
|
+
error_message = "Invalid response from server"
|
177
|
+
end
|
178
|
+
|
179
|
+
raise Error, "PAR request failed: #{error_message} (status: #{response[:status]})"
|
180
|
+
end
|
181
|
+
|
182
|
+
def process_response(response)
|
183
|
+
raise Error, "unexpected response status: #{response[:status]}" unless response[:status] == 201
|
184
|
+
|
185
|
+
begin
|
186
|
+
data = JSON.parse(response[:body])
|
187
|
+
Response.new(
|
188
|
+
request_uri: data["request_uri"],
|
189
|
+
expires_in: data["expires_in"]
|
190
|
+
)
|
191
|
+
rescue JSON::ParserError => e
|
192
|
+
raise Error, "invalid JSON response: #{e.message}"
|
193
|
+
rescue StandardError => e
|
194
|
+
raise Error, "failed to process response: #{e.message}"
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def encode_params(params)
|
199
|
+
params.map { |k, v| "#{CGI.escape(k)}=#{CGI.escape(v.to_s)}" }.join("&")
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AtprotoAuth
|
4
|
+
module PAR
|
5
|
+
# Generates client authentication JWTs according to RFC 7523 for AT Protocol OAuth.
|
6
|
+
# Creates signed assertions using ES256 with required claims including iss/sub (client_id),
|
7
|
+
# aud (token endpoint), jti (unique ID), and iat/exp (timing claims).
|
8
|
+
class ClientAssertion
|
9
|
+
class Error < AtprotoAuth::Error; end
|
10
|
+
|
11
|
+
# @param client_id [String] OAuth client ID
|
12
|
+
# @param signing_key [JOSE::JWK] Key to sign assertion with
|
13
|
+
def initialize(client_id:, signing_key:)
|
14
|
+
@client_id = client_id
|
15
|
+
@signing_key = signing_key
|
16
|
+
end
|
17
|
+
|
18
|
+
# Generates a new client assertion JWT
|
19
|
+
# @param audience [String] Issuer endpoint URL
|
20
|
+
# @param lifetime [Integer] How long assertion is valid for in seconds
|
21
|
+
# @return [String] Signed JWT assertion
|
22
|
+
# 5 minute default lifetime
|
23
|
+
def generate_jwt(audience:, lifetime: 300)
|
24
|
+
now = Time.now.to_i
|
25
|
+
|
26
|
+
payload = {
|
27
|
+
# Required claims
|
28
|
+
iss: @client_id, # Issuer is client_id
|
29
|
+
sub: @client_id, # Subject is client_id
|
30
|
+
aud: audience, # Audience is token endpoint
|
31
|
+
jti: SecureRandom.uuid, # Unique identifier
|
32
|
+
exp: now + lifetime, # Expiration time
|
33
|
+
iat: now # Issued at time
|
34
|
+
}
|
35
|
+
|
36
|
+
# Header specifying ES256 algorithm for signing
|
37
|
+
header = {
|
38
|
+
alg: "ES256",
|
39
|
+
typ: "JWT",
|
40
|
+
kid: @signing_key.fields["kid"]
|
41
|
+
}
|
42
|
+
|
43
|
+
# Sign and return the JWT
|
44
|
+
JWT.encode(payload, @signing_key.kty.key, "ES256", header)
|
45
|
+
rescue StandardError => e
|
46
|
+
raise Error, "Failed to generate client assertion: #{e.message}"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,140 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AtprotoAuth
|
4
|
+
module PAR
|
5
|
+
# Represents a pushed authorization request
|
6
|
+
class Request
|
7
|
+
# Configuration for request parameters
|
8
|
+
class Configuration
|
9
|
+
attr_accessor :client_id, :redirect_uri, :code_challenge,
|
10
|
+
:code_challenge_method, :state, :scope, :login_hint,
|
11
|
+
:nonce, :dpop_proof, :client_assertion_type,
|
12
|
+
:client_assertion
|
13
|
+
end
|
14
|
+
|
15
|
+
# Required parameters
|
16
|
+
attr_reader :response_type, :client_id, :code_challenge,
|
17
|
+
:code_challenge_method, :state, :redirect_uri, :scope
|
18
|
+
|
19
|
+
# Optional parameters
|
20
|
+
attr_reader :login_hint, :nonce, :dpop_proof
|
21
|
+
|
22
|
+
# Client authentication (for confidential clients)
|
23
|
+
attr_reader :client_assertion_type, :client_assertion
|
24
|
+
|
25
|
+
def self.build
|
26
|
+
config = Configuration.new
|
27
|
+
yield(config)
|
28
|
+
new(config)
|
29
|
+
end
|
30
|
+
|
31
|
+
def initialize(config)
|
32
|
+
# Required parameters
|
33
|
+
@response_type = "code" # Always "code" for AT Protocol OAuth
|
34
|
+
@client_id = config.client_id
|
35
|
+
@redirect_uri = config.redirect_uri
|
36
|
+
@code_challenge = config.code_challenge
|
37
|
+
@code_challenge_method = config.code_challenge_method
|
38
|
+
@state = config.state
|
39
|
+
@scope = config.scope
|
40
|
+
|
41
|
+
# Optional parameters
|
42
|
+
@login_hint = config.login_hint
|
43
|
+
@nonce = config.nonce
|
44
|
+
@dpop_proof = config.dpop_proof
|
45
|
+
|
46
|
+
# Client authentication
|
47
|
+
@client_assertion_type = config.client_assertion_type
|
48
|
+
@client_assertion = config.client_assertion
|
49
|
+
|
50
|
+
validate!
|
51
|
+
end
|
52
|
+
|
53
|
+
# Converts request to form-encoded parameters
|
54
|
+
# @return [String] Form-encoded request body
|
55
|
+
def to_form
|
56
|
+
encode_params(build_params)
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def build_params
|
62
|
+
params = {
|
63
|
+
"response_type" => response_type,
|
64
|
+
"client_id" => client_id,
|
65
|
+
"redirect_uri" => redirect_uri,
|
66
|
+
"code_challenge" => code_challenge,
|
67
|
+
"code_challenge_method" => code_challenge_method,
|
68
|
+
"state" => state,
|
69
|
+
"scope" => scope
|
70
|
+
}
|
71
|
+
|
72
|
+
add_optional_params(params)
|
73
|
+
add_client_auth_params(params)
|
74
|
+
params
|
75
|
+
end
|
76
|
+
|
77
|
+
def add_optional_params(params)
|
78
|
+
params["login_hint"] = login_hint if login_hint
|
79
|
+
params["nonce"] = nonce if nonce
|
80
|
+
end
|
81
|
+
|
82
|
+
def add_client_auth_params(params)
|
83
|
+
return unless client_assertion
|
84
|
+
|
85
|
+
params["client_assertion_type"] = CLIENT_ASSERTION_TYPE
|
86
|
+
params["client_assertion"] = client_assertion
|
87
|
+
end
|
88
|
+
|
89
|
+
def validate!
|
90
|
+
validate_required_params!
|
91
|
+
validate_response_type!
|
92
|
+
validate_code_challenge_method!
|
93
|
+
validate_scope!
|
94
|
+
validate_client_auth!
|
95
|
+
end
|
96
|
+
|
97
|
+
def validate_required_params!
|
98
|
+
%i[client_id redirect_uri code_challenge code_challenge_method state scope].each do |param|
|
99
|
+
value = send(param)
|
100
|
+
raise Error, "#{param} is required" if value.nil? || value.empty?
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def validate_response_type!
|
105
|
+
return if response_type == "code"
|
106
|
+
|
107
|
+
raise Error, "response_type must be 'code'"
|
108
|
+
end
|
109
|
+
|
110
|
+
def validate_code_challenge_method!
|
111
|
+
return if code_challenge_method == "S256"
|
112
|
+
|
113
|
+
raise Error, "code_challenge_method must be 'S256'"
|
114
|
+
end
|
115
|
+
|
116
|
+
def validate_scope!
|
117
|
+
scopes = scope.split
|
118
|
+
raise Error, "atproto scope is required" unless scopes.include?("atproto")
|
119
|
+
end
|
120
|
+
|
121
|
+
def validate_client_auth!
|
122
|
+
# If either auth parameter is present, both must be present and valid
|
123
|
+
has_assertion = !client_assertion.nil?
|
124
|
+
has_type = !client_assertion_type.nil?
|
125
|
+
|
126
|
+
return unless has_assertion || has_type
|
127
|
+
unless client_assertion_type == CLIENT_ASSERTION_TYPE
|
128
|
+
raise Error, "client_assertion_type must be #{CLIENT_ASSERTION_TYPE}"
|
129
|
+
end
|
130
|
+
|
131
|
+
raise Error, "client_assertion required with client_assertion_type" if client_assertion.nil?
|
132
|
+
raise Error, "client_assertion_type required with client_assertion" if client_assertion_type.nil?
|
133
|
+
end
|
134
|
+
|
135
|
+
def encode_params(params)
|
136
|
+
params.map { |k, v| "#{CGI.escape(k)}=#{CGI.escape(v.to_s)}" }.join("&")
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AtprotoAuth
|
4
|
+
module PAR
|
5
|
+
# Represents a PAR response
|
6
|
+
class Response
|
7
|
+
attr_reader :request_uri, :expires_in
|
8
|
+
|
9
|
+
def initialize(request_uri:, expires_in:)
|
10
|
+
@request_uri = request_uri
|
11
|
+
@expires_in = expires_in.to_i
|
12
|
+
validate!
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def validate!
|
18
|
+
raise Error, "request_uri is required" if request_uri.nil? || request_uri.empty?
|
19
|
+
raise Error, "expires_in must be positive" unless expires_in.positive?
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
require "base64"
|
5
|
+
require "openssl"
|
6
|
+
|
7
|
+
module AtprotoAuth
|
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 = AtprotoAuth::PAR::Client.new(endpoint: "https://auth.example.com/par")
|
18
|
+
#
|
19
|
+
# request = par.create_request(
|
20
|
+
# client_id: "https://app.example.com/client-metadata.json",
|
21
|
+
# redirect_uri: "https://app.example.com/callback",
|
22
|
+
# code_challenge: "abc123...",
|
23
|
+
# code_challenge_method: "S256",
|
24
|
+
# state: "xyz789...",
|
25
|
+
# scope: "atproto"
|
26
|
+
# )
|
27
|
+
#
|
28
|
+
# response = par.submit(request)
|
29
|
+
# auth_url = par.authorization_url(
|
30
|
+
# authorize_endpoint: "https://auth.example.com/authorize",
|
31
|
+
# request_uri: response.request_uri,
|
32
|
+
# client_id: request.client_id
|
33
|
+
# )
|
34
|
+
module PAR
|
35
|
+
# Error raised for PAR-related issues
|
36
|
+
class Error < AtprotoAuth::Error; end
|
37
|
+
|
38
|
+
CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
39
|
+
end
|
40
|
+
end
|