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 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

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 browser WebSocket constructors 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