From a70727f11638099882708d69ff07c4cd097e211c Mon Sep 17 00:00:00 2001 From: Stephane Eintrazi Date: Tue, 18 Aug 2020 22:57:11 +0200 Subject: [PATCH] feat(session): add create session cookie feature * Add free standing function sessions::create_session_cookie(credentials, id_token, duration) * Add example Signed-off-by: Stephane Eintrazi Signed-off-by: David Graeff --- examples/readme.md | 11 ++++ examples/session_cookie.rs | 41 ++++++++++++++ src/jwt.rs | 58 +++++++++++++++++++- src/sessions.rs | 109 +++++++++++++++++++++++++++++++++++-- 4 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 examples/session_cookie.rs diff --git a/examples/readme.md b/examples/readme.md index c13867e..54c7615 100644 --- a/examples/readme.md +++ b/examples/readme.md @@ -23,6 +23,17 @@ identified by the firebase user id. * Build and run with `cargo run --example firebase_user`. +## Session cookie example + +This example shows how to exchange a ID token, usually given by the firebase web framework on the client side, +into a server-side session cookie. + +Firebase Auth provides server-side session cookie management for traditional websites that rely on session cookies. +This solution has several advantages over client-side short-lived ID tokens, +which may require a redirect mechanism each time to update the session cookie on expiration. + +* Build and run with `cargo run --example session_cookie`. + ## Rocket Protected Route example [Rocket](https://rocket.rs) is a an easy to use web-framework for Rust. diff --git a/examples/session_cookie.rs b/examples/session_cookie.rs new file mode 100644 index 0000000..591e116 --- /dev/null +++ b/examples/session_cookie.rs @@ -0,0 +1,41 @@ +use firestore_db_and_auth::{ + errors::FirebaseError, Credentials, FirebaseAuthBearer, sessions::session_cookie +}; + +use chrono::Duration; + +mod utils; + +fn main() -> Result<(), FirebaseError> { + // Search for a credentials file in the root directory + use std::path::PathBuf; + + let mut credential_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + credential_file.push("firebase-service-account.json"); + let mut cred = Credentials::from_file(credential_file.to_str().unwrap())?; + + // Only download the public keys once, and cache them. + let jwkset = utils::from_cache_file(credential_file.with_file_name("cached_jwks.jwks").as_path(), &cred)?; + cred.add_jwks_public_keys(&jwkset); + cred.verify()?; + + let cookie = session_cookie::create(&cred, user_session.access_token(), Duration::seconds(3600))?; + println!("Created session cookie: {}", cookie); + + Ok(()) +} + +#[test] +fn create_session_cookie_test() -> Result<(), FirebaseError> { + let cred = utils::valid_test_credentials()?; + let user_session = utils::user_session_with_cached_refresh_token(&cred)?; + + assert_eq!(user_session.user_id, utils::TEST_USER_ID); + assert_eq!(user_session.project_id(), cred.project_id); + + use chrono::Duration; + let cookie = session_cookie::create(&cred, user_session.access_token(), Duration::seconds(3600))?; + + assert!(cookie.len() > 0); + Ok(()) +} diff --git a/src/jwt.rs b/src/jwt.rs index 91adf7d..e11f3e8 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -141,8 +141,8 @@ pub(crate) fn create_jwt( user_id: Option, audience: &str, ) -> Result -where - S: AsRef, + where + S: AsRef, { use std::ops::Add; @@ -242,3 +242,57 @@ pub(crate) fn verify_access_token( audience, }) } + +pub mod session_cookie { + use super::*; + use std::ops::Add; + + pub(crate) fn create_jwt_encoded( + credentials: &Credentials, + duration: chrono::Duration, + ) -> Result { + let scope = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/firebase.database", + "https://www.googleapis.com/auth/firebase.messaging", + "https://www.googleapis.com/auth/identitytoolkit", + "https://www.googleapis.com/auth/userinfo.email", + ]; + + const AUDIENCE: &str = "https://accounts.google.com/o/oauth2/token"; + + use biscuit::{ + jws::{Header, RegisteredHeader}, + ClaimsSet, Empty, RegisteredClaims, JWT, + }; + + let header: Header = Header::from(RegisteredHeader { + algorithm: SignatureAlgorithm::RS256, + key_id: Some(credentials.private_key_id.to_owned()), + ..Default::default() + }); + let expected_claims = ClaimsSet:: { + registered: RegisteredClaims { + issuer: Some(credentials.client_email.clone()), + audience: Some(SingleOrMultiple::Single(AUDIENCE.to_string())), + subject: Some(credentials.client_email.clone()), + expiry: Some(biscuit::Timestamp::from(Utc::now().add(duration))), + issued_at: Some(biscuit::Timestamp::from(Utc::now())), + ..Default::default() + }, + private: JwtOAuthPrivateClaims { + scope: Some(scope.join(" ")), + client_id: None, + uid: None, + }, + }; + let jwt = JWT::new_decoded(header, expected_claims); + + let secret = credentials + .keys + .secret + .as_ref() + .ok_or(Error::Generic("No private key added via add_keypair_key!"))?; + Ok(jwt.encode(&secret.deref())?.encoded()?.encode()) + } +} \ No newline at end of file diff --git a/src/sessions.rs b/src/sessions.rs index 1c479c0..564cce2 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -5,8 +5,8 @@ use super::credentials; use super::errors::{extract_google_api_error, FirebaseError}; use super::jwt::{ - create_jwt, is_expired, jwt_update_expiry_if, verify_access_token, AuthClaimsJWT, JWT_AUDIENCE_FIRESTORE, - JWT_AUDIENCE_IDENTITY, + create_jwt, is_expired, jwt_update_expiry_if, verify_access_token, AuthClaimsJWT, + JWT_AUDIENCE_FIRESTORE, JWT_AUDIENCE_IDENTITY, }; use super::FirebaseAuthBearer; @@ -58,7 +58,7 @@ pub mod user { /// Returns the current access token. /// This method will automatically refresh your access token, if it has expired. /// - /// If the refresh failed, this will + /// If the refresh failed, this will return an empty string. fn access_token(&self) -> String { let jwt = self.access_token_.borrow(); let jwt = jwt.as_str(); @@ -258,12 +258,23 @@ pub mod user { }) } - pub fn by_access_token(credentials: &Credentials, firebase_tokenid: &str) -> Result { - let result = verify_access_token(&credentials, firebase_tokenid)?; + /// Create a new firestore user session by a valid access token + /// + /// Remember that such a session cannot renew itself. As soon as the access token expired, + /// no further operations can be issued by this session. + /// + /// No network operation is performed, the access token is only checked for its validity. + /// + /// Arguments: + /// - `credentials` The credentials + /// - `access_token` An access token, sometimes called a firebase id token. + /// + pub fn by_access_token(credentials: &Credentials, access_token: &str) -> Result { + let result = verify_access_token(&credentials, access_token)?; Ok(Session { user_id: result.subject, project_id_: result.audience, - access_token_: RefCell::new(firebase_tokenid.to_owned()), + access_token_: RefCell::new(access_token.to_owned()), refresh_token: None, api_key: credentials.api_key.clone(), client: reqwest::blocking::Client::new(), @@ -273,6 +284,92 @@ pub mod user { } } +pub mod session_cookie { + use super::*; + + pub static GOOGLE_OAUTH2_URL: &str = "https://accounts.google.com/o/oauth2/token"; + + /// See https://cloud.google.com/identity-platform/docs/reference/rest/v1/projects/createSessionCookie + #[inline] + fn identitytoolkit_url(project_id: &str) -> String { + format!("https://identitytoolkit.googleapis.com/v1/projects/{}:createSessionCookie", project_id) + } + + /// See https://cloud.google.com/identity-platform/docs/reference/rest/v1/CreateSessionCookieResponse + #[derive(Debug, Deserialize)] + struct CreateSessionCookieResponseDTO { + #[serde(rename = "sessionCookie")] + session_cookie_jwk: String, + } + + /// https://cloud.google.com/identity-platform/docs/reference/rest/v1/projects/createSessionCookie + #[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] + struct SessionLoginDTO { + /// Required. A valid Identity Platform ID token. + #[serde(rename = "idToken")] + id_token: String, + /// The number of seconds until the session cookie expires. Specify a duration in seconds, between five minutes and fourteen days, inclusively. + #[serde(rename = "validDuration")] + valid_duration: u64, + #[serde(rename = "tenantId")] + #[serde(skip_serializing_if = "Option::is_none")] + tenant_id: Option, + } + + #[derive(Debug, Deserialize)] + struct Oauth2ResponseDTO { + access_token: String, + expires_in: u64, + token_type: String, + } + + /// Firebase Auth provides server-side session cookie management for traditional websites that rely on session cookies. + /// This solution has several advantages over client-side short-lived ID tokens, + /// which may require a redirect mechanism each time to update the session cookie on expiration: + /// + /// * Improved security via JWT-based session tokens that can only be generated using authorized service accounts. + /// * Stateless session cookies that come with all the benefit of using JWTs for authentication. + /// The session cookie has the same claims (including custom claims) as the ID token, making the same permissions checks enforceable on the session cookies. + /// * Ability to create session cookies with custom expiration times ranging from 5 minutes to 2 weeks. + /// * Flexibility to enforce cookie policies based on application requirements: domain, path, secure, httpOnly, etc. + /// * Ability to revoke session cookies when token theft is suspected using the existing refresh token revocation API. + /// * Ability to detect session revocation on major account changes. + /// + /// See https://firebase.google.com/docs/auth/admin/manage-cookies + /// + /// The generated session cookie is a JWT that includes the firebase user id in the "sub" (subject) field. + /// + /// Arguments: + /// - `credentials` The credentials + /// - `id_token` An access token, sometimes called a firebase id token. + /// - `duration` The cookie duration + /// + pub fn create(credentials: &credentials::Credentials, id_token: String, duration: chrono::Duration) -> Result { + // Generate the assertion from the admin credentials + let assertion = crate::jwt::session_cookie::create_jwt_encoded(credentials, duration)?; + + // Request Google Oauth2 to retrieve the access token in order to create a session cookie + let client = reqwest::blocking::Client::new(); + let response_oauth2: Oauth2ResponseDTO = client.post(GOOGLE_OAUTH2_URL) + .form(&[("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), ("assertion", &assertion)]) + .send()? + .json()?; + + // Create a session cookie with the access token previously retrieved + let response_session_cookie_json: CreateSessionCookieResponseDTO = client.post(&identitytoolkit_url(&credentials.project_id)) + .bearer_auth(&response_oauth2.access_token) + .json(&SessionLoginDTO { + id_token, + valid_duration: duration.num_seconds() as u64, + tenant_id: None, + }) + .send()? + .json()?; + + Ok(response_session_cookie_json.session_cookie_jwk) + } +} + /// Find the service account session defined in here pub mod service_account { use super::*;