authproto 0.3.0.beta1 → 0.3.0.beta2
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 +4 -4
- data/.yardopts +1 -0
- data/AGENTS.md +80 -0
- data/CHANGELOG.md +48 -7
- data/README.md +20 -0
- data/examples/confidential_client/.gitignore +2 -1
- data/examples/confidential_client/Gemfile.lock +5 -1
- data/examples/confidential_client/README.md +2 -2
- data/examples/confidential_client/app.rb +17 -17
- data/examples/confidential_client/config/{client-metadata.example.json → oauth-client-metadata.example.json} +0 -1
- data/examples/confidential_client/scripts/generate_keys.rb +15 -9
- data/examples/minisky_console/Gemfile.lock +2 -2
- data/examples/minisky_console/localhost.rb +8 -25
- data/examples/minisky_console/production.rb +1 -1
- data/lib/authproto/client.rb +150 -201
- data/lib/authproto/dpop/client.rb +12 -60
- data/lib/authproto/dpop/key_manager.rb +17 -17
- data/lib/authproto/dpop/nonce_manager.rb +5 -19
- data/lib/authproto/dpop/proof_generator.rb +27 -23
- data/lib/authproto/errors.rb +1 -1
- data/lib/authproto/http_client.rb +17 -31
- data/lib/authproto/localhost_server.rb +15 -6
- data/lib/authproto/metadata/authorization_server.rb +358 -0
- data/lib/authproto/metadata/client_metadata.rb +574 -0
- data/lib/authproto/metadata/resource_server.rb +137 -0
- data/lib/authproto/{server_metadata.rb → metadata.rb} +7 -7
- data/lib/authproto/minisky.rb +8 -10
- data/lib/authproto/par/client.rb +51 -55
- data/lib/authproto/par/client_assertion.rb +58 -15
- data/lib/authproto/par/request.rb +58 -140
- data/lib/authproto/par/response.rb +9 -10
- data/lib/authproto/par.rb +3 -5
- data/lib/authproto/pkce.rb +29 -106
- data/lib/authproto/serialization/session.rb +11 -11
- data/lib/authproto/serialization/token_set.rb +6 -6
- data/lib/authproto/state/session.rb +27 -31
- data/lib/authproto/state/session_manager.rb +2 -2
- data/lib/authproto/state/token_set.rb +4 -4
- data/lib/authproto/state.rb +3 -3
- data/lib/authproto/token/refresh.rb +35 -43
- data/lib/authproto/validated_uri.rb +57 -0
- data/lib/authproto/version.rb +1 -1
- data/lib/authproto.rb +1 -0
- data/misc/yard_attributes.rb +34 -0
- data/sig/authproto/client.rbs +9 -10
- data/sig/authproto/dpop/client.rbs +1 -8
- data/sig/authproto/dpop/nonce_manager.rbs +0 -1
- data/sig/authproto/dpop/proof_generator.rbs +5 -5
- data/sig/authproto/http_client.rbs +2 -2
- data/sig/authproto/{server_metadata → metadata}/authorization_server.rbs +18 -12
- data/sig/authproto/metadata/client_metadata.rbs +118 -0
- data/sig/authproto/metadata/resource_server.rbs +27 -0
- data/sig/authproto/minisky_oauth.rbs +5 -3
- data/sig/authproto/par/client.rbs +4 -6
- data/sig/authproto/par/client_assertion.rbs +2 -1
- data/sig/authproto/par/request.rbs +5 -19
- data/sig/authproto/par/response.rbs +1 -5
- data/sig/authproto/pkce.rbs +6 -16
- data/sig/authproto/serialization/session.rbs +2 -2
- data/sig/authproto/state/session.rbs +4 -7
- data/sig/authproto/state/session_manager.rbs +2 -2
- data/sig/authproto/token/refresh.rbs +4 -4
- data/sig/authproto/validated_uri.rbs +36 -0
- data/sig/authproto.rbs +5 -3
- metadata +13 -11
- data/lib/authproto/client_metadata.rb +0 -478
- data/lib/authproto/server_metadata/authorization_server.rb +0 -267
- data/lib/authproto/server_metadata/origin_url.rb +0 -71
- data/lib/authproto/server_metadata/resource_server.rb +0 -98
- data/sig/authproto/client_metadata.rbs +0 -113
- data/sig/authproto/server_metadata/origin_url.rbs +0 -21
- data/sig/authproto/server_metadata/resource_server.rbs +0 -27
data/lib/authproto/client.rb
CHANGED
|
@@ -5,17 +5,17 @@ require 'jose'
|
|
|
5
5
|
require 'json'
|
|
6
6
|
require 'uri'
|
|
7
7
|
|
|
8
|
-
require_relative 'client_metadata'
|
|
9
8
|
require_relative 'configuration'
|
|
10
9
|
require_relative 'dpop/client'
|
|
11
10
|
require_relative 'dpop/key_manager'
|
|
12
11
|
require_relative 'errors'
|
|
12
|
+
require_relative 'metadata/authorization_server'
|
|
13
|
+
require_relative 'metadata/client_metadata'
|
|
14
|
+
require_relative 'metadata/resource_server'
|
|
13
15
|
require_relative 'par'
|
|
14
16
|
require_relative 'par/client'
|
|
15
17
|
require_relative 'par/client_assertion'
|
|
16
18
|
require_relative 'par/request'
|
|
17
|
-
require_relative 'server_metadata/authorization_server'
|
|
18
|
-
require_relative 'server_metadata/resource_server'
|
|
19
19
|
require_relative 'state/session_manager'
|
|
20
20
|
require_relative 'state/token_set'
|
|
21
21
|
require_relative 'storage/key_builder'
|
|
@@ -66,7 +66,7 @@ module AuthProto
|
|
|
66
66
|
attr_reader :redirect_uri
|
|
67
67
|
|
|
68
68
|
# Validated client metadata.
|
|
69
|
-
# @return [ClientMetadata]
|
|
69
|
+
# @return [Metadata::ClientMetadata]
|
|
70
70
|
attr_reader :client_metadata
|
|
71
71
|
|
|
72
72
|
# Manager for stored OAuth sessions.
|
|
@@ -85,6 +85,7 @@ module AuthProto
|
|
|
85
85
|
# @param client_id [String] OAuth client ID URL
|
|
86
86
|
# @param redirect_uri [String] callback URI listed in `redirect_uris`
|
|
87
87
|
# @param metadata [Hash{String => Object}, nil] preloaded metadata, or `nil` to fetch `client_id`
|
|
88
|
+
# @param signing_key [Hash, JOSE::JWK, nil] private ES256 client-authentication key, separate from public metadata
|
|
88
89
|
# @param dpop_key [Hash{String => Object}, nil] existing private DPoP JWK, or `nil` to generate one
|
|
89
90
|
#
|
|
90
91
|
# @raise [InvalidClientMetadata] if client metadata is invalid or unavailable
|
|
@@ -92,13 +93,15 @@ module AuthProto
|
|
|
92
93
|
# @raise [DPoP::KeyManager::KeyError] if the DPoP key cannot be imported or generated
|
|
93
94
|
# @raise [HTTPClient::HTTPError, HTTPClient::SSRFError] if fetching client metadata fails
|
|
94
95
|
#
|
|
95
|
-
def initialize(client_id:, redirect_uri:, metadata: nil, dpop_key: nil)
|
|
96
|
+
def initialize(client_id:, redirect_uri:, metadata: nil, dpop_key: nil, signing_key: nil)
|
|
96
97
|
@client_id = client_id
|
|
97
98
|
@redirect_uri = redirect_uri
|
|
98
99
|
|
|
99
|
-
#
|
|
100
|
+
# Client metadata is fetched from .well-known if not provided directly
|
|
100
101
|
@client_metadata = load_client_metadata(metadata)
|
|
102
|
+
|
|
101
103
|
validate_redirect_uri
|
|
104
|
+
@signing_key = initialize_signing_key(signing_key)
|
|
102
105
|
|
|
103
106
|
@session_manager = State::SessionManager.new
|
|
104
107
|
@dpop_client = initialize_dpop(dpop_key)
|
|
@@ -106,12 +109,12 @@ module AuthProto
|
|
|
106
109
|
|
|
107
110
|
# Starts authorization, stores a pending session, and submits a PAR request.
|
|
108
111
|
#
|
|
109
|
-
# Supply exactly one of `handle` or `
|
|
112
|
+
# Supply exactly one of `handle` or `pds`. Open the returned `:url` in a browser
|
|
110
113
|
# and pass the eventual callback parameters to {#handle_callback}.
|
|
111
114
|
#
|
|
112
115
|
# @param handle [String, nil] account handle (optionally prefixed with `@`) or DID
|
|
113
|
-
# @param
|
|
114
|
-
# @param scope [String] space-separated scopes, including `atproto`
|
|
116
|
+
# @param pds [String, nil] PDS URL when no handle is supplied
|
|
117
|
+
# @param scope [String] space-separated scopes, which must all be declared in client metadata, including `atproto`
|
|
115
118
|
#
|
|
116
119
|
# @return [Hash{Symbol => String}] `:url` is the browser URL; `:session_id` identifies the pending session
|
|
117
120
|
# @raise [Error] if identity arguments or scope are invalid, the handle cannot be verified, or no HTTPS PDS is found
|
|
@@ -124,33 +127,25 @@ module AuthProto
|
|
|
124
127
|
# @raise [Storage::StorageError] if session storage or locking fails
|
|
125
128
|
# @raise [Encryption::EncryptionError] if session serialization cannot encrypt secrets
|
|
126
129
|
#
|
|
127
|
-
def authorize(handle: nil,
|
|
128
|
-
validate_auth_params(handle,
|
|
129
|
-
|
|
130
|
-
# Create new session
|
|
131
|
-
session = session_manager.create_session(
|
|
132
|
-
client_id: client_id,
|
|
133
|
-
scope: scope
|
|
134
|
-
)
|
|
130
|
+
def authorize(handle: nil, pds: nil, scope: 'atproto')
|
|
131
|
+
validate_auth_params(handle, pds, scope)
|
|
135
132
|
|
|
136
|
-
|
|
137
|
-
|
|
133
|
+
if !pds
|
|
134
|
+
did = DIDKit::DID.resolve_handle(handle)
|
|
135
|
+
raise Error, "Could not resolve or verify identity #{handle}" unless did
|
|
138
136
|
|
|
139
|
-
|
|
140
|
-
|
|
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"
|
|
137
|
+
pds = did.document.pds_endpoint
|
|
138
|
+
raise Error, "No HTTPS PDS location found for #{did}" unless pds && URI(pds).is_a?(URI::HTTPS)
|
|
146
139
|
end
|
|
147
140
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
)
|
|
141
|
+
authorization_server = resolve_auth_server(pds)
|
|
142
|
+
|
|
143
|
+
session = session_manager.create_session(client_id: @client_id, scope: scope)
|
|
144
|
+
session.authorization_server = authorization_server
|
|
145
|
+
session.did = did&.to_s
|
|
146
|
+
session_manager.update_session(session)
|
|
147
|
+
|
|
148
|
+
auth_url = submit_authorization_par(authorization_server, session, login_hint: handle)
|
|
154
149
|
|
|
155
150
|
{
|
|
156
151
|
url: auth_url,
|
|
@@ -180,46 +175,28 @@ module AuthProto
|
|
|
180
175
|
# @raise [Encryption::EncryptionError] if tokens cannot be encrypted for storage
|
|
181
176
|
#
|
|
182
177
|
def handle_callback(code:, state:, iss:)
|
|
183
|
-
# Find and validate session
|
|
184
178
|
session = session_manager.get_session_by_state(state)
|
|
185
|
-
raise CallbackError, "Invalid state parameter" unless session
|
|
186
179
|
|
|
187
|
-
|
|
180
|
+
raise CallbackError, "Invalid state parameter" unless session
|
|
188
181
|
raise CallbackError, "Issuer mismatch" unless session.auth_server && session.auth_server.issuer == iss
|
|
189
182
|
|
|
190
183
|
AuthProto.storage.with_lock(Storage::KeyBuilder.lock_key("session", session.session_id), ttl: 30) do
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
code: code,
|
|
194
|
-
session: session
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
# Validate token response
|
|
198
|
-
validate_token_response(token_response, session)
|
|
184
|
+
response = exchange_code(code: code, session: session)
|
|
185
|
+
validate_token_response(response, session)
|
|
199
186
|
|
|
200
|
-
# Create token set and store in session
|
|
201
187
|
token_set = State::TokenSet.new(
|
|
202
|
-
access_token:
|
|
203
|
-
token_type:
|
|
204
|
-
expires_in:
|
|
205
|
-
refresh_token:
|
|
206
|
-
scope:
|
|
207
|
-
sub:
|
|
188
|
+
access_token: response['access_token'],
|
|
189
|
+
token_type: response['token_type'],
|
|
190
|
+
expires_in: response['expires_in'],
|
|
191
|
+
refresh_token: response['refresh_token'],
|
|
192
|
+
scope: response['scope'],
|
|
193
|
+
sub: response['sub']
|
|
208
194
|
)
|
|
209
|
-
session.tokens = token_set
|
|
210
195
|
|
|
211
|
-
|
|
196
|
+
session.tokens = token_set
|
|
212
197
|
session_manager.update_session(session)
|
|
213
198
|
|
|
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
|
-
}
|
|
199
|
+
session.session_data
|
|
223
200
|
end
|
|
224
201
|
end
|
|
225
202
|
|
|
@@ -228,6 +205,7 @@ module AuthProto
|
|
|
228
205
|
# The result contains `:access_token` (String), `:token_type` (String, `DPoP`),
|
|
229
206
|
# `:expires_in` (Integer seconds remaining), `:refresh_token` (String or nil),
|
|
230
207
|
# and `:scope` (String).
|
|
208
|
+
# It also contains `:session_id` (String) and `:did` (String).
|
|
231
209
|
# This method does not refresh expired tokens.
|
|
232
210
|
#
|
|
233
211
|
# @param session_id [String] session identifier
|
|
@@ -237,13 +215,7 @@ module AuthProto
|
|
|
237
215
|
session = session_manager.get_session(session_id)
|
|
238
216
|
return nil unless session&.authorized?
|
|
239
217
|
|
|
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
|
-
}
|
|
218
|
+
session.session_data
|
|
247
219
|
end
|
|
248
220
|
|
|
249
221
|
# Refreshes a renewable session and persists the replacement tokens.
|
|
@@ -251,11 +223,11 @@ module AuthProto
|
|
|
251
223
|
# The result contains `:access_token` (String), `:token_type` (String, `DPoP`),
|
|
252
224
|
# `:expires_in` (Integer seconds remaining), `:refresh_token` (String or nil),
|
|
253
225
|
# and `:scope` (String).
|
|
254
|
-
# The result also contains `:session_id` (String). An expired access token can
|
|
226
|
+
# The result also contains `:session_id` (String) and `:did` (String). An expired access token can
|
|
255
227
|
# still be refreshed when the stored session has a refresh token.
|
|
256
228
|
#
|
|
257
229
|
# @param session_id [String] session to refresh
|
|
258
|
-
# @return [Hash{Symbol => String, Integer, nil}] replacement tokens and session
|
|
230
|
+
# @return [Hash{Symbol => String, Integer, nil}] replacement tokens and session identifiers
|
|
259
231
|
# @raise [TokenError] if the session is missing or has no refresh token
|
|
260
232
|
# @raise [Token::RefreshError] if token refresh fails or exhausts retries
|
|
261
233
|
# @raise [State::SessionError] if replacement tokens have a different subject
|
|
@@ -264,6 +236,7 @@ module AuthProto
|
|
|
264
236
|
#
|
|
265
237
|
def refresh_token(session_id)
|
|
266
238
|
session = session_manager.get_session(session_id)
|
|
239
|
+
|
|
267
240
|
raise TokenError, "Invalid session" unless session
|
|
268
241
|
raise TokenError, "Session not authorized" unless session.renewable?
|
|
269
242
|
|
|
@@ -272,23 +245,16 @@ module AuthProto
|
|
|
272
245
|
session: session,
|
|
273
246
|
dpop_client: @dpop_client,
|
|
274
247
|
auth_server: session.auth_server,
|
|
275
|
-
client_metadata: client_metadata
|
|
248
|
+
client_metadata: @client_metadata,
|
|
249
|
+
signing_key: @signing_key
|
|
276
250
|
)
|
|
277
251
|
|
|
278
|
-
new_tokens = refresher.
|
|
279
|
-
session.tokens = new_tokens
|
|
252
|
+
new_tokens = refresher.perform_refresh
|
|
280
253
|
|
|
281
|
-
|
|
254
|
+
session.tokens = new_tokens
|
|
282
255
|
session_manager.update_session(session)
|
|
283
256
|
|
|
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
|
-
}
|
|
257
|
+
session.session_data
|
|
292
258
|
end
|
|
293
259
|
end
|
|
294
260
|
|
|
@@ -318,19 +284,19 @@ module AuthProto
|
|
|
318
284
|
#
|
|
319
285
|
def auth_headers(session_id:, method:, url:)
|
|
320
286
|
session = session_manager.get_session(session_id)
|
|
287
|
+
|
|
321
288
|
raise TokenError, "Invalid session" unless session
|
|
322
289
|
raise TokenError, "Session not authorized" unless session.authorized?
|
|
323
290
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
http_uri: url,
|
|
291
|
+
dpop_proof = dpop_client.generate_proof(
|
|
292
|
+
method: method,
|
|
293
|
+
uri: url,
|
|
328
294
|
access_token: session.tokens.access_token
|
|
329
295
|
)
|
|
330
296
|
|
|
331
297
|
{
|
|
332
298
|
"Authorization" => "DPoP #{session.tokens.access_token}",
|
|
333
|
-
"DPoP" =>
|
|
299
|
+
"DPoP" => dpop_proof
|
|
334
300
|
}
|
|
335
301
|
end
|
|
336
302
|
|
|
@@ -340,10 +306,11 @@ module AuthProto
|
|
|
340
306
|
# @return [void]
|
|
341
307
|
# @raise [Storage::StorageError] if deletion or locking fails
|
|
342
308
|
#
|
|
343
|
-
def
|
|
309
|
+
def delete_session(session_id)
|
|
344
310
|
key = Storage::KeyBuilder.session_key(session_id)
|
|
311
|
+
|
|
345
312
|
AuthProto.storage.delete(key)
|
|
346
|
-
session_manager.
|
|
313
|
+
session_manager.delete_session(session_id)
|
|
347
314
|
end
|
|
348
315
|
|
|
349
316
|
# Invokes the session manager's expiry cleanup hook.
|
|
@@ -356,119 +323,116 @@ module AuthProto
|
|
|
356
323
|
session_manager.cleanup_expired
|
|
357
324
|
end
|
|
358
325
|
|
|
326
|
+
|
|
359
327
|
private
|
|
360
328
|
|
|
361
329
|
def load_client_metadata(metadata)
|
|
362
330
|
if metadata
|
|
363
|
-
ClientMetadata.new(metadata)
|
|
331
|
+
Metadata::ClientMetadata.new(metadata)
|
|
364
332
|
else
|
|
365
|
-
ClientMetadata.from_url(@client_id)
|
|
333
|
+
Metadata::ClientMetadata.from_url(@client_id)
|
|
366
334
|
end
|
|
367
335
|
end
|
|
368
336
|
|
|
369
337
|
def validate_redirect_uri
|
|
370
|
-
|
|
371
|
-
|
|
338
|
+
unless @client_metadata.redirect_uris.include?(@redirect_uri)
|
|
339
|
+
raise Error, "redirect_uri not found in client metadata"
|
|
340
|
+
end
|
|
372
341
|
end
|
|
373
342
|
|
|
374
|
-
def
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
343
|
+
def initialize_signing_key(key)
|
|
344
|
+
if @client_metadata.confidential?
|
|
345
|
+
raise InvalidClientMetadata, "Confidential clients require a signing_key" if key.nil?
|
|
346
|
+
else
|
|
347
|
+
raise InvalidClientMetadata, "Public clients must not supply a signing_key" unless key.nil?
|
|
348
|
+
return nil
|
|
349
|
+
end
|
|
380
350
|
|
|
381
|
-
|
|
382
|
-
|
|
351
|
+
assertion = PAR::ClientAssertion.new(client_id: @client_id, signing_key: key)
|
|
352
|
+
private_key = assertion.signing_key
|
|
383
353
|
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
354
|
+
if @client_metadata.jwks
|
|
355
|
+
public_key = private_key.to_public.to_map.to_h
|
|
389
356
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
357
|
+
key_in_jwks = @client_metadata.jwks['keys'].any? { |candidate|
|
|
358
|
+
metadata_key_matches_public_key?(candidate, public_key)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
raise InvalidClientMetadata, "signing_key must match an active public key advertised in jwks" if !key_in_jwks
|
|
362
|
+
end
|
|
393
363
|
|
|
394
|
-
|
|
395
|
-
|
|
364
|
+
private_key
|
|
365
|
+
end
|
|
396
366
|
|
|
397
|
-
|
|
367
|
+
def metadata_key_matches_public_key?(candidate, public_key)
|
|
368
|
+
return false if candidate['revoked']
|
|
369
|
+
return false if candidate.key?('alg') && candidate['alg'] != 'ES256'
|
|
370
|
+
return false if candidate.key?('use') && candidate['use'] != 'sig'
|
|
371
|
+
return false if candidate.key?('key_ops') && !candidate['key_ops'].include?('verify')
|
|
398
372
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
session.authorization_server = server
|
|
373
|
+
%w[kid kty crv x y].all? { |field| candidate[field] == public_key[field] }
|
|
374
|
+
end
|
|
402
375
|
|
|
403
|
-
|
|
404
|
-
|
|
376
|
+
def initialize_dpop(key)
|
|
377
|
+
key_manager = key ? DPoP::KeyManager.from_jwk(key) : DPoP::KeyManager.new
|
|
405
378
|
|
|
406
|
-
|
|
379
|
+
DPoP::Client.new(key_manager: key_manager)
|
|
407
380
|
end
|
|
408
381
|
|
|
409
|
-
def
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
382
|
+
def validate_auth_params(handle, pds, scope)
|
|
383
|
+
unless scope.is_a?(String) && scope.valid_encoding? && Metadata::ClientMetadata::SCOPE_PATTERN.match?(scope)
|
|
384
|
+
raise Error, "scope must contain valid tokens separated by single spaces"
|
|
385
|
+
end
|
|
413
386
|
|
|
414
|
-
|
|
415
|
-
|
|
387
|
+
requested_scopes = scope.split(' ')
|
|
388
|
+
raise Error, "scope must include 'atproto'" unless requested_scopes.include?('atproto')
|
|
389
|
+
|
|
390
|
+
undeclared_scopes = requested_scopes - @client_metadata.scope.split(' ')
|
|
391
|
+
|
|
392
|
+
unless undeclared_scopes.empty?
|
|
393
|
+
raise Error, "scope contains values not declared in client metadata: #{undeclared_scopes.join(' ')}"
|
|
394
|
+
end
|
|
416
395
|
|
|
417
|
-
|
|
396
|
+
raise Error, "handle or pds must be provided" if handle.nil? && pds.nil?
|
|
397
|
+
raise Error, "cannot provide both handle and pds" if handle && pds
|
|
418
398
|
end
|
|
419
399
|
|
|
420
|
-
def resolve_auth_server(
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
auth_server_url = resource_server.authorization_servers.first
|
|
400
|
+
def resolve_auth_server(pds)
|
|
401
|
+
resource_server = Metadata::ResourceServer.from_url(pds)
|
|
402
|
+
auth_server_url = resource_server.authorization_server
|
|
424
403
|
|
|
425
|
-
|
|
426
|
-
ServerMetadata::AuthorizationServer.from_issuer(auth_server_url)
|
|
404
|
+
Metadata::AuthorizationServer.from_url(auth_server_url)
|
|
427
405
|
end
|
|
428
406
|
|
|
429
|
-
def
|
|
430
|
-
# Create PAR client
|
|
407
|
+
def submit_authorization_par(authorization_server, session, login_hint: nil)
|
|
431
408
|
par_client = PAR::Client.new(
|
|
432
|
-
|
|
409
|
+
authorization_server: authorization_server,
|
|
433
410
|
dpop_client: @dpop_client
|
|
434
411
|
)
|
|
435
412
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
413
|
+
par_request = PAR::Request.build do |req|
|
|
414
|
+
req.client_id = @client_id
|
|
415
|
+
req.redirect_uri = @redirect_uri
|
|
416
|
+
req.state = session.state_token
|
|
417
|
+
req.scope = session.scope
|
|
418
|
+
req.login_hint = login_hint if login_hint
|
|
419
|
+
req.code_challenge = session.pkce_challenge
|
|
420
|
+
|
|
421
|
+
if @client_metadata.confidential?
|
|
422
|
+
req.client_assertion = PAR::ClientAssertion.new(client_id: @client_id, signing_key: @signing_key)
|
|
454
423
|
end
|
|
455
424
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
http_uri: auth_server.pushed_authorization_request_endpoint
|
|
425
|
+
req.dpop_proof = @dpop_client.generate_proof(
|
|
426
|
+
method: 'POST',
|
|
427
|
+
uri: authorization_server.pushed_authorization_request_endpoint
|
|
460
428
|
)
|
|
461
|
-
config.dpop_proof = proof
|
|
462
429
|
end
|
|
463
430
|
|
|
464
|
-
|
|
465
|
-
response = par_client.submit(par_request)
|
|
431
|
+
par_response = par_client.submit(par_request)
|
|
466
432
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
request_uri: response.request_uri,
|
|
471
|
-
client_id: client_id
|
|
433
|
+
par_client.build_authorization_url(
|
|
434
|
+
request_uri: par_response.request_uri,
|
|
435
|
+
client_id: @client_id
|
|
472
436
|
)
|
|
473
437
|
end
|
|
474
438
|
|
|
@@ -496,33 +460,28 @@ module AuthProto
|
|
|
496
460
|
end
|
|
497
461
|
|
|
498
462
|
def make_token_request(code, session)
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
http_uri: session.auth_server.token_endpoint
|
|
463
|
+
dpop_proof = dpop_client.generate_proof(
|
|
464
|
+
method: 'POST',
|
|
465
|
+
uri: session.auth_server.token_endpoint
|
|
503
466
|
)
|
|
504
467
|
|
|
505
468
|
body = {
|
|
506
|
-
grant_type:
|
|
469
|
+
grant_type: 'authorization_code',
|
|
507
470
|
code: code,
|
|
508
|
-
redirect_uri: redirect_uri,
|
|
509
|
-
client_id: client_id,
|
|
471
|
+
redirect_uri: @redirect_uri,
|
|
472
|
+
client_id: @client_id,
|
|
510
473
|
code_verifier: session.pkce_verifier
|
|
511
474
|
}
|
|
512
475
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
client_id: client_id,
|
|
518
|
-
signing_key: signing_key
|
|
476
|
+
if @client_metadata.confidential?
|
|
477
|
+
assertion = PAR::ClientAssertion.new(
|
|
478
|
+
client_id: @client_id,
|
|
479
|
+
signing_key: @signing_key
|
|
519
480
|
)
|
|
520
481
|
|
|
521
482
|
body.merge!(
|
|
522
483
|
client_assertion_type: PAR::CLIENT_ASSERTION_TYPE,
|
|
523
|
-
client_assertion:
|
|
524
|
-
audience: session.auth_server.issuer
|
|
525
|
-
)
|
|
484
|
+
client_assertion: assertion.generate_jwt(audience: session.auth_server.issuer)
|
|
526
485
|
)
|
|
527
486
|
end
|
|
528
487
|
|
|
@@ -531,7 +490,7 @@ module AuthProto
|
|
|
531
490
|
body: body,
|
|
532
491
|
headers: {
|
|
533
492
|
"Content-Type" => "application/x-www-form-urlencoded",
|
|
534
|
-
"DPoP" =>
|
|
493
|
+
"DPoP" => dpop_proof
|
|
535
494
|
}
|
|
536
495
|
)
|
|
537
496
|
end
|
|
@@ -540,16 +499,14 @@ module AuthProto
|
|
|
540
499
|
return false unless response[:status] == 400
|
|
541
500
|
|
|
542
501
|
error_data = JSON.parse(response[:body])
|
|
543
|
-
error_data[
|
|
502
|
+
error_data['error'] == DPoP::ERROR_USE_NONCE
|
|
544
503
|
rescue JSON::ParserError
|
|
545
504
|
false
|
|
546
505
|
end
|
|
547
506
|
|
|
548
507
|
def extract_dpop_nonce(response)
|
|
549
508
|
headers = response[:headers]
|
|
550
|
-
nonce = headers
|
|
551
|
-
headers["dpop-nonce"] ||
|
|
552
|
-
headers["Dpop-Nonce"]
|
|
509
|
+
nonce = headers.find { |k, v| k.downcase == 'dpop-nonce' }&.last
|
|
553
510
|
|
|
554
511
|
raise TokenError, "No DPoP nonce provided in error response" unless nonce
|
|
555
512
|
|
|
@@ -557,27 +514,19 @@ module AuthProto
|
|
|
557
514
|
end
|
|
558
515
|
|
|
559
516
|
def validate_token_response(response, session)
|
|
560
|
-
|
|
561
|
-
%w[access_token token_type expires_in scope sub].each do |field|
|
|
517
|
+
['access_token', 'token_type', 'expires_in', 'scope', 'sub'].each do |field|
|
|
562
518
|
raise TokenError, "Missing #{field} in token response" unless response[field]
|
|
563
519
|
end
|
|
564
520
|
|
|
565
|
-
#
|
|
566
|
-
raise TokenError, "
|
|
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"]
|
|
521
|
+
raise TokenError, "Invalid token_type: #{response['token_type']}" unless response['token_type'] == 'DPoP'
|
|
522
|
+
raise TokenError, "Missing atproto scope in token response" unless response['scope'].split.include?('atproto')
|
|
573
523
|
|
|
574
|
-
#
|
|
575
|
-
|
|
524
|
+
# If we have a pre-resolved DID, verify that it matches
|
|
525
|
+
raise TokenError, "Subject mismatch in token response" if session.did && session.did != response['sub']
|
|
576
526
|
|
|
577
|
-
|
|
578
|
-
response[:headers],
|
|
579
|
-
|
|
580
|
-
)
|
|
527
|
+
if response[:headers] && response[:headers]["DPoP-Nonce"]
|
|
528
|
+
dpop_client.process_response(response[:headers], session.auth_server.issuer)
|
|
529
|
+
end
|
|
581
530
|
end
|
|
582
531
|
end
|
|
583
532
|
end
|