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:
parent
eee5689a75
commit
067e2ebd0b
7 changed files with 149 additions and 2 deletions
70
apps/journal/app/lib/api-guard.server.test.ts
Normal file
70
apps/journal/app/lib/api-guard.server.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue