Merge branch 'v143/sso' into release/v1.43.0

# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/contract-stub-inventory.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:59:03 +02:00
commit 5c039a220b
39 files changed
+2649 -365

No files matched your search

@@ -37,7 +37,6 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
"anamnesis.life_circumstances.confirm",
],
"anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"],
"auth/claim/sso/route.ts": ["profile_claim.sso.start"],
"dawarich/connect/route.ts": ["dawarich.connect"],
"dawarich/disconnect/route.ts": ["dawarich.disconnect"],
"dawarich/places/route.ts": ["dawarich.places.get"],
@@ -61,7 +60,7 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
};
/** The size of the register when it was written; see the header. */
const REGISTER_SIZE = 21;
const REGISTER_SIZE = 20;
function routeFiles(): string[] {
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
+46 -2
View File
@@ -100,9 +100,11 @@ describe("S1 — the direct session-resolution set is frozen", () => {
"app/api/auth/claim/sso/route.ts":
"refuses to start a single-sign-on claim over a live session",
// Anonymous IdP callback. Probes for an already-valid session so a
// replayed callback redirects instead of minting a second identity.
// replayed callback redirects instead of minting a second identity, and
// (v1.43, #959) so a single-sign-on claim is refused over a live session,
// as the claim routes refuse it.
"app/api/auth/oidc/callback/route.ts":
"duplicate-callback probe on an otherwise anonymous route",
"duplicate-callback probe and the claim's live-session refusal on an otherwise anonymous route",
// Public liveness probe. The session read only decides whether the body
// carries operational detail; the status code is computed without it.
"app/api/health/route.ts": "admin-only detail on a public probe",
@@ -370,6 +372,48 @@ describe("S2 — the URL-token authentication set is frozen", () => {
});
});
describe("S4 — the single-sign-on claim cookie set is frozen", () => {
/**
* v1.43 (#959). A third way to act without a session: the sealed `hl_claim`
* cookie carries a managed-profile claim from `POST /api/auth/claim/sso`
* through the OIDC round trip, and the callback turns it — together with
* the provider's subject — into somebody's account. It is neither a session
* nor a URL credential, so neither leg above sees it.
*
* The handover token itself still only travels in the claim routes' bodies
* and the claim page's query string (S2); the cookie names the link by id.
* What this freezes is who may mint the cookie and who may act on it.
*/
const CLAIM_COOKIE_ALLOWLIST: Record<string, string> = {
"app/api/auth/claim/sso/route.ts":
"mints the cookie, only after checking the token and the username",
"app/api/auth/oidc/login/route.ts":
"requires the cookie for intent=claim and binds it into the OIDC state",
"app/api/auth/oidc/callback/route.ts":
"binds the provider's subject to the profile the cookie names",
};
const CLAIM_COOKIE_USE =
/\b(readClaimCookie|setClaimCookie|claimMatchesBinding|clearClaimCookie)\s*\(|\bCLAIM_COOKIE\b|["']hl_claim["']/;
it("no file under src/app touches the claim cookie unlisted", () => {
const users = appFilesMatching(CLAIM_COOKIE_USE);
expect(users.length).toBeGreaterThan(0);
expect(users).toEqual(Object.keys(CLAIM_COOKIE_ALLOWLIST).sort());
});
it("only the single-sign-on start mints it", () => {
const minters = appFilesMatching(/\bsetClaimCookie\s*\(/);
expect(minters).toEqual(["app/api/auth/claim/sso/route.ts"]);
});
it("every allowlist entry carries a reason", () => {
for (const [rel, reason] of Object.entries(CLAIM_COOKIE_ALLOWLIST)) {
expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0);
}
});
});
describe("S3 — the two families stay distinct", () => {
/**
* A file that both resolves a session AND authenticates by a URL token is
+174 -12
View File
@@ -5,7 +5,11 @@
* (`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;
* - single sign-on only refuses the PASSWORD claim before anything else,
* while the preview answers there and names `sso` as the way in (v1.43);
* - the single-sign-on start needs a configured provider, refuses a live
* session before the rate limit, checks the link before the username,
* and seals the link's id (never the token) into `hl_claim`;
* - 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;
@@ -24,7 +28,18 @@ vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn(),
getSession: vi.fn(),
}));
vi.mock("@/lib/auth/oidc", () => ({ isOidcOnly: vi.fn() }));
vi.mock("@/lib/auth/oidc", () => ({
isOidcOnly: vi.fn(),
isOidcConfigured: vi.fn(),
getOidcConfig: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((s: string) => `sealed(${s})`),
decrypt: vi.fn(),
}));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => true,
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
checkAuthSurfaceRateLimit: vi.fn(),
@@ -49,6 +64,7 @@ vi.mock("@/lib/managed-profiles/handover", async () => {
return {
HandoverError: actual.HandoverError,
previewHandover: vi.fn(),
usableHandoverForToken: vi.fn(),
claimManagedProfile: vi.fn(),
};
});
@@ -67,9 +83,11 @@ vi.mock("next/headers", () => ({
import { POST as claim } from "../route";
import { POST as preview } from "../preview/route";
import { POST as claimSso } from "../sso/route";
import { prisma } from "@/lib/db";
import { createSession, getSession } from "@/lib/auth/session";
import { isOidcOnly } from "@/lib/auth/oidc";
import { getOidcConfig, isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc";
import { auditLog } from "@/lib/auth/audit";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
@@ -77,6 +95,7 @@ import {
claimManagedProfile,
HandoverError,
previewHandover,
usableHandoverForToken,
} from "@/lib/managed-profiles/handover";
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
@@ -105,6 +124,12 @@ const PREVIEW = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(isOidcOnly).mockReturnValue(false);
vi.mocked(isOidcConfigured).mockReturnValue(false);
vi.mocked(getOidcConfig).mockReturnValue(null);
vi.mocked(usableHandoverForToken).mockResolvedValue({
handoverId: "h1",
profileId: "p1",
});
vi.mocked(getSession).mockResolvedValue(null as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
@@ -131,19 +156,62 @@ async function errorCode(res: Response): Promise<unknown> {
return body.meta?.errorCode;
}
const OIDC_CONFIG = {
issuerUrl: "https://idp.example.test",
clientId: "c",
clientSecret: "s",
scopes: "openid email",
buttonLabel: "Example ID",
};
function singleSignOnOnly(): void {
vi.mocked(isOidcOnly).mockReturnValue(true);
vi.mocked(isOidcConfigured).mockReturnValue(true);
vi.mocked(getOidcConfig).mockReturnValue(OIDC_CONFIG);
}
describe("on a single-sign-on-only instance", () => {
it("refuses the password claim before anything else", async () => {
singleSignOnOnly();
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
const res = await claim(post("/api/auth/claim", BODY));
expect(res.status).toBe(403);
expect(await errorCode(res)).toBe("profile_claim.oidc_only_unsupported");
expect(previewHandover).not.toHaveBeenCalled();
});
it("answers the preview and names single sign-on as the only way in", async () => {
singleSignOnOnly();
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(body.data.methods).toEqual(["sso"]);
expect(body.data.ssoProviderName).toBe("Example ID");
});
});
describe.each([
["claim", claim, "/api/auth/claim", BODY],
["preview", preview, "/api/auth/claim/preview", { token: TOKEN }],
["sso", claimSso, "/api/auth/claim/sso", { token: TOKEN, username: "alex" }],
] 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();
(name, route, path, body) => {
beforeEach(() => {
if (name === "sso") {
vi.mocked(isOidcConfigured).mockReturnValue(true);
vi.mocked(getOidcConfig).mockReturnValue(OIDC_CONFIG);
}
if (name === "sso") {
vi.mocked(usableHandoverForToken).mockImplementation(async () => {
// Routes the sso start through the same "link" spy the other two
// use, so one set of assertions covers all three.
const found = await previewHandover(TOKEN);
return found ? { handoverId: "h1", profileId: "p1" } : null;
});
}
});
it("refuses over a live session before spending the rate limit", async () => {
@@ -234,7 +302,7 @@ describe("POST /api/auth/claim", () => {
expect(res.status).toBe(201);
expect(claimManagedProfile).toHaveBeenCalledWith(
expect.objectContaining({
rawToken: TOKEN,
link: { rawToken: TOKEN },
username: "alex",
email: "alex@example.test",
passwordHash: "argon2-hash",
@@ -260,7 +328,101 @@ describe("POST /api/auth/claim/preview", () => {
"displayName",
"expiresAt",
"guardians",
"methods",
"ssoProviderName",
]);
expect(body.data.methods).toEqual(["password"]);
expect(body.data.ssoProviderName).toBeNull();
expect(body.data.expiresAt).toBe("2030-01-01T00:00:00.000Z");
});
});
describe("POST /api/auth/claim/sso", () => {
const SSO_BODY = { token: TOKEN, username: "alex" };
beforeEach(() => {
vi.mocked(isOidcConfigured).mockReturnValue(true);
vi.mocked(getOidcConfig).mockReturnValue(OIDC_CONFIG);
});
it("answers 404 sso_unavailable without an identity provider, before anything else", async () => {
vi.mocked(isOidcConfigured).mockReturnValue(false);
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
expect(res.status).toBe(404);
expect(await errorCode(res)).toBe("profile_claim.sso_unavailable");
expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled();
expect(usableHandoverForToken).not.toHaveBeenCalled();
});
it("works on a single-sign-on-only instance", async () => {
singleSignOnOnly();
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
expect(res.status).toBe(200);
});
it("draws on the password claim's bucket", async () => {
await claimSso(post("/api/auth/claim/sso", SSO_BODY));
expect(checkAuthSurfaceRateLimit).toHaveBeenCalledWith(
expect.anything(),
"auth:claim",
5,
15 * 60 * 1000,
);
});
it("checks the link before the username probe", async () => {
vi.mocked(usableHandoverForToken).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "x" } as never);
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
expect(res.status).toBe(404);
expect(await errorCode(res)).toBe("profile_claim.invalid");
expect(prisma.user.findUnique).not.toHaveBeenCalled();
expect(res.headers.get("set-cookie")).toBeNull();
});
it("answers 409 taken for a username in use, and sets no cookie", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "x" } as never);
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
expect(res.status).toBe(409);
expect(await errorCode(res)).toBe("profile_claim.taken");
expect(res.headers.get("set-cookie")).toBeNull();
});
it("refuses an unknown field and a managed- username (strict body)", async () => {
for (const body of [
{ ...SSO_BODY, email: "x@example.test" },
{ ...SSO_BODY, username: "managed-alex" },
]) {
const res = await claimSso(post("/api/auth/claim/sso", body));
expect(res.status).toBe(422);
}
expect(usableHandoverForToken).not.toHaveBeenCalled();
});
it("seals the link's id and the username, never the token, into hl_claim", async () => {
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
expect(res.status).toBe(200);
expect((await res.json()).data).toEqual({
redirectTo: "/api/auth/oidc/login?intent=claim",
});
const cookie = res.headers.get("set-cookie") ?? "";
expect(cookie).toMatch(/^hl_claim=/);
expect(cookie).toMatch(/Path=\/api\/auth\/oidc/);
expect(cookie).toMatch(/HttpOnly/i);
expect(cookie).toMatch(/Secure/i);
expect(cookie).toMatch(/SameSite=lax/i);
expect(cookie).toMatch(/Max-Age=600/);
const sealed = decodeURIComponent(cookie.split(";")[0]!.slice(9));
const payload = JSON.parse(sealed.replace(/^sealed\(/, "").slice(0, -1));
expect(payload.h).toBe("h1");
expect(payload.u).toBe("alex");
expect(typeof payload.b).toBe("string");
expect(cookie).not.toContain(TOKEN);
expect(JSON.stringify(vi.mocked(auditLog).mock.calls)).not.toContain(TOKEN);
expect(auditLog).toHaveBeenCalledWith(
"managed_profile.claim_sso.started",
expect.objectContaining({ userId: "p1", details: { handoverId: "h1" } }),
);
});
});
+12 -1
View File
@@ -4,6 +4,8 @@
* access (v1.42, #959). No health data.
*
* Anonymous by design: the person opening the link has no credentials yet.
* Answered on a single-sign-on-only server too (v1.43): `methods` then names
* only `sso`, and the page offers the identity provider.
* 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
@@ -15,7 +17,12 @@ 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 {
claimGate,
claimInvalid,
handoverMethods,
ssoProviderName,
} from "@/lib/managed-profiles/claim-gate";
import { previewHandover } from "@/lib/managed-profiles/handover";
import { claimPreviewSchema } from "@/lib/validations/managed-profile-handover";
@@ -46,5 +53,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
displayName: preview.displayName,
expiresAt: preview.expiresAt.toISOString(),
guardians: preview.guardians,
// v1.43 (#959) — which ways in this server offers, so the page shows the
// password form, the provider button, or both.
methods: handoverMethods(),
ssoProviderName: ssoProviderName(),
});
});
+1 -1
View File
@@ -104,7 +104,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
let claimed;
try {
claimed = await claimManagedProfile({
rawToken: token,
link: { rawToken: token },
username,
email,
passwordHash,
+69 -14
View File
@@ -1,29 +1,47 @@
/**
* `POST /api/auth/claim/sso` — start claiming a managed profile through
* single sign-on (#959).
* single sign-on (v1.43, #959).
*
* Anonymous, like the password claim beside it. `{ token, username }`: the
* link is checked and the username reserved for the round trip, a sealed
* `hl_claim` cookie (10 minutes, `httpOnly`, `Secure` through
* `shouldEmitSecureCookie()`) carries `{ handoverId, username, exp }`, and the
* answer names where to go: `{ redirectTo: "/api/auth/oidc/login?intent=claim" }`.
* The OIDC callback binds the identity provider's subject to the profile. The
* token never rides a URL. Works on a single-sign-on-only server; 404
* `profile_claim.sso_unavailable` when no identity provider is configured.
* Anonymous, like the password claim beside it, and ordered like it:
*
* 1. refusals that read no token (`claimGate`: no identity provider, a live
* session, the per-address bucket the password claim also draws on);
* 2. the body, strictly parsed, and the link checked;
* 3. the username probe, so the person learns a name is taken here rather
* than after the round trip to the provider.
*
* Nothing about the account changes here. The answer is a sealed `hl_claim`
* cookie (`claim-cookie.ts`) naming the link by its id and carrying the
* chosen username, and `{ redirectTo }` for the browser to navigate to. The
* OIDC callback binds the provider's subject to the profile, under the lock,
* with every check repeated. The token never rides a URL and never reaches the
* cookie.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import { notImplemented } from "@/lib/http/not-implemented";
import { claimGate } from "@/lib/managed-profiles/claim-gate";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
CLAIM_SSO_REDIRECT,
setClaimCookie,
} from "@/lib/managed-profiles/claim-cookie";
import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate";
import { usableHandoverForToken } from "@/lib/managed-profiles/handover";
import { claimSsoSchema } from "@/lib/validations/claim-sso";
export const POST = apiHandler(async (request: NextRequest) => {
// Refuses a signed-in caller and rate-limits the address, as the password
// claim does.
const gate = await claimGate(request, "claim", (await getSession()) !== null);
const gate = await claimGate(request, "sso", (await getSession()) !== null);
if (gate.refusal) return gate.refusal;
const { data: body, error: jsonError } = await safeJson(request, {
@@ -32,6 +50,43 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = claimSsoSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const { token, username } = parsed.data;
return notImplemented("profile_claim.sso.start");
// The link first, so the username probe below is no oracle for anybody
// without a live link (the password claim's argument).
const link = await usableHandoverForToken(token);
if (!link) {
annotate({
action: { name: "profile_claim.sso.start" },
meta: { found: false },
});
return claimInvalid();
}
const existing = await prisma.user.findUnique({
where: { username },
select: { id: true },
});
if (existing) {
return apiError("Username already taken", 409, {
errorCode: "profile_claim.taken",
});
}
const response = apiSuccess({ redirectTo: CLAIM_SSO_REDIRECT });
setClaimCookie(response, { handoverId: link.handoverId, username });
// Filed under the record, with no actor: nobody is signed in yet. No token,
// no username — the username is not the person's until the claim lands.
await auditLog("managed_profile.claim_sso.started", {
userId: link.profileId,
actorUserId: null,
ipAddress: gate.ip,
details: { handoverId: link.handoverId },
});
annotate({
action: { name: "profile_claim.sso.start" },
meta: { found: true },
});
return response;
});
+95 -2
View File
@@ -38,6 +38,18 @@ import {
mintNativeHandoff,
} from "@/lib/auth/oidc-native-handoff";
import { setPendingLinkCookie } from "@/lib/auth/oidc-pending-link";
import {
CLAIM_COOKIE,
CLAIM_ERROR_PATH,
claimMatchesBinding,
clearClaimCookie,
readClaimCookie,
} from "@/lib/managed-profiles/claim-cookie";
import {
completeSsoClaim,
SSO_CLAIM_LANDING,
} from "@/lib/managed-profiles/claim-sso";
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
const LOGIN_ERROR_URL = "/auth/login";
@@ -102,6 +114,14 @@ interface StoredOidcState {
native?: boolean;
/** The app's S256 PKCE challenge, bound at login; verified at the exchange. */
appCodeChallenge?: string;
/**
* v1.43 (#959) — set only when the login carried `intent=claim` and a valid
* `hl_claim` cookie. The callback then claims the managed profile the
* cookie names instead of signing in, and only when the cookie still
* carries this exact binding. Web only: never together with `native`.
*/
intent?: "claim";
claimBinding?: string;
}
function isStoredOidcState(value: unknown): value is StoredOidcState {
@@ -122,6 +142,13 @@ function isStoredOidcState(value: unknown): value is StoredOidcState {
if (v.native !== true) return false;
if (typeof v.appCodeChallenge !== "string") return false;
}
if (v.intent !== undefined) {
if (v.intent !== "claim") return false;
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
return false;
}
if (v.native !== undefined) return false;
}
return true;
}
@@ -153,12 +180,27 @@ export const GET = apiHandler(async (req: NextRequest) => {
}
}
const isNative = stored?.native === true;
const isClaim = stored?.intent === "claim";
// Native-aware error redirect: the custom scheme for a decrypted native flow,
// the web login page otherwise. Deletes the single-use state cookie either
// way. An error redirect carries no code, ticket, or session, so routing it
// to the scheme leaks nothing (spec §1).
// v1.43 (#959) — a refused claim returns to the claim page, never the
// sign-in page, and takes the claim cookie with it: the person starts again
// from the link, which is still theirs to use if the refusal left it open.
// Browser-only, like `errorRedirect`: a claim is never native.
const claimRedirect = (reason: string): NextResponse => {
const response = NextResponse.redirect(
oidcAppUrl(`${CLAIM_ERROR_PATH}?error=${reason}`),
);
deleteStateCookie(response);
clearClaimCookie(response);
return response;
};
const failRedirect = (reason: string): NextResponse => {
if (isClaim) return claimRedirect(reason);
if (isNative) {
const response = NextResponse.redirect(
buildNativeCallbackUrl({ error: reason }),
@@ -198,7 +240,9 @@ export const GET = apiHandler(async (req: NextRequest) => {
// Broken round-trip / missing-or-unreadable state blob / CSRF mismatch all
// resolve to the web `oidc_state` fallback: the flow is fundamentally broken
// and there is nothing safe to hand the app (spec §1 web-fallback).
if (!code || !state) return errorRedirect("oidc_state");
if (!code || !state) {
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
}
if (!stored) {
// Defence-in-depth idempotency for a duplicated callback. The single-use
@@ -240,7 +284,34 @@ export const GET = apiHandler(async (req: NextRequest) => {
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
) {
annotate({ meta: { reason: "csrf" } });
return errorRedirect("oidc_state");
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
}
// v1.43 (#959) — a claim is never finished over a live session (the claim
// routes' rule: a Guardian finishing their own link would otherwise be
// signed out of their account and into the person's), and is admitted only
// with its own sealed cookie, still bound to this sign-in, for a link that
// is still usable. All of it before the code is redeemed, so a refused claim
// spends nothing at the provider.
const pendingClaim = isClaim
? readClaimCookie(req.cookies.get(CLAIM_COOKIE)?.value)
: null;
if (isClaim) {
const liveSession = await validateSessionFromCookieValue(
req.cookies.get(SESSION_COOKIE_NAME)?.value,
);
if (liveSession) {
annotate({ meta: { reason: "claim_signed_in" } });
return failRedirect("claim_signed_in");
}
if (
!pendingClaim ||
!claimMatchesBinding(pendingClaim, stored.claimBinding as string) ||
!(await isHandoverUsable(pendingClaim.handoverId))
) {
annotate({ meta: { reason: "claim_expired" } });
return failRedirect("claim_expired");
}
}
try {
@@ -274,6 +345,28 @@ export const GET = apiHandler(async (req: NextRequest) => {
emailVerified = userinfo.emailVerified;
}
// v1.43 (#959) — the claim. The provider has authenticated somebody; bind
// that subject to the profile the cookie names, or refuse. Never falls
// through to the sign-in below: a refused claim signs nobody in, and
// provisions and links nothing.
if (pendingClaim) {
const outcome = await completeSsoClaim({
handoverId: pendingClaim.handoverId,
username: pendingClaim.username,
issuer: metadata.issuer,
sub: identity.sub,
email,
emailVerified,
ipAddress: ip,
userAgent: req.headers.get("user-agent"),
});
if (!outcome.ok) return failRedirect(outcome.reason);
const response = NextResponse.redirect(oidcAppUrl(SSO_CLAIM_LANDING));
deleteStateCookie(response);
clearClaimCookie(response);
return response;
}
// (1) Durable identity match — the (issuer, sub) pair stamped on a
// previous login, link, or provision. Email is deliberately NOT
// consulted here: after the stamp it is a display field, so an
@@ -214,3 +214,50 @@ describe("GET /api/auth/oidc/login", () => {
});
});
});
describe("GET /api/auth/oidc/login?intent=claim (v1.43, #959)", () => {
beforeEach(() => {
vi.mocked(getOidcConfig).mockReturnValue({
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
});
});
it("refuses a claim on the native leg with oidc_invalid_request", async () => {
const res = await GET(
makeRequest(
`/api/auth/oidc/login?intent=claim&client=native&code_challenge=${"a".repeat(43)}`,
),
);
expect(res.headers.get("location")).toMatch(
/^healthlog:\/\/oidc-callback\?error=oidc_invalid_request$/,
);
expect(discoverOidcMetadata).not.toHaveBeenCalled();
expect(res.cookies.get("oidc_auth_state")).toBeUndefined();
});
it("sends a claim without its cookie back to the claim page, before the provider", async () => {
const res = await GET(makeRequest("/api/auth/oidc/login?intent=claim"));
expect(res.headers.get("location")).toBe(
"https://healthlog.example.com/auth/claim?error=claim_expired",
);
expect(discoverOidcMetadata).not.toHaveBeenCalled();
expect(res.cookies.get("oidc_auth_state")).toBeUndefined();
});
it("routes the claim's other failures to the claim page too", async () => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
reset: 0,
ip: "1.2.3.4",
} as never);
const res = await GET(makeRequest("/api/auth/oidc/login?intent=claim"));
expect(res.headers.get("location")).toBe(
"https://healthlog.example.com/auth/claim?error=oidc_rate_limited",
);
});
});
+50 -5
View File
@@ -21,6 +21,12 @@ import {
OIDC_STATE_COOKIE_PATH,
OIDC_STATE_TTL_MS,
} from "@/lib/auth/oidc-cookie";
import {
CLAIM_COOKIE,
CLAIM_ERROR_PATH,
readClaimCookie,
} from "@/lib/managed-profiles/claim-cookie";
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
/**
* Redirects the browser to the configured OIDC provider's authorization
@@ -40,16 +46,34 @@ export const GET = apiHandler(async (req: NextRequest) => {
// nothing — an error redirect carries no code, ticket, or session (spec §1).
const isNative = req.nextUrl.searchParams.get("client") === "native";
const appCodeChallenge = req.nextUrl.searchParams.get("code_challenge");
// v1.43 (#959) — `intent=claim`: the sign-in claims a managed profile. The
// link and the username ride the sealed `hl_claim` cookie set by
// `POST /api/auth/claim/sso`, never this URL; the intent only says to look
// for it. Web only.
const isClaim = req.nextUrl.searchParams.get("intent") === "claim";
// Native-aware error redirect: the scheme for a native start, the web login
// page otherwise. Machine-readable `error=<reason>` in both.
// page otherwise, and the claim page for a claim. Machine-readable
// `error=<reason>` in all three.
const loginError = (reason: string): NextResponse => {
if (isNative) {
return NextResponse.redirect(buildNativeCallbackUrl({ error: reason }));
}
if (isClaim) {
return NextResponse.redirect(
oidcAppUrl(`${CLAIM_ERROR_PATH}?error=${reason}`),
);
}
return NextResponse.redirect(oidcAppUrl(`/auth/login?error=${reason}`));
};
// The app never claims a profile: the claim runs in a browser, and the new
// owner signs in to the app afterwards through the ordinary native leg.
if (isClaim && isNative) {
annotate({ meta: { reason: "claim_on_native" } });
return loginError("oidc_invalid_request");
}
const config = getOidcConfig();
if (!config) {
return loginError("oidc_disabled");
@@ -90,6 +114,19 @@ export const GET = apiHandler(async (req: NextRequest) => {
return loginError("oidc_invalid_request");
}
// A claim needs its sealed cookie and a link that is still usable. Checked
// here, after the rate limit, so a dead link is told before the round trip
// to the provider; the callback checks both again.
let claimBinding: string | null = null;
if (isClaim) {
const claim = readClaimCookie(req.cookies.get(CLAIM_COOKIE)?.value);
if (!claim || !(await isHandoverUsable(claim.handoverId))) {
annotate({ meta: { reason: "claim_expired" } });
return loginError("claim_expired");
}
claimBinding = claim.binding;
}
let metadata;
try {
metadata = await discoverOidcMetadata(config);
@@ -104,10 +141,12 @@ export const GET = apiHandler(async (req: NextRequest) => {
const codeChallenge = s256Challenge(codeVerifier);
// `next` is a post-login WEB path, meaningless to the app — pin it to "/" on
// the native branch (one less input to sanitize).
const safeNext = isNative
? "/"
: sanitizeOidcNextPath(req.nextUrl.searchParams.get("next"), req.url);
// the native branch (one less input to sanitize). A claim lands on a fixed
// page the callback names, so its `next` is pinned too.
const safeNext =
isNative || isClaim
? "/"
: sanitizeOidcNextPath(req.nextUrl.searchParams.get("next"), req.url);
const redirectUri = getOidcRedirectUri();
const authorizationUrl = buildAuthorizationUrl({
@@ -133,6 +172,12 @@ export const GET = apiHandler(async (req: NextRequest) => {
...(isNative
? { native: true as const, appCodeChallenge: appCodeChallenge! }
: {}),
// The claim's binding, inside the same tamper-authenticated blob: the
// callback admits the claim only when the cookie it carries back holds
// this exact value.
...(claimBinding !== null
? { intent: "claim" as const, claimBinding }
: {}),
}),
);
response.cookies.set(OIDC_STATE_COOKIE, cookiePayload, {
@@ -30,8 +30,8 @@ import {
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 { handoverMethods } from "@/lib/managed-profiles/claim-gate";
import {
createHandover,
HandoverError,
@@ -49,16 +49,15 @@ const notFound = () =>
});
/**
* 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.
* A link nobody could redeem is not minted. Since v1.43 the person can claim
* through the identity provider, so a single-sign-on-only server hands over
* too; this answers only when neither a password nor single sign-on is
* possible, which a working configuration never produces.
*/
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" },
);
const noMethodRefusal = () =>
apiError("Handing over a profile is not available on this server", 403, {
errorCode: "profile_claim.oidc_only_unsupported",
});
/** Same ceiling as creating, editing and inviting: ten an hour per caller. */
const CREATE_LIMIT = 10;
@@ -79,8 +78,10 @@ export const GET = apiHandler(
action: { name: "managed_profile.handover.read" },
meta: { profile_id: id, open: status.open !== null },
});
const methods = handoverMethods();
return apiSuccess({
available: !isOidcOnly(),
available: methods.length > 0,
methods,
open: status.open
? {
id: status.open.id,
@@ -107,7 +108,7 @@ export const POST = apiHandler(
if (!rateLimit.allowed) {
return apiError("Too many handover links, try again later", 429);
}
if (isOidcOnly()) return oidcOnlyRefusal();
if (handoverMethods().length === 0) return noMethodRefusal();
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
+9 -2
View File
@@ -6,7 +6,9 @@ import { ClaimProfile } from "@/components/auth/claim-profile";
/**
* v1.42 (#959) — `/auth/claim?token=hlp_…`, where the link a Guardian handed
* over (`/claim/<token>`) lands after the edge redirect.
* over (`/claim/<token>`) lands after the edge redirect. Since v1.43 also
* `/auth/claim?error=<reason>`, where a refused single-sign-on claim returns;
* the reason is a fixed word, never a credential.
*
* A URL-credential surface by shape (`session-surface-guard.test.ts`, S2): the
* page reads the one-time token off the query string and passes it to the
@@ -16,5 +18,10 @@ import { ClaimProfile } from "@/components/auth/claim-profile";
*/
export default function ClaimProfilePage() {
const searchParams = useSearchParams();
return <ClaimProfile token={searchParams.get("token")} />;
return (
<ClaimProfile
token={searchParams.get("token")}
error={searchParams.get("error")}
/>
);
}
@@ -19,6 +19,7 @@ vi.mock("@/hooks/use-auth", () => ({
vi.mock("@/lib/queries/use-profile-claim", () => ({
useProfileClaimPreview: () => ({ data: undefined, error: null }),
useClaimProfile: () => ({ mutate: () => {}, isPending: false }),
useClaimProfileSso: () => ({ mutate: () => {}, isPending: false }),
}));
import { ClaimProfile } from "../claim-profile";
@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { ProfileClaimPreview } from "@/lib/queries/use-profile-claim";
/**
* v1.43 (#959) — the claim page offers the ways in the server names: the
* password form, the identity provider, or both. A refused single-sign-on
* claim comes back with `?error=` and no link, and says what happened.
*/
let previewData: ProfileClaimPreview | undefined;
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: () => {}, refresh: () => {}, push: () => {} }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: null }),
clearCachesForSessionEnd: () => {},
}));
vi.mock("@/lib/queries/use-profile-claim", () => ({
useProfileClaimPreview: () => ({ data: previewData, error: null }),
useClaimProfile: () => ({ mutate: () => {}, isPending: false }),
useClaimProfileSso: () => ({ mutate: () => {}, isPending: false }),
}));
import { ClaimProfile, claimSsoErrorKey } from "../claim-profile";
const BASE: ProfileClaimPreview = {
displayName: "Robin",
expiresAt: "2030-01-01T00:00:00.000Z",
guardians: [],
};
function render(props: { token: string | null; error?: string | null }) {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<ClaimProfile {...props} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("ClaimProfile — ways in", () => {
it("offers only the provider on a single-sign-on-only server", () => {
previewData = { ...BASE, methods: ["sso"], ssoProviderName: "Example ID" };
const html = render({ token: "hlp_x" });
expect(html).toContain('data-slot="claim-sso-continue"');
expect(html).toContain("Continue with Example ID");
expect(html).not.toContain('id="claim-password"');
expect(html).not.toContain('id="claim-email"');
expect(html).not.toContain('data-slot="claim-submit"');
expect(html).toContain('id="claim-username"');
});
it("offers both when both are allowed", () => {
previewData = {
...BASE,
methods: ["password", "sso"],
ssoProviderName: "Example ID",
};
const html = render({ token: "hlp_x" });
expect(html).toContain('data-slot="claim-sso-continue"');
expect(html).toContain('id="claim-password"');
expect(html).toContain('data-slot="claim-submit"');
});
it("offers only the password when an older server names no methods", () => {
previewData = { ...BASE };
const html = render({ token: "hlp_x" });
expect(html).not.toContain('data-slot="claim-sso-continue"');
expect(html).toContain('id="claim-password"');
});
});
describe("ClaimProfile — a refused single-sign-on claim", () => {
it("says what happened and offers a way on", () => {
previewData = undefined;
const html = render({ token: null, error: "claim_sso_subject_bound" });
expect(html).toContain('data-testid="claim-sso-error"');
expect(html).toContain("already belongs to another account");
expect(html).toContain('href="/auth/login"');
});
it("maps every reason the callback sends, and reads an unknown one as a failure", () => {
expect(claimSsoErrorKey("claim_expired")).toBe("auth.claim.invalidHint");
expect(claimSsoErrorKey("claim_sso_subject_bound")).toBe(
"auth.claim.errorSubjectBound",
);
expect(claimSsoErrorKey("claim_email_taken")).toBe(
"auth.claim.errorEmailTaken",
);
expect(claimSsoErrorKey("claim_username_taken")).toBe(
"auth.claim.errorUsernameTaken",
);
expect(claimSsoErrorKey("claim_signed_in")).toBe(
"auth.claim.errorSignedIn",
);
expect(claimSsoErrorKey("oidc_failed")).toBe("auth.claim.errorSso");
expect(claimSsoErrorKey("<script>")).toBe("auth.claim.errorSso");
});
});
+209 -54
View File
@@ -4,9 +4,10 @@ import { useId, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2, LogOut } from "lucide-react";
import { KeyRound, Loader2, LogIn, LogOut } from "lucide-react";
import { accessLabel } from "@/components/settings/access/managed-profile-handover";
import { useClientTransport } from "@/components/auth/insecure-transport-banner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -15,8 +16,10 @@ import { PasswordStrength } from "@/components/ui/password-strength";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { loadDocument } from "@/lib/navigation/load-document";
import {
useClaimProfile,
useClaimProfileSso,
useProfileClaimPreview,
} from "@/lib/queries/use-profile-claim";
import { queryKeys } from "@/lib/query-keys";
@@ -33,8 +36,22 @@ import { queryKeys } from "@/lib/query-keys";
* record's name, who keeps which access for now, and until when the link
* holds. No health data. The decision about each Guardian comes right after
* the first sign-in, on the screen the claim lands on.
*
* v1.43 — the ways in follow the preview's `methods`: the password form, a
* "Continue with <provider>" button that claims through single sign-on, or
* both. The single-sign-on leg leaves the page for the identity provider and
* comes back to `/onboarding/handover`, or to this page with `?error=` when
* the callback refused; the link is not in that URL, so the error card asks
* the person to open the link again.
*/
export function ClaimProfile({ token }: { token: string | null }) {
export function ClaimProfile({
token,
error = null,
}: {
token: string | null;
/** The single-sign-on callback's refusal, from `?error=`. */
error?: string | null;
}) {
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslations();
@@ -44,6 +61,8 @@ export function ClaimProfile({ token }: { token: string | null }) {
const { user } = useAuth();
const preview = useProfileClaimPreview(token, !user);
const claim = useClaimProfile();
const claimSso = useClaimProfileSso();
const clientTransport = useClientTransport();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
@@ -73,6 +92,25 @@ export function ClaimProfile({ token }: { token: string | null }) {
}
}
function continueWithSso() {
if (!token || claimSso.isPending || usernameReserved) return;
claimSso.mutate(
{ token, username },
{
onSuccess: ({ redirectTo }) => {
// Only the path and query of the server's answer, so a response
// could never send the browser off this origin.
const next = new URL(redirectTo, window.location.origin);
if (clientTransport) {
next.searchParams.set("client_proto", clientTransport.protocol);
next.searchParams.set("client_host", clientTransport.host);
}
loadDocument(`${next.pathname}${next.search}`);
},
},
);
}
function submit(event: React.FormEvent) {
event.preventDefault();
if (!token || claim.isPending || usernameReserved) return;
@@ -118,6 +156,35 @@ export function ClaimProfile({ token }: { token: string | null }) {
);
}
if (!token && error) {
const bound = error === "claim_sso_subject_bound";
return (
<ClaimCard testId="claim-sso-error">
<Header
title={
error === "claim_expired"
? t("auth.claim.invalid")
: t("auth.claim.errorTitle")
}
description={t(claimSsoErrorKey(error))}
/>
{/* The way on: the sign-in page (for a sign-in that already belongs
to an account, that account's). The link itself is not here. */}
<Button
asChild
variant="outline"
size="lg"
className="mt-8 min-h-11 w-full"
data-slot="claim-to-login"
>
<Link href="/auth/login">
{bound ? t("auth.claim.signInInstead") : t("auth.login")}
</Link>
</Button>
</ClaimCard>
);
}
if (!token || previewStatus === 404 || previewStatus === 422) {
return (
<ClaimCard testId="claim-invalid">
@@ -192,12 +259,18 @@ export function ClaimProfile({ token }: { token: string | null }) {
}
const data = preview.data;
const errorKey = claim.isError ? claimErrorKey(claim.error) : null;
// An older server sends no `methods`: it only ever offered the password.
const methods = data.methods ?? ["password"];
const offersPassword = methods.includes("password");
const offersSso = methods.includes("sso");
const providerName = data.ssoProviderName ?? t("auth.claim.ssoFallbackName");
const failed = claimSso.isError ? claimSso : claim;
const errorKey = failed.isError ? claimErrorKey(failed.error) : null;
const errorText =
claim.error instanceof ApiError &&
claim.error.status === 422 &&
claim.error.message
? claim.error.message
failed.error instanceof ApiError &&
failed.error.status === 422 &&
failed.error.message
? failed.error.message
: errorKey
? t(errorKey)
: null;
@@ -241,7 +314,19 @@ export function ClaimProfile({ token }: { token: string | null }) {
</p>
</section>
<form onSubmit={submit} className="mt-6 space-y-4" data-slot="claim-form">
<form
onSubmit={
offersPassword
? submit
: (event) => {
// Enter in the username field on an SSO-only server.
event.preventDefault();
continueWithSso();
}
}
className="mt-6 space-y-4"
data-slot="claim-form"
>
<div className="space-y-2">
<Label htmlFor="claim-username">{t("auth.username")}</Label>
<Input
@@ -270,36 +355,43 @@ export function ClaimProfile({ token }: { token: string | null }) {
: t("auth.claim.usernameHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="claim-email">{t("auth.email")}</Label>
<Input
id="claim-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
autoCapitalize="none"
spellCheck={false}
aria-describedby={`${errorId}-email`}
/>
<p id={`${errorId}-email`} className="text-muted-foreground text-xs">
{t("auth.claim.emailHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="claim-password">{t("auth.password")}</Label>
<Input
id="claim-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
placeholder={t("auth.passwordMinLength")}
/>
<PasswordStrength password={password} />
</div>
{offersPassword && (
<>
<div className="space-y-2">
<Label htmlFor="claim-email">{t("auth.email")}</Label>
<Input
id="claim-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
autoCapitalize="none"
spellCheck={false}
aria-describedby={`${errorId}-email`}
/>
<p
id={`${errorId}-email`}
className="text-muted-foreground text-xs"
>
{t("auth.claim.emailHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="claim-password">{t("auth.password")}</Label>
<Input
id="claim-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
placeholder={t("auth.passwordMinLength")}
/>
<PasswordStrength password={password} />
</div>
</>
)}
<p className="text-sm">{t("auth.claim.consentNote")}</p>
<p className="text-muted-foreground text-xs">
@@ -320,22 +412,63 @@ export function ClaimProfile({ token }: { token: string | null }) {
</div>
)}
<Button
type="submit"
size="lg"
className="min-h-11 w-full"
disabled={claim.isPending || usernameReserved}
data-slot="claim-submit"
>
{claim.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="size-4" />
)}
{claim.isPending
? t("auth.claim.submitting")
: t("auth.claim.submit")}
</Button>
{offersPassword && (
<Button
type="submit"
size="lg"
className="min-h-11 w-full"
disabled={claim.isPending || usernameReserved}
data-slot="claim-submit"
>
{claim.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="size-4" />
)}
{claim.isPending
? t("auth.claim.submitting")
: t("auth.claim.submit")}
</Button>
)}
{offersPassword && offersSso && (
<div
className="text-muted-foreground flex items-center gap-3 text-xs"
aria-hidden="true"
>
<span className="bg-border h-px flex-1" />
{t("auth.claim.ssoDivider")}
<span className="bg-border h-px flex-1" />
</div>
)}
{offersSso && (
<div className="space-y-2" data-slot="claim-sso">
<Button
type="button"
size="lg"
variant={offersPassword ? "outline" : "default"}
className="min-h-11 w-full"
disabled={
claimSso.isPending || usernameReserved || username.length < 3
}
onClick={continueWithSso}
data-slot="claim-sso-continue"
>
{claimSso.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<LogIn className="size-4" />
)}
{claimSso.isPending
? t("auth.claim.ssoStarting")
: t("auth.claim.ssoContinue", { provider: providerName })}
</Button>
<p className="text-muted-foreground text-xs">
{t("auth.claim.ssoHint", { provider: providerName })}
</p>
</div>
)}
</form>
</ClaimCard>
);
@@ -378,6 +511,28 @@ function Header({
);
}
/**
* v1.43 — the message for the single-sign-on callback's refusal, from the
* claim page's `?error=`. Exported and pure so it can be pinned. A reason this
* build does not know reads as the generic failure.
*/
export function claimSsoErrorKey(reason: string): string {
switch (reason) {
case "claim_expired":
return "auth.claim.invalidHint";
case "claim_sso_subject_bound":
return "auth.claim.errorSubjectBound";
case "claim_email_taken":
return "auth.claim.errorEmailTaken";
case "claim_username_taken":
return "auth.claim.errorUsernameTaken";
case "claim_signed_in":
return "auth.claim.errorSignedIn";
default:
return "auth.claim.errorSso";
}
}
/** The message for a refused claim. Exported and pure so it can be pinned. */
export function claimErrorKey(err: unknown): string {
if (!(err instanceof ApiError)) return "auth.claim.errorOffline";
@@ -110,6 +110,18 @@ export function ManagedProfileHandover({
name: profileName,
})}
</p>
{/* v1.43 — how the person will get in, when it is not only a password
they set themselves (the description already implies that one). */}
{status.data.available && status.data.methods?.includes("sso") ? (
<p className="text-sm" data-slot="managed-profile-handover-methods">
{t(
status.data.methods.includes("password")
? "recordSharing.managed.handover.methodBoth"
: "recordSharing.managed.handover.methodSso",
{ name: profileName },
)}
</p>
) : null}
{!status.data.available ? (
<>
+20
View File
@@ -1,3 +1,4 @@
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import { describe, expect, it } from "vitest";
import { redactSecrets, redactOptional, PATH_SECRET_PATHS } from "../redact";
@@ -249,6 +250,25 @@ describe("redactSecrets", () => {
"/api/auth/claim/preview",
);
expect(redactSecrets("/api/auth/claim")).toBe("/api/auth/claim");
// v1.43 (#959) — the single-sign-on start and its landing carry no
// credential in the URL, and stay readable.
expect(redactSecrets("/api/auth/claim/sso")).toBe("/api/auth/claim/sso");
expect(redactSecrets("/api/auth/oidc/login?intent=claim")).toBe(
"/api/auth/oidc/login?intent=claim",
);
expect(redactSecrets("/auth/claim?error=claim_expired")).toBe(
"/auth/claim?error=claim_expired",
);
});
it("redacts the handover token in a single-sign-on claim body", () => {
const token = `hlp_${"ef".repeat(32)}`;
const excerpt = JSON.stringify(
redactSensitiveFields({ token, username: "robin" }),
);
expect(excerpt).not.toContain(token);
expect(excerpt).toContain("robin");
expect(redactSecrets(JSON.stringify({ token }))).not.toContain(token);
});
it("registers the clinician share-view prefix in PATH_SECRET_PATHS", () => {
+111
View File
@@ -0,0 +1,111 @@
/**
* v1.43 (#959) — the sealed cookie that carries a single-sign-on claim through
* the round trip to the identity provider.
*
* `POST /api/auth/claim/sso` checks the handover token and the chosen username
* and seals `{ handoverId, username, binding, exp }` into `hl_claim`. The
* token itself never leaves that request: the cookie names the link by its
* id, and it can only have been minted by a request that held the token.
*
* - AES-256-GCM with the at-rest key (`encrypt`), so it can be neither read
* nor forged, and it carries its own expiry on top of `Max-Age`.
* - `HttpOnly`, `SameSite=Lax` (the browser returns from the provider on a
* cross-site top-level GET and must carry it), `Secure` through
* `shouldEmitSecureCookie()`.
* - Scoped to `/api/auth/oidc`, the only two routes that read it.
* - `binding` is a fresh random value per start. The login copies it into
* the encrypted OIDC state, and the callback admits the claim only when
* the cookie and the state carry the same one, so a claim cookie from a
* different start (another tab, an older attempt) cannot be paired with
* this sign-in.
*/
import { randomBytes, timingSafeEqual } from "node:crypto";
import type { NextResponse } from "next/server";
import { decrypt, encrypt } from "@/lib/crypto";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
export const CLAIM_COOKIE = "hl_claim";
export const CLAIM_COOKIE_PATH = "/api/auth/oidc";
export const CLAIM_COOKIE_TTL_MS = 10 * 60 * 1000;
/**
* Where `POST /api/auth/claim/sso` sends the browser next. A fixed path:
* nothing caller-supplied ever reaches a redirect on this flow.
*/
export const CLAIM_SSO_REDIRECT = "/api/auth/oidc/login?intent=claim";
/** Where every refusal on the single-sign-on claim lands, with `?error=`. */
export const CLAIM_ERROR_PATH = "/auth/claim";
export interface PendingSsoClaim {
handoverId: string;
username: string;
binding: string;
}
/** Attach a freshly sealed claim to a response. Returns what was sealed. */
export function setClaimCookie(
response: NextResponse,
claim: { handoverId: string; username: string },
now: number = Date.now(),
): PendingSsoClaim {
const binding = randomBytes(32).toString("base64url");
const sealed = encrypt(
JSON.stringify({
h: claim.handoverId,
u: claim.username,
b: binding,
e: now + CLAIM_COOKIE_TTL_MS,
}),
);
response.cookies.set(CLAIM_COOKIE, sealed, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(CLAIM_COOKIE_TTL_MS / 1000),
path: CLAIM_COOKIE_PATH,
});
return { ...claim, binding };
}
/** The claim a cookie value carries, or null when absent, forged or expired. */
export function readClaimCookie(
raw: string | undefined,
now: number = Date.now(),
): PendingSsoClaim | null {
if (!raw) return null;
try {
const parsed = JSON.parse(decrypt(raw)) as Record<string, unknown>;
if (
typeof parsed.h !== "string" ||
parsed.h.length === 0 ||
typeof parsed.u !== "string" ||
parsed.u.length === 0 ||
typeof parsed.b !== "string" ||
parsed.b.length === 0 ||
typeof parsed.e !== "number" ||
parsed.e <= now
) {
return null;
}
return { handoverId: parsed.h, username: parsed.u, binding: parsed.b };
} catch {
return null;
}
}
/** Whether a claim belongs to the sign-in whose state carries `binding`. */
export function claimMatchesBinding(
claim: PendingSsoClaim,
binding: string,
): boolean {
const a = Buffer.from(claim.binding);
const b = Buffer.from(binding);
return a.length === b.length && timingSafeEqual(a, b);
}
/** Expire the claim cookie on a response; the path must repeat the set's. */
export function clearClaimCookie(response: NextResponse): void {
response.cookies.delete({ name: CLAIM_COOKIE, path: CLAIM_COOKIE_PATH });
}
+42 -9
View File
@@ -1,11 +1,14 @@
/**
* v1.42 (#959) — the refusals both anonymous claim routes give before they
* look at a token, in one place so the preview and the claim cannot disagree.
* v1.42 (#959) — the refusals the anonymous claim routes give before they look
* at a token, in one place so the preview and the claims cannot disagree.
*
* Order matters and is the register route's order:
*
* 1. A single-sign-on-only instance admits no password, and the claim sets
* one, so the handover is refused outright in v1.42.
* 1. What this server cannot do at all. The password claim sets a password,
* so a single-sign-on-only instance refuses it outright; the
* single-sign-on claim (v1.43) needs an identity provider, and answers
* 404 `profile_claim.sso_unavailable` without one. The preview is
* refused by neither: it is what tells the page which way is open.
* 2. A signed-in caller is refused with 409. Claiming creates a session for
* the new owner, and a Guardian testing their own link would otherwise be
* signed out of their account and into the person's. Refused before the
@@ -13,12 +16,14 @@
* would lock out the person the link is for, who may share the address.
* 3. The per-address rate limit, the anonymous auth-surface pattern: a
* misconfigured proxy trust chain collapses every caller into one tight
* bucket rather than one free-for-all.
* bucket rather than one free-for-all. Both claims share one bucket, so
* switching between them buys no extra attempts.
*/
import { NextResponse } from "next/server";
import { apiError } from "@/lib/api-response";
import { isOidcOnly } from "@/lib/auth/oidc";
import { getOidcConfig, isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc";
import type { HandoverMethod } from "@/lib/validations/claim-sso";
import { annotate } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -29,9 +34,28 @@ export function claimInvalid() {
});
}
/**
* v1.43 (#959) — how a handover link can be redeemed on this server: with a
* password unless the server allows single sign-on only, and through the
* identity provider whenever one is configured. Never empty in practice
* (`OIDC_ONLY` only takes effect with a configured provider), but the Guardian
* side refuses on an empty list rather than assuming it.
*/
export function handoverMethods(): HandoverMethod[] {
const methods: HandoverMethod[] = [];
if (!isOidcOnly()) methods.push("password");
if (isOidcConfigured()) methods.push("sso");
return methods;
}
/** The provider's name for the "Continue with …" button, or null. */
export function ssoProviderName(): string | null {
return getOidcConfig()?.buttonLabel ?? null;
}
export async function claimGate(
request: Request,
surface: "preview" | "claim",
surface: "preview" | "claim" | "sso",
/**
* Whether the request carries a live session. Resolved by the ROUTE with
* `getSession()` and handed in, rather than resolved here: a session read
@@ -41,15 +65,24 @@ export async function claimGate(
*/
signedIn: boolean,
): Promise<{ refusal: Response } | { refusal: null; ip: string | null }> {
if (isOidcOnly()) {
if (surface === "claim" && isOidcOnly()) {
return {
refusal: apiError(
"Taking over a profile is not available while this server allows single sign-on only",
"This server allows single sign-on only; take the profile over through the identity provider",
403,
{ errorCode: "profile_claim.oidc_only_unsupported" },
),
};
}
if (surface === "sso" && !isOidcConfigured()) {
return {
refusal: apiError(
"Single sign-on is not configured on this server",
404,
{ errorCode: "profile_claim.sso_unavailable" },
),
};
}
if (signedIn) {
annotate({
+178
View File
@@ -0,0 +1,178 @@
/**
* v1.43 (#959) — the callback's half of claiming a managed profile through
* single sign-on: the identity provider has authenticated somebody, the
* sealed claim cookie names the link and the chosen username, and this binds
* the one to the other.
*
* The refusals, in order, each one before anything about any account changes:
*
* 1. `claim_expired` — the link is no longer usable (used, withdrawn,
* replaced, expired, its creator gone, the profile no longer managed).
* 2. `claim_sso_subject_bound` — the provider's `(issuer, sub)` already
* signs in to an account here. One subject, one account: binding it a
* second time would make the sign-in ambiguous, and re-pointing it would
* hand the existing account's sign-in to the claimed profile.
* 3. `claim_email_taken` — the provider vouches (`email_verified: true`) for
* an address another account holds. An unverified address is not used at
* all: the account is created without one rather than with a claim
* nobody checked.
* 4. `claim_username_taken` — the username chosen at the start was taken in
* the ten minutes since.
*
* Then `claimManagedProfile` — the password claim's own transaction, under
* the managed-profile lock, with every link check repeated — sets the
* username, the address, no password and the subject; applies each Guardian's
* proposed access; resets disclaimer, onboarding and AI consent. A race lost
* at the unique indexes is re-read and answered as the refusal it turned out
* to be. The session is minted only after that commit.
*/
import { auditLog } from "@/lib/auth/audit";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { createSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
claimManagedProfile,
HandoverError,
isHandoverUsable,
} from "@/lib/managed-profiles/handover";
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
export type SsoClaimRefusal =
| "claim_expired"
| "claim_sso_subject_bound"
| "claim_email_taken"
| "claim_username_taken";
export type SsoClaimOutcome =
{ ok: true; profileId: string } | { ok: false; reason: SsoClaimRefusal };
/** Where the new owner lands: the decision about each former Guardian. */
export const SSO_CLAIM_LANDING = "/onboarding/handover";
interface SsoClaimInput {
handoverId: string;
username: string;
issuer: string;
sub: string;
email: string | null;
emailVerified: boolean | undefined;
ipAddress: string;
userAgent: string | null;
}
/** Which of the three unique values is held by an account already. */
async function collision(input: {
issuer: string;
sub: string;
email: string | null;
username: string;
}): Promise<SsoClaimRefusal | null> {
const bound = await prisma.user.findFirst({
where: { oidcIssuer: input.issuer, oidcSub: input.sub },
select: { id: true },
});
if (bound) {
// Under the account that holds the subject, so its owner's history shows
// that somebody tried to bind their sign-in to a second record.
await auditLog("auth.oidc.claim_conflict", {
userId: bound.id,
actorUserId: null,
});
return "claim_sso_subject_bound";
}
if (input.email) {
const byEmail = await prisma.user.findFirst({
where: { email: { equals: input.email, mode: "insensitive" } },
select: { id: true },
});
if (byEmail) return "claim_email_taken";
}
const byName = await prisma.user.findUnique({
where: { username: input.username },
select: { id: true },
});
return byName ? "claim_username_taken" : null;
}
export async function completeSsoClaim(
input: SsoClaimInput,
): Promise<SsoClaimOutcome> {
const refuse = (reason: SsoClaimRefusal): SsoClaimOutcome => {
annotate({
action: { name: "profile_claim.sso.refused" },
meta: { reason },
});
return { ok: false, reason };
};
if (!(await isHandoverUsable(input.handoverId))) {
return refuse("claim_expired");
}
const email =
input.email && input.emailVerified === true
? input.email.toLowerCase()
: null;
const identity = {
issuer: input.issuer,
sub: input.sub,
email,
username: input.username,
};
const early = await collision(identity);
if (early) return refuse(early);
let claimed;
try {
claimed = await claimManagedProfile({
link: { handoverId: input.handoverId },
username: input.username,
email,
passwordHash: null,
oidc: { issuer: input.issuer, sub: input.sub },
ipAddress: input.ipAddress,
});
} catch (error) {
if (error instanceof HandoverError) {
if (error.code === "taken") {
// Lost a race at a unique index: say which one.
return refuse((await collision(identity)) ?? "claim_username_taken");
}
return refuse("claim_expired");
}
throw error;
}
// The new owner starts with onboarding owed, as after the password claim.
// The fifth argument stays null: single sign-on is a delegated factor and
// must never satisfy a step-up (the OIDC login's rule).
await createSession(
claimed.profileId,
true,
input.ipAddress,
input.userAgent,
null,
);
void recordSignInDevice({
userId: claimed.profileId,
ip: input.ipAddress,
userAgent: input.userAgent,
alertOnNew: false,
});
await auditLog("auth.claim", {
userId: claimed.profileId,
actorUserId: null,
ipAddress: input.ipAddress,
details: { method: "sso" },
});
notifyGuardiansOfHandover("claimed", claimed.displayName, claimed.guardians);
annotate({
action: { name: "profile_claim.complete" },
meta: { guardians: claimed.guardians.length, method: "sso" },
});
return { ok: true, profileId: claimed.profileId };
}
+92 -15
View File
@@ -444,17 +444,19 @@ export interface HandoverPreview {
}
/**
* What a link would hand over, or null for every kind of unusable link.
*
* Deliberately minimal: the profile's name, the expiry, and who keeps which
* access. No health data, no date of birth, no usernames and no grant ids —
* the person holding the link has not proved anything yet beyond holding it.
* The open link behind a stored row, or null when it can no longer be
* claimed: used, withdrawn, expired, a state this code cannot read, a profile
* no longer managed, or a creator who lost access. The one definition of
* "usable" the preview, the single-sign-on start and the callback share.
*/
export async function previewHandover(
rawToken: string,
now: Date = new Date(),
): Promise<HandoverPreview | null> {
const row = await findHandoverByToken(prisma, rawToken);
async function usableHandover(
row: Awaited<ReturnType<typeof findHandoverByToken>>,
now: Date,
): Promise<{
row: NonNullable<Awaited<ReturnType<typeof findHandoverByToken>>>;
state: HandoverState;
displayName: string | null;
} | null> {
if (!row || !isOpen(row, now)) return null;
const state = parseHandoverState(row.proposalsJson);
if (!state) return null;
@@ -467,6 +469,26 @@ export async function previewHandover(
if (!(await isActiveGuardian(prisma, row.profileId, row.createdById, now))) {
return null;
}
return { row, state, displayName: profile.displayName };
}
/**
* What a link would hand over, or null for every kind of unusable link.
*
* Deliberately minimal: the profile's name, the expiry, and who keeps which
* access. No health data, no date of birth, no usernames and no grant ids —
* the person holding the link has not proved anything yet beyond holding it.
*/
export async function previewHandover(
rawToken: string,
now: Date = new Date(),
): Promise<HandoverPreview | null> {
const usable = await usableHandover(
await findHandoverByToken(prisma, rawToken),
now,
);
if (!usable) return null;
const { row, state } = usable;
const guardians = await prisma.accountGrant.findMany({
where: { grantorId: row.profileId, ...activeGuardianWhere(now) },
@@ -477,7 +499,7 @@ export async function previewHandover(
orderBy: { createdAt: "asc" },
});
return {
displayName: profile.displayName,
displayName: usable.displayName,
expiresAt: row.expiresAt,
guardians: guardians.map((g) => ({
displayName: g.grantee.displayName?.trim() || null,
@@ -486,6 +508,39 @@ export async function previewHandover(
};
}
/**
* v1.43 (#959) — the usable link behind a raw token, or null. The
* single-sign-on start seals its id, never the token, into the claim cookie.
*/
export async function usableHandoverForToken(
rawToken: string,
now: Date = new Date(),
): Promise<{ handoverId: string; profileId: string } | null> {
const usable = await usableHandover(
await findHandoverByToken(prisma, rawToken),
now,
);
return usable
? { handoverId: usable.row.id, profileId: usable.row.profileId }
: null;
}
/**
* v1.43 (#959) — whether the link with this id can still be claimed. The
* single-sign-on legs re-ask on every step, so a link withdrawn or replaced
* while the person is at the identity provider is refused before any account
* changes. The claim re-checks under the lock; this decides nothing alone.
*/
export async function isHandoverUsable(
handoverId: string,
now: Date = new Date(),
): Promise<boolean> {
const row = await prisma.managedProfileHandover.findUnique({
where: { id: handoverId },
});
return (await usableHandover(row, now)) !== null;
}
export interface ClaimedProfile {
profileId: string;
username: string;
@@ -502,15 +557,33 @@ export interface ClaimedProfile {
* strength: none of that work belongs inside the lock.
*/
export async function claimManagedProfile(input: {
rawToken: string;
/**
* The link: the raw token from the password form, or (v1.43) the id the
* single-sign-on start sealed into the claim cookie after checking the
* token itself.
*/
link: { rawToken: string } | { handoverId: string };
username: string;
email: string;
passwordHash: string;
/** Null on a single-sign-on claim whose provider vouched for no address. */
email: string | null;
/** Null on a single-sign-on claim: the account signs in at the provider. */
passwordHash: string | null;
/**
* v1.43 (#959) — the identity provider's subject, bound to the profile in
* the same transaction. The `(issuer, sub)` unique index refuses a subject
* that another account holds, as `taken`.
*/
oidc?: { issuer: string; sub: string };
ipAddress?: string | null;
now?: Date;
}): Promise<ClaimedProfile> {
const now = input.now ?? new Date();
const found = await findHandoverByToken(prisma, input.rawToken);
const found =
"rawToken" in input.link
? await findHandoverByToken(prisma, input.link.rawToken)
: await prisma.managedProfileHandover.findUnique({
where: { id: input.link.handoverId },
});
if (!found) throw new HandoverError("invalid");
try {
@@ -551,6 +624,9 @@ export async function claimManagedProfile(input: {
username: input.username,
email: input.email,
passwordHash: input.passwordHash,
...(input.oidc
? { oidcIssuer: input.oidc.issuer, oidcSub: input.oidc.sub }
: {}),
disclaimerAcknowledgedAt: null,
disclaimerAcknowledgedVersion: null,
onboardingCompletedAt: null,
@@ -636,6 +712,7 @@ export async function claimManagedProfile(input: {
})),
withdrawnInvitations,
consentsWithdrawn: consent.revoked.length,
method: input.oidc ? "sso" : "password",
},
client: tx,
});
+1
View File
@@ -355,6 +355,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
profile_claim: [
"profile_claim.invalid",
"profile_claim.oidc_only_unsupported",
"profile_claim.sso_unavailable",
"profile_claim.taken",
],
record_write: ["record_write.rate_limited"],
+1 -1
View File
@@ -736,7 +736,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"403": {
description:
"The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`).",
"Neither a password nor single sign-on could redeem a link on this instance (`meta.errorCode: profile_claim.oidc_only_unsupported`). Since v1.43 a single-sign-on-only instance hands over through the identity provider, so a working configuration never answers this.",
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
+9 -10
View File
@@ -43,7 +43,6 @@ import {
} from "@/lib/validations/step-up";
import {
dataEnvelope,
notImplementedResponse,
profileEmailRateLimitResponse,
stdResponses,
errorEnvelope,
@@ -768,7 +767,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Claim a managed profile through single sign-on (v1.43)",
description:
"Web only. Checks the link and the username, sets a sealed `hl_claim` cookie (10 minutes, `httpOnly`) and answers where to continue: `/api/auth/oidc/login?intent=claim`. The OIDC callback binds the identity provider's subject to the profile and signs the new owner in; a subject already bound to an account, or a verified email another account holds, is refused there. Works on a single-sign-on-only instance. 404 `profile_claim.invalid` (no usable link) or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` (username) or `auth.already_authenticated`; rate-limited per source address like the password claim.",
"Web only. Checks the link and the username, sets a sealed `hl_claim` cookie (10 minutes, `httpOnly`) and answers where to continue: `/api/auth/oidc/login?intent=claim`. The OIDC callback binds the identity provider's subject to the profile and signs the new owner in; a subject already bound to an account, or a verified email another account holds, is refused there. Works on a single-sign-on-only instance. When the callback refuses, the browser lands on `/auth/claim?error=<reason>` with reason `claim_expired` (the link was used, withdrawn, replaced or expired in the meantime, or the cookie is missing, expired or belongs to another start), `claim_sso_subject_bound`, `claim_email_taken`, `claim_username_taken` (taken during the round trip), `claim_signed_in`, or one of the sign-in reasons (`oidc_denied`, `oidc_state`, `oidc_failed`, `oidc_rate_limited`, `oidc_disabled`); on success it lands on `/onboarding/handover`, where the new owner decides each guardian's access. The cookie is cleared either way. 404 `profile_claim.invalid` (no usable link) or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` (username) or `auth.already_authenticated`; rate-limited per source address like the password claim.",
requestBody: {
required: true,
content: { "application/json": { schema: claimSsoRequest } },
@@ -795,7 +794,6 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},
@@ -807,7 +805,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Preview a managed-profile handover link",
description:
"What a handover link would hand over: the profile's display name, the link's expiry, and each guardian's proposal. Anonymous; the token travels in the body. An unknown, expired, used or withdrawn token, and a link whose creator lost access, all answer the same 404. Rate-limited per source address.",
"What a handover link would hand over: the profile's display name, the link's expiry, and each guardian's proposal. Anonymous; the token travels in the body. An unknown, expired, used or withdrawn token, and a link whose creator lost access, all answer the same 404. Rate-limited per source address. Since v1.43 answered on a single-sign-on-only instance too, with `methods` naming the ways in.",
requestBody: {
required: true,
content: { "application/json": { schema: claimPreviewRequest } },
@@ -821,11 +819,6 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"403": {
description:
"The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`).",
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
description:
"No usable link behind this token (`meta.errorCode: profile_claim.invalid`). Every failure class answers identically.",
@@ -2338,6 +2331,12 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
.describe(
"Browser leg only: where to land after login. Sanitised to an in-app path; ignored entirely on the native leg.",
),
intent: z
.literal("claim")
.optional()
.describe(
"v1.43 — `claim` continues a managed-profile claim started at POST /api/auth/claim/sso; the link and username ride the sealed `hl_claim` cookie that call set, never this URL. Without a valid cookie for a still-usable link the start redirects to `/auth/claim?error=claim_expired`. Browser leg only: with `client=native` it fails with `oidc_invalid_request`. `next` is ignored.",
),
client_proto: z
.enum(["http", "https"])
.optional()
@@ -2356,7 +2355,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
responses: {
"302": {
description:
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser; `/auth/claim?error=<reason>` with `intent=claim`, where `claim_expired` is one more reason) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
},
},
},
+7 -1
View File
@@ -20,6 +20,7 @@
* invalidation path (`grantDependentKeys`).
*/
import type { HandoverMethod } from "@/lib/validations/claim-sso";
import {
useMutation,
useQuery,
@@ -346,8 +347,13 @@ export function useRemoveManagedProfileGuardian() {
/** What `GET /api/managed-profiles/{id}/handover` answers. */
export interface ManagedProfileHandoverStatus {
/** False on a single-sign-on-only instance, where no link can be made. */
/** False only when no way to redeem a link exists on this instance. */
available: boolean;
/**
* v1.43 — how the person can redeem the link here. Absent from an older
* server, which only offered the password.
*/
methods?: HandoverMethod[];
open: {
id: string;
createdAt: string;
+36
View File
@@ -12,6 +12,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { apiPost } from "@/lib/api/api-fetch";
import type { HandoverAccess } from "@/lib/managed-profiles/handover-access";
import type { HandoverMethod } from "@/lib/validations/claim-sso";
import { queryKeys } from "@/lib/query-keys";
export interface ProfileClaimPreview {
@@ -21,6 +22,13 @@ export interface ProfileClaimPreview {
displayName: string | null;
proposal: HandoverAccess;
}[];
/**
* v1.43 — the ways in this server offers. Absent from an older server,
* which only ever offered the password.
*/
methods?: HandoverMethod[];
/** v1.43 — the provider's name for the button, or null. */
ssoProviderName?: string | null;
}
export function useProfileClaimPreview(token: string | null, enabled: boolean) {
@@ -64,3 +72,31 @@ export function useClaimProfile() {
),
});
}
/** Exactly the body `POST /api/auth/claim/sso` accepts (strict). */
export interface ProfileClaimSsoInput {
token: string;
username: string;
}
export function claimSsoBody(
input: ProfileClaimSsoInput,
): ProfileClaimSsoInput {
return { token: input.token, username: input.username };
}
/**
* v1.43 (#959) — start claiming through the identity provider. The answer
* sets the sealed claim cookie and names where the browser goes next; the
* caller navigates there as a full page load.
*/
export function useClaimProfileSso() {
return useMutation({
mutationKey: queryKeys.profileClaimSso(),
mutationFn: (input: ProfileClaimSsoInput) =>
apiPost<{ redirectTo: string }>(
"/api/auth/claim/sso",
claimSsoBody(input),
),
});
}
+2
View File
@@ -99,4 +99,6 @@ export const sharingKeys = {
["managed-profiles", "handover", "revoke"] as const,
handoverDecide: () => ["account", "handover-decision", "decide"] as const,
profileClaim: () => ["profile-claim", "submit"] as const,
/** v1.43 (#959) — starting the claim through single sign-on. */
profileClaimSso: () => ["profile-claim", "sso"] as const,
};
+1
View File
@@ -15,3 +15,4 @@ export type ClaimSsoInput = z.infer<typeof claimSsoSchema>;
/** How a handover link can be redeemed on this server. */
export const HANDOVER_METHODS = ["password", "sso"] as const;
export type HandoverMethod = (typeof HANDOVER_METHODS)[number];