From 72fbbfe3de49ac807c66b4ff54e28d8705bf8e8d Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Thu, 30 Jul 2026 09:58:04 -0700 Subject: [PATCH 1/2] feat: make initTokenRefreshLoop expiration-aware - Let onRefresh optionally resolve an { expiresAt } hint so the loop can schedule the next run from the token's remaining lifetime instead of a fixed interval - Add an options object (maxIntervalSec, minDelaySec, lifetimeFraction) for tuning the new scheduling behavior - Fall back to the original fixed 30-minute interval and back-off behavior when no hint is returned, keeping existing zero-arg callers (Google Calendar, Outlook, Airtable) unchanged - Add tests covering the fixed-interval path, expiration-aware scheduling, hint fallback, and delay clamping --- src/utils/oauth.test.ts | 188 ++++++++++++++++++++++++++++++++++++++++ src/utils/oauth.ts | 135 ++++++++++++++++++++++++++--- 2 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 src/utils/oauth.test.ts diff --git a/src/utils/oauth.test.ts b/src/utils/oauth.test.ts new file mode 100644 index 0000000..816c3b1 --- /dev/null +++ b/src/utils/oauth.test.ts @@ -0,0 +1,188 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' +import { initTokenRefreshLoop } from './oauth' + +const THIRTY_MIN_SEC = 30 * 60 + +describe('initTokenRefreshLoop > zero-arg callback (backward compatibility)', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('when onRefresh succeeds, should schedule the next run on a fixed 30-minute interval', async () => { + const onRefresh = vi.fn().mockResolvedValue(undefined) + + initTokenRefreshLoop(onRefresh) + + await vi.advanceTimersByTimeAsync(THIRTY_MIN_SEC * 1000) + expect(onRefresh).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(THIRTY_MIN_SEC * 1000) + expect(onRefresh).toHaveBeenCalledTimes(2) + }) + + test('when onRefresh fails, should back off exponentially capped at 30 minutes', async () => { + const onRefresh = vi.fn().mockRejectedValue(new Error('boom')) + + initTokenRefreshLoop(onRefresh) + + // Initial call after 30 min. + await vi.advanceTimersByTimeAsync(THIRTY_MIN_SEC * 1000) + expect(onRefresh).toHaveBeenCalledTimes(1) + + // errorStep=0 -> 15s backoff + await vi.advanceTimersByTimeAsync(15 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(2) + + // errorStep=1 -> 30s backoff + await vi.advanceTimersByTimeAsync(30 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(3) + + // errorStep=2 -> 60s backoff + await vi.advanceTimersByTimeAsync(60 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(4) + }) + + test('when onRefresh fails 8 times in a row, should stop retrying', async () => { + const onRefresh = vi.fn().mockRejectedValue(new Error('boom')) + + initTokenRefreshLoop(onRefresh) + + await vi.advanceTimersByTimeAsync(THIRTY_MIN_SEC * 1000) // call 1 (errorStep 0->1) + await vi.advanceTimersByTimeAsync(15 * 1000) // call 2 (errorStep 1->2) + await vi.advanceTimersByTimeAsync(30 * 1000) // call 3 (errorStep 2->3) + await vi.advanceTimersByTimeAsync(60 * 1000) // call 4 (errorStep 3->4) + await vi.advanceTimersByTimeAsync(120 * 1000) // call 5 (errorStep 4->5) + await vi.advanceTimersByTimeAsync(240 * 1000) // call 6 (errorStep 5->6) + await vi.advanceTimersByTimeAsync(480 * 1000) // call 7 (errorStep 6->7) + await vi.advanceTimersByTimeAsync(960 * 1000) // call 8 (errorStep 7, at max, stops rescheduling) + expect(onRefresh).toHaveBeenCalledTimes(8) + + // No further scheduled calls even after a long time. + await vi.advanceTimersByTimeAsync(THIRTY_MIN_SEC * 1000 * 10) + expect(onRefresh).toHaveBeenCalledTimes(8) + }) + + test('when onRefresh recovers after a failure, should reset back-off and resume the fixed interval', async () => { + const onRefresh = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValue(undefined) + + initTokenRefreshLoop(onRefresh) + + await vi.advanceTimersByTimeAsync(THIRTY_MIN_SEC * 1000) + expect(onRefresh).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(15 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(THIRTY_MIN_SEC * 1000) + expect(onRefresh).toHaveBeenCalledTimes(3) + }) +}) + +describe('initTokenRefreshLoop > expiration-aware callback', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('when onRefresh resolves an expiresAt hint, should schedule the next run based on remaining lifetime', async () => { + // Recomputed at call time (relative to the current, fake-timer-advanced + // clock) so each refresh reports the same 1000s of remaining lifetime. + const onRefresh = vi.fn().mockImplementation(async () => ({ + expiresAt: new Date(Date.now() + 1000 * 1000).toISOString(), + })) + + initTokenRefreshLoop(onRefresh, { maxIntervalSec: THIRTY_MIN_SEC }) + + // First call still happens after the initial maxIntervalSec delay + // (there is no expiration hint before the first call). + await vi.advanceTimersByTimeAsync(THIRTY_MIN_SEC * 1000) + expect(onRefresh).toHaveBeenCalledTimes(1) + + // Default lifetime fraction is 0.6, so next delay ~= 600s, well under + // the fixed 1800s interval. + await vi.advanceTimersByTimeAsync(600 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(2) + }) + + test('when onRefresh resolves with no hint, should fall back to the fixed interval', async () => { + const onRefresh = vi.fn().mockResolvedValue(undefined) + + initTokenRefreshLoop(onRefresh, { maxIntervalSec: 100 }) + + await vi.advanceTimersByTimeAsync(100 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(99 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(2) + }) + + test.each([null, undefined, 'not-a-date'])( + 'when expiresAt is %s, should fall back to the fixed interval', + async (expiresAt) => { + const onRefresh = vi.fn().mockResolvedValue({ expiresAt }) + + initTokenRefreshLoop(onRefresh, { maxIntervalSec: 100 }) + + await vi.advanceTimersByTimeAsync(100 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(99 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(2) + }, + ) + + test('when the token is about to expire, should clamp the delay to the configured minimum', async () => { + const expiresAt = new Date(Date.now() + 1000).toISOString() // 1s remaining + const onRefresh = vi.fn().mockResolvedValue({ expiresAt }) + + initTokenRefreshLoop(onRefresh, { + maxIntervalSec: THIRTY_MIN_SEC, + minDelaySec: 20, + }) + + await vi.advanceTimersByTimeAsync(THIRTY_MIN_SEC * 1000) + expect(onRefresh).toHaveBeenCalledTimes(1) + + // Without clamping, the lifetime-fraction delay would be ~0.6s. It + // should instead be clamped up to minDelaySec (20s). + await vi.advanceTimersByTimeAsync(19 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(2) + }) + + test('when the token has a very long remaining lifetime, should clamp the delay to maxIntervalSec', async () => { + const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString() // 24h remaining + const onRefresh = vi.fn().mockResolvedValue({ expiresAt }) + + initTokenRefreshLoop(onRefresh, { maxIntervalSec: 100 }) + + await vi.advanceTimersByTimeAsync(100 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(1) + + // Without clamping, the lifetime-fraction delay would be enormous + // (~14.4h). It should instead be clamped down to maxIntervalSec (100s). + await vi.advanceTimersByTimeAsync(99 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1 * 1000) + expect(onRefresh).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts index ac0eafa..6cb3dec 100644 --- a/src/utils/oauth.ts +++ b/src/utils/oauth.ts @@ -1,32 +1,143 @@ const TOKEN_REFRESH_INTERVAL_SEC = 30 * 60 +/** Floor for an expiration-derived delay, so a near-expired token doesn't cause a refresh storm. */ +const DEFAULT_MIN_REFRESH_DELAY_SEC = 10 + +/** + * Fraction of a token's remaining lifetime to wait before refreshing again, + * e.g. 0.6 means "refresh again after 60% of the remaining time has passed". + */ +const DEFAULT_TOKEN_REFRESH_LIFETIME_FRACTION = 0.6 + +const INITIAL_ERROR_BACKOFF_SEC = 15 +const MAX_ERROR_BACKOFF_STEP = 7 + /** - * Starts a background loop that calls `onRefresh` every 30 minutes, with - * exponential back-off on failure (up to ~128 minutes between retries). - * Stops retrying after 7 consecutive failures. + * Optional hint an `onRefresh` callback can return to let the loop schedule + * its next run based on actual token expiration instead of a fixed interval. */ -export const initTokenRefreshLoop = (onRefresh: () => Promise): void => { +export interface TokenRefreshHint { + /** ISO-8601 (or any `Date.parse`-compatible) timestamp of when the current token expires. */ + expiresAt?: string | null +} + +export interface TokenRefreshLoopOptions { + /** + * Upper bound (in seconds) for any scheduled delay — both the + * expiration-derived delay and the error back-off delay are capped at + * this value. Also used as the delay when no expiration hint is + * available. Defaults to 30 minutes, matching the original fixed-interval + * behavior. + */ + maxIntervalSec?: number + /** + * Lower bound (in seconds) for an expiration-derived delay, so an + * about-to-expire token doesn't schedule a near-immediate refresh loop. + * Defaults to 10 seconds. + */ + minDelaySec?: number + /** + * Fraction of a token's remaining lifetime to wait before refreshing + * again. Defaults to 0.6 (refresh once 60% of the remaining lifetime has + * elapsed). + */ + lifetimeFraction?: number +} + +type OnRefresh = () => Promise + +const getExpirationDelaySec = ( + hint: void | TokenRefreshHint, + maxIntervalSec: number, + minDelaySec: number, + lifetimeFraction: number, +): number => { + const expiresAt = hint?.expiresAt + if (!expiresAt) { + return maxIntervalSec + } + + const expiresAtMs = Date.parse(expiresAt) + if (isNaN(expiresAtMs)) { + return maxIntervalSec + } + + const remainingSec = (expiresAtMs - Date.now()) / 1000 + const lifetimeBasedSec = remainingSec * lifetimeFraction + + return Math.max(minDelaySec, Math.min(maxIntervalSec, lifetimeBasedSec)) +} + +/** + * Starts a background loop that periodically calls `onRefresh`, with + * exponential back-off on failure (capped at `options.maxIntervalSec`, + * default ~30 minutes). Stops retrying after 7 consecutive failures. + * + * By default (no return value from `onRefresh`, and no `options`) this + * polls on a fixed 30-minute interval — the original behavior, unchanged. + * + * To opt into expiration-aware scheduling, have `onRefresh` resolve with a + * `{ expiresAt }` hint describing when the current token expires. When + * present and parseable, the next run is scheduled after a fraction of the + * token's remaining lifetime (see `options.lifetimeFraction`) instead of the + * fixed interval, clamped between `options.minDelaySec` and + * `options.maxIntervalSec`. If the hint is absent, `null`, or unparseable, + * the loop falls back to the fixed-interval behavior for that cycle. + * + * @example Existing zero-config usage (unchanged behavior) + * ```ts + * initTokenRefreshLoop(async () => { + * const { token } = await getCredentials() + * accessToken = token + * }) + * ``` + * + * @example Expiration-aware usage + * ```ts + * initTokenRefreshLoop( + * async () => { + * const { token, metadata } = await getCredentials() + * accessToken = token + * return { expiresAt: metadata?.expiration as string | undefined } + * }, + * { maxIntervalSec: 5 * 60 }, + * ) + * ``` + */ +export const initTokenRefreshLoop = ( + onRefresh: OnRefresh, + options: TokenRefreshLoopOptions = {}, +): void => { + const maxIntervalSec = options.maxIntervalSec ?? TOKEN_REFRESH_INTERVAL_SEC + const minDelaySec = options.minDelaySec ?? DEFAULT_MIN_REFRESH_DELAY_SEC + const lifetimeFraction = + options.lifetimeFraction ?? DEFAULT_TOKEN_REFRESH_LIFETIME_FRACTION + let errorStep = 0 - const initErrorDelaySec = 15 - const maxErrorStep = 7 const run = async () => { - let nextTimeout = TOKEN_REFRESH_INTERVAL_SEC + let nextTimeout: number try { - await onRefresh() + const hint = await onRefresh() errorStep = 0 + nextTimeout = getExpirationDelaySec( + hint, + maxIntervalSec, + minDelaySec, + lifetimeFraction, + ) } catch { nextTimeout = Math.min( - initErrorDelaySec * Math.pow(2, errorStep), - nextTimeout, + INITIAL_ERROR_BACKOFF_SEC * Math.pow(2, errorStep), + maxIntervalSec, ) - if (errorStep >= maxErrorStep) return + if (errorStep >= MAX_ERROR_BACKOFF_STEP) return errorStep++ } setTimeout(run, nextTimeout * 1000) } - setTimeout(run, TOKEN_REFRESH_INTERVAL_SEC * 1000) + setTimeout(run, maxIntervalSec * 1000) } /** From 0da6e04b3c9fa1c026698f56054b14167ed645fe Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Thu, 30 Jul 2026 10:09:01 -0700 Subject: [PATCH 2/2] docs: document initTokenRefreshLoop options and bump version - Add an OAuth & Token Refresh section to README.md covering getCredentials and the new initTokenRefreshLoop options/expiresAt hint, with a fixed-interval and an expiration-aware example - Bump package version to 1.4.0 (minor, additive/backward-compatible change), aligned with a sibling PR bumping to the same version --- README.md | 27 +++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f4b8e54..7a12ff5 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,33 @@ signalReady() - `scrubSensitiveData(event)` - Sentry `beforeSend` hook that redacts values of settings keys matching `token`, `secret`, `password`, or `credential` with `[REDACTED]`. Drops the event if it cannot be safely serialized. - `reportError(error, context?)` - Capture an exception via Sentry with optional extra context. +### OAuth & Token Refresh + +- `getCredentials(tokenType?)` - Fetch a token (and optional metadata) from the Screenly OAuth service. Defaults to the `access_token` endpoint. +- `initTokenRefreshLoop(onRefresh, options?)` - Start a background loop that periodically calls `onRefresh`, with exponential back-off on failure. By default it polls on a fixed 30-minute interval, which is all existing callers need: + + ```ts + initTokenRefreshLoop(async () => { + const { token } = await getCredentials() + accessToken = token + }) + ``` + + To schedule refreshes based on actual token expiration instead of the fixed interval, resolve an `{ expiresAt }` hint from `onRefresh`. The next run is then scheduled after a fraction of the token's remaining lifetime (clamped between `options.minDelaySec` and `options.maxIntervalSec`) instead of waiting the full interval: + + ```ts + initTokenRefreshLoop( + async () => { + const { token, metadata } = await getCredentials() + accessToken = token + return { expiresAt: metadata?.expiration as string | undefined } + }, + { maxIntervalSec: 5 * 60 }, + ) + ``` + + If the hint is omitted, `null`, or unparseable, that cycle falls back to the fixed-interval behavior. + ## Web Components This library includes reusable web components for building consistent Edge Apps. See the [components documentation](https://github.com/Screenly/edge-apps-library/blob/main/docs/components.md) for usage details. diff --git a/package-lock.json b/package-lock.json index d3314c5..797fb67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@screenly/edge-apps", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@screenly/edge-apps", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index 7b6797b..be380d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@screenly/edge-apps", - "version": "1.3.0", + "version": "1.4.0", "description": "A TypeScript library for interfacing with Screenly Edge Apps API", "type": "module", "sideEffects": [