Changeset 2d66d66 in Klonkt


Ignore:
Timestamp:
07/19/2026 04:17:44 PM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
33e1dbd
Parents:
4407c67
git-author:
Robin <roboburr@…> (07/19/2026 04:17:27 PM)
git-committer:
Robin <roboburr@…> (07/19/2026 04:17:44 PM)
Message:

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@…>

Files:
7 edited

Legend:

Unmodified
Added
Removed
  • CHANGELOG.de.md

    r4407c67 r2d66d66  
    77
    88### Hinzugefügt
     9- **Verbundene Apps auf deiner Kontoseite widerrufen.** Ein Abschnitt
     10  "Verbundene Apps" listet jede App auf, der du per OAuth Zugriff gegeben hast
     11  (Name, Seite, Scope, zuletzt genutzt), mit einem Widerrufen-Knopf. Bereits
     12  vergebene Tokens erscheinen ebenfalls, da sie immer (gehasht) gespeichert
     13  waren; das Token selbst wird nie behalten, daher erfolgt der Widerruf über den
     14  Token-Hash.
    915- **Der Kontoinhaber kann seine eigenen Follower und Gefolgten über C2S lesen.**
    1016  Die `followers`- und `following`-Sammlungen bleiben für die Öffentlichkeit
  • CHANGELOG.md

    r4407c67 r2d66d66  
    77
    88### Added
     9- **Revoke connected apps from your account page.** A "Connected apps" section
     10  lists every app you authorized over OAuth (name, site, scope, last used) with
     11  a Revoke button. Tokens you already granted show up too, since they were
     12  always stored (hashed); the bearer itself is never kept, so revocation is keyed
     13  on the token hash.
    914- **The account owner can read their own followers and following over C2S.** The
    1015  `followers` and `following` collections stay count-only for the public
  • CHANGELOG.nl.md

    r4407c67 r2d66d66  
    77
    88### Toegevoegd
     9- **Trek verbonden apps in vanaf je accountpagina.** Een sectie "Verbonden apps"
     10  toont elke app die je via OAuth toegang gaf (naam, site, scope, laatst
     11  gebruikt) met een Intrekken-knop. Al eerder uitgegeven tokens verschijnen ook,
     12  want die stonden altijd al (gehasht) opgeslagen; het token zelf bewaren we
     13  nooit, dus intrekken gaat op de token-hash.
    914- **De account-eigenaar kan zijn eigen followers en following lezen via C2S.** De
    1015  `followers`- en `following`-collecties blijven count-only voor het publiek
  • src/routes/account.js

    r4407c67 r2d66d66  
    2323import { getPrimarySite } from '../middleware/site.js';
    2424import { renderPage } from '../middleware/render.js';
     25import OAuth from '../services/OAuthService.js';
     26import { t } from '../services/i18n.js';
    2527import { requireAuth } from '../middleware/auth.js';
    2628import { toWebp } from '../services/ImageWebpService.js';
     
    7577    // Display fallback: when you have no own account avatar, show your site's photo.
    7678    siteAvatar: editableSite ? editableSite.profile_photo : null,
     79    // OAuth apps (C2S) this user has authorized, so they can revoke them here.
     80    authorizations: OAuth.listAuthorizations(req.session.user.id),
    7781    success: req.query.success || null,
    7882    error: req.query.error || null,
    7983  });
     84});
     85
     86// ==================== REVOKE AN OAUTH APP AUTHORIZATION ====================
     87router.post('/oauth/revoke', requireAuth, (req, res) => {
     88  const lang = req.session.lang || (req.session.user && req.session.user.lang) || 'nl';
     89  const ok = OAuth.revokeAuthorization(req.session.user.id, req.body.token_hash);
     90  const msg = ok ? t(lang, 'acct.oauth_revoked') : t(lang, 'acct.oauth_revoke_none');
     91  res.redirect('/account?' + (ok ? 'success' : 'error') + '=' + encodeURIComponent(msg));
    8092});
    8193
  • src/services/OAuthService.js

    r4407c67 r2d66d66  
    109109}
    110110
    111 export default { registerClient, getClient, createCode, exchangeCode, verifyBearer, revokeToken, validRedirectUri };
     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};
  • src/services/i18n.js

    r4407c67 r2d66d66  
    744744    'acct.back_home': 'Terug naar home',
    745745    'acct.title': 'Account',
    746     'acct.subtitle': 'Profiel en avatar.',
     746    'acct.subtitle': 'Profiel en avatar.', 'acct.oauth_apps': 'Verbonden apps', 'acct.oauth_hint': 'Apps die je toegang tot je account hebt gegeven via OAuth. Trek in wat je niet meer vertrouwt of gebruikt.', 'acct.oauth_none': 'Nog geen apps verbonden.', 'acct.oauth_unknown_app': 'Onbekende app', 'acct.oauth_last_used': 'laatst gebruikt', 'acct.oauth_never': 'nooit', 'acct.oauth_revoke': 'Intrekken', 'acct.oauth_revoked': 'App-toegang ingetrokken.', 'acct.oauth_revoke_none': 'Die toegang bestond niet meer.',
    747747    'acct.viewer_mode': 'Kijker-modus',
    748748    'acct.viewer_note': 'Dit is een demo-account. Je kunt alles bekijken, maar niets wijzigen — ook geen foto of bio.',
     
    16711671    'acct.back_home': 'Back to home',
    16721672    'acct.title': 'Account',
    1673     'acct.subtitle': 'Profile and avatar.',
     1673    'acct.subtitle': 'Profile and avatar.', 'acct.oauth_apps': 'Connected apps', 'acct.oauth_hint': 'Apps you granted access to your account via OAuth. Revoke anything you no longer trust or use.', 'acct.oauth_none': 'No apps connected yet.', 'acct.oauth_unknown_app': 'Unknown app', 'acct.oauth_last_used': 'last used', 'acct.oauth_never': 'never', 'acct.oauth_revoke': 'Revoke', 'acct.oauth_revoked': 'App access revoked.', 'acct.oauth_revoke_none': 'That access no longer existed.',
    16741674    'acct.viewer_mode': 'Viewer mode',
    16751675    'acct.viewer_note': 'This is a demo account. You can view everything, but change nothing — not even your photo or bio.',
     
    25982598    'acct.back_home': 'Zurück zur Startseite',
    25992599    'acct.title': 'Konto',
    2600     'acct.subtitle': 'Profil und Avatar.',
     2600    'acct.subtitle': 'Profil und Avatar.', 'acct.oauth_apps': 'Verbundene Apps', 'acct.oauth_hint': 'Apps, denen du \u00fcber OAuth Zugriff auf dein Konto gegeben hast. Widerrufe, was du nicht mehr vertraust oder nutzt.', 'acct.oauth_none': 'Noch keine Apps verbunden.', 'acct.oauth_unknown_app': 'Unbekannte App', 'acct.oauth_last_used': 'zuletzt genutzt', 'acct.oauth_never': 'nie', 'acct.oauth_revoke': 'Widerrufen', 'acct.oauth_revoked': 'App-Zugriff widerrufen.', 'acct.oauth_revoke_none': 'Dieser Zugriff bestand nicht mehr.',
    26012601    'acct.viewer_mode': 'Betrachter-Modus',
    26022602    'acct.viewer_note': 'Dies ist ein Demo-Konto. Du kannst alles ansehen, aber nichts ändern — auch kein Foto oder keine Bio.',
  • src/views/pages/account.ejs

    r4407c67 r2d66d66  
    117117  <%# ── PASSWORD ──────────────────────────────────────────── %>
    118118  <%# In viewer mode there is no password section (nothing to change). %>
     119  <%# ── CONNECTED APPS (OAuth C2S) ─────────────────────────── %>
     120  <section class="ax-card">
     121    <div class="ax-card-title"><%= t('acct.oauth_apps') %></div>
     122    <p class="ax-tagline" style="margin:0 0 .6rem"><%= t('acct.oauth_hint') %></p>
     123    <% if (!authorizations || !authorizations.length) { %>
     124      <p class="ax-oauth-empty"><%= t('acct.oauth_none') %></p>
     125    <% } else { %>
     126      <ul class="ax-oauth-list">
     127        <% authorizations.forEach(function(a){ %>
     128          <li class="ax-oauth-item">
     129            <div class="ax-oauth-info">
     130              <strong><%= a.client_name || t('acct.oauth_unknown_app') %></strong>
     131              <span class="ax-oauth-meta">@<%= a.site_slug %> · <%= a.scope || 'c2s' %> · <%= t('acct.oauth_last_used') %> <%= a.last_used_at ? formatDate(a.last_used_at) : t('acct.oauth_never') %></span>
     132            </div>
     133            <form action="/account/oauth/revoke" method="post">
     134              <input type="hidden" name="token_hash" value="<%= a.token_hash %>">
     135              <button type="submit" class="ax-btn ax-btn-danger"><%= t('acct.oauth_revoke') %></button>
     136            </form>
     137          </li>
     138        <% }); %>
     139      </ul>
     140    <% } %>
     141  </section>
     142
    119143  <% if (canMutate) { %>
    120144  <% if (hasPassword) { %>
     
    331355  .ax-profile-id { flex-direction: column; align-items: flex-start; gap: 0.75rem; text-align: left; }
    332356}
     357
     358.ax-oauth-empty { margin: 0; color: var(--ink-soft, #888); }
     359.ax-oauth-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .6rem; }
     360.ax-oauth-item { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .7rem .85rem; border-radius: 12px; background: var(--paper-2, rgba(0,0,0,.04)); }
     361.ax-oauth-info { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
     362.ax-oauth-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
     363.ax-oauth-meta { font-size: .8rem; color: var(--ink-soft, #888); }
     364@media (max-width: 480px) {
     365  .ax-oauth-item { flex-direction: column; align-items: stretch; }
     366}
    333367</style>
Note: See TracChangeset for help on using the changeset viewer.