Commit c78fa043 authored by Grant's avatar Grant
Browse files

add shadow api

parent 98276ddf
Loading
Loading
Loading
Loading
+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);
+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();
+17 −0
Original line number Diff line number Diff line
@@ -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!;
};
+5 −1
Original line number Diff line number Diff line
@@ -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;
    }
  }
}
Loading