fix: enforce Terms gate on bearer-token API requests

Mobile API requests authenticated via OAuth2 bearer tokens bypassed the
Terms gate that the root loader applies to web cookie sessions. Extend
requireApiUser to compare the user's termsVersion with TERMS_VERSION
and return a structured 403 { code: "TERMS_OUTDATED", currentTermsVersion }
on mismatch so mobile clients can surface their own re-acceptance UI.

Spec delta on journal-auth captures the new requirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-08 01:59:28 +02:00
parent eee5689a75
commit 067e2ebd0b
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
7 changed files with 149 additions and 2 deletions

View file

@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TERMS_VERSION } from "./legal";
const mockGetAuthenticatedUser = vi.fn();
vi.mock("./oauth.server.ts", () => ({
getAuthenticatedUser: mockGetAuthenticatedUser,
}));
beforeEach(() => {
vi.clearAllMocks();
});
describe("requireApiUser", () => {
it("returns 401 when unauthenticated", async () => {
mockGetAuthenticatedUser.mockResolvedValue(null);
const { requireApiUser } = await import("./api-guard.server.ts");
try {
await requireApiUser(new Request("http://localhost/api/v1/routes"));
expect.fail("should throw");
} catch (err) {
expect(err).toBeInstanceOf(Response);
expect((err as Response).status).toBe(401);
const body = await (err as Response).json();
expect(body.code).toBe("UNAUTHORIZED");
}
});
it("returns the user when termsVersion matches", async () => {
const user = { id: "u1", termsVersion: TERMS_VERSION };
mockGetAuthenticatedUser.mockResolvedValue(user);
const { requireApiUser } = await import("./api-guard.server.ts");
const result = await requireApiUser(new Request("http://localhost/api/v1/routes"));
expect(result).toBe(user);
});
it("returns 403 TERMS_OUTDATED when termsVersion is stale", async () => {
mockGetAuthenticatedUser.mockResolvedValue({ id: "u1", termsVersion: "2020-01-01" });
const { requireApiUser } = await import("./api-guard.server.ts");
try {
await requireApiUser(new Request("http://localhost/api/v1/routes"));
expect.fail("should throw");
} catch (err) {
expect(err).toBeInstanceOf(Response);
expect((err as Response).status).toBe(403);
const body = await (err as Response).json();
expect(body.code).toBe("TERMS_OUTDATED");
expect(body.currentTermsVersion).toBe(TERMS_VERSION);
}
});
it("returns 403 TERMS_OUTDATED when termsVersion is null", async () => {
mockGetAuthenticatedUser.mockResolvedValue({ id: "u1", termsVersion: null });
const { requireApiUser } = await import("./api-guard.server.ts");
try {
await requireApiUser(new Request("http://localhost/api/v1/routes"));
expect.fail("should throw");
} catch (err) {
expect(err).toBeInstanceOf(Response);
expect((err as Response).status).toBe(403);
const body = await (err as Response).json();
expect(body.code).toBe("TERMS_OUTDATED");
expect(body.currentTermsVersion).toBe(TERMS_VERSION);
}
});
});

View file

@ -1,8 +1,13 @@
import { getAuthenticatedUser } from "./oauth.server.ts";
import { TERMS_VERSION } from "./legal.ts";
import { ERROR_CODES } from "@trails-cool/api";
/**
* Require authentication for an API route. Returns the user or throws a 401 Response.
* Require authentication for an API route. Returns the user or throws a
* Response: 401 if unauthenticated, 403 with `TERMS_OUTDATED` if the user's
* stored `terms_version` is missing or stale relative to the current
* `TERMS_VERSION`. Mirrors the cookie-session terms gate enforced by the
* root loader, so bearer-token API traffic can't bypass it.
*/
export async function requireApiUser(request: Request) {
const user = await getAuthenticatedUser(request);
@ -12,6 +17,16 @@ export async function requireApiUser(request: Request) {
{ status: 401 },
);
}
if (user.termsVersion !== TERMS_VERSION) {
throw Response.json(
{
error: "Terms of Service have been updated and must be re-accepted",
code: ERROR_CODES.TERMS_OUTDATED,
currentTermsVersion: TERMS_VERSION,
},
{ status: 403 },
);
}
return user;
}

View file

@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockUser = { id: "user-1", email: "test@test.com", username: "test", domain: "localhost", displayName: null, bio: null, createdAt: new Date() };
import { TERMS_VERSION } from "~/lib/legal";
const mockUser = { id: "user-1", email: "test@test.com", username: "test", domain: "localhost", displayName: null, bio: null, createdAt: new Date(), termsVersion: TERMS_VERSION };
const mockGetAuthenticatedUser = vi.fn();
const mockListRoutes = vi.fn();
const mockCreateRoute = vi.fn();