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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-11 12:34:33 +02:00
1 parent f65d25b0d0
commit fca1260b20
4 files changed
+321 -17

No files matched your search

@@ -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<typeof import("@/lib/oura/client")>()
).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
+11 -2
View File
@@ -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 },
+130
View File
@@ -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<typeof installFetchMock>) =>
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([
+139 -10
View File
@@ -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<OuraTokenResponse> {
): Promise<TokenAttempt> {
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<OuraTokenResponse> {
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<OuraTokenResponse> {
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,
);
}