| [d49b60b] | 1 | /**
|
|---|
| 2 | * OAuthService — OAuth 2.0 for ActivityPub Client-to-Server (C2S).
|
|---|
| 3 | *
|
|---|
| 4 | * The AP spec recommends OAuth 2.0 bearer tokens for C2S; the actor document
|
|---|
| 5 | * advertises endpoints.oauthAuthorizationEndpoint / oauthTokenEndpoint, and
|
|---|
| 6 | * RFC 8414 (/.well-known/oauth-authorization-server) advertises the
|
|---|
| 7 | * registration endpoint. Design choices (v1):
|
|---|
| 8 | * - PUBLIC clients only (native apps, RFC 8252): no client secrets,
|
|---|
| 9 | * PKCE S256 is REQUIRED on the authorization-code flow.
|
|---|
| 10 | * - A token is scoped to ONE user + ONE site (multi-site hub: the consent
|
|---|
| 11 | * page picks the site). Scope string is informational ('c2s').
|
|---|
| 12 | * - Tokens are stored hashed (sha256); codes are single-use, 10 min TTL.
|
|---|
| 13 | */
|
|---|
| 14 |
|
|---|
| 15 | import crypto from 'crypto';
|
|---|
| 16 | import db from '../config/database.js';
|
|---|
| 17 |
|
|---|
| 18 | const CODE_TTL_MS = 10 * 60 * 1000;
|
|---|
| 19 |
|
|---|
| 20 | const b64url = (buf) => buf.toString('base64url');
|
|---|
| 21 | const sha256 = (s) => crypto.createHash('sha256').update(s).digest();
|
|---|
| 22 |
|
|---|
| 23 | // Redirect URIs: https:// (web) or a custom scheme with a dot (reverse-DNS,
|
|---|
| 24 | // RFC 8252 §7.1, e.g. com.shaer.app:/callback). Plain http only for loopback.
|
|---|
| 25 | export function validRedirectUri(uri) {
|
|---|
| 26 | try {
|
|---|
| 27 | const u = new URL(uri);
|
|---|
| 28 | if (u.protocol === 'https:') return true;
|
|---|
| 29 | if (u.protocol === 'http:') return u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '[::1]';
|
|---|
| 30 | return /^[a-z0-9-]+(\.[a-z0-9-]+)+:$/i.test(u.protocol); // custom reverse-DNS scheme
|
|---|
| 31 | } catch { return false; }
|
|---|
| 32 | }
|
|---|
| 33 |
|
|---|
| 34 | // RFC 7591 (subset): register a public client. Returns the stored metadata.
|
|---|
| 35 | export function registerClient({ client_name, redirect_uris }) {
|
|---|
| 36 | const name = String(client_name || '').trim().slice(0, 120);
|
|---|
| 37 | const uris = (Array.isArray(redirect_uris) ? redirect_uris : [redirect_uris]).filter(Boolean).map(String);
|
|---|
| 38 | if (!name) return { error: 'invalid_client_metadata', error_description: 'client_name is required' };
|
|---|
| 39 | if (!uris.length || !uris.every(validRedirectUri)) {
|
|---|
| 40 | return { error: 'invalid_redirect_uri', error_description: 'redirect_uris must be https, loopback http, or a reverse-DNS custom scheme' };
|
|---|
| 41 | }
|
|---|
| 42 | const clientId = b64url(crypto.randomBytes(18));
|
|---|
| 43 | db.prepare('INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES (?,?,?)')
|
|---|
| 44 | .run(clientId, name, JSON.stringify(uris));
|
|---|
| 45 | return {
|
|---|
| 46 | client_id: clientId,
|
|---|
| 47 | client_name: name,
|
|---|
| 48 | redirect_uris: uris,
|
|---|
| 49 | token_endpoint_auth_method: 'none', // public client: PKCE, no secret
|
|---|
| 50 | grant_types: ['authorization_code'],
|
|---|
| 51 | response_types: ['code'],
|
|---|
| 52 | };
|
|---|
| 53 | }
|
|---|
| 54 |
|
|---|
| 55 | export function getClient(clientId) {
|
|---|
| 56 | const row = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(String(clientId || ''));
|
|---|
| 57 | if (!row) return null;
|
|---|
| 58 | let uris = []; try { uris = JSON.parse(row.redirect_uris); } catch { /* corrupt row */ }
|
|---|
| 59 | return { client_id: row.client_id, client_name: row.client_name, redirect_uris: uris };
|
|---|
| 60 | }
|
|---|
| 61 |
|
|---|
| 62 | // Authorization step (after user consent): mint a single-use code.
|
|---|
| 63 | export function createCode({ clientId, userId, siteSlug, redirectUri, codeChallenge, scope }) {
|
|---|
| 64 | if (!codeChallenge || !/^[A-Za-z0-9_-]{43}$/.test(String(codeChallenge))) {
|
|---|
| 65 | return { error: 'invalid_request', error_description: 'PKCE S256 code_challenge is required' };
|
|---|
| 66 | }
|
|---|
| 67 | const code = b64url(crypto.randomBytes(24));
|
|---|
| 68 | db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, site_slug, redirect_uri, code_challenge, scope, expires_at)
|
|---|
| 69 | VALUES (?,?,?,?,?,?,?,?)`)
|
|---|
| 70 | .run(code, clientId, userId, siteSlug, redirectUri, codeChallenge, scope || 'c2s',
|
|---|
| 71 | new Date(Date.now() + CODE_TTL_MS).toISOString());
|
|---|
| 72 | return { code };
|
|---|
| 73 | }
|
|---|
| 74 |
|
|---|
| 75 | // Token step: exchange code + PKCE verifier for a bearer token.
|
|---|
| 76 | export function exchangeCode({ code, client_id, redirect_uri, code_verifier }) {
|
|---|
| 77 | const row = db.prepare('SELECT * FROM oauth_codes WHERE code = ?').get(String(code || ''));
|
|---|
| 78 | // Single use: delete immediately, whatever happens next (replay protection).
|
|---|
| 79 | if (row) db.prepare('DELETE FROM oauth_codes WHERE code = ?').run(row.code);
|
|---|
| 80 | if (!row) return { error: 'invalid_grant' };
|
|---|
| 81 | if (Date.parse(row.expires_at) < Date.now()) return { error: 'invalid_grant', error_description: 'code expired' };
|
|---|
| 82 | if (row.client_id !== String(client_id || '')) return { error: 'invalid_grant', error_description: 'client mismatch' };
|
|---|
| 83 | if (row.redirect_uri !== String(redirect_uri || '')) return { error: 'invalid_grant', error_description: 'redirect_uri mismatch' };
|
|---|
| 84 | const expected = b64url(sha256(String(code_verifier || '')));
|
|---|
| 85 | if (expected !== row.code_challenge) return { error: 'invalid_grant', error_description: 'PKCE verification failed' };
|
|---|
| 86 | const token = b64url(crypto.randomBytes(32));
|
|---|
| 87 | db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
|
|---|
| 88 | .run(b64url(sha256(token)), row.client_id, row.user_id, row.site_slug, row.scope);
|
|---|
| 89 | return { access_token: token, token_type: 'Bearer', scope: row.scope };
|
|---|
| 90 | }
|
|---|
| 91 |
|
|---|
| 92 | // Resolve "Authorization: Bearer <token>" → { user, site } or null. The C2S
|
|---|
| 93 | // caller must additionally check the site matches the URL and permissions.
|
|---|
| 94 | export function verifyBearer(authHeader) {
|
|---|
| 95 | const m = /^Bearer\s+([A-Za-z0-9_-]{20,})$/i.exec(String(authHeader || '').trim());
|
|---|
| 96 | if (!m) return null;
|
|---|
| 97 | const hash = b64url(sha256(m[1]));
|
|---|
| 98 | const row = db.prepare('SELECT * FROM oauth_tokens WHERE token_hash = ?').get(hash);
|
|---|
| 99 | if (!row) return null;
|
|---|
| 100 | try { db.prepare('UPDATE oauth_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash); } catch { /* non-fatal */ }
|
|---|
| 101 | const user = db.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);
|
|---|
| 102 | const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
|
|---|
| 103 | if (!user || !site) return null;
|
|---|
| 104 | return { user, site, scope: row.scope, client_id: row.client_id };
|
|---|
| 105 | }
|
|---|
| 106 |
|
|---|
| 107 | export function revokeToken(token) {
|
|---|
| 108 | try { db.prepare('DELETE FROM oauth_tokens WHERE token_hash = ?').run(b64url(sha256(String(token || '')))); } catch { /* ignore */ }
|
|---|
| 109 | }
|
|---|
| 110 |
|
|---|
| 111 | export default { registerClient, getClient, createCode, exchangeCode, verifyBearer, revokeToken, validRedirectUri };
|
|---|