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

@@ -5,14 +5,17 @@ import { NextResponse } from "next/server";
import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle";
import { getDecisionWithOverrides } from "@/lib/decision-engine";
import { sendTokenExpirationWarning } from "@/lib/email";
import { decrypt } from "@/lib/encryption";
import { decrypt, encrypt } from "@/lib/encryption";
import {
daysUntilExpiry,
fetchBodyBattery,
fetchHrvStatus,
fetchIntensityMinutes,
isTokenExpired,
} from "@/lib/garmin";
import {
exchangeOAuth1ForOAuth2,
isAccessTokenExpired,
type OAuth1TokenData,
} from "@/lib/garmin-auth";
import { logger } from "@/lib/logger";
import {
activeUsersGauge,
@@ -20,13 +23,14 @@ import {
garminSyncTotal,
} from "@/lib/metrics";
import { createPocketBaseClient } from "@/lib/pocketbase";
import type { GarminTokens, User } from "@/types";
import type { User } from "@/types";
interface SyncResult {
success: boolean;
usersProcessed: number;
errors: number;
skippedExpired: number;
tokensRefreshed: number;
warningsSent: number;
timestamp: string;
}
@@ -47,6 +51,7 @@ export async function POST(request: Request) {
usersProcessed: 0,
errors: 0,
skippedExpired: 0,
tokensRefreshed: 0,
warningsSent: 0,
timestamp: new Date().toISOString(),
};
@@ -66,38 +71,81 @@ export async function POST(request: Request) {
const userSyncStartTime = Date.now();
try {
// Check if tokens are expired
// Check if refresh token is expired (user needs to re-auth via Python script)
// Note: garminTokenExpiresAt and lastPeriodDate are guaranteed non-null by filter above
const tokens: GarminTokens = {
oauth1: user.garminOauth1Token,
oauth2: user.garminOauth2Token,
// biome-ignore lint/style/noNonNullAssertion: filtered above
expires_at: user.garminTokenExpiresAt!.toISOString(),
};
if (isTokenExpired(tokens)) {
result.skippedExpired++;
continue;
if (user.garminRefreshTokenExpiresAt) {
const refreshTokenExpired =
new Date(user.garminRefreshTokenExpiresAt) <= new Date();
if (refreshTokenExpired) {
logger.info(
{ userId: user.id },
"Refresh token expired, skipping user",
);
result.skippedExpired++;
continue;
}
}
// Log sync start
logger.info({ userId: user.id }, "Garmin sync start");
// Check for token expiration warnings (exactly 14 or 7 days)
const daysRemaining = daysUntilExpiry(tokens);
if (daysRemaining === 14 || daysRemaining === 7) {
try {
await sendTokenExpirationWarning(user.email, daysRemaining, user.id);
result.warningsSent++;
} catch {
// Continue processing even if warning email fails
// Check for refresh token expiration warnings (exactly 14 or 7 days)
if (user.garminRefreshTokenExpiresAt) {
const refreshExpiry = new Date(user.garminRefreshTokenExpiresAt);
const now = new Date();
const diffMs = refreshExpiry.getTime() - now.getTime();
const daysRemaining = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (daysRemaining === 14 || daysRemaining === 7) {
try {
await sendTokenExpirationWarning(
user.email,
daysRemaining,
user.id,
);
result.warningsSent++;
} catch {
// Continue processing even if warning email fails
}
}
}
// Decrypt OAuth2 token
// Decrypt tokens
const oauth1Json = decrypt(user.garminOauth1Token);
const oauth1Data = JSON.parse(oauth1Json) as OAuth1TokenData;
const oauth2Json = decrypt(user.garminOauth2Token);
const oauth2Data = JSON.parse(oauth2Json);
const accessToken = oauth2Data.accessToken;
let oauth2Data = JSON.parse(oauth2Json);
// Check if access token needs refresh
// biome-ignore lint/style/noNonNullAssertion: filtered above
const accessTokenExpiresAt = user.garminTokenExpiresAt!;
if (isAccessTokenExpired(accessTokenExpiresAt)) {
logger.info({ userId: user.id }, "Access token expired, refreshing");
try {
const refreshResult = await exchangeOAuth1ForOAuth2(oauth1Data);
oauth2Data = refreshResult.oauth2;
// Update stored tokens
const encryptedOauth2 = encrypt(JSON.stringify(oauth2Data));
await pb.collection("users").update(user.id, {
garminOauth2Token: encryptedOauth2,
garminTokenExpiresAt: refreshResult.expires_at,
garminRefreshTokenExpiresAt: refreshResult.refresh_token_expires_at,
});
result.tokensRefreshed++;
logger.info({ userId: user.id }, "Access token refreshed");
} catch (refreshError) {
logger.error(
{ userId: user.id, err: refreshError },
"Failed to refresh access token",
);
result.errors++;
garminSyncTotal.inc({ status: "failure" });
continue;
}
}
const accessToken = oauth2Data.access_token;
// Fetch Garmin data
const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([