Compare commits
11 Commits
5a7bbc26fa
...
docs/ci-so
| Author | SHA1 | Date | |
|---|---|---|---|
| ec468e9983 | |||
| 2257df09eb | |||
| 17f9207a17 | |||
| 28c5c228f7 | |||
| 6e0ff2a821 | |||
| 0109df6963 | |||
| 752793f6c3 | |||
| 82d88f26ec | |||
| 44544dc3bc | |||
| b3fa93e9c4 | |||
| 1aba1ff93d |
196
android/APP_LINKS.md
Normal file
196
android/APP_LINKS.md
Normal 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.
|
||||||
123
android/PLAN.md
123
android/PLAN.md
@@ -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
|
||||||
@@ -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
|
Ships as `RunicGateway/Android-app#15`; bumps `versionCode`/`versionName` for a post-v1 release
|
||||||
(§10). Like M1–M4 it records itself in the §9 build-progress block on landing.
|
(§10). Like M1–M4 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
|
**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),
|
(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)
|
✅ 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.)
|
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 +779,29 @@ 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.
|
||||||
|
- **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
|
`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).
|
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
|
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
|
- 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
|
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
|
end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). No
|
||||||
|
|||||||
64
ci/SONARQUBE.md
Normal file
64
ci/SONARQUBE.md
Normal 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.
|
||||||
@@ -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,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 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.
|
||||||
|
|
||||||
|
*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
|
### /public (public.routes.js → public.controller.js) — all GET, no auth
|
||||||
| Method | Path | Notes |
|
| 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.
|
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
|
||||||
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
|
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
|
||||||
- **Validation** (`express-validator`) on all writes; centralized error handler.
|
- **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`.
|
- **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 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.
|
- **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.
|
||||||
|
|||||||
Reference in New Issue
Block a user