feat(claim): hand over a managed profile through single sign-on

POST /api/auth/claim/sso checks the handover link and the username and seals
the link id, the username and a per-start binding into an hl_claim cookie.
GET /api/auth/oidc/login?intent=claim carries the binding into the encrypted
OIDC state, and the callback binds the provider subject to the profile in the
claim transaction, refusing a subject or verified email another account
holds, a link that is no longer usable, and a live session.

The handover status and the claim preview name the redemption methods; a
single-sign-on-only server hands over through the identity provider, and only
the password claim stays refused there.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:12:59 +02:00
1 parent 5dab12472f
commit 9eae352bb1
20 files changed
+1075 -208

No files matched your search

+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;
});
+89 -2
View File
@@ -38,6 +38,17 @@ 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";
const LOGIN_ERROR_URL = "/auth/login";
@@ -102,6 +113,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 +141,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 +179,24 @@ 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).
const failRedirect = (reason: string): NextResponse => {
if (isClaim) {
// 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.
const response = NextResponse.redirect(
oidcAppUrl(`${CLAIM_ERROR_PATH}?error=${reason}`),
);
deleteStateCookie(response);
clearClaimCookie(response);
return response;
}
if (isNative) {
const response = NextResponse.redirect(
buildNativeCallbackUrl({ error: reason }),
@@ -198,7 +236,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 ? failRedirect("oidc_state") : errorRedirect("oidc_state");
}
if (!stored) {
// Defence-in-depth idempotency for a duplicated callback. The single-use
@@ -240,7 +280,32 @@ 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 ? failRedirect("oidc_state") : errorRedirect("oidc_state");
}
// v1.43 (#959) — a claim is admitted only with its own sealed cookie, still
// bound to this sign-in, and never 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). Both 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) {
if (
!pendingClaim ||
!claimMatchesBinding(pendingClaim, stored.claimBinding as string)
) {
annotate({ meta: { reason: "claim_cookie" } });
return failRedirect("claim_expired");
}
const liveSession = await validateSessionFromCookieValue(
req.cookies.get(SESSION_COOKIE_NAME)?.value,
);
if (liveSession) {
annotate({ meta: { reason: "claim_signed_in" } });
return failRedirect("claim_signed_in");
}
}
try {
@@ -274,6 +339,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, {