diff --git a/doc/development/authentication.md b/doc/development/authentication.md
new file mode 100644
index 0000000000000000000000000000000000000000..69e4edeefb6c247bda40d085f855ac7f89994403
--- /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
diff --git a/packages/client/src/components/LoginModal/Inhibit.tsx b/packages/client/src/components/LoginModal/Inhibit.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..76dedbe0ac01569934f352abd997f64e00189689
--- /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 0000000000000000000000000000000000000000..8f96e48cbc2c6ffa3cc077c2ea95f5295b5e5c73
--- /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 0000000000000000000000000000000000000000..ddfeeba520fe44994494389e3e4b2142f14dc1ad
--- /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 aad0f06a5b1dfd4605ced15a29583f38aaf3c8b9..2f3527287364b5508ef39f33abe992164cb27c90 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/__test__/api/client.test.ts b/packages/server/src/__test__/api/client.test.ts
index 39cbefdf25d8f1668732ffd640c8402441125df3..7e719325259fa6b850630a7fbf0f6f519dcc5b35 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;
@@ -105,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")
@@ -173,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 9280fa0b1b4dfbd530ed00017153fe459e88e2ca..60c10213ecc7d9adfd9d539f3cbc4a35e7393ff4 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
});
diff --git a/packages/server/src/api/auth.ts b/packages/server/src/api/auth.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8c89bc8aab681d48dd1737640e613e1f20ed9fd4
--- /dev/null
+++ b/packages/server/src/api/auth.ts
@@ -0,0 +1,81 @@
+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,
+ mode: e.mode,
+ });
+ } 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 ea10e6e03674234d81f0fae7516b45a2cbee760e..aa8dcd23c93019edaf9345504eb82abb4251b9e0 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 3802b696566379807161e58ff29c2bcfa713ce5f..3fe44b56630625814e6fb1d5bd09f2fecc7db86c 100644
--- a/packages/server/src/controllers/OpenIDController.ts
+++ b/packages/server/src/controllers/OpenIDController.ts
@@ -1,14 +1,43 @@
+import * as Sentry from "@sentry/node";
+import type Express from "express";
import * as openid from "openid-client";
+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 = "NORMAL",
+ INHIBIT = "INHIBIT",
+ TRUST = "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,10 +50,22 @@ export class OpenIDController {
const instance = (OpenIDController.instance = new OpenIDController());
- if (process.env.INHIBIT_LOGINS) {
+ if (process.env.AUTH_MODE === "TRUST") {
+ instance.mode = AuthMode.TRUST;
+
+ // eslint-disable-next-line no-console
+ console.warn(
+ "OpenID is not setup; AUTH_MODE is set to TRUST, proceed with caution!"
+ );
+ return;
+ }
+
+ 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;
}
@@ -52,7 +93,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 +133,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/index.ts b/packages/server/src/index.ts
index dc2758906b415bbcc90d9bfb9d4d419856c3b2e3..4f872693027cfd13b819949bfdb80724143f9ff2 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 5e2a53a0dcaa2edf08b1a0ecc2661b2fce97c85b..3acfecd60f332215d3ad74400d0c905cabdfb162 100644
--- a/packages/server/src/types.ts
+++ b/packages/server/src/types.ts
@@ -23,7 +23,6 @@ declare global {
PORT: string;
LOG_LEVEL?: string;
SESSION_SECRET: string;
- INHIBIT_LOGINS?: string; // if logins should be prohibited
PROMETHEUS_TOKEN?: string;
@@ -54,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/TypedPromise.ts b/packages/server/src/utils/TypedPromise.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3784b8f2152da980acc3fe1736f48529ef548bcf
--- /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;
+}
diff --git a/packages/server/src/utils/validate_environment.ts b/packages/server/src/utils/validate_environment.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1046353f8f8cb02b3a8a0f047b597044acad152e
--- /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