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
|
# 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
|
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
|
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
|
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.)
|
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
|
- **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.)
|
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/*`),
|
- **SSO (Google / Discord / OIDC)** — **v1** shipped this as a website browser hand-off: an SSO user
|
||||||
**link-only** (no auto-provisioning). For v1 the app does **not** do one-tap in-app SSO; instead an
|
links their identity and sets a password on the website, then uses password login in the app.
|
||||||
SSO user links their identity and sets a password on the website (the existing "set initial password"
|
`GET /auth/providers` is shown so the login screen can direct users to "sign in with … on the website."
|
||||||
path for SSO-provisioned accounts), then uses password login in the app. `GET /auth/sso/providers`
|
- **Native in-app SSO — now being built (M9), post-v1 additive.** The "possible later enhancement"
|
||||||
can still be shown so the login screen can direct users to "sign in with … on the website."
|
noted here is now the **Mobile SSO Authorization Bridge**: a Custom-Tab flow that hands a one-time
|
||||||
- *Possible later enhancement (out of v1):* true in-app SSO via a Custom-Tab flow that hands a
|
code back to the app's fixed callback (`runicgateway://auth/callback`), exchanged for the *existing*
|
||||||
one-time code back to an app link, exchanged for mobile tokens — a small new backend endpoint. Only
|
mobile bearer tokens. It **extends** the existing `/auth/sso/*` redirect flow rather than adding a
|
||||||
build it if password-for-SSO-users proves too clunky.
|
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)
|
### 4.3 Session model (all paths)
|
||||||
- **Refresh:** `POST /auth/mobile/refresh` `{ refreshToken }` → new pair. **Single-use / rotated:** store
|
- **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).
|
its `NTFY_*` deploy config (§13).
|
||||||
9. **M8 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor
|
9. **M8 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor
|
||||||
— after the direct-APK release is stable.
|
— 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)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,51 @@ A DB read never yields a usable reset link. See §4 `/auth/password/*`.
|
|||||||
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
|
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
|
||||||
replaces the whole set. Nothing is pushed unless the user subscribed.
|
replaces the whole set. Nothing is pushed unless the user subscribed.
|
||||||
|
|
||||||
|
### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
|
||||||
|
|
||||||
|
Two short-lived, self-pruning tables that bridge a browser SSO redirect flow to a native client. They
|
||||||
|
carry the **app ↔ website** PKCE + CSRF state (a *second* PKCE layer, distinct from the website ↔ IdP
|
||||||
|
PKCE the `sso_tx` cookie already carries) and the one-time authorization code the app exchanges for
|
||||||
|
bearer tokens. Neither holds a secret in the clear — the PKCE `code_challenge` is a hash by
|
||||||
|
construction, and the authorization code is stored as a **sha256 hash only** (same pattern as
|
||||||
|
`user_invites` / `password_resets` / `mobile_refresh_tokens`).
|
||||||
|
|
||||||
|
`mobile_auth_sessions` — one row per `/auth/mobile/sso/start`:
|
||||||
|
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | INT PK AUTO_INCREMENT | |
|
||||||
|
| session_id | CHAR(36) UNIQUE | opaque uuid; carried inside the signed `sso_tx` (mode `mobile`) so the callback can find this row |
|
||||||
|
| provider | VARCHAR(40) NOT NULL | provider id validated enabled at `/start` |
|
||||||
|
| code_challenge | VARCHAR(255) NOT NULL | app-supplied PKCE S256 challenge (base64url); verified at `/exchange` |
|
||||||
|
| redirect_uri | VARCHAR(255) NOT NULL | the requested app callback — **exact-match** against the allowlist (never prefix) |
|
||||||
|
| state | VARCHAR(255) NOT NULL | app-generated opaque CSRF value, echoed on the callback for the app to verify |
|
||||||
|
| status | ENUM('pending','completed','consumed') DEFAULT 'pending' | `pending`→`completed` when the code is minted; `consumed` after a successful exchange |
|
||||||
|
| user_id | INT NULL FK→users(id) ON DELETE CASCADE | set once SSO resolves the account |
|
||||||
|
| expires_at | DATETIME NOT NULL | short (~10 min — one redirect round-trip incl. TOTP) |
|
||||||
|
| created_at / used_at | DATETIME | `used_at` stamped at exchange |
|
||||||
|
|
||||||
|
`mobile_auth_codes` — one row per completed SSO callback (the code the app redeems):
|
||||||
|
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | INT PK AUTO_INCREMENT | |
|
||||||
|
| code_hash | CHAR(64) UNIQUE | sha256 hex of the opaque ≥128-bit code; the raw code never touches the DB |
|
||||||
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the authenticated account |
|
||||||
|
| session_id | CHAR(36) NOT NULL | the owning `mobile_auth_sessions.session_id` (ties the code to its PKCE challenge) |
|
||||||
|
| expires_at | DATETIME NOT NULL | very short (~5 min) |
|
||||||
|
| used_at | DATETIME NULL | set on first successful exchange — **single use** (a reused code fails) |
|
||||||
|
| created_at | DATETIME | |
|
||||||
|
|
||||||
|
Both self-prune (indexed `expires_at`): a best-effort sweep runs at boot beside the existing
|
||||||
|
`revoked_sessions` prune, and each bridge write opportunistically deletes expired rows — so no cron
|
||||||
|
infra is added (same approach as `revoked_sessions`).
|
||||||
|
|
||||||
|
**`mobile_refresh_tokens` additions (M9).** Two nullable columns are added to support the device
|
||||||
|
list/revoke surface: `device_name VARCHAR(100) NULL` (a friendly label) and `last_used_at DATETIME
|
||||||
|
NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
|
||||||
|
is otherwise unchanged.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. API contract
|
## 4. API contract
|
||||||
@@ -238,6 +283,58 @@ create/publish-post path for `news.post`. The stream catalog + event→stream ma
|
|||||||
- ntfy is treated as an **untrusted relay** — no per-user accounts, unguessable topics; an optional
|
- ntfy is treated as an **untrusted relay** — no per-user accounts, unguessable topics; an optional
|
||||||
`NTFY_PUBLISH_TOKEN` hardens backend→ntfy publishes but is not required. See docs/android/PLAN.md §11.
|
`NTFY_PUBLISH_TOKEN` hardens backend→ntfy publishes but is not required. See docs/android/PLAN.md §11.
|
||||||
|
|
||||||
|
### Mobile SSO Authorization Bridge (`/auth/mobile/sso/*`, M9)
|
||||||
|
|
||||||
|
Native "Sign in with Google/Discord" for the Android app **without shipping any OAuth secret in the
|
||||||
|
app**. The website stays the identity authority: each shard owner's provider credentials live in
|
||||||
|
`auth_providers` (encrypted at rest) and are only ever used server-side. The bridge is a **new
|
||||||
|
consumer of the existing SSO + mobile-bearer machinery**, not a parallel auth path — it reuses the
|
||||||
|
`/auth/sso/:provider/*` redirect flow, the link-only + opt-in-provisioning policy, the TOTP gate, and
|
||||||
|
issues the **same** token pair as `/auth/mobile/login`.
|
||||||
|
|
||||||
|
| Method | Path | Auth | Body / Query | Purpose |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| GET | `/auth/providers` | — | — | **reused** discovery; the app renders provider buttons from this (never exposes secrets) |
|
||||||
|
| GET | `/auth/mobile/sso/start` | — (rate-limited per-IP + per-provider) | `?provider&code_challenge&state&redirect_uri` | validate provider enabled + `redirect_uri` **exact-match** allowlist; insert a `mobile_auth_sessions` row; create the existing `sso_tx` tagged `mode:'mobile'` carrying `session_id`; **302 to the IdP** (existing authorize URL) |
|
||||||
|
| GET | `/auth/sso/:provider/callback` | — (signed `sso_tx`) | `?code&state` | **existing** endpoint; a new branch when `tx.mode==='mobile'`: resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into `mobile_auth_codes`, mark the session `completed`, and **302 to `redirect_uri?code=…&state=…`** (the app's original `state`) — **no cookie is set** |
|
||||||
|
| POST | `/auth/mobile/sso/exchange` | — (rate-limited per-IP) | `{code, code_verifier}` | validate the code exists / unexpired / unused (mark used) and `sha256(code_verifier)` matches the stored challenge → issue the existing mobile access + refresh pair (`createMobileSession`) → `{accessToken, refreshToken, expiresIn, user}` |
|
||||||
|
| POST | `/auth/mobile/refresh` | — | `{refreshToken}` | **reused** unchanged — rotate the pair |
|
||||||
|
| POST | `/auth/mobile/logout` | bearer | `{refreshToken?, all?}` | **reused** unchanged — revoke this (or all) refresh token(s) |
|
||||||
|
| GET | `/auth/me/sessions` · DELETE `…/:id` | cookie / bearer | — | list / revoke own **mobile sessions** (device_name, last_used_at, created_at) — the "Active Devices" surface (distinct from `/auth/me/devices`, which is push endpoints) |
|
||||||
|
|
||||||
|
**Two PKCE layers (do not conflate).**
|
||||||
|
- *Layer A (existing):* website ↔ IdP. The `code_verifier` is generated at `/start`, kept only in the
|
||||||
|
httpOnly `sso_tx` cookie, sent to the IdP token endpoint at the callback. Unchanged.
|
||||||
|
- *Layer B (new):* app ↔ website. The **app** generates `code_verifier`/`code_challenge`; the
|
||||||
|
challenge is stored in `mobile_auth_sessions` at `/start`; the verifier is presented at `/exchange`.
|
||||||
|
This is what stops an intercepted callback code from being redeemed by anyone but the real app.
|
||||||
|
|
||||||
|
**State / CSRF.** The app-generated `state` is stored at `/start`, echoed on the callback redirect,
|
||||||
|
and **verified by the app** before it calls `/exchange` — a CSRF guard independent of both PKCE
|
||||||
|
layers (a different app instance triggering `/start` cannot complete someone else's flow).
|
||||||
|
|
||||||
|
**Redirect-URI allowlist.** `/start` and the callback validate `redirect_uri` by **exact match**
|
||||||
|
against a configured allowlist (`MOBILE_AUTH_REDIRECT_URIS`, default the one fixed application-owned
|
||||||
|
callback `runicgateway://auth/callback`) — **never prefix match** (prefix matching on custom schemes
|
||||||
|
is a known open-redirect vector). Tokens are **never** placed in the callback URL — only the
|
||||||
|
short-lived authorization code. HTTPS App Link URIs can be appended to the allowlist later per shard;
|
||||||
|
that is the *only* server change App Links require (see docs/android/APP_LINKS.md).
|
||||||
|
|
||||||
|
**TOTP through the bridge.** A 2FA account keeps full parity: the callback stages the existing
|
||||||
|
pending-TOTP cookie (now also carrying the bridge `session_id`) and bounces the Custom Tab through the
|
||||||
|
web TOTP form; on a correct code the completion mints the authorization code and deep-links back to
|
||||||
|
the app — it never mints a session cookie for a mobile flow.
|
||||||
|
|
||||||
|
**Revocation latency (documented tradeoff).** Revoking a refresh token (device revoke / logout) stops
|
||||||
|
future renewals but does **not** invalidate an already-issued access token until it expires — up to
|
||||||
|
the access-token lifetime (`MOBILE_ACCESS_TTL`, default 15 min) of continued access. This is an
|
||||||
|
accepted tradeoff given the short lifetime. If instant revocation is ever required, add an
|
||||||
|
access-token (jti) blocklist check on the `requireAuth` path — the same `revoked_sessions` mechanism
|
||||||
|
web sessions already use.
|
||||||
|
|
||||||
|
**Authorization code.** Cryptographically random, ≥128 bits, stored **hash-only**, single-use, short
|
||||||
|
expiry (~5 min); `/exchange` is rate-limited per-IP. The bridge tables self-prune (§3).
|
||||||
|
|
||||||
### /public (public.routes.js → public.controller.js) — all GET, no auth
|
### /public (public.routes.js → public.controller.js) — all GET, no auth
|
||||||
| Method | Path | Notes |
|
| Method | Path | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
Reference in New Issue
Block a user