From a33e83daf804bfa6287dbf9998e96d865a8aa65f Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 9 Aug 2026 16:20:31 +0200 Subject: [PATCH] feat: implement discourse sso --- nuxt.config.ts | 1 + .../routes/blog/account/sso/discourse.get.ts | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 server/routes/blog/account/sso/discourse.get.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index fa93504..aa51f27 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -48,6 +48,7 @@ export default defineNuxtConfig({ discordGuildId: '', discordTesterRoleId: '', discordSupporterRoleId: '', + discourseSsoSecret: '', public: { apiBase: 'https://api.pretendo.cc', diff --git a/server/routes/blog/account/sso/discourse.get.ts b/server/routes/blog/account/sso/discourse.get.ts new file mode 100644 index 0000000..640eed5 --- /dev/null +++ b/server/routes/blog/account/sso/discourse.get.ts @@ -0,0 +1,65 @@ +import { GetUserDataResponse } from "@pretendonetwork/grpc/api/get_user_data_rpc"; +import { createHmac } from "node:crypto"; + +function getDicourseSignature(secret: string, payload: string) { + return createHmac('sha256', secret).update(payload).digest('hex'); +} + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(event); + const secret = config.discourseSsoSecret; + if (!secret) { + return sendRedirect(event, '/'); + } + + const query = getQuery(event); + const accessTokenCookie = getCookie(event, 'access_token'); + if (!query.sso) { + return sendRedirect(event, '/'); + } + + const signature = getDicourseSignature(secret, query.sso.toString()); + if (!query.sig || signature !== query.sig) { + return sendRedirect(event, '/'); + } + + const payload = new URLSearchParams(Buffer.from(query.sso.toString(), 'base64').toString()); + const nonce = payload.get('nonce'); + const returnSsoUrl = payload.get('return_sso_url'); + if (!nonce || !returnSsoUrl) { + return sendRedirect(event, '/'); + } + + let userData: GetUserDataResponse | null = null; + if (accessTokenCookie) { + const grpc = useApiGrpcWithToken(event, accessTokenCookie); + userData = await grpc.getUserData({}); + } + if (!userData) { + const redirectUrlParams = new URLSearchParams(); + redirectUrlParams.append('sso', query.sso.toString()); + redirectUrlParams.append('sig', query.sig.toString()); + const redirect = `/sso/discourse?${redirectUrlParams}`; + + const urlParams = new URLSearchParams(); + urlParams.append('redirect', redirect) + return sendRedirect(event, `/login?${urlParams}`); // Not logged in, redirect to login + } + + // Build final payload for discourse + const returnPayload = new URLSearchParams({ + nonce: nonce, + external_id: userData.pid.toString(), + email: `${userData.pid}@invalid.com`, // Don't change, used by other systems + username: userData.username, + name: userData.username, + avatar_url: `https://r2-cdn.pretendo.cc/mii/${userData.pid}/normal_face.png`, + avatar_force_update: 'true' + }); + const encodedReturnPayload = Buffer.from(returnPayload.toString()).toString('base64'); + + const url = new URL(returnSsoUrl); + url.searchParams.append('sso', encodedReturnPayload); + url.searchParams.append('sig', getDicourseSignature(secret, encodedReturnPayload)); + return sendRedirect(event, url.toString()); +});