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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user