Authentication¶
Tomoda exposes a single identity model — the User row — reached via several front doors. Once authenticated, every client carries the same JWT bearer token and uses the same refresh-token flow. This page is the cross-cutting view; for service-level details see backend/services/auth, and for the API-client wiring see Frontend API Client.
Authentication methods¶
Six independent entry points feed the same session pipeline:
| Method | Endpoint | Front-door identity |
|---|---|---|
| Email + password | POST /api/v1/auth/login |
Bcrypt-hashed password |
| Google OAuth | POST /api/v1/auth/google-login |
Google ID token, verified via google.golang.org/api/idtoken |
| Apple Sign-In | POST /api/v1/auth/apple-login |
Apple identity token, JWKS verified manually |
| LINE OAuth | POST /api/v1/auth/line-login |
LINE ID token |
| WebAuthn / passkeys | POST /api/v1/auth/webauthn/login/{begin,finish} |
Stored public key + signed challenge |
| OTP (signup / reset) | POST /api/v1/auth/otp/{send,verify} |
Short-lived code, purpose-tagged |
OTP is not a long-lived auth method on its own — it is a verification step that gates register, password reset, email change, and phone updates. Once verified, the same Register / Login paths mint the access tokens.
Where the code lives
- Backend service:
backend/internal/services/auth_service.go, plusauth_service_ext.gofor OAuth-specific helpers. - JWT middleware:
backend/internal/middleware/auth.go. - Session and refresh-token logic:
backend/internal/services/session_service.go, exposed viabackend/internal/handlers/session_handler.go. - Frontend session state:
frontend/contexts/AuthContext.tsx. - In-memory token cache + scheduled refresh:
frontend/utils/tokenManager.ts.
JWT structure¶
Access tokens are JWTs signed with HS256 using the secret from JWTConfig.Secret. The default expiry is 24 hours (jwt.expiry: 24h in backend/config.yaml and all environment overrides). The custom claims live in UserClaims in auth_service.go:
type UserClaims struct {
jwt.RegisteredClaims
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
Role models.UserRole `json:"role"`
EmailVerified bool `json:"email_verified"`
}
The library is github.com/golang-jwt/jwt/v5. ValidateToken rejects anything not signed with HMAC and returns typed claims; the middleware then puts user_id, email, and email_verified on the Gin context for downstream handlers.
JWTs may be passed in two ways:
Authorization: Bearer <token>for normal REST requests.?token=<token>query parameter for WebSocket upgrades, since browserWebSocketconstructors cannot set headers. The middleware checks the header first, then falls back to the query string.
Refresh-token flow¶
Refresh tokens are opaque, server-issued strings persisted in the RefreshToken table and additionally mirrored into Redis under refresh_token:<token> for fast lookups. They are issued alongside every successful login and rotated implicitly on use.
sequenceDiagram
participant Client
participant API as /auth/refresh
participant Sess as SessionService
participant Redis
participant DB as Postgres
participant Auth as AuthService
Client->>API: POST { refresh_token }
API->>Sess: ValidateRefreshToken(token)
Sess->>Redis: GET refresh_token:<token>
Redis-->>Sess: user_id (or miss)
Sess->>DB: FindRefreshTokenByToken (fallback on miss)
Sess->>DB: UPDATE last_used_at
Sess-->>API: *User
API->>Auth: GenerateToken(user)
Auth-->>API: new JWT
API-->>Client: { access_token, user }
On the client, setupTokenRefresh in frontend/utils/tokenManager.ts schedules a check every 4 minutes; if isTokenExpiringSoon reports less than 5 minutes of life left, it calls refreshAccessToken (mutex-guarded so concurrent callers share one in-flight refresh). On 401 from any API call, frontend/utils/api.ts emits an AUTH_SESSION_EXPIRED event that AuthContext listens to and triggers logout().
Sessions and revocation¶
Session rows track the per-device login state. They are listed for the user via GET /api/v1/auth/sessions and can be revoked individually (DELETE /auth/sessions/:id) or in bulk (DELETE /auth/sessions/all). Revocation deletes both the row and any associated refresh token — both in Postgres and in Redis. On POST /auth/logout, the client's refresh token is deleted, the JWT becomes effectively unusable on next refresh, and the client clears its local AsyncStorage keys (@auth_token, @refresh_token, @auth_user).
A separate LoginHistory row is appended on each login and surfaced via GET /auth/login-history for the user's security screen.
API keys (programmatic access)¶
For non-interactive clients, Tomoda issues scoped API keys via POST /api/v1/auth/api-keys. The plaintext key is shown once at creation; what is stored is an encrypted form on the APIKey row. Requests authenticate by sending X-API-Key: <key>; APIKeyAuth middleware validates and loads the key, and RequireScope("<scope>") can be chained for fine-grained authorisation. An empty scope set is treated as full access for backward compatibility, and the literal scope "all" is a wildcard.
End-to-end flow: Google login + first profile complete¶
sequenceDiagram
participant App as Expo App
participant Google
participant API as Gin API
participant Auth as AuthService
participant Sess as SessionService
participant DB as Postgres
App->>Google: native Google Sign-In
Google-->>App: id_token
App->>API: POST /auth/google-login { id_token }
API->>Auth: VerifyGoogleIDToken(id_token)
Auth->>Google: idtoken.Validate (audience = client_id)
Google-->>Auth: payload (sub, email, name, picture)
Auth->>DB: FindByEmail or Create user
Auth->>Auth: GenerateToken(user) // 24h JWT
Auth->>Sess: GenerateRefreshToken(userID, device, ip)
Sess->>DB: INSERT refresh_token
Sess->>Redis: SET refresh_token:<token> userID TTL
API-->>App: { token, refresh_token, user }
App->>App: AsyncStorage.setItem @auth_token / @refresh_token / @auth_user
App->>API: GET /auth/profile (Bearer)
API-->>App: full user
App->>API: PATCH /auth/profile (complete profile)
API-->>App: updated user
The same pattern applies to Apple, LINE, and passkey logins — the difference is only the verifier on the way in. After the bearer is set, the JWTAuth middleware governs every subsequent call uniformly.
Cross-references¶
- Service-level breakdown: Backend / Auth service.
- Client-side wiring (fetch wrappers, retry, session-expired event bus): Frontend / API Client.