Skip to content

Commit 0d27c34

Browse files
authored
Support introspection of personal access tokens (#5171)
You can now present a personal access token (mpt_ prefix) at introspection and have it accepted. This means personal access tokens can be presented to Synapse and used on the client-server API.
2 parents 5b02453 + 66f8814 commit 0d27c34

File tree

7 files changed

+325
-7
lines changed

7 files changed

+325
-7
lines changed

crates/handlers/src/activity_tracker/mod.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ mod worker;
1010
use std::net::IpAddr;
1111

1212
use chrono::{DateTime, Utc};
13-
use mas_data_model::{BrowserSession, Clock, CompatSession, Session};
13+
use mas_data_model::{
14+
BrowserSession, Clock, CompatSession, Session, personal::session::PersonalSession,
15+
};
1416
use mas_storage::BoxRepositoryFactory;
1517
use tokio_util::{sync::CancellationToken, task::TaskTracker};
1618
use ulid::Ulid;
@@ -115,7 +117,7 @@ impl ActivityTracker {
115117
pub async fn record_personal_access_token_session(
116118
&self,
117119
clock: &dyn Clock,
118-
session: &Session,
120+
session: &PersonalSession,
119121
ip: Option<IpAddr>,
120122
) {
121123
let res = self

crates/handlers/src/activity_tracker/worker.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,9 @@ impl Worker {
257257
repo.compat_session()
258258
.record_batch_activity(compat_sessions)
259259
.await?;
260-
// TODO: personal sessions: record
260+
repo.personal_session()
261+
.record_batch_activity(personal_sessions)
262+
.await?;
261263

262264
repo.save().await?;
263265
self.pending_records.clear();

crates/handlers/src/oauth2/introspection.rs

Lines changed: 229 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ use mas_axum_utils::{
1515
client_authorization::{ClientAuthorization, CredentialsVerificationError},
1616
record_error,
1717
};
18-
use mas_data_model::{BoxClock, Clock, Device, TokenFormatError, TokenType};
18+
use mas_data_model::{
19+
BoxClock, Clock, Device, TokenFormatError, TokenType, personal::session::PersonalSessionOwner,
20+
};
1921
use mas_iana::oauth::{OAuthClientAuthenticationMethod, OAuthTokenTypeHint};
2022
use mas_keystore::Encrypter;
2123
use mas_matrix::HomeserverConnection;
@@ -93,6 +95,14 @@ pub enum RouteError {
9395
#[error("unknown compat session {0}")]
9496
CantLoadCompatSession(Ulid),
9597

98+
/// The personal access token session is not valid.
99+
#[error("invalid personal access token session {0}")]
100+
InvalidPersonalSession(Ulid),
101+
102+
/// The personal access token session could not be found in the database.
103+
#[error("unknown personal access token session {0}")]
104+
CantLoadPersonalSession(Ulid),
105+
96106
/// The Device ID in the compat session can't be encoded as a scope
97107
#[error("device ID contains characters that are not allowed in a scope")]
98108
CantEncodeDeviceID(#[from] mas_data_model::ToScopeTokenError),
@@ -103,6 +113,9 @@ pub enum RouteError {
103113
#[error("unknown user {0}")]
104114
CantLoadUser(Ulid),
105115

116+
#[error("unknown OAuth2 client {0}")]
117+
CantLoadOAuth2Client(Ulid),
118+
106119
#[error("bad request")]
107120
BadRequest,
108121

@@ -131,7 +144,9 @@ impl IntoResponse for RouteError {
131144
e @ (Self::Internal(_)
132145
| Self::CantLoadCompatSession(_)
133146
| Self::CantLoadOAuthSession(_)
147+
| Self::CantLoadPersonalSession(_)
134148
| Self::CantLoadUser(_)
149+
| Self::CantLoadOAuth2Client(_)
135150
| Self::FailedToVerifyToken(_)) => (
136151
StatusCode::INTERNAL_SERVER_ERROR,
137152
Json(
@@ -167,6 +182,7 @@ impl IntoResponse for RouteError {
167182
| Self::InvalidUser(_)
168183
| Self::InvalidCompatSession(_)
169184
| Self::InvalidOAuthSession(_)
185+
| Self::InvalidPersonalSession(_)
170186
| Self::InvalidTokenFormat(_)
171187
| Self::CantEncodeDeviceID(_) => {
172188
INTROSPECTION_COUNTER.add(1, &[KeyValue::new(ACTIVE.clone(), false)]);
@@ -627,8 +643,94 @@ pub(crate) async fn post(
627643
}
628644

629645
TokenType::PersonalAccessToken => {
630-
// TODO
631-
return Err(RouteError::UnknownToken(TokenType::PersonalAccessToken));
646+
let access_token = repo
647+
.personal_access_token()
648+
.find_by_token(token)
649+
.await?
650+
.ok_or(RouteError::UnknownToken(TokenType::AccessToken))?;
651+
652+
if !access_token.is_valid(clock.now()) {
653+
return Err(RouteError::InvalidToken(TokenType::AccessToken));
654+
}
655+
656+
let session = repo
657+
.personal_session()
658+
.lookup(access_token.session_id)
659+
.await?
660+
.ok_or(RouteError::CantLoadPersonalSession(access_token.session_id))?;
661+
662+
if !session.is_valid() {
663+
return Err(RouteError::InvalidPersonalSession(session.id));
664+
}
665+
666+
let actor_user = repo
667+
.user()
668+
.lookup(session.actor_user_id)
669+
.await?
670+
.ok_or(RouteError::CantLoadUser(session.actor_user_id))?;
671+
672+
if !actor_user.is_valid() {
673+
return Err(RouteError::InvalidUser(actor_user.id));
674+
}
675+
676+
let client_id = match session.owner {
677+
PersonalSessionOwner::User(owner_user_id) => {
678+
let owner_user = repo
679+
.user()
680+
.lookup(owner_user_id)
681+
.await?
682+
.ok_or(RouteError::CantLoadUser(owner_user_id))?;
683+
684+
if !owner_user.is_valid() {
685+
return Err(RouteError::InvalidUser(owner_user.id));
686+
}
687+
688+
None
689+
}
690+
PersonalSessionOwner::OAuth2Client(owner_client_id) => {
691+
let owner_client = repo
692+
.oauth2_client()
693+
.lookup(owner_client_id)
694+
.await?
695+
.ok_or(RouteError::CantLoadOAuth2Client(owner_client_id))?;
696+
697+
// OAuth2 clients are always valid if they're in the database
698+
Some(owner_client.client_id.clone())
699+
}
700+
};
701+
702+
activity_tracker
703+
.record_personal_access_token_session(&clock, &session, ip)
704+
.await;
705+
706+
INTROSPECTION_COUNTER.add(
707+
1,
708+
&[
709+
KeyValue::new(KIND, "personal_access_token"),
710+
KeyValue::new(ACTIVE, true),
711+
],
712+
);
713+
714+
let scope = normalize_scope(session.scope);
715+
716+
IntrospectionResponse {
717+
active: true,
718+
scope: Some(scope),
719+
client_id,
720+
username: Some(actor_user.username),
721+
token_type: Some(OAuthTokenTypeHint::AccessToken),
722+
exp: access_token.expires_at,
723+
expires_in: access_token
724+
.expires_at
725+
.map(|expires_at| expires_at.signed_duration_since(clock.now())),
726+
iat: Some(access_token.created_at),
727+
nbf: Some(access_token.created_at),
728+
sub: Some(actor_user.sub),
729+
aud: None,
730+
iss: None,
731+
jti: None,
732+
device_id: None,
733+
}
632734
}
633735
};
634736

@@ -641,7 +743,9 @@ pub(crate) async fn post(
641743
mod tests {
642744
use chrono::Duration;
643745
use hyper::{Request, StatusCode};
644-
use mas_data_model::{AccessToken, Clock, RefreshToken};
746+
use mas_data_model::{
747+
AccessToken, Clock, RefreshToken, TokenType, personal::session::PersonalSessionOwner,
748+
};
645749
use mas_iana::oauth::OAuthTokenTypeHint;
646750
use mas_matrix::{HomeserverConnection, MockHomeserverConnection, ProvisionRequest};
647751
use mas_router::{OAuth2Introspection, OAuth2RegistrationEndpoint, SimpleRoute};
@@ -1074,4 +1178,125 @@ mod tests {
10741178
let response: ClientError = response.json();
10751179
assert_eq!(response.error, ClientErrorCode::AccessDenied);
10761180
}
1181+
1182+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
1183+
async fn test_introspect_personal_access_tokens(pool: PgPool) {
1184+
setup();
1185+
let state = TestState::from_pool(pool).await.unwrap();
1186+
1187+
// Provision a client which will be used to do introspection requests
1188+
let request = Request::post(OAuth2RegistrationEndpoint::PATH).json(json!({
1189+
"client_uri": "https://introspecting.com/",
1190+
"grant_types": [],
1191+
"token_endpoint_auth_method": "client_secret_basic",
1192+
}));
1193+
1194+
let response = state.request(request).await;
1195+
response.assert_status(StatusCode::CREATED);
1196+
let client: ClientRegistrationResponse = response.json();
1197+
let introspecting_client_id = client.client_id;
1198+
let introspecting_client_secret = client.client_secret.unwrap();
1199+
1200+
let mut repo = state.repository().await.unwrap();
1201+
1202+
// Provision an owner user (who provisions the personal session)
1203+
let owner_user = repo
1204+
.user()
1205+
.add(&mut state.rng(), &state.clock, "admin".to_owned())
1206+
.await
1207+
.unwrap();
1208+
1209+
// Provision an actor user (which the token represents)
1210+
let actor_user = repo
1211+
.user()
1212+
.add(&mut state.rng(), &state.clock, "bruce".to_owned())
1213+
.await
1214+
.unwrap();
1215+
1216+
// admin creates a personal session to control bruce's account
1217+
let personal_session = repo
1218+
.personal_session()
1219+
.add(
1220+
&mut state.rng(),
1221+
&state.clock,
1222+
PersonalSessionOwner::User(owner_user.id),
1223+
&actor_user,
1224+
"Test Personal Access Token".to_owned(),
1225+
Scope::from_iter([OPENID]),
1226+
)
1227+
.await
1228+
.unwrap();
1229+
1230+
// Generate a personal access token with proper token format
1231+
let token_string = TokenType::PersonalAccessToken.generate(&mut state.rng());
1232+
let _personal_access_token = repo
1233+
.personal_access_token()
1234+
.add(
1235+
&mut state.rng(),
1236+
&state.clock,
1237+
&personal_session,
1238+
&token_string,
1239+
Some(Duration::try_hours(1).unwrap()),
1240+
)
1241+
.await
1242+
.unwrap();
1243+
1244+
repo.save().await.unwrap();
1245+
1246+
// Now that we have a personal access token, we can introspect it
1247+
let request = Request::post(OAuth2Introspection::PATH)
1248+
.basic_auth(&introspecting_client_id, &introspecting_client_secret)
1249+
.form(json!({ "token": token_string }));
1250+
let response = state.request(request).await;
1251+
response.assert_status(StatusCode::OK);
1252+
let response: IntrospectionResponse = response.json();
1253+
assert!(response.active);
1254+
// Actor user
1255+
assert_eq!(response.username, Some("bruce".to_owned()));
1256+
// Not owned by a client
1257+
assert_eq!(response.client_id, None);
1258+
assert_eq!(response.token_type, Some(OAuthTokenTypeHint::AccessToken));
1259+
assert_eq!(response.scope, Some(Scope::from_iter([OPENID])));
1260+
1261+
// Do the same request, but with a token_type_hint
1262+
let last_active = state.clock.now();
1263+
let request = Request::post(OAuth2Introspection::PATH)
1264+
.basic_auth(&introspecting_client_id, &introspecting_client_secret)
1265+
.form(json!({"token": token_string, "token_type_hint": "access_token"}));
1266+
let response = state.request(request).await;
1267+
response.assert_status(StatusCode::OK);
1268+
let response: IntrospectionResponse = response.json();
1269+
assert!(response.active);
1270+
1271+
// Do the same request, but with the wrong token_type_hint
1272+
let request = Request::post(OAuth2Introspection::PATH)
1273+
.basic_auth(&introspecting_client_id, &introspecting_client_secret)
1274+
.form(json!({"token": token_string, "token_type_hint": "refresh_token"}));
1275+
let response = state.request(request).await;
1276+
response.assert_status(StatusCode::OK);
1277+
let response: IntrospectionResponse = response.json();
1278+
assert!(!response.active); // It shouldn't be active with wrong hint
1279+
1280+
// Advance the clock to invalidate the access token
1281+
state.clock.advance(Duration::try_hours(2).unwrap());
1282+
1283+
let request = Request::post(OAuth2Introspection::PATH)
1284+
.basic_auth(&introspecting_client_id, &introspecting_client_secret)
1285+
.form(json!({ "token": token_string }));
1286+
let response = state.request(request).await;
1287+
response.assert_status(StatusCode::OK);
1288+
let response: IntrospectionResponse = response.json();
1289+
assert!(!response.active); // It shouldn't be active anymore
1290+
1291+
state.activity_tracker.flush().await;
1292+
let mut repo = state.repository().await.unwrap();
1293+
let session = repo
1294+
.personal_session()
1295+
.lookup(personal_session.id)
1296+
.await
1297+
.unwrap()
1298+
.unwrap();
1299+
assert_eq!(session.last_active_at, Some(last_active));
1300+
repo.save().await.unwrap();
1301+
}
10771302
}

crates/oauth2-types/src/requests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,7 @@ pub struct IntrospectionResponse {
807807
pub jti: Option<String>,
808808

809809
/// MAS extension: explicit device ID
810+
/// Only used for compatibility access and refresh tokens.
810811
pub device_id: Option<String>,
811812
}
812813

crates/storage-pg/.sqlx/query-64b6e274e2bed6814f5ae41ddf57093589f7d1b2b8458521b635546b8012041e.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)