source: Klonkt/src/routes/oauth.js@ d8f7d9c

main
Last change on this file since d8f7d9c was bf72108, checked in by Robin <roboburr@…>, 8 weeks ago

Fix: OAuth consent hands off to native apps via an interstitial

After Allow/Deny the server 302-redirected to the client's redirect_uri. For a
native custom scheme (com.klonkt.shaer:/oauth) that 302 is silently dropped by
mobile browsers (no user gesture to launch an external app), so Allow/Deny
appeared to do nothing. finishRedirect() now keeps the 302 for http/https
clients but, for a non-http redirect_uri, serves a tiny interstitial that
meta/JS auto-forwards AND shows an 'Open the app' tap link — a tap is the user
gesture Android needs, and iOS's ASWebAuthenticationSession intercepts either
navigation. Used for both allow (code) and deny/error. Verified: native →
interstitial HTML with the code + tap link; https → still 302; deny → error in
the interstitial. 80 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 8.4 KB
Line 
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 */
14import express from 'express';
15import db from '../config/database.js';
16import OAuth from '../services/OAuthService.js';
17import { requireAuth } from '../middleware/auth.js';
18import { renderPage } from '../middleware/render.js';
19import PermissionsService from '../services/PermissionsService.js';
20import { apEnabled } from '../services/SettingsService.js';
21
22const router = express.Router();
23router.use((req, res, next) => { if (!apEnabled()) return next('router'); next(); });
24
25const 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.
28function 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.
38function 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).
54function 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
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).
75function 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 ────────────────────────────────────────────
81router.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 ────────────────────────────────
97router.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 ────────────────────────────────────────
104router.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
122router.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 ───────────────────────────────────────────────────────
143router.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
156export default router;
Note: See TracBrowser for help on using the repository browser.