Add Google device-authorization flow client
This commit is contained in:
114
src/telegram/google-device-flow.test.ts
Normal file
114
src/telegram/google-device-flow.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
116
src/telegram/google-device-flow.ts
Normal file
116
src/telegram/google-device-flow.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user