bevy_firebase_auth/
lib.rs

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// Sign In Methods
22// app id, client id, application id, and twitter's api key are all client_id
23// app secret, client secret, application secret, and twitter's api secret are all client_secret
24#[derive(Clone, Eq, PartialEq, Hash, Debug, Deserialize)]
25pub enum LoginProvider {
26    Google,
27    Github,
28    // NOT YET IMPLEMENTED
29    EmailPassword,
30    Apple,
31    Phone,
32    Anonymous,
33    GooglePlayGames,
34    AppleGameCenter,
35    Facebook,
36    Twitter,
37    Microsoft,
38    Yahoo,
39}
40
41/// e.g.
42/// ```
43/// # use bevy::prelude::*;
44/// # use bevy_firebase_auth::*;
45/// # use std::collections::HashMap;
46/// let mut map: LoginKeysMap = HashMap::new();
47/// map.insert(LoginProvider::Google, Some(("client_id".into(), "client_secret".into())));
48pub 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/// Event that is sent when an Authorization URL is created
56///
57/// # Examples
58///
59/// Consuming the event:
60/// ```
61/// # use bevy::prelude::*;
62/// # use bevy_firebase_auth::*;
63/// fn auth_url_listener(
64///     mut er: EventReader<AuthUrlsEvent>,
65/// ) {
66///     for e in er.iter() {
67///         for (provider, auth_url) in e.0.iter() {
68///             let mut provider_name = "";
69///             let mut display_url = "";
70///             match provider {
71///                 LoginProvider::Google => {
72///                     provider_name = "Google";
73///                     display_url = auth_url.as_str();
74///                 }
75///                 LoginProvider::Github => {
76///                     provider_name = "Github";
77///                     display_url = auth_url.as_str();
78///                 }
79///                 _ => (),
80///             }
81///
82///             println!(
83///                 "Go to this URL to sign in with {}:\n{}\n",
84///                 provider_name, display_url
85///             );
86///         }
87///     }
88/// }
89#[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// From plugin
102/// Bevy `Resource` containing the app's Firebase API key
103#[derive(Resource)]
104pub struct ApiKey(String);
105
106// From plugin
107/// Bevy `Resource` containing the app's Firebase Project ID
108#[derive(Resource)]
109pub struct ProjectId(pub String);
110
111#[derive(Resource)]
112pub struct RememberLoginFlag(pub bool);
113
114// TODO trim this down?
115/// Holds data from a user access token
116#[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// Retrieved
155#[derive(Resource)]
156struct GoogleToken(String);
157
158// Generated
159#[derive(Resource)]
160struct RedirectPort(u16);
161
162/// The status of the held access token
163#[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
174/// The Firebase Auth bevy plugin
175///
176/// # Examples
177///
178/// Usage:
179/// ```
180/// # use bevy::prelude::*;
181/// # use bevy_firebase_auth::*;
182/// # let mut app = App::new();
183/// app.add_plugins(bevy_firebase_auth::AuthPlugin {
184///     firebase_project_id: "YOUR-PROJECT-ID".into(),
185///     ..Default::default()
186/// });
187/// ```
188pub struct AuthPlugin {
189    pub firebase_api_key: String,
190    pub firebase_project_id: String,
191    pub login_keys: LoginKeysMap,
192    /// "http://127.0.0.1:9099"
193    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        // check for existing token
245
246        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
271// designed to be called on user managed state change
272// but ofc can be passed params
273/// Function to log in
274///
275/// Designed to be called on a user managed state change.
276///
277/// # Examples
278///
279/// ```
280/// # use bevy::prelude::*;
281/// # use bevy_firebase_auth::*;
282/// # let mut app = App::new();
283/// #[derive(Default, States, Debug, Clone, Eq, PartialEq, Hash)]
284/// enum AppAuthState {
285///     #[default]
286///     LogIn,
287///     LogOut,
288///     Delete
289/// };
290/// app.add_state::<AppAuthState>()
291/// .add_systems(OnEnter(AppAuthState::LogIn), log_in);
292pub 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
308/// Function to log out
309///
310/// Designed to be called on a user managed state change.
311///
312/// # Examples
313///
314/// ```
315/// # use bevy::prelude::*;
316/// # use bevy_firebase_auth::*;
317/// # let mut app = App::new();
318/// #[derive(Default, States, Debug, Clone, Eq, PartialEq, Hash)]
319/// enum AppAuthState {
320///     #[default]
321///     LogIn,
322///     LogOut,
323///     Delete
324/// };
325/// app.add_state::<AppAuthState>()
326/// .add_systems(OnEnter(AppAuthState::LogOut), log_out);
327/// ```
328pub 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    // sets up redirect server
364    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(); // first line of stream is like GET /?code=blahBlBlAh&otherStuff=1 HTTP/1.1
410
411                        let redirect_url = request_line.split_whitespace().nth(1).unwrap(); // gets second part of first line of stream, so the path & params
412                        let url = Url::parse(&("http://localhost".to_string() + redirect_url)); // reconstructs a valid URL
413
414                        let url = url.unwrap().to_owned();
415
416                        // gets the `code` param from reconstructed url
417                        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                                // Grab provider flag resource from world
426                                let selected_provider =
427                                    ctx.world.get_resource::<SelectedProvider>();
428
429                                if let Some(selected_provider) = selected_provider {
430                                    // Match on provider flag
431                                    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                    // message in browser
456                    // TODO allow user styling etc.
457                    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                        // Get Google Token
523                        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                        // TODO no github on emulator
545
546                        #[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                // Add common params
574                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                // Get Firebase Token
582                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                    // Set next state
599                    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                // Set state to logout on failure
670                ctx.run_on_main_thread(|ctx| {
671                    ctx.world.insert_resource(NextState(Some(AuthState::LogIn)))
672                })
673                .await;
674                return;
675            }
676        };
677
678        // Use Firebase Token
679        ctx.run_on_main_thread(move |ctx| {
680            ctx.world.insert_resource(firebase_token);
681
682            // Set next state
683            ctx.world
684                .insert_resource(NextState(Some(AuthState::LoggedIn)));
685        })
686        .await;
687    });
688}
689
690/// Function to delete an account from Firebase
691///
692/// To be triggered with on state change
693///
694/// # Examples
695///
696/// Usage:
697/// ```
698/// # use bevy::prelude::*;
699/// # use bevy_firebase_auth::*;
700/// # let mut app = App::new();
701/// #[derive(Default, States, Debug, Clone, Eq, PartialEq, Hash)]
702/// enum AppAuthState {
703///     #[default]
704///     LogIn,
705///     LogOut,
706///     Delete
707/// };
708/// app.add_systems(OnEnter(AppAuthState::Delete), delete_account);
709pub 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        // TODO handle errors like CREDENTIAL_TOO_OLD
737
738        ctx.run_on_main_thread(move |ctx| {
739            // Set next state
740            ctx.world
741                .insert_resource(NextState(Some(AuthState::LogOut)));
742        })
743        .await;
744    });
745}