Loading backend/example.env +0 −4 Original line number Diff line number Diff line Loading @@ -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= 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/express.ts +91 −33 Original line number Diff line number Diff line Loading @@ -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"); 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(); Loading
backend/example.env +0 −4 Original line number Diff line number Diff line Loading @@ -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= 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/express.ts +91 −33 Original line number Diff line number Diff line Loading @@ -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"); 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();