diff --git a/test_runner/build.gradle.kts b/test_runner/build.gradle.kts index 1c731d08d3..c94f5991ba 100644 --- a/test_runner/build.gradle.kts +++ b/test_runner/build.gradle.kts @@ -127,13 +127,13 @@ dependencies { testImplementation("com.github.stefanbirkner:system-rules:1.19.0") // https://github.com/ktorio/ktor/releases - val ktorVersion = "1.1.1" - testImplementation("io.ktor:ktor-server-core:$ktorVersion") - testImplementation("io.ktor:ktor-server-netty:$ktorVersion") - testImplementation("io.ktor:ktor-gson:$ktorVersion") + val ktorVersion = "1.1.3" + compile("io.ktor:ktor-server-core:$ktorVersion") + compile("io.ktor:ktor-server-netty:$ktorVersion") + compile("io.ktor:ktor-gson:$ktorVersion") // https://github.com/qos-ch/logback/releases - testImplementation("ch.qos.logback:logback-classic:1.2.3") + compile("ch.qos.logback:logback-classic:1.2.3") // mockito-inline is used to mock final classes // https://github.com/mockito/mockito/releases testImplementation("org.mockito:mockito-inline:2.23.4") diff --git a/test_runner/src/main/kotlin/ftl/cli/auth/LoginCommand.kt b/test_runner/src/main/kotlin/ftl/cli/auth/LoginCommand.kt index 11e0bd6f45..aa6991daa3 100644 --- a/test_runner/src/main/kotlin/ftl/cli/auth/LoginCommand.kt +++ b/test_runner/src/main/kotlin/ftl/cli/auth/LoginCommand.kt @@ -1,5 +1,6 @@ package ftl.cli.auth +import ftl.gc.UserAuth import picocli.CommandLine @CommandLine.Command( @@ -15,6 +16,7 @@ import picocli.CommandLine ) class LoginCommand : Runnable { override fun run() { + UserAuth().request() } @CommandLine.Option( diff --git a/test_runner/src/main/kotlin/ftl/config/FtlConstants.kt b/test_runner/src/main/kotlin/ftl/config/FtlConstants.kt index 12dbb72c1b..9ae9d3c700 100644 --- a/test_runner/src/main/kotlin/ftl/config/FtlConstants.kt +++ b/test_runner/src/main/kotlin/ftl/config/FtlConstants.kt @@ -5,10 +5,16 @@ import com.google.api.client.googleapis.util.Utils import com.google.api.client.http.HttpRequestInitializer import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.json.JsonFactory +import com.google.auth.oauth2.GoogleCredentials import com.google.auth.oauth2.ServiceAccountCredentials import ftl.args.AndroidArgs import ftl.args.IArgs import ftl.args.IosArgs +import ftl.config.FtlConstants.credential +import ftl.config.FtlConstants.defaultAndroidConfig +import ftl.config.FtlConstants.defaultIosConfig +import ftl.config.FtlConstants.useMock +import ftl.gc.UserAuth import ftl.http.HttpTimeoutIncrease import java.nio.file.Path import java.nio.file.Paths @@ -44,7 +50,15 @@ object FtlConstants { Paths.get(System.getProperty("user.home"), ".config/gcloud/application_default_credentials.json") } - val credential: HttpRequestInitializer by lazy { + val credential: GoogleCredentials by lazy { + if (UserAuth.exists()) { + UserAuth.load() + } else { + ServiceAccountCredentials.getApplicationDefault() + } + } + + val httpCredential: HttpRequestInitializer by lazy { if (useMock) { HttpRequestInitializer {} } else { @@ -53,10 +67,7 @@ object FtlConstants { // https://developers.google.com/identity/protocols/googlescopes // https://developers.google.com/identity/protocols/application-default-credentials // https://cloud.google.com/sdk/gcloud/reference/alpha/compute/instances/set-scopes - HttpTimeoutIncrease( - ServiceAccountCredentials.getApplicationDefault() - .createScoped(listOf("https://www.googleapis.com/auth/cloud-platform")) - ) + HttpTimeoutIncrease(credential.createScoped(listOf("https://www.googleapis.com/auth/cloud-platform"))) } } diff --git a/test_runner/src/main/kotlin/ftl/gc/GcAuth.kt b/test_runner/src/main/kotlin/ftl/gc/GcAuth.kt deleted file mode 100644 index 6b3d00f28d..0000000000 --- a/test_runner/src/main/kotlin/ftl/gc/GcAuth.kt +++ /dev/null @@ -1,57 +0,0 @@ -package ftl.gc - -import com.google.api.client.auth.oauth2.StoredCredential -import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow -import com.google.api.client.googleapis.auth.oauth2.GoogleCredential -import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport -import com.google.api.client.json.jackson2.JacksonFactory -import com.google.api.client.util.store.FileDataStoreFactory -import java.io.File - -// https://github.com/googleapis/google-oauth-java-client -// GoogleAuthorizationCodeFlow usage based on https://developers.google.com/sheets/api/quickstart/java -object GcAuth { - private val HOME = System.getProperty("user.home") - private val CRED_FOLDER = File(HOME, ".flank/") - val CRED = File(CRED_FOLDER, "StoredCredential") - - private val JSON_FACTORY = JacksonFactory.getDefaultInstance() - private var HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport() - private var DATA_STORE_FACTORY = FileDataStoreFactory(CRED_FOLDER) - - // https://github.com/bootstraponline/gcloud_cli/blob/40521a6e297830b9f652a9ab4d8002e309b4353a/google-cloud-sdk/platform/gsutil/gslib/utils/system_util.py#L177 - private const val CLIENT_ID = "32555940559.apps.googleusercontent.com" - private const val CLIENT_SECRET = "ZmssLNjJy2998hD4CTg2ejr2" - - private val flow by lazy { - // https://github.com/bootstraponline/gcloud_cli/blob/e4b5e01610abad2e31d8a6edb20b17b2f84c5395/google-cloud-sdk/lib/googlecloudsdk/core/config.py#L167 - val scopes = listOf("https://www.googleapis.com/auth/cloud-platform") - - GoogleAuthorizationCodeFlow.Builder(HTTP_TRANSPORT, JSON_FACTORY, CLIENT_ID, CLIENT_SECRET, scopes) - .setDataStoreFactory(DATA_STORE_FACTORY) - .setAccessType("offline") - .build() - } - private const val DATA_STORE_KEY = "default" - - fun hasUserAuth(): Boolean { - return defaultCredential() != null - } - - private fun createGoogleCredential(accessToken: String): GoogleCredential { - return GoogleCredential.Builder() - .setTransport(HTTP_TRANSPORT) - .setJsonFactory(JSON_FACTORY) - .setClientSecrets(CLIENT_ID, CLIENT_SECRET) - .build() - .setAccessToken(accessToken) - } - - private fun StoredCredential.toGoogleCredential(): GoogleCredential { - return createGoogleCredential(this.accessToken) - } - - private fun defaultCredential(): GoogleCredential? { - return flow.credentialDataStore[DATA_STORE_KEY]?.toGoogleCredential() - } -} diff --git a/test_runner/src/main/kotlin/ftl/gc/GcStorage.kt b/test_runner/src/main/kotlin/ftl/gc/GcStorage.kt index 221f16fdab..697543a287 100644 --- a/test_runner/src/main/kotlin/ftl/gc/GcStorage.kt +++ b/test_runner/src/main/kotlin/ftl/gc/GcStorage.kt @@ -27,6 +27,7 @@ object GcStorage { val storageOptions: StorageOptions by lazy { val builder = StorageOptions.newBuilder() if (FtlConstants.useMock) builder.setHost(FtlConstants.localhost) + builder.setCredentials(FtlConstants.credential) // The oauth lib for user auth needs to be replaced // https://github.com/TestArmada/flank/issues/464#issuecomment-455227703 diff --git a/test_runner/src/main/kotlin/ftl/gc/GcTesting.kt b/test_runner/src/main/kotlin/ftl/gc/GcTesting.kt index d05f9422c9..05a93f7bc0 100644 --- a/test_runner/src/main/kotlin/ftl/gc/GcTesting.kt +++ b/test_runner/src/main/kotlin/ftl/gc/GcTesting.kt @@ -4,13 +4,13 @@ import com.google.api.services.testing.Testing import ftl.config.FtlConstants import ftl.config.FtlConstants.JSON_FACTORY import ftl.config.FtlConstants.applicationName -import ftl.config.FtlConstants.credential +import ftl.config.FtlConstants.httpCredential import ftl.config.FtlConstants.httpTransport object GcTesting { val get: Testing by lazy { - val builder = Testing.Builder(httpTransport, JSON_FACTORY, credential) + val builder = Testing.Builder(httpTransport, JSON_FACTORY, httpCredential) .setApplicationName(applicationName) if (FtlConstants.useMock) builder.rootUrl = FtlConstants.localhost diff --git a/test_runner/src/main/kotlin/ftl/gc/GcToolResults.kt b/test_runner/src/main/kotlin/ftl/gc/GcToolResults.kt index 7675c271cb..1da1380660 100644 --- a/test_runner/src/main/kotlin/ftl/gc/GcToolResults.kt +++ b/test_runner/src/main/kotlin/ftl/gc/GcToolResults.kt @@ -9,14 +9,14 @@ import ftl.args.IArgs import ftl.config.FtlConstants import ftl.config.FtlConstants.JSON_FACTORY import ftl.config.FtlConstants.applicationName -import ftl.config.FtlConstants.credential +import ftl.config.FtlConstants.httpCredential import ftl.config.FtlConstants.httpTransport import ftl.http.executeWithRetry object GcToolResults { val service: ToolResults by lazy { - val builder = ToolResults.Builder(httpTransport, JSON_FACTORY, credential) + val builder = ToolResults.Builder(httpTransport, JSON_FACTORY, httpCredential) if (FtlConstants.useMock) builder.rootUrl = FtlConstants.localhost diff --git a/test_runner/src/main/kotlin/ftl/gc/UserAuth.kt b/test_runner/src/main/kotlin/ftl/gc/UserAuth.kt new file mode 100644 index 0000000000..599338dd1e --- /dev/null +++ b/test_runner/src/main/kotlin/ftl/gc/UserAuth.kt @@ -0,0 +1,117 @@ +package ftl.gc + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import com.google.auth.oauth2.ClientId +import com.google.auth.oauth2.MemoryTokensStorage +import com.google.auth.oauth2.UserAuthorizer +import com.google.auth.oauth2.UserCredentials +import ftl.config.FtlConstants +import io.ktor.application.call +import io.ktor.response.respondText +import io.ktor.routing.get +import io.ktor.routing.routing +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.net.URI +import java.nio.file.Path +import java.nio.file.Paths +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory + +class UserAuth { + + companion object { + private val home = System.getProperty("user.home")!! + private val dotFlank = Paths.get(home, ".flank/")!! + val userToken: Path = Paths.get(dotFlank.toString(), "UserToken") + + fun exists() = userToken.toFile().exists() + fun load(): UserCredentials { + return ObjectInputStream(FileInputStream(userToken.toFile())).use { + it.readObject() as UserCredentials + } + } + } + + // Silence Jetty logging. + private val logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) as Logger + + init { + logger.level = Level.OFF + } + + private var waitingForUserAuth = true + + private val server = embeddedServer(Netty, 8085) { + routing { + // 'code' and 'scope' are passed back into the callback as parameters + get("/oauth2callback") { + authCode = call.parameters["code"] ?: "" + call.respondText { "User authorized. Close the browser window." } + + waitingForUserAuth = false + } + } + } + + var authCode = "" + + // https://github.com/bootstraponline/gcloud_cli/blob/40521a6e297830b9f652a9ab4d8002e309b4353a/google-cloud-sdk/platform/gsutil/gslib/utils/system_util.py#L177 + private val clientId = ClientId.newBuilder() + .setClientId("32555940559.apps.googleusercontent.com") + .setClientSecret("ZmssLNjJy2998hD4CTg2ejr2") + .build()!! + + // https://github.com/bootstraponline/gcloud_cli/blob/e4b5e01610abad2e31d8a6edb20b17b2f84c5395/google-cloud-sdk/lib/googlecloudsdk/core/config.py#L167 + private val scopes = listOf("https://www.googleapis.com/auth/cloud-platform") + + private val tokenStore = MemoryTokensStorage() + private val authorizer = UserAuthorizer.newBuilder() + .setClientId(clientId) + .setScopes(scopes) + .setTokenStore(tokenStore) + .build()!! + private val userId = "flank" + val uri: URI = URI.create("http://localhost:8085") + + private fun printAuthorizationUrl() { + val url = authorizer.getAuthorizationUrl(userId, null, uri) + println("Visit the following URL in your browser:") + println(url) + } + + fun request() { + if (FtlConstants.useMock) return + printAuthorizationUrl() + + server.start(wait = false) + + while (waitingForUserAuth) { + runBlocking { delay(1000) } + } + + // trade OAuth2 authorization code for tokens. + // + // https://developers.google.com/gdata/docs/auth/oauth#NoLibrary + authorizer.getAndStoreCredentialsFromCode(userId, authCode, uri) + + server.stop(0, 0, TimeUnit.SECONDS) + + val userCredential = authorizer.getCredentials(userId) + + dotFlank.toFile().mkdirs() + ObjectOutputStream(FileOutputStream(userToken.toFile())).use { + it.writeObject(userCredential) + } + + println() + println("User token saved to $userToken") + } +} diff --git a/test_runner/src/test/kotlin/Debug.kt b/test_runner/src/test/kotlin/Debug.kt index 538945c2d8..145a2a8deb 100644 --- a/test_runner/src/test/kotlin/Debug.kt +++ b/test_runner/src/test/kotlin/Debug.kt @@ -1,14 +1,9 @@ import ftl.Main import picocli.CommandLine -object Debug { +fun main() { + // GoogleApiLogger.logAllToStdout() - @JvmStatic - @Suppress("UnusedPrivateMember") // Suppress detekt rule - fun main(args: Array) { - // GoogleApiLogger.logAllToStdout() - - val arguments = arrayOf("firebase", "test", "android", "run") // for debugging. run test from IntelliJ IDEA - CommandLine.run(Main(), System.out, *arguments) - } + val arguments = arrayOf("firebase", "test", "android", "run") // for debugging. run test from IntelliJ IDEA + CommandLine.run(Main(), System.out, *arguments) }