mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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 },
|
||||
|
||||
Reference in new issue
Block a user