From b270b6980a29928f5e93c9ef50b6137fabe56863 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 28 May 2026 16:01:39 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../com/tangem/datasource/api/auth/AuthApi.kt | 45 ++++++++++++++ .../api/auth/RequiresSessionAuth.kt | 16 +++++ .../api/auth/models/request/AuthApiRequest.kt | 46 +++++++++++++++ .../auth/models/request/NonceApiRequest.kt | 11 ++++ .../auth/models/request/RefreshApiRequest.kt | 11 ++++ .../auth/models/response/NonceApiResponse.kt | 13 ++++ .../models/response/ProblemDetailResponse.kt | 26 ++++++++ .../auth/models/response/TokenApiResponse.kt | 19 ++++++ .../datasource/api/common/config/ApiConfig.kt | 2 + .../datasource/api/common/config/Auth.kt | 59 +++++++++++++++++++ .../tangem/datasource/di/ApiConfigsModule.kt | 6 ++ .../com/tangem/datasource/di/NetworkModule.kt | 10 ++++ .../api/common/config/ApiConfigTest.kt | 1 + .../managers/ProdApiConfigsManagerTest.kt | 24 ++++++++ 14 files changed, 289 insertions(+) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/RequiresSessionAuth.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/AuthApiRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/NonceApiRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RefreshApiRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/NonceApiResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/ProblemDetailResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/TokenApiResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/common/config/Auth.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt new file mode 100644 index 0000000000..870c0cc60a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt @@ -0,0 +1,45 @@ +package com.tangem.datasource.api.auth + +import com.tangem.datasource.api.auth.models.request.AuthApiRequest +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.NonceApiResponse +import com.tangem.datasource.api.auth.models.response.TokenApiResponse +import com.tangem.datasource.api.common.response.ApiResponse +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Tangem Auth Service API (JWT session tokens / DPoP interceptor / refresh rotation) + */ +interface AuthApi { + + /** + * Request authentication nonce. + * + * Generates a nonce bound to the device public key for the authentication flow. + */ + @POST("api/v1/auth/nonce/auth") + suspend fun requestAuthNonce(@Body request: NonceApiRequest): ApiResponse + + /** + * Authenticate device. + * + * Authenticates a previously registered device using a device-key signature. Issues a new + * JWT access token with bound `walletIds[]` and risk tier. All subsequent auth after + * registration uses this endpoint. + */ + @POST("api/v1/auth/authenticate") + suspend fun authenticate(@Body request: AuthApiRequest): ApiResponse + + /** + * Refresh tokens. + * + * Rotates the refresh token and issues a new access token. Uses refresh-token rotation + * with family-based reuse detection — replaying a consumed token revokes the entire token + * family (SR-8). Sender-constraint is verified via the DPoP-proof header (`cnf.jkt`). + */ + @POST("api/v1/auth/refresh") + @RequiresSessionAuth + suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/RequiresSessionAuth.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/RequiresSessionAuth.kt new file mode 100644 index 0000000000..3ba07296b1 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/RequiresSessionAuth.kt @@ -0,0 +1,16 @@ +package com.tangem.datasource.api.auth + +/** + * Marks a Retrofit endpoint as requiring an authenticated session (DPoP, see + * [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)). + * + * Read at runtime by the session-auth interceptor: only methods + * carrying this annotation receive `Authorization: DPoP ` + `DPoP: ` + * headers; unannotated methods (e.g. public nonce endpoints) pass through unchanged. + * + * Mirrors the per-operation `security` blocks in the backend OpenAPI contract; follows the + * same on-method annotation pattern as `@ReadTimeout` / `@ConnectTimeout`. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class RequiresSessionAuth \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/AuthApiRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/AuthApiRequest.kt new file mode 100644 index 0000000000..4e9775755d --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/AuthApiRequest.kt @@ -0,0 +1,46 @@ +package com.tangem.datasource.api.auth.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Authentication request — authenticates a previously registered device. */ +@JsonClass(generateAdapter = true) +data class AuthApiRequest( + /** Signed authentication payload. */ + @Json(name = "payload") val payload: AuthenticationPayload, + /** EC signature over the authentication payload, signed by the device private key (Base64). */ + @Json(name = "signature") val signature: String, +) + +/** Signed authentication payload — the data that is signed by the device private key. */ +@JsonClass(generateAdapter = true) +data class AuthenticationPayload( + /** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */ + @Json(name = "devicePublicKey") val devicePublicKey: String, + /** Deciphered nonce value from the nonce endpoint. */ + @Json(name = "nonce") val nonce: String, + /** Platform attestation token (Play Integrity / App Attest). */ + @Json(name = "attestationToken") val attestationToken: String?, + /** Client-reported device metadata. */ + @Json(name = "metadata") val metadata: DeviceMetadata, +) { + + /** Device metadata collection. */ + @JsonClass(generateAdapter = true) + data class DeviceMetadata( + /** Device hardware model (e.g. `iPhone 15 Pro`). */ + @Json(name = "deviceModel") val deviceModel: String?, + /** Operating system (`android` / `ios`). */ + @Json(name = "os") val os: String, + /** OS version string (e.g. `17.4.1`). */ + @Json(name = "osVersion") val osVersion: String?, + /** Application version (e.g. `5.8.0`). */ + @Json(name = "appVersion") val appVersion: String?, + /** User-Agent header (e.g. `Tangem/5.8.0 (iPhone; iOS 17.4.1; Scale/3.00)`). */ + @Json(name = "userAgent") val userAgent: String?, + /** Client locale (e.g. `en-US`). */ + @Json(name = "locale") val locale: String?, + /** Client timezone (e.g. `Europe/Moscow`). */ + @Json(name = "timezone") val timezone: String?, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/NonceApiRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/NonceApiRequest.kt new file mode 100644 index 0000000000..4d28fb97a9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/NonceApiRequest.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.auth.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Request body for nonce generation (auth, upgrade, wallet flows). */ +@JsonClass(generateAdapter = true) +data class NonceApiRequest( + /** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */ + @Json(name = "devicePublicKey") val devicePublicKey: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RefreshApiRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RefreshApiRequest.kt new file mode 100644 index 0000000000..716084e642 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RefreshApiRequest.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.auth.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Token refresh request. */ +@JsonClass(generateAdapter = true) +data class RefreshApiRequest( + /** Refresh token from a previous token response. */ + @Json(name = "refreshToken") val refreshToken: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/NonceApiResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/NonceApiResponse.kt new file mode 100644 index 0000000000..9e4acadecd --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/NonceApiResponse.kt @@ -0,0 +1,13 @@ +package com.tangem.datasource.api.auth.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Ciphered nonce response. */ +@JsonClass(generateAdapter = true) +data class NonceApiResponse( + /** RSA-OAEP ciphered nonce value (Base64). */ + @Json(name = "cipheredNonce") val cipheredNonce: String, + /** Nonce expiration timestamp (ISO-8601). */ + @Json(name = "expiresAt") val expiresAt: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/ProblemDetailResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/ProblemDetailResponse.kt new file mode 100644 index 0000000000..18d938b3bb --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/ProblemDetailResponse.kt @@ -0,0 +1,26 @@ +package com.tangem.datasource.api.auth.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * RFC 9457 / RFC 7807 Problem Details response. Returned by Tangem Auth Service with + * `Content-Type: application/problem+json` on every 4xx / 5xx response. + */ +@JsonClass(generateAdapter = true) +data class ProblemDetailResponse( + /** URI identifying the problem type. */ + @Json(name = "type") val type: String, + /** Short human-readable summary (e.g. `"Too Many Requests"`). */ + @Json(name = "title") val title: String, + /** HTTP status code. */ + @Json(name = "status") val status: Int, + /** Human-readable explanation. */ + @Json(name = "detail") val detail: String?, + /** URI reference to this occurrence (e.g. `"/api/v1/auth/refresh"`). */ + @Json(name = "instance") val instance: String?, + /** Application-specific error code. */ + @Json(name = "code") val code: String?, + /** Retry delay for rate limiting (`429`). */ + @Json(name = "retryAfterSeconds") val retryAfterSeconds: Int?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/TokenApiResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/TokenApiResponse.kt new file mode 100644 index 0000000000..4eaa4f3155 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/TokenApiResponse.kt @@ -0,0 +1,19 @@ +package com.tangem.datasource.api.auth.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Token response — contains JWT access token and optional refresh token. */ +@JsonClass(generateAdapter = true) +data class TokenApiResponse( + /** JWT access token (HMAC-SHA256 signed). */ + @Json(name = "accessToken") val accessToken: String, + /** Access token expiration timestamp (ISO-8601). */ + @Json(name = "accessTokenExpiresAt") val accessTokenExpiresAt: String, + /** Refresh token for token rotation. `null` for ORANGE tier (requires device challenge each time). */ + @Json(name = "refreshToken") val refreshToken: String?, + /** Refresh token expiration timestamp (ISO-8601). `null` iff [refreshToken] is `null`. */ + @Json(name = "refreshTokenExpiresAt") val refreshTokenExpiresAt: String?, + /** List of wallet IDs bound to this device. */ + @Json(name = "walletIds") val walletIds: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index c4f1f54238..38a2f2df19 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -33,6 +33,7 @@ sealed class ApiConfig { News, GaslessTxService, SurveySparrow, + Auth, } private fun initializeId(): ID { @@ -49,6 +50,7 @@ sealed class ApiConfig { is News -> ID.News is GaslessTxService -> ID.GaslessTxService is SurveySparrow -> ID.SurveySparrow + is Auth -> ID.Auth } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Auth.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Auth.kt new file mode 100644 index 0000000000..b84cc5c5a4 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Auth.kt @@ -0,0 +1,59 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.BuildConfig + +/** + * Tangem Auth Service [ApiConfig] — endpoints for device registration, authentication, + * nonce issuance, refresh token rotation, and JWKS publication. + */ +internal class Auth : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() + + override val environmentConfigs: List = listOf( + createDevEnvironment(), + createMockedEnvironment(), + createProdEnvironment(), + ) + + private fun getInitialEnvironment(): ApiEnvironment { + return when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK + DEBUG_BUILD_TYPE, + INTERNAL_BUILD_TYPE, + -> ApiEnvironment.DEV + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + } + + private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = DEV_BASE_URL, + headers = emptyMap(), + ) + + private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.MOCK, + baseUrl = MOCK_BASE_URL, + headers = emptyMap(), + ) + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = PROD_BASE_URL, + headers = emptyMap(), + ) + + private companion object { + + // TODO Replace with real Auth Service hosts once the backend team confirms deployment. + // Swagger currently only declares `http://localhost:8080` for local development. + // [REDACTED_JIRA] + private const val DEV_BASE_URL = "http://localhost:8080/" + private const val MOCK_BASE_URL = "http://localhost:8080/" + private const val PROD_BASE_URL = "http://localhost:8080/" + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index 1ed7b48d48..4a56663883 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -113,4 +113,10 @@ internal object ApiConfigsModule { fun provideSurveySparrowConfig(environmentConfig: EnvironmentConfig): ApiConfig { return SurveySparrow(environmentConfig) } + + @Provides + @IntoSet + fun provideAuthConfig(): ApiConfig { + return Auth() + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 06bcabc816..33ec143d5e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.di import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.auth.AuthApi import com.tangem.datasource.api.common.blockaid.BlockAidApi import com.tangem.datasource.api.surveysparrow.SurveySparrowApi import com.tangem.datasource.api.common.config.ApiConfig @@ -208,6 +209,15 @@ internal object NetworkModule { ) } + @Provides + @Singleton + fun provideAuthApi(retrofitApiBuilder: RetrofitApiBuilder): AuthApi { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.Auth, + applyTimeoutAnnotations = false, + ) + } + @Provides @Singleton fun provideGaslessTxServiceApi(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApi { diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt index 1b886a0447..573031630f 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt @@ -88,6 +88,7 @@ class ApiConfigTest { appInfoProvider = mockk(), ) ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig) + ApiConfig.ID.Auth -> Auth() } } } diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index 3ef468d8db..ac33d421ac 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -130,6 +130,7 @@ internal class ProdApiConfigsManagerTest { appInfoProvider = appInfoProvider, ) ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig) + ApiConfig.ID.Auth -> Auth() } } } @@ -148,9 +149,32 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.News -> createNewsModel() ApiConfig.ID.GaslessTxService -> createGaslessTxServiceModel() ApiConfig.ID.SurveySparrow -> createSurveySparrowModel() + ApiConfig.ID.Auth -> createAuthModel() } } + private fun createAuthModel(): TestModel { + val environment = when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK + DEBUG_BUILD_TYPE, + INTERNAL_BUILD_TYPE, + -> ApiEnvironment.DEV + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + + return TestModel( + id = ApiConfig.ID.Auth, + expected = ApiEnvironmentConfig( + environment = environment, + baseUrl = "http://localhost:8080/", + headers = emptyMap(), + ), + ) + } + private fun createExpressModel(): TestModel { val environment = when (BuildConfig.BUILD_TYPE) { DEBUG_BUILD_TYPE,