Updated on 2026-08-14
This commit is contained in:
parent
16ed712a09
commit
f6f3aabbfc
11 changed files with 933 additions and 26 deletions
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.common.services.secure.SecureStorage
|
||||
import com.tangem.datasource.api.auth.AuthApi
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.lib.auth.AuthFeatureToggles
|
||||
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||
|
|
@ -16,11 +17,16 @@ import com.tangem.lib.auth.http.DpopAuthorizationInterceptor
|
|||
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
|
||||
import com.tangem.lib.auth.nonce.internal.DefaultAuthNonceDecryptor
|
||||
import com.tangem.lib.auth.nonce.internal.DisabledAuthNonceDecryptor
|
||||
import com.tangem.lib.auth.session.SessionTokenRefresher
|
||||
import com.tangem.lib.auth.session.SessionTokensStore
|
||||
import com.tangem.lib.auth.session.internal.AuthErrorConverter
|
||||
import com.tangem.lib.auth.session.internal.DefaultSessionTokenRefresher
|
||||
import com.tangem.lib.auth.session.internal.DefaultSessionTokensStore
|
||||
import com.tangem.lib.auth.session.internal.DisabledSessionTokenRefresher
|
||||
import com.tangem.lib.auth.session.internal.DisabledSessionTokensStore
|
||||
import com.tangem.sdk.storage.AndroidSecureStorageV2
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.info.AppInfoProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
|
@ -115,6 +121,33 @@ internal object AuthModule {
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSessionTokenRefresher(
|
||||
authFeatureToggles: AuthFeatureToggles,
|
||||
authApi: AuthApi,
|
||||
store: SessionTokensStore,
|
||||
deviceKeyManager: DeviceKeyManager,
|
||||
nonceDecryptor: AuthNonceDecryptor,
|
||||
appInfoProvider: AppInfoProvider,
|
||||
errorConverter: AuthErrorConverter,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): SessionTokenRefresher {
|
||||
if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledSessionTokenRefresher
|
||||
|
||||
return DefaultSessionTokenRefresher(
|
||||
authApi = authApi,
|
||||
store = store,
|
||||
deviceKeyManager = deviceKeyManager,
|
||||
nonceDecryptor = nonceDecryptor,
|
||||
appInfoProvider = appInfoProvider,
|
||||
errorConverter = errorConverter,
|
||||
clock = Clock.System,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDpopAuthorizationInterceptor(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.lib.auth.session
|
||||
|
||||
/**
|
||||
* Typed Tangem Auth Service failure produced by `AuthErrorConverter` out of the raw
|
||||
* `ApiResponseError`. Carries the parsed [AuthErrorResponse] when the server returned
|
||||
* an `application/problem+json` body (RFC 9457).
|
||||
*/
|
||||
sealed class AuthError(open val problem: AuthErrorResponse?) {
|
||||
|
||||
/** `400` — invalid nonce / signature / wallet already registered. */
|
||||
data class BadRequest(override val problem: AuthErrorResponse?) : AuthError(problem)
|
||||
|
||||
/** `401` — invalid / expired / revoked / replayed access or refresh token. */
|
||||
data class Unauthorized(override val problem: AuthErrorResponse?) : AuthError(problem)
|
||||
|
||||
/** `403` — device blocked (RED tier), max wallets exceeded, or token-device mismatch. */
|
||||
data class Forbidden(override val problem: AuthErrorResponse?) : AuthError(problem)
|
||||
|
||||
/** `404` — token / resource not found. */
|
||||
data class NotFound(override val problem: AuthErrorResponse?) : AuthError(problem)
|
||||
|
||||
/** `429` — server-side rate limit; honour [retryAfterSeconds] before retrying. */
|
||||
data class RateLimited(
|
||||
val retryAfterSeconds: Int?,
|
||||
override val problem: AuthErrorResponse?,
|
||||
) : AuthError(problem)
|
||||
|
||||
/** `5xx` — server-side outage. */
|
||||
data class ServerUnavailable(override val problem: AuthErrorResponse?) : AuthError(problem)
|
||||
|
||||
/** Connectivity issue (DNS, timeout, offline). */
|
||||
data object NetworkError : AuthError(problem = null)
|
||||
|
||||
/** Anything not covered above (parsing failure, unexpected exception). */
|
||||
data class Unknown(val cause: Throwable) : AuthError(problem = null)
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.tangem.lib.auth.session
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* RFC 9457 / RFC 7807 Problem Details response. Returned by Tangem Auth Service with
|
||||
* `Content-Type: application/problem+json` on every 4xx / 5xx response.
|
||||
*/
|
||||
@Serializable
|
||||
data class AuthErrorResponse(
|
||||
/** URI identifying the problem type. */
|
||||
@SerialName("type") val type: String,
|
||||
/** Short human-readable summary (e.g. `"Too Many Requests"`). */
|
||||
@SerialName("title") val title: String,
|
||||
/** HTTP status code. */
|
||||
@SerialName("status") val status: Int,
|
||||
/** Human-readable explanation. */
|
||||
@SerialName("detail") val detail: String? = null,
|
||||
/** URI reference to this occurrence (e.g. `"/api/v1/auth/refresh"`). */
|
||||
@SerialName("instance") val instance: String? = null,
|
||||
/** Application-specific error code. */
|
||||
@SerialName("code") val code: String? = null,
|
||||
/** Retry delay for rate limiting (`429`). */
|
||||
@SerialName("retryAfterSeconds") val retryAfterSeconds: Int? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.tangem.lib.auth.session
|
||||
|
||||
/**
|
||||
* Typed failure mode of `SessionTokenRefresher.refresh()`. Distinguishes terminal failures
|
||||
* (re-registration required) from transient ones (network / server) so callers can decide
|
||||
* whether to retry, surface UI, or trigger deferred-registration flow.
|
||||
*/
|
||||
sealed class SessionRefreshError {
|
||||
|
||||
/** API-level error from `/refresh`, `/authenticate` or `/nonce/auth`. Transient unless [cause] says otherwise. */
|
||||
data class Api(val cause: AuthError) : SessionRefreshError()
|
||||
|
||||
/**
|
||||
* Terminal — `/authenticate` returned 401/403. Session store was cleared; the device must
|
||||
* re-register ([REDACTED_TASK_KEY] / deferred-registration flow).
|
||||
*/
|
||||
data object SessionRevoked : SessionRefreshError()
|
||||
|
||||
/** Device key is not provisioned in Keystore (registration not yet run, or Keystore unavailable). */
|
||||
data object DeviceKeyUnavailable : SessionRefreshError()
|
||||
|
||||
/** RSA/OAEP decryption of the server-issued auth nonce failed. */
|
||||
data class NonceDecryptionFailed(val cause: Throwable) : SessionRefreshError()
|
||||
|
||||
/** Device-key signing of the authentication payload failed (Keystore I/O or ECDSA failure). */
|
||||
data class SigningFailed(val cause: Throwable) : SessionRefreshError()
|
||||
|
||||
/** Refresher is disabled via `AND_15438_BACKEND_AUTHENTICATION_ENABLED` feature toggle. */
|
||||
data object Disabled : SessionRefreshError()
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.tangem.lib.auth.session
|
||||
|
||||
import arrow.core.Either
|
||||
|
||||
/**
|
||||
* Refreshes (rotates) session tokens.
|
||||
*
|
||||
* Implementations must serialise refresh attempts so concurrent callers share a single
|
||||
* network round-trip — replaying a consumed refresh token causes the backend to revoke
|
||||
* the entire session chain (SR-8 / RFC 9449 §5).
|
||||
*
|
||||
* Refresh strategy:
|
||||
* 1. Call `/api/v1/auth/refresh` with the stored refresh token when it is present and unexpired.
|
||||
* 2. On 401/403 from `/refresh` (revoked / replayed / RED-tier downgrade), fall back to
|
||||
* full re-authentication via `/api/v1/auth/nonce/auth` + `/api/v1/auth/authenticate`
|
||||
* signed by the device key.
|
||||
* 3. On 401/403 from `/authenticate`, clear the session store and return
|
||||
* [SessionRefreshError.SessionRevoked] — the device must be re-registered (see [REDACTED_TASK_KEY]
|
||||
* for the deferred-registration flag).
|
||||
*/
|
||||
interface SessionTokenRefresher {
|
||||
|
||||
suspend fun refresh(): Either<SessionRefreshError, SessionTokens>
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.tangem.lib.auth.session.internal
|
||||
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
|
||||
import com.tangem.lib.auth.session.AuthError
|
||||
import com.tangem.lib.auth.session.AuthErrorResponse
|
||||
import com.tangem.utils.converter.Converter
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Converts the raw `ApiResponseError` produced by Retrofit into a typed [AuthError], parsing
|
||||
* `application/problem+json` payloads into [AuthErrorResponse] when present. Follows the
|
||||
* project pattern set by `TangemPayErrorConverter`, `ExpressErrorConverter`, etc.
|
||||
*/
|
||||
internal class AuthErrorConverter @Inject constructor() : Converter<Throwable, AuthError> {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override fun convert(error: Throwable): AuthError = when (error) {
|
||||
is ApiResponseError.HttpException -> convertHttp(error)
|
||||
is ApiResponseError.NetworkException,
|
||||
is ApiResponseError.TimeoutException,
|
||||
-> AuthError.NetworkError
|
||||
// Unwrap the wrapper so consumers inspect the original failure instead of
|
||||
// `ApiResponseError.UnknownException` itself.
|
||||
is ApiResponseError.UnknownException -> AuthError.Unknown(error.cause)
|
||||
else -> AuthError.Unknown(error)
|
||||
}
|
||||
|
||||
private fun convertHttp(error: ApiResponseError.HttpException): AuthError {
|
||||
val problem = error.errorBody?.let(::parseErrorResponse)
|
||||
return when (error.code) {
|
||||
Code.BAD_REQUEST -> AuthError.BadRequest(problem)
|
||||
Code.UNAUTHORIZED -> AuthError.Unauthorized(problem)
|
||||
Code.FORBIDDEN -> AuthError.Forbidden(problem)
|
||||
Code.NOT_FOUND -> AuthError.NotFound(problem)
|
||||
Code.TOO_MANY_REQUESTS -> AuthError.RateLimited(problem?.retryAfterSeconds, problem)
|
||||
else -> if (error.isServerError()) AuthError.ServerUnavailable(problem) else AuthError.Unknown(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseErrorResponse(response: String): AuthErrorResponse? {
|
||||
return runCatching { json.decodeFromString<AuthErrorResponse>(response) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
package com.tangem.lib.auth.session.internal
|
||||
|
||||
import android.util.Base64
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.raise.either
|
||||
import arrow.core.right
|
||||
import com.tangem.datasource.api.auth.AuthApi
|
||||
import com.tangem.datasource.api.auth.models.request.AuthApiRequest
|
||||
import com.tangem.datasource.api.auth.models.request.AuthenticationPayload
|
||||
import com.tangem.datasource.api.auth.models.request.AuthenticationPayload.DeviceMetadata
|
||||
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
|
||||
import com.tangem.datasource.api.auth.models.request.RefreshApiRequest
|
||||
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
|
||||
import com.tangem.lib.auth.session.AuthError
|
||||
import com.tangem.lib.auth.session.SessionRefreshError
|
||||
import com.tangem.lib.auth.session.SessionTokenRefresher
|
||||
import com.tangem.lib.auth.session.SessionTokens
|
||||
import com.tangem.lib.auth.session.SessionTokensStore
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.info.AppInfoProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultSessionTokenRefresher(
|
||||
private val authApi: AuthApi,
|
||||
private val store: SessionTokensStore,
|
||||
private val deviceKeyManager: DeviceKeyManager,
|
||||
private val nonceDecryptor: AuthNonceDecryptor,
|
||||
private val appInfoProvider: AppInfoProvider,
|
||||
private val errorConverter: AuthErrorConverter,
|
||||
private val clock: Clock,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : SessionTokenRefresher {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private var inFlight: CompletableDeferred<Either<SessionRefreshError, SessionTokens>>? = null
|
||||
|
||||
override suspend fun refresh(): Either<SessionRefreshError, SessionTokens> = withContext(dispatchers.io) {
|
||||
// True single-flight (SR-8 / RFC 9449 §5): concurrent callers share one network round-trip
|
||||
// and receive the same result — both on success and on transient failures. This prevents
|
||||
// refresh-token replay (which revokes the family) and avoids amplifying outages/rate limits.
|
||||
var isOwner = false
|
||||
val deferred = mutex.withLock {
|
||||
inFlight ?: CompletableDeferred<Either<SessionRefreshError, SessionTokens>>().also { deferred ->
|
||||
inFlight = deferred
|
||||
isOwner = true
|
||||
}
|
||||
}
|
||||
|
||||
if (isOwner) {
|
||||
try {
|
||||
deferred.complete(runRefresh(current = store.get().getOrNull()))
|
||||
} catch (t: Throwable) {
|
||||
// Propagate to every waiter — without this they'd suspend forever on `await()`.
|
||||
deferred.completeExceptionally(t)
|
||||
throw t
|
||||
} finally {
|
||||
// `NonCancellable` keeps the slot-clearing alive even if the owner coroutine is
|
||||
// cancelled mid-refresh, so the next caller can start a fresh attempt.
|
||||
withContext(NonCancellable) {
|
||||
mutex.withLock { inFlight = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deferred.await()
|
||||
}
|
||||
|
||||
private suspend fun runRefresh(current: SessionTokens?): Either<SessionRefreshError, SessionTokens> {
|
||||
val now = clock.now()
|
||||
|
||||
val isRefreshTokenValid = current?.refreshTokenExpiresAt != null && current.refreshTokenExpiresAt > now
|
||||
if (current?.refreshToken != null && isRefreshTokenValid) {
|
||||
when (val result = callRefresh(current.refreshToken)) {
|
||||
is RefreshOutcome.Success -> return result.tokens.right()
|
||||
RefreshOutcome.Unauthenticated -> Unit // fall through to /authenticate
|
||||
is RefreshOutcome.Transient -> return SessionRefreshError.Api(result.cause).left()
|
||||
}
|
||||
}
|
||||
|
||||
return runAuthenticate()
|
||||
}
|
||||
|
||||
private suspend fun callRefresh(refreshToken: String): RefreshOutcome {
|
||||
val response = authApi.refresh(RefreshApiRequest(refreshToken = refreshToken))
|
||||
return handleTokenResponse(response, clearOnUnauthenticated = false)
|
||||
}
|
||||
|
||||
private suspend fun runAuthenticate(): Either<SessionRefreshError, SessionTokens> = either {
|
||||
val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull()
|
||||
?: raise(SessionRefreshError.DeviceKeyUnavailable)
|
||||
|
||||
val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap()
|
||||
|
||||
val nonceResponse = authApi.requestAuthNonce(NonceApiRequest(devicePublicKey = devicePublicKeyBase64))
|
||||
val cipheredNonce = when (nonceResponse) {
|
||||
is ApiResponse.Success -> nonceResponse.data.cipheredNonce
|
||||
is ApiResponse.Error -> {
|
||||
val authError = errorConverter.convert(nonceResponse.cause)
|
||||
raise(SessionRefreshError.Api(authError))
|
||||
}
|
||||
}
|
||||
|
||||
val nonce = try {
|
||||
nonceDecryptor.decryptNonce(cipheredNonce)
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("Failed to decrypt auth nonce", e)
|
||||
raise(SessionRefreshError.NonceDecryptionFailed(e))
|
||||
}
|
||||
|
||||
val payload = AuthenticationPayload(
|
||||
devicePublicKey = devicePublicKeyBase64,
|
||||
nonce = nonce,
|
||||
attestationToken = null,
|
||||
metadata = buildDeviceMetadata(),
|
||||
)
|
||||
val signaturePayload = canonicalize(payload)
|
||||
val signature = try {
|
||||
deviceKeyManager.sign(signaturePayload).toBase64NoWrap()
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("Failed to sign authentication payload", e)
|
||||
raise(SessionRefreshError.SigningFailed(e))
|
||||
}
|
||||
|
||||
val authResponse = authApi.authenticate(AuthApiRequest(payload = payload, signature = signature))
|
||||
return when (val outcome = handleTokenResponse(authResponse, clearOnUnauthenticated = true)) {
|
||||
is RefreshOutcome.Success -> outcome.tokens.right()
|
||||
RefreshOutcome.Unauthenticated -> SessionRefreshError.SessionRevoked.left()
|
||||
is RefreshOutcome.Transient -> SessionRefreshError.Api(outcome.cause).left()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleTokenResponse(
|
||||
response: ApiResponse<TokenApiResponse>,
|
||||
clearOnUnauthenticated: Boolean,
|
||||
): RefreshOutcome {
|
||||
return when (response) {
|
||||
is ApiResponse.Success -> {
|
||||
val tokens = SessionTokensConverter.convertBack(response.data)
|
||||
store.save(tokens)
|
||||
RefreshOutcome.Success(tokens)
|
||||
}
|
||||
is ApiResponse.Error -> {
|
||||
when (val authError = errorConverter.convert(response.cause)) {
|
||||
is AuthError.Unauthorized, is AuthError.Forbidden -> {
|
||||
if (clearOnUnauthenticated) {
|
||||
TangemLogger.i("Session revoked: ${authError.problem?.detail ?: authError}")
|
||||
store.clear()
|
||||
}
|
||||
RefreshOutcome.Unauthenticated
|
||||
}
|
||||
else -> RefreshOutcome.Transient(authError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDeviceMetadata(): DeviceMetadata = DeviceMetadata(
|
||||
deviceModel = appInfoProvider.device,
|
||||
os = appInfoProvider.platform,
|
||||
osVersion = appInfoProvider.osVersion,
|
||||
appVersion = appInfoProvider.appVersion,
|
||||
userAgent = null,
|
||||
locale = appInfoProvider.language,
|
||||
timezone = appInfoProvider.timezone,
|
||||
)
|
||||
|
||||
private fun canonicalize(payload: AuthenticationPayload): ByteArray {
|
||||
// Stable, line-separated representation; backend treats the signed bytes opaquely. If the
|
||||
// server pins to a specific canonicalisation (e.g. CBOR / sorted JSON), update both sides
|
||||
// together.
|
||||
return buildString {
|
||||
append(payload.devicePublicKey).append('\n')
|
||||
append(payload.nonce).append('\n')
|
||||
append(payload.attestationToken.orEmpty()).append('\n')
|
||||
append(payload.metadata.deviceModel.orEmpty()).append('\n')
|
||||
append(payload.metadata.os).append('\n')
|
||||
append(payload.metadata.osVersion.orEmpty()).append('\n')
|
||||
append(payload.metadata.appVersion.orEmpty()).append('\n')
|
||||
append(payload.metadata.locale.orEmpty()).append('\n')
|
||||
append(payload.metadata.timezone.orEmpty())
|
||||
}.toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun ByteArray.toBase64NoWrap(): String = Base64.encodeToString(this, Base64.NO_WRAP)
|
||||
|
||||
private sealed interface RefreshOutcome {
|
||||
data class Success(val tokens: SessionTokens) : RefreshOutcome
|
||||
data object Unauthenticated : RefreshOutcome
|
||||
data class Transient(val cause: AuthError) : RefreshOutcome
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.lib.auth.session.internal
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import com.tangem.lib.auth.session.SessionRefreshError
|
||||
import com.tangem.lib.auth.session.SessionTokenRefresher
|
||||
import com.tangem.lib.auth.session.SessionTokens
|
||||
import com.tangem.utils.annotations.RemoveWithToggle
|
||||
|
||||
@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED")
|
||||
internal object DisabledSessionTokenRefresher : SessionTokenRefresher {
|
||||
|
||||
override suspend fun refresh(): Either<SessionRefreshError, SessionTokens> {
|
||||
return SessionRefreshError.Disabled.left()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
package com.tangem.lib.auth.session.internal
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
|
||||
import com.tangem.lib.auth.session.AuthError
|
||||
import com.tangem.lib.auth.session.AuthErrorResponse
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class AuthErrorConverterTest {
|
||||
|
||||
private val converter = AuthErrorConverter()
|
||||
|
||||
private val sampleBody = """
|
||||
{
|
||||
"type": "https://problems.tangem.com/auth/invalid-signature",
|
||||
"title": "Bad Request",
|
||||
"status": 400,
|
||||
"detail": "Nonce or signature validation failed.",
|
||||
"instance": "/api/v1/auth/refresh",
|
||||
"code": "invalid_signature",
|
||||
"retryAfterSeconds": null
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private val sampleProblem = AuthErrorResponse(
|
||||
type = "https://problems.tangem.com/auth/invalid-signature",
|
||||
title = "Bad Request",
|
||||
status = 400,
|
||||
detail = "Nonce or signature validation failed.",
|
||||
instance = "/api/v1/auth/refresh",
|
||||
code = "invalid_signature",
|
||||
retryAfterSeconds = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `400 with problem body is converted to BadRequest with parsed problem`() {
|
||||
val result = converter.convert(httpError(Code.BAD_REQUEST, sampleBody))
|
||||
|
||||
assertThat(result).isEqualTo(AuthError.BadRequest(sampleProblem))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `400 without body is converted to BadRequest with null problem`() {
|
||||
val result = converter.convert(httpError(Code.BAD_REQUEST, errorBody = null))
|
||||
|
||||
assertThat(result).isEqualTo(AuthError.BadRequest(problem = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `401 is converted to Unauthorized`() {
|
||||
val result = converter.convert(httpError(Code.UNAUTHORIZED, sampleBody))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.Unauthorized::class.java)
|
||||
assertThat((result as AuthError.Unauthorized).problem).isEqualTo(sampleProblem)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `403 is converted to Forbidden`() {
|
||||
val result = converter.convert(httpError(Code.FORBIDDEN, sampleBody))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.Forbidden::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `404 is converted to NotFound`() {
|
||||
val result = converter.convert(httpError(Code.NOT_FOUND, sampleBody))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.NotFound::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `429 surfaces retryAfterSeconds from problem`() {
|
||||
val rateLimitBody = """
|
||||
{
|
||||
"type": "https://problems.tangem.com/auth/rate-limit",
|
||||
"title": "Too Many Requests",
|
||||
"status": 429,
|
||||
"detail": "Try again later.",
|
||||
"instance": "/api/v1/auth/refresh",
|
||||
"code": "rate_limited",
|
||||
"retryAfterSeconds": 45
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = converter.convert(httpError(Code.TOO_MANY_REQUESTS, rateLimitBody))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.RateLimited::class.java)
|
||||
val rateLimited = result as AuthError.RateLimited
|
||||
assertThat(rateLimited.retryAfterSeconds).isEqualTo(45)
|
||||
assertThat(rateLimited.problem?.code).isEqualTo("rate_limited")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `429 without body has null retryAfterSeconds`() {
|
||||
val result = converter.convert(httpError(Code.TOO_MANY_REQUESTS, errorBody = null))
|
||||
|
||||
assertThat(result).isEqualTo(AuthError.RateLimited(retryAfterSeconds = null, problem = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `500 is converted to ServerUnavailable`() {
|
||||
val result = converter.convert(httpError(Code.INTERNAL_SERVER_ERROR, errorBody = null))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.ServerUnavailable::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `502 is converted to ServerUnavailable`() {
|
||||
val result = converter.convert(httpError(Code.BAD_GATEWAY, errorBody = null))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.ServerUnavailable::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `503 is converted to ServerUnavailable`() {
|
||||
val result = converter.convert(httpError(Code.SERVICE_UNAVAILABLE, errorBody = null))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.ServerUnavailable::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-server 4xx not explicitly mapped becomes Unknown`() {
|
||||
// 418 I_M_A_TEAPOT is a 4xx that isServerError() reports as non-server.
|
||||
val httpException = httpError(Code.IM_A_TEAPOT, errorBody = null)
|
||||
|
||||
val result = converter.convert(httpException)
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.Unknown::class.java)
|
||||
assertThat((result as AuthError.Unknown).cause).isSameInstanceAs(httpException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NetworkException becomes NetworkError`() {
|
||||
val result = converter.convert(ApiResponseError.NetworkException())
|
||||
|
||||
assertThat(result).isSameInstanceAs(AuthError.NetworkError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TimeoutException becomes NetworkError`() {
|
||||
val result = converter.convert(ApiResponseError.TimeoutException())
|
||||
|
||||
assertThat(result).isSameInstanceAs(AuthError.NetworkError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UnknownException is unwrapped to its underlying cause`() {
|
||||
val cause = IllegalStateException("boom")
|
||||
val result = converter.convert(ApiResponseError.UnknownException(cause))
|
||||
|
||||
// Consumers reading AuthError.Unknown.cause should see the original failure, not the wrapper.
|
||||
assertThat(result).isEqualTo(AuthError.Unknown(cause))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-ApiResponseError Throwable is wrapped as Unknown`() {
|
||||
val cause = RuntimeException("misc")
|
||||
val result = converter.convert(cause)
|
||||
|
||||
assertThat(result).isEqualTo(AuthError.Unknown(cause))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed JSON in errorBody yields null problem but preserves AuthError shape`() {
|
||||
val result = converter.convert(httpError(Code.BAD_REQUEST, errorBody = "{not json"))
|
||||
|
||||
assertThat(result).isEqualTo(AuthError.BadRequest(problem = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown JSON fields in errorBody are ignored (ignoreUnknownKeys)`() {
|
||||
val bodyWithExtra = """
|
||||
{
|
||||
"type": "https://problems.tangem.com/auth/x",
|
||||
"title": "Bad Request",
|
||||
"status": 400,
|
||||
"detail": null,
|
||||
"instance": null,
|
||||
"code": null,
|
||||
"retryAfterSeconds": null,
|
||||
"futureField": "ignored"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = converter.convert(httpError(Code.BAD_REQUEST, bodyWithExtra))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.BadRequest::class.java)
|
||||
assertThat((result as AuthError.BadRequest).problem?.title).isEqualTo("Bad Request")
|
||||
}
|
||||
|
||||
private fun httpError(code: Code, errorBody: String?): ApiResponseError.HttpException =
|
||||
ApiResponseError.HttpException(code = code, message = null, errorBody = errorBody)
|
||||
}
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
package com.tangem.lib.auth.session.internal
|
||||
|
||||
import arrow.core.None
|
||||
import arrow.core.Some
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.api.auth.AuthApi
|
||||
import com.tangem.datasource.api.auth.models.request.AuthApiRequest
|
||||
import com.tangem.datasource.api.auth.models.request.RefreshApiRequest
|
||||
import com.tangem.datasource.api.auth.models.response.NonceApiResponse
|
||||
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
|
||||
import com.tangem.lib.auth.session.SessionRefreshError
|
||||
import com.tangem.lib.auth.session.SessionTokens
|
||||
import com.tangem.lib.auth.session.SessionTokensStore
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import com.tangem.utils.info.AppInfoProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class DefaultSessionTokenRefresherTest {
|
||||
|
||||
private val authApi: AuthApi = mockk()
|
||||
private val store: SessionTokensStore = mockk(relaxUnitFun = true)
|
||||
private val deviceKeyManager: DeviceKeyManager = mockk()
|
||||
private val nonceDecryptor: AuthNonceDecryptor = mockk()
|
||||
private val appInfoProvider: AppInfoProvider = mockk(relaxed = true)
|
||||
private val errorConverter = AuthErrorConverter()
|
||||
private val dispatchers = TestingCoroutineDispatcherProvider()
|
||||
private val fixedClock = object : Clock {
|
||||
override fun now(): Instant = Instant.fromEpochSeconds(1_700_000_000)
|
||||
}
|
||||
|
||||
private lateinit var refresher: DefaultSessionTokenRefresher
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(authApi, store, deviceKeyManager, nonceDecryptor)
|
||||
mockkStatic(android.util.Base64::class)
|
||||
every { android.util.Base64.encodeToString(any(), any()) } answers {
|
||||
java.util.Base64.getEncoder().encodeToString(firstArg())
|
||||
}
|
||||
refresher = DefaultSessionTokenRefresher(
|
||||
authApi = authApi,
|
||||
store = store,
|
||||
deviceKeyManager = deviceKeyManager,
|
||||
nonceDecryptor = nonceDecryptor,
|
||||
appInfoProvider = appInfoProvider,
|
||||
errorConverter = errorConverter,
|
||||
clock = fixedClock,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() = unmockkAll()
|
||||
|
||||
@Test
|
||||
fun `refresh hits refresh endpoint when refresh token valid`() = runTest {
|
||||
val stored = SessionTokens(
|
||||
accessToken = "old-access",
|
||||
accessTokenExpiresAt = fixedClock.now().plus(60),
|
||||
refreshToken = "rt-1",
|
||||
refreshTokenExpiresAt = fixedClock.now().plus(3600),
|
||||
walletIds = listOf("w1"),
|
||||
)
|
||||
coEvery { store.get() } returns Some(stored)
|
||||
coEvery { authApi.refresh(RefreshApiRequest("rt-1")) } returns ApiResponse.Success(
|
||||
data = TokenApiResponse(
|
||||
accessToken = "new-access",
|
||||
accessTokenExpiresAt = "2024-01-01T00:00:00Z",
|
||||
refreshToken = "rt-2",
|
||||
refreshTokenExpiresAt = "2024-02-01T00:00:00Z",
|
||||
walletIds = listOf("w1", "w2"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = refresher.refresh()
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
val tokens = result.getOrNull()!!
|
||||
assertThat(tokens.accessToken).isEqualTo("new-access")
|
||||
assertThat(tokens.refreshToken).isEqualTo("rt-2")
|
||||
assertThat(tokens.walletIds).containsExactly("w1", "w2")
|
||||
coVerify { store.save(tokens) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh falls back to authenticate when refresh returns 401`() = runTest {
|
||||
val stored = SessionTokens(
|
||||
accessToken = "old-access",
|
||||
accessTokenExpiresAt = fixedClock.now().plus(60),
|
||||
refreshToken = "rt-1",
|
||||
refreshTokenExpiresAt = fixedClock.now().plus(3600),
|
||||
walletIds = listOf("w1"),
|
||||
)
|
||||
coEvery { store.get() } returns Some(stored)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
coEvery { authApi.refresh(any()) } returns ApiResponse.Error(
|
||||
cause = ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.UNAUTHORIZED,
|
||||
message = "revoked",
|
||||
errorBody = null,
|
||||
),
|
||||
) as ApiResponse<TokenApiResponse>
|
||||
stubAuthenticateHappyPath()
|
||||
|
||||
val result = refresher.refresh()
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
assertThat(result.getOrNull()?.accessToken).isEqualTo("post-auth-access")
|
||||
coVerify { authApi.refresh(any()) }
|
||||
coVerify { authApi.requestAuthNonce(any()) }
|
||||
coVerify { authApi.authenticate(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh clears store when authenticate returns 403`() = runTest {
|
||||
coEvery { store.get() } returns None
|
||||
|
||||
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
|
||||
coEvery { authApi.requestAuthNonce(any()) } returns ApiResponse.Success(
|
||||
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
|
||||
)
|
||||
coEvery { nonceDecryptor.decryptNonce("abc") } returns "nonce-decrypted"
|
||||
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
coEvery { authApi.authenticate(any<AuthApiRequest>()) } returns ApiResponse.Error(
|
||||
cause = ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.FORBIDDEN,
|
||||
message = "RED",
|
||||
errorBody = null,
|
||||
),
|
||||
) as ApiResponse<TokenApiResponse>
|
||||
|
||||
val result = refresher.refresh()
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(result.leftOrNull()).isEqualTo(SessionRefreshError.SessionRevoked)
|
||||
coVerify { store.clear() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent callers share a single refresh round-trip — both get same result`() = runTest {
|
||||
val stored = SessionTokens(
|
||||
accessToken = "old-access",
|
||||
accessTokenExpiresAt = fixedClock.now().plus(60),
|
||||
refreshToken = "rt-1",
|
||||
refreshTokenExpiresAt = fixedClock.now().plus(3600),
|
||||
walletIds = listOf("w1"),
|
||||
)
|
||||
coEvery { store.get() } returns Some(stored)
|
||||
|
||||
// Gate the network call so both callers reach the single-flight check before the owner
|
||||
// completes the in-flight Deferred.
|
||||
val networkGate = CompletableDeferred<Unit>()
|
||||
coEvery { authApi.refresh(RefreshApiRequest("rt-1")) } coAnswers {
|
||||
networkGate.await()
|
||||
ApiResponse.Success(
|
||||
data = TokenApiResponse(
|
||||
accessToken = "new-access",
|
||||
accessTokenExpiresAt = "2024-01-01T00:00:00Z",
|
||||
refreshToken = "rt-2",
|
||||
refreshTokenExpiresAt = "2024-02-01T00:00:00Z",
|
||||
walletIds = listOf("w1"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val a = async(start = CoroutineStart.UNDISPATCHED) { refresher.refresh() }
|
||||
val b = async(start = CoroutineStart.UNDISPATCHED) { refresher.refresh() }
|
||||
|
||||
networkGate.complete(Unit)
|
||||
|
||||
val resultA = a.await()
|
||||
val resultB = b.await()
|
||||
|
||||
assertThat(resultA).isEqualTo(resultB)
|
||||
assertThat(resultA.getOrNull()?.accessToken).isEqualTo("new-access")
|
||||
coVerify(exactly = 1) { authApi.refresh(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent callers share a transient failure — only one network call is made`() = runTest {
|
||||
val stored = SessionTokens(
|
||||
accessToken = "old-access",
|
||||
accessTokenExpiresAt = fixedClock.now().plus(60),
|
||||
refreshToken = "rt-1",
|
||||
refreshTokenExpiresAt = fixedClock.now().plus(3600),
|
||||
walletIds = listOf("w1"),
|
||||
)
|
||||
coEvery { store.get() } returns Some(stored)
|
||||
|
||||
val networkGate = CompletableDeferred<Unit>()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
coEvery { authApi.refresh(any()) } coAnswers {
|
||||
networkGate.await()
|
||||
ApiResponse.Error(
|
||||
cause = ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.INTERNAL_SERVER_ERROR,
|
||||
message = "boom",
|
||||
errorBody = null,
|
||||
),
|
||||
) as ApiResponse<TokenApiResponse>
|
||||
}
|
||||
|
||||
val a = async(start = CoroutineStart.UNDISPATCHED) { refresher.refresh() }
|
||||
val b = async(start = CoroutineStart.UNDISPATCHED) { refresher.refresh() }
|
||||
|
||||
networkGate.complete(Unit)
|
||||
|
||||
val resultA = a.await()
|
||||
val resultB = b.await()
|
||||
|
||||
// Both waiters receive the same transient failure; the failing network call wasn't repeated
|
||||
// — protects against amplifying outages or replaying a possibly-consumed refresh token.
|
||||
assertThat(resultA).isEqualTo(resultB)
|
||||
assertThat(resultA.leftOrNull()).isInstanceOf(SessionRefreshError.Api::class.java)
|
||||
coVerify(exactly = 1) { authApi.refresh(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent waiters receive the same exception when the owner's refresh throws`() = runTest {
|
||||
val stored = SessionTokens(
|
||||
accessToken = "old-access",
|
||||
accessTokenExpiresAt = fixedClock.now().plus(60),
|
||||
refreshToken = "rt-1",
|
||||
refreshTokenExpiresAt = fixedClock.now().plus(3600),
|
||||
walletIds = listOf("w1"),
|
||||
)
|
||||
coEvery { store.get() } returns Some(stored)
|
||||
|
||||
val networkGate = CompletableDeferred<Unit>()
|
||||
coEvery { authApi.refresh(any()) } coAnswers {
|
||||
networkGate.await()
|
||||
throw IllegalStateException("boom")
|
||||
}
|
||||
|
||||
val a = async(start = CoroutineStart.UNDISPATCHED) { runCatching { refresher.refresh() } }
|
||||
val b = async(start = CoroutineStart.UNDISPATCHED) { runCatching { refresher.refresh() } }
|
||||
|
||||
networkGate.complete(Unit)
|
||||
|
||||
val resultA = a.await()
|
||||
val resultB = b.await()
|
||||
|
||||
// Both the owner and the waiter receive the same `IllegalStateException` — proves waiters
|
||||
// can't suspend forever when the owner throws (deferred is completed exceptionally).
|
||||
assertThat(resultA.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java)
|
||||
assertThat(resultB.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java)
|
||||
coVerify(exactly = 1) { authApi.refresh(any()) }
|
||||
|
||||
// The inFlight slot must be cleared so the next call can start a fresh attempt.
|
||||
coEvery { authApi.refresh(any()) } returns ApiResponse.Success(
|
||||
data = TokenApiResponse(
|
||||
accessToken = "after-recovery",
|
||||
accessTokenExpiresAt = "2024-01-01T00:00:00Z",
|
||||
refreshToken = "rt-2",
|
||||
refreshTokenExpiresAt = "2024-02-01T00:00:00Z",
|
||||
walletIds = listOf("w1"),
|
||||
),
|
||||
)
|
||||
val recovered = refresher.refresh()
|
||||
assertThat(recovered.getOrNull()?.accessToken).isEqualTo("after-recovery")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh skips refresh endpoint when refresh token expired`() = runTest {
|
||||
val stored = SessionTokens(
|
||||
accessToken = "old-access",
|
||||
accessTokenExpiresAt = fixedClock.now().minus(60),
|
||||
refreshToken = "rt-1",
|
||||
refreshTokenExpiresAt = fixedClock.now().minus(1),
|
||||
walletIds = listOf("w1"),
|
||||
)
|
||||
coEvery { store.get() } returns Some(stored)
|
||||
stubAuthenticateHappyPath()
|
||||
|
||||
val result = refresher.refresh()
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
coVerify(exactly = 0) { authApi.refresh(any()) }
|
||||
coVerify { authApi.authenticate(any()) }
|
||||
}
|
||||
|
||||
private fun stubAuthenticateHappyPath() {
|
||||
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
|
||||
coEvery { authApi.requestAuthNonce(any()) } returns ApiResponse.Success(
|
||||
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
|
||||
)
|
||||
coEvery { nonceDecryptor.decryptNonce("abc") } returns "nonce-decrypted"
|
||||
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64)
|
||||
coEvery { authApi.authenticate(any<AuthApiRequest>()) } returns ApiResponse.Success(
|
||||
data = TokenApiResponse(
|
||||
accessToken = "post-auth-access",
|
||||
accessTokenExpiresAt = "2024-01-01T00:00:00Z",
|
||||
refreshToken = "post-auth-rt",
|
||||
refreshTokenExpiresAt = "2024-02-01T00:00:00Z",
|
||||
walletIds = listOf("w1"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Instant.plus(seconds: Long): Instant = Instant.fromEpochSeconds(epochSeconds + seconds)
|
||||
private fun Instant.minus(seconds: Long): Instant = Instant.fromEpochSeconds(epochSeconds - seconds)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue