From 4cc940907beea80e2d2e8967c4435f5e977227e9 Mon Sep 17 00:00:00 2001 From: Grant <3380410-grahhnt@users.noreply.gitlab.com> Date: Sat, 17 May 2025 19:33:18 -0600 Subject: [PATCH 1/4] [wip] initial --- .../server/src/__test__/api/client.test.ts | 9 +- packages/server/src/api/auth.ts | 80 +++++ packages/server/src/api/client.ts | 205 +------------ .../src/controllers/OpenIDController.ts | 280 ++++++++++++++++++ packages/server/src/types.ts | 1 + packages/server/src/utils/TypedPromise.ts | 36 +++ 6 files changed, 406 insertions(+), 205 deletions(-) create mode 100644 packages/server/src/api/auth.ts create mode 100644 packages/server/src/utils/TypedPromise.ts diff --git a/packages/server/src/__test__/api/client.test.ts b/packages/server/src/__test__/api/client.test.ts index 39cbefd..bfb9470 100644 --- a/packages/server/src/__test__/api/client.test.ts +++ b/packages/server/src/__test__/api/client.test.ts @@ -8,10 +8,12 @@ import { UserInfoResponse, } from "openid-client"; -import { __TESTING } from "../../api/client"; import { type CanvasController as ICanvasController } from "../../controllers/CanvasController"; import { type ExpressController as IExpressController } from "../../controllers/ExpressController"; -import { type OpenIDController as IOpenIDController } from "../../controllers/OpenIDController"; +import { + type OpenIDController as IOpenIDController, + OpenIDController, +} from "../../controllers/OpenIDController"; import { type Instance as IInstance } from "../../models/Instance"; import { type prismaMock as IPrismaMock } from "../_prisma.mock"; @@ -48,7 +50,8 @@ jest.mock("../../lib/RateLimiter", () => { }; }); -const { ClientParams, buildQuery } = __TESTING; +const ClientParams = OpenIDController["ClientParams"]; +const buildQuery = OpenIDController["buildQuery"]; describe("Client endpoints /api", () => { const OLD_ENV = process.env; diff --git a/packages/server/src/api/auth.ts b/packages/server/src/api/auth.ts new file mode 100644 index 0000000..09d7d2f --- /dev/null +++ b/packages/server/src/api/auth.ts @@ -0,0 +1,80 @@ +import { Router } from "express"; + +import { + InvalidAuthMode, + OpenIDController, +} from "../controllers/OpenIDController"; + +export const AuthEndpoints = Router(); + +/** + * Redirect to actual authorization page + */ +AuthEndpoints.get("/login", (req, res) => { + try { + res.redirect(OpenIDController.get().getAuthorizationURL()); + } catch (e) { + if (e instanceof InvalidAuthMode) { + res.status(400).json({ + success: false, + error: e.message, + }); + } else { + // eslint-disable-next-line no-console + console.error(e); + res.status(500).json({ + success: false, + error: "Internal server error", + }); + } + } +}); + +AuthEndpoints.get("/logout", (req, res) => { + res.send( + `
` + ); +}); + +AuthEndpoints.post("/logout", (req, res) => { + req.session.destroy(() => { + res.redirect("/"); + }); +}); + +AuthEndpoints.get("/callback", async (req, res) => { + await OpenIDController.get() + .callback(req) + .then((data) => { + // handle redirect + res.redirect(data.redirect); + }) + .catch((error) => { + if (error instanceof Error) { + // this was thrown by an unknown source + // if the error is intended to be shown to the client + // it should be sent wrapped in an object { error: Error } + res.status(500).json({ + success: false, + error: "Internal error", + }); + } else { + if ("error" in error) { + // handle friendly error + res.status(400).json({ + success: false, + error: error.error.message, + }); + } else { + // handle redirect + res.redirect(error.redirect); + } + } + }); +}); + +AuthEndpoints.get("/session", async (req, res) => { + res.json({ + session: req.session, + }); +}); diff --git a/packages/server/src/api/client.ts b/packages/server/src/api/client.ts index ea10e6e..aa8dcd2 100644 --- a/packages/server/src/api/client.ts +++ b/packages/server/src/api/client.ts @@ -1,206 +1,19 @@ -import * as Sentry from "@sentry/node"; import { Router } from "express"; -import { ResponseBodyError } from "openid-client"; import { CanvasController } from "../controllers/CanvasController"; -import { OpenID, OpenIDController } from "../controllers/OpenIDController"; -import { getLogger } from "../lib/Logger"; import { prisma } from "../lib/prisma"; import { RateLimiter } from "../lib/RateLimiter"; -import { Instance } from "../models/Instance"; +import { AuthEndpoints } from "./auth"; import SentryRouter from "./sentry"; -const Logger = getLogger("HTTP/CLIENT"); - -const ClientParams = { - TYPE: "auth_type", - ERROR: "auth_error", - ERROR_DESC: "auth_error_desc", - CAN_RETRY: "auth_retry", -}; - -const buildQuery = (obj: { [k in keyof typeof ClientParams]?: string }) => { - const params = new URLSearchParams(); - for (const [k, v] of Object.entries(obj)) { - const k_: keyof typeof ClientParams = k as any; - params.set(ClientParams[k_], v); - } - return "?" + params.toString(); -}; - const app = Router(); // register sentry tunnel // if the sentry environment variables aren't set, the loaded router will be empty app.use(SentryRouter); -/** - * Redirect to actual authorization page - */ -app.get("/login", (req, res) => { - if (process.env.INHIBIT_LOGINS) { - res.status(400).json({ - success: false, - error: "Login is not permitted.", - }); - return; - } - - res.redirect(OpenIDController.get().getAuthorizationURL()); -}); - -app.get("/logout", (req, res) => { - res.send( - `
` - ); -}); - -app.post("/logout", (req, res) => { - req.session.destroy(() => { - res.redirect("/"); - }); -}); - -/** - * Process token exchange from openid server - * - * This executes multiple database queries and should be ratelimited - */ -app.get("/callback", RateLimiter.HIGH, async (req, res) => { - if (process.env.INHIBIT_LOGINS) { - res.status(400).json({ - success: false, - error: "Login is not permitted.", - }); - return; - } - - let exchange: OpenID.ExchangeToken; - - try { - exchange = await OpenIDController.get().exchangeToken(req.originalUrl); - } catch (e) { - Sentry.captureException(e); - Logger.error("OID Exchange error", { error: e }); - - if (e instanceof ResponseBodyError) { - switch (e.error) { - case "invalid_client": - // this happens when we're configured with invalid credentials - Logger.error( - "OpenID is improperly configured. Cannot exchange tokens, do I have valid credentials?" - ); - res.redirect( - "/" + - buildQuery({ - TYPE: "op", - ERROR: "Internal Server Error", - ERROR_DESC: "I'm misconfigured.", - }) - ); - return; - case "invalid_grant": - res.redirect( - "/" + - buildQuery({ - TYPE: "op", - ERROR: "invalid_grant", - CAN_RETRY: "true", - }) - ); - return; - } - } - - res.redirect( - "/" + - buildQuery({ - TYPE: "op", - ERROR: "unknown error", - ERROR_DESC: "report this", - }) - ); - return; - } - - try { - const whoami = await OpenIDController.get().userInfo<{ - instance: { - software: { - name: string; - version: string; - logo_uri?: string; - repository?: string; - homepage?: string; - }; - instance: { - logo_uri?: string; - banner_uri?: string; - name?: string; - }; - }; - }>(exchange.access_token, exchange.claims()!.sub); - - const [username, hostname] = whoami.sub.split("@"); - - const instance = await Instance.fromAuth( - hostname, - whoami.instance.instance - ); - const instanceBan = await instance.getEffectiveBan(); - if (instanceBan) { - res.redirect( - "/" + - buildQuery({ - TYPE: "banned", - ERROR_DESC: instanceBan.publicNote || undefined, - }) - ); - return; - } - - const sub = [username, hostname].join("@"); - await prisma.user.upsert({ - where: { - sub, - }, - update: { - sub, - display_name: whoami.name, - picture_url: whoami.picture, - profile_url: whoami.profile, - }, - create: { - sub, - display_name: whoami.name, - picture_url: whoami.picture, - profile_url: whoami.profile, - }, - }); - - req.session.user = { - service: { - ...whoami.instance, - instance: { - ...whoami.instance.instance, - hostname, - }, - }, - user: { - picture_url: whoami.picture, - username, - }, - }; - req.session.save(); - res.redirect("/"); - } catch (e) { - Sentry.captureException(e); - Logger.error("callback error", e); - res - .status(500) - .json({ success: false, error: "internal error, try again" }); - } -}); +// register auth endpoints +app.use(AuthEndpoints); app.get("/canvas/pixel/:x/:y", RateLimiter.HIGH, async (req, res) => { const x = parseInt(req.params.x); @@ -288,15 +101,3 @@ app.get("/user/:sub", RateLimiter.HIGH, async (req, res) => { }); export default app; - -export let __TESTING: { - ClientParams: typeof ClientParams; - buildQuery: typeof buildQuery; -}; - -if (process.env.NODE_ENV === "test") { - __TESTING = { - ClientParams, - buildQuery, - }; -} diff --git a/packages/server/src/controllers/OpenIDController.ts b/packages/server/src/controllers/OpenIDController.ts index 3802b69..7945cf9 100644 --- a/packages/server/src/controllers/OpenIDController.ts +++ b/packages/server/src/controllers/OpenIDController.ts @@ -1,14 +1,45 @@ +import * as Sentry from "@sentry/node"; +import type Express from "express"; import * as openid from "openid-client"; +console.log("openid", openid); + +import { getLogger } from "../lib/Logger"; +import { prisma } from "../lib/prisma"; +import { Instance } from "../models/Instance"; +import { TypedPromise } from "../utils/TypedPromise"; + +const Logger = getLogger(); + export namespace OpenID { export type ExchangeToken = Awaited< ReturnType >; } +export enum AuthMode { + NORMAL, + INHIBIT, + TRUST, +} + +export class InvalidAuthMode extends Error { + readonly mode: T; + + constructor(mode: T, reason: string) { + super(reason + ` (mode: ${mode})`); + this.mode = mode; + } +} + +// export type WrappedCallbackResponse = +// | [error: false, { redirect: `/${string}`; good: true }] +// | [error: true, { error: Error } | { redirect: `/${string}` }]; + export class OpenIDController { private static instance: OpenIDController | undefined; config: openid.Configuration = {} as any; + mode: AuthMode = AuthMode.NORMAL; private constructor() {} @@ -21,7 +52,27 @@ export class OpenIDController { const instance = (OpenIDController.instance = new OpenIDController()); + if (process.env.DEV_TRUST_AUTH) { + if (process.env.NODE_ENV !== "development") { + // eslint-disable-next-line no-console + console.error( + "FATAL: DEV_TRUST_AUTH is set while not in development environment" + ); + process.exit(1); + } + + instance.mode = AuthMode.TRUST; + + // eslint-disable-next-line no-console + console.warn( + "OpenID is not setup; DEV_TRUST_AUTH environment variable set!" + ); + return; + } + if (process.env.INHIBIT_LOGINS) { + instance.mode = AuthMode.INHIBIT; + // eslint-disable-next-line no-console console.warn( "OpenID is not setup; INHIBIT_LOGINS environment variable set! Proceed with caution!" @@ -52,7 +103,24 @@ export class OpenIDController { return process.env.OIDC_CALLBACK_HOST + "/api/callback"; } + /** + * + * @throws InvalidAuthMode + * @throws InvalidAuthMode + * @returns + */ getAuthorizationURL() { + if (this.mode === AuthMode.INHIBIT) { + throw new InvalidAuthMode(AuthMode.INHIBIT, "Login not allowed"); + } + + if (this.mode === AuthMode.TRUST) { + throw new InvalidAuthMode( + AuthMode.TRUST, + "Read the docs on this login mode" + ); + } + return openid .buildAuthorizationUrl(this.config, { redirect_uri: this.getRedirectUrl(), @@ -75,4 +143,216 @@ export class OpenIDController { ): Promise { return openid.fetchUserInfo(this.config, accessToken, expectedSub) as any; } + + callback(req: Express.Request): TypedPromise< + { redirect: `/${string}` }, + | { redirect: `/${string}` } + | { + /** + * Friendly error message for client response + */ + error: InvalidAuthMode | Error; + } + > { + return new TypedPromise(async (res, rej) => { + if (this.mode === AuthMode.INHIBIT) { + rej({ + error: new InvalidAuthMode(AuthMode.INHIBIT, "Login not allowed"), + }); + return; + } + + if (this.mode === AuthMode.TRUST) { + // handle trust + const username = req.query.username; + const hostname = req.query.instance; + + if (!username || typeof username !== "string") { + rej({ error: new Error("?username is not valid") }); + return; + } + + if (!hostname || typeof hostname !== "string") { + rej({ error: new Error("?instance is not valid") }); + return; + } + + const instance = await Instance.fromAuth(hostname, {}); + const instanceBan = await instance.getEffectiveBan(); + if (instanceBan) { + res({ + redirect: `/${OpenIDController.buildQuery({ TYPE: "banned", ERROR_DESC: instanceBan.publicNote || undefined })}`, + }); + return; + } + + const sub = [username, hostname].join("@"); + await prisma.user.upsert({ + where: { + sub, + }, + update: { + sub, + }, + create: { + sub, + }, + }); + + req.session.user = { + service: { + instance: { + hostname, + }, + software: { + name: "auth-trust-mode", + version: "0.0.0", + }, + }, + user: { + username, + }, + }; + req.session.save(); + res({ + redirect: `/`, + }); + return; + } + + let exchange: OpenID.ExchangeToken; + + try { + exchange = await this.exchangeToken(req.originalUrl); + } catch (e) { + Sentry.captureException(e); + Logger.error("OpenID Exchange error", { error: e }); + + if (e instanceof openid.ResponseBodyError) { + switch (e.error) { + case "invalid_client": + // this happens when we're configured with invalid credentials + res({ + redirect: `/${OpenIDController.buildQuery({ + TYPE: "op", + ERROR: "Internal Server Error", + ERROR_DESC: "I'm misconfigured.", + })}`, + }); + return; + case "invalid_grant": + res({ + redirect: `/${OpenIDController.buildQuery({ + TYPE: "op", + ERROR: "invalid_grant", + CAN_RETRY: "true", + })}`, + }); + return; + } + } + + res({ + redirect: `/${OpenIDController.buildQuery({ + TYPE: "op", + ERROR: "unknown error", + ERROR_DESC: "report this", + })}`, + }); + return; + } + + try { + const whoami = await this.userInfo<{ + instance: { + software: { + name: string; + version: string; + logo_uri?: string; + repository?: string; + homepage?: string; + }; + instance: { + logo_uri?: string; + banner_uri?: string; + name?: string; + }; + }; + }>(exchange.access_token, exchange.claims()!.sub); + + const [username, hostname] = whoami.sub.split("@"); + + const instance = await Instance.fromAuth( + hostname, + whoami.instance.instance + ); + const instanceBan = await instance.getEffectiveBan(); + if (instanceBan) { + res({ + redirect: `/${OpenIDController.buildQuery({ TYPE: "banned", ERROR_DESC: instanceBan.publicNote || undefined })}`, + }); + return; + } + + const sub = [username, hostname].join("@"); + await prisma.user.upsert({ + where: { + sub, + }, + update: { + sub, + display_name: whoami.name, + picture_url: whoami.picture, + profile_url: whoami.profile, + }, + create: { + sub, + display_name: whoami.name, + picture_url: whoami.picture, + profile_url: whoami.profile, + }, + }); + + req.session.user = { + service: { + ...whoami.instance, + instance: { + ...whoami.instance.instance, + hostname, + }, + }, + user: { + picture_url: whoami.picture, + username, + }, + }; + req.session.save(); + res({ + redirect: `/`, + }); + } catch (e) { + Sentry.captureException(e); + Logger.error("callback error", { error: e }); + rej(e as any); + } + }); + } + + private static readonly ClientParams = { + TYPE: "auth_type", + ERROR: "auth_error", + ERROR_DESC: "auth_error_desc", + CAN_RETRY: "auth_retry", + }; + + private static readonly buildQuery = (obj: { + [k in keyof typeof this.ClientParams]?: string; + }) => { + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(obj)) { + const k_: keyof typeof this.ClientParams = k as any; + params.set(this.ClientParams[k_], v); + } + return "?" + params.toString(); + }; } diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 5e2a53a..e7e3808 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -24,6 +24,7 @@ declare global { LOG_LEVEL?: string; SESSION_SECRET: string; INHIBIT_LOGINS?: string; // if logins should be prohibited + DEV_TRUST_AUTH?: string; // DEV: allow any username to be used PROMETHEUS_TOKEN?: string; diff --git a/packages/server/src/utils/TypedPromise.ts b/packages/server/src/utils/TypedPromise.ts new file mode 100644 index 0000000..3784b8f --- /dev/null +++ b/packages/server/src/utils/TypedPromise.ts @@ -0,0 +1,36 @@ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class TypedPromise extends Promise { + constructor( + executor: ( + resolve: (value: TSuccess | PromiseLike) => any, + reject: (value: TError | Error | PromiseLike) => any + ) => void | PromiseLike + ) { + super(executor); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * # Although this has typed error types... + * Every caught exception should be checked as if it was a `never` + */ +export interface TypedPromise { + then( + onfulfilled?: + | ((value: TSuccess) => TResult1 | PromiseLike) + | undefined + | null, + onrejected?: + | ((reason: TError | Error) => TResult2 | PromiseLike) + | undefined + | null + ): TypedPromise; + + catch( + onrejected?: + | ((reason: TError | Error) => TResult | PromiseLike) + | undefined + | null + ): TypedPromise; +} -- GitLab From 2b56f7cdc65f22a493fd69a6f12256bb3cb48e41 Mon Sep 17 00:00:00 2001 From: Grant Date: Sat, 17 May 2025 22:28:50 -0600 Subject: [PATCH 2/4] add UI for login errors instead of raw JSON replies --- .../src/components/LoginModal/Inhibit.tsx | 5 + .../src/components/LoginModal/LoginModal.tsx | 27 ++++ .../src/components/LoginModal/Trust.tsx | 24 ++++ .../client/src/components/Toolbar/Palette.tsx | 135 +++++++++++------- packages/server/src/api/auth.ts | 1 + .../src/controllers/OpenIDController.ts | 24 +--- packages/server/src/index.ts | 77 +--------- packages/server/src/types.ts | 9 +- .../server/src/utils/validate_environment.ts | 121 ++++++++++++++++ 9 files changed, 274 insertions(+), 149 deletions(-) create mode 100644 packages/client/src/components/LoginModal/Inhibit.tsx create mode 100644 packages/client/src/components/LoginModal/LoginModal.tsx create mode 100644 packages/client/src/components/LoginModal/Trust.tsx create mode 100644 packages/server/src/utils/validate_environment.ts diff --git a/packages/client/src/components/LoginModal/Inhibit.tsx b/packages/client/src/components/LoginModal/Inhibit.tsx new file mode 100644 index 0000000..76dedbe --- /dev/null +++ b/packages/client/src/components/LoginModal/Inhibit.tsx @@ -0,0 +1,5 @@ +import { Alert } from "@nextui-org/react"; + +export const Inhibit = () => { + return Logging in is disabled; +}; diff --git a/packages/client/src/components/LoginModal/LoginModal.tsx b/packages/client/src/components/LoginModal/LoginModal.tsx new file mode 100644 index 0000000..8f96e48 --- /dev/null +++ b/packages/client/src/components/LoginModal/LoginModal.tsx @@ -0,0 +1,27 @@ +import { Modal, ModalBody, ModalContent, ModalHeader } from "@nextui-org/react"; +import { Inhibit } from "./Inhibit"; +import { Trust } from "./Trust"; + +export const LoginModal = ({ + mode, + onClose, +}: { + mode?: string; + onClose: () => any; +}) => { + return ( + + + {(_onClose) => ( + <> + Login + + {mode === "INHIBIT" && } + {mode === "TRUST" && } + + + )} + + + ); +}; diff --git a/packages/client/src/components/LoginModal/Trust.tsx b/packages/client/src/components/LoginModal/Trust.tsx new file mode 100644 index 0000000..ddfeeba --- /dev/null +++ b/packages/client/src/components/LoginModal/Trust.tsx @@ -0,0 +1,24 @@ +import { Alert, Button, Input } from "@nextui-org/react"; + +export const Trust = () => { + return ( + <> + DEVELOPMENT: Trust auth mode +
+ + + +
+ + ); +}; diff --git a/packages/client/src/components/Toolbar/Palette.tsx b/packages/client/src/components/Toolbar/Palette.tsx index aad0f06..2f35272 100644 --- a/packages/client/src/components/Toolbar/Palette.tsx +++ b/packages/client/src/components/Toolbar/Palette.tsx @@ -1,13 +1,16 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useAppContext } from "../../contexts/AppContext"; import { Canvas } from "../../lib/canvas"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faXmark } from "@fortawesome/free-solid-svg-icons"; import { KeybindManager } from "../../lib/keybinds"; -import { Button, Link } from "@nextui-org/react"; +import { Button } from "@nextui-org/react"; +import { LoginModal } from "../LoginModal/LoginModal"; +import { toast } from "react-toastify"; export const Palette = () => { const { config, user, cursor, setCursor } = useAppContext(); + const [mode, setMode] = useState(); useEffect(() => { Canvas.instance?.updateCursor(cursor.color); @@ -28,63 +31,87 @@ export const Palette = () => { }; }, []); + const handleLogin = useCallback(() => { + fetch("/api/login", { + redirect: "manual", + }).then(async (res) => { + const location = res.headers.get("location"); + if (location) { + window.location.href = location; + } else { + const data = await res.json(); + if ("error" in data) { + toast.error("Error while logging in: " + data.error); + } + if ("mode" in data) { + setMode(data.mode); + } + } + }); + }, []); + return ( -
-
- - {config.pallete.colors.map((color) => ( + <> +
+
- ))} -
- - {!user && ( -
- {import.meta.env.VITE_INCLUDE_EVENT_INFO ? ( - <>The event has ended - ) : ( -
- You are not logged in - -
- )} + > + + + {config.pallete.colors.map((color) => ( + + ))}
- )} -
+ + {!user && ( +
+ {import.meta.env.VITE_INCLUDE_EVENT_INFO ? ( + <>The event has ended + ) : ( +
+ You are not logged in + +
+ )} +
+ )} +
+ setMode(undefined)} /> + ); }; diff --git a/packages/server/src/api/auth.ts b/packages/server/src/api/auth.ts index 09d7d2f..8c89bc8 100644 --- a/packages/server/src/api/auth.ts +++ b/packages/server/src/api/auth.ts @@ -18,6 +18,7 @@ AuthEndpoints.get("/login", (req, res) => { res.status(400).json({ success: false, error: e.message, + mode: e.mode, }); } else { // eslint-disable-next-line no-console diff --git a/packages/server/src/controllers/OpenIDController.ts b/packages/server/src/controllers/OpenIDController.ts index 7945cf9..3fe44b5 100644 --- a/packages/server/src/controllers/OpenIDController.ts +++ b/packages/server/src/controllers/OpenIDController.ts @@ -2,8 +2,6 @@ import * as Sentry from "@sentry/node"; import type Express from "express"; import * as openid from "openid-client"; -console.log("openid", openid); - import { getLogger } from "../lib/Logger"; import { prisma } from "../lib/prisma"; import { Instance } from "../models/Instance"; @@ -18,9 +16,9 @@ export namespace OpenID { } export enum AuthMode { - NORMAL, - INHIBIT, - TRUST, + NORMAL = "NORMAL", + INHIBIT = "INHIBIT", + TRUST = "TRUST", } export class InvalidAuthMode extends Error { @@ -52,30 +50,22 @@ export class OpenIDController { const instance = (OpenIDController.instance = new OpenIDController()); - if (process.env.DEV_TRUST_AUTH) { - if (process.env.NODE_ENV !== "development") { - // eslint-disable-next-line no-console - console.error( - "FATAL: DEV_TRUST_AUTH is set while not in development environment" - ); - process.exit(1); - } - + if (process.env.AUTH_MODE === "TRUST") { instance.mode = AuthMode.TRUST; // eslint-disable-next-line no-console console.warn( - "OpenID is not setup; DEV_TRUST_AUTH environment variable set!" + "OpenID is not setup; AUTH_MODE is set to TRUST, proceed with caution!" ); return; } - if (process.env.INHIBIT_LOGINS) { + if (process.env.AUTH_MODE === "INHIBIT") { instance.mode = AuthMode.INHIBIT; // eslint-disable-next-line no-console console.warn( - "OpenID is not setup; INHIBIT_LOGINS environment variable set! Proceed with caution!" + "OpenID is not setup; AUTH_MODE is set to INHIBIT, logging in is disabled" ); return; } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index dc27589..4f87269 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -18,82 +18,7 @@ const Logger = getLogger("MAIN"); // Validate environment variables -if (!process.env.PORT || isNaN(parseInt(process.env.PORT))) { - Logger.error("PORT env is not a valid number"); - process.exit(1); -} - -if ( - !process.env.NODE_ENV || - ["development", "production"].indexOf(process.env.NODE_ENV) === -1 -) { - Logger.error("NODE_ENV is not valid [development, production]"); - process.exit(1); -} - -if (!process.env.SESSION_SECRET) { - Logger.error("SESSION_SECRET is not defined"); - process.exit(1); -} - -if (!process.env.NODE_APP_INSTANCE) { - Logger.warn( - "NODE_APP_INSTANCE is not defined, metrics will not include process label" - ); -} - -if (!process.env.PROMETHEUS_TOKEN) { - Logger.warn( - "PROMETHEUS_TOKEN is not defined, /metrics will not be accessable" - ); -} - -if (!process.env.REDIS_HOST) { - Logger.error("REDIS_HOST is not defined"); - process.exit(1); -} - -if (!process.env.REDIS_SESSION_PREFIX) { - Logger.info( - "REDIS_SESSION_PREFIX was not defined, defaulting to canvas_session:" - ); -} - -if (!process.env.REDIS_RATELIMIT_PREFIX) { - Logger.info( - "REDIS_RATELIMIT_PREFIX was not defined, defaulting to canvas_ratelimit:" - ); -} - -if (!process.env.INHIBIT_LOGIN) { - if (!process.env.AUTH_ENDPOINT) { - Logger.error("AUTH_ENDPOINT is not defined"); - process.exit(1); - } - - if (!process.env.AUTH_CLIENT) { - Logger.error("AUTH_CLIENT is not defined"); - process.exit(1); - } - - if (!process.env.AUTH_SECRET) { - Logger.error("AUTH_SECRET is not defined"); - process.exit(1); - } - - if (!process.env.OIDC_CALLBACK_HOST) { - Logger.error("OIDC_CALLBACK_HOST is not defined"); - process.exit(1); - } -} - -if (!process.env.PIXEL_LOG_PATH) { - Logger.warn("PIXEL_LOG_PATH is not defined, defaulting to packages/server"); -} - -if (!process.env.CACHE_WORKERS) { - Logger.warn("CACHE_WORKERS is not defined, defaulting to 1 worker"); -} +import "./utils/validate_environment"; // run startup tasks, all of these need to be completed to serve Promise.all([ diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index e7e3808..3acfecd 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -23,8 +23,6 @@ declare global { PORT: string; LOG_LEVEL?: string; SESSION_SECRET: string; - INHIBIT_LOGINS?: string; // if logins should be prohibited - DEV_TRUST_AUTH?: string; // DEV: allow any username to be used PROMETHEUS_TOKEN?: string; @@ -55,6 +53,13 @@ declare global { */ SERVE_ADMIN?: string; + /** + * How authentication should be handled + * - `NORMAL` (default) + * - `INHIBIT` completely disable logins + * - `TRUST` (DEVELOPMENT) trust all logins + */ + AUTH_MODE: "NORMAL" | "INHIBIT" | "TRUST"; AUTH_ENDPOINT: string; AUTH_CLIENT: string; AUTH_SECRET: string; diff --git a/packages/server/src/utils/validate_environment.ts b/packages/server/src/utils/validate_environment.ts new file mode 100644 index 0000000..1046353 --- /dev/null +++ b/packages/server/src/utils/validate_environment.ts @@ -0,0 +1,121 @@ +import { getLogger } from "../lib/Logger"; +const Logger = getLogger("MAIN"); + +if (!process.env.PORT || isNaN(parseInt(process.env.PORT))) { + Logger.error("PORT env is not a valid number"); + process.exit(1); +} + +if ( + !process.env.NODE_ENV || + ["development", "production"].indexOf(process.env.NODE_ENV) === -1 +) { + Logger.error("NODE_ENV is not valid [development, production]"); + process.exit(1); +} + +if (!process.env.SESSION_SECRET) { + Logger.error("SESSION_SECRET is not defined"); + process.exit(1); +} + +if (!process.env.NODE_APP_INSTANCE) { + Logger.warn( + "NODE_APP_INSTANCE is not defined, metrics will not include process label" + ); +} + +if (!process.env.PROMETHEUS_TOKEN) { + Logger.warn( + "PROMETHEUS_TOKEN is not defined, /metrics will not be accessable" + ); +} + +if (!process.env.REDIS_HOST) { + Logger.error("REDIS_HOST is not defined"); + process.exit(1); +} + +if (!process.env.REDIS_SESSION_PREFIX) { + Logger.info( + "REDIS_SESSION_PREFIX was not defined, defaulting to canvas_session:" + ); +} + +if (!process.env.REDIS_RATELIMIT_PREFIX) { + Logger.info( + "REDIS_RATELIMIT_PREFIX was not defined, defaulting to canvas_ratelimit:" + ); +} + +if (!process.env.INHIBIT_LOGIN) { + if (!process.env.AUTH_ENDPOINT) { + Logger.error("AUTH_ENDPOINT is not defined"); + process.exit(1); + } + + if (!process.env.AUTH_CLIENT) { + Logger.error("AUTH_CLIENT is not defined"); + process.exit(1); + } + + if (!process.env.AUTH_SECRET) { + Logger.error("AUTH_SECRET is not defined"); + process.exit(1); + } + + if (!process.env.OIDC_CALLBACK_HOST) { + Logger.error("OIDC_CALLBACK_HOST is not defined"); + process.exit(1); + } +} + +if (!process.env.PIXEL_LOG_PATH) { + Logger.warn("PIXEL_LOG_PATH is not defined, defaulting to packages/server"); +} + +if (!process.env.CACHE_WORKERS) { + Logger.warn("CACHE_WORKERS is not defined, defaulting to 1 worker"); +} + +// #region Authentication Environment Variables + +// AUTH_MODE defaults to NORMAL if not set +process.env.AUTH_MODE ??= "NORMAL"; + +if (process.env.AUTH_MODE === "NORMAL") { + // in normal auth we need all of these variables + + if (!process.env.AUTH_ENDPOINT) { + Logger.error("AUTH_ENDPOINT is not defined"); + process.exit(1); + } + + if (!process.env.AUTH_CLIENT) { + Logger.error("AUTH_CLIENT is not defined"); + process.exit(1); + } + + if (!process.env.AUTH_SECRET) { + Logger.error("AUTH_SECRET is not defined"); + process.exit(1); + } + + if (!process.env.OIDC_CALLBACK_HOST) { + Logger.error("OIDC_CALLBACK_HOST is not defined"); + process.exit(1); + } +} + +if ( + process.env.AUTH_MODE === "TRUST" && + process.env.NODE_ENV !== "development" +) { + // eslint-disable-next-line no-console + console.error( + "FATAL: AUTH_MODE is TRUST while not in development environment" + ); + process.exit(1); +} + +// #endregion -- GitLab From ef5b4c464ad2e5284be450802f8c36b561eb5d3d Mon Sep 17 00:00:00 2001 From: Grant Date: Sat, 17 May 2025 22:55:54 -0600 Subject: [PATCH 3/4] testing --- .../server/src/__test__/api/client.test.ts | 17 ---- packages/server/src/__test__/lib/oidc.test.ts | 79 ++++++++++++++++++- 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/packages/server/src/__test__/api/client.test.ts b/packages/server/src/__test__/api/client.test.ts index bfb9470..7e71932 100644 --- a/packages/server/src/__test__/api/client.test.ts +++ b/packages/server/src/__test__/api/client.test.ts @@ -108,18 +108,6 @@ describe("Client endpoints /api", () => { app = ExpressController.get().app; }); - it("should return 400 on INHIBIT_LOGINS", (done) => { - process.env.INHIBIT_LOGINS = "true"; - - request(app).get("/api/login").expect(400).expect( - { - success: false, - error: "Login is not permitted.", - }, - done - ); - }); - it("should redirect to OpenID.getAuthorizationURL()", (done) => { jest .spyOn(OpenIDController.prototype, "getAuthorizationURL") @@ -176,11 +164,6 @@ describe("Client endpoints /api", () => { app = ExpressController.get().app; }); - it("INHIBIT_LOGINS should return 400", (done) => { - process.env.INHIBIT_LOGINS = "true"; - request(app).get("/api/callback").expect(400, done); - }); - it("exchangeToken invalid_client", (done) => { jest .spyOn(OpenIDController.prototype, "exchangeToken") diff --git a/packages/server/src/__test__/lib/oidc.test.ts b/packages/server/src/__test__/lib/oidc.test.ts index 9280fa0..60c1021 100644 --- a/packages/server/src/__test__/lib/oidc.test.ts +++ b/packages/server/src/__test__/lib/oidc.test.ts @@ -1,13 +1,16 @@ import { mockReset } from "jest-mock-extended"; +import type { OpenIDController } from "../../controllers/OpenIDController"; import { type prismaMock as IPrismaMock } from "../_prisma.mock"; jest.mock("openid-client", () => { return { default: { discovery: jest.fn(), + buildAuthorizationUrl: jest.fn(() => new URL("http://mock.local")), }, discovery: jest.fn(), + buildAuthorizationUrl: jest.fn(() => new URL("http://mock.local")), }; }); @@ -71,8 +74,19 @@ describe("OpenID", () => { process.env = OLD_ENV; }); - it("should not setup config on INHIBIT_LOGINS", async () => { - process.env.INHIBIT_LOGINS = "true"; + it("should not setup config on AUTH_MODE = INHIBIT", async () => { + process.env.AUTH_MODE = "INHIBIT"; + const { OpenIDController } = await import( + "../../controllers/OpenIDController" + ); + + await OpenIDController.initialize(); + + expect(OpenIDController.get().config).toStrictEqual({}); + }); + + it("should not setup config on AUTH_MODE = TRUST", async () => { + process.env.AUTH_MODE = "TRUST"; const { OpenIDController } = await import( "../../controllers/OpenIDController" ); @@ -83,7 +97,6 @@ describe("OpenID", () => { }); it("should call openid-client#discovery otherwise", async () => { - delete process.env.INHIBIT_LOGINS; process.env.AUTH_ENDPOINT = "http://good.host"; process.env.AUTH_CLIENT = "__client__"; process.env.AUTH_SECRET = "__secret__"; @@ -124,5 +137,65 @@ describe("OpenID", () => { ); }); + describe("InvalidAuthMode", () => { + it("should work", async () => { + const { InvalidAuthMode, AuthMode } = await import( + "../../controllers/OpenIDController" + ); + + const err = new InvalidAuthMode(AuthMode.TRUST, "test"); + + expect(err.message).toBe("test (mode: TRUST)"); + }); + }); + + describe("#getAuthorizationURL", () => { + it("AuthMode.NORMAL", async () => { + const { default: openid } = await import("openid-client"); + + const { OpenIDController } = await import( + "../../controllers/OpenIDController" + ); + + // @ts-expect-error We don't need it to be initialized fully + const controller = new OpenIDController(); + + expect(controller.getAuthorizationURL()).toBe("http://mock.local/"); + expect(openid.buildAuthorizationUrl).toHaveBeenCalled(); + }); + + it("AuthMode.INHIBIT", async () => { + const { default: openid } = await import("openid-client"); + + const { OpenIDController, AuthMode, InvalidAuthMode } = await import( + "../../controllers/OpenIDController" + ); + + // @ts-expect-error We don't need it to be initialized fully + const controller: OpenIDController = new OpenIDController(); + + controller.mode = AuthMode.INHIBIT; + + expect(() => controller.getAuthorizationURL()).toThrow(InvalidAuthMode); + expect(openid.buildAuthorizationUrl).not.toHaveBeenCalled(); + }); + + it("AuthMode.TRUST", async () => { + const { default: openid } = await import("openid-client"); + + const { OpenIDController, AuthMode, InvalidAuthMode } = await import( + "../../controllers/OpenIDController" + ); + + // @ts-expect-error We don't need it to be initialized fully + const controller: OpenIDController = new OpenIDController(); + + controller.mode = AuthMode.TRUST; + + expect(() => controller.getAuthorizationURL()).toThrow(InvalidAuthMode); + expect(openid.buildAuthorizationUrl).not.toHaveBeenCalled(); + }); + }); + // the rest of this file is effectively passthroughs to the core library }); -- GitLab From 98b00b5d32f4a36574146a838f1bf229dd1eab82 Mon Sep 17 00:00:00 2001 From: Grant Date: Sat, 17 May 2025 23:12:03 -0600 Subject: [PATCH 4/4] documentation --- doc/development/authentication.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 doc/development/authentication.md diff --git a/doc/development/authentication.md b/doc/development/authentication.md new file mode 100644 index 0000000..69e4ede --- /dev/null +++ b/doc/development/authentication.md @@ -0,0 +1,19 @@ +--- +title: Authentication +--- + +# Authentication Modes + +## NORMAL (default) + +Requires [sc07/fediverse-auth](https://sc07.dev/sc07/fediverse-auth) to be running and details to be filled in the environment variables (`AUTH_ENDPOINT`, `AUTH_CLIENT`, `AUTH_SECRET`) + +## INHIBIT + +Completely disables logging in + +## TRUST (development) + +`NODE_ENV` needs to be `development` + +Replaces the login process with a form taking a username & instance hostname to assume instead \ No newline at end of file -- GitLab