Skip to content

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 emailAlreadyOwnedErrSocialLinkRequired: 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.

  1. 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.
  2. 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.life Ingress without the oauth2-proxy middleware (Argo exempt via Dex).
  3. 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.
  4. 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.