mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge managed profile handover into release/v1.42.0
# Conflicts: # docs/api/openapi.yaml # src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
commit
920bd19617
59 files changed
+7147
-694
No files matched your search
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* `/api/account/handover-decision` — the new owner's final word on each former
|
||||
* Guardian after claiming a managed profile (v1.42, #959).
|
||||
*
|
||||
* The claim applies the access the issuing Guardian proposed, so the record
|
||||
* is never without a decision. This is where the owner sees it and decides:
|
||||
*
|
||||
* - `GET` answers the decision that is waiting, or `pending: null`.
|
||||
* - `POST` records it. A Guardian the body leaves out keeps what the claim
|
||||
* gave them; after this, the ordinary Settings → Shared access panel is
|
||||
* where access changes.
|
||||
*
|
||||
* A grant-management surface, resolved like the others (`/api/account/
|
||||
* grants/**`): bare `requireAuth()`, which refuses under an acting-account
|
||||
* switch and refuses a narrow Bearer scope. The owner is the session user; no
|
||||
* party to any grant comes from the body, only the claim-time grant id that
|
||||
* names which former Guardian a decision is about.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
decideHandover,
|
||||
HandoverError,
|
||||
readPendingHandoverDecision,
|
||||
} from "@/lib/managed-profiles/handover";
|
||||
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { handoverDecisionSchema } from "@/lib/validations/managed-profile-handover";
|
||||
|
||||
export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const pending = await readPendingHandoverDecision(user.id);
|
||||
annotate({
|
||||
action: { name: "managed_profile.handover.decision_read" },
|
||||
meta: { pending: pending !== null },
|
||||
});
|
||||
return apiSuccess({
|
||||
pending: pending
|
||||
? {
|
||||
claimedAt: pending.claimedAt.toISOString(),
|
||||
guardians: pending.guardians,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = handoverDecisionSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await decideHandover({
|
||||
userId: user.id,
|
||||
decisions: parsed.data.decisions.map((d) => ({
|
||||
grantId: d.grantId,
|
||||
access: d.access,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HandoverError) {
|
||||
if (error.code === "unknown_guardian") {
|
||||
return apiError(
|
||||
"A decision names somebody who was not a Guardian",
|
||||
422,
|
||||
{
|
||||
errorCode: "managed_profile.handover.unknown_guardian",
|
||||
},
|
||||
);
|
||||
}
|
||||
return apiError("There is no handover decision waiting", 409, {
|
||||
errorCode: "managed_profile.handover.no_pending",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result.changed.length > 0) {
|
||||
const self = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { displayName: true, username: true },
|
||||
});
|
||||
notifyGuardiansOfHandover(
|
||||
"changed",
|
||||
self?.displayName?.trim() || self?.username || user.username,
|
||||
result.changed,
|
||||
);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "managed_profile.handover.decided" },
|
||||
meta: { changed: result.changed.length },
|
||||
});
|
||||
return apiSuccess({ decided: true, changed: result.changed.length });
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* v1.42 (#959) — the anonymous claim and its preview, at the route seam.
|
||||
*
|
||||
* The transaction itself is integration-tested
|
||||
* (`tests/integration/managed-profile-handover.test.ts`); here the order of
|
||||
* the refusals is pinned, because each one is a security property:
|
||||
*
|
||||
* - single sign-on only refuses before anything else;
|
||||
* - a live session is refused BEFORE the rate limit (a state, not an
|
||||
* attempt), and before any token is read;
|
||||
* - the rate limit runs before the body is parsed;
|
||||
* - an unusable token answers the same 404 on both routes, and the claim
|
||||
* checks the link BEFORE probing whether a username or email is taken, so
|
||||
* the route is not a name oracle for somebody without a live link;
|
||||
* - nothing is hashed for a token that is refused.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/oidc", () => ({ isOidcOnly: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
hashPassword: vi.fn(),
|
||||
checkPasswordStrength: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/password-breach-check", () => ({
|
||||
checkPasswordBreachIfEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() }));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn().mockResolvedValue("en"),
|
||||
}));
|
||||
vi.mock("@/lib/managed-profiles/handover", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/managed-profiles/handover")
|
||||
>("@/lib/managed-profiles/handover");
|
||||
return {
|
||||
HandoverError: actual.HandoverError,
|
||||
previewHandover: vi.fn(),
|
||||
claimManagedProfile: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/managed-profiles/handover-notify", () => ({
|
||||
notifyGuardiansOfHandover: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST as claim } from "../route";
|
||||
import { POST as preview } from "../preview/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createSession, getSession } from "@/lib/auth/session";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
|
||||
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
|
||||
import {
|
||||
claimManagedProfile,
|
||||
HandoverError,
|
||||
previewHandover,
|
||||
} from "@/lib/managed-profiles/handover";
|
||||
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
|
||||
|
||||
const TOKEN = `hlp_${"a".repeat(64)}`;
|
||||
const BODY = {
|
||||
token: TOKEN,
|
||||
username: "alex",
|
||||
email: "alex@example.test",
|
||||
password: "a long enough passphrase",
|
||||
};
|
||||
|
||||
function post(path: string, body: unknown): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const PREVIEW = {
|
||||
displayName: "Alex",
|
||||
expiresAt: new Date("2030-01-01T00:00:00Z"),
|
||||
guardians: [{ grantId: "g1", displayName: "Sam", proposal: "read" as const }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(isOidcOnly).mockReturnValue(false);
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
ip: "203.0.113.7",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(checkPasswordStrength).mockReturnValue({
|
||||
isAcceptable: true,
|
||||
feedback: [],
|
||||
} as never);
|
||||
vi.mocked(checkPasswordBreachIfEnabled).mockResolvedValue(null as never);
|
||||
vi.mocked(hashPassword).mockResolvedValue("argon2-hash");
|
||||
vi.mocked(previewHandover).mockResolvedValue(PREVIEW);
|
||||
vi.mocked(claimManagedProfile).mockResolvedValue({
|
||||
profileId: "p1",
|
||||
username: "alex",
|
||||
displayName: "Alex",
|
||||
guardians: [{ guardianId: "u-sam", access: "read" }],
|
||||
});
|
||||
});
|
||||
|
||||
async function errorCode(res: Response): Promise<unknown> {
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
return body.meta?.errorCode;
|
||||
}
|
||||
|
||||
describe.each([
|
||||
["claim", claim, "/api/auth/claim", BODY],
|
||||
["preview", preview, "/api/auth/claim/preview", { token: TOKEN }],
|
||||
] as const)(
|
||||
"POST %s — refusals that read no token",
|
||||
(_n, route, path, body) => {
|
||||
it("refuses on a single-sign-on-only instance before anything else", async () => {
|
||||
vi.mocked(isOidcOnly).mockReturnValue(true);
|
||||
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
|
||||
const res = await route(post(path, body));
|
||||
expect(res.status).toBe(403);
|
||||
expect(await errorCode(res)).toBe("profile_claim.oidc_only_unsupported");
|
||||
expect(previewHandover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses over a live session before spending the rate limit", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
|
||||
const res = await route(post(path, body));
|
||||
expect(res.status).toBe(409);
|
||||
expect(await errorCode(res)).toBe("auth.already_authenticated");
|
||||
expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled();
|
||||
expect(previewHandover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers 429 from the per-address bucket before parsing the body", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
ip: "203.0.113.7",
|
||||
} as never);
|
||||
const res = await route(post(path, body));
|
||||
expect(res.status).toBe(429);
|
||||
expect(previewHandover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers the same 404 for an unusable link", async () => {
|
||||
vi.mocked(previewHandover).mockResolvedValue(null);
|
||||
const res = await route(post(path, body));
|
||||
expect(res.status).toBe(404);
|
||||
expect(await errorCode(res)).toBe("profile_claim.invalid");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("POST /api/auth/claim", () => {
|
||||
it("checks the link before the username and email probe", async () => {
|
||||
vi.mocked(previewHandover).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "x" } as never);
|
||||
const res = await claim(post("/api/auth/claim", BODY));
|
||||
// A taken name would be a 409; the link is checked first, so 404.
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(hashPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers 409 profile_claim.taken for a name in use, before hashing", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
id: "x",
|
||||
} as never);
|
||||
const res = await claim(post("/api/auth/claim", BODY));
|
||||
expect(res.status).toBe(409);
|
||||
expect(await errorCode(res)).toBe("profile_claim.taken");
|
||||
expect(hashPassword).not.toHaveBeenCalled();
|
||||
expect(claimManagedProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a username with the managed-profile prefix as 422", async () => {
|
||||
const res = await claim(
|
||||
post("/api/auth/claim", { ...BODY, username: "managed-alex" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(previewHandover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an unknown field (strict body)", async () => {
|
||||
const res = await claim(
|
||||
post("/api/auth/claim", { ...BODY, userId: "someone-else" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("maps a race lost inside the transaction to the same 404", async () => {
|
||||
vi.mocked(claimManagedProfile).mockRejectedValue(
|
||||
new HandoverError("invalid"),
|
||||
);
|
||||
const res = await claim(post("/api/auth/claim", BODY));
|
||||
expect(res.status).toBe(404);
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a unique-constraint race to 409 taken", async () => {
|
||||
vi.mocked(claimManagedProfile).mockRejectedValue(
|
||||
new HandoverError("taken"),
|
||||
);
|
||||
const res = await claim(post("/api/auth/claim", BODY));
|
||||
expect(res.status).toBe(409);
|
||||
expect(await errorCode(res)).toBe("profile_claim.taken");
|
||||
});
|
||||
|
||||
it("signs the new owner in with onboarding owed and tells the guardians", async () => {
|
||||
const res = await claim(post("/api/auth/claim", BODY));
|
||||
expect(res.status).toBe(201);
|
||||
expect(claimManagedProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rawToken: TOKEN,
|
||||
username: "alex",
|
||||
email: "alex@example.test",
|
||||
passwordHash: "argon2-hash",
|
||||
}),
|
||||
);
|
||||
expect(createSession).toHaveBeenCalledWith("p1", true, "203.0.113.7", null);
|
||||
expect(notifyGuardiansOfHandover).toHaveBeenCalledWith("claimed", "Alex", [
|
||||
{ guardianId: "u-sam", access: "read" },
|
||||
]);
|
||||
const body = (await res.json()) as { data: unknown };
|
||||
expect(body.data).toEqual({ userId: "p1", username: "alex" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/claim/preview", () => {
|
||||
it("returns the minimal preview and nothing else", async () => {
|
||||
const res = await preview(
|
||||
post("/api/auth/claim/preview", { token: TOKEN }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(Object.keys(body.data).sort()).toEqual([
|
||||
"displayName",
|
||||
"expiresAt",
|
||||
"guardians",
|
||||
]);
|
||||
expect(body.data.expiresAt).toBe("2030-01-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,50 @@
|
||||
/**
|
||||
* `POST /api/auth/claim/preview` — what a handover link would hand over: the
|
||||
* profile's display name and each guardian's proposal (v1.42, #959).
|
||||
* Anonymous, token in the body, the same uniform 404 as the claim itself.
|
||||
* profile's display name, the link's expiry and each Guardian's proposed
|
||||
* access (v1.42, #959). No health data.
|
||||
*
|
||||
* Contract stub: answers 501.
|
||||
* Anonymous by design: the person opening the link has no credentials yet.
|
||||
* The one-time `hlp_` token travels in the BODY, never in this request's URL,
|
||||
* so it never reaches an access log or a wide event's `http.path`. Every
|
||||
* unusable link — malformed, unknown, expired, used, withdrawn, a profile no
|
||||
* longer managed, a creator who lost access — answers the same 404.
|
||||
*/
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async () => notImplemented());
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate";
|
||||
import { previewHandover } from "@/lib/managed-profiles/handover";
|
||||
import { claimPreviewSchema } from "@/lib/validations/managed-profile-handover";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Refuses to preview over a live session — see `claimGate`.
|
||||
const gate = await claimGate(
|
||||
request,
|
||||
"preview",
|
||||
(await getSession()) !== null,
|
||||
);
|
||||
if (gate.refusal) return gate.refusal;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = claimPreviewSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const preview = await previewHandover(parsed.data.token);
|
||||
annotate({
|
||||
action: { name: "profile_claim.preview" },
|
||||
meta: { found: preview !== null },
|
||||
});
|
||||
if (!preview) return claimInvalid();
|
||||
|
||||
return apiSuccess({
|
||||
displayName: preview.displayName,
|
||||
expiresAt: preview.expiresAt.toISOString(),
|
||||
guardians: preview.guardians,
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,155 @@
|
||||
/**
|
||||
* `POST /api/auth/claim` — claim a managed profile through its handover link
|
||||
* (v1.42, #959). Anonymous by design: the person claiming has no credentials
|
||||
* yet. The one-time `hlp_` token travels in the body, never in the URL of
|
||||
* this request, and every failure answers the same 404.
|
||||
* (v1.42, #959). The person the profile describes sets a username, an email
|
||||
* and a password, and the profile becomes their own account. No data moves.
|
||||
*
|
||||
* Contract stub: answers 501.
|
||||
* Anonymous by design: the claimant has no credentials yet, and the one-time
|
||||
* `hlp_` token in the BODY is what authorises the request. The order of the
|
||||
* work is the registration route's, for the same reasons:
|
||||
*
|
||||
* 1. refusals that read no token (`claimGate`: SSO-only, live session, rate
|
||||
* limit);
|
||||
* 2. the body, strictly parsed, and the link checked;
|
||||
* 3. the username and email collision probe, before any expensive work;
|
||||
* 4. password strength, the breach corpus, the Argon2id hash — outside the
|
||||
* lock, because none of it depends on the profile;
|
||||
* 5. the claim itself, one transaction under the managed-profile lock
|
||||
* (`claimManagedProfile`), where the token is consumed;
|
||||
* 6. after the commit: the session, the device record, and a notification
|
||||
* to each former Guardian on their own channels.
|
||||
*
|
||||
* A closed registration does not block a claim: no account is created, the
|
||||
* link is the door.
|
||||
*/
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async () => notImplemented());
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
|
||||
import { createSession, getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate";
|
||||
import {
|
||||
claimManagedProfile,
|
||||
HandoverError,
|
||||
previewHandover,
|
||||
} from "@/lib/managed-profiles/handover";
|
||||
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
|
||||
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
|
||||
import { claimSchema } from "@/lib/validations/managed-profile-handover";
|
||||
|
||||
const taken = () =>
|
||||
apiError("Username or email already taken", 409, {
|
||||
errorCode: "profile_claim.taken",
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Refuses to claim over a live session — see `claimGate`.
|
||||
const gate = await claimGate(request, "claim", (await getSession()) !== null);
|
||||
if (gate.refusal) return gate.refusal;
|
||||
const ip = gate.ip ?? "unknown";
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = claimSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
const { token, username, email, password } = parsed.data;
|
||||
|
||||
// The link first. Without this the collision probe below would answer 409
|
||||
// to anybody, token or not, and the claim route would be a username and
|
||||
// email oracle that registration being closed does not close. With it, only
|
||||
// the holder of a live link learns that a name is taken. The claim re-checks
|
||||
// everything under the lock; this read decides nothing on its own.
|
||||
if (!(await previewHandover(token))) return claimInvalid();
|
||||
|
||||
// Before the strength check, the breach lookup and the hash, exactly as the
|
||||
// registration route orders it (its comment carries the timing argument).
|
||||
// The profile's own row never collides here: its username is the generated
|
||||
// `managed-…` one, which no chosen name may start with, and it has no email.
|
||||
const [existingEmail, existingUsername] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { email }, select: { id: true } }),
|
||||
prisma.user.findUnique({ where: { username }, select: { id: true } }),
|
||||
]);
|
||||
if (existingEmail || existingUsername) return taken();
|
||||
|
||||
const locale = await resolveServerLocale({ request });
|
||||
const strength = checkPasswordStrength(password, [username, email], locale);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Password too weak (score < 3)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
const breach = await checkPasswordBreachIfEnabled(password);
|
||||
if (breach?.breached) {
|
||||
return apiError(
|
||||
getServerTranslator(locale).t("auth.passwordBreached"),
|
||||
422,
|
||||
);
|
||||
}
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
let claimed;
|
||||
try {
|
||||
claimed = await claimManagedProfile({
|
||||
rawToken: token,
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
ipAddress: ip,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HandoverError) {
|
||||
annotate({
|
||||
action: { name: "profile_claim.refused" },
|
||||
meta: { reason: error.code },
|
||||
});
|
||||
return error.code === "taken" ? taken() : claimInvalid();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// The new owner starts with onboarding owed: the claim reset it, because
|
||||
// the disclaimer and the setup were a Guardian's answers, not theirs.
|
||||
const ua = request.headers.get("user-agent");
|
||||
await createSession(claimed.profileId, true, ip, ua);
|
||||
void recordSignInDevice({
|
||||
userId: claimed.profileId,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
alertOnNew: false,
|
||||
});
|
||||
await auditLog("auth.claim", {
|
||||
userId: claimed.profileId,
|
||||
actorUserId: null,
|
||||
ipAddress: ip,
|
||||
details: { method: "password" },
|
||||
});
|
||||
|
||||
notifyGuardiansOfHandover(
|
||||
"claimed",
|
||||
claimed.displayName ?? claimed.username,
|
||||
claimed.guardians,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "profile_claim.complete" },
|
||||
meta: { guardians: claimed.guardians.length },
|
||||
});
|
||||
return apiSuccess(
|
||||
{ userId: claimed.profileId, username: claimed.username },
|
||||
201,
|
||||
);
|
||||
});
|
||||
@@ -1,21 +1,186 @@
|
||||
/**
|
||||
* `POST /api/managed-profiles/[id]/handover` — mint the one-time link that
|
||||
* hands a managed profile over to the person it describes, with a proposal
|
||||
* per guardian; `DELETE` withdraws the open link (v1.42, #959). Cookie-only
|
||||
* like every act on a managed profile; minting is step-up gated, withdrawing
|
||||
* is not.
|
||||
* `/api/managed-profiles/[id]/handover` — the Guardian's side of handing a
|
||||
* managed profile over to the person it describes (v1.42, #959).
|
||||
*
|
||||
* Contract stub: authenticates as the finished route will, then answers 501.
|
||||
* - `GET` reads whether a link is open, never the link itself (only its hash
|
||||
* exists). Cookie-only, no step-up: it discloses to a Guardian what they
|
||||
* already administer.
|
||||
* - `POST` mints the one-time link with a proposal per Guardian. Step-up
|
||||
* gated like every act that changes what a managed profile is
|
||||
* (`requireFreshMfa`), and rate-limited like its siblings.
|
||||
* - `DELETE` withdraws the open link. Cookie-only and NOT step-up gated:
|
||||
* taking a link back must never be harder than handing it out.
|
||||
*
|
||||
* All three answer the same 404 for "no such profile", "not managed" and "not
|
||||
* a Guardian of it", so the route is not an enumeration oracle. Bearer tokens
|
||||
* cannot reach any of them: every gate here resolves the cookie session.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
await requireCookieAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
import {
|
||||
apiHandler,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
requireCookieAuth,
|
||||
requireFreshMfa,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { buildHandoverUrl } from "@/lib/auth/handover-token";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
createHandover,
|
||||
HandoverError,
|
||||
readHandoverStatus,
|
||||
revokeHandover,
|
||||
} from "@/lib/managed-profiles/handover";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { createHandoverSchema } from "@/lib/validations/managed-profile-handover";
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
await requireCookieAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const notFound = () =>
|
||||
apiError("Managed profile not found", 404, {
|
||||
errorCode: "managed_profile.not_found",
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.42 refuses the handover on a single-sign-on-only instance: the claim
|
||||
* sets a password, and such an instance admits none. The SSO arm of the claim
|
||||
* is a separate piece of work.
|
||||
*/
|
||||
const oidcOnlyRefusal = () =>
|
||||
apiError(
|
||||
"Handing over a profile is not available while this server allows single sign-on only",
|
||||
403,
|
||||
{ errorCode: "profile_claim.oidc_only_unsupported" },
|
||||
);
|
||||
|
||||
/** Same ceiling as creating, editing and inviting: ten an hour per caller. */
|
||||
const CREATE_LIMIT = 10;
|
||||
const CREATE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const status = await readHandoverStatus({
|
||||
profileId: id,
|
||||
guardianId: user.id,
|
||||
});
|
||||
if (!status) return notFound();
|
||||
|
||||
annotate({
|
||||
action: { name: "managed_profile.handover.read" },
|
||||
meta: { profile_id: id, open: status.open !== null },
|
||||
});
|
||||
return apiSuccess({
|
||||
available: !isOidcOnly(),
|
||||
open: status.open
|
||||
? {
|
||||
id: status.open.id,
|
||||
createdAt: status.open.createdAt.toISOString(),
|
||||
expiresAt: status.open.expiresAt.toISOString(),
|
||||
createdByYou: status.open.createdByCaller,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
// Before the id is read and before any lock is taken, as the siblings do:
|
||||
// the service locks on the id it is handed before deciding the caller is
|
||||
// not a Guardian of it.
|
||||
const rateLimit = await checkRateLimit(
|
||||
`managed-profile:handover:${user.id}`,
|
||||
CREATE_LIMIT,
|
||||
CREATE_WINDOW_MS,
|
||||
);
|
||||
if (!rateLimit.allowed) {
|
||||
return apiError("Too many handover links, try again later", 429);
|
||||
}
|
||||
if (isOidcOnly()) return oidcOnlyRefusal();
|
||||
const { id } = await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = createHandoverSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
let created;
|
||||
try {
|
||||
created = await createHandover({
|
||||
profileId: id,
|
||||
guardianId: user.id,
|
||||
expiresInDays: parsed.data.expiresInDays,
|
||||
proposals: parsed.data.proposals.map((p) => ({
|
||||
grantId: p.grantId,
|
||||
proposal: p.proposal,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HandoverError) {
|
||||
if (error.code === "unknown_guardian") {
|
||||
return apiError(
|
||||
"A proposal names somebody who is not a Guardian",
|
||||
422,
|
||||
{
|
||||
errorCode: "managed_profile.handover.unknown_guardian",
|
||||
},
|
||||
);
|
||||
}
|
||||
return notFound();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "managed_profile.handover.create" },
|
||||
meta: {
|
||||
profile_id: id,
|
||||
expires_in_days: parsed.data.expiresInDays,
|
||||
proposals: parsed.data.proposals.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(
|
||||
{
|
||||
id: created.id,
|
||||
token: created.token,
|
||||
url: buildHandoverUrl(created.token, request.url),
|
||||
expiresAt: created.expiresAt.toISOString(),
|
||||
},
|
||||
201,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { id } = await params;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await revokeHandover({ profileId: id, guardianId: user.id });
|
||||
} catch (error) {
|
||||
if (error instanceof HandoverError) return notFound();
|
||||
throw error;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "managed_profile.handover.revoke" },
|
||||
meta: { profile_id: id, revoked: result.revoked },
|
||||
});
|
||||
return apiSuccess(result);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user