Threat Model — Authentication¶
| Created | 2026-08-09 |
| Last reviewed | 2026-08-09 |
| Next review due | 2027-02-09 (6-month cadence, or on any change to the auth surface) |
| Reviewers | lucaswu@tomoda.life |
| Version | 1.0 |
| Surface owner | Platform / Auth |
Scope: the Google/Apple sign-in surface and the two distinct authentication paths in the system, with focus on the security posture after the app's OAuth consent screen is opened to External (any Google account can sign in to the mobile app) while internal admin UIs stay tomoda.life-only.
This page is a STRIDE analysis of that surface. It is not a general auth reference — see Auth & Dex, oauth2-proxy, and the app repo's docs/architecture/auth.md for how the system works.
Data flow diagrams¶
The single most important structural fact: the mobile app and the internal admin UIs authenticate on completely separate paths. Opening one does not open the other. Each path is drawn separately below; the dashed box is the trust boundary the data enters.
Node shapes follow DFD convention: stadium = external entity, rectangle = process, cylinder = data store. The dashed subgraph is the trust boundary the data enters.
Path 1 — mobile app sign-in¶
The app gets a signed token from Google; the backend verifies it. The proxy is never involved.
flowchart LR
E1([E1 · App user<br/>any Google account]) -->|F1 credentials| E3([E3 · Google<br/>signs ID token])
E3 -->|F2 ID token JWT| P1
subgraph backend[trust boundary: backend]
P1[P1 · verify token<br/>aud · email_verified · nonce · subject]
P1 -->|F3 identity| D1[(D1 · user store<br/>RoleBase on signup)]
end
P1 -->|F4 session JWT| E1
The backend accepts the token only if the signature, audience, and email_verified check out, and it links by the Google subject (not the email). A new user lands as RoleBase. A session JWT (F4) returns to the app.
Path 2 — operator admin-UI sign-in¶
Every admin UI sits behind the cluster edge. A request is gated there before it can reach the UI or any production data.
flowchart LR
E2([E2 · Operator<br/>@tomoda.life]) -->|F5 HTTPS| P3
subgraph edge[trust boundary: cluster edge]
P3[P3 · gate<br/>oauth2-proxy allow-list<br/>or Argo Dex + RBAC]
P3 -->|F8 X-Auth-Request-Email| P5[P5 · admin UI<br/>Grafana · pgAdmin<br/>redis-insight · Argo]
end
P5 -->|F9 SQL / Redis| D2[(D2 · production data<br/>Postgres · Redis)]
The gate rejects any identity not on the operator allow-list (or outside tomoda.life for Argo). Only past the gate does a UI reach production data (D2). This gate is independent of the app's consent-screen setting, so opening the app to External does not open the admin UIs.
DFD elements¶
Every element in the two paths above, numbered so the STRIDE tables can reference each one. External entities and processes attract Spoofing/Tampering/Repudiation; data stores attract Tampering/Info-disclosure; every process is an EoP candidate.
| Ref | Type | Element | Path | Notes |
|---|---|---|---|---|
| E1 | External entity | Mobile app user | app | Any Google account after the External flip |
| E2 | External entity | Operator (browser) | admin | @tomoda.life, further narrowed by the allow-list |
| E3 | External entity | Google / Apple | app | Mints and signs ID tokens |
| P1 | Process | Backend token verify | app | verifyGoogleIDToken / verifyAppleIDToken |
| P2 | Process | Traefik forwardAuth | admin | Routes admin hosts through the gate |
| P3 | Process | oauth2-proxy | admin | --email-domain + operator allow-list |
| P4 | Process | Argo CD Dex | admin | hostedDomains + RBAC |
| P5 | Process | Admin UIs | admin | Grafana / pgAdmin / redis-insight / Argo |
| D1 | Data store | User store | app | Postgres users; link key is the Google subject |
| D2 | Data store | Production data | admin | Postgres / Redis reached by admin UIs |
| F1–F4 | Data flow | app path | app | credentials · ID token · identity · session JWT |
| F5–F9 | Data flow | admin path | admin | HTTPS request · auth header · SQL/Redis |
Assets and trust boundaries¶
| Asset | DFD | Boundary crossed |
|---|---|---|
| App user identity | E1→P1 | Public internet → backend boundary |
| Backend session (access + refresh JWT) | P1→E1 | Backend → app storage (E1) |
| Admin session cookie | E2→P3 | Browser → cluster-edge boundary |
| Admin authorization | P3/P4 | Edge → admin UI (P5) |
| OAuth client secret | (out-of-band) | GCP SM → ESO → argocd/monitoring ns |
| Production data | P5→D2 | Admin UI (P5) → data plane (D2) |
STRIDE¶
Spoofing¶
| DFD | Threat | Control | Residual |
|---|---|---|---|
| E3/P1 | Forged Google ID token (fake signature) | idtoken.Validate checks signature against Google's global JWKS; RS256 only |
Low — depends on Google key integrity |
| E3/P1 | Wrong-audience token replay (a token minted for another app pointed at ours) | aud checked against the explicit allow-list of our client IDs (verifyGoogleIDToken); library's single-aud check bypassed only to support multiple accepted auds, then re-checked in code |
Low |
| E3/P1 | Token with unverified email | email_verified claim required (Google + Apple); Apple's bool-or-string form handled |
Low |
| E1/P1/D1 | Email-collision account takeover — attacker signs in with a Google account whose email matches an existing tomoda account | emailAlreadyOwned → ErrSocialLinkRequired: a new Google subject is never auto-attached to an existing account by shared email; explicit link required. Link key is the Google subject, not the email |
This is the control that makes External safe. Without it, opening to all domains would let anyone create a Google account with a victim's email and take over |
| P3/P5 | Admin identity spoof via injected X-Auth-Request-Email header |
Grafana trusts the header, but the header is set by oauth2-proxy after auth; Traefik forwardAuth strips client-supplied copies. Requires the request to have passed the proxy | Medium — depends on Traefik never routing a protected host without the auth middleware. See Tampering |
Tampering¶
| DFD | Threat | Control | Residual |
|---|---|---|---|
| P2/P5 | A new admin UI is exposed without the auth middleware | Fail-closed CI gate (policy/admin_ingress_auth.rego, run by scripts/policy-check.sh in k8s-validate.yml): every rendered *.tomoda.life Ingress must carry the oauth2-proxy-auth middleware unless its host is on the public allow-list; Argo is exempt via its Dex path. A PR exposing an admin host without the gate fails CI |
Low — enforced automatically. Residual: the allow-list itself is the trust anchor; adding a host there is a reviewed decision |
| E3/P1 | Nonce dropped to enable token replay | Nonce checked when present (expectedNonce != ""); the app sends one |
Medium — nonce is optional server-side, so a client that omits it loses replay protection. Acceptable for now; ID tokens are short-lived and single-use in practice |
| P3 | Modified allow-list ConfigMap | GitOps: the allow-list lives in oauth2-proxy/application.yaml, synced by Argo CD with selfHeal. A manual kubectl edit is reverted |
Low |
Repudiation¶
| DFD | Threat | Control | Residual |
|---|---|---|---|
| E1/P1 | App login not attributable | Backend writes an audit log entry on Google/Apple login (auditService.Log ... AuditActionLogin) |
Low |
| E2/P5 | Admin action not attributable | oauth2-proxy injects X-Auth-Request-Email; Grafana provisions users from it; Argo RBAC ties actions to the Dex identity |
Low-Medium — pgAdmin/redis-insight don't record which operator connected beyond the proxy access log |
Information disclosure¶
| DFD | Threat | Control | Residual |
|---|---|---|---|
| (secret) | OAuth client secret leaked | Stored in GCP SM, projected by ESO; not in git (the app client ID is public by design) | Low |
| E2/P3 | Session cookie theft | --cookie-secure, --cookie-httponly, --cookie-samesite=lax; TLS everywhere |
Low |
| P5/D2 | Any @tomoda.life account reaching production data via redis-insight/pgAdmin |
Closed today by the oauth2-proxy operator allow-list — domain membership alone no longer grants access | Low |
| P1/E1 | App token grants more than intended after External flip | New external users get RoleBase; admin endpoints gated by access.Require(access.TomodaAdmin); a RoleBase user cannot reach admin |
Low |
Denial of service¶
| DFD | Threat | Control | Residual |
|---|---|---|---|
| E1/P1 | Login flood | Login routes are behind a rate limiter (loginLimit in routes.go) |
Low |
| P3 | oauth2-proxy outage blocks all admin access | Single replica; its session store choice (cookie) avoids a Redis dependency for the platform proxy | Medium — accepted; admin UIs are operational tooling, not user-facing |
Elevation of privilege¶
| DFD | Threat | Control | Residual |
|---|---|---|---|
| E1/P1 | External app user escalates to admin | Default RoleBase; role change is a privileged backend operation; admin routes gated by capability policy |
Low |
| E2/P4 | Operator on the allow-list gains Argo write unexpectedly | Argo RBAC defaults to readonly; only the four named admins get role:admin |
Low |
| P3/P4 | Shared OAuth client lets an app-client incident affect admin login | The app and admin UIs share client 287267207777-7d0c…. An app-client compromise or Google-side restriction would disrupt admin sign-in |
Medium — open. Mitigation is to decouple: give oauth2-proxy/Dex their own client. Tracked in Auth & Dex (client-coupling section) |
Findings and recommendations¶
Ordered by priority.
- Decouple the shared OAuth client (Medium). The public app and internal admin login share one client; an incident on the public side can take down operator access. Give the internal consumers their own client. Blast-radius hygiene, not an active vulnerability.
- CI guard for admin-Ingress middleware (Medium) — resolved. A fail-closed conftest policy (
policy/admin_ingress_auth.rego) now fails any PR that exposes a*.tomoda.lifeIngress without the oauth2-proxy middleware (Argo exempt via Dex). - Per-operator attribution for pgAdmin/redis-insight (Low). These record the operator only in the proxy access log. Acceptable given the tightened allow-list, but note it for incident response.
- Nonce is optional server-side (Low, accepted). A client omitting the nonce loses replay protection. Left as-is because ID tokens are short-lived; revisit if a replay path is demonstrated.
What stays safe when the app goes External¶
The External flip changes exactly one thing: any Google account can obtain an ID token our backend will accept and become a RoleBase app user. It does not:
- open any admin UI (each has its own domain gate + allow-list);
- allow account takeover (email-collision is blocked; link key is the Google subject);
- grant elevated roles (default
RoleBase, admin gated separately).
The controls this relies on are the backend's emailAlreadyOwned guard and the admin-side oauth2-proxy allow-list + Dex hostedDomains. Both are verified present.