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>
101 lines
3.1 KiB
TypeScript
101 lines
3.1 KiB
TypeScript
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);
|
|
}
|
|
});
|