source: Klonkt/src/views/pages/oauth-consent.ejs@ 21ef238

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

Feature: OAuth 2.0 for ActivityPub C2S (phase 1 — auth handshake)

First half of AP Client-to-Server: the auth layer native/web clients (Shaer)
need before they can drive a Klonkt account. The AP spec's own C2S half is what
keeps this inside-spec instead of cloning Mastodon's REST API.

  • OAuthService: public-client OAuth (RFC 8252), PKCE S256 REQUIRED, no secrets. Dynamic registration (RFC 7591 subset) with strict redirect_uri validation (https / loopback http / reverse-DNS custom scheme). Single-use 10-min codes; tokens stored sha256-hashed; a token is scoped to one user + one site.
  • routes/oauth.js: /oauth/register, /oauth/authorize (session-authed consent screen picking the site), /oauth/token, and RFC 8414 server metadata at /.well-known/oauth-authorization-server. Redirect params are appended to the registered URI verbatim (no new URL() round-trip that would mangle a native custom scheme). Pre-redirect validation errors never bounce to an unvalidated URI (open-redirect guard).
  • Actor doc advertises oauthAuthorizationEndpoint/oauthTokenEndpoint/uploadMedia in endpoints{} — all AP-spec terms, added to the AS2 conformance allowlist — so clients discover paths instead of hardcoding them (Klonkt's /ap/users/:slug differs from the daemon's /actors/:name; discovery makes that irrelevant).
  • oauth_clients/oauth_codes/oauth_tokens tables (additive).
  • i18n NL/EN/DE for the consent screen.

7 new OAuth tests (PKCE round-trip, replay protection, wrong-verifier reject,
bearer resolution incl. revoke, redirect-uri validation); 73 green. Verified
the full HTTP flow end to end (register → consent → code → token → bearer) and
that the raw Location header preserves the native redirect URI exactly. Beads:
klonkt-demo-srr. Next: klonkt-demo-1w4 (POST outbox accepts the activities).

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

  • Property mode set to 100644
File size: 3.3 KB
Line 
1<div class="oa-wrap">
2 <h1 class="oa-title"><%= t('oauth.title') %></h1>
3 <p class="oa-lead">
4 <strong><%= client.client_name %></strong>
5 <%= t('oauth.wants_access') %>
6 </p>
7
8 <form method="post" action="/oauth/authorize" class="oa-form">
9 <input type="hidden" name="client_id" value="<%= params.client_id %>">
10 <input type="hidden" name="redirect_uri" value="<%= params.redirect_uri %>">
11 <input type="hidden" name="code_challenge" value="<%= params.code_challenge %>">
12 <input type="hidden" name="scope" value="<%= params.scope %>">
13 <input type="hidden" name="state" value="<%= params.state %>">
14
15 <label class="oa-label"><%= t('oauth.post_as') %></label>
16 <% if (sites.length === 1) { %>
17 <input type="hidden" name="site_slug" value="<%= sites[0].slug %>">
18 <div class="oa-single"><%= sites[0].title || sites[0].slug %> <span class="oa-slug">@<%= sites[0].slug %></span></div>
19 <% } else { %>
20 <select name="site_slug" class="oa-select">
21 <% sites.forEach(function(s){ %>
22 <option value="<%= s.slug %>"><%= s.title || s.slug %> (@<%= s.slug %>)</option>
23 <% }); %>
24 </select>
25 <% } %>
26
27 <ul class="oa-scopes">
28 <li><%= t('oauth.scope_read') %></li>
29 <li><%= t('oauth.scope_write') %></li>
30 </ul>
31
32 <div class="oa-actions">
33 <button type="submit" name="decision" value="deny" class="btn oa-deny"><%= t('oauth.deny') %></button>
34 <button type="submit" name="decision" value="allow" class="btn btn-primary oa-allow"><%= t('oauth.allow') %></button>
35 </div>
36 </form>
37 <p class="oa-foot"><%= t('oauth.foot') %></p>
38</div>
39
40<style>
41 .oa-wrap { max-width: 460px; margin: 2.5rem auto; padding: 1.75rem 1.5rem; border-radius: 16px;
42 background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent);
43 border: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); }
44 .oa-title { margin: 0 0 .6rem; font-size: 1.4rem; }
45 .oa-lead { color: var(--ink-soft, #999); line-height: 1.55; margin: 0 0 1.4rem; }
46 .oa-lead strong { color: var(--ink, inherit); }
47 .oa-label { display: block; font-weight: 700; font-size: .82rem; margin: 0 0 .4rem; color: var(--ink-soft, #888); text-transform: uppercase; letter-spacing: .03em; }
48 .oa-single { padding: .6rem .8rem; border-radius: 10px; background: color-mix(in srgb, var(--ink, #000) 5%, transparent); font-weight: 600; }
49 .oa-slug { color: var(--ink-soft, #999); font-weight: 400; }
50 .oa-select { width: 100%; padding: .6rem .8rem; border-radius: 10px; font: inherit;
51 border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); background: transparent; color: var(--ink, inherit); }
52 .oa-scopes { list-style: none; padding: .9rem 0 0; margin: .9rem 0 0; border-top: 1px solid color-mix(in srgb, var(--ink, #000) 8%, transparent);
53 display: flex; flex-direction: column; gap: .5rem; color: var(--ink-soft, #aaa); }
54 .oa-scopes li { position: relative; padding-left: 1.5rem; line-height: 1.4; }
55 .oa-scopes li::before { content: "✓"; position: absolute; left: 0; color: #2fa85a; font-weight: 700; }
56 .oa-actions { display: flex; gap: .7rem; margin-top: 1.5rem; }
57 .oa-deny { flex: 0 0 auto; }
58 .oa-allow { flex: 1; }
59 .oa-foot { color: var(--ink-soft, #888); font-size: .78rem; line-height: 1.5; margin: 1.1rem 0 0; }
60</style>
Note: See TracBrowser for help on using the repository browser.