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

@@ -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"],
"auth/me/ai-usage/route.ts": ["auth.me.ai_usage.put"],
"biomarkers/[id]/merge/route.ts": ["labs.biomarker.merge"],
"dawarich/connect/route.ts": ["dawarich.connect"],
@@ -63,7 +62,7 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
};
/** The size of the register when it was written; see the header. */
const REGISTER_SIZE = 23;
const REGISTER_SIZE = 22;
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