import { CognitoIdentityProviderClient, InitiateAuthCommand, RespondToAuthChallengeCommand, } from '@aws-sdk/client-cognito-identity-provider'; import { environment } from '../environments/environment'; import { dpopKeyStore } from '../utils/dpopKeyStore'; import { withDpopThumbprint } from '../utils/dpopSession'; import { dpopHeaderFor } from '../utils/dpopRequestHeader'; import { revokeRefreshToken } from '../utils/revokeRefreshToken'; import { HttpError } from './HttpService'; import type { AcceptOutcome, InvitationPreview } from './ClinicianInvitationService'; import { akashicFetch } from '../config/clientManifest'; /** * Answering a clinician invitation before joining. The invitee signs in naming no practice, since naming it * would record them as its patient: on the patient client, or the practitioner client for an account the * patient client will not admit org-less. The session is isolated to this screen; the server keeps it from the practice. */ const client = new CognitoIdentityProviderClient({ region: environment.region }); const API_URL = environment.orgAuthorizationApi; export interface CodeChallenge { username: string; session: string; clientId: string; } export interface InvitationSession { accessToken: string; refreshToken?: string; clientId: string; } // The patient client refuses an org-less account with no patient record; the practitioner client may admit it. const refusedWithoutPractice = (error: unknown): boolean => (error as { name?: string })?.name === 'UserLambdaValidationException' && /No organizationId provided/.test(String((error as { message?: string })?.message)); /** Cognito answers an unknown address with a challenge too, so this never says whether an account exists. */ export const sendSignInCode = async (email: string): Promise => { const username = email.trim().toLowerCase(); const jkt = await dpopKeyStore.establishForSignIn(); const ask = (clientId: string) => client.send(new InitiateAuthCommand({ AuthFlow: 'USER_AUTH', ClientId: clientId, AuthParameters: { USERNAME: username, PREFERRED_CHALLENGE: 'EMAIL_OTP' }, ClientMetadata: withDpopThumbprint({}, jkt), })); let clientId: string = environment.cognitoPatientClient; let response; try { response = await ask(clientId); } catch (error) { if (!refusedWithoutPractice(error)) throw error; clientId = environment.cognitoPractitionerClient; response = await ask(clientId); } if (!response.Session) throw new Error('We could not send a code. Please try again.'); return { username, session: response.Session, clientId }; }; export const signInWithCode = async (challenge: CodeChallenge, code: string): Promise => { const jkt = await dpopKeyStore.establishForSignIn(); const response = await client.send(new RespondToAuthChallengeCommand({ ChallengeName: 'EMAIL_OTP', ClientId: challenge.clientId, ChallengeResponses: { USERNAME: challenge.username, EMAIL_OTP_CODE: code.trim() }, Session: challenge.session, ClientMetadata: withDpopThumbprint({}, jkt), })); const tokens = response.AuthenticationResult; if (!tokens?.AccessToken) throw new Error('Invalid code or auth parameter'); return { accessToken: tokens.AccessToken, refreshToken: tokens.RefreshToken, clientId: challenge.clientId }; }; /** `invalid` and `expired` are the server's own refusals of the link, said before anything is asked. */ export type InvitedAccount = 'known' | 'new' | 'unknown' | 'invalid' | 'expired'; /** * Whether the invitation's own address has an account now. No sign-in: the token is the key, and the * answer never names the address. Anything but a clear answer is `unknown`, and the page falls back to the link. */ export const askAboutInvitedAccount = async (token: string, organizationId: string): Promise => { try { const response = await akashicFetch(`${API_URL}/clinician-invitations/landing`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, organizationId }), }); if (!response.ok) { // Only our handler's reasons close the link; a gateway's bare 404 still falls back to it. const reason = ((await response.json().catch(() => ({}))) as { reason?: unknown })?.reason; if (response.status === 404 && reason === 'invalid') return 'invalid'; if (response.status === 410 && reason === 'expired') return 'expired'; return 'unknown'; } const account = ((await response.json()) as { account?: unknown })?.account; return account === 'known' || account === 'new' ? account : 'unknown'; } catch { return 'unknown'; } }; export const endInvitationSession = (session: InvitationSession): Promise => revokeRefreshToken(client, session.clientId, session.refreshToken ?? null); type Answer = { ok: boolean; status: number; body: Record; awsErrorType?: string }; const postAs = async (session: InvitationSession, path: string, body: Record, signal?: AbortSignal): Promise => { const url = `${API_URL}${path}`; const response = await akashicFetch(url, { method: 'POST', signal, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}`, ...(await dpopHeaderFor('POST', url)), }, body: JSON.stringify(body), }); const parsed: unknown = await response.json().catch(() => undefined); return { ok: response.ok, status: response.status, body: parsed && typeof parsed === 'object' ? parsed as Record : {}, awsErrorType: response.headers.get('x-amzn-errortype') ?? undefined, }; }; const text = (value: unknown): string | undefined => (typeof value === 'string' && value ? value : undefined); const refusal = (answer: Answer, fallback: string) => new HttpError(answer.status, text(answer.body.message) ?? fallback, answer.body, answer.awsErrorType); /** The invitation routes, called with the invitee's own session and naming the practice this page belongs to. */ export const invitationCallsFor = (session: InvitationSession, organizationId: string) => ({ previewInvitation: async (token: string): Promise => { const answer = await postAs(session, '/clinician-invitations/preview', { token, organizationId }); if (!answer.ok) throw refusal(answer, 'This invitation link is not valid.'); return answer.body as unknown as InvitationPreview; }, acceptInvitation: async (token: string, code?: string, signal?: AbortSignal): Promise => { const answer = await postAs( session, '/clinician-invitations/accept', code ? { token, code, organizationId } : { token, organizationId }, signal, ); if (answer.ok) { const secret = text((answer.body.signIn as { secret?: unknown } | null | undefined)?.secret); return { kind: 'accepted', organizationId: text(answer.body.organizationId) ?? organizationId, practitionerId: text(answer.body.practitionerId), signIn: secret ? { secret } : null, }; } // A gateway refusal carries no reason of ours, so it is never read as one. const reason = answer.awsErrorType ? undefined : text(answer.body.reason); if (reason === 'email-verification-required') { return { kind: 'code-required', invitedEmail: text(answer.body.invitedEmail) ?? '' }; } if (reason) { const detail = text(answer.body.detail); return { kind: 'refused', reason, message: text(answer.body.message) ?? 'This invitation could not be accepted.', ...(detail ? { detail } : {}) }; } throw refusal(answer, 'That could not be completed. Please try again.'); }, });