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 (
UserClaimspayload) 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, andemail_changepurposes - 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:
- Puts
user_id,email, andemail_verifiedonto the request context (api.WithIdentity) for downstream handlers, read viaapi.UserID(r),api.Email(r),api.EmailVerified(r). - Compares the token's
TokenVersionagainst the user's currenttoken_version(viaAuthService.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 browserWebSocketconstructors 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.
POST /auth/refresh
refresh_token:<tok>
refresh_tokens (UPDATE last_used_at)
back to client
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¶
native Google Sign-In
id_token
idtoken.Validate (aud = client_id)
FindByEmail or Create
signed JWT
refresh_token:<tok>
@auth_token, @refresh_token, @auth_user
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 verificationplatform/email— welcome email, OTP email, deactivation emailplatform/assets— avatar finalize with old-object cleanupAPIKeyStore,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)¶
purpose=password_reset
6-digit code
15-min JWT
otpToken + newPassword
ValidateOTPToken → bcrypt → Update → BumpTokenVersion
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¶
backend/internal/services/auth/auth_service.go— identity, OAuth verifiers, token generation,token_versionbackend/internal/services/auth/session_service.go— refresh tokens, sessions, login historybackend/internal/services/auth/otp_service.go— OTP codes + otpToken JWTbackend/internal/services/auth/auth_handler.go,session_handler.go,apikey_handler.go— HTTP surfacebackend/internal/services/auth/routes.go— route registration (gates injected)backend/internal/middleware/auth.go— JWT validation +token_versioncheckbackend/internal/middleware/rate_limiter.go—RegisterLimit,LoginLimit,ResetPasswordLimit