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:
@@ -23,6 +23,7 @@
|
|||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"oauth-1.0a": "^2.2.6",
|
||||||
"pino": "^10.1.1",
|
"pino": "^10.1.1",
|
||||||
"pocketbase": "^0.26.5",
|
"pocketbase": "^0.26.5",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
|||||||
node-cron:
|
node-cron:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
|
oauth-1.0a:
|
||||||
|
specifier: ^2.2.6
|
||||||
|
version: 2.2.6
|
||||||
pino:
|
pino:
|
||||||
specifier: ^10.1.1
|
specifier: ^10.1.1
|
||||||
version: 10.1.1
|
version: 10.1.1
|
||||||
@@ -1773,6 +1776,9 @@ packages:
|
|||||||
node-releases@2.0.27:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
|
|
||||||
|
oauth-1.0a@2.2.6:
|
||||||
|
resolution: {integrity: sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==}
|
||||||
|
|
||||||
obug@2.1.1:
|
obug@2.1.1:
|
||||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||||
|
|
||||||
@@ -3445,6 +3451,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
|
oauth-1.0a@2.2.6: {}
|
||||||
|
|
||||||
obug@2.1.1: {}
|
obug@2.1.1: {}
|
||||||
|
|
||||||
on-exit-leak-free@2.1.2: {}
|
on-exit-leak-free@2.1.2: {}
|
||||||
|
|||||||
@@ -45,13 +45,16 @@ oauth1_adapter = TypeAdapter(OAuth1Token)
|
|||||||
oauth2_adapter = TypeAdapter(OAuth2Token)
|
oauth2_adapter = TypeAdapter(OAuth2Token)
|
||||||
|
|
||||||
expires_at_ts = garth.client.oauth2_token.expires_at
|
expires_at_ts = garth.client.oauth2_token.expires_at
|
||||||
|
refresh_expires_at_ts = garth.client.oauth2_token.refresh_token_expires_at
|
||||||
tokens = {
|
tokens = {
|
||||||
"oauth1": oauth1_adapter.dump_python(garth.client.oauth1_token, mode='json'),
|
"oauth1": oauth1_adapter.dump_python(garth.client.oauth1_token, mode='json'),
|
||||||
"oauth2": oauth2_adapter.dump_python(garth.client.oauth2_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("\n--- Copy everything below this line ---")
|
||||||
print(json.dumps(tokens, indent=2))
|
print(json.dumps(tokens, indent=2))
|
||||||
print("--- Copy everything above this line ---")
|
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)")
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ export const USER_CUSTOM_FIELDS: CollectionField[] = [
|
|||||||
{ name: "garminOauth1Token", type: "text", max: 20000 },
|
{ name: "garminOauth1Token", type: "text", max: 20000 },
|
||||||
{ name: "garminOauth2Token", type: "text", max: 20000 },
|
{ name: "garminOauth2Token", type: "text", max: 20000 },
|
||||||
{ name: "garminTokenExpiresAt", type: "date" },
|
{ name: "garminTokenExpiresAt", type: "date" },
|
||||||
|
{ name: "garminRefreshTokenExpiresAt", type: "date" },
|
||||||
{ name: "calendarToken", type: "text" },
|
{ name: "calendarToken", type: "text" },
|
||||||
{ name: "lastPeriodDate", type: "date" },
|
{ name: "lastPeriodDate", type: "date" },
|
||||||
{ name: "cycleLength", type: "number" },
|
{ name: "cycleLength", type: "number" },
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ describe("GET /api/calendar/[userId]/[token].ics", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "valid-calendar-token-abc123def",
|
calendarToken: "valid-calendar-token-abc123def",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ describe("POST /api/calendar/regenerate-token", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "old-calendar-token-abc123",
|
calendarToken: "old-calendar-token-abc123",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// ABOUTME: Unit tests for Garmin sync cron endpoint.
|
// ABOUTME: Unit tests for Garmin sync cron endpoint.
|
||||||
// ABOUTME: Tests daily sync of Garmin biometric data for all connected users.
|
// 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";
|
import type { User } from "@/types";
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ import type { User } from "@/types";
|
|||||||
let mockUsers: User[] = [];
|
let mockUsers: User[] = [];
|
||||||
// Track DailyLog creations
|
// Track DailyLog creations
|
||||||
const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" });
|
const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" });
|
||||||
|
// Track user updates
|
||||||
|
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
||||||
|
|
||||||
// Mock PocketBase
|
// Mock PocketBase
|
||||||
vi.mock("@/lib/pocketbase", () => ({
|
vi.mock("@/lib/pocketbase", () => ({
|
||||||
@@ -20,6 +22,7 @@ vi.mock("@/lib/pocketbase", () => ({
|
|||||||
return [];
|
return [];
|
||||||
}),
|
}),
|
||||||
create: mockPbCreate,
|
create: mockPbCreate,
|
||||||
|
update: mockPbUpdate,
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
@@ -28,7 +31,14 @@ vi.mock("@/lib/pocketbase", () => ({
|
|||||||
const mockDecrypt = vi.fn((ciphertext: string) => {
|
const mockDecrypt = vi.fn((ciphertext: string) => {
|
||||||
// Return mock OAuth2 token JSON
|
// Return mock OAuth2 token JSON
|
||||||
if (ciphertext.includes("oauth2")) {
|
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:", "");
|
return ciphertext.replace("encrypted:", "");
|
||||||
});
|
});
|
||||||
@@ -57,10 +67,15 @@ vi.mock("@/lib/garmin", () => ({
|
|||||||
|
|
||||||
// Mock email sending
|
// Mock email sending
|
||||||
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
|
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
vi.mock("@/lib/email", () => ({
|
vi.mock("@/lib/email", () => ({
|
||||||
sendTokenExpirationWarning: (...args: unknown[]) =>
|
sendTokenExpirationWarning: (...args: unknown[]) =>
|
||||||
mockSendTokenExpirationWarning(...args),
|
mockSendTokenExpirationWarning(...args),
|
||||||
|
sendDailyEmail: (...args: unknown[]) => mockSendDailyEmail(...args),
|
||||||
|
sendPeriodConfirmationEmail: (...args: unknown[]) =>
|
||||||
|
mockSendPeriodConfirmationEmail(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock logger (required for route to run without side effects)
|
// Mock logger (required for route to run without side effects)
|
||||||
@@ -87,6 +102,7 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
garminOauth1Token: "encrypted:oauth1-token",
|
garminOauth1Token: "encrypted:oauth1-token",
|
||||||
garminOauth2Token: "encrypted:oauth2-token",
|
garminOauth2Token: "encrypted:oauth2-token",
|
||||||
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-token",
|
calendarToken: "cal-token",
|
||||||
lastPeriodDate: new Date("2025-01-01"),
|
lastPeriodDate: new Date("2025-01-01"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -112,8 +128,10 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
mockUsers = [];
|
mockUsers = [];
|
||||||
mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining
|
mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining
|
||||||
|
mockSendTokenExpirationWarning.mockResolvedValue(undefined); // Reset mock implementation
|
||||||
process.env.CRON_SECRET = validSecret;
|
process.env.CRON_SECRET = validSecret;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,9 +206,12 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token");
|
expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips users with expired tokens", async () => {
|
it("skips users with expired refresh tokens", async () => {
|
||||||
mockIsTokenExpired.mockReturnValue(true);
|
// Set refresh token to expired (in the past)
|
||||||
mockUsers = [createMockUser()];
|
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||||
|
mockUsers = [
|
||||||
|
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
|
||||||
|
];
|
||||||
|
|
||||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
@@ -415,9 +436,28 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Token expiration warnings", () => {
|
describe("Token expiration warnings", () => {
|
||||||
it("sends warning email when token expires in exactly 14 days", async () => {
|
// Use fake timers to ensure consistent date calculations
|
||||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
beforeEach(() => {
|
||||||
mockDaysUntilExpiry.mockReturnValue(14);
|
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}`));
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
@@ -430,9 +470,13 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
expect(body.warningsSent).toBe(1);
|
expect(body.warningsSent).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends warning email when token expires in exactly 7 days", async () => {
|
it("sends warning email when refresh token expires in exactly 7 days", async () => {
|
||||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
mockUsers = [
|
||||||
mockDaysUntilExpiry.mockReturnValue(7);
|
createMockUser({
|
||||||
|
email: "user@example.com",
|
||||||
|
garminRefreshTokenExpiresAt: daysFromNow(7),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
@@ -445,36 +489,40 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
expect(body.warningsSent).toBe(1);
|
expect(body.warningsSent).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send warning when token expires in 30 days", async () => {
|
it("does not send warning when refresh token expires in 30 days", async () => {
|
||||||
mockUsers = [createMockUser()];
|
mockUsers = [
|
||||||
mockDaysUntilExpiry.mockReturnValue(30);
|
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(30) }),
|
||||||
|
];
|
||||||
|
|
||||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send warning when token expires in 15 days", async () => {
|
it("does not send warning when refresh token expires in 15 days", async () => {
|
||||||
mockUsers = [createMockUser()];
|
mockUsers = [
|
||||||
mockDaysUntilExpiry.mockReturnValue(15);
|
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(15) }),
|
||||||
|
];
|
||||||
|
|
||||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send warning when token expires in 8 days", async () => {
|
it("does not send warning when refresh token expires in 8 days", async () => {
|
||||||
mockUsers = [createMockUser()];
|
mockUsers = [
|
||||||
mockDaysUntilExpiry.mockReturnValue(8);
|
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(8) }),
|
||||||
|
];
|
||||||
|
|
||||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send warning when token expires in 6 days", async () => {
|
it("does not send warning when refresh token expires in 6 days", async () => {
|
||||||
mockUsers = [createMockUser()];
|
mockUsers = [
|
||||||
mockDaysUntilExpiry.mockReturnValue(6);
|
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(6) }),
|
||||||
|
];
|
||||||
|
|
||||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
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 () => {
|
it("sends warnings for multiple users on different thresholds", async () => {
|
||||||
mockUsers = [
|
mockUsers = [
|
||||||
createMockUser({ id: "user1", email: "user1@example.com" }),
|
createMockUser({
|
||||||
createMockUser({ id: "user2", email: "user2@example.com" }),
|
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}`));
|
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 () => {
|
it("continues processing sync even if warning email fails", async () => {
|
||||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
mockUsers = [
|
||||||
mockDaysUntilExpiry.mockReturnValue(14);
|
createMockUser({
|
||||||
|
email: "user@example.com",
|
||||||
|
garminRefreshTokenExpiresAt: daysFromNow(14),
|
||||||
|
}),
|
||||||
|
];
|
||||||
mockSendTokenExpirationWarning.mockRejectedValueOnce(
|
mockSendTokenExpirationWarning.mockRejectedValueOnce(
|
||||||
new Error("Email failed"),
|
new Error("Email failed"),
|
||||||
);
|
);
|
||||||
@@ -520,10 +578,12 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
expect(body.usersProcessed).toBe(1);
|
expect(body.usersProcessed).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send warning for expired tokens", async () => {
|
it("does not send warning for expired refresh tokens", async () => {
|
||||||
mockUsers = [createMockUser()];
|
// Expired refresh tokens are skipped entirely (not synced), so no warning
|
||||||
mockIsTokenExpired.mockReturnValue(true);
|
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||||
mockDaysUntilExpiry.mockReturnValue(-1);
|
mockUsers = [
|
||||||
|
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
|
||||||
|
];
|
||||||
|
|
||||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ import { NextResponse } from "next/server";
|
|||||||
import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle";
|
import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle";
|
||||||
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
||||||
import { sendTokenExpirationWarning } from "@/lib/email";
|
import { sendTokenExpirationWarning } from "@/lib/email";
|
||||||
import { decrypt } from "@/lib/encryption";
|
import { decrypt, encrypt } from "@/lib/encryption";
|
||||||
import {
|
import {
|
||||||
daysUntilExpiry,
|
|
||||||
fetchBodyBattery,
|
fetchBodyBattery,
|
||||||
fetchHrvStatus,
|
fetchHrvStatus,
|
||||||
fetchIntensityMinutes,
|
fetchIntensityMinutes,
|
||||||
isTokenExpired,
|
|
||||||
} from "@/lib/garmin";
|
} from "@/lib/garmin";
|
||||||
|
import {
|
||||||
|
exchangeOAuth1ForOAuth2,
|
||||||
|
isAccessTokenExpired,
|
||||||
|
type OAuth1TokenData,
|
||||||
|
} from "@/lib/garmin-auth";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import {
|
import {
|
||||||
activeUsersGauge,
|
activeUsersGauge,
|
||||||
@@ -20,13 +23,14 @@ import {
|
|||||||
garminSyncTotal,
|
garminSyncTotal,
|
||||||
} from "@/lib/metrics";
|
} from "@/lib/metrics";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||||
import type { GarminTokens, User } from "@/types";
|
import type { User } from "@/types";
|
||||||
|
|
||||||
interface SyncResult {
|
interface SyncResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
usersProcessed: number;
|
usersProcessed: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
skippedExpired: number;
|
skippedExpired: number;
|
||||||
|
tokensRefreshed: number;
|
||||||
warningsSent: number;
|
warningsSent: number;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
@@ -47,6 +51,7 @@ export async function POST(request: Request) {
|
|||||||
usersProcessed: 0,
|
usersProcessed: 0,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
skippedExpired: 0,
|
skippedExpired: 0,
|
||||||
|
tokensRefreshed: 0,
|
||||||
warningsSent: 0,
|
warningsSent: 0,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -66,38 +71,81 @@ export async function POST(request: Request) {
|
|||||||
const userSyncStartTime = Date.now();
|
const userSyncStartTime = Date.now();
|
||||||
|
|
||||||
try {
|
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
|
// Note: garminTokenExpiresAt and lastPeriodDate are guaranteed non-null by filter above
|
||||||
const tokens: GarminTokens = {
|
if (user.garminRefreshTokenExpiresAt) {
|
||||||
oauth1: user.garminOauth1Token,
|
const refreshTokenExpired =
|
||||||
oauth2: user.garminOauth2Token,
|
new Date(user.garminRefreshTokenExpiresAt) <= new Date();
|
||||||
// biome-ignore lint/style/noNonNullAssertion: filtered above
|
if (refreshTokenExpired) {
|
||||||
expires_at: user.garminTokenExpiresAt!.toISOString(),
|
logger.info(
|
||||||
};
|
{ userId: user.id },
|
||||||
|
"Refresh token expired, skipping user",
|
||||||
if (isTokenExpired(tokens)) {
|
);
|
||||||
result.skippedExpired++;
|
result.skippedExpired++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Log sync start
|
// Log sync start
|
||||||
logger.info({ userId: user.id }, "Garmin sync start");
|
logger.info({ userId: user.id }, "Garmin sync start");
|
||||||
|
|
||||||
// Check for token expiration warnings (exactly 14 or 7 days)
|
// Check for refresh token expiration warnings (exactly 14 or 7 days)
|
||||||
const daysRemaining = daysUntilExpiry(tokens);
|
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) {
|
if (daysRemaining === 14 || daysRemaining === 7) {
|
||||||
try {
|
try {
|
||||||
await sendTokenExpirationWarning(user.email, daysRemaining, user.id);
|
await sendTokenExpirationWarning(
|
||||||
|
user.email,
|
||||||
|
daysRemaining,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
result.warningsSent++;
|
result.warningsSent++;
|
||||||
} catch {
|
} catch {
|
||||||
// Continue processing even if warning email fails
|
// 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 oauth2Json = decrypt(user.garminOauth2Token);
|
||||||
const oauth2Data = JSON.parse(oauth2Json);
|
let oauth2Data = JSON.parse(oauth2Json);
|
||||||
const accessToken = oauth2Data.accessToken;
|
|
||||||
|
// 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
|
// Fetch Garmin data
|
||||||
const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([
|
const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([
|
||||||
|
|||||||
@@ -29,9 +29,15 @@ vi.mock("@/lib/pocketbase", () => ({
|
|||||||
|
|
||||||
// Mock email sending
|
// Mock email sending
|
||||||
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
|
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
vi.mock("@/lib/email", () => ({
|
vi.mock("@/lib/email", () => ({
|
||||||
sendDailyEmail: (data: unknown) => mockSendDailyEmail(data),
|
sendDailyEmail: (data: unknown) => mockSendDailyEmail(data),
|
||||||
|
sendTokenExpirationWarning: (...args: unknown[]) =>
|
||||||
|
mockSendTokenExpirationWarning(...args),
|
||||||
|
sendPeriodConfirmationEmail: (...args: unknown[]) =>
|
||||||
|
mockSendPeriodConfirmationEmail(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { POST } from "./route";
|
import { POST } from "./route";
|
||||||
@@ -48,6 +54,7 @@ describe("POST /api/cron/notifications", () => {
|
|||||||
garminOauth1Token: "encrypted:oauth1-token",
|
garminOauth1Token: "encrypted:oauth1-token",
|
||||||
garminOauth2Token: "encrypted:oauth2-token",
|
garminOauth2Token: "encrypted:oauth2-token",
|
||||||
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-token",
|
calendarToken: "cal-token",
|
||||||
lastPeriodDate: new Date("2025-01-01"),
|
lastPeriodDate: new Date("2025-01-01"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ describe("GET /api/cycle/current", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-01"),
|
lastPeriodDate: new Date("2025-01-01"),
|
||||||
cycleLength: 31,
|
cycleLength: 31,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ describe("POST /api/cycle/period", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2024-12-15"),
|
lastPeriodDate: new Date("2024-12-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -100,6 +101,7 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "encrypted-token",
|
garminOauth1Token: "encrypted-token",
|
||||||
garminOauth2Token: "encrypted-token",
|
garminOauth2Token: "encrypted-token",
|
||||||
garminTokenExpiresAt: futureDate,
|
garminTokenExpiresAt: futureDate,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -129,6 +131,7 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "encrypted-token",
|
garminOauth1Token: "encrypted-token",
|
||||||
garminOauth2Token: "encrypted-token",
|
garminOauth2Token: "encrypted-token",
|
||||||
garminTokenExpiresAt: futureDate,
|
garminTokenExpiresAt: futureDate,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -156,6 +159,7 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -185,6 +189,7 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "encrypted-token",
|
garminOauth1Token: "encrypted-token",
|
||||||
garminOauth2Token: "encrypted-token",
|
garminOauth2Token: "encrypted-token",
|
||||||
garminTokenExpiresAt: pastDate,
|
garminTokenExpiresAt: pastDate,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -216,6 +221,7 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "encrypted-token",
|
garminOauth1Token: "encrypted-token",
|
||||||
garminOauth2Token: "encrypted-token",
|
garminOauth2Token: "encrypted-token",
|
||||||
garminTokenExpiresAt: futureDate,
|
garminTokenExpiresAt: futureDate,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -245,6 +251,7 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "encrypted-token",
|
garminOauth1Token: "encrypted-token",
|
||||||
garminOauth2Token: "encrypted-token",
|
garminOauth2Token: "encrypted-token",
|
||||||
garminTokenExpiresAt: futureDate,
|
garminTokenExpiresAt: futureDate,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -274,6 +281,7 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "encrypted-token",
|
garminOauth1Token: "encrypted-token",
|
||||||
garminOauth2Token: "encrypted-token",
|
garminOauth2Token: "encrypted-token",
|
||||||
garminTokenExpiresAt: futureDate,
|
garminTokenExpiresAt: futureDate,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -303,6 +311,7 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "encrypted-token",
|
garminOauth1Token: "encrypted-token",
|
||||||
garminOauth2Token: "encrypted-token",
|
garminOauth2Token: "encrypted-token",
|
||||||
garminTokenExpiresAt: futureDate,
|
garminTokenExpiresAt: futureDate,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -329,6 +338,7 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
|
|||||||
@@ -12,26 +12,41 @@ export const GET = withAuth(async (_request, user, pb) => {
|
|||||||
const connected = freshUser.garminConnected === true;
|
const connected = freshUser.garminConnected === true;
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
|
{
|
||||||
connected: false,
|
connected: false,
|
||||||
daysUntilExpiry: null,
|
daysUntilExpiry: null,
|
||||||
expired: false,
|
expired: false,
|
||||||
warningLevel: null,
|
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)
|
? String(freshUser.garminTokenExpiresAt)
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const tokens = {
|
const tokens = {
|
||||||
oauth1: "",
|
oauth1: "",
|
||||||
oauth2: "",
|
oauth2: "",
|
||||||
expires_at: expiresAt,
|
expires_at: accessTokenExpiresAt,
|
||||||
|
refresh_token_expires_at: refreshTokenExpiresAt || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const days = daysUntilExpiry(tokens);
|
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;
|
let warningLevel: "warning" | "critical" | null = null;
|
||||||
if (days <= 7) {
|
if (days <= 7) {
|
||||||
@@ -40,10 +55,15 @@ export const GET = withAuth(async (_request, user, pb) => {
|
|||||||
warningLevel = "warning";
|
warningLevel = "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
|
{
|
||||||
connected: true,
|
connected: true,
|
||||||
daysUntilExpiry: days,
|
daysUntilExpiry: days,
|
||||||
expired,
|
expired,
|
||||||
warningLevel,
|
warningLevel,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ describe("POST /api/garmin/tokens", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -141,6 +142,7 @@ describe("POST /api/garmin/tokens", () => {
|
|||||||
garminOauth1Token: `encrypted:${JSON.stringify(oauth1)}`,
|
garminOauth1Token: `encrypted:${JSON.stringify(oauth1)}`,
|
||||||
garminOauth2Token: `encrypted:${JSON.stringify(oauth2)}`,
|
garminOauth2Token: `encrypted:${JSON.stringify(oauth2)}`,
|
||||||
garminTokenExpiresAt: expiresAt,
|
garminTokenExpiresAt: expiresAt,
|
||||||
|
garminRefreshTokenExpiresAt: expect.any(String),
|
||||||
garminConnected: true,
|
garminConnected: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -267,6 +269,7 @@ describe("DELETE /api/garmin/tokens", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -304,6 +307,7 @@ describe("DELETE /api/garmin/tokens", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: null,
|
garminTokenExpiresAt: null,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
garminConnected: false,
|
garminConnected: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { logger } from "@/lib/logger";
|
|||||||
|
|
||||||
export const POST = withAuth(async (request, user, pb) => {
|
export const POST = withAuth(async (request, user, pb) => {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { oauth1, oauth2, expires_at } = body;
|
const { oauth1, oauth2, expires_at, refresh_token_expires_at } = body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!oauth1) {
|
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
|
// Encrypt tokens before storing
|
||||||
const encryptedOauth1 = encrypt(JSON.stringify(oauth1));
|
const encryptedOauth1 = encrypt(JSON.stringify(oauth1));
|
||||||
const encryptedOauth2 = encrypt(JSON.stringify(oauth2));
|
const encryptedOauth2 = encrypt(JSON.stringify(oauth2));
|
||||||
@@ -61,6 +78,7 @@ export const POST = withAuth(async (request, user, pb) => {
|
|||||||
garminOauth1Token: encryptedOauth1,
|
garminOauth1Token: encryptedOauth1,
|
||||||
garminOauth2Token: encryptedOauth2,
|
garminOauth2Token: encryptedOauth2,
|
||||||
garminTokenExpiresAt: expires_at,
|
garminTokenExpiresAt: expires_at,
|
||||||
|
garminRefreshTokenExpiresAt: refreshTokenExpiresAt,
|
||||||
garminConnected: true,
|
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({
|
const expiryDays = daysUntilExpiry({
|
||||||
oauth1: "",
|
oauth1: "",
|
||||||
oauth2: "",
|
oauth2: "",
|
||||||
expires_at,
|
expires_at,
|
||||||
|
refresh_token_expires_at: refreshTokenExpiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -103,6 +122,7 @@ export const DELETE = withAuth(async (_request, user, pb) => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: null,
|
garminTokenExpiresAt: null,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
garminConnected: false,
|
garminConnected: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ describe("GET /api/history", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ describe("POST /api/overrides", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -187,6 +188,7 @@ describe("DELETE /api/overrides", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ describe("GET /api/period-history", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ describe("PATCH /api/period-logs/[id]", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -276,6 +277,7 @@ describe("DELETE /api/period-logs/[id]", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ describe("GET /api/today", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-01"),
|
lastPeriodDate: new Date("2025-01-01"),
|
||||||
cycleLength: 31,
|
cycleLength: 31,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ describe("GET /api/user", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -138,6 +139,7 @@ describe("PATCH /api/user", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ export const GET = withAuth(async (_request, user, pb) => {
|
|||||||
? new Date(freshUser.lastPeriodDate as string).toISOString().split("T")[0]
|
? new Date(freshUser.lastPeriodDate as string).toISOString().split("T")[0]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
|
{
|
||||||
id: freshUser.id,
|
id: freshUser.id,
|
||||||
email: freshUser.email,
|
email: freshUser.email,
|
||||||
garminConnected: freshUser.garminConnected ?? false,
|
garminConnected: freshUser.garminConnected ?? false,
|
||||||
@@ -35,7 +36,11 @@ export const GET = withAuth(async (_request, user, pb) => {
|
|||||||
notificationTime: freshUser.notificationTime,
|
notificationTime: freshUser.notificationTime,
|
||||||
timezone: freshUser.timezone,
|
timezone: freshUser.timezone,
|
||||||
activeOverrides: freshUser.activeOverrides ?? [],
|
activeOverrides: freshUser.activeOverrides ?? [],
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -242,7 +247,11 @@ export default function GarminSettingsPage() {
|
|||||||
{/* Token Input Section */}
|
{/* Token Input Section */}
|
||||||
{showTokenInput && (
|
{showTokenInput && (
|
||||||
<div className="border border-input rounded-lg p-6">
|
<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="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">
|
<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">
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ describe("withAuth", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date(),
|
garminTokenExpiresAt: new Date(),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-token",
|
calendarToken: "cal-token",
|
||||||
lastPeriodDate: new Date("2025-01-01"),
|
lastPeriodDate: new Date("2025-01-01"),
|
||||||
cycleLength: 31,
|
cycleLength: 31,
|
||||||
|
|||||||
146
src/lib/garmin-auth.test.ts
Normal file
146
src/lib/garmin-auth.test.ts
Normal 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
114
src/lib/garmin-auth.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -110,6 +110,38 @@ describe("daysUntilExpiry", () => {
|
|||||||
expect(days).toBeGreaterThanOrEqual(6);
|
expect(days).toBeGreaterThanOrEqual(6);
|
||||||
expect(days).toBeLessThanOrEqual(7);
|
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", () => {
|
describe("fetchGarminData", () => {
|
||||||
|
|||||||
@@ -36,8 +36,15 @@ export function isTokenExpired(tokens: GarminTokens): boolean {
|
|||||||
return expiresAt <= new Date();
|
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 {
|
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 now = new Date();
|
||||||
const diffMs = expiresAt.getTime() - now.getTime();
|
const diffMs = expiresAt.getTime() - now.getTime();
|
||||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ describe("getCurrentUser", () => {
|
|||||||
garminOauth1Token: "encrypted1",
|
garminOauth1Token: "encrypted1",
|
||||||
garminOauth2Token: "encrypted2",
|
garminOauth2Token: "encrypted2",
|
||||||
garminTokenExpiresAt: "2025-06-01T00:00:00Z",
|
garminTokenExpiresAt: "2025-06-01T00:00:00Z",
|
||||||
|
garminRefreshTokenExpiresAt: "2025-07-01T00:00:00Z",
|
||||||
calendarToken: "cal-token-123",
|
calendarToken: "cal-token-123",
|
||||||
lastPeriodDate: "2025-01-01",
|
lastPeriodDate: "2025-01-01",
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
@@ -105,6 +106,7 @@ describe("getCurrentUser", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: "",
|
garminTokenExpiresAt: "",
|
||||||
|
garminRefreshTokenExpiresAt: "",
|
||||||
calendarToken: "token",
|
calendarToken: "token",
|
||||||
lastPeriodDate: "2025-01-15",
|
lastPeriodDate: "2025-01-15",
|
||||||
cycleLength: 31,
|
cycleLength: 31,
|
||||||
@@ -139,6 +141,7 @@ describe("getCurrentUser", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: "not-a-date",
|
garminTokenExpiresAt: "not-a-date",
|
||||||
|
garminRefreshTokenExpiresAt: "also-not-a-date",
|
||||||
calendarToken: "token",
|
calendarToken: "token",
|
||||||
lastPeriodDate: "",
|
lastPeriodDate: "",
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ function mapRecordToUser(record: RecordModel): User {
|
|||||||
garminOauth1Token: record.garminOauth1Token as string,
|
garminOauth1Token: record.garminOauth1Token as string,
|
||||||
garminOauth2Token: record.garminOauth2Token as string,
|
garminOauth2Token: record.garminOauth2Token as string,
|
||||||
garminTokenExpiresAt: parseDate(record.garminTokenExpiresAt),
|
garminTokenExpiresAt: parseDate(record.garminTokenExpiresAt),
|
||||||
|
garminRefreshTokenExpiresAt: parseDate(record.garminRefreshTokenExpiresAt),
|
||||||
calendarToken: record.calendarToken as string,
|
calendarToken: record.calendarToken as string,
|
||||||
lastPeriodDate: parseDate(record.lastPeriodDate),
|
lastPeriodDate: parseDate(record.lastPeriodDate),
|
||||||
cycleLength: record.cycleLength as number,
|
cycleLength: record.cycleLength as number,
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export interface User {
|
|||||||
garminConnected: boolean;
|
garminConnected: boolean;
|
||||||
garminOauth1Token: string; // encrypted JSON
|
garminOauth1Token: string; // encrypted JSON
|
||||||
garminOauth2Token: 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
|
// Calendar
|
||||||
calendarToken: string; // random secret for ICS URL
|
calendarToken: string; // random secret for ICS URL
|
||||||
@@ -87,6 +88,7 @@ export interface GarminTokens {
|
|||||||
oauth1: string;
|
oauth1: string;
|
||||||
oauth2: string;
|
oauth2: string;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
|
refresh_token_expires_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PhaseConfig {
|
export interface PhaseConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user