From 8f2f7221c2f59c47341ef80b2982ad9c1b0b21cf Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 22 Jul 2026 02:06:31 +0530
Subject: [PATCH] feat(simkl): add secure PKCE authentication
---
androidApp/src/main/AndroidManifest.xml | 11 +
composeApp/build.gradle.kts | 15 +
.../kotlin/com/nuvio/app/MainActivity.kt | 2 +
...PlatformLocalAccountDataCleaner.android.kt | 1 +
.../features/simkl/SimklPlatform.android.kt | 127 ++++++
.../trakt/TraktAuthStorage.android.kt | 7 +
.../nuvio/app/core/deeplink/AppUrlBridge.kt | 6 +-
.../core/storage/LocalAccountDataCleaner.kt | 8 +-
.../tracking/TrackingProviderBootstrap.kt | 9 +
.../features/profiles/ProfileRepository.kt | 6 +-
.../app/features/simkl/SimklApiMetadata.kt | 45 ++
.../app/features/simkl/SimklAuthModels.kt | 52 +++
.../app/features/simkl/SimklAuthParsing.kt | 64 +++
.../app/features/simkl/SimklAuthRepository.kt | 414 ++++++++++++++++++
.../com/nuvio/app/features/simkl/SimklPkce.kt | 51 +++
.../nuvio/app/features/simkl/SimklPlatform.kt | 20 +
.../app/features/tracking/TrackingProvider.kt | 25 ++
.../app/features/trakt/TraktAuthBridge.kt | 5 -
.../app/features/trakt/TraktAuthRepository.kt | 13 +
.../app/features/trakt/TraktAuthStorage.kt | 1 +
.../nuvio/app/features/simkl/SimklPkceTest.kt | 79 ++++
.../PlatformLocalAccountDataCleaner.ios.kt | 4 +-
.../app/features/simkl/SimklPlatform.ios.kt | 167 +++++++
.../features/trakt/TraktAuthStorage.ios.kt | 4 +
24 files changed, 1124 insertions(+), 12 deletions(-)
create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.android.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthParsing.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPkce.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthBridge.kt
create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPkceTest.kt
create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.ios.kt
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index c8e68ad95..c996f88e6 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -40,6 +40,17 @@
+
+
+
+
+
+
+
+
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)
+}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt
index cc8e84365..ea40727a4 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt
@@ -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()
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt
index 4089f5f16..5dde07007 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt
@@ -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)
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
index f00ca59ae..3fd89740c 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
@@ -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()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt
new file mode 100644
index 000000000..a35fddc0b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt
@@ -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
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
index c1c7ca369..62d1b417b 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
@@ -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)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt
new file mode 100644
index 000000000..a76bd9f74
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt
@@ -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 = 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 = 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")
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt
new file mode 100644
index 000000000..379f6d045
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt
@@ -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
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthParsing.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthParsing.kt
new file mode 100644
index 000000000..9d2e46bc6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthParsing.kt
@@ -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())
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt
new file mode 100644
index 000000000..ada9d59f5
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt
@@ -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 = _uiState.asStateFlow()
+
+ private val _isAuthenticated = MutableStateFlow(false)
+ override val isAuthenticated: StateFlow = _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(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(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(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,
+)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPkce.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPkce.kt
new file mode 100644
index 000000000..4dc0bf00b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPkce.kt
@@ -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
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.kt
new file mode 100644
index 000000000..18295df64
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.kt
@@ -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)
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt
index 49916f097..c12bf602a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt
@@ -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) {
+ val providers = providerSnapshot()
+ profileIds.forEach { profileId ->
+ providers.forEach { provider -> provider.removeStoredProfile(profileId) }
+ }
+ }
+
private fun providerSnapshot(): List = synchronized(lock) {
authProviders.values.toList()
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthBridge.kt
deleted file mode 100644
index 57e7c202b..000000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthBridge.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package com.nuvio.app.features.trakt
-
-fun handleTraktAuthCallbackUrl(url: String) {
- TraktAuthRepository.onAuthCallbackReceived(url)
-}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt
index e2c48d8d5..c1f503d28 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt
@@ -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? {
ensureLoaded()
if (!authState.isAuthenticated) return null
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt
index 3527be204..5337d208a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt
@@ -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)
}
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPkceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPkceTest.kt
new file mode 100644
index 000000000..cba7e81ce
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPkceTest.kt
@@ -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(
+ parseSimklAuthCallback("nuvio://auth/trakt?code=a&state=b", "nuvio://auth/simkl"),
+ )
+ assertIs(
+ 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))
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
index af7080491..536492d87 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
@@ -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")
}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.ios.kt
new file mode 100644
index 000000000..76c4b7819
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.ios.kt
@@ -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()
+ 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 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)
+ }
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt
index 3ccbaff4e..55b9a6e3f 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt
@@ -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))
+ }
}