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
HttpClient401 → 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/commonMain — AuthViewModel, 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.
| Method | Path | Body | 2xx | Failure |
|---|---|---|---|---|
POST | /api/v1/auth/login | LoginUserDTO{email,password,fcmToken?,timezone?} | 200 AuthResponse{token,refreshToken,tier} | 401 AUTH_INVALID_CREDENTIALS, 403 EMAIL_NOT_VERIFIED |
POST | /api/v1/auth/register | RegisterUserDTO{name,email,password,timezone?} (gated by SIGN_UP feature flag per X-Platform) | 201 | 400 (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} | 200 | 400 INVALID_REQUEST, 400 USER_NOT_FOUND_OR_ALREADY_VERIFIED |
POST | /api/v1/auth/verify-email | {email,code} | 200 | 400 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)
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/auth/me | Returns {tier} for the current user; used by AuthService.fetchMe() to validate a stored token at app start |
POST | /api/v1/auth/device-token | RegisterDeviceTokenDTO{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):
| Group | Limit / refill | Routes |
|---|---|---|
forgot | 5 / 10 min | /api/v1/auth/request-verification-email, /api/v1/auth/forgot-password |
verify | 20 / 10 min | /api/v1/auth/verify-email, /api/v1/auth/reset-password/verify |
reset | 10 / 10 min | /api/v1/auth/reset-password |
refresh | 30 / 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_SECRETenv var. Required in production (ENVIRONMENT=production); falls back to"secret"in dev. - TTL:
JWT_EXPIRATION_MSenv 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 bySecurity.ktvalidate { ... credential.payload.getClaim("id").asInt() }
- No
kidheader; no key rotation. See Refresh tokens & key rotation for the plannedkid-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:
- Registers the FCM token (if present) via
timerService.registerDeviceToken(userId, token, "android")— note the platform string is hardcoded to"android"inAuthRouting.kt; for non-Android clients, callPOST /api/v1/auth/device-tokenwith the correct platform after login. - Updates the user’s timezone if the supplied IANA id parses successfully and differs from the stored value. Invalid timezones are silently ignored.
- Issues a refresh token via
RefreshTokenService.issue(userId)and returns it asAuthResponse.refreshToken. The opaque token is stored hashed (SHA-256 + optionalREFRESH_TOKEN_PEPPER) inrefresh_sessionswithexpires_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
- Trigger:
registerauto-sends a verification code viaEmailVerificationService.requestVerificationCode(userId, email, ip). The user may also re-trigger withPOST /auth/request-verification-email(rate-limited underforgot). - Verify:
POST /auth/verify-email {email, code}callsauthService.verifyEmail(email, code). On success,Users.emailVerifiedAtis set; subsequent logins succeed. - Login gating:
AuthService.loginreturnsLoginResult.EmailNotVerifiedwheneverUsers.emailVerifiedAt is null, which maps to403 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. OptionaltokenPepperconstructor arg — set via DI so the hash alone is not a useful target if the DB leaks. - Compare: constant-time
slowEqualsbyte XOR — defends against timing oracles on PIN guessing. - Reset session token: 40 random bytes from
SecureRandom, base64url-no-padding, TTL 15 minutes, single-use (usedAtset on consume). - Refresh-session sweep: on successful password reset, every active
RefreshSessionsrow 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 set | Backing store | Key | Notes |
|---|---|---|---|
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. |
wasmJsMain | Same 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
| File | Purpose |
|---|---|
shared/src/commonMain/.../oter/services/auth/AuthService.kt | HTTP client for all auth endpoints; manages TokenStorage; returns ApiOutcome<T> |
shared/src/commonMain/.../oter/services/auth/AuthViewModel.kt | Single source of AuthState; collects SessionExpiredNotifier |
shared/src/commonMain/.../oter/services/auth/AuthIntent.kt | CheckAuth · Login · Register · Logout · SessionExpired |
shared/src/commonMain/.../oter/ui/state/AuthState.kt | Loading · Authenticated(tier) · Unauthenticated(...) |
shared/src/commonMain/.../oter/services/auth/TokenStorageImpl1.kt | expect class TokenStorageImpl : TokenStorage |
shared/src/{nonJs,js,wasmJs}Main/.../TokenStorageImpl.kt | Platform actual storage implementations |
shared/src/commonMain/.../oter/api/SessionExpiredNotifier.kt | App-wide 401 → re-auth signal |
shared/src/commonMain/.../oter/api/HttpClientConfig.kt | 401 → refresh → retry validator; broadcasts SessionExpiredNotifier on refresh failure |
shared/src/commonMain/.../oter/api/AuthPlugin.kt | isPublicAuthPath + bearer-header helper |
server/.../routing/AuthRouting.kt | All public /auth/* routes |
server/.../plugins/Routing.kt | Protected /api/v1/auth/{me, device-token, sync-timezone, logout} |
server/.../service/AuthService.kt | login, register, verifyEmail, requestVerificationEmail |
server/.../service/PasswordResetService.kt | requestReset, verifyPin, resetPassword + email templates |
server/.../service/RefreshTokenService.kt | issue, rotate, revoke, revokeAllForUser + replay-detection chain revocation |
server/.../utils/JWTUtils.kt | JWT minting + secret/TTL config (default 60 min) |
server/.../utils/TokenUtil.kt | Opaque token generation + SHA-256 base64url hashing + slowEquals constant-time compare |
server/.../plugins/Security.kt | JWT verifier + WebSocket ?token= fallback |
server/.../database/entities/RefreshSession.kt | Refresh sessions table with token_hash, expires_at, used_at, rotated_from_id |
Testing
- Unit (server) —
AuthService.logincoversSuccess,InvalidCredentials,EmailNotVerified(nullemailVerifiedAt). - Unit (server) —
PasswordResetService.verifyPincovers wrong PIN (attempts incremented), expired PIN (no result), max-attempts exceeded, success (issues session). - Unit (server) —
PasswordResetService.resetPasswordcovers expired session, already-used session, success path (RefreshSessions sweep). - Unit (server) —
RefreshTokenServiceTest(server/src/test/.../service/RefreshTokenServiceTest.kt) covers happy rotate (used_atset +rotated_from_idchain), replay →Reused+ chain-wide revocation, expired, revoked, unknown, per-token revoke leaves siblings intact. - Unit (client) —
AuthViewModel.dispatch(Login)transitions throughisLoading=true→ terminal state for eachApiOutcomebranch. - Unit (client) —
SessionExpiredNotifier.notifySessionExpired()→AuthState.Unauthenticated(errorMessage=…). - Integration — full register → verify → login flow with FCM token and timezone; assert token persisted in
TokenStorageandUsers.timeZoneupdated. - Edge case —
EmailSenderfailure during register: account is created, but verification email was not sent → user can re-trigger viarequest-verification-email.
Troubleshooting
Login returns 403 EMAIL_NOT_VERIFIED
- The account exists but
Users.emailVerifiedAtis null. CallPOST /auth/request-verification-email, thenPOST /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_SECRETmatches 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 plannedkid-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.rotatereturnedReusedand 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/refreshcalls with the same input plaintext in server logs. - Other causes: password reset revoked the chain;
JWT_SECRETrotated 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 withPOST /api/v1/auth/device-tokenand the correct platform string after login. - Confirm
google-services.jsonis 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
expiresAtis 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 withJWT_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-tokenafter login. - No rate limit on
/auth/loginor/auth/register. Brute-force protection currently relies on the password hash work factor and the lack of error differentiation. Adding aloginrate-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-passwordis 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. RotatingJWT_SECRETinvalidates every token in flight (forced re-login). Refresh tokens are not signed byJWT_SECRET, so the plannedkid-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.tieris in the JWT payload andAuthState.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,
HttpClientConfigthrows a retryableClientRequestException— the original request is not auto-reissued. Most service-layer code usesrunCatching/ApiOutcomeand surfaces the error to the caller. Migrating to Ktor’sAuth { bearer { refreshTokens } }plugin (deferred) would make refresh fully transparent.
Related docs
- Refresh tokens & key rotation — rotation rules, chain revocation, env vars, planned
kidrotation - Onboarding journey — the user-facing signup → verify → first login → dashboard path
- Notifications — FCM token registration + delivery (the device-token endpoint feeds into this)
- WebSocket debugging — context for the
?token=query-string fallback - FCM troubleshooting — Firebase Admin SDK configuration