feat: login policy hook for plugins and optional https redirect (#1584)

This commit is contained in:
LukeGus committed 2026-10-19 12:47:47 -05:00
1 parent 34a9c77114
commit ad46f42ab1
53 files changed
+15250 -59

No files matched your search

@@ -12,6 +12,7 @@ const h = vi.hoisted(() => ({
sessionId: undefined as string | undefined,
revoked: [] as Array<{ userId: string; except?: string }>,
trustedCleared: [] as string[],
unrestricted: [] as string[],
policyError: null as Error | null,
logins: [] as Array<{ hostId: number; outcome: unknown }>,
connectError: null as Error | null,
@@ -126,6 +127,9 @@ vi.mock("../../database/repositories/factory.js", () => ({
h.revoked.push({ userId, except });
return 0;
},
clearRestrictionForUser: async (userId: string) => {
h.unrestricted.push(userId);
},
}),
createCurrentTrustedDeviceRepository: () => ({
deleteByUserId: async (userId: string) => {
@@ -145,6 +149,8 @@ const { getLoginMethod, getSecondFactor } =
await import("../../auth/registry.js");
const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend");
const { hasAuthPolicies, clearAuthPolicies } =
await import("../../auth/policy.js");
function manifest(capabilities: string[], auth: Record<string, string[]> = {}) {
return {
@@ -655,6 +661,7 @@ describe("ctx.auth", () => {
h.factors = [];
h.revoked = [];
h.trustedCleared = [];
h.unrestricted = [];
h.policyError = null;
h.actor = "user-1";
h.sessionId = "session-a";
@@ -666,10 +673,65 @@ describe("ctx.auth", () => {
await auth.recordEnrollment("user-1", "pin");
expect(h.revoked).toEqual([{ userId: "user-1", except: "session-a" }]);
expect(h.trustedCleared).toEqual(["user-1"]);
expect(h.unrestricted).toEqual(["user-1"]);
h.actor = undefined;
h.sessionId = undefined;
});
it("needs auth:policy for a login policy and drops it on deactivate", async () => {
clearAuthPolicies();
const plain = createPluginAuth({
manifest: manifest(["auth:provide"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
expect(() => plain.policy.provide({ evaluate: () => ({}) })).toThrow(
PluginCapabilityError,
);
const bag = new DisposableBag("fixture");
const audit = vi.fn(async () => {});
const auth = createPluginAuth({
manifest: manifest(["auth:policy"]),
bag,
audit,
});
const remove = auth.policy.provide({ evaluate: () => ({}) });
expect(hasAuthPolicies()).toBe(true);
expect(audit).toHaveBeenCalledWith(
"auth_policy_provide",
"fixture sets login rules",
{ success: true },
);
remove();
expect(hasAuthPolicies()).toBe(false);
auth.policy.provide({ evaluate: () => ({}) });
await bag.disposeAll();
expect(hasAuthPolicies()).toBe(false);
});
it("lists second factors from every plugin", async () => {
h.granted = new Set(["auth:provide"]);
const bag = new DisposableBag("fixture");
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
bag,
audit: vi.fn(async () => {}),
});
auth.registerSecondFactor({
id: "pin",
labelKey: "pin",
isEnrolled: async () => false,
verify: async () => true,
});
expect(auth.listSecondFactors()).toContainEqual({
id: "pin",
pluginId: "fixture",
});
await bag.disposeAll();
});
it("refuses enrolment when core policy forbids second factors", async () => {
h.granted = new Set(["auth:provide"]);
h.factors = [];
@@ -71,6 +71,7 @@ const UNGATED: Record<string, string> = {
"rbac.hasFor": "a yes/no about a role permission, grants nothing",
"rbac.require": "route middleware for the plugin's own permissions",
"capabilities.has": "a yes/no about the plugin's own grants",
"auth.listSecondFactors": "ids of the second factors, no user data",
"disposables.add": "the plugin's own cleanup",
"schedule.every": "the plugin's own timers",
"schedule.after": "the plugin's own timers",