feat(m3): native auth — login+TOTP, token storage, refresh, access-level menu
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m41s

Implements M3 (docs/android/PLAN.md §4): the functional Kotlin auth pass.

- Native username/password (+ single-request TOTP) login over the existing
  POST /auth/mobile/login; a 401 { totpRequired } reveals the code field, 429
  surfaces a backoff message (§4.1).
- Token pair in EncryptedSharedPreferences (TokenStore behind SessionManager,
  the single source of truth for the in-memory bearer + observable Session);
  base URL stays in plain DataStore (§4.3).
- OkHttp AuthInterceptor (bearer) + TokenAuthenticator: one-shot, mutex-
  serialized refresh-on-401 that replays the request, on its own bare client so
  it can never recurse; single-use rotation; dead refresh signs out, transient
  network keeps the session.
- Logout (POST /auth/mobile/logout, this session or all devices) tears down
  locally even on failure.
- GET /auth/me re-validates the role on every resume; a surviving 401 signs out
  (role stays advisory — backend is authority).
- Declarative access-level menu (visibleEntries: public/signed-in/player) with a
  Sign in / Sign out toggle + a My Account screen.
- Custom-Tab hand-offs (androidx.browser) to the website for register / forgot-
  password / SSO — no native screens (§4.2).
- Settings → Server switch now also clears the stored session (§3).

Biometric app-lock is deferred to M6 (tokens already encrypted at rest; it is
opt-in UX, not a v1 requirement — decided at M3).

JVM unit tests (18): auth-DTO decode (incl. totpRequired vs a plain credential
401), the SessionManager lifecycle over a fake store, and the menu access filter
+ role mapping. No backend/API change — a pure consumer of the existing mobile
bearer + /auth/me surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
This commit is contained in:
2026-07-19 19:08:00 -05:00
parent 199df6cd64
commit 1c56eda64b
29 changed files with 1625 additions and 21 deletions

View File

@@ -0,0 +1,101 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
import com.runicgateway.app.data.api.dto.SafeUserDto
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Session lifecycle over a fake [TokenStore] (PLAN.md §4.3): restore on launch,
* establish/rotate/tear-down, and the invariant that the in-memory token pair and
* the observable [Session] state always agree.
*/
class SessionManagerTest {
/** In-memory stand-in for EncryptedSharedPreferences. */
private class FakeTokenStore(var stored: StoredSession? = null) : TokenStore {
override fun load(): StoredSession? = stored
override fun save(session: StoredSession) { stored = session }
override fun clear() { stored = null }
}
private fun user(id: Long = 1, name: String = "alice", role: String = "player") =
SafeUserDto(id = id, username = name, role = role)
@Test fun startsSignedOutWithNoStoredSession() {
val mgr = SessionManager(FakeTokenStore())
assertEquals(Session.SignedOut, mgr.state.value)
assertNull(mgr.currentAccessToken())
assertNull(mgr.currentRefreshToken())
}
@Test fun restoresSignedInFromStore() {
val store = FakeTokenStore(
StoredSession("access-1", "refresh-1", 5, "bob", "admin"),
)
val mgr = SessionManager(store)
val state = mgr.state.value
assertTrue(state is Session.SignedIn)
assertEquals("bob", (state as Session.SignedIn).user.username)
assertEquals(Role.ADMIN, state.user.role)
assertEquals("access-1", mgr.currentAccessToken())
assertEquals("refresh-1", mgr.currentRefreshToken())
}
@Test fun signInPersistsAndExposesTokens() {
val store = FakeTokenStore()
val mgr = SessionManager(store)
mgr.onSignedIn("access-A", "refresh-A", user(role = "player"))
assertTrue(mgr.state.value is Session.SignedIn)
assertEquals("access-A", mgr.currentAccessToken())
assertEquals("refresh-A", mgr.currentRefreshToken())
// Persisted so a relaunch restores it.
assertEquals("refresh-A", store.stored?.refreshToken)
}
@Test fun refreshRotatesTokensWhileSignedIn() {
val mgr = SessionManager(FakeTokenStore())
mgr.onSignedIn("access-A", "refresh-A", user())
mgr.onRefreshed("access-B", "refresh-B", user())
assertEquals("access-B", mgr.currentAccessToken())
assertEquals("refresh-B", mgr.currentRefreshToken())
}
@Test fun refreshIsNoOpAfterSignOut() {
val mgr = SessionManager(FakeTokenStore())
mgr.onSignedIn("access-A", "refresh-A", user())
mgr.onSignedOut()
// A refresh that races a logout must not resurrect the session.
mgr.onRefreshed("access-B", "refresh-B", user())
assertEquals(Session.SignedOut, mgr.state.value)
assertNull(mgr.currentAccessToken())
}
@Test fun userRefreshUpdatesRoleKeepingTokens() {
val mgr = SessionManager(FakeTokenStore())
mgr.onSignedIn("access-A", "refresh-A", user(role = "player"))
// A /auth/me re-validation reports a promotion.
mgr.onUserRefreshed(user(role = "editor"))
val state = mgr.state.value as Session.SignedIn
assertEquals(Role.EDITOR, state.user.role)
assertEquals("access-A", mgr.currentAccessToken())
}
@Test fun signOutClearsStoreAndState() {
val store = FakeTokenStore()
val mgr = SessionManager(store)
mgr.onSignedIn("access-A", "refresh-A", user())
mgr.onSignedOut()
assertEquals(Session.SignedOut, mgr.state.value)
assertNull(store.stored)
}
}

View File

@@ -0,0 +1,71 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decoding tests for the mobile auth wire shapes (PLAN.md §4.1). Field names come
* from the website's `auth/mobile` controller + `/auth/me`; the parser ignores
* unknown keys (additive backend fields, §8).
*/
class AuthDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun tokenResponseDecodesWithSafeUser() {
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"aaa.bbb.ccc","refreshToken":"r-123","expiresIn":"15m",
"user":{"id":7,"username":"alice","role":"player"}}""",
)
assertEquals("aaa.bbb.ccc", dto.accessToken)
assertEquals("r-123", dto.refreshToken)
assertEquals("15m", dto.expiresIn)
assertEquals(7L, dto.user.id)
assertEquals("alice", dto.user.username)
assertEquals("player", dto.user.role)
}
@Test fun meResponseIgnoresExtraUserFields() {
// /auth/me returns the full safe user row — the app only needs id/username/role.
val dto = json.decodeFromString<MeResponse>(
"""{"user":{"id":1,"username":"staff","role":"editor","status":"active",
"createdAt":"2026-01-01T00:00:00Z","totp_enabled":true}}""",
)
assertEquals(1L, dto.user.id)
assertEquals("editor", dto.user.role)
}
@Test fun totpRequiredErrorDecodes() {
val dto = json.decodeFromString<TotpRequiredError>(
"""{"totpRequired":true,"message":"A verification code is required."}""",
)
assertTrue(dto.totpRequired)
}
@Test fun totpRequiredDefaultsFalseForPlainCredentialFailure() {
// A bad-password 401 has no totpRequired flag — must not read as a 2FA prompt.
val dto = json.decodeFromString<TotpRequiredError>(
"""{"message":"Incorrect username or password."}""",
)
assertFalse(dto.totpRequired)
}
@Test fun tokenResponseExpiresInOptional() {
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":2,"username":"bob","role":"admin"}}""",
)
assertNull(dto.expiresIn)
assertEquals("admin", dto.user.role)
}
}

View File

@@ -0,0 +1,67 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.core.auth.Role
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionUser
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The declarative access-level menu filter (PLAN.md §5): one list gated by the
* current session, not scattered `if role ==` checks.
*/
class MenuAccessTest {
private fun signedIn(role: Role) =
Session.SignedIn(SessionUser(id = 1, username = "u", role = role))
private fun routes(session: Session) =
visibleEntries(APP_MENU, session).map { it.route }
@Test fun anonymousSeesOnlyPublicEntries() {
val visible = routes(Session.SignedOut)
assertTrue(visible.contains(Routes.HOME))
assertTrue(visible.contains(Routes.SHARD))
// Signed-in surfaces are hidden.
assertFalse(visible.contains(Routes.ACCOUNT))
}
@Test fun signedInPlayerSeesAccount() {
val visible = routes(signedIn(Role.PLAYER))
assertTrue(visible.contains(Routes.ACCOUNT))
assertTrue(visible.contains(Routes.HOME))
}
@Test fun staffSeeAccountButNoPlayerOnlyGroups() {
val visible = routes(signedIn(Role.EDITOR))
assertTrue(visible.contains(Routes.ACCOUNT))
// No PLAYER-access entry leaks to staff (none exist yet in M3; guard the rule).
val playerOnly = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
assertTrue(playerOnly.none { visible.contains(it) })
}
@Test fun publicEntryCountIsStableAcrossSessions() {
val publicCount = APP_MENU.count { it.access == MenuAccess.PUBLIC }
assertEquals(publicCount, routes(Session.SignedOut).size)
}
@Test fun roleFromWireMapsKnownAndUnknown() {
assertEquals(Role.ADMIN, Role.fromWire("admin"))
assertEquals(Role.PLAYER, Role.fromWire("player"))
assertEquals(Role.UNKNOWN, Role.fromWire("superuser"))
assertEquals(Role.UNKNOWN, Role.fromWire(null))
}
@Test fun playerAccessGatedFunction() {
// A synthetic PLAYER-gated entry is visible to a player, hidden from staff/anon.
val entries = listOf(MenuEntry("game", 0, MenuAccess.PLAYER))
assertTrue(visibleEntries(entries, signedIn(Role.PLAYER)).isNotEmpty())
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isEmpty())
assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty())
}
}