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,70 @@
1
+ .container {
2
+ max-width: 800px;
3
+ margin: 0 auto;
4
+ padding: 20px;
5
+ }
6
+
7
+ .header {
8
+ max-width: 400px;
9
+ margin: 0 auto;
10
+ }
11
+
12
+ .error {
13
+ background-color: #ffebee;
14
+ color: #c62828;
15
+ padding: 10px;
16
+ margin-bottom: 20px;
17
+ border-radius: 4px;
18
+ }
19
+
20
+ .auth-form {
21
+ max-width: 400px;
22
+ margin: 40px auto;
23
+ }
24
+
25
+ .form-group {
26
+ margin-bottom: 15px;
27
+ }
28
+
29
+ .form-group label {
30
+ display: block;
31
+ margin-bottom: 5px;
32
+ }
33
+
34
+ .form-group input {
35
+ width: 100%;
36
+ padding: 8px;
37
+ border: 1px solid #ddd;
38
+ border-radius: 4px;
39
+ }
40
+
41
+ button {
42
+ background: #2196f3;
43
+ color: white;
44
+ padding: 10px 20px;
45
+ border: none;
46
+ border-radius: 4px;
47
+ cursor: pointer;
48
+ }
49
+
50
+ button:hover {
51
+ background: #1976d2;
52
+ }
53
+
54
+ .token-info,
55
+ .api-test {
56
+ margin-top: 20px;
57
+ padding: 15px;
58
+ background: #f5f5f5;
59
+ border-radius: 4px;
60
+ }
61
+
62
+ pre {
63
+ overflow-x: auto;
64
+ white-space: pre-wrap;
65
+ word-wrap: break-word;
66
+ }
67
+
68
+ code {
69
+ font-size: 14px;
70
+ }
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jose"
4
+
5
+ # Generate a new EC key using P-256 curve
6
+ keypair = JOSE::JWK.generate_key([:ec, "P-256"])
7
+
8
+ # Get the key as a hash (need to convert from JOSE::Map)
9
+ jwk = keypair.to_map.to_h
10
+
11
+ # Pretty print the JWK to stdout
12
+ require "json"
13
+ puts JSON.pretty_generate({
14
+ keys: [jwk]
15
+ })
@@ -0,0 +1,29 @@
1
+ <div class="flex flex-col items-center md:flex md:flex-row md:justify-stretch md:items-center min-h-screen min-w-screen bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
2
+ <div class="px-6 pt-4 md:max-w-lg md:grid md:content-center md:justify-items-end md:self-stretch md:w-1/2 md:max-w-fix md:p-4 md:text-right md:dark:border-r md:dark:border-gray-700 md:bg-gray-100 md:dark:bg-gray-800">
3
+ <h1 class="text-xl md:text-2xl lg:text-5xl md:mt-4 mb-4 font-semibold text-brand">Successfully Authenticated!</h1>
4
+ </div>
5
+ <div class="w-full px-6 md:max-w-3xl md:px-12">
6
+ <div class="space-y-8">
7
+ <% if session[:error] %>
8
+ <div class="mb-8 bg-red-50 border-red-700 text-red-700 p-4 rounded-md">
9
+ <%= session[:error] %>
10
+ <% session[:error] = nil %>
11
+ </div>
12
+ <% end %>
13
+
14
+ <div class="token-info">
15
+ <p class="mb-1 text-gray-600 dark:text-gray-400 text-sm font-medium">Token Information</p>
16
+ <pre class="rounded-lg text-gray-700 dark:text-gray-100 bg-gray-100 dark:bg-slate-800 border border-gray-400 p-2 overflow-auto"><code><%= JSON.pretty_generate(session[:tokens]) %></code></pre>
17
+ </div>
18
+
19
+ <div class="api-test">
20
+ <p class="mb-1 text-gray-600 dark:text-gray-400 text-sm font-medium">Test API Call Result</p>
21
+ <pre class="rounded-lg text-gray-700 dark:text-gray-100 bg-gray-100 dark:bg-slate-800 border border-gray-400 p-2 overflow-auto"><code><%= JSON.pretty_generate(@api_result) %></code></pre>
22
+ </div>
23
+
24
+ <div class="flex">
25
+ <a href="/signout" aria-label="Signout" class="py-2 px-6 rounded-lg truncate cursor-pointer touch-manipulation tracking-wide overflow-hidden bg-blue-400 text-white">Sign out</a>
26
+ </div>
27
+ </div>
28
+ </div>
29
+ </div>
@@ -0,0 +1,44 @@
1
+ <div class="flex flex-col items-center md:flex md:flex-row md:justify-stretch md:items-center min-h-screen min-w-screen bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
2
+ <div class="px-6 pt-4 md:max-w-lg md:grid md:content-center md:justify-items-end md:self-stretch md:w-1/2 md:max-w-fix md:p-4 md:text-right md:dark:border-r md:dark:border-gray-700 md:bg-gray-100 md:dark:bg-gray-800">
3
+ <h1 class="text-xl md:text-2xl lg:text-5xl md:mt-4 mb-4 font-semibold text-brand">Sign in</h1>
4
+ <p class="hidden md:block max-w-xs text-gray-500 dark:text-gray-500">Enter your AT Protocol handle</p>
5
+ </div>
6
+ <div class="w-full px-6 md:max-w-3xl md:px-12">
7
+ <form method="post" action="/auth" class="flex flex-col py-4 space-y-4">
8
+ <div class="space-y-4">
9
+ <% if session[:error] %>
10
+ <div class="mb-8 bg-red-50 border-red-700 text-red-700 p-4 rounded-md">
11
+ <%= session[:error] %>
12
+ <% session[:error] = nil %>
13
+ </div>
14
+ <% end %>
15
+ <% if session[:notice] %>
16
+ <div class="mb-8 bg-yellow-50 border-yellow-700 text-yellow-700 p-4 rounded-md">
17
+ <%= session[:notice] %>
18
+ <% session[:notice] = nil %>
19
+ </div>
20
+ <% end %>
21
+ <fieldset>
22
+ <p class="mb-1 text-gray-600 dark:text-gray-400 text-sm font-medium">Handle</p>
23
+ <div class="flex flex-col space-y-4">
24
+ <div class="pl-1 pr-2 min-h-12 flex items-center justify-stretch rounded-lg text-gray-700 dark:text-gray-100 bg-gray-100 has-[:focus]:bg-gray-200 dark:bg-gray-800 dark:has-[:focus]:bg-gray-700 outline-none border-solid border-2 border-transparent hover:border-gray-400 has-[:focus]:border-brand hover:has-[:focus]:border-brand dark:hover:border-gray-500">
25
+ <div class="self-start shrink-0 grow-0 w-8 h-12 flex items-center justify-center text-gray-500">
26
+ <div>
27
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-5">
28
+ <path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z"></path>
29
+ </svg>
30
+ </div>
31
+ </div>
32
+ <div class="relative">
33
+ <input class="w-full bg-transparent bg-clip-padding text-base text-inherit outline-none dark:placeholder-gray-500" name="handle" type="text" placeholder="handle.bsky.social" aria-label="Handle" autocapitalize="none" autocorrect="off" autocomplete="username" spellcheck="false" dir="auto" enterkeyhint="next" required title="valid handle">
34
+ </div>
35
+ </div>
36
+ </div>
37
+ </fieldset>
38
+ </div>
39
+ <div class="pt-4">
40
+ <button role="Button" type="submit" aria-label="Next" class="py-2 px-6 rounded-lg truncate cursor-pointer touch-manipulation tracking-wide overflow-hidden bg-blue-400 text-white">Next</button>
41
+ </div>
42
+ </form>
43
+ </div>
44
+ </div>
@@ -0,0 +1,11 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>AT Protocol OAuth Example</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <script src="https://cdn.tailwindcss.com"></script>
7
+ </head>
8
+ <body>
9
+ <%= yield %>
10
+ </body>
11
+ </html>
@@ -0,0 +1,410 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/CyclomaticComplexity
4
+
5
+ module AtprotoAuth
6
+ # Main client class for AT Protocol OAuth implementation. Handles the complete
7
+ # OAuth flow including authorization, token management, and identity verification.
8
+ class Client
9
+ # Error raised when authorization callback fails
10
+ class CallbackError < Error; end
11
+
12
+ # Error raised when token operations fail
13
+ class TokenError < Error; end
14
+
15
+ # @return [String] OAuth client ID
16
+ attr_reader :client_id
17
+ # @return [String] OAuth redirect URI
18
+ attr_reader :redirect_uri
19
+ # @return [ClientMetadata] Validated client metadata
20
+ attr_reader :client_metadata
21
+ # @return [SessionManager] Session state manager
22
+ attr_reader :session_manager
23
+ # @return [Identity::Resolver] Identity resolver
24
+ attr_reader :identity_resolver
25
+ # @return [DPoP::Client] DPoP client
26
+ attr_reader :dpop_client
27
+
28
+ # Creates a new AT Protocol OAuth client
29
+ # @param client_id [String] OAuth client ID URL
30
+ # @param redirect_uri [String] OAuth redirect URI
31
+ # @param metadata [Hash, nil] Optional pre-loaded client metadata
32
+ # @param dpop_key [Hash, nil] Optional existing DPoP key in JWK format
33
+ # @raise [Error] if configuration is invalid
34
+ def initialize(client_id:, redirect_uri:, metadata: nil, dpop_key: nil)
35
+ @client_id = client_id
36
+ @redirect_uri = redirect_uri
37
+
38
+ # Initialize core dependencies
39
+ @client_metadata = load_client_metadata(metadata)
40
+ validate_redirect_uri!
41
+
42
+ @session_manager = State::SessionManager.new
43
+ @identity_resolver = Identity::Resolver.new
44
+ @dpop_client = initialize_dpop(dpop_key)
45
+ end
46
+
47
+ # Begins an authorization flow and generates authorization URL
48
+ # @param handle [String, nil] Optional user handle
49
+ # @param pds_url [String, nil] Optional PDS URL
50
+ # @param scope [String] OAuth scope (must include "atproto")
51
+ # @return [Hash] Authorization details including :url and :session_id
52
+ # @raise [Error] if parameters are invalid or resolution fails
53
+ def authorize(handle: nil, pds_url: nil, scope: "atproto")
54
+ validate_auth_params!(handle, pds_url, scope)
55
+
56
+ # Create new session
57
+ session = session_manager.create_session(
58
+ client_id: client_id,
59
+ scope: scope
60
+ )
61
+
62
+ # Resolve identity and authorization server if handle provided
63
+ if handle
64
+ auth_info = resolve_from_handle(handle, session)
65
+ elsif pds_url
66
+ auth_info = resolve_from_pds(pds_url, session)
67
+ else
68
+ raise Error, "Either handle or pds_url must be provided"
69
+ end
70
+
71
+ # Generate authorization URL
72
+ auth_url = generate_authorization_url(
73
+ auth_info[:server],
74
+ session,
75
+ login_hint: handle
76
+ )
77
+
78
+ {
79
+ url: auth_url,
80
+ session_id: session.session_id
81
+ }
82
+ end
83
+
84
+ # Handles the authorization callback and completes token exchange
85
+ # @param code [String] Authorization code from callback
86
+ # @param state [String] State parameter from callback
87
+ # @param iss [String] Issuer from callback (required by AT Protocol OAuth)
88
+ # @return [Hash] Token response including :access_token and :session_id
89
+ # @raise [CallbackError] if callback validation fails
90
+ # @raise [TokenError] if token exchange fails
91
+ def handle_callback(code:, state:, iss:)
92
+ # Find and validate session
93
+ session = session_manager.get_session_by_state(state)
94
+ raise CallbackError, "Invalid state parameter" unless session
95
+
96
+ # Verify issuer matches session
97
+ raise CallbackError, "Issuer mismatch" unless session.auth_server && session.auth_server.issuer == iss
98
+
99
+ # Exchange code for tokens
100
+ token_response = exchange_code(
101
+ code: code,
102
+ session: session
103
+ )
104
+
105
+ # Validate token response
106
+ validate_token_response!(token_response, session)
107
+
108
+ # Create token set and store in session
109
+ token_set = State::TokenSet.new(
110
+ access_token: token_response["access_token"],
111
+ token_type: token_response["token_type"],
112
+ expires_in: token_response["expires_in"],
113
+ refresh_token: token_response["refresh_token"],
114
+ scope: token_response["scope"],
115
+ sub: token_response["sub"]
116
+ )
117
+ session.tokens = token_set
118
+
119
+ {
120
+ access_token: token_set.access_token,
121
+ token_type: token_set.token_type,
122
+ expires_in: (token_set.expires_at - Time.now).to_i,
123
+ refresh_token: token_set.refresh_token,
124
+ scope: token_set.scope,
125
+ session_id: session.session_id
126
+ }
127
+ end
128
+
129
+ # Gets active tokens for a session
130
+ # @param session_id [String] ID of session to get tokens for
131
+ # @return [Hash, nil] Current token information if session exists and is authorized
132
+ def get_tokens(session_id)
133
+ session = session_manager.get_session(session_id)
134
+ return nil unless session&.authorized?
135
+
136
+ {
137
+ access_token: session.tokens.access_token,
138
+ token_type: session.tokens.token_type,
139
+ expires_in: (session.tokens.expires_at - Time.now).to_i,
140
+ refresh_token: session.tokens.refresh_token,
141
+ scope: session.tokens.scope
142
+ }
143
+ end
144
+
145
+ # Checks if a session has valid tokens
146
+ # @param session_id [String] ID of session to check
147
+ # @return [Boolean] true if session exists and has valid tokens
148
+ def authorized?(session_id)
149
+ session = session_manager.get_session(session_id)
150
+ session&.authorized? || false
151
+ end
152
+
153
+ # Generates headers for an authenticated request
154
+ # @param session_id [String] ID of session to use
155
+ # @param method [String] HTTP method for the request
156
+ # @param url [String] Full URL for the request
157
+ # @return [Hash] Headers to add to request
158
+ # @raise [TokenError] if session is invalid or unauthorized
159
+ def auth_headers(session_id:, method:, url:)
160
+ session = session_manager.get_session(session_id)
161
+ raise TokenError, "Invalid session" unless session
162
+ raise TokenError, "Session not authorized" unless session.authorized?
163
+
164
+ # Generate DPoP proof
165
+ proof = dpop_client.generate_proof(
166
+ http_method: method,
167
+ http_uri: url,
168
+ access_token: session.tokens.access_token
169
+ )
170
+
171
+ {
172
+ "Authorization" => "DPoP #{session.tokens.access_token}",
173
+ "DPoP" => proof
174
+ }
175
+ end
176
+
177
+ private
178
+
179
+ def load_client_metadata(metadata)
180
+ if metadata
181
+ ClientMetadata.new(metadata)
182
+ else
183
+ ClientMetadata.from_url(@client_id)
184
+ end
185
+ end
186
+
187
+ def validate_redirect_uri!
188
+ valid = @client_metadata.redirect_uris.include?(@redirect_uri)
189
+ raise Error, "redirect_uri not found in client metadata" unless valid
190
+ end
191
+
192
+ def initialize_dpop(key)
193
+ key_manager = if key
194
+ DPoP::KeyManager.from_jwk(key)
195
+ else
196
+ DPoP::KeyManager.new
197
+ end
198
+
199
+ DPoP::Client.new(key_manager: key_manager)
200
+ end
201
+
202
+ def validate_auth_params!(handle, pds_url, scope)
203
+ raise Error, "scope must include 'atproto'" unless scope.split.include?("atproto")
204
+ raise Error, "handle or pds_url must be provided" if handle.nil? && pds_url.nil?
205
+ raise Error, "cannot provide both handle and pds_url" if handle && pds_url
206
+ end
207
+
208
+ def resolve_from_handle(handle, session)
209
+ # Resolve handle to DID document
210
+ resolution = @identity_resolver.resolve_handle(handle)
211
+ session.did = resolution[:did]
212
+
213
+ # Get authorization server from PDS
214
+ server = resolve_auth_server(resolution[:pds])
215
+ session.authorization_server = server
216
+
217
+ { server: server, pds: resolution[:pds] }
218
+ end
219
+
220
+ def resolve_from_pds(pds_url, session)
221
+ # Get authorization server from PDS
222
+ server = resolve_auth_server(pds_url)
223
+ session.authorization_server = server
224
+
225
+ { server: server, pds: pds_url }
226
+ end
227
+
228
+ def resolve_auth_server(pds_url)
229
+ # Get resource server metadata
230
+ resource_server = ServerMetadata::ResourceServer.from_url(pds_url)
231
+ auth_server_url = resource_server.authorization_servers.first
232
+
233
+ # Get and validate authorization server metadata
234
+ ServerMetadata::AuthorizationServer.from_issuer(auth_server_url)
235
+ end
236
+
237
+ def generate_authorization_url(auth_server, session, login_hint: nil)
238
+ # Create PAR client
239
+ par_client = PAR::Client.new(
240
+ endpoint: auth_server.pushed_authorization_request_endpoint,
241
+ dpop_client: @dpop_client
242
+ )
243
+
244
+ signing_key = if client_metadata.jwks && !client_metadata.jwks["keys"].empty?
245
+ key_data = client_metadata.jwks["keys"].first
246
+ JOSE::JWK.from_map(key_data)
247
+ else
248
+ JOSE::JWK.generate_key([:ec, "P-256"])
249
+ end
250
+
251
+ client_assertion = PAR::ClientAssertion.new(
252
+ client_id: client_id,
253
+ signing_key: signing_key
254
+ )
255
+
256
+ # Build PAR request
257
+ par_request = PAR::Request.build do |config|
258
+ config.client_id = client_id
259
+ config.redirect_uri = redirect_uri
260
+ config.state = session.state_token
261
+ config.scope = session.scope
262
+ config.login_hint = login_hint if login_hint
263
+
264
+ # Add PKCE parameters
265
+ config.code_challenge = session.pkce_challenge
266
+ config.code_challenge_method = "S256"
267
+
268
+ # Add client assertion
269
+ config.client_assertion = client_assertion.generate_jwt(
270
+ audience: auth_server.issuer
271
+ )
272
+ config.client_assertion_type = PAR::CLIENT_ASSERTION_TYPE
273
+
274
+ # Add DPoP proof
275
+ proof = @dpop_client.generate_proof(
276
+ http_method: "POST",
277
+ http_uri: auth_server.pushed_authorization_request_endpoint
278
+ )
279
+ config.dpop_proof = proof
280
+ end
281
+
282
+ # Submit PAR request
283
+ response = par_client.submit(par_request)
284
+
285
+ # Build final authorization URL
286
+ par_client.authorization_url(
287
+ authorize_endpoint: auth_server.authorization_endpoint,
288
+ request_uri: response.request_uri,
289
+ client_id: client_id
290
+ )
291
+ end
292
+
293
+ def exchange_code(code:, session:)
294
+ # Initial token request without nonce
295
+ response = make_token_request(code, session)
296
+
297
+ # Handle DPoP nonce requirement
298
+ if requires_dpop_nonce?(response)
299
+ puts "*" * 88
300
+ puts "requires_dpop_nonce"
301
+ # Extract and store nonce from error response
302
+ extract_dpop_nonce(response)
303
+ dpop_client.process_response(response[:headers], session.auth_server.issuer)
304
+
305
+ # Retry request with nonce
306
+ response = make_token_request(code, session)
307
+ end
308
+
309
+ raise TokenError, "Token request failed: #{response[:status]}" unless response[:status] == 200
310
+
311
+ begin
312
+ JSON.parse(response[:body])
313
+ rescue JSON::ParserError => e
314
+ raise TokenError, "Invalid token response: #{e.message}"
315
+ end
316
+ end
317
+
318
+ def make_token_request(code, session)
319
+ # Generate proof
320
+ proof = dpop_client.generate_proof(
321
+ http_method: "POST",
322
+ http_uri: session.auth_server.token_endpoint
323
+ )
324
+
325
+ # Log the DPoP proof details
326
+ AtprotoAuth.configuration.logger.debug "Token Request DPoP Proof:"
327
+ AtprotoAuth.configuration.logger.debug "- Key: #{dpop_client.public_key}"
328
+ AtprotoAuth.configuration.logger.debug "- Proof: #{proof}"
329
+
330
+ body = {
331
+ grant_type: "authorization_code",
332
+ code: code,
333
+ redirect_uri: redirect_uri,
334
+ client_id: client_id,
335
+ code_verifier: session.pkce_verifier
336
+ }
337
+
338
+ # Add client authentication
339
+ if client_metadata.confidential?
340
+ signing_key = JOSE::JWK.from_map(client_metadata.jwks["keys"].first)
341
+ client_assertion = PAR::ClientAssertion.new(
342
+ client_id: client_id,
343
+ signing_key: signing_key
344
+ )
345
+
346
+ body.merge!(
347
+ client_assertion_type: PAR::CLIENT_ASSERTION_TYPE,
348
+ client_assertion: client_assertion.generate_jwt(
349
+ audience: session.auth_server.issuer
350
+ )
351
+ )
352
+ end
353
+
354
+ AtprotoAuth.configuration.http_client.post(
355
+ session.auth_server.token_endpoint,
356
+ body: body,
357
+ headers: {
358
+ "Content-Type" => "application/x-www-form-urlencoded",
359
+ "DPoP" => proof
360
+ }
361
+ )
362
+ end
363
+
364
+ def requires_dpop_nonce?(response)
365
+ return false unless response[:status] == 400
366
+
367
+ error_data = JSON.parse(response[:body])
368
+ error_data["error"] == "use_dpop_nonce"
369
+ rescue JSON::ParserError
370
+ false
371
+ end
372
+
373
+ def extract_dpop_nonce(response)
374
+ headers = response[:headers]
375
+ nonce = headers["DPoP-Nonce"] ||
376
+ headers["dpop-nonce"] ||
377
+ headers["Dpop-Nonce"]
378
+
379
+ raise TokenError, "No DPoP nonce provided in error response" unless nonce
380
+
381
+ nonce
382
+ end
383
+
384
+ def validate_token_response!(response, session)
385
+ # Required fields
386
+ %w[access_token token_type expires_in scope sub].each do |field|
387
+ raise TokenError, "Missing #{field} in token response" unless response[field]
388
+ end
389
+
390
+ # Token type must be DPoP
391
+ raise TokenError, "Invalid token_type: #{response["token_type"]}" unless response["token_type"] == "DPoP"
392
+
393
+ # Scope must include atproto
394
+ raise TokenError, "Missing atproto scope in token response" unless response["scope"].split.include?("atproto")
395
+
396
+ # If we have a pre-resolved DID, verify it matches
397
+ raise TokenError, "Subject mismatch in token response" if session.did && session.did != response["sub"]
398
+
399
+ # Process DPoP-Nonce from response headers if present
400
+ return unless response[:headers] && response[:headers]["DPoP-Nonce"]
401
+
402
+ dpop_client.process_response(
403
+ response[:headers],
404
+ session.auth_server.issuer
405
+ )
406
+ end
407
+ end
408
+ end
409
+
410
+ # rubocop:enable Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/CyclomaticComplexity