Implement automatic Garmin token refresh and fix expiry tracking

- Add OAuth1 to OAuth2 token exchange using Garmin's exchange endpoint
- Track refresh token expiry (~30 days) instead of access token expiry (~21 hours)
- Auto-refresh access tokens in cron sync before they expire
- Update Python script to output refresh_token_expires_at
- Add garminRefreshTokenExpiresAt field to User type and database schema
- Fix token input UX: show when warning active, not just when disconnected
- Add Cache-Control headers to /api/user and /api/garmin/status to prevent stale data
- Add oauth-1.0a package for OAuth1 signature generation

The system now automatically refreshes OAuth2 tokens using the stored OAuth1 token,
so users only need to re-run the Python auth script every ~30 days (when refresh
token expires) instead of every ~21 hours (when access token expires).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-14 20:33:10 +00:00
parent 6df145d916
commit b221acee40
31 changed files with 607 additions and 92 deletions

View File

@@ -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",

8
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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)")

View File

@@ -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" },

View File

@@ -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,

View File

@@ -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,

View File

@@ -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}`));

View File

@@ -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)) {
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);
// 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);
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([

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -12,26 +12,41 @@ export const GET = withAuth(async (_request, user, pb) => {
const connected = freshUser.garminConnected === true;
if (!connected) {
return NextResponse.json({
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({
return NextResponse.json(
{
connected: true,
daysUntilExpiry: days,
expired,
warningLevel,
});
},
{
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
},
);
});

View File

@@ -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,
});
});

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -26,7 +26,8 @@ export const GET = withAuth(async (_request, user, pb) => {
? new Date(freshUser.lastPeriodDate as string).toISOString().split("T")[0]
: null;
return NextResponse.json({
return NextResponse.json(
{
id: freshUser.id,
email: freshUser.email,
garminConnected: freshUser.garminConnected ?? false,
@@ -35,7 +36,11 @@ export const GET = withAuth(async (_request, user, pb) => {
notificationTime: freshUser.notificationTime,
timezone: freshUser.timezone,
activeOverrides: freshUser.activeOverrides ?? [],
});
},
{
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
},
);
});
/**

View File

@@ -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 && (
<div className="border border-input rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Connect Garmin</h2>
<h2 className="text-lg font-semibold mb-4">
{status?.connected && status?.warningLevel
? "Refresh Tokens"
: "Connect Garmin"}
</h2>
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-400 px-4 py-3 rounded text-sm">

View File

@@ -58,6 +58,7 @@ describe("withAuth", () => {
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: new Date(),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-token",
lastPeriodDate: new Date("2025-01-01"),
cycleLength: 31,

146
src/lib/garmin-auth.test.ts Normal file
View File

@@ -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,
);
});
});

114
src/lib/garmin-auth.ts Normal file
View File

@@ -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<RefreshResult> {
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;
}

View File

@@ -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", () => {

View File

@@ -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));

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {