diff --git a/src/app/api/auth/me/mfa/route.ts b/src/app/api/auth/me/mfa/route.ts index 703fbf091..c4663f7c0 100644 --- a/src/app/api/auth/me/mfa/route.ts +++ b/src/app/api/auth/me/mfa/route.ts @@ -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"), }); }); diff --git a/tests/integration/oidc-reproof.test.ts b/tests/integration/oidc-reproof.test.ts index 7d8beb9e7..cc5f728dc 100644 --- a/tests/integration/oidc-reproof.test.ts +++ b/tests/integration/oidc-reproof.test.ts @@ -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([]); + }); });