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,email_change, andaccount_deletionpurposes - 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). Re-authenticates: body carries password, or otp_token for accounts without a password |
| POST | /api/v1/auth/phone |
Set/verify phone number |
| POST | /api/v1/auth/link/google |
Connect a verified Google identity to the current account |
| POST | /api/v1/auth/link/apple |
Connect a verified Apple identity to the current account |
| DELETE | /api/v1/auth/link/google |
Disconnect Google (refused if it is the only sign-in method) |
| DELETE | /api/v1/auth/link/apple |
Disconnect Apple (refused if it is the only sign-in method) |
| 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:<sha256(token)>. The key is hashed because a raw token in the key name would hand a working session to anything that reads key names (SCAN, MONITOR, the slowlog, an RDB snapshot). They are issued alongside every successful login and their last_used_at is updated on each use.
POST /auth/refresh
refresh_token:<sha256>
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 IDs
FindByEmail or Create
signed JWT
refresh_token:<sha256>
@auth_token, @refresh_token, @auth_user
After the bearer is set, JWTAuth middleware governs every subsequent call uniformly.
Social identity verification & linking¶
The trust boundary is the provider token, never client-sent profile fields. Both social verifiers live in auth_service.go (verifyGoogleIDToken, verifyAppleIDToken) and are shared by the login and link paths.
- Google verifies signature,
iss, andexpviaidtoken.Validate, then checksaudagainst every shipped client ID:GOOGLE_CLIENT_ID(web, may also be comma-separated),GOOGLE_IOS_CLIENT_ID, andGOOGLE_ANDROID_CLIENT_ID. All three are projected from GCP Secret Manager in dev and prod. Empty config fails closed. - Apple parses the identity token with
jwt.WithValidMethods(["RS256"]),jwt.WithIssuer(appleIssuer), andjwt.WithExpirationRequired(), thenverifyAppleAudiencechecksaudagainst the configured Bundle ID / Services ID. Unconfigured audience fails closed (APPLE_CLIENT_ID/APPLE_SERVICE_IDmust be set).
Config is required, not optional
Both providers now fail closed. If GOOGLE_CLIENT_ID omits a shipped client ID, tokens minted under it are rejected; if the Apple audience env vars are unset, Apple login refuses. Set all client IDs in every environment.
Linking is keyed on the provider subject, never the email. A social login resolves an account only by google_id / apple_id. If the identity is unknown but its email already belongs to an account, the login returns 409 SOCIAL_LINK_REQUIRED rather than silently merging (that would be an account-takeover vector). To connect a provider after signup, the authenticated POST /auth/link/{google,apple} endpoints attach the verified identity to the current account, refusing with SOCIAL_ALREADY_LINKED (identity belongs to another account) or SOCIAL_LINK_CONFLICT (this account already has a different identity for that provider). A user can link both Google and Apple. The DELETE variants disconnect a provider, refused with LAST_LOGIN_METHOD if it would leave the account with no way to sign in (no password, passkey, or other linked provider).
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 deletion re-authenticates
DELETE /auth/profile is not satisfied by the bearer alone. confirmIdentity requires the account password when the account has one, and an otp_token (purpose account_deletion, proving control of the address) when it does not. A missing or wrong credential returns 401 TOKEN_INVALID. Clients read has_password on the user payload to decide which credential to collect.
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 under refresh_token:<sha256(token)>.
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 terminates every existing session.
Password changes end every session¶
Both UpdatePassword (in-session, re-authenticates with the current password) and ResetPassword (OTP-gated) finish with TerminateUserSessions: refresh tokens and session rows are deleted, then token_version is bumped to kill live access JWTs.
The caller's own session is not spared. token_version is a single per-user epoch, so it cannot express "every session but this one"; sparing the current device would need a per-session revocation primitive and a session identifier in the JWT, neither of which exists. Bumping token_version alone would also be insufficient, because ValidateRefreshToken does not consult it, so an attacker's refresh token would survive and mint a fresh access JWT. Clients should expect a 401 on the next request after a password change and re-authenticate.
Apple S2S notifications¶
HandleAppleNotification hands the raw payload to VerifyAppleNotification, which parses it as an ES256 JWT against the JWKS key named by its kid (GetApplePublicKeyEC) and rejects anything failing the signature, iss (https://appleid.apple.com), aud (configured Bundle ID or Services ID), or expiry checks. Only then is the events claim decoded, and on consent-revoked / account-delete it invokes DeactivateUserByAppleID, which triggers the standard 30-day purge path.
The kid does not authenticate the payload
Apple's JWKS keys are public and the kid is attacker-supplied, so resolving a key proves nothing on its own. Only the signature binds the claims to Apple. Since the endpoint is unauthenticated and its effect is account deactivation, the signature check is the sole gate.
The handler always answers 200 (Apple retries any non-2xx) and logs rejections rather than surfacing them.
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