authproto 0.3.0.beta1
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 +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +31 -0
- data/.tm_properties +1 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +52 -0
- data/MIT-LICENSE.txt +21 -0
- data/README.md +304 -0
- data/Rakefile +8 -0
- data/SECURITY.md +16 -0
- data/examples/confidential_client/.gitignore +5 -0
- data/examples/confidential_client/Gemfile +14 -0
- data/examples/confidential_client/Gemfile.lock +88 -0
- data/examples/confidential_client/README.md +226 -0
- data/examples/confidential_client/app.rb +290 -0
- data/examples/confidential_client/config/client-metadata.example.json +25 -0
- data/examples/confidential_client/config.ru +4 -0
- data/examples/confidential_client/public/styles.css +70 -0
- data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
- data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
- data/examples/confidential_client/scripts/generate_keys.rb +15 -0
- data/examples/confidential_client/views/authorized.erb +46 -0
- data/examples/confidential_client/views/index.erb +44 -0
- data/examples/confidential_client/views/layout.erb +11 -0
- data/examples/minisky_console/.gitignore +2 -0
- data/examples/minisky_console/Gemfile +12 -0
- data/examples/minisky_console/Gemfile.lock +131 -0
- data/examples/minisky_console/README.md +112 -0
- data/examples/minisky_console/app.rb +50 -0
- data/examples/minisky_console/bin/console +6 -0
- data/examples/minisky_console/config.ru +5 -0
- data/examples/minisky_console/localhost.rb +53 -0
- data/examples/minisky_console/production.rb +32 -0
- data/lib/authproto/client.rb +583 -0
- data/lib/authproto/client_metadata.rb +478 -0
- data/lib/authproto/configuration.rb +139 -0
- data/lib/authproto/dpop/client.rb +161 -0
- data/lib/authproto/dpop/key_manager.rb +223 -0
- data/lib/authproto/dpop/nonce_manager.rb +145 -0
- data/lib/authproto/dpop/proof_generator.rb +124 -0
- data/lib/authproto/dpop/stored_nonce.rb +37 -0
- data/lib/authproto/encryption.rb +250 -0
- data/lib/authproto/errors.rb +75 -0
- data/lib/authproto/http_client.rb +320 -0
- data/lib/authproto/insecure_http_client.rb +24 -0
- data/lib/authproto/localhost_server.rb +274 -0
- data/lib/authproto/minisky.rb +273 -0
- data/lib/authproto/par/client.rb +240 -0
- data/lib/authproto/par/client_assertion.rb +69 -0
- data/lib/authproto/par/request.rb +243 -0
- data/lib/authproto/par/response.rb +43 -0
- data/lib/authproto/par.rb +48 -0
- data/lib/authproto/pkce.rb +125 -0
- data/lib/authproto/serialization/base.rb +258 -0
- data/lib/authproto/serialization/dpop_key.rb +38 -0
- data/lib/authproto/serialization/session.rb +88 -0
- data/lib/authproto/serialization/stored_nonce.rb +46 -0
- data/lib/authproto/serialization/token_set.rb +54 -0
- data/lib/authproto/server_metadata/authorization_server.rb +267 -0
- data/lib/authproto/server_metadata/origin_url.rb +71 -0
- data/lib/authproto/server_metadata/resource_server.rb +98 -0
- data/lib/authproto/server_metadata.rb +28 -0
- data/lib/authproto/state/session.rb +168 -0
- data/lib/authproto/state/session_manager.rb +162 -0
- data/lib/authproto/state/token_set.rb +101 -0
- data/lib/authproto/state.rb +61 -0
- data/lib/authproto/storage/interface.rb +121 -0
- data/lib/authproto/storage/key_builder.rb +76 -0
- data/lib/authproto/storage/memory.rb +222 -0
- data/lib/authproto/storage/redis.rb +156 -0
- data/lib/authproto/storage/sqlite.rb +281 -0
- data/lib/authproto/token/refresh.rb +414 -0
- data/lib/authproto/version.rb +8 -0
- data/lib/authproto.rb +18 -0
- data/sig/authproto/client.rbs +43 -0
- data/sig/authproto/client_metadata.rbs +113 -0
- data/sig/authproto/dependencies.rbs +19 -0
- data/sig/authproto/dpop/client.rbs +23 -0
- data/sig/authproto/dpop/key_manager.rbs +26 -0
- data/sig/authproto/dpop/nonce_manager.rbs +23 -0
- data/sig/authproto/dpop/proof_generator.rbs +40 -0
- data/sig/authproto/dpop/stored_nonce.rbs +10 -0
- data/sig/authproto/encryption.rbs +42 -0
- data/sig/authproto/http_client.rbs +45 -0
- data/sig/authproto/insecure_http_client.rbs +8 -0
- data/sig/authproto/localhost_server.rbs +30 -0
- data/sig/authproto/minisky_oauth.rbs +22 -0
- data/sig/authproto/par/client.rbs +25 -0
- data/sig/authproto/par/client_assertion.rbs +10 -0
- data/sig/authproto/par/request.rbs +73 -0
- data/sig/authproto/par/response.rbs +17 -0
- data/sig/authproto/par.rbs +7 -0
- data/sig/authproto/pkce.rbs +24 -0
- data/sig/authproto/serialization/base.rbs +36 -0
- data/sig/authproto/serialization/dpop_key.rbs +14 -0
- data/sig/authproto/serialization/session.rbs +19 -0
- data/sig/authproto/serialization/stored_nonce.rbs +14 -0
- data/sig/authproto/serialization/token_set.rbs +14 -0
- data/sig/authproto/server_metadata/authorization_server.rbs +71 -0
- data/sig/authproto/server_metadata/origin_url.rbs +21 -0
- data/sig/authproto/server_metadata/resource_server.rbs +27 -0
- data/sig/authproto/state/session.rbs +28 -0
- data/sig/authproto/state/session_manager.rbs +15 -0
- data/sig/authproto/state/token_set.rbs +40 -0
- data/sig/authproto/state.rbs +6 -0
- data/sig/authproto/storage/interface.rbs +20 -0
- data/sig/authproto/storage/key_builder.rbs +16 -0
- data/sig/authproto/storage/memory.rbs +22 -0
- data/sig/authproto/storage/redis.rbs +19 -0
- data/sig/authproto/storage/redis_client.rbs +11 -0
- data/sig/authproto/storage/sqlite.rbs +26 -0
- data/sig/authproto/token/refresh.rbs +47 -0
- data/sig/authproto/version.rbs +3 -0
- data/sig/authproto.rbs +54 -0
- metadata +200 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jose'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'monitor'
|
|
6
|
+
|
|
7
|
+
require_relative '../configuration'
|
|
8
|
+
require_relative '../errors'
|
|
9
|
+
require_relative '../http_client'
|
|
10
|
+
require_relative '../par'
|
|
11
|
+
require_relative '../par/client_assertion'
|
|
12
|
+
require_relative '../state/token_set'
|
|
13
|
+
|
|
14
|
+
module AuthProto
|
|
15
|
+
|
|
16
|
+
#
|
|
17
|
+
# Token error types and synchronized refresh-token exchange operations.
|
|
18
|
+
#
|
|
19
|
+
|
|
20
|
+
module Token
|
|
21
|
+
|
|
22
|
+
#
|
|
23
|
+
# Base error class for token-related errors
|
|
24
|
+
#
|
|
25
|
+
|
|
26
|
+
class Error < AuthProto::Error
|
|
27
|
+
|
|
28
|
+
# Kind of token associated with the failure.
|
|
29
|
+
# @return [String]
|
|
30
|
+
attr_reader :token_type
|
|
31
|
+
|
|
32
|
+
# Machine-readable token error code.
|
|
33
|
+
# @return [String]
|
|
34
|
+
attr_reader :error_code
|
|
35
|
+
|
|
36
|
+
# Whether retrying the operation may succeed.
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
attr_reader :retry_possible
|
|
39
|
+
|
|
40
|
+
# Creates a token error with structured retry information.
|
|
41
|
+
#
|
|
42
|
+
# @param message [String] human-readable failure description
|
|
43
|
+
# @param token_type [String] kind of token associated with the failure
|
|
44
|
+
# @param error_code [String] machine-readable failure code
|
|
45
|
+
# @param retry_possible [Boolean] whether retrying may succeed
|
|
46
|
+
#
|
|
47
|
+
def initialize(message, token_type:, error_code:, retry_possible: false)
|
|
48
|
+
@token_type = token_type
|
|
49
|
+
@error_code = error_code
|
|
50
|
+
@retry_possible = retry_possible
|
|
51
|
+
super(message)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
#
|
|
56
|
+
# Raised for token expiry; its retry metadata permits a refresh/retry.
|
|
57
|
+
#
|
|
58
|
+
|
|
59
|
+
class ExpiredTokenError < Error
|
|
60
|
+
|
|
61
|
+
# Creates an error describing token expiry.
|
|
62
|
+
# @param token_type [String] kind of token that failed
|
|
63
|
+
#
|
|
64
|
+
def initialize(token_type:)
|
|
65
|
+
super(
|
|
66
|
+
"Token has expired",
|
|
67
|
+
token_type: token_type,
|
|
68
|
+
error_code: "token_expired",
|
|
69
|
+
retry_possible: true
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
#
|
|
75
|
+
# Raised when a token is structurally valid but has been invalidated or revoked
|
|
76
|
+
#
|
|
77
|
+
|
|
78
|
+
class InvalidTokenError < Error
|
|
79
|
+
|
|
80
|
+
# Creates an error describing token invalidation/revocation.
|
|
81
|
+
# @param token_type [String] kind of token that failed
|
|
82
|
+
#
|
|
83
|
+
def initialize(token_type:)
|
|
84
|
+
super(
|
|
85
|
+
"Token is invalid",
|
|
86
|
+
token_type: token_type,
|
|
87
|
+
error_code: "token_invalid",
|
|
88
|
+
retry_possible: false
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
#
|
|
94
|
+
# Raised during token refresh operations, includes retry information and server responses
|
|
95
|
+
#
|
|
96
|
+
|
|
97
|
+
class RefreshError < Error
|
|
98
|
+
|
|
99
|
+
# Creates a refresh error with retry metadata.
|
|
100
|
+
#
|
|
101
|
+
# @param message [String] failure description
|
|
102
|
+
# @param retry_possible [Boolean] whether a later retry may succeed
|
|
103
|
+
#
|
|
104
|
+
def initialize(message, retry_possible: true)
|
|
105
|
+
super(
|
|
106
|
+
message,
|
|
107
|
+
token_type: "refresh",
|
|
108
|
+
error_code: "refresh_failed",
|
|
109
|
+
retry_possible: retry_possible
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
#
|
|
115
|
+
# Handles token refresh operations with retry logic
|
|
116
|
+
#
|
|
117
|
+
|
|
118
|
+
class Refresh
|
|
119
|
+
include MonitorMixin
|
|
120
|
+
|
|
121
|
+
# Maximum attempts for one refresh operation.
|
|
122
|
+
# @return [Integer]
|
|
123
|
+
MAX_RETRIES = 3
|
|
124
|
+
|
|
125
|
+
# Initial retry delay in seconds before jitter.
|
|
126
|
+
# @return [Integer]
|
|
127
|
+
BASE_DELAY = 1
|
|
128
|
+
|
|
129
|
+
# Maximum retry delay in seconds before jitter.
|
|
130
|
+
# @return [Integer]
|
|
131
|
+
MAX_DELAY = 8
|
|
132
|
+
|
|
133
|
+
# Session containing the tokens to refresh.
|
|
134
|
+
# @return [State::Session]
|
|
135
|
+
attr_reader :session
|
|
136
|
+
|
|
137
|
+
# DPoP client used to sign refresh requests.
|
|
138
|
+
# @return [DPoP::Client]
|
|
139
|
+
attr_reader :dpop_client
|
|
140
|
+
|
|
141
|
+
# Server providing the token endpoint.
|
|
142
|
+
# @return [ServerMetadata::AuthorizationServer]
|
|
143
|
+
attr_reader :auth_server
|
|
144
|
+
|
|
145
|
+
# Client metadata used for optional confidential-client assertions.
|
|
146
|
+
# @return [ClientMetadata]
|
|
147
|
+
attr_reader :client_metadata
|
|
148
|
+
|
|
149
|
+
# Creates a synchronized refresh operation for an existing OAuth session.
|
|
150
|
+
#
|
|
151
|
+
# @param session [State::Session] session with an access and refresh token
|
|
152
|
+
# @param dpop_client [DPoP::Client] proof signer and nonce tracker
|
|
153
|
+
# @param auth_server [ServerMetadata::AuthorizationServer] server with a token endpoint
|
|
154
|
+
# @param client_metadata [ClientMetadata] client ID and authentication key configuration
|
|
155
|
+
#
|
|
156
|
+
def initialize(session:, dpop_client:, auth_server:, client_metadata:)
|
|
157
|
+
super() # Initialize MonitorMixin
|
|
158
|
+
@session = session
|
|
159
|
+
@dpop_client = dpop_client
|
|
160
|
+
@auth_server = auth_server
|
|
161
|
+
@attempt_count = 0
|
|
162
|
+
@client_metadata = client_metadata
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Exchanges a refresh token for a replacement token set.
|
|
166
|
+
#
|
|
167
|
+
# Retries eligible failures up to {MAX_RETRIES} times with exponential backoff and
|
|
168
|
+
# jitter, and handles a DPoP nonce challenge during token exchange. Does not assign
|
|
169
|
+
# the returned tokens to the session or persist them; the caller must do so.
|
|
170
|
+
#
|
|
171
|
+
# @return [State::TokenSet] validated replacement tokens
|
|
172
|
+
# @raise [RefreshError] if the session is not renewable, refresh fails, or retries are exhausted
|
|
173
|
+
#
|
|
174
|
+
def perform
|
|
175
|
+
synchronize do
|
|
176
|
+
raise RefreshError.new("No refresh token available", retry_possible: false) unless session.renewable?
|
|
177
|
+
raise RefreshError.new("No access token to refresh", retry_possible: false) if session.tokens.nil?
|
|
178
|
+
|
|
179
|
+
with_retries do
|
|
180
|
+
request_token_refresh
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def with_retries
|
|
189
|
+
@attempt_count = 0
|
|
190
|
+
last_error = nil
|
|
191
|
+
|
|
192
|
+
while @attempt_count < MAX_RETRIES
|
|
193
|
+
begin
|
|
194
|
+
return yield
|
|
195
|
+
rescue RefreshError, HTTPClient::HTTPError => e
|
|
196
|
+
last_error = e
|
|
197
|
+
@attempt_count += 1
|
|
198
|
+
|
|
199
|
+
# Don't retry if error indicates retry not possible
|
|
200
|
+
raise e if e.respond_to?(:retry_possible) && !e.retry_possible
|
|
201
|
+
|
|
202
|
+
sleep calculate_delay if @attempt_count < MAX_RETRIES
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Reached max retries
|
|
207
|
+
raise RefreshError.new(
|
|
208
|
+
"Token refresh failed after #{MAX_RETRIES} attempts: #{last_error.message}",
|
|
209
|
+
retry_possible: false
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def calculate_delay
|
|
214
|
+
# Exponential backoff with jitter
|
|
215
|
+
delay = [BASE_DELAY * (2**(@attempt_count - 1)), MAX_DELAY].min
|
|
216
|
+
delay + (rand * 0.5 * delay) # Add up to 50% jitter
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def request_token_refresh
|
|
220
|
+
# Initial token request without nonce
|
|
221
|
+
response = make_token_request(session)
|
|
222
|
+
|
|
223
|
+
# Handle DPoP nonce requirement
|
|
224
|
+
if requires_dpop_nonce?(response)
|
|
225
|
+
# Extract and store nonce from error response
|
|
226
|
+
extract_dpop_nonce(response)
|
|
227
|
+
dpop_client.process_response(response[:headers], auth_server.issuer)
|
|
228
|
+
|
|
229
|
+
# Retry request with nonce
|
|
230
|
+
response = make_token_request(session)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
handle_refresh_response(response)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def make_token_request(session)
|
|
237
|
+
# Generate proof
|
|
238
|
+
proof = dpop_client.generate_proof(
|
|
239
|
+
http_method: "POST",
|
|
240
|
+
http_uri: auth_server.token_endpoint
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
body = {
|
|
244
|
+
grant_type: "refresh_token",
|
|
245
|
+
refresh_token: session.tokens.refresh_token,
|
|
246
|
+
scope: session.scope,
|
|
247
|
+
client_id: client_metadata.client_id
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
# Add client authentication if available
|
|
251
|
+
add_client_authentication(body) if client_metadata.confidential?
|
|
252
|
+
|
|
253
|
+
AuthProto.configuration.http_client.post(
|
|
254
|
+
auth_server.token_endpoint,
|
|
255
|
+
body: body,
|
|
256
|
+
headers: {
|
|
257
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
|
258
|
+
"DPoP" => proof
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def requires_dpop_nonce?(response)
|
|
264
|
+
return false unless response[:status] == 400
|
|
265
|
+
return false if response[:body].nil?
|
|
266
|
+
|
|
267
|
+
error_data = JSON.parse(response[:body])
|
|
268
|
+
error_data.is_a?(Hash) && error_data["error"] == "use_dpop_nonce"
|
|
269
|
+
rescue JSON::ParserError
|
|
270
|
+
false
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def extract_dpop_nonce(response)
|
|
274
|
+
nonce = (response[:headers] || {}).detect { |k, v| k.downcase == "dpop-nonce" }&.last
|
|
275
|
+
|
|
276
|
+
unless nonce.is_a?(String) && !nonce.empty?
|
|
277
|
+
raise RefreshError.new("No DPoP nonce provided in error response", retry_possible: false)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
nonce
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def add_client_authentication(body)
|
|
284
|
+
return unless client_metadata.jwks && !client_metadata.jwks["keys"].empty?
|
|
285
|
+
|
|
286
|
+
signing_key = JOSE::JWK.from_map(client_metadata.jwks["keys"].first)
|
|
287
|
+
client_assertion = PAR::ClientAssertion.new(
|
|
288
|
+
client_id: client_metadata.client_id,
|
|
289
|
+
signing_key: signing_key
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
body.merge!(
|
|
293
|
+
client_assertion_type: PAR::CLIENT_ASSERTION_TYPE,
|
|
294
|
+
client_assertion: client_assertion.generate_jwt(
|
|
295
|
+
audience: auth_server.issuer
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def handle_refresh_response(response)
|
|
301
|
+
case response[:status]
|
|
302
|
+
when 200
|
|
303
|
+
process_successful_response(response)
|
|
304
|
+
when 400
|
|
305
|
+
handle_400_response(response)
|
|
306
|
+
when 401
|
|
307
|
+
raise RefreshError.new("Refresh token is invalid", retry_possible: false)
|
|
308
|
+
when 429
|
|
309
|
+
handle_rate_limit_response(response)
|
|
310
|
+
else
|
|
311
|
+
raise RefreshError, "Unexpected response: #{response[:status]}"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def process_successful_response(response)
|
|
316
|
+
raise RefreshError.new("Missing response body", retry_possible: false) if response[:body].nil?
|
|
317
|
+
|
|
318
|
+
data = JSON.parse(response[:body])
|
|
319
|
+
validate_refresh_response(data)
|
|
320
|
+
|
|
321
|
+
AuthProto::State::TokenSet.new(
|
|
322
|
+
access_token: data["access_token"],
|
|
323
|
+
token_type: data["token_type"],
|
|
324
|
+
expires_in: data["expires_in"],
|
|
325
|
+
refresh_token: data["refresh_token"],
|
|
326
|
+
scope: data["scope"],
|
|
327
|
+
sub: data["sub"]
|
|
328
|
+
)
|
|
329
|
+
rescue JSON::ParserError => e
|
|
330
|
+
raise RefreshError.new("Invalid response format: #{e.message}", retry_possible: false)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def handle_400_response(response)
|
|
334
|
+
raise RefreshError.new("Missing error response body", retry_possible: false) if response[:body].nil?
|
|
335
|
+
|
|
336
|
+
error_data = JSON.parse(response[:body])
|
|
337
|
+
|
|
338
|
+
unless error_data.is_a?(Hash)
|
|
339
|
+
raise RefreshError.new("Invalid error response format", retry_possible: false)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
error_description = error_data["error_description"] || error_data["error"]
|
|
343
|
+
|
|
344
|
+
case error_data["error"]
|
|
345
|
+
when "use_dpop_nonce"
|
|
346
|
+
extract_dpop_nonce(response)
|
|
347
|
+
dpop_client.process_response(response[:headers], auth_server.issuer)
|
|
348
|
+
raise RefreshError.new("Retry with DPoP nonce", retry_possible: true)
|
|
349
|
+
when "invalid_grant"
|
|
350
|
+
# The refresh token has been invalidated or already used
|
|
351
|
+
raise RefreshError.new(
|
|
352
|
+
"Refresh token has been invalidated: #{error_description}",
|
|
353
|
+
retry_possible: false
|
|
354
|
+
)
|
|
355
|
+
else
|
|
356
|
+
raise RefreshError.new(
|
|
357
|
+
"Refresh request failed: #{error_description}",
|
|
358
|
+
retry_possible: false
|
|
359
|
+
)
|
|
360
|
+
end
|
|
361
|
+
rescue JSON::ParserError
|
|
362
|
+
raise RefreshError.new("Invalid error response format", retry_possible: false)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def handle_rate_limit_response(response)
|
|
366
|
+
# Extract retry-after if available
|
|
367
|
+
retry_after = response[:headers]["Retry-After"]&.to_i || calculate_delay
|
|
368
|
+
raise RefreshError, "Rate limited - retry after #{retry_after} seconds"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def validate_refresh_response(data)
|
|
372
|
+
unless data.is_a?(Hash)
|
|
373
|
+
raise RefreshError.new("Token response must be a JSON object", retry_possible: false)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Required fields
|
|
377
|
+
%w[access_token token_type expires_in scope sub].each do |field|
|
|
378
|
+
raise RefreshError.new("Missing #{field} in response", retry_possible: false) unless data[field]
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
%w[access_token token_type scope sub].each do |field|
|
|
382
|
+
unless data[field].is_a?(String) && !data[field].empty?
|
|
383
|
+
raise RefreshError.new("Invalid #{field} in response", retry_possible: false)
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
unless data["expires_in"].is_a?(Integer)
|
|
388
|
+
raise RefreshError.new("Invalid expires_in in response", retry_possible: false)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
unless data["refresh_token"].nil? || data["refresh_token"].is_a?(String)
|
|
392
|
+
raise RefreshError.new("Invalid refresh_token in response", retry_possible: false)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Token type must be DPoP
|
|
396
|
+
unless data["token_type"] == "DPoP"
|
|
397
|
+
raise RefreshError.new("Invalid token_type: #{data["token_type"]}", retry_possible: false)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Scope must include original scopes
|
|
401
|
+
original_scopes = session.scope.split
|
|
402
|
+
response_scopes = data["scope"].split
|
|
403
|
+
unless (original_scopes - response_scopes).empty?
|
|
404
|
+
raise RefreshError.new("Invalid scope in response", retry_possible: false)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Subject must match
|
|
408
|
+
return if data["sub"] == session.tokens.sub
|
|
409
|
+
|
|
410
|
+
raise RefreshError.new("Subject mismatch in response", retry_possible: false)
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
data/lib/authproto.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'authproto/client'
|
|
4
|
+
require_relative 'authproto/configuration'
|
|
5
|
+
require_relative 'authproto/http_client'
|
|
6
|
+
require_relative 'authproto/insecure_http_client'
|
|
7
|
+
require_relative 'authproto/localhost_server'
|
|
8
|
+
require_relative 'authproto/serialization/dpop_key'
|
|
9
|
+
require_relative 'authproto/version'
|
|
10
|
+
|
|
11
|
+
#
|
|
12
|
+
# AuthProto is a Ruby library implementing the AT Protocol OAuth specification.
|
|
13
|
+
# It provides OAuth clients with DPoP, PAR, identity discovery, client metadata
|
|
14
|
+
# validation, and storage for sessions and tokens.
|
|
15
|
+
#
|
|
16
|
+
|
|
17
|
+
module AuthProto
|
|
18
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module AuthProto
|
|
2
|
+
class Client
|
|
3
|
+
class CallbackError < AuthProto::Error
|
|
4
|
+
end
|
|
5
|
+
class TokenError < AuthProto::Error
|
|
6
|
+
end
|
|
7
|
+
class SessionError < AuthProto::Error
|
|
8
|
+
attr_reader session_id: String?
|
|
9
|
+
def initialize: (String message, ?session_id: String?) -> void
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
attr_reader client_id: String
|
|
13
|
+
attr_reader redirect_uri: String
|
|
14
|
+
attr_reader client_metadata: ClientMetadata
|
|
15
|
+
attr_reader session_manager: State::SessionManager
|
|
16
|
+
attr_reader dpop_client: DPoP::Client
|
|
17
|
+
|
|
18
|
+
def initialize: (client_id: String, redirect_uri: String, ?metadata: json_object?, ?dpop_key: (json_object | JOSE::Map)?) -> void
|
|
19
|
+
def authorize: (?handle: String?, ?pds_url: String?, ?scope: String) -> authorization_result
|
|
20
|
+
def handle_callback: (code: String, state: String, iss: String) -> callback_result
|
|
21
|
+
def get_tokens: (String session_id) -> token_info?
|
|
22
|
+
def refresh_token: (String session_id) -> refreshed_tokens
|
|
23
|
+
def authorized?: (String session_id) -> bool
|
|
24
|
+
def auth_headers: (session_id: String, method: String, url: String) -> Hash[String, String]
|
|
25
|
+
def remove_session: (String session_id) -> void
|
|
26
|
+
def cleanup_expired_sessions: () -> void
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
def load_client_metadata: (json_object? metadata) -> ClientMetadata
|
|
30
|
+
def validate_redirect_uri: () -> void
|
|
31
|
+
def initialize_dpop: ((json_object | JOSE::Map)? key) -> DPoP::Client
|
|
32
|
+
def validate_auth_params!: (String? handle, String? pds_url, String scope) -> void
|
|
33
|
+
def resolve_from_handle: (String handle, State::Session session) -> { server: ServerMetadata::AuthorizationServer, pds: String }
|
|
34
|
+
def resolve_from_pds: (String pds_url, State::Session session) -> { server: ServerMetadata::AuthorizationServer, pds: String }
|
|
35
|
+
def resolve_auth_server: (String pds_url) -> ServerMetadata::AuthorizationServer
|
|
36
|
+
def generate_authorization_url: (ServerMetadata::AuthorizationServer auth_server, State::Session session, ?login_hint: String?) -> String
|
|
37
|
+
def exchange_code: (code: String, session: State::Session) -> json_object
|
|
38
|
+
def make_token_request: (String code, State::Session session) -> http_response
|
|
39
|
+
def requires_dpop_nonce?: (http_response response) -> bool
|
|
40
|
+
def extract_dpop_nonce: (http_response response) -> String
|
|
41
|
+
def validate_token_response: (json_object response, State::Session session) -> void
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module AuthProto
|
|
2
|
+
# Handles and validates AT Protocol OAuth client metadata.
|
|
3
|
+
class ClientMetadata
|
|
4
|
+
AUTH_PRIVATE_KEY_JWT: String
|
|
5
|
+
SCOPE_PATTERN: Regexp
|
|
6
|
+
|
|
7
|
+
@client_id: String
|
|
8
|
+
|
|
9
|
+
@grant_types: Array[String]
|
|
10
|
+
|
|
11
|
+
@response_types: Array[String]
|
|
12
|
+
|
|
13
|
+
@redirect_uris: Array[String]
|
|
14
|
+
|
|
15
|
+
@scope: String
|
|
16
|
+
|
|
17
|
+
@client_name: String?
|
|
18
|
+
|
|
19
|
+
@application_type: String
|
|
20
|
+
|
|
21
|
+
@client_uri: String?
|
|
22
|
+
|
|
23
|
+
@logo_uri: String?
|
|
24
|
+
|
|
25
|
+
@tos_uri: String?
|
|
26
|
+
|
|
27
|
+
@policy_uri: String?
|
|
28
|
+
|
|
29
|
+
@token_endpoint_auth_method: String
|
|
30
|
+
|
|
31
|
+
@token_endpoint_auth_signing_alg: String?
|
|
32
|
+
|
|
33
|
+
@jwks: Hash[String, untyped]?
|
|
34
|
+
|
|
35
|
+
@jwks_uri: String?
|
|
36
|
+
|
|
37
|
+
attr_reader client_id: String
|
|
38
|
+
attr_reader grant_types: Array[String]
|
|
39
|
+
attr_reader response_types: Array[String]
|
|
40
|
+
attr_reader redirect_uris: Array[String]
|
|
41
|
+
attr_reader scope: String
|
|
42
|
+
attr_reader client_name: String?
|
|
43
|
+
attr_reader client_uri: String?
|
|
44
|
+
attr_reader logo_uri: String?
|
|
45
|
+
attr_reader tos_uri: String?
|
|
46
|
+
attr_reader policy_uri: String?
|
|
47
|
+
attr_reader token_endpoint_auth_method: String
|
|
48
|
+
attr_reader jwks: Hash[String, untyped]?
|
|
49
|
+
attr_reader jwks_uri: String?
|
|
50
|
+
attr_reader application_type: String
|
|
51
|
+
|
|
52
|
+
def initialize: (Hash[String, untyped] metadata) -> void
|
|
53
|
+
|
|
54
|
+
def self.from_url: (String url) -> ClientMetadata
|
|
55
|
+
|
|
56
|
+
def confidential?: () -> bool
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def validate_and_set_metadata: (Hash[String, untyped] metadata) -> void
|
|
61
|
+
|
|
62
|
+
def validate_client_id: (String client_id) -> String
|
|
63
|
+
|
|
64
|
+
def valid_https_client_id?: (String client_id) -> bool
|
|
65
|
+
|
|
66
|
+
def valid_localhost_client_id?: (String client_id) -> bool
|
|
67
|
+
|
|
68
|
+
def validate_grant_types: (Array[String] grant_types) -> Array[String]
|
|
69
|
+
|
|
70
|
+
def validate_response_types: (Array[String] response_types) -> Array[String]
|
|
71
|
+
|
|
72
|
+
def validate_redirect_uris: (Array[String] uris) -> Array[String]
|
|
73
|
+
|
|
74
|
+
def validate_redirect_uri: (String value) -> void
|
|
75
|
+
|
|
76
|
+
def validate_https_redirect_uri: (URI::Generic uri, String? port) -> void
|
|
77
|
+
|
|
78
|
+
def validate_redirect_uri_origin: (URI::Generic uri) -> void
|
|
79
|
+
|
|
80
|
+
def validate_native_redirect_uri: (URI::Generic uri, String? port) -> void
|
|
81
|
+
|
|
82
|
+
def validate_custom_scheme: (URI::Generic uri) -> void
|
|
83
|
+
|
|
84
|
+
def validate_scope: (String scope) -> String
|
|
85
|
+
|
|
86
|
+
def validate_application_type: (String? type) -> String
|
|
87
|
+
|
|
88
|
+
def validate_client_name: (String? name) -> String?
|
|
89
|
+
|
|
90
|
+
def validate_client_uri: (String? uri) -> String?
|
|
91
|
+
|
|
92
|
+
def validate_https_uri: (String? uri) -> String?
|
|
93
|
+
|
|
94
|
+
def validate_jwks: (Hash[String, untyped] jwks) -> void
|
|
95
|
+
|
|
96
|
+
def validate_auth_methods: (Hash[String, untyped] metadata) -> void
|
|
97
|
+
|
|
98
|
+
def validate_auth_public: (Hash[String, untyped] metadata) -> void
|
|
99
|
+
|
|
100
|
+
def validate_auth_confidential: (Hash[String, untyped] metadata) -> void
|
|
101
|
+
|
|
102
|
+
def validate_dpop: (Hash[String, untyped] metadata) -> void
|
|
103
|
+
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
module AuthProto
|
|
108
|
+
module ApplicationType
|
|
109
|
+
WEB: String
|
|
110
|
+
NATIVE: String
|
|
111
|
+
ALL: Array[String]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Minimal nominal types for the JOSE objects exposed by AuthProto's API.
|
|
2
|
+
# JOSE currently ships no RBS; this is not a signature for its complete API.
|
|
3
|
+
module JOSE
|
|
4
|
+
class Map
|
|
5
|
+
def []: (String key) -> untyped
|
|
6
|
+
def to_h: () -> Hash[String, untyped]
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class SignedBinary < String
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class JWK
|
|
13
|
+
attr_accessor fields: Map
|
|
14
|
+
def self.from_map: (Hash[String, untyped] | Map jwk, ?untyped modules, ?untyped key) -> JWK
|
|
15
|
+
def self.generate_key: (untyped params) -> JWK
|
|
16
|
+
def to_map: (?untyped key, ?untyped jwe) -> Map
|
|
17
|
+
def to_public: () -> JWK
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module AuthProto
|
|
2
|
+
module DPoP
|
|
3
|
+
class Client
|
|
4
|
+
class Error < AuthProto::Error
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
attr_reader key_manager: KeyManager
|
|
8
|
+
attr_reader proof_generator: ProofGenerator
|
|
9
|
+
attr_reader nonce_manager: NonceManager
|
|
10
|
+
|
|
11
|
+
def initialize: (?key_manager: KeyManager?, ?nonce_ttl: Integer?) -> void
|
|
12
|
+
def generate_proof: (http_method: String, http_uri: String, ?access_token: String?, ?nonce: String?) -> JOSE::SignedBinary
|
|
13
|
+
def process_response: (Hash[String, String]? response_headers, String server_url) -> void
|
|
14
|
+
def request_headers: (String proof) -> { "DPoP" => String }
|
|
15
|
+
def public_key: () -> json_object
|
|
16
|
+
def export_key: (?include_private: bool) -> JOSE::Map
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
def extract_nonce: (Hash[String | Symbol, String] headers) -> String?
|
|
20
|
+
def origin_for_uri: (URI::HTTP uri) -> String
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module AuthProto
|
|
2
|
+
module DPoP
|
|
3
|
+
class KeyManager
|
|
4
|
+
class KeyError < AuthProto::Error
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
CURVE: String
|
|
8
|
+
ALGORITHM: String
|
|
9
|
+
attr_reader keypair: JOSE::JWK
|
|
10
|
+
|
|
11
|
+
def initialize: (?JOSE::JWK? keypair) -> void
|
|
12
|
+
def generate_keypair: () -> JOSE::JWK
|
|
13
|
+
def public_jwk: () -> json_object
|
|
14
|
+
def sign: (String data) -> JOSE::SignedBinary
|
|
15
|
+
def sign_segments: (Hash[String | Symbol, untyped] header, Hash[String | Symbol, untyped] payload) -> JOSE::SignedBinary
|
|
16
|
+
def deep_stringify_keys: (untyped obj) -> untyped
|
|
17
|
+
def verify: (String signed_jws) -> bool
|
|
18
|
+
def to_jwk: (?include_private: bool) -> JOSE::Map
|
|
19
|
+
def self.from_jwk: (json_object | JOSE::Map jwk) -> KeyManager
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
def generate_kid: (json_object | JOSE::Map jwk) -> String
|
|
23
|
+
def validate_keypair: () -> void
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module AuthProto
|
|
2
|
+
module DPoP
|
|
3
|
+
class NonceManager
|
|
4
|
+
@ttl: Integer
|
|
5
|
+
@serializer: Serialization::StoredNonce
|
|
6
|
+
|
|
7
|
+
class NonceError < AuthProto::Error
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
DEFAULT_TTL: Integer
|
|
11
|
+
def initialize: (?ttl: Integer?) -> void
|
|
12
|
+
def update: (nonce: String, server_url: String) -> void
|
|
13
|
+
def get: (String server_url) -> String?
|
|
14
|
+
def clear: (String server_url) -> bool
|
|
15
|
+
def valid_nonce?: (String server_url) -> bool
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
def normalize_server_url: (String url) -> String
|
|
19
|
+
def validate_inputs: (String nonce, String server_url) -> void
|
|
20
|
+
def validate_server_url: (String server_url) -> void
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|