Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow storing, extracting installation tokens #436

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
181 changes: 141 additions & 40 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -761,33 +763,73 @@ impl DefaultOctocrabBuilderConfig {
pub type DynBody = dyn http_body::Body<Data = Bytes, Error = BoxError> + Send + Unpin;

/// A cached API access token (which may be None)
pub struct CachedToken(RwLock<Option<SecretString>>);
pub struct CachedToken(RwLock<Option<SecretInstallationToken>>);

type SecretInstallationToken = secrecy::Secret<ActiveInstallationToken>;

/// 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<SecretString> {
self.0.read().unwrap().clone()
fn get(&self) -> Option<InstallationToken> {
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, "<none>"))
self.0.read().unwrap().fmt(f)
}
}

Expand Down Expand Up @@ -891,40 +933,98 @@ 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?;
/// # Ok(())
/// # }
/// ```
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<InstallationToken> {
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
}
}

Expand Down Expand Up @@ -1315,7 +1415,7 @@ impl Octocrab {
}

/// Requests a fresh installation auth token and caches it. Returns the token.
async fn request_installation_auth_token(&self) -> Result<SecretString> {
async fn request_installation_auth_token(&self) -> Result<InstallationToken> {
let (app, installation, token) = if let AuthState::Installation {
ref app,
installation,
Expand Down Expand Up @@ -1348,9 +1448,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
Expand Down Expand Up @@ -1407,14 +1507,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)?,
)
Expand Down
117 changes: 117 additions & 0 deletions tests/refetches_installation_token_test.rs
Original file line number Diff line number Diff line change
@@ -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<chrono::Utc>,
) -> 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()
}
Loading
Loading