OAuth 2.0 authorize/token flow - #161
Conversation
Lightweight OAuth 2.0 authorization code flow that reuses existing account system (authenticate()) and token infrastructure (createToken()). Bearer tokens are already verified by getWebIdFromRequestAsync(). Endpoints: - GET /oauth/authorize — login/consent page - POST /oauth/authorize — process credentials, redirect with auth code - POST /oauth/token — exchange code for Bearer token Shared infrastructure for Mastodon clients, remoteStorage apps (#106), and third-party pane auth. Closes #160
There was a problem hiding this comment.
Pull request overview
This PR implements the OAuth 2.0 authorization code flow for JSS, adding GET /oauth/authorize (login/consent page), POST /oauth/authorize (credential validation + auth code generation), and POST /oauth/token (auth code → Bearer token exchange). It reuses existing infrastructure: authenticate() for credential validation, createToken() for HMAC-signed Bearer tokens, and getClient() for client registration lookup. This is Step 2 of Mastodon API compatibility (following client registration in #159) and Phase 2 of remoteStorage support (#106).
Changes:
- New
src/ap/routes/oauth.jswith authorization endpoint (GET + POST) and token endpoint, including HTML login page and in-memory auth code store with 10-minute TTL - Route registration in
src/ap/index.jsunder the ActivityPub plugin - Auth bypass for
/oauth/authorizeand/oauth/tokenadded toserver.jsWAC authorization hook
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/ap/routes/oauth.js |
New file implementing OAuth 2.0 authorize/token flow with login page, auth code generation, and token exchange |
src/ap/index.js |
Imports and registers the three new OAuth route handlers |
src/server.js |
Adds /oauth/authorize and /oauth/token to the auth-skip path list |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export function createAuthorizePostHandler () { | ||
| return async (request, reply) => { | ||
| const body = parseBody(request) | ||
| const { username, password, client_id, redirect_uri, scope } = body | ||
|
|
||
| if (!username || !password) { | ||
| return reply.type('text/html').send( | ||
| loginPage({ clientId: client_id, redirectUri: redirect_uri, scope, 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, error: 'Invalid username or password' }) | ||
| ) | ||
| } | ||
|
|
||
| // 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 | ||
| }) | ||
|
|
||
| // Redirect back to client with code | ||
| const url = new URL(redirect_uri) | ||
| url.searchParams.set('code', code) | ||
| return reply.redirect(url.toString()) |
There was a problem hiding this comment.
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).
| 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' }) | ||
| } | ||
|
|
||
| // Look up and validate auth code | ||
| const authCode = authCodes.get(code) | ||
| if (!authCode || authCode.expiresAt < Date.now()) { | ||
| authCodes.delete(code) | ||
| 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' }) | ||
| } | ||
|
|
||
| // Consume code (one-time use) | ||
| authCodes.delete(code) |
There was a problem hiding this comment.
Security: client_secret is not validated. The client_secret is destructured from the request body on line 111 but never checked against the registered client's secret. Per RFC 6749 §2.3, confidential clients must authenticate at the token endpoint. Since the client registration (POST /api/v1/apps) issues a client_secret, the token endpoint should verify it matches. Without this, anyone who intercepts or guesses an auth code can exchange it for a token without knowing the client secret.
| 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)) | ||
| } |
There was a problem hiding this comment.
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.
| const url = new URL(redirect_uri) | ||
| url.searchParams.set('code', code) | ||
| return reply.redirect(url.toString()) |
There was a problem hiding this comment.
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.
| const client = getClient(client_id) | ||
| if (!client) { | ||
| return reply.code(400).send({ error: 'invalid_client', error_description: 'Unknown client_id. Register via POST /api/v1/apps first.' }) | ||
| } | ||
|
|
||
| return reply.type('text/html').send( | ||
| loginPage({ clientId: client_id, redirectUri: redirect_uri, scope: scope || 'read', clientName: client.name }) | ||
| ) |
There was a problem hiding this comment.
Security: redirect_uri not validated against registered client. Neither GET /oauth/authorize nor POST /oauth/authorize validates that the provided redirect_uri matches the redirect_uri registered by the client via POST /api/v1/apps. Per RFC 6749 §10.6, the authorization server MUST validate the redirect URI against the pre-registered value to prevent open redirect attacks. An attacker could use a legitimate client_id but supply their own redirect_uri to steal the authorization code.
In createAuthorizeHandler, after looking up the client with getClient(client_id), compare redirect_uri against client.redirect_uri and reject if they don't match. The same validation should apply in createAuthorizePostHandler.
- Validate client_id + redirect_uri in both GET and POST authorize handlers (prevents open redirect via form tampering) - Validate client_secret at token endpoint (RFC 6749 §2.3) - Handle OOB redirect URI (urn:ietf:wg:oauth:2.0:oob) used by some Mastodon clients — display code instead of redirecting - Extract shared validateClient() helper
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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)) | ||
| } |
There was a problem hiding this comment.
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.
| if (client.client_secret !== client_secret) { | ||
| return reply.code(401).send({ error: 'invalid_client', error_description: 'Invalid client_secret' }) | ||
| } |
There was a problem hiding this comment.
The client_secret comparison uses !== which is not constant-time. While the risk is lower here because client secrets are random UUIDs, the codebase already uses crypto.timingSafeEqual for similar comparisons in src/auth/token.js:96. For consistency and defense-in-depth, consider using crypto.timingSafeEqual(Buffer.from(client.client_secret), Buffer.from(client_secret)) (with a length check or try/catch for mismatched lengths).
| 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' }) | ||
| } | ||
| if (client.client_secret !== client_secret) { | ||
| return reply.code(401).send({ error: 'invalid_client', error_description: 'Invalid client_secret' }) | ||
| } | ||
|
|
||
| // Look up and validate auth code | ||
| const authCode = authCodes.get(code) | ||
| if (!authCode || authCode.expiresAt < Date.now()) { | ||
| authCodes.delete(code) | ||
| 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' }) | ||
| } | ||
|
|
||
| // Consume code (one-time use) | ||
| authCodes.delete(code) | ||
|
|
||
| // 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) | ||
| }) | ||
| } |
There was a problem hiding this comment.
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.
| const { client_id, redirect_uri, response_type, scope } = 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', clientName: client.name }) | ||
| ) |
There was a problem hiding this comment.
The OAuth 2.0 state parameter (RFC 6749 §4.1.1) is not preserved through the flow. Clients send state in the authorize request to protect against CSRF attacks. Per RFC 6749 §4.1.2, if a state parameter was present in the client's authorization request, the authorization server MUST include it in the redirect response.
Currently, state is not extracted from the query in GET /oauth/authorize, not passed to the login form as a hidden field, not read from the POST body in POST /oauth/authorize, and not appended to the redirect URL.
Mastodon clients (Elk, Phanpy, Ice Cubes) all send state and expect it echoed back. Without this, these clients will reject the redirect as a potential CSRF attack, breaking the intended use case.
| // Redirect back to client with code | ||
| const url = new URL(redirect_uri) | ||
| url.searchParams.set('code', code) | ||
| return reply.redirect(url.toString()) |
There was a problem hiding this comment.
The redirect response at line 132-133 is missing the state parameter. Per RFC 6749 §4.1.2, if the authorization request contained a state parameter, the response MUST include it verbatim. Without this, Mastodon clients will reject the callback as a CSRF attempt.
The state should be read from the POST body (passed through from the login form as a hidden field), and appended via url.searchParams.set('state', state) before redirecting.
| const account = await authenticate(username, password) | ||
| if (!account) { | ||
| return reply.type('text/html').send( | ||
| loginPage({ clientId: client_id, redirectUri: redirect_uri, scope, clientName: client.name, error: 'Invalid username or password' }) | ||
| ) | ||
| } |
There was a problem hiding this comment.
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.
- Preserve OAuth state parameter through entire flow (RFC 6749 §4.1.1) Required by Mastodon clients (Elk, Phanpy) for CSRF protection - Use crypto.timingSafeEqual for client_secret comparison - Rate limit POST /oauth/authorize (10/min per IP) to prevent brute force - Import crypto module explicitly
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
| } | ||
| }, createAuthorizePostHandler()) | ||
| fastify.post('/oauth/token', createTokenHandler()) |
There was a problem hiding this comment.
POST /oauth/token is missing rate limiting. While auth codes and client secrets are UUIDs (high entropy), this endpoint performs credential validation (client_secret comparison) and should be rate-limited to be consistent with all other authentication endpoints in the codebase (e.g., POST /idp/credentials at src/idp/index.js:222-229, POST /idp/interaction/:uid at src/idp/index.js:245-252). Add a rate limit config similar to the one applied to POST /oauth/authorize (10 requests per minute per IP).
| 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)) | ||
| } |
There was a problem hiding this comment.
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).
| const authCode = authCodes.get(code) | ||
| if (!authCode || authCode.expiresAt < Date.now()) { | ||
| authCodes.delete(code) | ||
| 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' }) | ||
| } | ||
|
|
||
| // Consume code (one-time use) | ||
| authCodes.delete(code) |
There was a problem hiding this comment.
Per RFC 6749 §10.5, if an authorization code is used more than once or presented with incorrect binding parameters, the authorization server SHOULD revoke all tokens issued based on that code and SHOULD deny the current request. Here, when clientId (line 175) or redirectUri (line 179) validation fails, the auth code is not consumed and remains available for retry. Consider moving authCodes.delete(code) immediately after the successful lookup on line 169, before any validation checks, to ensure one-time use regardless of whether subsequent checks pass.
| const escapedName = escapeHtml(clientName || clientId || 'Unknown app') | ||
|
|
||
| return `<!DOCTYPE html> | ||
| <html> |
There was a problem hiding this comment.
Both the login page (line 206) and OOB page (line 254) use <html> without the lang attribute. Every other HTML page in the codebase includes lang="en" on the <html> tag (see src/idp/views.js:421, src/auth/middleware.js:189, src/webid/profile.js:68, src/mashlib/index.js:122). The missing lang attribute is an accessibility concern (screen readers may not detect the page language) and deviates from the established codebase convention.
- Delete auth code immediately after lookup (RFC 6749 §10.5) so it can't be retried even if subsequent checks fail - Rate limit POST /oauth/token (10/min per IP) - Add lang="en" to HTML pages for accessibility
Implements draft-dejong-remotestorage-22 on top of existing storage infrastructure. No new dependencies — reuses filesystem storage, OAuth flow (#161), and WebFinger. Endpoints: - GET /storage/:user/* — read file or folder (RS JSON-LD listing) - HEAD /storage/:user/* — metadata only - PUT /storage/:user/* — write file (with If-Match/If-None-Match) - DELETE /storage/:user/* — delete file (with If-Match) Features: - Public folder (/storage/:user/public/*) readable without auth - Conditional requests (ETags, If-Match, If-None-Match) - WebFinger discovery (RS link relation added to existing response) - Bearer token auth via existing OAuth flow - Always on — no flag needed Refs #106
Summary
GET /oauth/authorize— login/consent page for Mastodon clients and remoteStorage appsPOST /oauth/authorize— validates credentials via existingauthenticate(), generates one-time auth code, redirects back to clientPOST /oauth/token— exchanges auth code for Bearer token via existingcreateToken()/oauth/authorizeand/oauth/tokenin server.jsReuses existing infrastructure:
authenticate()fromsrc/idp/accounts.js) — no new auth codecreateToken()fromsrc/auth/token.js) — HMAC-signed Bearer tokens already verified bygetWebIdFromRequestAsync()getClient()fromsrc/ap/routes/mastodon.js) — validates client_id from Step 1~208 lines. No new dependencies.
Motivation
One OAuth flow unlocks three ecosystems:
Flow
Test plan
POST /api/v1/appsto register clientGET /oauth/authorizeshows login page with client name?code=POST /oauth/tokenexchanges code for Bearer tokenAuthorization: BearerverificationCloses #160
Refs #106, #158, #159