Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-28 16:01:39 +04:00
parent fb96f3651f
commit b270b6980a
14 changed files with 289 additions and 0 deletions

View file

@ -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<NonceApiResponse>
/**
* 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<TokenApiResponse>
/**
* 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<TokenApiResponse>
}

View file

@ -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 <access-token>` + `DPoP: <proof-jwt>`
* 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

View file

@ -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?,
)
}

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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?,
)

View file

@ -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<String>,
)

View file

@ -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
}
}

View file

@ -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<ApiEnvironmentConfig> = 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/"
}
}

View file

@ -113,4 +113,10 @@ internal object ApiConfigsModule {
fun provideSurveySparrowConfig(environmentConfig: EnvironmentConfig): ApiConfig {
return SurveySparrow(environmentConfig)
}
@Provides
@IntoSet
fun provideAuthConfig(): ApiConfig {
return Auth()
}
}

View file

@ -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 {

View file

@ -88,6 +88,7 @@ class ApiConfigTest {
appInfoProvider = mockk(),
)
ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig)
ApiConfig.ID.Auth -> Auth()
}
}
}

View file

@ -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,