authproto 0.3.0.beta2 → 0.3.0.beta3
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/CHANGELOG.md +19 -0
- data/README.md +108 -5
- data/examples/confidential_client/README.md +10 -0
- data/examples/confidential_client/app.rb +8 -7
- data/examples/confidential_client/scripts/generate_keys.rb +1 -1
- data/examples/minisky_console/Gemfile.lock +2 -2
- data/examples/minisky_console/README.md +20 -1
- data/examples/minisky_console/localhost.rb +5 -3
- data/examples/minisky_console/production.rb +9 -3
- data/lib/authproto/client.rb +73 -90
- data/lib/authproto/configuration.rb +37 -16
- data/lib/authproto/dpop/client.rb +105 -40
- data/lib/authproto/dpop/{key_manager.rb → key_set.rb} +54 -120
- data/lib/authproto/dpop/nonce_manager.rb +33 -20
- data/lib/authproto/dpop/proof_generator.rb +17 -32
- data/lib/authproto/dpop.rb +19 -0
- data/lib/authproto/http_helpers.rb +85 -0
- data/lib/authproto/metadata/authorization_server.rb +12 -6
- data/lib/authproto/metadata/client_metadata.rb +18 -5
- data/lib/authproto/metadata/resource_server.rb +4 -2
- data/lib/authproto/minisky.rb +36 -34
- data/lib/authproto/par/client.rb +22 -73
- data/lib/authproto/par/client_assertion.rb +47 -16
- data/lib/authproto/par/request.rb +4 -8
- data/lib/authproto/par.rb +2 -1
- data/lib/authproto/serialization/session.rb +31 -13
- data/lib/authproto/serialization/token_set.rb +1 -2
- data/lib/authproto/state/session.rb +66 -6
- data/lib/authproto/state/session_manager.rb +95 -21
- data/lib/authproto/state/token_set.rb +16 -16
- data/lib/authproto/state.rb +1 -1
- data/lib/authproto/storage/key_builder.rb +3 -11
- data/lib/authproto/storage/memory.rb +8 -2
- data/lib/authproto/token/refresh.rb +26 -59
- data/lib/authproto/validated_uri.rb +23 -0
- data/lib/authproto/version.rb +1 -1
- data/lib/authproto.rb +0 -1
- data/sig/authproto/client.rbs +5 -7
- data/sig/authproto/dependencies.rbs +2 -0
- data/sig/authproto/dpop/client.rbs +11 -4
- data/sig/authproto/dpop/{key_manager.rbs → key_set.rbs} +9 -11
- data/sig/authproto/dpop/nonce_manager.rbs +4 -4
- data/sig/authproto/dpop/proof_generator.rbs +5 -10
- data/sig/authproto/dpop.rbs +6 -0
- data/sig/authproto/http_helpers.rbs +7 -0
- data/sig/authproto/localhost_server.rbs +1 -1
- data/sig/authproto/metadata/authorization_server.rbs +1 -0
- data/sig/authproto/metadata/client_metadata.rbs +2 -1
- data/sig/authproto/metadata/resource_server.rbs +1 -0
- data/sig/authproto/minisky_oauth.rbs +5 -2
- data/sig/authproto/par/client.rbs +4 -8
- data/sig/authproto/par/client_assertion.rbs +4 -0
- data/sig/authproto/par/request.rbs +0 -9
- data/sig/authproto/state/session.rbs +10 -3
- data/sig/authproto/state/session_manager.rbs +8 -3
- data/sig/authproto/state/token_set.rbs +1 -2
- data/sig/authproto/storage/key_builder.rbs +1 -2
- data/sig/authproto/token/refresh.rbs +3 -4
- data/sig/authproto.rbs +5 -2
- metadata +14 -19
- data/.rspec +0 -1
- data/.rubocop.yml +0 -31
- data/.tm_properties +0 -1
- data/.yardopts +0 -5
- data/AGENTS.md +0 -80
- data/Rakefile +0 -8
- data/examples/confidential_client/.gitignore +0 -6
- data/examples/minisky_console/.gitignore +0 -2
- data/lib/authproto/serialization/dpop_key.rb +0 -38
- data/misc/yard_attributes.rb +0 -34
- data/sig/authproto/serialization/dpop_key.rbs +0 -14
data/lib/authproto/client.rb
CHANGED
|
@@ -7,7 +7,6 @@ require 'uri'
|
|
|
7
7
|
|
|
8
8
|
require_relative 'configuration'
|
|
9
9
|
require_relative 'dpop/client'
|
|
10
|
-
require_relative 'dpop/key_manager'
|
|
11
10
|
require_relative 'errors'
|
|
12
11
|
require_relative 'metadata/authorization_server'
|
|
13
12
|
require_relative 'metadata/client_metadata'
|
|
@@ -73,44 +72,51 @@ module AuthProto
|
|
|
73
72
|
# @return [State::SessionManager]
|
|
74
73
|
attr_reader :session_manager
|
|
75
74
|
|
|
76
|
-
# DPoP
|
|
75
|
+
# Shared DPoP proof generator and per-server nonce manager.
|
|
77
76
|
# @return [DPoP::Client]
|
|
78
77
|
attr_reader :dpop_client
|
|
79
78
|
|
|
80
|
-
# Creates an OAuth client with
|
|
79
|
+
# Creates an OAuth client with shared DPoP helpers and a session manager.
|
|
81
80
|
#
|
|
82
81
|
# Configure {AuthProto.configure} with an HTTP transport before fetching metadata
|
|
83
|
-
# or starting authorization.
|
|
82
|
+
# or starting authorization. Session storage restores the DPoP key bound to each session.
|
|
84
83
|
#
|
|
85
84
|
# @param client_id [String] OAuth client ID URL
|
|
86
85
|
# @param redirect_uri [String] callback URI listed in `redirect_uris`
|
|
87
86
|
# @param metadata [Hash{String => Object}, nil] preloaded metadata, or `nil` to fetch `client_id`
|
|
88
87
|
# @param signing_key [Hash, JOSE::JWK, nil] private ES256 client-authentication key, separate from public metadata
|
|
89
|
-
# @param dpop_key [Hash{String => Object}, nil] existing private DPoP JWK, or `nil` to generate one
|
|
90
88
|
#
|
|
91
|
-
# @raise [InvalidClientMetadata] if
|
|
89
|
+
# @raise [InvalidClientMetadata] if metadata is invalid, unavailable, has a different client ID,
|
|
90
|
+
# or the supplied signing key is missing, unexpected or does not match inline public metadata
|
|
91
|
+
# @raise [PAR::ClientAssertion::Error] if the private assertion key is invalid or unusable
|
|
92
92
|
# @raise [Error] if the redirect URI is not registered
|
|
93
|
-
# @raise [DPoP::KeyManager::KeyError] if the DPoP key cannot be imported or generated
|
|
94
93
|
# @raise [HTTPClient::HTTPError, HTTPClient::SSRFError] if fetching client metadata fails
|
|
95
94
|
#
|
|
96
|
-
def initialize(client_id:, redirect_uri:, metadata: nil,
|
|
95
|
+
def initialize(client_id:, redirect_uri:, metadata: nil, signing_key: nil)
|
|
97
96
|
@client_id = client_id
|
|
98
97
|
@redirect_uri = redirect_uri
|
|
99
98
|
|
|
100
|
-
|
|
99
|
+
if metadata && metadata['client_id'] != @client_id
|
|
100
|
+
raise InvalidClientMetadata, "client_id mismatch: #{metadata['client_id']} != #{@client_id}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Client metadata is fetched from the client ID URL if not provided directly
|
|
101
104
|
@client_metadata = load_client_metadata(metadata)
|
|
102
105
|
|
|
103
106
|
validate_redirect_uri
|
|
104
|
-
|
|
107
|
+
initialize_client_assertion(signing_key)
|
|
105
108
|
|
|
106
|
-
|
|
107
|
-
@
|
|
109
|
+
signing_keys = @client_assertion ? { @signing_key_kid => @signing_key_thumbprint } : {}
|
|
110
|
+
@session_manager = State::SessionManager.new(supported_signing_keys: signing_keys)
|
|
111
|
+
|
|
112
|
+
@dpop_client = DPoP::Client.new
|
|
108
113
|
end
|
|
109
114
|
|
|
110
115
|
# Starts authorization, stores a pending session, and submits a PAR request.
|
|
111
116
|
#
|
|
112
117
|
# Supply exactly one of `handle` or `pds`. Open the returned `:url` in a browser
|
|
113
|
-
# and pass the eventual callback parameters to {#handle_callback}.
|
|
118
|
+
# and pass the eventual callback parameters to {#handle_callback}. Retain `:session_id`
|
|
119
|
+
# in the initiating browser session or native login context for callback verification.
|
|
114
120
|
#
|
|
115
121
|
# @param handle [String, nil] account handle (optionally prefixed with `@`) or DID
|
|
116
122
|
# @param pds [String, nil] PDS URL when no handle is supplied
|
|
@@ -124,6 +130,7 @@ module AuthProto
|
|
|
124
130
|
# @raise [PAR::ClientAssertion::Error] if confidential-client signing fails
|
|
125
131
|
# @raise [DPoP::Client::Error] if generating the PAR proof fails
|
|
126
132
|
# @raise [HTTPClient::SSRFError] if server discovery rejects a destination
|
|
133
|
+
# @raise [DPoP::KeySet::KeyError] if generating the session key fails
|
|
127
134
|
# @raise [Storage::StorageError] if session storage or locking fails
|
|
128
135
|
# @raise [Encryption::EncryptionError] if session serialization cannot encrypt secrets
|
|
129
136
|
#
|
|
@@ -138,12 +145,15 @@ module AuthProto
|
|
|
138
145
|
raise Error, "No HTTPS PDS location found for #{did}" unless pds && URI(pds).is_a?(URI::HTTPS)
|
|
139
146
|
end
|
|
140
147
|
|
|
141
|
-
authorization_server =
|
|
148
|
+
authorization_server = resolve_auth_server_info(pds)
|
|
142
149
|
|
|
143
|
-
session = session_manager.create_session(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
150
|
+
session = session_manager.create_session(
|
|
151
|
+
client_id: @client_id,
|
|
152
|
+
scope: scope,
|
|
153
|
+
auth_server: authorization_server,
|
|
154
|
+
did: did&.to_s,
|
|
155
|
+
signing_key_id: @signing_key_kid
|
|
156
|
+
)
|
|
147
157
|
|
|
148
158
|
auth_url = submit_authorization_par(authorization_server, session, login_hint: handle)
|
|
149
159
|
|
|
@@ -160,12 +170,18 @@ module AuthProto
|
|
|
160
170
|
# and `:scope` (String).
|
|
161
171
|
# It also contains `:session_id` (String) and `:did` (String).
|
|
162
172
|
#
|
|
173
|
+
# Supply the session ID retained when starting authorization as `expected_session_id`.
|
|
174
|
+
# For web apps, read it from the initiating browser's session, never from callback parameters
|
|
175
|
+
# or the state-token lookup. It is checked before exchanging the authorization code.
|
|
176
|
+
# Remove the browser's pending-session association after this method succeeds.
|
|
177
|
+
#
|
|
163
178
|
# @param code [String] authorization code from the callback
|
|
164
179
|
# @param state [String] state token from the callback
|
|
165
180
|
# @param iss [String] issuer URL from the callback
|
|
181
|
+
# @param expected_session_id [String] pending session ID retained by the caller when authorization started
|
|
166
182
|
#
|
|
167
183
|
# @return [Hash{Symbol => String, Integer, nil}] tokens and authenticated session details
|
|
168
|
-
# @raise [CallbackError] if state is unknown or
|
|
184
|
+
# @raise [CallbackError] if the expected session ID is invalid, state is unknown, or session/issuer validation fails
|
|
169
185
|
# @raise [TokenError] if token exchange or response validation fails
|
|
170
186
|
# @raise [DPoP::Client::Error, PAR::ClientAssertion::Error] if proof/assertion generation fails
|
|
171
187
|
# @raise [HTTPClient::HTTPError, HTTPClient::SSRFError] if token transport fails
|
|
@@ -174,15 +190,19 @@ module AuthProto
|
|
|
174
190
|
# @raise [Storage::StorageError] if session storage or locking fails
|
|
175
191
|
# @raise [Encryption::EncryptionError] if tokens cannot be encrypted for storage
|
|
176
192
|
#
|
|
177
|
-
def handle_callback(code:, state:, iss:)
|
|
178
|
-
|
|
193
|
+
def handle_callback(code:, state:, iss:, expected_session_id:)
|
|
194
|
+
unless expected_session_id.is_a?(String) && !expected_session_id.empty?
|
|
195
|
+
raise CallbackError, "Expected session ID is required"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
session = session_manager.get_session_by_state_token(state)
|
|
179
199
|
|
|
180
200
|
raise CallbackError, "Invalid state parameter" unless session
|
|
201
|
+
raise CallbackError, "Session ID mismatch" unless session.session_id == expected_session_id
|
|
181
202
|
raise CallbackError, "Issuer mismatch" unless session.auth_server && session.auth_server.issuer == iss
|
|
182
203
|
|
|
183
204
|
AuthProto.storage.with_lock(Storage::KeyBuilder.lock_key("session", session.session_id), ttl: 30) do
|
|
184
205
|
response = exchange_code(code: code, session: session)
|
|
185
|
-
validate_token_response(response, session)
|
|
186
206
|
|
|
187
207
|
token_set = State::TokenSet.new(
|
|
188
208
|
access_token: response['access_token'],
|
|
@@ -194,7 +214,7 @@ module AuthProto
|
|
|
194
214
|
)
|
|
195
215
|
|
|
196
216
|
session.tokens = token_set
|
|
197
|
-
session_manager.
|
|
217
|
+
session_manager.complete_authorization(session)
|
|
198
218
|
|
|
199
219
|
session.session_data
|
|
200
220
|
end
|
|
@@ -213,7 +233,7 @@ module AuthProto
|
|
|
213
233
|
#
|
|
214
234
|
def get_tokens(session_id)
|
|
215
235
|
session = session_manager.get_session(session_id)
|
|
216
|
-
return nil unless session
|
|
236
|
+
return nil unless session && session.tokens_valid?
|
|
217
237
|
|
|
218
238
|
session.session_data
|
|
219
239
|
end
|
|
@@ -230,6 +250,7 @@ module AuthProto
|
|
|
230
250
|
# @return [Hash{Symbol => String, Integer, nil}] replacement tokens and session identifiers
|
|
231
251
|
# @raise [TokenError] if the session is missing or has no refresh token
|
|
232
252
|
# @raise [Token::RefreshError] if token refresh fails or exhausts retries
|
|
253
|
+
# @raise [DPoP::Client::Error] if a refresh response lacks a valid nonce
|
|
233
254
|
# @raise [State::SessionError] if replacement tokens have a different subject
|
|
234
255
|
# @raise [Storage::StorageError] if session storage or locking fails
|
|
235
256
|
# @raise [Encryption::EncryptionError] if replacement tokens cannot be stored
|
|
@@ -246,7 +267,7 @@ module AuthProto
|
|
|
246
267
|
dpop_client: @dpop_client,
|
|
247
268
|
auth_server: session.auth_server,
|
|
248
269
|
client_metadata: @client_metadata,
|
|
249
|
-
|
|
270
|
+
client_assertion: @client_assertion
|
|
250
271
|
)
|
|
251
272
|
|
|
252
273
|
new_tokens = refresher.perform_refresh
|
|
@@ -265,13 +286,13 @@ module AuthProto
|
|
|
265
286
|
#
|
|
266
287
|
def authorized?(session_id)
|
|
267
288
|
session = session_manager.get_session(session_id)
|
|
268
|
-
session
|
|
289
|
+
session ? session.tokens_valid? : false
|
|
269
290
|
end
|
|
270
291
|
|
|
271
292
|
# Builds authorization headers and a fresh DPoP proof for one HTTP request.
|
|
272
293
|
#
|
|
273
294
|
# The caller sends the request. This method does not refresh tokens, send HTTP,
|
|
274
|
-
# or retry nonce challenges. Feed response
|
|
295
|
+
# or retry nonce challenges. Feed each full HTTP response to {DPoP::Client#process_response}
|
|
275
296
|
# and call {#refresh_token} explicitly when needed. Generate new headers for each retry.
|
|
276
297
|
#
|
|
277
298
|
# @param session_id [String] authorized session ID
|
|
@@ -286,9 +307,11 @@ module AuthProto
|
|
|
286
307
|
session = session_manager.get_session(session_id)
|
|
287
308
|
|
|
288
309
|
raise TokenError, "Invalid session" unless session
|
|
289
|
-
raise TokenError, "Session not authorized"
|
|
310
|
+
raise TokenError, "Session not authorized" if session.pending?
|
|
311
|
+
raise TokenError, "Session has expired" unless session.tokens_valid?
|
|
290
312
|
|
|
291
313
|
dpop_proof = dpop_client.generate_proof(
|
|
314
|
+
session: session,
|
|
292
315
|
method: method,
|
|
293
316
|
uri: url,
|
|
294
317
|
access_token: session.tokens.access_token
|
|
@@ -307,9 +330,6 @@ module AuthProto
|
|
|
307
330
|
# @raise [Storage::StorageError] if deletion or locking fails
|
|
308
331
|
#
|
|
309
332
|
def delete_session(session_id)
|
|
310
|
-
key = Storage::KeyBuilder.session_key(session_id)
|
|
311
|
-
|
|
312
|
-
AuthProto.storage.delete(key)
|
|
313
333
|
session_manager.delete_session(session_id)
|
|
314
334
|
end
|
|
315
335
|
|
|
@@ -340,7 +360,7 @@ module AuthProto
|
|
|
340
360
|
end
|
|
341
361
|
end
|
|
342
362
|
|
|
343
|
-
def
|
|
363
|
+
def initialize_client_assertion(key)
|
|
344
364
|
if @client_metadata.confidential?
|
|
345
365
|
raise InvalidClientMetadata, "Confidential clients require a signing_key" if key.nil?
|
|
346
366
|
else
|
|
@@ -352,7 +372,7 @@ module AuthProto
|
|
|
352
372
|
private_key = assertion.signing_key
|
|
353
373
|
|
|
354
374
|
if @client_metadata.jwks
|
|
355
|
-
public_key = private_key.
|
|
375
|
+
public_key = private_key.to_public_map.to_h
|
|
356
376
|
|
|
357
377
|
key_in_jwks = @client_metadata.jwks['keys'].any? { |candidate|
|
|
358
378
|
metadata_key_matches_public_key?(candidate, public_key)
|
|
@@ -361,24 +381,20 @@ module AuthProto
|
|
|
361
381
|
raise InvalidClientMetadata, "signing_key must match an active public key advertised in jwks" if !key_in_jwks
|
|
362
382
|
end
|
|
363
383
|
|
|
364
|
-
|
|
384
|
+
@client_assertion = assertion
|
|
385
|
+
@signing_key_kid = private_key.fields.fetch('kid')
|
|
386
|
+
@signing_key_thumbprint = private_key.thumbprint('SHA256')
|
|
365
387
|
end
|
|
366
388
|
|
|
367
389
|
def metadata_key_matches_public_key?(candidate, public_key)
|
|
368
390
|
return false if candidate['revoked']
|
|
369
|
-
return false if candidate.key?('alg') && candidate['alg'] !=
|
|
391
|
+
return false if candidate.key?('alg') && candidate['alg'] != PAR::ClientAssertion::ALGORITHM
|
|
370
392
|
return false if candidate.key?('use') && candidate['use'] != 'sig'
|
|
371
393
|
return false if candidate.key?('key_ops') && !candidate['key_ops'].include?('verify')
|
|
372
394
|
|
|
373
395
|
%w[kid kty crv x y].all? { |field| candidate[field] == public_key[field] }
|
|
374
396
|
end
|
|
375
397
|
|
|
376
|
-
def initialize_dpop(key)
|
|
377
|
-
key_manager = key ? DPoP::KeyManager.from_jwk(key) : DPoP::KeyManager.new
|
|
378
|
-
|
|
379
|
-
DPoP::Client.new(key_manager: key_manager)
|
|
380
|
-
end
|
|
381
|
-
|
|
382
398
|
def validate_auth_params(handle, pds, scope)
|
|
383
399
|
unless scope.is_a?(String) && scope.valid_encoding? && Metadata::ClientMetadata::SCOPE_PATTERN.match?(scope)
|
|
384
400
|
raise Error, "scope must contain valid tokens separated by single spaces"
|
|
@@ -397,7 +413,7 @@ module AuthProto
|
|
|
397
413
|
raise Error, "cannot provide both handle and pds" if handle && pds
|
|
398
414
|
end
|
|
399
415
|
|
|
400
|
-
def
|
|
416
|
+
def resolve_auth_server_info(pds)
|
|
401
417
|
resource_server = Metadata::ResourceServer.from_url(pds)
|
|
402
418
|
auth_server_url = resource_server.authorization_server
|
|
403
419
|
|
|
@@ -419,16 +435,11 @@ module AuthProto
|
|
|
419
435
|
req.code_challenge = session.pkce_challenge
|
|
420
436
|
|
|
421
437
|
if @client_metadata.confidential?
|
|
422
|
-
req.client_assertion =
|
|
438
|
+
req.client_assertion = @client_assertion
|
|
423
439
|
end
|
|
424
|
-
|
|
425
|
-
req.dpop_proof = @dpop_client.generate_proof(
|
|
426
|
-
method: 'POST',
|
|
427
|
-
uri: authorization_server.pushed_authorization_request_endpoint
|
|
428
|
-
)
|
|
429
440
|
end
|
|
430
441
|
|
|
431
|
-
par_response = par_client.submit(par_request)
|
|
442
|
+
par_response = par_client.submit(par_request, session: session)
|
|
432
443
|
|
|
433
444
|
par_client.build_authorization_url(
|
|
434
445
|
request_uri: par_response.request_uri,
|
|
@@ -437,30 +448,26 @@ module AuthProto
|
|
|
437
448
|
end
|
|
438
449
|
|
|
439
450
|
def exchange_code(code:, session:)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
# Handle DPoP nonce requirement
|
|
444
|
-
if requires_dpop_nonce?(response)
|
|
445
|
-
# Extract and store nonce from error response
|
|
446
|
-
extract_dpop_nonce(response)
|
|
447
|
-
dpop_client.process_response(response[:headers], session.auth_server.issuer)
|
|
448
|
-
|
|
449
|
-
# Retry request with nonce
|
|
450
|
-
response = make_token_request(code, session)
|
|
451
|
+
response = dpop_client.with_nonce_handling(session, session.auth_server.token_endpoint) do
|
|
452
|
+
make_token_request(code, session)
|
|
451
453
|
end
|
|
452
454
|
|
|
453
455
|
raise TokenError, "Token request failed: #{response[:status]}" unless response[:status] == 200
|
|
454
456
|
|
|
455
457
|
begin
|
|
456
|
-
JSON.parse(response[:body])
|
|
458
|
+
data = JSON.parse(response[:body])
|
|
457
459
|
rescue JSON::ParserError => e
|
|
458
460
|
raise TokenError, "Invalid token response: #{e.message}"
|
|
459
461
|
end
|
|
462
|
+
|
|
463
|
+
validate_token_response(data, session)
|
|
464
|
+
|
|
465
|
+
data
|
|
460
466
|
end
|
|
461
467
|
|
|
462
468
|
def make_token_request(code, session)
|
|
463
469
|
dpop_proof = dpop_client.generate_proof(
|
|
470
|
+
session: session,
|
|
464
471
|
method: 'POST',
|
|
465
472
|
uri: session.auth_server.token_endpoint
|
|
466
473
|
)
|
|
@@ -474,14 +481,9 @@ module AuthProto
|
|
|
474
481
|
}
|
|
475
482
|
|
|
476
483
|
if @client_metadata.confidential?
|
|
477
|
-
assertion = PAR::ClientAssertion.new(
|
|
478
|
-
client_id: @client_id,
|
|
479
|
-
signing_key: @signing_key
|
|
480
|
-
)
|
|
481
|
-
|
|
482
484
|
body.merge!(
|
|
483
485
|
client_assertion_type: PAR::CLIENT_ASSERTION_TYPE,
|
|
484
|
-
client_assertion:
|
|
486
|
+
client_assertion: @client_assertion.generate_jwt(audience: session.auth_server.issuer)
|
|
485
487
|
)
|
|
486
488
|
end
|
|
487
489
|
|
|
@@ -495,38 +497,19 @@ module AuthProto
|
|
|
495
497
|
)
|
|
496
498
|
end
|
|
497
499
|
|
|
498
|
-
def requires_dpop_nonce?(response)
|
|
499
|
-
return false unless response[:status] == 400
|
|
500
|
-
|
|
501
|
-
error_data = JSON.parse(response[:body])
|
|
502
|
-
error_data['error'] == DPoP::ERROR_USE_NONCE
|
|
503
|
-
rescue JSON::ParserError
|
|
504
|
-
false
|
|
505
|
-
end
|
|
506
|
-
|
|
507
|
-
def extract_dpop_nonce(response)
|
|
508
|
-
headers = response[:headers]
|
|
509
|
-
nonce = headers.find { |k, v| k.downcase == 'dpop-nonce' }&.last
|
|
510
|
-
|
|
511
|
-
raise TokenError, "No DPoP nonce provided in error response" unless nonce
|
|
512
|
-
|
|
513
|
-
nonce
|
|
514
|
-
end
|
|
515
|
-
|
|
516
500
|
def validate_token_response(response, session)
|
|
517
501
|
['access_token', 'token_type', 'expires_in', 'scope', 'sub'].each do |field|
|
|
518
502
|
raise TokenError, "Missing #{field} in token response" unless response[field]
|
|
519
503
|
end
|
|
520
504
|
|
|
521
|
-
|
|
505
|
+
unless response['token_type'].is_a?(String) && response['token_type'].casecmp?('DPoP')
|
|
506
|
+
raise TokenError, "Invalid token_type: #{response['token_type']}"
|
|
507
|
+
end
|
|
508
|
+
|
|
522
509
|
raise TokenError, "Missing atproto scope in token response" unless response['scope'].split.include?('atproto')
|
|
523
510
|
|
|
524
511
|
# If we have a pre-resolved DID, verify that it matches
|
|
525
512
|
raise TokenError, "Subject mismatch in token response" if session.did && session.did != response['sub']
|
|
526
|
-
|
|
527
|
-
if response[:headers] && response[:headers]["DPoP-Nonce"]
|
|
528
|
-
dpop_client.process_response(response[:headers], session.auth_server.issuer)
|
|
529
|
-
end
|
|
530
513
|
end
|
|
531
514
|
end
|
|
532
515
|
end
|
|
@@ -34,7 +34,7 @@ module AuthProto
|
|
|
34
34
|
# @yieldparam config [Configuration] mutable global settings
|
|
35
35
|
# @yieldreturn [Object] ignored
|
|
36
36
|
# @return [Configuration] validated global settings
|
|
37
|
-
# @raise [ConfigurationError] if
|
|
37
|
+
# @raise [ConfigurationError] if storage, HTTP transport or lifetime settings are invalid
|
|
38
38
|
# @example Configure the default transport
|
|
39
39
|
# AuthProto.configure do |config|
|
|
40
40
|
# config.http_client = AuthProto::HTTPClient.new
|
|
@@ -62,7 +62,7 @@ module AuthProto
|
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
# Raised when global
|
|
65
|
+
# Raised when global configuration settings are invalid.
|
|
66
66
|
class ConfigurationError < Error; end
|
|
67
67
|
|
|
68
68
|
#
|
|
@@ -71,18 +71,14 @@ module AuthProto
|
|
|
71
71
|
|
|
72
72
|
class Configuration
|
|
73
73
|
|
|
74
|
-
#
|
|
75
|
-
# @return [Integer]
|
|
76
|
-
attr_accessor :
|
|
74
|
+
# Default client assertion validity in seconds, read when generating each assertion.
|
|
75
|
+
# @return [Integer] positive lifetime in seconds; defaults to 300
|
|
76
|
+
attr_accessor :client_assertion_lifetime
|
|
77
77
|
|
|
78
|
-
#
|
|
79
|
-
# @return [Integer]
|
|
78
|
+
# Default stored nonce lifetime in seconds; used when creating nonce managers without an explicit TTL.
|
|
79
|
+
# @return [Integer] positive lifetime in seconds; defaults to 300
|
|
80
80
|
attr_accessor :dpop_nonce_lifetime
|
|
81
81
|
|
|
82
|
-
# Reserved encryption setting; serializers currently create their own service.
|
|
83
|
-
# @return [Object, nil]
|
|
84
|
-
attr_accessor :encryption
|
|
85
|
-
|
|
86
82
|
# Transport used for discovery, PAR and token requests; initially `nil`.
|
|
87
83
|
# @return [#get, #post, nil]
|
|
88
84
|
attr_accessor :http_client
|
|
@@ -91,6 +87,10 @@ module AuthProto
|
|
|
91
87
|
# @return [Logger]
|
|
92
88
|
attr_accessor :logger
|
|
93
89
|
|
|
90
|
+
# Lifetime of pending authorization attempts in seconds; defaults to 900 (15 minutes).
|
|
91
|
+
# @return [Integer] positive lifetime in seconds
|
|
92
|
+
attr_accessor :pending_session_ttl
|
|
93
|
+
|
|
94
94
|
# Storage backend; defaults to a new {Storage::Memory}.
|
|
95
95
|
# @return [Storage::Interface]
|
|
96
96
|
attr_accessor :storage
|
|
@@ -98,30 +98,51 @@ module AuthProto
|
|
|
98
98
|
# Creates default settings with in-memory storage and no HTTP transport.
|
|
99
99
|
#
|
|
100
100
|
def initialize
|
|
101
|
-
@
|
|
102
|
-
@dpop_nonce_lifetime = 300
|
|
103
|
-
@
|
|
101
|
+
@client_assertion_lifetime = 300 # 5 minutes
|
|
102
|
+
@dpop_nonce_lifetime = 300 # 5 minutes
|
|
103
|
+
@pending_session_ttl = 900 # 15 minutes
|
|
104
104
|
@http_client = nil
|
|
105
105
|
@logger = Logger.new($stdout)
|
|
106
106
|
@storage = AuthProto::Storage::Memory.new
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
-
# Checks
|
|
109
|
+
# Checks storage and transport interfaces and positive assertion, nonce and pending session lifetimes.
|
|
110
110
|
#
|
|
111
111
|
# A `nil` HTTP client is accepted, but cannot perform OAuth or discovery requests.
|
|
112
112
|
#
|
|
113
113
|
# @return [true] if validation succeeds
|
|
114
|
-
# @raise [ConfigurationError] if storage
|
|
114
|
+
# @raise [ConfigurationError] if storage, HTTP transport or lifetime settings are invalid
|
|
115
115
|
#
|
|
116
116
|
def validate
|
|
117
117
|
validate_storage
|
|
118
118
|
validate_http_client
|
|
119
|
+
validate_client_assertion_lifetime
|
|
120
|
+
validate_dpop_nonce_lifetime
|
|
121
|
+
validate_pending_session_ttl
|
|
119
122
|
true
|
|
120
123
|
end
|
|
121
124
|
|
|
122
125
|
|
|
123
126
|
private
|
|
124
127
|
|
|
128
|
+
def validate_client_assertion_lifetime
|
|
129
|
+
return if @client_assertion_lifetime.is_a?(Integer) && @client_assertion_lifetime.positive?
|
|
130
|
+
|
|
131
|
+
raise ConfigurationError, "Client assertion lifetime must be a positive integer"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate_dpop_nonce_lifetime
|
|
135
|
+
return if @dpop_nonce_lifetime.is_a?(Integer) && @dpop_nonce_lifetime.positive?
|
|
136
|
+
|
|
137
|
+
raise ConfigurationError, "DPoP nonce lifetime must be a positive integer"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def validate_pending_session_ttl
|
|
141
|
+
return if @pending_session_ttl.is_a?(Integer) && @pending_session_ttl.positive?
|
|
142
|
+
|
|
143
|
+
raise ConfigurationError, "Pending session TTL must be a positive integer"
|
|
144
|
+
end
|
|
145
|
+
|
|
125
146
|
def validate_storage
|
|
126
147
|
raise ConfigurationError, "Storage must be configured" if @storage.nil?
|
|
127
148
|
return if @storage.is_a?(AuthProto::Storage::Interface)
|