From fca1260b209d274ec58d29701cd888aaaba61717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:34:33 +0200 Subject: [PATCH] fix(oura): exchange and refresh tokens at Oura's current token endpoint Apps from Oura's current developer portal receive codes from moi.ouraring.com, and the documented api.ouraring.com/oauth/token answers those codes with 400 invalid_request, so connecting failed and every collection read came back 401. Token requests now go to moi.ouraring.com/oauth/v2/ext/oauth-token first. A code works once, so the documented endpoint is never asked first: a code whose callback names the current server goes nowhere else, and only an older app's code or refresh token gets one retry there after a 400, 401 or 404. The callback checks the RFC 9207 iss parameter when present and refuses a foreign issuer. --- .../api/oura/callback/__tests__/route.test.ts | 46 +++++- src/app/api/oura/callback/route.ts | 13 +- src/lib/oura/__tests__/client.test.ts | 130 +++++++++++++++ src/lib/oura/client.ts | 149 ++++++++++++++++-- 4 files changed, 321 insertions(+), 17 deletions(-) diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts index 07cb60d32..efaf97c1d 100644 --- a/src/app/api/oura/callback/__tests__/route.test.ts +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -41,7 +41,12 @@ const { vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock })); -vi.mock("@/lib/oura/client", () => ({ exchangeCode: exchangeMock })); +vi.mock("@/lib/oura/client", async (importOriginal) => ({ + isAcceptedOuraIssuer: ( + await importOriginal() + ).isAcceptedOuraIssuer, + exchangeCode: exchangeMock, +})); vi.mock("@/lib/oura/credentials", () => ({ getOuraClientCredentials: getCredsMock, })); @@ -65,10 +70,12 @@ function makeReq(opts: { code?: string; state?: string; cookie?: string; + iss?: string; }): NextRequest { const params = new URLSearchParams(); if (opts.code) params.set("code", opts.code); if (opts.state) params.set("state", opts.state); + if (opts.iss) params.set("iss", opts.iss); return { url: `https://app.example/api/oura/callback?${params}`, cookies: { @@ -127,10 +134,11 @@ describe("GET /api/oura/callback", () => { const res = await GET( makeReq({ code: "auth-code", state: "S", cookie: "S" }), ); - expect(exchangeMock).toHaveBeenCalledWith("auth-code", { - clientId: "c", - clientSecret: "s", - }); + expect(exchangeMock).toHaveBeenCalledWith( + "auth-code", + { clientId: "c", clientSecret: "s" }, + null, + ); const data = userUpdate.mock.calls[0]![0].data; expect(data.ouraAccessTokenEncrypted).toBe("enc:at"); expect(data.ouraRefreshTokenEncrypted).toBe("enc:rt"); @@ -139,6 +147,34 @@ describe("GET /api/oura/callback", () => { }); }); +// Oura's current authorization server names itself on the callback +// (RFC 9207); the issuer travels on to pick the token endpoint. +describe("GET /api/oura/callback — issuer", () => { + it("passes Oura's issuer on to the code exchange", async () => { + const iss = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous"; + const res = await GET(makeReq({ code: "c", state: "S", cookie: "S", iss })); + expect(exchangeMock).toHaveBeenCalledWith( + "c", + { clientId: "c", clientSecret: "s" }, + iss, + ); + expect(res.headers.get("location")).toContain("oura=connected"); + }); + + it("refuses a callback that names a foreign authorization server", async () => { + const res = await GET( + makeReq({ + code: "c", + state: "S", + cookie: "S", + iss: "https://evil.example/oauth", + }), + ); + expect(res.headers.get("location")).toContain("reason=issuer"); + expect(exchangeMock).not.toHaveBeenCalled(); + }); +}); + /** * v1.32.28 — connecting starts a sync straight away. The poll handler has * accepted a single-user payload since it was written; this callback is the diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index 553dd9c64..89f4284ba 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -4,7 +4,7 @@ import { getSession } from "@/lib/auth/session"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { exchangeCode } from "@/lib/oura/client"; +import { exchangeCode, isAcceptedOuraIssuer } from "@/lib/oura/client"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { oauthStateCookieName, @@ -67,11 +67,20 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!code) return ERR("nocode"); + // RFC 9207: an authorization server that names itself must be Oura's. The + // issuer also tells which token endpoint the code belongs to. Older apps + // send none, which is fine. + const iss = searchParams.get("iss"); + if (!isAcceptedOuraIssuer(iss)) { + annotate({ meta: { reason: "issuer" } }); + return ERR("issuer"); + } + try { const creds = await getOuraClientCredentials(userId); if (!creds) return ERR("nocreds"); - const tokens = await exchangeCode(code, creds); + const tokens = await exchangeCode(code, creds, iss); await prisma.user.update({ where: { id: userId }, diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 1fead0330..d34407fe9 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + OURA_OAUTH_LEGACY_TOKEN_URL, OURA_OAUTH_SCOPE, + OURA_OAUTH_TOKEN_URL, RESILIENCE_LEVELS, derivePeriodDaysFromCyclePhases, exchangeCode, @@ -12,6 +14,7 @@ import { fetchVo2Max, getAuthorizationUrl, getOuraCredentials, + isAcceptedOuraIssuer, mapCardiovascularAge, mapDailyActivity, mapDailySleep, @@ -132,6 +135,133 @@ describe("exchangeCode / refreshAccessToken", () => { }); }); +/** + * Oura moved its authorization server: apps from the current developer + * portal get codes from moi.ouraring.com, which the documented + * api.ouraring.com token endpoint refuses with 400 invalid_request. A code + * works once, so the live endpoint is always asked first. + */ +describe("token endpoint order", () => { + const MOI_ISS = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous"; + const OK = { + status: 200, + body: { access_token: "a", refresh_token: "r", expires_in: 86400 }, + }; + const urls = (mock: ReturnType) => + mock.mock.calls.map(([url]) => url); + + it("exchanges a code at the live endpoint, sending the secret in the body", async () => { + const fetchMock = installFetchMock([OK]); + await exchangeCode("code", CREDS, MOI_ISS); + expect(OURA_OAUTH_TOKEN_URL).toBe( + "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + ); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + const body = String(fetchMock.mock.calls[0]![1]!.body); + expect(body).toContain("grant_type=authorization_code"); + expect(body).toContain("client_id=cid"); + expect(body).toContain("client_secret=csecret"); + }); + + it("never hands a code from the current server to the legacy endpoint", async () => { + const fetchMock = installFetchMock([ + { status: 400, body: { error: "invalid_request" } }, + OK, + ]); + await expect(exchangeCode("code", CREDS, MOI_ISS)).rejects.toMatchObject({ + upstreamError: "invalid_request", + }); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + expect(urls(fetchMock)).not.toContain(OURA_OAUTH_LEGACY_TOKEN_URL); + }); + + it("asks the legacy endpoint only after the live one refused an older app's code", async () => { + const fetchMock = installFetchMock([ + { status: 401, body: { error: "invalid_client" } }, + OK, + ]); + const t = await exchangeCode("code", CREDS); + expect(t.access_token).toBe("a"); + expect(urls(fetchMock)).toEqual([ + OURA_OAUTH_TOKEN_URL, + OURA_OAUTH_LEGACY_TOKEN_URL, + ]); + }); + + it("does not try the legacy endpoint once the live one accepted the code", async () => { + const fetchMock = installFetchMock([OK]); + await exchangeCode("code", CREDS); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + }); + + it("refreshes at the live endpoint first", async () => { + const fetchMock = installFetchMock([OK]); + await refreshAccessToken("r1", CREDS); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + }); + + it.each([ + [400, { error: "invalid_request" }], + [404, null], + ])( + "falls back to the legacy endpoint on a %i from the live one", + async (status, body) => { + const fetchMock = installFetchMock([{ status, body }, OK]); + const t = await refreshAccessToken("r1", CREDS); + expect(t.refresh_token).toBe("r"); + expect(urls(fetchMock)).toEqual([ + OURA_OAUTH_TOKEN_URL, + OURA_OAUTH_LEGACY_TOKEN_URL, + ]); + }, + ); + + it("reports the live endpoint's refusal when both refuse", async () => { + installFetchMock([ + { status: 400, body: { error: "invalid_grant" } }, + { status: 400, body: { error: "invalid_request" } }, + ]); + await expect(refreshAccessToken("dead", CREDS)).rejects.toMatchObject({ + upstreamError: "invalid_grant", + }); + }); + + it("does not fall back on a server error or a rate limit", async () => { + for (const status of [429, 503]) { + const fetchMock = installFetchMock([{ status }, OK]); + await expect(refreshAccessToken("r1", CREDS)).rejects.toBeInstanceOf( + OuraApiError, + ); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + vi.unstubAllGlobals(); + } + }); +}); + +describe("isAcceptedOuraIssuer", () => { + it("accepts no issuer and Oura's own servers", () => { + expect(isAcceptedOuraIssuer(null)).toBe(true); + expect( + isAcceptedOuraIssuer( + "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous", + ), + ).toBe(true); + expect(isAcceptedOuraIssuer("https://cloud.ouraring.com")).toBe(true); + }); + + it("refuses anything else", () => { + for (const iss of [ + "https://evil.example/oauth", + "http://moi.ouraring.com/oauth", + "https://moi.ouraring.com.evil.example/", + "not a url", + "", + ]) { + expect(isAcceptedOuraIssuer(iss), iss).toBe(false); + } + }); +}); + describe("fetchReadiness pagination", () => { it("walks next_token across pages", async () => { installFetchMock([ diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 0460c315f..073c6a858 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -10,7 +10,9 @@ * * Oura specifics: standard authorization-code OAuth WITH refresh tokens (the * sync layer refreshes on 401, persisting BOTH rotated tokens). The token - * endpoint takes client_id/secret in the body. Collections are read by + * endpoint takes client_id/secret in the body (client_secret_post) and lives + * on moi.ouraring.com, with the documented api.ouraring.com endpoint as a + * fallback for older apps. Collections are read by * `start_date` / `end_date` (YYYY-MM-DD) and page via `next_token`. */ import { getEvent } from "@/lib/logging/context"; @@ -22,7 +24,63 @@ import { envOr, envValue } from "@/lib/env"; const OURA_API_BASE = "https://api.ouraring.com"; const OURA_OAUTH_AUTH_URL = "https://cloud.ouraring.com/oauth/authorize"; -const OURA_OAUTH_TOKEN_URL = "https://api.ouraring.com/oauth/token"; + +/** + * Oura's live token endpoint. Apps created in the current developer portal + * get their codes from this authorization server (the callback carries + * `iss=https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous`), and the + * documented `api.ouraring.com/oauth/token` answers those codes with + * `400 invalid_request`. Older apps are served here too. + */ +export const OURA_OAUTH_TOKEN_URL = + "https://moi.ouraring.com/oauth/v2/ext/oauth-token"; + +/** + * The documented endpoint, kept only as a fallback for the older apps the + * live endpoint turns away. Never tried first for a code: an authorization + * code works once (RFC 6749 4.1.2), so a rejected first attempt would burn + * it before the right endpoint saw it. + */ +export const OURA_OAUTH_LEGACY_TOKEN_URL = + "https://api.ouraring.com/oauth/token"; + +/** The origin of Oura's current authorization server. */ +const OURA_MOI_ORIGIN = "https://moi.ouraring.com"; + +/** + * Authorization-server origins an `iss` callback parameter may name + * (RFC 9207). Anything else is a mix-up and refused. + */ +const OURA_ISSUER_ORIGINS = new Set([ + OURA_MOI_ORIGIN, + "https://cloud.ouraring.com", + "https://api.ouraring.com", +]); + +/** + * Check the `iss` the authorization server sent with the callback. Absent is + * fine (older apps never send one); present, it must name one of Oura's own + * authorization servers. + */ +export function isAcceptedOuraIssuer(iss: string | null): boolean { + if (iss === null) return true; + try { + const url = new URL(iss); + return url.protocol === "https:" && OURA_ISSUER_ORIGINS.has(url.origin); + } catch { + return false; + } +} + +/** True when the code was issued by the current authorization server. */ +function issuedByMoi(iss: string | null | undefined): boolean { + if (!iss) return false; + try { + return new URL(iss).origin === OURA_MOI_ORIGIN; + } catch { + return false; + } +} export interface OuraCredentials { clientId: string; @@ -76,12 +134,18 @@ export interface OuraTokenResponse { scope?: string; } -async function postToken( +interface TokenAttempt { + status: number; + json: unknown; +} + +async function requestToken( + url: string, params: URLSearchParams, verb: string, -): Promise { +): Promise { const start = performance.now(); - const res = await safeFetch(OURA_OAUTH_TOKEN_URL, { + const res = await safeFetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -89,7 +153,7 @@ async function postToken( }, body: params.toString(), }); - const json = await res.json().catch(() => null); + const json: unknown = await res.json().catch(() => null); const verdict = classifyOuraResponse(res.status); getEvent()?.addExternalCall({ service: "oura", @@ -98,33 +162,91 @@ async function postToken( status: res.status, error: verdict.classification === "success" ? undefined : verdict.reason, }); + return { status: res.status, json }; +} + +function upstreamError(json: unknown): string | undefined { + if (json === null || typeof json !== "object") return undefined; + const error = (json as { error?: unknown }).error; + return typeof error === "string" ? error : undefined; +} + +/** + * Whether the legacy endpoint is worth asking after the live one refused: + * a 400, 401 or 404. The live endpoint may not know an older app's client, + * code or refresh token at all, and its refusal does not tell "unknown here" + * apart from "bad grant" reliably, so every such refusal gets the one retry. + * Server errors and rate limits do not: they say nothing about the grant. + */ +const FALLBACK_STATUSES = new Set([400, 401, 404]); + +function belongsElsewhere(attempt: TokenAttempt): boolean { + return FALLBACK_STATUSES.has(attempt.status); +} + +function tokenResult(attempt: TokenAttempt, verb: string): OuraTokenResponse { + const verdict = classifyOuraResponse(attempt.status); if (verdict.classification !== "success") { throw new OuraApiError({ verb, classification: verdict.classification, httpStatus: verdict.httpStatus, reason: verdict.reason, - upstreamError: typeof json?.error === "string" ? json.error : undefined, + upstreamError: upstreamError(attempt.json), }); } // A 2xx with an empty or non-JSON body (captive portal, gateway 200, 204) // leaves `json` null. Casting it straight to the token type handed callers a // null they then dereferenced, surfacing an unclassified TypeError instead of // a handled integration error. Narrow explicitly and classify as transient. - if (json === null || typeof json !== "object") { + if (attempt.json === null || typeof attempt.json !== "object") { throw new OuraApiError({ verb, classification: "transient", - httpStatus: res.status, + httpStatus: attempt.status, reason: "empty_token_body", }); } - return json as OuraTokenResponse; + return attempt.json as OuraTokenResponse; } +/** + * Ask the live endpoint, then the legacy one when `mayFallBack` allows it and + * the live endpoint refused with 400, 401 or 404. The legacy endpoint is + * never asked first. + */ +async function postToken( + params: URLSearchParams, + verb: string, + mayFallBack: boolean, +): Promise { + const live = await requestToken(OURA_OAUTH_TOKEN_URL, params, verb); + if (mayFallBack && belongsElsewhere(live)) { + const legacy = await requestToken( + OURA_OAUTH_LEGACY_TOKEN_URL, + params, + `${verb}.legacy`, + ); + if (legacy.status >= 200 && legacy.status < 300) { + return tokenResult(legacy, verb); + } + // Neither endpoint took it. The live endpoint's answer is the one that + // describes the grant for every current app, so report that one. + } + return tokenResult(live, verb); +} + +/** + * Exchange an authorization code. `iss` is the callback's issuer parameter: + * a code from the current authorization server goes to the live endpoint + * only, since a second attempt elsewhere could never succeed. Without an + * issuer (an older app) the legacy endpoint is the fallback after the live + * one has refused the code; the live endpoint is always asked first. + */ export async function exchangeCode( code: string, creds: OuraCredentials, + iss?: string | null, ): Promise { return postToken( new URLSearchParams({ @@ -135,9 +257,15 @@ export async function exchangeCode( client_secret: creds.clientSecret, }), "exchangeCode", + !issuedByMoi(iss), ); } +/** + * Refresh an access token: live endpoint first, the legacy endpoint when the + * live one refuses it (a refresh token of an older app). + * A refused refresh does not consume the token, so the retry is safe. + */ export async function refreshAccessToken( refreshToken: string, creds: OuraCredentials, @@ -150,6 +278,7 @@ export async function refreshAccessToken( client_secret: creds.clientSecret, }), "refreshAccessToken", + true, ); }