Skip to content

Commit f9d207c

Browse files
committed
Roblox OAuth refresh token
1 parent d6ddd69 commit f9d207c

File tree

4 files changed

+285
-27
lines changed

4 files changed

+285
-27
lines changed

src/oauth/routes/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ pub(crate) fn routes() -> Vec<rocket::Route> {
4848
discord::discord_refresh_token,
4949
roblox::roblox_oauth_initiate,
5050
roblox::roblox_oauth_callback,
51+
roblox::roblox_refresh_token,
5152
refresh_session,
5253
]
5354
}

src/oauth/routes/roblox.rs

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,37 @@
1-
use crate::config::Config;
2-
use crate::constants;
3-
use crate::database::wrappers::account_links::models::NewAccountLink;
4-
use crate::database::wrappers::account_links::AccountLinksDb;
5-
use crate::database::wrappers::roblox_connections::models::NewRobloxConnection;
6-
use crate::database::wrappers::roblox_connections::RobloxConnectionsDb;
7-
use crate::database::wrappers::sessions::models::Session;
8-
use crate::oauth::types::roblox::{RobloxOAuthScopeSet, RobloxOAuthScopes};
9-
use crate::oauth::types::OAuthCallback;
10-
use crate::oauth::utils::generate_state;
11-
use crate::oauth::utils::pixy::Pixy;
12-
use crate::oauth::utils::roblox::{construct_roblox_oauth_url, exchange_code, get_authorized_user};
13-
use crate::response::{ApiError, ApiResult};
14-
use crate::utils::construct_api_route;
15-
use crate::DbConn;
1+
use crate::{
2+
config::Config,
3+
constants,
4+
database::wrappers::{
5+
account_links::{models::NewAccountLink, AccountLinksDb},
6+
roblox_connections::{models::NewRobloxConnection, RobloxConnectionsDb},
7+
sessions::models::Session,
8+
},
9+
oauth::{
10+
types::{
11+
roblox::{RobloxOAuthScopeSet, RobloxOAuthScopes},
12+
OAuthCallback,
13+
},
14+
utils::{
15+
generate_state,
16+
pixy::Pixy,
17+
roblox::{
18+
construct_roblox_oauth_url, exchange_code, find_account_link, get_authorized_user,
19+
get_roblox_refresh_token_by_roblox_uid,
20+
update_roblox_connection_with_refresh_token,
21+
},
22+
},
23+
},
24+
response::{ApiError, ApiResponse, ApiResult},
25+
utils::construct_api_route,
26+
DbConn,
27+
};
1628
use diesel::Connection;
17-
use rocket::http::{Cookie, CookieJar, SameSite, Status};
18-
use rocket::response::Redirect;
19-
use rocket::time::Duration;
20-
use rocket::State;
29+
use rocket::{
30+
http::{Cookie, CookieJar, SameSite, Status},
31+
response::Redirect,
32+
time::Duration,
33+
State,
34+
};
2135

2236
/// Handles the Roblox OAuth callback by verifying the state and code verifier,
2337
/// and exchanging the code for a token.
@@ -144,3 +158,42 @@ pub(super) async fn roblox_oauth_initiate(
144158

145159
Ok(Redirect::to(redirect_uri))
146160
}
161+
162+
/// If the given roblox_uid belongs to the authenticated user,
163+
/// refreshes the access token of that roblox_uid
164+
///
165+
/// # Possible Responses
166+
///
167+
/// - `204 No Content` everything's successful
168+
/// - `400 Bad Request` roblox_uid isn't provided.
169+
/// - `401 Unauthorized`
170+
/// - The session cookie is missing.
171+
/// - The session is not found in the database.
172+
/// - `404 Not Found`
173+
/// - Account link.
174+
/// - Roblox connection.
175+
/// - `500 Internal Server Error`
176+
/// - Failed to encrypt the access token and/or refresh token.
177+
/// - Failed to update the Roblox connection.
178+
/// - Failed to parse the response from the Roblox token refresh endpoint
179+
#[post("/roblox/refresh-token?<roblox_uid>")]
180+
pub(super) async fn roblox_refresh_token(
181+
roblox_uid: String,
182+
conn: DbConn,
183+
session: Session,
184+
cfg: &State<Config>,
185+
) -> ApiResult {
186+
let account_link = find_account_link(&conn, session.discord_uid, roblox_uid).await?;
187+
let refresh_token =
188+
get_roblox_refresh_token_by_roblox_uid(&conn, &account_link.roblox_uid).await?;
189+
190+
update_roblox_connection_with_refresh_token(
191+
&conn,
192+
&account_link.roblox_uid,
193+
&refresh_token,
194+
cfg,
195+
)
196+
.await?;
197+
198+
Ok(ApiResponse::status(Status::NoContent))
199+
}

src/oauth/types/roblox.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ pub(crate) enum RobloxOAuthScope {
5151
UserUserNotificationWrite,
5252
}
5353

54-
/// The request body for the Roblox OAuth token endpoint.
54+
/// The request body for obtaining Roblox OAuth access token with code & code_verifier.
5555
#[derive(Serialize)]
5656
#[serde(crate = "rocket::serde")]
5757
pub(crate) struct RobloxAuthorizedCodeRequestBody<'a> {
@@ -68,6 +68,21 @@ pub(crate) struct RobloxAuthorizedCodeRequestBody<'a> {
6868
client_secret: String,
6969
}
7070

71+
/// The request body for obtaining Roblox OAuth access token with a refresh token.
72+
#[derive(Serialize)]
73+
#[serde(crate = "rocket::serde")]
74+
pub(crate) struct RobloxTokenRefreshRequestBody<'a> {
75+
/// The refresh token received from the token exchange endpoint.
76+
refresh_token: &'a str,
77+
/// The grant type for the OAuth2 request.
78+
grant_type: &'a str,
79+
/// The client ID for the OAuth2 application.
80+
client_id: &'a str,
81+
/// The client secret for the OAuth2 application.
82+
/// Must be an owned string as it is retrieved from the environment
83+
client_secret: String,
84+
}
85+
7186
/// The response body for the Roblox OAuth token endpoint.
7287
#[derive(Deserialize)]
7388
#[serde(crate = "rocket::serde")]
@@ -204,6 +219,29 @@ impl<'a> RobloxAuthorizedCodeRequestBody<'a> {
204219
}
205220
}
206221

222+
impl<'a> RobloxTokenRefreshRequestBody<'a> {
223+
/// Creates a new [`RobloxTokenRefreshRequestBody`] with the given refresh_token, grant_type and configuration.
224+
pub(crate) fn new(refresh_token: &'a str, cfg: &'a Config) -> Self {
225+
Self {
226+
refresh_token,
227+
grant_type: "refresh_token",
228+
client_id: &cfg.oauth.roblox.client_id,
229+
client_secret: std::env::var(constants::env::ROBLOX_CLIENT_SECRET)
230+
.unwrap_or_else(|_| panic!("{} must be set", constants::env::ROBLOX_CLIENT_SECRET)),
231+
}
232+
}
233+
234+
/// Converts the request body to a query parameter string.
235+
pub(crate) fn as_query_params(&self) -> String {
236+
url!([
237+
("refresh_token", self.refresh_token),
238+
("grant_type", self.grant_type),
239+
("client_id", self.client_id),
240+
("client_secret", self.client_secret)
241+
])
242+
}
243+
}
244+
207245
#[cfg(test)]
208246
mod tests {
209247
use super::{RobloxOAuthScope, RobloxOAuthScopeSet, RobloxOAuthScopes};

src/oauth/utils/roblox.rs

Lines changed: 173 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
1-
use crate::config::Config;
2-
use crate::constants;
3-
use crate::oauth::types::roblox::{
4-
RobloxAccessTokenResponse, RobloxAuthorizedCodeRequestBody, RobloxOAuthScopes,
5-
RobloxUserInfoResponse,
1+
use crate::{
2+
cipher::{decrypt, EncryptedData},
3+
config::Config,
4+
constants,
5+
database::wrappers::{
6+
account_links::{
7+
models::AccountLink, AccountLinkUserId, AccountLinksDb, DiscordMarker, RobloxMarker,
8+
},
9+
roblox_connections::{models::NewRobloxConnection, RobloxConnectionsDb},
10+
},
11+
oauth::types::roblox::{
12+
RobloxAccessTokenResponse, RobloxAuthorizedCodeRequestBody, RobloxOAuthScopes,
13+
RobloxTokenRefreshRequestBody, RobloxUserInfoResponse,
14+
},
15+
response::ApiError,
16+
url, DbConn,
617
};
7-
use crate::response::ApiError;
8-
use crate::url;
918
use rocket::http::Status;
19+
use std::sync::Arc;
1020

1121
/// Constructs the Roblox OAuth URL with the given scopes and state.
1222
/// The scopes are joined by an encoded space (`%20`).
@@ -97,3 +107,159 @@ pub(crate) fn get_authorized_user(access_token: &str) -> Result<RobloxUserInfoRe
97107
)
98108
})
99109
}
110+
111+
/// Exchanges the refresh token for an access token.
112+
///
113+
/// # Arguments
114+
///
115+
/// * `refresh_token` - The refresh token to exchange for an access token.
116+
/// * `cfg` - The application configuration.
117+
///
118+
/// # Returns
119+
///
120+
/// The [`RobloxAccessTokenResponse`] struct if the token refresh was successful, an [`ApiError`] otherwise.
121+
pub(crate) fn refresh_access_token(
122+
refresh_token: &str,
123+
cfg: &Config,
124+
) -> Result<RobloxAccessTokenResponse, ApiError> {
125+
let body = RobloxTokenRefreshRequestBody::new(refresh_token, cfg);
126+
let response = minreq::post(constants::roblox_api::TOKEN_URL)
127+
.with_header("Content-Type", "application/x-www-form-urlencoded")
128+
.with_body(body.as_query_params())
129+
.send()
130+
.map_err(|_| ApiError::message(Status::BadGateway, "Failed to refresh access token"))?;
131+
132+
response.json::<RobloxAccessTokenResponse>().map_err(|_| {
133+
ApiError::message(
134+
Status::InternalServerError,
135+
"Failed to parse token response",
136+
)
137+
})
138+
}
139+
140+
/// Finds an account link by discord uid & roblox uid
141+
///
142+
/// # Arguments
143+
///
144+
/// * `db_conn`
145+
/// * `discord_uid`
146+
/// * `roblox_uid`
147+
///
148+
/// # Returns
149+
///
150+
/// [`AccountLink`] if the account link was found, an [`ApiError`] otherwise.
151+
pub(crate) async fn find_account_link(
152+
db_conn: &DbConn,
153+
discord_uid: String,
154+
roblox_uid: String,
155+
) -> Result<AccountLink, ApiError> {
156+
db_conn
157+
.run(|conn| {
158+
let discord_marker = AccountLinkUserId::<DiscordMarker>::new(discord_uid);
159+
let roblox_marker = AccountLinkUserId::<RobloxMarker>::new(roblox_uid);
160+
161+
AccountLinksDb::find_one((discord_marker, roblox_marker), conn)
162+
})
163+
.await
164+
.ok_or_else(|| {
165+
ApiError::message(
166+
Status::NotFound,
167+
"Roblox account link with the given discord_uid & roblox_uid not found",
168+
)
169+
})
170+
}
171+
172+
/// Finds & decrypts the refresh token belonging to the given roblox uid
173+
///
174+
/// # Arguments
175+
///
176+
/// * `db_conn`
177+
/// * `roblox_uid`
178+
///
179+
/// # Returns
180+
///
181+
/// [`String`] if the refresh token was found, an [`ApiError`] otherwise.
182+
pub(crate) async fn get_roblox_refresh_token_by_roblox_uid(
183+
db_conn: &DbConn,
184+
roblox_uid: &str,
185+
) -> Result<String, ApiError> {
186+
let roblox_uid = Arc::<str>::from(roblox_uid);
187+
188+
db_conn
189+
.run(move |conn| {
190+
let Some(roblox_connection) = RobloxConnectionsDb::find_one(&roblox_uid, conn) else {
191+
return Err(ApiError::message(
192+
Status::NotFound,
193+
"Roblox connection with the given uid not found",
194+
));
195+
};
196+
197+
let decrypted_refresh_token = decrypt(&EncryptedData {
198+
data: roblox_connection.refresh_token,
199+
nonce: roblox_connection.refresh_token_nonce,
200+
})
201+
.map_err(|_| {
202+
ApiError::message(
203+
Status::InternalServerError,
204+
"Failed to decrypt the Roblox refresh token",
205+
)
206+
});
207+
208+
Ok(decrypted_refresh_token)
209+
})
210+
.await?
211+
}
212+
213+
/// Refreshes the access token belonging to the given roblox uid
214+
/// with the given refresh token
215+
///
216+
/// # Arguments
217+
///
218+
/// * `db_conn`
219+
/// * `roblox_uid`
220+
/// * `refresh_token`
221+
/// * `cfg` - Application config
222+
///
223+
/// # Returns
224+
///
225+
/// [`()`] if everything was successful, an [`ApiError`] otherwise.
226+
pub(crate) async fn update_roblox_connection_with_refresh_token(
227+
db_conn: &DbConn,
228+
roblox_uid: &str,
229+
refresh_token: &str,
230+
cfg: &Config,
231+
) -> Result<(), ApiError> {
232+
let new_token_info = refresh_access_token(refresh_token, cfg)?;
233+
let new_token_expires_at =
234+
chrono::Utc::now().naive_utc() + chrono::Duration::seconds(new_token_info.expires_in);
235+
236+
let update_connection = NewRobloxConnection::build()
237+
.refresh_token(new_token_info.refresh_token)
238+
.access_token(new_token_info.access_token)
239+
.expires_at(new_token_expires_at)
240+
.build_update()
241+
.map_err(|_| {
242+
ApiError::message(
243+
Status::InternalServerError,
244+
"Failed to encrypt access token and/or refresh token",
245+
)
246+
})?;
247+
248+
let roblox_uid = Arc::<str>::from(roblox_uid);
249+
250+
db_conn
251+
.run(move |conn| {
252+
RobloxConnectionsDb::update_one(&roblox_uid, update_connection, conn)?;
253+
254+
diesel::result::QueryResult::Ok(())
255+
})
256+
.await
257+
.map_err(|_| {
258+
ApiError::message(
259+
Status::InternalServerError,
260+
"Failed to update the Roblox connection due to an error",
261+
)
262+
})?;
263+
264+
Ok(())
265+
}

0 commit comments

Comments
 (0)