diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fc46317..81e1b31 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,6 @@ kotlinx-coroutines = "1.10.2" kotlinx-io = "0.8.2" junit = "4.13.2" vanniktechMavenPublish = "0.35.0" -toolmanager = "0.0.14" ktor = "3.4.2" androidxWork = "2.10.0" @@ -20,7 +19,6 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" } -light-toolmanager-server = { module = "com.thelightphone.toolmanager:server", version.ref = "toolmanager" } ktor-clientCore = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-clientCio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-clientWebsockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index e88082e..f6ec716 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -47,7 +47,6 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.io.core) - implementation(libs.light.toolmanager.server) implementation(libs.ktor.clientCore) implementation(libs.ktor.clientCio) implementation(libs.ktor.clientWebsockets) diff --git a/library/src/androidMain/kotlin/com/thelightphone/backup/BackupDependencyProvider.kt b/library/src/androidMain/kotlin/com/thelightphone/backup/BackupDependencyProvider.kt index 4d10385..641c8de 100644 --- a/library/src/androidMain/kotlin/com/thelightphone/backup/BackupDependencyProvider.kt +++ b/library/src/androidMain/kotlin/com/thelightphone/backup/BackupDependencyProvider.kt @@ -9,9 +9,6 @@ interface BackupDependencyProvider { // What to back up. Independent of which cloud provider the run is targeting. fun createDataSource(): BackupDataSource - // For (re-)authenticating an account - fun createOAuthTunnelClient(provider: RemoteBackupProvider): OAuthTunnelClient? - // Root folder within the provider's storage that backups are written under. fun rootFolderPath(): String diff --git a/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/CoroutineJobDataTree.kt b/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/CoroutineJobDataTree.kt deleted file mode 100644 index d140c1b..0000000 --- a/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/CoroutineJobDataTree.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.thelightphone.backup - -import com.thelightphone.toolmanager.datatree.DeferredJobTracker -import com.thelightphone.toolmanager.datatree.JobDataTree -import com.thelightphone.toolmanager.datatree.JobResult -import com.thelightphone.toolmanager.datatree.JobStart -import com.thelightphone.toolmanager.datatree.JobStatus -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import java.io.InputStream -import java.nio.file.Path -import java.util.concurrent.ConcurrentHashMap - -// A JobDataTree whose job is a single suspend operation awaited in the background (a websocket -// tunnel, a long-poll, etc) -abstract class CoroutineJobDataTree( - private val jobScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) -) : JobDataTree() { - private val jobTracker = DeferredJobTracker(jobScope) - - // Tracks the single in-flight job per path, so starting a new job at a path that already has - // one running supersedes (rather than races) it. - private val activeJobs = ConcurrentHashMap>() - - override suspend fun getBytes(filePath: Path): Result = - Result.failure(NoSuchElementException("no such job result: $filePath")) - - override suspend fun startJob( - path: Path, - params: Map, - selfOrigin: String, - mintCallbackState: (jobId: String) -> String - ): Result { - val jobId = jobTracker.startPending() - val redirectUrl = prepareRedirectUrl(jobId, path, params) - val job = jobScope.launch { - processResult(path, jobId, runJob(jobId)) - } - activeJobs.put(path, jobId to job)?.let { (previousJobId, previousJob) -> - previousJob.cancel() - jobTracker.complete( - previousJobId, - Result.failure(CancellationException("superseded by a newer job at $path")), - ) - } - return Result.success(JobStart(jobId, redirectUrl)) - } - - // Any synchronous setup startJob needs before it can return - most commonly a URL to send the - // caller to. - protected open fun prepareRedirectUrl(jobId: String, path: Path, params: Map): String? = null - - // Does the actual work this job represents. - protected abstract suspend fun runJob(jobId: String): T - - // Validates/consumes runJob's result - protected abstract suspend fun consumeResult(result: T): Result - - private suspend fun processResult(path: Path, jobId: String, result: T) { - // Only clear the path's active-job slot if it's still this job - activeJobs.compute(path) { _, current -> if (current?.first == jobId) null else current } - - // The job may have already been superseded (and marked failed) by a newer startJob() call - if (jobTracker.status(jobId) !is JobStatus.Running) return - - consumeResult(result).fold( - onSuccess = { jobResult -> jobTracker.complete(jobId, Result.success(jobResult)) }, - onFailure = { error -> jobTracker.complete(jobId, Result.failure(error)) }, - ) - } - - override suspend fun getJobStatus(path: Path, jobId: String): JobStatus = - jobTracker.status(jobId) -} diff --git a/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/OAuthJobDataTree.kt b/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/OAuthJobDataTree.kt deleted file mode 100644 index 7c0ab91..0000000 --- a/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/OAuthJobDataTree.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.thelightphone.backup - -import com.thelightphone.toolmanager.datatree.JobResult -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import java.nio.file.Path -import java.util.concurrent.ConcurrentHashMap -import kotlin.time.Clock -import kotlin.time.Duration.Companion.seconds -import kotlin.time.Instant - -data class StoredOAuthTokens( - val accessToken: String, - val refreshToken: String, - val expiresAt: Instant, - val scope: String, -) - -interface TokenStorage { - suspend fun saveOAuthDetails( - accountType: String, - accessToken: String, - refreshToken: String, - expiresAt: Instant, - scope: String - ) - - suspend fun getOAuthDetails(accountType: String): StoredOAuthTokens? - - suspend fun removeOAuthDetails(accountType: String): Boolean -} - -class OAuthJobDataTree( - private val accountType: String, - private val oAuthTunnelClient: OAuthTunnelClient, - private val tokenStorage: TokenStorage, - jobScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) -) : CoroutineJobDataTree(jobScope) { - private val pendingSpecs = ConcurrentHashMap() - - override fun prepareRedirectUrl(jobId: String, path: Path, params: Map): String? { - val spec = oAuthTunnelClient.newSpecForSessionId(jobId) - pendingSpecs[jobId] = spec - return spec.authUrl - } - - override suspend fun runJob(jobId: String): OAuthResult { - val spec = pendingSpecs.remove(jobId) - ?: error("runJob($jobId) called without a prior prepareJob($jobId)") - return oAuthTunnelClient.authenticate(spec) - } - - override suspend fun consumeResult(result: OAuthResult): Result { - val tokens = when (result) { - is OAuthResult.Failure -> return Result.failure(IllegalArgumentException("OAuth tunnel failed: ${result.error}")) - is OAuthResult.Success -> result.tokens.jsonObject - } - - val accessToken = tokens["access_token"]?.jsonPrimitive?.contentOrNull - val refreshToken = tokens["refresh_token"]?.jsonPrimitive?.contentOrNull - // expires_in is in seconds per the OAuth2 spec (RFC 6749 4.2.2). - val expiresInSeconds = tokens["expires_in"]?.jsonPrimitive?.contentOrNull?.toLongOrNull() - val scope = tokens["scope"]?.jsonPrimitive?.contentOrNull - if (accessToken == null || refreshToken == null || expiresInSeconds == null || scope == null) { - return Result.failure(IllegalArgumentException("missing or invalid OAuth token fields")) - } - - tokenStorage.saveOAuthDetails( - accountType = accountType, - accessToken = accessToken, - refreshToken = refreshToken, - expiresAt = Clock.System.now() + expiresInSeconds.seconds, - scope = scope, - ) - - return Result.success(JobResult(message = "Linked $accountType account.")) - } -} diff --git a/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/OAuthTunnelClient.kt b/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/OAuthTunnelClient.kt deleted file mode 100644 index db32b8f..0000000 --- a/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/OAuthTunnelClient.kt +++ /dev/null @@ -1,291 +0,0 @@ -package com.thelightphone.backup - -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.websocket.WebSockets -import io.ktor.client.plugins.websocket.webSocket -import io.ktor.client.request.forms.FormDataContent -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.Parameters -import io.ktor.http.contentType -import io.ktor.http.isSuccess -import io.ktor.websocket.Frame -import io.ktor.websocket.close -import io.ktor.websocket.readText -import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonPrimitive -import java.net.URLEncoder -import java.security.MessageDigest -import java.security.SecureRandom -import java.util.Base64 -import kotlin.time.Duration.Companion.minutes - -// CLIENT-SIDE FOR https://github.com/lightphone/light-oauth-relay - -enum class TunnelMode { - // Token exchange happens on the worker, mostly just for Google since they require client_secret for some scopes - EXCHANGE, - // Token exchange happens on device, here - RELAY -} - -sealed class OAuthResult { - data class Success(val tokens: JsonElement) : OAuthResult() - - // `code` is the OAuth2 `error` field (RFC 6749 5.2) when the failure came from a parseable - // provider/Worker error response - e.g. "invalid_grant" for a dead refresh token or - // authorization code, "access_denied" for a declined consent screen. Null when the failure - // happened before any such response existed (network error, timeout, unparsable body). - data class Failure(val error: String, val code: String? = null) : OAuthResult() -} - -private fun JsonElement.oauthErrorCode(): String? = - (this as? JsonObject)?.get("error")?.jsonPrimitive?.contentOrNull - -@Serializable -private data class StartMessage( - val type: String = "start", - val mode: String, - val provider: String? = null, - val verifier: String? = null, -) - -@Serializable -private data class TunnelResponse( - val type: String, - val tokens: JsonElement? = null, - val code: String? = null, - val error: JsonElement? = null, -) - -@Serializable -private data class RefreshRequest(val provider: String, val refresh_token: String) - -data class OAuthTunnelSpec( - val sessionId: String, - val verifier: String, - val challenge: String, - val authUrl: String, - val mode: TunnelMode, - val providerId: String? = null, // required for EXCHANGE mode, must match the Worker's provider table key - val tokenEndpoint: String? = null, // required for RELAY mode, device exchanges the code itself - val clientId: String? = null, // required for RELAY mode -) - -private val json = Json { - ignoreUnknownKeys = true - encodeDefaults = true -} - -interface OAuthTunnelClient { - fun newSpecForSessionId(sessionId: String): OAuthTunnelSpec - suspend fun authenticate(spec: OAuthTunnelSpec): OAuthResult - - suspend fun refreshToken(refreshToken: String): OAuthResult -} - -abstract class BaseOAuthTunnelClient(private val workerHost: String) : OAuthTunnelClient { - private val redirectUri = "https://$workerHost/callback" - private val client = HttpClient(CIO) { install(WebSockets) } - - abstract val mode: TunnelMode - abstract val clientId: String - abstract val authEndpoint: String - abstract val scope: String - - /** Required for EXCHANGE mode, must match a key in the Worker's provider table. */ - open val providerId: String? = null - - /** Required for RELAY mode, the provider's token endpoint the device will POST to directly. */ - open val tokenEndpoint: String? = null - - open fun buildAuthUrl(sessionId: String, challenge: String): String { - return authEndpoint + - "?client_id=${enc(clientId)}" + - "&redirect_uri=${enc(redirectUri)}" + - "&response_type=code" + - "&scope=${enc(scope)}" + - "&state=${enc(sessionId)}" + - "&code_challenge=${enc(challenge)}" + - "&code_challenge_method=S256" - } - - override fun newSpecForSessionId(sessionId: String): OAuthTunnelSpec { - val verifier = randomUrlSafeString(32) - val challenge = codeChallenge(verifier) - val url = buildAuthUrl(sessionId, challenge) - return OAuthTunnelSpec( - sessionId = sessionId, - verifier = verifier, - challenge = challenge, - authUrl = url, - mode = mode, - providerId = providerId, - tokenEndpoint = tokenEndpoint, - clientId = clientId, - ) - } - - override suspend fun authenticate(spec: OAuthTunnelSpec): OAuthResult { - return withTimeoutOrNull(5.minutes) { - var result: OAuthResult? = null - - client.webSocket( - urlString = "wss://$workerHost/tunnel/${spec.sessionId}", - ) { - val start = when (spec.mode) { - TunnelMode.EXCHANGE -> StartMessage( - mode = "exchange", - provider = spec.providerId, - verifier = spec.verifier, - ) - TunnelMode.RELAY -> StartMessage(mode = "relay") - } - send(Frame.Text(json.encodeToString(start))) - - val frame = incoming.receiveCatching().getOrNull() - if (frame is Frame.Text) { - val response = json.decodeFromString(frame.readText()) - result = when (response.type) { - "tokens" -> OAuthResult.Success(response.tokens!!) - "code" -> exchangeCodeDirectly(spec, response.code!!) - else -> OAuthResult.Failure( - response.error?.toString() ?: "unknown error", - code = response.error?.oauthErrorCode(), - ) - } - } - close() - } - - result - } ?: OAuthResult.Failure("timed out waiting for authorization") - } - - private suspend fun exchangeCodeDirectly(spec: OAuthTunnelSpec, code: String): OAuthResult { - return try { - val response = client.post(spec.tokenEndpoint!!) { - setBody( - FormDataContent( - Parameters.build { - append("client_id", spec.clientId!!) - append("code", code) - append("code_verifier", spec.verifier) - append("grant_type", "authorization_code") - append("redirect_uri", redirectUri) - }, - ), - ) - } - val body = json.parseToJsonElement(response.bodyAsText()) - if (response.status.isSuccess()) { - OAuthResult.Success(body) - } else { - OAuthResult.Failure(body.toString(), code = body.oauthErrorCode()) - } - } catch (e: Exception) { - OAuthResult.Failure("token exchange failed: ${e.message}") - } - } - - override suspend fun refreshToken(refreshToken: String): OAuthResult = when (mode) { - TunnelMode.EXCHANGE -> refreshViaWorker(refreshToken) - TunnelMode.RELAY -> refreshDirectly(refreshToken) - } - - private suspend fun refreshViaWorker(refreshToken: String): OAuthResult { - val provider = providerId - ?: return OAuthResult.Failure("refresh failed: no providerId configured for EXCHANGE mode") - return try { - val response = client.post("https://$workerHost/refresh") { - contentType(ContentType.Application.Json) - setBody(json.encodeToString(RefreshRequest(provider, refreshToken))) - } - val body = json.parseToJsonElement(response.bodyAsText()) - if (response.status.isSuccess()) { - OAuthResult.Success(body) - } else { - OAuthResult.Failure(body.toString(), code = body.oauthErrorCode()) - } - } catch (e: Exception) { - OAuthResult.Failure("refresh failed: ${e.message}") - } - } - - private suspend fun refreshDirectly(refreshToken: String): OAuthResult { - val endpoint = tokenEndpoint - ?: return OAuthResult.Failure("refresh failed: no tokenEndpoint configured for RELAY mode") - return try { - val response = client.post(endpoint) { - setBody( - FormDataContent( - Parameters.build { - append("client_id", clientId) - append("refresh_token", refreshToken) - append("grant_type", "refresh_token") - }, - ), - ) - } - val body = json.parseToJsonElement(response.bodyAsText()) - if (response.status.isSuccess()) { - OAuthResult.Success(body) - } else { - OAuthResult.Failure(body.toString(), code = body.oauthErrorCode()) - } - } catch (e: Exception) { - OAuthResult.Failure("refresh failed: ${e.message}") - } - } -} - -class GoogleOAuthTunnelClient(override val clientId: String, workerHost: String) : BaseOAuthTunnelClient(workerHost) { - override val mode = TunnelMode.EXCHANGE - override val providerId = "google" - override val authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth" - override val scope = "https://www.googleapis.com/auth/drive.file" - - override fun buildAuthUrl(sessionId: String, challenge: String): String { - return super.buildAuthUrl(sessionId, challenge) + "&access_type=offline&prompt=consent" - } -} - -// TODO -class OneDriveOAuthTunnelClient(override val clientId: String, workerHost: String) : BaseOAuthTunnelClient(workerHost) { - override val mode = TunnelMode.RELAY - override val authEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" - override val tokenEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/token" - override val scope = "offline_access Files.ReadWrite" -} - -class DropboxOAuthTunnelClient(override val clientId: String, workerHost: String) : BaseOAuthTunnelClient(workerHost) { - override val mode = TunnelMode.RELAY - override val authEndpoint = "https://www.dropbox.com/oauth2/authorize" - override val tokenEndpoint = "https://api.dropboxapi.com/oauth2/token" - override val scope = "files.content.write files.content.read" - - override fun buildAuthUrl(sessionId: String, challenge: String): String { - return super.buildAuthUrl(sessionId, challenge) + "&token_access_type=offline" - } -} - -private fun enc(s: String) = URLEncoder.encode(s, "UTF-8") - -private fun randomUrlSafeString(byteLength: Int): String { - val bytes = ByteArray(byteLength) - SecureRandom().nextBytes(bytes) - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) -} - -private fun codeChallenge(verifier: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII)) - return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) -} diff --git a/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteAccessTokenProvider.kt b/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteAccessTokenProvider.kt index c83e97b..99240ec 100644 --- a/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteAccessTokenProvider.kt +++ b/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteAccessTokenProvider.kt @@ -1,15 +1,5 @@ package com.thelightphone.backup -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlin.time.Clock -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds -import kotlin.time.Instant - // Provider-agnostic access-token caching interface RemoteAccessTokenProvider { @@ -21,103 +11,3 @@ interface RemoteAccessTokenProvider { // the cache. suspend fun invalidateAccessToken() } - -data class RefreshedAccessToken(val accessToken: String, val expiresAt: Instant) - -sealed class TokenRefreshOutcome { - data class Refreshed(val token: RefreshedAccessToken) : TokenRefreshOutcome() - - // The refresh token itself is dead (OAuth invalid_grant - revoked, expired, or the user pulled - // access). Don't retry - data class InvalidGrant(val message: String) : TokenRefreshOutcome() - - // Anything else - network failure, unexpected response shape, etc. Maybe retry - data class Failed(val cause: Throwable) : TokenRefreshOutcome() -} - -// Adapts OAuthTunnelClient.refreshToken's OAuthResult into the TokenRefreshOutcome shape -suspend fun OAuthTunnelClient.refreshAccessToken(refreshToken: String): TokenRefreshOutcome { - val tokens = when (val result = refreshToken(refreshToken)) { - is OAuthResult.Failure -> { - return if (result.code == "invalid_grant") { - TokenRefreshOutcome.InvalidGrant(result.error) - } else { - TokenRefreshOutcome.Failed(IllegalStateException(result.error)) - } - } - is OAuthResult.Success -> result.tokens.jsonObject - } - - val accessToken = tokens["access_token"]?.jsonPrimitive?.contentOrNull - // expires_in is in seconds per the OAuth2 spec (RFC 6749 4.2.2). - val expiresInSeconds = tokens["expires_in"]?.jsonPrimitive?.contentOrNull?.toLongOrNull() - if (accessToken == null || expiresInSeconds == null) { - return TokenRefreshOutcome.Failed(IllegalStateException("missing or invalid refreshed token fields")) - } - - return TokenRefreshOutcome.Refreshed(RefreshedAccessToken(accessToken, Clock.System.now() + expiresInSeconds.seconds)) -} - -// Base class for token storage. Handles refreshing, just pass a lambda -class StoredOAuthTokenProvider( - private val accountType: String, - private val tokenStorage: TokenStorage, - private val refresh: suspend (refreshToken: String) -> TokenRefreshOutcome, -) : RemoteAccessTokenProvider { - // Refreshes are a network round trip plus a re-persist, and every concurrent call needs a - // token - this mutex means concurrent callers during a refresh all await the one attempt - // rather than each firing their own. - private val mutex = Mutex() - private var cached: StoredOAuthTokens? = null - private var forceRefresh = false - - override suspend fun getAccessToken(): Result = mutex.withLock { - val stored = cached ?: tokenStorage.getOAuthDetails(accountType) - ?: return@withLock Result.failure( - RemoteBackupError.Unauthorized(IllegalStateException("no $accountType account linked")), - ) - cached = stored - - val expiringSoon = Clock.System.now() >= stored.expiresAt - EXPIRY_SKEW - if (!forceRefresh && !expiringSoon) { - return@withLock Result.success(stored.accessToken) - } - - when (val outcome = refresh(stored.refreshToken)) { - is TokenRefreshOutcome.Refreshed -> { - val updated = stored.copy( - accessToken = outcome.token.accessToken, - expiresAt = outcome.token.expiresAt, - ) - tokenStorage.saveOAuthDetails( - accountType = accountType, - accessToken = updated.accessToken, - refreshToken = updated.refreshToken, - expiresAt = updated.expiresAt, - scope = updated.scope, - ) - cached = updated - forceRefresh = false - Result.success(updated.accessToken) - } - is TokenRefreshOutcome.InvalidGrant -> { - // The refresh token is permanently dead - keeping it around would just mean every - // future call fails the same way, so drop it and let the caller detect "not linked". - tokenStorage.removeOAuthDetails(accountType) - cached = null - Result.failure(RemoteBackupError.Unauthorized(IllegalStateException(outcome.message))) - } - is TokenRefreshOutcome.Failed -> { - Result.failure(RemoteBackupError.Unauthorized(outcome.cause)) - } - } - } - - override suspend fun invalidateAccessToken() = mutex.withLock { - forceRefresh = true - } - - private companion object { - val EXPIRY_SKEW = 2.minutes - } -} diff --git a/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/OAuthJobDataTreeTest.kt b/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/OAuthJobDataTreeTest.kt deleted file mode 100644 index 82d3983..0000000 --- a/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/OAuthJobDataTreeTest.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.thelightphone.backup - -import com.thelightphone.toolmanager.datatree.JobStatus -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import java.nio.file.Path -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertTrue - -// TESTS MOSTLY LLM'ed - -// authenticate() suspends on `result` until the test completes it, standing in for the real -// implementation's wait on the websocket tunnel. -private class FakeOAuthTunnelClient : OAuthTunnelClient { - val result = CompletableDeferred() - - override fun newSpecForSessionId(sessionId: String) = OAuthTunnelSpec( - sessionId = sessionId, - verifier = "verifier", - challenge = "challenge", - authUrl = "https://example.com/auth?state=$sessionId", - mode = TunnelMode.EXCHANGE, - providerId = "fake", - ) - - override suspend fun authenticate(spec: OAuthTunnelSpec): OAuthResult = result.await() - - override suspend fun refreshToken(refreshToken: String): OAuthResult = - throw NotImplementedError("not exercised by these tests") -} - -private class FakeTokenStorage : TokenStorage { - data class Saved( - val accountType: String, - val accessToken: String, - val refreshToken: String, - val expiresAt: kotlin.time.Instant, - val scope: String, - ) - - val saved = mutableListOf() - - override suspend fun saveOAuthDetails( - accountType: String, - accessToken: String, - refreshToken: String, - expiresAt: kotlin.time.Instant, - scope: String, - ) { - saved += Saved(accountType, accessToken, refreshToken, expiresAt, scope) - } - - override suspend fun getOAuthDetails(accountType: String): StoredOAuthTokens? = - saved.lastOrNull { it.accountType == accountType } - ?.let { StoredOAuthTokens(it.accessToken, it.refreshToken, it.expiresAt, it.scope) } - - override suspend fun removeOAuthDetails(accountType: String): Boolean = - saved.removeAll { it.accountType == accountType } -} - -private fun tokensJson( - accessToken: String = "access-123", - refreshToken: String = "refresh-456", - expiresIn: Long = 3600, - scope: String = "scope-a scope-b", -) = buildJsonObject { - put("access_token", JsonPrimitive(accessToken)) - put("refresh_token", JsonPrimitive(refreshToken)) - put("expires_in", JsonPrimitive(expiresIn)) - put("scope", JsonPrimitive(scope)) -} - -class OAuthJobDataTreeTest { - private val path = Path.of("/oauth/google") - - // Dispatchers.Unconfined runs a launched coroutine synchronously up to its first suspension - // point, and resumes it synchronously on whatever thread completes that suspension. Since - // FakeOAuthTunnelClient only ever suspends on `result`, completing `result` from the test - // thread runs the rest of the job (processResult -> completeJob) to completion before the - // test's next line, with no dispatcher juggling needed. - private fun newTree(tunnel: FakeOAuthTunnelClient, tokenStorage: FakeTokenStorage) = OAuthJobDataTree( - accountType = "google", - oAuthTunnelClient = tunnel, - tokenStorage = tokenStorage, - jobScope = CoroutineScope(Dispatchers.Unconfined), - ) - - @Test - fun startJob_returnsAuthUrlAndIsRunningBeforeTunnelResolves() = runBlocking { - val tree = newTree(FakeOAuthTunnelClient(), FakeTokenStorage()) - - val started = tree.startJob(path, emptyMap(), "https://self") { it }.getOrThrow() - - assertTrue(started.redirectUrl!!.contains(started.jobId)) - assertEquals(JobStatus.Running, tree.getJobStatus(path, started.jobId)) - } - - @Test - fun successfulTunnel_savesTokensAndReportsSucceeded() = runBlocking { - val tunnel = FakeOAuthTunnelClient() - val tokenStorage = FakeTokenStorage() - val tree = newTree(tunnel, tokenStorage) - - val started = tree.startJob(path, emptyMap(), "https://self") { it }.getOrThrow() - tunnel.result.complete(OAuthResult.Success(tokensJson())) - - assertIs(tree.getJobStatus(path, started.jobId)) - val saved = tokenStorage.saved.single() - assertEquals("google", saved.accountType) - assertEquals("access-123", saved.accessToken) - assertEquals("refresh-456", saved.refreshToken) - assertEquals("scope-a scope-b", saved.scope) - } - - @Test - fun tunnelFailure_marksJobFailedWithoutSavingTokens() = runBlocking { - val tunnel = FakeOAuthTunnelClient() - val tokenStorage = FakeTokenStorage() - val tree = newTree(tunnel, tokenStorage) - - val started = tree.startJob(path, emptyMap(), "https://self") { it }.getOrThrow() - tunnel.result.complete(OAuthResult.Failure("access_denied")) - - val status = tree.getJobStatus(path, started.jobId) - assertIs(status) - assertTrue(status.message.contains("access_denied")) - assertTrue(tokenStorage.saved.isEmpty()) - } - - @Test - fun missingTokenField_marksJobFailedWithoutSavingTokens() = runBlocking { - val tunnel = FakeOAuthTunnelClient() - val tokenStorage = FakeTokenStorage() - val tree = newTree(tunnel, tokenStorage) - - val started = tree.startJob(path, emptyMap(), "https://self") { it }.getOrThrow() - tunnel.result.complete( - OAuthResult.Success( - buildJsonObject { - put("access_token", JsonPrimitive("access-123")) - put("expires_in", JsonPrimitive(3600)) - put("scope", JsonPrimitive("scope-a")) - // no refresh_token - }, - ), - ) - - assertIs(tree.getJobStatus(path, started.jobId)) - assertTrue(tokenStorage.saved.isEmpty()) - } - - @Test - fun secondStartJobAtSamePath_cancelsFirstJob() = runBlocking { - // Never completed, so the first job's authenticate() call hangs until cancelled. - val tunnel = FakeOAuthTunnelClient() - val tree = newTree(tunnel, FakeTokenStorage()) - - val first = tree.startJob(path, emptyMap(), "https://self", { it }).getOrThrow() - val second = tree.startJob(path, emptyMap(), "https://self", { it }).getOrThrow() - - assertIs(tree.getJobStatus(path, first.jobId)) - assertEquals(JobStatus.Running, tree.getJobStatus(path, second.jobId)) - } -} diff --git a/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/StoredOAuthTokenProviderTest.kt b/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/StoredOAuthTokenProviderTest.kt deleted file mode 100644 index 125378a..0000000 --- a/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/StoredOAuthTokenProviderTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.thelightphone.backup - -import kotlinx.coroutines.runBlocking -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Clock -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Instant - -// mostly LLM'ed -private class FakeGoogleTokenStorage(initial: StoredOAuthTokens? = null) : TokenStorage { - var stored: StoredOAuthTokens? = initial - private set - var saveCount = 0 - var removeCount = 0 - - override suspend fun saveOAuthDetails( - accountType: String, - accessToken: String, - refreshToken: String, - expiresAt: Instant, - scope: String, - ) { - saveCount++ - stored = StoredOAuthTokens(accessToken, refreshToken, expiresAt, scope) - } - - override suspend fun getOAuthDetails(accountType: String): StoredOAuthTokens? = stored - - override suspend fun removeOAuthDetails(accountType: String): Boolean { - removeCount++ - val existed = stored != null - stored = null - return existed - } -} - -private fun expiredTokens() = StoredOAuthTokens( - accessToken = "stale-access", - refreshToken = "refresh-1", - expiresAt = Clock.System.now() - 1.minutes, - scope = "scope-a", -) - -class StoredOAuthTokenProviderTest { - @Test - fun expiredToken_refreshSucceeds_savesAndReturnsNewAccessToken() = runBlocking { - val storage = FakeGoogleTokenStorage(expiredTokens()) - val provider = StoredOAuthTokenProvider("google", storage) { refreshToken -> - assertEquals("refresh-1", refreshToken) - TokenRefreshOutcome.Refreshed(RefreshedAccessToken("new-access", Clock.System.now() + 60.minutes)) - } - - val token = provider.getAccessToken().getOrThrow() - - assertEquals("new-access", token) - assertEquals(1, storage.saveCount) - assertEquals(0, storage.removeCount) - assertEquals("new-access", storage.stored?.accessToken) - } - - @Test - fun expiredToken_refreshFailsTransiently_keepsStoredTokens() = runBlocking { - val storage = FakeGoogleTokenStorage(expiredTokens()) - val provider = StoredOAuthTokenProvider("google", storage) { - TokenRefreshOutcome.Failed(IllegalStateException("network blip")) - } - - val result = provider.getAccessToken() - - assertTrue(result.isFailure) - assertEquals(0, storage.removeCount) - assertEquals("stale-access", storage.stored?.accessToken) - } - - @Test - fun expiredToken_refreshInvalidGrant_clearsStoredTokens() = runBlocking { - val storage = FakeGoogleTokenStorage(expiredTokens()) - val provider = StoredOAuthTokenProvider("google", storage) { - TokenRefreshOutcome.InvalidGrant("refresh token revoked") - } - - val result = provider.getAccessToken() - - assertTrue(result.isFailure) - assertEquals(1, storage.removeCount) - assertNull(storage.stored) - } - - @Test - fun noAccountLinked_failsWithoutCallingRefresh() = runBlocking { - val storage = FakeGoogleTokenStorage(initial = null) - var refreshCalled = false - val provider = StoredOAuthTokenProvider("google", storage) { - refreshCalled = true - TokenRefreshOutcome.Failed(IllegalStateException("should not be called")) - } - - val result = provider.getAccessToken() - - assertTrue(result.isFailure) - assertFalse(refreshCalled) - } -}