S.H.O.N.A.R._Desktop_Companion/shared/com/shonar/provider/ShonarAuthStore.kt
avi 76c867fca4 Standalone Shonar Desktop: vendor portable sources + local engine; decouple from ~/Projects/Shonar
- shared/ = portable Android-origin sources vendored from deferred/desktop-server
  (app/build.gradle.kts srcDir repointed; PlaybackController.kt excluded as Android-only)
- backend/ = bundled-lite engine (SQLite + inline queue); .venv symlinked from the
  old checkout, PYTHONPATH pins THIS backend's code over any editable install
- repoRoot() resolves this project dir (env SHONAR_REPO still wins); desktop-dev.sh
  watches shared/ + backend/
- Verified: :app:compileKotlin + :app:test green (23 tests); engine boots on :8010,
  self-migrates, /healthz ok
2026-09-14 17:14:54 -05:00

83 lines
3 KiB
Kotlin

package com.shonar.provider
import com.shonar.settings.SettingsStore
/**
* Secure persistence for the custom SHONAR server session.
*
* Stored (all in the Keystore-backed secure store, never in Room or logs):
* - base URL origin (e.g. https://shonar.example.com)
* - account email (hint only)
* - access token + expiry
* - refresh token + device id
*
* The user's password is NEVER stored here: it lives in memory for exactly
* one login call (see [ProviderCredential.ShonarLogin]).
*
* Rotation discipline (backend M1 has refresh reuse detection): every
* successful refresh overwrites the stored pair immediately, so a stored
* refresh token is always the newest one the server has issued.
*/
class ShonarAuthStore(private val secure: SettingsStore) {
suspend fun save(session: ShonarSession) {
secure.putString(KEY_BASE_URL, session.baseUrl)
secure.putString(KEY_EMAIL, session.email)
secure.putString(KEY_ACCESS, session.accessToken)
secure.putString(KEY_REFRESH, session.refreshToken)
secure.putString(KEY_EXPIRES_AT, session.expiresAtEpochSec.toString())
if (session.deviceId != null) secure.putString(KEY_DEVICE_ID, session.deviceId)
else secure.remove(KEY_DEVICE_ID)
}
suspend fun load(): ShonarSession? {
val baseUrl = secure.getString(KEY_BASE_URL) ?: return null
val access = secure.getString(KEY_ACCESS) ?: return null
val refresh = secure.getString(KEY_REFRESH) ?: return null
return ShonarSession(
baseUrl = baseUrl,
email = secure.getString(KEY_EMAIL).orEmpty(),
accessToken = access,
refreshToken = refresh,
expiresAtEpochSec = secure.getString(KEY_EXPIRES_AT)?.toLongOrNull(),
deviceId = secure.getString(KEY_DEVICE_ID),
)
}
/** Drop tokens but keep the URL + email so re-login is one step. */
suspend fun clearTokens() {
secure.remove(KEY_ACCESS)
secure.remove(KEY_REFRESH)
secure.remove(KEY_EXPIRES_AT)
secure.remove(KEY_DEVICE_ID)
}
/** Forget everything, including which server was configured. */
suspend fun clearAll() {
secure.remove(KEY_BASE_URL)
secure.remove(KEY_EMAIL)
clearTokens()
}
companion object {
private const val PREFIX = "provider.custom-shonar."
const val KEY_BASE_URL = PREFIX + "base_url"
const val KEY_EMAIL = PREFIX + "email"
const val KEY_ACCESS = PREFIX + "access_token"
const val KEY_REFRESH = PREFIX + "refresh_token"
const val KEY_EXPIRES_AT = PREFIX + "expires_at"
const val KEY_DEVICE_ID = PREFIX + "device_id"
}
}
/** In-memory session handed between login/refresh calls and the store. */
data class ShonarSession(
val baseUrl: String,
val email: String,
val accessToken: String,
val refreshToken: String,
val expiresAtEpochSec: Long?,
val deviceId: String?,
) {
override fun toString(): String = "ShonarSession(server=$baseUrl, account=$email, [redacted])"
}