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
|
@@ -15,72 +15,116 @@ module AuthProto
|
|
|
15
15
|
class SessionManager
|
|
16
16
|
|
|
17
17
|
# Creates a session manager and its encrypted session serializer.
|
|
18
|
+
#
|
|
19
|
+
# @param supported_signing_keys [Hash{String => String}] key IDs mapped to RFC 7638 SHA-256 thumbprints
|
|
20
|
+
# @raise [ArgumentError] if key IDs or thumbprints are not nonempty strings
|
|
18
21
|
# @raise [ArgumentError] if `ATPROTO_MASTER_KEY` is not valid Base64
|
|
19
22
|
#
|
|
20
|
-
def initialize
|
|
23
|
+
def initialize(supported_signing_keys: {})
|
|
24
|
+
keys = supported_signing_keys
|
|
25
|
+
|
|
26
|
+
unless keys.is_a?(Hash) && keys.all? { |id, thumb| [id, thumb].all? { |x| x.is_a?(String) && !x.empty? }}
|
|
27
|
+
raise ArgumentError, "supported_signing_keys must map nonempty key IDs to nonempty thumbprints"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@supported_signing_keys = keys.to_h { |id, thumb| [id.dup.freeze, thumb.dup.freeze] }.freeze
|
|
21
31
|
@serializer = Serialization::Session.new
|
|
22
32
|
end
|
|
23
33
|
|
|
24
34
|
# Creates and stores a session and its state-token lookup under a storage lock.
|
|
35
|
+
# The signing key thumbprint is taken from the configured key mapping.
|
|
25
36
|
#
|
|
26
37
|
# @param client_id [String] OAuth client ID
|
|
27
38
|
# @param scope [String] requested space-separated scopes
|
|
28
39
|
# @param auth_server [Metadata::AuthorizationServer, nil] pre-resolved authorization server
|
|
29
40
|
# @param did [String, nil] pre-resolved account DID
|
|
41
|
+
# @param signing_key_id [String, nil] client-authentication key ID selected before PAR
|
|
30
42
|
#
|
|
31
43
|
# @return [Session] newly created session
|
|
44
|
+
# @raise [ArgumentError] if the signing key ID is not configured
|
|
45
|
+
# @raise [DPoP::KeySet::KeyError] if session key generation fails
|
|
32
46
|
# @raise [PKCE::Error] if session PKCE generation fails
|
|
33
47
|
# @raise [Storage::StorageError] if writing or locking fails
|
|
34
48
|
# @raise [Serialization::ValidationError] if the session cannot be serialized
|
|
35
49
|
# @raise [Encryption::EncryptionError] if sensitive fields cannot be encrypted
|
|
36
50
|
#
|
|
37
|
-
def create_session(client_id:, scope:, auth_server: nil, did: nil)
|
|
51
|
+
def create_session(client_id:, scope:, auth_server: nil, did: nil, signing_key_id: nil)
|
|
52
|
+
if signing_key_id && !@supported_signing_keys.key?(signing_key_id)
|
|
53
|
+
raise ArgumentError, "Unsupported signing_key_id: #{signing_key_id}"
|
|
54
|
+
end
|
|
55
|
+
|
|
38
56
|
session = Session.new(
|
|
39
57
|
client_id: client_id,
|
|
40
58
|
scope: scope,
|
|
41
59
|
auth_server: auth_server,
|
|
42
|
-
did: did
|
|
60
|
+
did: did,
|
|
61
|
+
signing_key_id: signing_key_id,
|
|
62
|
+
signing_key_thumbprint: @supported_signing_keys[signing_key_id],
|
|
63
|
+
authorization_expires_at: Time.now + AuthProto.configuration.pending_session_ttl
|
|
43
64
|
)
|
|
44
65
|
|
|
45
|
-
# Store
|
|
66
|
+
# Store the session and state mapping with the same pending authorization lifetime.
|
|
46
67
|
session_key = Storage::KeyBuilder.session_key(session.session_id)
|
|
47
68
|
state_key = Storage::KeyBuilder.state_key(session.state_token)
|
|
48
69
|
|
|
49
70
|
AuthProto.storage.with_lock(session_key, ttl: 30) do
|
|
71
|
+
ttl = session_ttl(session)
|
|
50
72
|
serialized = @serializer.serialize(session)
|
|
51
|
-
AuthProto.storage.set(session_key, serialized)
|
|
52
|
-
AuthProto.storage.set(state_key, session.session_id)
|
|
73
|
+
AuthProto.storage.set(session_key, serialized, ttl: ttl)
|
|
74
|
+
AuthProto.storage.set(state_key, session.session_id, ttl: ttl)
|
|
53
75
|
end
|
|
54
76
|
|
|
55
77
|
session
|
|
56
78
|
end
|
|
57
79
|
|
|
58
|
-
# Persists a session
|
|
80
|
+
# Persists a session under a storage lock.
|
|
81
|
+
#
|
|
82
|
+
# Pending sessions retain their original deadline. Non-renewable authenticated sessions
|
|
83
|
+
# expire with their access token; renewable sessions have no storage expiry.
|
|
59
84
|
#
|
|
60
85
|
# @param session [Session] session to persist
|
|
61
86
|
# @return [Session] the supplied session
|
|
62
87
|
# @raise [Storage::StorageError] if writing or locking fails
|
|
63
88
|
# @raise [Serialization::ValidationError] if the session cannot be serialized
|
|
64
89
|
# @raise [Encryption::EncryptionError] if sensitive fields cannot be encrypted
|
|
90
|
+
# @raise [SessionError] if the pending deadline is missing or the session has expired
|
|
65
91
|
#
|
|
66
92
|
def update_session(session)
|
|
67
93
|
session_key = Storage::KeyBuilder.session_key(session.session_id)
|
|
68
94
|
|
|
69
95
|
AuthProto.storage.with_lock(session_key, ttl: 30) do
|
|
96
|
+
ttl = session_ttl(session)
|
|
70
97
|
serialized = @serializer.serialize(session)
|
|
71
|
-
AuthProto.storage.set(session_key, serialized)
|
|
72
|
-
|
|
73
|
-
state_key = Storage::KeyBuilder.state_key(session.state_token)
|
|
74
|
-
AuthProto.storage.set(state_key, session.session_id)
|
|
98
|
+
AuthProto.storage.set(session_key, serialized, ttl: ttl)
|
|
75
99
|
end
|
|
76
100
|
|
|
77
101
|
session
|
|
78
102
|
end
|
|
79
103
|
|
|
104
|
+
# Persists successful authorization without its state token and removes the callback lookup.
|
|
105
|
+
#
|
|
106
|
+
# @param session [Session] session with successfully validated and assigned tokens
|
|
107
|
+
# @return [Session] the supplied session, with its state token cleared
|
|
108
|
+
# @raise [Storage::StorageError] if writing, deletion or locking fails
|
|
109
|
+
# @raise [Serialization::ValidationError] if the session cannot be serialized
|
|
110
|
+
# @raise [Encryption::EncryptionError] if sensitive fields cannot be encrypted
|
|
111
|
+
#
|
|
112
|
+
def complete_authorization(session)
|
|
113
|
+
state_token = session.state_token
|
|
114
|
+
session.clear_state_token
|
|
115
|
+
update_session(session)
|
|
116
|
+
|
|
117
|
+
AuthProto.storage.delete(Storage::KeyBuilder.state_key(state_token)) if state_token
|
|
118
|
+
|
|
119
|
+
session
|
|
120
|
+
end
|
|
121
|
+
|
|
80
122
|
# Loads and decrypts a session by ID.
|
|
81
123
|
#
|
|
82
124
|
# Returns `nil` for missing, unreadable or malformed sessions and for expired
|
|
83
|
-
# sessions that have no refresh token.
|
|
125
|
+
# sessions that have no refresh token. Sessions with an unsupported signing key ID
|
|
126
|
+
# or a missing/mismatching key thumbprint are unavailable but remain in storage.
|
|
127
|
+
# Public and legacy sessions without an ID are accepted. Read/deserialization errors are logged.
|
|
84
128
|
#
|
|
85
129
|
# @param session_id [String] session identifier
|
|
86
130
|
# @return [Session, nil] restored session, or `nil` if unavailable
|
|
@@ -98,7 +142,18 @@ module AuthProto
|
|
|
98
142
|
|
|
99
143
|
begin
|
|
100
144
|
session = @serializer.deserialize(serialized)
|
|
101
|
-
|
|
145
|
+
|
|
146
|
+
if session.signing_key_id
|
|
147
|
+
thumbprint = @supported_signing_keys[session.signing_key_id]
|
|
148
|
+
return nil unless thumbprint && thumbprint == session.signing_key_thumbprint
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
if session.pending?
|
|
152
|
+
deadline = session.authorization_expires_at
|
|
153
|
+
return nil if !deadline || deadline <= Time.now
|
|
154
|
+
else
|
|
155
|
+
return nil if !session.tokens_valid? && !session.renewable?
|
|
156
|
+
end
|
|
102
157
|
|
|
103
158
|
session
|
|
104
159
|
rescue StandardError => e
|
|
@@ -111,13 +166,13 @@ module AuthProto
|
|
|
111
166
|
#
|
|
112
167
|
# Storage read errors are logged and treated as a missing session.
|
|
113
168
|
#
|
|
114
|
-
# @param
|
|
169
|
+
# @param state_token [String, nil] callback state token
|
|
115
170
|
# @return [Session, nil] restored session, or `nil` if unavailable
|
|
116
171
|
#
|
|
117
|
-
def
|
|
118
|
-
return nil unless
|
|
172
|
+
def get_session_by_state_token(state_token)
|
|
173
|
+
return nil unless state_token
|
|
119
174
|
|
|
120
|
-
state_key = Storage::KeyBuilder.state_key(
|
|
175
|
+
state_key = Storage::KeyBuilder.state_key(state_token)
|
|
121
176
|
|
|
122
177
|
begin
|
|
123
178
|
session_id = AuthProto.storage.get(state_key)
|
|
@@ -127,7 +182,9 @@ module AuthProto
|
|
|
127
182
|
return nil
|
|
128
183
|
end
|
|
129
184
|
|
|
130
|
-
get_session(session_id)
|
|
185
|
+
session = get_session(session_id)
|
|
186
|
+
|
|
187
|
+
session if !session.nil? && session.state_token == state_token
|
|
131
188
|
end
|
|
132
189
|
|
|
133
190
|
# Deletes a session and its state mapping if it can be retrieved.
|
|
@@ -141,11 +198,9 @@ module AuthProto
|
|
|
141
198
|
return unless session
|
|
142
199
|
|
|
143
200
|
session_key = Storage::KeyBuilder.session_key(session_id)
|
|
144
|
-
state_key = Storage::KeyBuilder.state_key(session.state_token)
|
|
145
|
-
|
|
146
201
|
AuthProto.storage.with_lock(session_key, ttl: 30) do
|
|
147
202
|
AuthProto.storage.delete(session_key)
|
|
148
|
-
AuthProto.storage.delete(state_key)
|
|
203
|
+
AuthProto.storage.delete(Storage::KeyBuilder.state_key(session.state_token)) if session.state_token
|
|
149
204
|
end
|
|
150
205
|
end
|
|
151
206
|
|
|
@@ -157,6 +212,25 @@ module AuthProto
|
|
|
157
212
|
def cleanup_expired
|
|
158
213
|
# No-op - expiry handled by storage TTL and retrieval validation
|
|
159
214
|
end
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
private
|
|
218
|
+
|
|
219
|
+
def session_ttl(session)
|
|
220
|
+
return nil if session.renewable?
|
|
221
|
+
|
|
222
|
+
if session.pending?
|
|
223
|
+
expires_at = session.authorization_expires_at
|
|
224
|
+
raise SessionError, "Pending session has no authorization deadline" unless expires_at
|
|
225
|
+
else
|
|
226
|
+
expires_at = session.tokens.expires_at
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
remaining = expires_at - Time.now
|
|
230
|
+
raise SessionError, "Session has expired" unless remaining.positive?
|
|
231
|
+
|
|
232
|
+
remaining.ceil
|
|
233
|
+
end
|
|
160
234
|
end
|
|
161
235
|
end
|
|
162
236
|
end
|
|
@@ -17,10 +17,6 @@ module AuthProto
|
|
|
17
17
|
# @return [String, nil]
|
|
18
18
|
attr_reader :refresh_token
|
|
19
19
|
|
|
20
|
-
# Token scheme; always `DPoP`.
|
|
21
|
-
# @return [String]
|
|
22
|
-
attr_reader :token_type
|
|
23
|
-
|
|
24
20
|
# Space-separated granted scopes.
|
|
25
21
|
# @return [String]
|
|
26
22
|
attr_reader :scope
|
|
@@ -36,35 +32,37 @@ module AuthProto
|
|
|
36
32
|
# Creates a token set and calculates its expiry from the current time.
|
|
37
33
|
#
|
|
38
34
|
# @param access_token [String] nonempty DPoP-bound access token
|
|
39
|
-
# @param token_type [String] must be `DPoP`
|
|
40
|
-
# @param expires_in [Integer] lifetime in seconds; negative
|
|
35
|
+
# @param token_type [String] must be `DPoP` (case-insensitive)
|
|
36
|
+
# @param expires_in [Integer] lifetime of the access token in seconds; negative value means it's already expired
|
|
41
37
|
# @param scope [String] nonempty space-separated granted scopes
|
|
42
38
|
# @param sub [String] nonempty account DID
|
|
43
39
|
# @param refresh_token [String, nil] refresh token, if issued
|
|
44
40
|
# @raise [ArgumentError] if required strings are missing, the token type is wrong, or expiry is not an integer
|
|
45
41
|
#
|
|
46
|
-
def initialize(
|
|
47
|
-
access_token:,
|
|
48
|
-
token_type:,
|
|
49
|
-
expires_in:,
|
|
50
|
-
scope:,
|
|
51
|
-
sub:,
|
|
52
|
-
refresh_token: nil
|
|
53
|
-
)
|
|
42
|
+
def initialize(access_token:, token_type:, expires_in:, scope:, sub:, refresh_token: nil)
|
|
54
43
|
validate_token_type(token_type)
|
|
44
|
+
|
|
55
45
|
validate_required('access_token', access_token)
|
|
56
46
|
validate_required('scope', scope)
|
|
57
47
|
validate_required('sub', sub)
|
|
48
|
+
|
|
58
49
|
validate_expires_in(expires_in)
|
|
59
50
|
|
|
60
51
|
@access_token = access_token
|
|
61
52
|
@refresh_token = refresh_token
|
|
62
|
-
@token_type = token_type
|
|
63
53
|
@scope = scope
|
|
64
54
|
@sub = sub
|
|
65
55
|
@expires_at = Time.now + expires_in
|
|
66
56
|
end
|
|
67
57
|
|
|
58
|
+
# Returns the token scheme, which is always `DPoP`.
|
|
59
|
+
#
|
|
60
|
+
# @return [String] `DPoP`
|
|
61
|
+
#
|
|
62
|
+
def token_type
|
|
63
|
+
'DPoP'
|
|
64
|
+
end
|
|
65
|
+
|
|
68
66
|
# Whether this token set includes a refresh token
|
|
69
67
|
# @return [Boolean]
|
|
70
68
|
def renewable?
|
|
@@ -84,7 +82,9 @@ module AuthProto
|
|
|
84
82
|
private
|
|
85
83
|
|
|
86
84
|
def validate_token_type(type)
|
|
87
|
-
|
|
85
|
+
unless type.is_a?(String) && type.casecmp?('DPoP')
|
|
86
|
+
raise ArgumentError, "token_type must be DPoP"
|
|
87
|
+
end
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
def validate_required(name, value)
|
data/lib/authproto/state.rb
CHANGED
|
@@ -39,20 +39,12 @@ module AuthProto
|
|
|
39
39
|
|
|
40
40
|
# Builds a namespaced storage key for a DPoP nonce.
|
|
41
41
|
#
|
|
42
|
+
# @param session_id [String] OAuth session identifier
|
|
42
43
|
# @param server_url [String] server origin URL
|
|
43
44
|
# @return [String] colon-separated key beginning with `atproto:`
|
|
44
45
|
#
|
|
45
|
-
def nonce_key(server_url)
|
|
46
|
-
build_key("nonce", server_url)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Builds a namespaced storage key for a DPoP key.
|
|
50
|
-
#
|
|
51
|
-
# @param client_id [String] OAuth client ID
|
|
52
|
-
# @return [String] colon-separated key beginning with `atproto:`
|
|
53
|
-
#
|
|
54
|
-
def dpop_key(client_id)
|
|
55
|
-
build_key("dpop", client_id)
|
|
46
|
+
def nonce_key(session_id, server_url)
|
|
47
|
+
build_key("nonce", session_id, server_url)
|
|
56
48
|
end
|
|
57
49
|
|
|
58
50
|
# Builds a namespaced storage key for a lock.
|
|
@@ -32,7 +32,7 @@ module AuthProto
|
|
|
32
32
|
#
|
|
33
33
|
# @param key [String] nonempty key beginning with `atproto:`
|
|
34
34
|
# @param value [Object] value retained by reference
|
|
35
|
-
# @param ttl [Integer, nil] positive expiry in seconds, or `nil`
|
|
35
|
+
# @param ttl [Integer, nil] positive expiry in seconds, or `nil` to clear any expiry
|
|
36
36
|
# @return [true] if the write succeeds
|
|
37
37
|
# @raise [StorageError] if key or TTL validation fails
|
|
38
38
|
#
|
|
@@ -42,7 +42,13 @@ module AuthProto
|
|
|
42
42
|
|
|
43
43
|
@monitor.synchronize do
|
|
44
44
|
@data[key] = value
|
|
45
|
-
|
|
45
|
+
|
|
46
|
+
if ttl
|
|
47
|
+
set_expiration(key, ttl)
|
|
48
|
+
else
|
|
49
|
+
@expirations.delete(key)
|
|
50
|
+
end
|
|
51
|
+
|
|
46
52
|
true
|
|
47
53
|
end
|
|
48
54
|
end
|
|
@@ -7,6 +7,7 @@ require 'monitor'
|
|
|
7
7
|
require_relative '../configuration'
|
|
8
8
|
require_relative '../errors'
|
|
9
9
|
require_relative '../http_client'
|
|
10
|
+
require_relative '../http_helpers'
|
|
10
11
|
require_relative '../par'
|
|
11
12
|
require_relative '../par/client_assertion'
|
|
12
13
|
require_relative '../state/token_set'
|
|
@@ -116,6 +117,7 @@ module AuthProto
|
|
|
116
117
|
#
|
|
117
118
|
|
|
118
119
|
class Refresh
|
|
120
|
+
include HTTPHelpers
|
|
119
121
|
include MonitorMixin
|
|
120
122
|
|
|
121
123
|
# Maximum attempts for one refresh operation.
|
|
@@ -152,9 +154,10 @@ module AuthProto
|
|
|
152
154
|
# @param dpop_client [DPoP::Client] proof signer and nonce tracker
|
|
153
155
|
# @param auth_server [Metadata::AuthorizationServer] server with a token endpoint
|
|
154
156
|
# @param client_metadata [Metadata::ClientMetadata] client ID and authentication key configuration
|
|
155
|
-
# @param
|
|
157
|
+
# @param client_assertion [PAR::ClientAssertion, nil] reusable validated signer; required for confidential clients
|
|
158
|
+
# @raise [ArgumentError] if confidential-client metadata is supplied without an assertion signer
|
|
156
159
|
#
|
|
157
|
-
def initialize(session:, dpop_client:, auth_server:, client_metadata:,
|
|
160
|
+
def initialize(session:, dpop_client:, auth_server:, client_metadata:, client_assertion: nil)
|
|
158
161
|
super() # Initialize MonitorMixin
|
|
159
162
|
|
|
160
163
|
@session = session
|
|
@@ -162,17 +165,23 @@ module AuthProto
|
|
|
162
165
|
@auth_server = auth_server
|
|
163
166
|
@attempt_count = 0
|
|
164
167
|
@client_metadata = client_metadata
|
|
165
|
-
@
|
|
168
|
+
@client_assertion = client_assertion
|
|
169
|
+
|
|
170
|
+
if client_metadata.confidential? && !client_assertion.is_a?(PAR::ClientAssertion)
|
|
171
|
+
raise ArgumentError, "Confidential clients require a client_assertion signer"
|
|
172
|
+
end
|
|
166
173
|
end
|
|
167
174
|
|
|
168
175
|
# Exchanges a refresh token for a replacement token set.
|
|
169
176
|
#
|
|
170
|
-
#
|
|
171
|
-
# jitter
|
|
177
|
+
# Makes up to {MAX_RETRIES} attempts for eligible failures, with exponential backoff
|
|
178
|
+
# and jitter. Each attempt can retry one DPoP nonce challenge. Does not assign
|
|
172
179
|
# the returned tokens to the session or persist them; the caller must do so.
|
|
180
|
+
# HTTP 400/401 OAuth errors preserve the server's error code and description in the exception message.
|
|
173
181
|
#
|
|
174
182
|
# @return [State::TokenSet] validated replacement tokens
|
|
175
183
|
# @raise [RefreshError] if the session is not renewable, refresh fails, or retries are exhausted
|
|
184
|
+
# @raise [DPoP::Client::Error] if any response lacks a valid nonce or nonce persistence fails
|
|
176
185
|
#
|
|
177
186
|
def perform_refresh
|
|
178
187
|
synchronize do
|
|
@@ -219,24 +228,15 @@ module AuthProto
|
|
|
219
228
|
end
|
|
220
229
|
|
|
221
230
|
def request_token_refresh
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
# Handle DPoP nonce requirement
|
|
226
|
-
if requires_dpop_nonce?(response)
|
|
227
|
-
# Extract and store nonce from error response
|
|
228
|
-
extract_dpop_nonce(response)
|
|
229
|
-
dpop_client.process_response(response[:headers], auth_server.issuer)
|
|
230
|
-
|
|
231
|
-
# Retry request with nonce
|
|
232
|
-
response = make_token_request(session)
|
|
231
|
+
response = dpop_client.with_nonce_handling(session, auth_server.token_endpoint) do
|
|
232
|
+
make_token_request(session)
|
|
233
233
|
end
|
|
234
234
|
|
|
235
235
|
handle_refresh_response(response)
|
|
236
236
|
end
|
|
237
237
|
|
|
238
238
|
def make_token_request(session)
|
|
239
|
-
dpop_proof = dpop_client.generate_proof(method: 'POST', uri: auth_server.token_endpoint)
|
|
239
|
+
dpop_proof = dpop_client.generate_proof(session: session, method: 'POST', uri: auth_server.token_endpoint)
|
|
240
240
|
|
|
241
241
|
body = {
|
|
242
242
|
grant_type: 'refresh_token',
|
|
@@ -257,35 +257,10 @@ module AuthProto
|
|
|
257
257
|
)
|
|
258
258
|
end
|
|
259
259
|
|
|
260
|
-
def requires_dpop_nonce?(response)
|
|
261
|
-
return false unless response[:status] == 400
|
|
262
|
-
return false if response[:body].nil?
|
|
263
|
-
|
|
264
|
-
error_data = JSON.parse(response[:body])
|
|
265
|
-
error_data.is_a?(Hash) && error_data['error'] == DPoP::ERROR_USE_NONCE
|
|
266
|
-
rescue JSON::ParserError
|
|
267
|
-
false
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
def extract_dpop_nonce(response)
|
|
271
|
-
nonce = (response[:headers] || {}).detect { |k, v| k.downcase == 'dpop-nonce' }&.last
|
|
272
|
-
|
|
273
|
-
unless nonce.is_a?(String) && !nonce.empty?
|
|
274
|
-
raise RefreshError.new("No DPoP nonce provided in error response", retry_possible: false)
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
nonce
|
|
278
|
-
end
|
|
279
|
-
|
|
280
260
|
def add_client_authentication(body)
|
|
281
|
-
client_assertion = PAR::ClientAssertion.new(
|
|
282
|
-
client_id: client_metadata.client_id,
|
|
283
|
-
signing_key: @signing_key
|
|
284
|
-
)
|
|
285
|
-
|
|
286
261
|
body.merge!(
|
|
287
262
|
client_assertion_type: PAR::CLIENT_ASSERTION_TYPE,
|
|
288
|
-
client_assertion: client_assertion.generate_jwt(audience: auth_server.issuer)
|
|
263
|
+
client_assertion: @client_assertion.generate_jwt(audience: auth_server.issuer)
|
|
289
264
|
)
|
|
290
265
|
end
|
|
291
266
|
|
|
@@ -293,10 +268,8 @@ module AuthProto
|
|
|
293
268
|
case response[:status]
|
|
294
269
|
when 200
|
|
295
270
|
process_successful_response(response)
|
|
296
|
-
when 400
|
|
297
|
-
|
|
298
|
-
when 401
|
|
299
|
-
raise RefreshError.new("Refresh token is invalid", retry_possible: false)
|
|
271
|
+
when 400, 401
|
|
272
|
+
handle_error_response(response)
|
|
300
273
|
when 429
|
|
301
274
|
handle_rate_limit_response(response)
|
|
302
275
|
else
|
|
@@ -322,7 +295,7 @@ module AuthProto
|
|
|
322
295
|
raise RefreshError.new("Invalid response format: #{e.message}", retry_possible: false)
|
|
323
296
|
end
|
|
324
297
|
|
|
325
|
-
def
|
|
298
|
+
def handle_error_response(response)
|
|
326
299
|
raise RefreshError.new("Missing error response body", retry_possible: false) if response[:body].nil?
|
|
327
300
|
|
|
328
301
|
error_data = JSON.parse(response[:body])
|
|
@@ -331,13 +304,9 @@ module AuthProto
|
|
|
331
304
|
raise RefreshError.new("Invalid error response format", retry_possible: false)
|
|
332
305
|
end
|
|
333
306
|
|
|
334
|
-
error_description = error_data['
|
|
307
|
+
error_description = [error_data['error'], error_data['error_description']].compact.join(': ')
|
|
335
308
|
|
|
336
309
|
case error_data['error']
|
|
337
|
-
when DPoP::ERROR_USE_NONCE
|
|
338
|
-
extract_dpop_nonce(response)
|
|
339
|
-
dpop_client.process_response(response[:headers], auth_server.issuer)
|
|
340
|
-
raise RefreshError.new("Retry with DPoP nonce", retry_possible: true)
|
|
341
310
|
when "invalid_grant"
|
|
342
311
|
# The refresh token has been invalidated or already used
|
|
343
312
|
raise RefreshError.new(
|
|
@@ -356,7 +325,8 @@ module AuthProto
|
|
|
356
325
|
|
|
357
326
|
def handle_rate_limit_response(response)
|
|
358
327
|
# Extract retry-after if available
|
|
359
|
-
retry_after = response
|
|
328
|
+
retry_after = response_header(response, 'Retry-After')
|
|
329
|
+
retry_after = retry_after&.to_i || calculate_delay
|
|
360
330
|
raise RefreshError, "Rate limited - retry after #{retry_after} seconds"
|
|
361
331
|
end
|
|
362
332
|
|
|
@@ -365,7 +335,6 @@ module AuthProto
|
|
|
365
335
|
raise RefreshError.new("Token response must be a JSON object", retry_possible: false)
|
|
366
336
|
end
|
|
367
337
|
|
|
368
|
-
# Required fields
|
|
369
338
|
%w[access_token token_type expires_in scope sub].each do |field|
|
|
370
339
|
raise RefreshError.new("Missing #{field} in response", retry_possible: false) unless data[field]
|
|
371
340
|
end
|
|
@@ -384,19 +353,17 @@ module AuthProto
|
|
|
384
353
|
raise RefreshError.new("Invalid refresh_token in response", retry_possible: false)
|
|
385
354
|
end
|
|
386
355
|
|
|
387
|
-
|
|
388
|
-
unless data['token_type'] == 'DPoP'
|
|
356
|
+
unless data['token_type'].casecmp?('DPoP')
|
|
389
357
|
raise RefreshError.new("Invalid token_type: #{data['token_type']}", retry_possible: false)
|
|
390
358
|
end
|
|
391
359
|
|
|
392
|
-
# Scope must include original scopes
|
|
393
360
|
original_scopes = session.scope.split
|
|
394
361
|
response_scopes = data['scope'].split
|
|
362
|
+
|
|
395
363
|
unless (original_scopes - response_scopes).empty?
|
|
396
364
|
raise RefreshError.new("Invalid scope in response", retry_possible: false)
|
|
397
365
|
end
|
|
398
366
|
|
|
399
|
-
# Subject must match
|
|
400
367
|
return if data['sub'] == session.tokens.sub
|
|
401
368
|
|
|
402
369
|
raise RefreshError.new("Subject mismatch in response", retry_possible: false)
|
|
@@ -18,11 +18,22 @@ module AuthProto
|
|
|
18
18
|
|
|
19
19
|
attr_reader :port, :userinfo, :path
|
|
20
20
|
|
|
21
|
+
# @!attribute [r] port
|
|
22
|
+
# Explicit port text from the input URL, or `nil` when omitted.
|
|
23
|
+
# @return [String, nil]
|
|
24
|
+
# @!attribute [r] userinfo
|
|
25
|
+
# User information from the input URL, including an explicitly empty component.
|
|
26
|
+
# @return [String, nil]
|
|
27
|
+
# @!attribute [r] path
|
|
28
|
+
# URL path, treating the root path `/` as an empty string.
|
|
29
|
+
# @return [String]
|
|
21
30
|
def_delegators :@uri, :scheme, :host, :hostname, :query, :fragment,
|
|
22
31
|
:user, :password, :registry, :opaque, :default_port,
|
|
23
32
|
:request_uri, :origin, :absolute?, :relative?, :hierarchical?,
|
|
24
33
|
:to_s, :to_str
|
|
25
34
|
|
|
35
|
+
# Parses an absolute HTTP(S) URL and preserves explicitly supplied components.
|
|
36
|
+
#
|
|
26
37
|
# @param url [String, URI::Generic] URL to parse; URI inputs use their serialized form
|
|
27
38
|
# @raise [URI::InvalidURIError] if the URL is malformed, not HTTP(S), or has no host
|
|
28
39
|
#
|
|
@@ -42,14 +53,26 @@ module AuthProto
|
|
|
42
53
|
@path = (@uri.path == '/') ? '' : @uri.path
|
|
43
54
|
end
|
|
44
55
|
|
|
56
|
+
# Returns the explicitly supplied port, without substituting a scheme default.
|
|
57
|
+
#
|
|
58
|
+
# @return [String, nil] port text, or `nil` if omitted
|
|
59
|
+
#
|
|
45
60
|
def specified_port
|
|
46
61
|
port
|
|
47
62
|
end
|
|
48
63
|
|
|
64
|
+
# Returns the parsed port, using the scheme default when no port was supplied.
|
|
65
|
+
#
|
|
66
|
+
# @return [Integer] effective HTTP(S) port
|
|
67
|
+
#
|
|
49
68
|
def effective_port
|
|
50
69
|
@uri.port
|
|
51
70
|
end
|
|
52
71
|
|
|
72
|
+
# Returns the underlying parsed Ruby URI object.
|
|
73
|
+
#
|
|
74
|
+
# @return [URI::HTTP, URI::HTTPS] parsed URI; this is the original object, not a copy
|
|
75
|
+
#
|
|
53
76
|
def to_uri
|
|
54
77
|
@uri
|
|
55
78
|
end
|
data/lib/authproto/version.rb
CHANGED
data/lib/authproto.rb
CHANGED
|
@@ -5,7 +5,6 @@ require_relative 'authproto/configuration'
|
|
|
5
5
|
require_relative 'authproto/http_client'
|
|
6
6
|
require_relative 'authproto/insecure_http_client'
|
|
7
7
|
require_relative 'authproto/localhost_server'
|
|
8
|
-
require_relative 'authproto/serialization/dpop_key'
|
|
9
8
|
require_relative 'authproto/validated_uri'
|
|
10
9
|
require_relative 'authproto/version'
|
|
11
10
|
|
data/sig/authproto/client.rbs
CHANGED
|
@@ -15,9 +15,9 @@ module AuthProto
|
|
|
15
15
|
attr_reader session_manager: State::SessionManager
|
|
16
16
|
attr_reader dpop_client: DPoP::Client
|
|
17
17
|
|
|
18
|
-
def initialize: (client_id: String, redirect_uri: String, ?metadata: json_object?, ?
|
|
18
|
+
def initialize: (client_id: String, redirect_uri: String, ?metadata: json_object?, ?signing_key: (json_object | JOSE::JWK)?) -> void
|
|
19
19
|
def authorize: (?handle: String?, ?pds: String?, ?scope: String) -> authorization_result
|
|
20
|
-
def handle_callback: (code: String, state: String, iss: String) -> callback_result
|
|
20
|
+
def handle_callback: (code: String, state: String, iss: String, expected_session_id: String) -> callback_result
|
|
21
21
|
def get_tokens: (String session_id) -> token_info?
|
|
22
22
|
def refresh_token: (String session_id) -> refreshed_tokens
|
|
23
23
|
def authorized?: (String session_id) -> bool
|
|
@@ -28,15 +28,13 @@ module AuthProto
|
|
|
28
28
|
private
|
|
29
29
|
def load_client_metadata: (json_object? metadata) -> Metadata::ClientMetadata
|
|
30
30
|
def validate_redirect_uri: () -> void
|
|
31
|
-
def
|
|
32
|
-
def
|
|
31
|
+
def initialize_client_assertion: ((json_object | JOSE::JWK)? key) -> String?
|
|
32
|
+
def metadata_key_matches_public_key?: (json_object candidate, json_object public_key) -> bool
|
|
33
33
|
def validate_auth_params: (String? handle, String? pds, String scope) -> void
|
|
34
|
-
def
|
|
34
|
+
def resolve_auth_server_info: (String pds) -> Metadata::AuthorizationServer
|
|
35
35
|
def submit_authorization_par: (Metadata::AuthorizationServer authorization_server, State::Session session, ?login_hint: String?) -> String
|
|
36
36
|
def exchange_code: (code: String, session: State::Session) -> json_object
|
|
37
37
|
def make_token_request: (String code, State::Session session) -> http_response
|
|
38
|
-
def requires_dpop_nonce?: (http_response response) -> bool
|
|
39
|
-
def extract_dpop_nonce: (http_response response) -> String
|
|
40
38
|
def validate_token_response: (json_object response, State::Session session) -> void
|
|
41
39
|
end
|
|
42
40
|
end
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
module AuthProto
|
|
2
2
|
module DPoP
|
|
3
3
|
class Client
|
|
4
|
+
include HTTPHelpers
|
|
4
5
|
class Error < AuthProto::Error
|
|
5
6
|
end
|
|
6
7
|
|
|
7
|
-
attr_reader key_manager: KeyManager
|
|
8
8
|
attr_reader proof_generator: ProofGenerator
|
|
9
9
|
attr_reader nonce_manager: NonceManager
|
|
10
10
|
|
|
11
|
-
def initialize: (?
|
|
12
|
-
def generate_proof: (method: String, uri: String, ?access_token: String
|
|
13
|
-
def
|
|
11
|
+
def initialize: (?nonce_ttl: Integer?) -> void
|
|
12
|
+
def generate_proof: (session: State::Session, method: String, uri: String, ?access_token: String?) -> JOSE::SignedBinary
|
|
13
|
+
def has_nonce_error?: (http_response response) -> bool
|
|
14
|
+
def process_response: (http_response response, server_url: String, session_id: String) -> void
|
|
15
|
+
|
|
16
|
+
def with_nonce_handling: (State::Session session, String url) { () -> http_response } -> http_response
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def nonce_challenge?: (String? challenge) -> bool
|
|
14
21
|
end
|
|
15
22
|
end
|
|
16
23
|
end
|