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)) + } }