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
|
@@ -5,9 +5,10 @@ require 'openssl'
|
|
|
5
5
|
require 'securerandom'
|
|
6
6
|
require 'uri'
|
|
7
7
|
|
|
8
|
+
require_relative '../dpop'
|
|
8
9
|
require_relative '../errors'
|
|
9
10
|
require_relative '../validated_uri'
|
|
10
|
-
require_relative '
|
|
11
|
+
require_relative 'key_set'
|
|
11
12
|
|
|
12
13
|
module AuthProto
|
|
13
14
|
module DPoP
|
|
@@ -24,49 +25,33 @@ module AuthProto
|
|
|
24
25
|
# Error raised for proof generation/validation issues
|
|
25
26
|
class ProofError < AuthProto::Error; end
|
|
26
27
|
|
|
27
|
-
# Key manager used to sign proofs.
|
|
28
|
-
# @return [KeyManager]
|
|
29
|
-
attr_reader :key_manager
|
|
30
|
-
|
|
31
|
-
# Creates a new ProofGenerator instance
|
|
32
|
-
#
|
|
33
|
-
# @param key_manager [KeyManager] Key manager to use for signing proofs
|
|
34
|
-
# @raise [ProofError] if key_manager is invalid
|
|
35
|
-
#
|
|
36
|
-
def initialize(key_manager)
|
|
37
|
-
raise ProofError, "key_manager is required" unless key_manager
|
|
38
|
-
raise ProofError, "invalid key_manager type" unless key_manager.is_a?(KeyManager)
|
|
39
|
-
|
|
40
|
-
@key_manager = key_manager
|
|
41
|
-
end
|
|
42
|
-
|
|
43
28
|
# Builds and signs a fresh DPoP JWT with `jti`, `htm`, `htu` and `iat` claims.
|
|
44
29
|
#
|
|
45
|
-
#
|
|
46
|
-
# query strings, fragments and default ports.
|
|
30
|
+
# Includes a nonce when provided and an access-token hash whenever an access token
|
|
31
|
+
# is supplied. The URI normalizer removes query strings, fragments and default ports.
|
|
47
32
|
#
|
|
33
|
+
# @param key_set [KeySet] key belonging to the session making this request
|
|
48
34
|
# @param method [String] nonempty HTTP method, converted to uppercase
|
|
49
35
|
# @param uri [String] absolute HTTP(S) request URL
|
|
50
36
|
# @param nonce [String, nil] server-provided nonce to include
|
|
51
37
|
# @param access_token [String, nil] access token to bind to the proof
|
|
52
|
-
# @param ath [Boolean, nil] include an access-token hash; `nil` enables it when a token is supplied
|
|
53
38
|
# @return [JOSE::SignedBinary] compact signed DPoP JWT
|
|
54
39
|
# @raise [ProofError] if input validation or proof generation fails
|
|
55
40
|
#
|
|
56
|
-
def generate(method:, uri:, nonce: nil, access_token: nil
|
|
41
|
+
def generate(key_set:, method:, uri:, nonce: nil, access_token: nil)
|
|
42
|
+
raise ProofError, "invalid key_set type" unless key_set.is_a?(KeySet)
|
|
43
|
+
|
|
57
44
|
validate_inputs(method, uri)
|
|
58
|
-
ath = !access_token.nil? if ath.nil?
|
|
59
45
|
|
|
60
|
-
header = build_header
|
|
46
|
+
header = build_header(key_set)
|
|
61
47
|
payload = build_payload(
|
|
62
48
|
method: method,
|
|
63
49
|
uri: uri,
|
|
64
50
|
nonce: nonce,
|
|
65
|
-
access_token: access_token
|
|
66
|
-
include_ath: ath
|
|
51
|
+
access_token: access_token
|
|
67
52
|
)
|
|
68
53
|
|
|
69
|
-
|
|
54
|
+
key_set.sign_segments(header, payload)
|
|
70
55
|
rescue StandardError => e
|
|
71
56
|
raise ProofError, "Failed to generate proof: #{e.message}"
|
|
72
57
|
end
|
|
@@ -82,15 +67,15 @@ module AuthProto
|
|
|
82
67
|
raise ProofError, "invalid uri: #{e.message}"
|
|
83
68
|
end
|
|
84
69
|
|
|
85
|
-
def build_header
|
|
70
|
+
def build_header(key_set)
|
|
86
71
|
{
|
|
87
72
|
typ: 'dpop+jwt',
|
|
88
|
-
alg:
|
|
89
|
-
jwk:
|
|
73
|
+
alg: ALGORITHM,
|
|
74
|
+
jwk: key_set.public_jwk
|
|
90
75
|
}
|
|
91
76
|
end
|
|
92
77
|
|
|
93
|
-
def build_payload(method:, uri:, nonce: nil, access_token: nil
|
|
78
|
+
def build_payload(method:, uri:, nonce: nil, access_token: nil)
|
|
94
79
|
payload = {
|
|
95
80
|
'jti' => SecureRandom.uuid,
|
|
96
81
|
'htm' => method.upcase,
|
|
@@ -101,8 +86,8 @@ module AuthProto
|
|
|
101
86
|
# Add the nonce if provided
|
|
102
87
|
payload['nonce'] = nonce if nonce
|
|
103
88
|
|
|
104
|
-
#
|
|
105
|
-
payload['ath'] = generate_access_token_hash(access_token)
|
|
89
|
+
# Bind the proof to the access token whenever one is supplied
|
|
90
|
+
payload['ath'] = generate_access_token_hash(access_token) unless access_token.nil?
|
|
106
91
|
|
|
107
92
|
payload
|
|
108
93
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuthProto
|
|
4
|
+
|
|
5
|
+
#
|
|
6
|
+
# Demonstrating Proof of Possession (DPoP) signing and request authentication.
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
module DPoP
|
|
10
|
+
|
|
11
|
+
# JWS signing algorithm used for DPoP proofs.
|
|
12
|
+
# @return [String]
|
|
13
|
+
ALGORITHM = "ES256"
|
|
14
|
+
|
|
15
|
+
# OAuth error value requesting a retry with a server-provided DPoP nonce.
|
|
16
|
+
# @return [String]
|
|
17
|
+
ERROR_USE_NONCE = "use_dpop_nonce"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'strscan'
|
|
4
|
+
|
|
5
|
+
module AuthProto
|
|
6
|
+
|
|
7
|
+
#
|
|
8
|
+
# Shared helpers for HTTP response hashes returned by the configured transport.
|
|
9
|
+
#
|
|
10
|
+
|
|
11
|
+
module HTTPHelpers
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# Looks up a response header without regard to the case of its name.
|
|
16
|
+
#
|
|
17
|
+
# @param response [Hash{Symbol => Object}] HTTP response with an optional `:headers` hash
|
|
18
|
+
# @param name [String] header name, in any case
|
|
19
|
+
# @param strip_parameters [Boolean] return only the trimmed value before the first semicolon
|
|
20
|
+
# @return [String, nil] header value, or `nil` if the header or headers hash is absent
|
|
21
|
+
#
|
|
22
|
+
def response_header(response, name, strip_parameters: false)
|
|
23
|
+
value = (response[:headers] || {}).find { |header, value| header.casecmp?(name) }&.last
|
|
24
|
+
return value unless value && strip_parameters
|
|
25
|
+
|
|
26
|
+
value.split(';', 2).first.to_s.strip
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Finds an authentication scheme in a WWW-Authenticate challenge list.
|
|
30
|
+
# Quoted commas and escapes are preserved while parsing. Scheme and parameter
|
|
31
|
+
# names are case-insensitive; parameter values retain their original case.
|
|
32
|
+
#
|
|
33
|
+
# @param header [String, nil] combined WWW-Authenticate header value
|
|
34
|
+
# @param scheme [String] authentication scheme to select
|
|
35
|
+
# @return [Hash{String => String}, nil] parameters, or `nil` for absent, malformed or multiple matching challenges
|
|
36
|
+
#
|
|
37
|
+
def authentication_challenge(header, scheme:)
|
|
38
|
+
return nil unless header.is_a?(String)
|
|
39
|
+
|
|
40
|
+
scanner = StringScanner.new(header)
|
|
41
|
+
matches = []
|
|
42
|
+
scanner.skip(/[ \t]*/)
|
|
43
|
+
|
|
44
|
+
loop do
|
|
45
|
+
name = scanner.scan(/[!#$%&'*+\-.^_`|~0-9A-Za-z]+/)
|
|
46
|
+
return nil unless name
|
|
47
|
+
|
|
48
|
+
whitespace = scanner.skip(/[ \t]*/)
|
|
49
|
+
params = {}
|
|
50
|
+
|
|
51
|
+
unless scanner.eos? || scanner.check(/,/)
|
|
52
|
+
return nil if whitespace.zero?
|
|
53
|
+
|
|
54
|
+
# Other schemes can carry token68 credentials rather than parameters.
|
|
55
|
+
unless scanner.scan(/[A-Za-z0-9\-._~+\/]+=*(?=[ \t]*(?:,|\z))/)
|
|
56
|
+
loop do
|
|
57
|
+
parameter = scanner.scan(/[!#$%&'*+\-.^_`|~0-9A-Za-z]+/)&.downcase
|
|
58
|
+
return nil unless parameter && !params.key?(parameter)
|
|
59
|
+
|
|
60
|
+
scanner.skip(/[ \t]*/)
|
|
61
|
+
return nil unless scanner.scan(/=/)
|
|
62
|
+
|
|
63
|
+
scanner.skip(/[ \t]*/)
|
|
64
|
+
value = scanner.scan(/"(?:\\.|[^"\\])*"/) || scanner.scan(/[!#$%&'*+\-.^_`|~0-9A-Za-z]+/)
|
|
65
|
+
return nil unless value
|
|
66
|
+
|
|
67
|
+
params[parameter] = value.start_with?('"') ? value[1...-1].gsub(/\\(.)/, '\\1') : value
|
|
68
|
+
scanner.skip(/[ \t]*/)
|
|
69
|
+
break unless scanner.check(/,[ \t]*[!#$%&'*+\-.^_`|~0-9A-Za-z]+[ \t]*=/)
|
|
70
|
+
|
|
71
|
+
scanner.scan(/,[ \t]*/)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
matches << params if name.casecmp?(scheme)
|
|
77
|
+
scanner.skip(/[ \t]*/)
|
|
78
|
+
break if scanner.eos?
|
|
79
|
+
return nil unless scanner.scan(/,[ \t]*/)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
matches.one? ? matches.first : nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -4,9 +4,12 @@ require 'json'
|
|
|
4
4
|
require 'uri'
|
|
5
5
|
|
|
6
6
|
require_relative '../configuration'
|
|
7
|
+
require_relative '../dpop'
|
|
7
8
|
require_relative '../errors'
|
|
8
9
|
require_relative '../http_client'
|
|
10
|
+
require_relative '../http_helpers'
|
|
9
11
|
require_relative '../metadata'
|
|
12
|
+
require_relative '../par/client_assertion'
|
|
10
13
|
require_relative '../validated_uri'
|
|
11
14
|
|
|
12
15
|
module AuthProto
|
|
@@ -34,6 +37,7 @@ module AuthProto
|
|
|
34
37
|
#
|
|
35
38
|
|
|
36
39
|
class AuthorizationServer
|
|
40
|
+
extend HTTPHelpers
|
|
37
41
|
|
|
38
42
|
# Metadata keys that must be present before capability validation.
|
|
39
43
|
# @return [Array<String>]
|
|
@@ -258,8 +262,10 @@ module AuthProto
|
|
|
258
262
|
@token_endpoint_auth_signing_alg_values_supported, 'token_endpoint_auth_signing_alg_values_supported'
|
|
259
263
|
)
|
|
260
264
|
|
|
261
|
-
|
|
262
|
-
|
|
265
|
+
algorithm = PAR::ClientAssertion::ALGORITHM
|
|
266
|
+
|
|
267
|
+
unless @token_endpoint_auth_signing_alg_values_supported.include?(algorithm)
|
|
268
|
+
raise InvalidAuthorizationServer, "token_endpoint_auth_signing_alg_values_supported must include #{algorithm}"
|
|
263
269
|
end
|
|
264
270
|
|
|
265
271
|
if @token_endpoint_auth_signing_alg_values_supported.include?('none')
|
|
@@ -270,8 +276,8 @@ module AuthProto
|
|
|
270
276
|
def validate_dpop_signing_algs
|
|
271
277
|
validate_string_array(@dpop_signing_alg_values_supported, 'dpop_signing_alg_values_supported')
|
|
272
278
|
|
|
273
|
-
unless @dpop_signing_alg_values_supported.include?(
|
|
274
|
-
raise InvalidAuthorizationServer, "dpop_signing_alg_values_supported must include
|
|
279
|
+
unless @dpop_signing_alg_values_supported.include?(DPoP::ALGORITHM)
|
|
280
|
+
raise InvalidAuthorizationServer, "dpop_signing_alg_values_supported must include #{DPoP::ALGORITHM}"
|
|
275
281
|
end
|
|
276
282
|
end
|
|
277
283
|
|
|
@@ -328,9 +334,9 @@ module AuthProto
|
|
|
328
334
|
raise InvalidAuthorizationServer, "Authorization server metadata must return HTTP 200"
|
|
329
335
|
end
|
|
330
336
|
|
|
331
|
-
content_type = response
|
|
337
|
+
content_type = response_header(response, 'Content-Type', strip_parameters: true)
|
|
332
338
|
|
|
333
|
-
unless content_type.casecmp?('application/json')
|
|
339
|
+
unless content_type.to_s.casecmp?('application/json')
|
|
334
340
|
raise InvalidAuthorizationServer, "Authorization server metadata must use application/json"
|
|
335
341
|
end
|
|
336
342
|
end
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'json'
|
|
4
3
|
require 'jose'
|
|
4
|
+
require 'json'
|
|
5
5
|
require 'uri'
|
|
6
6
|
|
|
7
7
|
require_relative '../configuration'
|
|
8
8
|
require_relative '../errors'
|
|
9
|
+
require_relative '../http_helpers'
|
|
9
10
|
require_relative '../metadata'
|
|
11
|
+
require_relative '../par/client_assertion'
|
|
10
12
|
require_relative '../validated_uri'
|
|
11
13
|
|
|
12
14
|
module AuthProto
|
|
@@ -45,6 +47,7 @@ module AuthProto
|
|
|
45
47
|
#
|
|
46
48
|
|
|
47
49
|
class ClientMetadata
|
|
50
|
+
extend HTTPHelpers
|
|
48
51
|
|
|
49
52
|
# Client authentication method for confidential clients
|
|
50
53
|
AUTH_METHOD_PRIVATE_KEY_JWT = "private_key_jwt"
|
|
@@ -166,9 +169,9 @@ module AuthProto
|
|
|
166
169
|
"expected HTTP 200 response for client metadata request, got #{response[:status]}"
|
|
167
170
|
end
|
|
168
171
|
|
|
169
|
-
content_type = (response
|
|
172
|
+
content_type = response_header(response, 'Content-Type', strip_parameters: true)
|
|
170
173
|
|
|
171
|
-
unless content_type.to_s.
|
|
174
|
+
unless content_type.to_s.downcase == 'application/json'
|
|
172
175
|
raise InvalidClientMetadata, "client metadata Content-Type must be application/json"
|
|
173
176
|
end
|
|
174
177
|
|
|
@@ -472,6 +475,8 @@ module AuthProto
|
|
|
472
475
|
raise InvalidClientMetadata, "jwks must have a 'keys' array" unless jwks['keys'].is_a?(Array)
|
|
473
476
|
raise InvalidClientMetadata, "jwks must contain at least one public key" if jwks['keys'].empty?
|
|
474
477
|
|
|
478
|
+
key_ids = Set.new
|
|
479
|
+
|
|
475
480
|
jwks['keys'].each_with_index do |key, index|
|
|
476
481
|
path = "jwks.keys.#{index}"
|
|
477
482
|
|
|
@@ -481,6 +486,12 @@ module AuthProto
|
|
|
481
486
|
raise InvalidClientMetadata, "#{path} must have a nonempty string kid"
|
|
482
487
|
end
|
|
483
488
|
|
|
489
|
+
if key_ids.include?(key['kid'])
|
|
490
|
+
raise InvalidClientMetadata, "#{path} has a duplicate kid: #{key['kid']}"
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
key_ids << key['kid']
|
|
494
|
+
|
|
484
495
|
if %w[d p q dp dq qi oth k].any? { |field| key.key?(field) }
|
|
485
496
|
raise InvalidClientMetadata, "#{path} must contain only public key material"
|
|
486
497
|
end
|
|
@@ -561,8 +572,10 @@ module AuthProto
|
|
|
561
572
|
end
|
|
562
573
|
|
|
563
574
|
def validate_auth_confidential(metadata)
|
|
564
|
-
|
|
565
|
-
|
|
575
|
+
algorithm = PAR::ClientAssertion::ALGORITHM
|
|
576
|
+
|
|
577
|
+
unless @token_endpoint_auth_signing_alg == algorithm
|
|
578
|
+
raise InvalidClientMetadata, "token_endpoint_auth_signing_alg must be #{algorithm}"
|
|
566
579
|
end
|
|
567
580
|
|
|
568
581
|
if @jwks.nil? && @jwks_uri.nil?
|
|
@@ -6,6 +6,7 @@ require 'uri'
|
|
|
6
6
|
require_relative '../configuration'
|
|
7
7
|
require_relative '../errors'
|
|
8
8
|
require_relative '../http_client'
|
|
9
|
+
require_relative '../http_helpers'
|
|
9
10
|
require_relative '../metadata'
|
|
10
11
|
require_relative '../validated_uri'
|
|
11
12
|
|
|
@@ -21,6 +22,7 @@ module AuthProto
|
|
|
21
22
|
#
|
|
22
23
|
|
|
23
24
|
class ResourceServer
|
|
25
|
+
extend HTTPHelpers
|
|
24
26
|
|
|
25
27
|
# The single authorization server origin advertised by the resource server.
|
|
26
28
|
# @return [String]
|
|
@@ -107,9 +109,9 @@ module AuthProto
|
|
|
107
109
|
raise InvalidAuthorizationServer, "Resource server metadata must return HTTP 200"
|
|
108
110
|
end
|
|
109
111
|
|
|
110
|
-
content_type = response
|
|
112
|
+
content_type = response_header(response, 'Content-Type', strip_parameters: true)
|
|
111
113
|
|
|
112
|
-
unless content_type.casecmp?("application/json")
|
|
114
|
+
unless content_type.to_s.casecmp?("application/json")
|
|
113
115
|
raise InvalidAuthorizationServer, "Resource server metadata must use application/json"
|
|
114
116
|
end
|
|
115
117
|
end
|
data/lib/authproto/minisky.rb
CHANGED
|
@@ -6,6 +6,7 @@ require 'net/http'
|
|
|
6
6
|
require 'uri'
|
|
7
7
|
|
|
8
8
|
require_relative 'client'
|
|
9
|
+
require_relative 'http_helpers'
|
|
9
10
|
|
|
10
11
|
#
|
|
11
12
|
# Experimental Minisky client backed by an AuthProto session.
|
|
@@ -13,6 +14,7 @@ require_relative 'client'
|
|
|
13
14
|
#
|
|
14
15
|
|
|
15
16
|
class Minisky::OAuthClient
|
|
17
|
+
include AuthProto::HTTPHelpers
|
|
16
18
|
include Minisky::Requests
|
|
17
19
|
|
|
18
20
|
# Maximum authenticated XRPC retries after nonce challenges.
|
|
@@ -41,14 +43,14 @@ class Minisky::OAuthClient
|
|
|
41
43
|
# @param redirect_uri [String] registered callback URI
|
|
42
44
|
# @param metadata [Hash{String => Object}, nil] preloaded metadata, or `nil` to fetch it
|
|
43
45
|
# @param signing_key [Hash, JOSE::JWK, nil] private ES256 key for confidential-client authentication
|
|
44
|
-
# @param
|
|
46
|
+
# @param session_id [String, nil] stored session ID to resume; `nil` starts without an attached session
|
|
45
47
|
#
|
|
46
48
|
# @raise [AuthProto::InvalidClientMetadata] if metadata is invalid or unavailable
|
|
47
49
|
# @raise [AuthProto::Error] if OAuth client or DPoP initialization fails
|
|
48
50
|
# @raise [AuthProto::HTTPClient::HTTPError, AuthProto::HTTPClient::SSRFError] if fetching metadata fails
|
|
49
51
|
# @see AuthProto::Client#initialize
|
|
50
52
|
#
|
|
51
|
-
def initialize(host, client_id:, redirect_uri:, metadata: nil,
|
|
53
|
+
def initialize(host, client_id:, redirect_uri:, metadata: nil, signing_key: nil, session_id: nil)
|
|
52
54
|
@host = host
|
|
53
55
|
@session_id = session_id
|
|
54
56
|
|
|
@@ -59,7 +61,6 @@ class Minisky::OAuthClient
|
|
|
59
61
|
client_id: client_id,
|
|
60
62
|
redirect_uri: redirect_uri,
|
|
61
63
|
metadata: metadata,
|
|
62
|
-
dpop_key: dpop_key,
|
|
63
64
|
signing_key: signing_key
|
|
64
65
|
)
|
|
65
66
|
end
|
|
@@ -87,19 +88,22 @@ class Minisky::OAuthClient
|
|
|
87
88
|
|
|
88
89
|
# Completes OAuth authorization and remembers the authenticated session ID.
|
|
89
90
|
#
|
|
91
|
+
# @param expected_session_id [String] pending session ID retained in the initiating browser session or native context
|
|
90
92
|
# @param options [Hash{Symbol => String}] callback parameters forwarded to {AuthProto::Client#handle_callback}
|
|
91
93
|
# @option options [String] :code authorization code
|
|
92
94
|
# @option options [String] :state callback state token
|
|
93
95
|
# @option options [String] :iss authorization server issuer
|
|
94
96
|
#
|
|
95
97
|
# @return [Hash{Symbol => String, Integer, nil}] token and session details from the OAuth client
|
|
96
|
-
# @raise [AuthProto::Client::CallbackError] if state or issuer validation fails
|
|
98
|
+
# @raise [AuthProto::Client::CallbackError] if expected session, state or issuer validation fails
|
|
97
99
|
# @raise [AuthProto::Client::TokenError] if token exchange or validation fails
|
|
98
100
|
# @raise [AuthProto::Error] if proofs, storage or serialization fail
|
|
99
101
|
# @see AuthProto::Client#handle_callback
|
|
100
102
|
#
|
|
101
|
-
def handle_callback(**options)
|
|
102
|
-
oauth_client.handle_callback(**options).tap
|
|
103
|
+
def handle_callback(expected_session_id:, **options)
|
|
104
|
+
oauth_client.handle_callback(**options, expected_session_id: expected_session_id).tap do |result|
|
|
105
|
+
@session_id = result.fetch(:session_id)
|
|
106
|
+
end
|
|
103
107
|
end
|
|
104
108
|
|
|
105
109
|
# Sends an XRPC GET through Minisky, adding OAuth handling when authenticated.
|
|
@@ -117,7 +121,7 @@ class Minisky::OAuthClient
|
|
|
117
121
|
# @raise [ArgumentError] if the method is invalid or `auth` is a truthy non-Boolean value
|
|
118
122
|
# @raise [Minisky::AuthError] if no completed OAuth login or refresh capability is available
|
|
119
123
|
# @raise [Minisky::BadResponse] if the final response has an error or redirect status
|
|
120
|
-
# @raise [AuthProto::DPoP::Client::Error] if proof handling fails or
|
|
124
|
+
# @raise [AuthProto::DPoP::Client::Error] if proof handling fails or any DPoP response lacks a valid nonce
|
|
121
125
|
# @raise [AuthProto::Error] if OAuth refresh or token persistence fails
|
|
122
126
|
# @raise [JSON::ParserError] if a JSON response cannot be parsed
|
|
123
127
|
# @raise [Net::OpenTimeout, Net::ReadTimeout, IOError, SystemCallError] if HTTP transport fails
|
|
@@ -152,7 +156,7 @@ class Minisky::OAuthClient
|
|
|
152
156
|
# @raise [ArgumentError] if the method is invalid or `auth` is a truthy non-Boolean value
|
|
153
157
|
# @raise [Minisky::AuthError] if no completed OAuth login or refresh capability is available
|
|
154
158
|
# @raise [Minisky::BadResponse] if the final response has an error or redirect status
|
|
155
|
-
# @raise [AuthProto::DPoP::Client::Error] if proof handling fails or
|
|
159
|
+
# @raise [AuthProto::DPoP::Client::Error] if proof handling fails or any DPoP response lacks a valid nonce
|
|
156
160
|
# @raise [AuthProto::Error] if OAuth refresh or token persistence fails
|
|
157
161
|
# @raise [JSON::ParserError] if a JSON response cannot be parsed
|
|
158
162
|
# @raise [Net::OpenTimeout, Net::ReadTimeout, IOError, SystemCallError] if HTTP transport fails
|
|
@@ -196,6 +200,7 @@ class Minisky::OAuthClient
|
|
|
196
200
|
# @raise [AuthProto::Client::TokenError] if the session cannot be refreshed
|
|
197
201
|
# @raise [AuthProto::Token::RefreshError] if refresh fails
|
|
198
202
|
# @raise [AuthProto::Storage::StorageError] if token persistence fails
|
|
203
|
+
# @raise [AuthProto::DPoP::Client::Error] if a refresh response has a missing or invalid nonce
|
|
199
204
|
#
|
|
200
205
|
def perform_token_refresh
|
|
201
206
|
oauth_client.refresh_token(session_id)
|
|
@@ -209,14 +214,17 @@ class Minisky::OAuthClient
|
|
|
209
214
|
#
|
|
210
215
|
def check_access
|
|
211
216
|
session = oauth_client.session_manager.get_session(session_id) if session_id
|
|
212
|
-
raise Minisky::AuthError, "Complete OAuth login before making authenticated requests" unless session&.tokens
|
|
213
|
-
|
|
214
|
-
return :ok unless session.tokens.expired?
|
|
215
|
-
|
|
216
|
-
raise Minisky::AuthError, "OAuth session cannot be refreshed; log in again" unless session.renewable?
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
|
|
218
|
+
if !session || session.pending?
|
|
219
|
+
raise Minisky::AuthError, "Complete OAuth login before making authenticated requests"
|
|
220
|
+
elsif session.tokens_valid?
|
|
221
|
+
:ok
|
|
222
|
+
elsif session.renewable?
|
|
223
|
+
perform_token_refresh
|
|
224
|
+
:refreshed
|
|
225
|
+
else
|
|
226
|
+
raise Minisky::AuthError, "OAuth session cannot be refreshed; log in again"
|
|
227
|
+
end
|
|
220
228
|
end
|
|
221
229
|
|
|
222
230
|
|
|
@@ -237,35 +245,29 @@ class Minisky::OAuthClient
|
|
|
237
245
|
|
|
238
246
|
request = request_class.new(url, request_headers)
|
|
239
247
|
request.body = body unless body.nil?
|
|
248
|
+
|
|
240
249
|
response = make_request(request)
|
|
250
|
+
response_hash = { status: response.code.to_i, headers: response.each_header.to_h, body: response.body }
|
|
241
251
|
|
|
242
|
-
|
|
243
|
-
oauth_client.dpop_client.process_response(response.each_header.to_h, url.to_s) if nonce && !nonce.empty?
|
|
244
|
-
error = authentication_error(response)
|
|
252
|
+
oauth_client.dpop_client.process_response(response_hash, server_url: url.to_s, session_id: session_id)
|
|
245
253
|
|
|
246
|
-
if
|
|
247
|
-
unless nonce && !nonce.empty?
|
|
248
|
-
raise AuthProto::DPoP::Client::Error, "DPoP nonce challenge (HTTP 401) is missing DPoP-Nonce"
|
|
249
|
-
end
|
|
254
|
+
if oauth_client.dpop_client.has_nonce_error?(response_hash)
|
|
250
255
|
return response if nonce_retries >= MAX_NONCE_RETRIES
|
|
251
|
-
|
|
252
256
|
nonce_retries += 1
|
|
253
|
-
elsif response.code.to_i == 401 && error == "invalid_token" && !refreshed
|
|
254
|
-
perform_token_refresh
|
|
255
|
-
refreshed = true
|
|
256
257
|
else
|
|
257
|
-
|
|
258
|
+
error = authentication_error(response)
|
|
259
|
+
|
|
260
|
+
if response.code.to_i == 401 && error == "invalid_token" && !refreshed
|
|
261
|
+
perform_token_refresh
|
|
262
|
+
refreshed = true
|
|
263
|
+
else
|
|
264
|
+
return response
|
|
265
|
+
end
|
|
258
266
|
end
|
|
259
267
|
end
|
|
260
268
|
end
|
|
261
269
|
|
|
262
270
|
def authentication_error(response)
|
|
263
|
-
|
|
264
|
-
# ambiguous multi-challenge headers to Minisky's normal error handling.
|
|
265
|
-
challenge = response["WWW-Authenticate"].to_s
|
|
266
|
-
return unless challenge.match?(/\ADPoP\s/i)
|
|
267
|
-
return if challenge.match?(/,\s*(?:Bearer|Basic|DPoP)\b/i)
|
|
268
|
-
|
|
269
|
-
challenge[/\berror\s*=\s*"(use_dpop_nonce|invalid_token)"/i, 1]
|
|
271
|
+
authentication_challenge(response['WWW-Authenticate'], scheme: 'DPoP')&.fetch('error', nil)
|
|
270
272
|
end
|
|
271
273
|
end
|