mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
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"] })
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user