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_idandroleclaim. 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:
AccountType— what kind of account is this? Defaults tostandardand is set at signup or bydatabase.SeedSyntheticUser. Used to filter special accounts out of analytics, leaderboards, anti-abuse, and (future) MFA enforcement.Role— what is this account allowed to do inside the app? Defaults tobase.
| 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:
- One
AccountType, one shell, no cross-cutting. EachAccountTypemounts 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). Roleadds or hides sections inside(internal). Theadmin/curator/auditor/operator/support/developerroles unlock matching screens viaStack.Protected guard={isCurator}entries plus conditional menu items inAppNav. Inside(tomoda), roles are irrelevant — standard accounts are rolebaseby definition.- 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):
- Hub → Account → "Link consumer account."
- The client opens the link modal: either sign in as an existing standard account, or register a fresh standard account.
- On success, the server inserts the
account_linksrow and returns the linked account's refresh token. - The client stores the linked refresh token in the OS keychain (biometric-gated when available).
Switch flow (every time after):
- Hub → "Switch to consumer account" (or "Switch back to team account").
- 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.
- The refresh token is exchanged at
/auth/refreshfor a new access JWT. AuthContextswaps 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 to read next¶
- Where the
authslice 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.