Files
HealthLog/src/lib/oauth/signed-state.ts
T
Marc-André Bombeck 6224409678 fix(oura): request the stress scope and exchange a repeated callback once
Resilience sits behind Oura's `stress` scope, not `heart_health`, so a
connection that granted every scope HealthLog asked for still answered 401
on `daily_resilience`. `src/lib/oura/scope.ts` now maps every collection the
sync reads to the scope the live API enforces and is the one source for the
authorize request, the per-collection permission detection, the reconnect
hint and the settings card. The request asks for `stress` as well.

Oura names a missing scope in its 401 body ("Token is not authorized access
stress scope."), while a dead token reads differently. A collection refusal
now carries that scope: it spends no token refresh, never counts toward the
reconnect threshold and marks only the collections it names. A 403 (a lapsed
membership) is a persistent failure rather than a reconnect. The callback
also records what the token response says was granted, so an unticked
permission shows on the card before the first sync.

A browser can deliver an OAuth callback twice with the same code. Both
requests exchanged it, and Oura answers a reused code by revoking the token
the first exchange issued. Every connect callback (Oura, Polar, Strava,
WHOOP, Withings, Fitbit, Google Health) now claims its state in
`oauth_callback_claims` before the exchange: one request exchanges, the
duplicate waits for it and redirects to the same page without touching any
token. The ledger flows claim inside the transaction that consumes their
state row. A failed exchange settles with its error page, and a new connect
mints a new state.

Oura token refreshes are serialised per user with an advisory lock, so
overlapping syncs no longer spend the same single-use refresh token, and the
write stays conditional so a reconnect's new grant is never overwritten by a
refresh of the old one.
2026-10-21 00:05:22 +02:00

147 lines
5.5 KiB
TypeScript

/**
* v1.17.0 — stateless, HMAC-signed OAuth `state` for the Polar + Oura
* authorization-code flows.
*
* Unlike WHOOP / Withings (which carry the in-flight `(nonce → userId)` mapping
* in a short-lived DB ledger row), the F4 providers store their tokens directly
* on `User` and the merged schema ships NO `*OAuthState` table. The CSRF
* defence therefore lives in the signed state itself: the `state` param is
* `<provider>.<userId>.<expiryMs>.<nonce>` plus an HMAC-SHA256 tag over that
* payload, keyed by `API_TOKEN_HMAC_KEY`. The same opaque token is also set as
* an httpOnly cookie at connect time, so the callback enforces BOTH:
*
* 1. signature + expiry + provider binding are valid (the token wasn't forged
* or replayed past its TTL), AND
* 2. the `state` URL param equals the cookie value byte-for-byte (double-
* submit — an attacker who can plant a `state` query param cannot also set
* our httpOnly cookie).
*
* The `userId` rides INSIDE the signed payload (tamper-evident), so the
* callback resolves identity from the verified token rather than the ambient
* session — the same property the WHOOP ledger row gives, without a table.
*
* Security notes:
* - The payload is signed, not encrypted. A `userId` (an opaque cuid) is not
* a secret, and it never leaves the user's own browser round-trip. The
* signature is what matters: a third party cannot mint a state for another
* user without the server HMAC key.
* - `verifySignedState` is fully constant-time on the tag compare and rejects
* a provider / expiry / shape mismatch BEFORE the (already constant-time)
* compare, so a malformed probe can't leak timing about the key.
*/
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/** Providers that share this signed-state machinery. */
export type OAuthStateProvider = "polar" | "oura" | "strava";
/** 10-minute TTL — long enough to approve a consent screen, short enough that
* an abandoned handshake's token is useless within the hour. Mirrors the
* WHOOP / Withings ledger-row TTL. */
export const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
/** httpOnly cookie name carrying the in-flight signed state, per provider. */
export function oauthStateCookieName(provider: OAuthStateProvider): string {
return `${provider}_oauth_state`;
}
function getKey(): string {
const key = process.env.API_TOKEN_HMAC_KEY;
if (!key || key.length < 32) {
throw new Error(
"API_TOKEN_HMAC_KEY must be set (>=32 chars) to sign OAuth state",
);
}
return key;
}
/** Field separator — `.` is base64url-safe-adjacent and absent from every
* field (provider is a fixed literal, userId is a cuid, the rest are digits /
* base64url), so the split is unambiguous. */
const SEP = ".";
function sign(payload: string): string {
return createHmac("sha256", getKey()).update(payload).digest("base64url");
}
/**
* Mint a signed state token for a user starting `provider`'s OAuth flow. The
* returned string is used BOTH as the OAuth `state` URL param and as the
* httpOnly cookie value.
*/
export function mintSignedState(
provider: OAuthStateProvider,
userId: string,
now: number = Date.now(),
): string {
const expiry = now + OAUTH_STATE_TTL_MS;
const nonce = randomBytes(16).toString("base64url");
const payload = [provider, userId, String(expiry), nonce].join(SEP);
const tag = sign(payload);
return `${payload}${SEP}${tag}`;
}
export interface VerifiedState {
provider: OAuthStateProvider;
userId: string;
expiry: number;
}
/**
* Verify a signed state token. Returns the decoded `{ provider, userId, expiry }`
* only when the signature is valid, the provider matches, and the TTL has not
* elapsed. Returns null on any failure — a forged tag, a wrong provider, an
* expired token, or a malformed shape. Never throws on bad input (only on a
* missing server key, which is an operator misconfiguration).
*/
export function verifySignedState(
provider: OAuthStateProvider,
token: string | null | undefined,
now: number = Date.now(),
): VerifiedState | null {
if (!token || token.length > 512) return null;
const parts = token.split(SEP);
if (parts.length !== 5) return null;
const [gotProvider, userId, expiryStr, nonce, tag] = parts as [
string,
string,
string,
string,
string,
];
if (gotProvider !== provider) return null;
if (!userId || !nonce || !tag) return null;
const expiry = Number.parseInt(expiryStr, 10);
if (!Number.isFinite(expiry) || String(expiry) !== expiryStr) return null;
const payload = [gotProvider, userId, expiryStr, nonce].join(SEP);
const expected = sign(payload);
// Constant-time compare; length guard first so timingSafeEqual never throws
// on mismatched buffer sizes.
if (
tag.length !== expected.length ||
!timingSafeEqual(Buffer.from(tag), Buffer.from(expected))
) {
return null;
}
// Signature is valid; now enforce the TTL.
if (expiry <= now) return null;
return { provider, userId, expiry };
}
/**
* Constant-time double-submit check: the `state` URL param must equal the
* cookie value byte-for-byte. Both are the same minted token, so an attacker
* who can inject a `state` query param cannot also set our httpOnly cookie.
*/
export function stateMatchesCookie(
urlState: string | null | undefined,
cookieState: string | null | undefined,
): urlState is string {
if (!urlState || !cookieState) return false;
if (urlState.length !== cookieState.length) return false;
return timingSafeEqual(Buffer.from(urlState), Buffer.from(cookieState));
}