fix: key stored api keys by base url
This commit is contained in:
@@ -16,7 +16,10 @@ class PreferencesApiKeyStoreTest {
|
||||
|
||||
@Before
|
||||
fun clearStore() = runBlocking {
|
||||
store.invalidateApiKey("setup").getOrThrow()
|
||||
context.getSharedPreferences("kanbn_api_key_store", android.content.Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.clear()
|
||||
.commit()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -47,15 +50,56 @@ class PreferencesApiKeyStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankAndMalformedBaseUrlStillWork() = runBlocking {
|
||||
val saveBlankResult = store.saveApiKey("", "kan_key")
|
||||
fun saveGetForDifferentBaseUrlsAreIsolated() = runBlocking {
|
||||
store.saveApiKey("https://kan.bn/", "kan_key").getOrThrow()
|
||||
store.saveApiKey("https://next.kan.bn/", "next_key").getOrThrow()
|
||||
|
||||
val firstResult = store.getApiKey("https://kan.bn/")
|
||||
val secondResult = store.getApiKey("https://next.kan.bn/")
|
||||
|
||||
assertEquals("kan_key", firstResult.getOrNull())
|
||||
assertEquals("next_key", secondResult.getOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidateOneBaseUrlDoesNotRemoveOtherBaseUrlKey() = runBlocking {
|
||||
store.saveApiKey("https://kan.bn/", "kan_key").getOrThrow()
|
||||
store.saveApiKey("https://next.kan.bn/", "next_key").getOrThrow()
|
||||
|
||||
val invalidateResult = store.invalidateApiKey("https://kan.bn/")
|
||||
val removedResult = store.getApiKey("https://kan.bn/")
|
||||
val keptResult = store.getApiKey("https://next.kan.bn/")
|
||||
|
||||
assertEquals(true, invalidateResult.isSuccess)
|
||||
assertNull(removedResult.getOrNull())
|
||||
assertEquals("next_key", keptResult.getOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keyDerivationUsesNormalizedBaseUrl() = runBlocking {
|
||||
val saveResult = store.saveApiKey(" HTTPS://KAN.BN/api/v1 ", "kan_key")
|
||||
val getNormalizedResult = store.getApiKey("https://kan.bn/api/v1/")
|
||||
val getEquivalentResult = store.getApiKey("https://kan.bn/api/v1")
|
||||
|
||||
assertEquals(true, saveResult.isSuccess)
|
||||
assertEquals("kan_key", getNormalizedResult.getOrNull())
|
||||
assertEquals("kan_key", getEquivalentResult.getOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedBaseUrlStillUsesDeterministicFallbackKeying() = runBlocking {
|
||||
val saveBlankResult = store.saveApiKey("", "blank_key")
|
||||
val getBlankResult = store.getApiKey(" ")
|
||||
val saveMalformedResult = store.saveApiKey("not a url", "bad_url_key")
|
||||
val getMalformedResult = store.getApiKey("not a url")
|
||||
val invalidateMalformedResult = store.invalidateApiKey("::://")
|
||||
val getAfterInvalidateResult = store.getApiKey(" ")
|
||||
val getAfterUnrelatedInvalidateResult = store.getApiKey("not a url")
|
||||
|
||||
assertEquals(true, saveBlankResult.isSuccess)
|
||||
assertEquals("kan_key", getMalformedResult.getOrNull())
|
||||
assertEquals("blank_key", getBlankResult.getOrNull())
|
||||
assertEquals(true, saveMalformedResult.isSuccess)
|
||||
assertEquals("bad_url_key", getMalformedResult.getOrNull())
|
||||
assertEquals(true, invalidateMalformedResult.isSuccess)
|
||||
assertNull(getAfterInvalidateResult.getOrNull())
|
||||
assertEquals("bad_url_key", getAfterUnrelatedInvalidateResult.getOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package space.hackenslacker.kanbn4droid.app.auth
|
||||
|
||||
import android.content.Context
|
||||
import java.security.MessageDigest
|
||||
|
||||
interface ApiKeyStore {
|
||||
suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit>
|
||||
@@ -15,26 +16,71 @@ class PreferencesApiKeyStore(
|
||||
|
||||
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
|
||||
return runCatching {
|
||||
preferences.edit().putString(API_KEY_PREFERENCE_KEY, apiKey).apply()
|
||||
val preferenceKey = preferenceKeyForBaseUrl(baseUrl)
|
||||
preferences.edit()
|
||||
.remove(LEGACY_API_KEY_PREFERENCE_KEY)
|
||||
.putString(preferenceKey, apiKey)
|
||||
.apply()
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getApiKey(baseUrl: String): Result<String?> {
|
||||
return runCatching {
|
||||
preferences.getString(API_KEY_PREFERENCE_KEY, null)
|
||||
val preferenceKey = preferenceKeyForBaseUrl(baseUrl)
|
||||
val keyedValue = preferences.getString(preferenceKey, null)
|
||||
if (keyedValue != null) {
|
||||
keyedValue
|
||||
} else {
|
||||
val legacyValue = preferences.getString(LEGACY_API_KEY_PREFERENCE_KEY, null)
|
||||
if (legacyValue != null) {
|
||||
preferences.edit()
|
||||
.remove(LEGACY_API_KEY_PREFERENCE_KEY)
|
||||
.putString(preferenceKey, legacyValue)
|
||||
.apply()
|
||||
}
|
||||
legacyValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
|
||||
return runCatching {
|
||||
preferences.edit().remove(API_KEY_PREFERENCE_KEY).apply()
|
||||
val preferenceKey = preferenceKeyForBaseUrl(baseUrl)
|
||||
preferences.edit().remove(preferenceKey).apply()
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
internal fun preferenceKeyForBaseUrl(baseUrl: String): String {
|
||||
return deriveApiKeyPreferenceKey(baseUrl)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val PREFERENCES_NAME = "kanbn_api_key_store"
|
||||
private const val API_KEY_PREFERENCE_KEY = "api_key"
|
||||
private const val API_KEY_PREFERENCE_KEY_PREFIX = "api_key_"
|
||||
private const val LEGACY_API_KEY_PREFERENCE_KEY = "api_key"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun deriveApiKeyPreferenceKey(baseUrl: String): String {
|
||||
val normalizedBaseUrl = when (val normalized = UrlNormalizer.normalize(baseUrl)) {
|
||||
is UrlValidationResult.Valid -> normalized.normalizedUrl
|
||||
is UrlValidationResult.Invalid -> baseUrl.trim()
|
||||
}
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(normalizedBaseUrl.toByteArray(Charsets.UTF_8))
|
||||
return "api_key_${digest.toHexString()}"
|
||||
}
|
||||
|
||||
private fun ByteArray.toHexString(): String {
|
||||
val builder = StringBuilder(size * 2)
|
||||
for (byte in this) {
|
||||
val value = byte.toInt() and 0xff
|
||||
if (value < 0x10) {
|
||||
builder.append('0')
|
||||
}
|
||||
builder.append(value.toString(16))
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package space.hackenslacker.kanbn4droid.app.auth
|
||||
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
|
||||
sealed interface UrlValidationResult {
|
||||
data class Valid(val normalizedUrl: String) : UrlValidationResult
|
||||
@@ -26,7 +27,7 @@ object UrlNormalizer {
|
||||
return UrlValidationResult.Invalid("Base URL must start with http:// or https://")
|
||||
}
|
||||
|
||||
val host = uri.host
|
||||
val host = uri.host?.lowercase(Locale.ROOT)
|
||||
if (host.isNullOrBlank()) {
|
||||
return UrlValidationResult.Invalid("Enter a valid server URL")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package space.hackenslacker.kanbn4droid.app.auth
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ApiKeyStoreKeyDerivationTest {
|
||||
|
||||
@Test
|
||||
fun equivalentNormalizedBaseUrlsProduceSamePreferenceKey() {
|
||||
val first = deriveApiKeyPreferenceKey(" HTTPS://KAN.BN/api/v1 ")
|
||||
val second = deriveApiKeyPreferenceKey("https://kan.bn/api/v1/")
|
||||
val third = deriveApiKeyPreferenceKey("https://kan.bn/api/v1")
|
||||
|
||||
assertEquals(first, second)
|
||||
assertEquals(second, third)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentNormalizedBaseUrlsProduceDifferentPreferenceKeys() {
|
||||
val first = deriveApiKeyPreferenceKey("https://kan.bn/")
|
||||
val second = deriveApiKeyPreferenceKey("https://next.kan.bn/")
|
||||
|
||||
assertNotEquals(first, second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedBaseUrlsUseTrimmedDeterministicFallback() {
|
||||
val first = deriveApiKeyPreferenceKey("not a url")
|
||||
val second = deriveApiKeyPreferenceKey("not a url")
|
||||
val trimmedEquivalent = deriveApiKeyPreferenceKey(" not a url ")
|
||||
val different = deriveApiKeyPreferenceKey("::://")
|
||||
|
||||
assertEquals(first, second)
|
||||
assertEquals(first, trimmedEquivalent)
|
||||
assertNotEquals(first, different)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user