Skip to content

OAuth 2.0 authorize/token flow - #161

Merged
melvincarvalho merged 4 commits into
gh-pagesfrom
feature/oauth-flow
Mar 10, 2026
Merged

OAuth 2.0 authorize/token flow#161
melvincarvalho merged 4 commits into
gh-pagesfrom
feature/oauth-flow

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

Summary

  • Adds GET /oauth/authorize — login/consent page for Mastodon clients and remoteStorage apps
  • Adds POST /oauth/authorize — validates credentials via existing authenticate(), generates one-time auth code, redirects back to client
  • Adds POST /oauth/token — exchanges auth code for Bearer token via existing createToken()
  • Auth skip added for /oauth/authorize and /oauth/token in server.js

Reuses existing infrastructure:

  • Account system (authenticate() from src/idp/accounts.js) — no new auth code
  • Token system (createToken() from src/auth/token.js) — HMAC-signed Bearer tokens already verified by getWebIdFromRequestAsync()
  • Client registration (getClient() from src/ap/routes/mastodon.js) — validates client_id from Step 1

~208 lines. No new dependencies.

Motivation

One OAuth flow unlocks three ecosystems:

Flow

1. Client registers:     POST /api/v1/apps → {client_id, client_secret}
2. Client redirects to:  GET /oauth/authorize?client_id=X&redirect_uri=Y&scope=read
3. User logs in:          POST /oauth/authorize → redirect to Y?code=CODE
4. Client exchanges:     POST /oauth/token {grant_type=authorization_code, code, client_id, ...}
5. Server returns:       {access_token, token_type: "Bearer", scope}
6. Client uses:          Authorization: Bearer <token>

Test plan

  • POST /api/v1/apps to register client
  • GET /oauth/authorize shows login page with client name
  • Login with wrong credentials shows error
  • Login with correct credentials redirects with ?code=
  • POST /oauth/token exchanges code for Bearer token
  • Code is one-time use (second exchange fails)
  • Expired codes (>10 min) are rejected
  • Bearer token works with existing Authorization: Bearer verification
  • Elk/Phanpy can complete OAuth login flow

Closes #160
Refs #106, #158, #159

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.js with 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.js under the ActivityPub plugin
  • Auth bypass for /oauth/authorize and /oauth/token added to server.js WAC 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.

Comment thread src/ap/routes/oauth.js
Comment on lines +70 to +101
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())

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 thread src/ap/routes/oauth.js Outdated
Comment on lines +111 to +137
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)

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: 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.

Copilot uses AI. Check for mistakes.
Comment thread src/ap/routes/oauth.js
Comment on lines +29 to +39
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))
}

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 thread src/ap/routes/oauth.js
Comment on lines +99 to +101
const url = new URL(redirect_uri)
url.searchParams.set('code', code)
return reply.redirect(url.toString())

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.
Comment thread src/ap/routes/oauth.js Outdated
Comment on lines +56 to +63
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 })
)

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: 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.

Copilot uses AI. Check for mistakes.
- 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread src/ap/routes/oauth.js
Comment on lines +32 to +42
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))
}

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 thread src/ap/routes/oauth.js Outdated
Comment on lines +158 to +160
if (client.client_secret !== client_secret) {
return reply.code(401).send({ error: 'invalid_client', error_description: 'Invalid client_secret' })
}

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 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).

Copilot uses AI. Check for mistakes.
Comment thread src/ap/routes/oauth.js
Comment on lines +140 to +189
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)
})
}

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.
Comment thread src/ap/routes/oauth.js Outdated
Comment on lines +71 to +84
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 })
)

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 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.

Copilot uses AI. Check for mistakes.
Comment thread src/ap/routes/oauth.js Outdated
Comment on lines +130 to +133
// Redirect back to client with code
const url = new URL(redirect_uri)
url.searchParams.set('code', code)
return reply.redirect(url.toString())

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 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.

Copilot uses AI. Check for mistakes.
Comment thread src/ap/routes/oauth.js
Comment on lines +108 to +113
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' })
)
}

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.
- 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread src/ap/index.js Outdated
}
}
}, createAuthorizePostHandler())
fastify.post('/oauth/token', createTokenHandler())

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.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/ap/routes/oauth.js
Comment on lines +33 to +43
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))
}

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.
Comment thread src/ap/routes/oauth.js Outdated
Comment on lines +169 to +184
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)

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.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/ap/routes/oauth.js Outdated
const escapedName = escapeHtml(clientName || clientId || 'Unknown app')

return `<!DOCTYPE html>
<html>

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.

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.

Copilot uses AI. Check for mistakes.
- 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
@melvincarvalho
melvincarvalho merged commit 9600b5c into gh-pages Mar 10, 2026
melvincarvalho added a commit that referenced this pull request Mar 10, 2026
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
melvincarvalho added a commit that referenced this pull request Mar 10, 2026
Document the three new features from PRs #159, #161, #162:
- Mastodon-compatible API endpoints and OAuth 2.0 flow
- remoteStorage protocol (always on, no flag needed)
- Updated project structure with new files
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth 2.0 authorize/token flow

2 participants