source: Klonkt/src/services/OAuthService.js@ 9f9b45f

main
Last change on this file since 9f9b45f was 2d66d66, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: revoke connected OAuth apps from the account page

You issue C2S bearer tokens (Shaer, etc.) but had no way to see or revoke them.
The account page now has a 'Connected apps' section listing every authorization
(app name via the client join, site, scope, last used) with a Revoke button.
Already-issued tokens appear because they were always stored (hashed) with the
user/client/site; the bearer is never kept, so revocation is keyed on the safe
token_hash and scoped to the owner (you cannot revoke someone else's).

  • OAuthService.listAuthorizations(userId) / revokeAuthorization(userId, hash).
  • account.js: authorizations passed to the page; POST /account/oauth/revoke.
  • account.ejs: the section + styles; i18n NL/EN/DE. Visible to viewers too (revoking your own app access is a safety action).

83 tests green. Live-verified: two apps listed on /account, revoke one -> it is
gone and the other stays, token count drops in the DB.

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

  • Property mode set to 100644
File size: 6.9 KB
Line 
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
15import crypto from 'crypto';
16import db from '../config/database.js';
17
18const CODE_TTL_MS = 10 * 60 * 1000;
19
20const b64url = (buf) => buf.toString('base64url');
21const 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.
25export 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.
35export 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
55export 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.
63export 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.
76export 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.
94export 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
107export function revokeToken(token) {
108 try { db.prepare('DELETE FROM oauth_tokens WHERE token_hash = ?').run(b64url(sha256(String(token || '')))); } catch { /* ignore */ }
109}
110
111// The active authorizations (bearer tokens) a user has granted, with the app
112// name and the site each is scoped to. The bearer itself is never stored, so
113// revocation is keyed on token_hash: safe to render, you cannot derive the
114// token from its hash.
115export function listAuthorizations(userId) {
116 return db.prepare(`
117 SELECT t.token_hash, t.site_slug, t.scope, t.created_at, t.last_used_at, c.client_name
118 FROM oauth_tokens t
119 LEFT JOIN oauth_clients c ON c.client_id = t.client_id
120 WHERE t.user_id = ?
121 ORDER BY t.created_at DESC
122 `).all(String(userId || ''));
123}
124
125// Revoke one authorization, scoped to the owner so a user can only revoke their
126// own tokens. Returns true when a row was removed.
127export function revokeAuthorization(userId, tokenHash) {
128 try {
129 const r = db.prepare('DELETE FROM oauth_tokens WHERE token_hash = ? AND user_id = ?')
130 .run(String(tokenHash || ''), String(userId || ''));
131 return r.changes > 0;
132 } catch { return false; }
133}
134
135export default {
136 registerClient, getClient, createCode, exchangeCode, verifyBearer, revokeToken, validRedirectUri,
137 listAuthorizations, revokeAuthorization,
138};
Note: See TracBrowser for help on using the repository browser.