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:
bobbanandClaude Sonnet 5 committed 2026-09-14 21:57:13 +02:00
commit 6bd2ed52c1
52 files changed
+8103

No files matched your search

+100
View File
@@ -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);
}
});