diff --git a/sshn-lib/.gitignore b/sshn-lib/.gitignore new file mode 100644 index 0000000..4cb512e --- /dev/null +++ b/sshn-lib/.gitignore @@ -0,0 +1 @@ +/.env \ No newline at end of file diff --git a/sshn-lib/Cargo.toml b/sshn-lib/Cargo.toml index d1bc1d1..4847a66 100644 --- a/sshn-lib/Cargo.toml +++ b/sshn-lib/Cargo.toml @@ -8,16 +8,12 @@ edition = "2021" [dependencies] chrono = "0.4.38" graphql_client = "0.14.0" -reqwest = { version = "0.12.3", default-features = false, features = [ - "rustls-tls", - "json", - "charset", - "http2", - "macos-system-configuration", -] } -serde = "1.0.198" +reqwest = { version = "0.12.3", features = ["json"] } + +serde = { version = "1.0.198", features = ["derive"] } serde_urlencoded = "0.7.1" thiserror = "1.0.58" [dev-dependencies] +dotenv = "0.15.0" tokio = { version = "1.37.0", features = ["full"] } diff --git a/sshn-lib/queries.graphql b/sshn-lib/queries.graphql index bf76556..5de2f7b 100644 --- a/sshn-lib/queries.graphql +++ b/sshn-lib/queries.graphql @@ -76,3 +76,12 @@ mutation PostApplication($publicationId: ID!, $locale: String) { } } } + +query GetIdentityConfig($realm: String!) { + identityConfig(realm: $realm) { + self + authorization_endpoint + token_endpoint + portalClientId + } +} diff --git a/sshn-lib/src/client.rs b/sshn-lib/src/client.rs index a22f16f..5cac5d1 100644 --- a/sshn-lib/src/client.rs +++ b/sshn-lib/src/client.rs @@ -2,48 +2,60 @@ use std::collections::HashMap; use chrono::{Duration, Utc}; use graphql_client::GraphQLQuery; +use serde::{de::DeserializeOwned, Serialize}; use crate::{ - constants::{AUTH_URL, GRAPHQL_URL, LOCALE}, + constants::{CLIENT_ID, GRAPHQL_URL, LOCALE, TOKEN_URL}, error::{Error, Result}, queries::{ - get_publications_list, + get_identity_config, get_publications_list, post_application::{self, HousingApplyState}, - GetPublicationsList, GraphqlResponse, PostApplication, + GetIdentityConfig, GetPublicationsList, GraphqlResponse, PostApplication, }, tokens::{RefreshTokenResponse, Token}, }; pub struct Client { - base_url: String, - access_token: Token, + graphql_url: String, http_client: reqwest::Client, } +pub enum LoginType { + AuthCode(String), + Password { username: String, password: String }, +} + impl Client { - pub fn new>(base_url: U, access_token: Token) -> Self { + pub fn new(graphql_url: Option) -> Self { Self { + graphql_url: graphql_url.unwrap_or(GRAPHQL_URL.to_string()), http_client: reqwest::Client::new(), - base_url: base_url.into(), - access_token, } } - pub async fn login_with_refresh_token>( - refresh_token: R, - ) -> Result<(Client, Token)> { - let client = reqwest::Client::new(); - + pub async fn login(self, login_type: LoginType) -> Result { let mut params = HashMap::new(); - params.insert("grant_type", "refresh_token"); - params.insert("refresh_token", refresh_token.as_ref()); - params.insert("client_id", "portal-legacy"); + params.insert("client_id", CLIENT_ID); + + match &login_type { + LoginType::AuthCode(code) => { + params.insert("grant_type", "authorization_code"); + params.insert("authorization_code", code); + } + LoginType::Password { username, password } => { + params.insert("grant_type", "password"); + + params.insert("username", &username); + params.insert("password", &password); + } + }; let body = serde_urlencoded::to_string(¶ms)?; - let response = client - .post(AUTH_URL) + let response = self + .http_client + .post(TOKEN_URL) .body(body) .header( reqwest::header::CONTENT_TYPE, @@ -52,21 +64,53 @@ impl Client { .send() .await?; - let tokens = response.json::().await?; + match response.error_for_status() { + Ok(response) => { + let tokens = response.json::().await?; + + let access_token = Token::new( + tokens.access_token, + Utc::now() + Duration::seconds(tokens.expires_in), + ); + + let refresh_token = Token::new( + tokens.refresh_token, + Utc::now() + Duration::seconds(tokens.refresh_expires_in), + ); + + let authenticated_client = AuthenticatedClient { + graphql_url: self.graphql_url, + http_client: self.http_client, + token_url: TOKEN_URL.to_string(), + access_token, + refresh_token, + }; - let access_token = Token::new( - tokens.access_token, - Utc::now() + Duration::seconds(tokens.expires_in), - ); + Ok(authenticated_client) + } + Err(err) => Err(Error::HttpRequest(err)), + } + } - let client = Client::new(GRAPHQL_URL, access_token); + pub async fn get_endpoints(&self) -> Result { + let variables = get_identity_config::Variables { + realm: String::from("sshn"), + }; - let new_refresh_token = Token::new( - tokens.refresh_token, - Utc::now() + Duration::seconds(tokens.refresh_expires_in), - ); + let request_body = GetIdentityConfig::build_query(variables); - Ok((client, new_refresh_token)) + let response = self + .http_client + .post(&self.graphql_url) + .json(&request_body) + .send() + .await?; + + let body = response + .json::>() + .await?; + + Ok(body.data) } pub async fn get_publications_list( @@ -85,7 +129,7 @@ impl Client { let response = self .http_client - .post(&self.base_url) + .post(&self.graphql_url) .json(&request_body) .send() .await?; @@ -96,47 +140,104 @@ impl Client { Ok(body.data) } +} - /// Reply to a publication, given that publications id. - pub async fn reply_to_publication>(&self, publication_id: I) -> Result<()> { - let variables = post_application::Variables { - publication_id: publication_id.into(), - locale: Some(String::from(LOCALE)), - }; +pub struct AuthenticatedClient { + graphql_url: String, + token_url: String, + http_client: reqwest::Client, + access_token: Token, + refresh_token: Token, +} - let request_body = PostApplication::build_query(variables); +impl AuthenticatedClient { + async fn refresh_tokens(&mut self) -> Result<()> { + if self.refresh_token.expires() < Utc::now() { + return Err(Error::TokenExpired); + } + + let mut params = HashMap::new(); + + params.insert("client_id", CLIENT_ID); + params.insert("grant_type", "refresh_token"); + params.insert("refresh_token", self.refresh_token.as_ref()); + + let body = serde_urlencoded::to_string(¶ms)?; let response = self .http_client - .post(&self.base_url) + .post(&self.token_url) + .body(body) .header( - reqwest::header::AUTHORIZATION, - format!("Bearer {}", self.access_token.as_ref()), + reqwest::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", ) - .json(&request_body) .send() .await?; - match response.error_for_status() { - Ok(response) => { - let body = response - .json::>() - .await?; - - if let Some(unit) = body.data.housing_apply_to_unit { - match unit.state { - HousingApplyState::OK => {} - _ => { - let error = Error::Api(unit.description.unwrap_or(String::new())); - - return Err(error); - } - }; - }; + let tokens = response.json::().await?; - Ok(()) - } - Err(err) => Err(Error::HttpRequest(err)), + self.access_token = Token::new( + tokens.access_token, + Utc::now() + Duration::seconds(tokens.expires_in), + ); + + self.refresh_token = Token::new( + tokens.refresh_token, + Utc::now() + Duration::seconds(tokens.refresh_expires_in), + ); + + Ok(()) + } + + async fn check_expiration(&mut self) -> Result<()> { + if self.access_token.expires() < Utc::now() { + self.refresh_tokens().await?; } + + Ok(()) + } + + async fn query(&mut self, query: &Q) -> Result { + self.check_expiration().await?; + + let response = self + .http_client + .post(&self.graphql_url) + .bearer_auth(self.access_token.as_ref()) + .json(query) + .send() + .await?; + + let response = response.error_for_status()?; + + let response_body = response.json::>().await?; + + Ok(response_body.data) + } + + /// Reply to a publication, given that publications id. + pub async fn reply_to_publication>(&mut self, publication_id: I) -> Result<()> { + let variables = post_application::Variables { + publication_id: publication_id.into(), + locale: Some(String::from(LOCALE)), + }; + + let request_body = PostApplication::build_query(variables); + + let data: post_application::ResponseData = self.query(&request_body).await?; + + if let Some(unit) = data.housing_apply_to_unit { + match unit.state { + HousingApplyState::OK => {} + _ => { + let error = Error::Api(unit.description.unwrap_or(String::new())); + + return Err(error); + } + }; + }; + + Ok(()) } } diff --git a/sshn-lib/src/constants.rs b/sshn-lib/src/constants.rs index ab07781..488c093 100644 --- a/sshn-lib/src/constants.rs +++ b/sshn-lib/src/constants.rs @@ -1,6 +1,8 @@ -pub const AUTH_URL: &str = - "https://auth.embracecloud.nl/auth/realms/sshn/protocol/openid-connect/token"; - pub const GRAPHQL_URL: &str = "https://gateway.embracecloud.nl/graphql"; +pub const TOKEN_URL: &str = + "https://auth.embracecloud.nl/auth/realms/sshn/protocol/openid-connect/token"; + pub const LOCALE: &str = "nl-NL"; + +pub const CLIENT_ID: &str = "portal"; diff --git a/sshn-lib/src/error.rs b/sshn-lib/src/error.rs index f143de8..fda54c6 100644 --- a/sshn-lib/src/error.rs +++ b/sshn-lib/src/error.rs @@ -8,6 +8,10 @@ pub enum Error { EncodeFormData(#[from] serde_urlencoded::ser::Error), #[error("Error sending HTTP request: {0}")] HttpRequest(#[from] reqwest::Error), + #[error("The refresh token expired")] + TokenExpired, + #[error("The authentication endpoint is missing")] + NoAuthUrl, } pub type Result = result::Result; diff --git a/sshn-lib/src/lib.rs b/sshn-lib/src/lib.rs index ef120a1..1f43278 100644 --- a/sshn-lib/src/lib.rs +++ b/sshn-lib/src/lib.rs @@ -4,38 +4,44 @@ pub mod error; mod queries; mod tokens; -pub use crate::client::Client; +pub use crate::client::{AuthenticatedClient, Client}; #[cfg(test)] mod tests { use chrono::Utc; - use crate::{constants::GRAPHQL_URL, tokens::Token}; + use crate::client::LoginType; use super::*; - #[tokio::test] - async fn test_refresh_token_login() { - let refresh_token = ""; - - let (client, _new_refresh_token) = Client::login_with_refresh_token(refresh_token) - .await - .unwrap(); - } - #[tokio::test] async fn test_get_publications() { - let client = Client::new(GRAPHQL_URL, Token::default()); + let client = Client::new(None); - let data = client.get_publications_list(30).await.unwrap(); + let data = client.get_endpoints().await.unwrap(); println!("{:?}", data); } #[tokio::test] - async fn test_post_application() { - let client = Client::new(GRAPHQL_URL, Token::new("", Utc::now())); + async fn test_login() { + dotenv::dotenv().ok(); - client.reply_to_publication("").await.unwrap(); + let username = std::env::var("SSHN_USERNAME").unwrap(); + let password = std::env::var("SSHN_PASSWORD").unwrap(); + + let client = Client::new(None); + + let _auth_client = client + .login(LoginType::Password { username, password }) + .await + .unwrap(); } + + // #[tokio::test] + // async fn test_post_application() { + // let client = Client::new(None); + + // client.reply_to_publication("").await.unwrap(); + // } } diff --git a/sshn-lib/src/queries.rs b/sshn-lib/src/queries.rs index f8c5399..7dab022 100644 --- a/sshn-lib/src/queries.rs +++ b/sshn-lib/src/queries.rs @@ -21,6 +21,14 @@ pub struct GetPublicationsList; )] pub struct PostApplication; +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "schema.graphql", + query_path = "queries.graphql", + response_derives = "Debug" +)] +pub struct GetIdentityConfig; + #[derive(Deserialize, Debug)] pub struct GraphqlResponse { pub data: T,