/** *, half 2 - turn a provider's error into something a patient can act on. * * Why this exists * `LoginPage` and `SignupPage` both rendered `error.message` verbatim, so a patient who took longer * than the auth-session window to fetch their emailed code was shown Cognito's own words: * * "Verification failed: Invalid session for the user, session is expired." * * Three defects in one sentence: it is a provider internal, it never states the remedy (request a new * code), and it is **indistinguishable from a mistyped code** - so the natural next action is to * retype the same dead code, which can never succeed. * * Half 1 raised `AuthSessionValidity` from Cognito's 3-minute default to 15, which makes this rarer. * It does not make it comprehensible, which is why this half is the durable one. * * The invariant * EVERY return value's `message` is one of `AUTH_ERROR_SENTENCES`. An unmapped provider error * falls back to the generic sentence rather than passing anything through, so **adding a new provider * error can never leak text to a patient** - the default is closed. The test asserts this as an * allowlist over a corpus of real Cognito strings plus unmapped ones, because "it isn't equal to THIS * string" would be satisfied by a mapper that passes every other string through. * * Distinguishing "expired" from "mistyped" is wanted HERE and is deliberately FORBIDDEN on the * contact-email verify endpoint, whose uniform refusal is anti-oracle design * (`utils/pending-email-verification/verifyFlow.ts`). Different door, different constraints. Nothing * here reveals whether an account exists: the caller has already passed the email step and holds a * challenge session. */ export type AuthErrorKind = | 'wrong-practice' | 'not-here' | 'expired-session' | 'invalid-code' | 'wrong-password' | 'too-many-attempts' | 'temporarily-locked' | 'account-inactive' | 'no-account' | 'unknown' export interface AuthErrorCopy { kind: AuthErrorKind /** Always one of `AUTH_ERROR_SENTENCES`. Never the provider's text. */ message: string /** * Whether a fresh code is the remedy for THIS failure. * * NOTHING GATES A CONTROL ON THIS, AND NOTHING SHOULD. This comment used to say the page offers * the resend on it; it does not. The login page shows the resend for the whole code step, * deliberately, because the commonest failure on a passwordless sign-in produces no error at all: * the email never arrives, or lands in spam. There is no AuthErrorCopy in that case, so a control * gated on this field is absent exactly when somebody needs it. * * It survives as a classification, asserted by the auth-error tests and read by no screen. Keep it * accurate; do not turn it back into a gate. */ canResendCode: boolean } const EXPIRED_SESSION = 'That login code has expired. Request a new one and we’ll email it to you.' /* * NAMES THE COMMONEST CAUSE, WHICH THE OLD WORDING POINTED AWAY FROM. * * "Check it and try again" describes a typo, and a typo is not what usually happened: sending a new code * SILENTLY INVALIDATES the previous one, so the ordinary failure is somebody reading a code out of an * older email. It cost three codes in one evening - rejected twice before anybody worked out why - * and the message was actively steering them at re-reading the same dead digits. * * It now names the real cause and the remedy, and the control that performs that remedy sits directly * beneath it. */ const INVALID_CODE = 'That code did not match. It may have expired, or a newer code may have replaced it. Sending a new one is the quickest fix.' const TOO_MANY_ATTEMPTS = 'Too many attempts. Please wait a few minutes before trying again.' /* * COGNITO HAS LOCKED THE SIGN-IN FOR A WHILE, and "try again" is the one thing that cannot work yet. It says * "Too many invalid credentials attempts. User temporarily locked." after repeated wrong answers. The wait is * Cognito's and is not ours to state, so none is promised; a new code is offered as what it is, not as a way * past the lock. */ const TEMPORARILY_LOCKED = 'Too many sign-in attempts. Wait a little before trying again. If you need a new code, choose Send a new code.' /* * THE PASSWORD WAS NOT ACCEPTED, AND THIS SENTENCE DELIBERATELY DOES NOT SAY WHICH HALF WAS WRONG. * * The card asks for a wrong password to read distinctly from an unknown address. Cognito will not let * us: it answers both with the same `NotAuthorizedException`, on purpose, so that a sign-in form cannot * be used to discover which addresses have accounts. Inventing the distinction would mean guessing, and * guessing wrong tells somebody their password is bad when they mistyped their email. * -> So it names what the reader can act on instead - check it, or take the door that always works. * * NAMING THE CODE PATH IS THE LOAD-BEARING HALF. The password field is optional and every account can * still sign in without one, so somebody who never had a password must not read this as a dead end. */ const WRONG_PASSWORD = 'That email and password did not match. Check them, or leave the password blank and we’ll email you a code instead.' /* * THIS ACCOUNT CANNOT USE A PASSWORD AT ALL, WHICH IS NOT THE SAME AS GETTING ONE WRONG. * * Cognito answers `SELECT_CHALLENGE` with only `EMAIL_OTP` available when the preferred PASSWORD * challenge is not open to that user - an account created by staff that has never had one set. Measured * on a real fixture 2026-08-31. * * WITHOUT THIS IT LANDS ON THE "no challenge and no throw" ARM AND READS AS A DEAD ACCOUNT: "This * email is no longer active... sign in with your other email address." That tells somebody their account * is gone when the only thing wrong is that they typed in an optional field - and it is exactly the * mislabel class this file already documents twice. * * IT DOES NOT SEND A CODE BY ITSELF. Changing method on failure is what the second control * forbids; clearing the field is one action and it is the reader's to take. */ export const PASSWORD_NOT_SET = 'This account doesn’t use a password yet. Leave the password blank and we’ll email you a one-time code instead.' // Pre-existing, deliberate copy - a merged/disabled account. Kept word for word so this refactor // does not quietly rewrite a message that was written on purpose (duplicate-resolution flow). const ACCOUNT_INACTIVE = 'This email is no longer active. If you recently linked your accounts, please sign in with your other email address. Need help? Contact support.' /** * - THE ONE FAILURE WHERE "try again" IS GUARANTEED NOT TO WORK. * * A staff member signing in on a portal belonging to a practice they do not work at is refused * precisely by the server - *"Practitioner is not authorized to access organization "* - and was * shown the generic shrug. They are at the wrong door, and no number of attempts moves them. * * WHY IT DOES NOT NAME EITHER PARTY, WHICH IS THE WHOLE DIFFICULTY * The caller is NOT SIGNED IN. *"You do not work at Badger Family Health Clinic"* would confirm that * the practice exists AND that this address belongs to somebody who works at one. *"You are signing in * at the wrong practice"* is barely better - it implies an account exists somewhere else. * * *"This account is not set up at this practice"* implies neither. It names the SITUATION, states the * remedy, and discloses nothing a stranger could not already read off the page they are standing on. * The same rule signup already follows: a sign-in error that confirms an account is a disclosure. */ const WRONG_PRACTICE = 'This sign-in page belongs to one practice, and this account is not set up at it. ' + 'Check the address your practice gave you, or ask them to add you.' // The trigger refuses an org-less sign-in with one generic sentence for everyone who is not an invited // collaborator, so a prober learns nothing. Retrying cannot change it; this says where to go instead. const NOT_HERE = 'This account can’t sign in here. If a practice invited you, use the sign-in address they gave you.' const GENERIC = 'We could not sign you in. Try again, or contact support if this continues.' /** The complete set of sentences a patient can ever see from these flows. */ export const AUTH_ERROR_SENTENCES: readonly string[] = [ WRONG_PRACTICE, NOT_HERE, EXPIRED_SESSION, INVALID_CODE, WRONG_PASSWORD, PASSWORD_NOT_SET, TOO_MANY_ATTEMPTS, TEMPORARILY_LOCKED, ACCOUNT_INACTIVE, GENERIC, ] const textOf = (error: unknown): string => { if (typeof error === 'string') return error if (error && typeof error === 'object' && 'message' in error) { const m = (error as { message?: unknown }).message return typeof m === 'string' ? m : '' } return '' } export function describeAuthError(error: unknown): AuthErrorCopy { // The credential record speaks its own sentences, written here rather than by the provider. if (error instanceof Error && (error.name === 'CredentialStorageUnavailable' || error.name === 'CredentialSuperseded' || error.name === 'SignOutUnconfirmed')) { return { kind: 'unknown', message: error.message, canResendCode: false } } const raw = textOf(error).toLowerCase() // Cognito says "Invalid session for the user, session is expired." for a lapsed challenge and // "Invalid code or auth parameter" for a bad code - both start with "Invalid". The matchers are // therefore deliberately specific ('invalid session' / 'invalid code') rather than a bare // 'invalid', which would collapse the two and make every expiry read as a typo - precisely the // defect this card exists to fix. Being specific is also what makes these branches // order-independent; I reordered them and all tests still passed, so nothing here relies on // sequence. Keep them specific and that stays true. //. FIRST, and specific enough that order does not actually matter - none of the // branches below can match these strings. It is first because it is the only outcome here where // retrying is not merely unhelpful but impossible. // // MATCHED ON THE ORGANIZATION PAIRING, NOT ON "not authorized". A bare 'not authorized' would // swallow every future authorization failure into a sentence about the wrong practice. // // AND THE SERVER HAD TO CHANGE WITH THIS. `checkPractitionerRoleExists` used to catch its own // HealthLake failure and return `false`, so an OUTAGE produced this exact "is not authorized" // message - which would have told a legitimate staff member, in this new and much more confident // wording, that they do not work where they work. It now reports a verification failure instead, // which lands on GENERIC below, where "try again" is the correct advice. if ( // The server says "not authorized to access this organization"; this matched only // the older "is not authorized to access organization", so the one refusal that can never be // retried was falling through to GENERIC and telling people to try again. Still matched on the // organization pairing, not on a bare "not authorized". /not authorized to access (this )?organization/.test(raw) || /does not have (valid )?practitioner ids/.test(raw) ) { return { kind: 'wrong-practice', message: WRONG_PRACTICE, canResendCode: false } } if (raw.includes('no organizationid provided')) { return { kind: 'not-here', message: NOT_HERE, canResendCode: false } } if (raw.includes('session is expired') || raw.includes('invalid session')) { return { kind: 'expired-session', message: EXPIRED_SESSION, canResendCode: true } } // Sign-up confirmation words it "Invalid verification code provided", which "invalid code" misses, so a // mistyped sign-up code fell to the generic sentence. The exception name catches any other wording. const name = error && typeof error === 'object' && 'name' in error ? String((error as { name?: unknown }).name) : '' if ( raw.includes('invalid code') || raw.includes('invalid verification code') || raw.includes('code mismatch') || raw.includes('codemismatch') || name === 'CodeMismatchException' ) { return { kind: 'invalid-code', message: INVALID_CODE, canResendCode: false } } /* * A REJECTED PASSWORD. Matched on Cognito's whole phrase rather than on `NotAuthorizedException`, * because that exception is also what a revoked or superseded REFRESH token produces - and answering * a dead session with "check your password" would send somebody hunting a typo in a field they never * filled in. * * `canResendCode: false` is deliberate and is control 2 of the card. The remedy for a bad password is * to retype it or to clear the field, NOT to have a code sent - a screen that quietly changes method * on failure is unreadable, and it would spend a code per typo, which is the exact cost the password * field exists to remove. */ // Matched on Cognito's own lock phrases, never on `NotAuthorizedException`, which also answers a wrong // password and a revoked session. if (raw.includes('too many invalid credentials attempts') || raw.includes('user temporarily locked')) { return { kind: 'temporarily-locked', message: TEMPORARILY_LOCKED, canResendCode: false } } if (raw.includes('incorrect username or password')) { return { kind: 'wrong-password', message: WRONG_PASSWORD, canResendCode: false } } // THE PHRASES THAT MEAN RATE-LIMITED, not the bare words "too many". Same defect as the one below: // "Too many redirects" and "Too many open connections" are TRANSPORT failures, and they were being // answered with "Please wait a few minutes before trying again" - advice for a limit that is not // there, about an attempt the user never made. // // Milder than the disabled case, because waiting and retrying is at least harmless. It is still the // product explaining a fault as something the person did. if ( raw.includes('attempt limit exceeded') || raw.includes('too many attempts') || raw.includes('too many failed attempts') || raw.includes('too many requests') ) { return { kind: 'too-many-attempts', message: TOO_MANY_ATTEMPTS, canResendCode: false } } // MATCHED ON COGNITO'S WHOLE PHRASE, NOT THE BARE WORD. `includes('disabled')` also catches a // TRANSPORT failure: Chrome's `net::ERR_NETWORK_ACCESS_DISABLED` contains it, and that mapped a // dropped connection onto "This email is no longer active... sign in with your other email address." // // That is the worst mislabel available here. The wrong-practice sentence at least sends somebody to // their practice; this one tells a person their account is gone and points them at an address they // may not have, for a failure that would clear itself on the next attempt. It is control ③ of this // card - a transient failure must not be reported as a statement about the person - arriving through // a different branch than the one that control was written for. // // Anything else falls to GENERIC, which is this file's whole design: the default is closed. if (raw.includes('user is disabled')) { return { kind: 'account-inactive', message: ACCOUNT_INACTIVE, canResendCode: false } } if (raw.includes('user does not exist') || raw.includes('usernotfound')) { return { kind: 'no-account', message: GENERIC, canResendCode: false } } if (/does not have (valid )?patient ids/.test(raw)) { return { kind: 'no-account', message: GENERIC, canResendCode: false } } return { kind: 'unknown', message: GENERIC, canResendCode: false } }