Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355//! Signing in to Google as an installed app: OAuth 2.0 with PKCE and a//! loopback redirect, as//! https://developers.google.com/identity/protocols/oauth2/native-app//! describes. The browser sends the user to Google, Google sends them//! back to a listener on 127.0.0.1, and the code it brings is traded for//! a refresh token, which later trades for short-lived access tokens.//!//! Signing in waits on the network, on the command line; refreshing//! does not, in the app: refreshRequest and readToken let Http send it on//! the event loop.
const std = @import("std");const Http = @import("Http.zig");const ascii = std.ascii;const base64 = std.base64;const json = std.json;const mem = std.mem;const testing = std.testing;const Allocator = std.mem.Allocator;const Environ = std.process.Environ;const Io = std.Io;const Sha256 = std.crypto.hash.sha2.Sha256;const Uri = std.Uri;const Writer = Io.Writer;const log = std.log.scoped(.oauth);
/// Read and change mail and labels; not delete for good, not settings.pub const scope = "https://www.googleapis.com/auth/gmail.modify";
/// Where sign-in happens. Tests point these at a local server.pub const Endpoints = struct { auth: []const u8, token: []const u8,
pub const google: Endpoints = .{ .auth = "https://accounts.google.com/o/oauth2/v2/auth", .token = "https://oauth2.googleapis.com/token", };};
/// The app's OAuth client: a Desktop app client from the Google Cloud/// console. Its secret is not secret, as Google says of installed apps;/// PKCE is what protects the code.pub const Client = struct { id: []const u8, secret: ?[]const u8,
/// Where the client file goes: the JSON the console downloads. pub const file_name = "google-client.json";
/// Reads $XDG_CONFIG_HOME/parcel/google-client.json, or /// ~/.config/parcel/google-client.json. Null when there is none. pub fn load(arena: Allocator, io: Io, environ: *const Environ.Map) !?Client { const path = if (environ.get("XDG_CONFIG_HOME")) |config| try Io.Dir.path.join(arena, &.{ config, "parcel", file_name }) else if (environ.get("HOME")) |home| try Io.Dir.path.join(arena, &.{ home, ".config", "parcel", file_name }) else return null; const bytes = Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024)) catch |err| switch (err) { error.FileNotFound => return null, else => |e| { log.err("{s}: {t}", .{ path, e }); return e; }, }; return parse(arena, bytes) catch |err| { log.err("{s}: not a Desktop app client file ({t})", .{ path, err }); return err; }; }
/// Parses the console's file: the client sits under "installed". pub fn parse(arena: Allocator, bytes: []const u8) !Client { const File = struct { installed: struct { client_id: []const u8, client_secret: ?[]const u8 = null }, }; const file = try json.parseFromSliceLeaky(File, arena, bytes, .{ .ignore_unknown_fields = true }); return .{ .id = file.installed.client_id, .secret = file.installed.client_secret }; }};
/// What the token endpoint hands back.pub const Token = struct { access: []const u8, /// Only from signing in; a refresh keeps the old one. refresh: ?[]const u8, /// Seconds the access token lasts. expires_in: u32,};
/// Shows the user the sign-in page, given its URL.pub const Opener = *const fn (io: Io, url: []const u8) void;
/// Signs in: listens on a loopback port, has `open` show Google's page,/// waits for the browser to come back with a code, and trades the code/// for tokens. Returns them, the refresh token included.pub fn login(arena: Allocator, io: Io, client: Client, endpoints: Endpoints, open: Opener) !Token { var address: Io.net.IpAddress = .{ .ip4 = .loopback(0) }; var server = try address.listen(io, .{}); defer server.deinit(io); const redirect = try arena.print("http://127.0.0.1:{d}", .{server.socket.address.getPort()});
var verifier: [43]u8 = undefined; randomUrlSafe(io, &verifier); var state: [22]u8 = undefined; randomUrlSafe(io, &state); defer std.crypto.secureZero(u8, &verifier);
const attempt: Attempt = .{ .endpoints = endpoints, .client = client, .redirect = redirect, .verifier = &verifier, .state = &state, }; open(io, try authUrl(arena, attempt)); return awaitRedirect(arena, io, &server, attempt);}
/// One try at signing in.const Attempt = struct { endpoints: Endpoints, client: Client, /// The loopback URL Google sends the browser back to. redirect: []const u8, /// PKCE's secret: Google sees its hash first and the verifier itself /// only with the code. verifier: []const u8, /// Ties the redirect to this attempt. state: []const u8,};
/// The request that trades a refresh token for a fresh access token;/// readToken reads its response.pub fn refreshRequest(arena: Allocator, endpoints: Endpoints, client: Client, refresh_token: []const u8) !Http.Request { return tokenRequest(arena, endpoints, &.{ .{ "grant_type", "refresh_token" }, .{ "refresh_token", refresh_token }, .{ "client_id", client.id }, .{ "client_secret", client.secret orelse "" }, });}
/// Google's sign-in page for this attempt. The challenge is the/// verifier's SHA-256, so only whoever holds the verifier can use the code.fn authUrl(arena: Allocator, attempt: Attempt) ![]const u8 { var digest: [Sha256.digest_length]u8 = undefined; Sha256.hash(attempt.verifier, &digest, .{}); var challenge: [base64.url_safe_no_pad.Encoder.calcSize(Sha256.digest_length)]u8 = undefined; _ = base64.url_safe_no_pad.Encoder.encode(&challenge, &digest);
var out: Writer.Allocating = .init(arena); try out.writer.print("{s}?", .{attempt.endpoints.auth}); try form(&out.writer, &.{ .{ "client_id", attempt.client.id }, .{ "redirect_uri", attempt.redirect }, .{ "response_type", "code" }, .{ "scope", scope }, .{ "code_challenge", &challenge }, .{ "code_challenge_method", "S256" }, .{ "state", attempt.state }, }); return out.written();}
/// Serves the loopback port until the browser arrives with this/// attempt's state, trades its code for tokens, and tells the browser/// how that went. Anything else, such as a favicon request, gets a 404/// and the wait goes on.fn awaitRedirect(arena: Allocator, io: Io, server: *Io.net.Server, attempt: Attempt) !Token { while (true) { const stream = try server.accept(io); defer stream.close(io); var read_buf: [8192]u8 = undefined; var write_buf: [1024]u8 = undefined; var reader = stream.reader(io, &read_buf); var writer = stream.writer(io, &write_buf); var http: std.http.Server = .init(&reader.interface, &writer.interface); var request = http.receiveHead() catch continue; const redirect = parseRedirect(arena, request.head.target) catch |err| { notFound(&request); if (err == error.NotRedirect) continue; return err; }; if (!mem.eql(u8, redirect.state, attempt.state)) { notFound(&request); continue; } const token = if (redirect.code) |code| exchange(arena, attempt.endpoints, &.{ .{ "grant_type", "authorization_code" }, .{ "code", code }, .{ "code_verifier", attempt.verifier }, .{ "redirect_uri", attempt.redirect }, .{ "client_id", attempt.client.id }, .{ "client_secret", attempt.client.secret orelse "" }, }) else refused: { log.err("Google refused sign-in: {s}", .{redirect.err orelse "no reason given"}); break :refused error.SignInRefused; }; const page = if (token) |_| page_signed_in else |_| page_refused; request.respond(page, .{ .keep_alive = false, .extra_headers = &.{.{ .name = "content-type", .value = "text/html; charset=utf-8" }}, }) catch |err| log.warn("answering the browser: {t}", .{err}); return token; }}
fn notFound(request: *std.http.Server.Request) void { request.respond("Not found\n", .{ .status = .not_found, .keep_alive = false }) catch |err| log.debug("answering the browser: {t}", .{err});}
const page_signed_in = \\<!doctype html><title>Parcel</title> \\<p style="font: 16px sans-serif; margin: 4em auto; width: 24em">Signed in. You can close this tab.</p>;const page_refused = \\<!doctype html><title>Parcel</title> \\<p style="font: 16px sans-serif; margin: 4em auto; width: 24em">Sign-in did not finish. See the terminal.</p>;
const Redirect = struct { state: []const u8, code: ?[]const u8, err: ?[]const u8 };
/// Reads the query Google redirects with: `/?state=…&code=…`, or/// `error=…` in place of the code.fn parseRedirect(arena: Allocator, target: []const u8) !Redirect { const query_start = mem.findScalar(u8, target, '?') orelse return error.NotRedirect; if (!mem.eql(u8, target[0..query_start], "/")) return error.NotRedirect; var redirect: Redirect = .{ .state = "", .code = null, .err = null }; var pairs = mem.splitScalar(u8, target[query_start + 1 ..], '&'); while (pairs.next()) |pair| { const eq = mem.findScalar(u8, pair, '=') orelse continue; const value = Uri.percentDecodeInPlace(try arena.dupe(u8, pair[eq + 1 ..])); const key = pair[0..eq]; if (mem.eql(u8, key, "state")) redirect.state = value; if (mem.eql(u8, key, "code")) redirect.code = value; if (mem.eql(u8, key, "error")) redirect.err = value; } if (redirect.state.len == 0) return error.NotRedirect; return redirect;}
/// POSTs a form to the token endpoint and waits for the tokens.fn exchange(arena: Allocator, endpoints: Endpoints, fields: []const [2][]const u8) !Token { const request = try tokenRequest(arena, endpoints, fields); defer std.crypto.secureZero(u8, @constCast(request.body.?)); return readToken(arena, try Http.fetch(arena, request));}
/// A form POSTed to the token endpoint.fn tokenRequest(arena: Allocator, endpoints: Endpoints, fields: []const [2][]const u8) !Http.Request { var body: Writer.Allocating = .init(arena); try form(&body.writer, fields); const headers = try arena.dupe([]const u8, &.{"content-type: application/x-www-form-urlencoded"}); return .{ .method = .post, .url = endpoints.token, .headers = headers, .body = body.written() };}
/// The tokens in the token endpoint's response. A refusal is logged with/// Google's reason.pub fn readToken(arena: Allocator, response: Http.Response) !Token { const Reply = struct { access_token: ?[]const u8 = null, refresh_token: ?[]const u8 = null, expires_in: u32 = 0, @"error": ?[]const u8 = null, error_description: ?[]const u8 = null, }; const reply = json.parseFromSliceLeaky(Reply, arena, response.body, .{ .ignore_unknown_fields = true }) catch Reply{}; if (response.status != 200 or reply.access_token == null) { log.err("token endpoint said {d}: {s}: {s}", .{ response.status, reply.@"error" orelse "?", reply.error_description orelse "", }); return error.TokenRefused; } return .{ .access = reply.access_token.?, .refresh = reply.refresh_token, .expires_in = reply.expires_in };}
/// Writes `key=value` pairs, `&`-separated, each side percent-encoded./// A pair with an empty value is left out.fn form(w: *Writer, fields: []const [2][]const u8) Writer.Error!void { var first = true; for (fields) |field| { if (field[1].len == 0) continue; if (!first) try w.writeByte('&'); first = false; try Uri.Component.percentEncode(w, field[0], unreserved); try w.writeByte('='); try Uri.Component.percentEncode(w, field[1], unreserved); }}
/// RFC 3986's unreserved characters, the ones a form value keeps as is.fn unreserved(c: u8) bool { return ascii.isAlphanumeric(c) or c == '-' or c == '.' or c == '_' or c == '~';}
/// Fills `out` with random characters PKCE allows in a verifier.fn randomUrlSafe(io: Io, out: []u8) void { const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; io.random(out); for (out) |*c| c.* = alphabet[c.* & 63];}
test "the sign-in URL carries the client, the redirect, and the verifier's challenge" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); // RFC 7636 appendix B: this verifier's S256 challenge. const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; const url = try authUrl(arena, .{ .endpoints = .google, .client = .{ .id = "abc.apps.googleusercontent.com", .secret = null }, .redirect = "http://127.0.0.1:9004", .verifier = verifier, .state = "xyz", }); try testing.expectEqualStrings( "https://accounts.google.com/o/oauth2/v2/auth?client_id=abc.apps.googleusercontent.com" ++ "&redirect_uri=http%3A%2F%2F127.0.0.1%3A9004&response_type=code" ++ "&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgmail.modify" ++ "&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&state=xyz", url, );}
test "the redirect gives up its code, or Google's error" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const ok = try parseRedirect(arena, "/?state=xyz&code=4%2F0Ab_c&scope=https%3A%2F%2Fmail.google.com%2F"); try testing.expectEqualStrings("xyz", ok.state); try testing.expectEqualStrings("4/0Ab_c", ok.code.?); const refused = try parseRedirect(arena, "/?error=access_denied&state=xyz"); try testing.expectEqualStrings("access_denied", refused.err.?); try testing.expectEqual(null, refused.code); try testing.expectError(error.NotRedirect, parseRedirect(arena, "/favicon.ico"));}
test "the console's client file gives the id and the secret" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const client = try Client.parse(arena_state.allocator(), \\{"installed":{"client_id":"123-abc.apps.googleusercontent.com","project_id":"parcel", \\"auth_uri":"https://accounts.google.com/o/oauth2/auth","client_secret":"GOCSPX-x", \\"redirect_uris":["http://localhost"]}} ); try testing.expectEqualStrings("123-abc.apps.googleusercontent.com", client.id); try testing.expectEqualStrings("GOCSPX-x", client.secret.?);}