1use std::{
2 collections::HashMap,
3 fs::{create_dir_all, remove_file, write, File},
4 io::{self, BufRead, BufReader, Write},
5 net::TcpListener,
6};
7
8use reqwest::Client;
9use serde::Deserialize;
10use serde_json::Value;
11use url::Url;
12
13use bevy::prelude::*;
14
15use bevy_tokio_tasks::TokioTasksRuntime;
16
17use dirs::cache_dir;
18
19use ron::de::from_reader;
20
21#[derive(Clone, Eq, PartialEq, Hash, Debug, Deserialize)]
25pub enum LoginProvider {
26 Google,
27 Github,
28 EmailPassword,
30 Apple,
31 Phone,
32 Anonymous,
33 GooglePlayGames,
34 AppleGameCenter,
35 Facebook,
36 Twitter,
37 Microsoft,
38 Yahoo,
39}
40
41pub type LoginKeysMap = HashMap<LoginProvider, Option<(String, String)>>;
49pub type AuthUrlsMap = HashMap<LoginProvider, Url>;
50pub type AuthCodesMap = HashMap<LoginProvider, String>;
51
52#[derive(Resource)]
53struct LoginKeys(LoginKeysMap);
54
55#[derive(Event, Debug)]
90pub struct AuthUrlsEvent(pub AuthUrlsMap);
91
92#[derive(Event, Debug)]
93pub struct AuthCodeEvent((LoginProvider, String));
94
95#[derive(Event, Resource)]
96pub struct SelectedProvider(pub LoginProvider);
97
98#[derive(Resource, Clone)]
99pub struct AuthEmulatorUrl(String);
100
101#[derive(Resource)]
104pub struct ApiKey(String);
105
106#[derive(Resource)]
109pub struct ProjectId(pub String);
110
111#[derive(Resource)]
112pub struct RememberLoginFlag(pub bool);
113
114#[derive(Deserialize, Resource, Default, Debug)]
117pub struct TokenData {
118 #[serde(rename = "localId")]
119 #[serde(alias = "user_id")]
120 pub local_id: String,
121 #[serde(rename = "emailVerified")]
122 pub email_verified: Option<bool>,
123 #[serde(rename = "email")]
124 pub email: Option<String>,
125 #[serde(rename = "oauthIdToken")]
126 pub oauth_id_token: Option<String>,
127 #[serde(rename = "oauthAccessToken")]
128 pub oauth_access_token: Option<String>,
129 #[serde(rename = "oauthTokenSecret")]
130 pub oauth_token_secret: Option<String>,
131 #[serde(rename = "rawUserInfo")]
132 pub raw_user_info: Option<String>,
133 #[serde(rename = "firstName")]
134 pub first_name: Option<String>,
135 #[serde(rename = "lastName")]
136 pub last_name: Option<String>,
137 #[serde(rename = "fullName")]
138 pub full_name: Option<String>,
139 #[serde(rename = "displayName")]
140 pub display_name: Option<String>,
141 #[serde(rename = "photoUrl")]
142 pub photo_url: Option<String>,
143 #[serde(rename = "idToken")]
144 #[serde(alias = "id_token")]
145 pub id_token: String,
146 #[serde(rename = "refreshToken")]
147 #[serde(alias = "refresh_token")]
148 pub refresh_token: String,
149 #[serde(rename = "expiresIn")]
150 #[serde(alias = "expires_in")]
151 pub expires_in: String,
152}
153
154#[derive(Resource)]
156struct GoogleToken(String);
157
158#[derive(Resource)]
160struct RedirectPort(u16);
161
162#[derive(Default, States, Debug, Clone, Eq, PartialEq, Hash)]
164pub enum AuthState {
165 #[default]
166 LoggedOut,
167 LogOut,
168 Refreshing,
169 LogIn,
170 GotAuthCode,
171 LoggedIn,
172}
173
174pub struct AuthPlugin {
189 pub firebase_api_key: String,
190 pub firebase_project_id: String,
191 pub login_keys: LoginKeysMap,
192 pub emulator_url: Option<String>,
194}
195
196impl Default for AuthPlugin {
197 fn default() -> Self {
198 let keys_path = "keys.ron";
199 let f = File::open(&keys_path);
200
201 let login_keys = match f {
202 Ok(f) => {
203 let login_keys: LoginKeysMap = match from_reader(f) {
204 Ok(keys) => keys,
205 Err(err) => {
206 println!("File read error: {:?}", err);
207 HashMap::new()
208 }
209 };
210 login_keys
211 }
212 Err(err) => {
213 println!("File open error: {:?}", err);
214 HashMap::new()
215 }
216 };
217
218 AuthPlugin {
219 firebase_api_key: "API_KEY".into(),
220 firebase_project_id: "demo-bevy".into(),
221 emulator_url: Some("http://127.0.0.1:9099".into()),
222 login_keys,
223 }
224 }
225}
226
227impl Plugin for AuthPlugin {
228 fn build(&self, app: &mut App) {
229 app.insert_resource(ApiKey(self.firebase_api_key.clone()))
230 .insert_resource(ProjectId(self.firebase_project_id.clone()))
231 .insert_resource(TokenData::default())
232 .insert_resource(LoginKeys(self.login_keys.clone()))
233 .insert_resource(RememberLoginFlag(false))
234 .add_state::<AuthState>()
235 .add_event::<AuthUrlsEvent>()
236 .add_event::<AuthCodeEvent>()
237 .add_systems(OnEnter(AuthState::LogIn), init_login)
238 .add_systems(OnEnter(AuthState::GotAuthCode), auth_code_to_firebase_token)
239 .add_systems(OnEnter(AuthState::Refreshing), refresh_login)
240 .add_systems(OnEnter(AuthState::LoggedIn), save_refresh_token)
241 .add_systems(OnEnter(AuthState::LoggedIn), login_clear_resources)
242 .add_systems(OnEnter(AuthState::LogOut), logout_clear_resources);
243
244 let path = cache_dir()
247 .clone()
248 .unwrap()
249 .join(std::env::var("CARGO_PKG_NAME").unwrap())
250 .join("login")
251 .join("firebase-refresh.key");
252
253 let token = std::fs::read_to_string(path);
254
255 match token {
256 Ok(token) => {
257 app.insert_resource(TokenData {
258 refresh_token: token,
259 ..Default::default()
260 });
261 }
262 Err(_) => {}
263 }
264
265 if self.emulator_url.is_some() {
266 app.insert_resource(AuthEmulatorUrl(self.emulator_url.clone().unwrap()));
267 }
268 }
269}
270
271pub fn log_in(
293 current_state: Res<State<AuthState>>,
294 mut next_state: ResMut<NextState<AuthState>>,
295 token_data: Option<Res<TokenData>>,
296) {
297 if *current_state.get() != AuthState::LoggedOut {
298 return;
299 }
300
301 if token_data.is_none() || token_data.unwrap().refresh_token.clone().is_empty() {
302 next_state.set(AuthState::LogIn);
303 } else {
304 next_state.set(AuthState::Refreshing);
305 }
306}
307
308pub fn log_out(current_state: Res<State<AuthState>>, mut next_state: ResMut<NextState<AuthState>>) {
329 if *current_state.get() == AuthState::LoggedOut {
330 return;
331 }
332
333 next_state.set(AuthState::LogOut);
334}
335
336fn logout_clear_resources(mut commands: Commands, mut next_state: ResMut<NextState<AuthState>>) {
337 commands.remove_resource::<TokenData>();
338
339 let path = cache_dir()
340 .clone()
341 .unwrap()
342 .join(std::env::var("CARGO_PKG_NAME").unwrap())
343 .join("login")
344 .join("firebase-refresh.key");
345 let _ = remove_file(path);
346
347 next_state.set(AuthState::LoggedOut);
348
349 println!("Logged out.");
350}
351
352fn login_clear_resources(mut commands: Commands) {
353 commands.remove_resource::<RedirectPort>();
354 commands.remove_resource::<GoogleToken>();
355}
356
357fn init_login(
358 mut commands: Commands,
359 login_keys: Res<LoginKeys>,
360 mut ew: EventWriter<AuthUrlsEvent>,
361 runtime: ResMut<TokioTasksRuntime>,
362) {
363 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
365
366 match listener.set_nonblocking(true) {
367 Ok(_) => {}
368 Err(err) => println!(
369 "Couldn't set nonblocking listener! This may cause an app freeze on exit. {:?}",
370 err
371 ),
372 };
373
374 let port = listener.local_addr().unwrap().port();
375
376 commands.insert_resource(RedirectPort(port));
377
378 let mut auth_urls = HashMap::new();
379
380 for (provider, optional_keys) in login_keys.0.iter() {
381 let mut client_id = String::new();
382 if let Some(keys) = optional_keys {
383 client_id = keys.0.clone();
384 }
385 match provider {
386 LoginProvider::Google => {
387 let google_url = Url::parse(&format!("https://accounts.google.com/o/oauth2/v2/auth?scope=openid profile email&response_type=code&redirect_uri=http://127.0.0.1:{}&client_id={}",port, client_id)).unwrap();
388 auth_urls.insert(LoginProvider::Google, google_url);
389 }
390 LoginProvider::Github => {
391 let github_url: Url = Url::parse(&format!("https://github.com/login/oauth/authorize?scope=read:user&redirect_uri=http://127.0.0.1:{}&client_id={}", port, client_id )).unwrap();
392 auth_urls.insert(LoginProvider::Github, github_url);
393 }
394 unknown_provider => {
395 panic!("NOT IMPLEMENTED! {:?}", unknown_provider);
396 }
397 }
398 }
399
400 ew.send(AuthUrlsEvent(auth_urls));
401
402 runtime.spawn_background_task(|mut ctx| async move {
403 for stream in listener.incoming() {
404 match stream {
405 Ok(mut stream) => {
406 {
407 let mut reader = BufReader::new(&stream);
408 let mut request_line = String::new();
409 reader.read_line(&mut request_line).unwrap(); let redirect_url = request_line.split_whitespace().nth(1).unwrap(); let url = Url::parse(&("http://localhost".to_string() + redirect_url)); let url = url.unwrap().to_owned();
415
416 let code_pair = url.query_pairs().find(|pair| {
418 let (key, _) = pair;
419 key == "code"
420 });
421
422 if let Some(code_pair) = code_pair {
423 let code = code_pair.1.into_owned();
424 ctx.run_on_main_thread(move |ctx| {
425 let selected_provider =
427 ctx.world.get_resource::<SelectedProvider>();
428
429 if let Some(selected_provider) = selected_provider {
430 match selected_provider.0.clone() {
432 LoginProvider::Google => {
433 ctx.world.send_event(AuthCodeEvent((
434 LoginProvider::Google,
435 code,
436 )));
437 }
438 LoginProvider::Github => {
439 ctx.world.send_event(AuthCodeEvent((
440 LoginProvider::Github,
441 code,
442 )));
443 }
444 _ => panic!("NO SELECTED PROVIDER"),
445 }
446 }
447
448 ctx.world
449 .insert_resource(NextState(Some(AuthState::GotAuthCode)));
450 })
451 .await;
452 }
453 }
454
455 let message = "Login Complete! You can close this window.";
458 let response = format!(
459 "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}",
460 message.len(),
461 message
462 );
463 stream.write_all(response.as_bytes()).unwrap();
464 break;
465 }
466 Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
467 ctx.sleep_updates(60).await;
468 continue;
469 }
470 Err(e) => {
471 panic!("IO_ERR: {:?}", e);
472 }
473 }
474 }
475 });
476}
477
478fn auth_code_to_firebase_token(
479 mut auth_code_event_reader: EventReader<AuthCodeEvent>,
480 runtime: ResMut<TokioTasksRuntime>,
481 port: Res<RedirectPort>,
482 api_key: Res<ApiKey>,
483 emulator: Option<Res<AuthEmulatorUrl>>,
484 login_keys: Res<LoginKeys>,
485) {
486 let root_url = match emulator {
487 Some(url) => format!("{}/identitytoolkit.googleapis.com", url.0.clone()),
488 None => "https://identitytoolkit.googleapis.com".into(),
489 };
490
491 for auth_code_event in auth_code_event_reader.iter() {
492 let (provider, auth_code) = auth_code_event.0.clone();
493
494 if let Some(keys) = login_keys.0.get(&provider) {
495 if let Some((client_id, client_secret)) = keys {
496 let api_key = api_key.0.clone();
497 let port = format!("{}", port.0);
498 let auth_code = auth_code.clone();
499 let root_url = root_url.clone();
500 let client_secret = client_secret.clone();
501 let client_id = client_id.clone();
502 let provider = provider.clone();
503
504 runtime.spawn_background_task(|mut ctx| async move {
505 let client = reqwest::Client::new();
506 let mut body: HashMap<String, Value> = HashMap::new();
507
508 match provider.clone() {
509 LoginProvider::Google => {
510 let form = reqwest::multipart::Form::new()
511 .text("code", auth_code)
512 .text("client_id", client_id)
513 .text("client_secret", client_secret)
514 .text("redirect_uri", format!("http://127.0.0.1:{port}"))
515 .text("grant_type", "authorization_code");
516
517 #[derive(Deserialize, Debug)]
518 struct GoogleTokenResponse {
519 id_token: String,
520 }
521
522 let google_token = client
524 .post("https://www.googleapis.com/oauth2/v3/token")
525 .multipart(form)
526 .send()
527 .await
528 .unwrap()
529 .json::<GoogleTokenResponse>()
530 .await
531 .unwrap();
532
533 let id_token = google_token.id_token;
534
535 body.insert(
536 "postBody".into(),
537 Value::String(format!(
538 "id_token={}&providerId={}",
539 id_token, "google.com"
540 )),
541 );
542 }
543 LoginProvider::Github => {
544 #[derive(Deserialize, Debug)]
547 struct GithubTokenResponse {
548 access_token: String
549 }
550
551 let response = client.post(format!("https://github.com/login/oauth/access_token?client_id={}&client_secret={}&code={}",client_id,client_secret,auth_code))
552 .header("Accept", "application/json")
553 .send()
554 .await
555 .unwrap()
556 .json::<GithubTokenResponse>()
557 .await
558 .unwrap();
559
560 let access_token = response.access_token;
561
562 body.insert(
563 "postBody".into(),
564 Value::String(format!(
565 "access_token={}&providerId={}",
566 access_token, "github.com"
567 ))
568 );
569 }
570 _ => (),
571 }
572
573 body.insert(
575 "requestUri".into(),
576 Value::String(format!("http://127.0.0.1:{port}")),
577 );
578 body.insert("returnIdpCredential".into(), true.into());
579 body.insert("returnSecureToken".into(), true.into());
580
581 let firebase_token = client
583 .post(format!(
584 "{}/v1/accounts:signInWithIdp?key={}",
585 root_url, api_key
586 ))
587 .json(&body)
588 .send()
589 .await
590 .unwrap()
591 .json::<TokenData>()
592 .await
593 .unwrap();
594
595 ctx.run_on_main_thread(move |ctx| {
596 ctx.world.insert_resource(firebase_token);
597
598 ctx.world
600 .insert_resource(NextState(Some(AuthState::LoggedIn)));
601 })
602 .await;
603 });
604 }
605 }
606 }
607}
608
609fn save_refresh_token(token_data: Res<TokenData>, remember_login: Res<RememberLoginFlag>) {
610 if !remember_login.0 {
611 return;
612 }
613
614 let path = cache_dir()
615 .unwrap()
616 .join(std::env::var("CARGO_PKG_NAME").unwrap())
617 .join("login");
618
619 let dir_result = create_dir_all(path.clone());
620
621 match dir_result {
622 Ok(()) => {}
623 Err(err) => println!("Couldn't create login directory: {:?}", err),
624 }
625
626 let save_result = write(
627 path.clone().join("firebase-refresh.key"),
628 token_data.refresh_token.as_str(),
629 );
630
631 match save_result {
632 Ok(()) => {}
633 Err(err) => println!("Couldn't save refresh token to {:?}: {:?}", path, err),
634 }
635}
636
637fn refresh_login(
638 token_data: Res<TokenData>,
639 firebase_api_key: Res<ApiKey>,
640 runtime: ResMut<TokioTasksRuntime>,
641 emulator: Option<Res<AuthEmulatorUrl>>,
642) {
643 let refresh_token = token_data.refresh_token.clone();
644 let api_key = firebase_api_key.0.clone();
645 let root_url = match emulator {
646 Some(url) => format!("{}/securetoken.googleapis.com", url.0),
647 None => "https://securetoken.googleapis.com".into(),
648 };
649
650 runtime.spawn_background_task(|mut ctx| async move {
651 let client = Client::new();
652
653 let firebase_token = client
654 .post(format!("{}/v1/token?key={}", root_url, api_key))
655 .header("content-type", "application/x-www-form-urlencoded")
656 .body(format!(
657 "grant_type=refresh_token&refresh_token={}",
658 refresh_token
659 ))
660 .send()
661 .await
662 .unwrap()
663 .json::<TokenData>()
664 .await;
665
666 let firebase_token = match firebase_token {
667 Ok(token) => token,
668 Err(_) => {
669 ctx.run_on_main_thread(|ctx| {
671 ctx.world.insert_resource(NextState(Some(AuthState::LogIn)))
672 })
673 .await;
674 return;
675 }
676 };
677
678 ctx.run_on_main_thread(move |ctx| {
680 ctx.world.insert_resource(firebase_token);
681
682 ctx.world
684 .insert_resource(NextState(Some(AuthState::LoggedIn)));
685 })
686 .await;
687 });
688}
689
690pub fn delete_account(
710 token_data: Res<TokenData>,
711 firebase_api_key: Res<ApiKey>,
712 runtime: ResMut<TokioTasksRuntime>,
713 emulator: Option<Res<AuthEmulatorUrl>>,
714) {
715 let api_key = firebase_api_key.0.clone();
716 let id_token = token_data.id_token.clone();
717 let root_url = match emulator {
718 Some(url) => format!("{}/identitytoolkit.googleapis.com", url.0),
719 None => "https://identitytoolkit.googleapis.com".into(),
720 };
721 runtime.spawn_background_task(|mut ctx| async move {
722 let client = Client::new();
723 let mut body = HashMap::new();
724 body.insert("idToken", id_token);
725
726 let _res = client
727 .post(format!("{}/v1/accounts:delete?key={}", root_url, api_key))
728 .header("content-type", "application/json")
729 .json(&body)
730 .send()
731 .await
732 .unwrap()
733 .text()
734 .await;
735
736 ctx.run_on_main_thread(move |ctx| {
739 ctx.world
741 .insert_resource(NextState(Some(AuthState::LogOut)));
742 })
743 .await;
744 });
745}