diff --git a/backend/example.env b/backend/example.env
index ca154612ea2791d3fcd996bd2f7aa3a30d0e24b0..c6c0bdad8cc794e4e65a94ae48a0bad6b46608a5 100644
--- a/backend/example.env
+++ b/backend/example.env
@@ -7,10 +7,6 @@ PORT=3000
OIDC_ISSUER=http://localhost:3000
-# redirect routes to this host
-# used by some alternate packager, like vite
-CLIENT_HOST=http://localhost:5173
-
# Lemmy Polyfill
LEMMY_HOST=
LEMMY_USER=
diff --git a/backend/src/lib/api.ts b/backend/src/lib/api.ts
index 26e9075f2e1a226870072c3ddba521b5a6e5008e..2f6be2a584fc682837ff9e78304e28593780acc7 100644
--- a/backend/src/lib/api.ts
+++ b/backend/src/lib/api.ts
@@ -3,6 +3,7 @@ import cookieParser from "cookie-parser";
import { doesInstanceSupportOIDC, oidc } from "./oidc.js";
import {
DOMAIN_REGEX,
+ getExpressIP,
isInstanceDomainValid,
makeClientPublic,
} from "./utils.js";
@@ -12,6 +13,7 @@ import { prisma } from "./prisma.js";
import { ReceiveCodeProvider } from "./delivery/receive.js";
import { IProfile, getUserMeta } from "./instance/userMeta.js";
import { IInstance, getInstanceMeta } from "./instance/instanceMeta.js";
+import { ShadowAPI, UserLoginError } from "./shadow.js";
const app = express.Router();
@@ -25,7 +27,8 @@ app.use(cookieParser());
* Get the current user's session if it exists
*/
app.get("/whoami", async (req, res) => {
- const session = await oidc.Session.find(req.cookies._session);
+ const ctx = oidc.app.createContext(req, res);
+ const session = await oidc.Session.get(ctx);
if (!session || !session.accountId)
return res.json({ success: false, error: "no-session" });
@@ -261,6 +264,17 @@ app.post("/login/step/verify", async (req, res) => {
return;
}
+ if (process.env.BYPASS_CODE_VERIFICATION) {
+ console.warn("BYPASS_CODE_VERIFICATION is set; not verifying 2fa codes");
+
+ req.session.user = { sub: session.user_sub };
+ req.session.login = undefined;
+ req.session.save(() => {
+ res.json({ success: true });
+ });
+ return;
+ }
+
switch (session.mode) {
case "SEND_CODE": {
// code has been sent, we're expecting the user to give a code
@@ -367,6 +381,40 @@ app.post("/interaction/:uid/confirm", async (req, res) => {
.status(400)
.json({ success: false, error: "Invalid interaction" });
+ if (!interaction.session?.accountId) {
+ return res
+ .status(400)
+ .json({ success: false, error: "Failed to get accountId" });
+ }
+
+ const ipAddress = getExpressIP(req);
+
+ try {
+ await ShadowAPI.canUserLogin({
+ sub: interaction.session.accountId,
+ ip: ipAddress,
+ });
+ } catch (e) {
+ if (e instanceof UserLoginError) {
+ res.status(400).json({
+ success: false,
+ error: "shadow",
+ metadata: {
+ message: e.message,
+ },
+ });
+ } else {
+ res.status(400).json({
+ success: false,
+ error: "shadow",
+ metadata: {
+ message: "internal error",
+ },
+ });
+ }
+ return;
+ }
+
let grant;
if (interaction.grantId) {
grant = await oidc.Grant.find(interaction.grantId);
diff --git a/backend/src/lib/express.ts b/backend/src/lib/express.ts
index 4b4bd54dedb13cb343878ea2c882aa8abe2f99f8..5bf32de00e38fb6a21308a202fe6be8fba6d2fd9 100644
--- a/backend/src/lib/express.ts
+++ b/backend/src/lib/express.ts
@@ -34,51 +34,109 @@ app.use(
secure:
process.env.NODE_ENV === "production" && !process.env.USE_INSECURE,
sameSite: "lax",
+ httpOnly: false,
},
// TODO: do not use memory store
})
);
-app.use("/interaction/:uid", async (req, res, next) => {
- const interaction = await oidc.Interaction.find(req.params.uid);
- if (interaction?.prompt.name === "login") {
- if (typeof req.session.user === "undefined") {
- res.redirect("/login?return=" + encodeURIComponent(req.originalUrl));
- } else {
- try {
- const returnTo = await oidc.interactionResult(req, res, {
- login: { accountId: req.session.user.sub },
- });
-
- req.session.destroy(() => {
- res.redirect(returnTo);
- });
- } catch (e) {
- console.error("Error while in interaction middleware", e);
-
- req.session.destroy(() => {
- if (e instanceof OIDC_Errors.SessionNotFound) {
- res.send("
session lost
try logging in again");
- } else {
- res.send("unknown error
try logging in again");
- }
+const interactionMiddleware = (
+ req: express.Request,
+ resp: express.Response
+) => {
+ return new Promise<
+ | { type: "continue" }
+ | { type: "redirect"; to: string }
+ | { type: "error"; error: "session_lost" }
+ | { type: "error"; error: "unknown" }
+ >(async (res) => {
+ const interaction = await oidc.Interaction.find(req.params.uid);
+ if (interaction?.prompt.name === "login") {
+ if (typeof req.session.user === "undefined") {
+ res({
+ type: "redirect",
+ to:
+ "/login?return=" +
+ encodeURIComponent("/interaction/" + req.params.uid),
});
+ } else {
+ try {
+ const returnTo = await oidc.interactionResult(req, resp, {
+ login: { accountId: req.session.user.sub },
+ });
+
+ req.session.destroy(() => {
+ res({ type: "redirect", to: returnTo });
+ });
+ } catch (e) {
+ console.error("Error while in interaction middleware", e);
+
+ req.session.destroy(() => {
+ if (e instanceof OIDC_Errors.SessionNotFound) {
+ res({ type: "error", error: "session_lost" });
+ } else {
+ res({ type: "error", error: "unknown" });
+ }
+ });
+ }
}
+ } else {
+ res({ type: "continue" });
}
+ });
+};
- return;
- }
-
- next();
-});
-
-if (process.env.CLIENT_HOST) {
- app.get(["/interaction*", "/login"], (req, res) => {
- const url = new URL(req.originalUrl, process.env.CLIENT_HOST!);
- res.redirect(url.toString());
+if (process.env.NODE_ENV === "development") {
+ // expose the internals of the interaction middleware for the vite dev server to access
+ app.post("/_dev/interaction/:uid", async (req, res) => {
+ const middleware = await interactionMiddleware(req, res);
+
+ switch (middleware.type) {
+ case "redirect":
+ res.redirect(middleware.to);
+ break;
+ case "error":
+ switch (middleware.error) {
+ case "session_lost":
+ res.send("session lost
Try login again
");
+ break;
+ case "unknown":
+ res.send("unknown error
");
+ break;
+ }
+ break;
+ case "continue":
+ default:
+ res.end();
+ break;
+ }
});
}
+app.use("/interaction/:uid", async (req, res, next) => {
+ const middleware = await interactionMiddleware(req, res);
+
+ switch (middleware.type) {
+ case "redirect":
+ res.redirect(middleware.to);
+ break;
+ case "error":
+ switch (middleware.error) {
+ case "session_lost":
+ res.send("session lost
Try login again
");
+ break;
+ case "unknown":
+ res.send("unknown error
");
+ break;
+ }
+ break;
+ case "continue":
+ default:
+ next();
+ break;
+ }
+});
+
if (process.env.SERVE_FRONTEND) {
const indexFile = path.join(process.env.SERVE_FRONTEND, "index.html");
diff --git a/backend/src/lib/oidc.ts b/backend/src/lib/oidc.ts
index b4c34e9500538986fd95b61e2ff3807e8b415699..2238de744b20a32738c40b15664f9fa4e2b88da9 100644
--- a/backend/src/lib/oidc.ts
+++ b/backend/src/lib/oidc.ts
@@ -4,6 +4,7 @@ import { PrismaAdapter } from "./adapter.js";
import { Issuer } from "openid-client";
import { IInstance, getInstanceMeta } from "./instance/instanceMeta.js";
import { IProfile, getUserMeta } from "./instance/userMeta.js";
+import { ShadowAPI, UserLoginError } from "./shadow.js";
/**
* ⚠ DEVELOPMENT KEYS ⚠
@@ -113,6 +114,115 @@ export const oidc = new Provider(process.env.OIDC_ISSUER!, {
},
});
+type RouteName =
+ | "authorization"
+ | "backchannel_authentication"
+ | "client_delete"
+ | "client_update"
+ | "client"
+ | "code_verification"
+ | "cors.device_authorization"
+ | "cors.discovery"
+ | "cors.introspection"
+ | "cors.jwks"
+ | "cors.pushed_authorization_request"
+ | "cors.revocation"
+ | "cors.token"
+ | "cors.userinfo"
+ | "device_authorization"
+ | "device_resume"
+ | "discovery"
+ | "end_session_confirm"
+ | "end_session_success"
+ | "end_session"
+ | "introspection"
+ | "jwks"
+ | "pushed_authorization_request"
+ | "registration"
+ | "resume"
+ | "revocation"
+ | "token"
+ | "userinfo";
+
+/**
+ * Check if the requesting/requested user is banned via Shadow
+ *
+ * Requesting user: /api/oidc/auth
+ * Requested user: /api/oidc/token,/api/oidc/me
+ *
+ * Requesting user should be given an UI explaining why, with an option to logout
+ *
+ * Requested user should return an access_token invalid error
+ */
+oidc.use(async (ctx, next) => {
+ // check shadow privileges
+
+ if (ctx.path.startsWith("/api/oidc/auth")) {
+ // force all auth to show consent page
+ ctx.query.prompt = "consent";
+ }
+
+ await next();
+
+ const route: RouteName | undefined = ctx.oidc?.route;
+
+ const BLACKLISTED_ENDPOINTS: RouteName[] = [
+ "authorization",
+ "backchannel_authentication",
+ "code_verification",
+ "token",
+ "userinfo",
+ "cors.token",
+ "cors.userinfo",
+ ];
+
+ if (!route) return;
+
+ if (BLACKLISTED_ENDPOINTS.indexOf(route) === -1) {
+ // not an endpoint we care about
+ return;
+ }
+
+ switch (route) {
+ case "token":
+ case "userinfo":
+ case "cors.token":
+ case "cors.userinfo":
+ if ("access_token" in ctx.body) {
+ // is an authorization response
+
+ const token = await oidc.AccessToken.find(ctx.body.access_token);
+ if (!token) {
+ // no idea how we got here, but we can just let it be
+ return;
+ }
+
+ const { accountId } = token;
+
+ try {
+ await ShadowAPI.canUserLogin({ sub: accountId, ip: ctx.ip });
+ } catch (e) {
+ await token.destroy(); // regardless of error, we destroy the access token
+
+ if (e instanceof UserLoginError) {
+ // has details we can share
+ ctx.body = {
+ error: "invalid_grant",
+ error_description: "shadow: " + e.message,
+ };
+ } else {
+ // internal shadow error
+ ctx.body = {
+ error: "invalid_grant",
+ error_description: "internal error",
+ };
+ }
+ }
+ }
+ break;
+ }
+});
+
/**
* Check if instance supports OIDC discovery and dynamic client registration
* @param instance_hostname
diff --git a/backend/src/lib/shadow.ts b/backend/src/lib/shadow.ts
new file mode 100644
index 0000000000000000000000000000000000000000..226c10d0f3c7888d9da0a21ea9b302d54a899beb
--- /dev/null
+++ b/backend/src/lib/shadow.ts
@@ -0,0 +1,96 @@
+/**
+ * Shadow moderation API utilities
+ */
+
+// TODO: Redis subscriptions for ban notifications
+
+interface IShadowAPI {
+ /**
+ * Check if a user can login
+ *
+ * This should be expected to be called frequently
+ * Cache this and bust it with Redis subscription
+ *
+ * @throws UserLoginError on failure
+ * @param user
+ * @returns true
+ */
+ canUserLogin(user: IShadowUser): Promise;
+}
+
+class ShadowAPI_ implements IShadowAPI {
+ private async api(
+ endpoint: `/${string}`,
+ method = "GET",
+ body?: any
+ ): Promise<{ status: number; data: T }> {
+ let headers: { [k: string]: string } = {
+ Authorization: "Bearer " + process.env.SHADOW_TOKEN,
+ };
+ let params: RequestInit = {
+ method,
+ };
+
+ if (typeof body !== "undefined") {
+ headers["Content-Type"] = "application/json";
+ params.body = JSON.stringify(body);
+ }
+
+ params.headers = headers;
+
+ const req = await fetch(
+ process.env.SHADOW_HOST! + "/api/fediverse-auth/v1" + endpoint,
+ params
+ );
+
+ const res = await req.json();
+
+ return {
+ status: req.status,
+ data: res as any,
+ };
+ }
+
+ async canUserLogin(user: IShadowUser): Promise {
+ const { status, data } = await this.api<{
+ can_login: boolean;
+ reason?: string;
+ }>("/login", "POST", {
+ sub: user.sub,
+ ip: user.ip,
+ });
+
+ if (status === 200 && data.can_login) {
+ return true;
+ } else {
+ throw new UserLoginError(data?.reason || "Unknown error");
+ }
+ }
+}
+
+export class UserLoginError extends Error {
+ constructor(reason: string) {
+ super(reason);
+ this.name = "UserLoginError";
+ }
+}
+
+/**
+ * The SHADOW_HOST environment variable isn't set
+ *
+ * Assume every login is permitted
+ */
+class ShadowAPI_Masked implements IShadowAPI {
+ async canUserLogin(user: IShadowUser): Promise {
+ return true;
+ }
+}
+
+interface IShadowUser {
+ sub: string;
+ ip: string;
+}
+
+export const ShadowAPI = process.env.SHADOW_HOST
+ ? new ShadowAPI_()
+ : new ShadowAPI_Masked();
diff --git a/backend/src/lib/utils.ts b/backend/src/lib/utils.ts
index ba210ef651c9650d227e348a5b9ca9121814e818..ee41aa5e765c8756084fd5442355aa413792b207 100644
--- a/backend/src/lib/utils.ts
+++ b/backend/src/lib/utils.ts
@@ -2,6 +2,7 @@ import { NodeInfo } from "../types/nodeinfo.js";
import { IOIDC_Public_Client } from "../types/oidc.js";
import { getNodeInfo } from "./nodeinfo.js";
import { oidc } from "./oidc.js";
+import { type Request } from "express";
/**
* Domain name regex
@@ -71,3 +72,19 @@ export const getSafeURL = (unsafe_url: string): string | undefined => {
return unsafe_url;
};
+
+export const getExpressIP = (req: Request): string => {
+ if (process.env.NODE_ENV === "production") {
+ let ip: string | undefined;
+
+ if (typeof req.headers["x-forwarded-for"] === "string") {
+ ip = req.headers["x-forwarded-for"];
+ } else {
+ ip = req.headers["x-forwarded-for"]?.[0];
+ }
+
+ return ip || req.socket.remoteAddress!;
+ }
+
+ return req.socket.remoteAddress!;
+};
diff --git a/backend/src/types/env.d.ts b/backend/src/types/env.d.ts
index b2876225ca011366e9ebf3459a28a62409fff8e9..0d64b51554e7d458d3b01baae6f5e9a761773f9a 100644
--- a/backend/src/types/env.d.ts
+++ b/backend/src/types/env.d.ts
@@ -7,7 +7,6 @@ declare global {
PORT: string;
OIDC_ISSUER: string;
- CLIENT_HOST?: string;
LEMMY_HOST?: string;
LEMMY_USER?: string;
@@ -22,6 +21,11 @@ declare global {
OIDC_COOKIE_KEYS_FILE?: string;
OIDC_REGISTRATION_TOKEN?: string;
USE_INSECURE?: string;
+
+ SHADOW_HOST?: string;
+ SHADOW_TOKEN?: string;
+
+ BYPASS_CODE_VERIFICATION?: string;
}
}
}
diff --git a/frontend/example.env b/frontend/example.env
index fc87a6244dd651ed0603be9e927562197766c241..9e6caae61ec6ba660223b2ffed505ac907c5ce72 100644
--- a/frontend/example.env
+++ b/frontend/example.env
@@ -1,3 +1,3 @@
# Development Use
# Specify the backend url
-# VITE_APP_ROOT=http://localhost:3000
\ No newline at end of file
+# DEV_BACKEND_HOST=http://localhost:3000
\ No newline at end of file
diff --git a/frontend/src/Interaction/InteractionPage.tsx b/frontend/src/Interaction/InteractionPage.tsx
index 3349d5275f067bc3fc0af5ab6b7eaae51460955f..0e5bbdab89599912c7497fdc9199d56966cfea5f 100644
--- a/frontend/src/Interaction/InteractionPage.tsx
+++ b/frontend/src/Interaction/InteractionPage.tsx
@@ -23,7 +23,10 @@ export const InteractionPage = () => {
const [loading_approve, setLoading_Approve] = useState(false);
const [loading_deny, setLoading_Deny] = useState(false);
const [fatalError, setFatalError] = useState();
- const [error, setError] = useState();
+ const [error, setError] = useState<{
+ type: "shadow" | "error";
+ message: string;
+ }>();
const [interaction, setInteraction] = useState();
const [user, setUser] = useState<{ sub: string }>();
@@ -85,13 +88,31 @@ export const InteractionPage = () => {
const doApprove = async () => {
setLoading_Approve(true);
api<
- { success: true; returnTo: string } | { success: false; error: string }
+ | { success: true; returnTo: string }
+ | { success: false; error: string; metadata?: any }
>("/api/v1/interaction/" + interactionId + "/confirm", "POST")
- .then((data) => {
- if (data.status === 200 && data.data.success) {
- window.open(data.data.returnTo, "_self");
+ .then(({ status, data }): any => {
+ if (status === 200 && data.success) {
+ window.open(data.returnTo, "_self");
} else {
- setError((data.data as any).error);
+ if (data.success) {
+ setError({
+ type: "error",
+ message: "Unknown error",
+ });
+ } else {
+ if (data.error === "shadow") {
+ setError({
+ type: "shadow",
+ message: data.metadata?.message || "Unknown Error",
+ });
+ } else {
+ setError({
+ type: "error",
+ message: data.error,
+ });
+ }
+ }
}
})
.finally(() => {
@@ -102,7 +123,8 @@ export const InteractionPage = () => {
const doDeny = async () => {
setLoading_Deny(true);
api<
- { success: true; returnTo: string } | { success: false; error: string }
+ | { success: true; returnTo: string }
+ | { success: false; error: string; metadata?: any }
>("/api/v1/interaction/" + interactionId + "/abort", "POST")
.then((data) => {
if (data.status === 200 && data.data.success) {
@@ -132,7 +154,17 @@ export const InteractionPage = () => {
Fediverse Auth
- {error && {error}}
+ {error?.type === "error" && (
+ {error.message}
+ )}
+
+ {error?.type === "shadow" && (
+
+ Shadow Error
+
+ {error.message}
+
+ )}
{client && }
{user ? (
diff --git a/frontend/src/Logout/Logout.tsx b/frontend/src/Logout/Logout.tsx
index ac14cc6e8db5f2beecf043ab9e672b3f13764388..25031a1f42bc720a12b9291984d8dca5717903f4 100644
--- a/frontend/src/Logout/Logout.tsx
+++ b/frontend/src/Logout/Logout.tsx
@@ -17,7 +17,7 @@ export const LogoutPage = () => {
>();
useEffect(() => {
- fetch(import.meta.env.VITE_APP_ROOT + "/api/v1/whoami", {
+ fetch("/api/v1/whoami", {
credentials: "include",
})
.then((a) => a.json())
@@ -33,7 +33,7 @@ export const LogoutPage = () => {
const doLogout = () => {
setLoading(true);
- fetch(import.meta.env.VITE_APP_ROOT + "/api/v1/logout", {
+ fetch("/api/v1/logout", {
method: "POST",
credentials: "include",
})
diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fea1047f1aa79eedee74667a9e9e3df7fb6aee1f
--- /dev/null
+++ b/frontend/src/env.d.ts
@@ -0,0 +1,10 @@
+declare global {
+ namespace NodeJS {
+ interface ProcessEnv {
+ NODE_ENV: "development" | "production";
+ DEV_BACKEND_HOST?: string;
+ }
+ }
+}
+
+export {};
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 861b04b35601de92787a1a0db6c9fa190975d220..4fd54843ca6be1ff9ba5c125bd25417b8b9df6fc 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,7 +1,99 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react-swc'
+import { PluginOption, defineConfig, loadEnv } from "vite";
+import react from "@vitejs/plugin-react-swc";
+
+/**
+ * Run middlewares during development
+ * @param API_BACKEND_HOST
+ * @returns
+ */
+function dev_middleware(API_BACKEND_HOST: string | undefined): PluginOption {
+ return {
+ name: "dev-middleware",
+ config() {
+ return {
+ server: {},
+ preview: {},
+ };
+ },
+ configureServer(server) {
+ if (typeof API_BACKEND_HOST === "undefined") {
+ throw new Error("DEV_BACKEND_HOST is not specified");
+ }
+
+ // the backend has interaction middleware to sync login sessions w/ oidc-provider
+ // this adds the middleware to vite
+ server.middlewares.use("/interaction", async (req, res, next) => {
+ // grab the interaction ID from the url (which has the format of /interaction/:uid)
+ const interactionId = (req.url || "").slice(1).split("/")[0];
+
+ // send request to backend using development endpoint
+ // (only exposed when backend is NODE_ENV===development)
+ const middle = await fetch(
+ API_BACKEND_HOST + "/_dev/interaction/" + interactionId,
+ {
+ method: "POST",
+ redirect: "manual",
+ headers: {
+ // we need to pass the cookies the client is using to keep sessions
+ cookie: req.headers.cookie || "",
+ },
+ }
+ );
+
+ // if the middleware is redirecting we need to follow the redirect
+ if (middle.headers.get("Location")) {
+ let location = new URL(
+ middle.headers.get("Location")!,
+ "http://" + req.headers.host
+ );
+
+ if (location.host !== req.headers.host) {
+ // sometimes the backend will send a redirect including the backend's port,
+ // which will cause an error in development mode due to the backend not serving the client
+ location.host = req.headers.host!;
+ }
+
+ // use a temporary redirect, like what the backend would send
+ res.statusCode = 302;
+ res.setHeader("Location", location.toString());
+ res.end();
+ return;
+ }
+
+ // something errored in the backend
+ if (middle.status !== 200) {
+ console.log(await middle.text());
+ res.write(
+ "Backend internal error, check console (development message)"
+ );
+ res.end();
+ return;
+ }
+
+ // the middleware didn't need to do anything, resume normal rendering
+ next();
+ });
+ },
+ };
+}
// https://vitejs.dev/config/
-export default defineConfig({
- plugins: [react()],
-})
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), "");
+
+ if (env.DEV_BACKEND_HOST) {
+ return {
+ plugins: [react(), dev_middleware(env.DEV_BACKEND_HOST)],
+ server: {
+ proxy: {
+ "/api": env.DEV_BACKEND_HOST,
+ "/logout": env.DEV_BACKEND_HOST,
+ },
+ },
+ };
+ }
+
+ return {
+ plugins: [react()],
+ };
+});