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:
@@ -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
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).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", () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user