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.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:scheme="nuvio"
|
||||
android:host="auth"
|
||||
android:path="/simkl" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:scheme="nuvio"
|
||||
android:host="auth"
|
||||
|
|
|
|||
|
|
@ -108,6 +108,21 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
)
|
||||
}
|
||||
|
||||
outDir.resolve("com/nuvio/app/features/simkl").apply {
|
||||
mkdirs()
|
||||
resolve("SimklConfig.kt").writeText(
|
||||
"""
|
||||
|package com.nuvio.app.features.simkl
|
||||
|
|
||||
|object SimklConfig {
|
||||
| const val CLIENT_ID = "${props.getProperty("SIMKL_CLIENT_ID", "")}"
|
||||
| const val REDIRECT_URI = "${props.getProperty("SIMKL_REDIRECT_URI", "nuvio://auth/simkl")}"
|
||||
| const val APP_NAME = "${props.getProperty("SIMKL_APP_NAME", "nuvio")}"
|
||||
|}
|
||||
""".trimMargin()
|
||||
)
|
||||
}
|
||||
|
||||
outDir.resolve("com/nuvio/app/features/player/skip").apply {
|
||||
mkdirs()
|
||||
resolve("IntroDbConfig.kt").writeText(
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import com.nuvio.app.features.trakt.TraktAuthStorage
|
|||
import com.nuvio.app.features.trakt.TraktCommentsStorage
|
||||
import com.nuvio.app.features.trakt.TraktLibraryStorage
|
||||
import com.nuvio.app.features.trakt.TraktSettingsStorage
|
||||
import com.nuvio.app.features.simkl.SimklAuthStorage
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
||||
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
|
||||
import com.nuvio.app.core.ui.CardDepthStyleStorage
|
||||
|
|
@ -104,6 +105,7 @@ class MainActivity : AppCompatActivity() {
|
|||
TraktCommentsStorage.initialize(applicationContext)
|
||||
TraktLibraryStorage.initialize(applicationContext)
|
||||
TraktSettingsStorage.initialize(applicationContext)
|
||||
SimklAuthStorage.initialize(applicationContext)
|
||||
LibraryDisplaySettingsStorage.initialize(applicationContext)
|
||||
ContinueWatchingPreferencesStorage.initialize(applicationContext)
|
||||
ResumePromptStorage.initialize(applicationContext)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
"nuvio_mdblist_settings",
|
||||
"nuvio_auth",
|
||||
"nuvio_trakt_auth",
|
||||
"nuvio_simkl_auth",
|
||||
"nuvio_trakt_library",
|
||||
"nuvio_trakt_settings",
|
||||
"nuvio_watched",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun removeProfile(profileId: Int) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.remove(ProfileScopedKey.of(payloadKey, profileId))
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.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)
|
||||
|
|
|
|||
|
|
@ -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 onProfileChanged()
|
||||
fun clearLocalState()
|
||||
fun removeStoredProfile(profileId: Int)
|
||||
fun handleAuthCallback(url: String): Boolean = false
|
||||
}
|
||||
|
||||
object TrackingProviderRegistry {
|
||||
|
|
@ -73,6 +75,29 @@ object TrackingProviderRegistry {
|
|||
.filter { provider -> capability in provider.descriptor.capabilities }
|
||||
.sortedBy { provider -> provider.descriptor.id.ordinal }
|
||||
|
||||
fun handleAuthCallback(url: String): Boolean =
|
||||
providersWith(TrackingCapability.AUTHENTICATION)
|
||||
.any { provider -> provider.handleAuthCallback(url) }
|
||||
|
||||
fun ensureLoaded() {
|
||||
providerSnapshot().forEach(TrackingAuthProvider::ensureLoaded)
|
||||
}
|
||||
|
||||
fun onProfileChanged() {
|
||||
providerSnapshot().forEach(TrackingAuthProvider::onProfileChanged)
|
||||
}
|
||||
|
||||
fun clearLocalState() {
|
||||
providerSnapshot().forEach(TrackingAuthProvider::clearLocalState)
|
||||
}
|
||||
|
||||
fun removeStoredProfiles(profileIds: Iterable<Int>) {
|
||||
val providers = providerSnapshot()
|
||||
profileIds.forEach { profileId ->
|
||||
providers.forEach { provider -> provider.removeStoredProfile(profileId) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun providerSnapshot(): List<TrackingAuthProvider> = synchronized(lock) {
|
||||
authProviders.values.toList()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
override fun removeStoredProfile(profileId: Int) {
|
||||
TraktAuthStorage.removeProfile(profileId)
|
||||
}
|
||||
|
||||
fun snapshot(): TraktAuthUiState {
|
||||
ensureLoaded()
|
||||
return _uiState.value
|
||||
|
|
@ -154,6 +158,15 @@ object TraktAuthRepository : TrackingAuthProvider {
|
|||
}
|
||||
}
|
||||
|
||||
override fun handleAuthCallback(url: String): Boolean {
|
||||
if (!isTraktAuthCallback(url)) return false
|
||||
onAuthCallbackReceived(url)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isTraktAuthCallback(url: String): Boolean =
|
||||
url == TraktConfig.REDIRECT_URI || url.startsWith("${TraktConfig.REDIRECT_URI}?")
|
||||
|
||||
suspend fun authorizedHeaders(): Map<String, String>? {
|
||||
ensureLoaded()
|
||||
if (!authState.isAuthenticated) return null
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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