Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -120,18 +120,7 @@ internal class SessionReconciler(
*/
private suspend fun logoutOnInvalidCredentials() {
val authStatusLog: (AuthStatus?) -> Unit = { authStatus ->
logcat {
buildString {
append("Owner: MainActivity | Received authStatus: ")
append(
when (authStatus) {
is AuthStatus.LoggedIn -> "LoggedIn"
AuthStatus.LoggedOut -> "LoggedOut"
null -> "null"
},
)
}
}
logcat { "Owner: MainActivity | Received authStatus: ${authStatus.logName()}" }
}
combine(
authTokenService.authStatus.onEach(authStatusLog).filterNotNull().distinctUntilChanged(),
Expand All @@ -140,7 +129,7 @@ internal class SessionReconciler(
) { authStatus: AuthStatus, isDemoMode: Boolean, isLoggedIn: Boolean ->
logcat {
"SessionReconciler.logoutOnInvalidCredentials: " +
"authStatus:$authStatus | " +
"authStatus:${authStatus.logName()} | " +
"isDemoMode:$isDemoMode | " +
"isLoggedIn:$isLoggedIn"
}
Expand All @@ -153,3 +142,13 @@ internal class SessionReconciler(
}.collect()
}
}

/**
* Renders only the variant name. [AuthStatus.LoggedIn] holds the access and refresh tokens, and this
* value is logged at INFO, which reaches Datadog and Crashlytics breadcrumbs.
*/
private fun AuthStatus?.logName(): String = when (this) {
is AuthStatus.LoggedIn -> "LoggedIn"
AuthStatus.LoggedOut -> "LoggedOut"
null -> "null"
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,25 @@ internal class AuthTokenServiceImpl(
override suspend fun refreshAndGetAccessToken(): AccessToken? {
val refreshToken = getRefreshToken() ?: return null
return when (val result = authRepository.exchange(RefreshTokenGrant(refreshToken.token))) {
is AuthTokenResult.Error -> {
logcat { "Refreshing token failed. Invalidating present tokens" }
is AuthTokenResult.Error.BackendErrorResponse -> {
logcat { "Refreshing token was rejected by the backend. Invalidating present tokens" }
logoutAndInvalidateTokens()
null
}

is AuthTokenResult.Error.IOError -> {
// The backend was never reached, so this says nothing about whether the tokens are still
// valid. Keeping them lets a later request retry the refresh; discarding them would force a
// full BankID re-login over the same network that just failed one small request.
logcat(LogPriority.WARN) { "Refreshing token could not reach the backend. Keeping present tokens" }
null
}

is AuthTokenResult.Error.UnknownError -> {
logcat(LogPriority.WARN) { "Refreshing token failed with an unknown error. Keeping present tokens" }
null
}

is AuthTokenResult.Success -> {
logcat(LogPriority.VERBOSE) { "Refreshing token success. Updating tokens" }
authTokenStorage.updateTokens(result.accessToken, result.refreshToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,31 @@ class MemberIdService(
.decodeToString()
val payloadJsonObject: JsonObject = Json.parseToJsonElement(decodedPayload).jsonObject
val subContent: JsonElement = payloadJsonObject.getOrElse("sub") {
logcat(LogPriority.ERROR) { "Failed to find `sub` in jsonElement for accessToken: $accessToken" }
logcat(LogPriority.ERROR) { "Failed to find `sub` in access token payload. ${accessToken.tokenShape()}" }
return null
}
val subText = subContent.jsonPrimitive.content
if (!subText.startsWith("mem_")) {
logcat(LogPriority.ERROR) { "Failed to find the `mem_` prefix for accessToken: $accessToken" }
logcat(LogPriority.ERROR) { "Access token `sub` lacks the `mem_` prefix. ${accessToken.tokenShape()}" }
return null
}
subText.removePrefix("mem_")
} catch (exception: SerializationException) {
logcat(LogPriority.ERROR, exception) { "Got serializationException for accessToken: $accessToken" }
logcat(LogPriority.ERROR, exception) {
"Got serializationException parsing access token. ${accessToken.tokenShape()}"
}
null
} catch (exception: IllegalArgumentException) {
logcat(LogPriority.ERROR, exception) { "Got illegalArgumentException for accessToken: $accessToken" }
logcat(LogPriority.ERROR, exception) {
"Got illegalArgumentException parsing access token. ${accessToken.tokenShape()}"
}
null
}
}
}

/**
* Enough to tell a malformed token from a well-formed one, without putting a live bearer credential
* into logs that ship to Datadog and Crashlytics.
*/
private fun String.tokenShape(): String = "segments=${split(".").size}, length=$length"
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,10 @@ import kotlin.time.Instant
data class LocalAccessToken(
val token: String,
val expiryDate: Instant,
)
) {
/**
* Redacted, so that interpolating this token, or anything holding it, can never publish a live
* bearer credential to a log sink.
*/
override fun toString(): String = "LocalAccessToken(token=REDACTED, expiryDate=$expiryDate)"
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,10 @@ import kotlin.time.Instant
data class LocalRefreshToken(
val token: String,
val expiryDate: Instant,
)
) {
/**
* Redacted, so that interpolating this token, or anything holding it, can never publish a live
* bearer credential to a log sink.
*/
override fun toString(): String = "LocalRefreshToken(token=REDACTED, expiryDate=$expiryDate)"
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.hedvig.android.auth
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import com.hedvig.android.auth.event.AuthEventStorage
import com.hedvig.android.auth.storage.AuthTokenStorage
import com.hedvig.android.auth.test.FakeAuthRepository
Expand All @@ -11,6 +13,8 @@ import com.hedvig.android.core.datastore.TestPreferencesDataStore
import com.hedvig.android.logger.TestLogcatLoggingRule
import com.hedvig.android.test.clock.TestClock
import com.hedvig.authlib.AccessToken
import com.hedvig.authlib.AuthRepository
import com.hedvig.authlib.AuthTokenResult
import com.hedvig.authlib.RefreshToken
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
Expand Down Expand Up @@ -78,14 +82,51 @@ internal class AuthTokenServiceImplTest {
assertThat(status).isEqualTo(AuthStatus.LoggedOut)
}

private fun TestScope.authTokenService(storage: AuthTokenStorage, clock: Clock): AuthTokenService =
AuthTokenServiceImpl(
storage,
FakeAuthRepository(),
AuthEventStorage(),
ApplicationScope(backgroundScope),
clock,
@Test
fun `a network error while refreshing keeps the stored tokens`() = runTest {
val clock = TestClock()
val storage = authTokenStorage(clock)
storage.updateTokens(
accessToken = AccessToken("access", expiryInSeconds = 60),
refreshToken = RefreshToken("refresh", expiryInSeconds = 6000),
)
val authRepository = FakeAuthRepository()
val service = authTokenService(storage, clock, authRepository)
authRepository.exchangeResponse.add(AuthTokenResult.Error.IOError("no network"))

service.refreshAndGetAccessToken()

assertThat(storage.getTokens().first()).isNotNull()
}

@Test
fun `the backend rejecting the refresh token clears the stored tokens`() = runTest {
val clock = TestClock()
val storage = authTokenStorage(clock)
storage.updateTokens(
accessToken = AccessToken("access", expiryInSeconds = 60),
refreshToken = RefreshToken("refresh", expiryInSeconds = 6000),
)
val authRepository = FakeAuthRepository()
val service = authTokenService(storage, clock, authRepository)
authRepository.exchangeResponse.add(AuthTokenResult.Error.BackendErrorResponse("invalid_grant"))

service.refreshAndGetAccessToken()

assertThat(storage.getTokens().first()).isNull()
}

private fun TestScope.authTokenService(
storage: AuthTokenStorage,
clock: Clock,
authRepository: AuthRepository = FakeAuthRepository(),
): AuthTokenService = AuthTokenServiceImpl(
storage,
authRepository,
AuthEventStorage(),
ApplicationScope(backgroundScope),
clock,
)

private fun TestScope.authTokenStorage(clock: Clock) = AuthTokenStorage(
TestPreferencesDataStore(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.hedvig.android.auth.token

import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.doesNotContain
import com.hedvig.android.auth.AuthStatus
import kotlin.time.Instant
import org.junit.Test

/**
* These values get interpolated into logs that ship to Datadog and Crashlytics, so a token surviving
* [toString] is a live credential leaving the device.
*/
internal class TokenRedactionTest {
private val accessToken = LocalAccessToken("access-token-secret", Instant.fromEpochSeconds(1))
private val refreshToken = LocalRefreshToken("refresh-token-secret", Instant.fromEpochSeconds(2))

@Test
fun `access token is not rendered by toString`() {
assertThat(accessToken.toString()).doesNotContain("access-token-secret")
}

@Test
fun `refresh token is not rendered by toString`() {
assertThat(refreshToken.toString()).doesNotContain("refresh-token-secret")
}

@Test
fun `expiry stays visible so the log remains useful`() {
assertThat(accessToken.toString()).contains("expiryDate")
}

@Test
fun `rendering the whole auth status leaks neither token`() {
val rendered = AuthStatus.LoggedIn(accessToken, refreshToken).toString()

assertThat(rendered).doesNotContain("access-token-secret")
assertThat(rendered).doesNotContain("refresh-token-secret")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public sealed interface AuthAttemptResult {

public data class BackendErrorResponse(val message: String) : Error

public data class IOError(val message: String) : Error
public data class IOError(val message: String, val throwable: Throwable? = null) : Error

public data class UnknownError(val message: String) : Error
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public class NetworkAuthRepository(
when (e) {
is CancellationException -> throw e

is IOException -> AuthAttemptResult.Error.IOError("IO Error with message: ${e.message ?: "unknown message"}")
is IOException -> AuthAttemptResult.Error.IOError("IO Error with message: ${e.message ?: "unknown message"}", e)

is NoTransformationFoundException -> AuthAttemptResult.Error.BackendErrorResponse(
e.message ?: "unknown error",
Expand Down
1 change: 1 addition & 0 deletions app/feature/feature-login/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ dependencies {
implementation(projects.navigationCommon)
implementation(projects.navigationCompose)
implementation(projects.navigationCore)
implementation(projects.networkClients)

testImplementation(libs.androidx.datastore.core)
testImplementation(libs.androidx.junit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ private class BankIdStateImpl(

fun initialize() {
canOpenBankId = context.canBankIdAppHandleUri(bankIdUri).also {
logcat { "Trying to resolve BankID app with bankIdUri:$bankIdUri | result: canOpenBankId=$it" }
// The URI carries a single-use autostart token, so log only what identifies the target.
logcat { "Trying to resolve BankID app for ${bankIdUri.host} | result: canOpenBankId=$it" }
}
}

Expand All @@ -70,7 +71,7 @@ private class BankIdStateImpl(
true
} catch (e: PackageManager.NameNotFoundException) {
logcat(LogPriority.WARN) {
"Could not resolve BankID app with bankIdUri:$uri + exception: $e"
"Could not resolve BankID app for ${uri.host} + exception: $e"
}
false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import com.hedvig.android.logger.LogPriority
import com.hedvig.android.logger.logcat
import com.hedvig.android.molecule.public.MoleculePresenter
import com.hedvig.android.molecule.public.MoleculePresenterScope
import com.hedvig.android.network.clients.TLS_DIAG_TAG
import com.hedvig.android.network.clients.TlsDiagnostics
import com.hedvig.authlib.AuthAttemptResult
import com.hedvig.authlib.AuthRepository
import com.hedvig.authlib.AuthTokenResult
Expand All @@ -40,6 +42,7 @@ internal class SwedishLoginPresenter(
private val authTokenService: AuthTokenService,
private val authRepository: AuthRepository,
private val demoManager: DemoManager,
private val tlsDiagnostics: TlsDiagnostics,
private val savedStateHandle: SavedStateHandle,
) : MoleculePresenter<SwedishLoginEvent, SwedishLoginUiState> {
@Composable
Expand Down Expand Up @@ -123,7 +126,15 @@ internal class SwedishLoginPresenter(
}

is AuthAttemptResult.Error -> {
logcat(LogPriority.ERROR) { "Got Error when signing in with BankId: $result" }
val tlsFailure = (result as? AuthAttemptResult.Error.IOError)
?.let { tlsDiagnostics.describe(it.throwable) }
if (tlsFailure != null) {
// Nothing reached us, so this is the member's network refusing the handshake rather than a
// fault of ours. WARN keeps it out of the app's error rate while the evidence stays greppable.
logcat(LogPriority.WARN, tag = TLS_DIAG_TAG) { "BankId login blocked by certificate trust. $tlsFailure" }
} else {
logcat(LogPriority.ERROR) { "Got Error when signing in with BankId: $result" }
}
startLoginAttemptFailed = true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.hedvig.android.core.common.di.ActivityRetainedScope
import com.hedvig.android.core.common.di.HedvigViewModel
import com.hedvig.android.core.demomode.DemoManager
import com.hedvig.android.molecule.public.MoleculeViewModel
import com.hedvig.android.network.clients.TlsDiagnostics
import com.hedvig.authlib.AuthRepository
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
Expand All @@ -17,9 +18,10 @@ internal class SwedishLoginViewModel(
authTokenService: AuthTokenService,
authRepository: AuthRepository,
demoManager: DemoManager,
tlsDiagnostics: TlsDiagnostics,
@Assisted savedStateHandle: SavedStateHandle,
) : MoleculeViewModel<SwedishLoginEvent, SwedishLoginUiState>(
SwedishLoginUiState(BankIdUiState.Loading, false),
SwedishLoginPresenter(authTokenService, authRepository, demoManager, savedStateHandle),
SwedishLoginPresenter(authTokenService, authRepository, demoManager, tlsDiagnostics, savedStateHandle),
SharingStarted.WhileSubscribed(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.hedvig.android.auth.test.TestAuthTokenService
import com.hedvig.android.core.demomode.DemoManager
import com.hedvig.android.logger.TestLogcatLoggingRule
import com.hedvig.android.molecule.test.test
import com.hedvig.android.network.clients.TlsDiagnostics
import com.hedvig.authlib.AccessToken
import com.hedvig.authlib.AuthAttemptResult
import com.hedvig.authlib.AuthRepository
Expand Down Expand Up @@ -303,6 +304,9 @@ class SwedishLoginPresenterTest {

override suspend fun setDemoMode(demoMode: Boolean) {}
},
object : TlsDiagnostics {
override suspend fun describe(throwable: Throwable?): String? = null
},
savedStateHandle,
)
}
Expand Down
Loading
Loading