11 Commits

Author SHA1 Message Date
ec468e9983 docs(ci): use the actual case-sensitive SonarQube project keys
link and Android-app reuse the pre-existing capitalised keys
(Runic-Gateway-link, Runic-Gateway-Android-app); the server rejects
case-variant duplicates. Note the case-sensitivity gotcha.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:21:04 -05:00
2257df09eb docs(ci): document the SonarQube static-analysis setup
Adds docs/ci/SONARQUBE.md covering the non-blocking push-to-main scan
wired into website, link, and Android-app: server URL, per-repo project
keys/sources, the SONAR_TOKEN secret + SONAR_HOST_URL variable, and how
to onboard a new repo.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:13:54 -05:00
17f9207a17 Merge pull request 'docs(website): document the SPA Content-Security-Policy' (#26) from docs/csp-security-headers into main
Reviewed-on: #26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:04:02 +00:00
28c5c228f7 docs(website): document the SPA Content-Security-Policy
Replace the vague "helmet with a CSP suited to the SPA" line with the actual
policy now implemented in server/src/app.js: per-directive sources and the
rationale for each non-'self' allowance (Google Fonts, inline React styles,
external/embedded images, same-origin REST+SSE), why upgrade-insecure-requests
is omitted, the scoped looser CSP for the /api/docs Swagger UI route, and the
X-Powered-By handling across the public and internal listeners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 23:02:57 -05:00
6e0ff2a821 Merge pull request 'docs(android): spec Android App Links (assetlinks.json + build-time host)' (#25) from docs/app-links into main
Reviewed-on: #25
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 23:42:24 +00:00
0109df6963 Merge branch 'main' into docs/app-links 2026-07-20 23:42:13 +00:00
752793f6c3 docs(android): spec Android App Links (assetlinks.json + build-time host)
Promote APP_LINKS.md from a deferred design note into an implementation spec
matching the website `feat/mobile-app-links` and android `feat/app-links`
branches: the server-side `/.well-known/assetlinks.json` route + `mobile_app_links_enabled`
toggle + additive redirect-allowlist entry, and the app-side `autoVerify`
intent-filter driven by a build-time `appLinkHost` (a single multi-tenant APK
cannot autoVerify open-ended shard domains, so App Links are a white-label /
first-party build opt-in; the custom scheme stays the permanent fallback).

Update PLAN.md §9 (M9 follow-up) and the §14 open item, and the redirect-URI
allowlist section of website/BACKEND_DESIGN.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 18:37:04 -05:00
82d88f26ec Merge pull request 'docs(android): record the M9 Part 2 native-SSO app plan' (#24) from docs/m9-native-sso-part2 into main
Reviewed-on: #24
2026-07-20 22:56:27 +00:00
44544dc3bc docs(android): record the M9 Part 2 native-SSO app plan
Add the "M9 plan — native SSO login" block to android/PLAN.md: the two-part
(backend-first) split, the frozen Part-1 bridge contract the app codes against
(/auth/providers, /auth/mobile/sso/start Custom-Tab redirect, the code/error
callback deep link, /auth/mobile/sso/exchange), and the six Part-2 app work
items (PKCE+state, SsoAuthManager, SsoApi+DTOs, the callback intent-filter,
the login-screen provider list, and the JVM tests).

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 17:52:56 -05:00
b3fa93e9c4 Merge pull request 'docs(auth): design the mobile SSO authorization bridge (M9)' (#23) from docs/mobile-sso-bridge into main
Reviewed-on: #23
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 22:09:08 +00:00
1aba1ff93d 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>
2026-07-20 17:01:57 -05:00
4 changed files with 492 additions and 11 deletions

196
android/APP_LINKS.md Normal file
View File

@@ -0,0 +1,196 @@
# Android App Links — implementation spec
Status: **implementation spec (M9 follow-up).** Stacks on the native SSO bridge (M9 Part 2):
the app already handles the **custom-scheme** callback `runicgateway://auth/callback`, and that stays
the permanent default and universal fallback. App Links are an **opt-in hardening** layered on top —
a verified `https://` callback that only the domain's real owner can claim.
Read alongside: the "Mobile SSO Authorization Bridge" section of
[`../website/BACKEND_DESIGN.md`](../website/BACKEND_DESIGN.md) (endpoints/tables/allowlist), and
[`PLAN.md`](./PLAN.md) §4.2 / §9 (the app milestone). This spec matches what ships on the
`feat/mobile-app-links` (website) and `feat/app-links` (android) branches.
---
## 1. The problem it solves
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 internal client, but it is not *owned* by anyone:
any other Android app can register an intent-filter for `runicgateway://auth/callback` and, if chosen
by the user, intercept the callback. The code is single-use, PKCE-bound (Layer B), 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**.
That is only half the problem. The other half is an Android platform constraint that decides the whole
shape of the app side:
> **`android:autoVerify` needs a *literal* host at build time.** An intent-filter's `<data android:host>`
> is a static string in the merged manifest; there is no "any host" or runtime host. A **single
> published multi-tenant APK therefore cannot autoVerify an open-ended set of shard domains** — the set
> is not known when the APK is built.
So App Links here are **not** a drop-in replacement for the custom scheme. They split into two pieces
that ship independently:
1. **Server (`assetlinks.json`) — shippable now, benefits any App-Links-capable build.** Every shard
can auto-serve its Digital Asset Links statement behind an admin toggle. This is a pure add and is
implemented on `feat/mobile-app-links`.
2. **App (`autoVerify` intent-filter) — a *build-time* opt-in.** Because the host must be baked in,
App Links are available to:
- a **white-label / first-party build** that bakes one shard's host (`-PappLinkHost=play.myshard.com`);
- a future **canonical relay domain** (`runicgateway.app`, PLAN §14 — *not yet secured*) that all
shards could bounce their final callback through, autoVerified by the generic build.
The **generic multi-tenant build bakes no host and stays custom-scheme-only** — correct and safe.
The custom scheme is never removed. It is the fallback on every build, for every shard, always.
## 3. Server design — `feat/mobile-app-links`
### 3.1 Auto-served `assetlinks.json`
- **Route:** `GET /.well-known/assetlinks.json`, served at the **web root** (outside `/api/v1`, before
the SPA catch-all) in `server/src/app.js`.
- **Gate:** the 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.
- **Body:** the Digital Asset Links statement for the fixed package `com.runicgateway.app` and the
release signing cert SHA-256 fingerprint(s):
```json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.runicgateway.app",
"sha256_cert_fingerprints": ["AB:CD:…"]
}
}
]
```
- **Fingerprint source:** env `MOBILE_APP_CERT_SHA256` — comma-separated (supports **cert rotation** and
a debug + release cert during testing). It is a **constant of the published app**, identical for every
shard, so it is a shipped/env default, not something each owner types. The package name is likewise
fixed (`MOBILE_APP_PACKAGE`, default `com.runicgateway.app`).
- **Enabled but no fingerprint configured ⇒ 404** (+ a one-time warn): serving a statement with no
fingerprint asserts nothing and would only mislead the verifier.
- Response is `application/json`, `Cache-Control: public, max-age=3600` (the Play verifier and the OS
re-fetch it; it changes only on a cert rotation).
### 3.2 Redirect-URI allowlist extension
`mobileSso.controller` validates the app's `redirect_uri` by **exact match** against
`MOBILE_AUTH_REDIRECT_URIS` (default `runicgateway://auth/callback`). App Links add exactly one more
acceptable value, and **only when the toggle is on**:
- When `mobile_app_links_enabled`, `/start` additionally accepts the **self-origin** HTTPS callback
`https://<request-host>/mobile/callback` (derived from the request/`APP_BASE_URL`, never from
attacker-controlled input). Still **exact match** — never a prefix match.
- The static custom-scheme allowlist is never narrowed; the HTTPS entry is *additive*.
- No new table or schema: the check reads the one boolean setting.
### 3.3 Public settings advertise the capability
`settings.getPublic()` gains `mobileAppLinks: <bool>` (mirrors the toggle) so a client can tell whether
a shard opted in before requesting an HTTPS `redirect_uri` (a white-label build uses it to avoid asking
for a callback the server would reject).
## 4. App design — `feat/app-links`
### 4.1 Build-time host (`appLinkHost`)
- Gradle property `appLinkHost` (default empty). Wired in `app/build.gradle.kts` into **both**:
- `BuildConfig.APP_LINK_HOST` — read by `SsoAuthManager` to decide the redirect;
- `manifestPlaceholders["appLinkHost"]` — substituted into the App Link intent-filter's host.
- **Default (generic build):** empty ⇒ `BuildConfig.APP_LINK_HOST = ""` and the placeholder falls back
to the reserved sentinel `runic-gateway.invalid` (RFC 6761 — never resolves), so the `autoVerify`
filter is **inert**: it matches no real link and verification simply never succeeds. No custom-scheme
behaviour changes.
- **White-label build:** `./gradlew assembleRelease -PappLinkHost=play.myshard.com` bakes that one host
into the filter and enables the HTTPS redirect for that host.
### 4.2 Manifest
A second intent-filter on `MainActivity`, alongside the unchanged custom-scheme one:
```xml
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="${appLinkHost}"
android:path="/mobile/callback" />
</intent-filter>
```
### 4.3 `SsoAuthManager` (pure Kotlin, unit-tested on the JVM)
- **Redirect selection in `buildStartUrl`:** request the HTTPS `redirect_uri`
`https://<pairedHost>/mobile/callback` **iff** `BuildConfig.APP_LINK_HOST` is non-blank *and* equals
the paired base-URL host (case-insensitive); otherwise the fixed custom-scheme `REDIRECT_URI`. A
white-label build that bakes the host is responsible for enabling the server toggle too (§3.2).
- **Verified-callback matcher + host-trust check:** a new `matchesAppLinkCallback(scheme, host, path)`
accepts only `scheme == https`, `path == /mobile/callback`, and **`host == the paired base-URL host`**.
The paired-host equality is defense-in-depth: even though `autoVerify` already means only a real,
opted-in shard domain can route here, the app still refuses any HTTPS callback whose host isn't the
shard it is currently paired to.
- The rest is unchanged: both matchers feed the *same* `complete(state, code, error)` → `/exchange` →
`SessionManager.onSignedIn`. There is no second auth path.
### 4.4 `MainActivity`
`handleSsoCallback` routes a VIEW intent through **`matchesCallback(...) || matchesAppLinkCallback(...)`**;
everything downstream (state check, exchange, sign-in) is shared. Custom-scheme and App Link callbacks
are indistinguishable past the edge.
## 5. Turning it on for a shard
1. Publish/point the app build at the shard host (`-PappLinkHost=<host>`) — or use the generic build and
leave App Links off.
2. Set `MOBILE_APP_CERT_SHA256` (release cert fingerprint) in the website env.
3. Admin → Shard/Settings: enable **App Links** (`mobile_app_links_enabled`).
4. Verify `https://<host>/.well-known/assetlinks.json` returns the statement; confirm Android verifies
(`adb shell pm get-app-links com.runicgateway.app`).
If any step is skipped the app transparently keeps using the custom scheme — nothing breaks.
## 6. Testing
- **Server (`node --test`):** route 404s when the toggle is off; 404s when on but no fingerprint;
returns the correct statement + content-type when on and configured; the redirect allowlist accepts
`https://<host>/mobile/callback` only when enabled and rejects it otherwise (custom scheme always
accepted).
- **App (JVM unit tests):** `matchesAppLinkCallback` accepts only https + `/mobile/callback` + the paired
host and rejects a foreign host / http / wrong path; `buildStartUrl` requests the HTTPS redirect only
when the baked host matches the paired host, else the custom scheme.
## 7. What does *not* change
- The bridge's server design (PKCE Layer A/B, single-use codes, `/start` + `/exchange`) is untouched;
App Links are *one more allowlist entry* + *one static file route*. That is the whole point of keeping
the allowlist exact-match and configurable from day one.
- The custom scheme remains on every build and is the permanent fallback.
- No change to `servuo-plugins/` — App Links are entirely a website ↔ app concern.

View File

@@ -1,6 +1,6 @@
# Android App — Plan
Status: **M0M7 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: **M0M7 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
@@ -316,6 +316,76 @@ pinned. No `website`/`link`/`servuo-plugins` code change is expected in Part 2.
Ships as `RunicGateway/Android-app#15`; bumps `versionCode`/`versionName` for a post-v1 release
(§10). Like M1M4 it records itself in the §9 build-progress block on landing.
### M9 plan — native SSO login (in progress)
M9 spans `website/` + `android-app/` + `docs/`, so — like M7 — it ships in **two parts**, backend
first (the app is a pure consumer of the bridge contract; §4.2, §9 item 10).
**Part 1 — `website/` backend + `docs/` — ✅ LANDED** (the Mobile SSO Authorization Bridge:
`mobile_auth_sessions`/`mobile_auth_codes` tables, `GET /auth/mobile/sso/start`, the `mode:'mobile'`
branch in the reused SSO callback + TOTP completion, `POST /auth/mobile/sso/exchange`, the exact-match
`MOBILE_AUTH_REDIRECT_URIS` allowlist, and bridge-table cleanup). Canonical ref:
`../website/BACKEND_DESIGN.md` → "Mobile SSO Authorization Bridge".
**Part 2 — the Android app (this milestone).** The native in-app "Sign in with Google / Discord"
client. **Additive** — a new auth slice (`core/auth/sso` + a `SsoApi`/`SsoAuthManager` + a login-screen
provider list) that feeds the *existing* M3 session machinery; it adds **no** new token-storage or
refresh code, and touches no other screen. **No backend work** — every endpoint it calls is merged.
The Part-1 contract the app codes against (verified against the merged `website` source):
- `GET /auth/providers` → `[{ id, name, icon, loginUrl, priority }]` (public discovery, no secrets).
`icon` ∈ `google|discord|oidc|oauth2`. Render the provider buttons from this — don't hardcode.
- `GET /auth/mobile/sso/start?provider&code_challenge&state&redirect_uri` — **opened in a Custom Tab**
(not an XHR): it 302s through the IdP and finally deep-links back to `redirect_uri`. `redirect_uri`
must be an **exact** allowlist entry — the app always sends the one fixed callback
`runicgateway://auth/callback`.
- The callback deep link carries **either** `?code=<one-time>&state=<echoed>` (success) **or**
`?error=<reason>&state=<echoed>` (`invalid_provider`/`provider_unavailable`/`server_error`, or an
IdP/link refusal) — **never a token**.
- `POST /auth/mobile/sso/exchange` `{ code, code_verifier }` → the **same** `{ accessToken,
refreshToken, expiresIn, user }` pair as `/auth/mobile/login`; `401` on an unknown/expired/used code
or a PKCE-verifier mismatch.
Work items:
1. **PKCE + state (Layer B, app↔website).** A pure-JVM `Pkce` helper (unit-testable, no Android
framework types): `code_verifier` = 32 random bytes base64url (RFC 7636 S256), `code_challenge` =
base64url(SHA-256(verifier)), plus a random `state`. `java.util.Base64` URL encoder without padding
+ `MessageDigest` — matches the backend's `crypto.createHash('sha256')…base64url` exactly.
2. **`SsoAuthManager` (Singleton) — the flow orchestrator.** Holds the **pending** `{state, verifier}`
in memory (lost on process death → the exchange fails closed and the user retries; acceptable and
safe, documented). `buildStartUrl(provider)` mints PKCE+state, stashes pending, and builds the
absolute `/start` URL off `BaseUrlHolder` for the Custom Tab. `isCallback(uri)` matches our scheme;
`complete(uri)` verifies `state` (CSRF), maps an `error`, exchanges the `code` with the stashed
`verifier`, and on success drives `SessionManager.onSignedIn` — the *same* entry the password login
uses, so push registration (`PushManager` observes the session) and the menu react identically. It
exposes an `outcome: StateFlow` (Idle/Success/Failed(reason)) the login screen consumes, robust to a
ViewModel/activity recreation while the Custom Tab is foreground.
3. **`SsoApi` + DTOs.** `GET api/v1/auth/providers` → `List<SsoProviderDto>`; `POST
api/v1/auth/mobile/sso/exchange` tagged `Http.NO_SESSION_HEADER` (no bearer; a credential-style
`401` must not be read as an expired session or trip the refresh `Authenticator`) → the reused
`MobileTokenResponse`. Lenient Json (additive fields safe, §8).
4. **Deep link.** Register the `runicgateway://auth/callback` intent-filter on `MainActivity`
(`VIEW` + `DEFAULT` + `BROWSABLE`, `scheme/host/path` from one shared constant) and set
`launchMode="singleTop"` so the returning Custom Tab reuses the running task; `onCreate`/`onNewIntent`
route a matching `ACTION_VIEW` intent to `SsoAuthManager.complete` on `lifecycleScope`. Custom scheme
only for now — App Links deferred (`APP_LINKS.md`).
5. **Login screen.** Replace the single "SSO on the website" hand-off with a native provider list from
`GET /auth/providers`: one button per provider (Google/Discord/OIDC glyph from `icon`), each opening
its `/start` URL in a Custom Tab via the existing `WebHandoff`. The `LoginViewModel` collects
`SsoAuthManager.outcome` → a success pops back like a password sign-in; a failure surfaces a friendly
inline error (reusing the existing `LoginError` channel + a new SSO string). Falls back to the
website login hand-off when discovery returns no providers or the base URL is unset.
6. **Tests (JVM, `testDebugUnitTest`).** `Pkce` (verifier charset/length, challenge = base64url-SHA-256
of a known vector, no padding), start-URL building (encoded params, fixed `redirect_uri`), and
`SsoAuthManager.complete` over a fake `SsoApi` + `SessionManager`: success signs in; a mismatched or
missing `state` fails without exchanging; an `error=` callback maps to the right reason; a `401`
exchange maps to expired-code; a missing pending (process death) fails closed.
Ships as a `RunicGateway/Android-app` PR; bumps `versionCode`/`versionName` for a post-v1 release
(§10) and records itself in the §9 build-progress block on landing. No `website`/`link`/`servuo-plugins`
code change is expected in Part 2.
**Prerequisite progress (§8):** all v1 prerequisites are **done** (2026-07-19) — ✅ password reset
(item 2; website#75 + docs#8), ✅ role-agnostic `/auth/me/*` self surface (item 1; website#76 + docs#10),
✅ version/health surfacing (item 4) and ✅ branding for mobile (item 6). **Push notifications (item 3)
@@ -467,14 +537,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 +779,29 @@ push, and Play (M6M8) 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.
- **Follow-up — App Links (opt-in hardening on top of Part 2):** a website `GET
/.well-known/assetlinks.json` route behind the `mobile_app_links_enabled` admin toggle, a
self-origin HTTPS entry added to the redirect-URI allowlist when enabled, and an app-side
`autoVerify` intent-filter for `https://<host>/mobile/callback` driven by a **build-time**
`appLinkHost` (a single multi-tenant APK cannot autoVerify open-ended shard domains, so the
generic build stays custom-scheme; white-label/first-party builds bake one host). The custom
scheme remains the permanent fallback on every build. Full spec + rollout in
[`APP_LINKS.md`](./APP_LINKS.md).
---
@@ -823,7 +921,12 @@ it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6;
`runicgateway.app` domain (needed for a verified app-link host and a matching package namespace). Also
the fixed launcher name (baked at build even though in-app branding is per-shard — one APK, any shard).
Since SSO/invite/reset are website-handled, the app mostly *opens* website URLs rather than needing its
own verified app links — confirm whether any deep-link-back is wanted at all for v1.
own verified app links. **App Links resolved (M9 follow-up):** the SSO callback is the one place a
verified deep-link-back helps; the server side (`assetlinks.json` + toggle) ships for any shard, but
the app-side `autoVerify` needs a **literal build-time host**, so it is a white-label/first-party build
opt-in (`-PappLinkHost=<host>`) — the generic multi-tenant build stays custom-scheme. A canonical
`runicgateway.app` relay host, if secured, would let the generic build autoVerify one central domain.
See [`APP_LINKS.md`](./APP_LINKS.md).
- ntfy: exact upstream image + pinned tag (Part-1 landed the compose service — confirm the tag), and
its reverse-proxy hostname/path. The hostname must land in `NTFY_ALLOWED_ORIGINS` before M7 Part 2 is
end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). No

64
ci/SONARQUBE.md Normal file
View File

@@ -0,0 +1,64 @@
# SonarQube static analysis
Each code repo in the Runic Gateway org reports static-analysis results to the
self-hosted **SonarQube** server for review. Analysis is **non-blocking**: it
runs on push to `main` (i.e. *after* merge), never on pull requests, so it never
gates a PR. It complements each repo's PR gate and release pipeline — it only
feeds the dashboard.
## Server
- **URL:** `https://sonar.whitlocktech.com`
- Each repo is a separate SonarQube project, keyed as below.
## Projects
| Repo | Project key | Sources analysed | Language |
|---|---|---|---|
| `website` | `runic-gateway-website` | `server/src`, `client/src`, `bot/src` | JS/TS |
| `link` | `Runic-Gateway-link` | `sidecar/src` | Rust |
| `Android-app` | `Runic-Gateway-Android-app` | `app/src/main` | Kotlin |
> Project keys are **case-sensitive** and must match what already exists on the
> server — SonarQube refuses to create a key that differs only in case from an
> existing one. `link` and `Android-app` reuse the pre-existing capitalised keys
> above; `website` predates this note with its lower-case key.
## How it's wired
Each repo carries two files, identical in shape across repos:
- **`sonar-project.properties`** (repo root) — declares the project key, sources,
tests, and exclusions. The Sonar scanner reads this.
- **`.gitea/workflows/sonarqube.yml`** — a `SonarQube` workflow that, on push to
`main` (and via manual `workflow_dispatch`), checks out with full history
(`fetch-depth: 0`, needed for accurate blame + "new code") and runs
`sonarsource/sonarqube-scan-action@v4`.
The scan is **source-based** — it does not build the project or run a language
toolchain, so the workflows are lightweight (checkout + scan only). Richer
signals (Rust Clippy, Android Lint, JaCoCo coverage) are left as documented,
commented-out enrichment in each repo's `sonar-project.properties`; enable them
per repo when wanted.
## One-time setup per repo (Gitea UI → Repo → Settings → Actions)
Both are consumed by the scan action via `env:` in the workflow:
- **Secret `SONAR_TOKEN`** — a SonarQube *Analysis* token (My Account →
Security in SonarQube; project-scoped or global).
- **Variable `SONAR_HOST_URL`** — the SonarQube base URL reachable from the
self-hosted runner. Kept as a **variable, not committed**, so the internal
address stays out of git.
The self-hosted `ubuntu-latest` runner must be able to reach `SONAR_HOST_URL` on
the network. Nothing waits on the SonarQube Quality Gate, so a failing gate does
not fail the job — check the dashboard.
## Adding a new repo
1. Create the project in SonarQube; note its key.
2. Add `sonar-project.properties` (copy an existing repo's, adjust key + sources).
3. Add `.gitea/workflows/sonarqube.yml` (copy verbatim — it's language-agnostic).
4. Set the `SONAR_TOKEN` secret and `SONAR_HOST_URL` variable in the repo's
Gitea Actions settings.

View File

@@ -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
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
@@ -238,6 +283,67 @@ 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_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.
*App Links (implemented).* When the admin toggle `mobile_app_links_enabled` is **on**, `/start` also
accepts the self-origin HTTPS callback `https://<request-host>/mobile/callback` — one *additive*
exact-match entry, derived from the request/`APP_BASE_URL` and never from client input; the
custom-scheme allowlist is never narrowed. The shard then auto-serves `GET
/.well-known/assetlinks.json` (fixed package `com.runicgateway.app` + `MOBILE_APP_CERT_SHA256`
fingerprints; 404 when the toggle is off or no fingerprint is configured), and
`settings.getPublic()` advertises `mobileAppLinks: <bool>`. These two things — one static file route
and one more allowlist entry — are the *entire* server surface 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
| Method | Path | Notes |
|---|---|---|
@@ -303,7 +409,19 @@ who"; `activity_log` provides the history feed.
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
- **Validation** (`express-validator`) on all writes; centralized error handler.
- **helmet** with a CSP suited to the SPA (self + inline styles as needed; image sources for uploads/hero).
- **helmet** with a Content-Security-Policy tuned for the built React SPA (see `server/src/app.js`):
`default-src 'self'`; `script-src 'self'` (the Vite build emits only external module chunks — the
inline module-preload polyfill is disabled in `client/vite.config.js` to keep this valid);
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com` (React's pervasive inline
`style={{…}}` attributes can't be nonce'd, plus the Google Fonts stylesheet); `font-src 'self'
https://fonts.gstatic.com` (Cinzel); `img-src 'self' data: https:` (same-origin uploads, plus
external https images embedded in wiki/news bodies or `BRAND_*` logo/hero/favicon); `connect-src
'self'` (REST + SSE are same-origin); `frame-ancestors 'self'`; `object-src 'none'`; `base-uri
'self'`. `upgrade-insecure-requests` is intentionally **not** set (TLS terminates at the proxy, there
are no mixed-content subresources, and it would break a local `npm start` over plain http). The
`/api/docs` Swagger UI route gets a **looser** policy that additionally allows inline script/style,
since swagger-ui-express injects an inline bootstrap. helmet also strips `X-Powered-By`; the two
internal-only listeners (`internalApp.js`, `bot/src/app.js`) disable it explicitly too.
- **Admin not indexed**: `X-Robots-Tag: noindex, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`.
- **No directory browsing** (express.static doesn't list; no `serve-index`).
- **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed.