fix(auth): offer the provider re-proof to a browser only

The MFA status read listed oidc among the proof methods for a Bearer
caller too, although the provider round trip stamps a browser session
and nothing else, and the API contract says a token is never offered
it. The status read now leaves it out on the Bearer path.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 11:03:09 +01:00
1 parent fc117d7cd9
commit 6fb50b01b8
2 files changed
+37 -3

No files matched your search

+8 -3
View File
@@ -27,7 +27,7 @@ import { recentProofMethods } from "@/lib/auth/second-factor";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user, authMethod } = await requireAuth();
const [recoveryCodesRemaining, webauthn, proofMethods] = await Promise.all([
countRemainingRecoveryCodes(user.id),
@@ -50,7 +50,12 @@ export const GET = apiHandler(async () => {
passkeyNudgeDismissed: Boolean(user.passkeyUpgradeNudgeDismissed),
// The ways this account can confirm it is you, the list the recent-proof
// refusal names. The passkey card offers exactly these before adding one,
// so an account without a password is not handed a password field.
proofMethods,
// so an account without a password is not handed a password field. The
// provider round trip stamps a browser session and nothing else, so a
// token is never offered it.
proofMethods:
authMethod === "cookie"
? proofMethods
: proofMethods.filter((method) => method !== "oidc"),
});
});
+29
View File
@@ -59,6 +59,7 @@ vi.mock("@/lib/auth/oidc", async (importOriginal) => {
});
const oidc = await import("@/lib/auth/oidc");
const { hashToken } = await import("@/lib/auth/hmac");
const USER_ID = "user-oidc-reproof";
const SUB = "idp-sub-reproof";
@@ -313,4 +314,32 @@ describe("confirming at the identity provider", () => {
expect(back.searchParams.get("reproof")).toBe("failed");
expect(oidc.discoverOidcMetadata).not.toHaveBeenCalled();
});
it("is listed on the status read for the browser, never for a token", async () => {
const { GET } = await import("@/app/api/auth/me/mfa/route");
const status = async () => {
const res = await (GET as unknown as RouteFn)(
new NextRequest("http://localhost:3000/api/auth/me/mfa"),
);
expect(res.status).toBe(200);
return ((await res.json()) as { data: { proofMethods: string[] } }).data
.proofMethods;
};
await startSession(HOUR);
expect(await status()).toEqual(["oidc"]);
cookieJar.clear();
const raw = `hlk_mfastatus${"0".repeat(64 - "mfastatus".length)}`;
await getPrismaClient().apiToken.create({
data: {
userId: USER_ID,
name: "device",
tokenHash: hashToken(raw),
permissions: ["*"],
},
});
headerJar.set("authorization", `Bearer ${raw}`);
expect(await status()).toEqual([]);
});
});