import { CognitoIdentityProviderClient, InitiateAuthCommand, RespondToAuthChallengeCommand, type AuthenticationResultType, } from "@aws-sdk/client-cognito-identity-provider"; import { environment } from "../environments/environment"; import { dpopKeyStore } from "../utils/dpopKeyStore"; import { withDpopThumbprint } from "../utils/dpopSession"; import { akashicFetch } from "../config/clientManifest"; /** * THE OWNER ACCEPT PAGE'S CALLS. The owner email's one button opens `#/owner-accept?t=`. The token is * the only key: these calls are public, and it travels in the request body, never in a URL the server logs. * Opening the page only reads; the Accept press claims the practice and returns a one-time secret, which * answers the staff client's one owner challenge and signs the owner in. No code is emailed or typed. */ // The three calls live in the org-authorization stack, which has room under the resource cap. const API = environment.orgAuthorizationApi; const client = new CognitoIdentityProviderClient({ region: environment.region }); // "accepting": this link's acceptance was admitted and still owes work (a preview, or a 202 press); the same link finishes it. export type OwnerAcceptState = "open" | "accepting" | "expired" | "spent" | "superseded" | "accepted" | "invalid"; export interface OwnerAcceptPreview { state: OwnerAcceptState; /** Set for a link support sent after a stranger's claim; absent for a contractor's owner link. */ purpose?: "stranger-claim"; organizationId?: string; organizationName?: string; contractorName?: string; ownerEmail?: string; attestation?: { version: string; text: string }; needsName?: boolean; } export type OwnerAcceptClaim = | { kind: "accepted"; organizationId: string; email: string; secret?: string } | { kind: "closed"; state: OwnerAcceptState } | { kind: "refused"; code: string; message?: string } | { kind: "failed"; message: string; canResend: boolean }; const post = async (path: string, body: unknown): Promise<{ status: number; body: Record }> => { const response = await akashicFetch(`${API}/owner-accept/${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); let parsed: Record = {}; try { parsed = (await response.json()) ?? {}; } catch { parsed = {}; } return { status: response.status, body: parsed }; }; const STATES: OwnerAcceptState[] = ["open", "accepting", "expired", "spent", "superseded", "accepted", "invalid"]; const stateOf = (value: unknown): OwnerAcceptState | undefined => STATES.includes(value as OwnerAcceptState) ? (value as OwnerAcceptState) : undefined; /** What the link opens. Claims nothing. Undefined when the server could not be asked. */ export async function previewOwnerAccept(token: string): Promise { try { const { status, body } = await post("preview", { token }); const state = stateOf(body.state); if (!state) return status === 404 ? { state: "invalid" } : undefined; return { ...(body as Omit), state }; } catch { return undefined; } } export async function claimOwnerAccept( token: string, attestationVersion: string, name?: { firstName: string; lastName: string }, ): Promise { try { const { status, body } = await post("claim", { token, attestation: { statementVersion: attestationVersion }, ...(name ?? {}), }); if (status === 200 && body.state === "accepted") { const signIn = body.signIn as { secret?: unknown } | null | undefined; return { kind: "accepted", organizationId: String(body.organizationId ?? ""), email: String(body.email ?? ""), secret: typeof signIn?.secret === "string" ? signIn.secret : undefined, }; } const state = stateOf(body.state); if (state) return { kind: "closed", state }; if (status === 400 && typeof body.code === "string") { return { kind: "refused", code: body.code, message: typeof body.message === "string" ? body.message : undefined }; } return { kind: "failed", message: typeof body.message === "string" ? body.message : "We could not accept the practice just now. Please try again.", canResend: body.canResend === true, }; } catch { return { kind: "failed", message: "We could not reach Akashic. Check your connection and try again.", canResend: false }; } } /** Mails a fresh link to the named address. True once it is on its way. */ export async function resendOwnerAccept(token: string): Promise { try { const { status } = await post("resend", { token }); return status === 202; } catch { return false; } } /** * The sign-in the press earned: CUSTOM_AUTH on the staff client, one challenge, answered with the secret. * Carries the browser's DPoP thumbprint like every staff sign-in, so the tokens are bound to this browser. */ export async function signInWithOwnerSecret( email: string, secret: string, organizationId: string, ): Promise { const metadata = () => dpopKeyStore.establishForSignIn().then((jkt) => withDpopThumbprint({ organizationId }, jkt)); const started = await client.send(new InitiateAuthCommand({ AuthFlow: "CUSTOM_AUTH", ClientId: environment.cognitoPractitionerClient, AuthParameters: { USERNAME: email }, ClientMetadata: await metadata(), })); if (started.ChallengeName !== "CUSTOM_CHALLENGE" || !started.Session) return undefined; const answered = await client.send(new RespondToAuthChallengeCommand({ ChallengeName: "CUSTOM_CHALLENGE", ClientId: environment.cognitoPractitionerClient, ChallengeResponses: { USERNAME: email, ANSWER: secret }, Session: started.Session, ClientMetadata: await metadata(), })); return answered.AuthenticationResult; }