Skip to content

Commit 8b5e2a5

Browse files
Add OAuth 2.0 authorize/token flow
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 (JavaScriptSolidServer#106), and third-party pane auth. Closes JavaScriptSolidServer#160
1 parent ebc9d95 commit 8b5e2a5

3 files changed

Lines changed: 216 additions & 1 deletion

File tree

src/ap/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createOutboxHandler, createOutboxPostHandler } from './routes/outbox.js
1111
import { createCollectionsHandler } from './routes/collections.js'
1212
import { createActorHandler } from './routes/actor.js'
1313
import { createAppsHandler, createVerifyCredentialsHandler, createInstanceHandler } from './routes/mastodon.js'
14+
import { createAuthorizeHandler, createAuthorizePostHandler, createTokenHandler } from './routes/oauth.js'
1415

1516
// Shared state for actor handler (accessed by server.js)
1617
let sharedActorHandler = null
@@ -179,6 +180,11 @@ export async function activityPubPlugin(fastify, options = {}) {
179180
fastify.post('/api/v1/apps', createAppsHandler())
180181
fastify.get('/api/v1/accounts/verify_credentials', createVerifyCredentialsHandler(config))
181182
fastify.get('/api/v1/instance', createInstanceHandler(config))
183+
184+
// OAuth 2.0 authorize/token flow (Mastodon clients, remoteStorage, third-party panes)
185+
fastify.get('/oauth/authorize', createAuthorizeHandler())
186+
fastify.post('/oauth/authorize', createAuthorizePostHandler())
187+
fastify.post('/oauth/token', createTokenHandler())
182188
}
183189

184190
export default activityPubPlugin

src/ap/routes/oauth.js

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* OAuth 2.0 authorize/token flow
3+
* Shared infrastructure for Mastodon clients, remoteStorage apps, and third-party panes
4+
*
5+
* Refs: https://docs.joinmastodon.org/methods/oauth/
6+
* https://datatracker.ietf.org/doc/html/rfc6749
7+
*
8+
* Related: #158, #159 (Mastodon API), #106 (remoteStorage), #160 (this)
9+
*/
10+
11+
import { getClient } from './mastodon.js'
12+
import { authenticate } from '../../idp/accounts.js'
13+
import { createToken } from '../../auth/token.js'
14+
15+
// Auth codes: code → { clientId, redirectUri, webId, scope, expiresAt }
16+
const authCodes = new Map()
17+
18+
// Clean up expired codes every 60s
19+
setInterval(() => {
20+
const now = Date.now()
21+
for (const [code, data] of authCodes) {
22+
if (data.expiresAt < now) authCodes.delete(code)
23+
}
24+
}, 60000).unref()
25+
26+
/**
27+
* Parse request body — handles JSON and form-urlencoded
28+
*/
29+
function parseBody (request) {
30+
if (request.body && typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
31+
return request.body
32+
}
33+
const raw = Buffer.isBuffer(request.body) ? request.body.toString() : String(request.body || '')
34+
const ct = request.headers['content-type'] || ''
35+
if (ct.includes('application/json')) {
36+
try { return JSON.parse(raw) } catch { return {} }
37+
}
38+
return Object.fromEntries(new URLSearchParams(raw))
39+
}
40+
41+
/**
42+
* GET /oauth/authorize — Show login/consent page
43+
*/
44+
export function createAuthorizeHandler () {
45+
return async (request, reply) => {
46+
const { client_id, redirect_uri, response_type, scope } = request.query
47+
48+
if (!client_id || !redirect_uri) {
49+
return reply.code(400).send({ error: 'Missing client_id or redirect_uri' })
50+
}
51+
52+
if (response_type && response_type !== 'code') {
53+
return reply.code(400).send({ error: 'unsupported_response_type', error_description: 'Only response_type=code is supported' })
54+
}
55+
56+
const client = getClient(client_id)
57+
if (!client) {
58+
return reply.code(400).send({ error: 'invalid_client', error_description: 'Unknown client_id. Register via POST /api/v1/apps first.' })
59+
}
60+
61+
return reply.type('text/html').send(
62+
loginPage({ clientId: client_id, redirectUri: redirect_uri, scope: scope || 'read', clientName: client.name })
63+
)
64+
}
65+
}
66+
67+
/**
68+
* POST /oauth/authorize — Process login form
69+
*/
70+
export function createAuthorizePostHandler () {
71+
return async (request, reply) => {
72+
const body = parseBody(request)
73+
const { username, password, client_id, redirect_uri, scope } = body
74+
75+
if (!username || !password) {
76+
return reply.type('text/html').send(
77+
loginPage({ clientId: client_id, redirectUri: redirect_uri, scope, error: 'Username and password are required' })
78+
)
79+
}
80+
81+
const account = await authenticate(username, password)
82+
if (!account) {
83+
return reply.type('text/html').send(
84+
loginPage({ clientId: client_id, redirectUri: redirect_uri, scope, error: 'Invalid username or password' })
85+
)
86+
}
87+
88+
// Generate one-time auth code (10 min TTL)
89+
const code = crypto.randomUUID()
90+
authCodes.set(code, {
91+
clientId: client_id,
92+
redirectUri: redirect_uri,
93+
webId: account.webId,
94+
scope: scope || 'read',
95+
expiresAt: Date.now() + 600_000
96+
})
97+
98+
// Redirect back to client with code
99+
const url = new URL(redirect_uri)
100+
url.searchParams.set('code', code)
101+
return reply.redirect(url.toString())
102+
}
103+
}
104+
105+
/**
106+
* POST /oauth/token — Exchange auth code for Bearer token
107+
*/
108+
export function createTokenHandler () {
109+
return async (request, reply) => {
110+
const body = parseBody(request)
111+
const { grant_type, code, client_id, client_secret, redirect_uri } = body
112+
113+
if (grant_type !== 'authorization_code') {
114+
return reply.code(400).send({ error: 'unsupported_grant_type' })
115+
}
116+
117+
if (!code) {
118+
return reply.code(400).send({ error: 'invalid_request', error_description: 'Missing code' })
119+
}
120+
121+
// Look up and validate auth code
122+
const authCode = authCodes.get(code)
123+
if (!authCode || authCode.expiresAt < Date.now()) {
124+
authCodes.delete(code)
125+
return reply.code(400).send({ error: 'invalid_grant', error_description: 'Code expired or invalid' })
126+
}
127+
128+
if (authCode.clientId !== client_id) {
129+
return reply.code(400).send({ error: 'invalid_client' })
130+
}
131+
132+
if (authCode.redirectUri !== redirect_uri) {
133+
return reply.code(400).send({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' })
134+
}
135+
136+
// Consume code (one-time use)
137+
authCodes.delete(code)
138+
139+
// Generate Bearer token using existing token infrastructure
140+
const accessToken = createToken(authCode.webId)
141+
142+
return reply.send({
143+
access_token: accessToken,
144+
token_type: 'Bearer',
145+
scope: authCode.scope,
146+
created_at: Math.floor(Date.now() / 1000)
147+
})
148+
}
149+
}
150+
151+
/**
152+
* Minimal login page HTML
153+
*/
154+
function loginPage ({ clientId, redirectUri, scope, clientName, error }) {
155+
const escapedError = error ? escapeHtml(error) : ''
156+
const escapedName = escapeHtml(clientName || clientId || 'Unknown app')
157+
158+
return `<!DOCTYPE html>
159+
<html>
160+
<head>
161+
<meta charset="utf-8">
162+
<meta name="viewport" content="width=device-width, initial-scale=1">
163+
<title>Authorize ${escapedName}</title>
164+
<style>
165+
* { box-sizing: border-box; margin: 0; padding: 0; }
166+
body { font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
167+
.card { background: white; border-radius: 12px; padding: 2rem; max-width: 400px; width: 90%; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
168+
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
169+
.subtitle { color: #666; margin-bottom: 1.5rem; font-size: 0.9rem; }
170+
.scope { background: #f0f0f0; padding: 0.5rem 0.75rem; border-radius: 6px; margin-bottom: 1.5rem; font-size: 0.85rem; color: #444; }
171+
label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.25rem; color: #333; }
172+
input[type="text"], input[type="password"] { width: 100%; padding: 0.6rem; border: 1px solid #ddd; border-radius: 6px; font-size: 1rem; margin-bottom: 1rem; }
173+
input:focus { outline: none; border-color: #4a9eff; box-shadow: 0 0 0 2px rgba(74,158,255,0.2); }
174+
button { width: 100%; padding: 0.7rem; background: #4a9eff; color: white; border: none; border-radius: 6px; font-size: 1rem; font-weight: 500; cursor: pointer; }
175+
button:hover { background: #3a8eef; }
176+
.error { background: #fee; color: #c00; padding: 0.6rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.85rem; }
177+
</style>
178+
</head>
179+
<body>
180+
<div class="card">
181+
<h1>Authorize</h1>
182+
<p class="subtitle"><strong>${escapedName}</strong> wants access to your account</p>
183+
<div class="scope">Scope: ${escapeHtml(scope || 'read')}</div>
184+
${escapedError ? `<div class="error">${escapedError}</div>` : ''}
185+
<form method="POST" action="/oauth/authorize">
186+
<input type="hidden" name="client_id" value="${escapeHtml(clientId || '')}">
187+
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirectUri || '')}">
188+
<input type="hidden" name="scope" value="${escapeHtml(scope || 'read')}">
189+
<label for="username">Username</label>
190+
<input type="text" id="username" name="username" required autocomplete="username">
191+
<label for="password">Password</label>
192+
<input type="password" id="password" name="password" required autocomplete="current-password">
193+
<button type="submit">Authorize</button>
194+
</form>
195+
</div>
196+
</body>
197+
</html>`
198+
}
199+
200+
function escapeHtml (str) {
201+
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
202+
}
203+
204+
export default {
205+
createAuthorizeHandler,
206+
createAuthorizePostHandler,
207+
createTokenHandler
208+
}

src/server.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,8 @@ export function createServer(options = {}) {
354354
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, solidos-ui, well-known, notifications, nostr, git, and AP
355355
const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
356356
const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following',
357-
'/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials'];
357+
'/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials',
358+
'/oauth/authorize', '/oauth/token'];
358359
// Check if request wants ActivityPub content for profile
359360
const accept = request.headers.accept || '';
360361
const wantsAP = accept.includes('activity+json') || accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"');

0 commit comments

Comments
 (0)