mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
An account created through OIDC holds no password, passkey or second factor, so the recent-proof gate in front of token minting, MCP connections, exports and the admin reset had nothing to ask it for and the web could only say "sign in again" (#1296). GET /api/auth/oidc/reproof sends the browser back to the configured provider with prompt=login and max_age=0, its own state, nonce and PKCE verifier, and the signed-in account and session sealed into the state cookie. The shared callback branches on that intent: it refuses unless the browser still carries the bound session (checked before the code is redeemed), the ID token names the account's linked issuer and subject, and auth_time lies inside the gate's five-minute window. It then stamps Session.reproofAt exactly as a password re-proof does, creates no session, and returns to the page it left with ?reproof=done|failed. recentProofMethods offers "oidc" only to an account without a second factor whose identity is linked while a provider is configured, and the fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the list as proofMethods, so the passkey card offers what the account can actually give, and register-options takes { method: "oidc" } on a fresh provider re-proof, so such an account can add a passkey as a second way to confirm. Both re-proof dialogs offer the provider and a toast reports the outcome on return. The callback's missing-state branch and the passkey card's enrollment flow move into their own functions to keep both handlers under the size ratchet.
451 lines
22 KiB
TypeScript
451 lines
22 KiB
TypeScript
/**
|
|
* Structural guards on the surfaces that resolve a caller's identity.
|
|
*
|
|
* `bearer-scope-enforcement-guard.test.ts` freezes ONE mechanism: the files
|
|
* that turn an `Authorization: Bearer` value into a user. That guard is
|
|
* correct and it holds. What it is not is coverage of "who can resolve a
|
|
* caller". Two other families do the same job by other means, and a sweep
|
|
* written by copying the Bearer guard sees neither of them:
|
|
*
|
|
* A. A route or page that reads the session cookie itself, instead of
|
|
* going through `requireAuth()` inside `apiHandler`. `requireAuth` is
|
|
* where the fail-closed scope arm, the audit row, and the admin
|
|
* boundary live; a direct `getSession()` has none of them and answers
|
|
* to nothing but the file it sits in.
|
|
* B. A surface whose whole credential is a token in the URL — the
|
|
* clinician share tree serves one person's complete health record to
|
|
* an anonymous caller holding an `hls_` path segment, and never
|
|
* touches a session at all. No amount of grepping for `getSession`
|
|
* will ever show it to you.
|
|
*
|
|
* Neither family is wrong. Both are deliberate, and both are documented at
|
|
* their own call sites. The defect this file closes is that nothing said so
|
|
* when a new member joined.
|
|
*
|
|
* These are tripwires, not proofs. They cannot show an allowlist is
|
|
* correct — only that it has not changed without someone editing this file.
|
|
*
|
|
* A note on why every matcher carries its own count assertion: several legs
|
|
* below overlap (four of the five share-token files also sit under a
|
|
* `[token]` segment). If one leg's regex silently stopped matching, the
|
|
* union would be unchanged and the guard would stay green for the wrong
|
|
* reason. Each leg therefore asserts its own membership, so a matcher that
|
|
* matches nothing fails instead of passing.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import { join } from "node:path";
|
|
|
|
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
|
|
|
|
const SRC = join(process.cwd(), "src");
|
|
|
|
/**
|
|
* Every non-test `.ts` / `.tsx` under `src/app/`, which is the whole of the
|
|
* request-handling tree: API routes, RSC pages, layouts, route handlers.
|
|
* The generated Prisma client is excluded by construction (it is not under
|
|
* `app/`) and never read — see CLAUDE.md.
|
|
*/
|
|
function appFiles(): string[] {
|
|
return walkSourceFiles(SRC, { floor: 3000 })
|
|
.filter((p) => p.startsWith("app/"))
|
|
.filter((p) => !p.includes("__tests__"))
|
|
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
|
.sort();
|
|
}
|
|
|
|
function read(rel: string): string {
|
|
return readSourceText(join(SRC, rel));
|
|
}
|
|
|
|
function appFilesMatching(re: RegExp): string[] {
|
|
return appFiles().filter((rel) => re.test(read(rel)));
|
|
}
|
|
|
|
describe("S1 — the direct session-resolution set is frozen", () => {
|
|
/**
|
|
* Files under `src/app` that resolve the caller from the session cookie
|
|
* WITHOUT going through `requireAuth()`. Every other authenticated route
|
|
* in the tree — 300-odd of them — reaches its user through `apiHandler`
|
|
* plus `requireAuth`, which is what makes this list short enough to read.
|
|
*
|
|
* An entry here is not a finding. It is a place where the protections
|
|
* `requireAuth` applies centrally have to be argued locally, so the
|
|
* reason is the point of the entry.
|
|
*/
|
|
const SESSION_RESOLUTION_ALLOWLIST: Record<string, string> = {
|
|
// Completes the native ASWebAuthenticationSession handoff. Validates the
|
|
// browser session itself because it also needs `session.createdAt` for
|
|
// the freshness comparison, which `requireAuth` does not project.
|
|
"app/api/auth/native/complete/route.ts":
|
|
"reads createdAt for the handoff freshness check",
|
|
// Anonymous self-registration. Probes for an already-valid session so an
|
|
// invitation opened from inside one is refused instead of minting a second
|
|
// account over the caller's own cookie. `requireAuth` is the wrong gate
|
|
// here twice over: it would throw 401 at the anonymous caller the route
|
|
// exists for, and this route wants the ABSENCE of a session.
|
|
"app/api/auth/register/route.ts": "refuses to register over a live session",
|
|
// v1.42 (#959) — the anonymous managed-profile claim and its preview. Same
|
|
// argument as registration: claiming signs the new owner in, so a Guardian
|
|
// opening their own handover link must be refused rather than signed out
|
|
// of their account and into the person's. The probe only asks whether a
|
|
// session EXISTS; the credential these routes act on is the one-time
|
|
// `hlp_` token in the body, and the probe is resolved in the route file
|
|
// itself (not inside `claimGate`) so both stay visible on this list.
|
|
"app/api/auth/claim/route.ts":
|
|
"refuses to claim a profile over a live session",
|
|
"app/api/auth/claim/preview/route.ts":
|
|
"refuses to preview a handover link over a live session",
|
|
// v1.43 (#959) — the single-sign-on claim start, same argument.
|
|
"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, and
|
|
// (v1.43, #959) so a single-sign-on claim is refused over a live session,
|
|
// as the claim routes refuse it.
|
|
// And the identity-provider re-proof (#1296), which stamps only
|
|
// the session the start bound into the sealed state, and only while the
|
|
// browser still carries it.
|
|
"app/api/auth/oidc/callback/route.ts":
|
|
"duplicate-callback probe, the claim's live-session refusal and the re-proof's session binding 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",
|
|
// OAuth authorize runs under `withBackgroundEvent`, not `apiHandler`,
|
|
// because it owes RFC-format error bodies — so it resolves the
|
|
// consenting human itself.
|
|
"app/api/mcp/oauth/authorize/route.ts":
|
|
"consent decision on a route outside apiHandler",
|
|
// Provider OAuth callbacks. The browser arrives as a top-level
|
|
// navigation, so the code is bound to the session user and redirected.
|
|
// v1.44 — Withings and Google Health join: a navigation cannot carry the
|
|
// record-session header a record resolver needs, and the session user may
|
|
// be a guardian finishing a managed profile's connect, which
|
|
// `verifyCallbackActor` checks against a live MANAGE grant.
|
|
"app/api/google-health/callback/route.ts":
|
|
"binds the OAuth code to the session user",
|
|
"app/api/withings/callback/route.ts":
|
|
"binds the OAuth code to the session user",
|
|
"app/api/oura/callback/route.ts":
|
|
"binds the OAuth code to the session user",
|
|
"app/api/polar/callback/route.ts":
|
|
"binds the OAuth code to the session user",
|
|
"app/api/strava/callback/route.ts":
|
|
"binds the OAuth code to the session user",
|
|
"app/api/whoop/callback/route.ts":
|
|
"binds the OAuth code to the session user",
|
|
// RSC prefetch: the server component hydrates the client query cache
|
|
// with the session user's own data. Absence of a session skips the
|
|
// prefetch rather than failing the render.
|
|
//
|
|
// These seven resolve through `getUnswitchedSession()`, not `getSession()`,
|
|
// and they stay listed here BECAUSE of it. The helper is still a session
|
|
// derivation outside `requireAuth` — moving the call one file deeper into
|
|
// `lib/` would have taken them off this list while changing nothing about
|
|
// what they do, and a guard that a refactor can walk out of is not one.
|
|
// What the helper adds is the record identity: it answers null while the
|
|
// browser acts on somebody else's record, so the cache these pages seed
|
|
// can only ever hold the caller's own. See
|
|
// `prefetch-page-caching-guard.test.ts` for the rule that keeps a sixth
|
|
// prefetching page from reaching for the bare helper.
|
|
"app/checkups/page.tsx":
|
|
"RSC prefetch of the session user's reminder list, skipped under a switch",
|
|
"app/coach/page.tsx":
|
|
"RSC prefetch of the session user's coach state, skipped under a switch",
|
|
"app/insights/page.tsx":
|
|
"RSC prefetch of the session user's insights, skipped under a switch",
|
|
"app/insights/mood/page.tsx":
|
|
"RSC prefetch of the session user's mood calendar and line, skipped under a switch",
|
|
"app/insights/workouts/page.tsx":
|
|
"RSC prefetch of the session user's workouts, skipped under a switch",
|
|
"app/medications/page.tsx":
|
|
"RSC prefetch of the session user's medications, skipped under a switch",
|
|
"app/mood/page.tsx":
|
|
"RSC prefetch of the session user's mood list, skipped under a switch",
|
|
"app/page.tsx":
|
|
"RSC prefetch of the session user's dashboard snapshot, skipped under a switch",
|
|
// Onboarding routing. Reads the actor's OWN setup state through
|
|
// `loadOnboardingFlowState` (a `getSession` wrapper, listed in the regex
|
|
// below) to guard the step URL and to render the screens; the three
|
|
// writes behind them refuse under a switch, so the pages read the same
|
|
// record the routes write.
|
|
"app/onboarding/[step]/page.tsx":
|
|
"guards the step URL on the actor's own setup state",
|
|
"app/onboarding/page.tsx":
|
|
"resumes or welcomes on the actor's own setup state",
|
|
};
|
|
|
|
/**
|
|
* The identity-deriving helpers exported by `lib/auth/session.ts`, plus the
|
|
* one that wraps it. The lifecycle exports (`createSession`,
|
|
* `destroySession`, `destroyAllSessions`, `destroySessionById`,
|
|
* `destroyOtherSessions`) are deliberately NOT here: they act on a session,
|
|
* they do not hand the handler an identity to scope a query by. Adding a
|
|
* further deriving helper means adding it to this regex.
|
|
*
|
|
* `getUnswitchedSession` (`lib/auth/acting-carrier.ts`) is listed for the
|
|
* reason a wrapper is the easiest way to disappear from a guard: it returns
|
|
* `getSession()`'s answer or null, so a caller of it derives an identity
|
|
* exactly as much as a caller of the thing it wraps. v1.39 (C2) —
|
|
* `loadOnboardingFlowState` (`lib/onboarding/load-flow-state.ts`) is the
|
|
* same shape: `getSession()` plus the caller's own setup row.
|
|
*/
|
|
const SESSION_DERIVING_HELPERS =
|
|
/\b(getSession|getUnswitchedSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt|loadOnboardingFlowState)\s*\(/;
|
|
|
|
/**
|
|
* Reaching for the cookie by name is the way around the helpers. The
|
|
* reader (`readSessionCookie`, which tries both names), the exported names
|
|
* and either raw literal all count; the `__Host-` name joined the old one
|
|
* when a `Secure` session cookie started being issued under it.
|
|
*/
|
|
const RAW_SESSION_COOKIE =
|
|
/\b(readSessionCookie|SESSION_COOKIE_NAMES?|HOST_SESSION_COOKIE|LEGACY_SESSION_COOKIE)\b|"(__Host-)?healthlog_session"/;
|
|
|
|
it("no file under src/app derives a session outside requireAuth unlisted", () => {
|
|
const resolvers = appFilesMatching(SESSION_DERIVING_HELPERS);
|
|
|
|
// Non-zero proof. If the helper names are ever renamed out from under
|
|
// this regex, the match set collapses to empty — which must fail here
|
|
// rather than quietly agree with an emptied allowlist.
|
|
expect(resolvers.length).toBeGreaterThan(0);
|
|
expect(resolvers).toEqual(Object.keys(SESSION_RESOLUTION_ALLOWLIST).sort());
|
|
});
|
|
|
|
it("no file under src/app reads the session cookie by name unlisted", () => {
|
|
const cookieReaders = appFilesMatching(RAW_SESSION_COOKIE);
|
|
|
|
// Same non-zero proof, for this leg's own matcher. These two files also
|
|
// appear in the leg above, which is exactly why this count is asserted
|
|
// separately: an overlapping leg cannot borrow another leg's evidence.
|
|
expect(cookieReaders.length).toBeGreaterThan(0);
|
|
expect(cookieReaders).toEqual([
|
|
"app/api/auth/native/complete/route.ts",
|
|
"app/api/auth/oidc/callback/route.ts",
|
|
]);
|
|
|
|
// And whatever it finds must already be an argued session surface.
|
|
for (const rel of cookieReaders) {
|
|
expect(Object.keys(SESSION_RESOLUTION_ALLOWLIST)).toContain(rel);
|
|
}
|
|
});
|
|
|
|
it("every allowlist entry carries a reason", () => {
|
|
for (const [rel, reason] of Object.entries(SESSION_RESOLUTION_ALLOWLIST)) {
|
|
expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("S2 — the URL-token authentication set is frozen", () => {
|
|
/**
|
|
* Files whose credential is a token in the URL — a path segment or a
|
|
* query parameter — rather than a session cookie or a Bearer header.
|
|
*
|
|
* This is the family the Bearer guard cannot see and a `getSession` sweep
|
|
* cannot see. `src/app/c/[token]/**` resolves one user's entire health
|
|
* record from an `hls_` path segment held by an anonymous caller. That is
|
|
* the product's design, it predates every guard in this file, and it is
|
|
* not a defect. What was a defect is that a sixth door could have been
|
|
* cut next to it without anything saying so.
|
|
*/
|
|
const URL_TOKEN_ALLOWLIST: Record<string, string> = {
|
|
// Clinician share link: the `hls_` path segment is the whole credential.
|
|
"app/c/[token]/page.tsx": "serves the shared record view",
|
|
"app/c/[token]/d/[id]/route.ts": "serves a shared document's bytes",
|
|
"app/c/[token]/fhir/route.ts": "serves the shared record as a FHIR bundle",
|
|
"app/c/[token]/report.pdf/route.ts": "serves the shared record as a PDF",
|
|
// The segment's not-found page: the copy a dead link shows. It reads no
|
|
// token and resolves nothing; the page above decides, and answers 404.
|
|
"app/c/[token]/not-found.tsx": "renders the dead-link copy; reads no token",
|
|
// The passphrase gate for the same token. Anonymous by design; mints the
|
|
// token-scoped unlock cookie the view above checks.
|
|
"app/api/c/[token]/unlock/route.ts": "verifies the share-link passphrase",
|
|
// Webhook entrypoints. The provider supports no headers on a delivery,
|
|
// so the shared secret rides the path segment and is scrubbed from logs
|
|
// by `PATH_SECRET_PATHS`. These resolve a record from the payload after
|
|
// the secret compares equal.
|
|
"app/api/whoop/webhook/[token]/route.ts":
|
|
"path-segment shared secret, plus an HMAC body signature",
|
|
"app/api/withings/webhook/[token]/route.ts":
|
|
"path-segment shared secret; no body signature is available",
|
|
// The legacy form of the same Withings secret, still alive for
|
|
// subscriptions that have not rotated to the path-segment URL.
|
|
"app/api/withings/webhook/route.ts":
|
|
"legacy query-parameter form of the Withings webhook secret",
|
|
// Shape check only. Validates the `hlv_` prefix and redirects; touches
|
|
// no database, resolves nothing, and is not an enumeration oracle.
|
|
// Listed because it is a URL-token surface, with the reason being that
|
|
// it authenticates nothing.
|
|
"app/invite/[token]/page.tsx":
|
|
"validates the invite token's shape and redirects, or says the link is invalid; no lookup",
|
|
// v1.42 (#959) — the managed-profile handover link. The landing is the
|
|
// invite landing's twin: a shape check on the `hlp_` segment and a
|
|
// redirect, no lookup (the proxy normally answers it at the edge).
|
|
"app/claim/[token]/page.tsx":
|
|
"validates the handover token's shape and redirects; no lookup",
|
|
// The claim page reads the token off `?token=` and sends it in the BODY of
|
|
// the anonymous preview and claim requests, which are the only places a
|
|
// handover token is resolved. The page itself authenticates nothing and
|
|
// renders nothing about the profile before the preview answers.
|
|
"app/auth/claim/page.tsx":
|
|
"carries the handover token from the query string into the claim requests' body",
|
|
// v1.42.2 — notification quick answers. A notification app (ntfy's
|
|
// `http` action) calls it with no session, so the signed, user- and
|
|
// action-bound, short-lived token in the path is the credential. Rate
|
|
// limited before verification, single-use through the mood entry's unique
|
|
// key, and scrubbed from logs by `PATH_SECRET_PATHS`.
|
|
"app/api/notifications/action/[token]/route.ts":
|
|
"signed single-use quick-answer token from a notification action",
|
|
};
|
|
|
|
/**
|
|
* Leg 1 — route shape. A dynamic segment whose name carries `token` or
|
|
* `secret` puts a credential in the URL by construction. This matcher
|
|
* reads the filesystem, not the file, so no variable name can slip past
|
|
* it; a new such route is caught before anyone reads its body.
|
|
*/
|
|
function urlCredentialSegmentFiles(): string[] {
|
|
return appFiles().filter((rel) =>
|
|
rel.split("/").some((seg) => /^\[.*(token|secret).*\]$/i.test(seg)),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Leg 2 — share-token resolution by content, so a share surface that ever
|
|
* moves out from under a `[token]` segment is still caught.
|
|
*/
|
|
const SHARE_TOKEN_RESOLVERS =
|
|
/\b(resolveShareToken|resolveShareGateState|resolveShareReportDownload|loadShareViewData)\s*\(/;
|
|
|
|
/**
|
|
* Leg 3 — a credential read out of the query string.
|
|
*/
|
|
const QUERY_CREDENTIAL =
|
|
/searchParams\.get\(\s*["'](secret|token|key|passphrase)["']\s*\)/;
|
|
|
|
it("every URL-credential route segment is named", () => {
|
|
const bySegment = urlCredentialSegmentFiles();
|
|
|
|
expect(bySegment.length).toBeGreaterThan(0);
|
|
expect(bySegment).toEqual([
|
|
"app/api/c/[token]/unlock/route.ts",
|
|
"app/api/notifications/action/[token]/route.ts",
|
|
"app/api/whoop/webhook/[token]/route.ts",
|
|
"app/api/withings/webhook/[token]/route.ts",
|
|
"app/c/[token]/d/[id]/route.ts",
|
|
"app/c/[token]/fhir/route.ts",
|
|
"app/c/[token]/not-found.tsx",
|
|
"app/c/[token]/page.tsx",
|
|
"app/c/[token]/report.pdf/route.ts",
|
|
"app/claim/[token]/page.tsx",
|
|
"app/invite/[token]/page.tsx",
|
|
]);
|
|
});
|
|
|
|
it("every share-token resolver is named", () => {
|
|
const byResolver = appFilesMatching(SHARE_TOKEN_RESOLVERS);
|
|
|
|
expect(byResolver.length).toBeGreaterThan(0);
|
|
expect(byResolver).toEqual([
|
|
"app/api/c/[token]/unlock/route.ts",
|
|
"app/c/[token]/d/[id]/route.ts",
|
|
"app/c/[token]/fhir/route.ts",
|
|
"app/c/[token]/page.tsx",
|
|
"app/c/[token]/report.pdf/route.ts",
|
|
]);
|
|
});
|
|
|
|
it("every query-string credential read is named", () => {
|
|
const byQuery = appFilesMatching(QUERY_CREDENTIAL);
|
|
|
|
expect(byQuery.length).toBeGreaterThan(0);
|
|
expect(byQuery).toEqual([
|
|
"app/api/withings/webhook/route.ts",
|
|
"app/auth/claim/page.tsx",
|
|
]);
|
|
});
|
|
|
|
it("the three legs together equal the frozen allowlist", () => {
|
|
const union = [
|
|
...new Set([
|
|
...urlCredentialSegmentFiles(),
|
|
...appFilesMatching(SHARE_TOKEN_RESOLVERS),
|
|
...appFilesMatching(QUERY_CREDENTIAL),
|
|
]),
|
|
].sort();
|
|
|
|
expect(union.length).toBeGreaterThan(0);
|
|
expect(union).toEqual(Object.keys(URL_TOKEN_ALLOWLIST).sort());
|
|
});
|
|
|
|
it("every allowlist entry carries a reason", () => {
|
|
for (const [rel, reason] of Object.entries(URL_TOKEN_ALLOWLIST)) {
|
|
expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
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
|
|
* not forbidden, but it is a shape nobody has reviewed. If one appears,
|
|
* it should appear in a diff that says so rather than in production.
|
|
*/
|
|
it("no file authenticates by both a session and a URL token", () => {
|
|
const sessionFiles = appFilesMatching(
|
|
/\b(getSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt)\s*\(/,
|
|
);
|
|
const tokenFiles = appFiles().filter((rel) =>
|
|
rel.split("/").some((seg) => /^\[.*(token|secret).*\]$/i.test(seg)),
|
|
);
|
|
|
|
expect(sessionFiles.length).toBeGreaterThan(0);
|
|
expect(tokenFiles.length).toBeGreaterThan(0);
|
|
|
|
const both = sessionFiles.filter((rel) => tokenFiles.includes(rel));
|
|
expect(both).toEqual([]);
|
|
});
|
|
});
|