diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index af6ddafbb..21817c661 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -477,12 +477,16 @@ pub fn BrowserContext(comptime CDP_T: type) type { self.cdp.browser.notification.unregister(.http_response_header_done, self); } - pub fn fetchEnable(self: *Self) !void { + pub fn fetchEnable(self: *Self, authRequests: bool) !void { try self.cdp.browser.notification.register(.http_request_intercept, self, onHttpRequestIntercept); + if (authRequests) { + try self.cdp.browser.notification.register(.http_request_auth_required, self, onHttpRequestAuthRequired); + } } pub fn fetchDisable(self: *Self) void { self.cdp.browser.notification.unregister(.http_request_intercept, self); + self.cdp.browser.notification.unregister(.http_request_auth_required, self); } pub fn onPageRemove(ctx: *anyopaque, _: Notification.PageRemove) !void { @@ -548,6 +552,12 @@ pub fn BrowserContext(comptime CDP_T: type) type { try gop.value_ptr.appendSlice(arena, try arena.dupe(u8, msg.data)); } + pub fn onHttpRequestAuthRequired(ctx: *anyopaque, data: *const Notification.RequestAuthRequired) !void { + const self: *Self = @alignCast(@ptrCast(ctx)); + defer self.resetNotificationArena(); + try @import("domains/fetch.zig").requestAuthRequired(self.notification_arena, self, data); + } + fn resetNotificationArena(self: *Self) void { defer _ = self.cdp.notification_arena.reset(.{ .retain_with_limit = 1024 * 64 }); } diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig index 9422e1780..ca4258bd7 100644 --- a/src/cdp/domains/fetch.zig +++ b/src/cdp/domains/fetch.zig @@ -32,12 +32,14 @@ pub fn processMessage(cmd: anytype) !void { continueRequest, failRequest, fulfillRequest, + continueWithAuth, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .disable => return disable(cmd), .enable => return enable(cmd), .continueRequest => return continueRequest(cmd), + .continueWithAuth => return continueWithAuth(cmd), .failRequest => return failRequest(cmd), .fulfillRequest => return fulfillRequest(cmd), } @@ -144,12 +146,8 @@ fn enable(cmd: anytype) !void { return cmd.sendResult(null, .{}); } - if (params.handleAuthRequests) { - log.warn(.cdp, "not implemented", .{ .feature = "Fetch.enable handleAuthRequests is not supported yet" }); - } - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - try bc.fetchEnable(); + try bc.fetchEnable(params.handleAuthRequests); return cmd.sendResult(null, .{}); } @@ -276,6 +274,63 @@ fn continueRequest(cmd: anytype) !void { return cmd.sendResult(null, .{}); } +// https://chromedevtools.github.io/devtools-protocol/tot/Fetch/#type-AuthChallengeResponse +const AuthChallengeResponse = enum { + Default, + CancelAuth, + ProvideCredentials, +}; + +fn continueWithAuth(cmd: anytype) !void { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const params = (try cmd.params(struct { + requestId: []const u8, // "INTERCEPT-{d}" + authChallengeResponse: struct { + response: AuthChallengeResponse, + username: []const u8 = "", + password: []const u8 = "", + }, + })) orelse return error.InvalidParams; + + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + var intercept_state = &bc.intercept_state; + const request_id = try idFromRequestId(params.requestId); + const transfer = intercept_state.remove(request_id) orelse return error.RequestNotFound; + + log.debug(.cdp, "request intercept", .{ + .state = "continue with auth", + .id = transfer.id, + .response = params.authChallengeResponse.response, + }); + + if (params.authChallengeResponse.response != .ProvideCredentials) { + transfer.abortAuthChallenge(); + return cmd.sendResult(null, .{}); + } + + // cancel the request, deinit the transfer on error. + errdefer transfer.abortAuthChallenge(); + + // restart the request with the provided credentials. + const arena = transfer.arena.allocator(); + transfer.updateCredentials( + try std.fmt.allocPrintZ(arena, "{s}:{s}", .{ + params.authChallengeResponse.username, + params.authChallengeResponse.password, + }), + ); + + transfer.reset(); + try bc.cdp.browser.http_client.process(transfer); + + if (intercept_state.empty()) { + page.request_intercepted = false; + } + + return cmd.sendResult(null, .{}); +} + fn fulfillRequest(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; @@ -346,6 +401,50 @@ fn failRequest(cmd: anytype) !void { return cmd.sendResult(null, .{}); } +pub fn requestAuthRequired(arena: Allocator, bc: anytype, intercept: *const Notification.RequestAuthRequired) !void { + // unreachable because we _have_ to have a page. + const session_id = bc.session_id orelse unreachable; + const target_id = bc.target_id orelse unreachable; + const page = bc.session.currentPage() orelse unreachable; + + // We keep it around to wait for modifications to the request. + // NOTE: we assume whomever created the request created it with a lifetime of the Page. + // TODO: What to do when receiving replies for a previous page's requests? + + const transfer = intercept.transfer; + try bc.intercept_state.put(transfer); + + const challenge = transfer._auth_challenge orelse return error.NullAuthChallenge; + + try bc.cdp.sendEvent("Fetch.authRequired", .{ + .requestId = try std.fmt.allocPrint(arena, "INTERCEPT-{d}", .{transfer.id}), + .request = network.TransferAsRequestWriter.init(transfer), + .frameId = target_id, + .resourceType = switch (transfer.req.resource_type) { + .script => "Script", + .xhr => "XHR", + .document => "Document", + }, + .authChallenge = .{ + .source = if (challenge.source == .server) "Server" else "Proxy", + .origin = "", // TODO get origin, could be the proxy address for example. + .scheme = if (challenge.scheme == .digest) "digest" else "basic", + .realm = challenge.realm, + }, + .networkId = try std.fmt.allocPrint(arena, "REQ-{d}", .{transfer.id}), + }, .{ .session_id = session_id }); + + log.debug(.cdp, "request auth required", .{ + .state = "paused", + .id = transfer.id, + .url = transfer.uri, + }); + // Await continueWithAuth + + intercept.wait_for_interception.* = true; + page.request_intercepted = true; +} + // Get u64 from requestId which is formatted as: "INTERCEPT-{d}" fn idFromRequestId(request_id: []const u8) !u64 { if (!std.mem.startsWith(u8, request_id, "INTERCEPT-")) { diff --git a/src/http/Client.zig b/src/http/Client.zig index 6ea567403..6ef1da978 100644 --- a/src/http/Client.zig +++ b/src/http/Client.zig @@ -325,6 +325,11 @@ fn makeRequest(self: *Client, handle: *Handle, transfer: *Transfer) !void { } try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PRIVATE, transfer)); + + // add credentials + if (req.credentials) |creds| { + try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PROXYUSERPWD, creds.ptr)); + } } // Once soon as this is called, our "perform" loop is responsible for @@ -365,6 +370,22 @@ fn perform(self: *Client, timeout_ms: c_int) !void { const easy = msg.easy_handle.?; const transfer = try Transfer.fromEasy(easy); + // In case of auth challenge + if (transfer._auth_challenge != null and transfer._tries < 10) { // TODO give a way to configure the number of auth retries. + if (transfer.client.notification) |notification| { + var wait_for_interception = false; + notification.dispatch(.http_request_auth_required, &.{ .transfer = transfer, .wait_for_interception = &wait_for_interception }); + if (wait_for_interception) { + // the request is put on hold to be intercepted. + // In this case we ignore callbacks for now. + // Note: we don't deinit transfer on purpose: we want to keep + // using it for the following request. + self.endTransfer(transfer); + continue; + } + } + } + // release it ASAP so that it's available; some done_callbacks // will load more resources. self.endTransfer(transfer); @@ -542,6 +563,7 @@ pub const Request = struct { body: ?[]const u8 = null, cookie_jar: *CookieJar, resource_type: ResourceType, + credentials: ?[:0]const u8 = null, // arbitrary data that can be associated with this request ctx: *anyopaque = undefined, @@ -559,6 +581,46 @@ pub const Request = struct { }; }; +pub const AuthChallenge = struct { + status: u16, + source: enum { server, proxy }, + scheme: enum { basic, digest }, + realm: []const u8, + + pub fn parse(status: u16, header: []const u8) !AuthChallenge { + var ac: AuthChallenge = .{ + .status = status, + .source = undefined, + .realm = "TODO", // TODO parser and set realm + .scheme = undefined, + }; + + const sep = std.mem.indexOfPos(u8, header, 0, ": ") orelse return error.InvalidHeader; + const hname = header[0..sep]; + const hvalue = header[sep + 2 ..]; + + if (std.ascii.eqlIgnoreCase("WWW-Authenticate", hname)) { + ac.source = .server; + } else if (std.ascii.eqlIgnoreCase("Proxy-Authenticate", hname)) { + ac.source = .proxy; + } else { + return error.InvalidAuthChallenge; + } + + const pos = std.mem.indexOfPos(u8, std.mem.trim(u8, hvalue, std.ascii.whitespace[0..]), 0, " ") orelse hvalue.len; + const _scheme = hvalue[0..pos]; + if (std.ascii.eqlIgnoreCase(_scheme, "basic")) { + ac.scheme = .basic; + } else if (std.ascii.eqlIgnoreCase(_scheme, "digest")) { + ac.scheme = .digest; + } else { + return error.UnknownAuthChallengeScheme; + } + + return ac; + } +}; + pub const Transfer = struct { arena: ArenaAllocator, id: usize = 0, @@ -571,7 +633,6 @@ pub const Transfer = struct { bytes_received: usize = 0, // We'll store the response header here - proxy_response_header: ?ResponseHeader = null, response_header: ?ResponseHeader = null, // track if the header callbacks done have been called. @@ -582,7 +643,22 @@ pub const Transfer = struct { _handle: ?*Handle = null, _redirecting: bool = false, - _forbidden: bool = false, + _auth_challenge: ?AuthChallenge = null, + + // number of times the transfer has been tried. + // incremented by reset func. + _tries: u8 = 0, + + pub fn reset(self: *Transfer) void { + self._redirecting = false; + self._auth_challenge = null; + self._notified_fail = false; + self._header_done_called = false; + self.response_header = null; + self.bytes_received = 0; + + self._tries += 1; + } fn deinit(self: *Transfer) void { self.req.headers.deinit(); @@ -600,7 +676,11 @@ pub const Transfer = struct { try errorCheck(c.curl_easy_getinfo(easy, c.CURLINFO_EFFECTIVE_URL, &url)); var status: c_long = undefined; - try errorCheck(c.curl_easy_getinfo(easy, c.CURLINFO_RESPONSE_CODE, &status)); + if (self._auth_challenge) |_| { + status = 407; + } else { + try errorCheck(c.curl_easy_getinfo(easy, c.CURLINFO_RESPONSE_CODE, &status)); + } self.response_header = .{ .url = url, @@ -633,6 +713,10 @@ pub const Transfer = struct { self.req.url = url; } + pub fn updateCredentials(self: *Transfer, userpwd: [:0]const u8) void { + self.req.credentials = userpwd; + } + pub fn replaceRequestHeaders(self: *Transfer, allocator: Allocator, headers: []const Http.Header) !void { self.req.headers.deinit(); @@ -657,6 +741,14 @@ pub const Transfer = struct { self.deinit(); } + // abortAuthChallenge is called when an auth chanllenge interception is + // abort. We don't call self.client.endTransfer here b/c it has been done + // before interception process. + pub fn abortAuthChallenge(self: *Transfer) void { + self.client.requestFailed(self, error.AbortAuthChallenge); + self.deinit(); + } + // redirectionCookies manages cookies during redirections handled by Curl. // It sets the cookies from the current response to the cookie jar. // It also immediately sets cookies for the following request. @@ -782,20 +874,44 @@ pub const Transfer = struct { transfer._redirecting = false; if (status == 401 or status == 407) { - transfer._forbidden = true; + // The auth challenge must be parsed from a following + // WWW-Authenticate or Proxy-Authenticate header. + transfer._auth_challenge = .{ + .status = status, + .source = undefined, + .scheme = undefined, + .realm = undefined, + }; return buf_len; } - transfer._forbidden = false; + transfer._auth_challenge = null; transfer.bytes_received = buf_len; return buf_len; } - if (transfer._redirecting == false and transfer._forbidden == false) { + if (transfer._redirecting == false and transfer._auth_challenge != null) { transfer.bytes_received += buf_len; } if (buf_len != 2) { + if (transfer._auth_challenge != null) { + // try to parse auth challenge. + if (std.ascii.startsWithIgnoreCase(header, "WWW-Authenticate") or + std.ascii.startsWithIgnoreCase(header, "Proxy-Authenticate")) + { + const ac = AuthChallenge.parse( + transfer._auth_challenge.?.status, + header, + ) catch |err| { + // We can't parse the auth challenge + log.err(.http, "parse auth challenge", .{ .err = err, .header = header }); + // Should we cancel the request? I don't think so. + return buf_len; + }; + transfer._auth_challenge = ac; + } + } return buf_len; } @@ -823,7 +939,7 @@ pub const Transfer = struct { return c.CURL_WRITEFUNC_ERROR; }; - if (transfer._redirecting) { + if (transfer._redirecting or transfer._auth_challenge != null) { return chunk_len; } diff --git a/src/notification.zig b/src/notification.zig index 43707e40c..392a57e76 100644 --- a/src/notification.zig +++ b/src/notification.zig @@ -64,6 +64,7 @@ pub const Notification = struct { http_request_start: List = .{}, http_request_intercept: List = .{}, http_request_done: List = .{}, + http_request_auth_required: List = .{}, http_response_data: List = .{}, http_response_header_done: List = .{}, notification_created: List = .{}, @@ -77,6 +78,7 @@ pub const Notification = struct { http_request_fail: *const RequestFail, http_request_start: *const RequestStart, http_request_intercept: *const RequestIntercept, + http_request_auth_required: *const RequestAuthRequired, http_request_done: *const RequestDone, http_response_data: *const ResponseData, http_response_header_done: *const ResponseHeaderDone, @@ -106,6 +108,11 @@ pub const Notification = struct { wait_for_interception: *bool, }; + pub const RequestAuthRequired = struct { + transfer: *Transfer, + wait_for_interception: *bool, + }; + pub const ResponseData = struct { data: []const u8, transfer: *Transfer,