| 1 | /**
|
|---|
| 2 | * OAuth 2.0 routes for ActivityPub Client-to-Server (native/web clients).
|
|---|
| 3 | *
|
|---|
| 4 | * POST /oauth/register dynamic client registration (RFC 7591 subset)
|
|---|
| 5 | * GET /.well-known/oauth-authorization-server server metadata (RFC 8414)
|
|---|
| 6 | * GET /oauth/authorize consent screen (session-authenticated)
|
|---|
| 7 | * POST /oauth/authorize user grants → redirect back with ?code
|
|---|
| 8 | * POST /oauth/token code + PKCE verifier → bearer token
|
|---|
| 9 | *
|
|---|
| 10 | * Auth model: PUBLIC clients + PKCE only (see OAuthService). The consent screen
|
|---|
| 11 | * reuses Klonkt's normal login session; the token it mints is scoped to one
|
|---|
| 12 | * user + one of their sites.
|
|---|
| 13 | */
|
|---|
| 14 | import express from 'express';
|
|---|
| 15 | import db from '../config/database.js';
|
|---|
| 16 | import OAuth from '../services/OAuthService.js';
|
|---|
| 17 | import { requireAuth } from '../middleware/auth.js';
|
|---|
| 18 | import { renderPage } from '../middleware/render.js';
|
|---|
| 19 | import PermissionsService from '../services/PermissionsService.js';
|
|---|
| 20 | import { apEnabled } from '../services/SettingsService.js';
|
|---|
| 21 |
|
|---|
| 22 | const router = express.Router();
|
|---|
| 23 | router.use((req, res, next) => { if (!apEnabled()) return next('router'); next(); });
|
|---|
| 24 |
|
|---|
| 25 | const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
|
|---|
| 26 |
|
|---|
| 27 | // Sites this user may post as (owner or co-admin). The consent screen lists these.
|
|---|
| 28 | function manageableSites(user) {
|
|---|
| 29 | return db.prepare('SELECT id, slug, title, owner_id FROM sites ORDER BY created_at')
|
|---|
| 30 | .all()
|
|---|
| 31 | .filter((s) => PermissionsService.canAdminSite(user, s));
|
|---|
| 32 | }
|
|---|
| 33 |
|
|---|
| 34 | // Append query params to a redirect URI WITHOUT re-serializing it: native custom
|
|---|
| 35 | // schemes (com.shaer.app:/cb) get mangled by new URL().toString() (→ //cb/), and
|
|---|
| 36 | // RFC 6749 §4.1.2 says to append to the registered URI as-is. The URI is already
|
|---|
| 37 | // validated against the registered set before we ever call this.
|
|---|
| 38 | function redirectWith(redirectUri, params) {
|
|---|
| 39 | const q = Object.entries(params)
|
|---|
| 40 | .filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|---|
| 41 | .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|---|
| 42 | .join('&');
|
|---|
| 43 | const sep = redirectUri.includes('?') ? '&' : '?';
|
|---|
| 44 | return q ? `${redirectUri}${sep}${q}` : redirectUri;
|
|---|
| 45 | }
|
|---|
| 46 |
|
|---|
| 47 | // Hand control back to the client at redirect_uri + params. For a web client
|
|---|
| 48 | // (http/https) a plain 302 is right. For a NATIVE custom scheme
|
|---|
| 49 | // (com.klonkt.shaer:/oauth) a 302 is unreliable: mobile browsers routinely drop
|
|---|
| 50 | // a server redirect to a custom scheme (no user gesture). So we serve a tiny
|
|---|
| 51 | // interstitial that both auto-forwards AND offers a tap link — a tap is a user
|
|---|
| 52 | // gesture that launches the app on Android, and iOS's ASWebAuthenticationSession
|
|---|
| 53 | // intercepts either navigation. Same page for allow and deny (neutral copy).
|
|---|
| 54 | function finishRedirect(res, redirectUri, params) {
|
|---|
| 55 | const target = redirectWith(redirectUri, params);
|
|---|
| 56 | if (/^https?:\/\//i.test(redirectUri)) return res.redirect(target);
|
|---|
| 57 | const attr = target.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|---|
| 58 | return res.type('html').send(`<!doctype html>
|
|---|
| 59 | <html lang="en"><head><meta charset="utf-8">
|
|---|
| 60 | <meta name="viewport" content="width=device-width,initial-scale=1">
|
|---|
| 61 | <meta http-equiv="refresh" content="0;url=${attr}">
|
|---|
| 62 | <title>Return to the app</title>
|
|---|
| 63 | <style>body{font-family:system-ui,-apple-system,sans-serif;background:#111;color:#eee;margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;text-align:center}
|
|---|
| 64 | .box{padding:1.5rem}p{color:#aaa;line-height:1.5}a.btn{display:inline-block;margin-top:1.2rem;padding:.85rem 1.7rem;border-radius:12px;background:#5A32E6;color:#fff;text-decoration:none;font-weight:700}</style>
|
|---|
| 65 | </head><body><div class="box">
|
|---|
| 66 | <p>Almost done. If the app doesn't open by itself:</p>
|
|---|
| 67 | <a class="btn" href="${attr}">Open the app</a>
|
|---|
| 68 | </div>
|
|---|
| 69 | <script>location.replace(${JSON.stringify(target)});</script>
|
|---|
| 70 | </body></html>`);
|
|---|
| 71 | }
|
|---|
| 72 |
|
|---|
| 73 | // Bounce back to the client with an OAuth error (RFC 6749 §4.1.2.1) when we have
|
|---|
| 74 | // a validated redirect_uri; otherwise render a plain error (open-redirect guard).
|
|---|
| 75 | function authError(res, redirectUri, state, error, desc) {
|
|---|
| 76 | if (redirectUri) return finishRedirect(res, redirectUri, { error, error_description: desc, state });
|
|---|
| 77 | return res.status(400).json({ error, error_description: desc });
|
|---|
| 78 | }
|
|---|
| 79 |
|
|---|
| 80 | // ── RFC 8414: server metadata ────────────────────────────────────────────
|
|---|
| 81 | router.get('/.well-known/oauth-authorization-server', (req, res) => {
|
|---|
| 82 | const base = baseUrl(req);
|
|---|
| 83 | res.type('application/json').json({
|
|---|
| 84 | issuer: base,
|
|---|
| 85 | authorization_endpoint: `${base}/oauth/authorize`,
|
|---|
| 86 | token_endpoint: `${base}/oauth/token`,
|
|---|
| 87 | registration_endpoint: `${base}/oauth/register`,
|
|---|
| 88 | response_types_supported: ['code'],
|
|---|
| 89 | grant_types_supported: ['authorization_code'],
|
|---|
| 90 | code_challenge_methods_supported: ['S256'],
|
|---|
| 91 | token_endpoint_auth_methods_supported: ['none'],
|
|---|
| 92 | scopes_supported: ['c2s'],
|
|---|
| 93 | });
|
|---|
| 94 | });
|
|---|
| 95 |
|
|---|
| 96 | // ── RFC 7591: dynamic client registration ────────────────────────────────
|
|---|
| 97 | router.post('/oauth/register', (req, res) => {
|
|---|
| 98 | const out = OAuth.registerClient({ client_name: req.body.client_name, redirect_uris: req.body.redirect_uris });
|
|---|
| 99 | if (out.error) return res.status(400).json(out);
|
|---|
| 100 | return res.status(201).json(out);
|
|---|
| 101 | });
|
|---|
| 102 |
|
|---|
| 103 | // ── Authorization: consent screen ────────────────────────────────────────
|
|---|
| 104 | router.get('/oauth/authorize', requireAuth, (req, res) => {
|
|---|
| 105 | const { client_id, redirect_uri, response_type, code_challenge, code_challenge_method, scope, state } = req.query;
|
|---|
| 106 | const client = OAuth.getClient(client_id);
|
|---|
| 107 | // Pre-redirect validation errors must NOT bounce to an unvalidated URI.
|
|---|
| 108 | if (!client) return res.status(400).json({ error: 'invalid_client' });
|
|---|
| 109 | if (!client.redirect_uris.includes(String(redirect_uri || ''))) return res.status(400).json({ error: 'invalid_request', error_description: 'redirect_uri not registered' });
|
|---|
| 110 | if (response_type !== 'code') return authError(res, redirect_uri, state, 'unsupported_response_type');
|
|---|
| 111 | if (code_challenge_method !== 'S256' || !code_challenge) return authError(res, redirect_uri, state, 'invalid_request', 'PKCE S256 required');
|
|---|
| 112 |
|
|---|
| 113 | const sites = manageableSites(req.session.user);
|
|---|
| 114 | if (!sites.length) return authError(res, redirect_uri, state, 'access_denied', 'no manageable sites for this account');
|
|---|
| 115 |
|
|---|
| 116 | return renderPage(req, res, 'pages/oauth-consent', {
|
|---|
| 117 | pageTitleKey: 'oauth.title', bodyClass: 'on-special',
|
|---|
| 118 | client, sites, params: { client_id, redirect_uri, code_challenge, scope: scope || 'c2s', state: state || '' },
|
|---|
| 119 | });
|
|---|
| 120 | });
|
|---|
| 121 |
|
|---|
| 122 | router.post('/oauth/authorize', requireAuth, (req, res) => {
|
|---|
| 123 | const { client_id, redirect_uri, code_challenge, scope, state, site_slug, decision } = req.body;
|
|---|
| 124 | const client = OAuth.getClient(client_id);
|
|---|
| 125 | if (!client || !client.redirect_uris.includes(String(redirect_uri || ''))) {
|
|---|
| 126 | return res.status(400).json({ error: 'invalid_request', error_description: 'client/redirect mismatch' });
|
|---|
| 127 | }
|
|---|
| 128 | if (decision !== 'allow') return authError(res, redirect_uri, state, 'access_denied');
|
|---|
| 129 |
|
|---|
| 130 | const site = db.prepare('SELECT id, slug, owner_id FROM sites WHERE slug = ?').get(String(site_slug || ''));
|
|---|
| 131 | if (!site || !PermissionsService.canAdminSite(req.session.user, site)) {
|
|---|
| 132 | return authError(res, redirect_uri, state, 'access_denied', 'not allowed to post as this site');
|
|---|
| 133 | }
|
|---|
| 134 | const out = OAuth.createCode({
|
|---|
| 135 | clientId: client_id, userId: req.session.user.id, siteSlug: site.slug,
|
|---|
| 136 | redirectUri: redirect_uri, codeChallenge: code_challenge, scope,
|
|---|
| 137 | });
|
|---|
| 138 | if (out.error) return authError(res, redirect_uri, state, out.error, out.error_description);
|
|---|
| 139 | return finishRedirect(res, redirect_uri, { code: out.code, state });
|
|---|
| 140 | });
|
|---|
| 141 |
|
|---|
| 142 | // ── Token exchange ───────────────────────────────────────────────────────
|
|---|
| 143 | router.post('/oauth/token', (req, res) => {
|
|---|
| 144 | res.set('Cache-Control', 'no-store');
|
|---|
| 145 | if (req.body.grant_type !== 'authorization_code') {
|
|---|
| 146 | return res.status(400).json({ error: 'unsupported_grant_type' });
|
|---|
| 147 | }
|
|---|
| 148 | const out = OAuth.exchangeCode({
|
|---|
| 149 | code: req.body.code, client_id: req.body.client_id,
|
|---|
| 150 | redirect_uri: req.body.redirect_uri, code_verifier: req.body.code_verifier,
|
|---|
| 151 | });
|
|---|
| 152 | if (out.error) return res.status(400).json(out);
|
|---|
| 153 | return res.json(out);
|
|---|
| 154 | });
|
|---|
| 155 |
|
|---|
| 156 | export default router;
|
|---|