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, email_change, and account_deletion 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). 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:

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

Client
POST /auth/refresh
SessionService.ValidateRefreshToken
token lookup
Redis
refresh_token:<sha256>
→ 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 IDs
postgres / users
FindByEmail or Create
GenerateToken
signed JWT
SessionService.GenerateRefreshToken
refresh_tokens (INSERT)
Redis
refresh_token:<sha256>
↓ { 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.

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, and exp via idtoken.Validate, then checks aud against every shipped client ID: GOOGLE_CLIENT_ID (web, may also be comma-separated), GOOGLE_IOS_CLIENT_ID, and GOOGLE_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), and jwt.WithExpirationRequired(), then verifyAppleAudience checks aud against the configured Bundle ID / Services ID. Unconfigured audience fails closed (APPLE_CLIENT_ID / APPLE_SERVICE_ID must 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 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 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)

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