From b54146ce5b1d1eed1003ca2d046e746e90381b43 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:47:42 +0100 Subject: [PATCH] fix(oauth): echo state parameter back in authorization redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth 2.0 requires the server to echo the state parameter in the callback redirect. Mastodon clients (e.g. murmel.social) send a state value and fail with 'missing parameters' if it is absent. Thread state through: GET query → session store → hidden form field → POST body → callback redirect (approve and deny paths). --- lib/mastodon/routes/oauth.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/mastodon/routes/oauth.js b/lib/mastodon/routes/oauth.js index 20cd096..2ca8a87 100644 --- a/lib/mastodon/routes/oauth.js +++ b/lib/mastodon/routes/oauth.js @@ -206,6 +206,7 @@ router.get("/oauth/authorize", async (req, res, next) => { code_challenge, code_challenge_method, force_login, + state, } = req.query; // Restore OAuth params from session after login redirect. @@ -221,6 +222,7 @@ router.get("/oauth/authorize", async (req, res, next) => { scope = p.scope; code_challenge = p.code_challenge; code_challenge_method = p.code_challenge_method; + state = p.state; } if (response_type !== "code") { @@ -262,7 +264,7 @@ router.get("/oauth/authorize", async (req, res, next) => { // login redirect chain due to a re-encoding bug in indieauth.js. req.session.pendingOAuth = { client_id, redirect_uri, response_type, scope, - code_challenge, code_challenge_method, + code_challenge, code_challenge_method, state, }; // Redirect to Indiekit's login page with a simple return path. return res.redirect("/session/login?redirect=/oauth/authorize"); @@ -300,6 +302,7 @@ router.get("/oauth/authorize", async (req, res, next) => { +
@@ -323,6 +326,7 @@ router.post("/oauth/authorize", async (req, res, next) => { scope, code_challenge, code_challenge_method, + state, decision, } = req.body; @@ -365,6 +369,7 @@ router.post("/oauth/authorize", async (req, res, next) => { "error_description", "The resource owner denied the request", ); + if (state) url.searchParams.set("state", state); return redirectToUri(res, redirect_uri, url.toString()); } return res.status(403).json({ @@ -413,9 +418,10 @@ router.post("/oauth/authorize", async (req, res, next) => { `); } - // Redirect with code + // Redirect with code (and state, which must be echoed back per OAuth 2.0 spec) const url = new URL(redirect_uri); url.searchParams.set("code", code); + if (state) url.searchParams.set("state", state); redirectToUri(res, redirect_uri, url.toString()); } catch (error) { next(error);