Skip to content

Commit

Permalink
Update Login command to use new Auth method (#2230)
Browse files Browse the repository at this point in the history
  • Loading branch information
igorsmotto authored Jan 10, 2025
1 parent 0945c4a commit 39444b1
Show file tree
Hide file tree
Showing 9 changed files with 157 additions and 129 deletions.
4 changes: 4 additions & 0 deletions maestro-cli/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ dependencies {
implementation(libs.square.okhttp)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.cors)
implementation(libs.ktor.server.status.pages)
implementation(libs.jarchivelib)
implementation(libs.commons.codec)
implementation(libs.kotlinx.coroutines.core)
Expand Down
64 changes: 21 additions & 43 deletions maestro-cli/src/main/java/maestro/cli/api/ApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.map
import maestro.cli.CliError
import maestro.cli.analytics.Analytics
import maestro.cli.analytics.AnalyticsReport
Expand Down Expand Up @@ -35,6 +34,7 @@ import java.nio.file.Path
import kotlin.io.path.absolutePathString
import kotlin.io.path.exists
import kotlin.time.Duration.Companion.minutes
import okhttp3.MediaType

class ApiClient(
private val baseUrl: String,
Expand Down Expand Up @@ -110,55 +110,33 @@ class ApiClient(
}
}

fun magicLinkLogin(email: String, redirectUrl: String): Result<String, Response> {
return post<Map<String, Any>>(
"/magiclink/login", mapOf(
"deviceId" to "",
"email" to email,
"redirectUrl" to redirectUrl,
"agent" to "cli",
)
).map { it["requestToken"].toString() }
fun getAuthUrl(port: String): String {
return "$baseUrl/maestroLogin/authUrl?port=$port"
}

fun magicLinkSignUp(email: String, teamName: String, redirectUrl: String): Result<String, Response> {
return post(
"/magiclink/signup", mapOf(
"deviceId" to "",
"userEmail" to email,
"teamName" to teamName,
"redirectUrl" to redirectUrl,
"agent" to "cli",
)
)
}
fun exchangeToken(code: String): String {
val requestBody = code.toRequestBody("text/plain".toMediaType())

fun magicLinkGetToken(requestToken: String): Result<String, Response> {
return post<Map<String, Any>>(
"/magiclink/gettoken", mapOf(
"requestToken" to requestToken,
)
).map { it["authToken"].toString() }
val request = Request.Builder()
.url("$baseUrl/maestroLogin/exchange")
.post(requestBody)
.build()

client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
return response.body?.string() ?: throw IOException("Empty response body")
}
}

fun isAuthTokenValid(authToken: String): Boolean {
val request = try {
Request.Builder()
.get()
.header("Authorization", "Bearer $authToken")
.url("$baseUrl/auth")
.build()
} catch (e: IllegalArgumentException) {
if (e.message?.contains("Unexpected char") == true) {
return false
} else {
throw e
}
}
val response = client.newCall(request).execute()
val request = Request.Builder()
.url("$baseUrl/maestroLogin/valid")
.header("Authorization", "Bearer $authToken")
.get()
.build()

response.use {
return response.isSuccessful
client.newCall(request).execute().use { response ->
return !(!response.isSuccessful && (response.code == 401 || response.code == 403))
}
}

Expand Down
164 changes: 103 additions & 61 deletions maestro-cli/src/main/java/maestro/cli/auth/Auth.kt
Original file line number Diff line number Diff line change
@@ -1,90 +1,134 @@
package maestro.cli.auth

import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.getOrElse
import maestro.cli.CliError
import maestro.cli.api.ApiClient
import maestro.cli.util.PrintUtils
import maestro.cli.util.PrintUtils.message
import io.ktor.http.ContentType
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import java.awt.Desktop
import java.net.URI
import java.nio.file.Paths
import kotlin.io.path.createDirectories
import kotlin.io.path.deleteIfExists
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.readText
import kotlin.io.path.writeText
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import maestro.cli.api.ApiClient
import maestro.cli.util.PrintUtils.err
import maestro.cli.util.PrintUtils.info
import maestro.cli.util.PrintUtils.message
import maestro.cli.util.PrintUtils.success
import maestro.cli.util.getFreePort

private const val SUCCESS_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>Authentication Successful</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-white from-blue-500 to-purple-600 min-h-screen flex items-center justify-center">
<div class="bg-white p-8 rounded-lg border border-gray-300 max-w-md w-full mx-4">
<div class="text-center">
<svg class="w-16 h-16 text-green-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<h1 class="text-2xl font-bold text-gray-800 mb-2">Authentication Successful!</h1>
<p class="text-gray-600">You can close this window and return to the CLI.</p>
</div>
</div>
</body>
</html>
"""

private const val FAILURE_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>Authentication Failed</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-white min-h-screen flex items-center justify-center">
<div class="bg-white p-8 rounded-lg border border-gray-300 max-w-md w-full mx-4">
<div class="text-center">
<svg class="w-16 h-16 text-red-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
<h1 class="text-2xl font-bold text-gray-800 mb-2">Authentication Failed</h1>
<p class="text-gray-600">Something went wrong. Please try again.</p>
</div>
</div>
</body>
</html>
"""

class Auth(
private val client: ApiClient,
private val apiClient: ApiClient
) {

fun getCachedAuthToken(): String? {
if (!cachedAuthTokenFile.exists()) return null
if (cachedAuthTokenFile.isDirectory()) return null
val cachedAuthToken = cachedAuthTokenFile.readText()
return if (client.isAuthTokenValid(cachedAuthToken)) {
cachedAuthToken
} else {
message("Existing auth token is invalid or expired")
cachedAuthTokenFile.deleteIfExists()
null
}
return cachedAuthToken
// return if (apiClient.isAuthTokenValid(cachedAuthToken)) {
// cachedAuthToken
// } else {
// message("Existing Authentication token is invalid or expired")
// cachedAuthTokenFile.deleteIfExists()
// null
// }
}

fun triggerSignInFlow(): String {
message("No auth token found")
val email = PrintUtils.prompt("Sign In or Sign Up using your email address:")
var isLogin = true
val requestToken = client.magicLinkLogin(email, AUTH_SUCCESS_REDIRECT_URL).getOrElse { loginError ->
val errorBody = try {
loginError.body?.string()
} catch (e: Exception) {
e.message
}
val deferredToken = CompletableDeferred<String>()

if (loginError.code == 403 && errorBody?.contains("not an authorized email address") == true) {
isLogin = false
message("No existing team found for this email domain")
val team = PrintUtils.prompt("Enter a team name to create your team:")
client.magicLinkSignUp(email, team, AUTH_SUCCESS_REDIRECT_URL).getOrElse { signUpError ->
throw CliError(signUpError.body?.string() ?: signUpError.message)
val port = getFreePort()
val server = embeddedServer(Netty, configure = { shutdownTimeout = 0; shutdownGracePeriod = 0 }, port = port) {
routing {
get("/callback") {
handleCallback(call, deferredToken)
}
} else {
throw CliError(
errorBody ?: loginError.message
)
}
}
}.start(wait = false)

if (isLogin) {
message("We sent a login link to $email. Click on the link there to finish logging in...")
val authUrl = apiClient.getAuthUrl(port.toString())
info("Your browser has been opened to visit:\n\n\t$authUrl")

if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().browse(URI(authUrl))
} else {
message("We sent an email to $email. Click on the link there to finish creating your account...")
err("Failed to open browser on this platform. Please open the above URL in your preferred browser.")
throw UnsupportedOperationException("Failed to open browser automatically on this platform. Please open the above URL in your preferred browser.")
}

while (true) {
val errResponse = when (val result = client.magicLinkGetToken(requestToken)) {
is Ok -> {
if (isLogin) {
message("✅ Login successful")
} else {
message("✅ Team created successfully")
}
setCachedAuthToken(result.value)
return result.value
}
is Err -> result.error
}
val errorMessage = errResponse.body?.string() ?: errResponse.message
if (
"Login process not complete" !in errorMessage
&& "Email is not authorized" !in errorMessage
) {
throw CliError("Failed to get auth token (${errResponse.code}): $errorMessage")
}
Thread.sleep(1000)
val token = runBlocking {
deferredToken.await()
}
server.stop(0, 0)
setCachedAuthToken(token)
success("Authentication completed.")
return token
}

private suspend fun handleCallback(call: ApplicationCall, deferredToken: CompletableDeferred<String>) {
val code = call.request.queryParameters["code"]
if (code.isNullOrEmpty()) {
err("No authorization code received. Please try again.")
call.respondText(FAILURE_HTML, ContentType.Text.Html)
return
}

val newApiKey = apiClient.exchangeToken(code)

call.respondText(SUCCESS_HTML, ContentType.Text.Html)
deferredToken.complete(newApiKey)
}

private fun setCachedAuthToken(token: String?) {
Expand All @@ -98,8 +142,6 @@ class Auth(

companion object {

private const val AUTH_SUCCESS_REDIRECT_URL = "https://console.mobile.dev/auth/success"

private val cachedAuthTokenFile by lazy {
Paths.get(
System.getProperty("user.home"),
Expand Down
13 changes: 8 additions & 5 deletions maestro-cli/src/main/java/maestro/cli/command/LoginCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import maestro.cli.auth.Auth
import maestro.cli.util.PrintUtils.message
import picocli.CommandLine
import java.util.concurrent.Callable
import kotlin.io.path.absolutePathString
import maestro.cli.report.TestDebugReporter
import maestro.debuglog.LogConfig

@CommandLine.Command(
name = "login",
Expand All @@ -22,22 +25,22 @@ class LoginCommand : Callable<Int> {
@CommandLine.Mixin
var showHelpMixin: ShowHelpMixin? = null

@CommandLine.Option(names = ["--apiUrl"], description = ["API base URL"])
private var apiUrl: String = "https://api.mobile.dev"

private val auth by lazy {
Auth(ApiClient(apiUrl))
Auth(ApiClient("https://api.copilot.mobile.dev/v2"))
}

override fun call(): Int {
LogConfig.configure(logFileName = null, printToConsole = false) // Disable all logs from Login

val existingToken = auth.getCachedAuthToken()

if (existingToken != null) {
message("Already logged in. Run \"maestro logout\" to logout.")
return 0
}

auth.triggerSignInFlow()
val token = auth.triggerSignInFlow()
println(token)

return 0
}
Expand Down
13 changes: 3 additions & 10 deletions maestro-cli/src/main/java/maestro/cli/command/LogoutCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import java.nio.file.Path
import java.nio.file.Paths
import java.util.concurrent.Callable
import kotlin.io.path.deleteIfExists
import maestro.cli.util.PrintUtils
import maestro.cli.util.PrintUtils.message

@CommandLine.Command(
name = "logout",
Expand All @@ -28,18 +30,9 @@ class LogoutCommand : Callable<Int> {
override fun call(): Int {
cachedAuthTokenFile.deleteIfExists()

message("Logged out")
message("Logged out.")

return 0
}

companion object {

// TODO reuse
private fun message(message: String) {
println(Ansi.ansi().render("@|cyan \n$message|@"))
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import java.awt.Desktop
import java.net.ServerSocket
import java.net.URI
import java.util.concurrent.Callable
import maestro.cli.util.getFreePort

@CommandLine.Command(
name = "studio",
Expand Down Expand Up @@ -89,12 +90,4 @@ class StudioCommand : Callable<Int> {
}
}

private fun getFreePort(): Int {
(9999..11000).forEach { port ->
try {
ServerSocket(port).use { return it.localPort }
} catch (ignore: Exception) {}
}
ServerSocket(0).use { return it.localPort }
}
}
Loading

0 comments on commit 39444b1

Please sign in to comment.