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