From f1d0e2bce69112f5956f3f7fb7fd08b6cd339a07 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 11 Jul 2026 20:36:45 +0800 Subject: [PATCH] Gracefully handle expired Google OAuth refresh tokens in Timesync --- AGENTS.md | 3 ++ README.md | 32 ++++++++++++ app/api/__tests__/reauth.test.ts | 48 +++++++++++++++++ app/api/availability/route.ts | 8 ++- app/api/book/route.ts | 12 ++++- components/BookingWidget.tsx | 18 ++++++- lib/__tests__/google.test.ts | 70 +++++++++++++++++++++++++ lib/cors.ts | 9 ++++ lib/google.ts | 90 +++++++++++++++++++++++--------- lib/provider.ts | 3 ++ vitest.config.ts | 12 +++++ 11 files changed, 275 insertions(+), 30 deletions(-) create mode 100644 app/api/__tests__/reauth.test.ts create mode 100644 lib/__tests__/google.test.ts create mode 100644 vitest.config.ts diff --git a/AGENTS.md b/AGENTS.md index e9bacee..ce47616 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,9 @@ embeddable modal. No database — Google Calendar is the source of truth. - **`lib/provider.ts`** — picks `lib/google.ts` (real free/busy + event insert) when `isLive()`, else deterministic mock busy blocks. **All calendar access goes through here.** - **`lib/google.ts`** — `googleapis` OAuth + `freebusy.query` + `events.insert` (with Meet link). + Wraps calls so an expired/revoked refresh token (`invalid_grant`) throws a typed `CalendarAuthError` + (re-exported from `lib/provider.ts`); routes map it to a `503 { code: "reauth_required" }` (via + `reauthRequired` in `lib/cors.ts`) instead of a generic 500. - **`lib/clientHooks.ts`** — `useOrigin` / `useBookerTimeZone` via `useSyncExternalStore` (SSR-safe, and avoids the `set-state-in-effect` lint rule). diff --git a/README.md b/README.md index 7033b33..484dcef 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ email both sides. - [Embedding in another app](#embedding-in-another-app) - [HTTP API](#http-api) - [Deploy to Vercel](#deploy-to-vercel) +- [Troubleshooting](#troubleshooting) - [Design system](#design-system) - [Tech stack](#tech-stack) - [Contributing](#contributing) @@ -138,6 +139,11 @@ All endpoints return JSON and send permissive CORS headers, so you can build a f - `POST /api/book` → `{ eventTypeId, start, name, email, notes }` → creates the event. Re-validates the slot server-side and returns `409` if it was just taken. +When the owner's Google refresh token has expired or been revoked, `/api/availability` and +`/api/book` return **`503`** with `{ "error": "...", "code": "reauth_required" }` (never an +unhandled `500`). The widget surfaces this as a *"Calendar temporarily unavailable"* message. +See [Troubleshooting](#troubleshooting). + --- ## Deploy to Vercel @@ -149,6 +155,32 @@ All endpoints return JSON and send permissive CORS headers, so you can build a f --- +## Troubleshooting + +### Bookings fail with "Calendar temporarily unavailable" / re-authenticate + +Google refresh tokens can stop working — most commonly with an `invalid_grant` error. This happens +when the token is revoked (owner removed the app under [Google Account → Security → Third-party +access](https://myaccount.google.com/connections)), the password is changed, the token sits unused +past its expiry, or a project still in **Testing** mode expires the token after 7 days. + +When this happens the calendar APIs return a typed **`503 { code: "reauth_required" }`** (not a +generic `500`), and the booking widget shows *"Calendar temporarily unavailable — the owner needs to +re-authenticate."* + +**Fix — mint a fresh refresh token:** + +1. Visit **`/setup`** on your deployment and click **Connect Google Calendar** (re-runs the OAuth + consent flow). This mints a new `GOOGLE_REFRESH_TOKEN`. +2. Update `GOOGLE_REFRESH_TOKEN` in your host's environment variables (locally it's rewritten into + `.env.local`). +3. Redeploy / restart the server so the new token is picked up. + +To avoid the recurring 7-day expiry, move your Google Cloud OAuth consent screen from **Testing** to +**In production** (publishing status), so refresh tokens no longer auto-expire. + +--- + ## Design system The UI is built with **clico-ds** — a playful neo-brutalist design system (cream paper, 2px ink diff --git a/app/api/__tests__/reauth.test.ts b/app/api/__tests__/reauth.test.ts new file mode 100644 index 0000000..5cd1df9 --- /dev/null +++ b/app/api/__tests__/reauth.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +// Make the provider behave as if Google's refresh token is expired/revoked. +vi.mock("@/lib/provider", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchBusy: vi.fn(async () => { + throw new actual.CalendarAuthError(); + }), + book: vi.fn(async () => { + throw new actual.CalendarAuthError(); + }), + }; +}); + +import { GET as availabilityGET } from "@/app/api/availability/route"; +import { POST as bookPOST } from "@/app/api/book/route"; + +describe("graceful invalid_grant handling", () => { + it("availability returns a typed 503 instead of an unhandled 500", async () => { + const req = new NextRequest("http://localhost/api/availability?date=2026-07-13&eventType=30min"); + const res = await availabilityGET(req); + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.code).toBe("reauth_required"); + expect(typeof body.error).toBe("string"); + }); + + it("booking returns a typed 503 instead of an unhandled 500", async () => { + const req = new NextRequest("http://localhost/api/book", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + eventTypeId: "30min", + start: "2026-07-13T01:00:00.000Z", // 09:00 SGT, a Monday + name: "Jane Doe", + email: "jane@example.com", + }), + }); + const res = await bookPOST(req); + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.code).toBe("reauth_required"); + expect(body.error).toMatch(/re-?authenticate/i); + }); +}); diff --git a/app/api/availability/route.ts b/app/api/availability/route.ts index 234c61d..6b07792 100644 --- a/app/api/availability/route.ts +++ b/app/api/availability/route.ts @@ -1,9 +1,9 @@ import type { NextRequest } from "next/server"; import { config } from "@/lib/config"; import { computeSlots } from "@/lib/availability"; -import { fetchBusy } from "@/lib/provider"; +import { fetchBusy, CalendarAuthError } from "@/lib/provider"; import { parseDateStr, zonedWallToUtc } from "@/lib/time"; -import { json, preflight } from "@/lib/cors"; +import { json, preflight, reauthRequired } from "@/lib/cors"; export const dynamic = "force-dynamic"; @@ -27,6 +27,10 @@ export async function GET(req: NextRequest) { try { busy = await fetchBusy(dayStart.toISOString(), dayEnd.toISOString()); } catch (err) { + if (err instanceof CalendarAuthError) { + console.error("[timesync] calendar re-auth required", err); + return reauthRequired(err.message); + } console.error("[timesync] fetchBusy failed", err); return json( { error: "Calendar is temporarily unavailable. Please try again shortly." }, diff --git a/app/api/book/route.ts b/app/api/book/route.ts index a3cc386..95508c8 100644 --- a/app/api/book/route.ts +++ b/app/api/book/route.ts @@ -1,9 +1,9 @@ import type { NextRequest } from "next/server"; import { config, getEventType } from "@/lib/config"; import { computeSlots } from "@/lib/availability"; -import { book, fetchBusy } from "@/lib/provider"; +import { book, fetchBusy, CalendarAuthError } from "@/lib/provider"; import { toDateStr, zonedDateParts, zonedWallToUtc } from "@/lib/time"; -import { json, preflight } from "@/lib/cors"; +import { json, preflight, reauthRequired } from "@/lib/cors"; export const dynamic = "force-dynamic"; @@ -45,6 +45,10 @@ export async function POST(req: NextRequest) { try { busy = await fetchBusy(dayStart.toISOString(), dayEnd.toISOString()); } catch (err) { + if (err instanceof CalendarAuthError) { + console.error("[timesync] calendar re-auth required", err); + return reauthRequired(err.message); + } console.error("[timesync] fetchBusy failed", err); return json( { error: "Calendar is temporarily unavailable. Please try again shortly." }, @@ -81,6 +85,10 @@ export async function POST(req: NextRequest) { ...result, }); } catch (err) { + if (err instanceof CalendarAuthError) { + console.error("[timesync] calendar re-auth required", err); + return reauthRequired(err.message); + } console.error("[timesync] booking failed", err); return json({ error: "Could not create the event. Please try again." }, { status: 500 }); } diff --git a/components/BookingWidget.tsx b/components/BookingWidget.tsx index f5777ff..c3140cf 100644 --- a/components/BookingWidget.tsx +++ b/components/BookingWidget.tsx @@ -114,6 +114,7 @@ export default function BookingWidget({ const [slots, setSlots] = useState(null); const [slotsLoading, setSlotsLoading] = useState(false); const [slotsError, setSlotsError] = useState(false); + const [slotsErrorMsg, setSlotsErrorMsg] = useState(null); const [selectedSlot, setSelectedSlot] = useState(null); const [step, setStep] = useState<"pick" | "details" | "done">("pick"); @@ -132,11 +133,17 @@ export default function BookingWidget({ setSlotsLoading(true); setSlots(null); setSlotsError(false); + setSlotsErrorMsg(null); try { const r = await fetch(`/api/availability?date=${dateStr}&eventType=${etId}`); if (!r.ok) { + const j = await r.json().catch(() => null); setSlots([]); setSlotsError(true); + // Surface the owner-needs-to-re-authenticate case explicitly. + if (j?.code === "reauth_required" && typeof j.error === "string") { + setSlotsErrorMsg(j.error); + } return; } const j = await r.json(); @@ -322,6 +329,7 @@ export default function BookingWidget({ slots={slots} loading={slotsLoading} error={slotsError} + errorMsg={slotsErrorMsg} onPick={pickSlot} /> @@ -692,6 +700,7 @@ function SlotPanel({ slots, loading, error, + errorMsg, onPick, }: { selectedDate: string | null; @@ -699,6 +708,7 @@ function SlotPanel({ slots: Slot[] | null; loading: boolean; error: boolean; + errorMsg?: string | null; onPick: (slot: Slot) => void; }) { return ( @@ -720,8 +730,12 @@ function SlotPanel({ ) : error ? (
-

Couldn’t load times

-

Please try again shortly.

+

+ {errorMsg ? "Calendar temporarily unavailable" : "Couldn’t load times"} +

+

+ {errorMsg ?? "Please try again shortly."} +

) : slots && slots.length > 0 ? ( slots.map((slot) => ( diff --git a/lib/__tests__/google.test.ts b/lib/__tests__/google.test.ts new file mode 100644 index 0000000..6e80bbf --- /dev/null +++ b/lib/__tests__/google.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; + +// Simulate Google rejecting an expired/revoked refresh token. googleapis surfaces +// this as a Gaxios error whose message and response body carry `invalid_grant`. +vi.mock("googleapis", () => { + const invalidGrant = Object.assign( + new Error("invalid_grant (Token has been expired or revoked.)"), + { + response: { + data: { error: "invalid_grant", error_description: "Token has been expired or revoked." }, + }, + }, + ); + class OAuth2 { + setCredentials() {} + generateAuthUrl() { + return ""; + } + async getToken() { + return { tokens: {} }; + } + } + return { + google: { + auth: { OAuth2 }, + calendar: () => ({ + freebusy: { query: () => Promise.reject(invalidGrant) }, + events: { insert: () => Promise.reject(invalidGrant) }, + }), + }, + }; +}); + +import { CalendarAuthError, createEvent, getBusy, isInvalidGrant } from "../google"; + +describe("isInvalidGrant", () => { + it("matches the invalid_grant message", () => { + expect(isInvalidGrant(new Error("invalid_grant"))).toBe(true); + }); + + it("matches a Gaxios-shaped error body", () => { + expect(isInvalidGrant({ response: { data: { error: "invalid_grant" } } })).toBe(true); + }); + + it("ignores unrelated errors", () => { + expect(isInvalidGrant(new Error("network timeout"))).toBe(false); + expect(isInvalidGrant(null)).toBe(false); + }); +}); + +describe("calendar calls surface a typed re-auth error", () => { + it("getBusy throws CalendarAuthError on invalid_grant", async () => { + await expect(getBusy("2026-06-22T00:00:00Z", "2026-06-22T23:59:00Z")).rejects.toBeInstanceOf( + CalendarAuthError, + ); + }); + + it("createEvent throws CalendarAuthError on invalid_grant", async () => { + const err = await createEvent({ + summary: "Chat", + description: "", + start: "2026-06-22T01:00:00.000Z", + end: "2026-06-22T01:30:00.000Z", + attendeeEmail: "jane@example.com", + attendeeName: "Jane", + }).catch((e) => e); + expect(err).toBeInstanceOf(CalendarAuthError); + expect((err as CalendarAuthError).code).toBe("reauth_required"); + }); +}); diff --git a/lib/cors.ts b/lib/cors.ts index 5672329..5bf7827 100644 --- a/lib/cors.ts +++ b/lib/cors.ts @@ -13,3 +13,12 @@ export function json(data: unknown, init?: ResponseInit) { export function preflight() { return new NextResponse(null, { status: 204, headers: CORS }); } + +/** + * Typed 503 for an expired/revoked Google refresh token. Distinct from a generic + * 500 so clients can detect it via `code: "reauth_required"` and show a clear + * "owner needs to re-authenticate" message instead of a broken state. + */ +export function reauthRequired(message: string) { + return json({ error: message, code: "reauth_required" }, { status: 503 }); +} diff --git a/lib/google.ts b/lib/google.ts index 47027bf..5185ec2 100644 --- a/lib/google.ts +++ b/lib/google.ts @@ -7,6 +7,44 @@ const SCOPES = [ "https://www.googleapis.com/auth/calendar.readonly", ]; +/** + * Thrown when Google rejects the stored refresh token (`invalid_grant`) — i.e. + * the token was revoked/expired and the owner must re-run the OAuth flow. + * Routes translate this into an HTTP 503 with `code: "reauth_required"` instead + * of a generic 500, so the UI can show a clear message. + */ +export class CalendarAuthError extends Error { + readonly code = "reauth_required" as const; + constructor(message = "Calendar authorization has expired — the owner needs to re-authenticate.") { + super(message); + this.name = "CalendarAuthError"; + } +} + +/** Detect the Google OAuth `invalid_grant` failure across its several shapes. */ +export function isInvalidGrant(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const e = err as { + message?: unknown; + response?: { data?: { error?: unknown; error_description?: unknown } }; + }; + const data = e.response?.data; + const candidates: unknown[] = [e.message, data?.error, data?.error_description]; + return candidates.some( + (c) => typeof c === "string" && c.toLowerCase().includes("invalid_grant"), + ); +} + +/** Run a calendar call, converting an expired-refresh-token error into a typed one. */ +async function withAuthErrors(fn: () => Promise): Promise { + try { + return await fn(); + } catch (err) { + if (isInvalidGrant(err)) throw new CalendarAuthError(); + throw err; + } +} + function client(redirectUri?: string) { return new google.auth.OAuth2( process.env.GOOGLE_CLIENT_ID, @@ -36,14 +74,16 @@ function calendar() { } export async function getBusy(timeMin: string, timeMax: string): Promise { - const res = await calendar().freebusy.query({ - requestBody: { - timeMin, - timeMax, - timeZone: config.timeZone, - items: [{ id: "primary" }], - }, - }); + const res = await withAuthErrors(() => + calendar().freebusy.query({ + requestBody: { + timeMin, + timeMax, + timeZone: config.timeZone, + items: [{ id: "primary" }], + }, + }), + ); const busy = res.data.calendars?.primary?.busy ?? []; return busy .filter((b): b is { start: string; end: string } => Boolean(b.start && b.end)) @@ -61,24 +101,26 @@ export type CreateEventArgs = { export async function createEvent(args: CreateEventArgs) { const requestId = `timesync-${Date.now()}-${Math.round(Math.random() * 1e9).toString(36)}`; - const res = await calendar().events.insert({ - calendarId: "primary", - conferenceDataVersion: 1, - sendUpdates: "all", - requestBody: { - summary: args.summary, - description: args.description, - start: { dateTime: args.start, timeZone: config.timeZone }, - end: { dateTime: args.end, timeZone: config.timeZone }, - attendees: [{ email: args.attendeeEmail, displayName: args.attendeeName }], - conferenceData: { - createRequest: { - requestId, - conferenceSolutionKey: { type: "hangoutsMeet" }, + const res = await withAuthErrors(() => + calendar().events.insert({ + calendarId: "primary", + conferenceDataVersion: 1, + sendUpdates: "all", + requestBody: { + summary: args.summary, + description: args.description, + start: { dateTime: args.start, timeZone: config.timeZone }, + end: { dateTime: args.end, timeZone: config.timeZone }, + attendees: [{ email: args.attendeeEmail, displayName: args.attendeeName }], + conferenceData: { + createRequest: { + requestId, + conferenceSolutionKey: { type: "hangoutsMeet" }, + }, }, }, - }, - }); + }), + ); const ev = res.data; const meetLink = ev.hangoutLink ?? diff --git a/lib/provider.ts b/lib/provider.ts index 7270b34..ae8df59 100644 --- a/lib/provider.ts +++ b/lib/provider.ts @@ -6,6 +6,9 @@ import type { Interval } from "./availability"; import { zonedDateParts, zonedWallToUtc } from "./time"; import * as gcal from "./google"; +// Re-exported so routes catch the typed re-auth error via the provider boundary. +export { CalendarAuthError } from "./google"; + export async function fetchBusy(timeMin: string, timeMax: string): Promise { if (isLive()) return gcal.getBusy(timeMin, timeMax); return mockBusy(timeMin); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..aaf6a8c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +// Mirror the tsconfig `@/*` path alias so tests can import route handlers/libs +// the same way app code does. +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./", import.meta.url)), + }, + }, +});