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 },