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:
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user