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",
],
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() } },
);
+1 -1
View File
@@ -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",