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

View File

@@ -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) => {
<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_method" value="${escapeHtml(code_challenge_method || "")}">
<input type="hidden" name="state" value="${escapeHtml(state || "")}">
<input type="hidden" name="response_type" value="code">
<div class="actions">
<button type="submit" name="decision" value="approve" class="approve">Authorize</button>
@@ -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) => {
</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);
url.searchParams.set("code", code);
if (state) url.searchParams.set("state", state);
redirectToUri(res, redirect_uri, url.toString());
} catch (error) {
next(error);