Add Google device-authorization flow client

This commit is contained in:
EugeneTes
2026-09-23 09:59:11 +00:00
parent e776c32ca3
commit 1a4960e5bb
2 changed files with 230 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
import { createDeviceFlowClient } from "./google-device-flow.js";
type FetchCall = { url: string; init?: RequestInit };
function stubFetch(responses: Array<{ status?: number; body: unknown }>) {
const calls: FetchCall[] = [];
let i = 0;
const fn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
calls.push({ url, init });
const entry = responses[i++];
if (!entry) throw new Error(`unexpected fetch call: ${url}`);
const status = entry.status ?? 200;
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(entry.body),
json: async () => entry.body,
} as Response;
});
return { fn, calls };
}
const opts = { clientId: "cid", clientSecret: "csecret" };
describe("createDeviceFlowClient.start", () => {
it("POSTs client_id and scope, parses the response, and computes expiresAt", async () => {
const { fn, calls } = stubFetch([
{
body: {
device_code: "dev-1",
user_code: "ABCD-EFGH",
verification_url: "https://google.com/device",
expires_in: 900,
interval: 5,
},
},
]);
const before = Date.now();
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
const code = await client.start();
const after = Date.now();
expect(calls[0].url).toBe("https://oauth2.googleapis.com/device/code");
expect(calls[0].init?.method).toBe("POST");
const body = new URLSearchParams(calls[0].init!.body as string);
expect(body.get("client_id")).toBe("cid");
expect(body.get("scope")).toBe("https://www.googleapis.com/auth/calendar");
expect(code.deviceCode).toBe("dev-1");
expect(code.userCode).toBe("ABCD-EFGH");
expect(code.verificationUrl).toBe("https://google.com/device");
expect(code.interval).toBe(5);
expect(code.expiresAt).toBeGreaterThanOrEqual(before + 900_000);
expect(code.expiresAt).toBeLessThanOrEqual(after + 900_000);
});
});
describe("createDeviceFlowClient.complete", () => {
it("returns success and extracts the refresh_token", async () => {
const { fn, calls } = stubFetch([
{ body: { access_token: "at", refresh_token: "rt", expires_in: 3600, token_type: "Bearer" } },
]);
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
const result = await client.complete("dev-1");
expect(result).toEqual({ kind: "success", refreshToken: "rt" });
const body = new URLSearchParams(calls[0].init!.body as string);
expect(body.get("client_id")).toBe("cid");
expect(body.get("client_secret")).toBe("csecret");
expect(body.get("device_code")).toBe("dev-1");
expect(body.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code");
});
it("returns pending for authorization_pending", async () => {
const { fn } = stubFetch([{ status: 400, body: { error: "authorization_pending" } }]);
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
expect(await client.complete("dev-1")).toEqual({ kind: "pending" });
});
it("returns slow_down for slow_down", async () => {
const { fn } = stubFetch([{ status: 400, body: { error: "slow_down" } }]);
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
expect(await client.complete("dev-1")).toEqual({ kind: "slow_down" });
});
it("returns denied for access_denied", async () => {
const { fn } = stubFetch([{ status: 400, body: { error: "access_denied" } }]);
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
expect(await client.complete("dev-1")).toEqual({ kind: "denied" });
});
it("returns expired for expired_token", async () => {
const { fn } = stubFetch([{ status: 400, body: { error: "expired_token" } }]);
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
expect(await client.complete("dev-1")).toEqual({ kind: "expired" });
});
it("returns error for unknown error codes and non-400 failures", async () => {
const { fn } = stubFetch([{ status: 400, body: { error: "internal_error", error_description: "boom" } }]);
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
const r = await client.complete("dev-1");
expect(r.kind).toBe("error");
if (r.kind === "error") expect(r.message).toMatch(/internal_error/);
});
it("returns error when the success body is missing refresh_token", async () => {
const { fn } = stubFetch([{ body: { access_token: "at", expires_in: 3600 } }]);
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
const r = await client.complete("dev-1");
expect(r.kind).toBe("error");
if (r.kind === "error") expect(r.message).toMatch(/no refresh token/);
});
});

View File

@@ -0,0 +1,116 @@
export interface DeviceCode {
deviceCode: string;
userCode: string;
verificationUrl: string;
expiresAt: number;
interval: number;
}
export type CompleteResult =
| { kind: "success"; refreshToken: string }
| { kind: "pending" }
| { kind: "denied" }
| { kind: "expired" }
| { kind: "slow_down" }
| { kind: "error"; message: string };
export interface DeviceFlowClient {
start(): Promise<DeviceCode>;
complete(deviceCode: string): Promise<CompleteResult>;
}
const DEVICE_CODE_URL = "https://oauth2.googleapis.com/device/code";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
const DEFAULT_SCOPE = "https://www.googleapis.com/auth/calendar";
interface DeviceCodeResponse {
device_code: string;
user_code: string;
verification_url: string;
expires_in: number;
interval: number;
}
interface TokenSuccess {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
}
interface TokenError {
error: string;
error_description?: string;
}
export function createDeviceFlowClient(opts: {
clientId: string;
clientSecret: string;
scope?: string;
fetchFn?: typeof fetch;
}): DeviceFlowClient {
const scope = opts.scope ?? DEFAULT_SCOPE;
const fetchFn = opts.fetchFn ?? fetch;
return {
async start() {
const resp = await fetchFn(DEVICE_CODE_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: opts.clientId, scope }).toString(),
});
if (!resp.ok) {
throw new Error(`Google device/code failed: ${resp.status} ${await resp.text()}`);
}
const data = (await resp.json()) as DeviceCodeResponse;
return {
deviceCode: data.device_code,
userCode: data.user_code,
verificationUrl: data.verification_url,
expiresAt: Date.now() + data.expires_in * 1000,
interval: data.interval,
};
},
async complete(deviceCode: string) {
const resp = await fetchFn(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: opts.clientId,
client_secret: opts.clientSecret,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}).toString(),
});
if (resp.ok) {
const data = (await resp.json()) as TokenSuccess;
if (typeof data.refresh_token !== "string" || !data.refresh_token) {
return {
kind: "error",
message:
"Google returned no refresh token. Revoke this app at https://myaccount.google.com/permissions and try /connect_calendar again.",
};
}
return { kind: "success", refreshToken: data.refresh_token };
}
if (resp.status !== 400) {
return { kind: "error", message: `Google token endpoint returned HTTP ${resp.status}: ${await resp.text()}` };
}
const err = (await resp.json()) as TokenError;
switch (err.error) {
case "authorization_pending":
return { kind: "pending" };
case "slow_down":
return { kind: "slow_down" };
case "access_denied":
return { kind: "denied" };
case "expired_token":
return { kind: "expired" };
default:
return {
kind: "error",
message: err.error_description ? `${err.error}: ${err.error_description}` : err.error,
};
}
},
};
}