@
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m22s

feat(auth): trusted devices & recovery codes on the mobile client

Consumes the merged backend trusted-device + MFA feature
(RunicGateway/website#93, docs#32) per docs/android/PLAN.md §4.1.1.

Login (POST /auth/mobile/login):
- "Trust this device" checkbox and a "use a recovery code instead"
  toggle on the 401 { totpRequired } step; sends trustDevice /
  recoveryCode / device_name and replays a stored X-Trust-Token.
- A returned trustToken is stored in a dedicated, username-scoped
  EncryptedSharedPreferences file (runic_trust, AES-256-GCM), separate
  from the session store so it deliberately SURVIVES logout — the token
  is only consulted at a fresh login, so clearing it there would make
  the feature a no-op. Cleared only on a Settings→Server switch,
  untrust-all, or server-side revocation. (Supersedes the handoff note
  that said clear-on-logout; matches the canonical rg_trust design.)

Account → Security:
- Trusted Devices screen: list / revoke one / untrust all / trust this
  device (persists the returned token).
- Recovery Codes screen: remaining count + password-stepped regenerate
  with a show-once copy/share display; the one-time batch from enabling
  2FA is also surfaced on the account screen.

Login-time trust cap (trustLimitReached) is surfaced + resolved on the
Trusted Devices screen rather than a blocking login modal, since the
native login has already issued the session.

Tests: DTO decode for all new wire shapes + AccountRepository logic
(the 409 cap-body parse, revoke, recovery). 154 unit tests pass;
assembleDebug clean.

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-22 00:41:56 -05:00
parent befbc01670
commit a1fa4901ef
25 changed files with 1329 additions and 33 deletions

View File

@@ -54,6 +54,66 @@ class AccountDtoTest {
assertTrue(json.decodeFromString<TotpStateDto>("""{"totp_enabled":true}""").totp_enabled)
}
@Test fun totpEnableCarriesOneTimeRecoveryCodes() {
// Enabling 2FA now returns the fresh single-use batch once (TRUSTED_DEVICES_MFA.md).
val dto = json.decodeFromString<TotpStateDto>(
"""{"totp_enabled":true,"recoveryCodes":["aaaa-1111","bbbb-2222"]}""",
)
assertTrue(dto.totp_enabled)
assertEquals(listOf("aaaa-1111", "bbbb-2222"), dto.recoveryCodes)
}
@Test fun totpStateDisableHasNoRecoveryCodes() {
// Disable (and older backends) omit the field — must decode to null, not crash.
val dto = json.decodeFromString<TotpStateDto>("""{"totp_enabled":false}""")
assertFalse(dto.totp_enabled)
assertEquals(null, dto.recoveryCodes)
}
@Test fun trustedDeviceDecodes() {
val dto = json.decodeFromString<TrustedDeviceDto>(
"""{"id":5,"platform":"mobile","deviceName":"Pixel 8","userAgent":"RunicGatewayApp/1.0",
"createdAt":"2026-07-20T10:00:00Z","lastUsedAt":"2026-07-22T09:00:00Z",
"expiresAt":"2026-08-19T10:00:00Z"}""",
)
assertEquals(5L, dto.id)
assertEquals("mobile", dto.platform)
assertEquals("Pixel 8", dto.deviceName)
assertEquals("2026-07-22T09:00:00Z", dto.lastUsedAt)
}
@Test fun trustDeviceResultCarriesNativeToken() {
val dto = json.decodeFromString<TrustDeviceResultDto>(
"""{"trusted":true,"trustToken":"opaque-token-abc"}""",
)
assertTrue(dto.trusted)
assertEquals("opaque-token-abc", dto.trustToken)
}
@Test fun trustedDeviceLimitDecodesDevices() {
val dto = json.decodeFromString<TrustedDeviceLimitDto>(
"""{"error":"trusted_device_limit","devices":[
{"id":1,"platform":"web","deviceName":"Firefox"},
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}""",
)
assertEquals("trusted_device_limit", dto.error)
assertEquals(2, dto.devices.size)
assertEquals(2L, dto.devices[1].id)
}
@Test fun recoveryStatusAndCodesDecode() {
assertEquals(7, json.decodeFromString<RecoveryStatusDto>("""{"remaining":7}""").remaining)
val codes = json.decodeFromString<RecoveryCodesDto>(
"""{"recoveryCodes":["c1","c2","c3"]}""",
)
assertEquals(3, codes.recoveryCodes.size)
}
@Test fun revokedResultsDecode() {
assertTrue(json.decodeFromString<RevokedFlagDto>("""{"revoked":true}""").revoked)
assertEquals(4, json.decodeFromString<RevokedCountDto>("""{"revoked":4}""").revoked)
}
@Test fun linkedIdentityDecodes() {
val dto = json.decodeFromString<LinkedIdentityDto>(
"""{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""",

View File

@@ -68,4 +68,35 @@ class AuthDtoTest {
assertNull(dto.expiresIn)
assertEquals("admin", dto.user.role)
}
@Test fun loginCarriesTrustTokenWhenDeviceTrusted() {
// trustDevice accepted → an opaque token to persist + replay (TRUSTED_DEVICES_MFA.md).
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
"trustToken":"opaque-abc"}""",
)
assertEquals("opaque-abc", dto.trustToken)
assertFalse(dto.trustLimitReached)
}
@Test fun loginSignalsTrustLimitWithDevices() {
// At the cap: login still succeeds, but no token; the device list is returned.
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
"trustLimitReached":true,"devices":[{"id":1,"platform":"mobile","deviceName":"Old"}]}""",
)
assertNull(dto.trustToken)
assertTrue(dto.trustLimitReached)
assertEquals(1, dto.devices.size)
}
@Test fun loginWithoutTrustFieldsDefaultsCleanly() {
// A normal (no-trust) login omits every trust field — must not crash or mis-flag.
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":4,"username":"d","role":"player"}}""",
)
assertNull(dto.trustToken)
assertFalse(dto.trustLimitReached)
assertTrue(dto.devices.isEmpty())
}
}

View File

@@ -0,0 +1,120 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
import com.runicgateway.app.data.api.dto.RevokedCountDto
import com.runicgateway.app.data.api.dto.RevokedFlagDto
import com.runicgateway.app.data.api.dto.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.api.dto.UsernameResponse
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.Response
/**
* [AccountRepository] trusted-device + recovery logic (TRUSTED_DEVICES_MFA.md) over a
* fake [MeApi]. The interesting case is the `409` cap: the device list must survive
* into a typed [TrustOutcome.LimitReached] rather than being lost as a bare error.
*/
class AccountTrustedDevicesTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
/** A fake MeApi; only the trusted-device/recovery methods under test are wired. */
private open class FakeMeApi(
var trustResponse: Response<TrustDeviceResultDto>? = null,
var devices: List<TrustedDeviceDto> = emptyList(),
var revokeFlag: Boolean = true,
var revokeCount: Int = 0,
var remaining: Int = 0,
var generated: List<String> = emptyList(),
) : MeApi {
override suspend fun trustedDevices(): List<TrustedDeviceDto> = devices
override suspend fun trustThisDevice(body: TrustDeviceRequest): Response<TrustDeviceResultDto> =
trustResponse ?: Response.success(TrustDeviceResultDto(trusted = true, trustToken = "t"))
override suspend fun revokeTrustedDevice(id: Long): RevokedFlagDto = RevokedFlagDto(revokeFlag)
override suspend fun revokeAllTrustedDevices(): RevokedCountDto = RevokedCountDto(revokeCount)
override suspend fun recoveryCodesStatus(): RecoveryStatusDto = RecoveryStatusDto(remaining)
override suspend fun generateRecoveryCodes(body: RecoveryGenerateRequest): RecoveryCodesDto =
RecoveryCodesDto(generated)
// Unused by these tests.
override suspend fun getAccount(): PlayerAccountDto = PlayerAccountDto()
override suspend fun changeUsername(body: ChangeUsernameRequest): UsernameResponse = UsernameResponse()
override suspend fun changePassword(body: ChangePasswordRequest) = Unit
override suspend fun totpSetup(): TotpSetupDto = TotpSetupDto()
override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
override suspend fun unlinkIdentity(provider: String) = Unit
}
private fun repo(api: MeApi) = AccountRepository(api, json)
@Test fun trustThisDeviceReturnsToken() = runTest {
val api = FakeMeApi(trustResponse = Response.success(TrustDeviceResultDto(true, "opaque-xyz")))
val outcome = repo(api).trustThisDevice("Pixel")
assertTrue(outcome is TrustOutcome.Trusted)
assertEquals("opaque-xyz", (outcome as TrustOutcome.Trusted).trustToken)
}
@Test fun trustThisDeviceParsesCapDevicesFrom409() = runTest {
val body = """{"error":"trusted_device_limit","devices":[
{"id":1,"platform":"web","deviceName":"Firefox"},
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}"""
.toResponseBody("application/json".toMediaTypeOrNull())
val api = FakeMeApi(trustResponse = Response.error(409, body))
val outcome = repo(api).trustThisDevice(null)
assertTrue(outcome is TrustOutcome.LimitReached)
val devices = (outcome as TrustOutcome.LimitReached).devices
assertEquals(2, devices.size)
assertEquals("Pixel", devices[1].deviceName)
}
@Test fun trustThisDeviceOtherErrorIsServerError() = runTest {
val body = """{"message":"boom"}""".toResponseBody("application/json".toMediaTypeOrNull())
val api = FakeMeApi(trustResponse = Response.error(500, body))
assertTrue(repo(api).trustThisDevice(null) is TrustOutcome.ServerError)
}
@Test fun revokeMapsFlagAndCount() = runTest {
val revoked = repo(FakeMeApi(revokeFlag = true)).revokeTrustedDevice(9)
assertTrue(revoked is ApiResult.Ok && revoked.data)
val all = repo(FakeMeApi(revokeCount = 3)).revokeAllTrustedDevices()
assertTrue(all is ApiResult.Ok && all.data == 3)
}
@Test fun recoveryStatusAndGenerateMap() = runTest {
val status = repo(FakeMeApi(remaining = 6)).recoveryCodesStatus()
assertTrue(status is ApiResult.Ok && status.data.remaining == 6)
val gen = repo(FakeMeApi(generated = listOf("a", "b"))).generateRecoveryCodes("pw")
assertTrue(gen is ApiResult.Ok)
assertEquals(listOf("a", "b"), (gen as ApiResult.Ok).data.recoveryCodes)
}
}