Skip to content

Commit

Permalink
🚧 wip(client/user): added oauth2 example, working on user client
Browse files Browse the repository at this point in the history
Signed-off-by: xtrm <oss@xtrm.me>
  • Loading branch information
xtrm-en committed Jan 31, 2024
1 parent 54b3a52 commit 6d6fd9d
Show file tree
Hide file tree
Showing 9 changed files with 1,113 additions and 50 deletions.
819 changes: 813 additions & 6 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,13 @@ rust-version = "1.56"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
reqwest = "0.11"
reqwest = { version = "0.11", features = ["json"] }
thiserror = "1.0"
chrono = "0.4.33"
urlencoding = "2.1.3"

[dev-dependencies]
once_cell = "1.19.0"
render = "0.3"
rocket = { version = "0.5.0", features = ["serde_json"] }
tokio = { version = "1.13", features = ["full"] }
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,18 @@
# ft-rs
> *Because of course it starts with `ft`*
A practical wrapper for the [42 API](https://api.intra.42.fr), written in Rust.

> [!WARNING]
> This is WIP and early in development, expect errors, dumb code, and breaking changes every 5 minutes.
> I'm currently in my testing phase, this isn't even *test ready*, so don't think about *production* yet...
## Todolist

- [ ] Proper filtering options
- [ ] Oauth2 handholding?
- [ ] Better project structure

## License

This project is released under the [BSD Zero Clause](./LICENSE), go wild.
78 changes: 78 additions & 0 deletions examples/oauth2.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#![allow(unused_braces)]

use std::time::Duration;

use ft_rs::{models::token::AccessToken, FtClient};
use once_cell::sync::Lazy;
use render::html;
use rocket::{get, http::{Cookie, CookieJar}, response::{content::RawHtml, Redirect}, routes, time::OffsetDateTime, uri};

const CALLBACK_URL: &str = "http://localhost:1337/oauth/callback";
const SCOPES: &[&str] = &["public", "projects", "profile"];
static CLIENT: Lazy<FtClient> = Lazy::new(|| {
FtClient::from_app(
std::env::var("FT_RS_TEST_UID").expect("FT_RS_TEST_UID not set"),
std::env::var("FT_RS_TEST_SECRET").expect("FT_RS_TEST_SECRET not set")
).expect("Couldn't build the client")
});


#[derive(rocket::response::Responder)]
#[response(status = 303)]
struct CustomRedirect<'a> {
inner: Redirect,
access_token: Cookie<'a>,
}

#[get("/")]
async fn index(cookies: &CookieJar<'_>) -> RawHtml<String> {
if let Some(token) = cookies.get("access_token") {
let token = token.value();
let token = serde_json::from_str::<AccessToken>(token).unwrap();
let user = CLIENT.fetch_user_data(&token).await.unwrap();

RawHtml(html! {
<div>
<h1>{"Hello, world!"}</h1>
<p>{"You are logged in as "}{user.login}{"."}</p>
<img src={&user.image.link} width={"500"} alt={"Profile Picture"} />
</div>
})
} else {
let auth_url = CLIENT.get_authorization_url(CALLBACK_URL, SCOPES);
RawHtml(html! {
<div>
<h1>{"Hello, world!"}</h1>
<a href={&auth_url}>{"Login"}</a>
</div>
})
}
}

#[get("/oauth/callback?<code>")]
async fn callback<'a>(code: String) -> CustomRedirect<'a> {
let token = CLIENT.fetch_access_token(&code, CALLBACK_URL).await.unwrap();
let token = serde_json::to_string(&token).unwrap();

CustomRedirect {
inner: Redirect::to(uri!(index)),
access_token: Cookie::build(("access_token", token))
.path("/")
.expires(OffsetDateTime::now_utc() + Duration::from_secs(15 * 60))
.http_only(true)
.build()
}
}

#[tokio::main]
async fn main() {
let config = rocket::Config {
port: 1337,
..Default::default()
};

let _ = rocket::custom(config)
.mount("/", routes![index, callback])
.launch()
.await;
}
172 changes: 134 additions & 38 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use reqwest::Client;
use rocket::time::ext;
use std::time::Duration;

use crate::{models::{self, token::AccessToken}, FtError, Result};
Expand All @@ -12,15 +13,24 @@ macro_rules! endpoint {
($path:expr) => { format!("{}{}", API_URL, $path) };
}

enum AuthType {
App {
uid: String,
secret: String,

last_token: Option<AccessToken>
},
User {
token: AccessToken
}
}

/// The client struct, used to make requests to the API.
///
/// You can create a client with the [`from_app`](#method.from_app) method.
pub struct FtClient {
app_uid: String,
app_secret: String,

auth_type: AuthType,
client: Client,
last_valid_token: Option<AccessToken>
}

impl FtClient {
Expand All @@ -38,7 +48,7 @@ impl FtClient {
pub fn from_app<U: Into<String>, S: Into<String>>(
app_uid: U,
app_secret: S,
) -> crate::Result<Self> {
) -> Result<Self> {
let app_uid = app_uid.into();
let app_secret = app_secret.into();

Expand All @@ -51,16 +61,60 @@ impl FtClient {
Err(FtError::ReqwestBuilderError(err))
} else {
Ok(Self {
app_uid,
app_secret,

auth_type: AuthType::App {
uid: app_uid,
secret: app_secret,
last_token: None
},
client: client.unwrap(),
})
}
}

pub fn from_user(token: AccessToken) -> Result<Self> {
let client = reqwest::ClientBuilder::new()
.user_agent(format!("{}/{}", PKG_NAME, PKG_VERSION))
.connect_timeout(Duration::from_secs(30))
.build();

if let Err(err) = client {
Err(FtError::ReqwestBuilderError(err))
} else {
Ok(Self {
auth_type: AuthType::User {
token
},
client: client.unwrap(),
last_valid_token: None
})
}
}

/// Fetches a new access token from the API.
async fn handle_error(&self, res: reqwest::Response) -> Result<()> {
let status = res.status().as_u16();
let text = res.text().await?;
let json_object = serde_json::from_str::<serde_json::Value>(&text)?;

let error = json_object.get("error")
.unwrap_or(&serde_json::Value::Null)
.as_str()
.unwrap_or("unknown");
let error_description = json_object.get("error_description")
.unwrap_or(&serde_json::Value::Null)
.as_str()
.unwrap_or("No description provided.");
let status = json_object.get("status")
.unwrap_or(&serde_json::Value::Null)
.as_u64()
.unwrap_or(status as _);

Err(FtError::from_api_error(
error.parse()?,
status,
error_description.to_string()
))
}

/// Fetches a new app access token from the API.
///
/// This method will return the last valid token if it is still valid, as per the API's documentation.
///
Expand All @@ -74,51 +128,93 @@ impl FtClient {
/// #[tokio::main]
/// async fn main() -> ft_rs::Result<()> {
/// let client = FtClient::from_app("my_uid", "my_super_secret_secret")?;
/// let token = client.fetch_token().await?;
/// let token = client.fetch_app_token().await?;
/// println!("Token: {:?}", token);
/// Ok(())
/// }
/// ```
pub async fn fetch_token(&self) -> Result<models::token::AccessToken> {
pub async fn fetch_app_token(&self, extra_data: bool) -> Result<models::token::AccessToken> {
if let AuthType::App { uid, secret, .. } = &self.auth_type {
let res = self.client
.post(endpoint!("/oauth/token"))
.form(&[
("grant_type", "client_credentials"),
("client_id", &uid),
("client_secret", &secret)
])
.send()
.await?;

if res.status().is_success() {
let token = res.json::<AccessToken>().await?;
if extra_data {
unimplemented!("Extra data is not implemented yet.");
}
Ok(token)
} else {
self.handle_error(res).await?;
unreachable!()
}
} else {
Err(FtError::InvalidAuthType)
}
}

/// Ensures that the current token is still valid, and fetches a new one if it is not.
///
/// This method is called automatically by the API Client when making a request, so there is no need to call it manually.
pub async fn ensure_app_token(&mut self) -> Result<()> {
self.auth_type.try_refresh_token(self).await?;
Ok(())
}

pub fn get_authorization_url(&self, callback_url: &str, scopes: &[&str]) -> String {
format!(
"{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&response_type=code",
API_URL,
self.app_uid,
urlencoding::encode(callback_url),
scopes.join(" ")
)
}

pub async fn fetch_access_token(&self, code: &str, callback_url: &str) -> Result<AccessToken> {
let res = self.client
.post(endpoint!("/oauth/token"))
.form(&[
("grant_type", "client_credentials"),
("grant_type", "authorization_code"),
("client_id", &self.app_uid),
("client_secret", &self.app_secret)
("client_secret", &self.app_secret),
("code", code),
("redirect_uri", callback_url),
])
.send()
.await?;
let text = res.text().await?;
let json_object = serde_json::from_str::<serde_json::Value>(&text)?;

if let Some(error) = json_object.get("error") {
let error = error.as_str().unwrap();
let error_description = json_object.get("error_description")
.unwrap_or(&serde_json::Value::Null)
.as_str()
.unwrap_or("No description provided.");
Err(FtError::from_api_error(
error.parse()?,
error_description.to_string()
))
} else {
let token = serde_json::from_str::<AccessToken>(&text)?;
if res.status().is_success() {
let token = res.json::<AccessToken>().await?;
Ok(token)
} else {
self.handle_error(res).await?;
unreachable!()
}
}

/// Ensures that the last valid token is still valid, and fetches a new one if it is not.
///
/// This method is called automatically by the API Client when making a request, so there is no need to call it manually.
pub async fn ensure_valid_token(&mut self) -> Result<()> {
if let Some(token) = &self.last_valid_token {
if token.is_expired() {
self.last_valid_token = Some(self.fetch_token().await?);
}
pub async fn fetch_user_data(&self, token: &AccessToken) -> Result<models::user::User> {
let res = self.client
.get(endpoint!("/v2/me"))
.bearer_auth(token.access_token.clone())
.send()
.await?;

if res.status().is_success() {
let text = res.text().await?;
println!("{}", text);
let user = serde_json::from_str::<models::user::User>(&text)?;
Ok(user)
} else {
self.last_valid_token = Some(self.fetch_token().await?);
self.handle_error(res).await?;
unreachable!()
}
Ok(())
}
}
10 changes: 8 additions & 2 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,23 @@ pub enum FtError {
#[from]
source: serde_json::Error
},
#[error("API error: {error:?}: {error_description:?}")]

#[error("Invalid auth type, tried to use a user token with an app client or vice-versa")]
InvalidAuthType,

#[error("API error {error_status:?}: {error:?}: {error_description:?}")]
ApiError {
error: ErrorType,
error_status: u64,
error_description: String
},
}

impl FtError {
pub fn from_api_error(error: ErrorType, error_description: String) -> Self {
pub fn from_api_error(error: ErrorType, status: u64, error_description: String) -> Self {
Self::ApiError {
error,
error_status: status,
error_description
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/models/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod token;
pub mod token;
pub mod user;
Loading

0 comments on commit 6d6fd9d

Please sign in to comment.