Index: CHANGELOG.de.md
===================================================================
--- CHANGELOG.de.md	(revision dd568e79b476d730bac4264e3e6d0bab5cfb9753)
+++ CHANGELOG.de.md	(revision bf72108b0805b6f9d9780ea0ffef023a60b86f4d)
@@ -28,4 +28,12 @@
 
 ### Behoben
+- **OAuth-Zustimmung übergibt jetzt zuverlässig an native Apps.** Nach
+  Allow/Deny war die Weiterleitung an ein natives Custom-Scheme (z. B.
+  `com.klonkt.shaer:/oauth`) ein einfacher 302, den mobile Browser stillschweigend
+  verwerfen. Der Zustimmungsschritt liefert nun eine kleine Zwischenseite für
+  Nicht-http-Redirect-URIs, die automatisch weiterleitet und einen "App öffnen"-
+  Tipp-Link bietet (ein Tipp startet die App auf Android zuverlässig; iOS'
+  Web-Auth-Session fängt sie ohnehin ab). Web-Clients (http/https) bekommen
+  weiterhin einen 302.
 - **Besucher können auf die eigenen Kommentare des Seiteninhabers antworten.**
   Der Knopf "über das Fediverse antworten" erschien nur bei Kommentaren anderer;
Index: CHANGELOG.md
===================================================================
--- CHANGELOG.md	(revision dd568e79b476d730bac4264e3e6d0bab5cfb9753)
+++ CHANGELOG.md	(revision bf72108b0805b6f9d9780ea0ffef023a60b86f4d)
@@ -26,4 +26,10 @@
 
 ### Fixed
+- **OAuth consent now hands off reliably to native apps.** After Allow/Deny, a
+  redirect to a native custom scheme (e.g. `com.klonkt.shaer:/oauth`) was a plain
+  302, which mobile browsers silently drop. The consent step now serves a tiny
+  interstitial for non-http redirect URIs that auto-forwards and offers an "Open
+  the app" tap link (a tap reliably launches the app on Android; iOS's web-auth
+  session intercepts either way). Web (http/https) clients still get a 302.
 - **Visitors can reply to the site owner's own comments.** The "reply via the
   fediverse" button only appeared on comments from others; the site's own
Index: CHANGELOG.nl.md
===================================================================
--- CHANGELOG.nl.md	(revision dd568e79b476d730bac4264e3e6d0bab5cfb9753)
+++ CHANGELOG.nl.md	(revision bf72108b0805b6f9d9780ea0ffef023a60b86f4d)
@@ -27,4 +27,11 @@
 
 ### Opgelost
+- **OAuth-toestemming geeft nu betrouwbaar over aan native apps.** Na Allow/Deny
+  was de redirect naar een native custom-scheme (bijv. `com.klonkt.shaer:/oauth`)
+  een gewone 302, en die negeren mobiele browsers stilzwijgend. De toestemmings-
+  stap serveert nu een klein tussenscherm voor niet-http redirect-URI's dat
+  automatisch doorstuurt én een "Open de app"-tikknop biedt (een tik opent de app
+  betrouwbaar op Android; iOS' web-auth-sessie vangt 'm sowieso op). Web-clients
+  (http/https) krijgen nog steeds een 302.
 - **Bezoekers kunnen reageren op de eigen reacties van de site-eigenaar.** De
   knop "reageer via de fediverse" verscheen alleen bij reacties van anderen; bij
Index: src/routes/oauth.js
===================================================================
--- src/routes/oauth.js	(revision dd568e79b476d730bac4264e3e6d0bab5cfb9753)
+++ src/routes/oauth.js	(revision bf72108b0805b6f9d9780ea0ffef023a60b86f4d)
@@ -45,8 +45,34 @@
 }
 
+// Hand control back to the client at redirect_uri + params. For a web client
+// (http/https) a plain 302 is right. For a NATIVE custom scheme
+// (com.klonkt.shaer:/oauth) a 302 is unreliable: mobile browsers routinely drop
+// a server redirect to a custom scheme (no user gesture). So we serve a tiny
+// interstitial that both auto-forwards AND offers a tap link — a tap is a user
+// gesture that launches the app on Android, and iOS's ASWebAuthenticationSession
+// intercepts either navigation. Same page for allow and deny (neutral copy).
+function finishRedirect(res, redirectUri, params) {
+  const target = redirectWith(redirectUri, params);
+  if (/^https?:\/\//i.test(redirectUri)) return res.redirect(target);
+  const attr = target.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
+  return res.type('html').send(`<!doctype html>
+<html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<meta http-equiv="refresh" content="0;url=${attr}">
+<title>Return to the app</title>
+<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}
+.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>
+</head><body><div class="box">
+<p>Almost done. If the app doesn't open by itself:</p>
+<a class="btn" href="${attr}">Open the app</a>
+</div>
+<script>location.replace(${JSON.stringify(target)});</script>
+</body></html>`);
+}
+
 // Bounce back to the client with an OAuth error (RFC 6749 §4.1.2.1) when we have
 // a validated redirect_uri; otherwise render a plain error (open-redirect guard).
 function authError(res, redirectUri, state, error, desc) {
-  if (redirectUri) return res.redirect(redirectWith(redirectUri, { error, error_description: desc, state }));
+  if (redirectUri) return finishRedirect(res, redirectUri, { error, error_description: desc, state });
   return res.status(400).json({ error, error_description: desc });
 }
@@ -111,5 +137,5 @@
   });
   if (out.error) return authError(res, redirect_uri, state, out.error, out.error_description);
-  return res.redirect(redirectWith(redirect_uri, { code: out.code, state }));
+  return finishRedirect(res, redirect_uri, { code: out.code, state });
 });
 
