docs(auth): design the mobile SSO authorization bridge (M9)
Record the plan before coding: native Android "Sign in with Google/Discord"
via a Mobile SSO Authorization Bridge that extends the existing /auth/sso/*
redirect flow and terminates in the existing mobile bearer tokens.
- BACKEND_DESIGN.md: mobile_auth_sessions / mobile_auth_codes schema, the
/auth/mobile/sso/{start,exchange} contract, the two PKCE layers, state/CSRF,
exact-match redirect-URI allowlist, TOTP parity, and the documented
revocation-latency window.
- android/PLAN.md: promote §4.2's "possible later enhancement" to milestone M9
(backend-first, mirroring M7); status note.
- android/APP_LINKS.md: new architecture note on the per-shard assetlinks.json
/ pairing multi-tenancy question (App Links deferred; custom scheme only now).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
103
android/APP_LINKS.md
Normal file
103
android/APP_LINKS.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Architecture note — App Links & the multi-tenant callback problem
|
||||
|
||||
Status: **design note; not yet implemented.** Written before the App Links work begins so the
|
||||
multi-tenancy question is decided on paper first (per the mobile-SSO spec). The native SSO bridge
|
||||
ships with the **custom-scheme** callback only (`runicgateway://auth/callback`); everything below is
|
||||
the *later* hardening path and its open design questions.
|
||||
|
||||
Read alongside: the "Mobile SSO Authorization Bridge" section of
|
||||
[`../website/BACKEND_DESIGN.md`](../website/BACKEND_DESIGN.md) (the endpoints/tables), and
|
||||
[`PLAN.md`](./PLAN.md) §4.2 / §9 (the app milestone).
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem
|
||||
|
||||
The mobile SSO bridge redirects the browser back to the app with a one-time code:
|
||||
|
||||
```
|
||||
runicgateway://auth/callback?code=…&state=…
|
||||
```
|
||||
|
||||
A **custom URI scheme** is fine for a self-hosted, single-tenant, internal client, but it is *not*
|
||||
owned by anyone: any other Android app can also register an intent-filter for
|
||||
`runicgateway://auth/callback` and, if chosen by the user (or if it registers more specifically),
|
||||
intercept the callback. The code is single-use, PKCE-bound, and short-lived — so an interceptor
|
||||
still cannot complete `/exchange` without the app's `code_verifier` — but a hijacked callback is
|
||||
still a denial-of-service and a phishing surface we would rather close.
|
||||
|
||||
**Android App Links** (verified `https://` deep links) close it: the OS only routes an `https://`
|
||||
link to an app that has proven, via a file served from *that domain*, that it owns the app. An
|
||||
attacker cannot serve that file on a domain they do not control.
|
||||
|
||||
## 2. Why this is harder here than in a normal app
|
||||
|
||||
RunicGateway is **self-hosted per shard**. There is no single canonical domain — every shard owner
|
||||
runs the website on **their own** domain (`play.exampleshard.com`, `uo.anothershard.net`, …). App
|
||||
Links verification is **per-domain**: the domain must serve
|
||||
|
||||
```
|
||||
https://<shard-domain>/.well-known/assetlinks.json
|
||||
```
|
||||
|
||||
asserting the Android app's **package name** + **signing-certificate SHA-256 fingerprint**. The one
|
||||
published app binary (one package name, one signing cert) must therefore be verifiable against
|
||||
**every** shard domain that wants App Links — a domain set that is open-ended and not known at build
|
||||
time.
|
||||
|
||||
Two consequences:
|
||||
|
||||
1. **The shard must serve `assetlinks.json`.** Shard owners will not hand-edit a JSON file with a
|
||||
cert fingerprint. The website has to **auto-serve** it from an admin setting.
|
||||
2. **The app must know which shard domain it is paired to** before it can trust an App Link for that
|
||||
domain. This is a *pairing/bootstrapping* problem, not just a callback-security detail — it is the
|
||||
part that makes App Links more than a drop-in swap for the custom scheme.
|
||||
|
||||
## 3. Proposed shape (when we build it)
|
||||
|
||||
### 3.1 Server: auto-served `assetlinks.json`
|
||||
|
||||
- One published app ⇒ one package name (`com.runicgateway.app`) and one release signing cert. Its
|
||||
SHA-256 fingerprint is a **constant of the published app**, not shard-specific.
|
||||
- Add a website route `GET /.well-known/assetlinks.json` (served at the **web root**, outside
|
||||
`/api/v1`) that emits the Digital Asset Links statement for that fixed package + fingerprint.
|
||||
- Gate it behind an admin setting `mobile_app_links_enabled` (default **off**). Off ⇒ the route 404s
|
||||
and the app stays on the custom scheme for that shard. On ⇒ the shard opts into App Links.
|
||||
- The fingerprint is the same for every shard, so it can be a shipped constant / env default
|
||||
(`MOBILE_APP_CERT_SHA256`) rather than something each owner types. The **only** per-shard action is
|
||||
flipping the setting on.
|
||||
- When enabled, the shard also registers its `https://<domain>/mobile/callback` URL into the mobile
|
||||
redirect-URI allowlist (see the bridge's exact-match allowlist) **in addition to** the custom
|
||||
scheme — the custom scheme is never removed, it is the universal fallback.
|
||||
|
||||
### 3.2 App: which domain do I trust?
|
||||
|
||||
- The app already stores the shard **base URL** it is paired to (first-run connect flow, PLAN §3).
|
||||
That base URL's host is the *only* domain the app should accept an App Link callback from.
|
||||
- The intent-filter for `https://…/mobile/callback` cannot be scoped to a runtime host in the
|
||||
manifest (intent-filters are static). Options, in order of preference:
|
||||
1. **Custom scheme stays the default**; App Links are an *opt-in* the app only relies on after it
|
||||
has (a) a paired base URL and (b) confirmed that host serves a valid `assetlinks.json`. Until
|
||||
both hold, the app requests the custom-scheme `redirect_uri` at `/start`. This keeps a single
|
||||
code path and avoids trusting an unverified `https` callback.
|
||||
2. Register a broad `https` autoVerify intent-filter and **reject at runtime** any callback whose
|
||||
host ≠ the paired base-URL host. AutoVerify only succeeds for domains that actually serve the
|
||||
file, so in practice only real, opted-in shard domains route to the app; the runtime host check
|
||||
is defense-in-depth.
|
||||
- **Decision to make at build time:** whether to ship the `https` autoVerify intent-filter at all in
|
||||
v1 of the native SSO client, or defer it entirely and ship custom-scheme-only. Given the spec's
|
||||
guidance ("custom scheme is the practical default; App Links can be layered on per-instance"),
|
||||
**custom-scheme-only for the first native-SSO release** is the recommended path.
|
||||
|
||||
## 4. Recommendation
|
||||
|
||||
- **This round:** custom scheme only. No `assetlinks.json` route, no autoVerify intent-filter, no
|
||||
pairing changes. The bridge's redirect-URI allowlist contains exactly the one fixed
|
||||
application-owned callback (`runicgateway://auth/callback`).
|
||||
- **Follow-up (opt-in hardening), only if/when the app is published publicly:** implement §3.1
|
||||
(auto-served `assetlinks.json` behind an admin toggle) and §3.2 option 1 (App Links relied on only
|
||||
after the paired host is verified). Keep the custom scheme as the permanent fallback.
|
||||
|
||||
Nothing in the bridge's server design has to change to add App Links later: it is purely *more
|
||||
entries in the redirect-URI allowlist* plus a static file route. That is the point of keeping the
|
||||
allowlist exact-match and configurable from day one.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Android App — Plan
|
||||
|
||||
Status: **M0–M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag.** This document is the
|
||||
Status: **M0–M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag. M9 (native SSO login) is now underway backend-first — the Mobile SSO Authorization Bridge is being built in `website/` + `docs/` ahead of the app-side client (§4.2, §9 M9); custom-scheme callback only for now, App Links deferred (see [`APP_LINKS.md`](./APP_LINKS.md)).** This document is the
|
||||
design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the
|
||||
API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API
|
||||
reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated
|
||||
@@ -467,14 +467,19 @@ completes them in a Custom Tab, then returns and signs in natively (§4.1):
|
||||
new username + password. (No mobile register/invite endpoints needed.)
|
||||
- **Forgot / reset password** — the app links to the website's reset page (the flow being built in §8
|
||||
before app work). The user resets there, then signs into the app. (No mobile reset endpoint needed.)
|
||||
- **SSO (Google / Discord / OIDC)** — SSO stays the website's browser redirect flow (`/auth/sso/*`),
|
||||
**link-only** (no auto-provisioning). For v1 the app does **not** do one-tap in-app SSO; instead an
|
||||
SSO user links their identity and sets a password on the website (the existing "set initial password"
|
||||
path for SSO-provisioned accounts), then uses password login in the app. `GET /auth/sso/providers`
|
||||
can still be shown so the login screen can direct users to "sign in with … on the website."
|
||||
- *Possible later enhancement (out of v1):* true in-app SSO via a Custom-Tab flow that hands a
|
||||
one-time code back to an app link, exchanged for mobile tokens — a small new backend endpoint. Only
|
||||
build it if password-for-SSO-users proves too clunky.
|
||||
- **SSO (Google / Discord / OIDC)** — **v1** shipped this as a website browser hand-off: an SSO user
|
||||
links their identity and sets a password on the website, then uses password login in the app.
|
||||
`GET /auth/providers` is shown so the login screen can direct users to "sign in with … on the website."
|
||||
- **Native in-app SSO — now being built (M9), post-v1 additive.** The "possible later enhancement"
|
||||
noted here is now the **Mobile SSO Authorization Bridge**: a Custom-Tab flow that hands a one-time
|
||||
code back to the app's fixed callback (`runicgateway://auth/callback`), exchanged for the *existing*
|
||||
mobile bearer tokens. It **extends** the existing `/auth/sso/*` redirect flow rather than adding a
|
||||
parallel auth path — same PKCE-vs-IdP, same link-only + opt-in-provisioning policy, same TOTP gate,
|
||||
same token shape as `/auth/mobile/login`. The bridge adds a **second** PKCE layer (app ↔ website)
|
||||
and an app-generated `state` (CSRF, verified by the app before exchange). Backend + docs land first
|
||||
(this document's canonical API ref is `../website/BACKEND_DESIGN.md` → "Mobile SSO Authorization
|
||||
Bridge"); the native app client is M9. Custom-scheme callback only for now — App Links are deferred
|
||||
(see [`APP_LINKS.md`](./APP_LINKS.md)).
|
||||
|
||||
### 4.3 Session model (all paths)
|
||||
- **Refresh:** `POST /auth/mobile/refresh` `{ refreshToken }` → new pair. **Single-use / rotated:** store
|
||||
@@ -704,6 +709,23 @@ push, and Play (M6–M8) follow the designed app.
|
||||
its `NTFY_*` deploy config (§13).
|
||||
9. **M8 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor
|
||||
— after the direct-APK release is stable.
|
||||
10. **M9 — Native SSO login** (post-v1, additive; independent of M8): in-app "Sign in with Google /
|
||||
Discord" via the **Mobile SSO Authorization Bridge** (§4.2). **Backend-first**, mirroring M7's
|
||||
split:
|
||||
- **Part 1 — backend + docs (in progress):** `mobile_auth_sessions` + `mobile_auth_codes` bridge
|
||||
tables; `GET /auth/mobile/sso/start` (seeds a bridge session, reuses the existing SSO redirect
|
||||
tagged `mode:'mobile'`); a mobile branch in the SSO callback + TOTP-completion that mints a
|
||||
single-use, hashed, PKCE-bound authorization code and redirects to the fixed app callback instead
|
||||
of setting a cookie; `POST /auth/mobile/sso/exchange` (code + PKCE verifier → the existing mobile
|
||||
bearer token pair); an exact-match redirect-URI allowlist; boot-time + opportunistic cleanup of
|
||||
the bridge tables. Reuses `GET /auth/providers` for discovery and `POST /auth/mobile/{refresh,
|
||||
logout}` unchanged. See `../website/BACKEND_DESIGN.md`.
|
||||
- **Part 2 — app client:** register the `runicgateway://auth/callback` intent-filter; generate
|
||||
`code_verifier`/`code_challenge` + `state`; open the Custom Tab at `/auth/mobile/sso/start`;
|
||||
verify `state` on the callback; `POST …/exchange`; store the returned pair in the existing
|
||||
`TokenStore` (M3). No new token-storage or refresh code — it feeds the M3 session machinery.
|
||||
- **Deferred:** App Links / per-shard `assetlinks.json` / pairing — custom scheme only for now
|
||||
([`APP_LINKS.md`](./APP_LINKS.md)).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user