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,583 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'didkit/did'
4
+ require 'jose'
5
+ require 'json'
6
+ require 'uri'
7
+
8
+ require_relative 'client_metadata'
9
+ require_relative 'configuration'
10
+ require_relative 'dpop/client'
11
+ require_relative 'dpop/key_manager'
12
+ require_relative 'errors'
13
+ require_relative 'par'
14
+ require_relative 'par/client'
15
+ require_relative 'par/client_assertion'
16
+ require_relative 'par/request'
17
+ require_relative 'server_metadata/authorization_server'
18
+ require_relative 'server_metadata/resource_server'
19
+ require_relative 'state/session_manager'
20
+ require_relative 'state/token_set'
21
+ require_relative 'storage/key_builder'
22
+ require_relative 'token/refresh'
23
+
24
+ module AuthProto
25
+
26
+ #
27
+ # Main client class for AT Protocol OAuth implementation. Handles the complete
28
+ # OAuth flow including authorization, token management, and identity verification.
29
+ #
30
+
31
+ class Client
32
+
33
+ # Error raised when authorization callback fails
34
+ class CallbackError < Error; end
35
+
36
+ # Error raised when token operations fail
37
+ class TokenError < Error; end
38
+
39
+ #
40
+ # Error raised when session operations fail
41
+ #
42
+
43
+ class SessionError < Error
44
+
45
+ # Identifier of the session associated with the failure, if known.
46
+ # @return [String, nil]
47
+ attr_reader :session_id
48
+
49
+ # Creates a session error with an optional session identifier.
50
+ #
51
+ # @param message [String] explanation of the failure
52
+ # @param session_id [String, nil] associated session ID
53
+ #
54
+ def initialize(message, session_id: nil)
55
+ @session_id = session_id
56
+ super(message)
57
+ end
58
+ end
59
+
60
+ # OAuth client ID URL.
61
+ # @return [String]
62
+ attr_reader :client_id
63
+
64
+ # Callback URI registered in the client metadata.
65
+ # @return [String]
66
+ attr_reader :redirect_uri
67
+
68
+ # Validated client metadata.
69
+ # @return [ClientMetadata]
70
+ attr_reader :client_metadata
71
+
72
+ # Manager for stored OAuth sessions.
73
+ # @return [State::SessionManager]
74
+ attr_reader :session_manager
75
+
76
+ # DPoP key, proof and nonce manager.
77
+ # @return [DPoP::Client]
78
+ attr_reader :dpop_client
79
+
80
+ # Creates an OAuth client with its own DPoP key and session manager.
81
+ #
82
+ # Configure {AuthProto.configure} with an HTTP transport before fetching metadata
83
+ # or starting authorization. Reuse `dpop_key` when restoring tokens bound to that key.
84
+ #
85
+ # @param client_id [String] OAuth client ID URL
86
+ # @param redirect_uri [String] callback URI listed in `redirect_uris`
87
+ # @param metadata [Hash{String => Object}, nil] preloaded metadata, or `nil` to fetch `client_id`
88
+ # @param dpop_key [Hash{String => Object}, nil] existing private DPoP JWK, or `nil` to generate one
89
+ #
90
+ # @raise [InvalidClientMetadata] if client metadata is invalid or unavailable
91
+ # @raise [Error] if the redirect URI is not registered
92
+ # @raise [DPoP::KeyManager::KeyError] if the DPoP key cannot be imported or generated
93
+ # @raise [HTTPClient::HTTPError, HTTPClient::SSRFError] if fetching client metadata fails
94
+ #
95
+ def initialize(client_id:, redirect_uri:, metadata: nil, dpop_key: nil)
96
+ @client_id = client_id
97
+ @redirect_uri = redirect_uri
98
+
99
+ # Initialize core dependencies
100
+ @client_metadata = load_client_metadata(metadata)
101
+ validate_redirect_uri
102
+
103
+ @session_manager = State::SessionManager.new
104
+ @dpop_client = initialize_dpop(dpop_key)
105
+ end
106
+
107
+ # Starts authorization, stores a pending session, and submits a PAR request.
108
+ #
109
+ # Supply exactly one of `handle` or `pds_url`. Open the returned `:url` in a browser
110
+ # and pass the eventual callback parameters to {#handle_callback}.
111
+ #
112
+ # @param handle [String, nil] account handle (optionally prefixed with `@`) or DID
113
+ # @param pds_url [String, nil] PDS URL when no handle is supplied
114
+ # @param scope [String] space-separated scopes, including `atproto`
115
+ #
116
+ # @return [Hash{Symbol => String}] `:url` is the browser URL; `:session_id` identifies the pending session
117
+ # @raise [Error] if identity arguments or scope are invalid, the handle cannot be verified, or no HTTPS PDS is found
118
+ # @raise [DIDKit::DIDError, DIDKit::FormatError, DIDKit::APIError] if DID validation or document fetching fails
119
+ # @raise [InvalidAuthorizationServer] if server discovery or metadata validation fails
120
+ # @raise [PAR::Error] if the pushed authorization request fails
121
+ # @raise [PAR::ClientAssertion::Error] if confidential-client signing fails
122
+ # @raise [DPoP::Client::Error] if generating the PAR proof fails
123
+ # @raise [HTTPClient::SSRFError] if server discovery rejects a destination
124
+ # @raise [Storage::StorageError] if session storage or locking fails
125
+ # @raise [Encryption::EncryptionError] if session serialization cannot encrypt secrets
126
+ #
127
+ def authorize(handle: nil, pds_url: nil, scope: "atproto")
128
+ validate_auth_params(handle, pds_url, scope)
129
+
130
+ # Create new session
131
+ session = session_manager.create_session(
132
+ client_id: client_id,
133
+ scope: scope
134
+ )
135
+
136
+ # Store session with storage backend
137
+ session_manager.update_session(session)
138
+
139
+ # Resolve identity and authorization server if handle provided
140
+ if handle
141
+ auth_info = resolve_from_handle(handle, session)
142
+ elsif pds_url
143
+ auth_info = resolve_from_pds(pds_url, session)
144
+ else
145
+ raise Error, "Either handle or pds_url must be provided"
146
+ end
147
+
148
+ # Generate authorization URL
149
+ auth_url = generate_authorization_url(
150
+ auth_info[:server],
151
+ session,
152
+ login_hint: handle
153
+ )
154
+
155
+ {
156
+ url: auth_url,
157
+ session_id: session.session_id
158
+ }
159
+ end
160
+
161
+ # Validates a callback, exchanges its code, and stores the authenticated session.
162
+ #
163
+ # The result contains `:access_token` (String), `:token_type` (String, `DPoP`),
164
+ # `:expires_in` (Integer seconds remaining), `:refresh_token` (String or nil),
165
+ # and `:scope` (String).
166
+ # It also contains `:session_id` (String) and `:did` (String).
167
+ #
168
+ # @param code [String] authorization code from the callback
169
+ # @param state [String] state token from the callback
170
+ # @param iss [String] issuer URL from the callback
171
+ #
172
+ # @return [Hash{Symbol => String, Integer, nil}] tokens and authenticated session details
173
+ # @raise [CallbackError] if state is unknown or the issuer does not match the session
174
+ # @raise [TokenError] if token exchange or response validation fails
175
+ # @raise [DPoP::Client::Error, PAR::ClientAssertion::Error] if proof/assertion generation fails
176
+ # @raise [HTTPClient::HTTPError, HTTPClient::SSRFError] if token transport fails
177
+ # @raise [State::SessionError] if token subject conflicts with the session DID
178
+ # @raise [ArgumentError] if returned token fields fail token-set validation
179
+ # @raise [Storage::StorageError] if session storage or locking fails
180
+ # @raise [Encryption::EncryptionError] if tokens cannot be encrypted for storage
181
+ #
182
+ def handle_callback(code:, state:, iss:)
183
+ # Find and validate session
184
+ session = session_manager.get_session_by_state(state)
185
+ raise CallbackError, "Invalid state parameter" unless session
186
+
187
+ # Verify issuer matches session
188
+ raise CallbackError, "Issuer mismatch" unless session.auth_server && session.auth_server.issuer == iss
189
+
190
+ AuthProto.storage.with_lock(Storage::KeyBuilder.lock_key("session", session.session_id), ttl: 30) do
191
+ # Exchange code for tokens
192
+ token_response = exchange_code(
193
+ code: code,
194
+ session: session
195
+ )
196
+
197
+ # Validate token response
198
+ validate_token_response(token_response, session)
199
+
200
+ # Create token set and store in session
201
+ token_set = State::TokenSet.new(
202
+ access_token: token_response["access_token"],
203
+ token_type: token_response["token_type"],
204
+ expires_in: token_response["expires_in"],
205
+ refresh_token: token_response["refresh_token"],
206
+ scope: token_response["scope"],
207
+ sub: token_response["sub"]
208
+ )
209
+ session.tokens = token_set
210
+
211
+ # Update stored session
212
+ session_manager.update_session(session)
213
+
214
+ {
215
+ access_token: token_set.access_token,
216
+ token_type: token_set.token_type,
217
+ expires_in: (token_set.expires_at - Time.now).to_i,
218
+ refresh_token: token_set.refresh_token,
219
+ scope: token_set.scope,
220
+ session_id: session.session_id,
221
+ did: session.did
222
+ }
223
+ end
224
+ end
225
+
226
+ # Reads tokens for a session with a currently valid access token.
227
+ #
228
+ # The result contains `:access_token` (String), `:token_type` (String, `DPoP`),
229
+ # `:expires_in` (Integer seconds remaining), `:refresh_token` (String or nil),
230
+ # and `:scope` (String).
231
+ # This method does not refresh expired tokens.
232
+ #
233
+ # @param session_id [String] session identifier
234
+ # @return [Hash{Symbol => String, Integer, nil}, nil] token details, or `nil` for missing/unauthorized sessions
235
+ #
236
+ def get_tokens(session_id)
237
+ session = session_manager.get_session(session_id)
238
+ return nil unless session&.authorized?
239
+
240
+ {
241
+ access_token: session.tokens.access_token,
242
+ token_type: session.tokens.token_type,
243
+ expires_in: (session.tokens.expires_at - Time.now).to_i,
244
+ refresh_token: session.tokens.refresh_token,
245
+ scope: session.tokens.scope
246
+ }
247
+ end
248
+
249
+ # Refreshes a renewable session and persists the replacement tokens.
250
+ #
251
+ # The result contains `:access_token` (String), `:token_type` (String, `DPoP`),
252
+ # `:expires_in` (Integer seconds remaining), `:refresh_token` (String or nil),
253
+ # and `:scope` (String).
254
+ # The result also contains `:session_id` (String). An expired access token can
255
+ # still be refreshed when the stored session has a refresh token.
256
+ #
257
+ # @param session_id [String] session to refresh
258
+ # @return [Hash{Symbol => String, Integer, nil}] replacement tokens and session ID
259
+ # @raise [TokenError] if the session is missing or has no refresh token
260
+ # @raise [Token::RefreshError] if token refresh fails or exhausts retries
261
+ # @raise [State::SessionError] if replacement tokens have a different subject
262
+ # @raise [Storage::StorageError] if session storage or locking fails
263
+ # @raise [Encryption::EncryptionError] if replacement tokens cannot be stored
264
+ #
265
+ def refresh_token(session_id)
266
+ session = session_manager.get_session(session_id)
267
+ raise TokenError, "Invalid session" unless session
268
+ raise TokenError, "Session not authorized" unless session.renewable?
269
+
270
+ AuthProto.storage.with_lock(Storage::KeyBuilder.lock_key("session", session.session_id), ttl: 30) do
271
+ refresher = Token::Refresh.new(
272
+ session: session,
273
+ dpop_client: @dpop_client,
274
+ auth_server: session.auth_server,
275
+ client_metadata: client_metadata
276
+ )
277
+
278
+ new_tokens = refresher.perform
279
+ session.tokens = new_tokens
280
+
281
+ # Update stored session
282
+ session_manager.update_session(session)
283
+
284
+ {
285
+ access_token: new_tokens.access_token,
286
+ token_type: new_tokens.token_type,
287
+ expires_in: (new_tokens.expires_at - Time.now).to_i,
288
+ refresh_token: new_tokens.refresh_token,
289
+ scope: new_tokens.scope,
290
+ session_id: session.session_id
291
+ }
292
+ end
293
+ end
294
+
295
+ # Checks if a session has valid tokens
296
+ #
297
+ # @param session_id [String] ID of session to check
298
+ # @return [Boolean] true if session exists and has valid tokens
299
+ #
300
+ def authorized?(session_id)
301
+ session = session_manager.get_session(session_id)
302
+ session&.authorized? || false
303
+ end
304
+
305
+ # Builds authorization headers and a fresh DPoP proof for one HTTP request.
306
+ #
307
+ # The caller sends the request. This method does not refresh tokens, send HTTP,
308
+ # or retry nonce challenges. Feed response headers to {DPoP::Client#process_response}
309
+ # and call {#refresh_token} explicitly when needed. Generate new headers for each retry.
310
+ #
311
+ # @param session_id [String] authorized session ID
312
+ # @param method [String] HTTP method for the proof
313
+ # @param url [String] absolute request URL without a query string or fragment
314
+ #
315
+ # @return [Hash{String => String}] `Authorization: DPoP ...` and `DPoP` proof headers
316
+ # @raise [TokenError] if the session is missing or its access token is expired
317
+ # @raise [DPoP::Client::Error] if proof generation or nonce lookup fails
318
+ #
319
+ def auth_headers(session_id:, method:, url:)
320
+ session = session_manager.get_session(session_id)
321
+ raise TokenError, "Invalid session" unless session
322
+ raise TokenError, "Session not authorized" unless session.authorized?
323
+
324
+ # Generate DPoP proof
325
+ proof = dpop_client.generate_proof(
326
+ http_method: method,
327
+ http_uri: url,
328
+ access_token: session.tokens.access_token
329
+ )
330
+
331
+ {
332
+ "Authorization" => "DPoP #{session.tokens.access_token}",
333
+ "DPoP" => proof
334
+ }
335
+ end
336
+
337
+ # Removes a stored session and its state-token lookup.
338
+ #
339
+ # @param session_id [String] session identifier
340
+ # @return [void]
341
+ # @raise [Storage::StorageError] if deletion or locking fails
342
+ #
343
+ def remove_session(session_id)
344
+ key = Storage::KeyBuilder.session_key(session_id)
345
+ AuthProto.storage.delete(key)
346
+ session_manager.remove_session(session_id)
347
+ end
348
+
349
+ # Invokes the session manager's expiry cleanup hook.
350
+ #
351
+ # Currently a no-op; expiry is checked during retrieval and by storage TTLs.
352
+ #
353
+ # @return [void]
354
+ #
355
+ def cleanup_expired_sessions
356
+ session_manager.cleanup_expired
357
+ end
358
+
359
+ private
360
+
361
+ def load_client_metadata(metadata)
362
+ if metadata
363
+ ClientMetadata.new(metadata)
364
+ else
365
+ ClientMetadata.from_url(@client_id)
366
+ end
367
+ end
368
+
369
+ def validate_redirect_uri
370
+ valid = @client_metadata.redirect_uris.include?(@redirect_uri)
371
+ raise Error, "redirect_uri not found in client metadata" unless valid
372
+ end
373
+
374
+ def initialize_dpop(key)
375
+ key_manager = if key
376
+ DPoP::KeyManager.from_jwk(key)
377
+ else
378
+ DPoP::KeyManager.new
379
+ end
380
+
381
+ DPoP::Client.new(key_manager: key_manager)
382
+ end
383
+
384
+ def validate_auth_params(handle, pds_url, scope)
385
+ raise Error, "scope must include 'atproto'" unless scope.split.include?("atproto")
386
+ raise Error, "handle or pds_url must be provided" if handle.nil? && pds_url.nil?
387
+ raise Error, "cannot provide both handle and pds_url" if handle && pds_url
388
+ end
389
+
390
+ def resolve_from_handle(handle, session)
391
+ did = DIDKit::DID.resolve_handle(handle)
392
+ raise Error, "Could not resolve or verify identity #{handle}" unless did
393
+
394
+ pds_url = did.document.pds_endpoint
395
+ raise Error, "No HTTPS PDS location found for #{did}" unless pds_url && URI(pds_url).is_a?(URI::HTTPS)
396
+
397
+ session.did = did.to_s
398
+
399
+ # Get authorization server from PDS
400
+ server = resolve_auth_server(pds_url)
401
+ session.authorization_server = server
402
+
403
+ # Update stored session
404
+ session_manager.update_session(session)
405
+
406
+ { server: server, pds: pds_url }
407
+ end
408
+
409
+ def resolve_from_pds(pds_url, session)
410
+ # Get authorization server from PDS
411
+ server = resolve_auth_server(pds_url)
412
+ session.authorization_server = server
413
+
414
+ # Update stored session
415
+ session_manager.update_session(session)
416
+
417
+ { server: server, pds: pds_url }
418
+ end
419
+
420
+ def resolve_auth_server(pds_url)
421
+ # Get resource server metadata
422
+ resource_server = ServerMetadata::ResourceServer.from_url(pds_url)
423
+ auth_server_url = resource_server.authorization_servers.first
424
+
425
+ # Get and validate authorization server metadata
426
+ ServerMetadata::AuthorizationServer.from_issuer(auth_server_url)
427
+ end
428
+
429
+ def generate_authorization_url(auth_server, session, login_hint: nil)
430
+ # Create PAR client
431
+ par_client = PAR::Client.new(
432
+ endpoint: auth_server.pushed_authorization_request_endpoint,
433
+ dpop_client: @dpop_client
434
+ )
435
+
436
+ # Build PAR request
437
+ par_request = PAR::Request.build do |config|
438
+ config.client_id = client_id
439
+ config.redirect_uri = redirect_uri
440
+ config.state = session.state_token
441
+ config.scope = session.scope
442
+ config.login_hint = login_hint if login_hint
443
+
444
+ # Add PKCE parameters
445
+ config.code_challenge = session.pkce_challenge
446
+ config.code_challenge_method = "S256"
447
+
448
+ # Public clients use PKCE and DPoP without client authentication.
449
+ if client_metadata.confidential?
450
+ signing_key = JOSE::JWK.from_map(client_metadata.jwks.fetch("keys").first)
451
+ client_assertion = PAR::ClientAssertion.new(client_id: client_id, signing_key: signing_key)
452
+ config.client_assertion = client_assertion.generate_jwt(audience: auth_server.issuer)
453
+ config.client_assertion_type = PAR::CLIENT_ASSERTION_TYPE
454
+ end
455
+
456
+ # Add DPoP proof
457
+ proof = @dpop_client.generate_proof(
458
+ http_method: "POST",
459
+ http_uri: auth_server.pushed_authorization_request_endpoint
460
+ )
461
+ config.dpop_proof = proof
462
+ end
463
+
464
+ # Submit PAR request
465
+ response = par_client.submit(par_request)
466
+
467
+ # Build final authorization URL
468
+ par_client.authorization_url(
469
+ authorize_endpoint: auth_server.authorization_endpoint,
470
+ request_uri: response.request_uri,
471
+ client_id: client_id
472
+ )
473
+ end
474
+
475
+ def exchange_code(code:, session:)
476
+ # Initial token request without nonce
477
+ response = make_token_request(code, session)
478
+
479
+ # Handle DPoP nonce requirement
480
+ if requires_dpop_nonce?(response)
481
+ # Extract and store nonce from error response
482
+ extract_dpop_nonce(response)
483
+ dpop_client.process_response(response[:headers], session.auth_server.issuer)
484
+
485
+ # Retry request with nonce
486
+ response = make_token_request(code, session)
487
+ end
488
+
489
+ raise TokenError, "Token request failed: #{response[:status]}" unless response[:status] == 200
490
+
491
+ begin
492
+ JSON.parse(response[:body])
493
+ rescue JSON::ParserError => e
494
+ raise TokenError, "Invalid token response: #{e.message}"
495
+ end
496
+ end
497
+
498
+ def make_token_request(code, session)
499
+ # Generate proof
500
+ proof = dpop_client.generate_proof(
501
+ http_method: "POST",
502
+ http_uri: session.auth_server.token_endpoint
503
+ )
504
+
505
+ body = {
506
+ grant_type: "authorization_code",
507
+ code: code,
508
+ redirect_uri: redirect_uri,
509
+ client_id: client_id,
510
+ code_verifier: session.pkce_verifier
511
+ }
512
+
513
+ # Add client authentication
514
+ if client_metadata.confidential?
515
+ signing_key = JOSE::JWK.from_map(client_metadata.jwks["keys"].first)
516
+ client_assertion = PAR::ClientAssertion.new(
517
+ client_id: client_id,
518
+ signing_key: signing_key
519
+ )
520
+
521
+ body.merge!(
522
+ client_assertion_type: PAR::CLIENT_ASSERTION_TYPE,
523
+ client_assertion: client_assertion.generate_jwt(
524
+ audience: session.auth_server.issuer
525
+ )
526
+ )
527
+ end
528
+
529
+ AuthProto.configuration.http_client.post(
530
+ session.auth_server.token_endpoint,
531
+ body: body,
532
+ headers: {
533
+ "Content-Type" => "application/x-www-form-urlencoded",
534
+ "DPoP" => proof
535
+ }
536
+ )
537
+ end
538
+
539
+ def requires_dpop_nonce?(response)
540
+ return false unless response[:status] == 400
541
+
542
+ error_data = JSON.parse(response[:body])
543
+ error_data["error"] == "use_dpop_nonce"
544
+ rescue JSON::ParserError
545
+ false
546
+ end
547
+
548
+ def extract_dpop_nonce(response)
549
+ headers = response[:headers]
550
+ nonce = headers["DPoP-Nonce"] ||
551
+ headers["dpop-nonce"] ||
552
+ headers["Dpop-Nonce"]
553
+
554
+ raise TokenError, "No DPoP nonce provided in error response" unless nonce
555
+
556
+ nonce
557
+ end
558
+
559
+ def validate_token_response(response, session)
560
+ # Required fields
561
+ %w[access_token token_type expires_in scope sub].each do |field|
562
+ raise TokenError, "Missing #{field} in token response" unless response[field]
563
+ end
564
+
565
+ # Token type must be DPoP
566
+ raise TokenError, "Invalid token_type: #{response["token_type"]}" unless response["token_type"] == "DPoP"
567
+
568
+ # Scope must include atproto
569
+ raise TokenError, "Missing atproto scope in token response" unless response["scope"].split.include?("atproto")
570
+
571
+ # If we have a pre-resolved DID, verify it matches
572
+ raise TokenError, "Subject mismatch in token response" if session.did && session.did != response["sub"]
573
+
574
+ # Process DPoP-Nonce from response headers if present
575
+ return unless response[:headers] && response[:headers]["DPoP-Nonce"]
576
+
577
+ dpop_client.process_response(
578
+ response[:headers],
579
+ session.auth_server.issuer
580
+ )
581
+ end
582
+ end
583
+ end