diff --git a/lib/mastodon/routes/oauth.js b/lib/mastodon/routes/oauth.js index a83b8f1..1d9a4ff 100644 --- a/lib/mastodon/routes/oauth.js +++ b/lib/mastodon/routes/oauth.js @@ -158,7 +158,7 @@ router.get("/.well-known/oauth-authorization-server", (req, res) => { "write:statuses", ], response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "client_credentials"], + grant_types_supported: ["authorization_code", "client_credentials", "refresh_token"], token_endpoint_auth_methods_supported: [ "client_secret_basic", "client_secret_post", @@ -174,7 +174,7 @@ router.get("/.well-known/oauth-authorization-server", (req, res) => { router.get("/oauth/authorize", async (req, res, next) => { try { - const { + let { client_id, redirect_uri, response_type, @@ -184,6 +184,21 @@ router.get("/oauth/authorize", async (req, res, next) => { force_login, } = req.query; + // Restore OAuth params from session after login redirect. + // Indiekit's login flow doesn't re-encode the redirect param, so query + // params with & are stripped during the /session/login → /session/auth + // round-trip. We store them in the session before redirecting. + if (!response_type && req.session?.pendingOAuth) { + const p = req.session.pendingOAuth; + delete req.session.pendingOAuth; + client_id = p.client_id; + redirect_uri = p.redirect_uri; + response_type = p.response_type; + scope = p.scope; + code_challenge = p.code_challenge; + code_challenge_method = p.code_challenge_method; + } + if (response_type !== "code") { return res.status(400).json({ error: "unsupported_response_type", @@ -219,11 +234,14 @@ router.get("/oauth/authorize", async (req, res, next) => { // Check if user is logged in via IndieAuth session const session = req.session; if (!session?.access_token && !force_login) { - // Not logged in — redirect to Indiekit login, then back here - const returnUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`; - return res.redirect( - `/auth?redirect=${encodeURIComponent(returnUrl)}`, - ); + // Store OAuth params in session — they won't survive Indiekit's + // 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, + }; + // Redirect to Indiekit's login page with a simple return path. + return res.redirect("/session/login?redirect=/oauth/authorize"); } // Render simple authorization page @@ -304,6 +322,11 @@ router.post("/oauth/authorize", async (req, res, next) => { const code = randomHex(32); const collections = req.app.locals.mastodonCollections; + // Note: accessToken is NOT set here — it's added later during token exchange. + // The sparse unique index on accessToken skips documents where the field is + // absent, allowing multiple auth codes to coexist. Setting it to null would + // cause E11000 duplicate key errors because MongoDB sparse indexes still + // enforce uniqueness on explicit null values. await collections.ap_oauth_tokens.insertOne({ code, clientId: client_id, @@ -311,11 +334,8 @@ router.post("/oauth/authorize", async (req, res, next) => { redirectUri: redirect_uri, codeChallenge: code_challenge || null, codeChallengeMethod: code_challenge_method || null, - accessToken: null, createdAt: new Date(), expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes - usedAt: null, - revokedAt: null, }); // Out-of-band: show code on page @@ -381,19 +401,14 @@ router.post("/oauth/token", async (req, res, next) => { }); } + // No code field — this is a direct token grant, not a code exchange. + // Omitting code (instead of setting null) avoids sparse index collisions. const accessToken = randomHex(64); await collections.ap_oauth_tokens.insertOne({ - code: null, clientId, scopes: ["read"], - redirectUri: null, - codeChallenge: null, - codeChallengeMethod: null, accessToken, createdAt: new Date(), - expiresAt: null, - usedAt: null, - revokedAt: null, grantType: "client_credentials", }); @@ -405,10 +420,49 @@ router.post("/oauth/token", async (req, res, next) => { }); } + // ─── Refresh token grant ────────────────────────────────────────── + if (grant_type === "refresh_token") { + const { refresh_token } = req.body; + if (!refresh_token) { + return res.status(400).json({ + error: "invalid_request", + error_description: "Missing refresh_token", + }); + } + + const existing = await collections.ap_oauth_tokens.findOne({ + refreshToken: refresh_token, + revokedAt: null, + }); + + if (!existing) { + return res.status(400).json({ + error: "invalid_grant", + error_description: "Refresh token is invalid or revoked", + }); + } + + // Rotate: new access token + new refresh token + const newAccessToken = randomHex(64); + const newRefreshToken = randomHex(64); + await collections.ap_oauth_tokens.updateOne( + { _id: existing._id }, + { $set: { accessToken: newAccessToken, refreshToken: newRefreshToken } }, + ); + + return res.json({ + access_token: newAccessToken, + token_type: "Bearer", + scope: existing.scopes.join(" "), + created_at: Math.floor(existing.createdAt.getTime() / 1000), + refresh_token: newRefreshToken, + }); + } + if (grant_type !== "authorization_code") { return res.status(400).json({ error: "unsupported_grant_type", - error_description: "Only authorization_code and client_credentials are supported", + error_description: "Only authorization_code, client_credentials, and refresh_token are supported", }); } @@ -469,11 +523,13 @@ router.post("/oauth/token", async (req, res, next) => { } } - // Generate access token + // Generate access token and refresh token. + // Clear expiresAt — it was set for the auth code, not the access token. const accessToken = randomHex(64); + const refreshToken = randomHex(64); await collections.ap_oauth_tokens.updateOne( { _id: grant._id }, - { $set: { accessToken } }, + { $set: { accessToken, refreshToken, expiresAt: null } }, ); res.json({ @@ -481,6 +537,7 @@ router.post("/oauth/token", async (req, res, next) => { token_type: "Bearer", scope: grant.scopes.join(" "), created_at: Math.floor(grant.createdAt.getTime() / 1000), + refresh_token: refreshToken, }); } catch (error) { next(error); @@ -501,8 +558,9 @@ router.post("/oauth/revoke", async (req, res, next) => { } const collections = req.app.locals.mastodonCollections; + // Match by access token or refresh token await collections.ap_oauth_tokens.updateOne( - { accessToken: token }, + { $or: [{ accessToken: token }, { refreshToken: token }] }, { $set: { revokedAt: new Date() } }, ); diff --git a/package.json b/package.json index 14dab21..c5e2fb1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.5.8", + "version": "3.5.9", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit",