From 1c56eda64bf5827c92873f0454f322fecf48543c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sun, 19 Jul 2026 19:08:00 -0500 Subject: [PATCH] =?UTF-8?q?feat(m3):=20native=20auth=20=E2=80=94=20login+T?= =?UTF-8?q?OTP,=20token=20storage,=20refresh,=20access-level=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr --- app/build.gradle.kts | 3 + .../app/core/auth/EncryptedTokenStore.kt | 73 +++++++ .../com/runicgateway/app/core/auth/Session.kt | 60 ++++++ .../app/core/auth/SessionManager.kt | 99 ++++++++++ .../runicgateway/app/core/auth/TokenStore.kt | 31 +++ .../app/core/net/AuthInterceptor.kt | 39 ++++ .../com/runicgateway/app/core/net/Http.kt | 19 ++ .../app/core/net/TokenAuthenticator.kt | 86 ++++++++ .../runicgateway/app/core/web/WebHandoff.kt | 32 +++ .../runicgateway/app/core/web/WebsiteUrls.kt | 37 ++++ .../com/runicgateway/app/data/api/AuthApi.kt | 40 ++++ .../app/data/api/AuthRefreshApi.kt | 27 +++ .../runicgateway/app/data/api/dto/AuthDto.kt | 67 +++++++ .../app/data/repository/AuthRepository.kt | 115 +++++++++++ .../data/repository/ConnectionRepository.kt | 8 +- .../com/runicgateway/app/di/NetworkModule.kt | 44 ++++- .../com/runicgateway/app/di/StorageModule.kt | 22 +++ .../java/com/runicgateway/app/ui/RunicApp.kt | 93 +++++++-- .../runicgateway/app/ui/auth/AccountScreen.kt | 89 +++++++++ .../runicgateway/app/ui/auth/LoginScreen.kt | 184 ++++++++++++++++++ .../app/ui/auth/LoginViewModel.kt | 101 ++++++++++ .../runicgateway/app/ui/navigation/Menu.kt | 59 ++++++ .../runicgateway/app/ui/navigation/Routes.kt | 4 + .../app/ui/session/SessionViewModel.kt | 39 ++++ app/src/main/res/values/strings.xml | 30 +++ .../app/core/auth/SessionManagerTest.kt | 101 ++++++++++ .../app/data/api/dto/AuthDtoTest.kt | 71 +++++++ .../app/ui/navigation/MenuAccessTest.kt | 67 +++++++ gradle/libs.versions.toml | 6 + 29 files changed, 1625 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/core/auth/EncryptedTokenStore.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/auth/Session.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/auth/SessionManager.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/auth/TokenStore.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/net/AuthInterceptor.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/net/Http.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/net/TokenAuthenticator.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/web/WebHandoff.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/web/WebsiteUrls.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/AuthRefreshApi.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/dto/AuthDto.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt create mode 100644 app/src/main/java/com/runicgateway/app/di/StorageModule.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt create mode 100644 app/src/test/java/com/runicgateway/app/core/auth/SessionManagerTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/dto/AuthDtoTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5579932..a7d40dd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,6 +93,9 @@ dependencies { implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.security.crypto) + // Web hand-off (Chrome Custom Tabs) for register / invite / reset / SSO (§4.2) + implementation(libs.androidx.browser) + // Images implementation(libs.coil.compose) diff --git a/app/src/main/java/com/runicgateway/app/core/auth/EncryptedTokenStore.kt b/app/src/main/java/com/runicgateway/app/core/auth/EncryptedTokenStore.kt new file mode 100644 index 0000000..f5a32a0 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/auth/EncryptedTokenStore.kt @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.auth + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * [TokenStore] backed by Jetpack Security's [EncryptedSharedPreferences] + * (Tink/AES-256-GCM), so the token pair is encrypted at rest (PLAN.md §2, §4.3). + * The base URL stays in plain DataStore ([com.runicgateway.app.core.prefs.ServerPreferences]); + * only tokens live here. + * + * The prefs handle is created lazily so a first-launch device (no session yet) + * pays the keystore cost only once a user actually signs in. + */ +@Singleton +class EncryptedTokenStore @Inject constructor( + @param:ApplicationContext private val context: Context, +) : TokenStore { + + private val prefs: SharedPreferences by lazy { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + override fun load(): StoredSession? { + val access = prefs.getString(KEY_ACCESS, null) ?: return null + val refresh = prefs.getString(KEY_REFRESH, null) ?: return null + val username = prefs.getString(KEY_USERNAME, null) ?: return null + val role = prefs.getString(KEY_ROLE, null) ?: return null + val id = prefs.getLong(KEY_USER_ID, -1L) + if (id < 0) return null + return StoredSession(access, refresh, id, username, role) + } + + override fun save(session: StoredSession) { + prefs.edit() + .putString(KEY_ACCESS, session.accessToken) + .putString(KEY_REFRESH, session.refreshToken) + .putLong(KEY_USER_ID, session.userId) + .putString(KEY_USERNAME, session.username) + .putString(KEY_ROLE, session.role) + .apply() + } + + override fun clear() { + prefs.edit().clear().apply() + } + + private companion object { + const val PREFS_NAME = "runic_session" + const val KEY_ACCESS = "access_token" + const val KEY_REFRESH = "refresh_token" + const val KEY_USER_ID = "user_id" + const val KEY_USERNAME = "username" + const val KEY_ROLE = "role" + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/auth/Session.kt b/app/src/main/java/com/runicgateway/app/core/auth/Session.kt new file mode 100644 index 0000000..a2f5f58 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/auth/Session.kt @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.auth + +import com.runicgateway.app.data.api.dto.SafeUserDto + +/** + * The signed-in identity the app carries (PLAN.md §4.3, §5). Role is *advisory* + * for menu rendering only — the backend re-checks every gated call, so the app + * treats a 403 as authoritative and never assumes access from this value. + */ +data class SessionUser( + val id: Long, + val username: String, + val role: Role, +) { + val isPlayer: Boolean get() = role == Role.PLAYER +} + +/** + * The account roles the backend issues. The three staff roles are gated by + * capability, not rank (PLAN.md §5); [UNKNOWN] absorbs any future role so an + * additive backend change never crashes the menu. + */ +enum class Role(val wire: String) { + PLAYER("player"), + MODERATOR("moderator"), + EDITOR("editor"), + ADMIN("admin"), + UNKNOWN(""); + + val isStaff: Boolean get() = this == MODERATOR || this == EDITOR || this == ADMIN + + companion object { + fun fromWire(value: String?): Role = + entries.firstOrNull { it.wire.equals(value, ignoreCase = true) } ?: UNKNOWN + } +} + +/** The two auth states the UI observes. */ +sealed interface Session { + data object SignedOut : Session + data class SignedIn(val user: SessionUser) : Session +} + +internal fun SafeUserDto.toSessionUser(): SessionUser = + SessionUser(id = id, username = username, role = Role.fromWire(role)) + +internal fun SafeUserDto.toStored(accessToken: String, refreshToken: String): StoredSession = + StoredSession( + accessToken = accessToken, + refreshToken = refreshToken, + userId = id, + username = username, + role = role, + ) + +internal fun StoredSession.toSessionUser(): SessionUser = + SessionUser(id = userId, username = username, role = Role.fromWire(role)) diff --git a/app/src/main/java/com/runicgateway/app/core/auth/SessionManager.kt b/app/src/main/java/com/runicgateway/app/core/auth/SessionManager.kt new file mode 100644 index 0000000..fcf4b66 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/auth/SessionManager.kt @@ -0,0 +1,99 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.auth + +import com.runicgateway.app.data.api.dto.SafeUserDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The single source of truth for the current session (PLAN.md §4.3). It holds the + * in-memory token pair the network layer reads on every call, mirrors the + * signed-in identity into an observable [state] the UI + menu react to, and keeps + * the encrypted [TokenStore] in sync. + * + * Threading: [state] and the token holders are read from the UI thread and + * written from both coroutines (login/logout) and the OkHttp + * [com.runicgateway.app.core.net.TokenAuthenticator] dispatcher thread (silent + * refresh), so tokens live in [AtomicReference]s and the mutators are + * `@Synchronized` to keep the token pair and [state] consistent with each other. + */ +@Singleton +class SessionManager @Inject constructor( + private val store: TokenStore, +) { + private val accessRef = AtomicReference(null) + private val refreshRef = AtomicReference(null) + + private val _state: MutableStateFlow + val state: StateFlow + + init { + val restored = store.load() + if (restored != null) { + accessRef.set(restored.accessToken) + refreshRef.set(restored.refreshToken) + _state = MutableStateFlow(Session.SignedIn(restored.toSessionUser())) + } else { + _state = MutableStateFlow(Session.SignedOut) + } + state = _state.asStateFlow() + } + + /** The bearer for the current request, or null when signed out. */ + fun currentAccessToken(): String? = accessRef.get() + + /** The refresh token the authenticator rotates, or null when signed out. */ + fun currentRefreshToken(): String? = refreshRef.get() + + val isSignedIn: Boolean get() = _state.value is Session.SignedIn + + /** Establish a session from a successful login (§4.1). */ + @Synchronized + fun onSignedIn(accessToken: String, refreshToken: String, user: SafeUserDto) { + accessRef.set(accessToken) + refreshRef.set(refreshToken) + store.save(user.toStored(accessToken, refreshToken)) + _state.value = Session.SignedIn(user.toSessionUser()) + } + + /** + * Store a rotated token pair after a silent refresh (§4.3). Keeps the current + * user; if somehow signed out already, it is a no-op (the refresh raced a + * logout and must not resurrect the session). + */ + @Synchronized + fun onRefreshed(accessToken: String, refreshToken: String, user: SafeUserDto) { + if (_state.value !is Session.SignedIn) return + accessRef.set(accessToken) + refreshRef.set(refreshToken) + store.save(user.toStored(accessToken, refreshToken)) + // Refresh may carry an updated role — reflect it so the menu stays honest. + _state.value = Session.SignedIn(user.toSessionUser()) + } + + /** Refresh the cached identity from a `/auth/me` re-validation (§4.3). */ + @Synchronized + fun onUserRefreshed(user: SafeUserDto) { + val current = _state.value + if (current !is Session.SignedIn) return + val access = accessRef.get() ?: return + val refresh = refreshRef.get() ?: return + store.save(user.toStored(access, refresh)) + _state.value = Session.SignedIn(user.toSessionUser()) + } + + /** Tear the session down — user logout, dead refresh, or a server switch (§3). */ + @Synchronized + fun onSignedOut() { + accessRef.set(null) + refreshRef.set(null) + store.clear() + _state.value = Session.SignedOut + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/auth/TokenStore.kt b/app/src/main/java/com/runicgateway/app/core/auth/TokenStore.kt new file mode 100644 index 0000000..020febe --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/auth/TokenStore.kt @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.auth + +/** + * The at-rest home for a signed-in session (PLAN.md §4.3): the access + refresh + * tokens plus the cached safe-user. Tokens are sensitive, so the production + * implementation stores them in EncryptedSharedPreferences — never plain + * DataStore or logs. Kept behind an interface so [SessionManager] is unit-testable + * against an in-memory fake. + */ +interface TokenStore { + /** The persisted session restored on launch, or null when signed out. */ + fun load(): StoredSession? + + /** Persist (overwrite) the current session atomically. */ + fun save(session: StoredSession) + + /** Wipe every stored token — sign-out and the Settings → Server hard reset (§3). */ + fun clear() +} + +/** A persisted session: the token pair and the non-sensitive user it belongs to. */ +data class StoredSession( + val accessToken: String, + val refreshToken: String, + val userId: Long, + val username: String, + val role: String, +) diff --git a/app/src/main/java/com/runicgateway/app/core/net/AuthInterceptor.kt b/app/src/main/java/com/runicgateway/app/core/net/AuthInterceptor.kt new file mode 100644 index 0000000..cff6650 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/net/AuthInterceptor.kt @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.net + +import com.runicgateway.app.core.auth.SessionManager +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Attaches the current bearer access token to outbound calls (PLAN.md §4.1). + * Public endpoints simply carry a token the backend ignores; the credential + * endpoints (login/refresh) tag themselves [Http.NO_SESSION_HEADER] and are left + * bare so a credential `401` is never mistaken for an expired session. A request + * that already set its own Authorization (the authenticator's retry) is untouched. + */ +@Singleton +class AuthInterceptor @Inject constructor( + private val sessionManager: SessionManager, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + if (request.header(Http.NO_SESSION_HEADER) != null) { + return chain.proceed(request) + } + if (request.header(Http.AUTHORIZATION) != null) { + return chain.proceed(request) + } + val token = sessionManager.currentAccessToken() + ?: return chain.proceed(request) + val authed = request.newBuilder() + .header(Http.AUTHORIZATION, Http.bearer(token)) + .build() + return chain.proceed(authed) + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/net/Http.kt b/app/src/main/java/com/runicgateway/app/core/net/Http.kt new file mode 100644 index 0000000..eddaad5 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/net/Http.kt @@ -0,0 +1,19 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.net + +/** Shared HTTP constants for the auth layer (PLAN.md §4). */ +object Http { + /** + * Marks the credential endpoints (login, refresh) that must run *without* a + * bearer and must never trigger the refresh-on-401 [TokenAuthenticator]. + * [AuthInterceptor] sees it and skips attaching a token; the authenticator + * sees it on the failed request and declines to refresh. It is a harmless + * unknown header to the backend. + */ + const val NO_SESSION_HEADER = "X-Runic-No-Session" + const val AUTHORIZATION = "Authorization" + + fun bearer(token: String): String = "Bearer $token" +} diff --git a/app/src/main/java/com/runicgateway/app/core/net/TokenAuthenticator.kt b/app/src/main/java/com/runicgateway/app/core/net/TokenAuthenticator.kt new file mode 100644 index 0000000..89a5ef0 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/net/TokenAuthenticator.kt @@ -0,0 +1,86 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.net + +import com.runicgateway.app.core.auth.SessionManager +import com.runicgateway.app.data.api.AuthRefreshApi +import com.runicgateway.app.data.api.dto.MobileRefreshRequest +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Transparently refreshes an expired access token on a bearer `401` and replays + * the request (PLAN.md §4.1, §4.3). Refresh tokens are single-use and rotated, so + * this is serialized behind a mutex: concurrent 401s trigger exactly one refresh + * and the losers reuse its result. A refresh that comes back `401` means the + * session is truly dead → sign out; a network error leaves the session intact so + * a later call can retry. + * + * The refresh call runs on [AuthRefreshApi] (its own bare client with no + * authenticator), so it can never recurse back into here. + */ +@Singleton +class TokenAuthenticator @Inject constructor( + private val sessionManager: SessionManager, + private val refreshApi: AuthRefreshApi, +) : Authenticator { + + private val lock = Any() + + override fun authenticate(route: Route?, response: Response): Request? { + val failed = response.request + // Credential endpoints (login/refresh) must never be "refreshed". + if (failed.header(Http.NO_SESSION_HEADER) != null) return null + // Give up after a single refresh+replay to avoid an auth loop. + if (priorResponseCount(response) >= 2) return null + + val attemptedAuth = failed.header(Http.AUTHORIZATION) + + synchronized(lock) { + // Another thread may have already refreshed while we waited on the lock. + val current = sessionManager.currentAccessToken() + if (current != null && Http.bearer(current) != attemptedAuth) { + return failed.retryWith(current) + } + + val refreshToken = sessionManager.currentRefreshToken() + ?: return null // already signed out + + val refreshed = try { + refreshApi.refresh(MobileRefreshRequest(refreshToken)).execute() + } catch (_: IOException) { + // Transient — surface the original 401 but keep the session. + return null + } + + val body = refreshed.body() + if (!refreshed.isSuccessful || body == null) { + // The refresh token is dead (401/expired/revoked) → session is over. + sessionManager.onSignedOut() + return null + } + + sessionManager.onRefreshed(body.accessToken, body.refreshToken, body.user) + return failed.retryWith(body.accessToken) + } + } + + private fun Request.retryWith(accessToken: String): Request = + newBuilder().header(Http.AUTHORIZATION, Http.bearer(accessToken)).build() + + private fun priorResponseCount(response: Response): Int { + var count = 1 + var prior = response.priorResponse + while (prior != null) { + count++ + prior = prior.priorResponse + } + return count + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/web/WebHandoff.kt b/app/src/main/java/com/runicgateway/app/core/web/WebHandoff.kt new file mode 100644 index 0000000..2f1f4fe --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/web/WebHandoff.kt @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.web + +import android.content.ActivityNotFoundException +import android.content.Context +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent + +/** + * Opens the website's own pages in a Chrome Custom Tab (PLAN.md §4.2): + * registration, invite acceptance, forgot/reset password, and SSO all stay + * website-handled, so the app hands off rather than rebuilding those flows. The + * user completes them in the browser and returns to sign in natively (§4.1). + */ +object WebHandoff { + + /** + * Launch [url] in a Custom Tab. Returns false if no browser could handle it + * (extremely rare on Android) so the caller can surface a fallback. + */ + fun open(context: Context, url: String): Boolean = try { + CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + .launchUrl(context, Uri.parse(url)) + true + } catch (_: ActivityNotFoundException) { + false + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/web/WebsiteUrls.kt b/app/src/main/java/com/runicgateway/app/core/web/WebsiteUrls.kt new file mode 100644 index 0000000..a4d3185 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/web/WebsiteUrls.kt @@ -0,0 +1,37 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.web + +import com.runicgateway.app.core.net.BaseUrlHolder +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Resolves the website's front-end page paths against the configured base URL, + * for the Custom-Tab hand-offs (PLAN.md §4.2). These are the React SPA routes + * (mirrored from `website/client` `App.jsx`), not API endpoints. Null before a + * shard site is configured. + */ +@Singleton +class WebsiteUrls @Inject constructor( + private val baseUrlHolder: BaseUrlHolder, +) { + private fun resolve(path: String): String? = + baseUrlHolder.current?.resolve(path)?.toString() + + /** Create an account on the website. */ + fun register(): String? = resolve(REGISTER) + + /** Forgot / reset password (the flow built on the backend before app work, §8). */ + fun forgotPassword(): String? = resolve(FORGOT) + + /** The website login page — carries the SSO provider buttons (§4.2). */ + fun login(): String? = resolve(LOGIN) + + private companion object { + const val REGISTER = "account/register" + const val FORGOT = "account/forgot" + const val LOGIN = "account/login" + } +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt b/app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt new file mode 100644 index 0000000..107319e --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api + +import com.runicgateway.app.data.api.dto.MeResponse +import com.runicgateway.app.data.api.dto.MobileLoginRequest +import com.runicgateway.app.data.api.dto.MobileLogoutRequest +import com.runicgateway.app.data.api.dto.MobileTokenResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.POST + +/** + * The native bearer-auth surface (PLAN.md §4.1). Login and logout run on the main + * OkHttp client; [com.runicgateway.app.core.net.AuthInterceptor] attaches the + * access token to logout + `/auth/me`, and [com.runicgateway.app.core.net.TokenAuthenticator] + * transparently refreshes on a `401`. + * + * Login is tagged [com.runicgateway.app.core.net.Http.NO_SESSION_HEADER] so it + * carries no bearer and a credential `401` (bad password / `totpRequired`) is not + * misread as an expired session. It returns a raw [Response] so the caller can + * inspect the status and parse the `{ totpRequired }` error body. + */ +interface AuthApi { + + // Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER. + @Headers("X-Runic-No-Session: 1") + @POST("api/v1/auth/mobile/login") + suspend fun login(@Body body: MobileLoginRequest): Response + + @POST("api/v1/auth/mobile/logout") + suspend fun logout(@Body body: MobileLogoutRequest): Response + + /** Current user — the app's authoritative role source, re-validated on resume (§4.3). */ + @GET("api/v1/auth/me") + suspend fun me(): MeResponse +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/AuthRefreshApi.kt b/app/src/main/java/com/runicgateway/app/data/api/AuthRefreshApi.kt new file mode 100644 index 0000000..0587f35 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/AuthRefreshApi.kt @@ -0,0 +1,27 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api + +import com.runicgateway.app.data.api.dto.MobileRefreshRequest +import com.runicgateway.app.data.api.dto.MobileTokenResponse +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.Headers +import retrofit2.http.POST + +/** + * The token-rotation endpoint, isolated onto its own **bare** OkHttp client + * (no auth interceptor, no authenticator) so refreshing can never recurse + * through the very [com.runicgateway.app.core.net.TokenAuthenticator] that calls + * it (PLAN.md §4.3). It is a blocking [Call] because the authenticator runs on an + * OkHttp dispatcher thread, outside any coroutine, and executes it synchronously. + * + * Tagged `NO_SESSION` so it carries no stale bearer. + */ +interface AuthRefreshApi { + + @Headers("X-Runic-No-Session: 1") + @POST("api/v1/auth/mobile/refresh") + fun refresh(@Body body: MobileRefreshRequest): Call +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/AuthDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/AuthDto.kt new file mode 100644 index 0000000..284a570 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/AuthDto.kt @@ -0,0 +1,67 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api.dto + +import kotlinx.serialization.Serializable + +/** + * The mobile bearer-auth wire shapes (PLAN.md §4.1). Field names match the + * backend's `auth/mobile` controller and `/auth/me` exactly; every DTO ignores + * unknown keys (NetworkModule's lenient Json), so additive backend fields are + * safe (§8, recorded for M1). + */ + +/** `POST /auth/mobile/login` body. [code] is only sent on the 2FA retry. */ +@Serializable +data class MobileLoginRequest( + val username: String, + val password: String, + val code: String? = null, +) + +/** `POST /auth/mobile/refresh` body. */ +@Serializable +data class MobileRefreshRequest(val refreshToken: String) + +/** `POST /auth/mobile/logout` body — revoke this session or (with [all]) every session. */ +@Serializable +data class MobileLogoutRequest( + val refreshToken: String? = null, + val all: Boolean? = null, +) + +/** + * Success payload from login and refresh: the token pair, the access lifetime + * (a zeit/ms duration string, e.g. "15m"), and the safe (secret-stripped) user. + */ +@Serializable +data class MobileTokenResponse( + val accessToken: String, + val refreshToken: String, + val expiresIn: String? = null, + val user: SafeUserDto, +) + +/** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */ +@Serializable +data class SafeUserDto( + val id: Long, + val username: String, + val role: String, +) + +/** `GET /auth/me` envelope — the role source, re-validated on resume (§4.3). */ +@Serializable +data class MeResponse(val user: SafeUserDto) + +/** + * The `401 { totpRequired: true }` body the single-request 2FA flow returns when + * an account has TOTP on and no/invalid code accompanied the login (§4.1). Parsed + * from the error body since it is not a 2xx response. + */ +@Serializable +data class TotpRequiredError( + val totpRequired: Boolean = false, + val message: String? = null, +) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt new file mode 100644 index 0000000..617d258 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt @@ -0,0 +1,115 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.repository + +import com.runicgateway.app.core.auth.SessionManager +import com.runicgateway.app.data.api.AuthApi +import com.runicgateway.app.data.api.dto.MobileLoginRequest +import com.runicgateway.app.data.api.dto.MobileLogoutRequest +import com.runicgateway.app.data.api.dto.MobileTokenResponse +import com.runicgateway.app.data.api.dto.TotpRequiredError +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.Json +import retrofit2.Response +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Native username/password (+TOTP) auth — the app's only native credential flow + * (PLAN.md §4.1). It drives the [SessionManager]: a successful login establishes + * the session; logout revokes it. Registration/invite/reset/SSO are website + * hand-offs (§4.2), not here. + */ +@Singleton +class AuthRepository @Inject constructor( + private val authApi: AuthApi, + private val sessionManager: SessionManager, + private val json: Json, +) { + + /** Outcome of a login attempt (§4.1). */ + sealed interface LoginResult { + data object Success : LoginResult + + /** The account has 2FA on — reveal the code field and resubmit with a code. */ + data object TotpRequired : LoginResult + data object InvalidCredentials : LoginResult + + /** Guarded by per-IP backoff → slowdown → hard cap; back off and retry. */ + data object RateLimited : LoginResult + + /** Any other server failure (5xx / unexpected). */ + data object ServerError : LoginResult + + /** No answer — offline, DNS, TLS, timeout. */ + data object NetworkError : LoginResult + } + + suspend fun login(username: String, password: String, code: String? = null): LoginResult { + val response: Response = try { + authApi.login(MobileLoginRequest(username = username, password = password, code = code)) + } catch (e: CancellationException) { + throw e + } catch (_: IOException) { + return LoginResult.NetworkError + } + + if (response.isSuccessful) { + val body = response.body() ?: return LoginResult.ServerError + sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user) + return LoginResult.Success + } + + return when (response.code()) { + 401 -> if (isTotpRequired(response)) LoginResult.TotpRequired else LoginResult.InvalidCredentials + 429 -> LoginResult.RateLimited + else -> LoginResult.ServerError + } + } + + /** + * Revoke this session (or, with [allDevices], every session) and clear local + * tokens (§4.3). Best-effort: the local session is torn down even if the + * network call fails, so the user is always signed out locally. + */ + suspend fun logout(allDevices: Boolean = false) { + val refreshToken = sessionManager.currentRefreshToken() + try { + authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices)) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Ignore — we still drop the local session below. + } + sessionManager.onSignedOut() + } + + /** + * Re-validate the session against `GET /auth/me` on app resume (§4.3). A + * success refreshes the cached role (roles change server-side); a `401` that + * survives the silent refresh means the session is dead → sign out. Transient + * failures are ignored so a flaky network doesn't bounce the user. + */ + suspend fun revalidate() { + if (!sessionManager.isSignedIn) return + try { + val me = authApi.me() + sessionManager.onUserRefreshed(me.user) + } catch (e: CancellationException) { + throw e + } catch (e: retrofit2.HttpException) { + if (e.code() == 401) sessionManager.onSignedOut() + } catch (_: IOException) { + // Offline — keep the session; the next authed call will re-check. + } + } + + private fun isTotpRequired(response: Response<*>): Boolean = try { + val raw = response.errorBody()?.string() + !raw.isNullOrBlank() && json.decodeFromString(raw).totpRequired + } catch (_: Exception) { + false + } +} diff --git a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt index 35caa91..61e719f 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt @@ -3,6 +3,7 @@ */ package com.runicgateway.app.data.repository +import com.runicgateway.app.core.auth.SessionManager import com.runicgateway.app.core.net.BaseUrlHolder import com.runicgateway.app.core.net.ServerUrl import com.runicgateway.app.core.prefs.ServerPreferences @@ -24,6 +25,7 @@ class ConnectionRepository @Inject constructor( private val api: PublicApi, private val prefs: ServerPreferences, private val baseUrlHolder: BaseUrlHolder, + private val sessionManager: SessionManager, private val config: com.runicgateway.app.core.AppConfig, ) { @@ -81,10 +83,12 @@ class ConnectionRepository @Inject constructor( } /** - * Hard reset for a Settings → Server switch (§3): clear the saved URL and - * deactivate it. Token/cache clearing joins here in M3 once sessions exist. + * Hard reset for a Settings → Server switch (§3): sign out (clear stored + * tokens), clear the saved URL, and deactivate it — the app returns to a + * signed-out state against the new host. */ suspend fun disconnect() { + sessionManager.onSignedOut() prefs.clear() baseUrlHolder.set(null) } diff --git a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt index 54fd838..a8e70ef 100644 --- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -6,9 +6,13 @@ package com.runicgateway.app.di import android.os.Build import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.runicgateway.app.BuildConfig +import com.runicgateway.app.core.net.AuthInterceptor import com.runicgateway.app.core.net.BaseUrlHolder import com.runicgateway.app.core.net.HostSelectionInterceptor +import com.runicgateway.app.core.net.TokenAuthenticator import com.runicgateway.app.core.net.UserAgentInterceptor +import com.runicgateway.app.data.api.AuthApi +import com.runicgateway.app.data.api.AuthRefreshApi import com.runicgateway.app.data.api.PublicApi import dagger.Module import dagger.Provides @@ -42,16 +46,25 @@ object NetworkModule { return UserAgentInterceptor(ua) } + /** + * The main client every API and the SSE stream ride on. Order: identify (UA), + * retarget onto the configured shard host, then attach the bearer; the + * [TokenAuthenticator] handles silent refresh-on-401 (§4.1). Logging sits last + * so it observes the final, authed request. + */ @Provides @Singleton fun provideOkHttpClient( hostSelectionInterceptor: HostSelectionInterceptor, userAgentInterceptor: UserAgentInterceptor, + authInterceptor: AuthInterceptor, + tokenAuthenticator: TokenAuthenticator, ): OkHttpClient { val builder = OkHttpClient.Builder() - // User-Agent first, then host retargeting, so both apply to every call. .addInterceptor(userAgentInterceptor) .addInterceptor(hostSelectionInterceptor) + .addInterceptor(authInterceptor) + .authenticator(tokenAuthenticator) if (BuildConfig.DEBUG) { builder.addInterceptor( @@ -75,4 +88,33 @@ object NetworkModule { @Provides @Singleton fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java) + + @Provides + @Singleton + fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java) + + /** + * Token refresh runs on its own **bare** client — UA + host retargeting only, + * no auth interceptor and no authenticator — so a refresh can never recurse + * back through [TokenAuthenticator] (§4.3). This throwaway client/Retrofit is + * not exposed as a bean, so there is no ambiguous [OkHttpClient] binding. + */ + @Provides + @Singleton + fun provideAuthRefreshApi( + hostSelectionInterceptor: HostSelectionInterceptor, + userAgentInterceptor: UserAgentInterceptor, + json: Json, + ): AuthRefreshApi { + val bareClient = OkHttpClient.Builder() + .addInterceptor(userAgentInterceptor) + .addInterceptor(hostSelectionInterceptor) + .build() + val retrofit = Retrofit.Builder() + .baseUrl(BaseUrlHolder.PLACEHOLDER_BASE_URL) + .client(bareClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + return retrofit.create(AuthRefreshApi::class.java) + } } diff --git a/app/src/main/java/com/runicgateway/app/di/StorageModule.kt b/app/src/main/java/com/runicgateway/app/di/StorageModule.kt new file mode 100644 index 0000000..05cc4ff --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/di/StorageModule.kt @@ -0,0 +1,22 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.di + +import com.runicgateway.app.core.auth.EncryptedTokenStore +import com.runicgateway.app.core.auth.TokenStore +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** Binds the at-rest token store to its EncryptedSharedPreferences impl (§4.3). */ +@Module +@InstallIn(SingletonComponent::class) +abstract class StorageModule { + + @Binds + @Singleton + abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore +} diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index a61079b..9d99f39 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -4,7 +4,6 @@ package com.runicgateway.app.ui import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -15,6 +14,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem @@ -24,11 +24,15 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -37,13 +41,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.runicgateway.app.R +import com.runicgateway.app.core.auth.Session import com.runicgateway.app.data.api.dto.BrandDto +import com.runicgateway.app.ui.auth.AccountScreen +import com.runicgateway.app.ui.auth.LoginScreen +import com.runicgateway.app.ui.auth.roleLabelRes import com.runicgateway.app.ui.contact.ContactScreen import com.runicgateway.app.ui.home.HomeScreen +import com.runicgateway.app.ui.navigation.APP_MENU import com.runicgateway.app.ui.navigation.Routes +import com.runicgateway.app.ui.navigation.visibleEntries import com.runicgateway.app.ui.news.NewsScreen import com.runicgateway.app.ui.news.PostScreen import com.runicgateway.app.ui.page.PageScreen +import com.runicgateway.app.ui.session.SessionViewModel import com.runicgateway.app.ui.shard.ChampsScreen import com.runicgateway.app.ui.shard.GovernorsScreen import com.runicgateway.app.ui.shard.GuildsScreen @@ -54,27 +65,17 @@ import com.runicgateway.app.ui.wiki.WikiPageScreen import com.runicgateway.app.ui.wiki.WikiScreen import kotlinx.coroutines.launch -/** A navigation menu entry (PLAN.md §5). For M1 every entry is public. */ -private data class MenuEntry(val route: String, val labelRes: Int) - -private val PUBLIC_MENU = listOf( - MenuEntry(Routes.HOME, R.string.menu_home), - MenuEntry(Routes.NEWS, R.string.menu_news), - MenuEntry(Routes.WIKI, R.string.menu_wiki), - MenuEntry(Routes.SHARD, R.string.menu_shard), - MenuEntry(Routes.page("about"), R.string.menu_about), - MenuEntry(Routes.CONTACT, R.string.menu_contact), -) - /** Destinations that show the drawer (hamburger); others show a back arrow. */ private val TOP_LEVEL_ROUTES = setOf( - Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, + Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT, ) /** * The main app shell once a shard site is configured (PLAN.md §5): one shared, - * declarative navigation drawer over the public content graph, plus the - * Settings → Server switch. The signed-in menu groups and auth toggle join in M3. + * declarative, access-level navigation drawer whose entries are filtered by the + * current session, plus the Sign in / Sign out toggle and the Settings → Server + * switch. The signed-in role is re-validated against the backend on every resume + * (§4.3), so a server-side demotion drops menu access promptly. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -82,14 +83,24 @@ fun RunicApp( brand: BrandDto?, onChangeServer: () -> Unit, modifier: Modifier = Modifier, + sessionViewModel: SessionViewModel = hiltViewModel(), ) { val navController = rememberNavController() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() + val session by sessionViewModel.session.collectAsStateWithLifecycle() + + // Re-validate the cached role each time the app returns to the foreground (§4.3). + LifecycleResumeEffect(Unit) { + sessionViewModel.revalidate() + onPauseOrDispose { } + } + val backStackEntry by navController.currentBackStackEntryAsState() val currentRoute = backStackEntry?.destination?.route val isTopLevel = currentRoute in TOP_LEVEL_ROUTES + val entries = visibleEntries(APP_MENU, session) ModalNavigationDrawer( drawerState = drawerState, @@ -99,12 +110,12 @@ fun RunicApp( Spacer(Modifier.height(12.dp)) Text( text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name), - style = androidx.compose.material3.MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), ) HorizontalDivider() Spacer(Modifier.height(8.dp)) - PUBLIC_MENU.forEach { entry -> + entries.forEach { entry -> NavigationDrawerItem( label = { Text(stringResource(entry.labelRes)) }, selected = currentRoute == entry.route, @@ -115,7 +126,29 @@ fun RunicApp( modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), ) } + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + // Sign in / Sign out toggles on the session (§5). + val signInLabel = if (session is Session.SignedIn) { + R.string.menu_sign_out + } else { + R.string.menu_sign_in + } + NavigationDrawerItem( + label = { Text(stringResource(signInLabel)) }, + selected = false, + onClick = { + scope.launch { drawerState.close() } + if (session is Session.SignedIn) { + sessionViewModel.signOut() + navController.navigateTopLevel(Routes.HOME) + } else { + navController.navigate(Routes.LOGIN) + } + }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), + ) NavigationDrawerItem( label = { Text(stringResource(R.string.menu_change_server)) }, selected = false, @@ -158,6 +191,9 @@ fun RunicApp( RunicNavHost( navController = navController, brand = brand, + session = session, + onSignOut = { sessionViewModel.signOut() }, + onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) }, modifier = Modifier.padding(innerPadding), ) } @@ -168,6 +204,9 @@ fun RunicApp( private fun RunicNavHost( navController: NavHostController, brand: BrandDto?, + session: Session, + onSignOut: () -> Unit, + onSignOutEverywhere: () -> Unit, modifier: Modifier = Modifier, ) { NavHost( @@ -226,6 +265,24 @@ private fun RunicNavHost( composable(Routes.CONTACT) { ContactScreen() } + composable(Routes.LOGIN) { + LoginScreen(onSignedIn = { navController.popBackStack() }) + } + composable(Routes.ACCOUNT) { + // Only meaningful while signed in; a sign-out (here or from the drawer) + // sends the user home rather than leaving a stale identity on screen. + when (val s = session) { + is Session.SignedIn -> AccountScreen( + username = s.user.username, + roleLabel = stringResource(roleLabelRes(s.user.role)), + onSignOut = onSignOut, + onSignOutEverywhere = onSignOutEverywhere, + ) + Session.SignedOut -> LaunchedEffect(Unit) { + navController.navigateTopLevel(Routes.HOME) + } + } + } } } diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt new file mode 100644 index 0000000..b15c496 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt @@ -0,0 +1,89 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.annotation.StringRes +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.runicgateway.app.R +import com.runicgateway.app.core.auth.Role + +/** + * The signed-in account surface (PLAN.md §5, "My Account"). For the M3 functional + * pass it shows the identity + role and the sign-out controls; full self-service + * (change username/password, TOTP, linked identities via the `/auth/me` surface) + * lands in M4 (§6.3). + */ +@Composable +fun AccountScreen( + username: String, + roleLabel: String, + onSignOut: () -> Unit, + onSignOutEverywhere: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(20.dp)) { + Text( + text = username, + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = roleLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + + OutlinedButton( + onClick = onSignOut, + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp), + ) { + Text(stringResource(R.string.account_sign_out)) + } + + TextButton( + onClick = onSignOutEverywhere, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + ) { + Text(stringResource(R.string.account_sign_out_all)) + } + } +} + +/** Human label for a role (advisory display only — §4.3). */ +@StringRes +fun roleLabelRes(role: Role): Int = when (role) { + Role.PLAYER -> R.string.role_player + Role.MODERATOR -> R.string.role_moderator + Role.EDITOR -> R.string.role_editor + Role.ADMIN -> R.string.role_admin + Role.UNKNOWN -> R.string.role_unknown +} diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..27ed383 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt @@ -0,0 +1,184 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.runicgateway.app.R +import com.runicgateway.app.core.web.WebHandoff +import com.runicgateway.app.ui.auth.LoginViewModel.LoginError + +/** + * Native username/password (+TOTP) login (PLAN.md §4.1) — the app's only native + * credential screen. Registration, forgot-password, and SSO are website hand-offs + * opened in a Custom Tab (§4.2); the user completes them in the browser and + * returns here to sign in. + */ +@Composable +fun LoginScreen( + onSignedIn: () -> Unit, + modifier: Modifier = Modifier, + viewModel: LoginViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(state.signedIn) { + if (state.signedIn) onSignedIn() + } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.login_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.login_subtitle), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp, bottom = 24.dp), + ) + + OutlinedTextField( + value = state.username, + onValueChange = viewModel::onUsernameChange, + singleLine = true, + enabled = !state.submitting, + label = { Text(stringResource(R.string.login_username)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = state.password, + onValueChange = viewModel::onPasswordChange, + singleLine = true, + enabled = !state.submitting, + label = { Text(stringResource(R.string.login_password)) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = if (state.totpRequired) ImeAction.Next else ImeAction.Go, + ), + keyboardActions = KeyboardActions(onGo = { viewModel.submit() }), + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) + + if (state.totpRequired) { + OutlinedTextField( + value = state.code, + onValueChange = viewModel::onCodeChange, + singleLine = true, + enabled = !state.submitting, + label = { Text(stringResource(R.string.login_totp_code)) }, + supportingText = { Text(stringResource(R.string.login_totp_hint)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Go, + ), + keyboardActions = KeyboardActions(onGo = { viewModel.submit() }), + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) + } + + state.error?.let { err -> + Text( + text = stringResource(loginErrorRes(err)), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) + } + + Button( + onClick = viewModel::submit, + enabled = !state.submitting, + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp), + ) { + if (state.submitting) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text(stringResource(R.string.login_button)) + } + } + + // ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ── + viewModel.registerUrl?.let { url -> + TextButton( + onClick = { WebHandoff.open(context, url) }, + modifier = Modifier.padding(top = 16.dp), + ) { Text(stringResource(R.string.login_register)) } + } + viewModel.forgotPasswordUrl?.let { url -> + TextButton(onClick = { WebHandoff.open(context, url) }) { + Text(stringResource(R.string.login_forgot)) + } + } + viewModel.ssoLoginUrl?.let { url -> + TextButton(onClick = { WebHandoff.open(context, url) }) { + Text(stringResource(R.string.login_sso)) + } + } + } +} + +private fun loginErrorRes(error: LoginError): Int = when (error) { + LoginError.INVALID_CREDENTIALS -> R.string.login_error_credentials + LoginError.BAD_CODE -> R.string.login_error_code + LoginError.RATE_LIMITED -> R.string.login_error_rate_limited + LoginError.SERVER -> R.string.login_error_server + LoginError.NETWORK -> R.string.login_error_network +} diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt new file mode 100644 index 0000000..82b7c97 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt @@ -0,0 +1,101 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.core.web.WebsiteUrls +import com.runicgateway.app.data.repository.AuthRepository +import com.runicgateway.app.data.repository.AuthRepository.LoginResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Drives the native login screen (PLAN.md §4.1): username/password, single-request + * TOTP (the code field is revealed once the backend answers `totpRequired`), and + * friendly 429 backoff handling. Registration / forgot-password / SSO are website + * hand-offs whose URLs it exposes (§4.2). + */ +@HiltViewModel +class LoginViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val websiteUrls: WebsiteUrls, +) : ViewModel() { + + /** The transient error surfaced under the form after a failed attempt. */ + enum class LoginError { INVALID_CREDENTIALS, BAD_CODE, RATE_LIMITED, SERVER, NETWORK } + + data class UiState( + val username: String = "", + val password: String = "", + val code: String = "", + /** True once the account is known to have 2FA on — reveal the code field. */ + val totpRequired: Boolean = false, + val submitting: Boolean = false, + val error: LoginError? = null, + val signedIn: Boolean = false, + ) + + private val _state = MutableStateFlow(UiState()) + val state: StateFlow = _state.asStateFlow() + + fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) } + fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) } + fun onCodeChange(value: String) = + _state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) } + + val registerUrl: String? get() = websiteUrls.register() + val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword() + val ssoLoginUrl: String? get() = websiteUrls.login() + + fun submit() { + val s = _state.value + if (s.submitting) return + if (s.username.isBlank() || s.password.isBlank()) { + _state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) } + return + } + // If 2FA is being requested, a code must accompany the resubmit. + if (s.totpRequired && s.code.isBlank()) { + _state.update { it.copy(error = LoginError.BAD_CODE) } + return + } + + _state.update { it.copy(submitting = true, error = null) } + viewModelScope.launch { + val code = s.code.trim().takeIf { it.isNotBlank() } + when (authRepository.login(s.username.trim(), s.password, code)) { + LoginResult.Success -> + _state.update { it.copy(submitting = false, signedIn = true) } + + LoginResult.TotpRequired -> + // Reveal the code field; a wrong code re-lands here as BAD_CODE. + _state.update { + it.copy( + submitting = false, + totpRequired = true, + error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null, + ) + } + + LoginResult.InvalidCredentials -> + _state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) } + + LoginResult.RateLimited -> + _state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) } + + LoginResult.ServerError -> + _state.update { it.copy(submitting = false, error = LoginError.SERVER) } + + LoginResult.NetworkError -> + _state.update { it.copy(submitting = false, error = LoginError.NETWORK) } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt new file mode 100644 index 0000000..cec691a --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -0,0 +1,59 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.navigation + +import androidx.annotation.StringRes +import com.runicgateway.app.R +import com.runicgateway.app.core.auth.Session + +/** + * One shared, declarative, access-level navigation definition (PLAN.md §5): a + * single list where each entry declares the minimum access it needs, filtered by + * the current session — not a pile of `if role ==` checks. The server stays the + * source of truth; a hidden item is a UX convenience and every gated call still + * enforces on the backend. + */ +enum class MenuAccess { + /** Visible to everyone, signed in or not. */ + PUBLIC, + + /** Visible to any signed-in account (§5, "My Account"). */ + SIGNED_IN, + + /** Visible only to a player (linked game data lands in M4, §6.3). */ + PLAYER, +} + +data class MenuEntry( + val route: String, + @param:StringRes val labelRes: Int, + val access: MenuAccess = MenuAccess.PUBLIC, +) + +/** + * The full menu, in display order. Public content first, then the signed-in + * surfaces. Player game-data groups (My Characters / Vendors / Houses) join in M4. + */ +val APP_MENU: List = listOf( + MenuEntry(Routes.HOME, R.string.menu_home), + MenuEntry(Routes.NEWS, R.string.menu_news), + MenuEntry(Routes.WIKI, R.string.menu_wiki), + MenuEntry(Routes.SHARD, R.string.menu_shard), + MenuEntry(Routes.page("about"), R.string.menu_about), + MenuEntry(Routes.CONTACT, R.string.menu_contact), + MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN), +) + +/** + * The entries the given [session] may see. Pure + side-effect-free so the access + * gating is unit-tested without Compose. + */ +fun visibleEntries(entries: List, session: Session): List = + entries.filter { entry -> + when (entry.access) { + MenuAccess.PUBLIC -> true + MenuAccess.SIGNED_IN -> session is Session.SignedIn + MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer + } + } diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index e0969c3..de3eb2b 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -14,6 +14,10 @@ object Routes { const val WIKI = "wiki" const val CONTACT = "contact" + /** Native login (§4.1) and the signed-in account surface (§5). */ + const val LOGIN = "login" + const val ACCOUNT = "account" + /** Public shard hub (§6.2). */ const val SHARD = "shard" diff --git a/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt new file mode 100644 index 0000000..e855656 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.session + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.core.auth.SessionManager +import com.runicgateway.app.data.repository.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Activity-scoped view of the current session for the app shell (PLAN.md §4.3, + * §5): the shared menu observes [session] to reveal signed-in groups + the + * sign-in/out toggle, and the shell drives resume re-validation and sign-out. + * Login itself lives in [com.runicgateway.app.ui.auth.LoginViewModel]. + */ +@HiltViewModel +class SessionViewModel @Inject constructor( + sessionManager: SessionManager, + private val authRepository: AuthRepository, +) : ViewModel() { + + val session: StateFlow = sessionManager.state + + /** Re-validate the cached role against the backend on app resume. */ + fun revalidate() { + viewModelScope.launch { authRepository.revalidate() } + } + + /** Sign out of this session, or every session with [allDevices]. */ + fun signOut(allDevices: Boolean = false) { + viewModelScope.launch { authRepository.logout(allDevices) } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 25b6055..f53d9d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,8 +41,38 @@ Shard About Contact + My account + Sign in + Sign out Change server + + Sign in + Sign in with your shard account. + Username + Password + Authentication code + Enter the 6-digit code from your authenticator app. + Sign in + Create an account + Forgot your password? + Sign in with Google or Discord (on the website) + Incorrect username or password. + That code didn\'t match. Try the current code. + Too many attempts. Please try again shortly. + Something went wrong. Please try again. + Can\'t reach the site. Check your connection and try again. + + + My account + Sign out + Sign out on all devices + Player + Moderator + Editor + Administrator + Signed in + Online Under maintenance diff --git a/app/src/test/java/com/runicgateway/app/core/auth/SessionManagerTest.kt b/app/src/test/java/com/runicgateway/app/core/auth/SessionManagerTest.kt new file mode 100644 index 0000000..e04eb0f --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/auth/SessionManagerTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/AuthDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/AuthDtoTest.kt new file mode 100644 index 0000000..a1b153b --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/AuthDtoTest.kt @@ -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( + """{"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( + """{"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( + """{"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( + """{"message":"Incorrect username or password."}""", + ) + assertFalse(dto.totpRequired) + } + + @Test fun tokenResponseExpiresInOptional() { + val dto = json.decodeFromString( + """{"accessToken":"a","refreshToken":"r","user":{"id":2,"username":"bob","role":"admin"}}""", + ) + assertNull(dto.expiresIn) + assertEquals("admin", dto.user.role) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt new file mode 100644 index 0000000..f7f08ba --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt @@ -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()) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 072f917..710e05e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,9 @@ retrofitSerializationConverter = "1.0.0" datastore = "1.1.1" securityCrypto = "1.1.0-alpha06" +# Web hand-off (Chrome Custom Tabs) — §4.2 +browser = "1.8.0" + # Images coil = "2.7.0" @@ -79,6 +82,9 @@ retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } +# Web hand-off +androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } + # Images coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } -- 2.49.1