Skip to content

Commit 3d0de39

Browse files
committed
WIP oauth2 authorization / example
1 parent c2dfa43 commit 3d0de39

9 files changed

Lines changed: 317 additions & 70 deletions

File tree

Cargo.toml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,24 @@ edition = "2021"
55

66
[features]
77
default = ["oauth2"]
8+
rustls-tls = ["oauth2/rustls-tls", "reqwest/rustls-tls"]
9+
native-tls = ["oauth2/native-tls", "reqwest/native-tls"]
810

911
[dependencies]
1012
async-trait = "0.1"
1113
oauth1 = { version = "0.5", package = "oauth1-request" }
12-
oauth2 = { version = "4.1", optional = true, features = ["reqwest"] }
13-
reqwest = { version = "0.11", features = ["json"] }
14+
oauth2 = { version = "4.1", optional = true, default-features = false, features = ["reqwest"] }
15+
reqwest = { version = "0.11", default-features = false, features = ["json"] }
1416
serde = { version = "1.0", features = ["derive"] }
1517
serde_json = "1.0"
1618
serde_urlencoded = "0.7"
17-
strum_macros = "0.24"
19+
strum = { version = "0.24", features = ["derive"] }
1820
thiserror = "1.0"
1921
url = "2.2"
2022

2123
[dev-dependencies]
24+
axum = "0.4.8"
2225
tokio = { version = "1.17.0", features = ["macros", "rt-multi-thread"] }
26+
tower-http = { version = "0.2.5", features = ["trace"] }
27+
tracing = "0.1.32"
28+
tracing-subscriber = { version = "0.3.9", features = ["env-filter"] }

examples/oauth2_callback.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
use axum::{
2+
extract::{Extension, Query},
3+
http::StatusCode,
4+
response::{IntoResponse, Redirect},
5+
routing::{get, post},
6+
Json, Router,
7+
};
8+
use serde::Deserialize;
9+
use std::net::SocketAddr;
10+
use std::sync::{Arc, Mutex};
11+
use tower_http::trace::TraceLayer;
12+
use tracing_subscriber::prelude::*;
13+
14+
use twitter_v2::oauth2::{AuthorizationCode, CsrfToken, PkceCodeChallenge, PkceCodeVerifier};
15+
use twitter_v2::{Oauth2Client, Oauth2Token, Scope};
16+
17+
pub struct Oauth2Ctx {
18+
client: Oauth2Client,
19+
verifier: Option<PkceCodeVerifier>,
20+
state: Option<CsrfToken>,
21+
token: Option<Oauth2Token>,
22+
}
23+
24+
async fn login(Extension(ctx): Extension<Arc<Mutex<Oauth2Ctx>>>) -> impl IntoResponse {
25+
let mut ctx = ctx.lock().unwrap();
26+
// create challenge
27+
let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
28+
// create authorization url
29+
let (url, state) = ctx.client.auth_url(
30+
challenge,
31+
[Scope::TweetRead, Scope::TweetWrite, Scope::UsersRead],
32+
);
33+
// set context for reference in callback
34+
ctx.verifier = Some(verifier);
35+
ctx.state = Some(state);
36+
// redirect user
37+
Redirect::to(url.to_string().parse().unwrap())
38+
}
39+
40+
#[derive(Deserialize)]
41+
pub struct CallbackParams {
42+
code: AuthorizationCode,
43+
state: CsrfToken,
44+
}
45+
46+
async fn callback(
47+
Extension(ctx): Extension<Arc<Mutex<Oauth2Ctx>>>,
48+
Query(CallbackParams { code, state }): Query<CallbackParams>,
49+
) -> impl IntoResponse {
50+
let (client, verifier) = {
51+
let mut ctx = ctx.lock().unwrap();
52+
// get previous state from ctx (see login)
53+
let saved_state = ctx.state.take().ok_or_else(|| {
54+
(
55+
StatusCode::INTERNAL_SERVER_ERROR,
56+
"No previous state found".to_string(),
57+
)
58+
})?;
59+
// // check state returned to see if it matches, otherwise throw an error
60+
if state.secret() != saved_state.secret() {
61+
return Err((
62+
StatusCode::BAD_REQUEST,
63+
"Invalid state returned".to_string(),
64+
));
65+
}
66+
// // get verifier from ctx
67+
let verifier = ctx.verifier.take().ok_or_else(|| {
68+
(
69+
StatusCode::INTERNAL_SERVER_ERROR,
70+
"No PKCE verifier found".to_string(),
71+
)
72+
})?;
73+
let client = ctx.client.clone();
74+
(client, verifier)
75+
};
76+
77+
tracing::debug!("Code received {}", code.secret());
78+
// request oauth2 token
79+
let token = client
80+
.request_token(code, verifier)
81+
.await
82+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
83+
tracing::debug!("Token received {}", token.access_token().secret());
84+
// // set context for use with twitter API
85+
ctx.lock().unwrap().token = Some(token);
86+
87+
Ok(Redirect::to("/tweets".parse().unwrap()))
88+
}
89+
90+
#[tokio::main]
91+
async fn main() {
92+
// initialize tracing
93+
tracing_subscriber::registry()
94+
.with(tracing_subscriber::EnvFilter::new(
95+
std::env::var("RUST_LOG")
96+
.unwrap_or_else(|_| "oauth2_callback=debug,tower_http=debug".into()),
97+
))
98+
.with(tracing_subscriber::fmt::layer())
99+
.init();
100+
101+
// serve on port 3000
102+
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
103+
104+
// initialize Oauth2Client with ID and Secret and the callback to this server
105+
let oauth_ctx = Oauth2Ctx {
106+
client: Oauth2Client::new(
107+
std::env::var("CLIENT_ID").expect("could not find CLIENT_ID"),
108+
std::env::var("CLIENT_SECRET").expect("could not find CLIENT_SECRET"),
109+
format!("http://{addr}/callback").parse().unwrap(),
110+
),
111+
verifier: None,
112+
state: None,
113+
token: None,
114+
};
115+
116+
// initialize server
117+
let app = Router::new()
118+
.route("/login", get(login))
119+
.route("/callback", get(callback))
120+
.layer(TraceLayer::new_for_http())
121+
.layer(Extension(Arc::new(Mutex::new(oauth_ctx))));
122+
123+
// run server
124+
println!("\nOpen http://{}/login in your browser\n", addr);
125+
tracing::debug!("Serving at {}", addr);
126+
axum::Server::bind(&addr)
127+
.serve(app.into_make_service())
128+
.await
129+
.unwrap();
130+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
#[cfg(feature = "oauth2")]
2+
mod oauth2;
3+
14
use crate::error::{Error, Result};
25
use async_trait::async_trait;
36
use reqwest::header::HeaderValue;
47
use reqwest::Request;
58
use std::collections::BTreeSet;
69
use std::fmt;
710

11+
#[cfg(feature = "oauth2")]
12+
pub use self::oauth2::*;
13+
814
#[async_trait]
915
pub trait Authorization {
1016
async fn header(&self, request: &Request) -> Result<HeaderValue>;

src/authorization/oauth2.rs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
use super::Authorization;
2+
use crate::error::{Error, Result};
3+
use oauth2::basic::{BasicClient, BasicRequestTokenError};
4+
use oauth2::{
5+
AccessToken, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge,
6+
PkceCodeVerifier, RedirectUrl, RefreshToken, RevocationUrl, TokenResponse, TokenUrl,
7+
};
8+
use std::time::SystemTime;
9+
use strum::{Display, EnumString};
10+
use url::Url;
11+
12+
#[derive(Copy, Clone, Debug, EnumString, Display)]
13+
#[strum(serialize_all = "snake_case")]
14+
pub enum Scope {
15+
#[strum(serialize = "tweet.read")]
16+
TweetRead,
17+
#[strum(serialize = "tweet.write")]
18+
TweetWrite,
19+
#[strum(serialize = "tweet.moderate.write")]
20+
TweetModerateWrite,
21+
#[strum(serialize = "users.read")]
22+
UsersRead,
23+
#[strum(serialize = "follows.read")]
24+
FollowsRead,
25+
#[strum(serialize = "follows.write")]
26+
FollowsWrite,
27+
#[strum(serialize = "offline.access")]
28+
OfflineAccess,
29+
#[strum(serialize = "space.read")]
30+
SpaceRead,
31+
#[strum(serialize = "mute.read")]
32+
MuteRead,
33+
#[strum(serialize = "mute.write")]
34+
MuteWrite,
35+
#[strum(serialize = "like.read")]
36+
LikeRead,
37+
#[strum(serialize = "like.write")]
38+
LikeWrite,
39+
#[strum(serialize = "list.read")]
40+
ListRead,
41+
#[strum(serialize = "list.write")]
42+
ListWrite,
43+
#[strum(serialize = "block.read")]
44+
BlockRead,
45+
#[strum(serialize = "block.write")]
46+
BlockWrite,
47+
}
48+
49+
impl From<Scope> for oauth2::Scope {
50+
fn from(scope: Scope) -> Self {
51+
oauth2::Scope::new(scope.to_string())
52+
}
53+
}
54+
55+
#[derive(Clone, Debug)]
56+
pub struct Oauth2Client(BasicClient);
57+
58+
impl Oauth2Client {
59+
pub fn new(client_id: impl ToString, client_secret: impl ToString, callback_url: Url) -> Self {
60+
Self(
61+
BasicClient::new(
62+
ClientId::new(client_id.to_string()),
63+
Some(ClientSecret::new(client_secret.to_string())),
64+
AuthUrl::from_url("https://twitter.com/i/oauth2/authorize".parse().unwrap()),
65+
Some(TokenUrl::from_url(
66+
"https://api.twitter.com/2/oauth2/token".parse().unwrap(),
67+
)),
68+
)
69+
.set_revocation_uri(RevocationUrl::from_url(
70+
"https://api.twitter.com/2/oauth2/revoke".parse().unwrap(),
71+
))
72+
.set_redirect_uri(RedirectUrl::from_url(callback_url)),
73+
)
74+
}
75+
76+
pub fn auth_url(
77+
&self,
78+
challenge: PkceCodeChallenge,
79+
scopes: impl IntoIterator<Item = Scope>,
80+
) -> (Url, CsrfToken) {
81+
self.0
82+
.authorize_url(CsrfToken::new_random)
83+
.set_pkce_challenge(challenge)
84+
.add_scopes(scopes.into_iter().map(|s| s.into()))
85+
.url()
86+
}
87+
88+
pub async fn request_token(
89+
&self,
90+
code: AuthorizationCode,
91+
verifier: PkceCodeVerifier,
92+
) -> Result<Oauth2Token> {
93+
let res = self
94+
.0
95+
.exchange_code(code)
96+
.set_pkce_verifier(verifier)
97+
.request_async(oauth2::reqwest::async_http_client)
98+
.await
99+
.map_err(|err| {
100+
println!("{:?}", err);
101+
Error::from(err)
102+
})?;
103+
Ok(Oauth2Token {
104+
oauth_client: self.clone(),
105+
access_token: res.access_token().clone(),
106+
refresh_token: res.refresh_token().cloned(),
107+
expires: SystemTime::now()
108+
+ res.expires_in().ok_or_else(|| {
109+
Error::Oauth2TokenError(BasicRequestTokenError::Other(
110+
"Missing expiration".to_string(),
111+
))
112+
})?,
113+
scopes: res
114+
.scopes()
115+
.ok_or_else(|| {
116+
Error::Oauth2TokenError(BasicRequestTokenError::Other(
117+
"Missing scopes".to_string(),
118+
))
119+
})?
120+
.iter()
121+
.map(|s| {
122+
s.parse().map_err(|err| {
123+
Error::Oauth2TokenError(BasicRequestTokenError::Other(format!(
124+
"Invalid scope: {}",
125+
err
126+
)))
127+
})
128+
})
129+
.collect::<Result<Vec<_>>>()?,
130+
})
131+
}
132+
}
133+
134+
#[derive(Clone, Debug)]
135+
pub struct Oauth2Token {
136+
oauth_client: Oauth2Client,
137+
access_token: AccessToken,
138+
refresh_token: Option<RefreshToken>,
139+
expires: SystemTime,
140+
scopes: Vec<Scope>,
141+
}
142+
143+
impl Oauth2Token {
144+
pub fn access_token(&self) -> &AccessToken {
145+
&self.access_token
146+
}
147+
pub fn refresh_token(&self) -> Option<&RefreshToken> {
148+
self.refresh_token.as_ref()
149+
}
150+
pub fn expires(&self) -> SystemTime {
151+
self.expires
152+
}
153+
pub fn is_expired(&self) -> bool {
154+
self.expires < SystemTime::now()
155+
}
156+
pub fn scopes(&self) -> &[Scope] {
157+
&self.scopes
158+
}
159+
}

src/error.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ pub enum Error {
1010
Url(#[from] url::ParseError),
1111
#[error("Invalid Authorization header value: {_0}")]
1212
InvalidAuthorizationHeader(InvalidHeaderValue),
13+
#[cfg(feature = "oauth2")]
14+
#[error(transparent)]
15+
Oauth2TokenError(
16+
#[from] oauth2::basic::BasicRequestTokenError<oauth2::reqwest::Error<reqwest::Error>>,
17+
),
1318
}
1419

1520
pub type Result<T, E = Error> = std::result::Result<T, E>;

src/expansions.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use strum_macros::Display;
1+
use strum::Display;
22

33
#[derive(Copy, Clone, Debug, Display)]
44
#[strum(serialize_all = "snake_case")]

src/fields.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use serde::Serialize;
2-
use strum_macros::Display;
2+
use strum::Display;
33

44
#[macro_export]
55
macro_rules! fields {

src/lib.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1-
mod authentication;
1+
#[cfg(feature = "oauth2")]
2+
pub extern crate oauth2;
3+
4+
mod authorization;
25
mod data;
36
mod error;
47
mod expansions;
58
mod fields;
69
mod id;
7-
#[cfg(feature = "oauth2")]
8-
mod oauth2;
910
mod query;
1011
mod requests;
1112

12-
pub use authentication::*;
13+
pub use authorization::*;
1314
pub use data::*;
1415
pub use error::*;
1516
pub use requests::*;
1617

17-
use authentication::Authorization;
18+
use authorization::Authorization;
1819
use expansions::TweetExpansion;
1920
use fields::Field;
2021
use id::ToId;

0 commit comments

Comments
 (0)