mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Read the acting-account carrier from one place
The rule that decides what a request says it is acting as — session stamp on the cookie transport, selector header on Bearer, a header on a cookie request naming nothing — sat inside the resolver that acts on the answer. A second caller needs the same question answered before any auth context exists, and two implementations of "whose record is this" drift; the copy that drifts is the one nobody reads. Move the decision, the header name and the two predicates into `auth/acting-carrier`, as a pure function of what the transport carried. The resolver keeps what only it may do: turning a claim into a data scope once a live grant says so. No behaviour changes — the resolver's arms are the same arms, and its suite passes untouched. The header-name guard follows the constant to its new file.
This commit is contained in:
1 parent
1efd337f53
commit
f24ae6deb8
3 files changed
+130
-85
No files matched your search
@@ -113,7 +113,7 @@ describe("the acting-account carrier is read in one place", () => {
|
||||
const namers = filesMatching(/x-healthlog-account/i);
|
||||
expect(namers.length).toBeGreaterThan(0);
|
||||
expect(namers).toEqual([
|
||||
"lib/api-handler.ts",
|
||||
"lib/auth/acting-carrier.ts",
|
||||
"lib/openapi/routes/account-sharing.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
+24
-84
@@ -19,6 +19,13 @@ import {
|
||||
STEP_UP_ELEVATION_TTL_SECONDS,
|
||||
} from "./auth/step-up";
|
||||
import { hashToken } from "./auth/hmac";
|
||||
import {
|
||||
carrierTarget,
|
||||
decideActingCarrier,
|
||||
readSelectorHeader,
|
||||
selectorNamesAnAccount,
|
||||
type ActingCarrier,
|
||||
} from "./auth/acting-carrier";
|
||||
import { AssistantDisabledError } from "./feature-flags";
|
||||
import { ConsentRequiredError } from "./ai/consent-guard";
|
||||
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config";
|
||||
@@ -597,69 +604,23 @@ async function authenticateBearer(
|
||||
//
|
||||
// One function decides, for every transport, whether this request acts on
|
||||
// somebody else's record — the same argument that put `resolveBearerToken` in
|
||||
// one file for two wires. Two carriers converge here because they are two
|
||||
// answers to one question, and two answers that live apart drift.
|
||||
// one file for two wires. Two carriers converge on one decision because they
|
||||
// are two answers to one question, and two answers that live apart drift.
|
||||
//
|
||||
// Neither carrier authorises anything. Both merely NAME an account; the grant
|
||||
// table decides, on this request, with no cached verdict anywhere. The
|
||||
// difference between them is who can set them:
|
||||
//
|
||||
// * The cookie transport carries it on the session ROW (`actingAsUserId`).
|
||||
// A browser cannot address that row — the cookie holds a secret whose hash
|
||||
// is the lookup key — so the value is server state, written only by the
|
||||
// switch endpoint after it has validated the grant.
|
||||
// * The Bearer transport carries it in a per-request HEADER. A long-lived
|
||||
// token must not accumulate switch state: stamping it on the ApiToken row
|
||||
// would make the credential itself ambient authority, and the token has to
|
||||
// keep meaning "this person" for as long as it exists.
|
||||
//
|
||||
// The header is a Bearer-only carrier. On a cookie request it is refused, not
|
||||
// ignored — a client that sends a selector over a transport that does not read
|
||||
// one believes it is selecting an account and is not, and the failure of that
|
||||
// belief is a support case about data that looks wrong.
|
||||
// That decision now lives in `./auth/acting-carrier`, because a second caller
|
||||
// needs it: the idempotency wrapper files its replay cell under the record a
|
||||
// request claims, and it runs before any auth context exists. What stays here
|
||||
// is what only this file may do — turning a claim into a data scope after a
|
||||
// live grant says so.
|
||||
|
||||
/**
|
||||
* The per-request account selector, on the Bearer transport only.
|
||||
* The selector header's name, re-exported.
|
||||
*
|
||||
* Cross-origin JavaScript cannot set it: a custom request header forces a CORS
|
||||
* preflight and this app sends no CORS headers at all, so the preflight is
|
||||
* never answered. It is a selector regardless — the grant table is what
|
||||
* authorises, and the header is treated as hostile input everywhere below.
|
||||
* It is declared beside the carrier reader it belongs to; every route-facing
|
||||
* helper is imported from this module, and the OpenAPI table and the suites
|
||||
* that drive a switched request were written against that path.
|
||||
*/
|
||||
export const ACCOUNT_SELECTOR_HEADER = "x-healthlog-account";
|
||||
|
||||
/**
|
||||
* Longest selector value worth a database round trip. Account ids are 25-char
|
||||
* cuids; anything past this names no account that could exist and is refused
|
||||
* as such, on the same path and with the same bytes as an unknown account.
|
||||
*/
|
||||
const MAX_SELECTOR_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* What, if anything, this request says it is acting as.
|
||||
*
|
||||
* `misplaced-header` is its own case rather than an error thrown at read time
|
||||
* so that every mode below decides for itself — the modes disagree about the
|
||||
* session carrier and must not disagree about this one by accident.
|
||||
*/
|
||||
type ActingCarrier =
|
||||
| { kind: "none" }
|
||||
| { kind: "session"; accountId: string }
|
||||
| { kind: "header"; accountId: string }
|
||||
| { kind: "misplaced-header" };
|
||||
|
||||
/** The selector header, or null when the request did not send one. */
|
||||
async function readSelectorHeader(): Promise<string | null> {
|
||||
try {
|
||||
const headerList = await headers();
|
||||
return headerList.get(ACCOUNT_SELECTOR_HEADER);
|
||||
} catch {
|
||||
// Outside a Next.js request scope (direct unit invocation of a legacy
|
||||
// route) there is no header to read. Same posture as the Bearer branch of
|
||||
// `authenticateCaller`.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export { ACCOUNT_SELECTOR_HEADER } from "./auth/acting-carrier";
|
||||
|
||||
/**
|
||||
* The acting-account carrier for this request.
|
||||
@@ -677,32 +638,11 @@ async function readSelectorHeader(): Promise<string | null> {
|
||||
* next request rather than from the next login.
|
||||
*/
|
||||
async function readActingCarrier(auth: AuthContext): Promise<ActingCarrier> {
|
||||
const header = await readSelectorHeader();
|
||||
|
||||
if (auth.authMethod === "bearer") {
|
||||
// No session row exists on this transport, so the header is the only
|
||||
// carrier there could be.
|
||||
return header === null
|
||||
? { kind: "none" }
|
||||
: { kind: "header", accountId: header };
|
||||
}
|
||||
|
||||
if (header !== null) return { kind: "misplaced-header" };
|
||||
|
||||
const stamped = auth.session.actingAsUserId ?? null;
|
||||
return stamped === null
|
||||
? { kind: "none" }
|
||||
: { kind: "session", accountId: stamped };
|
||||
}
|
||||
|
||||
/** Could this value name an account at all? */
|
||||
function selectorNamesAnAccount(value: string): boolean {
|
||||
return value.length > 0 && value.length <= MAX_SELECTOR_LENGTH;
|
||||
}
|
||||
|
||||
/** The account a carrier names, or null when it names nothing. */
|
||||
function carrierTarget(carrier: ActingCarrier): string | null {
|
||||
return "accountId" in carrier ? carrier.accountId : null;
|
||||
return decideActingCarrier({
|
||||
transport: auth.authMethod === "bearer" ? "bearer" : "cookie",
|
||||
stamped: auth.session.actingAsUserId ?? null,
|
||||
header: await readSelectorHeader(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* What a request says it is acting as.
|
||||
*
|
||||
* It lives apart from `requireRecordAuth`, which acts on the answer, because
|
||||
* asking the question and deciding what it permits are different jobs and the
|
||||
* asking is about to have more than one caller.
|
||||
*
|
||||
* The claim is a SELECTOR and never an authorisation. Nothing in this file
|
||||
* looks at a grant, and no caller may treat a carrier as permission: the
|
||||
* account named here is an id to be checked. What it decides is only which
|
||||
* account is being named, and by which transport — the question `grants.ts`
|
||||
* warns must have exactly one answer, because a second implementation of it
|
||||
* drifts and the copy that drifts is the one nobody reads.
|
||||
*
|
||||
* The transport rules, unchanged from where they were first written:
|
||||
*
|
||||
* * The cookie transport carries the claim on the session ROW. A browser
|
||||
* cannot address that row, so the value is server state written only by
|
||||
* the switch endpoint after it validated a grant.
|
||||
* * The Bearer transport carries it in a per-request HEADER. A long-lived
|
||||
* token must not accumulate switch state.
|
||||
* * The header on a cookie request is `misplaced`, not ignored — a client
|
||||
* that believes it is selecting an account and is not produces a support
|
||||
* case about data that looks wrong.
|
||||
*/
|
||||
import { headers } from "next/headers";
|
||||
|
||||
/**
|
||||
* The per-request account selector, on the Bearer transport only.
|
||||
*
|
||||
* Cross-origin JavaScript cannot set it: a custom request header forces a CORS
|
||||
* preflight and this app sends no CORS headers at all, so the preflight is
|
||||
* never answered. It is a selector regardless — the grant table is what
|
||||
* authorises, and the header is treated as hostile input everywhere it is read.
|
||||
*/
|
||||
export const ACCOUNT_SELECTOR_HEADER = "x-healthlog-account";
|
||||
|
||||
/**
|
||||
* Longest selector value worth a database round trip. Account ids are 25-char
|
||||
* cuids; anything past this names no account that could exist and is refused
|
||||
* as such, on the same path and with the same bytes as an unknown account.
|
||||
*/
|
||||
export const MAX_SELECTOR_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* What, if anything, this request says it is acting as.
|
||||
*
|
||||
* `misplaced-header` is its own case rather than an error thrown at read time
|
||||
* so that every caller decides for itself — the callers disagree about what to
|
||||
* do with a misplaced claim and must not disagree about detecting one.
|
||||
*/
|
||||
export type ActingCarrier =
|
||||
| { kind: "none" }
|
||||
| { kind: "session"; accountId: string }
|
||||
| { kind: "header"; accountId: string }
|
||||
| { kind: "misplaced-header" };
|
||||
|
||||
/** The selector header, or null when the request did not send one. */
|
||||
export async function readSelectorHeader(): Promise<string | null> {
|
||||
try {
|
||||
const headerList = await headers();
|
||||
return headerList.get(ACCOUNT_SELECTOR_HEADER);
|
||||
} catch {
|
||||
// Outside a Next.js request scope (direct unit invocation of a legacy
|
||||
// route) there is no header to read. Same posture as the Bearer branch of
|
||||
// `authenticateCaller`.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The carrier decision itself, as a pure function of what the transport
|
||||
* carried. Pure on purpose: it is the one statement of the rule, and a rule
|
||||
* that needs a request context to be read is a rule that gets re-implemented.
|
||||
*/
|
||||
export function decideActingCarrier(input: {
|
||||
transport: "cookie" | "bearer";
|
||||
/** The session row's stamp. Ignored on the Bearer transport, which has none. */
|
||||
stamped: string | null;
|
||||
header: string | null;
|
||||
}): ActingCarrier {
|
||||
if (input.transport === "bearer") {
|
||||
// No session row exists on this transport, so the header is the only
|
||||
// carrier there could be.
|
||||
return input.header === null
|
||||
? { kind: "none" }
|
||||
: { kind: "header", accountId: input.header };
|
||||
}
|
||||
|
||||
if (input.header !== null) return { kind: "misplaced-header" };
|
||||
|
||||
return input.stamped === null
|
||||
? { kind: "none" }
|
||||
: { kind: "session", accountId: input.stamped };
|
||||
}
|
||||
|
||||
/** The account a carrier names, or null when it names nothing. */
|
||||
export function carrierTarget(carrier: ActingCarrier): string | null {
|
||||
return "accountId" in carrier ? carrier.accountId : null;
|
||||
}
|
||||
|
||||
/** Could this value name an account at all? */
|
||||
export function selectorNamesAnAccount(value: string): boolean {
|
||||
return value.length > 0 && value.length <= MAX_SELECTOR_LENGTH;
|
||||
}
|
||||
Reference in new issue
Block a user