From 3922fb70eb7096cb1e4e099bb9faa7d57d8b44f0 Mon Sep 17 00:00:00 2001 From: Marcus Pettersen Irgens Date: Fri, 4 Aug 2023 13:27:11 +0200 Subject: [PATCH 1/2] Allow storing, extracting installation tokens --- CHANGELOG.md | 7 + src/lib.rs | 180 ++++++++++++++++----- tests/refetches_installation_token_test.rs | 117 ++++++++++++++ tests/resources/sample_app.key | 28 ++++ 4 files changed, 292 insertions(+), 40 deletions(-) create mode 100644 tests/refetches_installation_token_test.rs create mode 100644 tests/resources/sample_app.key diff --git a/CHANGELOG.md b/CHANGELOG.md index 5683d491..f80a1e25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Allow extract the installation token from the client. +- Allow building clients with installation tokens. + +### Changed +- `octocrab::Octocrab::installation` now returns a builder type. + ## [0.29.1](https://github.com/XAMPPRocky/octocrab/compare/v0.29.0...v0.29.1) - 2023-07-31 ### Other diff --git a/src/lib.rs b/src/lib.rs index 6e157e00..b0a12213 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -188,6 +188,7 @@ pub mod etag; pub mod models; pub mod params; pub mod service; + use crate::service::body::BodyStreamExt; use http::{HeaderMap, HeaderValue, Method, Uri}; @@ -371,6 +372,7 @@ pub struct NoSvc {} //Indicates weather builder supports with_layer(This is somewhat redundant given NoSvc exists, but we have to use this until specialization is stable) pub struct NotLayerReady {} + pub struct LayerReady {} //Indicates weather the builder supports auth @@ -761,33 +763,73 @@ impl DefaultOctocrabBuilderConfig { pub type DynBody = dyn http_body::Body + Send + Unpin; /// A cached API access token (which may be None) -pub struct CachedToken(RwLock>); +pub struct CachedToken(RwLock>); + +type SecretInstallationToken = secrecy::Secret; + +/// Wrapper for [InstallationToken] that provideds implementations required to +/// hold secret data. +/// +/// See [secrecy::ClonableSecret] and [secrecy::DebugSecret] for more information. +#[derive(Clone, Debug)] +struct ActiveInstallationToken(InstallationToken); + +// This implementation of Zeroize only zeroizes the token itself. +impl secrecy::Zeroize for ActiveInstallationToken { + fn zeroize(&mut self) { + self.0.token.zeroize() + } +} + +impl secrecy::CloneableSecret for ActiveInstallationToken {} + +impl secrecy::DebugSecret for ActiveInstallationToken {} impl CachedToken { + fn new(token: InstallationToken) -> Self { + Self(RwLock::new(Some(SecretInstallationToken::new( + ActiveInstallationToken(token), + )))) + } fn clear(&self) { *self.0.write().unwrap() = None; } - fn get(&self) -> Option { - self.0.read().unwrap().clone() + fn get(&self) -> Option { + self.0 + .read() + .unwrap() + .as_ref() + .filter(|token| filter_expired_token(token)) + .map(|secret| secret.expose_secret().0.clone()) } - fn set(&self, value: String) { - *self.0.write().unwrap() = Some(SecretString::new(value)); + fn set(&self, value: InstallationToken) { + *self.0.write().unwrap() = + Some(SecretInstallationToken::new(ActiveInstallationToken(value))); } } -impl fmt::Debug for CachedToken { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.read().unwrap().fmt(f) - } +/// Used with [CachedToken::get] to filter out tokens before they expire. +fn filter_expired_token(token: &SecretInstallationToken) -> bool { + let Some(expires_at) = token.expose_secret().0.expires_at.as_deref() else { + return true; + }; + let expires_at = match chrono::DateTime::parse_from_rfc3339(expires_at) { + Err(err) => { + #[cfg(feature = "tracing")] + { + tracing::info!(error = ?err, "Failed to parse installation access token's expiration as an RFC3339 timestamp"); + } + return true; + } + Ok(time) => time, + }; + + expires_at > (chrono::Utc::now() + chrono::Duration::minutes(1)) } -impl fmt::Display for CachedToken { +impl fmt::Debug for CachedToken { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let option = self.0.read().unwrap(); - option - .as_ref() - .map(|s| s.expose_secret().fmt(f)) - .unwrap_or_else(|| write!(f, "")) + self.0.read().unwrap().fmt(f) } } @@ -891,40 +933,97 @@ impl Octocrab { /// Returns a new `Octocrab` based on the current builder but /// authorizing via a specific installation ID. + /// /// Typically you will first construct an `Octocrab` using /// `OctocrabBuilder::app` to authenticate as your Github App, /// then obtain an installation ID, and then pass that here to /// obtain a new `Octocrab` with which you can make API calls /// with the permissions of that installation. - pub fn installation(&self, id: InstallationId) -> Octocrab { + /// + /// ## Constructing an installation client using a known token + /// + /// You can construct an installation client with a known token. + /// If the token has expired, the client will automatically + /// fetch a new one. + /// + /// ```rust + /// # use octocrab::models::{InstallationId, InstallationToken}; + /// # + /// # async fn to_octocrab_installation_client(app_client: octocrab::Octocrab, my_known_token: InstallationToken) -> octocrab::Result<()> { + /// // Create an installation client using the app client + /// let installation_client = app_client + /// .installation(123.into()) + /// .with_token(my_known_token) + /// .build(); + /// + /// // Do something with the client + /// let repo = installation_client.repos("foo", "bar").get().await?; + /// eprintln!("Found {name}", name = repo.name); + /// + /// // Extract the token the client is using, either the same one as previously, + /// // or a new token that was generated when fetching repository data. + /// let my_known_token = installation_client.installation_token(false).await?; + /// # } + /// ``` + pub fn installation(&self, id: InstallationId) -> InstallationClientBuilder { let app_auth = if let AuthState::App(ref app_auth) = self.auth_state { app_auth.clone() } else { panic!("Github App authorization is required to target an installation"); }; + + InstallationClientBuilder { + id, + token: Default::default(), + app_auth, + crab: self, + } + } + + /// Emit an installation token authenticating the client. + /// + /// If there is no token, it has expired, or if `force_refetch` is set, a + /// new token will be fetched. + /// + /// See also https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#http-based-git-access-by-an-installation + pub async fn installation_token(&self, force_refetch: bool) -> Result { + let AuthState::Installation { token: cached_token ,.. } = &self.auth_state else { + panic!("Not authorized as an installation"); + }; + + Ok(match cached_token.get() { + Some(token) if !force_refetch => token, + _ => self.request_installation_auth_token().await?, + }) + } +} + +pub struct InstallationClientBuilder<'octo> { + id: InstallationId, + token: CachedToken, + app_auth: AppAuth, + crab: &'octo Octocrab, +} + +impl<'octo> InstallationClientBuilder<'octo> { + pub fn build(self) -> Octocrab { Octocrab { - client: self.client.clone(), + client: self.crab.client.clone(), auth_state: AuthState::Installation { - app: app_auth, - installation: id, - token: CachedToken::default(), + app: self.app_auth, + installation: self.id, + token: self.token, }, } } - /// Similar to `installation`, but also eagerly caches the installation - /// token and returns the token. The returned token can be used to make - /// https git requests to e.g. clone repositories that the installation - /// has access to. + /// Set the installation token to use for the client. /// - /// See also https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#http-based-git-access-by-an-installation - pub async fn installation_and_token( - &self, - id: InstallationId, - ) -> Result<(Octocrab, SecretString)> { - let crab = self.installation(id); - let token = crab.request_installation_auth_token().await?; - Ok((crab, token)) + /// When the token expires, or if the token has already expired, the client + /// will automatically re-fetch a new installation token. + pub fn with_token(mut self, token: InstallationToken) -> Self { + self.token = CachedToken::new(token); + self } } @@ -1315,7 +1414,7 @@ impl Octocrab { } /// Requests a fresh installation auth token and caches it. Returns the token. - async fn request_installation_auth_token(&self) -> Result { + async fn request_installation_auth_token(&self) -> Result { let (app, installation, token) = if let AuthState::Installation { ref app, installation, @@ -1348,9 +1447,9 @@ impl Octocrab { let _status = response.status(); let token_object = - InstallationToken::from_response(crate::map_github_error(response).await?).await?; - token.set(token_object.token.clone()); - Ok(SecretString::new(token_object.token)) + InstallationToken::from_response(map_github_error(response).await?).await?; + token.set(token_object.clone()); + Ok(token_object) } /// Send the given request to the underlying service @@ -1407,14 +1506,15 @@ impl Octocrab { Some(HeaderValue::from_bytes(&buf).expect("base64 is always valid HeaderValue")) } AuthState::Installation { ref token, .. } => { - let token = if let Some(token) = token.get() { - token + // Get the raw token value from any contained token, or fetch a new one. + let installation_token = if let Some(token) = token.get() { + token.token.clone() } else { - self.request_installation_auth_token().await? + self.request_installation_auth_token().await?.token.clone() }; Some( - HeaderValue::from_str(format!("Bearer {}", token.expose_secret()).as_str()) + HeaderValue::from_str(format!("Bearer {}", installation_token).as_str()) .map_err(http::Error::from) .context(HttpSnafu)?, ) diff --git a/tests/refetches_installation_token_test.rs b/tests/refetches_installation_token_test.rs new file mode 100644 index 00000000..9ca07114 --- /dev/null +++ b/tests/refetches_installation_token_test.rs @@ -0,0 +1,117 @@ +//! Checks that the client tries to re-fetch an installation token if the contained token has +//! expired. +mod mock_error; + +use mock_error::setup_error_handler; +use octocrab::models::{AppId, InstallationId, InstallationToken}; +use octocrab::Octocrab; +use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, +}; + +async fn setup_api( + token_template: ResponseTemplate, + secret_template: ResponseTemplate, +) -> MockServer { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/app/installations/123/access_tokens")) + .respond_with(token_template) + .expect(1) + .mount(&mock_server) + .await; + + Mock::given(method("GET")) + .and(path("/repos/foo/bar/actions/secrets/GH_TOKEN")) + .and(wiremock::matchers::header( + "Authorization", + "Bearer NEW_TOKEN", + )) + .respond_with(secret_template) + .expect(1) + .mount(&mock_server) + .await; + + setup_error_handler( + &mock_server, + "POST on /app/installations/123/access_tokens was not received", + ) + .await; + mock_server +} + +fn setup_octocrab(uri: &str) -> Octocrab { + let client = Octocrab::builder() + .base_uri(uri) + .unwrap() + .app( + AppId(456), + jsonwebtoken::EncodingKey::from_rsa_pem(include_bytes!("resources/sample_app.key")) + .unwrap(), + ) + .build() + .unwrap(); + + // Set an expired installation token on this app client + client + .installation(InstallationId(123)) + .with_token(gen_installation_access_token( + "EXPIRED_TOKEN", + chrono::Utc::now() - chrono::Duration::minutes(1), + )) + .build() +} + +#[tokio::test] +async fn will_refetch_installation_token() { + let new_token_response = ResponseTemplate::new(200).set_body_json( + // New token that expires in the future. + gen_installation_access_token("NEW_TOKEN", chrono::Utc::now() + chrono::Duration::hours(1)), + ); + + // Some other response to return. + let other_endpoint_response = ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": "GH_TOKEN", + "created_at": "2019-08-10T14:59:22Z", + "updated_at": "2019-08-10T14:59:22Z", + })); + + let mock_server = setup_api(new_token_response, other_endpoint_response).await; + let client = setup_octocrab(&mock_server.uri()); + + let result = client + .repos("foo", "bar") + .secrets() + .get_secret("GH_TOKEN") + .await; + + assert!( + result.is_ok(), + "expected successful result, got error: {:#?}", + result + ); +} + +// Create a sample access token for an installation, +fn gen_installation_access_token( + token: &str, + expiration: chrono::DateTime, +) -> InstallationToken { + // Constructing this from JSON because it's a non-exhaustive struct type. + serde_json::from_value(serde_json::json!({ + "token": token, + "expires_at": expiration.to_rfc3339(), + "permissions": { + "actions": "read", + "checks": "write", + "contents": "read", + "issues": "write", + "metadata": "read", + "single_file": "write", + "statuses": "write", + }, + })) + .unwrap() +} diff --git a/tests/resources/sample_app.key b/tests/resources/sample_app.key new file mode 100644 index 00000000..f469e299 --- /dev/null +++ b/tests/resources/sample_app.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCOMVrSWFkD64Tt +eC2ao7ERPWyiZEWzG2B9PH9+dwLFCYklChdeh6qOElKi0ylOVK+PUxvhyIPXnCp2 +8GuoIKHoHMcx6WnGfFxO9vYWCR054bqHnNW4fmxV9/hcNa3U9yzYXmLheemZhD8L +pUgD/VUAGKF3KQAQVsO3CWwQlIsvrrVR4WbEV8t8d01YCU+NflrAzHPdVovLuyXK +QgskvVXuntXs/0VwmcW71siU0NoIEkW6TwLmfZc2GIe3JMO2awSlsthznMP+31D9 +SlzR8LmEIRooL8sXx/qMxzNL0Po8ZE6yzFBykoFpQJGiQm2CcPgCNRySp0qDyOH3 +jWLguPdrAgMBAAECggEADVSTLyUZM0ThMWIS5Gx0LSmVBaRg5GmsohEJ4tFFcTNz +qAVKK5KMasVM+fbR6IYH72fbNU+XDJ+XW48uiJSGmTwZcJRxVipSfRSp/WbdVo/S +7OPHJYS0o1qb7gkaQtzpV1+B5aGIRNwhDPZxye32CgxFFubBGscHkiFQAD3szotu +pF/pnH0fapSJu0+JPS5tWNKp/w3vSRTYBOPIy8W/8na+PL91A/whq4GxoBsPVWjZ +u1g3JjYmSNV4s6ZFAMot6eowfIOJ8vnkJX2F6NP9sFvDinFBNxnbU4EEkaZFBn9F +mNArxZG8rjwIM/gm3flA6XMGwo6oEckVOZ2yLbhAAQKBgQDH3QDQ3x672PSFgyhc +JSdanwHafKbS5jd2L0mBBV58JtwcP8/DlXlYIHFTwoTAfFcAy20XoX7viaMDUEoF +QGa2hCe09JtHWvnbmM40jgrOi7cyeAAoo91itKio0MPF6JIvIgw+HgIkE6ljkzzH +jWAZZHVVk4Cn0ipNphBjH4TihQKBgQC2IZqrYPE7UViZ1n4YNgU6fx/Qo7B1RVej +3c/4JBkHcCYHh/LEYRQxSew+FG903A6jbM0mAHDNDKLJWuW3gA0GoIoJ4iviXmcp +axUEACb6ygUTbfx7cB8onvA0XpmXy6RJOoKMF1Waqzo+uayHs7o/1kBvbkIxV5JJ +igiB8hotLwKBgH1vI7LzRUupxxUAEtV57+/8+WHRd7XHDKnc1anm37zVerE0D0X1 +yrlXayihXUWdA9GY2nfJQGw/mpJa0onnOE92M6FrHwUygLukdE4hk4C7yRcgnyDi +bvAi5/NDSossAosYOEzH8poHyPiYkL3A6b4mAUnbEBTDXw9qmMBNKM4tAoGBAKLJ +/GXr2xHqzmeKOOBJAHldgMFKXYEj/oZ/zs/668gjLEqU758pKhQ3/4kpWMm5mvfl +WqP5xtjvz1xr+2D9eicPPPJCjnjhahGyHXGa9Tw5bzoDl6V4/NCg5w/X8i6kHO46 +9s5iWOhK3V+NM7GDKhi+1o8CnPVfUWibkKkdDNqPAoGAV4Ievn8lOeHZO4yQNk77 +Tzm2mFlZWy3reAldLtxpprcbkDUNdqI8xb8SzswUh8TfJX7UWKeTTrSfy4udGOnJ +E4Ob51ye2wbsVjbxMdDXt6VGbZmG/+ijgWtpnfiwmR4lD6+wxEbmY7zUAHLXWGow +isaL7GvOh0xlnJ0+utCfFp0= +-----END PRIVATE KEY----- From 810eddebf8dfad3b490771b8d41a6495088ce523 Mon Sep 17 00:00:00 2001 From: Marcus Pettersen Irgens Date: Fri, 4 Aug 2023 14:15:19 +0200 Subject: [PATCH 2/2] Fix failing doctest --- src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib.rs b/src/lib.rs index b0a12213..ba34f2ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -963,6 +963,7 @@ impl Octocrab { /// // Extract the token the client is using, either the same one as previously, /// // or a new token that was generated when fetching repository data. /// let my_known_token = installation_client.installation_token(false).await?; + /// # Ok(()) /// # } /// ``` pub fn installation(&self, id: InstallationId) -> InstallationClientBuilder {