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

+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, {