Loading backend/src/lib/api.ts +49 −1 Original line number Diff line number Diff line Loading @@ -3,6 +3,7 @@ import cookieParser from "cookie-parser"; import { doesInstanceSupportOIDC, oidc } from "./oidc.js"; import { DOMAIN_REGEX, getExpressIP, isInstanceDomainValid, makeClientPublic, } from "./utils.js"; Loading @@ -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(); Loading @@ -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" }); Loading Loading @@ -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 Loading Loading @@ -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); Loading backend/src/lib/oidc.ts +110 −0 Original line number Diff line number Diff line Loading @@ -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 ⚠ Loading Loading @@ -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 Loading backend/src/lib/shadow.ts 0 → 100644 +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(); backend/src/lib/utils.ts +17 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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!; }; backend/src/types/env.d.ts +5 −1 Original line number Diff line number Diff line Loading @@ -7,7 +7,6 @@ declare global { PORT: string; OIDC_ISSUER: string; CLIENT_HOST?: string; LEMMY_HOST?: string; LEMMY_USER?: string; Loading @@ -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 Loading
backend/src/lib/api.ts +49 −1 Original line number Diff line number Diff line Loading @@ -3,6 +3,7 @@ import cookieParser from "cookie-parser"; import { doesInstanceSupportOIDC, oidc } from "./oidc.js"; import { DOMAIN_REGEX, getExpressIP, isInstanceDomainValid, makeClientPublic, } from "./utils.js"; Loading @@ -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(); Loading @@ -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" }); Loading Loading @@ -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 Loading Loading @@ -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); Loading
backend/src/lib/oidc.ts +110 −0 Original line number Diff line number Diff line Loading @@ -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 ⚠ Loading Loading @@ -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 Loading
backend/src/lib/shadow.ts 0 → 100644 +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();
backend/src/lib/utils.ts +17 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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!; };
backend/src/types/env.d.ts +5 −1 Original line number Diff line number Diff line Loading @@ -7,7 +7,6 @@ declare global { PORT: string; OIDC_ISSUER: string; CLIENT_HOST?: string; LEMMY_HOST?: string; LEMMY_USER?: string; Loading @@ -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