feat(simkl): add secure PKCE authentication

This commit is contained in:
tapframe 2026-07-22 02:06:31 +05:30
parent 857311cce7
commit 8f2f7221c2
24 changed files with 1124 additions and 12 deletions

View file

@ -40,6 +40,17 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="nuvio"
android:host="auth"
android:path="/simkl" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="nuvio"
android:host="auth"

View file

@ -108,6 +108,21 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
)
}
outDir.resolve("com/nuvio/app/features/simkl").apply {
mkdirs()
resolve("SimklConfig.kt").writeText(
"""
|package com.nuvio.app.features.simkl
|
|object SimklConfig {
| const val CLIENT_ID = "${props.getProperty("SIMKL_CLIENT_ID", "")}"
| const val REDIRECT_URI = "${props.getProperty("SIMKL_REDIRECT_URI", "nuvio://auth/simkl")}"
| const val APP_NAME = "${props.getProperty("SIMKL_APP_NAME", "nuvio")}"
|}
""".trimMargin()
)
}
outDir.resolve("com/nuvio/app/features/player/skip").apply {
mkdirs()
resolve("IntroDbConfig.kt").writeText(

View file

@ -48,6 +48,7 @@ import com.nuvio.app.features.trakt.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktLibraryStorage
import com.nuvio.app.features.trakt.TraktSettingsStorage
import com.nuvio.app.features.simkl.SimklAuthStorage
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
import com.nuvio.app.core.ui.CardDepthStyleStorage
@ -104,6 +105,7 @@ class MainActivity : AppCompatActivity() {
TraktCommentsStorage.initialize(applicationContext)
TraktLibraryStorage.initialize(applicationContext)
TraktSettingsStorage.initialize(applicationContext)
SimklAuthStorage.initialize(applicationContext)
LibraryDisplaySettingsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext)
ResumePromptStorage.initialize(applicationContext)

View file

@ -19,6 +19,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_mdblist_settings",
"nuvio_auth",
"nuvio_trakt_auth",
"nuvio_simkl_auth",
"nuvio_trakt_library",
"nuvio_trakt_settings",
"nuvio_watched",

View file

@ -0,0 +1,127 @@
package com.nuvio.app.features.simkl
import android.content.Context
import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import com.nuvio.app.core.storage.ProfileScopedKey
import java.security.KeyStore
import java.security.MessageDigest
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
internal actual object SimklPlatformClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
}
internal actual object SimklPkceCrypto {
private val secureRandom = SecureRandom()
actual fun secureRandomBytes(size: Int): ByteArray =
ByteArray(size).also(secureRandom::nextBytes)
actual fun sha256(value: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(value)
}
internal actual object SimklAuthStorage {
private const val PREFERENCES_NAME = "nuvio_simkl_auth"
private const val METADATA_KEY = "simkl_auth_metadata"
private const val ACCESS_TOKEN_KEY = "simkl_access_token"
private const val CODE_VERIFIER_KEY = "simkl_code_verifier"
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val KEY_ALIAS = "nuvio.simkl.credentials.v1"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_TAG_BITS = 128
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
}
actual fun loadMetadataPayload(): String? =
preferences?.getString(ProfileScopedKey.of(METADATA_KEY), null)
actual fun saveMetadataPayload(payload: String) {
preferences?.edit()?.putString(ProfileScopedKey.of(METADATA_KEY), payload)?.apply()
}
actual fun loadAccessToken(): String? = loadEncrypted(ACCESS_TOKEN_KEY)
actual fun saveAccessToken(value: String?) = saveEncrypted(ACCESS_TOKEN_KEY, value)
actual fun loadCodeVerifier(): String? = loadEncrypted(CODE_VERIFIER_KEY)
actual fun saveCodeVerifier(value: String?) = saveEncrypted(CODE_VERIFIER_KEY, value)
actual fun removeProfile(profileId: Int) {
preferences?.edit()
?.remove(ProfileScopedKey.of(METADATA_KEY, profileId))
?.remove(ProfileScopedKey.of(ACCESS_TOKEN_KEY, profileId))
?.remove(ProfileScopedKey.of(CODE_VERIFIER_KEY, profileId))
?.apply()
}
private fun loadEncrypted(key: String): String? {
val scopedKey = ProfileScopedKey.of(key)
val stored = preferences?.getString(scopedKey, null) ?: return null
return runCatching { decrypt(stored) }
.onFailure { preferences?.edit()?.remove(scopedKey)?.apply() }
.getOrNull()
}
private fun saveEncrypted(key: String, value: String?) {
val scopedKey = ProfileScopedKey.of(key)
val editor = preferences?.edit() ?: return
if (value.isNullOrBlank()) {
editor.remove(scopedKey).apply()
} else {
editor.putString(scopedKey, encrypt(value)).apply()
}
}
private fun encrypt(value: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey())
val ciphertext = cipher.doFinal(value.encodeToByteArray())
return "${cipher.iv.toBase64()}.${ciphertext.toBase64()}"
}
private fun decrypt(value: String): String {
val separator = value.indexOf('.')
require(separator > 0 && separator < value.lastIndex) { "Invalid encrypted credential" }
val iv = value.substring(0, separator).fromBase64()
val ciphertext = value.substring(separator + 1).fromBase64()
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
return cipher.doFinal(ciphertext).decodeToString()
}
private fun getOrCreateSecretKey(): SecretKey {
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER).run {
init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
generateKey()
}
}
private fun ByteArray.toBase64(): String =
Base64.encodeToString(this, Base64.NO_WRAP)
private fun String.fromBase64(): ByteArray =
Base64.decode(this, Base64.NO_WRAP)
}

View file

@ -23,4 +23,11 @@ internal actual object TraktAuthStorage {
?.putString(ProfileScopedKey.of(payloadKey), payload)
?.apply()
}
actual fun removeProfile(profileId: Int) {
preferences
?.edit()
?.remove(ProfileScopedKey.of(payloadKey, profileId))
?.apply()
}
}

View file

@ -1,6 +1,7 @@
package com.nuvio.app.core.deeplink
import com.nuvio.app.features.trakt.handleTraktAuthCallbackUrl
import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import io.ktor.http.Url
import io.ktor.http.encodeURLParameter
import kotlinx.coroutines.flow.MutableStateFlow
@ -41,7 +42,8 @@ fun handleAppUrl(url: String) {
val normalizedUrl = url.trim()
if (normalizedUrl.isBlank()) return
handleTraktAuthCallbackUrl(normalizedUrl)
ensureTrackingProvidersRegistered()
TrackingProviderRegistry.handleAuthCallback(normalizedUrl)
AppDeepLinkRepository.handleUrl(normalizedUrl)
}

View file

@ -3,6 +3,7 @@ package com.nuvio.app.core.storage
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.sync.SyncManager
import com.nuvio.app.core.sync.ProfileSettingsSync
import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
@ -20,13 +21,14 @@ import com.nuvio.app.features.p2p.P2pSettingsRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.player.SubtitleRepository
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.profiles.MAX_PROFILES
import com.nuvio.app.features.search.SearchRepository
import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.streams.StreamContextStore
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
import com.nuvio.app.features.streams.StreamLaunchStore
import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.core.ui.CardDepthStyleRepository
import com.nuvio.app.core.ui.PosterCardStyleRepository
@ -38,6 +40,8 @@ import com.nuvio.app.features.watched.WatchedRepository
internal object LocalAccountDataCleaner {
fun wipe() {
ensureTrackingProvidersRegistered()
TrackingProviderRegistry.removeStoredProfiles(1..MAX_PROFILES)
SyncManager.cancelAccountSync()
WatchProgressSourceCoordinator.clearLocalState()
ProfileSettingsSync.clearAccountState()
@ -65,7 +69,7 @@ internal object LocalAccountDataCleaner {
ThemeSettingsRepository.clearLocalState()
PosterCardStyleRepository.clearLocalState()
CardDepthStyleRepository.clearLocalState()
TraktAuthRepository.clearLocalState()
TrackingProviderRegistry.clearLocalState()
TraktSettingsRepository.clearLocalState()
PlayerSettingsRepository.clearLocalState()
StreamBadgeSettingsRepository.clearLocalState()

View file

@ -0,0 +1,9 @@
package com.nuvio.app.core.tracking
import com.nuvio.app.features.simkl.SimklAuthRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
fun ensureTrackingProvidersRegistered() {
TraktAuthRepository.descriptor
SimklAuthRepository.descriptor
}

View file

@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.auth.isAnonymous
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.core.sync.putSyncOriginClientId
import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
import com.nuvio.app.features.collection.CollectionRepository
@ -25,7 +26,7 @@ import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.search.SearchHistoryRepository
import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watched.WatchedRepository
@ -155,7 +156,8 @@ object ProfileRepository {
persist()
WatchedRepository.onProfileChanged(profileIndex)
TraktSettingsRepository.onProfileChanged()
TraktAuthRepository.onProfileChanged()
ensureTrackingProvidersRegistered()
TrackingProviderRegistry.onProfileChanged()
LibraryRepository.onProfileChanged(profileIndex)
LibraryDisplaySettingsRepository.onProfileChanged()
WatchProgressRepository.onProfileChanged(profileIndex)

View file

@ -0,0 +1,45 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.core.build.AppVersionConfig
import io.ktor.http.encodeURLParameter
internal const val SIMKL_API_BASE_URL = "https://api.simkl.com"
internal const val SIMKL_AUTHORIZE_URL = "https://simkl.com/oauth/authorize"
internal val simklAppVersion: String
get() = AppVersionConfig.VERSION_NAME.ifBlank { "dev" }
internal fun buildSimklApiUrl(
path: String,
query: Map<String, String> = emptyMap(),
): String {
val normalizedPath = path.trim().let { value ->
if (value.startsWith('/')) value else "/$value"
}
val parameters = linkedMapOf(
"client_id" to SimklConfig.CLIENT_ID,
"app-name" to SimklConfig.APP_NAME,
"app-version" to simklAppVersion,
).apply { putAll(query) }
return buildString {
append(SIMKL_API_BASE_URL)
append(normalizedPath)
append('?')
append(
parameters.entries.joinToString("&") { (key, value) ->
"${key.encodeURLParameter()}=${value.encodeURLParameter()}"
},
)
}
}
internal fun simklRequestHeaders(
accessToken: String? = null,
contentTypeJson: Boolean = false,
): Map<String, String> = buildMap {
put("User-Agent", "${SimklConfig.APP_NAME}/$simklAppVersion")
accessToken?.trim()?.takeIf(String::isNotBlank)?.let { token ->
put("Authorization", "Bearer $token")
}
if (contentTypeJson) put("Content-Type", "application/json")
}

View file

@ -0,0 +1,52 @@
package com.nuvio.app.features.simkl
import kotlinx.serialization.Serializable
enum class SimklConnectionMode {
DISCONNECTED,
AWAITING_APPROVAL,
CONNECTED,
}
enum class SimklAuthError {
MISSING_CLIENT_ID,
INVALID_CALLBACK,
INVALID_CALLBACK_STATE,
AUTHORIZATION_EXPIRED,
TOKEN_EXCHANGE_FAILED,
INVALID_TOKEN_RESPONSE,
AUTHORIZATION_REVOKED,
}
data class SimklAuthUiState(
val mode: SimklConnectionMode = SimklConnectionMode.DISCONNECTED,
val credentialsConfigured: Boolean = false,
val isLoading: Boolean = false,
val username: String? = null,
val accountId: Long? = null,
val tokenExpiresAtEpochMs: Long? = null,
val pendingAuthorizationStartedAtEpochMs: Long? = null,
val error: SimklAuthError? = null,
)
@Serializable
internal data class SimklStoredAuthState(
val username: String? = null,
val accountId: Long? = null,
val tokenExpiresAtEpochMs: Long? = null,
val pendingAuthorizationState: String? = null,
val pendingAuthorizationStartedAtEpochMs: Long? = null,
) {
val hasPendingAuthorization: Boolean
get() = !pendingAuthorizationState.isNullOrBlank()
}
internal sealed interface SimklAuthCallback {
data class AuthorizationCode(
val code: String,
val state: String,
) : SimklAuthCallback
data object Invalid : SimklAuthCallback
data object NotSimkl : SimklAuthCallback
}

View file

@ -0,0 +1,64 @@
package com.nuvio.app.features.simkl
import io.ktor.http.Url
import io.ktor.http.encodeURLParameter
internal const val SIMKL_AUTHORIZATION_TIMEOUT_MS = 5L * 60L * 1_000L
internal fun parseSimklAuthCallback(
callbackUrl: String,
redirectUri: String,
): SimklAuthCallback {
if (callbackUrl != redirectUri && !callbackUrl.startsWith("$redirectUri?")) {
return SimklAuthCallback.NotSimkl
}
val parsed = runCatching { Url(callbackUrl) }.getOrNull() ?: return SimklAuthCallback.Invalid
val code = parsed.parameters["code"].orEmpty().trim()
val state = parsed.parameters["state"].orEmpty().trim()
if (code.isBlank() || state.isBlank()) return SimklAuthCallback.Invalid
return SimklAuthCallback.AuthorizationCode(code = code, state = state)
}
internal fun isSimklAuthorizationExpired(
startedAtEpochMs: Long?,
nowEpochMs: Long,
): Boolean = startedAtEpochMs == null ||
nowEpochMs < startedAtEpochMs ||
nowEpochMs - startedAtEpochMs > SIMKL_AUTHORIZATION_TIMEOUT_MS
internal fun constantTimeEquals(left: String, right: String): Boolean {
val leftBytes = left.encodeToByteArray()
val rightBytes = right.encodeToByteArray()
val length = maxOf(leftBytes.size, rightBytes.size)
var difference = leftBytes.size xor rightBytes.size
for (index in 0 until length) {
val leftByte = leftBytes.getOrElse(index) { 0 }.toInt()
val rightByte = rightBytes.getOrElse(index) { 0 }.toInt()
difference = difference or (leftByte xor rightByte)
}
return difference == 0
}
internal fun buildSimklAuthorizationUrl(
clientId: String,
redirectUri: String,
appName: String,
appVersion: String,
material: SimklPkceMaterial,
): String = buildString {
append(SIMKL_AUTHORIZE_URL)
append("?response_type=code")
append("&client_id=")
append(clientId.encodeURLParameter())
append("&redirect_uri=")
append(redirectUri.encodeURLParameter())
append("&code_challenge=")
append(material.challenge.encodeURLParameter())
append("&code_challenge_method=S256")
append("&state=")
append(material.state.encodeURLParameter())
append("&app-name=")
append(appName.encodeURLParameter())
append("&app-version=")
append(appVersion.encodeURLParameter())
}

View file

@ -0,0 +1,414 @@
package com.nuvio.app.features.simkl
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpRequestRaw
import com.nuvio.app.features.tracking.TrackingAuthProvider
import com.nuvio.app.features.tracking.TrackingCapability
import com.nuvio.app.features.tracking.TrackingProviderDescriptor
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object SimklAuthRepository : TrackingAuthProvider {
private val log = Logger.withTag("SimklAuth")
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val authorizationMutex = Mutex()
private val _uiState = MutableStateFlow(SimklAuthUiState())
val uiState: StateFlow<SimklAuthUiState> = _uiState.asStateFlow()
private val _isAuthenticated = MutableStateFlow(false)
override val isAuthenticated: StateFlow<Boolean> = _isAuthenticated.asStateFlow()
override val descriptor = TrackingProviderDescriptor(
id = TrackingProviderId.SIMKL,
displayName = "Simkl",
capabilities = setOf(
TrackingCapability.AUTHENTICATION,
TrackingCapability.LIBRARY_READ,
TrackingCapability.LIBRARY_WRITE,
TrackingCapability.WATCHED_READ,
TrackingCapability.WATCHED_WRITE,
TrackingCapability.PROGRESS_READ,
TrackingCapability.PROGRESS_WRITE,
TrackingCapability.SCROBBLE,
),
)
private var hasLoaded = false
private var storedState = SimklStoredAuthState()
private var accessToken: String? = null
init {
TrackingProviderRegistry.register(this)
}
override fun ensureLoaded() {
if (hasLoaded) return
loadFromDisk()
}
override fun onProfileChanged() {
loadFromDisk()
}
override fun clearLocalState() {
hasLoaded = false
storedState = SimklStoredAuthState()
accessToken = null
publish()
}
override fun removeStoredProfile(profileId: Int) {
SimklAuthStorage.removeProfile(profileId)
}
fun snapshot(): SimklAuthUiState {
ensureLoaded()
return uiState.value
}
fun hasRequiredCredentials(): Boolean = SimklConfig.CLIENT_ID.isNotBlank()
fun onConnectRequested(): String? {
ensureLoaded()
if (!hasRequiredCredentials()) {
publish(error = SimklAuthError.MISSING_CLIENT_ID)
return null
}
val material = generateSimklPkceMaterial()
SimklAuthStorage.saveCodeVerifier(material.verifier)
storedState = storedState.copy(
pendingAuthorizationState = material.state,
pendingAuthorizationStartedAtEpochMs = SimklPlatformClock.nowEpochMs(),
)
persistMetadata()
publish(error = null)
return authorizationUrl(material)
}
fun pendingAuthorizationUrl(): String? {
ensureLoaded()
val state = storedState.pendingAuthorizationState?.takeIf(String::isNotBlank) ?: return null
val verifier = SimklAuthStorage.loadCodeVerifier()?.takeIf(String::isNotBlank) ?: run {
clearPendingAuthorization()
persistMetadata()
publish(error = SimklAuthError.AUTHORIZATION_EXPIRED)
return null
}
if (isSimklAuthorizationExpired(
startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs,
nowEpochMs = SimklPlatformClock.nowEpochMs(),
)
) {
clearPendingAuthorization()
persistMetadata()
publish(error = SimklAuthError.AUTHORIZATION_EXPIRED)
return null
}
return authorizationUrl(
SimklPkceMaterial(
verifier = verifier,
challenge = SimklPkceCrypto.sha256(verifier.encodeToByteArray()).base64UrlWithoutPadding(),
state = state,
),
)
}
fun onCancelAuthorization() {
ensureLoaded()
clearPendingAuthorization()
persistMetadata()
publish(error = null)
}
override fun handleAuthCallback(url: String): Boolean {
ensureLoaded()
return when (val callback = parseSimklAuthCallback(url, SimklConfig.REDIRECT_URI)) {
SimklAuthCallback.NotSimkl -> false
SimklAuthCallback.Invalid -> {
clearPendingAuthorization()
persistMetadata()
publish(error = SimklAuthError.INVALID_CALLBACK)
true
}
is SimklAuthCallback.AuthorizationCode -> {
scope.launch { completeAuthorization(callback) }
true
}
}
}
fun onDisconnectRequested() {
ensureLoaded()
accessToken = null
SimklAuthStorage.saveAccessToken(null)
clearPendingAuthorization()
storedState = SimklStoredAuthState()
persistMetadata()
publish(error = null)
}
internal fun authorizedAccessToken(): String? {
ensureLoaded()
val token = accessToken?.takeIf(String::isNotBlank) ?: return null
val expiresAt = storedState.tokenExpiresAtEpochMs
if (expiresAt != null && SimklPlatformClock.nowEpochMs() >= expiresAt - TOKEN_EXPIRY_SKEW_MS) {
invalidateCredentials(SimklAuthError.AUTHORIZATION_EXPIRED)
return null
}
return token
}
internal fun onUnauthorizedResponse() {
invalidateCredentials(SimklAuthError.AUTHORIZATION_REVOKED)
}
suspend fun refreshUserSettings(): String? {
val token = authorizedAccessToken() ?: return null
return fetchAndStoreUserSettings(token)
}
private suspend fun completeAuthorization(callback: SimklAuthCallback.AuthorizationCode) =
authorizationMutex.withLock {
publish(isLoading = true, error = null)
val expectedState = storedState.pendingAuthorizationState
val verifier = SimklAuthStorage.loadCodeVerifier()
val isExpired = isSimklAuthorizationExpired(
startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs,
nowEpochMs = SimklPlatformClock.nowEpochMs(),
)
if (expectedState.isNullOrBlank() || verifier.isNullOrBlank() || isExpired) {
clearPendingAuthorization()
persistMetadata()
publish(isLoading = false, error = SimklAuthError.AUTHORIZATION_EXPIRED)
return@withLock
}
if (!constantTimeEquals(callback.state, expectedState)) {
clearPendingAuthorization()
persistMetadata()
publish(isLoading = false, error = SimklAuthError.INVALID_CALLBACK_STATE)
return@withLock
}
val request = SimklTokenRequest(
code = callback.code,
clientId = SimklConfig.CLIENT_ID,
codeVerifier = verifier,
redirectUri = SimklConfig.REDIRECT_URI,
)
val response = try {
httpRequestRaw(
method = "POST",
url = "$SIMKL_API_BASE_URL/oauth/token",
headers = simklRequestHeaders(contentTypeJson = true),
body = json.encodeToString(request),
)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.w { "Simkl token exchange failed: ${error.message}" }
clearPendingAuthorization()
persistMetadata()
publish(isLoading = false, error = SimklAuthError.TOKEN_EXCHANGE_FAILED)
return@withLock
}
if (response.status !in 200..299) {
clearPendingAuthorization()
persistMetadata()
publish(isLoading = false, error = SimklAuthError.TOKEN_EXCHANGE_FAILED)
return@withLock
}
val token = runCatching { json.decodeFromString<SimklTokenResponse>(response.body) }
.getOrNull()
?.takeIf { it.accessToken.isNotBlank() }
if (token == null) {
clearPendingAuthorization()
persistMetadata()
publish(isLoading = false, error = SimklAuthError.INVALID_TOKEN_RESPONSE)
return@withLock
}
accessToken = token.accessToken
SimklAuthStorage.saveAccessToken(token.accessToken)
clearPendingAuthorization()
storedState = storedState.copy(
tokenExpiresAtEpochMs = token.expiresIn
?.takeIf { seconds -> seconds > 0L }
?.let { seconds -> SimklPlatformClock.nowEpochMs() + seconds * 1_000L },
)
persistMetadata()
publish(isLoading = false, error = null)
fetchAndStoreUserSettings(token.accessToken)
}
private suspend fun fetchAndStoreUserSettings(token: String): String? {
val response = try {
httpRequestRaw(
method = "POST",
url = buildSimklApiUrl("/users/settings"),
headers = simklRequestHeaders(accessToken = token, contentTypeJson = true),
body = "{}",
)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.w { "Failed to fetch Simkl user settings: ${error.message}" }
return null
}
if (response.status == 401) {
onUnauthorizedResponse()
return null
}
if (response.status !in 200..299) return null
val settings = runCatching { json.decodeFromString<SimklUserSettingsResponse>(response.body) }
.getOrNull() ?: return null
storedState = storedState.copy(
username = settings.user?.name,
accountId = settings.account?.id,
)
persistMetadata()
publish(error = null)
return storedState.username
}
private fun loadFromDisk() {
hasLoaded = true
storedState = SimklAuthStorage.loadMetadataPayload()
?.trim()
?.takeIf(String::isNotEmpty)
?.let { payload ->
runCatching { json.decodeFromString<SimklStoredAuthState>(payload) }
.onFailure { error -> log.w { "Failed to parse Simkl auth metadata: ${error.message}" } }
.getOrNull()
}
?: SimklStoredAuthState()
accessToken = SimklAuthStorage.loadAccessToken()?.takeIf(String::isNotBlank)
if (accessToken != null && storedState.tokenExpiresAtEpochMs?.let { expiresAt ->
SimklPlatformClock.nowEpochMs() >= expiresAt - TOKEN_EXPIRY_SKEW_MS
} == true
) {
accessToken = null
SimklAuthStorage.saveAccessToken(null)
storedState = SimklStoredAuthState()
persistMetadata()
}
if (storedState.hasPendingAuthorization && isSimklAuthorizationExpired(
startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs,
nowEpochMs = SimklPlatformClock.nowEpochMs(),
)
) {
clearPendingAuthorization()
persistMetadata()
}
publish(error = null)
}
private fun invalidateCredentials(error: SimklAuthError) {
accessToken = null
SimklAuthStorage.saveAccessToken(null)
clearPendingAuthorization()
storedState = SimklStoredAuthState()
persistMetadata()
publish(isLoading = false, error = error)
}
private fun clearPendingAuthorization() {
SimklAuthStorage.saveCodeVerifier(null)
storedState = storedState.copy(
pendingAuthorizationState = null,
pendingAuthorizationStartedAtEpochMs = null,
)
}
private fun persistMetadata() {
SimklAuthStorage.saveMetadataPayload(json.encodeToString(storedState))
}
private fun publish(
isLoading: Boolean = _uiState.value.isLoading,
error: SimklAuthError? = _uiState.value.error,
) {
val authenticated = !accessToken.isNullOrBlank()
_isAuthenticated.value = authenticated
_uiState.value = SimklAuthUiState(
mode = when {
authenticated -> SimklConnectionMode.CONNECTED
storedState.hasPendingAuthorization -> SimklConnectionMode.AWAITING_APPROVAL
else -> SimklConnectionMode.DISCONNECTED
},
credentialsConfigured = hasRequiredCredentials(),
isLoading = isLoading,
username = storedState.username,
accountId = storedState.accountId,
tokenExpiresAtEpochMs = storedState.tokenExpiresAtEpochMs,
pendingAuthorizationStartedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs,
error = error,
)
}
private fun authorizationUrl(material: SimklPkceMaterial): String =
buildSimklAuthorizationUrl(
clientId = SimklConfig.CLIENT_ID,
redirectUri = SimklConfig.REDIRECT_URI,
appName = SimklConfig.APP_NAME,
appVersion = simklAppVersion,
material = material,
)
private const val TOKEN_EXPIRY_SKEW_MS = 60_000L
}
@Serializable
private data class SimklTokenRequest(
val code: String,
@SerialName("client_id") val clientId: String,
@SerialName("code_verifier") val codeVerifier: String,
@SerialName("redirect_uri") val redirectUri: String,
@SerialName("grant_type") val grantType: String = "authorization_code",
)
@Serializable
private data class SimklTokenResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("token_type") val tokenType: String? = null,
val scope: String? = null,
@SerialName("expires_in") val expiresIn: Long? = null,
)
@Serializable
private data class SimklUserSettingsResponse(
val user: SimklUser? = null,
val account: SimklAccount? = null,
)
@Serializable
private data class SimklUser(
val name: String? = null,
)
@Serializable
private data class SimklAccount(
val id: Long? = null,
)

View file

@ -0,0 +1,51 @@
package com.nuvio.app.features.simkl
internal data class SimklPkceMaterial(
val verifier: String,
val challenge: String,
val state: String,
)
internal fun generateSimklPkceMaterial(): SimklPkceMaterial =
createSimklPkceMaterial(
verifierEntropy = SimklPkceCrypto.secureRandomBytes(32),
stateEntropy = SimklPkceCrypto.secureRandomBytes(32),
)
internal fun createSimklPkceMaterial(
verifierEntropy: ByteArray,
stateEntropy: ByteArray,
): SimklPkceMaterial {
require(verifierEntropy.size >= 32) { "PKCE verifier entropy must be at least 32 bytes" }
require(stateEntropy.size >= 16) { "OAuth state entropy must be at least 16 bytes" }
val verifier = verifierEntropy.base64UrlWithoutPadding()
require(verifier.length in 43..128) { "PKCE verifier must be 43-128 characters" }
return SimklPkceMaterial(
verifier = verifier,
challenge = SimklPkceCrypto.sha256(verifier.encodeToByteArray()).base64UrlWithoutPadding(),
state = stateEntropy.base64UrlWithoutPadding(),
)
}
internal fun ByteArray.base64UrlWithoutPadding(): String {
if (isEmpty()) return ""
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
return buildString((size * 4 + 2) / 3) {
var index = 0
while (index < size) {
val first = this@base64UrlWithoutPadding[index].toInt() and 0xff
val second = this@base64UrlWithoutPadding.getOrNull(index + 1)?.toInt()?.and(0xff)
val third = this@base64UrlWithoutPadding.getOrNull(index + 2)?.toInt()?.and(0xff)
append(alphabet[first ushr 2])
append(alphabet[((first and 0x03) shl 4) or ((second ?: 0) ushr 4)])
if (second != null) {
append(alphabet[((second and 0x0f) shl 2) or ((third ?: 0) ushr 6)])
}
if (third != null) {
append(alphabet[third and 0x3f])
}
index += 3
}
}
}

View file

@ -0,0 +1,20 @@
package com.nuvio.app.features.simkl
internal expect object SimklPlatformClock {
fun nowEpochMs(): Long
}
internal expect object SimklPkceCrypto {
fun secureRandomBytes(size: Int): ByteArray
fun sha256(value: ByteArray): ByteArray
}
internal expect object SimklAuthStorage {
fun loadMetadataPayload(): String?
fun saveMetadataPayload(payload: String)
fun loadAccessToken(): String?
fun saveAccessToken(value: String?)
fun loadCodeVerifier(): String?
fun saveCodeVerifier(value: String?)
fun removeProfile(profileId: Int)
}

View file

@ -45,6 +45,8 @@ interface TrackingAuthProvider {
fun ensureLoaded()
fun onProfileChanged()
fun clearLocalState()
fun removeStoredProfile(profileId: Int)
fun handleAuthCallback(url: String): Boolean = false
}
object TrackingProviderRegistry {
@ -73,6 +75,29 @@ object TrackingProviderRegistry {
.filter { provider -> capability in provider.descriptor.capabilities }
.sortedBy { provider -> provider.descriptor.id.ordinal }
fun handleAuthCallback(url: String): Boolean =
providersWith(TrackingCapability.AUTHENTICATION)
.any { provider -> provider.handleAuthCallback(url) }
fun ensureLoaded() {
providerSnapshot().forEach(TrackingAuthProvider::ensureLoaded)
}
fun onProfileChanged() {
providerSnapshot().forEach(TrackingAuthProvider::onProfileChanged)
}
fun clearLocalState() {
providerSnapshot().forEach(TrackingAuthProvider::clearLocalState)
}
fun removeStoredProfiles(profileIds: Iterable<Int>) {
val providers = providerSnapshot()
profileIds.forEach { profileId ->
providers.forEach { provider -> provider.removeStoredProfile(profileId) }
}
}
private fun providerSnapshot(): List<TrackingAuthProvider> = synchronized(lock) {
authProviders.values.toList()
}

View file

@ -1,5 +0,0 @@
package com.nuvio.app.features.trakt
fun handleTraktAuthCallbackUrl(url: String) {
TraktAuthRepository.onAuthCallbackReceived(url)
}

View file

@ -91,6 +91,10 @@ object TraktAuthRepository : TrackingAuthProvider {
publish()
}
override fun removeStoredProfile(profileId: Int) {
TraktAuthStorage.removeProfile(profileId)
}
fun snapshot(): TraktAuthUiState {
ensureLoaded()
return _uiState.value
@ -154,6 +158,15 @@ object TraktAuthRepository : TrackingAuthProvider {
}
}
override fun handleAuthCallback(url: String): Boolean {
if (!isTraktAuthCallback(url)) return false
onAuthCallbackReceived(url)
return true
}
private fun isTraktAuthCallback(url: String): Boolean =
url == TraktConfig.REDIRECT_URI || url.startsWith("${TraktConfig.REDIRECT_URI}?")
suspend fun authorizedHeaders(): Map<String, String>? {
ensureLoaded()
if (!authState.isAuthenticated) return null

View file

@ -3,4 +3,5 @@ package com.nuvio.app.features.trakt
internal expect object TraktAuthStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
fun removeProfile(profileId: Int)
}

View file

@ -0,0 +1,79 @@
package com.nuvio.app.features.simkl
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
class SimklPkceTest {
@Test
fun `base64 url encoding is unpadded and url safe`() {
assertEquals("", byteArrayOf().base64UrlWithoutPadding())
assertEquals("Zg", "f".encodeToByteArray().base64UrlWithoutPadding())
assertEquals("Zm8", "fo".encodeToByteArray().base64UrlWithoutPadding())
assertEquals("Zm9v", "foo".encodeToByteArray().base64UrlWithoutPadding())
assertEquals("-_8", byteArrayOf(0xfb.toByte(), 0xff.toByte()).base64UrlWithoutPadding())
}
@Test
fun `pkce material uses compliant verifier and S256 challenge`() {
val material = createSimklPkceMaterial(
verifierEntropy = ByteArray(32) { it.toByte() },
stateEntropy = ByteArray(32) { (it + 32).toByte() },
)
assertEquals(43, material.verifier.length)
assertEquals("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", material.verifier)
assertEquals("6oZqdX5MOLq_qBJ8vppAnT4fk6AP8UiP9zX8-Rev_9A", material.challenge)
assertTrue(material.verifier.all { it.isLetterOrDigit() || it in "-._~" })
assertFalse('=' in material.verifier)
assertEquals(43, material.challenge.length)
assertTrue(material.state.length >= 22)
}
@Test
fun `oauth state comparison requires an exact value`() {
assertTrue(constantTimeEquals("same-state", "same-state"))
assertFalse(constantTimeEquals("same-state", "different-state"))
assertFalse(constantTimeEquals("same-state", "same-state-extra"))
}
@Test
fun `authorization URL uses browser host and exact S256 method`() {
val url = buildSimklAuthorizationUrl(
clientId = "client id",
redirectUri = "nuvio://auth/simkl",
appName = "nuvio",
appVersion = "1.2.3",
material = SimklPkceMaterial("verifier", "challenge", "state"),
)
assertTrue(url.startsWith("https://simkl.com/oauth/authorize?"))
assertTrue("client_id=client+id" in url || "client_id=client%20id" in url)
assertTrue("code_challenge_method=S256" in url)
assertTrue("redirect_uri=nuvio%3A%2F%2Fauth%2Fsimkl" in url)
}
@Test
fun `callback parser rejects other routes and missing state`() {
assertIs<SimklAuthCallback.NotSimkl>(
parseSimklAuthCallback("nuvio://auth/trakt?code=a&state=b", "nuvio://auth/simkl"),
)
assertIs<SimklAuthCallback.Invalid>(
parseSimklAuthCallback("nuvio://auth/simkl?code=a", "nuvio://auth/simkl"),
)
assertEquals(
SimklAuthCallback.AuthorizationCode(code = "a", state = "b"),
parseSimklAuthCallback("nuvio://auth/simkl?code=a&state=b", "nuvio://auth/simkl"),
)
}
@Test
fun `pending authorization expires after five minutes`() {
assertFalse(isSimklAuthorizationExpired(1_000L, 301_000L))
assertTrue(isSimklAuthorizationExpired(1_000L, 301_001L))
assertTrue(isSimklAuthorizationExpired(null, 1_000L))
assertTrue(isSimklAuthorizationExpired(2_000L, 1_000L))
}
}

View file

@ -1,6 +1,7 @@
package com.nuvio.app.core.storage
import platform.Foundation.NSUserDefaults
import com.nuvio.app.features.profiles.MAX_PROFILES
internal actual object PlatformLocalAccountDataCleaner {
private val plainKeys = listOf(
@ -52,6 +53,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"mdblist_use_letterboxd",
"mdblist_use_audience",
"trakt_auth_payload",
"simkl_auth_metadata",
"trakt_library_payload",
"trakt_settings_payload",
"library_display_settings_payload",
@ -65,7 +67,7 @@ internal actual object PlatformLocalAccountDataCleaner {
plainKeys.forEach(defaults::removeObjectForKey)
(1..4).forEach { profileId ->
(1..MAX_PROFILES).forEach { profileId ->
profileIndexedPrefixes.forEach { prefix ->
defaults.removeObjectForKey("$prefix$profileId")
}

View file

@ -0,0 +1,167 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256_DIGEST_LENGTH
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.alloc
import kotlinx.cinterop.get
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.refTo
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.value
import platform.CoreFoundation.CFDataCreate
import platform.CoreFoundation.CFDataGetBytePtr
import platform.CoreFoundation.CFDataGetLength
import platform.CoreFoundation.CFDataRef
import platform.CoreFoundation.CFDictionaryCreateMutable
import platform.CoreFoundation.CFDictionarySetValue
import platform.CoreFoundation.CFMutableDictionaryRef
import platform.CoreFoundation.CFRelease
import platform.CoreFoundation.CFStringCreateWithCString
import platform.CoreFoundation.CFTypeRefVar
import platform.CoreFoundation.kCFBooleanTrue
import platform.CoreFoundation.kCFStringEncodingUTF8
import platform.Foundation.NSDate
import platform.Foundation.NSUserDefaults
import platform.Foundation.timeIntervalSince1970
import platform.Security.SecItemAdd
import platform.Security.SecItemCopyMatching
import platform.Security.SecItemDelete
import platform.Security.SecRandomCopyBytes
import platform.Security.errSecItemNotFound
import platform.Security.errSecSuccess
import platform.Security.kSecAttrAccount
import platform.Security.kSecAttrService
import platform.Security.kSecClass
import platform.Security.kSecClassGenericPassword
import platform.Security.kSecMatchLimit
import platform.Security.kSecMatchLimitOne
import platform.Security.kSecRandomDefault
import platform.Security.kSecReturnData
import platform.Security.kSecValueData
internal actual object SimklPlatformClock {
actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1_000.0).toLong()
}
internal actual object SimklPkceCrypto {
@OptIn(ExperimentalForeignApi::class)
actual fun secureRandomBytes(size: Int): ByteArray {
require(size > 0)
val bytes = ByteArray(size)
val status = SecRandomCopyBytes(kSecRandomDefault, size.toULong(), bytes.refTo(0))
check(status == errSecSuccess) { "Secure random generation failed" }
return bytes
}
@OptIn(ExperimentalForeignApi::class)
actual fun sha256(value: ByteArray): ByteArray {
require(value.isNotEmpty())
val output = UByteArray(CC_SHA256_DIGEST_LENGTH.toInt())
CC_SHA256(value.refTo(0), value.size.toUInt(), output.refTo(0))
return ByteArray(output.size) { index -> output[index].toByte() }
}
}
internal actual object SimklAuthStorage {
private const val METADATA_KEY = "simkl_auth_metadata"
private const val ACCESS_TOKEN_KEY = "simkl_access_token"
private const val CODE_VERIFIER_KEY = "simkl_code_verifier"
private const val KEYCHAIN_SERVICE = "com.nuvio.media.simkl"
actual fun loadMetadataPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(METADATA_KEY))
actual fun saveMetadataPayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(METADATA_KEY))
}
actual fun loadAccessToken(): String? = loadKeychainValue(ACCESS_TOKEN_KEY)
actual fun saveAccessToken(value: String?) = saveKeychainValue(ACCESS_TOKEN_KEY, value)
actual fun loadCodeVerifier(): String? = loadKeychainValue(CODE_VERIFIER_KEY)
actual fun saveCodeVerifier(value: String?) = saveKeychainValue(CODE_VERIFIER_KEY, value)
actual fun removeProfile(profileId: Int) {
NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(METADATA_KEY, profileId))
deleteKeychainValue(ACCESS_TOKEN_KEY, profileId)
deleteKeychainValue(CODE_VERIFIER_KEY, profileId)
}
@OptIn(ExperimentalForeignApi::class)
private fun loadKeychainValue(key: String): String? = withKeychainQuery(key) { query ->
CFDictionarySetValue(query, kSecReturnData, kCFBooleanTrue)
CFDictionarySetValue(query, kSecMatchLimit, kSecMatchLimitOne)
memScoped {
val result = alloc<CFTypeRefVar>()
val status = SecItemCopyMatching(query, result.ptr)
if (status == errSecItemNotFound) return@memScoped null
if (status != errSecSuccess) return@memScoped null
val data: CFDataRef = result.value?.reinterpret() ?: return@memScoped null
try {
val length = CFDataGetLength(data).toInt()
val bytes = CFDataGetBytePtr(data) ?: return@memScoped null
ByteArray(length) { index -> bytes[index].toByte() }.decodeToString()
} finally {
CFRelease(data)
}
}
}
@OptIn(ExperimentalForeignApi::class)
private fun saveKeychainValue(key: String, value: String?) {
deleteKeychainValue(key)
if (value.isNullOrBlank()) return
withKeychainQuery(key) { query ->
val bytes = value.encodeToByteArray().toUByteArray()
val data = CFDataCreate(null, bytes.refTo(0), bytes.size.toLong())
?: error("Unable to encode Simkl credential")
try {
CFDictionarySetValue(query, kSecValueData, data)
check(SecItemAdd(query, null) == errSecSuccess) { "Unable to store Simkl credential" }
} finally {
CFRelease(data)
}
}
}
@OptIn(ExperimentalForeignApi::class)
private fun deleteKeychainValue(key: String, profileId: Int? = null) {
withKeychainQuery(key, profileId) { query -> SecItemDelete(query) }
}
@OptIn(ExperimentalForeignApi::class)
private inline fun <T> withKeychainQuery(
key: String,
profileId: Int? = null,
block: (CFMutableDictionaryRef) -> T,
): T {
val service = CFStringCreateWithCString(null, KEYCHAIN_SERVICE, kCFStringEncodingUTF8)
?: error("Unable to encode Keychain service")
val account = CFStringCreateWithCString(
null,
profileId?.let { id -> ProfileScopedKey.of(key, id) } ?: ProfileScopedKey.of(key),
kCFStringEncodingUTF8,
) ?: error("Unable to encode Keychain account")
val query = CFDictionaryCreateMutable(
allocator = null,
capacity = 0L,
keyCallBacks = null,
valueCallBacks = null,
) ?: error("Unable to create Keychain query")
try {
CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword)
CFDictionarySetValue(query, kSecAttrService, service)
CFDictionarySetValue(query, kSecAttrAccount, account)
return block(query)
} finally {
CFRelease(query)
CFRelease(account)
CFRelease(service)
}
}
}

View file

@ -12,4 +12,8 @@ internal actual object TraktAuthStorage {
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey))
}
actual fun removeProfile(profileId: Int) {
NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(payloadKey, profileId))
}
}