Skip to content

Authentication

Tomoda exposes a single identity model — the User row — reached via several front doors. Once authenticated, every client carries the same access JWT and uses the same refresh-token flow. This page is the system-level view of how authentication is composed; for the implementation (services, struct fields, OTP internals, code paths) see backend/services/auth, and for the client wiring see Frontend API Client.

Authentication methods

Tomoda supports six independent authentication entry points. All of them converge on the same session pipeline — once a user is identified, the rest of the system treats them uniformly.

Method Front-door identity
Email + password Bcrypt-hashed password
Google OAuth Google ID token, verified server-side
Apple Sign-In Apple identity token, JWKS verified server-side
LINE OAuth LINE ID token
WebAuthn / passkey Stored public key + signed challenge
OTP (signup / reset) Short-lived 6-digit code, purpose-tagged

OTP is not a long-lived auth method on its own — it is a verification step that gates registration, password reset, email change, and phone updates. Once verified, the same registration / login paths mint the access tokens.

Token model

Every authenticated session is anchored by three pieces of state:

Token / state Lifetime Purpose Lives in
Access JWT 24 h Bearer credential carried on every API call and on WS upgrade Client memory + storage
Refresh token 7 d (web) / 30 d (native) Opaque server-issued string used to mint new access JWTs Postgres + Redis mirror
Session row Until revoked Per-device record (UA, IP, last-used) — surfaces "active sessions" UX Postgres

The access JWT is the only token the system trusts inline; the refresh token is exchanged at /auth/refresh for a new JWT; the session row is the audit/revocation surface users see on their security screen. Refresh tokens may be presented over HTTP (header) or query-string (WS upgrade, since browsers can't set headers on the upgrade).

High-level flow

                  ┌──────────────────────────────┐
   identity ────► │  AuthService — verify caller │
   provider       │  (password / OAuth / passkey)│
                  └─────────────┬────────────────┘
                                │  user resolved
                                ▼
                  ┌──────────────────────────────┐
                  │  Issue access JWT + refresh  │
                  │  Create Session row          │
                  └─────────────┬────────────────┘
                                │
                                ▼
                  ┌──────────────────────────────┐
                  │  Client stores tokens; every │
                  │  subsequent call carries JWT │
                  └──────────────────────────────┘

Refresh-token rotation, revocation, and session listing are all driven from the same Session + RefreshToken records — that uniformity is the system-level invariant.

Cross-cutting invariants

These hold across every auth method and every protected endpoint:

  • One identity, many doors. Whichever method authenticated the caller, the downstream system sees a single user_id and role claim. No handler branches on "how did this user log in?".
  • Access JWT is stateless; refresh token is stateful. The JWT is HMAC-signed and verified inline — no DB lookup. Refresh tokens are looked up in Redis (fast path) with Postgres as the source of truth.
  • Refresh rotation is mandatory. Every refresh-token use updates last_used_at; long-idle tokens beyond the per-client TTL are rejected.
  • Revocation is per-session. Logging out a device deletes its refresh token and its session row in both Postgres and Redis. "Log out all devices" iterates the user's sessions.
  • OTP gates sensitive mutations. Registration, password reset, email change, and phone update all require a successful OTP verify before the mutation handler runs.
  • WebSocket auth uses the same JWT. The hub's upgrade handler accepts the access JWT via ?token= query parameter because browsers cannot set headers on the WS upgrade — the validation path is otherwise identical.
  • API keys are an orthogonal scheme. Non-interactive callers may use scoped X-API-Key: sk_<uuid> credentials; they bypass the JWT path entirely and are validated by a dedicated middleware.

Account types and roles

Every User row carries two orthogonal identity fields, both defined in backend/internal/models/user.go:

  • AccountTypewhat kind of account is this? Defaults to standard and is set at signup or by database.SeedSyntheticUser. Used to filter special accounts out of analytics, leaderboards, anti-abuse, and (future) MFA enforcement.
  • Rolewhat is this account allowed to do inside the app? Defaults to base.
Account type Used for
standard Regular consumer signups
tomoda Internal Tomoda accounts (admins, curators, ops). Cannot use the consumer app directly; link a standard account to access it.
synthetic System-owned probe accounts (Cloudflare login monitor — see database.SeedSyntheticUser). Mounts the standard shell so end-to-end monitors can exercise real consumer routes; filtered out of analytics, leaderboards, anti-abuse.
partner Partner accounts (merchants, advertisers, organizations). Cannot use the consumer app directly; link a standard account to access it.
Role Used for
base Default for new accounts — no elevated permissions
admin Full administrative access (Tomoda or partner admins)
curator May add curated content to Tomoda
auditor Read-only access for monitoring / compliance review
operator State management in app (ops actions)
support Manage support cases
developer Internal Tomoda devs; unlocks dev-mode UI and features

Both columns are indexed varchar(20). The pair is the basis for RBAC: an admin of a partner account is scoped to that partner via PartnerMembership (see Partners), while an admin of a tomoda account has global administrative access. See the Authorization section below for the policy table.

Authorization

Authorization is centralized in the backend/internal/access package. Handlers mount pre-built policies rather than hand-rolling role checks — the named set is small so the authorization model is auditable from one place.

Global policies (claim-based, no DB read)

access.Require(policy) reads account_type and role from the JWT claims placed on the request context by JWTAuth middleware (via api.AccountType(r) / api.Role(r)). Both fields ride on every token issued by AuthService.GenerateToken.

Policy Account type Role Mounted on
TomodaAdmin tomoda admin /api/v1/admin/*
TomodaCurator tomoda or external-curators curator or admin (reserved)
TomodaAuditor tomoda auditor or admin (reserved)
TomodaOperator tomoda operator or admin (reserved)
TomodaSupport tomoda support or admin (reserved)

Reserved policies are defined but not yet mounted — they'll be wired when the first handler in each category ships.

Partner-scoped policies (membership-based, one DB read)

Partner routes look like /api/v1/partner/:partner_id/.... The middleware reads :partner_id from the route and looks up the caller's row in PartnerMembership. There is no "current partner" in the JWT or session — partner context is always URL-derived.

Middleware Allowed membership roles Used for
access.RequirePartnerAnyMember owner, admin, staff Read-only partner views
access.RequirePartnerAdmin owner, admin Updating the partner, managing staff
access.RequirePartnerOwner owner Deleting the partner, managing other owners, billing

See Backend → Partners for the full route table and the "≥1 owner per partner" invariant.

Latency vs. revocation tradeoff

The global policies are claim-based and do not re-read the DB on every request — a demoted admin remains an admin until their JWT expires (up to 24 h, the configured token lifetime). For routes where instant revocation matters, mount JWTAuth plus a DB-checked guard rather than access.Require. We don't have such a guard yet; add RequireFresh(userRepo, policy) next to the existing helpers when needed.

Partner policies always read the DB (membership lookup), so partner permission changes take effect on the next request.

Frontend authorization shell

The router mirrors the account-type and role split at the navigation layer. Three rules govern how the app's chrome and tabs warp around the caller's identity:

  1. One AccountType, one shell, no cross-cutting. Each AccountType mounts a distinct top-level shell. Standard consumers (and synthetic probes) see (tomoda). Partner-account users see (partner). Tomoda team members see (internal). A given JWT renders exactly one of these shells, never two. Tomoda or partner team members who also want to use the consumer app do not share their team JWT with the consumer surface; they link a separate standard account and switch into it (see Linked accounts below).
  2. Role adds or hides sections inside (internal). The admin / curator / auditor / operator / support / developer roles unlock matching screens via Stack.Protected guard={isCurator} entries plus conditional menu items in AppNav. Inside (tomoda), roles are irrelevant — standard accounts are role base by definition.
  3. Linked accounts give multi-faceted users their second identity. A Tomoda staffer or a partner admin who also wants to be a Tomoda consumer creates a separate standard account and links it. From the team shell → Hub → Account, they tap "Link consumer account" once. After that, the switch flow re-authenticates (password or biometric) and swaps the active session, mounting the consumer shell with the linked account's JWT.

Root layout shape

// app/_layout.tsx
<Stack>
  <Stack.Screen name="index" />     {/* marketing landing (web) */}
  <Stack.Screen name="(social)" />  {/* public share targets */}

  <Stack.Protected guard={!isAuthenticated || profileIncomplete}>
    <Stack.Screen name="auth" />    {/* login + onboarding */}
  </Stack.Protected>

  {/* Standard consumer shell. Synthetic probes also mount here so
    * end-to-end monitors exercise real consumer routes. */}
  <Stack.Protected
    guard={isAuthenticated && !profileIncomplete && (accountType === AccountTypes.Standard || accountType === AccountTypes.Synthetic)}
  >
    <Stack.Screen name="(tomoda)" options={{ animation: 'none' }} />
    <Stack.Screen name="(capture)" />
  </Stack.Protected>

  <Stack.Protected
    guard={isAuthenticated && !profileIncomplete && accountType === AccountTypes.Partner}
  >
    <Stack.Screen name="(partner)" options={{ animation: 'none' }} />
  </Stack.Protected>

  <Stack.Protected
    guard={isAuthenticated && !profileIncomplete && accountType === AccountTypes.Tomoda}
  >
    <Stack.Screen name="(internal)" options={{ animation: 'none' }} />
  </Stack.Protected>
</Stack>

The shells are mutually exclusive. Stack.Protected auto-navigates to the first available screen when guards change, so a session swap (login, logout, or linked-account switch) causes the previous shell to fully unmount and the new one to mount fresh. No state or stack history bleeds across identities.

Account types → router surfaces

AccountType Mounted shell Default landing Linked-account capable
standard (tomoda) /discover — (this is the consumer surface)
tomoda (internal) /manager/dashboard (or first available section by role) Yes — link a standard account to use the consumer app
partner (partner) /dashboard Yes — link a standard account to use the consumer app
synthetic (tomoda) /discover — (system-owned, not user-facing)

Roles → in-shell gating

Inside (internal), roles do not change which shell is mounted; they add or reveal sections inside it. Two implementation patterns:

Pattern When to use
Stack.Protected on the screen entry — wrap <Stack.Screen name="curator" /> in <Stack.Protected guard={isCurator}> inside (internal)/_layout.tsx The screen file exists for all Tomoda accounts but is not reachable in nav without the role. Cleanest for genuinely role-only routes.
Conditional menu item in AppNav — gate the entry-point button by useAccess().isCurator (or analogous predicate) The screen exists but does not need its own guard (the screen will just show empty data without the role). Cheaper, fine when the API also enforces the role.

Always pair frontend gating with backend access.Require* middleware on the matching endpoint. Hiding a button is UX, not security; the server is the source of truth.

Linked accounts

A team account (tomoda or partner) and a consumer account (standard) are separate users server-side, with their own user IDs, JWTs, and refresh tokens. The link is a server-side row (account_links) that pairs them. The link gives the client permission to mint a session for the linked account without going through full credential entry every time.

Link flow (first time, from the team shell):

  1. Hub → Account → "Link consumer account."
  2. The client opens the link modal: either sign in as an existing standard account, or register a fresh standard account.
  3. On success, the server inserts the account_links row and returns the linked account's refresh token.
  4. The client stores the linked refresh token in the OS keychain (biometric-gated when available).

Switch flow (every time after):

  1. Hub → "Switch to consumer account" (or "Switch back to team account").
  2. The client retrieves the linked account's refresh token from the keychain. Face ID / Touch ID gates the read on devices that support it; password fallback otherwise.
  3. The refresh token is exchanged at /auth/refresh for a new access JWT.
  4. AuthContext swaps to the new JWT + user record. The root layout re-renders; the previous shell unmounts; the new shell mounts.

Why separate JWTs, not a "view" flag on one JWT?

  • Backend authorization is JWT-claim-driven (see Authorization above). The active JWT is the source of truth for what the caller can do; faking a "view" on the client would diverge UI from server enforcement.
  • Linking makes the consumer footprint of a team member observable server-side (analytics, anti-abuse, account-type filters).
  • Re-auth on switch gives a clean audit trail and lets us require biometric for the team ↔ consumer crossing in either direction.

Frontend surface. contexts/LinkedAccountContext.tsx exposes linked, linkStandardAccount, switchToAccount, and unlinkAccount. The hub screens consume it; no other surface should reach into the linked-account API.

Where the frontend identity predicates live

Predicate Source
isAuthenticated AuthContext (has JWT + refresh token)
profileIncomplete AuthContext (user row missing username or date_of_birth)
accountType AuthContext.claims.account_type (JWT-decoded). Source of truth for UI gating because the backend enforces access.Require* from the same claim. user.account_type is a denormalized echo from /me and may drift; do not gate on it. Compare against AccountTypes.
Role predicates useAccess() hook — derives isAdmin, isCurator, isAuditor, isOperator, isSupport from claims.account_type === 'tomoda' plus claims.role. Partner-scoped checks (inPartner, canAdminPartner, canOwnPartner) use the partner-membership repo.
Linked accounts useLinkedAccounts() hook — exposes the list, the link flow, and the switch flow

Use useAccess() inside screens and components; never read JWT claims directly outside AuthContext. Compare account types and roles against the AccountTypes / UserRoles consts exported from AuthContext; bare string literals are easier to mistype and harder to grep.

  • Where the auth slice sits among the backend domains: Domains.
  • Service-level implementation (handlers, stores, OTP internals, password-reset flow, Apple JWKS handling, refresh-token TTL logic): Backend → Auth service.
  • Client-side wiring (token cache, scheduled refresh, 401 → session-expired event bus): Frontend → API Client.
  • Underlying middleware chain and per-IP throttling that fronts the auth endpoints: Backend → Security.