fix(oauth): echo state parameter back in authorization redirect

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).
This commit is contained in:
svemagie
2026-03-27 16:47:42 +01:00
parent 9b6db9865c
commit b54146ce5b
+8 -2
View File
@@ -206,6 +206,7 @@ router.get("/oauth/authorize", async (req, res, next) => {
code_challenge, code_challenge,
code_challenge_method, code_challenge_method,
force_login, force_login,
state,
} = req.query; } = req.query;
// Restore OAuth params from session after login redirect. // Restore OAuth params from session after login redirect.
@@ -221,6 +222,7 @@ router.get("/oauth/authorize", async (req, res, next) => {
scope = p.scope; scope = p.scope;
code_challenge = p.code_challenge; code_challenge = p.code_challenge;
code_challenge_method = p.code_challenge_method; code_challenge_method = p.code_challenge_method;
state = p.state;
} }
if (response_type !== "code") { 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. // login redirect chain due to a re-encoding bug in indieauth.js.
req.session.pendingOAuth = { req.session.pendingOAuth = {
client_id, redirect_uri, response_type, scope, 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. // Redirect to Indiekit's login page with a simple return path.
return res.redirect("/session/login?redirect=/oauth/authorize"); return res.redirect("/session/login?redirect=/oauth/authorize");
@@ -300,6 +302,7 @@ router.get("/oauth/authorize", async (req, res, next) => {
<input type="hidden" name="scope" value="${escapeHtml(requestedScopes.join(" "))}"> <input type="hidden" name="scope" value="${escapeHtml(requestedScopes.join(" "))}">
<input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge || "")}"> <input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge || "")}">
<input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method || "")}"> <input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method || "")}">
<input type="hidden" name="state" value="${escapeHtml(state || "")}">
<input type="hidden" name="response_type" value="code"> <input type="hidden" name="response_type" value="code">
<div class="actions"> <div class="actions">
<button type="submit" name="decision" value="approve" class="approve">Authorize</button> <button type="submit" name="decision" value="approve" class="approve">Authorize</button>
@@ -323,6 +326,7 @@ router.post("/oauth/authorize", async (req, res, next) => {
scope, scope,
code_challenge, code_challenge,
code_challenge_method, code_challenge_method,
state,
decision, decision,
} = req.body; } = req.body;
@@ -365,6 +369,7 @@ router.post("/oauth/authorize", async (req, res, next) => {
"error_description", "error_description",
"The resource owner denied the request", "The resource owner denied the request",
); );
if (state) url.searchParams.set("state", state);
return redirectToUri(res, redirect_uri, url.toString()); return redirectToUri(res, redirect_uri, url.toString());
} }
return res.status(403).json({ return res.status(403).json({
@@ -413,9 +418,10 @@ router.post("/oauth/authorize", async (req, res, next) => {
</html>`); </html>`);
} }
// Redirect with code // Redirect with code (and state, which must be echoed back per OAuth 2.0 spec)
const url = new URL(redirect_uri); const url = new URL(redirect_uri);
url.searchParams.set("code", code); url.searchParams.set("code", code);
if (state) url.searchParams.set("state", state);
redirectToUri(res, redirect_uri, url.toString()); redirectToUri(res, redirect_uri, url.toString());
} catch (error) { } catch (error) {
next(error); next(error);