From 9ca3830f044dac0a6634fcc261e0eac7de894bb8 Mon Sep 17 00:00:00 2001 From: Adrian Kumpf <8999358+adriankumpf@users.noreply.github.com> Date: Sat, 30 Jan 2021 18:56:23 +0100 Subject: [PATCH] Refresh access tokens with new auth endpoint --- lib/tesla_api/auth.ex | 349 ++++++++++++++++-- lib/tesla_api/auth/mfa.ex | 297 --------------- ...38_use_variable_length_type_for_tokens.exs | 10 + 3 files changed, 321 insertions(+), 335 deletions(-) delete mode 100644 lib/tesla_api/auth/mfa.ex create mode 100644 priv/repo/migrations/20210130174838_use_variable_length_type_for_tokens.exs diff --git a/lib/tesla_api/auth.ex b/lib/tesla_api/auth.ex index d192bc8e..74863eda 100644 --- a/lib/tesla_api/auth.ex +++ b/lib/tesla_api/auth.ex @@ -1,66 +1,339 @@ defmodule TeslaApi.Auth do - alias __MODULE__.MFA + use Tesla + + require Logger + + adapter Tesla.Adapter.Finch, name: TeslaMate.HTTP, receive_timeout: 15_000 + + plug Tesla.Middleware.BaseUrl, "https://auth.tesla.com" + plug Tesla.Middleware.JSON + alias TeslaApi.Error - defstruct [:token, :type, :expires_in, :refresh_token, :created_at] - + @web_client_id "ownerapi" @client_id "81527cff06843c8634fdc09e8ac0abefb46ac849f38fe1e431c2ef2106796384" @client_secret "c7257eb71a564034f9419ee651c7d0e5f7aa6bfbd18bafb5c5c033b093bb2fa3" - defdelegate login(email, password), to: MFA - defdelegate login(device_id, mfa_passcode, ctx), to: MFA + @base_url "https://owner-api.teslamotors.com" - def refresh(%__MODULE__{token: token, refresh_token: refresh_token}) do + defstruct [:token, :type, :expires_in, :refresh_token, :created_at] + + defmodule MFA.Ctx do + defstruct [:state, :code_verifier, :transaction_id, :headers, :devices] + end + + def refresh(%__MODULE__{refresh_token: refresh_token}) do + with {:ok, %{access_token: _} = tokens} <- refresh_oauth_access_token(refresh_token), + {:ok, auth} <- get_api_tokens(tokens) do + {:ok, auth} + else + error -> + handle_error(error, :token_refresh) + end + end + + defp refresh_oauth_access_token(refresh_token) do data = %{ grant_type: "refresh_token", - client_id: @client_id, - client_secret: @client_secret, + scope: "openid email offline_access", + client_id: @web_client_id, refresh_token: refresh_token } - TeslaApi.post("/oauth/token", data, opts: [access_token: token]) - |> handle_response() + case post("/oauth2/v3/token", data) do + {:ok, + %Tesla.Env{ + status: 200, + body: %{"access_token" => access_token, "refresh_token" => refresh_token} + }} -> + {:ok, %{access_token: access_token, refresh_token: refresh_token}} + + error -> + error + end end - defp handle_response(response) do - case response do - {:ok, %Tesla.Env{status: 200, body: body}} when body == %{} -> - :ok + def login(email, password) do + state = random_string(15) + code_verifier = random_code_verifier() - {:ok, %Tesla.Env{status: 200, body: %{"response" => true}}} -> - :ok + with {:ok, form_data} <- load_form(state, code_verifier), + {:ok, env = %Tesla.Env{}} <- submit_form(form_data, email, password), + {:ok, {redirect_uri, code}} <- parse_location_header(env, state), + {:ok, tokens} <- get_web_token(code, code_verifier, redirect_uri, state), + {:ok, auth} <- get_api_tokens(tokens) do + {:ok, auth} + end + rescue + e -> + Logger.error(Exception.format(:error, e, __STACKTRACE__)) + {:error, %Error{reason: e, message: "An unexpected error occurred"}} + end - {:ok, %Tesla.Env{status: 200, body: body}} when is_map(body) -> + def login(device_id, mfa_passcode, %MFA.Ctx{} = ctx) do + with {:ok, env} <- verify_passcode(device_id, mfa_passcode, ctx), + {:ok, {redirect_uri, code}} <- parse_location_header(env, ctx.state), + {:ok, tokens} <- get_web_token(code, ctx.code_verifier, redirect_uri, ctx.state), + {:ok, auth} <- get_api_tokens(tokens) do + {:ok, auth} + end + rescue + e -> + Logger.error(Exception.format(:error, e, __STACKTRACE__)) + {:error, %Error{reason: e, message: "An unexpected error occurred"}} + end + + def login(email, password, mfa_passcode) when is_binary(mfa_passcode) do + with {:ok, {:mfa, [%{"id" => id} | _], ctx}} <- login(email, password) do + login(id, mfa_passcode, ctx) + end + end + + defp load_form(state, code_verifier) do + params = [ + client_id: @web_client_id, + redirect_uri: "https://auth.tesla.com/void/callback", + response_type: "code", + scope: "openid email offline_access", + code_challenge: challenge(code_verifier), + code_challenge_method: "S265", + state: state + ] + + case get("/oauth2/v3/authorize", query: params) do + {:ok, %Tesla.Env{status: 200, headers: resp_headers, body: resp_body}} -> + cookies = + resp_headers + |> Enum.filter(&match?({"set-cookie", _}, &1)) + |> Enum.map(fn {_, cookie} -> cookie |> String.split(";") |> hd() end) + |> Enum.join("; ") + + form = + Floki.parse_document!(resp_body) + |> Floki.find("form input") + |> Map.new(fn input -> + [key] = input |> Floki.attribute("name") + value = input |> Floki.attribute("value") |> List.first() + {key, value} + end) + + {:ok, {form, cookies, state, code_verifier}} + + {:ok, %Tesla.Env{status: 200} = env} -> + {:error, %Error{reason: :invalid_credentials, message: "Invalid credentials", env: env}} + + error -> + handle_error(error, :authorization_request_failed) + end + end + + defp submit_form({form, cookies, state, code_verifier}, username, password) do + transaction_id = Map.fetch!(form, "transaction_id") + + encoded_form = + form + |> Map.replace!("identity", username) + |> Map.replace!("credential", password) + |> URI.encode_query() + + headers = [ + {"Content-Type", "application/x-www-form-urlencoded"}, + {"Cookie", cookies} + ] + + case post("/oauth2/v3/authorize", encoded_form, headers: headers) do + {:ok, %Tesla.Env{status: 200, body: body} = env} -> + if String.contains?(body, "/oauth2/v3/authorize/mfa/verify") do + headers = [{"referer", env.url}, {"cookie", cookies}] + + with {:ok, devices} <- list_devices(transaction_id, headers) do + ctx = %MFA.Ctx{ + state: state, + code_verifier: code_verifier, + transaction_id: transaction_id, + headers: headers, + devices: devices + } + + {:ok, {:mfa, devices, ctx}} + end + else + {:error, %Error{reason: :mfa_input_not_found, env: env}} + end + + {:ok, %Tesla.Env{status: 302} = env} -> + {:ok, env} + + {:ok, %Tesla.Env{status: 401} = env} -> + message = "Invalid email address and password combination" + {:error, %Error{reason: :invalid_credentials, message: message, env: env}} + + error -> + handle_error(error, :authorization_failed) + end + end + + defp list_devices(transaction_id, headers) do + params = [transaction_id: transaction_id] + + case get("/oauth2/v3/authorize/mfa/factors", query: params, headers: headers) do + {:ok, %Tesla.Env{status: 200, body: %{"data" => devices}}} -> + {:ok, devices} + + error -> + handle_error(error, :mfa_factor_lookup_failed) + end + end + + defp verify_passcode(device_id, mfa_passcode, %MFA.Ctx{} = ctx) do + params = [transaction_id: ctx.transaction_id] + + data = %{ + transaction_id: ctx.transaction_id, + factor_id: device_id, + passcode: mfa_passcode + } + + case post("/oauth2/v3/authorize/mfa/verify", data, headers: ctx.headers) do + {:ok, %Tesla.Env{status: 200, body: body} = env} -> + case body do + %{"data" => %{"approved" => true, "valid" => true}} -> + case get("/oauth2/v3/authorize", query: params, headers: ctx.headers) do + {:ok, %Tesla.Env{status: 302} = env} -> + {:ok, env} + + error -> + handle_error(error) + end + + %{"data" => %{}} -> + error = %Error{ + reason: :mfa_passcode_invalid, + message: "Incorrect verfification code", + env: env + } + + {:error, error} + end + + error -> + handle_error(error, :mfa_verification_failed) + end + end + + defp parse_location_header(%Tesla.Env{status: 302} = env, state) do + {query, uri} = + env + |> Tesla.get_header("location") + |> URI.parse() + |> Map.get_and_update!(:query, &{&1, nil}) + + %{"code" => code, "state" => ^state} = URI.decode_query(query) + + {:ok, {URI.to_string(uri), code}} + end + + defp get_web_token(code, code_verifier, redirect_uri, state) do + data = %{ + grant_type: "authorization_code", + client_id: @web_client_id, + code: code, + code_verifier: code_verifier, + redirect_uri: redirect_uri + } + + case post("/oauth2/v3/token", data) do + {:ok, + %Tesla.Env{ + status: 200, + body: %{ + "access_token" => access_token, + "refresh_token" => refresh_token, + "state" => ^state + } + }} -> + {:ok, %{access_token: access_token, refresh_token: refresh_token}} + + error -> + handle_error(error, :web_token_error) + end + end + + defp get_api_tokens(%{access_token: access_token, refresh_token: refresh_token}) do + data = %{ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + client_id: @client_id, + client_secret: @client_secret + } + + headers = [{"Authorization", "Bearer #{access_token}"}] + + case post("#{@base_url}/oauth/token", data, headers: headers) do + {:ok, %Tesla.Env{status: 200, body: body}} -> auth = %__MODULE__{ token: body["access_token"], type: body["token_type"], expires_in: body["expires_in"], - refresh_token: body["refresh_token"], + refresh_token: refresh_token, created_at: body["created_at"] } {:ok, auth} - {:ok, %Tesla.Env{status: 401} = e} -> - error = %Error{ - reason: :invalid_credentials, - message: "Invalid email address and password combination", - env: e - } - - {:error, error} - - {:ok, %Tesla.Env{} = e} -> - {:error, %Error{reason: :unknown, message: "An unknown error has occurred.", env: e}} - - {:error, %{reason: reason} = e} -> - error = %Error{ - reason: :unknown, - message: "An unknown error has occurred: #{inspect(reason)}", - env: e - } - - {:error, error} + error -> + handle_error(error, :api_token_error) end end + + defp handle_error(response, reason \\ :unknown) + + defp handle_error({:ok, %Tesla.Env{} = env}, reason) do + message = + case env.body do + %{"error" => %{"message" => message}} when is_binary(message) -> + message + + body when is_binary(body) -> + case Floki.parse_document(body) do + {:error, _} -> body + {:ok, _} -> nil + end + + _ -> + nil + end + + {:error, %Error{reason: reason, message: message, env: env}} + end + + defp handle_error({:error, reason}, _reason) when is_atom(reason) do + {:error, %Error{reason: reason}} + end + + defp handle_error({:error, error}, reason) do + {:error, %Error{reason: reason, message: error}} + end + + defp random_code_verifier do + random_string(86) |> base64_url_encode() + end + + defp challenge(value) do + value + |> (&:crypto.hash(:sha256, &1)).() + |> base64_url_encode() + end + + defp random_string(length) do + :crypto.strong_rand_bytes(length) + |> base64_url_encode() + |> binary_part(0, length) + end + + defp base64_url_encode(data) do + data + |> Base.encode64(padding: false) + |> String.replace("+", "-") + |> String.replace("/", "_") + end end diff --git a/lib/tesla_api/auth/mfa.ex b/lib/tesla_api/auth/mfa.ex deleted file mode 100644 index 99bfa21e..00000000 --- a/lib/tesla_api/auth/mfa.ex +++ /dev/null @@ -1,297 +0,0 @@ -defmodule TeslaApi.Auth.MFA do - use Tesla - - require Logger - - adapter Tesla.Adapter.Finch, name: TeslaMate.HTTP, receive_timeout: 15_000 - - plug Tesla.Middleware.BaseUrl, "https://auth.tesla.com" - plug Tesla.Middleware.Headers, [{"x-requested-with", "com.teslamotors.tesla"}] - plug Tesla.Middleware.JSON - - alias TeslaApi.Error - - @web_client_id "ownerapi" - @client_id "81527cff06843c8634fdc09e8ac0abefb46ac849f38fe1e431c2ef2106796384" - @client_secret "c7257eb71a564034f9419ee651c7d0e5f7aa6bfbd18bafb5c5c033b093bb2fa3" - - defmodule Ctx do - defstruct [:state, :code_verifier, :transaction_id, :headers, :devices] - end - - def login(email, password) do - state = random_string(15) - code_verifier = random_code_verifier() - - with {:ok, form_data} <- load_form(state, code_verifier), - {:ok, env = %Tesla.Env{}} <- submit_form(form_data, email, password), - {:ok, {redirect_uri, code}} <- parse_location_header(env, state), - {:ok, access_token} <- get_web_token(code, code_verifier, redirect_uri, state), - {:ok, auth} <- get_api_tokens(access_token) do - {:ok, auth} - end - rescue - e -> - Logger.error(Exception.format(:error, e, __STACKTRACE__)) - {:error, %Error{reason: e, message: "An unexpected error occurred"}} - end - - def login(device_id, mfa_passcode, %Ctx{} = ctx) do - with {:ok, env} <- verify_passcode(device_id, mfa_passcode, ctx), - {:ok, {redirect_uri, code}} <- parse_location_header(env, ctx.state), - {:ok, access_token} <- get_web_token(code, ctx.code_verifier, redirect_uri, ctx.state), - {:ok, auth} <- get_api_tokens(access_token) do - {:ok, auth} - end - rescue - e -> - Logger.error(Exception.format(:error, e, __STACKTRACE__)) - {:error, %Error{reason: e, message: "An unexpected error occurred"}} - end - - def login(email, password, mfa_passcode) when is_binary(mfa_passcode) do - with {:ok, {:mfa, [%{"id" => id} | _], ctx}} <- login(email, password) do - login(id, mfa_passcode, ctx) - end - end - - defp load_form(state, code_verifier) do - params = [ - client_id: @web_client_id, - redirect_uri: "https://auth.tesla.com/void/callback", - response_type: "code", - scope: "openid email offline_access", - code_challenge: challenge(code_verifier), - code_challenge_method: "S265", - state: state - ] - - case get("/oauth2/v3/authorize", query: params) do - {:ok, %Tesla.Env{status: 200, headers: resp_headers, body: resp_body}} -> - cookies = - resp_headers - |> Enum.filter(&match?({"set-cookie", _}, &1)) - |> Enum.map(fn {_, cookie} -> cookie |> String.split(";") |> hd() end) - |> Enum.join("; ") - - form = - Floki.parse_document!(resp_body) - |> Floki.find("form input") - |> Map.new(fn input -> - [key] = input |> Floki.attribute("name") - value = input |> Floki.attribute("value") |> List.first() - {key, value} - end) - - {:ok, {form, cookies, state, code_verifier}} - - {:ok, %Tesla.Env{status: 200} = env} -> - {:error, %Error{reason: :invalid_credentials, message: "Invalid credentials", env: env}} - - error -> - handle_error(error, :authorization_request_failed) - end - end - - defp submit_form({form, cookies, state, code_verifier}, username, password) do - transaction_id = Map.fetch!(form, "transaction_id") - - encoded_form = - form - |> Map.replace!("identity", username) - |> Map.replace!("credential", password) - |> URI.encode_query() - - headers = [ - {"Content-Type", "application/x-www-form-urlencoded"}, - {"Cookie", cookies} - ] - - case post("/oauth2/v3/authorize", encoded_form, headers: headers) do - {:ok, %Tesla.Env{status: 200, body: body} = env} -> - if String.contains?(body, "/oauth2/v3/authorize/mfa/verify") do - headers = [{"referer", env.url}, {"cookie", cookies}] - - with {:ok, devices} <- list_devices(transaction_id, headers) do - ctx = %Ctx{ - state: state, - code_verifier: code_verifier, - transaction_id: transaction_id, - headers: headers, - devices: devices - } - - {:ok, {:mfa, devices, ctx}} - end - else - {:error, %Error{reason: :mfa_input_not_found, env: env}} - end - - {:ok, %Tesla.Env{status: 302} = env} -> - {:ok, env} - - {:ok, %Tesla.Env{status: 401} = env} -> - message = "Invalid email address and password combination" - {:error, %Error{reason: :invalid_credentials, message: message, env: env}} - - error -> - handle_error(error, :authorization_failed) - end - end - - defp list_devices(transaction_id, headers) do - params = [transaction_id: transaction_id] - - case get("/oauth2/v3/authorize/mfa/factors", query: params, headers: headers) do - {:ok, %Tesla.Env{status: 200, body: %{"data" => devices}}} -> - {:ok, devices} - - error -> - handle_error(error, :mfa_factor_lookup_failed) - end - end - - defp verify_passcode(device_id, mfa_passcode, %Ctx{} = ctx) do - params = [transaction_id: ctx.transaction_id] - - data = %{ - transaction_id: ctx.transaction_id, - factor_id: device_id, - passcode: mfa_passcode - } - - case post("/oauth2/v3/authorize/mfa/verify", data, headers: ctx.headers) do - {:ok, %Tesla.Env{status: 200, body: body} = env} -> - case body do - %{"data" => %{"approved" => true, "valid" => true}} -> - case get("/oauth2/v3/authorize", query: params, headers: ctx.headers) do - {:ok, %Tesla.Env{status: 302} = env} -> - {:ok, env} - - error -> - handle_error(error) - end - - %{"data" => %{}} -> - error = %Error{ - reason: :mfa_passcode_invalid, - message: "Incorrect verfification code", - env: env - } - - {:error, error} - end - - error -> - handle_error(error, :mfa_verification_failed) - end - end - - defp parse_location_header(%Tesla.Env{status: 302} = env, state) do - {query, uri} = - env - |> Tesla.get_header("location") - |> URI.parse() - |> Map.get_and_update!(:query, &{&1, nil}) - - %{"code" => code, "state" => ^state} = URI.decode_query(query) - - {:ok, {URI.to_string(uri), code}} - end - - defp get_web_token(code, code_verifier, redirect_uri, state) do - data = %{ - grant_type: "authorization_code", - client_id: @web_client_id, - code: code, - code_verifier: code_verifier, - redirect_uri: redirect_uri - } - - case post("/oauth2/v3/token", data) do - {:ok, %Tesla.Env{status: 200, body: %{"access_token" => access_token, "state" => ^state}}} -> - {:ok, access_token} - - error -> - handle_error(error, :web_token_error) - end - end - - defp get_api_tokens(access_token) do - data = %{ - grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", - client_id: @client_id, - client_secret: @client_secret - } - - headers = [{"Authorization", "Bearer #{access_token}"}] - - case post("https://owner-api.teslamotors.com/oauth/token", data, headers: headers) do - {:ok, %Tesla.Env{status: 200, body: body}} -> - auth = %TeslaApi.Auth{ - token: body["access_token"], - type: body["token_type"], - expires_in: body["expires_in"], - refresh_token: body["refresh_token"], - created_at: body["created_at"] - } - - {:ok, auth} - - error -> - handle_error(error, :api_token_error) - end - end - - defp handle_error(response, reason \\ :unknown) - - defp handle_error({:ok, %Tesla.Env{} = env}, reason) do - message = - case env.body do - %{"error" => %{"message" => message}} when is_binary(message) -> - message - - body when is_binary(body) -> - case Floki.parse_document(body) do - {:error, _} -> body - {:ok, _} -> nil - end - - _ -> - nil - end - - {:error, %Error{reason: reason, message: message, env: env}} - end - - defp handle_error({:error, reason}, _reason) when is_atom(reason) do - {:error, %Error{reason: reason}} - end - - defp handle_error({:error, error}, reason) do - {:error, %Error{reason: reason, message: error}} - end - - defp random_code_verifier do - random_string(86) |> base64_url_encode() - end - - defp challenge(value) do - value - |> (&:crypto.hash(:sha256, &1)).() - |> base64_url_encode() - end - - defp random_string(length) do - :crypto.strong_rand_bytes(length) - |> base64_url_encode() - |> binary_part(0, length) - end - - defp base64_url_encode(data) do - data - |> Base.encode64(padding: false) - |> String.replace("+", "-") - |> String.replace("/", "_") - end -end diff --git a/priv/repo/migrations/20210130174838_use_variable_length_type_for_tokens.exs b/priv/repo/migrations/20210130174838_use_variable_length_type_for_tokens.exs new file mode 100644 index 00000000..0f9eb8ea --- /dev/null +++ b/priv/repo/migrations/20210130174838_use_variable_length_type_for_tokens.exs @@ -0,0 +1,10 @@ +defmodule TeslaMate.Repo.Migrations.UseVariableLengthTypeForTokens do + use Ecto.Migration + + def change do + alter table(:tokens) do + modify :access, :text, from: :string + modify :refresh, :text, from: :string + end + end +end