mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 14:32:28 +00:00
feat(simkl): add secure PKCE authentication
This commit is contained in:
parent
857311cce7
commit
8f2f7221c2
24 changed files with 1124 additions and 12 deletions
|
|
@ -40,6 +40,17 @@
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data
|
||||||
|
android:scheme="nuvio"
|
||||||
|
android:host="auth"
|
||||||
|
android:path="/simkl" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data
|
<data
|
||||||
android:scheme="nuvio"
|
android:scheme="nuvio"
|
||||||
android:host="auth"
|
android:host="auth"
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,21 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
outDir.resolve("com/nuvio/app/features/simkl").apply {
|
||||||
|
mkdirs()
|
||||||
|
resolve("SimklConfig.kt").writeText(
|
||||||
|
"""
|
||||||
|
|package com.nuvio.app.features.simkl
|
||||||
|
|
|
||||||
|
|object SimklConfig {
|
||||||
|
| const val CLIENT_ID = "${props.getProperty("SIMKL_CLIENT_ID", "")}"
|
||||||
|
| const val REDIRECT_URI = "${props.getProperty("SIMKL_REDIRECT_URI", "nuvio://auth/simkl")}"
|
||||||
|
| const val APP_NAME = "${props.getProperty("SIMKL_APP_NAME", "nuvio")}"
|
||||||
|
|}
|
||||||
|
""".trimMargin()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
outDir.resolve("com/nuvio/app/features/player/skip").apply {
|
outDir.resolve("com/nuvio/app/features/player/skip").apply {
|
||||||
mkdirs()
|
mkdirs()
|
||||||
resolve("IntroDbConfig.kt").writeText(
|
resolve("IntroDbConfig.kt").writeText(
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import com.nuvio.app.features.trakt.TraktAuthStorage
|
||||||
import com.nuvio.app.features.trakt.TraktCommentsStorage
|
import com.nuvio.app.features.trakt.TraktCommentsStorage
|
||||||
import com.nuvio.app.features.trakt.TraktLibraryStorage
|
import com.nuvio.app.features.trakt.TraktLibraryStorage
|
||||||
import com.nuvio.app.features.trakt.TraktSettingsStorage
|
import com.nuvio.app.features.trakt.TraktSettingsStorage
|
||||||
|
import com.nuvio.app.features.simkl.SimklAuthStorage
|
||||||
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
||||||
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
|
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
|
||||||
import com.nuvio.app.core.ui.CardDepthStyleStorage
|
import com.nuvio.app.core.ui.CardDepthStyleStorage
|
||||||
|
|
@ -104,6 +105,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
TraktCommentsStorage.initialize(applicationContext)
|
TraktCommentsStorage.initialize(applicationContext)
|
||||||
TraktLibraryStorage.initialize(applicationContext)
|
TraktLibraryStorage.initialize(applicationContext)
|
||||||
TraktSettingsStorage.initialize(applicationContext)
|
TraktSettingsStorage.initialize(applicationContext)
|
||||||
|
SimklAuthStorage.initialize(applicationContext)
|
||||||
LibraryDisplaySettingsStorage.initialize(applicationContext)
|
LibraryDisplaySettingsStorage.initialize(applicationContext)
|
||||||
ContinueWatchingPreferencesStorage.initialize(applicationContext)
|
ContinueWatchingPreferencesStorage.initialize(applicationContext)
|
||||||
ResumePromptStorage.initialize(applicationContext)
|
ResumePromptStorage.initialize(applicationContext)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
||||||
"nuvio_mdblist_settings",
|
"nuvio_mdblist_settings",
|
||||||
"nuvio_auth",
|
"nuvio_auth",
|
||||||
"nuvio_trakt_auth",
|
"nuvio_trakt_auth",
|
||||||
|
"nuvio_simkl_auth",
|
||||||
"nuvio_trakt_library",
|
"nuvio_trakt_library",
|
||||||
"nuvio_trakt_settings",
|
"nuvio_trakt_settings",
|
||||||
"nuvio_watched",
|
"nuvio_watched",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
package com.nuvio.app.features.simkl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
internal actual object SimklPlatformClock {
|
||||||
|
actual fun nowEpochMs(): Long = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal actual object SimklPkceCrypto {
|
||||||
|
private val secureRandom = SecureRandom()
|
||||||
|
|
||||||
|
actual fun secureRandomBytes(size: Int): ByteArray =
|
||||||
|
ByteArray(size).also(secureRandom::nextBytes)
|
||||||
|
|
||||||
|
actual fun sha256(value: ByteArray): ByteArray =
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal actual object SimklAuthStorage {
|
||||||
|
private const val PREFERENCES_NAME = "nuvio_simkl_auth"
|
||||||
|
private const val METADATA_KEY = "simkl_auth_metadata"
|
||||||
|
private const val ACCESS_TOKEN_KEY = "simkl_access_token"
|
||||||
|
private const val CODE_VERIFIER_KEY = "simkl_code_verifier"
|
||||||
|
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||||
|
private const val KEY_ALIAS = "nuvio.simkl.credentials.v1"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val GCM_TAG_BITS = 128
|
||||||
|
|
||||||
|
private var preferences: SharedPreferences? = null
|
||||||
|
|
||||||
|
fun initialize(context: Context) {
|
||||||
|
preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadMetadataPayload(): String? =
|
||||||
|
preferences?.getString(ProfileScopedKey.of(METADATA_KEY), null)
|
||||||
|
|
||||||
|
actual fun saveMetadataPayload(payload: String) {
|
||||||
|
preferences?.edit()?.putString(ProfileScopedKey.of(METADATA_KEY), payload)?.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadAccessToken(): String? = loadEncrypted(ACCESS_TOKEN_KEY)
|
||||||
|
|
||||||
|
actual fun saveAccessToken(value: String?) = saveEncrypted(ACCESS_TOKEN_KEY, value)
|
||||||
|
|
||||||
|
actual fun loadCodeVerifier(): String? = loadEncrypted(CODE_VERIFIER_KEY)
|
||||||
|
|
||||||
|
actual fun saveCodeVerifier(value: String?) = saveEncrypted(CODE_VERIFIER_KEY, value)
|
||||||
|
|
||||||
|
actual fun removeProfile(profileId: Int) {
|
||||||
|
preferences?.edit()
|
||||||
|
?.remove(ProfileScopedKey.of(METADATA_KEY, profileId))
|
||||||
|
?.remove(ProfileScopedKey.of(ACCESS_TOKEN_KEY, profileId))
|
||||||
|
?.remove(ProfileScopedKey.of(CODE_VERIFIER_KEY, profileId))
|
||||||
|
?.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadEncrypted(key: String): String? {
|
||||||
|
val scopedKey = ProfileScopedKey.of(key)
|
||||||
|
val stored = preferences?.getString(scopedKey, null) ?: return null
|
||||||
|
return runCatching { decrypt(stored) }
|
||||||
|
.onFailure { preferences?.edit()?.remove(scopedKey)?.apply() }
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveEncrypted(key: String, value: String?) {
|
||||||
|
val scopedKey = ProfileScopedKey.of(key)
|
||||||
|
val editor = preferences?.edit() ?: return
|
||||||
|
if (value.isNullOrBlank()) {
|
||||||
|
editor.remove(scopedKey).apply()
|
||||||
|
} else {
|
||||||
|
editor.putString(scopedKey, encrypt(value)).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encrypt(value: String): String {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey())
|
||||||
|
val ciphertext = cipher.doFinal(value.encodeToByteArray())
|
||||||
|
return "${cipher.iv.toBase64()}.${ciphertext.toBase64()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decrypt(value: String): String {
|
||||||
|
val separator = value.indexOf('.')
|
||||||
|
require(separator > 0 && separator < value.lastIndex) { "Invalid encrypted credential" }
|
||||||
|
val iv = value.substring(0, separator).fromBase64()
|
||||||
|
val ciphertext = value.substring(separator + 1).fromBase64()
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
|
||||||
|
return cipher.doFinal(ciphertext).decodeToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrCreateSecretKey(): SecretKey {
|
||||||
|
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
|
||||||
|
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||||
|
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER).run {
|
||||||
|
init(
|
||||||
|
KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
generateKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.toBase64(): String =
|
||||||
|
Base64.encodeToString(this, Base64.NO_WRAP)
|
||||||
|
|
||||||
|
private fun String.fromBase64(): ByteArray =
|
||||||
|
Base64.decode(this, Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
@ -23,4 +23,11 @@ internal actual object TraktAuthStorage {
|
||||||
?.putString(ProfileScopedKey.of(payloadKey), payload)
|
?.putString(ProfileScopedKey.of(payloadKey), payload)
|
||||||
?.apply()
|
?.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun removeProfile(profileId: Int) {
|
||||||
|
preferences
|
||||||
|
?.edit()
|
||||||
|
?.remove(ProfileScopedKey.of(payloadKey, profileId))
|
||||||
|
?.apply()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.nuvio.app.core.deeplink
|
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.Url
|
||||||
import io.ktor.http.encodeURLParameter
|
import io.ktor.http.encodeURLParameter
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -41,7 +42,8 @@ fun handleAppUrl(url: String) {
|
||||||
val normalizedUrl = url.trim()
|
val normalizedUrl = url.trim()
|
||||||
if (normalizedUrl.isBlank()) return
|
if (normalizedUrl.isBlank()) return
|
||||||
|
|
||||||
handleTraktAuthCallbackUrl(normalizedUrl)
|
ensureTrackingProvidersRegistered()
|
||||||
|
TrackingProviderRegistry.handleAuthCallback(normalizedUrl)
|
||||||
AppDeepLinkRepository.handleUrl(normalizedUrl)
|
AppDeepLinkRepository.handleUrl(normalizedUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.core.storage
|
||||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||||
import com.nuvio.app.core.sync.SyncManager
|
import com.nuvio.app.core.sync.SyncManager
|
||||||
import com.nuvio.app.core.sync.ProfileSettingsSync
|
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.addons.AddonRepository
|
||||||
import com.nuvio.app.features.catalog.CatalogRepository
|
import com.nuvio.app.features.catalog.CatalogRepository
|
||||||
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
|
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.plugins.PluginRepository
|
||||||
import com.nuvio.app.features.player.SubtitleRepository
|
import com.nuvio.app.features.player.SubtitleRepository
|
||||||
import com.nuvio.app.features.profiles.ProfileRepository
|
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.search.SearchRepository
|
||||||
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
||||||
import com.nuvio.app.features.streams.StreamContextStore
|
import com.nuvio.app.features.streams.StreamContextStore
|
||||||
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
|
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
|
||||||
import com.nuvio.app.features.streams.StreamLaunchStore
|
import com.nuvio.app.features.streams.StreamLaunchStore
|
||||||
import com.nuvio.app.features.streams.StreamsRepository
|
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.features.trakt.TraktSettingsRepository
|
||||||
import com.nuvio.app.core.ui.CardDepthStyleRepository
|
import com.nuvio.app.core.ui.CardDepthStyleRepository
|
||||||
import com.nuvio.app.core.ui.PosterCardStyleRepository
|
import com.nuvio.app.core.ui.PosterCardStyleRepository
|
||||||
|
|
@ -38,6 +40,8 @@ import com.nuvio.app.features.watched.WatchedRepository
|
||||||
|
|
||||||
internal object LocalAccountDataCleaner {
|
internal object LocalAccountDataCleaner {
|
||||||
fun wipe() {
|
fun wipe() {
|
||||||
|
ensureTrackingProvidersRegistered()
|
||||||
|
TrackingProviderRegistry.removeStoredProfiles(1..MAX_PROFILES)
|
||||||
SyncManager.cancelAccountSync()
|
SyncManager.cancelAccountSync()
|
||||||
WatchProgressSourceCoordinator.clearLocalState()
|
WatchProgressSourceCoordinator.clearLocalState()
|
||||||
ProfileSettingsSync.clearAccountState()
|
ProfileSettingsSync.clearAccountState()
|
||||||
|
|
@ -65,7 +69,7 @@ internal object LocalAccountDataCleaner {
|
||||||
ThemeSettingsRepository.clearLocalState()
|
ThemeSettingsRepository.clearLocalState()
|
||||||
PosterCardStyleRepository.clearLocalState()
|
PosterCardStyleRepository.clearLocalState()
|
||||||
CardDepthStyleRepository.clearLocalState()
|
CardDepthStyleRepository.clearLocalState()
|
||||||
TraktAuthRepository.clearLocalState()
|
TrackingProviderRegistry.clearLocalState()
|
||||||
TraktSettingsRepository.clearLocalState()
|
TraktSettingsRepository.clearLocalState()
|
||||||
PlayerSettingsRepository.clearLocalState()
|
PlayerSettingsRepository.clearLocalState()
|
||||||
StreamBadgeSettingsRepository.clearLocalState()
|
StreamBadgeSettingsRepository.clearLocalState()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState
|
||||||
import com.nuvio.app.core.auth.isAnonymous
|
import com.nuvio.app.core.auth.isAnonymous
|
||||||
import com.nuvio.app.core.network.SupabaseProvider
|
import com.nuvio.app.core.network.SupabaseProvider
|
||||||
import com.nuvio.app.core.sync.putSyncOriginClientId
|
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.addons.AddonRepository
|
||||||
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
|
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
|
||||||
import com.nuvio.app.features.collection.CollectionRepository
|
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.search.SearchHistoryRepository
|
||||||
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
||||||
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
|
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.trakt.TraktSettingsRepository
|
||||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||||
import com.nuvio.app.features.watched.WatchedRepository
|
import com.nuvio.app.features.watched.WatchedRepository
|
||||||
|
|
@ -155,7 +156,8 @@ object ProfileRepository {
|
||||||
persist()
|
persist()
|
||||||
WatchedRepository.onProfileChanged(profileIndex)
|
WatchedRepository.onProfileChanged(profileIndex)
|
||||||
TraktSettingsRepository.onProfileChanged()
|
TraktSettingsRepository.onProfileChanged()
|
||||||
TraktAuthRepository.onProfileChanged()
|
ensureTrackingProvidersRegistered()
|
||||||
|
TrackingProviderRegistry.onProfileChanged()
|
||||||
LibraryRepository.onProfileChanged(profileIndex)
|
LibraryRepository.onProfileChanged(profileIndex)
|
||||||
LibraryDisplaySettingsRepository.onProfileChanged()
|
LibraryDisplaySettingsRepository.onProfileChanged()
|
||||||
WatchProgressRepository.onProfileChanged(profileIndex)
|
WatchProgressRepository.onProfileChanged(profileIndex)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
package com.nuvio.app.features.simkl
|
||||||
|
|
||||||
|
import com.nuvio.app.core.build.AppVersionConfig
|
||||||
|
import io.ktor.http.encodeURLParameter
|
||||||
|
|
||||||
|
internal const val SIMKL_API_BASE_URL = "https://api.simkl.com"
|
||||||
|
internal const val SIMKL_AUTHORIZE_URL = "https://simkl.com/oauth/authorize"
|
||||||
|
|
||||||
|
internal val simklAppVersion: String
|
||||||
|
get() = AppVersionConfig.VERSION_NAME.ifBlank { "dev" }
|
||||||
|
|
||||||
|
internal fun buildSimklApiUrl(
|
||||||
|
path: String,
|
||||||
|
query: Map<String, String> = emptyMap(),
|
||||||
|
): String {
|
||||||
|
val normalizedPath = path.trim().let { value ->
|
||||||
|
if (value.startsWith('/')) value else "/$value"
|
||||||
|
}
|
||||||
|
val parameters = linkedMapOf(
|
||||||
|
"client_id" to SimklConfig.CLIENT_ID,
|
||||||
|
"app-name" to SimklConfig.APP_NAME,
|
||||||
|
"app-version" to simklAppVersion,
|
||||||
|
).apply { putAll(query) }
|
||||||
|
return buildString {
|
||||||
|
append(SIMKL_API_BASE_URL)
|
||||||
|
append(normalizedPath)
|
||||||
|
append('?')
|
||||||
|
append(
|
||||||
|
parameters.entries.joinToString("&") { (key, value) ->
|
||||||
|
"${key.encodeURLParameter()}=${value.encodeURLParameter()}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun simklRequestHeaders(
|
||||||
|
accessToken: String? = null,
|
||||||
|
contentTypeJson: Boolean = false,
|
||||||
|
): Map<String, String> = buildMap {
|
||||||
|
put("User-Agent", "${SimklConfig.APP_NAME}/$simklAppVersion")
|
||||||
|
accessToken?.trim()?.takeIf(String::isNotBlank)?.let { token ->
|
||||||
|
put("Authorization", "Bearer $token")
|
||||||
|
}
|
||||||
|
if (contentTypeJson) put("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,414 @@
|
||||||
|
package com.nuvio.app.features.simkl
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import com.nuvio.app.features.addons.httpRequestRaw
|
||||||
|
import com.nuvio.app.features.tracking.TrackingAuthProvider
|
||||||
|
import com.nuvio.app.features.tracking.TrackingCapability
|
||||||
|
import com.nuvio.app.features.tracking.TrackingProviderDescriptor
|
||||||
|
import com.nuvio.app.features.tracking.TrackingProviderId
|
||||||
|
import com.nuvio.app.features.tracking.TrackingProviderRegistry
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
object SimklAuthRepository : TrackingAuthProvider {
|
||||||
|
private val log = Logger.withTag("SimklAuth")
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val authorizationMutex = Mutex()
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(SimklAuthUiState())
|
||||||
|
val uiState: StateFlow<SimklAuthUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _isAuthenticated = MutableStateFlow(false)
|
||||||
|
override val isAuthenticated: StateFlow<Boolean> = _isAuthenticated.asStateFlow()
|
||||||
|
|
||||||
|
override val descriptor = TrackingProviderDescriptor(
|
||||||
|
id = TrackingProviderId.SIMKL,
|
||||||
|
displayName = "Simkl",
|
||||||
|
capabilities = setOf(
|
||||||
|
TrackingCapability.AUTHENTICATION,
|
||||||
|
TrackingCapability.LIBRARY_READ,
|
||||||
|
TrackingCapability.LIBRARY_WRITE,
|
||||||
|
TrackingCapability.WATCHED_READ,
|
||||||
|
TrackingCapability.WATCHED_WRITE,
|
||||||
|
TrackingCapability.PROGRESS_READ,
|
||||||
|
TrackingCapability.PROGRESS_WRITE,
|
||||||
|
TrackingCapability.SCROBBLE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private var hasLoaded = false
|
||||||
|
private var storedState = SimklStoredAuthState()
|
||||||
|
private var accessToken: String? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
TrackingProviderRegistry.register(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun ensureLoaded() {
|
||||||
|
if (hasLoaded) return
|
||||||
|
loadFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onProfileChanged() {
|
||||||
|
loadFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearLocalState() {
|
||||||
|
hasLoaded = false
|
||||||
|
storedState = SimklStoredAuthState()
|
||||||
|
accessToken = null
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun removeStoredProfile(profileId: Int) {
|
||||||
|
SimklAuthStorage.removeProfile(profileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snapshot(): SimklAuthUiState {
|
||||||
|
ensureLoaded()
|
||||||
|
return uiState.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasRequiredCredentials(): Boolean = SimklConfig.CLIENT_ID.isNotBlank()
|
||||||
|
|
||||||
|
fun onConnectRequested(): String? {
|
||||||
|
ensureLoaded()
|
||||||
|
if (!hasRequiredCredentials()) {
|
||||||
|
publish(error = SimklAuthError.MISSING_CLIENT_ID)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val material = generateSimklPkceMaterial()
|
||||||
|
SimklAuthStorage.saveCodeVerifier(material.verifier)
|
||||||
|
storedState = storedState.copy(
|
||||||
|
pendingAuthorizationState = material.state,
|
||||||
|
pendingAuthorizationStartedAtEpochMs = SimklPlatformClock.nowEpochMs(),
|
||||||
|
)
|
||||||
|
persistMetadata()
|
||||||
|
publish(error = null)
|
||||||
|
return authorizationUrl(material)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pendingAuthorizationUrl(): String? {
|
||||||
|
ensureLoaded()
|
||||||
|
val state = storedState.pendingAuthorizationState?.takeIf(String::isNotBlank) ?: return null
|
||||||
|
val verifier = SimklAuthStorage.loadCodeVerifier()?.takeIf(String::isNotBlank) ?: run {
|
||||||
|
clearPendingAuthorization()
|
||||||
|
persistMetadata()
|
||||||
|
publish(error = SimklAuthError.AUTHORIZATION_EXPIRED)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (isSimklAuthorizationExpired(
|
||||||
|
startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs,
|
||||||
|
nowEpochMs = SimklPlatformClock.nowEpochMs(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
clearPendingAuthorization()
|
||||||
|
persistMetadata()
|
||||||
|
publish(error = SimklAuthError.AUTHORIZATION_EXPIRED)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return authorizationUrl(
|
||||||
|
SimklPkceMaterial(
|
||||||
|
verifier = verifier,
|
||||||
|
challenge = SimklPkceCrypto.sha256(verifier.encodeToByteArray()).base64UrlWithoutPadding(),
|
||||||
|
state = state,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onCancelAuthorization() {
|
||||||
|
ensureLoaded()
|
||||||
|
clearPendingAuthorization()
|
||||||
|
persistMetadata()
|
||||||
|
publish(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleAuthCallback(url: String): Boolean {
|
||||||
|
ensureLoaded()
|
||||||
|
return when (val callback = parseSimklAuthCallback(url, SimklConfig.REDIRECT_URI)) {
|
||||||
|
SimklAuthCallback.NotSimkl -> false
|
||||||
|
SimklAuthCallback.Invalid -> {
|
||||||
|
clearPendingAuthorization()
|
||||||
|
persistMetadata()
|
||||||
|
publish(error = SimklAuthError.INVALID_CALLBACK)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
is SimklAuthCallback.AuthorizationCode -> {
|
||||||
|
scope.launch { completeAuthorization(callback) }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDisconnectRequested() {
|
||||||
|
ensureLoaded()
|
||||||
|
accessToken = null
|
||||||
|
SimklAuthStorage.saveAccessToken(null)
|
||||||
|
clearPendingAuthorization()
|
||||||
|
storedState = SimklStoredAuthState()
|
||||||
|
persistMetadata()
|
||||||
|
publish(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun authorizedAccessToken(): String? {
|
||||||
|
ensureLoaded()
|
||||||
|
val token = accessToken?.takeIf(String::isNotBlank) ?: return null
|
||||||
|
val expiresAt = storedState.tokenExpiresAtEpochMs
|
||||||
|
if (expiresAt != null && SimklPlatformClock.nowEpochMs() >= expiresAt - TOKEN_EXPIRY_SKEW_MS) {
|
||||||
|
invalidateCredentials(SimklAuthError.AUTHORIZATION_EXPIRED)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun onUnauthorizedResponse() {
|
||||||
|
invalidateCredentials(SimklAuthError.AUTHORIZATION_REVOKED)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshUserSettings(): String? {
|
||||||
|
val token = authorizedAccessToken() ?: return null
|
||||||
|
return fetchAndStoreUserSettings(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun completeAuthorization(callback: SimklAuthCallback.AuthorizationCode) =
|
||||||
|
authorizationMutex.withLock {
|
||||||
|
publish(isLoading = true, error = null)
|
||||||
|
val expectedState = storedState.pendingAuthorizationState
|
||||||
|
val verifier = SimklAuthStorage.loadCodeVerifier()
|
||||||
|
val isExpired = isSimklAuthorizationExpired(
|
||||||
|
startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs,
|
||||||
|
nowEpochMs = SimklPlatformClock.nowEpochMs(),
|
||||||
|
)
|
||||||
|
if (expectedState.isNullOrBlank() || verifier.isNullOrBlank() || isExpired) {
|
||||||
|
clearPendingAuthorization()
|
||||||
|
persistMetadata()
|
||||||
|
publish(isLoading = false, error = SimklAuthError.AUTHORIZATION_EXPIRED)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
if (!constantTimeEquals(callback.state, expectedState)) {
|
||||||
|
clearPendingAuthorization()
|
||||||
|
persistMetadata()
|
||||||
|
publish(isLoading = false, error = SimklAuthError.INVALID_CALLBACK_STATE)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = SimklTokenRequest(
|
||||||
|
code = callback.code,
|
||||||
|
clientId = SimklConfig.CLIENT_ID,
|
||||||
|
codeVerifier = verifier,
|
||||||
|
redirectUri = SimklConfig.REDIRECT_URI,
|
||||||
|
)
|
||||||
|
val response = try {
|
||||||
|
httpRequestRaw(
|
||||||
|
method = "POST",
|
||||||
|
url = "$SIMKL_API_BASE_URL/oauth/token",
|
||||||
|
headers = simklRequestHeaders(contentTypeJson = true),
|
||||||
|
body = json.encodeToString(request),
|
||||||
|
)
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log.w { "Simkl token exchange failed: ${error.message}" }
|
||||||
|
clearPendingAuthorization()
|
||||||
|
persistMetadata()
|
||||||
|
publish(isLoading = false, error = SimklAuthError.TOKEN_EXCHANGE_FAILED)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status !in 200..299) {
|
||||||
|
clearPendingAuthorization()
|
||||||
|
persistMetadata()
|
||||||
|
publish(isLoading = false, error = SimklAuthError.TOKEN_EXCHANGE_FAILED)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
val token = runCatching { json.decodeFromString<SimklTokenResponse>(response.body) }
|
||||||
|
.getOrNull()
|
||||||
|
?.takeIf { it.accessToken.isNotBlank() }
|
||||||
|
if (token == null) {
|
||||||
|
clearPendingAuthorization()
|
||||||
|
persistMetadata()
|
||||||
|
publish(isLoading = false, error = SimklAuthError.INVALID_TOKEN_RESPONSE)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken = token.accessToken
|
||||||
|
SimklAuthStorage.saveAccessToken(token.accessToken)
|
||||||
|
clearPendingAuthorization()
|
||||||
|
storedState = storedState.copy(
|
||||||
|
tokenExpiresAtEpochMs = token.expiresIn
|
||||||
|
?.takeIf { seconds -> seconds > 0L }
|
||||||
|
?.let { seconds -> SimklPlatformClock.nowEpochMs() + seconds * 1_000L },
|
||||||
|
)
|
||||||
|
persistMetadata()
|
||||||
|
publish(isLoading = false, error = null)
|
||||||
|
fetchAndStoreUserSettings(token.accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchAndStoreUserSettings(token: String): String? {
|
||||||
|
val response = try {
|
||||||
|
httpRequestRaw(
|
||||||
|
method = "POST",
|
||||||
|
url = buildSimklApiUrl("/users/settings"),
|
||||||
|
headers = simklRequestHeaders(accessToken = token, contentTypeJson = true),
|
||||||
|
body = "{}",
|
||||||
|
)
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log.w { "Failed to fetch Simkl user settings: ${error.message}" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (response.status == 401) {
|
||||||
|
onUnauthorizedResponse()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (response.status !in 200..299) return null
|
||||||
|
val settings = runCatching { json.decodeFromString<SimklUserSettingsResponse>(response.body) }
|
||||||
|
.getOrNull() ?: return null
|
||||||
|
storedState = storedState.copy(
|
||||||
|
username = settings.user?.name,
|
||||||
|
accountId = settings.account?.id,
|
||||||
|
)
|
||||||
|
persistMetadata()
|
||||||
|
publish(error = null)
|
||||||
|
return storedState.username
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadFromDisk() {
|
||||||
|
hasLoaded = true
|
||||||
|
storedState = SimklAuthStorage.loadMetadataPayload()
|
||||||
|
?.trim()
|
||||||
|
?.takeIf(String::isNotEmpty)
|
||||||
|
?.let { payload ->
|
||||||
|
runCatching { json.decodeFromString<SimklStoredAuthState>(payload) }
|
||||||
|
.onFailure { error -> log.w { "Failed to parse Simkl auth metadata: ${error.message}" } }
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
?: SimklStoredAuthState()
|
||||||
|
accessToken = SimklAuthStorage.loadAccessToken()?.takeIf(String::isNotBlank)
|
||||||
|
if (accessToken != null && storedState.tokenExpiresAtEpochMs?.let { expiresAt ->
|
||||||
|
SimklPlatformClock.nowEpochMs() >= expiresAt - TOKEN_EXPIRY_SKEW_MS
|
||||||
|
} == true
|
||||||
|
) {
|
||||||
|
accessToken = null
|
||||||
|
SimklAuthStorage.saveAccessToken(null)
|
||||||
|
storedState = SimklStoredAuthState()
|
||||||
|
persistMetadata()
|
||||||
|
}
|
||||||
|
if (storedState.hasPendingAuthorization && isSimklAuthorizationExpired(
|
||||||
|
startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs,
|
||||||
|
nowEpochMs = SimklPlatformClock.nowEpochMs(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
clearPendingAuthorization()
|
||||||
|
persistMetadata()
|
||||||
|
}
|
||||||
|
publish(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidateCredentials(error: SimklAuthError) {
|
||||||
|
accessToken = null
|
||||||
|
SimklAuthStorage.saveAccessToken(null)
|
||||||
|
clearPendingAuthorization()
|
||||||
|
storedState = SimklStoredAuthState()
|
||||||
|
persistMetadata()
|
||||||
|
publish(isLoading = false, error = error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearPendingAuthorization() {
|
||||||
|
SimklAuthStorage.saveCodeVerifier(null)
|
||||||
|
storedState = storedState.copy(
|
||||||
|
pendingAuthorizationState = null,
|
||||||
|
pendingAuthorizationStartedAtEpochMs = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistMetadata() {
|
||||||
|
SimklAuthStorage.saveMetadataPayload(json.encodeToString(storedState))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish(
|
||||||
|
isLoading: Boolean = _uiState.value.isLoading,
|
||||||
|
error: SimklAuthError? = _uiState.value.error,
|
||||||
|
) {
|
||||||
|
val authenticated = !accessToken.isNullOrBlank()
|
||||||
|
_isAuthenticated.value = authenticated
|
||||||
|
_uiState.value = SimklAuthUiState(
|
||||||
|
mode = when {
|
||||||
|
authenticated -> SimklConnectionMode.CONNECTED
|
||||||
|
storedState.hasPendingAuthorization -> SimklConnectionMode.AWAITING_APPROVAL
|
||||||
|
else -> SimklConnectionMode.DISCONNECTED
|
||||||
|
},
|
||||||
|
credentialsConfigured = hasRequiredCredentials(),
|
||||||
|
isLoading = isLoading,
|
||||||
|
username = storedState.username,
|
||||||
|
accountId = storedState.accountId,
|
||||||
|
tokenExpiresAtEpochMs = storedState.tokenExpiresAtEpochMs,
|
||||||
|
pendingAuthorizationStartedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs,
|
||||||
|
error = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authorizationUrl(material: SimklPkceMaterial): String =
|
||||||
|
buildSimklAuthorizationUrl(
|
||||||
|
clientId = SimklConfig.CLIENT_ID,
|
||||||
|
redirectUri = SimklConfig.REDIRECT_URI,
|
||||||
|
appName = SimklConfig.APP_NAME,
|
||||||
|
appVersion = simklAppVersion,
|
||||||
|
material = material,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val TOKEN_EXPIRY_SKEW_MS = 60_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SimklTokenRequest(
|
||||||
|
val code: String,
|
||||||
|
@SerialName("client_id") val clientId: String,
|
||||||
|
@SerialName("code_verifier") val codeVerifier: String,
|
||||||
|
@SerialName("redirect_uri") val redirectUri: String,
|
||||||
|
@SerialName("grant_type") val grantType: String = "authorization_code",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SimklTokenResponse(
|
||||||
|
@SerialName("access_token") val accessToken: String,
|
||||||
|
@SerialName("token_type") val tokenType: String? = null,
|
||||||
|
val scope: String? = null,
|
||||||
|
@SerialName("expires_in") val expiresIn: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SimklUserSettingsResponse(
|
||||||
|
val user: SimklUser? = null,
|
||||||
|
val account: SimklAccount? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SimklUser(
|
||||||
|
val name: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SimklAccount(
|
||||||
|
val id: Long? = null,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,8 @@ interface TrackingAuthProvider {
|
||||||
fun ensureLoaded()
|
fun ensureLoaded()
|
||||||
fun onProfileChanged()
|
fun onProfileChanged()
|
||||||
fun clearLocalState()
|
fun clearLocalState()
|
||||||
|
fun removeStoredProfile(profileId: Int)
|
||||||
|
fun handleAuthCallback(url: String): Boolean = false
|
||||||
}
|
}
|
||||||
|
|
||||||
object TrackingProviderRegistry {
|
object TrackingProviderRegistry {
|
||||||
|
|
@ -73,6 +75,29 @@ object TrackingProviderRegistry {
|
||||||
.filter { provider -> capability in provider.descriptor.capabilities }
|
.filter { provider -> capability in provider.descriptor.capabilities }
|
||||||
.sortedBy { provider -> provider.descriptor.id.ordinal }
|
.sortedBy { provider -> provider.descriptor.id.ordinal }
|
||||||
|
|
||||||
|
fun handleAuthCallback(url: String): Boolean =
|
||||||
|
providersWith(TrackingCapability.AUTHENTICATION)
|
||||||
|
.any { provider -> provider.handleAuthCallback(url) }
|
||||||
|
|
||||||
|
fun ensureLoaded() {
|
||||||
|
providerSnapshot().forEach(TrackingAuthProvider::ensureLoaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onProfileChanged() {
|
||||||
|
providerSnapshot().forEach(TrackingAuthProvider::onProfileChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearLocalState() {
|
||||||
|
providerSnapshot().forEach(TrackingAuthProvider::clearLocalState)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeStoredProfiles(profileIds: Iterable<Int>) {
|
||||||
|
val providers = providerSnapshot()
|
||||||
|
profileIds.forEach { profileId ->
|
||||||
|
providers.forEach { provider -> provider.removeStoredProfile(profileId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun providerSnapshot(): List<TrackingAuthProvider> = synchronized(lock) {
|
private fun providerSnapshot(): List<TrackingAuthProvider> = synchronized(lock) {
|
||||||
authProviders.values.toList()
|
authProviders.values.toList()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
package com.nuvio.app.features.trakt
|
|
||||||
|
|
||||||
fun handleTraktAuthCallbackUrl(url: String) {
|
|
||||||
TraktAuthRepository.onAuthCallbackReceived(url)
|
|
||||||
}
|
|
||||||
|
|
@ -91,6 +91,10 @@ object TraktAuthRepository : TrackingAuthProvider {
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun removeStoredProfile(profileId: Int) {
|
||||||
|
TraktAuthStorage.removeProfile(profileId)
|
||||||
|
}
|
||||||
|
|
||||||
fun snapshot(): TraktAuthUiState {
|
fun snapshot(): TraktAuthUiState {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
return _uiState.value
|
return _uiState.value
|
||||||
|
|
@ -154,6 +158,15 @@ object TraktAuthRepository : TrackingAuthProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun handleAuthCallback(url: String): Boolean {
|
||||||
|
if (!isTraktAuthCallback(url)) return false
|
||||||
|
onAuthCallbackReceived(url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTraktAuthCallback(url: String): Boolean =
|
||||||
|
url == TraktConfig.REDIRECT_URI || url.startsWith("${TraktConfig.REDIRECT_URI}?")
|
||||||
|
|
||||||
suspend fun authorizedHeaders(): Map<String, String>? {
|
suspend fun authorizedHeaders(): Map<String, String>? {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
if (!authState.isAuthenticated) return null
|
if (!authState.isAuthenticated) return null
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,5 @@ package com.nuvio.app.features.trakt
|
||||||
internal expect object TraktAuthStorage {
|
internal expect object TraktAuthStorage {
|
||||||
fun loadPayload(): String?
|
fun loadPayload(): String?
|
||||||
fun savePayload(payload: String)
|
fun savePayload(payload: String)
|
||||||
|
fun removeProfile(profileId: Int)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
package com.nuvio.app.features.simkl
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class SimklPkceTest {
|
||||||
|
@Test
|
||||||
|
fun `base64 url encoding is unpadded and url safe`() {
|
||||||
|
assertEquals("", byteArrayOf().base64UrlWithoutPadding())
|
||||||
|
assertEquals("Zg", "f".encodeToByteArray().base64UrlWithoutPadding())
|
||||||
|
assertEquals("Zm8", "fo".encodeToByteArray().base64UrlWithoutPadding())
|
||||||
|
assertEquals("Zm9v", "foo".encodeToByteArray().base64UrlWithoutPadding())
|
||||||
|
assertEquals("-_8", byteArrayOf(0xfb.toByte(), 0xff.toByte()).base64UrlWithoutPadding())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pkce material uses compliant verifier and S256 challenge`() {
|
||||||
|
val material = createSimklPkceMaterial(
|
||||||
|
verifierEntropy = ByteArray(32) { it.toByte() },
|
||||||
|
stateEntropy = ByteArray(32) { (it + 32).toByte() },
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(43, material.verifier.length)
|
||||||
|
assertEquals("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", material.verifier)
|
||||||
|
assertEquals("6oZqdX5MOLq_qBJ8vppAnT4fk6AP8UiP9zX8-Rev_9A", material.challenge)
|
||||||
|
assertTrue(material.verifier.all { it.isLetterOrDigit() || it in "-._~" })
|
||||||
|
assertFalse('=' in material.verifier)
|
||||||
|
assertEquals(43, material.challenge.length)
|
||||||
|
assertTrue(material.state.length >= 22)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `oauth state comparison requires an exact value`() {
|
||||||
|
assertTrue(constantTimeEquals("same-state", "same-state"))
|
||||||
|
assertFalse(constantTimeEquals("same-state", "different-state"))
|
||||||
|
assertFalse(constantTimeEquals("same-state", "same-state-extra"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `authorization URL uses browser host and exact S256 method`() {
|
||||||
|
val url = buildSimklAuthorizationUrl(
|
||||||
|
clientId = "client id",
|
||||||
|
redirectUri = "nuvio://auth/simkl",
|
||||||
|
appName = "nuvio",
|
||||||
|
appVersion = "1.2.3",
|
||||||
|
material = SimklPkceMaterial("verifier", "challenge", "state"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(url.startsWith("https://simkl.com/oauth/authorize?"))
|
||||||
|
assertTrue("client_id=client+id" in url || "client_id=client%20id" in url)
|
||||||
|
assertTrue("code_challenge_method=S256" in url)
|
||||||
|
assertTrue("redirect_uri=nuvio%3A%2F%2Fauth%2Fsimkl" in url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `callback parser rejects other routes and missing state`() {
|
||||||
|
assertIs<SimklAuthCallback.NotSimkl>(
|
||||||
|
parseSimklAuthCallback("nuvio://auth/trakt?code=a&state=b", "nuvio://auth/simkl"),
|
||||||
|
)
|
||||||
|
assertIs<SimklAuthCallback.Invalid>(
|
||||||
|
parseSimklAuthCallback("nuvio://auth/simkl?code=a", "nuvio://auth/simkl"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
SimklAuthCallback.AuthorizationCode(code = "a", state = "b"),
|
||||||
|
parseSimklAuthCallback("nuvio://auth/simkl?code=a&state=b", "nuvio://auth/simkl"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pending authorization expires after five minutes`() {
|
||||||
|
assertFalse(isSimklAuthorizationExpired(1_000L, 301_000L))
|
||||||
|
assertTrue(isSimklAuthorizationExpired(1_000L, 301_001L))
|
||||||
|
assertTrue(isSimklAuthorizationExpired(null, 1_000L))
|
||||||
|
assertTrue(isSimklAuthorizationExpired(2_000L, 1_000L))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.nuvio.app.core.storage
|
package com.nuvio.app.core.storage
|
||||||
|
|
||||||
import platform.Foundation.NSUserDefaults
|
import platform.Foundation.NSUserDefaults
|
||||||
|
import com.nuvio.app.features.profiles.MAX_PROFILES
|
||||||
|
|
||||||
internal actual object PlatformLocalAccountDataCleaner {
|
internal actual object PlatformLocalAccountDataCleaner {
|
||||||
private val plainKeys = listOf(
|
private val plainKeys = listOf(
|
||||||
|
|
@ -52,6 +53,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
||||||
"mdblist_use_letterboxd",
|
"mdblist_use_letterboxd",
|
||||||
"mdblist_use_audience",
|
"mdblist_use_audience",
|
||||||
"trakt_auth_payload",
|
"trakt_auth_payload",
|
||||||
|
"simkl_auth_metadata",
|
||||||
"trakt_library_payload",
|
"trakt_library_payload",
|
||||||
"trakt_settings_payload",
|
"trakt_settings_payload",
|
||||||
"library_display_settings_payload",
|
"library_display_settings_payload",
|
||||||
|
|
@ -65,7 +67,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
||||||
|
|
||||||
plainKeys.forEach(defaults::removeObjectForKey)
|
plainKeys.forEach(defaults::removeObjectForKey)
|
||||||
|
|
||||||
(1..4).forEach { profileId ->
|
(1..MAX_PROFILES).forEach { profileId ->
|
||||||
profileIndexedPrefixes.forEach { prefix ->
|
profileIndexedPrefixes.forEach { prefix ->
|
||||||
defaults.removeObjectForKey("$prefix$profileId")
|
defaults.removeObjectForKey("$prefix$profileId")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
package com.nuvio.app.features.simkl
|
||||||
|
|
||||||
|
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||||
|
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256
|
||||||
|
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256_DIGEST_LENGTH
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.alloc
|
||||||
|
import kotlinx.cinterop.get
|
||||||
|
import kotlinx.cinterop.memScoped
|
||||||
|
import kotlinx.cinterop.ptr
|
||||||
|
import kotlinx.cinterop.refTo
|
||||||
|
import kotlinx.cinterop.reinterpret
|
||||||
|
import kotlinx.cinterop.value
|
||||||
|
import platform.CoreFoundation.CFDataCreate
|
||||||
|
import platform.CoreFoundation.CFDataGetBytePtr
|
||||||
|
import platform.CoreFoundation.CFDataGetLength
|
||||||
|
import platform.CoreFoundation.CFDataRef
|
||||||
|
import platform.CoreFoundation.CFDictionaryCreateMutable
|
||||||
|
import platform.CoreFoundation.CFDictionarySetValue
|
||||||
|
import platform.CoreFoundation.CFMutableDictionaryRef
|
||||||
|
import platform.CoreFoundation.CFRelease
|
||||||
|
import platform.CoreFoundation.CFStringCreateWithCString
|
||||||
|
import platform.CoreFoundation.CFTypeRefVar
|
||||||
|
import platform.CoreFoundation.kCFBooleanTrue
|
||||||
|
import platform.CoreFoundation.kCFStringEncodingUTF8
|
||||||
|
import platform.Foundation.NSDate
|
||||||
|
import platform.Foundation.NSUserDefaults
|
||||||
|
import platform.Foundation.timeIntervalSince1970
|
||||||
|
import platform.Security.SecItemAdd
|
||||||
|
import platform.Security.SecItemCopyMatching
|
||||||
|
import platform.Security.SecItemDelete
|
||||||
|
import platform.Security.SecRandomCopyBytes
|
||||||
|
import platform.Security.errSecItemNotFound
|
||||||
|
import platform.Security.errSecSuccess
|
||||||
|
import platform.Security.kSecAttrAccount
|
||||||
|
import platform.Security.kSecAttrService
|
||||||
|
import platform.Security.kSecClass
|
||||||
|
import platform.Security.kSecClassGenericPassword
|
||||||
|
import platform.Security.kSecMatchLimit
|
||||||
|
import platform.Security.kSecMatchLimitOne
|
||||||
|
import platform.Security.kSecRandomDefault
|
||||||
|
import platform.Security.kSecReturnData
|
||||||
|
import platform.Security.kSecValueData
|
||||||
|
|
||||||
|
internal actual object SimklPlatformClock {
|
||||||
|
actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1_000.0).toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal actual object SimklPkceCrypto {
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual fun secureRandomBytes(size: Int): ByteArray {
|
||||||
|
require(size > 0)
|
||||||
|
val bytes = ByteArray(size)
|
||||||
|
val status = SecRandomCopyBytes(kSecRandomDefault, size.toULong(), bytes.refTo(0))
|
||||||
|
check(status == errSecSuccess) { "Secure random generation failed" }
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual fun sha256(value: ByteArray): ByteArray {
|
||||||
|
require(value.isNotEmpty())
|
||||||
|
val output = UByteArray(CC_SHA256_DIGEST_LENGTH.toInt())
|
||||||
|
CC_SHA256(value.refTo(0), value.size.toUInt(), output.refTo(0))
|
||||||
|
return ByteArray(output.size) { index -> output[index].toByte() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal actual object SimklAuthStorage {
|
||||||
|
private const val METADATA_KEY = "simkl_auth_metadata"
|
||||||
|
private const val ACCESS_TOKEN_KEY = "simkl_access_token"
|
||||||
|
private const val CODE_VERIFIER_KEY = "simkl_code_verifier"
|
||||||
|
private const val KEYCHAIN_SERVICE = "com.nuvio.media.simkl"
|
||||||
|
|
||||||
|
actual fun loadMetadataPayload(): String? =
|
||||||
|
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(METADATA_KEY))
|
||||||
|
|
||||||
|
actual fun saveMetadataPayload(payload: String) {
|
||||||
|
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(METADATA_KEY))
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadAccessToken(): String? = loadKeychainValue(ACCESS_TOKEN_KEY)
|
||||||
|
|
||||||
|
actual fun saveAccessToken(value: String?) = saveKeychainValue(ACCESS_TOKEN_KEY, value)
|
||||||
|
|
||||||
|
actual fun loadCodeVerifier(): String? = loadKeychainValue(CODE_VERIFIER_KEY)
|
||||||
|
|
||||||
|
actual fun saveCodeVerifier(value: String?) = saveKeychainValue(CODE_VERIFIER_KEY, value)
|
||||||
|
|
||||||
|
actual fun removeProfile(profileId: Int) {
|
||||||
|
NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(METADATA_KEY, profileId))
|
||||||
|
deleteKeychainValue(ACCESS_TOKEN_KEY, profileId)
|
||||||
|
deleteKeychainValue(CODE_VERIFIER_KEY, profileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun loadKeychainValue(key: String): String? = withKeychainQuery(key) { query ->
|
||||||
|
CFDictionarySetValue(query, kSecReturnData, kCFBooleanTrue)
|
||||||
|
CFDictionarySetValue(query, kSecMatchLimit, kSecMatchLimitOne)
|
||||||
|
memScoped {
|
||||||
|
val result = alloc<CFTypeRefVar>()
|
||||||
|
val status = SecItemCopyMatching(query, result.ptr)
|
||||||
|
if (status == errSecItemNotFound) return@memScoped null
|
||||||
|
if (status != errSecSuccess) return@memScoped null
|
||||||
|
val data: CFDataRef = result.value?.reinterpret() ?: return@memScoped null
|
||||||
|
try {
|
||||||
|
val length = CFDataGetLength(data).toInt()
|
||||||
|
val bytes = CFDataGetBytePtr(data) ?: return@memScoped null
|
||||||
|
ByteArray(length) { index -> bytes[index].toByte() }.decodeToString()
|
||||||
|
} finally {
|
||||||
|
CFRelease(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun saveKeychainValue(key: String, value: String?) {
|
||||||
|
deleteKeychainValue(key)
|
||||||
|
if (value.isNullOrBlank()) return
|
||||||
|
withKeychainQuery(key) { query ->
|
||||||
|
val bytes = value.encodeToByteArray().toUByteArray()
|
||||||
|
val data = CFDataCreate(null, bytes.refTo(0), bytes.size.toLong())
|
||||||
|
?: error("Unable to encode Simkl credential")
|
||||||
|
try {
|
||||||
|
CFDictionarySetValue(query, kSecValueData, data)
|
||||||
|
check(SecItemAdd(query, null) == errSecSuccess) { "Unable to store Simkl credential" }
|
||||||
|
} finally {
|
||||||
|
CFRelease(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun deleteKeychainValue(key: String, profileId: Int? = null) {
|
||||||
|
withKeychainQuery(key, profileId) { query -> SecItemDelete(query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private inline fun <T> withKeychainQuery(
|
||||||
|
key: String,
|
||||||
|
profileId: Int? = null,
|
||||||
|
block: (CFMutableDictionaryRef) -> T,
|
||||||
|
): T {
|
||||||
|
val service = CFStringCreateWithCString(null, KEYCHAIN_SERVICE, kCFStringEncodingUTF8)
|
||||||
|
?: error("Unable to encode Keychain service")
|
||||||
|
val account = CFStringCreateWithCString(
|
||||||
|
null,
|
||||||
|
profileId?.let { id -> ProfileScopedKey.of(key, id) } ?: ProfileScopedKey.of(key),
|
||||||
|
kCFStringEncodingUTF8,
|
||||||
|
) ?: error("Unable to encode Keychain account")
|
||||||
|
val query = CFDictionaryCreateMutable(
|
||||||
|
allocator = null,
|
||||||
|
capacity = 0L,
|
||||||
|
keyCallBacks = null,
|
||||||
|
valueCallBacks = null,
|
||||||
|
) ?: error("Unable to create Keychain query")
|
||||||
|
try {
|
||||||
|
CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword)
|
||||||
|
CFDictionarySetValue(query, kSecAttrService, service)
|
||||||
|
CFDictionarySetValue(query, kSecAttrAccount, account)
|
||||||
|
return block(query)
|
||||||
|
} finally {
|
||||||
|
CFRelease(query)
|
||||||
|
CFRelease(account)
|
||||||
|
CFRelease(service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,4 +12,8 @@ internal actual object TraktAuthStorage {
|
||||||
actual fun savePayload(payload: String) {
|
actual fun savePayload(payload: String) {
|
||||||
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey))
|
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun removeProfile(profileId: Int) {
|
||||||
|
NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(payloadKey, profileId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue