Skip to content

Commit 603c4d1

Browse files
committed
Make sure the refresh token is idempotent
This allows using a refresh token multiple times, as long as the new pair of tokens were not used in the meantime.
1 parent 662f199 commit 603c4d1

File tree

1 file changed

+266
-9
lines changed

1 file changed

+266
-9
lines changed

crates/handlers/src/oauth2/token.rs

Lines changed: 266 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ use oauth2_types::{
3939
scope,
4040
};
4141
use thiserror::Error;
42-
use tracing::debug;
42+
use tracing::{debug, info};
4343
use ulid::Ulid;
4444

4545
use super::{generate_id_token, generate_token_pair};
@@ -98,6 +98,20 @@ pub(crate) enum RouteError {
9898
#[error("failed to load oauth session")]
9999
NoSuchOAuthSession,
100100

101+
#[error(
102+
"failed to load the next refresh token ({next:?}) from the previous one ({previous:?})"
103+
)]
104+
NoSuchNextRefreshToken { next: Ulid, previous: Ulid },
105+
106+
#[error("failed to load the access token ({access_token:?}) associated with the next refresh token ({refresh_token:?})")]
107+
NoSuchNextAccessToken {
108+
access_token: Ulid,
109+
refresh_token: Ulid,
110+
},
111+
112+
#[error("no access token associated with the refresh token {refresh_token:?}")]
113+
NoAccessTokenOnRefreshToken { refresh_token: Ulid },
114+
101115
#[error("device code grant expired")]
102116
DeviceCodeExpired,
103117

@@ -122,7 +136,10 @@ impl IntoResponse for RouteError {
122136
Self::Internal(_)
123137
| Self::NoSuchBrowserSession
124138
| Self::NoSuchOAuthSession
125-
| Self::ProvisionDeviceFailed(_) => (
139+
| Self::ProvisionDeviceFailed(_)
140+
| Self::NoSuchNextRefreshToken { .. }
141+
| Self::NoSuchNextAccessToken { .. }
142+
| Self::NoAccessTokenOnRefreshToken { .. } => (
126143
StatusCode::INTERNAL_SERVER_ERROR,
127144
Json(ClientError::from(ClientErrorCode::ServerError)),
128145
),
@@ -482,6 +499,7 @@ async fn authorization_code_grant(
482499
Ok((params, repo))
483500
}
484501

502+
#[allow(clippy::too_many_lines)]
485503
async fn refresh_token_grant(
486504
rng: &mut BoxRng,
487505
clock: &impl Clock,
@@ -518,10 +536,6 @@ async fn refresh_token_grant(
518536
.await?;
519537
}
520538

521-
if !refresh_token.is_valid() {
522-
return Err(RouteError::RefreshTokenInvalid(refresh_token.id));
523-
}
524-
525539
if !session.is_valid() {
526540
return Err(RouteError::SessionInvalid(session.id));
527541
}
@@ -534,6 +548,77 @@ async fn refresh_token_grant(
534548
});
535549
}
536550

551+
if !refresh_token.is_valid() {
552+
// We're seing a refresh token that already has been consumed, this might be a
553+
// double-refresh or a replay attack
554+
555+
// First, get the next refresh token
556+
let Some(next_refresh_token_id) = refresh_token.next_refresh_token_id() else {
557+
// If we don't have a 'next' refresh token, it may just be because this was
558+
// before we were recording those. Let's just treat it as a replay.
559+
return Err(RouteError::RefreshTokenInvalid(refresh_token.id));
560+
};
561+
562+
let Some(next_refresh_token) = repo
563+
.oauth2_refresh_token()
564+
.lookup(next_refresh_token_id)
565+
.await?
566+
else {
567+
return Err(RouteError::NoSuchNextRefreshToken {
568+
next: next_refresh_token_id,
569+
previous: refresh_token.id,
570+
});
571+
};
572+
573+
// Check if the next refresh token was already consumed or not
574+
if !next_refresh_token.is_valid() {
575+
// XXX: This is a replay, we *may* want to invalidate the session
576+
return Err(RouteError::RefreshTokenInvalid(next_refresh_token.id));
577+
}
578+
579+
// Check if the associated access token was already used
580+
let Some(access_token_id) = next_refresh_token.access_token_id else {
581+
// This should in theory not happen: this means an access token got cleaned up,
582+
// but the refresh token was still valid.
583+
return Err(RouteError::NoAccessTokenOnRefreshToken {
584+
refresh_token: next_refresh_token.id,
585+
});
586+
};
587+
588+
// Load it
589+
let next_access_token = repo
590+
.oauth2_access_token()
591+
.lookup(access_token_id)
592+
.await?
593+
.ok_or(RouteError::NoSuchNextAccessToken {
594+
access_token: access_token_id,
595+
refresh_token: next_refresh_token_id,
596+
})?;
597+
598+
if next_access_token.is_used() {
599+
// XXX: This is a replay, we *may* want to invalidate the session
600+
return Err(RouteError::RefreshTokenInvalid(next_refresh_token.id));
601+
}
602+
603+
// Looks like it's a double-refresh, client lost their refresh token on
604+
// the way back. Let's revoke the two new access and refresh tokens, and
605+
// issue new ones
606+
info!(
607+
oauth2_session.id = %session.id,
608+
oauth2_client.id = %client.id,
609+
%refresh_token.id,
610+
"A refresh token was used twice, but the new refresh token was lost. Revoking the old ones and issuing new ones."
611+
);
612+
613+
repo.oauth2_access_token()
614+
.revoke(clock, next_access_token)
615+
.await?;
616+
617+
repo.oauth2_refresh_token()
618+
.revoke(clock, next_refresh_token)
619+
.await?;
620+
}
621+
537622
activity_tracker
538623
.record_oauth2_session(clock, &session)
539624
.await;
@@ -550,9 +635,12 @@ async fn refresh_token_grant(
550635
if let Some(access_token_id) = refresh_token.access_token_id {
551636
let access_token = repo.oauth2_access_token().lookup(access_token_id).await?;
552637
if let Some(access_token) = access_token {
553-
repo.oauth2_access_token()
554-
.revoke(clock, access_token)
555-
.await?;
638+
// If it is a double-refresh, it might already be revoked
639+
if !access_token.state.is_revoked() {
640+
repo.oauth2_access_token()
641+
.revoke(clock, access_token)
642+
.await?;
643+
}
556644
}
557645
}
558646

@@ -1123,6 +1211,175 @@ mod tests {
11231211
let _: AccessTokenResponse = response.json();
11241212
}
11251213

1214+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
1215+
async fn test_double_refresh(pool: PgPool) {
1216+
setup();
1217+
let state = TestState::from_pool(pool).await.unwrap();
1218+
1219+
// Provision a client
1220+
let request =
1221+
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
1222+
"client_uri": "https://example.com/",
1223+
"redirect_uris": ["https://example.com/callback"],
1224+
"token_endpoint_auth_method": "none",
1225+
"response_types": ["code"],
1226+
"grant_types": ["authorization_code", "refresh_token"],
1227+
}));
1228+
1229+
let response = state.request(request).await;
1230+
response.assert_status(StatusCode::CREATED);
1231+
1232+
let ClientRegistrationResponse { client_id, .. } = response.json();
1233+
1234+
// Let's provision a user and create a session for them. This part is hard to
1235+
// test with just HTTP requests, so we'll use the repository directly.
1236+
let mut repo = state.repository().await.unwrap();
1237+
1238+
let user = repo
1239+
.user()
1240+
.add(&mut state.rng(), &state.clock, "alice".to_owned())
1241+
.await
1242+
.unwrap();
1243+
1244+
let browser_session = repo
1245+
.browser_session()
1246+
.add(&mut state.rng(), &state.clock, &user, None)
1247+
.await
1248+
.unwrap();
1249+
1250+
// Lookup the client in the database.
1251+
let client = repo
1252+
.oauth2_client()
1253+
.find_by_client_id(&client_id)
1254+
.await
1255+
.unwrap()
1256+
.unwrap();
1257+
1258+
// Get a token pair
1259+
let session = repo
1260+
.oauth2_session()
1261+
.add_from_browser_session(
1262+
&mut state.rng(),
1263+
&state.clock,
1264+
&client,
1265+
&browser_session,
1266+
Scope::from_iter([OPENID]),
1267+
)
1268+
.await
1269+
.unwrap();
1270+
1271+
let (AccessToken { access_token, .. }, RefreshToken { refresh_token, .. }) =
1272+
generate_token_pair(
1273+
&mut state.rng(),
1274+
&state.clock,
1275+
&mut repo,
1276+
&session,
1277+
Duration::microseconds(5 * 60 * 1000 * 1000),
1278+
)
1279+
.await
1280+
.unwrap();
1281+
1282+
repo.save().await.unwrap();
1283+
1284+
// First check that the token is valid
1285+
assert!(state.is_access_token_valid(&access_token).await);
1286+
1287+
// Now call the token endpoint to get an access token.
1288+
let request =
1289+
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
1290+
"grant_type": "refresh_token",
1291+
"refresh_token": refresh_token,
1292+
"client_id": client.client_id,
1293+
}));
1294+
1295+
let first_response = state.request(request).await;
1296+
first_response.assert_status(StatusCode::OK);
1297+
let first_response: AccessTokenResponse = first_response.json();
1298+
1299+
// Call a second time, it should work, as we haven't done anything yet with the
1300+
// token
1301+
let request =
1302+
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
1303+
"grant_type": "refresh_token",
1304+
"refresh_token": refresh_token,
1305+
"client_id": client.client_id,
1306+
}));
1307+
1308+
let second_response = state.request(request).await;
1309+
second_response.assert_status(StatusCode::OK);
1310+
let second_response: AccessTokenResponse = second_response.json();
1311+
1312+
// Check that we got new tokens
1313+
assert_ne!(first_response.access_token, second_response.access_token);
1314+
assert_ne!(first_response.refresh_token, second_response.refresh_token);
1315+
1316+
// Check that the old-new token is invalid
1317+
assert!(
1318+
!state
1319+
.is_access_token_valid(&first_response.access_token)
1320+
.await
1321+
);
1322+
1323+
// Check that the new-new token is valid
1324+
assert!(
1325+
state
1326+
.is_access_token_valid(&second_response.access_token)
1327+
.await
1328+
);
1329+
1330+
// Do a third refresh, this one should not work, as we've used the new
1331+
// access token
1332+
let request =
1333+
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
1334+
"grant_type": "refresh_token",
1335+
"refresh_token": refresh_token,
1336+
"client_id": client.client_id,
1337+
}));
1338+
1339+
let third_response = state.request(request).await;
1340+
third_response.assert_status(StatusCode::BAD_REQUEST);
1341+
1342+
// The other reason we consider a new refresh token to be 'used' is if
1343+
// it was already used in a refresh
1344+
// So, if we do a refresh with the second_response.refresh_token, then
1345+
// another refresh with the result, redoing one with
1346+
// second_response.refresh_token again should fail
1347+
let request =
1348+
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
1349+
"grant_type": "refresh_token",
1350+
"refresh_token": second_response.refresh_token,
1351+
"client_id": client.client_id,
1352+
}));
1353+
1354+
// This one is fine
1355+
let fourth_response = state.request(request).await;
1356+
fourth_response.assert_status(StatusCode::OK);
1357+
let fourth_response: AccessTokenResponse = fourth_response.json();
1358+
1359+
// Do another one, it should be fine as well
1360+
let request =
1361+
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
1362+
"grant_type": "refresh_token",
1363+
"refresh_token": fourth_response.refresh_token,
1364+
"client_id": client.client_id,
1365+
}));
1366+
1367+
let fifth_response = state.request(request).await;
1368+
fifth_response.assert_status(StatusCode::OK);
1369+
1370+
// But now, if we re-do with the second_response.refresh_token, it should
1371+
// fail
1372+
let request =
1373+
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
1374+
"grant_type": "refresh_token",
1375+
"refresh_token": second_response.refresh_token,
1376+
"client_id": client.client_id,
1377+
}));
1378+
1379+
let sixth_response = state.request(request).await;
1380+
sixth_response.assert_status(StatusCode::BAD_REQUEST);
1381+
}
1382+
11261383
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
11271384
async fn test_client_credentials(pool: PgPool) {
11281385
setup();

0 commit comments

Comments
 (0)