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

@@ -63,6 +63,7 @@ vi.mock("../../database/repositories/factory.js", () => ({
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
decryptSystemSecret: async (value: string) => value.replace(/^sysenc:/, ""),
isSystemEncrypted: (value: string) => value.startsWith("sysenc:"),
}));
vi.mock("../../utils/audit-logger.js", () => ({
@@ -156,4 +157,20 @@ describe("ctx.secrets store", () => {
});
expect(state.rows.size).toBe(0);
});
it("seals without an acting user and refuses without secrets:own", async () => {
const denied = contextFor([]);
await expect(denied.secrets.seal("v")).rejects.toThrow(/secrets:own/);
await expect(denied.secrets.unseal("sysenc:v")).rejects.toThrow(
/secrets:own/,
);
const ctx = contextFor(["secrets:own"]);
const sealed = await ctx.secrets.seal("totp-secret");
expect(sealed).toBe("sysenc:totp-secret");
expect(await ctx.secrets.unseal(sealed)).toBe("totp-secret");
// A plain string was never sealed and must not read back as one.
expect(await ctx.secrets.unseal("totp-secret")).toBeNull();
expect(JSON.stringify(auditEntries)).not.toContain("totp-secret");
});
});
+61 -1
View File
@@ -9,6 +9,10 @@ const h = vi.hoisted(() => ({
cleared: [] as string[],
clearedAll: 0,
pooled: [] as string[],
sessionId: undefined as string | undefined,
revoked: [] as Array<{ userId: string; except?: string }>,
trustedCleared: [] as string[],
policyError: null as Error | null,
}));
vi.mock("../../utils/logger.js", () => {
@@ -29,7 +33,15 @@ vi.mock("../../plugins/permissions.js", async () => {
},
};
});
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
vi.mock("../../plugins/actor.js", () => ({
getActor: () => h.actor,
getActorSessionId: () => h.sessionId,
}));
vi.mock("../../auth/core-auth.js", () => ({
assertSecondFactorEnrollmentAllowed: () => {
if (h.policyError) throw h.policyError;
},
}));
vi.mock("../../hosts/ssh-connection-pool.js", () => ({
withConnection: async (
key: string,
@@ -74,6 +86,17 @@ vi.mock("../../database/repositories/factory.js", () => ({
},
removeSecondFactor: async () => true,
}),
createCurrentSessionRepository: () => ({
revokeAllForUser: async (userId: string, except?: string) => {
h.revoked.push({ userId, except });
return 0;
},
}),
createCurrentTrustedDeviceRepository: () => ({
deleteByUserId: async (userId: string) => {
h.trustedCleared.push(userId);
},
}),
}));
const { createPluginAuth, createPluginSsh } =
@@ -359,4 +382,41 @@ describe("ctx.auth", () => {
expect(getLoginMethod("corp-sso")).toBeUndefined();
expect(getSecondFactor("fixture", "pin")).toBeUndefined();
});
it("revokes other sessions and trusted devices on enrolment, keeping the caller's session", async () => {
h.granted = new Set(["auth:provide"]);
h.factors = [];
h.revoked = [];
h.trustedCleared = [];
h.policyError = null;
h.actor = "user-1";
h.sessionId = "session-a";
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
await auth.recordEnrollment("user-1", "pin");
expect(h.revoked).toEqual([{ userId: "user-1", except: "session-a" }]);
expect(h.trustedCleared).toEqual(["user-1"]);
h.actor = undefined;
h.sessionId = undefined;
});
it("refuses enrolment when core policy forbids second factors", async () => {
h.granted = new Set(["auth:provide"]);
h.factors = [];
const { LoginMethodError } = await import("@termix/plugin-sdk/backend");
h.policyError = new LoginMethodError("password login is off", 409);
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
await expect(auth.recordEnrollment("user-1", "pin")).rejects.toMatchObject({
status: 409,
});
expect(h.factors).toEqual([]);
h.policyError = null;
});
});