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

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