feat(auth): implement portal SSO and local admin authentication
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m57s
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s

Added support for portal SSO with JWT authentication and local admin login. Updated environment configuration to include AUTH_REQUIRED, AUTH_JWT_SECRET, AUTH_ISSUER, and AUTH_PORTAL_URL. Enhanced the auth plugin to handle JWT verification based on the new configuration. Introduced new routes for authentication and updated the API client to manage token handling and redirects. Improved user experience by integrating authentication checks across various routes and components.
This commit is contained in:
Denozordec
2026-07-18 18:25:29 +07:00
parent 60e15ca40a
commit 6a6cb34eeb
22 changed files with 1101 additions and 97 deletions
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { hasPermission, permissionForRequest } from "../src/lib/permissions.js";
describe("permissions helpers", () => {
it("hasPermission respects admin ⊃ write ⊃ read", () => {
expect(hasPermission(["cfdm:domains:write"], "cfdm:domains:read")).toBe(
true,
);
expect(hasPermission(["cfdm:domains:admin"], "cfdm:domains:write")).toBe(
true,
);
expect(hasPermission(["cfdm:domains:read"], "cfdm:domains:write")).toBe(
false,
);
});
it("permissionForRequest maps domains and settings", () => {
expect(permissionForRequest("GET", "/api/v1/domains")).toBe(
"cfdm:domains:read",
);
expect(permissionForRequest("POST", "/api/v1/domains")).toBe(
"cfdm:domains:write",
);
expect(permissionForRequest("GET", "/api/v1/settings")).toBe(
"cfdm:settings:admin",
);
expect(permissionForRequest("POST", "/api/v1/sync/foo")).toBe(
"cfdm:domains:write",
);
});
});
describe("auth plugin (AUTH_REQUIRED)", () => {
const secret = "test-secret-at-least-8";
const issuer = "https://auth.shnt.top";
beforeAll(() => {
process.env.AUTH_REQUIRED = "true";
process.env.AUTH_JWT_SECRET = secret;
process.env.AUTH_ISSUER = issuer;
process.env.AUTH_PORTAL_URL = "http://localhost:5175";
});
afterAll(() => {
delete process.env.AUTH_REQUIRED;
delete process.env.AUTH_JWT_SECRET;
delete process.env.AUTH_ISSUER;
delete process.env.AUTH_PORTAL_URL;
});
it("GET /api/v1/auth/config exposes portal settings", async () => {
const app = await buildApp({
config: {
...loadConfig(),
authRequired: true,
jwtSecret: secret,
authIssuer: issuer,
authPortalUrl: "http://localhost:5175",
staticDir: null,
},
memory: true,
});
const res = await app.inject({ method: "GET", url: "/api/v1/auth/config" });
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({
required: true,
portal_url: "http://localhost:5175",
});
await app.close();
});
it("401 without token; 403 without cfdm app; 200 with rights", async () => {
const app = await buildApp({
config: {
...loadConfig(),
authRequired: true,
jwtSecret: secret,
authIssuer: issuer,
authPortalUrl: "http://localhost:5175",
staticDir: null,
},
memory: true,
});
const noAuth = await app.inject({ method: "GET", url: "/api/v1/domains" });
expect(noAuth.statusCode).toBe(401);
const tokenNoApp = app.jwt.sign(
{
sub: "u1",
email: "[email protected]",
name: "A",
apps: ["vps"],
permissions: ["cfdm:domains:read"],
iss: issuer,
},
{ expiresIn: "1h" },
);
const forbiddenApp = await app.inject({
method: "GET",
url: "/api/v1/domains",
headers: { authorization: `Bearer ${tokenNoApp}` },
});
expect(forbiddenApp.statusCode).toBe(403);
const okToken = app.jwt.sign(
{
sub: "u2",
email: "[email protected]",
name: "R",
apps: ["cfdm"],
permissions: ["cfdm:domains:read"],
iss: issuer,
},
{ expiresIn: "1h" },
);
const okRead = await app.inject({
method: "GET",
url: "/api/v1/domains",
headers: { authorization: `Bearer ${okToken}` },
});
expect(okRead.statusCode).toBe(200);
const denyWrite = await app.inject({
method: "POST",
url: "/api/v1/domains",
headers: { authorization: `Bearer ${okToken}` },
payload: { name: "x" },
});
expect(denyWrite.statusCode).toBe(403);
const loginBlocked = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: "admin", password: "admin" },
});
expect(loginBlocked.statusCode).toBe(403);
await app.close();
});
});