Scaffold Homelab Manager foundation
Monorepo (Express+TS+Drizzle/libSQL server, React+Vite+Tabler web) matching the stack used by ScheduleTaskManager and Sloth Manager. Includes Authentik OIDC login with local admin/operator/viewer roles (first user becomes admin), a generalized audit log, encrypted-at-rest storage for future integration API tokens, the DB schema for all planned modules, and the Tabler-styled app shell/nav. Also ports the Secrets (expiry tracker) and IP Addresses (IPAM) modules from Sloth Manager onto the new stack. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
6bd2ed52c1
52 files changed
+8103
No files matched your search
@@ -0,0 +1,44 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, type UserRole } from "../db/schema.js";
|
||||
|
||||
type CurrentUser = typeof users.$inferSelect;
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Express {
|
||||
interface Request {
|
||||
currentUser?: CurrentUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Requires a valid session AND a matching local user row; attaches req.currentUser. */
|
||||
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const sessionUser = req.session.user;
|
||||
if (!sessionUser) {
|
||||
return res.status(401).json({ error: "unauthorized" });
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.oidcSub, sessionUser.sub)).limit(1);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "unauthorized" });
|
||||
}
|
||||
|
||||
req.currentUser = user;
|
||||
next();
|
||||
}
|
||||
|
||||
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
|
||||
|
||||
/** Must run after requireAuth. Rejects unless the current user's role is >= minRole. */
|
||||
export function requireRole(minRole: UserRole) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.currentUser;
|
||||
if (!user || roleRank[user.role] < roleRank[minRole]) {
|
||||
return res.status(403).json({ error: "forbidden" });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as client from "openid-client";
|
||||
import { env } from "../env.js";
|
||||
|
||||
let configPromise: Promise<client.Configuration> | null = null;
|
||||
|
||||
export function getOidcConfig(): Promise<client.Configuration> {
|
||||
if (!configPromise) {
|
||||
configPromise = client.discovery(
|
||||
new URL(env.authentik.issuerUrl),
|
||||
env.authentik.clientId,
|
||||
env.authentik.clientSecret,
|
||||
);
|
||||
}
|
||||
return configPromise;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Router } from "express";
|
||||
import * as client from "openid-client";
|
||||
import { getOidcConfig } from "./oidc.js";
|
||||
import { upsertUserFromLogin } from "./users.js";
|
||||
import { env } from "../env.js";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
authRouter.get("/login", async (req, res, next) => {
|
||||
try {
|
||||
const config = await getOidcConfig();
|
||||
const codeVerifier = client.randomPKCECodeVerifier();
|
||||
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
||||
const state = client.randomState();
|
||||
|
||||
req.session.pendingAuth = { codeVerifier, state };
|
||||
|
||||
const redirectUri = new URL("/auth/callback", env.appBaseUrl).toString();
|
||||
const authUrl = client.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri,
|
||||
scope: "openid email profile",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
});
|
||||
|
||||
req.session.save((err) => {
|
||||
if (err) return next(err);
|
||||
res.redirect(authUrl.href);
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.get("/callback", async (req, res, next) => {
|
||||
try {
|
||||
const pending = req.session.pendingAuth;
|
||||
if (!pending) {
|
||||
return res.status(400).send("Login session expired. Please try signing in again.");
|
||||
}
|
||||
|
||||
const config = await getOidcConfig();
|
||||
const currentUrl = new URL(req.originalUrl, env.appBaseUrl);
|
||||
|
||||
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: pending.codeVerifier,
|
||||
expectedState: pending.state,
|
||||
});
|
||||
|
||||
const claims = tokens.claims();
|
||||
if (!claims?.sub) {
|
||||
return res.status(400).send("Identity provider did not return a valid identity.");
|
||||
}
|
||||
|
||||
let email: string | undefined = typeof claims.email === "string" ? claims.email : undefined;
|
||||
let name: string | undefined = typeof claims.name === "string" ? claims.name : undefined;
|
||||
try {
|
||||
const userinfo = await client.fetchUserInfo(config, tokens.access_token, claims.sub);
|
||||
email = userinfo.email ?? email;
|
||||
name = userinfo.name ?? userinfo.preferred_username ?? name;
|
||||
} catch {
|
||||
// fall back to ID token claims already captured above
|
||||
}
|
||||
|
||||
await upsertUserFromLogin({ sub: claims.sub, email, name });
|
||||
|
||||
delete req.session.pendingAuth;
|
||||
req.session.user = { sub: claims.sub, email, name, idToken: tokens.id_token };
|
||||
req.session.save((err) => {
|
||||
if (err) return next(err);
|
||||
res.redirect("/");
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.get("/logout", async (req, res, next) => {
|
||||
const idToken = req.session.user?.idToken;
|
||||
try {
|
||||
const config = await getOidcConfig();
|
||||
let endSessionUrl: URL | undefined;
|
||||
try {
|
||||
endSessionUrl = client.buildEndSessionUrl(config, {
|
||||
post_logout_redirect_uri: env.appBaseUrl,
|
||||
...(idToken ? { id_token_hint: idToken } : {}),
|
||||
});
|
||||
} catch {
|
||||
// Provider doesn't advertise RP-Initiated Logout; just clear our own session.
|
||||
}
|
||||
|
||||
req.session.destroy((err) => {
|
||||
if (err) return next(err);
|
||||
res.redirect(endSessionUrl ? endSessionUrl.href : "/");
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
|
||||
/**
|
||||
* Called on every successful OIDC login. The very first user ever to sign in
|
||||
* becomes admin; everyone after defaults to viewer until an admin promotes
|
||||
* them from the Users page.
|
||||
*/
|
||||
export async function upsertUserFromLogin(params: {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}) {
|
||||
const [existing] = await db.select().from(users).where(eq(users.oidcSub, params.sub)).limit(1);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set({
|
||||
email: params.email ?? existing.email,
|
||||
name: params.name ?? existing.name,
|
||||
lastLoginAt: now,
|
||||
})
|
||||
.where(eq(users.id, existing.id))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const anyUser = await db.select({ id: users.id }).from(users).limit(1);
|
||||
const role = anyUser.length === 0 ? ("admin" as const) : ("viewer" as const);
|
||||
|
||||
const [created] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
oidcSub: params.sub,
|
||||
email: params.email,
|
||||
name: params.name,
|
||||
role,
|
||||
lastLoginAt: now,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
Reference in new issue
Block a user