| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
| 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 thread
Comment on lines
+33
to
+43
|
||
|
|
||
| /** | ||
| * 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 thread
Comment on lines
+109
to
+114
|
||
|
|
||
| // 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 thread
Comment on lines
+92
to
+135
|
||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 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 thread
Comment on lines
+142
to
+193
|
||
| } | ||
|
|
||
| /** | ||
| * 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | ||
| } | ||
|
|
||
| export default { | ||
| createAuthorizeHandler, | ||
| createAuthorizePostHandler, | ||
| createTokenHandler | ||
| } | ||
| Back | FazBrowse Home | New Git URL |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low QualityDuplicated 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.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.