mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
27 issues fixed from multi-dimensional code review (4 Critical, 6 High, 11 Medium, 6 Low): Security (Critical): - Escape HTML in OAuth authorization page to prevent XSS (C1) - Add CSRF protection to OAuth authorize flow (C2) - Replace bypassable regex sanitizer with sanitize-html library (C3) - Enforce OAuth scopes on all Mastodon API routes (C4) Security (Medium/Low): - Fix SSRF via DNS resolution before private IP check (M1) - Add rate limiting to API, auth, and app registration endpoints (M2) - Validate redirect_uri on POST /oauth/authorize (M4) - Fix custom emoji URL injection with scheme validation + escaping (M5) - Remove data: scheme from allowed image sources (L6) - Add access token expiry (1hr) and refresh token rotation (90d) (M3) - Hash client secrets before storage (L3) Architecture: - Extract batch-broadcast.js — shared delivery logic (H1a) - Extract init-indexes.js — MongoDB index creation (H1b) - Extract syndicator.js — syndication logic (H1c) - Create federation-actions.js facade for controllers (M6) - index.js reduced from 1810 to ~1169 lines (35%) Performance: - Cache moderation data with 30s TTL + write invalidation (H6) - Increase inbox queue throughput to 10 items/sec (H5) - Make account enrichment non-blocking with fire-and-forget (H4) - Remove ephemeral getReplies/getLikes/getShares from ingest (M11) - Fix LRU caches to use true LRU eviction (L1) - Fix N+1 backfill queries with batch $in lookup (L2) UI/UX: - Split 3441-line reader.css into 15 feature-scoped files (H2) - Extract inline Alpine.js interaction component (H3) - Reduce sidebar navigation from 7 to 3 items (M7) - Add ARIA live regions for dynamic content updates (M8) - Extract shared CW/non-CW content partial (M9) - Document form handling pattern convention (M10) - Add accessible labels to functional emoji icons (L4) - Convert profile editor to Alpine.js (L5) Audit: documentation-central/audits/2026-03-24-activitypub-code-review.md Plan: documentation-central/plans/2026-03-24-activitypub-audit-fixes.md
709 lines
22 KiB
JavaScript
709 lines
22 KiB
JavaScript
/**
|
|
* OAuth2 routes for Mastodon Client API.
|
|
*
|
|
* Handles app registration, authorization, token exchange, and revocation.
|
|
*/
|
|
import crypto from "node:crypto";
|
|
import { getToken as getCsrfToken, validateToken as validateCsrf } from "../../csrf.js";
|
|
import express from "express";
|
|
|
|
const router = express.Router(); // eslint-disable-line new-cap
|
|
|
|
/**
|
|
* Generate cryptographically random hex string.
|
|
* @param {number} bytes - Number of random bytes
|
|
* @returns {string} Hex-encoded random string
|
|
*/
|
|
function randomHex(bytes) {
|
|
return crypto.randomBytes(bytes).toString("hex");
|
|
}
|
|
|
|
/**
|
|
* Escape HTML special characters to prevent XSS.
|
|
* @param {string} str - Untrusted string
|
|
* @returns {string} Safe HTML text
|
|
*/
|
|
function escapeHtml(str) {
|
|
return String(str ?? "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
/**
|
|
* Hash a client secret for secure storage.
|
|
* @param {string} secret - Plaintext secret
|
|
* @returns {string} SHA-256 hex hash
|
|
*/
|
|
function hashSecret(secret) {
|
|
return crypto.createHash("sha256").update(secret).digest("hex");
|
|
}
|
|
|
|
/**
|
|
* Parse redirect_uris from request — accepts space-separated string or array.
|
|
* @param {string|string[]} value
|
|
* @returns {string[]}
|
|
*/
|
|
function parseRedirectUris(value) {
|
|
if (!value) return ["urn:ietf:wg:oauth:2.0:oob"];
|
|
if (Array.isArray(value)) return value.map((v) => v.trim());
|
|
return value
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Parse scopes from request — accepts space-separated string.
|
|
* @param {string} value
|
|
* @returns {string[]}
|
|
*/
|
|
function parseScopes(value) {
|
|
if (!value) return ["read"];
|
|
return value
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
// ─── POST /api/v1/apps — Register client application ────────────────────────
|
|
|
|
router.post("/api/v1/apps", async (req, res, next) => {
|
|
try {
|
|
const { client_name, redirect_uris, scopes, website } = req.body;
|
|
|
|
const clientId = randomHex(16);
|
|
const clientSecret = randomHex(32);
|
|
const redirectUris = parseRedirectUris(redirect_uris);
|
|
const parsedScopes = parseScopes(scopes);
|
|
|
|
const doc = {
|
|
clientId,
|
|
clientSecretHash: hashSecret(clientSecret),
|
|
name: client_name || "",
|
|
redirectUris,
|
|
scopes: parsedScopes,
|
|
website: website || null,
|
|
confidential: true,
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
const collections = req.app.locals.mastodonCollections;
|
|
await collections.ap_oauth_apps.insertOne(doc);
|
|
|
|
res.json({
|
|
id: doc._id?.toString() || clientId,
|
|
name: doc.name,
|
|
website: doc.website,
|
|
redirect_uris: redirectUris,
|
|
redirect_uri: redirectUris.join(" "),
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
client_secret_expires_at: 0,
|
|
vapid_key: "",
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// ─── GET /api/v1/apps/verify_credentials ─────────────────────────────────────
|
|
|
|
router.get("/api/v1/apps/verify_credentials", async (req, res, next) => {
|
|
try {
|
|
const token = req.mastodonToken;
|
|
if (!token) {
|
|
return res.status(401).json({ error: "The access token is invalid" });
|
|
}
|
|
|
|
const collections = req.app.locals.mastodonCollections;
|
|
const app = await collections.ap_oauth_apps.findOne({
|
|
clientId: token.clientId,
|
|
});
|
|
|
|
if (!app) {
|
|
return res.status(404).json({ error: "Application not found" });
|
|
}
|
|
|
|
res.json({
|
|
id: app._id.toString(),
|
|
name: app.name,
|
|
website: app.website,
|
|
scopes: app.scopes,
|
|
redirect_uris: app.redirectUris,
|
|
redirect_uri: app.redirectUris.join(" "),
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// ─── GET /.well-known/oauth-authorization-server ─────────────────────────────
|
|
|
|
router.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
|
|
res.json({
|
|
issuer: baseUrl,
|
|
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
|
token_endpoint: `${baseUrl}/oauth/token`,
|
|
revocation_endpoint: `${baseUrl}/oauth/revoke`,
|
|
scopes_supported: [
|
|
"read",
|
|
"write",
|
|
"follow",
|
|
"push",
|
|
"profile",
|
|
"read:accounts",
|
|
"read:blocks",
|
|
"read:bookmarks",
|
|
"read:favourites",
|
|
"read:filters",
|
|
"read:follows",
|
|
"read:lists",
|
|
"read:mutes",
|
|
"read:notifications",
|
|
"read:search",
|
|
"read:statuses",
|
|
"write:accounts",
|
|
"write:blocks",
|
|
"write:bookmarks",
|
|
"write:conversations",
|
|
"write:favourites",
|
|
"write:filters",
|
|
"write:follows",
|
|
"write:lists",
|
|
"write:media",
|
|
"write:mutes",
|
|
"write:notifications",
|
|
"write:reports",
|
|
"write:statuses",
|
|
],
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "client_credentials", "refresh_token"],
|
|
token_endpoint_auth_methods_supported: [
|
|
"client_secret_basic",
|
|
"client_secret_post",
|
|
"none",
|
|
],
|
|
code_challenge_methods_supported: ["S256"],
|
|
service_documentation: "https://docs.joinmastodon.org/api/",
|
|
app_registration_endpoint: `${baseUrl}/api/v1/apps`,
|
|
});
|
|
});
|
|
|
|
// ─── GET /oauth/authorize — Show authorization page ──────────────────────────
|
|
|
|
router.get("/oauth/authorize", async (req, res, next) => {
|
|
try {
|
|
let {
|
|
client_id,
|
|
redirect_uri,
|
|
response_type,
|
|
scope,
|
|
code_challenge,
|
|
code_challenge_method,
|
|
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",
|
|
error_description: "Only response_type=code is supported",
|
|
});
|
|
}
|
|
|
|
const collections = req.app.locals.mastodonCollections;
|
|
const app = await collections.ap_oauth_apps.findOne({ clientId: client_id });
|
|
|
|
if (!app) {
|
|
return res.status(400).json({
|
|
error: "invalid_client",
|
|
error_description: "Client application not found",
|
|
});
|
|
}
|
|
|
|
// Determine redirect URI — use provided or default to first registered
|
|
const resolvedRedirectUri =
|
|
redirect_uri || app.redirectUris[0] || "urn:ietf:wg:oauth:2.0:oob";
|
|
|
|
// Validate redirect_uri is registered
|
|
if (!app.redirectUris.includes(resolvedRedirectUri)) {
|
|
return res.status(400).json({
|
|
error: "invalid_redirect_uri",
|
|
error_description: "Redirect URI not registered for this application",
|
|
});
|
|
}
|
|
|
|
// Validate requested scopes are subset of app scopes
|
|
const requestedScopes = scope ? scope.split(/\s+/) : app.scopes;
|
|
|
|
// Check if user is logged in via IndieAuth session
|
|
const session = req.session;
|
|
if (!session?.access_token && !force_login) {
|
|
// 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
|
|
const appName = app.name || "An application";
|
|
res.type("html").send(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Authorize ${escapeHtml(appName)}</title>
|
|
<style>
|
|
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
|
|
h1 { font-size: 1.4rem; }
|
|
.scopes { background: #f5f5f5; padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0; }
|
|
.scopes code { display: block; margin: 0.25rem 0; }
|
|
.actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; }
|
|
button { padding: 0.6rem 1.5rem; border-radius: 6px; font-size: 1rem; cursor: pointer; border: 1px solid #ccc; }
|
|
.approve { background: #2b90d9; color: white; border-color: #2b90d9; }
|
|
.deny { background: white; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Authorize ${escapeHtml(appName)}</h1>
|
|
<p>${escapeHtml(appName)} wants to access your account with these permissions:</p>
|
|
<div class="scopes">
|
|
${requestedScopes.map((s) => `<code>${escapeHtml(s)}</code>`).join("")}
|
|
</div>
|
|
<form method="POST" action="/oauth/authorize">
|
|
<input type="hidden" name="_csrf" value="${escapeHtml(getCsrfToken(req.session))}">
|
|
<input type="hidden" name="client_id" value="${escapeHtml(client_id)}">
|
|
<input type="hidden" name="redirect_uri" value="${escapeHtml(resolvedRedirectUri)}">
|
|
<input type="hidden" name="scope" value="${escapeHtml(requestedScopes.join(" "))}">
|
|
<input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge || "")}">
|
|
<input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method || "")}">
|
|
<input type="hidden" name="response_type" value="code">
|
|
<div class="actions">
|
|
<button type="submit" name="decision" value="approve" class="approve">Authorize</button>
|
|
<button type="submit" name="decision" value="deny" class="deny">Deny</button>
|
|
</div>
|
|
</form>
|
|
</body>
|
|
</html>`);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// ─── POST /oauth/authorize — Process authorization decision ──────────────────
|
|
|
|
router.post("/oauth/authorize", async (req, res, next) => {
|
|
try {
|
|
const {
|
|
client_id,
|
|
redirect_uri,
|
|
scope,
|
|
code_challenge,
|
|
code_challenge_method,
|
|
decision,
|
|
} = req.body;
|
|
|
|
// Validate CSRF token
|
|
if (!validateCsrf(req)) {
|
|
return res.status(403).json({
|
|
error: "invalid_request",
|
|
error_description: "Invalid or missing CSRF token",
|
|
});
|
|
}
|
|
|
|
const collections = req.app.locals.mastodonCollections;
|
|
|
|
// Re-validate redirect_uri against registered app URIs.
|
|
// The GET handler validates this, but POST body can be tampered.
|
|
const app = await collections.ap_oauth_apps.findOne({ clientId: client_id });
|
|
if (!app) {
|
|
return res.status(400).json({
|
|
error: "invalid_client",
|
|
error_description: "Client application not found",
|
|
});
|
|
}
|
|
if (
|
|
redirect_uri &&
|
|
redirect_uri !== "urn:ietf:wg:oauth:2.0:oob" &&
|
|
!app.redirectUris.includes(redirect_uri)
|
|
) {
|
|
return res.status(400).json({
|
|
error: "invalid_redirect_uri",
|
|
error_description: "Redirect URI not registered for this application",
|
|
});
|
|
}
|
|
|
|
// User denied
|
|
if (decision === "deny") {
|
|
if (redirect_uri && redirect_uri !== "urn:ietf:wg:oauth:2.0:oob") {
|
|
const url = new URL(redirect_uri);
|
|
url.searchParams.set("error", "access_denied");
|
|
url.searchParams.set(
|
|
"error_description",
|
|
"The resource owner denied the request",
|
|
);
|
|
return redirectToUri(res, redirect_uri, url.toString());
|
|
}
|
|
return res.status(403).json({
|
|
error: "access_denied",
|
|
error_description: "The resource owner denied the request",
|
|
});
|
|
}
|
|
|
|
// Generate authorization code
|
|
const code = randomHex(32);
|
|
|
|
// 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,
|
|
scopes: scope ? scope.split(/\s+/) : ["read"],
|
|
redirectUri: redirect_uri,
|
|
codeChallenge: code_challenge || null,
|
|
codeChallengeMethod: code_challenge_method || null,
|
|
createdAt: new Date(),
|
|
expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
|
|
});
|
|
|
|
// Out-of-band: show code on page
|
|
if (!redirect_uri || redirect_uri === "urn:ietf:wg:oauth:2.0:oob") {
|
|
return res.type("html").send(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Authorization Code</title>
|
|
<style>
|
|
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
|
|
code { display: block; background: #f5f5f5; padding: 1rem; border-radius: 6px; word-break: break-all; margin: 1rem 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Authorization Code</h1>
|
|
<p>Copy this code and paste it into the application:</p>
|
|
<code>${escapeHtml(code)}</code>
|
|
</body>
|
|
</html>`);
|
|
}
|
|
|
|
// Redirect with code
|
|
const url = new URL(redirect_uri);
|
|
url.searchParams.set("code", code);
|
|
redirectToUri(res, redirect_uri, url.toString());
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// ─── POST /oauth/token — Exchange code for access token ──────────────────────
|
|
|
|
router.post("/oauth/token", async (req, res, next) => {
|
|
try {
|
|
const { grant_type, code, redirect_uri, code_verifier } = req.body;
|
|
|
|
// Extract client credentials from request (3 methods)
|
|
const { clientId, clientSecret } = extractClientCredentials(req);
|
|
|
|
const collections = req.app.locals.mastodonCollections;
|
|
|
|
if (grant_type === "client_credentials") {
|
|
// Client credentials grant — limited access for pre-login API calls
|
|
if (!clientId || !clientSecret) {
|
|
return res.status(401).json({
|
|
error: "invalid_client",
|
|
error_description: "Client authentication required",
|
|
});
|
|
}
|
|
|
|
const app = await collections.ap_oauth_apps.findOne({
|
|
clientId,
|
|
clientSecretHash: hashSecret(clientSecret),
|
|
confidential: true,
|
|
});
|
|
|
|
if (!app) {
|
|
return res.status(401).json({
|
|
error: "invalid_client",
|
|
error_description: "Invalid client credentials",
|
|
});
|
|
}
|
|
|
|
// 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({
|
|
clientId,
|
|
scopes: ["read"],
|
|
accessToken,
|
|
createdAt: new Date(),
|
|
grantType: "client_credentials",
|
|
expiresAt: new Date(Date.now() + 3600 * 1000),
|
|
});
|
|
|
|
return res.json({
|
|
access_token: accessToken,
|
|
token_type: "Bearer",
|
|
scope: "read",
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
expires_in: 3600,
|
|
});
|
|
}
|
|
|
|
// ─── 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,
|
|
expiresAt: new Date(Date.now() + 3600 * 1000),
|
|
refreshExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000),
|
|
},
|
|
},
|
|
);
|
|
|
|
return res.json({
|
|
access_token: newAccessToken,
|
|
token_type: "Bearer",
|
|
scope: existing.scopes.join(" "),
|
|
created_at: Math.floor(existing.createdAt.getTime() / 1000),
|
|
refresh_token: newRefreshToken,
|
|
expires_in: 3600,
|
|
});
|
|
}
|
|
|
|
if (grant_type !== "authorization_code") {
|
|
return res.status(400).json({
|
|
error: "unsupported_grant_type",
|
|
error_description: "Only authorization_code, client_credentials, and refresh_token are supported",
|
|
});
|
|
}
|
|
|
|
if (!code) {
|
|
return res.status(400).json({
|
|
error: "invalid_request",
|
|
error_description: "Missing authorization code",
|
|
});
|
|
}
|
|
|
|
// Atomic claim-or-fail: find the code and mark it used in one operation
|
|
const grant = await collections.ap_oauth_tokens.findOneAndUpdate(
|
|
{
|
|
code,
|
|
usedAt: null,
|
|
revokedAt: null,
|
|
expiresAt: { $gt: new Date() },
|
|
},
|
|
{ $set: { usedAt: new Date() } },
|
|
{ returnDocument: "before" },
|
|
);
|
|
|
|
if (!grant) {
|
|
return res.status(400).json({
|
|
error: "invalid_grant",
|
|
error_description:
|
|
"Authorization code is invalid, expired, or already used",
|
|
});
|
|
}
|
|
|
|
// Validate redirect_uri matches
|
|
if (redirect_uri && grant.redirectUri && redirect_uri !== grant.redirectUri) {
|
|
return res.status(400).json({
|
|
error: "invalid_grant",
|
|
error_description: "Redirect URI mismatch",
|
|
});
|
|
}
|
|
|
|
// Verify PKCE code_verifier if code_challenge was stored
|
|
if (grant.codeChallenge) {
|
|
if (!code_verifier) {
|
|
return res.status(400).json({
|
|
error: "invalid_grant",
|
|
error_description: "Missing code_verifier for PKCE",
|
|
});
|
|
}
|
|
|
|
const expectedChallenge = crypto
|
|
.createHash("sha256")
|
|
.update(code_verifier)
|
|
.digest("base64url");
|
|
|
|
if (expectedChallenge !== grant.codeChallenge) {
|
|
return res.status(400).json({
|
|
error: "invalid_grant",
|
|
error_description: "Invalid code_verifier",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Generate access token and refresh token with expiry.
|
|
const ACCESS_TOKEN_TTL = 3600 * 1000; // 1 hour
|
|
const REFRESH_TOKEN_TTL = 90 * 24 * 3600 * 1000; // 90 days
|
|
const accessToken = randomHex(64);
|
|
const refreshToken = randomHex(64);
|
|
await collections.ap_oauth_tokens.updateOne(
|
|
{ _id: grant._id },
|
|
{
|
|
$set: {
|
|
accessToken,
|
|
refreshToken,
|
|
expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL),
|
|
refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL),
|
|
},
|
|
},
|
|
);
|
|
|
|
res.json({
|
|
access_token: accessToken,
|
|
token_type: "Bearer",
|
|
scope: grant.scopes.join(" "),
|
|
created_at: Math.floor(grant.createdAt.getTime() / 1000),
|
|
refresh_token: refreshToken,
|
|
expires_in: 3600,
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// ─── POST /oauth/revoke — Revoke a token ────────────────────────────────────
|
|
|
|
router.post("/oauth/revoke", async (req, res, next) => {
|
|
try {
|
|
const { token } = req.body;
|
|
|
|
if (!token) {
|
|
return res.status(400).json({
|
|
error: "invalid_request",
|
|
error_description: "Missing token parameter",
|
|
});
|
|
}
|
|
|
|
const collections = req.app.locals.mastodonCollections;
|
|
// Match by access token or refresh token
|
|
await collections.ap_oauth_tokens.updateOne(
|
|
{ $or: [{ accessToken: token }, { refreshToken: token }] },
|
|
{ $set: { revokedAt: new Date() } },
|
|
);
|
|
|
|
// RFC 7009: always return 200 even if token wasn't found
|
|
res.json({});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract client credentials from request using 3 methods:
|
|
* 1. HTTP Basic Auth (client_secret_basic)
|
|
* 2. POST body (client_secret_post)
|
|
* 3. client_id only (none — public clients)
|
|
*/
|
|
function extractClientCredentials(req) {
|
|
// Method 1: HTTP Basic Auth
|
|
const authHeader = req.get("authorization");
|
|
if (authHeader?.startsWith("Basic ")) {
|
|
const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
|
|
const colonIndex = decoded.indexOf(":");
|
|
if (colonIndex > 0) {
|
|
return {
|
|
clientId: decoded.slice(0, colonIndex),
|
|
clientSecret: decoded.slice(colonIndex + 1),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Method 2 & 3: POST body
|
|
return {
|
|
clientId: req.body.client_id || null,
|
|
clientSecret: req.body.client_secret || null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Redirect to a URI, handling custom schemes for native apps.
|
|
*
|
|
* HTTP(S) redirect URIs use a standard 302 redirect (web clients).
|
|
* Custom scheme URIs (fedilab://, moshidon-android-auth://) use an
|
|
* HTML page with JavaScript + meta refresh. Android Chrome Custom Tabs
|
|
* block 302 redirects to non-HTTP schemes but allow client-side navigation.
|
|
*
|
|
* @param {object} res - Express response
|
|
* @param {string} originalUri - The registered redirect_uri (to detect scheme)
|
|
* @param {string} fullUrl - The complete redirect URL with query params
|
|
*/
|
|
function redirectToUri(res, originalUri, fullUrl) {
|
|
if (originalUri.startsWith("http://") || originalUri.startsWith("https://")) {
|
|
return res.redirect(fullUrl);
|
|
}
|
|
|
|
// Native app — HTML page with JS redirect + meta refresh fallback
|
|
res.type("html").send(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta http-equiv="refresh" content="0;url=${escapeHtml(fullUrl)}">
|
|
<title>Redirecting…</title>
|
|
</head>
|
|
<body>
|
|
<p>Redirecting to application…</p>
|
|
<script>window.location.href = ${JSON.stringify(fullUrl)};</script>
|
|
</body>
|
|
</html>`);
|
|
}
|
|
|
|
export default router;
|