timdorr / tesla-api

🚘 A Ruby gem and unofficial documentation of Tesla's JSON API for the Model S, 3, X, and Y.
https://tesla-api.timdorr.com/
MIT License
2k stars 534 forks source link

Examples of successful login on Android #781

Closed Odaym closed 4 months ago

Odaym commented 4 months ago

Hi, so I'd like to know if there are any examples of a successful login attempt on Android, any example apps, something I can use to help me do it for my own app.

Here's what I've tried so far but I'm getting stuck at the challenge step.

class TeslaAuthViewModel : ViewModel() {

    private lateinit var codeVerifier: String
    private lateinit var codeChallenge: String

    private val _authState = MutableLiveData<String>()
    val authState: LiveData<String> = _authState

    init {
        generateCodeVerifierAndChallenge()
    }

    private val retrofit = Retrofit.Builder()
        .baseUrl("https://auth.tesla.com/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    private val teslaAuthApi = retrofit.create(TeslaAuthApi::class.java)

    private fun generateCodeVerifierAndChallenge() {
        codeVerifier = generateRandomString(86)
        codeChallenge = generateCodeChallenge(codeVerifier)
        Log.d("TeslaAuthViewModel", "Code Verifier: $codeVerifier")
        Log.d("TeslaAuthViewModel", "Code Challenge: $codeChallenge")
    }

    private fun getAuthUrl(loginHint: String? = null): String {
        val authUrl = Uri.parse("https://auth.tesla.com/oauth2/v3/authorize").buildUpon()
            .appendQueryParameter("client_id", "ownerapi")
            .appendQueryParameter("code_challenge", codeChallenge)
            .appendQueryParameter("code_challenge_method", "S256")
            .appendQueryParameter("redirect_uri", "https://auth.tesla.com/void/callback")
            .appendQueryParameter("response_type", "code")
            .appendQueryParameter("scope", "openid email offline_access")
            .appendQueryParameter("state", generateRandomString(16))
            .apply {
                loginHint?.let {
                    appendQueryParameter("login_hint", it)
                }
            }
            .build()
            .toString()

        Log.d("TeslaAuthViewModel", "Auth URL: $authUrl")

        return authUrl
    }

    fun handleRedirectUri(uri: Uri) {
        Log.d("TeslaAuthViewModel", "Handling Redirect URI: $uri")
        val code = uri.getQueryParameter("code")
        if (code != null) {
            Log.d("TeslaAuthViewModel", "Authorization Code: $code")
            exchangeAuthorizationCode(code)
        } else {
            Log.e("TeslaAuthViewModel", "Authorization failed: No code found in redirect URI")
            _authState.postValue("Authorization failed")
        }
    }

    private fun exchangeAuthorizationCode(code: String) {
        viewModelScope.launch {
            try {
                Log.d("TeslaAuthViewModel", "Exchanging Authorization Code for Token")
                val response = teslaAuthApi.exchangeCode(
                    mapOf(
                        "grant_type" to "authorization_code",
                        "client_id" to "ownerapi",
                        "code" to code,
                        "code_verifier" to codeVerifier,
                        "redirect_uri" to "https://auth.tesla.com/void/callback"
                    )
                )

                if (response.isSuccessful) {
                    val tokenResponse = response.body()
                    Log.d("TeslaAuthViewModel", "Token Response: $tokenResponse")
                    _authState.postValue(tokenResponse?.accessToken ?: "Authorization failed")
                } else {
                    Log.e("TeslaAuthViewModel", "Authorization failed: ${response.errorBody()?.string()}")
                    _authState.postValue("Authorization failed")
                }
            } catch (e: Exception) {
                Log.e("TeslaAuthViewModel", "Authorization failed with exception", e)
                _authState.postValue("Authorization failed")
            }
        }
    }

    private fun generateRandomString(length: Int): String {
        val allowedChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
        return (1..length)
            .map { allowedChars.random() }
            .joinToString("")
    }

    @SuppressLint("NewApi")
    private fun generateCodeChallenge(verifier: String): String {
        val bytes = verifier.toByteArray(StandardCharsets.US_ASCII)
        val md = MessageDigest.getInstance("SHA-256")
        val digest = md.digest(bytes)
        return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
    }

    private suspend fun extractHiddenFields(html: String): Map<String, String> {
        val document = Jsoup.parse(html)
        val hiddenFields = mutableMapOf<String, String>()
        document.select("input[type=hidden]").forEach { element ->
            hiddenFields[element.attr("name")] = element.attr("value")
        }
        Log.d("TeslaAuthViewModel", "Hidden Fields: $hiddenFields")
        return hiddenFields
    }

    private suspend fun checkForChallenge(html: String): Boolean {
        return html.contains("sec_chlge_form")
    }

    private suspend fun submitChallengeForm(html: String, cookie: String) {
        val document = Jsoup.parse(html)
        val challengeForm = document.select("form#chlge").first()
        val action = challengeForm?.attr("action") ?: ""
        val hiddenFields = mutableMapOf<String, String>()
        challengeForm?.select("input[type=hidden]")?.forEach { element ->
            hiddenFields[element.attr("name")] = element.attr("value")
        }
        val challengeUrl = "https://auth.tesla.com$action"
        Log.d("TeslaAuthViewModel", "Submitting Challenge Form: $hiddenFields")
        val response = teslaAuthApi.submitLoginForm(challengeUrl, hiddenFields, cookie)
        if (response.isSuccessful) {
            val locationHeader = response.headers()["location"]
            Log.d("TeslaAuthViewModel", "Location Header: $locationHeader")
            if (locationHeader != null) {
                val uri = Uri.parse(locationHeader)
                handleRedirectUri(uri)
            } else {
                Log.e("TeslaAuthViewModel", "Authorization failed: Location header is null")
                _authState.postValue("Authorization failed")
            }
        } else {
            Log.e("TeslaAuthViewModel", "Challenge Form Submission Response Body: ${response.body()?.string()}")
            _authState.postValue("Authorization failed")
        }
    }

    suspend fun submitLoginForm(
        url: String,
        hiddenFields: Map<String, String>,
        email: String,
        password: String,
        cookie: String
    ): Response<ResponseBody> {
        val formBody = hiddenFields.toMutableMap().apply {
            put("identity", email)
            put("credential", password)
        }

        Log.d("TeslaAuthViewModel", "Submitting Login Form: $formBody")

        return teslaAuthApi.submitLoginForm(url, formBody, cookie)
    }

    fun performLogin(email: String, password: String) {
        viewModelScope.launch {
            try {
                val authUrl = getAuthUrl(email)
                val initialResponse = teslaAuthApi.getLoginPage(authUrl)
                if (initialResponse.isSuccessful) {
                    val html = initialResponse.body()?.string() ?: ""
                    Log.d("TeslaAuthViewModel", "Initial Login Page Response Body: $html")
                    val hiddenFields = extractHiddenFields(html)
                    val cookie = initialResponse.headers()["set-cookie"] ?: ""
                    Log.d("TeslaAuthViewModel", "Initial Cookie: $cookie")
                    val submitResponse = submitLoginForm(authUrl, hiddenFields, email, password, cookie)
                    if (submitResponse.isSuccessful) {
                        val responseBody = submitResponse.body()?.string() ?: ""
                        Log.d("TeslaAuthViewModel", "Form Submission Response Body: $responseBody")
                        if (checkForChallenge(responseBody)) {
                            submitChallengeForm(responseBody, cookie)
                        } else {
                            val locationHeader = submitResponse.headers()["location"]
                            Log.d("TeslaAuthViewModel", "Location Header: $locationHeader")
                            if (locationHeader != null) {
                                val uri = Uri.parse(locationHeader)
                                handleRedirectUri(uri)
                            } else {
                                Log.e("TeslaAuthViewModel", "Authorization failed: Location header is null")
                                _authState.postValue("Authorization failed")
                            }
                        }
                    } else {
                        Log.e("TeslaAuthViewModel", "Form Submission Response Body: ${submitResponse.body()?.string()}")
                        _authState.postValue("Authorization failed")
                    }
                } else {
                    Log.e("TeslaAuthViewModel", "Initial Login Page Request failed: ${initialResponse.errorBody()?.string()}")
                    _authState.postValue("Authorization failed")
                }
            } catch (e: Exception) {
                Log.e("TeslaAuthViewModel", "Authorization failed with exception", e)
                _authState.postValue("Authorization failed")
            }
        }
    }
}

Here's the logs that are generated when this is run and sign in with valid credentials are provided:

 Hidden Fields: {_csrf=kNAnroAB-lFfZjT_dZmw5eWbwIQ9O7zE4tDo, _phase=authenticate, cancel=, transaction_id=Tzhufv3Z, fingerPrint=, _process=1, change_identity=, identity=csopacua@outlook.com, correlation_id=fe9ad78b-22c1-43c3-bc15-628b43560851, auth_method=email-login, =}

12:40:57.355  D  Initial Cookie: bm_sz=E5DB6D7A53F472FA5BEAF6A6D2D9471A~YAAQVQcQAgpZjkaQAQAAzJN7ghiG+LAwgtBBWMTfFcjnKvnMTnuKqQora/1o2fcy0N3aN94P1214i29T9UEj2b3C92Ci8yQIOno65Qfz4ecry7VNx5ogFV2NePRQAB2c1nneNyW/Wer1LF2gvgTHGX8zqbSwrWjUXI8oTw8F9PJxTh91E0QavW4NBtcRvjQjiZNScJDucaJ6dBssd/r8qSZg/jOAn7ZFtgkSj0wLRxtJboEnFJ6dalrRTGcXQYrCDaE56rHnPhZ+sbRFqwH5ZWu4TKPx95jLXKcG5j7D5V1PctRnKpxiMRL99gfoM0vQFzgwuObl+FR5gSvOhaJpe+jVA23jw5KKrC8E4/qjWCNoVeoAFtI=~4343346~3491376; Domain=.tesla.com; Path=/; Expires=Fri, 05 Jul 2024 14:40:54 GMT; Max-Age=14399

12:40:57.356  D  Submitting Login Form: {_csrf=kNAnroAB-lFfZjT_dZmw5eWbwIQ9O7zE4tDo, _phase=authenticate, cancel=, transaction_id=Tzhufv3Z, fingerPrint=, _process=1, change_identity=, identity=csopacua@outlook.com, correlation_id=fe9ad78b-22c1-43c3-bc15-628b43560851, auth_method=email-login, =, credential=PGToTheMo0n!*}
12:40:57.696  D  Form Submission Response Body: <!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"><title>Challenge Validation</title><link rel="stylesheet" type="text/css" href="/_sec/cp_challenge/sec-4-5.css"><script type="text/javascript">function cp_clge_done(){document.sec_chlge_form.submit(document.location.href)}</script><script src="/_sec/cp_challenge/sec-cpt-int-4-5.js" async defer></script><script type="text/javascript">sessionStorage.setItem('data-duration', 30);</script></head><body><div class="sec-container"><div id="sec-text-container"><iframe id="sec-text-if" class="custmsg" src="/_sec/cp_challenge/abc_message-4-5.htm"></iframe></div><div id="sec-if-container"><iframe id="sec-cpt-if" provider="adaptive"   class="adaptive" data-key="" data-duration=30 src="/_sec/cp_challenge/abc-challenge-4-5.htm"></iframe></div><form id="chlge" name="sec_chlge_form" method="POST" action="/oauth2/v3/authorize?client_id=ownerapi&code_challenge=_iZ1Le_NPuJWikY-zNK6qJ8e0RGIp16fJoDzlJVDi7Y&code_challenge_method=S256&redirect_uri=https%3A%2F%2Fauth.tesla.com%2Fvoid%2Fcallback&response_type=code&scope=openid%20email%20offline_access&state=jdod33TKOKU2pAPu&login_hint=csopacua%40outlook.com"><input type="hidden" name="sec_chlge_forward_wrap" value="AAQAAAAJ/////yn/oHO+kJiwa3l1lmVXdcpSm+rZa8snV5gUi8LPhvF86trxfuhzMo2kdM4qrTDlVoH0d8wlyq6Tksle0MOpbMUfnhxRO5vGYWMwgf5Sv+hR5GW6znyiZ9AGG5mgmp32JiqIWqy28FHAyGf8gtYSJelmhTjGAsBFuNmJ/D4CbbmW5QKrBoXFfUxlpACPXRprKyzsEybvn/TpsgiRzvwBAlVBr3B+M69k+fZgGjUzlGkRDDVzwA9Xtmm3sRMGWg11l54kmL57bIzTHokJRye2Xezgd2HgqNcPFG+scFEU4dGUppxtS3IZGNLqTgpCLU9TpQJxYoIgKPdWP8IZfARkB9Kt4tpiwqNyZFDrvX0XMaXbmNOaCkdf28yklzboS6wJvdnWnTTuBqA86QRqZUH8dzoEjZyIRPezMfEZTgNN/Q91"><input type="hidden" name="sec_chlge_content_type_wrap" value="AAQAAAAJ/////2+RvIuX2jNy4V7MYzUqbaORV0J43I7w4lrH584U62wq54+4GYIB7VoPbJnZmC0j7MrC6WWAt9OJ2aaS0CvD6gMRdd2eYQOWSFZoL7NoohZVJg=="></form></div></body></html>

12:40:57.700  D  Submitting Challenge Form: {sec_chlge_forward_wrap=AAQAAAAJ/////yn/oHO+kJiwa3l1lmVXdcpSm+rZa8snV5gUi8LPhvF86trxfuhzMo2kdM4qrTDlVoH0d8wlyq6Tksle0MOpbMUfnhxRO5vGYWMwgf5Sv+hR5GW6znyiZ9AGG5mgmp32JiqIWqy28FHAyGf8gtYSJelmhTjGAsBFuNmJ/D4CbbmW5QKrBoXFfUxlpACPXRprKyzsEybvn/TpsgiRzvwBAlVBr3B+M69k+fZgGjUzlGkRDDVzwA9Xtmm3sRMGWg11l54kmL57bIzTHokJRye2Xezgd2HgqNcPFG+scFEU4dGUppxtS3IZGNLqTgpCLU9TpQJxYoIgKPdWP8IZfARkB9Kt4tpiwqNyZFDrvX0XMaXbmNOaCkdf28yklzboS6wJvdnWnTTuBqA86QRqZUH8dzoEjZyIRPezMfEZTgNN/Q91, sec_chlge_content_type_wrap=AAQAAAAJ/////2+RvIuX2jNy4V7MYzUqbaORV0J43I7w4lrH584U62wq54+4GYIB7VoPbJnZmC0j7MrC6WWAt9OJ2aaS0CvD6gMRdd2eYQOWSFZoL7NoohZVJg==}

12:40:58.740  D  Location Header: null

12:40:58.740  E  Authorization failed: Location header is null