Skip to content

Auth

Purpose

The auth domain owns every code path that produces a JWT or refresh token: registration, password login, social login (Google, Apple, LINE), WebAuthn (passkeys), OTP email verification, password reset, session listing, and revocation. Three services compose together: AuthService does identity, SessionService does refresh tokens and session listing, OTPService does one-time codes for sensitive flows. Two stores back them (SessionStore, APIKeyStore), and lean profile/account lifecycle reads come from the user domain.

Responsibilities

  • Issue HS256-signed JWTs (UserClaims payload) and verify them on every protected request
  • Carry a token_version (tv) revocation epoch in each JWT and reject tokens whose epoch is stale
  • Generate, validate, and revoke refresh tokens (web TTL is shorter than native)
  • Verify Google ID tokens via google.golang.org/api/idtoken
  • Verify Apple ID tokens by fetching and caching Apple's JWKS keys in Redis
  • Handle Apple server-to-server consent-revoked / account-delete notifications
  • Run WebAuthn registration and login ceremonies (github.com/go-webauthn/webauthn)
  • Generate and verify 6-digit OTP codes for signup, password_reset, and email_change purposes
  • Enforce device-fingerprint registration caps (max 3 accounts/device, 30-day Redis window)
  • Reject disposable email domains and accounts under 18
  • Record login attempts and expose login history
  • List, revoke single, and revoke-all sessions
  • Issue and validate long-lived API keys (sk_<uuid>)

HTTP endpoints

Routes register through the functions in backend/internal/services/auth/routes.go; rate-limit and JWT gates are passed in as func(http.Handler) http.Handler middleware so the package never imports middleware. Composition lives in backend/internal/wiring/router.go.

Public

Method Path Description
POST /api/v1/auth/register Email/password registration (rate-limited)
POST /api/v1/auth/login Email-or-username + password (rate-limited)
POST /api/v1/auth/google-login Google ID token exchange
POST /api/v1/auth/line-login LINE OAuth exchange
POST /api/v1/auth/apple-login Apple ID token exchange (verifies via Apple JWKS)
POST /api/v1/auth/refresh Exchange refresh token for a new access token
POST /api/v1/auth/logout Revoke the current refresh token
POST /api/v1/auth/reset-password Finish OTP-verified password reset (requires otpToken)
POST /api/v1/auth/otp/send Send a 6-digit code to email
POST /api/v1/auth/otp/verify Verify code, return short-lived otpToken JWT
POST /api/v1/auth/webauthn/login/begin Start passkey login ceremony
POST /api/v1/auth/webauthn/login/finish Finish passkey login
POST /api/v1/auth/apple/notifications Apple S2S notifications (ES256-signed; no JWT)
GET /.well-known/webauthn RPID-allowed origins for related-origin requests

Authenticated (reachable before email verification)

Method Path Description
GET /api/v1/auth/profile Current user
PATCH /api/v1/auth/profile Update profile fields
DELETE /api/v1/auth/profile Soft-deactivate account (30-day grace)
POST /api/v1/auth/phone Set/verify phone number
POST /api/v1/auth/email/change Begin email-change flow (sends OTP to new email)
POST /api/v1/auth/email/change/confirm Confirm email change with the code
GET /api/v1/auth/sessions List active sessions
DELETE /api/v1/auth/sessions/{id} Revoke a single session
DELETE /api/v1/auth/sessions/all Sign out everywhere
GET /api/v1/auth/login-history Recent login attempts (success + failures)

Verified account required

Method Path Description
PUT /api/v1/auth/profile/password Change password (requires current)
PUT /api/v1/auth/profile/name Change display name (14-day cooldown)
PUT /api/v1/auth/profile/username Change username (uniqueness checked)
POST /api/v1/auth/user/webhook/test Fire a test webhook to the user's configured URL
POST /api/v1/auth/webauthn/register/begin Enroll a passkey (challenge)
POST /api/v1/auth/webauthn/register/finish Enroll a passkey (attestation)
POST /api/v1/auth/api-keys Mint an API key (sk_<uuid>)
GET /api/v1/auth/api-keys List API keys (masked)
DELETE /api/v1/auth/api-keys/{id} Revoke an API key
PUT /api/v1/users/me/avatar Set avatar to a freshly-uploaded key (post-presign confirm). Body: { "key": "avatars/..." }. See storage for the presigned PUT flow.
DELETE /api/v1/users/me/avatar Clear the avatar. Idempotent; old object enqueued for async delete.

Key types

type UserClaims struct {
    jwt.RegisteredClaims
    UserID        uuid.UUID
    Email         string
    Role          models.UserRole    // "base" | "admin" | "curator" | "auditor" | "operator" | "support" | "developer"
    AccountType   models.AccountType // "standard" | "tomoda" | "synthetic" | "partner" | "external-curators"
    EmailVerified bool               // always true post-OTP; kept for old-token compat
    TokenVersion  int64              // "tv": revocation epoch stamped at sign time
}

AuthService (backend/internal/services/auth/auth_service.go) depends on the user domain's UserStore, UserService, and ProfileStore, the local APIKeyStore and SessionService, platform/cache, platform/assets, platform/email, and the JWT/Google/LINE/Apple/WebAuthn config blocks.

SessionService (session_service.go) exposes GenerateRefreshToken, ValidateRefreshToken, RevokeRefreshToken, RevokeAllUserTokens, GetUserSessions, RevokeSession, RecordLoginAttempt, GetLoginHistory, CheckLoginRateLimit, and CleanupExpiredTokens.

OTPService (otp_service.go) exposes SendOTP(email, purpose), VerifyOTP(email, code, purpose) (otpToken, err), and ValidateOTPToken(token) (email, err).

JWT validation

Access tokens are JWTs signed with HS256 using JWTConfig.Secret (backend/config.yaml, env override JWT_SECRET). The library is github.com/golang-jwt/jwt/v5. AuthService.ValidateToken rejects anything not signed with HMAC and returns typed UserClaims.

The JWTAuth middleware in backend/internal/middleware/auth.go then does two things on every protected request:

  1. Puts user_id, email, and email_verified onto the request context (api.WithIdentity) for downstream handlers, read via api.UserID(r), api.Email(r), api.EmailVerified(r).
  2. Compares the token's TokenVersion against the user's current token_version (via AuthService.CurrentTokenVersion). A mismatch means the account revoked all sessions since the token was signed, so the request is rejected even though the signature is still valid.

JWTs may be presented two ways:

  • Authorization: Bearer <token> — normal REST requests.
  • ?token=<token> — WebSocket upgrades, since browser WebSocket constructors cannot set headers. The middleware checks the header first, then falls back to the query string.

token_version is the global revoke lever

AuthService.BumpTokenVersion increments users.token_version, invalidating every outstanding JWT for the user at once. Password reset, revoke-all-sessions, and account suspend/delete call it. Per-device logout instead deletes the single refresh token; the access token expires on its own.

Refresh-token flow

Refresh tokens are opaque, server-issued strings persisted in refresh_tokens and mirrored into Redis under refresh_token:<token> for fast lookups. They are issued alongside every successful login and their last_used_at is updated on each use.

Client
POST /auth/refresh
SessionService.ValidateRefreshToken
token lookup
Redis
refresh_token:<tok>
→ miss →
postgres
refresh_tokens (UPDATE last_used_at)
AuthService.GenerateToken
new JWT
back to client
Redis is checked first; Postgres is the fallback and the source of truth for last_used_at.

On the client side, frontend/utils/tokenManager.ts schedules a check every 4 minutes via setupTokenRefresh; if isTokenExpiringSoon reports less than 5 minutes of life left, it calls refreshAccessToken (mutex-guarded so concurrent callers share one in-flight refresh). On a 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

sessions rows track per-device login state. They are listed via GET /auth/sessions and revoked individually (DELETE /auth/sessions/{id}) or in bulk (DELETE /auth/sessions/all). Revocation deletes both the session row and its refresh token, in Postgres and in Redis.

POST /auth/logout deletes the caller's refresh token, and the client clears its local AsyncStorage keys (@auth_token, @refresh_token, @auth_user). A login_history row is appended on each login and surfaced via GET /auth/login-history.

API keys (programmatic access)

For non-interactive clients, Tomoda mints scoped API keys via POST /auth/api-keys. The plaintext key (sk_<uuid>) is returned once at creation; List returns the masked SafeAPIKey shape. Requests authenticate by sending X-API-Key: <key>; the 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; the literal scope "all" is a wildcard.

End-to-end: Google login + first profile complete

Expo App
native Google Sign-In
Google
id_token
↓ POST /auth/google-login
chi API
AuthService.VerifyGoogleIDToken
idtoken.Validate (aud = client_id)
postgres / users
FindByEmail or Create
GenerateToken
signed JWT
SessionService.GenerateRefreshToken
refresh_tokens (INSERT)
Redis
refresh_token:<tok>
↓ { token, refresh_token, user }
AsyncStorage
@auth_token, @refresh_token, @auth_user
Apple, LINE, and passkey logins follow the same shape. Only the verifier (rust box) changes.

After the bearer is set, JWTAuth middleware governs every subsequent call uniformly.

Data model

Table Used for
users Identity, OAuth IDs (google_id, apple_id, line_id), password hash, role, token_version, deactivated_at
refresh_tokens Long-lived tokens, indexed by token string
sessions One row per active device, denormalises device_info + ip + last_active_at
login_history Audit trail of every login attempt (method, success, fail_reason, ip)
web_authn_credentials Passkey credentials per user
api_keys Long-lived sk_<uuid> keys with scopes
otps Short-lived 6-digit codes with a purpose discriminator and used flag

Toggleable account preferences (is_location_shared, marketing_consent, notifications_enabled, language, chat/map prefs) live on user_profiles, not users. See user.

Dependencies

  • user domain — UserStore, UserService (deactivate/reactivate hook), ProfileStore
  • platform/cache — Apple JWKS cache, refresh-token cache, device fingerprint counters, WebAuthn session blobs, email-change verification
  • platform/email — welcome email, OTP email, deactivation email
  • platform/assets — avatar finalize with old-object cleanup
  • APIKeyStore, SessionStore — persistence for keys and sessions/refresh tokens

Notable behavior

OTP-first signup

Email verification is not a separate token flow. Registration sets email_verified = true because callers complete OTP verification before posting /auth/register.

Account reactivation on login

finalizeLogin detects DeactivatedAt != nil and reactivates the account within the 30-day grace window before issuing tokens. Beyond 30 days the user is hard-deleted by the scheduled purge job (see user).

Refresh-token TTL by client

SessionService.GenerateRefreshToken sniffs the User-Agent. Browsers (mozilla, chrome, safari, etc.) get 7 days; native clients get 30 days. The same value is mirrored into Redis (refresh_token:<token>) for fast lookup.

Password reset (OTP flow)

User
1. send code
POST /auth/otp/send
purpose=password_reset
OTPService.SendOTP
EmailService
6-digit code
2. verify code → otpToken
POST /auth/otp/verify
OTPService.VerifyOTP
otpToken
15-min JWT
3. reset password
POST /auth/reset-password
otpToken + newPassword
AuthService.ResetPassword
ValidateOTPToken → bcrypt → Update → BumpTokenVersion
postgres / users

The OTP is stored in otps with a 10-minute TTL; the returned otpToken JWT lives 15 minutes. A successful reset bumps token_version, logging out every existing session.

Apple S2S notifications

HandleAppleNotification accepts ES256-signed payloads from Apple, verifies the kid against GetApplePublicKeyEC, and on consent-revoked / account-delete invokes DeactivateUserByAppleID, which then triggers the standard 30-day purge path.

Where to look