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,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ipaddr'
|
|
4
|
+
require 'socket'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
module AuthProto
|
|
9
|
+
|
|
10
|
+
#
|
|
11
|
+
# One-shot loopback OAuth callback listener for use from IRB.
|
|
12
|
+
#
|
|
13
|
+
|
|
14
|
+
class LocalhostServer
|
|
15
|
+
|
|
16
|
+
# Maximum total size of an incoming request header block in bytes.
|
|
17
|
+
# @return [Integer]
|
|
18
|
+
MAX_HEADER_BYTES = 16_384
|
|
19
|
+
|
|
20
|
+
# Maximum time in seconds to read one incoming request header block.
|
|
21
|
+
# @return [Integer]
|
|
22
|
+
REQUEST_TIMEOUT = 5
|
|
23
|
+
|
|
24
|
+
# Literal IP ranges accepted for callback listeners.
|
|
25
|
+
# @return [Array<IPAddr>]
|
|
26
|
+
LOOPBACK_RANGES = [IPAddr.new("127.0.0.0/8"), IPAddr.new("::1/128")].freeze
|
|
27
|
+
|
|
28
|
+
#
|
|
29
|
+
# Raised when the authorization server returns an OAuth error to the loopback listener.
|
|
30
|
+
#
|
|
31
|
+
|
|
32
|
+
class CallbackError < StandardError
|
|
33
|
+
|
|
34
|
+
# OAuth error code returned in the callback.
|
|
35
|
+
# @return [String]
|
|
36
|
+
attr_reader :error_code
|
|
37
|
+
|
|
38
|
+
# Optional human-readable error description from the authorization server.
|
|
39
|
+
# @return [String, nil]
|
|
40
|
+
attr_reader :error_description
|
|
41
|
+
|
|
42
|
+
# Creates an error for an authorization server's rejected callback.
|
|
43
|
+
#
|
|
44
|
+
# @param error_code [String] callback `error` value
|
|
45
|
+
# @param error_description [String, nil] callback `error_description` value
|
|
46
|
+
#
|
|
47
|
+
def initialize(error_code, error_description)
|
|
48
|
+
@error_code = error_code
|
|
49
|
+
@error_description = error_description
|
|
50
|
+
|
|
51
|
+
super([error_code, error_description].compact.join(": "))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
#
|
|
56
|
+
# Signals an invalid callback HTTP request; the listener catches it and keeps waiting.
|
|
57
|
+
#
|
|
58
|
+
|
|
59
|
+
class InvalidRequest < StandardError
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Creates a one-shot loopback callback listener and blocks until login finishes.
|
|
63
|
+
#
|
|
64
|
+
# Open the authorization URL yourself while this call blocks. The callback block
|
|
65
|
+
# runs in the calling thread. Unrelated/malformed requests keep the listener alive.
|
|
66
|
+
# The listener closes after success, an OAuth error, a block exception, or interruption.
|
|
67
|
+
#
|
|
68
|
+
# @param metadata [Hash{String, Symbol => Object}] metadata whose first redirect URI selects host, port and path
|
|
69
|
+
# @param timeout [Numeric] positive maximum wait for incoming connections, in seconds; defaults to 300
|
|
70
|
+
# @param block [Proc] callback handler required to finish token exchange
|
|
71
|
+
#
|
|
72
|
+
# @yield [params] callback handler; any exception is propagated after socket cleanup
|
|
73
|
+
# @yieldparam params [Hash{Symbol => String}] `:code`, `:state`, and `:iss` from the query string
|
|
74
|
+
# @yieldreturn [Object] arbitrary result returned from `start`, including `nil` or `false`
|
|
75
|
+
# @return [Object] callback block result
|
|
76
|
+
#
|
|
77
|
+
# @raise [ArgumentError] if metadata/timeout is invalid or no block is supplied
|
|
78
|
+
# @raise [CallbackError] if the callback contains an OAuth `error` parameter
|
|
79
|
+
# @raise [Timeout::Error] if waiting for an incoming connection exceeds the deadline
|
|
80
|
+
# @raise [SystemCallError, IOError] if binding or accepting a connection fails
|
|
81
|
+
# @raise [Interrupt] when interrupted, for example with Ctrl-C
|
|
82
|
+
#
|
|
83
|
+
def self.start(metadata, timeout: 300, &block)
|
|
84
|
+
new(metadata).start(timeout: timeout, &block)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Selects the first redirect URI in metadata for a loopback HTTP listener.
|
|
88
|
+
#
|
|
89
|
+
# Accepts `localhost`, IPv4 loopback, or IPv6 loopback. The URI must have a valid
|
|
90
|
+
# port and no user information or fragment. Its path is used as the callback path.
|
|
91
|
+
#
|
|
92
|
+
# @param metadata [Hash{String, Symbol => Object}] metadata containing a nonempty `redirect_uris` array of strings
|
|
93
|
+
# @raise [ArgumentError] if the first redirect URI is missing, malformed or not a supported HTTP loopback URL
|
|
94
|
+
#
|
|
95
|
+
def initialize(metadata)
|
|
96
|
+
redirects = metadata['redirect_uris'] || metadata[:redirect_uris]
|
|
97
|
+
|
|
98
|
+
unless redirects.is_a?(Array) && redirects.first.is_a?(String)
|
|
99
|
+
raise ArgumentError, "metadata must contain a non-empty redirect_uris array"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@uri = URI(redirects.first)
|
|
103
|
+
|
|
104
|
+
unless @uri.is_a?(URI::HTTP) && @uri.scheme == "http" && loopback_host?(@uri.hostname)
|
|
105
|
+
raise ArgumentError, "The first redirect URI must be an HTTP loopback URL"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "The redirect URI should have a valid port" unless @uri.port.between?(1, 65_535)
|
|
109
|
+
raise ArgumentError, "The redirect URI should not include user info" unless !@uri.userinfo
|
|
110
|
+
raise ArgumentError, "The redirect URI should not include a URL fragment" unless !@uri.fragment
|
|
111
|
+
|
|
112
|
+
@path = (@uri.path.empty?) ? "/" : @uri.path
|
|
113
|
+
rescue URI::InvalidURIError
|
|
114
|
+
raise ArgumentError, "Invalid redirect URI"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Listens on the configured loopback URI until a callback completes.
|
|
118
|
+
#
|
|
119
|
+
# Open the authorization URL yourself while this call blocks. The callback block
|
|
120
|
+
# runs in the calling thread. Unrelated/malformed requests keep the listener alive.
|
|
121
|
+
# The listener closes after success, an OAuth error, a block exception, or interruption.
|
|
122
|
+
#
|
|
123
|
+
# @param timeout [Numeric] positive maximum wait for incoming connections, in seconds; defaults to 300
|
|
124
|
+
# @param block [Proc] callback handler required to finish token exchange
|
|
125
|
+
#
|
|
126
|
+
# @yield [params] callback handler; any exception is propagated after socket cleanup
|
|
127
|
+
# @yieldparam params [Hash{Symbol => String}] `:code`, `:state`, and `:iss` from the query string
|
|
128
|
+
# @yieldreturn [Object] arbitrary result returned from `start`, including `nil` or `false`
|
|
129
|
+
# @return [Object] callback block result
|
|
130
|
+
#
|
|
131
|
+
# @raise [ArgumentError] if metadata/timeout is invalid or no block is supplied
|
|
132
|
+
# @raise [CallbackError] if the callback contains an OAuth `error` parameter
|
|
133
|
+
# @raise [Timeout::Error] if waiting for an incoming connection exceeds the deadline
|
|
134
|
+
# @raise [SystemCallError, IOError] if binding or accepting a connection fails
|
|
135
|
+
# @raise [Interrupt] when interrupted, for example with Ctrl-C
|
|
136
|
+
#
|
|
137
|
+
def start(timeout: 300, &block)
|
|
138
|
+
raise ArgumentError, "A callback block is required" unless block_given?
|
|
139
|
+
raise ArgumentError, "timeout must be positive" unless timeout.is_a?(Numeric) && timeout.positive?
|
|
140
|
+
|
|
141
|
+
server = TCPServer.new(@uri.hostname, @uri.port)
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
puts "Waiting for OAuth callback at #{@uri} (Ctrl-C to cancel)"
|
|
145
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
146
|
+
|
|
147
|
+
loop do
|
|
148
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
149
|
+
|
|
150
|
+
unless remaining.positive? && IO.select([server], nil, nil, remaining)
|
|
151
|
+
raise Timeout::Error, "Timed out waiting for the OAuth callback"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
socket = server.accept
|
|
155
|
+
success, result = handle_connection(socket, &block)
|
|
156
|
+
|
|
157
|
+
return result if success
|
|
158
|
+
end
|
|
159
|
+
ensure
|
|
160
|
+
server.close
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def loopback_host?(host)
|
|
168
|
+
if host&.downcase == "localhost"
|
|
169
|
+
true
|
|
170
|
+
elsif host
|
|
171
|
+
ip = IPAddr.new(host)
|
|
172
|
+
LOOPBACK_RANGES.any? { |range| range.include?(ip) }
|
|
173
|
+
else
|
|
174
|
+
false
|
|
175
|
+
end
|
|
176
|
+
rescue IPAddr::InvalidAddressError
|
|
177
|
+
false
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def handle_connection(socket, &block)
|
|
181
|
+
params = handle_request(socket)
|
|
182
|
+
return [false, nil] unless params
|
|
183
|
+
|
|
184
|
+
begin
|
|
185
|
+
if params['error']
|
|
186
|
+
raise CallbackError.new(params['error'], params['error_description'])
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
callback_params = params.slice('code', 'state', 'iss').transform_keys(&:to_sym)
|
|
190
|
+
result = yield callback_params
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
respond(socket, 400, "Bad Request", "Authentication failed: #{e.inspect}")
|
|
193
|
+
raise
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
respond(socket, 200, "OK", "Authentication complete - you can close this tab.")
|
|
197
|
+
[true, result]
|
|
198
|
+
ensure
|
|
199
|
+
socket.close
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def handle_request(socket)
|
|
203
|
+
method, target = read_request(socket)
|
|
204
|
+
|
|
205
|
+
if method != 'GET'
|
|
206
|
+
respond(socket, 405, "Method Not Allowed", "Use GET for the OAuth callback.")
|
|
207
|
+
return
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
request_uri = URI(target)
|
|
211
|
+
|
|
212
|
+
unless target.start_with?('/') && !request_uri.host && !request_uri.fragment && request_uri.path == @path
|
|
213
|
+
respond(socket, 404, "Not Found", "Waiting for the OAuth callback.")
|
|
214
|
+
return
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
extract_callback_params(request_uri)
|
|
218
|
+
rescue InvalidRequest, URI::InvalidURIError, ArgumentError
|
|
219
|
+
respond(socket, 400, "Bad Request", "Invalid callback request.")
|
|
220
|
+
nil
|
|
221
|
+
rescue Timeout::Error
|
|
222
|
+
respond(socket, 408, "Request Timeout", "Request timed out.")
|
|
223
|
+
nil
|
|
224
|
+
rescue IOError, SystemCallError
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def read_request(socket)
|
|
229
|
+
Timeout.timeout(REQUEST_TIMEOUT) do
|
|
230
|
+
bytes = 0
|
|
231
|
+
lines = []
|
|
232
|
+
|
|
233
|
+
loop do
|
|
234
|
+
line = socket.gets("\n", MAX_HEADER_BYTES + 1)
|
|
235
|
+
raise InvalidRequest unless line
|
|
236
|
+
|
|
237
|
+
bytes += line.bytesize
|
|
238
|
+
raise InvalidRequest if bytes > MAX_HEADER_BYTES
|
|
239
|
+
break if line == "\r\n" || line == "\n"
|
|
240
|
+
|
|
241
|
+
lines << line
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
match = /\A([A-Z]+) (\S+) HTTP\/1\.[01]\r?\n\z/.match(lines.first.to_s)
|
|
245
|
+
raise InvalidRequest unless match
|
|
246
|
+
|
|
247
|
+
[match[1], match[2]]
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def extract_callback_params(uri)
|
|
252
|
+
pairs = URI.decode_www_form(uri.query.to_s)
|
|
253
|
+
raise InvalidRequest unless pairs.map(&:first).uniq.length == pairs.length
|
|
254
|
+
|
|
255
|
+
params = pairs.to_h
|
|
256
|
+
|
|
257
|
+
unless params['error'] || %w[code state iss].all? { |key| params[key] && !params[key].empty? }
|
|
258
|
+
raise InvalidRequest
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
params
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def respond(socket, status, reason, body)
|
|
265
|
+
socket.write("HTTP/1.1 #{status} #{reason}\r\n" +
|
|
266
|
+
"Content-Type: text/plain; charset=utf-8\r\n" +
|
|
267
|
+
"Content-Length: #{body.bytesize}\r\n" +
|
|
268
|
+
"Cache-Control: no-store\r\n" +
|
|
269
|
+
"Connection: close\r\n\r\n#{body}\n")
|
|
270
|
+
rescue IOError, SystemCallError
|
|
271
|
+
# A closed browser tab must not discard successful tokens or mask an error.
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'minisky'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
require_relative 'client'
|
|
9
|
+
|
|
10
|
+
#
|
|
11
|
+
# Experimental Minisky client backed by an AuthProto session.
|
|
12
|
+
# Requiring authproto alone does not load Minisky or this integration.
|
|
13
|
+
#
|
|
14
|
+
|
|
15
|
+
class Minisky::OAuthClient
|
|
16
|
+
include Minisky::Requests
|
|
17
|
+
|
|
18
|
+
# Maximum authenticated XRPC retries after nonce challenges.
|
|
19
|
+
# @return [Integer]
|
|
20
|
+
MAX_NONCE_RETRIES = 2
|
|
21
|
+
|
|
22
|
+
# PDS host or base URL used by Minisky.
|
|
23
|
+
# @return [String]
|
|
24
|
+
attr_reader :host
|
|
25
|
+
|
|
26
|
+
# OAuth client that owns the session, tokens and DPoP key.
|
|
27
|
+
# @return [AuthProto::Client]
|
|
28
|
+
attr_reader :oauth_client
|
|
29
|
+
|
|
30
|
+
# Most recent authorization/callback session ID; `nil` before authorization.
|
|
31
|
+
# @return [String, nil]
|
|
32
|
+
attr_reader :session_id
|
|
33
|
+
|
|
34
|
+
# Creates an experimental Minisky client backed by an OAuth session.
|
|
35
|
+
#
|
|
36
|
+
# Disables Minisky's app-password token management and enables authenticated
|
|
37
|
+
# requests by default. Configure AuthProto's global storage/transport separately.
|
|
38
|
+
#
|
|
39
|
+
# @param host [String] PDS host or base URL used for XRPC requests
|
|
40
|
+
# @param client_id [String] OAuth client metadata URL or localhost client ID
|
|
41
|
+
# @param redirect_uri [String] registered callback URI
|
|
42
|
+
# @param metadata [Hash{String => Object}, nil] preloaded metadata, or `nil` to fetch it
|
|
43
|
+
# @param dpop_key [Hash{String => Object}, nil] existing private DPoP JWK, or `nil` to generate one
|
|
44
|
+
#
|
|
45
|
+
# @raise [AuthProto::InvalidClientMetadata] if metadata is invalid or unavailable
|
|
46
|
+
# @raise [AuthProto::Error] if OAuth client or DPoP initialization fails
|
|
47
|
+
# @raise [AuthProto::HTTPClient::HTTPError, AuthProto::HTTPClient::SSRFError] if fetching metadata fails
|
|
48
|
+
# @see AuthProto::Client#initialize
|
|
49
|
+
#
|
|
50
|
+
def initialize(host, client_id:, redirect_uri:, metadata: nil, dpop_key: nil, session_id: nil)
|
|
51
|
+
@host = host
|
|
52
|
+
@session_id = session_id
|
|
53
|
+
|
|
54
|
+
@send_auth_headers = true
|
|
55
|
+
@auto_manage_tokens = false
|
|
56
|
+
|
|
57
|
+
@oauth_client = AuthProto::Client.new(
|
|
58
|
+
client_id: client_id,
|
|
59
|
+
redirect_uri: redirect_uri,
|
|
60
|
+
metadata: metadata,
|
|
61
|
+
dpop_key: dpop_key
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns no app-password credential configuration; OAuth owns this client's tokens.
|
|
66
|
+
# @return [nil]
|
|
67
|
+
def config
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Starts OAuth authorization and remembers the pending session ID.
|
|
72
|
+
#
|
|
73
|
+
# @param options [Hash{Symbol => Object}] options forwarded to {AuthProto::Client#authorize}
|
|
74
|
+
# @option options [String] :handle account handle, optionally prefixed with `@`
|
|
75
|
+
# @option options [String] :pds_url PDS URL, as an alternative to `handle`
|
|
76
|
+
# @option options [String] :scope ("atproto") requested scopes
|
|
77
|
+
#
|
|
78
|
+
# @return [Hash{Symbol => String}] `:url` to open and pending `:session_id`
|
|
79
|
+
# @raise [AuthProto::Error] if authorization, discovery, PAR or persistence fails
|
|
80
|
+
# @see AuthProto::Client#authorize
|
|
81
|
+
#
|
|
82
|
+
def authorize(**options)
|
|
83
|
+
oauth_client.authorize(**options).tap { |result| @session_id = result.fetch(:session_id) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Completes OAuth authorization and remembers the authenticated session ID.
|
|
87
|
+
#
|
|
88
|
+
# @param options [Hash{Symbol => String}] callback parameters forwarded to {AuthProto::Client#handle_callback}
|
|
89
|
+
# @option options [String] :code authorization code
|
|
90
|
+
# @option options [String] :state callback state token
|
|
91
|
+
# @option options [String] :iss authorization server issuer
|
|
92
|
+
#
|
|
93
|
+
# @return [Hash{Symbol => String, Integer, nil}] token and session details from the OAuth client
|
|
94
|
+
# @raise [AuthProto::Client::CallbackError] if state or issuer validation fails
|
|
95
|
+
# @raise [AuthProto::Client::TokenError] if token exchange or validation fails
|
|
96
|
+
# @raise [AuthProto::Error] if proofs, storage or serialization fail
|
|
97
|
+
# @see AuthProto::Client#handle_callback
|
|
98
|
+
#
|
|
99
|
+
def handle_callback(**options)
|
|
100
|
+
oauth_client.handle_callback(**options).tap { |result| @session_id = result.fetch(:session_id) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Sends an XRPC GET through Minisky, adding OAuth handling when authenticated.
|
|
104
|
+
#
|
|
105
|
+
# Authenticated requests check token expiry, generate a fresh DPoP proof for each
|
|
106
|
+
# attempt, retry at most {MAX_NONCE_RETRIES} nonce challenges, and refresh once on
|
|
107
|
+
# an `invalid_token` response. `auth: false` delegates to Minisky without OAuth.
|
|
108
|
+
#
|
|
109
|
+
# @param method [String, URI::HTTP] XRPC NSID or URL accepted by Minisky
|
|
110
|
+
# @param params [Hash, nil] query parameters encoded using `URI.encode_www_form`
|
|
111
|
+
# @param auth [Boolean] whether to authenticate; defaults to Minisky's auth mode
|
|
112
|
+
# @param headers [Hash{String => String}, nil] custom headers; OAuth authorization/proof headers are replaced
|
|
113
|
+
#
|
|
114
|
+
# @return [Hash, Array, String, Numeric, Boolean, nil] parsed JSON or raw response body returned by Minisky
|
|
115
|
+
# @raise [ArgumentError] if the method is invalid or `auth` is a truthy non-Boolean value
|
|
116
|
+
# @raise [Minisky::AuthError] if no completed OAuth login or refresh capability is available
|
|
117
|
+
# @raise [Minisky::BadResponse] if the final response has an error or redirect status
|
|
118
|
+
# @raise [AuthProto::DPoP::Client::Error] if proof handling fails or a nonce challenge lacks its nonce
|
|
119
|
+
# @raise [AuthProto::Error] if OAuth refresh or token persistence fails
|
|
120
|
+
# @raise [JSON::ParserError] if a JSON response cannot be parsed
|
|
121
|
+
# @raise [Net::OpenTimeout, Net::ReadTimeout, IOError, SystemCallError] if HTTP transport fails
|
|
122
|
+
#
|
|
123
|
+
def get_request(method, params = nil, auth: default_auth_mode, headers: nil)
|
|
124
|
+
return super unless auth
|
|
125
|
+
|
|
126
|
+
raise ArgumentError, "OAuth requests accept auth: true or false, not a bearer token" unless auth == true
|
|
127
|
+
|
|
128
|
+
url = build_request_uri(method).dup
|
|
129
|
+
url.query = URI.encode_www_form(params) if params && !params.empty?
|
|
130
|
+
|
|
131
|
+
response = oauth_request(Net::HTTP::Get, url, headers || {})
|
|
132
|
+
handle_response(response)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Sends an XRPC POST through Minisky, adding OAuth handling when authenticated.
|
|
136
|
+
#
|
|
137
|
+
# Authenticated requests check token expiry, generate a fresh DPoP proof for each
|
|
138
|
+
# attempt, retry at most {MAX_NONCE_RETRIES} nonce challenges, and refresh once on
|
|
139
|
+
# an `invalid_token` response. `auth: false` delegates to Minisky without OAuth.
|
|
140
|
+
# Non-string data is JSON-encoded, adding `Content-Type: application/json` unless
|
|
141
|
+
# a content type was supplied. Strings are transmitted unchanged.
|
|
142
|
+
#
|
|
143
|
+
# @param method [String, URI::HTTP] XRPC NSID or URL accepted by Minisky
|
|
144
|
+
# @param data [String, #to_json, nil] raw body bytes, JSON-encodable data, or no body
|
|
145
|
+
# @param params [Hash, nil] query parameters encoded using `URI.encode_www_form`
|
|
146
|
+
# @param auth [Boolean] whether to authenticate; defaults to Minisky's auth mode
|
|
147
|
+
# @param headers [Hash{String => String}, nil] custom headers; OAuth authorization/proof headers are replaced
|
|
148
|
+
#
|
|
149
|
+
# @return [Hash, Array, String, Numeric, Boolean, nil] parsed JSON or raw response body returned by Minisky
|
|
150
|
+
# @raise [ArgumentError] if the method is invalid or `auth` is a truthy non-Boolean value
|
|
151
|
+
# @raise [Minisky::AuthError] if no completed OAuth login or refresh capability is available
|
|
152
|
+
# @raise [Minisky::BadResponse] if the final response has an error or redirect status
|
|
153
|
+
# @raise [AuthProto::DPoP::Client::Error] if proof handling fails or a nonce challenge lacks its nonce
|
|
154
|
+
# @raise [AuthProto::Error] if OAuth refresh or token persistence fails
|
|
155
|
+
# @raise [JSON::ParserError] if a JSON response cannot be parsed
|
|
156
|
+
# @raise [Net::OpenTimeout, Net::ReadTimeout, IOError, SystemCallError] if HTTP transport fails
|
|
157
|
+
#
|
|
158
|
+
def post_request(method, data = nil, auth: default_auth_mode, headers: nil, params: nil)
|
|
159
|
+
return super unless auth
|
|
160
|
+
|
|
161
|
+
raise ArgumentError, "OAuth requests accept auth: true or false, not a bearer token" unless auth == true
|
|
162
|
+
|
|
163
|
+
url = build_request_uri(method).dup
|
|
164
|
+
url.query = URI.encode_www_form(params) if params && !params.empty?
|
|
165
|
+
headers = (headers || {}).dup
|
|
166
|
+
|
|
167
|
+
body = if data.is_a?(String) || data.nil?
|
|
168
|
+
data.to_s
|
|
169
|
+
else
|
|
170
|
+
unless headers.keys.any? { |key| key.to_s.downcase == "content-type" }
|
|
171
|
+
headers["Content-Type"] = "application/json"
|
|
172
|
+
end
|
|
173
|
+
data.to_json
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
response = oauth_request(Net::HTTP::Post, url, headers, body: body)
|
|
177
|
+
handle_response(response)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Rejects Minisky's app-password login entry point.
|
|
181
|
+
#
|
|
182
|
+
# Accepts and ignores any arguments. Use {#authorize} and {#handle_callback} for OAuth login.
|
|
183
|
+
#
|
|
184
|
+
# @return [void]
|
|
185
|
+
# @raise [NotImplementedError] always
|
|
186
|
+
#
|
|
187
|
+
def log_in(*)
|
|
188
|
+
raise NotImplementedError, "Use authorize and handle_callback for OAuth login"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Refreshes and persists tokens for the current session via the OAuth client.
|
|
192
|
+
#
|
|
193
|
+
# @return [Hash{Symbol => String, Integer, nil}] replacement tokens and session ID
|
|
194
|
+
# @raise [AuthProto::Client::TokenError] if the session cannot be refreshed
|
|
195
|
+
# @raise [AuthProto::Token::RefreshError] if refresh fails
|
|
196
|
+
# @raise [AuthProto::Storage::StorageError] if token persistence fails
|
|
197
|
+
#
|
|
198
|
+
def perform_token_refresh
|
|
199
|
+
oauth_client.refresh_token(session_id)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Checks the OAuth login and refreshes an expired access token if possible.
|
|
203
|
+
#
|
|
204
|
+
# @return [Symbol] `:ok` for a usable token, or `:refreshed` after successful refresh
|
|
205
|
+
# @raise [Minisky::AuthError] if login is incomplete or expired tokens cannot be refreshed
|
|
206
|
+
# @raise [AuthProto::Error] if token refresh, serialization or persistence fails
|
|
207
|
+
#
|
|
208
|
+
def check_access
|
|
209
|
+
session = oauth_client.session_manager.get_session(session_id) if session_id
|
|
210
|
+
raise Minisky::AuthError, "Complete OAuth login before making authenticated requests" unless session&.tokens
|
|
211
|
+
|
|
212
|
+
return :ok unless session.tokens.expired?
|
|
213
|
+
|
|
214
|
+
raise Minisky::AuthError, "OAuth session cannot be refreshed; log in again" unless session.renewable?
|
|
215
|
+
|
|
216
|
+
perform_token_refresh
|
|
217
|
+
:refreshed
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
def oauth_request(request_class, url, headers, body: nil)
|
|
224
|
+
check_access
|
|
225
|
+
|
|
226
|
+
nonce_retries = 0
|
|
227
|
+
refreshed = false
|
|
228
|
+
|
|
229
|
+
proof_url = url.dup
|
|
230
|
+
proof_url.query = nil
|
|
231
|
+
proof_url.fragment = nil
|
|
232
|
+
|
|
233
|
+
loop do
|
|
234
|
+
# Fresh proof for every attempt; callers cannot override OAuth auth headers.
|
|
235
|
+
request_headers = headers.reject { |k, v| %w[authorization dpop].include?(k.to_s.downcase) }
|
|
236
|
+
request_headers.merge!(oauth_client.auth_headers(
|
|
237
|
+
session_id: session_id, method: request_class::METHOD, url: proof_url.to_s
|
|
238
|
+
))
|
|
239
|
+
|
|
240
|
+
request = request_class.new(url, request_headers)
|
|
241
|
+
request.body = body unless body.nil?
|
|
242
|
+
response = make_request(request)
|
|
243
|
+
|
|
244
|
+
nonce = response["DPoP-Nonce"]
|
|
245
|
+
oauth_client.dpop_client.process_response(response.each_header.to_h, url.to_s) if nonce && !nonce.empty?
|
|
246
|
+
error = authentication_error(response)
|
|
247
|
+
|
|
248
|
+
if response.code.to_i == 401 && error == "use_dpop_nonce"
|
|
249
|
+
unless nonce && !nonce.empty?
|
|
250
|
+
raise AuthProto::DPoP::Client::Error, "DPoP nonce challenge (HTTP 401) is missing DPoP-Nonce"
|
|
251
|
+
end
|
|
252
|
+
return response if nonce_retries >= MAX_NONCE_RETRIES
|
|
253
|
+
|
|
254
|
+
nonce_retries += 1
|
|
255
|
+
elsif response.code.to_i == 401 && error == "invalid_token" && !refreshed
|
|
256
|
+
perform_token_refresh
|
|
257
|
+
refreshed = true
|
|
258
|
+
else
|
|
259
|
+
return response
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def authentication_error(response)
|
|
265
|
+
# Only interpret a single DPoP challenge. Leave other auth schemes and
|
|
266
|
+
# ambiguous multi-challenge headers to Minisky's normal error handling.
|
|
267
|
+
challenge = response["WWW-Authenticate"].to_s
|
|
268
|
+
return unless challenge.match?(/\ADPoP\s/i)
|
|
269
|
+
return if challenge.match?(/,\s*(?:Bearer|Basic|DPoP)\b/i)
|
|
270
|
+
|
|
271
|
+
challenge[/\berror\s*=\s*"(use_dpop_nonce|invalid_token)"/i, 1]
|
|
272
|
+
end
|
|
273
|
+
end
|