mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
A Guardian signed in through single sign-on with a second factor enrolled could not hand a managed profile over once five minutes had passed since sign-in: the handover answered 401 auth.stepup.required, and the panel printed "Confirm your second factor, then try again" with no way to do so. The same held for every action behind requireFreshMfa: creating, editing and ending a managed profile, adding and removing a Guardian, disabling the second factor, rotating recovery codes, removing a security key or a passkey, changing the password, the data wipe, account deletion and the key rotation. The recent-proof routes had the dialog; these had text. - requireFreshMfa names the proofs that clear a stale stamp in meta.methods (the possession proofs the account holds, never a password, which stamps reproofAt only). - The re-proof flow treats that refusal like auth.reproof.required: open the dialog, post the proof to /api/auth/reproof, send the request once more. The flow moves into a React-free module so it can be driven in tests; the hook binds it to the dialog. - The managed-profile mutation hooks take a required proof guard, and every caller passes recentProof.run. The other surfaces wrap their requests in recentProof.run; disabling the second factor asks before its confirmation opens, so the code typed there is not spent twice. - An account with nothing to re-prove in place (single sign-on only, no factor) is told to sign in again when adding a factor, instead of being offered a password it does not have. - step-up-web-caller-guard freezes the gated route methods and fails when a web request to one of them runs outside the flow. An account whose only second factor is the identity provider's is still asked for an app factor on the managed-profile acts: the app cannot see the provider's factor. Enrolling right after a sign-in needs no further proof, and the confirmation stamps the session, so the act follows. Refs #959
145 lines
4.7 KiB
TypeScript
145 lines
4.7 KiB
TypeScript
import { ApiError } from "@/lib/api/api-fetch";
|
|
import {
|
|
isReproofRequired,
|
|
type ExistingFactorProof,
|
|
type ReauthMethod,
|
|
} from "./existing-factor-reauth-dialog";
|
|
|
|
/**
|
|
* The re-proof flow without React: catch the refusal, ask for a proof, post
|
|
* it, run the action once more. `useRecentProof` binds it to component state
|
|
* and the dialog; the unit suite drives it directly, because this repository's
|
|
* component tests run without a browser DOM and the flow is what matters.
|
|
*/
|
|
|
|
const KNOWN_METHODS: readonly ReauthMethod[] = [
|
|
"password",
|
|
"totp",
|
|
"webauthn",
|
|
"passkey",
|
|
];
|
|
|
|
/** The proofs a refusal says this account can give. */
|
|
function methodsFrom(err: ApiError): ReauthMethod[] {
|
|
const raw = err.meta?.methods;
|
|
if (!Array.isArray(raw)) return [];
|
|
return raw.filter((m): m is ReauthMethod =>
|
|
KNOWN_METHODS.includes(m as ReauthMethod),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A refusal the re-proof dialog can clear.
|
|
*
|
|
* Two gates answer this way. The recent-proof gate (`auth.reproof.required`)
|
|
* in front of exports, share links, tokens, invitations and the admin data
|
|
* actions; and the fresh-factor gate (`auth.stepup.required`, from
|
|
* `requireFreshMfa`) in front of the managed-profile acts, the second-factor
|
|
* changes, passkey removal, the password change, account deletion, the data
|
|
* wipe and the key rotation. The second one names the proofs it would take in
|
|
* `meta.methods` on a browser session, and a `POST /api/auth/reproof` with one
|
|
* of them stamps the session the way signing in with the factor does, so the
|
|
* same dialog clears both. A fresh-factor refusal without `methods` (an
|
|
* account with no factor at all says `auth.stepup.mfa_not_enrolled` instead)
|
|
* is not something a dialog can answer, and stays with the caller's own
|
|
* message.
|
|
*/
|
|
export function isProofRefusal(err: unknown): boolean {
|
|
if (isReproofRequired(err)) return true;
|
|
return (
|
|
err instanceof ApiError &&
|
|
err.status === 401 &&
|
|
err.meta?.errorCode === "auth.stepup.required" &&
|
|
methodsFrom(err).length > 0
|
|
);
|
|
}
|
|
|
|
/** The person closed the re-proof dialog. Nothing ran; show nothing. */
|
|
export class ReproofCancelledError extends Error {
|
|
constructor() {
|
|
super("Re-proof cancelled");
|
|
this.name = "ReproofCancelledError";
|
|
}
|
|
}
|
|
|
|
export interface RecentProofFlowView {
|
|
/** Open the dialog offering these proofs, or close it with `null`. */
|
|
setMethods: (methods: ReauthMethod[] | null) => void;
|
|
setPending: (pending: boolean) => void;
|
|
setError: (error: string | null) => void;
|
|
}
|
|
|
|
export interface RecentProofFlow {
|
|
run: <T>(action: () => Promise<T>) => Promise<T>;
|
|
/** The dialog collected a proof: post it and release the waiting action. */
|
|
prove: (proof: ExistingFactorProof) => Promise<void>;
|
|
/** The dialog was closed without a proof. */
|
|
cancel: () => void;
|
|
}
|
|
|
|
export function createRecentProofFlow(deps: {
|
|
view: RecentProofFlowView;
|
|
/** Posts the proof to `/api/auth/reproof`. */
|
|
submit: (proof: ExistingFactorProof) => Promise<unknown>;
|
|
/** Read at the moment they are needed, so a locale switch is honoured. */
|
|
messages: () => { signInAgain: string; failed: string };
|
|
}): RecentProofFlow {
|
|
let waiter: { resolve: () => void; reject: () => void } | null = null;
|
|
|
|
const run = async <T>(action: () => Promise<T>): Promise<T> => {
|
|
try {
|
|
return await action();
|
|
} catch (err) {
|
|
if (!isProofRefusal(err)) throw err;
|
|
const offered = methodsFrom(err as ApiError);
|
|
// Nothing to re-prove in place: an account that signs in through an
|
|
// identity provider only. A fresh sign-in is the proof it can give.
|
|
if (offered.length === 0) {
|
|
throw new ApiError(deps.messages().signInAgain, 401, {
|
|
errorCode: "auth.reproof.sign_in_again",
|
|
});
|
|
}
|
|
await new Promise<void>((resolve, reject) => {
|
|
waiter = {
|
|
resolve,
|
|
reject: () => reject(new ReproofCancelledError()),
|
|
};
|
|
deps.view.setError(null);
|
|
deps.view.setMethods(offered);
|
|
});
|
|
// Once. A second refusal is the answer, not a reason to ask again.
|
|
return action();
|
|
}
|
|
};
|
|
|
|
const prove = async (proof: ExistingFactorProof): Promise<void> => {
|
|
deps.view.setPending(true);
|
|
deps.view.setError(null);
|
|
try {
|
|
await deps.submit(proof);
|
|
deps.view.setMethods(null);
|
|
const w = waiter;
|
|
waiter = null;
|
|
w?.resolve();
|
|
} catch (err) {
|
|
deps.view.setError(
|
|
err instanceof ApiError && err.status === 429
|
|
? err.message
|
|
: deps.messages().failed,
|
|
);
|
|
} finally {
|
|
deps.view.setPending(false);
|
|
}
|
|
};
|
|
|
|
const cancel = () => {
|
|
deps.view.setMethods(null);
|
|
deps.view.setError(null);
|
|
const w = waiter;
|
|
waiter = null;
|
|
w?.reject();
|
|
};
|
|
|
|
return { run, prove, cancel };
|
|
}
|