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