// src/utils/recoverSignedInAs.ts // // - WHICH DOOR A RESTORED SESSION CAME THROUGH, when nothing wrote it down. // // THE DEFECT THIS EXISTS TO END. `resolvePortalIdentity` next door has two patient branches and both // require `loggedInAs`. That value is not in the token - it lives in one separate localStorage stamp, // written when somebody presses Sign In. A cold page load has no sign-in, so the only way the value // can be known is by reading the stamp back, and when the stamp is missing while the tokens are // present and valid the resolver matches nothing and the fall-through runs `logout()` on a session // that is perfectly good. // // Observed on staging 2026-08-31: on the dashboard, valid access token in storage, full page load -> // `#/login` with no message. A stamp and a token are TWO RECORDS OF ONE FACT and they can disagree; // every session established before the stamp existed has tokens and no stamp, so the first reload // after a deploy signs those people out. // // THE RECOVERY IS ALREADY IN STORAGE, WHICH IS WHY THE FALL-THROUGH DOES NOT HAVE TO BE WEAKENED. // The two patient-side doors are two different Cognito app clients - `cognitoPatientClient` and // `cognitoRelatedPersonClient` - and the ID token records which one minted it in `aud`. That is more // authoritative than the stamp, because the server issued it and the browser only copied it. // // IT MUST ANSWER "NOBODY" MORE READILY THAN IT ANSWERS AT ALL. Answering for a practitioner token, or // for a token it cannot read, would resolve somebody the staff branch should have decided - which is // the fall-through weakened by another name, and that fall-through is what keeps a staff session from // being read as a patient one. Every path that is not one of the two patient clients returns // `undefined` and leaves the caller exactly where it was. // // PURE, and extracted for the reason `portalIdentity.ts` beside it was: the DECISION is the part that // was wrong, and a decision needing a browser, a mount and a restored session to exercise is a // decision nobody checks. /** The two identities a stamp can hold. A staff sign-in writes no stamp and needs none. */ export type SignedInAs = 'Patient' | 'RelatedPerson'; /** The unverified claim set of a JWT, or `undefined` for anything that is not readable as one. */ const claimsOf = (token: string): Record | undefined => { // No signature check, deliberately, and it costs nothing: this token was issued to this browser // and is already trusted by everything else that reads it. The only question asked here is which // app client minted it, and a forged answer could at most pick the wrong patient-side door for // somebody who already holds the token. const payload = token.split('.')[1]; if (!payload) { return undefined; } try { const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); const parsed: unknown = JSON.parse(json); return parsed && typeof parsed === 'object' ? parsed as Record : undefined; } catch { // A malformed token is a "nobody", never a throw. This runs during the boot effect, so an // exception here is a white screen rather than a sign-in screen. return undefined; } }; /** * Who signed in, for a session being restored from storage. * * The stamp wins whenever there is a usable one - it is what the person actually chose. The token is * only consulted when the stamp cannot answer, and it can only ever answer with one of the two * patient-side identities. */ export const recoverSignedInAs = (input: { /** The `_loggedInAs` stamp as it came out of storage. */ stored: string | null | undefined; /** The ID token as it came out of storage. */ idToken: string | null | undefined; patientClientId: string; relatedPersonClientId: string; }): SignedInAs | undefined => { const { stored, idToken, patientClientId, relatedPersonClientId } = input; // Compared against the two known values rather than cast. A stamp holding anything else is a // stamp we did not write, and treating it as an identity is how a role nobody compares against // ends up flowing through the resolver. if (stored === 'Patient' || stored === 'RelatedPerson') { return stored; } if (!idToken) { return undefined; } const aud = claimsOf(idToken)?.aud; // Cognito sends a single string; the JWT spec allows an array and some pools do send one. const audience = Array.isArray(aud) ? aud.find((entry) => typeof entry === 'string') : aud; if (audience === patientClientId) { return 'Patient'; } if (audience === relatedPersonClientId) { return 'RelatedPerson'; } // A practitioner token, an unreadable token, or a client we do not know. The caller keeps // whatever it had, and for the staff mount that is the correct and unchanged answer. return undefined; };