feat: move totp and passkeys into plugins (C1)

This commit is contained in:
LukeGus committed 2026-09-24 16:57:24 -05:00
1 parent f4f26a9b8b
commit 5c18435105
235 files changed
+22545 -5610

No files matched your search

-121
View File
@@ -1,121 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const authApiMock = vi.hoisted(() => ({
get: vi.fn(async () => ({ data: {} })),
post: vi.fn(async () => ({ data: {} })),
delete: vi.fn(async () => ({ data: { success: true } })),
}));
const browserMock = vi.hoisted(() => ({
startAuthentication: vi.fn(async () => ({ id: "cred-1" })),
startRegistration: vi.fn(async () => ({ id: "cred-1" })),
browserSupportsWebAuthn: vi.fn(() => true),
}));
vi.mock("@/main-axios", () => ({
authApi: authApiMock,
handleApiError: (error: unknown) => error,
}));
vi.mock("@simplewebauthn/browser", () => browserMock);
import { isPasskeySupported, loginWithPasskey } from "../../api/webauthn-api";
beforeEach(() => {
authApiMock.get.mockClear();
authApiMock.post.mockClear();
authApiMock.delete.mockClear();
browserMock.startAuthentication.mockClear();
browserMock.browserSupportsWebAuthn.mockClear();
});
describe("isPasskeySupported", () => {
it("reports what the browser helper returns", () => {
browserMock.browserSupportsWebAuthn.mockReturnValueOnce(false);
expect(isPasskeySupported()).toBe(false);
browserMock.browserSupportsWebAuthn.mockReturnValueOnce(true);
expect(isPasskeySupported()).toBe(true);
});
});
describe("loginWithPasskey", () => {
it("passes the challenge from options through to verify", async () => {
authApiMock.post
.mockResolvedValueOnce({
data: { options: { challenge: "abc" }, challengeId: "chal-1" },
})
.mockResolvedValueOnce({ data: { success: true, username: "luke" } });
const result = await loginWithPasskey("luke", true);
expect(authApiMock.post).toHaveBeenNthCalledWith(
1,
"/users/webauthn/authenticate/options",
{ username: "luke" },
);
expect(browserMock.startAuthentication).toHaveBeenCalledWith({
optionsJSON: { challenge: "abc" },
});
expect(authApiMock.post).toHaveBeenNthCalledWith(
2,
"/users/webauthn/authenticate/verify",
{
challengeId: "chal-1",
response: { id: "cred-1" },
rememberMe: true,
},
);
expect(result).toEqual({ success: true, username: "luke" });
});
it("omits the username so discoverable passkeys work", async () => {
authApiMock.post
.mockResolvedValueOnce({
data: { options: { challenge: "abc" }, challengeId: "chal-2" },
})
.mockResolvedValueOnce({ data: { success: true } });
await loginWithPasskey();
expect(authApiMock.post).toHaveBeenNthCalledWith(
1,
"/users/webauthn/authenticate/options",
{},
);
expect(authApiMock.post).toHaveBeenNthCalledWith(
2,
"/users/webauthn/authenticate/verify",
expect.objectContaining({ rememberMe: false }),
);
});
it("returns the totp challenge instead of a session", async () => {
authApiMock.post
.mockResolvedValueOnce({
data: { options: { challenge: "abc" }, challengeId: "chal-3" },
})
.mockResolvedValueOnce({
data: { success: true, requires_totp: true, temp_token: "tmp" },
});
const result = await loginWithPasskey("luke");
expect(result.requires_totp).toBe(true);
expect(result.temp_token).toBe("tmp");
});
it("surfaces a cancelled prompt to the caller", async () => {
authApiMock.post.mockResolvedValueOnce({
data: { options: { challenge: "abc" }, challengeId: "chal-4" },
});
const cancelled = Object.assign(new Error("cancelled"), {
name: "NotAllowedError",
});
browserMock.startAuthentication.mockRejectedValueOnce(cancelled);
await expect(loginWithPasskey("luke")).rejects.toMatchObject({
name: "NotAllowedError",
});
expect(authApiMock.post).toHaveBeenCalledTimes(1);
});
});
-1
View File
@@ -22,7 +22,6 @@ const mainAxios = vi.hoisted(() => ({
verifyPasswordResetCode: vi.fn(),
completePasswordReset: vi.fn(),
getOIDCAuthorizeUrl: vi.fn(),
verifyTOTPLogin: vi.fn(),
isElectron: vi.fn(),
getCurrentToken: vi.fn(),
getOidcSilentLoginDefault: vi.fn(),
@@ -32,7 +32,6 @@ vi.mock("@/main-axios", () => ({
verifyPasswordResetCode: vi.fn(),
completePasswordReset: vi.fn(),
getOIDCAuthorizeUrl: vi.fn(),
verifyTOTPLogin: vi.fn(),
getCurrentToken: vi.fn().mockReturnValue(null),
requestTrustedProxyLogin: vi.fn(),
}));
@@ -42,11 +41,6 @@ vi.mock("@/api/sso-provider-api", () => ({
ldapLogin: vi.fn(),
}));
vi.mock("@/api/webauthn-api", () => ({
isPasskeySupported: () => false,
loginWithPasskey: vi.fn(),
}));
import { Auth } from "@/auth/Auth";
function setEmbeddedServerStatus(failure: unknown) {
+63 -17
View File
@@ -246,23 +246,69 @@ describe("login methods on the login screen", () => {
});
it("picks up the second factor step after a redirect login", async () => {
window.history.replaceState(
{},
"",
"/?second_factor=1&second_factors=totp",
);
render(<Auth onLogin={vi.fn()} />);
const input = await screen.findByPlaceholderText("000000");
// Backup codes are letters and digits, and are accepted now.
fireEvent.change(input, { target: { value: "abcd-2345" } });
expect((input as HTMLInputElement).value).toBe("ABCD2345");
const dispose = registerSecondFactor({
id: "pin",
pluginId: "corp",
titleKey: "corp.pin",
component: ({ verify }: SecondFactorUIProps) => (
<button onClick={() => void verify({ pin: "1234" })}>send pin</button>
),
});
window.history.replaceState({}, "", "/?second_factor=1&second_factors=pin");
authMethodsApi.verifySecondFactor.mockResolvedValue({ success: true });
fireEvent.submit(input.closest("form")!);
await waitFor(() =>
expect(authMethodsApi.verifySecondFactor).toHaveBeenCalledWith("totp", {
totp_code: "ABCD2345",
rememberMe: false,
}),
);
try {
render(<Auth onLogin={vi.fn()} />);
fireEvent.click(await screen.findByText("send pin"));
await waitFor(() =>
expect(authMethodsApi.verifySecondFactor).toHaveBeenCalledWith("pin", {
pin: "1234",
rememberMe: false,
}),
);
} finally {
dispose();
}
});
it("offers every registered factor when the server lists none", async () => {
const dispose = registerSecondFactor({
id: "pin",
pluginId: "corp",
titleKey: "corp.pin",
component: () => <span>pin challenge</span>,
});
window.history.replaceState({}, "", "/?second_factor=1");
try {
render(<Auth onLogin={vi.fn()} />);
expect(await screen.findByText("pin challenge")).toBeTruthy();
} finally {
dispose();
}
});
it("draws an inline method under the password form, not in the external list", async () => {
authMethodsApi.getLoginMethods.mockResolvedValue([
{
id: "quick-key",
pluginId: "keys",
kind: "form",
labelKey: "keys.title",
instances: [],
},
]);
const dispose = registerLoginMethod({
id: "quick-key",
pluginId: "keys",
titleKey: "keys.title",
placement: "inline",
component: () => <button>quick key</button>,
});
try {
render(<Auth onLogin={vi.fn()} />);
expect(await screen.findByText("quick key")).toBeTruthy();
expect(screen.queryByText("auth.external")).toBeNull();
} finally {
dispose();
}
});
});
+1 -2
View File
@@ -26,8 +26,7 @@ describe("ElectronLoginForm", () => {
it.each([
"auth_component",
"totp_auth_component",
"passkey_auth_component",
"second_factor_auth_component",
"method_auth_component",
])("accepts a login hand-off from %s", async (source) => {
const onAuthSuccess = vi.fn();
@@ -22,9 +22,9 @@ describe("AdminSecondFactorsSection", () => {
it("shows a factor whose plugin is off and resets after confirming", async () => {
api.getUserSecondFactors.mockResolvedValue([
{
pluginId: "core",
pluginId: "totp",
factorId: "totp",
labelKey: "auth.totpFactor",
labelKey: "factor",
available: true,
},
{ pluginId: "yubi", factorId: "otp", labelKey: null, available: false },
@@ -46,6 +46,8 @@ describe("AdminSecondFactorsSection", () => {
expect(
await screen.findByText("admin.secondFactorUnavailable:yubi"),
).toBeTruthy();
// A plugin's label resolves in its own namespace.
expect(screen.getByText("totp:factor")).toBeTruthy();
fireEvent.click(screen.getByText("admin.resetSecondFactors"));
expect(requestConfirm).toHaveBeenCalled();
await waitFor(() =>
@@ -15,7 +15,6 @@ const api = vi.hoisted(() => ({
adminUpdateUserSnippet: vi.fn(async () => ({})),
adminDeleteUserSnippet: vi.fn(async () => ({})),
adminResetUserPassword: vi.fn(async () => ({ dataWiped: false })),
adminDisableUserTotp: vi.fn(async () => ({})),
adminExportUserData: vi.fn(async () => ({})),
getSessions: vi.fn(async () => ({ sessions: [] as unknown[] })),
revokeSession: vi.fn(async () => ({})),
@@ -56,7 +55,6 @@ function makeUser(overrides: Partial<AdminUser> = {}): AdminUser {
isOidc: false,
passwordHash: "hash",
dataUnlocked: true,
totpEnabled: false,
...overrides,
};
}
@@ -72,7 +70,7 @@ function renderPanel(
onBack={callbacks.onBack ?? vi.fn()}
onOpenHostTab={callbacks.onOpenHostTab as never}
onUserDeleted={callbacks.onUserDeleted ?? vi.fn()}
onTotpDisabled={callbacks.onTotpDisabled ?? vi.fn()}
onSecondFactorsReset={callbacks.onSecondFactorsReset ?? vi.fn()}
/>,
);
}
@@ -144,21 +142,6 @@ describe("AdminUserManagePanel", () => {
});
});
it("disables TOTP through the confirm dialog", async () => {
const onTotpDisabled = vi.fn();
renderPanel(makeUser({ totpEnabled: true }), { onTotpDisabled });
expect(screen.getByText("admin.totpStatusEnabled")).toBeTruthy();
await userEvent.click(screen.getByText("admin.disableTotp"));
await userEvent.click(screen.getByText("common.confirm"));
await waitFor(() => {
expect(api.adminDisableUserTotp).toHaveBeenCalledWith("u2");
expect(onTotpDisabled).toHaveBeenCalled();
});
expect(screen.getByText("admin.totpStatusDisabled")).toBeTruthy();
});
it("creates a snippet for the target user", async () => {
renderPanel(makeUser());