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.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +16 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +179 -0
  6. data/Rakefile +16 -0
  7. data/examples/confidential_client/Gemfile +12 -0
  8. data/examples/confidential_client/Gemfile.lock +84 -0
  9. data/examples/confidential_client/README.md +110 -0
  10. data/examples/confidential_client/app.rb +136 -0
  11. data/examples/confidential_client/config/client-metadata.json +25 -0
  12. data/examples/confidential_client/config.ru +4 -0
  13. data/examples/confidential_client/public/client-metadata.json +24 -0
  14. data/examples/confidential_client/public/styles.css +70 -0
  15. data/examples/confidential_client/scripts/generate_keys.rb +15 -0
  16. data/examples/confidential_client/views/authorized.erb +29 -0
  17. data/examples/confidential_client/views/index.erb +44 -0
  18. data/examples/confidential_client/views/layout.erb +11 -0
  19. data/lib/atproto_auth/client.rb +410 -0
  20. data/lib/atproto_auth/client_metadata.rb +264 -0
  21. data/lib/atproto_auth/configuration.rb +17 -0
  22. data/lib/atproto_auth/dpop/client.rb +122 -0
  23. data/lib/atproto_auth/dpop/key_manager.rb +235 -0
  24. data/lib/atproto_auth/dpop/nonce_manager.rb +138 -0
  25. data/lib/atproto_auth/dpop/proof_generator.rb +112 -0
  26. data/lib/atproto_auth/errors.rb +47 -0
  27. data/lib/atproto_auth/http_client.rb +227 -0
  28. data/lib/atproto_auth/identity/document.rb +104 -0
  29. data/lib/atproto_auth/identity/resolver.rb +221 -0
  30. data/lib/atproto_auth/identity.rb +24 -0
  31. data/lib/atproto_auth/par/client.rb +203 -0
  32. data/lib/atproto_auth/par/client_assertion.rb +50 -0
  33. data/lib/atproto_auth/par/request.rb +140 -0
  34. data/lib/atproto_auth/par/response.rb +23 -0
  35. data/lib/atproto_auth/par.rb +40 -0
  36. data/lib/atproto_auth/pkce.rb +105 -0
  37. data/lib/atproto_auth/server_metadata/authorization_server.rb +175 -0
  38. data/lib/atproto_auth/server_metadata/origin_url.rb +51 -0
  39. data/lib/atproto_auth/server_metadata/resource_server.rb +71 -0
  40. data/lib/atproto_auth/server_metadata.rb +24 -0
  41. data/lib/atproto_auth/state/session.rb +117 -0
  42. data/lib/atproto_auth/state/session_manager.rb +75 -0
  43. data/lib/atproto_auth/state/token_set.rb +68 -0
  44. data/lib/atproto_auth/state.rb +54 -0
  45. data/lib/atproto_auth/version.rb +5 -0
  46. data/lib/atproto_auth.rb +56 -0
  47. data/sig/atproto_auth/client_metadata.rbs +95 -0
  48. data/sig/atproto_auth/dpop/client.rbs +38 -0
  49. data/sig/atproto_auth/dpop/key_manager.rbs +33 -0
  50. data/sig/atproto_auth/dpop/nonce_manager.rbs +48 -0
  51. data/sig/atproto_auth/dpop/proof_generator.rbs +42 -0
  52. data/sig/atproto_auth/http_client.rbs +58 -0
  53. data/sig/atproto_auth/identity/document.rbs +31 -0
  54. data/sig/atproto_auth/identity/resolver.rbs +41 -0
  55. data/sig/atproto_auth/par/client.rbs +31 -0
  56. data/sig/atproto_auth/par/request.rbs +73 -0
  57. data/sig/atproto_auth/par/response.rbs +17 -0
  58. data/sig/atproto_auth/pkce.rbs +24 -0
  59. data/sig/atproto_auth/server_metadata/authorization_server.rbs +69 -0
  60. data/sig/atproto_auth/server_metadata/origin_url.rbs +21 -0
  61. data/sig/atproto_auth/server_metadata/resource_server.rbs +27 -0
  62. data/sig/atproto_auth/state/session.rbs +50 -0
  63. data/sig/atproto_auth/state/session_manager.rbs +26 -0
  64. data/sig/atproto_auth/state/token_set.rbs +40 -0
  65. data/sig/atproto_auth/version.rbs +3 -0
  66. data/sig/atproto_auth.rbs +39 -0
  67. 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