Commit 661fe3a7 authored by Grant's avatar Grant
Browse files

Merge branch 'shadow' into 'main'

Shadow API

See merge request !1
parents 44af8c72 ce9a3d8c
Loading
Loading
Loading
Loading
+0 −4
Original line number Diff line number Diff line
@@ -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=
+49 −1
Original line number Diff line number Diff line
@@ -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);
+91 −33
Original line number Diff line number Diff line
@@ -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 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.redirect("/login?return=" + encodeURIComponent(req.originalUrl));
        res({
          type: "redirect",
          to:
            "/login?return=" +
            encodeURIComponent("/interaction/" + req.params.uid),
        });
      } else {
        try {
        const returnTo = await oidc.interactionResult(req, res, {
          const returnTo = await oidc.interactionResult(req, resp, {
            login: { accountId: req.session.user.sub },
          });

          req.session.destroy(() => {
          res.redirect(returnTo);
            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.send("<h1>session lost</h1>try logging in again");
              res({ type: "error", error: "session_lost" });
            } else {
            res.send("<h1>unknown error</h1> try logging in again");
              res({ type: "error", error: "unknown" });
            }
          });
        }
      }

    return;
    } else {
      res({ type: "continue" });
    }

  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("<h1>session lost</h1><p>Try login again</p>");
            break;
          case "unknown":
            res.send("<h1>unknown error</h1>");
            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("<h1>session lost</h1><p>Try login again</p>");
          break;
        case "unknown":
          res.send("<h1>unknown error</h1>");
          break;
      }
      break;
    case "continue":
    default:
      next();
      break;
  }
});

if (process.env.SERVE_FRONTEND) {
  const indexFile = path.join(process.env.SERVE_FRONTEND, "index.html");

+110 −0
Original line number Diff line number Diff line
@@ -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
+96 −0
Original line number Diff line number Diff line
/**
 * 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<true>;
}

class ShadowAPI_ implements IShadowAPI {
  private async api<T>(
    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<true> {
    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<true> {
    return true;
  }
}

interface IShadowUser {
  sub: string;
  ip: string;
}

export const ShadowAPI = process.env.SHADOW_HOST
  ? new ShadowAPI_()
  : new ShadowAPI_Masked();
Loading