mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +00:00
282 lines
7.7 KiB
TypeScript
282 lines
7.7 KiB
TypeScript
/**
|
|
* Linking an external (SSO or LDAP) account to a password account and back.
|
|
* The linked account keeps its sign-in identities, so it signs in through the
|
|
* same provider afterwards; unlinking drops them.
|
|
*/
|
|
|
|
import http from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import express from "express";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
interface User {
|
|
id: string;
|
|
username: string;
|
|
isAdmin: boolean;
|
|
passwordHash: string;
|
|
}
|
|
|
|
const h = vi.hoisted(() => ({
|
|
users: new Map<string, User>(),
|
|
identities: [] as Array<{
|
|
userId: string;
|
|
providerId: string;
|
|
subject: string;
|
|
}>,
|
|
deleted: [] as string[],
|
|
revoked: [] as string[],
|
|
}));
|
|
|
|
function withIdentities(user: User | undefined) {
|
|
if (!user) return null;
|
|
return {
|
|
...user,
|
|
isExternal: h.identities.some((row) => row.userId === user.id),
|
|
};
|
|
}
|
|
|
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
|
createCurrentUserRepository: () => ({
|
|
findById: async (id: string) => withIdentities(h.users.get(id)),
|
|
findByUsername: async (username: string) =>
|
|
withIdentities(
|
|
[...h.users.values()].find((user) => user.username === username),
|
|
),
|
|
}),
|
|
createCurrentUserAuthRepository: () => ({
|
|
listIdentitiesForUser: async (userId: string) =>
|
|
h.identities.filter((row) => row.userId === userId),
|
|
moveIdentities: async (from: string, to: string) => {
|
|
for (const row of h.identities) if (row.userId === from) row.userId = to;
|
|
},
|
|
unlinkIdentitiesForUser: async (userId: string) => {
|
|
h.identities = h.identities.filter((row) => row.userId !== userId);
|
|
},
|
|
}),
|
|
}));
|
|
vi.mock("../../../database/routes/delete-user-data.js", () => ({
|
|
deleteUserAndRelatedData: async (userId: string) => {
|
|
h.deleted.push(userId);
|
|
h.users.delete(userId);
|
|
},
|
|
}));
|
|
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
|
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
|
|
}));
|
|
vi.mock("../../../utils/auth-manager.js", () => ({ AuthManager: {} }));
|
|
vi.mock("../../../utils/logger.js", () => {
|
|
const log = {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
success: vi.fn(),
|
|
};
|
|
return { authLogger: log };
|
|
});
|
|
|
|
const { registerUserExternalAccountRoutes } =
|
|
await import("../../../database/routes/user-external-account-routes.js");
|
|
|
|
let server: http.Server;
|
|
let base: string;
|
|
|
|
beforeEach(async () => {
|
|
h.users = new Map([
|
|
[
|
|
"admin",
|
|
{
|
|
id: "admin",
|
|
username: "admin",
|
|
isAdmin: true,
|
|
passwordHash: "x",
|
|
},
|
|
],
|
|
[
|
|
"sso",
|
|
{
|
|
id: "sso",
|
|
username: "alice-sso",
|
|
isAdmin: false,
|
|
passwordHash: "",
|
|
},
|
|
],
|
|
[
|
|
"local",
|
|
{
|
|
id: "local",
|
|
username: "alice",
|
|
isAdmin: false,
|
|
passwordHash: "hash",
|
|
},
|
|
],
|
|
]);
|
|
h.identities = [{ userId: "sso", providerId: "ldap:4", subject: "alice" }];
|
|
h.deleted = [];
|
|
h.revoked = [];
|
|
|
|
const router = express.Router();
|
|
router.use(express.json());
|
|
registerUserExternalAccountRoutes(router, {
|
|
authenticateJWT: (req, _res, next) => {
|
|
(req as unknown as { userId: string }).userId =
|
|
req.header("x-user") ?? "";
|
|
next();
|
|
},
|
|
authManager: {
|
|
revokeAllUserSessions: async (userId: string) => {
|
|
h.revoked.push(userId);
|
|
},
|
|
logoutUser: () => {},
|
|
} as never,
|
|
});
|
|
const app = express();
|
|
app.use("/users", router);
|
|
server = http.createServer(app);
|
|
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
|
|
function post(path: string, body: unknown, user = "admin") {
|
|
return fetch(`${base}/users${path}`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", "x-user": user },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
describe("linking an external account to a password account", () => {
|
|
it("moves the identities, then removes the external account", async () => {
|
|
const response = await post("/link-external-to-password", {
|
|
externalUserId: "sso",
|
|
targetUsername: "alice",
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(h.identities).toEqual([
|
|
{ userId: "local", providerId: "ldap:4", subject: "alice" },
|
|
]);
|
|
expect(h.revoked).toEqual(["sso"]);
|
|
expect(h.deleted).toEqual(["sso"]);
|
|
});
|
|
|
|
it("no longer takes the 2.8 path and field name", async () => {
|
|
const response = await post("/link-oidc-to-password", {
|
|
oidcUserId: "sso",
|
|
targetUsername: "alice",
|
|
});
|
|
expect(response.status).toBe(404);
|
|
expect(h.identities[0].userId).toBe("sso");
|
|
});
|
|
|
|
it("refuses a target that already signs in externally", async () => {
|
|
h.identities.push({ userId: "local", providerId: "ldap:4", subject: "x" });
|
|
const response = await post("/link-external-to-password", {
|
|
externalUserId: "sso",
|
|
targetUsername: "alice",
|
|
});
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it("is for admins only", async () => {
|
|
const response = await post(
|
|
"/link-external-to-password",
|
|
{ externalUserId: "sso", targetUsername: "alice" },
|
|
"local",
|
|
);
|
|
expect(response.status).toBe(403);
|
|
expect(h.identities[0].userId).toBe("sso");
|
|
});
|
|
|
|
it("refuses a target that is not a password account", async () => {
|
|
const response = await post("/link-external-to-password", {
|
|
externalUserId: "sso",
|
|
targetUsername: "alice-sso",
|
|
});
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it("merges a 2.9.0 duplicate back into its 2.8 SSO account", async () => {
|
|
h.users.set("orig", {
|
|
id: "orig",
|
|
username: "bob",
|
|
isAdmin: false,
|
|
passwordHash: "",
|
|
});
|
|
h.users.set("dup", {
|
|
id: "dup",
|
|
username: "bob-1",
|
|
isAdmin: false,
|
|
passwordHash: "",
|
|
});
|
|
h.identities.push(
|
|
{ userId: "orig", providerId: "legacy-oidc", subject: "sub-9" },
|
|
{ userId: "dup", providerId: "1", subject: "sub-9" },
|
|
);
|
|
|
|
const response = await post("/link-external-to-password", {
|
|
externalUserId: "dup",
|
|
targetUsername: "bob",
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(h.deleted).toEqual(["dup"]);
|
|
expect(
|
|
h.identities
|
|
.filter((row) => row.userId === "orig")
|
|
.map((row) => row.providerId),
|
|
).toEqual(["legacy-oidc", "1"]);
|
|
});
|
|
|
|
it("does not treat an unrelated SSO account as a duplicate target", async () => {
|
|
h.users.set("other", {
|
|
id: "other",
|
|
username: "carol",
|
|
isAdmin: false,
|
|
passwordHash: "",
|
|
});
|
|
h.identities.push({
|
|
userId: "other",
|
|
providerId: "legacy-oidc",
|
|
subject: "sub-x",
|
|
});
|
|
|
|
const response = await post("/link-external-to-password", {
|
|
externalUserId: "sso",
|
|
targetUsername: "carol",
|
|
});
|
|
expect(response.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe("unlinking", () => {
|
|
it("drops the identities so the provider no longer signs in as the user", async () => {
|
|
await post("/link-external-to-password", {
|
|
externalUserId: "sso",
|
|
targetUsername: "alice",
|
|
});
|
|
const response = await post("/unlink-external-from-password", {
|
|
userId: "local",
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(h.identities).toEqual([]);
|
|
});
|
|
|
|
it("no longer takes the 2.8 path", async () => {
|
|
const response = await post("/unlink-oidc-from-password", {
|
|
userId: "local",
|
|
});
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it("refuses to leave an account with no way in", async () => {
|
|
const response = await post("/unlink-external-from-password", {
|
|
userId: "sso",
|
|
});
|
|
expect(response.status).toBe(400);
|
|
expect(h.identities).toHaveLength(1);
|
|
});
|
|
});
|