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 && (
-

Connect Garmin

+

+ {status?.connected && status?.warningLevel + ? "Refresh Tokens" + : "Connect Garmin"} +

diff --git a/src/lib/auth-middleware.test.ts b/src/lib/auth-middleware.test.ts index 74189b8..1c3ef80 100644 --- a/src/lib/auth-middleware.test.ts +++ b/src/lib/auth-middleware.test.ts @@ -58,6 +58,7 @@ describe("withAuth", () => { garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: new Date(), + garminRefreshTokenExpiresAt: null, calendarToken: "cal-token", lastPeriodDate: new Date("2025-01-01"), cycleLength: 31, diff --git a/src/lib/garmin-auth.test.ts b/src/lib/garmin-auth.test.ts new file mode 100644 index 0000000..d94c607 --- /dev/null +++ b/src/lib/garmin-auth.test.ts @@ -0,0 +1,146 @@ +// ABOUTME: Unit tests for Garmin OAuth1 to OAuth2 token exchange functionality. +// ABOUTME: Tests access token expiry checks and token exchange logic. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { exchangeOAuth1ForOAuth2, isAccessTokenExpired } from "./garmin-auth"; + +describe("isAccessTokenExpired", () => { + it("returns false when token expires in the future", () => { + const futureDate = new Date(); + futureDate.setHours(futureDate.getHours() + 2); + expect(isAccessTokenExpired(futureDate)).toBe(false); + }); + + it("returns true when token has expired", () => { + const pastDate = new Date(); + pastDate.setHours(pastDate.getHours() - 1); + expect(isAccessTokenExpired(pastDate)).toBe(true); + }); + + it("returns true when token expires within 5 minute buffer", () => { + const nearFutureDate = new Date(); + nearFutureDate.setMinutes(nearFutureDate.getMinutes() + 3); + expect(isAccessTokenExpired(nearFutureDate)).toBe(true); + }); + + it("returns false when token expires beyond 5 minute buffer", () => { + const safeDate = new Date(); + safeDate.setMinutes(safeDate.getMinutes() + 10); + expect(isAccessTokenExpired(safeDate)).toBe(false); + }); + + it("handles ISO string dates", () => { + const futureDate = new Date(); + futureDate.setHours(futureDate.getHours() + 2); + expect(isAccessTokenExpired(futureDate.toISOString())).toBe(false); + }); +}); + +describe("exchangeOAuth1ForOAuth2", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("calls Garmin exchange endpoint with OAuth1 authorization", async () => { + const mockOAuth2Response = { + scope: "test-scope", + jti: "test-jti", + access_token: "new-access-token", + token_type: "Bearer", + refresh_token: "new-refresh-token", + expires_in: 3600, + refresh_token_expires_in: 2592000, + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockOAuth2Response), + }); + + const oauth1Token = { + oauth_token: "test-oauth1-token", + oauth_token_secret: "test-oauth1-secret", + }; + + const result = await exchangeOAuth1ForOAuth2(oauth1Token); + + expect(global.fetch).toHaveBeenCalledWith( + "https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/x-www-form-urlencoded", + Authorization: expect.stringContaining("OAuth"), + }), + }), + ); + + expect(result.oauth2).toEqual(mockOAuth2Response); + expect(result.expires_at).toBeDefined(); + expect(result.refresh_token_expires_at).toBeDefined(); + }); + + it("throws error when exchange fails", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + text: () => Promise.resolve("Unauthorized"), + }); + + const oauth1Token = { + oauth_token: "invalid-token", + oauth_token_secret: "invalid-secret", + }; + + await expect(exchangeOAuth1ForOAuth2(oauth1Token)).rejects.toThrow( + "OAuth exchange failed: 401", + ); + }); + + it("calculates correct expiry timestamps", async () => { + const expiresIn = 3600; // 1 hour + const refreshExpiresIn = 2592000; // 30 days + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + scope: "test-scope", + jti: "test-jti", + access_token: "token", + token_type: "Bearer", + refresh_token: "refresh", + expires_in: expiresIn, + refresh_token_expires_in: refreshExpiresIn, + }), + }); + + const now = Date.now(); + const result = await exchangeOAuth1ForOAuth2({ + oauth_token: "token", + oauth_token_secret: "secret", + }); + + const expiresAt = new Date(result.expires_at).getTime(); + const refreshExpiresAt = new Date( + result.refresh_token_expires_at, + ).getTime(); + + // Allow 5 second tolerance for test execution time + expect(expiresAt).toBeGreaterThanOrEqual(now + expiresIn * 1000 - 5000); + expect(expiresAt).toBeLessThanOrEqual(now + expiresIn * 1000 + 5000); + + expect(refreshExpiresAt).toBeGreaterThanOrEqual( + now + refreshExpiresIn * 1000 - 5000, + ); + expect(refreshExpiresAt).toBeLessThanOrEqual( + now + refreshExpiresIn * 1000 + 5000, + ); + }); +}); diff --git a/src/lib/garmin-auth.ts b/src/lib/garmin-auth.ts new file mode 100644 index 0000000..ec24a2d --- /dev/null +++ b/src/lib/garmin-auth.ts @@ -0,0 +1,114 @@ +// ABOUTME: Garmin OAuth1 to OAuth2 token exchange functionality. +// ABOUTME: Uses OAuth1 tokens to refresh expired OAuth2 access tokens. +import { createHmac } from "node:crypto"; +import OAuth from "oauth-1.0a"; + +import { logger } from "@/lib/logger"; + +const GARMIN_EXCHANGE_URL = + "https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0"; + +const OAUTH_CONSUMER = { + key: "fc3e99d2-118c-44b8-8ae3-03370dde24c0", + secret: "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF", +}; + +export interface OAuth1TokenData { + oauth_token: string; + oauth_token_secret: string; +} + +export interface OAuth2TokenData { + scope: string; + jti: string; + access_token: string; + token_type: string; + refresh_token: string; + expires_in: number; + refresh_token_expires_in: number; +} + +export interface RefreshResult { + oauth2: OAuth2TokenData; + expires_at: string; + refresh_token_expires_at: string; +} + +function hashFunctionSha1(baseString: string, key: string): string { + return createHmac("sha1", key).update(baseString).digest("base64"); +} + +/** + * Exchange OAuth1 token for a fresh OAuth2 token. + * This is how Garmin "refreshes" tokens - by re-exchanging OAuth1 for OAuth2. + * The OAuth1 token lasts ~1 year, OAuth2 access token ~21 hours. + */ +export async function exchangeOAuth1ForOAuth2( + oauth1Token: OAuth1TokenData, +): Promise { + const oauth = new OAuth({ + consumer: OAUTH_CONSUMER, + signature_method: "HMAC-SHA1", + hash_function: hashFunctionSha1, + }); + + const requestData = { + url: GARMIN_EXCHANGE_URL, + method: "POST", + }; + + const token = { + key: oauth1Token.oauth_token, + secret: oauth1Token.oauth_token_secret, + }; + + const authHeader = oauth.toHeader(oauth.authorize(requestData, token)); + + logger.info("Exchanging OAuth1 token for fresh OAuth2 token"); + + const response = await fetch(GARMIN_EXCHANGE_URL, { + method: "POST", + headers: { + ...authHeader, + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + if (!response.ok) { + const text = await response.text(); + logger.error( + { status: response.status, body: text }, + "OAuth1 to OAuth2 exchange failed", + ); + throw new Error(`OAuth exchange failed: ${response.status} - ${text}`); + } + + const oauth2Data = (await response.json()) as OAuth2TokenData; + + const now = Date.now(); + const expiresAt = new Date(now + oauth2Data.expires_in * 1000).toISOString(); + const refreshTokenExpiresAt = new Date( + now + oauth2Data.refresh_token_expires_in * 1000, + ).toISOString(); + + logger.info( + { expiresAt, refreshTokenExpiresAt }, + "OAuth2 token refreshed successfully", + ); + + return { + oauth2: oauth2Data, + expires_at: expiresAt, + refresh_token_expires_at: refreshTokenExpiresAt, + }; +} + +/** + * Check if access token is expired or expiring within the buffer period. + * Buffer is 5 minutes to ensure we refresh before actual expiry. + */ +export function isAccessTokenExpired(expiresAt: Date | string): boolean { + const expiryTime = new Date(expiresAt).getTime(); + const bufferMs = 5 * 60 * 1000; // 5 minutes buffer + return Date.now() >= expiryTime - bufferMs; +} diff --git a/src/lib/garmin.test.ts b/src/lib/garmin.test.ts index d6dd473..8646096 100644 --- a/src/lib/garmin.test.ts +++ b/src/lib/garmin.test.ts @@ -110,6 +110,38 @@ describe("daysUntilExpiry", () => { expect(days).toBeGreaterThanOrEqual(6); expect(days).toBeLessThanOrEqual(7); }); + + it("uses refresh_token_expires_at when available", () => { + const accessExpiry = new Date(); + accessExpiry.setDate(accessExpiry.getDate() + 1); // Access token expires in 1 day + const refreshExpiry = new Date(); + refreshExpiry.setDate(refreshExpiry.getDate() + 30); // Refresh token expires in 30 days + + const tokens: GarminTokens = { + oauth1: "token1", + oauth2: "token2", + expires_at: accessExpiry.toISOString(), + refresh_token_expires_at: refreshExpiry.toISOString(), + }; + const days = daysUntilExpiry(tokens); + // Should use refresh token expiry (30 days), not access token expiry (1 day) + expect(days).toBeGreaterThanOrEqual(29); + expect(days).toBeLessThanOrEqual(30); + }); + + it("falls back to expires_at when refresh_token_expires_at not available", () => { + const accessExpiry = new Date(); + accessExpiry.setDate(accessExpiry.getDate() + 5); + + const tokens: GarminTokens = { + oauth1: "token1", + oauth2: "token2", + expires_at: accessExpiry.toISOString(), + }; + const days = daysUntilExpiry(tokens); + expect(days).toBeGreaterThanOrEqual(4); + expect(days).toBeLessThanOrEqual(5); + }); }); describe("fetchGarminData", () => { diff --git a/src/lib/garmin.ts b/src/lib/garmin.ts index 6e43edc..e779f5c 100644 --- a/src/lib/garmin.ts +++ b/src/lib/garmin.ts @@ -36,8 +36,15 @@ export function isTokenExpired(tokens: GarminTokens): boolean { return expiresAt <= new Date(); } +/** + * Calculate days until refresh token expiry. + * This is what users care about - when they need to re-authenticate. + * Falls back to access token expiry if refresh token expiry not available. + */ export function daysUntilExpiry(tokens: GarminTokens): number { - const expiresAt = new Date(tokens.expires_at); + const expiresAt = tokens.refresh_token_expires_at + ? new Date(tokens.refresh_token_expires_at) + : new Date(tokens.expires_at); const now = new Date(); const diffMs = expiresAt.getTime() - now.getTime(); return Math.floor(diffMs / (1000 * 60 * 60 * 24)); diff --git a/src/lib/pocketbase.test.ts b/src/lib/pocketbase.test.ts index f587046..3691c8f 100644 --- a/src/lib/pocketbase.test.ts +++ b/src/lib/pocketbase.test.ts @@ -68,6 +68,7 @@ describe("getCurrentUser", () => { garminOauth1Token: "encrypted1", garminOauth2Token: "encrypted2", garminTokenExpiresAt: "2025-06-01T00:00:00Z", + garminRefreshTokenExpiresAt: "2025-07-01T00:00:00Z", calendarToken: "cal-token-123", lastPeriodDate: "2025-01-01", cycleLength: 28, @@ -105,6 +106,7 @@ describe("getCurrentUser", () => { garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: "", + garminRefreshTokenExpiresAt: "", calendarToken: "token", lastPeriodDate: "2025-01-15", cycleLength: 31, @@ -139,6 +141,7 @@ describe("getCurrentUser", () => { garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: "not-a-date", + garminRefreshTokenExpiresAt: "also-not-a-date", calendarToken: "token", lastPeriodDate: "", cycleLength: 28, diff --git a/src/lib/pocketbase.ts b/src/lib/pocketbase.ts index 32ff780..9f3dfd8 100644 --- a/src/lib/pocketbase.ts +++ b/src/lib/pocketbase.ts @@ -96,6 +96,7 @@ function mapRecordToUser(record: RecordModel): User { garminOauth1Token: record.garminOauth1Token as string, garminOauth2Token: record.garminOauth2Token as string, garminTokenExpiresAt: parseDate(record.garminTokenExpiresAt), + garminRefreshTokenExpiresAt: parseDate(record.garminRefreshTokenExpiresAt), calendarToken: record.calendarToken as string, lastPeriodDate: parseDate(record.lastPeriodDate), cycleLength: record.cycleLength as number, diff --git a/src/types/index.ts b/src/types/index.ts index d0c60d7..ada6690 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,7 +22,8 @@ export interface User { garminConnected: boolean; garminOauth1Token: string; // encrypted JSON garminOauth2Token: string; // encrypted JSON - garminTokenExpiresAt: Date | null; + garminTokenExpiresAt: Date | null; // access token expiry (~21 hours) + garminRefreshTokenExpiresAt: Date | null; // refresh token expiry (~30 days) // Calendar calendarToken: string; // random secret for ICS URL @@ -87,6 +88,7 @@ export interface GarminTokens { oauth1: string; oauth2: string; expires_at: string; + refresh_token_expires_at?: string; } export interface PhaseConfig {