diff --git a/package.json b/package.json index 5d586b5..7529882 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "lucide-react": "^0.562.0", "next": "16.1.1", "node-cron": "^4.2.1", + "oauth-1.0a": "^2.2.6", "pino": "^10.1.1", "pocketbase": "^0.26.5", "prom-client": "^15.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efcdfe7..7da21aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: node-cron: specifier: ^4.2.1 version: 4.2.1 + oauth-1.0a: + specifier: ^2.2.6 + version: 2.2.6 pino: specifier: ^10.1.1 version: 10.1.1 @@ -1773,6 +1776,9 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + oauth-1.0a@2.2.6: + resolution: {integrity: sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -3445,6 +3451,8 @@ snapshots: node-releases@2.0.27: {} + oauth-1.0a@2.2.6: {} + obug@2.1.1: {} on-exit-leak-free@2.1.2: {} diff --git a/scripts/garmin_auth.py b/scripts/garmin_auth.py index 7e2b691..ba4f198 100644 --- a/scripts/garmin_auth.py +++ b/scripts/garmin_auth.py @@ -45,13 +45,16 @@ oauth1_adapter = TypeAdapter(OAuth1Token) oauth2_adapter = TypeAdapter(OAuth2Token) expires_at_ts = garth.client.oauth2_token.expires_at +refresh_expires_at_ts = garth.client.oauth2_token.refresh_token_expires_at tokens = { "oauth1": oauth1_adapter.dump_python(garth.client.oauth1_token, mode='json'), "oauth2": oauth2_adapter.dump_python(garth.client.oauth2_token, mode='json'), - "expires_at": datetime.fromtimestamp(expires_at_ts).isoformat() + "expires_at": datetime.fromtimestamp(expires_at_ts).isoformat(), + "refresh_token_expires_at": datetime.fromtimestamp(refresh_expires_at_ts).isoformat() } print("\n--- Copy everything below this line ---") print(json.dumps(tokens, indent=2)) print("--- Copy everything above this line ---") -print(f"\nTokens expire: {tokens['expires_at']}") +print(f"\nAccess token expires: {tokens['expires_at']}") +print(f"Refresh token expires: {tokens['refresh_token_expires_at']} (re-run script before this date)") diff --git a/scripts/setup-db.ts b/scripts/setup-db.ts index b455fdc..2b3cd48 100644 --- a/scripts/setup-db.ts +++ b/scripts/setup-db.ts @@ -149,6 +149,7 @@ export const USER_CUSTOM_FIELDS: CollectionField[] = [ { name: "garminOauth1Token", type: "text", max: 20000 }, { name: "garminOauth2Token", type: "text", max: 20000 }, { name: "garminTokenExpiresAt", type: "date" }, + { name: "garminRefreshTokenExpiresAt", type: "date" }, { name: "calendarToken", type: "text" }, { name: "lastPeriodDate", type: "date" }, { name: "cycleLength", type: "number" }, diff --git a/src/app/api/calendar/[userId]/[token].ics/route.test.ts b/src/app/api/calendar/[userId]/[token].ics/route.test.ts index 6b9e364..c79e3ea 100644 --- a/src/app/api/calendar/[userId]/[token].ics/route.test.ts +++ b/src/app/api/calendar/[userId]/[token].ics/route.test.ts @@ -79,6 +79,7 @@ describe("GET /api/calendar/[userId]/[token].ics", () => { garminOauth1Token: "encrypted-token-1", garminOauth2Token: "encrypted-token-2", garminTokenExpiresAt: new Date("2025-06-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "valid-calendar-token-abc123def", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, diff --git a/src/app/api/calendar/regenerate-token/route.test.ts b/src/app/api/calendar/regenerate-token/route.test.ts index 99866b3..4fc682c 100644 --- a/src/app/api/calendar/regenerate-token/route.test.ts +++ b/src/app/api/calendar/regenerate-token/route.test.ts @@ -41,6 +41,7 @@ describe("POST /api/calendar/regenerate-token", () => { garminOauth1Token: "encrypted-token-1", garminOauth2Token: "encrypted-token-2", garminTokenExpiresAt: new Date("2025-06-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "old-calendar-token-abc123", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, diff --git a/src/app/api/cron/garmin-sync/route.test.ts b/src/app/api/cron/garmin-sync/route.test.ts index 3653dfd..d92e636 100644 --- a/src/app/api/cron/garmin-sync/route.test.ts +++ b/src/app/api/cron/garmin-sync/route.test.ts @@ -1,6 +1,6 @@ // ABOUTME: Unit tests for Garmin sync cron endpoint. // ABOUTME: Tests daily sync of Garmin biometric data for all connected users. -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { User } from "@/types"; @@ -8,6 +8,8 @@ import type { User } from "@/types"; let mockUsers: User[] = []; // Track DailyLog creations const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" }); +// Track user updates +const mockPbUpdate = vi.fn().mockResolvedValue({}); // Mock PocketBase vi.mock("@/lib/pocketbase", () => ({ @@ -20,6 +22,7 @@ vi.mock("@/lib/pocketbase", () => ({ return []; }), create: mockPbCreate, + update: mockPbUpdate, })), })), })); @@ -28,7 +31,14 @@ vi.mock("@/lib/pocketbase", () => ({ const mockDecrypt = vi.fn((ciphertext: string) => { // Return mock OAuth2 token JSON if (ciphertext.includes("oauth2")) { - return JSON.stringify({ accessToken: "mock-token-123" }); + return JSON.stringify({ access_token: "mock-token-123" }); + } + // Return mock OAuth1 token JSON (needed for refresh flow) + if (ciphertext.includes("oauth1")) { + return JSON.stringify({ + oauth_token: "mock-oauth1-token", + oauth_token_secret: "mock-oauth1-secret", + }); } return ciphertext.replace("encrypted:", ""); }); @@ -57,10 +67,15 @@ vi.mock("@/lib/garmin", () => ({ // Mock email sending const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined); +const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined); +const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined); vi.mock("@/lib/email", () => ({ sendTokenExpirationWarning: (...args: unknown[]) => mockSendTokenExpirationWarning(...args), + sendDailyEmail: (...args: unknown[]) => mockSendDailyEmail(...args), + sendPeriodConfirmationEmail: (...args: unknown[]) => + mockSendPeriodConfirmationEmail(...args), })); // Mock logger (required for route to run without side effects) @@ -87,6 +102,7 @@ describe("POST /api/cron/garmin-sync", () => { garminOauth1Token: "encrypted:oauth1-token", garminOauth2Token: "encrypted:oauth2-token", garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now + garminRefreshTokenExpiresAt: null, calendarToken: "cal-token", lastPeriodDate: new Date("2025-01-01"), cycleLength: 28, @@ -112,8 +128,10 @@ describe("POST /api/cron/garmin-sync", () => { beforeEach(() => { vi.clearAllMocks(); + vi.resetModules(); mockUsers = []; mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining + mockSendTokenExpirationWarning.mockResolvedValue(undefined); // Reset mock implementation process.env.CRON_SECRET = validSecret; }); @@ -188,9 +206,12 @@ describe("POST /api/cron/garmin-sync", () => { expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token"); }); - it("skips users with expired tokens", async () => { - mockIsTokenExpired.mockReturnValue(true); - mockUsers = [createMockUser()]; + it("skips users with expired refresh tokens", async () => { + // Set refresh token to expired (in the past) + const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago + mockUsers = [ + createMockUser({ garminRefreshTokenExpiresAt: expiredDate }), + ]; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); @@ -415,9 +436,28 @@ describe("POST /api/cron/garmin-sync", () => { }); describe("Token expiration warnings", () => { - it("sends warning email when token expires in exactly 14 days", async () => { - mockUsers = [createMockUser({ email: "user@example.com" })]; - mockDaysUntilExpiry.mockReturnValue(14); + // Use fake timers to ensure consistent date calculations + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-15T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // Helper to create a date N days from now + function daysFromNow(days: number): Date { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000); + } + + it("sends warning email when refresh token expires in exactly 14 days", async () => { + mockUsers = [ + createMockUser({ + email: "user@example.com", + garminRefreshTokenExpiresAt: daysFromNow(14), + }), + ]; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); @@ -430,9 +470,13 @@ describe("POST /api/cron/garmin-sync", () => { expect(body.warningsSent).toBe(1); }); - it("sends warning email when token expires in exactly 7 days", async () => { - mockUsers = [createMockUser({ email: "user@example.com" })]; - mockDaysUntilExpiry.mockReturnValue(7); + it("sends warning email when refresh token expires in exactly 7 days", async () => { + mockUsers = [ + createMockUser({ + email: "user@example.com", + garminRefreshTokenExpiresAt: daysFromNow(7), + }), + ]; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); @@ -445,36 +489,40 @@ describe("POST /api/cron/garmin-sync", () => { expect(body.warningsSent).toBe(1); }); - it("does not send warning when token expires in 30 days", async () => { - mockUsers = [createMockUser()]; - mockDaysUntilExpiry.mockReturnValue(30); + it("does not send warning when refresh token expires in 30 days", async () => { + mockUsers = [ + createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(30) }), + ]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); }); - it("does not send warning when token expires in 15 days", async () => { - mockUsers = [createMockUser()]; - mockDaysUntilExpiry.mockReturnValue(15); + it("does not send warning when refresh token expires in 15 days", async () => { + mockUsers = [ + createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(15) }), + ]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); }); - it("does not send warning when token expires in 8 days", async () => { - mockUsers = [createMockUser()]; - mockDaysUntilExpiry.mockReturnValue(8); + it("does not send warning when refresh token expires in 8 days", async () => { + mockUsers = [ + createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(8) }), + ]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); }); - it("does not send warning when token expires in 6 days", async () => { - mockUsers = [createMockUser()]; - mockDaysUntilExpiry.mockReturnValue(6); + it("does not send warning when refresh token expires in 6 days", async () => { + mockUsers = [ + createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(6) }), + ]; await POST(createMockRequest(`Bearer ${validSecret}`)); @@ -483,11 +531,17 @@ describe("POST /api/cron/garmin-sync", () => { it("sends warnings for multiple users on different thresholds", async () => { mockUsers = [ - createMockUser({ id: "user1", email: "user1@example.com" }), - createMockUser({ id: "user2", email: "user2@example.com" }), + createMockUser({ + id: "user1", + email: "user1@example.com", + garminRefreshTokenExpiresAt: daysFromNow(14), + }), + createMockUser({ + id: "user2", + email: "user2@example.com", + garminRefreshTokenExpiresAt: daysFromNow(7), + }), ]; - // First user at 14 days, second user at 7 days - mockDaysUntilExpiry.mockReturnValueOnce(14).mockReturnValueOnce(7); const response = await POST(createMockRequest(`Bearer ${validSecret}`)); @@ -507,8 +561,12 @@ describe("POST /api/cron/garmin-sync", () => { }); it("continues processing sync even if warning email fails", async () => { - mockUsers = [createMockUser({ email: "user@example.com" })]; - mockDaysUntilExpiry.mockReturnValue(14); + mockUsers = [ + createMockUser({ + email: "user@example.com", + garminRefreshTokenExpiresAt: daysFromNow(14), + }), + ]; mockSendTokenExpirationWarning.mockRejectedValueOnce( new Error("Email failed"), ); @@ -520,10 +578,12 @@ describe("POST /api/cron/garmin-sync", () => { expect(body.usersProcessed).toBe(1); }); - it("does not send warning for expired tokens", async () => { - mockUsers = [createMockUser()]; - mockIsTokenExpired.mockReturnValue(true); - mockDaysUntilExpiry.mockReturnValue(-1); + it("does not send warning for expired refresh tokens", async () => { + // Expired refresh tokens are skipped entirely (not synced), so no warning + const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago + mockUsers = [ + createMockUser({ garminRefreshTokenExpiresAt: expiredDate }), + ]; await POST(createMockRequest(`Bearer ${validSecret}`)); diff --git a/src/app/api/cron/garmin-sync/route.ts b/src/app/api/cron/garmin-sync/route.ts index f064e73..930c52d 100644 --- a/src/app/api/cron/garmin-sync/route.ts +++ b/src/app/api/cron/garmin-sync/route.ts @@ -5,14 +5,17 @@ import { NextResponse } from "next/server"; import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle"; import { getDecisionWithOverrides } from "@/lib/decision-engine"; import { sendTokenExpirationWarning } from "@/lib/email"; -import { decrypt } from "@/lib/encryption"; +import { decrypt, encrypt } from "@/lib/encryption"; import { - daysUntilExpiry, fetchBodyBattery, fetchHrvStatus, fetchIntensityMinutes, - isTokenExpired, } from "@/lib/garmin"; +import { + exchangeOAuth1ForOAuth2, + isAccessTokenExpired, + type OAuth1TokenData, +} from "@/lib/garmin-auth"; import { logger } from "@/lib/logger"; import { activeUsersGauge, @@ -20,13 +23,14 @@ import { garminSyncTotal, } from "@/lib/metrics"; import { createPocketBaseClient } from "@/lib/pocketbase"; -import type { GarminTokens, User } from "@/types"; +import type { User } from "@/types"; interface SyncResult { success: boolean; usersProcessed: number; errors: number; skippedExpired: number; + tokensRefreshed: number; warningsSent: number; timestamp: string; } @@ -47,6 +51,7 @@ export async function POST(request: Request) { usersProcessed: 0, errors: 0, skippedExpired: 0, + tokensRefreshed: 0, warningsSent: 0, timestamp: new Date().toISOString(), }; @@ -66,38 +71,81 @@ export async function POST(request: Request) { const userSyncStartTime = Date.now(); try { - // Check if tokens are expired + // Check if refresh token is expired (user needs to re-auth via Python script) // Note: garminTokenExpiresAt and lastPeriodDate are guaranteed non-null by filter above - const tokens: GarminTokens = { - oauth1: user.garminOauth1Token, - oauth2: user.garminOauth2Token, - // biome-ignore lint/style/noNonNullAssertion: filtered above - expires_at: user.garminTokenExpiresAt!.toISOString(), - }; - - if (isTokenExpired(tokens)) { - result.skippedExpired++; - continue; + if (user.garminRefreshTokenExpiresAt) { + const refreshTokenExpired = + new Date(user.garminRefreshTokenExpiresAt) <= new Date(); + if (refreshTokenExpired) { + logger.info( + { userId: user.id }, + "Refresh token expired, skipping user", + ); + result.skippedExpired++; + continue; + } } // Log sync start logger.info({ userId: user.id }, "Garmin sync start"); - // Check for token expiration warnings (exactly 14 or 7 days) - const daysRemaining = daysUntilExpiry(tokens); - if (daysRemaining === 14 || daysRemaining === 7) { - try { - await sendTokenExpirationWarning(user.email, daysRemaining, user.id); - result.warningsSent++; - } catch { - // Continue processing even if warning email fails + // Check for refresh token expiration warnings (exactly 14 or 7 days) + if (user.garminRefreshTokenExpiresAt) { + const refreshExpiry = new Date(user.garminRefreshTokenExpiresAt); + const now = new Date(); + const diffMs = refreshExpiry.getTime() - now.getTime(); + const daysRemaining = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (daysRemaining === 14 || daysRemaining === 7) { + try { + await sendTokenExpirationWarning( + user.email, + daysRemaining, + user.id, + ); + result.warningsSent++; + } catch { + // Continue processing even if warning email fails + } } } - // Decrypt OAuth2 token + // Decrypt tokens + const oauth1Json = decrypt(user.garminOauth1Token); + const oauth1Data = JSON.parse(oauth1Json) as OAuth1TokenData; const oauth2Json = decrypt(user.garminOauth2Token); - const oauth2Data = JSON.parse(oauth2Json); - const accessToken = oauth2Data.accessToken; + let oauth2Data = JSON.parse(oauth2Json); + + // Check if access token needs refresh + // biome-ignore lint/style/noNonNullAssertion: filtered above + const accessTokenExpiresAt = user.garminTokenExpiresAt!; + if (isAccessTokenExpired(accessTokenExpiresAt)) { + logger.info({ userId: user.id }, "Access token expired, refreshing"); + try { + const refreshResult = await exchangeOAuth1ForOAuth2(oauth1Data); + oauth2Data = refreshResult.oauth2; + + // Update stored tokens + const encryptedOauth2 = encrypt(JSON.stringify(oauth2Data)); + await pb.collection("users").update(user.id, { + garminOauth2Token: encryptedOauth2, + garminTokenExpiresAt: refreshResult.expires_at, + garminRefreshTokenExpiresAt: refreshResult.refresh_token_expires_at, + }); + + result.tokensRefreshed++; + logger.info({ userId: user.id }, "Access token refreshed"); + } catch (refreshError) { + logger.error( + { userId: user.id, err: refreshError }, + "Failed to refresh access token", + ); + result.errors++; + garminSyncTotal.inc({ status: "failure" }); + continue; + } + } + + const accessToken = oauth2Data.access_token; // Fetch Garmin data const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([ diff --git a/src/app/api/cron/notifications/route.test.ts b/src/app/api/cron/notifications/route.test.ts index 7b87d92..c73c89f 100644 --- a/src/app/api/cron/notifications/route.test.ts +++ b/src/app/api/cron/notifications/route.test.ts @@ -29,9 +29,15 @@ vi.mock("@/lib/pocketbase", () => ({ // Mock email sending const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined); +const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined); +const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined); vi.mock("@/lib/email", () => ({ sendDailyEmail: (data: unknown) => mockSendDailyEmail(data), + sendTokenExpirationWarning: (...args: unknown[]) => + mockSendTokenExpirationWarning(...args), + sendPeriodConfirmationEmail: (...args: unknown[]) => + mockSendPeriodConfirmationEmail(...args), })); import { POST } from "./route"; @@ -48,6 +54,7 @@ describe("POST /api/cron/notifications", () => { garminOauth1Token: "encrypted:oauth1-token", garminOauth2Token: "encrypted:oauth2-token", garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-token", lastPeriodDate: new Date("2025-01-01"), cycleLength: 28, diff --git a/src/app/api/cycle/current/route.test.ts b/src/app/api/cycle/current/route.test.ts index 2484b8c..12acbfc 100644 --- a/src/app/api/cycle/current/route.test.ts +++ b/src/app/api/cycle/current/route.test.ts @@ -59,6 +59,7 @@ describe("GET /api/cycle/current", () => { garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: new Date("2025-06-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-01"), cycleLength: 31, diff --git a/src/app/api/cycle/period/route.test.ts b/src/app/api/cycle/period/route.test.ts index 6ae28ef..f46cc80 100644 --- a/src/app/api/cycle/period/route.test.ts +++ b/src/app/api/cycle/period/route.test.ts @@ -43,6 +43,7 @@ describe("POST /api/cycle/period", () => { garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: new Date("2025-06-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2024-12-15"), cycleLength: 28, diff --git a/src/app/api/garmin/status/route.test.ts b/src/app/api/garmin/status/route.test.ts index 014a334..06936c5 100644 --- a/src/app/api/garmin/status/route.test.ts +++ b/src/app/api/garmin/status/route.test.ts @@ -71,6 +71,7 @@ describe("GET /api/garmin/status", () => { garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: new Date("2025-01-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, @@ -100,6 +101,7 @@ describe("GET /api/garmin/status", () => { garminOauth1Token: "encrypted-token", garminOauth2Token: "encrypted-token", garminTokenExpiresAt: futureDate, + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, @@ -129,6 +131,7 @@ describe("GET /api/garmin/status", () => { garminOauth1Token: "encrypted-token", garminOauth2Token: "encrypted-token", garminTokenExpiresAt: futureDate, + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, @@ -156,6 +159,7 @@ describe("GET /api/garmin/status", () => { garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: new Date("2025-01-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, @@ -185,6 +189,7 @@ describe("GET /api/garmin/status", () => { garminOauth1Token: "encrypted-token", garminOauth2Token: "encrypted-token", garminTokenExpiresAt: pastDate, + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, @@ -216,6 +221,7 @@ describe("GET /api/garmin/status", () => { garminOauth1Token: "encrypted-token", garminOauth2Token: "encrypted-token", garminTokenExpiresAt: futureDate, + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, @@ -245,6 +251,7 @@ describe("GET /api/garmin/status", () => { garminOauth1Token: "encrypted-token", garminOauth2Token: "encrypted-token", garminTokenExpiresAt: futureDate, + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, @@ -274,6 +281,7 @@ describe("GET /api/garmin/status", () => { garminOauth1Token: "encrypted-token", garminOauth2Token: "encrypted-token", garminTokenExpiresAt: futureDate, + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, @@ -303,6 +311,7 @@ describe("GET /api/garmin/status", () => { garminOauth1Token: "encrypted-token", garminOauth2Token: "encrypted-token", garminTokenExpiresAt: futureDate, + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, @@ -329,6 +338,7 @@ describe("GET /api/garmin/status", () => { garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: new Date("2025-01-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, diff --git a/src/app/api/garmin/status/route.ts b/src/app/api/garmin/status/route.ts index 5087ce4..4ceefda 100644 --- a/src/app/api/garmin/status/route.ts +++ b/src/app/api/garmin/status/route.ts @@ -12,26 +12,41 @@ export const GET = withAuth(async (_request, user, pb) => { const connected = freshUser.garminConnected === true; if (!connected) { - return NextResponse.json({ - connected: false, - daysUntilExpiry: null, - expired: false, - warningLevel: null, - }); + return NextResponse.json( + { + connected: false, + daysUntilExpiry: null, + expired: false, + warningLevel: null, + }, + { + headers: { "Cache-Control": "no-store, no-cache, must-revalidate" }, + }, + ); } - const expiresAt = freshUser.garminTokenExpiresAt + // Use refresh token expiry for user-facing warnings (when they need to re-auth) + // Fall back to access token expiry if refresh expiry not set + const refreshTokenExpiresAt = freshUser.garminRefreshTokenExpiresAt + ? String(freshUser.garminRefreshTokenExpiresAt) + : ""; + const accessTokenExpiresAt = freshUser.garminTokenExpiresAt ? String(freshUser.garminTokenExpiresAt) : ""; const tokens = { oauth1: "", oauth2: "", - expires_at: expiresAt, + expires_at: accessTokenExpiresAt, + refresh_token_expires_at: refreshTokenExpiresAt || undefined, }; const days = daysUntilExpiry(tokens); - const expired = isTokenExpired(tokens); + + // Check if refresh token is expired (user needs to re-authenticate) + const expired = refreshTokenExpiresAt + ? new Date(refreshTokenExpiresAt) <= new Date() + : isTokenExpired(tokens); let warningLevel: "warning" | "critical" | null = null; if (days <= 7) { @@ -40,10 +55,15 @@ export const GET = withAuth(async (_request, user, pb) => { warningLevel = "warning"; } - return NextResponse.json({ - connected: true, - daysUntilExpiry: days, - expired, - warningLevel, - }); + return NextResponse.json( + { + connected: true, + daysUntilExpiry: days, + expired, + warningLevel, + }, + { + headers: { "Cache-Control": "no-store, no-cache, must-revalidate" }, + }, + ); }); diff --git a/src/app/api/garmin/tokens/route.test.ts b/src/app/api/garmin/tokens/route.test.ts index 4a94058..326ea77 100644 --- a/src/app/api/garmin/tokens/route.test.ts +++ b/src/app/api/garmin/tokens/route.test.ts @@ -53,6 +53,7 @@ describe("POST /api/garmin/tokens", () => { garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: new Date("2025-01-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, @@ -141,6 +142,7 @@ describe("POST /api/garmin/tokens", () => { garminOauth1Token: `encrypted:${JSON.stringify(oauth1)}`, garminOauth2Token: `encrypted:${JSON.stringify(oauth2)}`, garminTokenExpiresAt: expiresAt, + garminRefreshTokenExpiresAt: expect.any(String), garminConnected: true, }); }); @@ -267,6 +269,7 @@ describe("DELETE /api/garmin/tokens", () => { garminOauth1Token: "encrypted-token-1", garminOauth2Token: "encrypted-token-2", garminTokenExpiresAt: new Date("2025-06-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, @@ -304,6 +307,7 @@ describe("DELETE /api/garmin/tokens", () => { garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: null, + garminRefreshTokenExpiresAt: null, garminConnected: false, }); }); diff --git a/src/app/api/garmin/tokens/route.ts b/src/app/api/garmin/tokens/route.ts index 59dfeba..831e08d 100644 --- a/src/app/api/garmin/tokens/route.ts +++ b/src/app/api/garmin/tokens/route.ts @@ -9,7 +9,7 @@ import { logger } from "@/lib/logger"; export const POST = withAuth(async (request, user, pb) => { const body = await request.json(); - const { oauth1, oauth2, expires_at } = body; + const { oauth1, oauth2, expires_at, refresh_token_expires_at } = body; // Validate required fields if (!oauth1) { @@ -52,6 +52,23 @@ export const POST = withAuth(async (request, user, pb) => { ); } + // Validate refresh_token_expires_at if provided + let refreshTokenExpiresAt = refresh_token_expires_at; + if (refreshTokenExpiresAt) { + const refreshExpiryDate = new Date(refreshTokenExpiresAt); + if (Number.isNaN(refreshExpiryDate.getTime())) { + return NextResponse.json( + { error: "refresh_token_expires_at must be a valid date" }, + { status: 400 }, + ); + } + } else { + // If not provided, estimate refresh token expiry as ~30 days from now + refreshTokenExpiresAt = new Date( + Date.now() + 30 * 24 * 60 * 60 * 1000, + ).toISOString(); + } + // Encrypt tokens before storing const encryptedOauth1 = encrypt(JSON.stringify(oauth1)); const encryptedOauth2 = encrypt(JSON.stringify(oauth2)); @@ -61,6 +78,7 @@ export const POST = withAuth(async (request, user, pb) => { garminOauth1Token: encryptedOauth1, garminOauth2Token: encryptedOauth2, garminTokenExpiresAt: expires_at, + garminRefreshTokenExpiresAt: refreshTokenExpiresAt, garminConnected: true, }); @@ -84,11 +102,12 @@ export const POST = withAuth(async (request, user, pb) => { ); } - // Calculate days until expiry + // Calculate days until refresh token expiry (what users care about) const expiryDays = daysUntilExpiry({ oauth1: "", oauth2: "", expires_at, + refresh_token_expires_at: refreshTokenExpiresAt, }); return NextResponse.json({ @@ -103,6 +122,7 @@ export const DELETE = withAuth(async (_request, user, pb) => { garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: null, + garminRefreshTokenExpiresAt: null, garminConnected: false, }); diff --git a/src/app/api/history/route.test.ts b/src/app/api/history/route.test.ts index eac9b01..7779ead 100644 --- a/src/app/api/history/route.test.ts +++ b/src/app/api/history/route.test.ts @@ -41,6 +41,7 @@ describe("GET /api/history", () => { garminOauth1Token: "encrypted-token-1", garminOauth2Token: "encrypted-token-2", garminTokenExpiresAt: new Date("2025-06-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, diff --git a/src/app/api/overrides/route.test.ts b/src/app/api/overrides/route.test.ts index 3266548..a62982c 100644 --- a/src/app/api/overrides/route.test.ts +++ b/src/app/api/overrides/route.test.ts @@ -55,6 +55,7 @@ describe("POST /api/overrides", () => { garminOauth1Token: "encrypted-token-1", garminOauth2Token: "encrypted-token-2", garminTokenExpiresAt: new Date("2025-06-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, @@ -187,6 +188,7 @@ describe("DELETE /api/overrides", () => { garminOauth1Token: "encrypted-token-1", garminOauth2Token: "encrypted-token-2", garminTokenExpiresAt: new Date("2025-06-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, diff --git a/src/app/api/period-history/route.test.ts b/src/app/api/period-history/route.test.ts index 24934f5..e83574d 100644 --- a/src/app/api/period-history/route.test.ts +++ b/src/app/api/period-history/route.test.ts @@ -41,6 +41,7 @@ describe("GET /api/period-history", () => { garminOauth1Token: "encrypted-token-1", garminOauth2Token: "encrypted-token-2", garminTokenExpiresAt: new Date("2025-06-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, diff --git a/src/app/api/period-logs/[id]/route.test.ts b/src/app/api/period-logs/[id]/route.test.ts index 84b73ea..0020f28 100644 --- a/src/app/api/period-logs/[id]/route.test.ts +++ b/src/app/api/period-logs/[id]/route.test.ts @@ -50,6 +50,7 @@ describe("PATCH /api/period-logs/[id]", () => { garminOauth1Token: "encrypted-token-1", garminOauth2Token: "encrypted-token-2", garminTokenExpiresAt: new Date("2025-06-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, @@ -276,6 +277,7 @@ describe("DELETE /api/period-logs/[id]", () => { garminOauth1Token: "encrypted-token-1", garminOauth2Token: "encrypted-token-2", garminTokenExpiresAt: new Date("2025-06-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, diff --git a/src/app/api/today/route.test.ts b/src/app/api/today/route.test.ts index 8ef8f93..df88f4c 100644 --- a/src/app/api/today/route.test.ts +++ b/src/app/api/today/route.test.ts @@ -48,6 +48,7 @@ describe("GET /api/today", () => { garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: new Date("2025-06-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-01"), cycleLength: 31, diff --git a/src/app/api/user/route.test.ts b/src/app/api/user/route.test.ts index 3dc840d..d2b0c4a 100644 --- a/src/app/api/user/route.test.ts +++ b/src/app/api/user/route.test.ts @@ -59,6 +59,7 @@ describe("GET /api/user", () => { garminOauth1Token: "encrypted-token-1", garminOauth2Token: "encrypted-token-2", garminTokenExpiresAt: new Date("2025-06-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, @@ -138,6 +139,7 @@ describe("PATCH /api/user", () => { garminOauth1Token: "encrypted-token-1", garminOauth2Token: "encrypted-token-2", garminTokenExpiresAt: new Date("2025-06-01"), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index c43c082..ba5a994 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -26,16 +26,21 @@ export const GET = withAuth(async (_request, user, pb) => { ? new Date(freshUser.lastPeriodDate as string).toISOString().split("T")[0] : null; - return NextResponse.json({ - id: freshUser.id, - email: freshUser.email, - garminConnected: freshUser.garminConnected ?? false, - cycleLength: freshUser.cycleLength, - lastPeriodDate, - notificationTime: freshUser.notificationTime, - timezone: freshUser.timezone, - activeOverrides: freshUser.activeOverrides ?? [], - }); + return NextResponse.json( + { + id: freshUser.id, + email: freshUser.email, + garminConnected: freshUser.garminConnected ?? false, + cycleLength: freshUser.cycleLength, + lastPeriodDate, + notificationTime: freshUser.notificationTime, + timezone: freshUser.timezone, + activeOverrides: freshUser.activeOverrides ?? [], + }, + { + headers: { "Cache-Control": "no-store, no-cache, must-revalidate" }, + }, + ); }); /** diff --git a/src/app/settings/garmin/page.tsx b/src/app/settings/garmin/page.tsx index ee57ba9..2781d91 100644 --- a/src/app/settings/garmin/page.tsx +++ b/src/app/settings/garmin/page.tsx @@ -139,7 +139,12 @@ export default function GarminSettingsPage() { } }; - const showTokenInput = !status?.connected || status?.expired; + // Show token input when: + // - Not connected + // - Token expired + // - Warning level active (so user can proactively paste new tokens) + const showTokenInput = + !status?.connected || status?.expired || status?.warningLevel; if (loading) { return ( @@ -242,7 +247,11 @@ export default function GarminSettingsPage() { {/* Token Input Section */} {showTokenInput && (