mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user