get forwarded auth
All checks were successful
Build .deb on release / Build-Deb (push) Successful in 2m37s

This commit is contained in:
Alex Janka 2024-05-25 16:15:53 +10:00
parent 40817a7419
commit 3990228403
8 changed files with 240 additions and 11 deletions

6
Cargo.lock generated
View file

@ -2849,7 +2849,7 @@ dependencies = [
[[package]] [[package]]
name = "tesla-charge-controller" name = "tesla-charge-controller"
version = "1.3.3" version = "1.4.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap 4.5.4", "clap 4.5.4",
@ -2861,6 +2861,8 @@ dependencies = [
"log 0.4.21", "log 0.4.21",
"notify-debouncer-mini", "notify-debouncer-mini",
"prometheus", "prometheus",
"rand 0.8.5",
"reqwest",
"rocket", "rocket",
"ron", "ron",
"serde", "serde",
@ -2875,7 +2877,7 @@ dependencies = [
[[package]] [[package]]
name = "tesla-common" name = "tesla-common"
version = "0.3.1" version = "0.3.1"
source = "git+https://git.alexjanka.com/alex/tesla-common#6c6cb913c33a94b6246d21af1525fad02b5cd3d8" source = "git+https://git.alexjanka.com/alex/tesla-common#74859ebe6f1c96ae6a62d9156beffd8df46dea37"
dependencies = [ dependencies = [
"ron", "ron",
"serde", "serde",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "tesla-charge-controller" name = "tesla-charge-controller"
version = "1.3.3" version = "1.4.0"
edition = "2021" edition = "2021"
license = "MITNFA" license = "MITNFA"
description = "Controls Tesla charge rate based on solar charge data" description = "Controls Tesla charge rate based on solar charge data"
@ -32,3 +32,8 @@ libmodbus-rs = "0.8.3"
if_chain = "1.0.2" if_chain = "1.0.2"
notify-debouncer-mini = { version = "0.4.1", default-features = false } notify-debouncer-mini = { version = "0.4.1", default-features = false }
lazy_static = "1.4" lazy_static = "1.4"
rand = "0.8"
reqwest = "0.12.4"
# [patch."https://git.alexjanka.com/alex/tesla-common"]
# tesla-common = { path = "../tesla-common" }

View file

@ -261,12 +261,13 @@ struct MonitoredValues {
climate_keeper: String, climate_keeper: String,
} }
#[derive(Clone, Copy, Debug)] #[derive(Clone, Debug)]
// these are the messages that the webserver can send the api thread // these are the messages that the webserver can send the api thread
pub enum InterfaceRequest { pub enum InterfaceRequest {
FlashLights, FlashLights,
StopCharge, StopCharge,
SetChargeRate(i64), SetChargeRate(i64),
NewAuth(AuthInfo),
} }
const KEY_REFRESH_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); const KEY_REFRESH_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
@ -346,6 +347,13 @@ impl TeslaInterface {
Ok(_) => {} Ok(_) => {}
Err(e) => log::error!("Error setting charge rate: {e:?}"), Err(e) => log::error!("Error setting charge rate: {e:?}"),
}, },
InterfaceRequest::NewAuth(new) => {
self.api.access_token = new.access_token;
self.api.refresh_token = new.refresh_token;
if let Err(e) = self.save_key() {
log::error!("failed to save key: {e:?}");
}
}
} }
} }

View file

@ -38,6 +38,18 @@ pub enum ServerError {
InvalidParameters, InvalidParameters,
#[error("prometheus")] #[error("prometheus")]
Prometheus(#[from] prometheus::Error), Prometheus(#[from] prometheus::Error),
#[error("uri")]
Uri,
#[error(transparent)]
Auth(#[from] AuthKeyError),
#[error(transparent)]
Channel(#[from] tokio::sync::mpsc::error::SendError<crate::api_interface::InterfaceRequest>),
}
impl From<rocket::http::uri::Error<'_>> for ServerError {
fn from(_: rocket::http::uri::Error<'_>) -> Self {
Self::Uri
}
} }
impl<T> From<PoisonError<T>> for ServerError { impl<T> From<PoisonError<T>> for ServerError {
@ -49,10 +61,13 @@ impl<T> From<PoisonError<T>> for ServerError {
impl<'a> Responder<'a, 'a> for ServerError { impl<'a> Responder<'a, 'a> for ServerError {
fn respond_to(self, _: &'a rocket::Request<'_>) -> rocket::response::Result<'a> { fn respond_to(self, _: &'a rocket::Request<'_>) -> rocket::response::Result<'a> {
Err(match self { Err(match self {
ServerError::Lock => rocket::http::Status::InternalServerError,
ServerError::NoData => rocket::http::Status::ServiceUnavailable, ServerError::NoData => rocket::http::Status::ServiceUnavailable,
Self::InvalidParameters => rocket::http::Status::BadRequest, ServerError::InvalidParameters => rocket::http::Status::BadRequest,
ServerError::Prometheus(_) => rocket::http::Status::InternalServerError, ServerError::Auth(_) => rocket::http::Status::Unauthorized,
ServerError::Channel(_)
| ServerError::Uri
| ServerError::Lock
| ServerError::Prometheus(_) => rocket::http::Status::InternalServerError,
}) })
} }
} }
@ -106,3 +121,24 @@ pub enum ConfigError {
#[error("io")] #[error("io")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }
#[derive(Debug, thiserror::Error)]
pub(crate) enum AuthKeyError {
#[error("Error returned from Tesla")]
TeslaError(TeslaError),
#[error(transparent)]
SerdeError(#[from] serde_json::Error),
#[error(transparent)]
Reqwest(#[from] reqwest::Error),
#[error("Secrets not registered")]
Secrets,
}
#[derive(serde::Deserialize, Debug)]
#[allow(dead_code)]
pub(crate) struct TeslaError {
error: String,
error_description: String,
#[serde(rename = "referenceID")]
reference_id: String,
}

View file

@ -167,14 +167,14 @@ async fn main() {
.map(|(_, tcrc)| tcrc.tcrc_state.clone()) .map(|(_, tcrc)| tcrc.tcrc_state.clone())
.unwrap_or_default(); .unwrap_or_default();
let server_handle = server::launch_server(server::ServerState { let server_handle = server::launch_server(server::ServerState::new(
car_state, car_state,
pl_state,
tcrc_state, tcrc_state,
pl_state,
api_requests, api_requests,
pli_requests, pli_requests,
tcrc_requests, tcrc_requests,
}); ));
if let Some((mut interface, mut tesla_charge_rate_controller)) = interface_and_tcrc { if let Some((mut interface, mut tesla_charge_rate_controller)) = interface_and_tcrc {
// spawn the api / charge rate control loop // spawn the api / charge rate control loop

View file

@ -0,0 +1,76 @@
use tesla_common::AuthInfo;
use crate::errors::AuthKeyError;
#[derive(serde::Deserialize, Debug)]
#[allow(dead_code)]
struct AuthKeySuccess {
access_token: String,
refresh_token: String,
id_token: String,
expires_in: usize,
state: String,
token_type: String,
}
impl AuthKeySuccess {
fn get_keys(&self) -> AuthInfo {
AuthInfo {
access_token: tesla_common::AccessToken(self.access_token.clone()),
refresh_token: Some(tesla_common::RefreshToken(self.refresh_token.clone())),
}
}
}
#[derive(serde::Deserialize, Debug)]
#[serde(untagged)]
enum AuthKeyResponse {
Ok(AuthKeySuccess),
Error(crate::errors::TeslaError),
}
pub async fn register_auth_key(code: String) -> Result<AuthInfo, AuthKeyError> {
if let Some(secrets) = super::SECRETS.as_ref() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
"Content-Type",
"application/x-www-form-urlencoded".parse().unwrap(),
);
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let res = client
.post("https://auth.tesla.com/oauth2/v3/token")
.headers(headers)
.body(
[
"grant_type=authorization_code&client_id=",
&secrets.client_id,
"&client_secret=",
&secrets.client_secret,
"&code=",
&code,
"&audience=",
&secrets.audience,
"&redirect_uri=",
&secrets.redirect_uri,
]
.concat(),
)
.send()
.await?
.text()
.await?;
let keys = match serde_json::from_str(&res) {
Ok(AuthKeyResponse::Ok(v)) => Ok(v),
Ok(AuthKeyResponse::Error(e)) => Err(AuthKeyError::TeslaError(e)),
Err(e) => Err(AuthKeyError::SerdeError(e)),
}?;
Ok(keys.get_keys())
} else {
Err(AuthKeyError::Secrets)
}
}

View file

@ -1,5 +1,6 @@
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use rand::Rng;
use rocket::{ use rocket::{
fairing::{Fairing, Info, Kind}, fairing::{Fairing, Info, Kind},
http::Header, http::Header,
@ -30,6 +31,28 @@ pub struct ServerState {
pub api_requests: UnboundedSender<InterfaceRequest>, pub api_requests: UnboundedSender<InterfaceRequest>,
pub pli_requests: UnboundedSender<PliRequest>, pub pli_requests: UnboundedSender<PliRequest>,
pub tcrc_requests: UnboundedSender<TcrcRequest>, pub tcrc_requests: UnboundedSender<TcrcRequest>,
waiting_for_auth: std::sync::Mutex<Option<(String, std::time::Instant)>>,
}
impl ServerState {
pub fn new(
car_state: Arc<RwLock<CarState>>,
tcrc_state: Arc<RwLock<TcrcState>>,
pl_state: Option<Arc<RwLock<PlState>>>,
api_requests: UnboundedSender<InterfaceRequest>,
pli_requests: UnboundedSender<PliRequest>,
tcrc_requests: UnboundedSender<TcrcRequest>,
) -> Self {
Self {
car_state,
tcrc_state,
pl_state,
api_requests,
pli_requests,
tcrc_requests,
waiting_for_auth: std::sync::Mutex::new(None),
}
}
} }
pub async fn launch_server(state: ServerState) { pub async fn launch_server(state: ServerState) {
@ -77,11 +100,89 @@ fn rocket(state: ServerState) -> rocket::Rocket<rocket::Build> {
sync_time, sync_time,
read_ram, read_ram,
read_eeprom, read_eeprom,
set_regulator_state set_regulator_state,
reauthenticate,
auth
], ],
) )
} }
#[derive(Debug)]
struct Secrets {
client_id: String,
client_secret: String,
audience: String,
redirect_uri: String,
}
impl Secrets {
fn load() -> Option<Self> {
Some(Self {
client_id: std::env::var("CLIENT_ID").ok()?,
client_secret: std::env::var("CLIENT_SECRET").ok()?,
audience: std::env::var("AUDIENCE").ok()?,
redirect_uri: std::env::var("REDIRECT_URI").ok()?,
})
}
}
lazy_static::lazy_static! {
static ref SECRETS: Option<Secrets> = Secrets::load();
}
mod authentication;
#[get("/reauthenticate")]
async fn reauthenticate(
state: &State<ServerState>,
) -> Result<rocket::response::Redirect, ServerError> {
let state_random: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(16)
.map(char::from)
.collect();
let mut auth = state.waiting_for_auth.lock()?;
let secrets = SECRETS.as_ref().ok_or(ServerError::Lock)?;
let uri = rocket::http::uri::Absolute::parse_owned(format!(
"https://auth.tesla.com/oauth2/v3/authorize?&client_id={}&locale=en-US&prompt=login&redirect_uri={}&response_type=code&scope=openid%20vehicle_device_data%20offline_access%20user_data%20vehicle_cmds%20vehicle_charging_cmds&state={state_random}",
secrets.client_id,
secrets.redirect_uri
))?;
*auth = Some((state_random.clone(), std::time::Instant::now()));
log::info!("set auth {auth:?}");
Ok(rocket::response::Redirect::to(uri))
}
#[post("/auth", format = "application/json", data = "<data>")]
async fn auth(
data: rocket::serde::json::Json<tesla_common::SuccessfulAuth>,
state: &State<ServerState>,
) -> Result<(), ServerError> {
log::info!("got reauthentication data");
let data = data.0;
let code = {
let mut guard = state.waiting_for_auth.lock()?;
if guard.as_ref().is_some_and(|(state_random, time)| {
*state_random == data.state
&& std::time::Instant::now().duration_since(*time)
< std::time::Duration::from_secs(30 * 60)
}) {
guard.take();
log::info!("matched reauthentication link");
Ok(data.code)
} else {
Err(ServerError::NoData)
}?
};
let new = authentication::register_auth_key(code).await?;
state.api_requests.send(InterfaceRequest::NewAuth(new))?;
Ok(())
}
#[get("/home")] #[get("/home")]
async fn home(state: &State<ServerState>) -> Result<Json<bool>, ServerError> { async fn home(state: &State<ServerState>) -> Result<Json<bool>, ServerError> {
let location_data = &state let location_data = &state

View file

@ -37,6 +37,7 @@
<a class="outlink" href="/regulator">Regulator control→</a> <a class="outlink" href="/regulator">Regulator control→</a>
<a class="outlink" href="/pid">PID control variables→</a> <a class="outlink" href="/pid">PID control variables→</a>
<a class="outlink" href="/shutoff">Shutoff voltage control→</a> <a class="outlink" href="/shutoff">Shutoff voltage control→</a>
<a class="outlink" href="/reauthenticate">Reauthenticate→</a>
</p> </p>
</div> </div>
</body> </body>