- 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
83 lines
3 KiB
Kotlin
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])"
|
|
}
|