//! 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. //! //! JMAP servers sign in the same way, under the OAuth profile for open //! public clients (draft-ietf-mailmaint-oauth-public): the session //! resource names its authorization server, whose metadata names where //! to register, and parcel registers itself, so no client is set up by //! hand. //! //! `login` waits on the network, for the command line. In the app, //! SignIn.zig takes the same steps on the event loop through the //! requests and readers here. const std = @import("std"); const build_options = @import("build_options"); 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, .scope = scope, .issuer = null, .redirect = redirect, .verifier = &verifier, .state = &state, }; open(io, try authUrl(arena, attempt)); return awaitRedirect(arena, io, &server, attempt); } /// One try at signing in. pub const Attempt = struct { endpoints: Endpoints, client: Client, /// What it asks to reach: Gmail's scope, or a JMAP server's. scope: []const u8, /// The authorization server, which its redirect must name (RFC /// 9207); null for Google, which names none. issuer: ?[]const u8, /// 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. pub 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", attempt.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, code) 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}); } pub const page_signed_in = \\
Signed in. You can close this tab.
; const page_refused = \\Sign-in did not finish. See the terminal.
; pub const page_refused_app = \\Sign-in did not finish. See Parcel.
; /// What the browser comes back with: `iss` names the authorization /// server, where it says. pub const Redirect = struct { state: []const u8, code: ?[]const u8, err: ?[]const u8, iss: ?[]const u8 }; /// Reads the query Google redirects with: `/?state=…&code=…`, or /// `error=…` in place of the code. pub 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, .iss = 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 (mem.eql(u8, key, "iss")) redirect.iss = value; } if (redirect.state.len == 0) return error.NotRedirect; return redirect; } /// Trades the code for tokens and waits for them. fn exchange(arena: Allocator, attempt: Attempt, code: []const u8) !Token { const request = try codeRequest(arena, attempt, code); defer std.crypto.secureZero(u8, @constCast(request.body.?)); return readToken(arena, try Http.fetch(arena, request)); } /// The request that trades the code the browser brought for tokens; /// readToken reads its response. pub fn codeRequest(arena: Allocator, attempt: Attempt, code: []const u8) !Http.Request { return tokenRequest(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 "" }, }); } /// 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 == '~'; } // JMAP servers, under the profile for open public clients. /// The same for every copy of parcel, as registration asks (RFC 7591). const software_id = "7823a28b-e81a-4c47-9e4d-994115dfd38a"; /// The redirect parcel registers; each sign-in adds its port, which the /// profile allows for a loopback address. pub const registered_redirect = "http://127.0.0.1/"; /// What a JMAP account signed in with OAuth keeps, as JSON: the client /// parcel registered as, where refreshes go, and the refresh token, /// which a refresh may replace. pub const Grant = struct { client_id: []const u8, token_endpoint: []const u8, refresh_token: []const u8, pub fn parse(arena: Allocator, bytes: []const u8) !Grant { return json.parseFromSliceLeaky(Grant, arena, bytes, .{ .ignore_unknown_fields = true }); } pub fn write(self: Grant, arena: Allocator) ![]const u8 { return json.Stringify.valueAlloc(arena, self, .{}); } /// The request that trades its refresh token for an access token. pub fn refreshRequest(self: Grant, arena: Allocator) !Http.Request { return tokenRequest(arena, .{ .auth = "", .token = self.token_endpoint }, &.{ .{ "grant_type", "refresh_token" }, .{ "refresh_token", self.refresh_token }, .{ "client_id", self.client_id }, }); } }; /// An authorization server, as its metadata describes it (RFC 8414). pub const Server = struct { issuer: []const u8, endpoints: Endpoints, registration: []const u8, /// What parcel asks of it: the mail, and to stay signed in. scope: []const u8, }; /// Where `url`'s well-known document `name` is: after the host, before /// the path, as RFC 8414 and RFC 9728 put it. pub fn wellKnown(arena: Allocator, url: []const u8, name: []const u8) ![]const u8 { const end = mem.findAny(u8, url, "?#") orelse url.len; const path_start = (try origin(url)).len; const path = url[path_start..end]; const rest = if (mem.eql(u8, path, "/")) "" else path; return mem.concat(arena, u8, &.{ url[0..path_start], "/.well-known/", name, rest }); } /// `url`'s scheme, host, and port. pub fn origin(url: []const u8) ![]const u8 { const host_start = (mem.find(u8, url, "://") orelse return error.NotUrl) + 3; const end = mem.findAny(u8, url, "?#") orelse url.len; return url[0 .. mem.findScalarPos(u8, url[0..end], host_start, '/') orelse end]; } /// The authorization server that `resource`'s protected resource /// metadata names (RFC 9728). The document must be about `resource` /// itself, or another could send the token elsewhere. pub fn readResource(arena: Allocator, body: []const u8, resource: []const u8) ![]const u8 { const Doc = struct { resource: []const u8 = "", authorization_servers: []const []const u8 = &.{} }; const doc = json.parseFromSliceLeaky(Doc, arena, body, .{ .ignore_unknown_fields = true }) catch return error.NoOAuth; if (!mem.eql(u8, doc.resource, resource)) return error.WrongResource; if (doc.authorization_servers.len == 0) return error.NoOAuth; return doc.authorization_servers[0]; } /// The metadata of the authorization server `issuer`, which must name /// itself so. It must take registrations and PKCE's S256. pub fn readServer(arena: Allocator, body: []const u8, issuer: []const u8) !Server { const Doc = struct { issuer: []const u8 = "", authorization_endpoint: ?[]const u8 = null, token_endpoint: ?[]const u8 = null, registration_endpoint: ?[]const u8 = null, scopes_supported: ?[]const []const u8 = null, code_challenge_methods_supported: ?[]const []const u8 = null, }; const doc = json.parseFromSliceLeaky(Doc, arena, body, .{ .ignore_unknown_fields = true }) catch return error.NoOAuth; if (!mem.eql(u8, doc.issuer, issuer)) return error.WrongIssuer; if (doc.code_challenge_methods_supported) |methods| { if (!has(methods, "S256")) return error.NoOAuth; } const supported = doc.scopes_supported orelse &.{}; // The profile's scope for mail, or JMAP's own where only those are // offered; OpenID's servers keep a sign-in only with offline_access. const mail = "urn:ietf:params:oauth:scope:mail"; const jmap = "urn:ietf:params:jmap:core urn:ietf:params:jmap:mail urn:ietf:params:jmap:submission"; const want = if (supported.len == 0 or has(supported, mail)) mail else jmap; const offline = has(supported, "offline_access"); return .{ .issuer = doc.issuer, .endpoints = .{ .auth = doc.authorization_endpoint orelse return error.NoOAuth, .token = doc.token_endpoint orelse return error.NoOAuth, }, .registration = doc.registration_endpoint orelse return error.NoOAuth, .scope = if (offline) try mem.concat(arena, u8, &.{ want, " offline_access" }) else want, }; } /// Registers parcel with `server` as a public client (RFC 7591). pub fn registrationRequest(arena: Allocator, server: Server) !Http.Request { const body = try json.Stringify.valueAlloc(arena, .{ .redirect_uris = &[_][]const u8{registered_redirect}, .token_endpoint_auth_method = "none", .grant_types = &[_][]const u8{ "authorization_code", "refresh_token" }, .response_types = &[_][]const u8{"code"}, .scope = server.scope, .client_name = "Parcel", .client_uri = "https://parcel.rockorager.dev", .software_id = software_id, .software_version = build_options.version, }, .{}); const headers = try arena.dupe([]const u8, &.{"content-type: application/json"}); return .{ .method = .post, .url = server.registration, .headers = headers, .body = body }; } /// What registering gave: the client id, and the scope to ask for, which /// the server may have narrowed. pub const Registration = struct { client_id: []const u8, scope: []const u8 }; /// Reads the registration's response. The server may change what was /// asked; a redirect it dropped leaves nowhere to come back to. pub fn readRegistration(arena: Allocator, response: Http.Response, asked: []const u8) !Registration { const Reply = struct { client_id: ?[]const u8 = null, redirect_uris: ?[]const []const u8 = null, scope: ?[]const u8 = null, @"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 / 100 != 2 or reply.client_id == null) { log.warn("registering said {d}: {s}: {s}", .{ response.status, reply.@"error" orelse "?", reply.error_description orelse "", }); return error.RegistrationRefused; } if (reply.redirect_uris) |uris| if (!has(uris, registered_redirect)) return error.RegistrationRefused; return .{ .client_id = reply.client_id.?, .scope = reply.scope orelse asked }; } fn has(list: []const []const u8, item: []const u8) bool { for (list) |s| if (mem.eql(u8, s, item)) return true; return false; } /// Fills `out` with random characters PKCE allows in a verifier. pub 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 }, .scope = scope, .issuer = 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")); const named = try parseRedirect(arena, "/?code=c&state=xyz&iss=https%3A%2F%2Fapi.fastmail.com"); try testing.expectEqualStrings("https://api.fastmail.com", named.iss.?); } test "a JMAP server's sign-in is found from its session resource, as Fastmail's documents it" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const session = "https://api.fastmail.com/jmap/session"; try testing.expectEqualStrings( "https://api.fastmail.com/.well-known/oauth-protected-resource/jmap/session", try wellKnown(arena, session, "oauth-protected-resource"), ); try testing.expectEqualStrings( "https://api.fastmail.com/.well-known/oauth-authorization-server", try wellKnown(arena, "https://api.fastmail.com/", "oauth-authorization-server"), ); try testing.expectEqualStrings( "https://auth.example.com:8443/.well-known/oauth-authorization-server/acme", try wellKnown(arena, "https://auth.example.com:8443/acme?x=1", "oauth-authorization-server"), ); const resource = \\{"scopes_supported":["urn:ietf:params:oauth:scope:mail","offline_access"], \\"resource":"https:\/\/api.fastmail.com\/jmap\/session","authorization_servers":["https:\/\/api.fastmail.com"]} ; try testing.expectEqualStrings("https://api.fastmail.com", try readResource(arena, resource, session)); // A document about another resource could send the token elsewhere. try testing.expectError(error.WrongResource, readResource(arena, resource, "https://evil.example/jmap/session")); const metadata = \\{"issuer":"https://api.fastmail.com","registration_endpoint":"https://api.fastmail.com/oauth/register", \\"authorization_endpoint":"https://api.fastmail.com/oauth/authorize", \\"token_endpoint":"https://api.fastmail.com/oauth/refresh", \\"scopes_supported":["urn:ietf:params:oauth:scope:mail","urn:ietf:params:oauth:scope:contacts","openid", \\"offline_access"],"code_challenge_methods_supported":["S256"]} ; const server = try readServer(arena, metadata, "https://api.fastmail.com"); try testing.expectEqualStrings("https://api.fastmail.com/oauth/authorize", server.endpoints.auth); try testing.expectEqualStrings("https://api.fastmail.com/oauth/refresh", server.endpoints.token); try testing.expectEqualStrings("https://api.fastmail.com/oauth/register", server.registration); try testing.expectEqualStrings("urn:ietf:params:oauth:scope:mail offline_access", server.scope); try testing.expectError(error.WrongIssuer, readServer(arena, metadata, "https://api.fastmail.com/other")); // A server offering only JMAP's scopes, and no offline_access, is asked for those. const jmap_only = \\{"issuer":"https://a.example","registration_endpoint":"r","authorization_endpoint":"a", \\"token_endpoint":"t","scopes_supported":["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail"]} ; const other = try readServer(arena, jmap_only, "https://a.example"); try testing.expectEqualStrings( "urn:ietf:params:jmap:core urn:ietf:params:jmap:mail urn:ietf:params:jmap:submission", other.scope, ); const no_pkce = \\{"issuer":"https://a.example","registration_endpoint":"r","authorization_endpoint":"a", \\"token_endpoint":"t","code_challenge_methods_supported":["plain"]} ; try testing.expectError(error.NoOAuth, readServer(arena, no_pkce, "https://a.example")); } test "registration takes the server's client id and narrowed scope, and fails without the redirect" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const asked = "urn:ietf:params:oauth:scope:mail offline_access"; const ok = try readRegistration(arena, .{ .status = 201, .url = "", .body = \\{"client_id":"abc","redirect_uris":["http://127.0.0.1/"],"scope":"urn:ietf:params:oauth:scope:mail"} }, asked); try testing.expectEqualStrings("abc", ok.client_id); try testing.expectEqualStrings("urn:ietf:params:oauth:scope:mail", ok.scope); const kept = try readRegistration(arena, .{ .status = 201, .url = "", .body = "{\"client_id\":\"abc\"}" }, asked); try testing.expectEqualStrings(asked, kept.scope); try testing.expectError(error.RegistrationRefused, readRegistration(arena, .{ .status = 201, .url = "", .body = \\{"client_id":"abc","redirect_uris":["https://example.com/"]} }, asked)); try testing.expectError(error.RegistrationRefused, readRegistration(arena, .{ .status = 400, .url = "", .body = \\{"error":"invalid_redirect_uri"} }, asked)); } 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.?); }