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.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +31 -0
  4. data/.tm_properties +1 -0
  5. data/.yardopts +4 -0
  6. data/CHANGELOG.md +52 -0
  7. data/MIT-LICENSE.txt +21 -0
  8. data/README.md +304 -0
  9. data/Rakefile +8 -0
  10. data/SECURITY.md +16 -0
  11. data/examples/confidential_client/.gitignore +5 -0
  12. data/examples/confidential_client/Gemfile +14 -0
  13. data/examples/confidential_client/Gemfile.lock +88 -0
  14. data/examples/confidential_client/README.md +226 -0
  15. data/examples/confidential_client/app.rb +290 -0
  16. data/examples/confidential_client/config/client-metadata.example.json +25 -0
  17. data/examples/confidential_client/config.ru +4 -0
  18. data/examples/confidential_client/public/styles.css +70 -0
  19. data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
  20. data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
  21. data/examples/confidential_client/scripts/generate_keys.rb +15 -0
  22. data/examples/confidential_client/views/authorized.erb +46 -0
  23. data/examples/confidential_client/views/index.erb +44 -0
  24. data/examples/confidential_client/views/layout.erb +11 -0
  25. data/examples/minisky_console/.gitignore +2 -0
  26. data/examples/minisky_console/Gemfile +12 -0
  27. data/examples/minisky_console/Gemfile.lock +131 -0
  28. data/examples/minisky_console/README.md +112 -0
  29. data/examples/minisky_console/app.rb +50 -0
  30. data/examples/minisky_console/bin/console +6 -0
  31. data/examples/minisky_console/config.ru +5 -0
  32. data/examples/minisky_console/localhost.rb +53 -0
  33. data/examples/minisky_console/production.rb +32 -0
  34. data/lib/authproto/client.rb +583 -0
  35. data/lib/authproto/client_metadata.rb +478 -0
  36. data/lib/authproto/configuration.rb +139 -0
  37. data/lib/authproto/dpop/client.rb +161 -0
  38. data/lib/authproto/dpop/key_manager.rb +223 -0
  39. data/lib/authproto/dpop/nonce_manager.rb +145 -0
  40. data/lib/authproto/dpop/proof_generator.rb +124 -0
  41. data/lib/authproto/dpop/stored_nonce.rb +37 -0
  42. data/lib/authproto/encryption.rb +250 -0
  43. data/lib/authproto/errors.rb +75 -0
  44. data/lib/authproto/http_client.rb +320 -0
  45. data/lib/authproto/insecure_http_client.rb +24 -0
  46. data/lib/authproto/localhost_server.rb +274 -0
  47. data/lib/authproto/minisky.rb +273 -0
  48. data/lib/authproto/par/client.rb +240 -0
  49. data/lib/authproto/par/client_assertion.rb +69 -0
  50. data/lib/authproto/par/request.rb +243 -0
  51. data/lib/authproto/par/response.rb +43 -0
  52. data/lib/authproto/par.rb +48 -0
  53. data/lib/authproto/pkce.rb +125 -0
  54. data/lib/authproto/serialization/base.rb +258 -0
  55. data/lib/authproto/serialization/dpop_key.rb +38 -0
  56. data/lib/authproto/serialization/session.rb +88 -0
  57. data/lib/authproto/serialization/stored_nonce.rb +46 -0
  58. data/lib/authproto/serialization/token_set.rb +54 -0
  59. data/lib/authproto/server_metadata/authorization_server.rb +267 -0
  60. data/lib/authproto/server_metadata/origin_url.rb +71 -0
  61. data/lib/authproto/server_metadata/resource_server.rb +98 -0
  62. data/lib/authproto/server_metadata.rb +28 -0
  63. data/lib/authproto/state/session.rb +168 -0
  64. data/lib/authproto/state/session_manager.rb +162 -0
  65. data/lib/authproto/state/token_set.rb +101 -0
  66. data/lib/authproto/state.rb +61 -0
  67. data/lib/authproto/storage/interface.rb +121 -0
  68. data/lib/authproto/storage/key_builder.rb +76 -0
  69. data/lib/authproto/storage/memory.rb +222 -0
  70. data/lib/authproto/storage/redis.rb +156 -0
  71. data/lib/authproto/storage/sqlite.rb +281 -0
  72. data/lib/authproto/token/refresh.rb +414 -0
  73. data/lib/authproto/version.rb +8 -0
  74. data/lib/authproto.rb +18 -0
  75. data/sig/authproto/client.rbs +43 -0
  76. data/sig/authproto/client_metadata.rbs +113 -0
  77. data/sig/authproto/dependencies.rbs +19 -0
  78. data/sig/authproto/dpop/client.rbs +23 -0
  79. data/sig/authproto/dpop/key_manager.rbs +26 -0
  80. data/sig/authproto/dpop/nonce_manager.rbs +23 -0
  81. data/sig/authproto/dpop/proof_generator.rbs +40 -0
  82. data/sig/authproto/dpop/stored_nonce.rbs +10 -0
  83. data/sig/authproto/encryption.rbs +42 -0
  84. data/sig/authproto/http_client.rbs +45 -0
  85. data/sig/authproto/insecure_http_client.rbs +8 -0
  86. data/sig/authproto/localhost_server.rbs +30 -0
  87. data/sig/authproto/minisky_oauth.rbs +22 -0
  88. data/sig/authproto/par/client.rbs +25 -0
  89. data/sig/authproto/par/client_assertion.rbs +10 -0
  90. data/sig/authproto/par/request.rbs +73 -0
  91. data/sig/authproto/par/response.rbs +17 -0
  92. data/sig/authproto/par.rbs +7 -0
  93. data/sig/authproto/pkce.rbs +24 -0
  94. data/sig/authproto/serialization/base.rbs +36 -0
  95. data/sig/authproto/serialization/dpop_key.rbs +14 -0
  96. data/sig/authproto/serialization/session.rbs +19 -0
  97. data/sig/authproto/serialization/stored_nonce.rbs +14 -0
  98. data/sig/authproto/serialization/token_set.rbs +14 -0
  99. data/sig/authproto/server_metadata/authorization_server.rbs +71 -0
  100. data/sig/authproto/server_metadata/origin_url.rbs +21 -0
  101. data/sig/authproto/server_metadata/resource_server.rbs +27 -0
  102. data/sig/authproto/state/session.rbs +28 -0
  103. data/sig/authproto/state/session_manager.rbs +15 -0
  104. data/sig/authproto/state/token_set.rbs +40 -0
  105. data/sig/authproto/state.rbs +6 -0
  106. data/sig/authproto/storage/interface.rbs +20 -0
  107. data/sig/authproto/storage/key_builder.rbs +16 -0
  108. data/sig/authproto/storage/memory.rbs +22 -0
  109. data/sig/authproto/storage/redis.rbs +19 -0
  110. data/sig/authproto/storage/redis_client.rbs +11 -0
  111. data/sig/authproto/storage/sqlite.rbs +26 -0
  112. data/sig/authproto/token/refresh.rbs +47 -0
  113. data/sig/authproto/version.rbs +3 -0
  114. data/sig/authproto.rbs +54 -0
  115. 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