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

@@ -71,6 +71,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-01-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
@@ -100,6 +101,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
@@ -129,6 +131,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
@@ -156,6 +159,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-01-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
@@ -185,6 +189,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: pastDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
@@ -216,6 +221,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
@@ -245,6 +251,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
@@ -274,6 +281,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
@@ -303,6 +311,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
@@ -329,6 +338,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-01-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,