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:
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user