feat(google-meet): add oauth doctor

This commit is contained in:
Peter Steinberger
2026-04-25 07:36:11 +01:00
parent 2ff7eb36cf
commit d37f165bee
4 changed files with 445 additions and 4 deletions

View File

@@ -966,6 +966,121 @@ describe("google-meet plugin", () => {
}
});
it("CLI doctor verifies Google Meet OAuth refresh without printing secrets", async () => {
const program = new Command();
const stdout = captureStdout();
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => {
return new Response(
JSON.stringify({
access_token: "new-access-token",
expires_in: 3600,
token_type: "Bearer",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchMock);
const ensureRuntime = vi.fn(async () => {
throw new Error("runtime should not be loaded for OAuth doctor");
});
registerGoogleMeetCli({
program,
config: resolveGoogleMeetConfig({
oauth: {
clientId: "client-id",
clientSecret: "client-secret",
refreshToken: "rt-secret",
},
}),
ensureRuntime: ensureRuntime as unknown as () => Promise<GoogleMeetRuntime>,
});
try {
await program.parseAsync(["googlemeet", "doctor", "--oauth", "--json"], { from: "user" });
const output = stdout.output();
expect(output).not.toContain("new-access-token");
expect(output).not.toContain("rt-secret");
expect(output).not.toContain("client-secret");
expect(JSON.parse(output)).toMatchObject({
ok: true,
configured: true,
tokenSource: "refresh-token",
checks: [
{ id: "oauth-config", ok: true },
{ id: "oauth-token", ok: true },
],
});
expect(ensureRuntime).not.toHaveBeenCalled();
const body = fetchMock.mock.calls[0]?.[1]?.body as URLSearchParams;
expect(body.get("grant_type")).toBe("refresh_token");
} finally {
stdout.restore();
}
});
it("CLI doctor can prove Google Meet API create access", async () => {
const program = new Command();
const stdout = captureStdout();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url === "https://oauth2.googleapis.com/token") {
return new Response(
JSON.stringify({
access_token: "new-access-token",
expires_in: 3600,
token_type: "Bearer",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === "https://meet.googleapis.com/v2/spaces") {
return new Response(
JSON.stringify({
name: "spaces/new-space",
meetingUri: "https://meet.google.com/new-abcd-xyz",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response("not found", { status: 404 });
}),
);
registerGoogleMeetCli({
program,
config: resolveGoogleMeetConfig({
oauth: {
clientId: "client-id",
refreshToken: "refresh-token",
},
}),
ensureRuntime: async () => ({}) as GoogleMeetRuntime,
});
try {
await program.parseAsync(["googlemeet", "doctor", "--oauth", "--create-space", "--json"], {
from: "user",
});
expect(JSON.parse(stdout.output())).toMatchObject({
ok: true,
tokenSource: "refresh-token",
createdSpace: "spaces/new-space",
meetingUri: "https://meet.google.com/new-abcd-xyz",
checks: [
{ id: "oauth-config", ok: true },
{ id: "oauth-token", ok: true },
{ id: "meet-spaces-create", ok: true },
],
});
} finally {
stdout.restore();
}
});
it("CLI recover-tab focuses and summarizes an existing Meet tab", async () => {
const program = new Command();
const stdout = captureStdout();

View File

@@ -57,6 +57,18 @@ type SetupOptions = {
json?: boolean;
};
type DoctorOptions = {
json?: boolean;
oauth?: boolean;
meeting?: string;
createSpace?: boolean;
accessToken?: string;
refreshToken?: string;
clientId?: string;
clientSecret?: string;
expiresAt?: string;
};
type JsonOptions = {
json?: boolean;
};
@@ -173,6 +185,151 @@ function writeDoctorStatus(status: ReturnType<GoogleMeetRuntime["status"]>): voi
}
}
type OAuthDoctorCheck = {
id: string;
ok: boolean;
message: string;
};
type OAuthDoctorReport = {
ok: boolean;
configured: boolean;
tokenSource?: "cached-access-token" | "refresh-token";
expiresAt?: number;
scope?: string;
meetingUri?: string;
createdSpace?: string;
checks: OAuthDoctorCheck[];
};
function sanitizeOAuthErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message
.replace(/(access_token["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]")
.replace(/(refresh_token["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]")
.replace(/(client_secret["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]");
}
async function buildOAuthDoctorReport(
config: GoogleMeetConfig,
options: DoctorOptions,
): Promise<OAuthDoctorReport> {
const clientId = options.clientId?.trim() || config.oauth.clientId;
const clientSecret = options.clientSecret?.trim() || config.oauth.clientSecret;
const refreshToken = options.refreshToken?.trim() || config.oauth.refreshToken;
const accessToken = options.accessToken?.trim() || config.oauth.accessToken;
const expiresAt = parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt;
const checks: OAuthDoctorCheck[] = [];
const hasRefreshConfig = Boolean(clientId && refreshToken);
const hasAccessConfig = Boolean(accessToken);
if (!hasRefreshConfig && !hasAccessConfig) {
checks.push({
id: "oauth-config",
ok: false,
message:
"Missing Google Meet OAuth credentials. Configure oauth.clientId and oauth.refreshToken, or pass --client-id and --refresh-token.",
});
return { ok: false, configured: false, checks };
}
checks.push({
id: "oauth-config",
ok: true,
message: hasRefreshConfig
? "Google Meet OAuth refresh credentials are configured"
: "Google Meet cached access token is configured",
});
let token: Awaited<ReturnType<typeof resolveGoogleMeetAccessToken>>;
try {
token = await resolveGoogleMeetAccessToken({
clientId,
clientSecret,
refreshToken,
accessToken,
expiresAt,
});
checks.push({
id: "oauth-token",
ok: true,
message: token.refreshed
? "Refresh token minted an access token"
: "Cached access token is still valid",
});
} catch (error) {
checks.push({
id: "oauth-token",
ok: false,
message: sanitizeOAuthErrorMessage(error),
});
return { ok: false, configured: true, checks };
}
const report: OAuthDoctorReport = {
ok: true,
configured: true,
tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
expiresAt: token.expiresAt,
checks,
};
const meeting = options.meeting?.trim();
if (meeting) {
try {
const space = await fetchGoogleMeetSpace({ accessToken: token.accessToken, meeting });
checks.push({
id: "meet-spaces-get",
ok: true,
message: `Resolved ${space.name}`,
});
report.meetingUri = space.meetingUri;
} catch (error) {
checks.push({
id: "meet-spaces-get",
ok: false,
message: sanitizeOAuthErrorMessage(error),
});
}
}
if (options.createSpace) {
try {
const created = await createGoogleMeetSpace({ accessToken: token.accessToken });
checks.push({
id: "meet-spaces-create",
ok: true,
message: `Created ${created.space.name}`,
});
report.createdSpace = created.space.name;
report.meetingUri = created.meetingUri;
} catch (error) {
checks.push({
id: "meet-spaces-create",
ok: false,
message: sanitizeOAuthErrorMessage(error),
});
}
}
report.ok = checks.every((check) => check.ok);
return report;
}
function writeOAuthDoctorReport(report: OAuthDoctorReport): void {
writeStdoutLine("Google Meet OAuth: %s", report.ok ? "OK" : "needs attention");
writeStdoutLine("configured: %s", report.configured ? "yes" : "no");
if (report.tokenSource) {
writeStdoutLine("token source: %s", report.tokenSource);
}
if (report.meetingUri) {
writeStdoutLine("meeting uri: %s", report.meetingUri);
}
for (const check of report.checks) {
writeStdoutLine("[%s] %s: %s", check.ok ? "ok" : "fail", check.id, check.message);
}
}
function writeRecoverCurrentTabResult(
result: Awaited<ReturnType<GoogleMeetRuntime["recoverCurrentTab"]>>,
): void {
@@ -754,8 +911,25 @@ export function registerGoogleMeetCli(params: {
.command("doctor")
.description("Show human-readable Meet session/browser/realtime health")
.argument("[session-id]", "Meet session ID")
.option("--oauth", "Verify Google Meet OAuth token refresh without printing secrets", false)
.option("--meeting <value>", "Also verify spaces.get for a Meet URL, code, or spaces/{id}")
.option("--create-space", "Also verify spaces.create by creating a throwaway Meet space", false)
.option("--access-token <token>", "Access token override")
.option("--refresh-token <token>", "Refresh token override")
.option("--client-id <id>", "OAuth client id override")
.option("--client-secret <secret>", "OAuth client secret override")
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
.option("--json", "Print JSON output", false)
.action(async (sessionId: string | undefined, options: JsonOptions) => {
.action(async (sessionId: string | undefined, options: DoctorOptions) => {
if (options.oauth) {
const report = await buildOAuthDoctorReport(params.config, options);
if (options.json) {
writeStdoutJson(report);
return;
}
writeOAuthDoctorReport(report);
return;
}
const rt = await params.ensureRuntime();
const status = rt.status(sessionId);
if (options.json) {