Skip to Content
ArchitectureAuthOverview

Auth subsystem

Cross-cutting reference for OterApp’s authentication stack: JWT issuance, rotating refresh tokens, password reset, email-verification gating, per-platform token storage, and the shared HttpClient 401 → refresh → retry plumbing. For the user journey (signup → verify → first login → land at dashboard), see Onboarding journey.

Overview

OterApp uses a short-lived HMAC-256 JWT access token (60 min default) paired with a rotating opaque refresh token (30 day TTL). Login is a self-contained transaction that also captures the device’s FCM token and IANA timezone in one request. Email verification is enforced — accounts cannot log in until verified. Password reset is a 3-step PIN + opaque session token flow; the PIN and session token are both stored hashed (SHA-256 + optional pepper) and verified with a constant-time compare. A 401 from any non-public path triggers an automatic POST /api/v1/auth/refresh attempt; on success the caller re-issues the failed request, on failure the shared SessionExpiredNotifier drives every client back to Unauthenticated. See Refresh tokens & key rotation for the rotation rules and replay-detection chain revocation.

Architecture

The client lives entirely in shared/commonMainAuthViewModel, AuthService, AuthState, AuthIntent, and the per-platform TokenStorage actuals are the only moving parts.

Authentication state machine

API endpoints

Public (no Authorization header)

All endpoints are mounted under the /api/v1/ version prefix. The client whitelist (isPublicAuthPath) keys off the /auth/<endpoint> suffix, so both /api/v1/auth/login and /auth/login shapes work in the client check — but the server only serves the versioned path.

MethodPathBody2xxFailure
POST/api/v1/auth/loginLoginUserDTO{email,password,fcmToken?,timezone?}200 AuthResponse{token,refreshToken,tier}401 AUTH_INVALID_CREDENTIALS, 403 EMAIL_NOT_VERIFIED
POST/api/v1/auth/registerRegisterUserDTO{name,email,password,timezone?} (gated by SIGN_UP feature flag per X-Platform)201400 (invalid email), 403 FEATURE_DISABLED, 500 (insert failed)
POST/api/v1/auth/refresh{refreshToken}200 AuthResponse{token,refreshToken,tier} (old refresh marked used; new pair returned)401 AUTH_INVALID_CREDENTIALS (unknown / expired / replayed — uniform error)
POST/api/v1/auth/request-verification-email{email}200400 INVALID_REQUEST, 400 USER_NOT_FOUND_OR_ALREADY_VERIFIED
POST/api/v1/auth/verify-email{email,code}200400 INVALID_REQUEST, 400 INVALID_OR_EXPIRED_CODE
POST/api/v1/auth/forgot-password{email}200 (always, even if email is unknown)(server returns 200 to avoid email enumeration)
POST/api/v1/auth/reset-password/verify{email,pin}200 {reset_token}400 INVALID_REQUEST
POST/api/v1/auth/reset-password{resetToken,newPassword}200 (also revokes all active RefreshSessions)400 INVALID_REQUEST

The client whitelist for these paths lives in AuthPlugin.kt (isPublicAuthPath) — it both suppresses the Authorization header on outbound requests and exempts the path from the global 401 → refresh-then-broadcast handler.

Protected (Bearer JWT required)

MethodPathPurpose
GET/api/v1/auth/meReturns {tier} for the current user; used by AuthService.fetchMe() to validate a stored token at app start
POST/api/v1/auth/device-tokenRegisterDeviceTokenDTO{token,platform} — register/refresh an FCM token for the principal (platform defaults to "android" when blank)
POST/api/v1/auth/sync-timezone{timezone} — server validates the IANA id and updates Users.timeZone (responds `{updated: true
POST/api/v1/auth/logout{refreshToken} — revokes the supplied refresh token server-side. Best-effort: the client clears local storage regardless of the response. Returns 204.

Rate limits

Registered in Application.kt (io.ktor.server.plugins.ratelimit):

GroupLimit / refillRoutes
forgot5 / 10 min/api/v1/auth/request-verification-email, /api/v1/auth/forgot-password
verify20 / 10 min/api/v1/auth/verify-email, /api/v1/auth/reset-password/verify
reset10 / 10 min/api/v1/auth/reset-password
refresh30 / 10 min/api/v1/auth/refresh

/api/v1/auth/login and /api/v1/auth/register are not currently rate-limited — see Known issues.

JWT details

Issued by JWTUtils.makeJWT(id: Int):

  • Algorithm: HMAC-256
  • Secret: JWT_SECRET env var. Required in production (ENVIRONMENT=production); falls back to "secret" in dev.
  • TTL: JWT_EXPIRATION_MS env var, default 60 minutes (paired with the rotating refresh token, which has its own 30-day TTL).
  • Claims:
    • sub: "Authentication" (literal)
    • iss: "https://jwt-provider-domain/"
    • aud: "jwt-audience"
    • id: Int (user id) — read by Security.kt validate { ... credential.payload.getClaim("id").asInt() }
  • No kid header; no key rotation. See Refresh tokens & key rotation for the planned kid-based rotation.

WebSocket auth fallback

Browsers cannot set Authorization on a WebSocket handshake. As an exception, the JWT plugin accepts the token via ?token= query string only on paths containing /timers/notifications:

authHeader { call -> call.request.headers[HttpHeaders.Authorization]?.let { parseAuthorizationHeader(it) } ?: if (call.request.path().contains("/timers/notifications")) { call.request.queryParameters["token"]?.let { HttpAuthHeader.Single("Bearer", it) } } else null }

Do not add new WS paths to this list without a security review — ?token= ends up in server logs unless you filter explicitly.

Login flow (client-side)

Server-side, the same /auth/login handler also:

  1. Registers the FCM token (if present) via timerService.registerDeviceToken(userId, token, "android") — note the platform string is hardcoded to "android" in AuthRouting.kt; for non-Android clients, call POST /api/v1/auth/device-token with the correct platform after login.
  2. Updates the user’s timezone if the supplied IANA id parses successfully and differs from the stored value. Invalid timezones are silently ignored.
  3. Issues a refresh token via RefreshTokenService.issue(userId) and returns it as AuthResponse.refreshToken. The opaque token is stored hashed (SHA-256 + optional REFRESH_TOKEN_PEPPER) in refresh_sessions with expires_at = now + 30d.

Token lifecycle — access + refresh

The access JWT lives 60 minutes by default; once it expires (or any other 401 happens on a non-public path), the shared HttpClientConfig validator transparently rotates the refresh token before surfacing the error.

Rotation rules (single-use, chain revocation on replay), env vars, and the full security-properties matrix live in Refresh tokens & key rotation.

Email verification

  1. Trigger: register auto-sends a verification code via EmailVerificationService.requestVerificationCode(userId, email, ip). The user may also re-trigger with POST /auth/request-verification-email (rate-limited under forgot).
  2. Verify: POST /auth/verify-email {email, code} calls authService.verifyEmail(email, code). On success, Users.emailVerifiedAt is set; subsequent logins succeed.
  3. Login gating: AuthService.login returns LoginResult.EmailNotVerified whenever Users.emailVerifiedAt is null, which maps to 403 EMAIL_NOT_VERIFIED.

Password reset

Three steps, with state stored in PasswordResetPins and PasswordResetSessions (both UUIDTable).

Key invariants (PasswordResetService.kt):

  • PIN format: 6 numeric digits, allow leading zeros ("%06d".format(Random.nextInt(0, 1_000_000))).
  • PIN TTL: 10 minutes from issue.
  • PIN attempts: 5 wrong attempts and the record stops responding (next request requires a new PIN).
  • PIN storage: sha256(pin + pepper) base64url-no-padding. Optional tokenPepper constructor arg — set via DI so the hash alone is not a useful target if the DB leaks.
  • Compare: constant-time slowEquals byte XOR — defends against timing oracles on PIN guessing.
  • Reset session token: 40 random bytes from SecureRandom, base64url-no-padding, TTL 15 minutes, single-use (usedAt set on consume).
  • Refresh-session sweep: on successful password reset, every active RefreshSessions row for the user is revoked, so any leaked refresh tokens stop working immediately.

Session expiry & 401 handling

The HTTP client validator in HttpClientConfig.kt intercepts a 401 on a non-public path and tries a refresh once, with mutex coalescing for concurrent callers; only if refresh fails does it broadcast SessionExpiredNotifier:

statusCode == 401 -> { val path = response.call.request.url.encodedPath if (isPublicAuthPath(path)) return@validateResponse val refreshed = refreshMutex.withLock { if (Clock.System.now().toEpochMilliseconds() - lastRefreshAtMs < 2_000L) true else if (refreshHandler()) { lastRefreshAtMs = Clock.System.now().toEpochMilliseconds(); true } else false } if (refreshed) throw ClientRequestException(response, "Token refreshed; retry the request.") tokenStorage.clearToken(); tokenStorage.clearRefreshToken() SessionExpiredNotifier.notifySessionExpired() throw ClientRequestException(response, "Session expired. Please log in again.") }

SessionExpiredNotifier is a process-wide SharedFlow<Unit> collected in AuthViewModel.init:

init { scope.launch { SessionExpiredNotifier.events.collect { dispatch(AuthIntent.SessionExpired) } } }

SessionExpired resets state to Unauthenticated(errorMessage = "Your session expired. Please sign in again."), so every screen that observes AuthViewModel.state re-routes to the login screen — no per-feature plumbing needed. A 401 on a public auth path is treated as a normal API error (not a session expiry), since the user wasn’t authenticated to begin with.

Token storage (per platform)

Common contract: TokenStorage with saveToken / getToken / clearToken plus the refresh-token siblings (saveRefreshToken / getRefreshToken / clearRefreshToken). TokenStorageImpl is expect class resolved per source set:

Source setBacking storeKeyNotes
nonJsMain (Android, iOS, desktop JVM)AndroidX DataStore<Preferences>stringPreferencesKey("auth_token") + stringPreferencesKey("refresh_token")Encrypted at rest on Android via DataStore + filesystem perms; on iOS via the protected app container; on desktop relies on OS file permissions
jsMain (Compose JS / wasm dev)Browser localStorage"auth_token" + "refresh_token"Plaintext; XSS-exposed. Acceptable for the dev/preview JS target. The refresh token sits in the same storage as the access token — acknowledged parity-of-risk; the production web client should migrate to httpOnly cookies. See Refresh tokens & key rotation.
wasmJsMainSame as jsMain (separate actual)"auth_token" + "refresh_token"Same caveats as above

There is no separate “remember me” toggle — a stored token is always reused until it expires or is cleared (by logout, password reset, or a 401 whose refresh also fails). The refresh token survives access-token rotation, app restart, and process kill; it’s only cleared on explicit logout, /auth/refresh returning 401, or a password reset that revokes the user’s refresh-session set.

Key files

FilePurpose
shared/src/commonMain/.../oter/services/auth/AuthService.ktHTTP client for all auth endpoints; manages TokenStorage; returns ApiOutcome<T>
shared/src/commonMain/.../oter/services/auth/AuthViewModel.ktSingle source of AuthState; collects SessionExpiredNotifier
shared/src/commonMain/.../oter/services/auth/AuthIntent.ktCheckAuth · Login · Register · Logout · SessionExpired
shared/src/commonMain/.../oter/ui/state/AuthState.ktLoading · Authenticated(tier) · Unauthenticated(...)
shared/src/commonMain/.../oter/services/auth/TokenStorageImpl1.ktexpect class TokenStorageImpl : TokenStorage
shared/src/{nonJs,js,wasmJs}Main/.../TokenStorageImpl.ktPlatform actual storage implementations
shared/src/commonMain/.../oter/api/SessionExpiredNotifier.ktApp-wide 401 → re-auth signal
shared/src/commonMain/.../oter/api/HttpClientConfig.kt401 → refresh → retry validator; broadcasts SessionExpiredNotifier on refresh failure
shared/src/commonMain/.../oter/api/AuthPlugin.ktisPublicAuthPath + bearer-header helper
server/.../routing/AuthRouting.ktAll public /auth/* routes
server/.../plugins/Routing.ktProtected /api/v1/auth/{me, device-token, sync-timezone, logout}
server/.../service/AuthService.ktlogin, register, verifyEmail, requestVerificationEmail
server/.../service/PasswordResetService.ktrequestReset, verifyPin, resetPassword + email templates
server/.../service/RefreshTokenService.ktissue, rotate, revoke, revokeAllForUser + replay-detection chain revocation
server/.../utils/JWTUtils.ktJWT minting + secret/TTL config (default 60 min)
server/.../utils/TokenUtil.ktOpaque token generation + SHA-256 base64url hashing + slowEquals constant-time compare
server/.../plugins/Security.ktJWT verifier + WebSocket ?token= fallback
server/.../database/entities/RefreshSession.ktRefresh sessions table with token_hash, expires_at, used_at, rotated_from_id

Testing

  • Unit (server)AuthService.login covers Success, InvalidCredentials, EmailNotVerified (null emailVerifiedAt).
  • Unit (server)PasswordResetService.verifyPin covers wrong PIN (attempts incremented), expired PIN (no result), max-attempts exceeded, success (issues session).
  • Unit (server)PasswordResetService.resetPassword covers expired session, already-used session, success path (RefreshSessions sweep).
  • Unit (server)RefreshTokenServiceTest (server/src/test/.../service/RefreshTokenServiceTest.kt) covers happy rotate (used_at set + rotated_from_id chain), replay → Reused + chain-wide revocation, expired, revoked, unknown, per-token revoke leaves siblings intact.
  • Unit (client)AuthViewModel.dispatch(Login) transitions through isLoading=true → terminal state for each ApiOutcome branch.
  • Unit (client)SessionExpiredNotifier.notifySessionExpired()AuthState.Unauthenticated(errorMessage=…).
  • Integration — full register → verify → login flow with FCM token and timezone; assert token persisted in TokenStorage and Users.timeZone updated.
  • Edge caseEmailSender failure during register: account is created, but verification email was not sent → user can re-trigger via request-verification-email.

Troubleshooting

Login returns 403 EMAIL_NOT_VERIFIED

  • The account exists but Users.emailVerifiedAt is null. Call POST /auth/request-verification-email, then POST /auth/verify-email.

Login returns 401 AUTH_INVALID_CREDENTIALS for the right password

  • Confirm email matches the lowercased/trimmed form stored on signup (server lowercases on both write and read).
  • Check JWT_SECRET matches between the server that issued the token and the server now verifying — secret rotation invalidates every JWT in flight (see Refresh tokens & key rotation for the planned kid-based mitigation).

Refresh suddenly fails for an active session

  • Most likely replay-detection chain revocation: an old refresh token was presented after rotation, so RefreshTokenService.rotate returned Reused and revoked every active refresh row for that user. Next legitimate refresh hits the revoked branch and returns 401. Expected behavior — user re-logs in. Look for two /auth/refresh calls with the same input plaintext in server logs.
  • Other causes: password reset revoked the chain; JWT_SECRET rotated and the just-minted access token can’t validate (but refresh would still succeed).

FCM token not delivered to server

  • The login endpoint registers FCM tokens as platform "android" only. Non-Android clients must follow up with POST /api/v1/auth/device-token and the correct platform string after login.
  • Confirm google-services.json is the variant matching the FCM project; the project id used by the server’s Firebase Admin SDK must match (see Notifications → Configuration).

Password reset PIN not received

  • Check AWS SES sender domain is verified.
  • PIN expires after 10 minutes; if expired, re-request via POST /auth/forgot-password (rate-limited 5/10min per IP).
  • After 5 wrong attempts the PIN is locked — request a new one.

/auth/forgot-password returns 200 even though the email doesn’t exist

  • Intentional, to avoid email-enumeration. If you need to know whether a given email is registered for debugging, query the DB directly — never expose that through an endpoint.

401 storms after the user changes their phone’s clock

  • The JWT expiresAt is server-issued and absolute; a phone-clock change cannot expire it. A 401 storm usually means the secret was rotated or the server was redeployed with JWT_SECRET=secret (dev fallback). Verify the env var is set in production.

Known issues

  • FCM platform hardcoded to "android" at the login endpoint. Non-Android clients should re-register their token via /api/v1/auth/device-token after login.
  • No rate limit on /auth/login or /auth/register. Brute-force protection currently relies on the password hash work factor and the lack of error differentiation. Adding a login rate-limit group is a small change but a behavior change for legitimate users with mistyped passwords.
  • Refresh tokens are bearer secrets — anyone with the plaintext refresh token can mint access tokens until rotation or explicit revoke. Storage on jsMain/wasmJsMain is localStorage (XSS-exposed); on Android/iOS/desktop it’s DataStore-backed. The replay-revokes-chain rule mitigates leaked-token reuse but doesn’t prevent first-use by an attacker.
  • /auth/forgot-password is silent on unknown emails (correct, anti-enumeration) — but the server logs include the supplied email, so log retention policies should treat auth logs as PII.
  • JWT has no kid. Rotating JWT_SECRET invalidates every token in flight (forced re-login). Refresh tokens are not signed by JWT_SECRET, so the planned kid-based rotation would let secret rotation happen without re-prompting passwords — see Refresh tokens & key rotation.
  • Per-tier opt-outs not yet on the client. Users.tier is in the JWT payload and AuthState.Authenticated, but UI gating uses local feature flags rather than reacting to tier changes mid-session. A tier change requires re-login to take effect everywhere.
  • Caller-side retry is not automatic. On refresh success, HttpClientConfig throws a retryable ClientRequestException — the original request is not auto-reissued. Most service-layer code uses runCatching / ApiOutcome and surfaces the error to the caller. Migrating to Ktor’s Auth { bearer { refreshTokens } } plugin (deferred) would make refresh fully transparent.

← Back to architecture index