import { olderThan } from "../utils/versionOrder"; import { useState, useCallback, useEffect, useMemo, createContext, useContext, useRef, } from "react"; import { CognitoIdentityProviderClient, CognitoIdentityProviderClientConfig, InitiateAuthCommand, SignUpCommand, ConfirmSignUpCommand, ResendConfirmationCodeCommand, InitiateAuthCommandInput, SignUpCommandInput, ConfirmSignUpCommandInput, ResendConfirmationCodeCommandInput, InitiateAuthCommandOutput, SignUpCommandOutput, RespondToAuthChallengeCommand, RespondToAuthChallengeCommandInput, ChallengeNameType, RespondToAuthChallengeCommandOutput, GetTokensFromRefreshTokenCommand, } from "@aws-sdk/client-cognito-identity-provider"; import { AuthenticationResultType } from "@aws-sdk/client-cognito-identity-provider/dist-types/models/models_0"; import { environment } from "../environments/environment.ts"; import { authStorage, credentials, demoClientFor, demoTabAtLoad } from "./authStorage"; import { CredentialOutcomeUnknown, CredentialStorageUnavailable, CredentialSuperseded, SignOutUnconfirmed, type CommittedSession, type LoginTicket, } from "./credentialRecord"; import { DEMO_ENDED_EVENT } from "./demoTab"; // Production signs people in by emailed code only, so a password is refused before it leaves the browser. export const PASSWORD_SIGN_IN_OFF = "Sign-in with a password is not available. Leave it blank and we'll email you a code."; import { Coding, Organization, Patient, Practitioner, PractitionerRole, RelatedPerson, } from "fhir/r4"; import { toast } from "sonner"; import { type StaffWorkspace, getPractitionerRoleDefaultWorkspace, getPractitionerRoleWorkspaces, sortStaffWorkspaces, } from "../utils/staffRoleCatalog.ts"; import { applyPractitionerWorkspaceGate } from "../utils/staffAccess.ts"; import { chooseSignInWorkspace } from "../utils/signInWorkspace.ts"; import { isStaffExperience, isPatientExperience } from "../utils/mountContext.ts"; import { crossPortalHolding } from "../utils/crossPortalConflict.ts"; import { agencyGrantFor, tokenNamesPractice, tokenRolesAtPractice } from "../utils/agencyGrant.ts"; import { resolvePortalIdentity, type PortalIdentityKind } from "../utils/portalIdentity.ts"; import { recoverSignedInAs } from "../utils/recoverSignedInAs.ts"; import { toE164 } from "../utils/phoneNumber"; import { sendSignOutBeacon } from "./signOutBeacon.ts"; import { setAuthHint, clearAuthHint, syncAuthFrame } from "../utils/authHintCookie.ts"; import { dpopKeyStore } from "../utils/dpopKeyStore.ts"; import { srpPasswordSignIn } from "../utils/srpPasswordSignIn.ts"; import { dpopHeaderFor } from "../utils/dpopRequestHeader.ts"; import { withDpopThumbprint, planClientRefresh, readDpopJkt, } from "../utils/dpopSession.ts"; import { clearCampaignCtaAttribution } from "../utils/campaignCtaAttribution.ts"; import { holdSessionEndReasonUntilReload, recordSessionEnd } from './sessionEndReason'; import { noteSessionEndingHere } from './sessionEndedElsewhere'; import { settleAdminGrant, type AdminGrantOutcome } from '../utils/settleAdminGrant'; import { admittedWithin, isJustJoined, JOIN_PROBES, JoinNotReady, joinProbesFor, readWhileJoining } from "../utils/justJoinedHandoff"; import { onPractitionerRolesChanged } from "../utils/practitionerRolesChanged"; import { adoptOwnNewRole } from "../utils/adoptOwnNewRole"; import { clearAllDirectThreadCaches } from "../utils/directThreadCacheStorage.ts"; import { clearPortalSession } from '../environments/portalStorageKey'; import { portalSendRecovery } from '../utils/messaging/portalSendRecovery'; import { revokeRefreshToken } from '../utils/revokeRefreshToken'; import { clearCognitoLibrarySession } from '../utils/cognitoLibrarySession'; import { akashicFetch } from '../config/clientManifest'; import { setWriteThroughViewer } from '../utils/writeThroughHold'; import { clearReceiptAttempts } from '../utils/unconfirmedReceipts'; const viewerScope = (userId: string | undefined, relatedPersonId: string | undefined, organizationId: string | undefined): string | null => (userId ? `${userId}|${relatedPersonId ?? ''}|${organizationId ?? ''}` : null); /** * How long sign-out waits for the beacon to LEAVE the browser before reloading anyway. * * Long enough for an IndexedDB read and a local WebCrypto sign - milliseconds in practice. Short enough * that a blocked or hung request never holds someone on a shared machine at a screen they believe they have * signed out of. It is NOT a network timeout: the response is never awaited, only the send. */ const BEACON_ISSUE_TIMEOUT_MS = 250; // A Check again after a join is one bounded probe of the gateways, start to finish. const RECHECK_MS = 5000; type JoinSetup = "idle" | "setting-up" | "unconfirmed"; /** Which join the setup state belongs to; `op` changes with every new join, scope change or sign-out. */ type JoinScope = { op: number; organizationId?: string; practitionerId?: string; userSub?: string }; export interface ICognitoUser { email: string; nickname: string; phone_number: string; preferred_username: string; sub: string; username: string; "custom:practitionerIds"?: string | undefined; "custom:patientIds"?: string | undefined; "custom:relatedPersonId"?: string | undefined; "custom:relatedPersonIds"?: string | undefined; } export interface IUser { cognito?: ICognitoUser; fhir?: Patient | Practitioner | RelatedPerson; } // Act-as target: the RelatedPerson a logged-in user has switched into, and the Patient that // RelatedPerson is a proxy for. Set/cleared together via setActingAs (mirrors the wpOrganization // ref+state pattern below) - every fetchWithAuth request then carries an X-Acting-As header // derived from actingAsRef while this is non-null. export type ActingAsTarget = { relatedPersonId: string; subjectPatientId: string; subjectName?: string; // 'billing' opens only the subject's invoices: a helper granted billing without a switch-in tier. // 'messaging' opens only the subject's Messages: an assist-only helper the patient let message for them. scope?: ActingAsScope; }; export type ActingAsScope = 'billing' | 'messaging'; /** An entry that did not happen: refused by the server, or never answered. Neither is a grant that ended. */ export type ActingAsEntryNotice = { subjectName: string; outcome: 'refused' | 'unknown' }; const cognitoClient = new CognitoIdentityProviderClient({ region: environment.region, }); /** * The client a refresh token is spent through: one attempt only. The SDK otherwise resends a transient failure itself, * and a rotating refresh token sent twice may already have been rotated by the first. */ export const singleAttemptRefreshClient = (requestHandler?: CognitoIdentityProviderClientConfig['requestHandler']) => new CognitoIdentityProviderClient({ region: environment.region, maxAttempts: 1, ...(requestHandler ? { requestHandler } : {}) }); export const refreshClient = singleAttemptRefreshClient(); const LOCAL_STORAGE_PREFIX = environment.localStoragePrefix; // Shared key derivation for the persisted act-as target - read/write/logout must all agree on // this exact key, so it's a single constant rather than three independent template literals. const ACTING_AS_STORAGE_KEY = `${LOCAL_STORAGE_PREFIX}_actingAs`; // How long a tab waits on another tab's renewal of the same tuple before it asks for a new sign-in instead. const OTHER_RENEWAL_WAIT_MS = 20000; // The committed session (epoch) a refresh, re-mint or grant was asked under, and the tuple it holds now. A new // sign-in or sign-out in any tab changes the epoch, and nothing asked under the old one spends, saves or ends anything. const committedEpoch = (): string | null => credentials().epoch(); const committedTuple = () => { const view = credentials().current(); return view.state === "signed-in" ? view.session.tuple : undefined; }; const unexpiredToken = (epoch: string | null): string | undefined => { const view = credentials().current(); if (view.state !== "signed-in" || view.session.epoch !== epoch) return undefined; return Date.now() < view.session.tuple.expiresAt ? view.session.tuple.accessToken : undefined; }; // A persisted act-as target is usable only if it carries the two string fields the header // (X-Acting-As) and the subject fetch (GET /Patient/:id) actually need. Guards against a // valid-JSON-but-malformed value (e.g. missing subjectPatientId) silently producing // "X-Acting-As: RelatedPerson/undefined" or "GET /Patient/undefined" on load. const isActingAsTarget = (value: unknown): value is ActingAsTarget => typeof value === "object" && value !== null && typeof (value as Record).relatedPersonId === "string" && typeof (value as Record).subjectPatientId === "string" && [undefined, 'billing', 'messaging'].includes((value as Record).scope as string | undefined); // Act-as target persistence (, Option B - reload on switch): survives a reload so // X-Acting-As is live again from the very first render, before any provider below AuthService // fires its first fetch (useHttpService reads actingAsRef directly). read* returns null on // anything missing/unparsable/malformed so a corrupt value never wedges the app in a broken // proxy view; write* mirrors the actingAsRef/actingAs set-or-clear pair (see setActingAs below). const readPersistedActingAs = (raw: string | null): ActingAsTarget | null => { if (!raw) { return null; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (error) { console.warn('Discarding an invalid persisted act-as target:', error); return null; } if (!isActingAsTarget(parsed)) { console.warn('Discarding a malformed persisted act-as target:', parsed); authStorage.removeItem(ACTING_AS_STORAGE_KEY); return null; } return parsed; }; // Each entry is stored with its own attempt, so a clear removes only the exact value this tab wrote or loaded, never a // newer entry another tab of the same browser wrote since. Web Storage has no compare-and-remove: a write landing // between the read and the removal below is still lost. const persistedActingAsRaw = (): string | null => authStorage.getItem(ACTING_AS_STORAGE_KEY); const writePersistedActingAs = (target: ActingAsTarget | null, owned: string | null): string | null => { if (target) { const raw = JSON.stringify({ ...target, attempt: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` }); authStorage.setItem(ACTING_AS_STORAGE_KEY, raw); return raw; } let stored: string | null; try { stored = persistedActingAsRaw(); } catch { // Unreadable storage is left in place; there is no stored target to act as. return null; } if (stored !== null && stored === owned) authStorage.removeItem(ACTING_AS_STORAGE_KEY); return null; }; const COGNITO_GROUP_TAG_SYSTEM = "https://akashichealthrecords.com/fhir/tags/cognitoGroup"; const USER_ROLES = { ADMIN: "Admin", PRACTITIONER: "Practitioner", RECEPTIONIST: "Receptionist", PLATFORM_MANAGER: "PlatformManager", GENERIC: "Generic", HR: "HR", BILLING: "Billing", // CARD 246 (B52) — this key was MISSING while `STAFF_WORKSPACES.MARKETING` ('Marketing') existed. // `activeUserRole` is `activeWorkspace ?? selectedWorkspace ?? userRole`, so a Marketing hire carried // the literal 'Marketing' - a value no branch in the sidebar builder compared against. They signed in // to a nav containing exactly one item: Log Out. Their permissions were fine and both their routes // rendered by URL; there was simply no way to reach them. // USER_ROLES and STAFF_WORKSPACES are two parallel enumerations of the same concept. A workspace // added to one and not the other produces a role that silently matches nothing - see the guard in // __tests__/userRolesCoverWorkspaces.test.ts, which fails if they diverge again. MARKETING: "Marketing", // Card 608 -- see the note above: a workspace here and not in STAFF_WORKSPACES (or the reverse) // produces a role that silently matches nothing. REFERRALS: "Referrals", // The two diagnostic hats. USER_ROLES and STAFF_WORKSPACES are parallel enumerations of one // concept, and a workspace in one and not the other is a role that silently matches nothing. LAB_TECH: "LabTech", IMAGING_TECH: "ImagingTech", RECORDS: "Records", PATIENT: "Patient", RELATED_PERSON: "RelatedPerson", }; /* CARD 315 — `withImpliedWorkspaces` IS DELETED, NOT EXTENDED, and the distinction is the design. It used to add the Platform Manager workspace to any Admin. It fixed a real lockout, and it was the wrong SHAPE to generalise: an implication lives in code, so an org could never change it. A hospital narrowing its administrator to Admin-only - separating administration from finance, which is precisely what a large practice asks for - was impossible while the rule was hardcoded. The founding admin now receives EVERY workspace as a STORED ASSIGNMENT at org creation (`utils/foundingAdminAssignment.ts`, written onto their PractitionerRole), along with a permission profile on the Organization that the org can later edit. Spencer's *"all workspaces out of the gate"* is a statement about the INITIAL STATE - which is what an assignment is and an implication is not. Extending the shim would have made the hardcoded rule bigger and less removable, in the direction we already knew was wrong. NO BACKFILL, and that is a ruling rather than an omission: *"before we even go to beta the entire system will be wiped and everything will be brand new and fresh"* (Spencer, 2026-08-15). Every org that exists today is dummy data, so there is nobody to take Platform Manager away from. */ export const useAuthService = () => { const [currentUser, setCurrentUser] = useState(null); const [userRole, setUserRole] = useState(); const [practitionerRoles, setPractitionerRoles] = useState< PractitionerRole[] >([]); const [selectedPractitionerRole, setSelectedPractitionerRole] = useState(); const [selectedWorkspace, setSelectedWorkspace] = useState(); const [selectedRelatedPatient, setSelectedRelatedPatient] = useState(); const [relatedPatientLoading, setRelatedPatientLoading] = useState(false); // Only a committed read signs this page in; until the first one settles, protected routes show loading. const [isLoggedIn, setIsLoggedIn] = useState(() => credentials().current().state === "signed-in"); const [loggedInAs, setLoggedInAsState] = useState<"Patient" | "RelatedPerson">(); /** * THE SAME VALUE, READABLE IN THE TICK IT IS WRITTEN, AND THAT IS THE WHOLE POINT OF IT. * * `fetchUserData` CLOSES OVER `loggedInAs`, so a sign-in that sets it and resolves the user in one * tick resolves against the OLD value. The emailed-code path never noticed: the code screen is a * round trip, and the re-render it forces is what made the state value current by the time anything * read it. `LoginPage` says so in as many words - *"the emailed-code round trip guarantees a * re-render between here and there"* - and that sentence was load-bearing without anything enforcing * it. * * A PASSWORD SIGN-IN HAS NO ROUND TRIP, so it walked straight into the case that comment * describes. Measured on the patient portal against an account holding a patient record and seven * related persons: correct password, tokens minted with every claim present, `login()` called in the * same tick, `loggedInAs` still `undefined` inside the closure, `resolvePortalIdentity` matching * nothing, and the console showing exactly * `Unable to fetch FHIR resource: missing resource type {fhirResourceType: undefined, id: undefined}` * before `logout()` ran. On screen: back at the sign-in form with NO MESSAGE and nothing in storage. * * A ref, written synchronously by the setter below, so the value does not depend on a render * happening between deciding who signed in and looking them up. The state is kept because everything * that RENDERS from it must still re-render; the ref is only for readers that run before that. */ const loggedInAsRef = useRef<"Patient" | "RelatedPerson" | undefined>(undefined); const setLoggedInAs = useCallback((next?: "Patient" | "RelatedPerson") => { loggedInAsRef.current = next; setLoggedInAsState(next); }, []); /** * Set when the restored session belongs to the OTHER portal - the value names which kind is being * held, so the screen can say what is at stake rather than "something is wrong". * * Not an error state and not a logged-out state: while this is set the session is still LIVE and * every token is still in place. It is a question awaiting an answer, and `undefined` is the answer * "carry on" - which is what makes cancelling free. */ const [crossPortalConflict, setCrossPortalConflict] = useState<"staff" | "patient" | undefined>(); // Set when an agency's staff member is refused their own profile; the session stays live. const [agencyTermsOwed, setAgencyTermsOwed] = useState<{ contractorId: string } | undefined>(); // A member who has just joined: "setting-up" during the bounded join wait, "unconfirmed" when the session // opened before every gateway admitted them (utils/justJoinedHandoff). It belongs to the join it was opened // for: `joinScope` names that user, practice and practitioner, and its `op` moves on whenever they change or // the session ends, so an answer from an earlier join or check is dropped rather than written into a later one. const [joinSetup, setJoinSetup] = useState("idle"); const joinScope = useRef({ op: 0 }); const openJoinScope = useCallback((phase: JoinSetup, scope?: Omit): number => { joinScope.current = { ...scope, op: joinScope.current.op + 1 }; setJoinSetup(phase); return joinScope.current.op; }, []); const settleJoinScope = useCallback((op: number | undefined, phase: JoinSetup) => { if (op !== undefined && joinScope.current.op === op) setJoinSetup(phase); }, []); // Counts the Check again presses that confirmed access, so what was refused while it settled is read again. const [joinRecovery, setJoinRecovery] = useState(0); const [isLoading, setIsLoading] = useState(true); const [organizationCheckComplete, setOrganizationCheckComplete] = useState(false); const wpOrganization = useRef(null); const [currentOrganization, setCurrentOrganization] = useState(null); // One read on mount: the target this tab starts as and the stored value it owns come from the same snapshot. const [hydrated] = useState(() => { const raw = persistedActingAsRaw(); const target = readPersistedActingAs(raw); return { target, raw: target ? raw : null }; }); const persistedActingAs = hydrated.target; // The stored value this tab loaded or last wrote: the only one its own clear may remove. const ownedActingAsRaw = useRef(hydrated.raw); const actingAsRef = useRef(persistedActingAs); const [actingAs, setActingAsState] = useState(persistedActingAs); // WHICH SESSION AN ANSWER WAS ASKED IN. Moves on every set or clear of acting-as (the same person entered // again included), on sign-out, and on any change of viewer or practice. Target ids cannot tell A from A // again; this can, so a check answered late is applied only to the session that asked it. const actingAsSessionRef = useRef(0); const sessionScope = `${currentUser?.fhir?.id ?? ''}|${currentOrganization?.id ?? ''}`; const sessionScopeRef = useRef(sessionScope); if (sessionScopeRef.current !== sessionScope) { sessionScopeRef.current = sessionScope; actingAsSessionRef.current += 1; } // WHO THEY WERE ACTING FOR WHEN THE SESSION ENDED WITHOUT ANYBODY SAYING SO. Held beside // `actingAs` rather than in the banner, because the paths that end a session are not all in the // banner - one of them is a Patient fetch three providers away - and a notice only one writer // can reach is a notice most writers will forget. const [actingAsEnded, setActingAsEnded] = useState<{ subjectName: string } | null>(null); // Why an entry did not happen, held here because entering remounts the page that asked. Shown only to the viewer // it failed for; a new entry or an acknowledgement retires it. const [entryNotice, setEntryNotice] = useState<(ActingAsEntryNotice & { viewer: string }) | null>(null); const noteActingAsEntryFailed = useCallback((target: ActingAsTarget, outcome: ActingAsEntryNotice['outcome']) => { setEntryNotice({ subjectName: target.subjectName || 'that account', outcome, viewer: sessionScopeRef.current }); }, []); // A write held for one viewer, subject or practice is never laid over another's lists; see writeThroughHold. const viewerUserId = useRef(undefined); viewerUserId.current = currentUser?.fhir?.id; useEffect(() => { setWriteThroughViewer(viewerScope(currentUser?.fhir?.id, actingAs?.relatedPersonId, currentOrganization?.id)); }, [currentUser?.fhir?.id, actingAs?.relatedPersonId, currentOrganization?.id]); // Act-as "entering/validating" signal ( fast-follow A3b): true from the start of // useActAs().enter() until its validate GET resolves/reverts. actingAs itself is set optimistically // BEFORE that GET (see useActAs.tsx), so without this a bad target would flash the "Viewing X" // banner before the 403 reverts it. Lives here (shared context) rather than as local state inside // useActAs, because enter() is invoked from one call-site (ConfirmActAsDialog) while the banner // reads it from a separate useActAs() call-site - only shared state via this context is visible to // both. const [isActingAsEntering, setIsActingAsEntering] = useState(false); const pendingUserInitialization = useRef(false); // Holds the single in-flight token refresh so concurrent callers share one network round trip. // See refreshAccessToken below for why this is load-bearing rather than an optimisation. // Keyed by the committed epoch it was asked for, so a later session never picks it up as its own. const refreshInFlight = useRef<{ origin: string | null; run: Promise } | null>(null); const getCognitoGroup = useCallback( (practitionerRole: PractitionerRole): string | undefined => { if (!practitionerRole.meta?.tag) { return undefined; } const tag = practitionerRole.meta.tag.find( (tag: Coding) => tag.system === COGNITO_GROUP_TAG_SYSTEM, ); return tag?.code; }, [], ); /** * THE ONLY WAY THIS FILE MAY ASK WHAT WORKSPACES SOMEBODY HAS, and it exists because asking twice * is how the defect came back. The switcher read one derivation and the sign-in restore read another; * gating the first left the second offering a workspace the token will not carry. * * PER ROLE, never once over the merged set - the grant belongs to the role's ORGANIZATION, and one * person can hold a clinical role at one practice and a receptionist role at another. Gating the union * would let either practice's settings answer for the other. */ const openableWorkspacesFor = useCallback( (role: PractitionerRole): StaffWorkspace[] => applyPractitionerWorkspaceGate( getPractitionerRoleWorkspaces(role, getCognitoGroup(role)), role, currentOrganization, ), [getCognitoGroup, currentOrganization], ); const availableWorkspaces = useMemo(() => { const workspaces = practitionerRoles .filter((role) => role.active !== false) .flatMap(openableWorkspacesFor); return sortStaffWorkspaces(Array.from(new Set(workspaces))); }, [openableWorkspacesFor, practitionerRoles]); const activateWorkspace = useCallback((workspace: StaffWorkspace) => { setSelectedWorkspace(workspace); setUserRole(workspace); authStorage.setItem(`${LOCAL_STORAGE_PREFIX}_workspace`, workspace); }, []); const isCurrentUserPractitionerRole = useCallback( (practitionerRole: PractitionerRole): boolean => { if (currentUser?.fhir?.resourceType !== "Practitioner" || !currentUser.fhir.id) { return false; } const practitionerId = practitionerRole.practitioner?.reference ?.split("/") .pop(); return practitionerId === currentUser.fhir.id; }, [currentUser?.fhir], ); const upsertCurrentUserPractitionerRole = useCallback( (practitionerRole: PractitionerRole) => { if (!isCurrentUserPractitionerRole(practitionerRole)) { return; } setPractitionerRoles((currentRoles) => { const existingRole = currentRoles.some( (role) => role.id === practitionerRole.id, ); return existingRole ? currentRoles.map((role) => role.id === practitionerRole.id ? practitionerRole : role, ) : [...currentRoles, practitionerRole]; }); setSelectedPractitionerRole((currentRole) => currentRole?.id === practitionerRole.id ? practitionerRole : currentRole, ); }, [isCurrentUserPractitionerRole], ); const removeCurrentUserPractitionerRole = useCallback( (practitionerRole: PractitionerRole) => { if (!isCurrentUserPractitionerRole(practitionerRole)) { return; } const remainingRoles = practitionerRoles.filter( (role) => role.id !== practitionerRole.id, ); setPractitionerRoles(remainingRoles); setSelectedPractitionerRole((currentRole) => currentRole?.id === practitionerRole.id ? remainingRoles.find((role) => role.active !== false) ?? remainingRoles[0] : currentRole, ); }, [isCurrentUserPractitionerRole, practitionerRoles], ); useEffect(() => { if (!selectedPractitionerRole) { return; } authStorage.setItem( `${LOCAL_STORAGE_PREFIX}_practitionerRole`, JSON.stringify(selectedPractitionerRole), ); }, [selectedPractitionerRole]); useEffect(() => { if (!isStaffExperience() || availableWorkspaces.length === 0) { return; } const roleDefaultWorkspace = selectedPractitionerRole ? getPractitionerRoleDefaultWorkspace( selectedPractitionerRole, getCognitoGroup(selectedPractitionerRole), ) : undefined; const nextWorkspace = selectedWorkspace && availableWorkspaces.includes(selectedWorkspace) ? selectedWorkspace : roleDefaultWorkspace && availableWorkspaces.includes(roleDefaultWorkspace) ? roleDefaultWorkspace : availableWorkspaces[0]; if (!nextWorkspace) { return; } if (selectedWorkspace !== nextWorkspace) { setSelectedWorkspace(nextWorkspace); } if (userRole !== nextWorkspace) { setUserRole(nextWorkspace); } authStorage.setItem(`${LOCAL_STORAGE_PREFIX}_workspace`, nextWorkspace); }, [ availableWorkspaces, getCognitoGroup, selectedPractitionerRole, selectedWorkspace, userRole, ]); /** * A CLEAR HAS TO REACH STORAGE, AND THIS EFFECT ONLY EVER WROTE. * * `setLoggedInAs(undefined)` - which the sign-in screen already calls on both of its failure * branches - changed React state and left "Patient" sitting in localStorage, where the next mount * hydrated it straight back. "Leaves unset" and "clears" are different things, and treating them as * the same is what let a dead patient sign-in attempt outlive itself. * * The ref is load-bearing rather than defensive. This effect is declared BEFORE the hydration effect * below, so on mount it runs FIRST, with state still undefined - an unguarded removal would delete * the stored stamp before hydration could read it, which is the fix below reversed. Skipping exactly * one run leaves hydration to set the value and every later change to mirror it. */ const loggedInAsHydrated = useRef(false); useEffect(() => { if (!loggedInAsHydrated.current) { loggedInAsHydrated.current = true; return; } if (loggedInAs) { authStorage.setItem(`${LOCAL_STORAGE_PREFIX}_loggedInAs`, loggedInAs); } else { authStorage.removeItem(`${LOCAL_STORAGE_PREFIX}_loggedInAs`); } }, [loggedInAs]); /** * WHO SIGNED IN IS A FACT ABOUT THE SESSION, NOT ABOUT THE MOUNT - and reading it back only on the * patient portal is what made the staff portal render nothing at all, forever. * * This used to be wrapped in `if (isPatientExperience())`, so on the staff mount `loggedInAs` stayed * `undefined` whatever was in storage. `resolvePortalIdentity` then computed `signedInOnThePatientSide` * as false, took its staff branch, and resolved the person's practitioner - for a session signed in as * a PATIENT, carrying a patient-scoped token. `CrossPortalGate` never rendered, because it is only * consulted when NOTHING resolves, and something had. * * MEASURED 2026-08-30 on an account holding both identities: every request 200, the practitioner * fetched fine, `loggedInAs` sitting in storage as "Patient" the whole time, and the page showing a * full-viewport gate and nothing else. Nothing failed, which is why no log ever pointed at it. * * It cannot change which Cognito client is used: `getClientId` branches on the mount FIRST and * ignores this value on the staff side. A staff sign-in still leaves the key unset, so a staff-only * account reads exactly as it did before - the only case this changes is the one it exists for, * somebody who genuinely holds both records. */ useEffect(() => { /** * THE STAMP IS NOT THE ONLY RECORD OF WHO SIGNED IN, AND IT USED TO BE TREATED AS IF IT WERE. * * A reload has no sign-in, so this stamp was the single source of the value, and a session * carrying valid tokens but no stamp resolved nobody - straight into the fall-through written for * "this session belongs to the other portal", which signs out. Observed on staging 2026-08-31: * on the dashboard, access token in storage, full page load, back at `#/login` with no message. * Every session established before the stamp existed is in exactly that state, so the first * reload after a deploy evicts those people. * * The ID token beside it already answers the question - the two patient-side doors are two * different Cognito app clients and `aud` says which one minted the session. Reading it back is * a recovery, not a relaxation: `recoverSignedInAs` answers only for those two clients, so a * practitioner token still resolves nobody here and the staff branch decides staff sessions * exactly as before. * * IT DOES NOT REPAIR THE STAMP, AND I CHECKED RATHER THAN ASSUMED. I first wrote here that the * mirror effect above would write the recovered value back, so a stampless session would acquire * one on its first load. Walked on staging 2026-08-31 with the stamp removed and the tokens left * alone: the session restores correctly and the stamp stays absent, so that sentence was wrong. * Such a session takes this path on every load instead of once, which costs a base64 decode of a * token already in memory and nothing else - every other reader takes the value from state, not * from storage. Left alone deliberately: writing here to make a comment true would put a second * writer on a key that already has exactly one. */ // The ID token is read from the committed tuple once the first read settles, before the user is initialized. let live = true; void credentials().ready().then(() => { const restored = live && recoverSignedInAs({ stored: authStorage.getItem(`${LOCAL_STORAGE_PREFIX}_loggedInAs`), idToken: committedTuple()?.idToken ?? null, patientClientId: environment.cognitoPatientClient, relatedPersonClientId: environment.cognitoRelatedPersonClient, }); if (restored) { setLoggedInAs(restored); } }); return () => { live = false; }; }, []); const getClientId = useCallback((signInAs?: "Patient" | "RelatedPerson") => { if (demoTabAtLoad) { const demoClient = demoClientFor(!isPatientExperience()); if (!demoClient) throw new Error("This stack has no demo sign-in."); return demoClient; } if (isPatientExperience()) { if (signInAs === "RelatedPerson") { return environment.cognitoRelatedPersonClient; } return environment.cognitoPatientClient; } else { return environment.cognitoPractitionerClient; } }, []); const setWpOrganization = useCallback((organization: Organization | null) => { // ONLY FORWARD FOR THE SAME PRACTICE. Saves, reads and broadcasts answer out of order; an older version of the // practice held now (a delayed save answer after a newer arrival) never replaces the newer one every later // practice save is built from. const held = wpOrganization.current; if (organization && held?.id && held.id === organization.id && olderThan(organization, held)) return; if (joinScope.current.organizationId && joinScope.current.organizationId !== organization?.id) openJoinScope("idle"); wpOrganization.current = organization; // Leaving a practice retires its viewer lifetime now, before any held send or answer can resume under it. setWriteThroughViewer(viewerScope(viewerUserId.current, actingAsRef.current?.relatedPersonId, organization?.id)); setCurrentOrganization(organization); setOrganizationCheckComplete(true); }, [openJoinScope]); /** * ENDING AN ACTING-AS SESSION IS LOUD BY DEFAULT, AND SILENCE HAS TO BE ASKED FOR. * * The first fix for this hardened the ONE writer it went through and left the others alone, * and the session went on ending silently through a different path. Hardening callers one at a * time cannot work here: the defect is not any particular caller, it is that a silent null was * the cheapest thing to write. So the funnel records it, every caller inherits that, and a * caller that genuinely should say nothing has to declare itself. * * `announced` means SOMETHING ELSE ALREADY TOLD THEM - the person pressed Exit, or the switch * they were waiting on simply did not happen in front of them. It does NOT mean 'this one is * unimportant'. */ const setActingAs = useCallback(( target: ActingAsTarget | null, options?: { announced?: boolean }, ) => { const previous = actingAsRef.current; actingAsRef.current = target; actingAsSessionRef.current += 1; setActingAsState(target); ownedActingAsRaw.current = writePersistedActingAs(target, ownedActingAsRaw.current); if (target) { // Switching in clears any standing notice: they are somewhere else now and the old one // would be describing a session two switches ago. setActingAsEnded(null); setEntryNotice(null); return; } if (previous && !options?.announced) { setActingAsEnded({ subjectName: previous.subjectName || 'that account' }); } }, []); // Everything a sign-out does in this tab for the session it ends, once that session's retirement is under way. const signOutHere = useCallback((ending: CommittedSession | undefined, retire: () => Promise, replaced?: () => boolean) => { noteSessionEndingHere(ending?.epoch ?? null); // Card 072 — tell the server this sign-out was DELIBERATE, so My History can say "Signed out" // rather than the TTL sweeper's "Session ended". Fire-and-forget, and deliberately FIRST: it needs // the access token, which the next line clears. It never throws and is never awaited - logging out // must not wait on the network (see signOutBeacon.ts for why it is `keepalive`). // `wpOrganization` is a REF, so reading it here is always current and needs no dependency - which // matters because this callback deliberately has an empty dep list. The org is REQUIRED: without // `X-Organization-ID` the authorizer grants a patient no roles at all and the call is silently // 403'd (observed on a real logout, 2026-07-31). See signOutBeacon.ts. // CARD 489 — the beacon now carries a DPoP proof, which means building it is ASYNC, which means the // request no longer starts in this synchronous turn. `keepalive` only carries a request that has // already STARTED, and the reload at the end of this function would otherwise race it. // Hold the promise and wait for the request to be ISSUED - never answered - before reloading. const beaconIssued = sendSignOutBeacon(wpOrganization.current?.id, { loggedInAs: authStorage.getItem(`${LOCAL_STORAGE_PREFIX}_loggedInAs`), accessToken: ending?.tuple.accessToken ?? null, expiresAt: ending?.tuple.expiresAt ?? 0, }); const retired = retire(); // Card 251 §1c item 20 — clear the frame hint in the same breath as the tokens. If it outlived // them, the login page would render with the APP's minimal header instead of the practice's real // one, which is precisely the "signed out into a room with no doors" state the auth-state rule // exists to prevent. clearAuthHint(); // SWEPT BY PREFIX, NOT LISTED. This was ten `removeItem` calls plus the acting-as key below, and // the list was correct - it was correct every time anybody checked it. But it is hand-maintained, // and three leaks have already happened exactly this way: the in-memory `loggedInAs` ref, the // campaign CTA attribution, and the message-thread cache that left patient and practitioner NAMES // on the browser for whoever used it next. Each was added to the session and not to the removals, // and nothing failed anywhere. A sweep makes the fourth impossible rather than unlikely. // // It clears THIS portal's prefix only. Storage is namespaced by role, so a staff sign-out must not // evict a patient session sitting beside it - the two hold sessions at once by design. Anything // under the prefix that must survive a sign-out is named in `SESSION_SURVIVORS`, where the reason // has to be written down; there is deliberately no way to omit a key by accident. clearPortalSession(LOCAL_STORAGE_PREFIX, authStorage); // This tab's unconfirmed-send record goes too, and no send begun before can write it back. portalSendRecovery().clear(); clearReceiptAttempts(); // AND THE SECOND STORE, WHICH IS NOT UNDER THAT PREFIX AND SO SURVIVED EVERY SIGN-OUT UNTIL NOW. // `amazon-cognito-identity-js` caches its own copy of the session under // `CognitoIdentityServiceProvider..*` whenever a password sign-in succeeds. Measured on a // real staff browser 2026-09-02: our own keys all went, the reload correctly showed the sign-in form, // and five of the library's keys were still there - a refresh token among them, which lasts seven // days. Nothing here reads them, so the screen was honest; the front desk is a shared machine and a // refresh token left in its storage is a credential either way. // Scoped to THIS portal's app client, never the whole namespace: the patient and practitioner // clients share it, and the two portals hold sessions at once by design. // The REF, not the state: this callback has an empty dependency list, so closed-over state here is // whatever it was on the first render - and a related person signs in on a third app client, whose // keys a `getClientId()` with no argument would leave behind while clearing a client they never used. clearCognitoLibrarySession(ending?.tuple.clientId ?? getClientId(loggedInAsRef.current), localStorage); // The IN-MEMORY copy, and it has to be here rather than left to a re-render. The stored // acting-as key is under the prefix and goes with the sweep above; this ref is not. Now that // `fetchUserData` reads the ref, a stale one outlives the session it belongs to: sign out as a // patient, sign in as somebody else on the same page, and the resolver would answer with the // previous person's kind. Written directly rather than through `setLoggedInAs` so this does not // acquire a dependency on it. loggedInAsRef.current = undefined; // Card 077 S4e-2 — a campaign CTA token captured for this login must not survive past it: on a // shared device, a captured-but-unused token left in sessionStorage would otherwise still be // sitting there for whoever logs in next on this tab, wrongly attributing THEIR booking to a // campaign that targeted a different person. sessionStorage.setItem/reload alone does not clear // it - a reload (below) is not a tab close. clearCampaignCtaAttribution(); // The message-thread cache is keyed per identity, so the literal-name removals above could never // have reached it - it survived every sign-out, leaving patient and practitioner NAMES on the // browser for whoever used it next. Swept by prefix rather than by name; see the module header // for why the sweep is not scoped to the departing identity. clearAllDirectThreadCaches(); // Before the session flips off: the login page that renders in place until the reload must not spend // the reason a sign-out recorded. holdSessionEndReasonUntilReload(); setIsLoggedIn(false); setCurrentUser(null); setPractitionerRoles([]); setSelectedPractitionerRole(undefined); setSelectedWorkspace(undefined); actingAsRef.current = null; actingAsSessionRef.current += 1; setActingAsState(null); joinScope.current = { op: joinScope.current.op + 1 }; setJoinSetup("idle"); // A demo tab ends its visit now, so no sign-in form on the demo client shows before the reload. if (demoTabAtLoad) window.dispatchEvent(new CustomEvent(DEMO_ENDED_EVENT, { detail: "SIGNED_OUT" })); toast.success("User successfully logged out") // Card 251 §1b part 3 — SIGN OUT HANDS THE USER BACK TO THE PRACTICE'S WEBSITE, and the reload is // what re-renders the page server-side with their real header and menu around it. // // BUT A BARE RELOAD KEEPS THE HASH, and on a shared front-desk machine that is a real leak: the // URL bar still reads `#/dashboard/patients/1234` after the receptionist signs out, so the next // person sees where the last one was - and signing in silently returns THEM to that screen. // Dropping the hash lands everyone on the portal page itself, which is the page the practice // actually sends people to. // `location.href = pathname` rather than `hash = ''`: clearing the hash alone does not reload in // every browser, and this reload is load-bearing - it is what brings the practice's chrome back. // // CARD 489 — AND IT WAITS FOR THE SIGN-OUT BEACON TO BE ISSUED FIRST, bounded by a timeout. // Everything above has already happened: the tokens are gone, the state is cleared, the toast is up. // The only thing still outstanding is a locally-signed request leaving the browser, and `keepalive` // carries it the rest of the way once it has STARTED. // THE TIMEOUT IS THE LOAD-BEARING HALF, and it is why this does not violate the beacon's founding // rule. Someone walking away from a shared machine must never be held up by the network - so the // reload happens on whichever comes first, and a hung or blocked request costs a quarter second // rather than a session that stays on screen. // Do NOT await the beacon's RESPONSE. This waits for it to be sent, not answered; the server's reply // is of no interest to someone who has already left. const reload = () => { if (replaced?.()) return; window.location.href = `${window.location.pathname}${window.location.search}`; }; // A retirement still writing when this reloads is owed in this tab and finished by the next load. void Promise.race([Promise.all([beaconIssued, retired]), new Promise((resolve) => setTimeout(resolve, BEACON_ISSUE_TIMEOUT_MS))]) .then(reload, reload); }, []); const logout = useCallback(() => { // The exact session this sign-out ends, captured before anything is cleared; a later one is never touched. const signedIn = credentials().current(); const ending = signedIn.state === "signed-in" ? signedIn.session : undefined; // Retired for every tab before any tab is told, then only that session's own token is revoked, at the client // that issued it. Revocation stays fire-and-forget: nobody leaving a shared machine is held on the network. // A retirement the browser cannot record ends this tab and says the other tabs may still be signed in. // Sign-ins admitted before this press are cancelled with it, pending or committed while it waited. signOutHere(ending, () => credentials().signOut(ending?.epoch, credentials().pendingLogins()).then( (held) => held ?? ending, (error) => { if (error instanceof SignOutUnconfirmed) recordSessionEnd("signout-unconfirmed"); return ending; }, ).then((held) => { if (held?.tuple.refreshToken) void revokeRefreshToken(cognitoClient, held.tuple.clientId, held.tuple.refreshToken); })); }, [signOutHere]); // Ends the named session only, as the record holds it now: a failure from an older one never signs out, clears or // reloads the session that replaced it, even one this tab has not yet heard about. const endOwnSession = useCallback(async (epoch: string | null, reason?: Parameters[0]) => { if (epoch === null) { if (committedEpoch() !== null) return; if (reason) recordSessionEnd(reason); logout(); return; } const current = await credentials().readSession(); if (typeof current !== "object" || current.epoch !== epoch) return; let held: CommittedSession | undefined; let unconfirmed = false; // Marked as ending here first, so its own retirement is never shown as an end from elsewhere. noteSessionEndingHere(epoch); try { held = await credentials().retire(epoch); } catch (error) { if (!(error instanceof SignOutUnconfirmed)) throw error; unconfirmed = true; } // Replaced before its retirement ran: the newer session is left exactly as it is. if (!held && !unconfirmed) return; const ending = held ?? current; const revoke = async () => { if (ending.tuple.refreshToken) void revokeRefreshToken(cognitoClient, ending.tuple.clientId, ending.tuple.refreshToken); }; // A retirement that was not recorded says nothing of who holds the record now, and this tab's view was forced signed // out. Its shared state is cleared only when a fresh read finds this session or none; another owner, or no answer, // keeps this session's own retirement owed and its own revocation, and leaves everything else as it is. if (unconfirmed) { const owner = await credentials().readOwner(); if (owner === "unreadable" || (owner && owner.epoch !== epoch)) { void revoke(); return; } } // Another session may have committed while this retirement finished: only this session's own token goes then, and // nothing of the tab now showing the other one is cleared, reset or reloaded. const replaced = () => { const now = credentials().current(); return now.state === "signed-in" && now.session.epoch !== epoch; }; if (replaced()) { if (held) void revoke(); return; } if (reason) recordSessionEnd(reason); if (unconfirmed) recordSessionEnd("signout-unconfirmed"); signOutHere(ending, revoke, replaced); }, [logout, signOutHere]); // Another tab holds the one renewal of this tuple: wait for its result and read it, never spending the token a // second time. A renewal whose outcome is unknown, or one that outlasts the wait, asks for a new sign-in instead. const awaitOtherRenewal = useCallback(async (epoch: string, commitAllowed: () => boolean): Promise => { const coordinator = credentials(); const deadline = Date.now() + OTHER_RENEWAL_WAIT_MS; for (;;) { const view = await coordinator.reread(); if (!commitAllowed() || view.state !== "signed-in" || view.session.epoch !== epoch) return null; if (!view.refreshing) return Date.now() < view.session.tuple.expiresAt ? view.session.tuple.accessToken : null; if (view.refreshing === "unknown" || Date.now() >= deadline) { recordSessionEnd("renewal-unknown"); logout(); throw new CredentialOutcomeUnknown(); } await coordinator.nextChange(Math.max(0, deadline - Date.now())); } }, [logout]); // One renewal of `epoch`'s committed tuple, claimed in the record before its refresh token is read, so no other tab // spends it. `commitAllowed` is asked before every send; a provider answer always goes through the record, which // alone decides whether it still owns the tuple. const performTokenRefresh = useCallback(async (epoch: string | null, commitAllowed: () => boolean = () => true): Promise => { if (!epoch || !commitAllowed()) return null; const coordinator = credentials(); const claim = await coordinator.reserveRefresh(epoch); if (claim.kind === "gone") return null; if (claim.kind === "elsewhere") return awaitOtherRenewal(epoch, commitAllowed); const { reservation } = claim; const owned = async () => commitAllowed() && (await coordinator.refreshCurrent(reservation)); // Once the provider has answered, the claim is never let go: its token may already be rotated. let answered = false; try { if (!(await owned())) return null; const refreshToken = reservation.tuple.refreshToken; if (!refreshToken) { // Nothing to renew with. To the person this is indistinguishable from a refusal, and it is the // same sentence either way: the session ended and could not be renewed. answered = true; recordSessionEnd("refresh-rejected"); logout(); throw new Error("No refresh token available."); } // What this refresh is allowed to claim about the browser key. THE COMMITTED ACCESS TOKEN IS // THE ORACLE: it stays a statement of what this session was bound to after it expires. Reading the key // STORE instead would let a session move onto whatever key the browser holds without presenting credentials. const plan = planClientRefresh({ tokenJkt: readDpopJkt(reservation.tuple.accessToken), storedJkt: (await dpopKeyStore.currentThumbprint()) ?? undefined, }); if (!(await owned())) return null; if (plan.kind === "key-lost") { // The token is bound to a key this browser can no longer produce. Do NOT mint a replacement: the server // refuses an unregistered key on a refresh, so the session ends either way - this way legibly. answered = true; console.error("This device can no longer prove the key its session was issued to - signing out."); recordSessionEnd("device-key-lost"); logout(); throw new Error("Refresh token rejected: DPoP key unavailable"); } // ClientMetadata is OMITTED ENTIRELY when unbound, never sent empty: an empty `dpop_jkt` reaches // `planDpopBinding` as a MALFORMED thumbprint and fails token generation. const clientMetadata = plan.kind === "bound" ? { dpop_jkt: plan.jkt } : undefined; // Asked once more just before the token leaves: the claim may have been retired by a sign-out or a new sign-in. if (!(await owned())) return null; let result: { AuthenticationResult?: AuthenticationResultType }; try { // GetTokensFromRefreshToken - NOT the `/oauth2/token` POST, which has no ClientMetadata at all, so a refresh // through it could never carry the thumbprint and every refreshed access token would come back UNBOUND. // Sent to the client that issued this tuple, which is not always the one this page would sign in with. const command = new GetTokensFromRefreshTokenCommand({ ClientId: reservation.tuple.clientId, RefreshToken: refreshToken, ClientMetadata: clientMetadata, }); // From here the token is the provider's: whatever happens next, this claim is never let go. answered = true; result = await refreshClient.send(command); } catch (error) { // Only a 4xx is Cognito's verdict on this token. Anything else - a 5xx, a 429, a network failure with no // status - may have reached it and rotated the token, so it is never sent again and its claim is kept. const status = (error as { $metadata?: { httpStatusCode?: number } }) ?.$metadata?.httpStatusCode; if (status !== undefined && status >= 400 && status < 500 && status !== 429) { // Only the session that owned the tuple is ended; a refusal for an older one ends nothing newer. if (committedEpoch() !== epoch) return null; console.error(`Refresh refused (${status}) - the session is over.`); recordSessionEnd("refresh-rejected"); logout(); throw new Error(`Refresh token rejected: ${status}`); } // Not a refusal, so the committed tuple stays; the held claim sends the next request to a new sign-in. await coordinator.holdUnknown(reservation); console.error("The session could not be renewed and may have been rotated:", error); throw new CredentialOutcomeUnknown(); } // The provider answered and may have rotated the token, so it is kept through the record before anything else. try { const committed = await coordinator.commitRefresh(reservation, result.AuthenticationResult); setAuthHint(); return commitAllowed() ? committed.tuple.accessToken : null; } catch (error) { if (error instanceof CredentialSuperseded) return null; if (error instanceof CredentialOutcomeUnknown) { recordSessionEnd("renewal-unknown"); logout(); } throw error; } } finally { if (!answered) await coordinator.releaseRefresh(reservation); } }, [logout, awaitOtherRenewal]); /** * Refreshes the access token, collapsing concurrent callers in this page onto ONE request; the credential record * collapses them across tabs. Under refresh-token ROTATION the first response invalidates the token any other * in-flight call holds, so a second spend of the same tuple is what signed users out at random. */ const refreshAccessToken = useCallback(async (): Promise => { await credentials().ready(); // Shared only within one session: a refresh another session started is not this one to wait on. const origin = committedEpoch(); const sameSession = () => committedEpoch() === origin; if (refreshInFlight.current?.origin === origin) { return refreshInFlight.current.run; } // Assigned in the same tick the promise is created, so a second caller sees this one or none. const run = (async () => { // Another tab may already have renewed this session; that tuple is used as it stands, never renewed again. await credentials().reread(); return unexpiredToken(origin) ?? performTokenRefresh(origin, sameSession); })(); const entry = { origin, run }; refreshInFlight.current = entry; try { return await run; } finally { // Cleared on FAILURE as well as success, so one blip never becomes a permanently failing session. if (refreshInFlight.current === entry) refreshInFlight.current = null; } }, [performTokenRefresh]); // A new token even while the committed one is still valid: one minted before the role index caught up carries // too few scopes, and refreshAccessToken would hand that same token back. Same claim and in-flight collapsing. const remintAccessToken = useCallback(async (commitAllowed?: () => boolean): Promise => { await credentials().ready(); const origin = committedEpoch(); const sameSession = () => committedEpoch() === origin && (commitAllowed?.() ?? true); if (refreshInFlight.current?.origin === origin) await refreshInFlight.current.run.catch(() => null); // Asked again after the wait: a session that ended meanwhile has nothing left to re-mint. if (!sameSession()) return null; const run = performTokenRefresh(origin, sameSession); const entry = { origin, run }; refreshInFlight.current = entry; try { return await run; } finally { if (refreshInFlight.current === entry) refreshInFlight.current = null; } }, [performTokenRefresh]); // Adding a role to yourself leaves this session's workspaces and token scopes as they were at sign-in. useEffect(() => { let active = true; const stop = onPractitionerRolesChanged((role) => { if (!role) return; const organizationId = wpOrganization.current?.id; void adoptOwnNewRole(role, { isMine: isCurrentUserPractitionerRole, reMint: remintAccessToken, adopt: upsertCurrentUserPractitionerRole, stillCurrent: () => active && wpOrganization.current?.id === organizationId, onRefreshFailed: () => toast.warning("Your clinical role is saved. Sign out and back in to open its workspace."), }); }); return () => { active = false; stop(); }; }, [isCurrentUserPractitionerRole, remintAccessToken, upsertCurrentUserPractitionerRole]); // Only a committed tuple is handed out, read from the record now: a wakeup another tab never delivered cannot leave // a retired or replaced session's token in use here. const getValidToken = useCallback(async (): Promise => { await credentials().ready(); const session = await credentials().readSession(); if (session === "unreadable") throw new CredentialStorageUnavailable(); if (!session) { throw new Error("No signed-in session."); } if (Date.now() < session.tuple.expiresAt) { return session.tuple.accessToken; } const newAccessToken = await refreshAccessToken(); if (!newAccessToken) { throw new Error("Failed to retrieve refreshed access token."); } return newAccessToken; }, [refreshAccessToken]); const fetchUserInfo = useCallback(async (): Promise => { const token = await getValidToken(); if (!token) throw new Error("No valid token available"); // CARD 477 — NO DPoP PROOF HERE, DELIBERATELY, and this note exists so nobody "completes the // sweep" by adding one. This is COGNITO'S OWN `/oauth2/userInfo`, not our gateway: our authorizer // never runs on it, nothing there reads a proof, and a `DPoP` header would only be an unrecognised // header on somebody else's endpoint. The rule is "every authenticated request to OUR API carries a // proof" - the boundary is whose server answers, not whether a Bearer token is present. const response = await fetch( `${environment.cognitoDomain}/oauth2/userInfo`, { headers: { Authorization: `Bearer ${token}`, }, }, ); if (!response.ok) throw new Error("Failed to fetch user info"); return response.json(); }, [getValidToken]); // One cheap read per gateway the workspace opens on, where the member's role and permissions reach (see // joinProbesFor). Only a refusal written by the gateway itself counts: a route answering for itself, even // with a 403, has admitted this member. const gatewaysAdmit = useCallback(async (signal: AbortSignal): Promise => { const token = await getValidToken(); const organizationId = wpOrganization.current?.id; const ask = async (url: string) => { const headers: Record = { Authorization: `Bearer ${token}` }; if (organizationId) { headers["X-Organization-ID"] = organizationId; headers["X-Organization-Context"] = "wordpress"; } Object.assign(headers, await dpopHeaderFor("GET", url)); return akashicFetch(url, { headers, signal }); }; const capabilities = await ask(`${environment.apiUrl}/my-capabilities`) .then((response) => (response.ok ? response.json() : undefined)) .catch(() => undefined); const urls: Record = { presence: `${environment.apiUrl}/presence`, location: `${environment.fhirBaseApi}/Location?_count=1`, colleagues: `${environment.fhirIdentityApi}/colleagues`, "collaboration-team": `${environment.fhirClinicalApi}/collaboration-team`, }; const probes = joinProbesFor(organizationId, capabilities, tokenRolesAtPractice(token, organizationId)); const answers = await Promise.all(probes.map(async (probe) => { const response = await ask(urls[probe]); return !(response.status === 403 && Boolean(response.headers.get("x-amzn-errortype")?.trim())); })); return answers.every(Boolean); }, [getValidToken]); const fetchFhirResource = useCallback( async (userType: string, id: string, signal?: AbortSignal): Promise => { const token = await getValidToken(); if (!token) throw new Error("No valid token available"); /* * THE IDENTITY FAMILY HAS ITS OWN HOST, AND THIS IS THE READ THAT PROVES IT. * * `userType` here is Practitioner, Patient or RelatedPerson - all three moved out of the base * stack when it hit CloudFormation's 500-resource ceiling, and each nested stack carries its own * gateway and its own hostname. Asking fhir-base for them does not 404: an unmatched route makes * API Gateway refuse the CORS PREFLIGHT with a 403, so the browser never sends the request and * the failure arrives as a bare network error with nothing to read. * * That is what took staff sign-in down: Cognito authenticated, this read could not be made, and * the caller treats a failed read as not-signed-in. */ const url = `${environment.fhirIdentityApi}/${userType}/${id}`; const headers: HeadersInit = { Authorization: `Bearer ${token}`, }; if (wpOrganization.current?.id) { headers["X-Organization-ID"] = wpOrganization.current.id; headers["X-Organization-Context"] = "wordpress"; } else { console.warn("Fetching FHIR resource without organization context"); } // CARD 477 — THIS REQUEST IS WHY A REAL SIGN-IN COULD NOT PRODUCE A WORKING SESSION. // // It builds its headers BY HAND and calls `fetch` directly instead of going through // `HttpService.fetchWithAuth`, so it was the one authenticated call to our own gateway that never // carried a DPoP proof. Once the authorizer began refusing a BOUND token that arrives without one, // this exact call - the signed-in user fetching their OWN Practitioner record, at bootstrap - came // back 403 `DPOP_REFUSED: proof-missing`, and the caller below treats a failure here as "no user" // and signs the person out. Observed 2026-08-17: dashboard reached, session gone ~3 seconds later. // // THE AUTHORIZER'S LOG NAMED THE CULPRIT PRECISELY and it is worth keeping: it showed // `X-Organization-ID` PRESENT and `DPoP` ABSENT. Those two headers are set together in exactly one // place other than `HttpService` - here. // // The proof is attached rather than this being routed through `HttpService`, deliberately: // `HttpService` is a hook that consumes `AuthService`'s context, so calling it from inside // `AuthService` is a cycle. The shape to keep is that anything building `Authorization` for OUR // gateway also builds the proof; `noHandRolledAuthHeaders.test.ts` is what stops a fifth one. Object.assign(headers, await dpopHeaderFor("GET", url)); const response = await akashicFetch(url, { headers, signal }); if (!response.ok) { const errorBody = await response.text().catch(() => ''); throw Object.assign( new Error(`Failed to fetch FHIR resource: ${response.status} ${response.statusText}${errorBody ? ` — ${errorBody}` : ''}`), { status: response.status }, ); } return response.json(); }, [getValidToken], ); const serializeIds = useCallback((ids: string[]): string => { return JSON.stringify(ids); }, []); const deserializeIds = useCallback( (serializedIds: string | undefined): string[] => { if (!serializedIds) return []; try { const parsed = JSON.parse(serializedIds); return Array.isArray(parsed) ? parsed : []; } catch (error) { console.warn("Failed to parse custom attribute IDs:", error); return []; } }, [], ); const fetchUserData = useCallback(async (options?: { keepSession?: boolean; epoch?: string | null }): Promise => { // The committed session this read is for. Once another replaces it, nothing read here is published and nothing // here ends the replacement. const epoch = options?.epoch !== undefined ? options.epoch : committedEpoch(); const replaced = () => committedEpoch() !== epoch; // A refresh of a session already in use passes keepSession: a failed read then reports false and the // person stays signed in, rather than being signed out over a dropped request. let loaded = true; const endSession = (reason: Parameters[0]) => { loaded = false; if (options?.keepSession) return; void endOwnSession(epoch, reason); }; const cognitoUser = await fetchUserInfo(); if (replaced()) return false; const practitionerIds = deserializeIds( cognitoUser["custom:practitionerIds"], ); const patientIds = deserializeIds(cognitoUser["custom:patientIds"]); const relatedPersonId = cognitoUser["custom:relatedPersonId"]; /** * CARD 775 — THE ID AND THE TYPE ARE ONE DECISION, and they were two. * * WHAT USED TO BE HERE: `id` was picked first - practitioner, then patient, then related person - * with no reference to which portal this was, and the TYPE was picked twelve lines below with no * reference to anything else. For anybody holding a SINGLE identity the two agree by accident, * which is why it survived; for somebody holding BOTH, on the patient portal, the type was `Patient` * and the id was **their practitioner's**. The server refused it and the app signed itself out with * no message - no password to retry, because signing in is an emailed one-time code. * * AND IT IS THE FOUNDING MODEL RATHER THAN AN EDGE CASE: one human = one account whose home record * is a Patient. Staff at a small practice are usually patients at that practice, so the receptionist, * the practice manager and the physician were each locked out of their own portal. * * The fix is NOT to resolve staff to their Practitioner on the patient portal - that inverts the * model, where the practitioner hat is the situational part. */ const identity = resolvePortalIdentity({ isStaffMount: isStaffExperience(), isPatientMount: isPatientExperience(), // The role VOCABULARY stays here. `resolvePortalIdentity` takes booleans and returns a kind, so // there is never a second enumeration of these strings to drift from this one. // THE REF, NOT THE STATE. This runs during sign-in, and on the password path that is the same // tick the value was written in - see `loggedInAsRef`. Reading the state here resolves nobody, // and resolving nobody is a silent sign-out. loggedInAsPatient: loggedInAsRef.current === USER_ROLES.PATIENT, loggedInAsRelatedPerson: loggedInAsRef.current === USER_ROLES.RELATED_PERSON, practitionerIds, patientIds, relatedPersonId, }); const FHIR_RESOURCE_TYPE_BY_KIND: Record = { practitioner: USER_ROLES.PRACTITIONER, patient: USER_ROLES.PATIENT, relatedPerson: USER_ROLES.RELATED_PERSON, }; const id = identity?.id; const fhirResourceType = identity && FHIR_RESOURCE_TYPE_BY_KIND[identity.kind]; if (!fhirResourceType) { /** * OPENING ONE PORTAL USED TO SILENTLY DESTROY A SESSION BELONGING TO THE OTHER. * * None of the three branches above can match when a signed-in STAFF session is restored on the * patient mount, or a PATIENT session on the staff mount - each branch pairs an identity with the * experience it belongs to. The account is perfectly valid; it is simply the wrong door. Falling * through to `logout()` therefore ended a live session on a mere page load. * * AND THE COST IS NOT "CLICK BACK". There is no password - signing in is an emailed one-time * code. A wrong bookmark mid-shift cost an email round trip, with no warning beforehand and no * explanation afterwards. Measured in My History as *"Signed out"* at exactly the moment the other * portal was opened, so the eviction was always deliberate; only the silence was the defect. * * SO THIS IS A DISCRIMINATION, NOT A BLANKET SOFTENING. Holding an identity of the OTHER kind is * a recoverable situation and gets a choice. Holding NO usable identity at all is a broken session * and still ends immediately - offering to "keep" a session that cannot resolve anybody would be * an invitation to a loop. */ const holding = crossPortalHolding({ isPatientMount: isPatientExperience(), practitionerIds, patientIds, relatedPersonId, }); if (holding) { // Deliberately NOT `logout()`. The session stays intact and untouched until the person // chooses - that is the whole fix, and the control on it is that cancelling leaves them signed // in. `App` renders the choice; this only reports it. setCrossPortalConflict(holding); return false; } // SIGNED IN, AND THE ACCOUNT IS MISSING SOMETHING THE APP NEEDS TO OPEN A SESSION WITH. It is // not the person's doing and there is nothing for them to fix, so the sentence says to try again // and to tell us if it repeats - which is the only useful next step for a defect this shape. endSession("session-incomplete"); console.error("Unable to fetch FHIR resource: missing resource type.", { fhirResourceType, id }); return false; } if (!id) { endSession("session-incomplete"); console.error("Unable to fetch FHIR resource: missing id.", { fhirResourceType, id }); return false; } let fhirResource; let joinOp: number | undefined; let joining = false; // A JOIN'S RESULT COUNTS ONLY WHILE ITS OPERATION IS CURRENT. A sign-out, another user or practitioner, or a // practice change moves `joinScope`; a join that ends after that, read or refused, writes nothing at all: // no user, roles, workspace, Terms state, storage, end reason or sign-out. Asked again after every await. const superseded = () => replaced() || (joining && joinScope.current.op !== joinOp); try { // A member who joined this practice seconds ago can be refused here until the role index catches // up (utils/justJoinedHandoff); only for them, the refusal is waited out, bounded. // The setup state is opened for this user, practice and practitioner; a read for anybody else clears it. const scope = { organizationId: wpOrganization.current?.id, practitionerId: id, userSub: cognitoUser.sub }; const held = joinScope.current; const sameScope = held.organizationId === scope.organizationId && held.practitionerId === id && held.userSub === scope.userSub; joining = isJustJoined(scope.organizationId, id); joinOp = joining ? openJoinScope("setting-up", scope) : sameScope ? undefined : openJoinScope("idle", scope); const op = joinOp; const current = () => joinScope.current.op === op; fhirResource = await readWhileJoining((signal) => fetchFhirResource(fhirResourceType, id, signal), scope.organizationId, id, { reMint: () => remintAccessToken(current), admitted: gatewaysAdmit, onSettled: (admitted) => settleJoinScope(op, admitted ? "idle" : "unconfirmed") }); } catch (error) { if (superseded()) return false; settleJoinScope(joinOp, "idle"); // Agency staff refused their own profile here are most likely owed contractor Terms: keep the // session and say so, rather than ending it with a sentence that blames our gateway. // A join wait that ran out reads the token already held: its budget is spent, and a refresh now could // hang the ending past it. The grant claim is in the held token whether or not it has expired. const grant = (error as { status?: number })?.status === 403 ? agencyGrantFor( error instanceof JoinNotReady ? committedTuple()?.accessToken ?? null : await getValidToken().catch(() => null), wpOrganization.current?.id, ) : undefined; if (superseded()) return false; if (grant) { setAgencyTermsOwed(grant); return false; } // Just joined, and the access was still not there when the wait ran out. Their token predates the join, so // its missing practice proves nothing; they are told it was not ready, and that signing in again will do. if (error instanceof JoinNotReady) { endSession("join-not-ready"); console.error("The join wait ran out before this member's own record could be read:", error); return false; } // Refused, and the token itself no longer names this practice: they were removed from its staff. // Signing in again cannot change that, so the note says so instead of blaming us. if ((error as { status?: number })?.status === 403 && tokenNamesPractice(await getValidToken().catch(() => null), wpOrganization.current?.id) === false) { endSession("access-ended"); return false; } // THE READ THAT OPENS THE SESSION FAILED, AND THIS ARM USED TO END IT IN SILENCE. Cognito had // already authenticated the person; only their own record could not be fetched. Every other exit // here says why, and this is the one whose cause a person can least guess at - the two live // examples are the authorizer refusing an unproven token, and the identity family moving to a // stack whose hostname the client had not been told about. Both reach the browser as a bare // network error with nothing on screen. endSession("profile-unreadable"); console.error("Failed to fetch FHIR resource for user:", error); return false; } if (superseded()) return false; // Read from the record itself before anything is shown: a replacement whose wakeup has not arrived still counts. if (epoch && !(await credentials().stillCommitted(epoch))) return false; if (superseded()) return false; if (fhirResourceType === USER_ROLES.PRACTITIONER) { setCurrentUser({ cognito: cognitoUser, fhir: fhirResource.practitioner }); } else if (fhirResourceType === USER_ROLES.PATIENT) { setCurrentUser({ cognito: cognitoUser, fhir: fhirResource }); } else if (fhirResourceType === USER_ROLES.RELATED_PERSON) { setCurrentUser({ cognito: cognitoUser, fhir: fhirResource }); } if (fhirResource.roles) { const nextPractitionerRoles = fhirResource.roles as PractitionerRole[]; setPractitionerRoles(nextPractitionerRoles); const storedRoleJson = authStorage.getItem( `${LOCAL_STORAGE_PREFIX}_practitionerRole`, ); let storedRoleId: string | undefined; if (storedRoleJson) { try { storedRoleId = (JSON.parse(storedRoleJson) as PractitionerRole)?.id; } catch (storedRoleError) { console.warn('Discarding an invalid stored practitioner role:', storedRoleError); authStorage.removeItem(`${LOCAL_STORAGE_PREFIX}_practitionerRole`); } } const storedRole = storedRoleId ? nextPractitionerRoles.find((role) => role.id === storedRoleId) : undefined; const nextSelectedRole = storedRole || nextPractitionerRoles.find((role) => role.active !== false) || nextPractitionerRoles[0]; setSelectedPractitionerRole(nextSelectedRole); // Card 315 — the workspaces are whatever the ROLE says they are. Nothing is added on top any // more: an implication an org cannot edit is exactly what that card removed. // GATED, through the same helper the switcher uses. Leaving this one un-gated reinstated the // defect for the person it matters most to: somebody whose ONLY workspace is Practitioner. Their // gated set is empty, so the effect that would have corrected the restored choice returns early // and the stored value stands - they land INSIDE the workspace the switcher will not offer. const assignedWorkspaces = sortStaffWorkspaces(Array.from(new Set( nextPractitionerRoles .filter((role) => role.active !== false) .flatMap(openableWorkspacesFor), ))); const storedWorkspace = authStorage.getItem( `${LOCAL_STORAGE_PREFIX}_workspace`, ) as StaffWorkspace | null; const nextWorkspace = chooseSignInWorkspace({ stored: storedWorkspace, selectedRole: nextSelectedRole, roles: nextPractitionerRoles.filter((role) => role.active !== false), assigned: assignedWorkspaces, groupOf: getCognitoGroup, }); if (nextWorkspace) { setSelectedWorkspace(nextWorkspace); setUserRole(nextWorkspace); authStorage.setItem(`${LOCAL_STORAGE_PREFIX}_workspace`, nextWorkspace); } } if (fhirResourceType === USER_ROLES.PATIENT) { setUserRole(USER_ROLES.PATIENT); } if (fhirResourceType === USER_ROLES.RELATED_PERSON) { setUserRole(USER_ROLES.RELATED_PERSON); setRelatedPatientLoading(true); try { const relatedPatient = await fetchFhirResource( USER_ROLES.PATIENT, fhirResource.patient.reference.split("/")[1], ); if (replaced()) return false; setSelectedRelatedPatient(relatedPatient); if (!relatedPatient) { // A caregiver whose person cannot be resolved is the same failure as above, one record along. endSession("profile-unreadable"); console.error("The related patient this session opens against could not be resolved."); } } catch (error: any) { if (replaced()) return false; endSession("profile-unreadable"); console.error("Failed to fetch the related patient for this session:", error); } finally { setRelatedPatientLoading(false); } } return loaded; }, [ fetchUserInfo, deserializeIds, fetchFhirResource, getValidToken, getCognitoGroup, loggedInAs, endOwnSession, remintAccessToken, gatewaysAdmit, openJoinScope, settleJoinScope, ]); // One bounded admission check per press, for a member whose session opened before every gateway admitted them. // The whole check is cut off at five seconds, its token and proof included. Its answer counts only for the // join it was pressed in; a yes there ends the notice and asks what was refused meanwhile to be read again. const recheckInFlight = useRef<{ op: number; check: Promise } | null>(null); const recheckJoinAccess = useCallback((): Promise => { const { op } = joinScope.current; if (recheckInFlight.current?.op === op) return recheckInFlight.current.check; const organizationId = wpOrganization.current?.id; const check = admittedWithin(gatewaysAdmit, RECHECK_MS).then((admitted) => { if (joinScope.current.op !== op || wpOrganization.current?.id !== organizationId) return false; if (admitted) { settleJoinScope(op, "idle"); setJoinRecovery((count) => count + 1); } return admitted; }).finally(() => { if (recheckInFlight.current?.check === check) recheckInFlight.current = null; }); recheckInFlight.current = { op, check }; return check; }, [gatewaysAdmit, settleJoinScope]); /** * Reserves the epoch a sign-in will commit under, before the provider is asked for tokens. Every sign-in path * takes one here and hands it to `login`; a later admission, in this tab or another, supersedes it. */ const admitSignIn = useCallback( // `clientId` names the app client a sign-in that does not go through `getClientId` will mint at. (signInAs?: "Patient" | "RelatedPerson", clientId?: string): Promise => credentials().admitLogin(clientId ?? getClientId(signInAs)), [getClientId], ); const login = useCallback( async (authenticationResult: AuthenticationResultType | undefined, ticket: LoginTicket) => { // Only the admitted sign-in commits, and only a complete tuple that reads back as committed signs this page in. // A newer sign-in or sign-out in any tab supersedes it, and a refresh still in flight for the session before // it may no longer spend, save or end anything. let session: CommittedSession; try { session = await credentials().commitLogin(ticket, authenticationResult); } catch (error) { if (error instanceof CredentialStorageUnavailable) recordSessionEnd("credential-storage"); throw error; } // A server-readable hint that a session exists, set only once one is committed; see authHintCookie.ts. setAuthHint(); setIsLoggedIn(true); try { await fetchUserData({ epoch: session.epoch }); } catch (error) { console.error("Failed to fetch user info after login:", error); await endOwnSession(session.epoch); } // Replaced while its profile was read: this sign-in is not the one now shown, so it does not finish as one. if (committedEpoch() !== session.epoch) throw new CredentialSuperseded(); }, [fetchUserData, endOwnSession], ); // Administrator access granted to the signed-in person takes effect in this session once a renewed token // carries it; the profile is then re-read without ever ending the session. // Its re-mints belong to the session that asked: once that one ends, a later try spends nobody else's token. const takeUpAdminGrant = useCallback( (organizationId: string): Promise => { const origin = committedEpoch(); return settleAdminGrant({ organizationId, reMint: () => remintAccessToken(() => committedEpoch() === origin), reloadProfile: () => fetchUserData({ keepSession: true }), wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), }); }, [remintAccessToken, fetchUserData], ); // One re-read of the signed-in person's own profile per page load, shared by every screen that asks; // it keeps the session whatever the read says. const ownProfileRecheck = useRef | null>(null); const recheckOwnProfileOnce = useCallback((): Promise => { ownProfileRecheck.current ??= fetchUserData({ keepSession: true }).catch(() => false); return ownProfileRecheck.current; }, [fetchUserData]); const initializeUser = useCallback(async () => { if (pendingUserInitialization.current) { return; } pendingUserInitialization.current = true; // Settled before any protected route or authenticated request: there is no legacy-key fallback to read meanwhile. const view = await credentials().ready(); const accessToken = view.state === "signed-in"; // Storage that cannot be read is said plainly, never shown as a signed-in or a merely signed-out page. if (view.state === "unavailable") recordSessionEnd("credential-storage"); // Card 316 — bring the portal frame into line with the session that ACTUALLY exists. // // THIS IS WHAT CLOSES THE SURVIVES-A-RELOAD CASE. `setAuthHint` runs when a session BEGINS; a // reload with a session already in localStorage never called it, so a cookie that was missing, // expired or dropped stayed missing - and the practice's WordPress header sat above the app through // every reload, which is what claude-4 observed on the signed-in staff portal. // Runs BEFORE the network: the frame must not wait on `fetchUserData` to be right. syncAuthFrame(accessToken); if (accessToken) { setIsLoggedIn(true); try { await fetchUserData(); } catch (error) { console.error("Failed to initialize auth:", error); setCurrentUser(null); } } else { // Card 242 (B1) — NOT an error. Having no access token before you have signed in is the normal // state of the login screen, and this fires on every logged-out load of the most-visited surface // in the product. Logging it at error level is what teaches everyone - us included - that console // errors here are background noise, which is the habit that lets a real one go unread. Card 235's // whole method is reading this console. console.debug("No access token found. Skipping user initialization."); } setIsLoading(false); pendingUserInitialization.current = false; }, [fetchUserData]); useEffect(() => { if (organizationCheckComplete) { initializeUser(); } }, [organizationCheckComplete, initializeUser]); const initiateAuth = useCallback( async ( username: string, signInAs?: "Patient" | "RelatedPerson", /* * OPTIONAL, AND ITS ABSENCE IS THE PATH EVERY ACCOUNT HAS ALWAYS HAD. The pool has no * MFA, so the emailed code is not a second factor - it is the only one, and every account already * carries a password nothing ever asked for. Supplying one here skips the mailbox; omitting it * leaves the code path byte for byte as it was, which is what an account with no password set, or * anybody who has forgotten theirs, depends on. * * STILL `USER_AUTH`, NOT `USER_PASSWORD_AUTH`. That other flow does not forward `ClientMetadata` * the way this one does, so the organizationId and the DPoP thumbprint below would not reach the * pre-token trigger - and the token it returned would not be the token this product issues. Only * the PREFERRED_CHALLENGE changes. */ password?: string, ): Promise => { if (environment.production && password?.trim()) throw new Error(PASSWORD_SIGN_IN_OFF); let params: InitiateAuthCommandInput; // CARD 260 — establish this browser's DPoP key HERE, on the sign-in path, because this is the only // path allowed to create one. Never on refresh: the server refuses an unregistered key there. // Answers null when the browser cannot keep a key, and then the sign-in proceeds unbound. const dpopJkt = await dpopKeyStore.establishForSignIn(); if (signInAs === "RelatedPerson") { params = { AuthFlow: "CUSTOM_AUTH", ClientId: getClientId(signInAs), AuthParameters: { USERNAME: username, }, ClientMetadata: withDpopThumbprint( { organizationId: wpOrganization.current?.id as string }, dpopJkt, ), }; } else { // A password is only ever offered when one was typed. `trim()` rather than a truthy check, // because a field containing spaces is an empty field to the person looking at it, and sending // it would spend a rejected attempt on nothing. const offered = password?.trim() ? password : undefined; params = { AuthFlow: "USER_AUTH", ClientId: getClientId(signInAs), AuthParameters: { USERNAME: username, PREFERRED_CHALLENGE: offered ? "PASSWORD" : "EMAIL_OTP", /* * THE PASSWORD MUST BE SENT HERE. COGNITO'S PASSWORD FACTOR IS ONE-STEP AND THERE IS NO * TWO-STEP FORM OF IT. * * An earlier version of this asked for the PASSWORD challenge and withheld the password, * intending to answer it on `RespondToAuthChallenge` the way the emailed code is answered. * Cognito refuses that outright: * * 400 InvalidParameterException "Missing required parameter PASSWORD" * * `PREFERRED_CHALLENGE: PASSWORD` is a request to mint NOW, not a request for a challenge * to answer later. Only EMAIL_OTP and CUSTOM_AUTH are two-step here. * * AND THE REASON THAT WAS ATTEMPTED IS A MISREADING, MEASURED AND CORRECTED. The claim was * that a one-step mint loses this ClientMetadata and returns a token with no custom claims. * Decoding a token minted exactly this way shows the opposite - `organization_roles`, * `organization_role_codings`, `practitioner_id`, `fhirUser` and `collaboration_member` are * ALL present, and the PreAuthentication trigger demonstrably reads `organizationId` from * this same metadata (a wrong one is refused by name). * * EXACTLY ONE CLAIM IS LOST, AND IT IS `dpop_jkt`. A password sign-in is therefore NOT * DPoP-bound, while the emailed-code path is. That is a real gap, it is carded, and it is * deliberately not hidden behind a broken sign-in: a refused password helps nobody. */ ...(offered ? { PASSWORD: offered } : {}), }, ClientMetadata: withDpopThumbprint( { organizationId: wpOrganization.current?.id as string }, dpopJkt, ), }; } return cognitoClient.send(new InitiateAuthCommand(params)); }, [getClientId], ); /** * A PASSWORD SIGN-IN THAT KEEPS ITS BINDING. Separate from `initiateAuth` because it is a different * exchange, not a different parameter: SRP returns a challenge and the tokens are minted by the answer * to it, which is the only way our ClientMetadata reaches the pre-token trigger on a password path. * * The thumbprint is established HERE, on the sign-in path, for the same reason it is in `initiateAuth` * - this is one of the only paths allowed to create a key, and a refresh is refused an unregistered one. */ const signInWithPassword = useCallback( async ( username: string, password: string, signInAs?: "Patient" | "RelatedPerson", ): Promise => { if (environment.production) throw new Error(PASSWORD_SIGN_IN_OFF); const dpopJkt = await dpopKeyStore.establishForSignIn(); return srpPasswordSignIn({ userPoolId: environment.cognitoUserPoolId, clientId: getClientId(signInAs), username, password, clientMetadata: withDpopThumbprint( { organizationId: wpOrganization.current?.id as string }, dpopJkt, ), }); }, [getClientId], ); const generatePassword = useCallback(() => { const length = 32; const numbers = "0123456789"; const specialCharacters = "!@#$%^&*()_+[]{}|;:,.<>?"; const uppercaseLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const lowercaseLetters = "abcdefghijklmnopqrstuvwxyz"; let password = [ numbers[Math.floor(Math.random() * numbers.length)], specialCharacters[Math.floor(Math.random() * specialCharacters.length)], uppercaseLetters[Math.floor(Math.random() * uppercaseLetters.length)], lowercaseLetters[Math.floor(Math.random() * lowercaseLetters.length)], ]; const allCharacters = numbers + specialCharacters + uppercaseLetters + lowercaseLetters; for (let i = password.length; i < length; i++) { password.push( allCharacters[Math.floor(Math.random() * allCharacters.length)], ); } password = password.sort(() => Math.random() - 0.5); return password.join(""); }, []); const signUp = useCallback( (data: { email: string; password: string; firstname: string; lastname: string; phone?: string; dob?: string; }): Promise => { const { email, firstname, lastname, phone, dob } = data; const userAttributes = [ { Name: "email", Value: email }, { Name: "family_name", Value: lastname }, { Name: "given_name", Value: firstname }, ]; // Capture phone + DOB on the Cognito user so the post-confirmation trigger can seed them onto // the new Patient (more identity data points => better dedup at create). Phone is stored E.164 // (Cognito's phone_number requires it); DOB passes through as YYYY-MM-DD. const e164Phone = phone ? toE164(phone) : null; if (e164Phone) { userAttributes.push({ Name: "phone_number", Value: e164Phone }); } if (dob) { userAttributes.push({ Name: "birthdate", Value: dob }); } const input: SignUpCommandInput = { ClientId: getClientId("Patient"), Username: email, UserAttributes: userAttributes, ClientMetadata: { organizationId: wpOrganization.current?.id as string, resourceType: "Patient", }, }; return cognitoClient.send(new SignUpCommand(input)); }, [getClientId], ); const confirmSignUp = useCallback( (username: string, code: string) => { const input: ConfirmSignUpCommandInput = { ClientId: getClientId("Patient"), Username: username, ConfirmationCode: code, }; return cognitoClient.send(new ConfirmSignUpCommand(input)); }, [getClientId], ); const resendConfirmationCode = useCallback( (username: string) => { const input: ResendConfirmationCodeCommandInput = { ClientId: getClientId("Patient"), Username: username, }; return cognitoClient.send(new ResendConfirmationCodeCommand(input)); }, [getClientId], ); const respondToAuthChallenge = useCallback( async ( username: string, answer: string, challengeName: ChallengeNameType | undefined, session: string, ): Promise => { if (environment.production && challengeName === "PASSWORD") throw new Error(PASSWORD_SIGN_IN_OFF); let challengeResponses: { [key: string]: string }; if (challengeName === "CUSTOM_CHALLENGE") { challengeResponses = { USERNAME: username, ANSWER: answer, }; } else if (challengeName === "PASSWORD") { // The password is ANSWERED here rather than sent on InitiateAuth, so the tokens are minted by // this call - the one whose ClientMetadata the pre-token trigger reads. A one-step mint returns // a token with none of the custom claims, and the app then signs itself out. challengeResponses = { USERNAME: username, PASSWORD: answer, }; } else { challengeResponses = { USERNAME: username, EMAIL_OTP_CODE: answer, }; } // CARD 260 — THIS IS THE CALL THAT ISSUES THE TOKENS, so this is the ClientMetadata the pre-token // trigger actually receives. Both sign-in flows here are two-step (InitiateAuth returns a CHALLENGE, // never tokens), so a thumbprint sent only on InitiateAuth would never reach the trigger and every // session would come out UNBOUND - silently, because the server tolerates unbound during rollout. // Idempotent: the key was established at InitiateAuth and this reads it back. const dpopJkt = await dpopKeyStore.establishForSignIn(); const input: RespondToAuthChallengeCommandInput = { ChallengeName: challengeName, ClientId: getClientId(loggedInAs), ChallengeResponses: challengeResponses, Session: session, ClientMetadata: withDpopThumbprint( { organizationId: wpOrganization.current?.id as string }, dpopJkt, ), }; return cognitoClient.send(new RespondToAuthChallengeCommand(input)); }, [getClientId, loggedInAs], ); return { currentUser, setCurrentUser, takeUpAdminGrant, recheckOwnProfileOnce, userRole, USER_ROLES, practitionerRoles, availableWorkspaces, selectedPractitionerRole, setSelectedPractitionerRole, selectedWorkspace, setSelectedWorkspace, activateWorkspace, upsertCurrentUserPractitionerRole, removeCurrentUserPractitionerRole, selectedRelatedPatient, relatedPatientLoading, getCognitoGroup, isLoggedIn, setLoggedInAs, // Card 541 — which kind of session is being held on the wrong portal, or undefined. crossPortalConflict, agencyTermsOwed, joinSetup, recheckJoinAccess, joinRecovery, isLoading, wpOrganization, currentOrganization, organizationCheckComplete, setWpOrganization, actingAs, actingAsRef, actingAsSessionRef, setActingAs, actingAsEnded, acknowledgeActingAsEnded: () => setActingAsEnded(null), actingAsEntryNotice: entryNotice && entryNotice.viewer === sessionScope ? { subjectName: entryNotice.subjectName, outcome: entryNotice.outcome } : null, noteActingAsEntryFailed, acknowledgeActingAsEntryNotice: () => setEntryNotice(null), isActingAsEntering, setIsActingAsEntering, initiateAuth, signInWithPassword, admitSignIn, login, logout, signUp, confirmSignUp, resendConfirmationCode, respondToAuthChallenge, getValidToken, fetchUserInfo, fetchFhirResource, serializeIds, deserializeIds, generatePassword, }; }; const AuthServiceContext = createContext< ReturnType | undefined >(undefined); export const AuthServiceProvider = ({children,}: { children: Parameters[0]["children"]; }) => { const authService = useAuthService(); return ( {children} ); }; export const useAuthServiceContext = () => { const context = useContext(AuthServiceContext); if (context === undefined) { throw new Error( "useAuthServiceContext must be used within an AuthServiceProvider", ); } return context; }; /** * The same accessor, tolerant of an absent provider - for surfaces that only want to REFINE what they show * based on who is signed in, and must still render when they cannot find out. * * The rule this exists to serve: **a core surface must never be breakable by a refinement.** Card 236 * learned it once - `ClinicalNoteEditor` called the throwing scribe accessor, so a missing provider would * have taken down note writing entirely for an optional paid feature. Card 263 hit the same shape from the * other side: `EncounterDocumentation` began reading the caller's identity to decide whose clinical note * an encounter is, and the throwing accessor turned "we do not know who you are" into a blank screen where * a practitioner was documenting a visit. * * Do NOT use this for an authorization decision. Absent means UNKNOWN, and callers must choose the safe * reading for their own case - which is not always "deny" (see `mayWriteClinicalNote`, where refusing on * unknown would leave a visit nobody can document). */ export const useOptionalAuthServiceContext = () => useContext(AuthServiceContext);