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