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
|
@@ -1,84 +1,71 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
3
4
|
require 'uri'
|
|
4
5
|
|
|
5
6
|
require_relative '../errors'
|
|
6
|
-
require_relative '
|
|
7
|
+
require_relative '../http_helpers'
|
|
7
8
|
require_relative 'nonce_manager'
|
|
8
9
|
require_relative 'proof_generator'
|
|
9
10
|
|
|
10
11
|
module AuthProto
|
|
11
12
|
|
|
12
13
|
#
|
|
13
|
-
#
|
|
14
|
+
# Shared proof generation and server-origin nonces for DPoP authentication.
|
|
14
15
|
#
|
|
15
16
|
|
|
16
17
|
module DPoP
|
|
17
18
|
|
|
18
|
-
ERROR_USE_NONCE = "use_dpop_nonce"
|
|
19
|
-
|
|
20
|
-
# High-level client for managing DPoP operations. Integrates key management,
|
|
21
|
-
# proof generation, and nonce tracking to provide a complete DPoP client
|
|
22
|
-
# implementation according to RFC 9449.
|
|
23
19
|
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
# - Header construction for requests
|
|
29
|
-
# - Response processing for nonce updates
|
|
20
|
+
# Shared DPoP client using session-owned signing keys and nonces scoped to
|
|
21
|
+
# each session and server origin. Generates RFC 9449 proofs, processes nonce
|
|
22
|
+
# updates, and detects nonce challenges. Response processing requires a nonce
|
|
23
|
+
# on every DPoP response, as required by the AT Protocol OAuth profile.
|
|
30
24
|
#
|
|
31
25
|
|
|
32
26
|
class Client
|
|
27
|
+
include HTTPHelpers
|
|
33
28
|
|
|
34
29
|
# Error raised for DPoP client operations
|
|
35
30
|
class Error < AuthProto::Error; end
|
|
36
31
|
|
|
37
|
-
# Key manager used to sign proofs.
|
|
38
|
-
# @return [KeyManager]
|
|
39
|
-
attr_reader :key_manager
|
|
40
|
-
|
|
41
32
|
# Generator for per-request DPoP JWTs.
|
|
42
33
|
# @return [ProofGenerator]
|
|
43
34
|
attr_reader :proof_generator
|
|
44
35
|
|
|
45
|
-
# Persistent nonce store, indexed by server origin.
|
|
36
|
+
# Persistent nonce store, indexed by session and server origin.
|
|
46
37
|
# @return [NonceManager]
|
|
47
38
|
attr_reader :nonce_manager
|
|
48
39
|
|
|
49
|
-
# Creates
|
|
40
|
+
# Creates a shared proof generator and nonce storage scoped to sessions and server origins.
|
|
50
41
|
#
|
|
51
|
-
# @param
|
|
52
|
-
# @param nonce_ttl [Integer, nil] stored nonce lifetime in seconds, or the nonce manager's default
|
|
53
|
-
# @raise [KeyManager::KeyError] if key generation fails
|
|
54
|
-
# @raise [ProofGenerator::ProofError] if the key manager is invalid
|
|
42
|
+
# @param nonce_ttl [Integer, nil] stored nonce lifetime in seconds; `nil` uses {Configuration#dpop_nonce_lifetime}
|
|
55
43
|
# @raise [ArgumentError] if the encryption master key is not valid Base64
|
|
56
44
|
#
|
|
57
|
-
def initialize(
|
|
58
|
-
@key_manager = key_manager || KeyManager.new
|
|
45
|
+
def initialize(nonce_ttl: nil)
|
|
59
46
|
@nonce_manager = NonceManager.new(ttl: nonce_ttl)
|
|
60
|
-
@proof_generator = ProofGenerator.new
|
|
47
|
+
@proof_generator = ProofGenerator.new
|
|
61
48
|
end
|
|
62
49
|
|
|
63
|
-
# Generates a fresh DPoP proof using
|
|
50
|
+
# Generates a fresh DPoP proof using the stored nonce for the session and origin, if available.
|
|
64
51
|
#
|
|
52
|
+
# @param session [State::Session] session whose DPoP key signs the request
|
|
65
53
|
# @param method [String] HTTP method, converted to uppercase
|
|
66
|
-
# @param uri [String] absolute HTTP(S) URL;
|
|
54
|
+
# @param uri [String] absolute HTTP(S) request URL; query and fragment are omitted from the signed `htu`
|
|
67
55
|
# @param access_token [String, nil] token to bind with an `ath` claim
|
|
68
|
-
# @param nonce [String, nil] explicit nonce; `nil` looks up the origin's stored nonce
|
|
69
56
|
# @return [JOSE::SignedBinary] compact signed DPoP JWT
|
|
70
57
|
# @raise [Error] if URI parsing, nonce lookup or signing fails
|
|
71
58
|
#
|
|
72
|
-
def generate_proof(method:, uri:, access_token: nil
|
|
59
|
+
def generate_proof(session:, method:, uri:, access_token: nil)
|
|
73
60
|
parsed_uri = URI(uri)
|
|
74
61
|
|
|
75
62
|
server_url = "#{parsed_uri.scheme}://#{parsed_uri.host}"
|
|
76
63
|
server_url += ":#{parsed_uri.port}" if parsed_uri.port != parsed_uri.default_port
|
|
77
64
|
|
|
78
|
-
|
|
79
|
-
nonce ||= @nonce_manager.get(server_url)
|
|
65
|
+
nonce = @nonce_manager.get(server_url: server_url, session_id: session.session_id)
|
|
80
66
|
|
|
81
67
|
@proof_generator.generate(
|
|
68
|
+
key_set: session.dpop_key,
|
|
82
69
|
method: method,
|
|
83
70
|
uri: uri,
|
|
84
71
|
nonce: nonce,
|
|
@@ -89,25 +76,103 @@ module AuthProto
|
|
|
89
76
|
end
|
|
90
77
|
|
|
91
78
|
# Stores a `DPoP-Nonce` response header for future requests to the same origin.
|
|
92
|
-
#
|
|
79
|
+
# Call after every DPoP HTTP attempt, before accepting a result or deciding to retry.
|
|
80
|
+
# Header names are case-insensitive; missing, malformed and duplicate nonces are rejected.
|
|
93
81
|
#
|
|
94
|
-
# @param
|
|
82
|
+
# @param session_id [String] session that made the request
|
|
83
|
+
# @param response [Hash{Symbol => Object}] HTTP response with `:status`, `:headers`, and `:body`
|
|
95
84
|
# @param server_url [String] request URL whose origin owns the nonce
|
|
96
85
|
# @return [void]
|
|
97
86
|
# @raise [Error] if nonce validation or persistence fails
|
|
98
87
|
#
|
|
99
|
-
def process_response(
|
|
100
|
-
|
|
88
|
+
def process_response(response, server_url:, session_id:)
|
|
89
|
+
raise Error, "Expected a response hash" unless response.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
response_headers = response[:headers]
|
|
92
|
+
raise Error, "Missing or invalid response headers" unless response_headers.is_a?(Hash)
|
|
101
93
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
94
|
+
if response_headers.keys.count { |name| name.casecmp?('DPoP-Nonce') } > 1
|
|
95
|
+
raise Error, "Multiple DPoP-Nonce headers"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
nonce = response_header(response, 'DPoP-Nonce')
|
|
99
|
+
|
|
100
|
+
if nonce.nil? || nonce == ''
|
|
101
|
+
raise Error, "Missing DPoP-Nonce header"
|
|
102
|
+
end
|
|
105
103
|
|
|
106
104
|
# Store new nonce for future requests
|
|
107
|
-
@nonce_manager.update(nonce: nonce, server_url: server_url)
|
|
105
|
+
@nonce_manager.update(nonce: nonce, server_url: server_url, session_id: session_id)
|
|
108
106
|
rescue StandardError => e
|
|
109
107
|
raise Error, "Failed to process response: #{e.message}"
|
|
110
108
|
end
|
|
109
|
+
|
|
110
|
+
# Detects a nonce challenge without treating other errors as nonce challenges.
|
|
111
|
+
#
|
|
112
|
+
# Recognizes HTTP 400 JSON errors and an HTTP 401 DPoP authentication
|
|
113
|
+
# challenge. Header/parameter names and the scheme are case-insensitive;
|
|
114
|
+
# error values are case-sensitive. Multiple DPoP challenges are treated as ambiguous.
|
|
115
|
+
#
|
|
116
|
+
# @param response [Hash{Symbol => Object}] HTTP response with status, headers and body
|
|
117
|
+
# @return [Boolean] whether the response requests a retry with a server nonce
|
|
118
|
+
# @raise [Error] if the response or its headers have an invalid type
|
|
119
|
+
#
|
|
120
|
+
def has_nonce_error?(response)
|
|
121
|
+
raise Error, "Expected a response hash" unless response.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
headers = response[:headers]
|
|
124
|
+
raise Error, "Missing or invalid response headers" unless headers.is_a?(Hash)
|
|
125
|
+
|
|
126
|
+
case response[:status]
|
|
127
|
+
when 400
|
|
128
|
+
return false unless response[:body].is_a?(String)
|
|
129
|
+
|
|
130
|
+
begin
|
|
131
|
+
data = JSON.parse(response[:body])
|
|
132
|
+
data.is_a?(Hash) && data['error'] == DPoP::ERROR_USE_NONCE
|
|
133
|
+
rescue JSON::ParserError
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
when 401
|
|
137
|
+
challenge = response_header(response, 'WWW-Authenticate')
|
|
138
|
+
nonce_challenge?(challenge)
|
|
139
|
+
else
|
|
140
|
+
false
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Runs a DPoP request and retries once if the server requests a new nonce.
|
|
145
|
+
# Processes every response before returning it or invoking the block again.
|
|
146
|
+
# The block must build a fresh proof and send the request on each invocation;
|
|
147
|
+
# the initial proof may already include a cached nonce.
|
|
148
|
+
#
|
|
149
|
+
# @param session [State::Session] session making the request
|
|
150
|
+
# @param url [String] request URL whose origin owns the nonce
|
|
151
|
+
# @yield sends one HTTP request using a fresh DPoP proof
|
|
152
|
+
# @yieldreturn [Hash{Symbol => Object}] HTTP response with status, headers and body
|
|
153
|
+
# @return [Hash{Symbol => Object}] final response, including any remaining error
|
|
154
|
+
# @raise [Error] if a response lacks a valid nonce or nonce persistence fails
|
|
155
|
+
#
|
|
156
|
+
def with_nonce_handling(session, url)
|
|
157
|
+
response = yield
|
|
158
|
+
|
|
159
|
+
process_response(response, server_url: url, session_id: session.session_id)
|
|
160
|
+
|
|
161
|
+
if has_nonce_error?(response)
|
|
162
|
+
response = yield
|
|
163
|
+
|
|
164
|
+
process_response(response, server_url: url, session_id: session.session_id)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
response
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def nonce_challenge?(challenge)
|
|
174
|
+
authentication_challenge(challenge, scheme: 'DPoP')&.fetch('error', nil) == DPoP::ERROR_USE_NONCE
|
|
175
|
+
end
|
|
111
176
|
end
|
|
112
177
|
end
|
|
113
178
|
end
|
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'base64'
|
|
4
3
|
require 'jose'
|
|
5
4
|
require 'json'
|
|
6
|
-
require 'openssl'
|
|
7
5
|
|
|
6
|
+
require_relative '../dpop'
|
|
8
7
|
require_relative '../errors'
|
|
9
8
|
|
|
10
9
|
module AuthProto
|
|
11
10
|
module DPoP
|
|
12
11
|
|
|
13
12
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# Holds an ES256 signing keypair and imports/exports JWKs for DPoP proofs.
|
|
14
|
+
# Keys are held in memory; the session serializer handles encrypted persistence.
|
|
16
15
|
# Uses JOSE for cryptographic operations and key format handling.
|
|
17
16
|
#
|
|
18
17
|
|
|
19
|
-
class
|
|
18
|
+
class KeySet
|
|
20
19
|
|
|
21
20
|
# Error raised when key operations fail
|
|
22
21
|
class KeyError < AuthProto::Error; end
|
|
@@ -25,78 +24,31 @@ module AuthProto
|
|
|
25
24
|
# @return [String]
|
|
26
25
|
CURVE = "P-256"
|
|
27
26
|
|
|
28
|
-
# JWS signing algorithm used for DPoP proofs.
|
|
29
|
-
# @return [String]
|
|
30
|
-
ALGORITHM = "ES256"
|
|
31
|
-
|
|
32
27
|
# Private EC P-256 keypair used for signing.
|
|
33
28
|
# @return [JOSE::JWK]
|
|
34
|
-
attr_reader :
|
|
29
|
+
attr_reader :key
|
|
35
30
|
|
|
36
|
-
# Creates a new
|
|
31
|
+
# Creates a new KeySet instance with an optional existing keypair
|
|
37
32
|
#
|
|
38
|
-
# @param
|
|
39
|
-
# @raise [KeyError] if the provided
|
|
33
|
+
# @param key [JOSE::JWK, nil] Optional existing keypair to use
|
|
34
|
+
# @raise [KeyError] if the provided key is invalid
|
|
40
35
|
#
|
|
41
|
-
def initialize(
|
|
42
|
-
@
|
|
36
|
+
def initialize(key = nil)
|
|
37
|
+
@key = key || generate_keypair
|
|
43
38
|
validate_keypair
|
|
44
39
|
end
|
|
45
40
|
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
# @return [JOSE::JWK] The newly generated keypair
|
|
49
|
-
# @raise [KeyError] if key generation fails
|
|
50
|
-
#
|
|
51
|
-
def generate_keypair
|
|
52
|
-
# Generate base keypair
|
|
53
|
-
base_key = JOSE::JWK.generate_key([:ec, CURVE])
|
|
54
|
-
base_map = base_key.to_map
|
|
55
|
-
|
|
56
|
-
# Create new map with all required properties
|
|
57
|
-
key_map = {
|
|
58
|
-
'kty' => base_map['kty'],
|
|
59
|
-
'crv' => base_map['crv'],
|
|
60
|
-
'x' => base_map['x'],
|
|
61
|
-
'y' => base_map['y'],
|
|
62
|
-
'd' => base_map['d'],
|
|
63
|
-
'use' => 'sig',
|
|
64
|
-
'kid' => generate_kid(base_map)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
# Create new JWK with all properties
|
|
68
|
-
JOSE::JWK.from_map(key_map)
|
|
69
|
-
rescue StandardError => e
|
|
70
|
-
raise KeyError, "Failed to generate keypair: #{e.message}"
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Exports the public key with `use: sig` and a key ID.
|
|
41
|
+
# Exports the public key for embedding in a DPoP proof.
|
|
74
42
|
#
|
|
75
43
|
# @return [Hash{String => Object}] public JWK without private key material
|
|
76
44
|
# @raise [KeyError] if public-key export fails
|
|
77
45
|
#
|
|
78
46
|
def public_jwk
|
|
79
|
-
|
|
80
|
-
# If somehow the properties aren't set, add them
|
|
81
|
-
jwk['use'] ||= 'sig'
|
|
82
|
-
jwk['kid'] ||= generate_kid(jwk)
|
|
83
|
-
jwk
|
|
47
|
+
@key.to_public_map.to_h
|
|
84
48
|
rescue StandardError => e
|
|
85
49
|
raise KeyError, "Failed to export public key: #{e.message}"
|
|
86
50
|
end
|
|
87
51
|
|
|
88
|
-
# Signs bytes with this keypair and returns a compact JWS.
|
|
89
|
-
#
|
|
90
|
-
# @param data [String] payload bytes to sign
|
|
91
|
-
# @return [JOSE::SignedBinary] compact JWS (a string subclass)
|
|
92
|
-
# @raise [KeyError] if signing fails
|
|
93
|
-
#
|
|
94
|
-
def sign(data)
|
|
95
|
-
@keypair.sign(data).compact
|
|
96
|
-
rescue StandardError => e
|
|
97
|
-
raise KeyError, "Failed to sign data: #{e.message}"
|
|
98
|
-
end
|
|
99
|
-
|
|
100
52
|
# Signs a JSON payload with the supplied protected JWS header and ES256.
|
|
101
53
|
# Hash keys are recursively converted to strings. The algorithm is forced to `ES256`.
|
|
102
54
|
#
|
|
@@ -111,7 +63,7 @@ module AuthProto
|
|
|
111
63
|
payload = deep_stringify_keys(payload)
|
|
112
64
|
|
|
113
65
|
# Configure JOSE to use ES256 for signing
|
|
114
|
-
signing_config = { 'alg' =>
|
|
66
|
+
signing_config = { 'alg' => ALGORITHM }
|
|
115
67
|
|
|
116
68
|
# Merge our header with JOSE's required fields
|
|
117
69
|
full_header = header.merge(signing_config)
|
|
@@ -120,7 +72,7 @@ module AuthProto
|
|
|
120
72
|
payload_json = JSON.generate(payload)
|
|
121
73
|
|
|
122
74
|
# Create the JWS with our header and payload
|
|
123
|
-
jws = @
|
|
75
|
+
jws = @key.sign(payload_json, full_header)
|
|
124
76
|
|
|
125
77
|
# Get the compact serialization
|
|
126
78
|
jws.compact
|
|
@@ -128,46 +80,13 @@ module AuthProto
|
|
|
128
80
|
raise KeyError, "Failed to sign segments: #{e.message}"
|
|
129
81
|
end
|
|
130
82
|
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
# @param obj [Object] input structure or scalar value
|
|
134
|
-
# @return [Object] transformed hash/array, or the original scalar
|
|
135
|
-
#
|
|
136
|
-
def deep_stringify_keys(obj)
|
|
137
|
-
case obj
|
|
138
|
-
when Hash
|
|
139
|
-
obj.each_with_object({}) do |(k, v), hash|
|
|
140
|
-
hash[k.to_s] = deep_stringify_keys(v)
|
|
141
|
-
end
|
|
142
|
-
when Array
|
|
143
|
-
obj.map { |v| deep_stringify_keys(v) }
|
|
144
|
-
else
|
|
145
|
-
obj
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
# Verifies a compact JWS using this keypair.
|
|
150
|
-
#
|
|
151
|
-
# @param signed_jws [String, JOSE::SignedBinary] complete signed JWS
|
|
152
|
-
# @return [Boolean] whether the signature is valid
|
|
153
|
-
# @raise [KeyError] if decoding or verification raises an error
|
|
154
|
-
#
|
|
155
|
-
def verify(signed_jws)
|
|
156
|
-
verified, payload, = @keypair.verify(signed_jws)
|
|
157
|
-
verified
|
|
158
|
-
rescue StandardError => e
|
|
159
|
-
raise KeyError, "Failed to verify signature: #{e.message}"
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# Exports this keypair as a JOSE map.
|
|
83
|
+
# Exports this keypair including its private signing material.
|
|
163
84
|
#
|
|
164
|
-
# @
|
|
165
|
-
# @return [JOSE::Map] JWK fields; call `to_h` for a Ruby hash
|
|
85
|
+
# @return [Hash{String => Object}] private JWK including the secret `d` component
|
|
166
86
|
# @raise [KeyError] if export fails
|
|
167
87
|
#
|
|
168
|
-
def
|
|
169
|
-
key
|
|
170
|
-
key.to_map
|
|
88
|
+
def private_jwk
|
|
89
|
+
@key.to_map.to_h
|
|
171
90
|
rescue StandardError => e
|
|
172
91
|
raise KeyError, "Failed to export key: #{e.message}"
|
|
173
92
|
end
|
|
@@ -175,40 +94,55 @@ module AuthProto
|
|
|
175
94
|
# Imports an EC P-256 JWK and validates that it can sign and verify.
|
|
176
95
|
#
|
|
177
96
|
# @param jwk [Hash{String => Object}, JOSE::Map] JWK including private key material
|
|
178
|
-
# @return [
|
|
97
|
+
# @return [KeySet] imported and validated keypair wrapper
|
|
179
98
|
# @raise [KeyError] if import or key validation fails
|
|
180
99
|
#
|
|
181
100
|
def self.from_jwk(jwk)
|
|
182
|
-
|
|
183
|
-
new(
|
|
101
|
+
key = JOSE::JWK.from_map(jwk)
|
|
102
|
+
new(key)
|
|
184
103
|
rescue StandardError => e
|
|
185
104
|
raise KeyError, "Failed to import key: #{e.message}"
|
|
186
105
|
end
|
|
187
106
|
|
|
107
|
+
|
|
188
108
|
private
|
|
189
109
|
|
|
190
|
-
def
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
110
|
+
def generate_keypair
|
|
111
|
+
JOSE::JWK.generate_key([:ec, CURVE])
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
raise KeyError, "Failed to generate keypair: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def sign(data)
|
|
117
|
+
@key.sign(data, { 'alg' => ALGORITHM }).compact
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
raise KeyError, "Failed to sign data: #{e.message}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def deep_stringify_keys(obj)
|
|
123
|
+
case obj
|
|
124
|
+
when Hash
|
|
125
|
+
obj.each_with_object({}) do |(k, v), hash|
|
|
126
|
+
hash[k.to_s] = deep_stringify_keys(v)
|
|
127
|
+
end
|
|
128
|
+
when Array
|
|
129
|
+
obj.map { |v| deep_stringify_keys(v) }
|
|
130
|
+
else
|
|
131
|
+
obj
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def verify(signed_jws)
|
|
136
|
+
verified, payload, = @key.verify(signed_jws)
|
|
137
|
+
verified
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
raise KeyError, "Failed to verify signature: #{e.message}"
|
|
202
140
|
end
|
|
203
141
|
|
|
204
|
-
# Validates that the keypair meets DPoP requirements
|
|
205
|
-
# @raise [KeyError] if validation fails
|
|
206
142
|
def validate_keypair
|
|
207
|
-
|
|
208
|
-
raise KeyError, "Invalid key type: #{@keypair.kty}, must be :ec" unless @keypair.kty.is_a?(JOSE::JWK::KTY_EC)
|
|
143
|
+
raise KeyError, "Invalid key type: #{@key.kty}, must be :ec" unless @key.kty.is_a?(JOSE::JWK::KTY_EC)
|
|
209
144
|
|
|
210
|
-
|
|
211
|
-
curve = @keypair.to_map['crv']
|
|
145
|
+
curve = @key.to_map['crv']
|
|
212
146
|
raise KeyError, "Invalid curve: #{curve}, must be #{CURVE}" unless curve == CURVE
|
|
213
147
|
|
|
214
148
|
# Verify we can perform basic operations
|
|
@@ -14,61 +14,64 @@ module AuthProto
|
|
|
14
14
|
|
|
15
15
|
#
|
|
16
16
|
# Manages DPoP nonces provided by servers during the OAuth flow.
|
|
17
|
-
# Tracks separate nonces for each server using persistent storage.
|
|
17
|
+
# Tracks separate nonces for each session and server using persistent storage.
|
|
18
18
|
# Thread-safe to handle concurrent requests.
|
|
19
19
|
#
|
|
20
20
|
|
|
21
21
|
class NonceManager
|
|
22
22
|
|
|
23
|
+
# Regexp for checking the nonce value for invalid characters
|
|
24
|
+
NONCE_REGEXP = /\A[\x21\x23-\x5B\x5D-\x7E]+\z/
|
|
25
|
+
|
|
23
26
|
# Error for nonce-related issues
|
|
24
27
|
class NonceError < AuthProto::Error; end
|
|
25
28
|
|
|
26
|
-
# Default stored nonce lifetime in seconds.
|
|
27
|
-
# @return [Integer]
|
|
28
|
-
DEFAULT_TTL = 300 # 5 minutes
|
|
29
|
-
|
|
30
29
|
# Creates a nonce manager using the global storage backend.
|
|
31
30
|
#
|
|
32
|
-
# @param ttl [Integer, nil] nonce lifetime in seconds; `nil` uses {
|
|
31
|
+
# @param ttl [Integer, nil] nonce lifetime in seconds; `nil` uses {Configuration#dpop_nonce_lifetime}
|
|
33
32
|
# @raise [ArgumentError] if the master encryption key is not valid Base64
|
|
34
33
|
#
|
|
35
34
|
def initialize(ttl: nil)
|
|
36
|
-
@ttl = ttl ||
|
|
35
|
+
@ttl = ttl || AuthProto.configuration.dpop_nonce_lifetime
|
|
37
36
|
@serializer = Serialization::StoredNonce.new
|
|
38
37
|
end
|
|
39
38
|
|
|
40
|
-
# Stores a nonce for the URL's origin, replacing any previous value.
|
|
39
|
+
# Stores a nonce for the session and URL's origin, replacing any previous value.
|
|
41
40
|
#
|
|
42
|
-
# @param nonce [String]
|
|
41
|
+
# @param nonce [String] server-provided nonce using RFC 9449's `1*NQCHAR` syntax
|
|
42
|
+
# @param session_id [String] nonempty OAuth session identifier
|
|
43
43
|
# @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
|
|
44
44
|
# @return [void]
|
|
45
45
|
# @raise [NonceError] if inputs are invalid or storage reports failure
|
|
46
46
|
# @raise [Storage::StorageError] if backend validation or storage fails
|
|
47
47
|
#
|
|
48
|
-
def update(nonce:, server_url:)
|
|
48
|
+
def update(nonce:, server_url:, session_id:)
|
|
49
|
+
validate_session_id(session_id)
|
|
49
50
|
validate_inputs(nonce, server_url)
|
|
50
51
|
origin = normalize_server_url(server_url)
|
|
51
52
|
|
|
52
53
|
stored_nonce = StoredNonce.new(nonce, origin)
|
|
53
54
|
serialized = @serializer.serialize(stored_nonce)
|
|
54
55
|
|
|
55
|
-
key = Storage::KeyBuilder.nonce_key(origin)
|
|
56
|
+
key = Storage::KeyBuilder.nonce_key(session_id, origin)
|
|
56
57
|
return if AuthProto.storage.set(key, serialized, ttl: @ttl)
|
|
57
58
|
|
|
58
59
|
raise NonceError, "Failed to store nonce"
|
|
59
60
|
end
|
|
60
61
|
|
|
61
|
-
# Retrieves the current nonce for a URL's origin.
|
|
62
|
+
# Retrieves the current nonce for a session and URL's origin.
|
|
62
63
|
#
|
|
64
|
+
# @param session_id [String] nonempty OAuth session identifier
|
|
63
65
|
# @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
|
|
64
66
|
# @return [String, nil] stored nonce, or `nil` when missing/expired according to storage TTL
|
|
65
|
-
# @raise [NonceError] if the URL is invalid or serialized data cannot be read
|
|
67
|
+
# @raise [NonceError] if the session ID or URL is invalid, or serialized data cannot be read
|
|
66
68
|
# @raise [Storage::StorageError] if storage retrieval fails
|
|
67
69
|
#
|
|
68
|
-
def get(server_url)
|
|
70
|
+
def get(server_url:, session_id:)
|
|
71
|
+
validate_session_id(session_id)
|
|
69
72
|
validate_server_url(server_url)
|
|
70
73
|
origin = normalize_server_url(server_url)
|
|
71
|
-
key = Storage::KeyBuilder.nonce_key(origin)
|
|
74
|
+
key = Storage::KeyBuilder.nonce_key(session_id, origin)
|
|
72
75
|
|
|
73
76
|
stored = AuthProto.storage.get(key)
|
|
74
77
|
return nil unless stored
|
|
@@ -81,22 +84,30 @@ module AuthProto
|
|
|
81
84
|
end
|
|
82
85
|
end
|
|
83
86
|
|
|
84
|
-
# Removes the stored nonce for a URL's origin.
|
|
87
|
+
# Removes the stored nonce for a session and URL's origin.
|
|
85
88
|
#
|
|
89
|
+
# @param session_id [String] nonempty OAuth session identifier
|
|
86
90
|
# @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
|
|
87
91
|
# @return [Boolean] backend deletion status
|
|
88
|
-
# @raise [NonceError] if the URL is invalid
|
|
92
|
+
# @raise [NonceError] if the session ID or URL is invalid
|
|
89
93
|
# @raise [Storage::StorageError] if deletion fails
|
|
90
94
|
#
|
|
91
|
-
def clear(server_url)
|
|
95
|
+
def clear(server_url:, session_id:)
|
|
96
|
+
validate_session_id(session_id)
|
|
92
97
|
validate_server_url(server_url)
|
|
93
98
|
origin = normalize_server_url(server_url)
|
|
94
|
-
key = Storage::KeyBuilder.nonce_key(origin)
|
|
99
|
+
key = Storage::KeyBuilder.nonce_key(session_id, origin)
|
|
95
100
|
AuthProto.storage.delete(key)
|
|
96
101
|
end
|
|
97
102
|
|
|
98
103
|
private
|
|
99
104
|
|
|
105
|
+
def validate_session_id(session_id)
|
|
106
|
+
return if session_id.is_a?(String) && !session_id.empty?
|
|
107
|
+
|
|
108
|
+
raise NonceError, "session_id must be a nonempty string"
|
|
109
|
+
end
|
|
110
|
+
|
|
100
111
|
def normalize_server_url(url)
|
|
101
112
|
uri = URI(url)
|
|
102
113
|
port = uri.port
|
|
@@ -109,7 +120,9 @@ module AuthProto
|
|
|
109
120
|
end
|
|
110
121
|
|
|
111
122
|
def validate_inputs(nonce, server_url)
|
|
112
|
-
raise NonceError, "nonce
|
|
123
|
+
raise NonceError, "nonce must not be empty" if nonce.to_s == ''
|
|
124
|
+
raise NonceError, "Invalid DPoP nonce" unless nonce.is_a?(String)
|
|
125
|
+
raise NonceError, "Invalid characters in the DPoP nonce" unless nonce.ascii_only? && nonce =~ NONCE_REGEXP
|
|
113
126
|
|
|
114
127
|
validate_server_url(server_url)
|
|
115
128
|
end
|