fix: key stored api keys by base url
This commit is contained in:
@@ -16,7 +16,10 @@ class PreferencesApiKeyStoreTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun clearStore() = runBlocking {
|
fun clearStore() = runBlocking {
|
||||||
store.invalidateApiKey("setup").getOrThrow()
|
context.getSharedPreferences("kanbn_api_key_store", android.content.Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.clear()
|
||||||
|
.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -47,15 +50,56 @@ class PreferencesApiKeyStoreTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun blankAndMalformedBaseUrlStillWork() = runBlocking {
|
fun saveGetForDifferentBaseUrlsAreIsolated() = runBlocking {
|
||||||
val saveBlankResult = store.saveApiKey("", "kan_key")
|
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 getMalformedResult = store.getApiKey("not a url")
|
||||||
val invalidateMalformedResult = store.invalidateApiKey("::://")
|
val invalidateMalformedResult = store.invalidateApiKey("::://")
|
||||||
val getAfterInvalidateResult = store.getApiKey(" ")
|
val getAfterUnrelatedInvalidateResult = store.getApiKey("not a url")
|
||||||
|
|
||||||
assertEquals(true, saveBlankResult.isSuccess)
|
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)
|
assertEquals(true, invalidateMalformedResult.isSuccess)
|
||||||
assertNull(getAfterInvalidateResult.getOrNull())
|
assertEquals("bad_url_key", getAfterUnrelatedInvalidateResult.getOrNull())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package space.hackenslacker.kanbn4droid.app.auth
|
package space.hackenslacker.kanbn4droid.app.auth
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
interface ApiKeyStore {
|
interface ApiKeyStore {
|
||||||
suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit>
|
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> {
|
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
|
||||||
return runCatching {
|
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
|
Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getApiKey(baseUrl: String): Result<String?> {
|
override suspend fun getApiKey(baseUrl: String): Result<String?> {
|
||||||
return runCatching {
|
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> {
|
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
preferences.edit().remove(API_KEY_PREFERENCE_KEY).apply()
|
val preferenceKey = preferenceKeyForBaseUrl(baseUrl)
|
||||||
|
preferences.edit().remove(preferenceKey).apply()
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun preferenceKeyForBaseUrl(baseUrl: String): String {
|
||||||
|
return deriveApiKeyPreferenceKey(baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
private const val PREFERENCES_NAME = "kanbn_api_key_store"
|
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
|
package space.hackenslacker.kanbn4droid.app.auth
|
||||||
|
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
sealed interface UrlValidationResult {
|
sealed interface UrlValidationResult {
|
||||||
data class Valid(val normalizedUrl: String) : 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://")
|
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()) {
|
if (host.isNullOrBlank()) {
|
||||||
return UrlValidationResult.Invalid("Enter a valid server URL")
|
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