fix: omit null fields instead of setting them in OAuth token documents

MongoDB sparse indexes skip documents where the indexed field is ABSENT,
but still enforce uniqueness on explicit null values. The auth code insert
set accessToken:null and the client_credentials insert set code:null,
causing E11000 duplicate key errors on the second authorization attempt.

Fix: omit accessToken/code entirely from inserts where they don't apply.
The field gets added later during token exchange ($set in updateOne).
This commit is contained in:
Ricardo
2026-03-20 17:25:25 +01:00
parent f55cfbfcd2
commit a8947b205f
2 changed files with 80 additions and 22 deletions
+79 -21
View File
@@ -158,7 +158,7 @@ router.get("/.well-known/oauth-authorization-server", (req, res) => {
"write:statuses", "write:statuses",
], ],
response_types_supported: ["code"], 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: [ token_endpoint_auth_methods_supported: [
"client_secret_basic", "client_secret_basic",
"client_secret_post", "client_secret_post",
@@ -174,7 +174,7 @@ router.get("/.well-known/oauth-authorization-server", (req, res) => {
router.get("/oauth/authorize", async (req, res, next) => { router.get("/oauth/authorize", async (req, res, next) => {
try { try {
const { let {
client_id, client_id,
redirect_uri, redirect_uri,
response_type, response_type,
@@ -184,6 +184,21 @@ router.get("/oauth/authorize", async (req, res, next) => {
force_login, force_login,
} = req.query; } = 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") { if (response_type !== "code") {
return res.status(400).json({ return res.status(400).json({
error: "unsupported_response_type", 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 // Check if user is logged in via IndieAuth session
const session = req.session; const session = req.session;
if (!session?.access_token && !force_login) { if (!session?.access_token && !force_login) {
// Not logged in — redirect to Indiekit login, then back here // Store OAuth params in session — they won't survive Indiekit's
const returnUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`; // login redirect chain due to a re-encoding bug in indieauth.js.
return res.redirect( req.session.pendingOAuth = {
`/auth?redirect=${encodeURIComponent(returnUrl)}`, 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 // Render simple authorization page
@@ -304,6 +322,11 @@ router.post("/oauth/authorize", async (req, res, next) => {
const code = randomHex(32); const code = randomHex(32);
const collections = req.app.locals.mastodonCollections; 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({ await collections.ap_oauth_tokens.insertOne({
code, code,
clientId: client_id, clientId: client_id,
@@ -311,11 +334,8 @@ router.post("/oauth/authorize", async (req, res, next) => {
redirectUri: redirect_uri, redirectUri: redirect_uri,
codeChallenge: code_challenge || null, codeChallenge: code_challenge || null,
codeChallengeMethod: code_challenge_method || null, codeChallengeMethod: code_challenge_method || null,
accessToken: null,
createdAt: new Date(), createdAt: new Date(),
expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
usedAt: null,
revokedAt: null,
}); });
// Out-of-band: show code on page // 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); const accessToken = randomHex(64);
await collections.ap_oauth_tokens.insertOne({ await collections.ap_oauth_tokens.insertOne({
code: null,
clientId, clientId,
scopes: ["read"], scopes: ["read"],
redirectUri: null,
codeChallenge: null,
codeChallengeMethod: null,
accessToken, accessToken,
createdAt: new Date(), createdAt: new Date(),
expiresAt: null,
usedAt: null,
revokedAt: null,
grantType: "client_credentials", 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") { if (grant_type !== "authorization_code") {
return res.status(400).json({ return res.status(400).json({
error: "unsupported_grant_type", 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 accessToken = randomHex(64);
const refreshToken = randomHex(64);
await collections.ap_oauth_tokens.updateOne( await collections.ap_oauth_tokens.updateOne(
{ _id: grant._id }, { _id: grant._id },
{ $set: { accessToken } }, { $set: { accessToken, refreshToken, expiresAt: null } },
); );
res.json({ res.json({
@@ -481,6 +537,7 @@ router.post("/oauth/token", async (req, res, next) => {
token_type: "Bearer", token_type: "Bearer",
scope: grant.scopes.join(" "), scope: grant.scopes.join(" "),
created_at: Math.floor(grant.createdAt.getTime() / 1000), created_at: Math.floor(grant.createdAt.getTime() / 1000),
refresh_token: refreshToken,
}); });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -501,8 +558,9 @@ router.post("/oauth/revoke", async (req, res, next) => {
} }
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
// Match by access token or refresh token
await collections.ap_oauth_tokens.updateOne( await collections.ap_oauth_tokens.updateOne(
{ accessToken: token }, { $or: [{ accessToken: token }, { refreshToken: token }] },
{ $set: { revokedAt: new Date() } }, { $set: { revokedAt: new Date() } },
); );
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "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.", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [ "keywords": [
"indiekit", "indiekit",