diff --git a/src/auth.rs b/src/auth.rs index ecb2b215..b030c473 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,7 +1,7 @@ //! Authentication related types and functions. -use crate::models::AppId; use crate::Result; +use crate::{models::AppId, Octocrab}; use either::Either; use jsonwebtoken::{Algorithm, EncodingKey, Header}; use secrecy::{ExposeSecret, SecretString}; @@ -258,6 +258,93 @@ impl DeviceCodes { } } +/// See https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-web-application-flow-to-generate-a-user-access-token +#[derive(serde::Serialize)] +pub struct ExchangeWebFlowCodeBuilder< + 'octo, + 'client_id, + 'code, + 'client_secret, + 'redirect_uri, + 'code_verifier, + 'repository_id, +> { + #[serde(skip)] + crab: &'octo Octocrab, + /// The client ID for your GitHub App. + client_id: &'client_id str, + /// The code you received in the previous step. + code: &'code str, + /// The client secret for your GitHub App. + client_secret: &'client_secret str, + /// The URL in your application where users will be sent after authorization. + #[serde(skip_serializing_if = "Option::is_none")] + redirect_uri: Option<&'redirect_uri str>, + /// For the PKCE challenge. + #[serde(skip_serializing_if = "Option::is_none")] + code_verifier: Option<&'code_verifier str>, + /// The ID of a single repository that the user access token can access. + #[serde(skip_serializing_if = "Option::is_none")] + repository_id: Option<&'repository_id str>, +} + +impl<'octo, 'client_id, 'code, 'client_secret, 'redirect_uri, 'code_verifier, 'repository_id> + ExchangeWebFlowCodeBuilder< + 'octo, + 'client_id, + 'code, + 'client_secret, + 'redirect_uri, + 'code_verifier, + 'repository_id, + > +{ + pub fn new( + crab: &'octo Octocrab, + client_id: &'client_id SecretString, + client_secret: &'client_secret SecretString, + code: &'code str, + ) -> Self { + Self { + crab, + client_id: client_id.expose_secret(), + code, + client_secret: client_secret.expose_secret(), + redirect_uri: None, + code_verifier: None, + repository_id: None, + } + } + + /// Set the `redirect_uri` for exchange web flow code request to be created. + pub fn redirect_uri(mut self, redirect_uri: &'redirect_uri str) -> Self { + self.redirect_uri = Some(redirect_uri); + self + } + + /// Set the `code_verifier` for exchange web flow code request to be created. + pub fn code_verifier(mut self, code_verifier: &'code_verifier str) -> Self { + self.code_verifier = Some(code_verifier); + self + } + + /// Set the `repository_id` for exchange web flow code request to be created. + pub fn repository_id(mut self, repository_id: &'repository_id str) -> Self { + self.repository_id = Some(repository_id); + self + } + + /// Sends the actual request. + /// Exchange a code for a user access token + /// + /// see: https://docs.github.com/en/developers/apps/identifying-and-authorizing-users-for-github-apps + /// + pub async fn send(self) -> crate::Result { + let route = "/login/oauth/access_token"; + self.crab.post(route, Some(&self)).await + } +} + /// See https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#input-parameters #[derive(Serialize)] struct DeviceFlow<'a> { diff --git a/tests/auth_exchange_web_flow_code_test.rs b/tests/auth_exchange_web_flow_code_test.rs new file mode 100644 index 00000000..89d714a4 --- /dev/null +++ b/tests/auth_exchange_web_flow_code_test.rs @@ -0,0 +1,79 @@ +mod mock_error; + +use mock_error::setup_error_handler; +use octocrab::{auth, Octocrab}; +use secrecy::SecretString; +use serde_json::json; +use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, +}; + +async fn setup_post_api(template: ResponseTemplate) -> MockServer { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path(format!("/login/oauth/access_token"))) + .respond_with(template.clone()) + .mount(&mock_server) + .await; + + setup_error_handler( + &mock_server, + &format!("POST on /login/oauth/access_token was not received"), + ) + .await; + mock_server +} + +fn setup_octocrab(uri: &str) -> Octocrab { + Octocrab::builder().base_uri(uri).unwrap().build().unwrap() +} + +const CLIENT_SECRET: &str = "some_secret"; +const CLIENT_ID: &str = "some_client_id"; +const CODE: &str = "a_code"; +const REDIRECT_URI: &str = "https://yourapp/auth/callback-example"; + +#[tokio::test] +async fn should_return_oauth_response() { + let expected_response = json!({ + "access_token":"gho_16C7e42F292c6912E7710c838347Ae178B4a", + "scope":"repo,gist", + "token_type":"bearer" + } + ); + let template = ResponseTemplate::new(201).set_body_json(expected_response); + let mock_server = setup_post_api(template).await; + let client = setup_octocrab(&mock_server.uri()); + + let result = auth::ExchangeWebFlowCodeBuilder::new( + &client, + &SecretString::from(CLIENT_ID), + &SecretString::from(CLIENT_SECRET), + CODE, + ) + .redirect_uri(REDIRECT_URI) + .send() + .await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn should_fail_when_receving_a_server_error() { + let template = ResponseTemplate::new(500); + let mock_server = setup_post_api(template).await; + let client = setup_octocrab(&mock_server.uri()); + + let result = auth::ExchangeWebFlowCodeBuilder::new( + &client, + &SecretString::from(CLIENT_ID), + &SecretString::from(CLIENT_SECRET), + CODE, + ) + .send() + .await; + + assert!(result.is_err()); +}