Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/ap/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createOutboxHandler, createOutboxPostHandler } from './routes/outbox.js
import { createCollectionsHandler } from './routes/collections.js'
import { createActorHandler } from './routes/actor.js'
import { createAppsHandler, createVerifyCredentialsHandler, createInstanceHandler } from './routes/mastodon.js'
import { createAuthorizeHandler, createAuthorizePostHandler, createTokenHandler } from './routes/oauth.js'

// Shared state for actor handler (accessed by server.js)
let sharedActorHandler = null
Expand Down Expand Up @@ -179,6 +180,27 @@ export async function activityPubPlugin(fastify, options = {}) {
fastify.post('/api/v1/apps', createAppsHandler())
fastify.get('/api/v1/accounts/verify_credentials', createVerifyCredentialsHandler(config))
fastify.get('/api/v1/instance', createInstanceHandler(config))

// OAuth 2.0 authorize/token flow (Mastodon clients, remoteStorage, third-party panes)
fastify.get('/oauth/authorize', createAuthorizeHandler())
fastify.post('/oauth/authorize', {
config: {
rateLimit: {
max: 10,
timeWindow: '1 minute',
keyGenerator: (request) => request.ip
}
}
}, createAuthorizePostHandler())
fastify.post('/oauth/token', {
config: {
rateLimit: {
max: 10,
timeWindow: '1 minute',
keyGenerator: (request) => request.ip
}
}
}, createTokenHandler())
}

export default activityPubPlugin
284 changes: 284 additions & 0 deletions src/ap/routes/oauth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
/**
* OAuth 2.0 authorize/token flow
* Shared infrastructure for Mastodon clients, remoteStorage apps, and third-party panes
*
* Refs: https://docs.joinmastodon.org/methods/oauth/
* https://datatracker.ietf.org/doc/html/rfc6749
*
* Related: #158, #159 (Mastodon API), #106 (remoteStorage), #160 (this)
*/

import crypto from 'crypto'
import { getClient } from './mastodon.js'
import { authenticate } from '../../idp/accounts.js'
import { createToken } from '../../auth/token.js'

// Mastodon OOB redirect — display code instead of redirecting
const OOB_REDIRECT = 'urn:ietf:wg:oauth:2.0:oob'

// Auth codes: code → { clientId, redirectUri, webId, scope, expiresAt }
const authCodes = new Map()

// Clean up expired codes every 60s
setInterval(() => {
const now = Date.now()
for (const [code, data] of authCodes) {
if (data.expiresAt < now) authCodes.delete(code)
}
}, 60000).unref()

/**
* Parse request body — handles JSON and form-urlencoded
*/
function parseBody (request) {
if (request.body && typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
return request.body
}
const raw = Buffer.isBuffer(request.body) ? request.body.toString() : String(request.body || '')
const ct = request.headers['content-type'] || ''
if (ct.includes('application/json')) {
try { return JSON.parse(raw) } catch { return {} }
}
return Object.fromEntries(new URLSearchParams(raw))
}
Comment on lines +33 to +43

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated parseBody function. This function is an exact copy of the one in mastodon.js (lines 20–31). Consider extracting it to a shared utility module (e.g., src/ap/routes/utils.js or src/ap/utils.js) and importing it in both files to avoid duplication and ensure consistent behavior if the parsing logic needs to be updated.

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +43

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parseBody function is duplicated verbatim from src/ap/routes/mastodon.js (lines 20-31). This is the same pattern of duplication noted in the stored memories. Consider extracting this into a shared utility (e.g., src/ap/utils.js) to avoid divergence if one copy is updated but not the other.

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +43

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseBody is an exact duplicate of the function in src/ap/routes/mastodon.js:20-31. Since oauth.js already imports from mastodon.js (for getClient), consider exporting parseBody from mastodon.js and reusing it here instead of duplicating the logic. This also applies to escapeHtml which is duplicated in at least 4 files across the codebase (mastodon.js:145, idp/views.js:768, mashlib/index.js:224, webid/profile.js:126).

Copilot uses AI. Check for mistakes.

/**
* Validate client_id and redirect_uri against registered client
* Returns { client, error } — client is null if validation fails
*/
function validateClient (clientId, redirectUri) {
if (!clientId || !redirectUri) {
return { client: null, error: 'Missing client_id or redirect_uri' }
}

const client = getClient(clientId)
if (!client) {
return { client: null, error: 'Unknown client_id. Register via POST /api/v1/apps first.' }
}

// Validate redirect_uri matches registered value (RFC 6749 §10.6)
if (redirectUri !== OOB_REDIRECT && redirectUri !== client.redirect_uri) {
return { client: null, error: 'redirect_uri does not match registered value' }
}

return { client, error: null }
}

/**
* GET /oauth/authorize — Show login/consent page
*/
export function createAuthorizeHandler () {
return async (request, reply) => {
const { client_id, redirect_uri, response_type, scope, state } = request.query

if (response_type && response_type !== 'code') {
return reply.code(400).send({ error: 'unsupported_response_type', error_description: 'Only response_type=code is supported' })
}

const { client, error } = validateClient(client_id, redirect_uri)
if (!client) {
return reply.code(400).send({ error: 'invalid_client', error_description: error })
}

return reply.type('text/html').send(
loginPage({ clientId: client_id, redirectUri: redirect_uri, scope: scope || 'read', state, clientName: client.name })
)
}
}

/**
* POST /oauth/authorize — Process login form
*/
export function createAuthorizePostHandler () {
return async (request, reply) => {
const body = parseBody(request)
const { username, password, client_id, redirect_uri, scope, state } = body

// Validate client + redirect_uri (prevent open redirect via form tampering)
const { client, error: clientError } = validateClient(client_id, redirect_uri)
if (!client) {
return reply.code(400).send({ error: 'invalid_client', error_description: clientError })
}

if (!username || !password) {
return reply.type('text/html').send(
loginPage({ clientId: client_id, redirectUri: redirect_uri, scope, state, clientName: client.name, error: 'Username and password are required' })
)
}

const account = await authenticate(username, password)
if (!account) {
return reply.type('text/html').send(
loginPage({ clientId: client_id, redirectUri: redirect_uri, scope, state, clientName: client.name, error: 'Invalid username or password' })
)
}
Comment on lines +109 to +114

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The POST /oauth/authorize endpoint accepts username/password credentials but has no rate limiting, making it vulnerable to brute-force credential attacks. The server's global rate limit is configured with global: false (src/server.js:245), so it doesn't apply here automatically. The write rate limit on wildcard POST routes also doesn't apply since these specific routes take precedence.

Consider adding per-IP rate limiting similar to the pod creation endpoint (src/server.js:394-401), e.g., limiting to a small number of failed login attempts per minute.

Copilot uses AI. Check for mistakes.

// Generate one-time auth code (10 min TTL)
const code = crypto.randomUUID()
authCodes.set(code, {
clientId: client_id,
redirectUri: redirect_uri,
webId: account.webId,
scope: scope || 'read',
expiresAt: Date.now() + 600_000
})

// Handle OOB redirect — display code to user instead of redirecting
if (redirect_uri === OOB_REDIRECT) {
return reply.type('text/html').send(oobPage(code))
}

// Redirect back to client with code + state (RFC 6749 §4.1.2)
const url = new URL(redirect_uri)
url.searchParams.set('code', code)
if (state) url.searchParams.set('state', state)
return reply.redirect(url.toString())
Comment on lines +92 to +135

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: POST /oauth/authorize does not validate client_id. The GET handler calls getClient(client_id) to verify the client is registered, but the POST handler does not. Since the client_id comes from a hidden form field that could be tampered with, the POST handler should also validate that client_id corresponds to a registered client and that the redirect_uri matches the registered one. Without this, an attacker can submit the form with an arbitrary client_id and redirect_uri, generating a valid auth code and redirecting to any URL (open redirect with credential leakage).

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +135

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: new URL(redirect_uri) will throw for non-HTTP URIs. Some Mastodon clients use urn:ietf:wg:oauth:2.0:oob as a redirect_uri (indicating out-of-band auth where the code is displayed to the user instead of redirected). new URL() will throw a TypeError for this value since it's not a valid URL with a base, causing an unhandled error / 500 response. Consider adding a try-catch or checking if the redirect_uri is the special OOB value before constructing the URL.

Copilot uses AI. Check for mistakes.
}
}

/**
* POST /oauth/token — Exchange auth code for Bearer token
*/
export function createTokenHandler () {
return async (request, reply) => {
const body = parseBody(request)
const { grant_type, code, client_id, client_secret, redirect_uri } = body

if (grant_type !== 'authorization_code') {
return reply.code(400).send({ error: 'unsupported_grant_type' })
}

if (!code) {
return reply.code(400).send({ error: 'invalid_request', error_description: 'Missing code' })
}

// Validate client credentials (RFC 6749 §2.3)
const client = getClient(client_id)
if (!client) {
return reply.code(401).send({ error: 'invalid_client', error_description: 'Unknown client_id' })
}
try {
if (!crypto.timingSafeEqual(Buffer.from(client.client_secret), Buffer.from(client_secret || ''))) {
return reply.code(401).send({ error: 'invalid_client', error_description: 'Invalid client_secret' })
}
} catch {
return reply.code(401).send({ error: 'invalid_client', error_description: 'Invalid client_secret' })
}

// Look up auth code and consume immediately (RFC 6749 §10.5 — one-time use)
const authCode = authCodes.get(code)
authCodes.delete(code)

if (!authCode || authCode.expiresAt < Date.now()) {
return reply.code(400).send({ error: 'invalid_grant', error_description: 'Code expired or invalid' })
}

if (authCode.clientId !== client_id) {
return reply.code(400).send({ error: 'invalid_client' })
}

if (authCode.redirectUri !== redirect_uri) {
return reply.code(400).send({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' })
}

// Generate Bearer token using existing token infrastructure
const accessToken = createToken(authCode.webId)

return reply.send({
access_token: accessToken,
token_type: 'Bearer',
scope: authCode.scope,
created_at: Math.floor(Date.now() / 1000)
})
}
Comment on lines +142 to +193

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new OAuth flow has no test coverage. The codebase has comprehensive tests for authentication (test/auth.test.js), IdP interactions (test/idp.test.js), and other route handlers. The OAuth flow has several security-critical behaviors that should be tested: auth code one-time use, code expiration, client_id/redirect_uri validation, credential validation, and token exchange. The PR description includes a test plan with checkboxes but no automated tests.

Copilot uses AI. Check for mistakes.
}

/**
* Minimal login page HTML
*/
function loginPage ({ clientId, redirectUri, scope, state, clientName, error }) {
const escapedError = error ? escapeHtml(error) : ''
const escapedName = escapeHtml(clientName || clientId || 'Unknown app')

return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorize ${escapedName}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.card { background: white; border-radius: 12px; padding: 2rem; max-width: 400px; width: 90%; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
.subtitle { color: #666; margin-bottom: 1.5rem; font-size: 0.9rem; }
.scope { background: #f0f0f0; padding: 0.5rem 0.75rem; border-radius: 6px; margin-bottom: 1.5rem; font-size: 0.85rem; color: #444; }
label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.25rem; color: #333; }
input[type="text"], input[type="password"] { width: 100%; padding: 0.6rem; border: 1px solid #ddd; border-radius: 6px; font-size: 1rem; margin-bottom: 1rem; }
input:focus { outline: none; border-color: #4a9eff; box-shadow: 0 0 0 2px rgba(74,158,255,0.2); }
button { width: 100%; padding: 0.7rem; background: #4a9eff; color: white; border: none; border-radius: 6px; font-size: 1rem; font-weight: 500; cursor: pointer; }
button:hover { background: #3a8eef; }
.error { background: #fee; color: #c00; padding: 0.6rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.85rem; }
</style>
</head>
<body>
<div class="card">
<h1>Authorize</h1>
<p class="subtitle"><strong>${escapedName}</strong> wants access to your account</p>
<div class="scope">Scope: ${escapeHtml(scope || 'read')}</div>
${escapedError ? `<div class="error">${escapedError}</div>` : ''}
<form method="POST" action="/oauth/authorize">
<input type="hidden" name="client_id" value="${escapeHtml(clientId || '')}">
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirectUri || '')}">
<input type="hidden" name="scope" value="${escapeHtml(scope || 'read')}">
${state ? `<input type="hidden" name="state" value="${escapeHtml(state)}">` : ''}
<label for="username">Username</label>
<input type="text" id="username" name="username" required autocomplete="username">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<button type="submit">Authorize</button>
</form>
</div>
</body>
</html>`
}

/**
* OOB (out-of-band) code display page
* Used when redirect_uri is urn:ietf:wg:oauth:2.0:oob
*/
function oobPage (code) {
return `<!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>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.card { background: white; border-radius: 12px; padding: 2rem; max-width: 400px; width: 90%; box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; }
h1 { font-size: 1.25rem; margin-bottom: 1rem; }
.code { background: #f0f0f0; padding: 1rem; border-radius: 6px; font-family: monospace; font-size: 0.9rem; word-break: break-all; user-select: all; }
p { color: #666; margin-top: 1rem; font-size: 0.85rem; }
</style>
</head>
<body>
<div class="card">
<h1>Authorization Successful</h1>
<div class="code">${escapeHtml(code)}</div>
<p>Copy this code and paste it into your application.</p>
</div>
</body>
</html>`
}

function escapeHtml (str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

export default {
createAuthorizeHandler,
createAuthorizePostHandler,
createTokenHandler
}
3 changes: 2 additions & 1 deletion src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,8 @@ export function createServer(options = {}) {
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, solidos-ui, well-known, notifications, nostr, git, and AP
const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following',
'/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials'];
'/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials',
'/oauth/authorize', '/oauth/token'];
// Check if request wants ActivityPub content for profile
const accept = request.headers.accept || '';
const wantsAP = accept.includes('activity+json') || accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"');
Expand Down