1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-07 17:03:01 +03:00

Implement the device access token request

This commit is contained in:
Quentin Gliech
2023-12-08 16:11:20 +01:00
parent 67ab42155c
commit 7d9d97a006
5 changed files with 366 additions and 10 deletions

View File

@@ -280,7 +280,7 @@ pub(crate) async fn complete(
url_builder, url_builder,
&key_store, &key_store,
client, client,
&grant, Some(&grant),
browser_session, browser_session,
None, None,
Some(&valid_authentication), Some(&valid_authentication),

View File

@@ -90,6 +90,7 @@ pub(crate) async fn get(
GrantType::AuthorizationCode, GrantType::AuthorizationCode,
GrantType::RefreshToken, GrantType::RefreshToken,
GrantType::ClientCredentials, GrantType::ClientCredentials,
GrantType::DeviceCode,
]); ]);
let token_endpoint_auth_methods_supported = client_auth_methods_supported.clone(); let token_endpoint_auth_methods_supported = client_auth_methods_supported.clone();

View File

@@ -59,7 +59,7 @@ pub(crate) fn generate_id_token(
url_builder: &UrlBuilder, url_builder: &UrlBuilder,
key_store: &Keystore, key_store: &Keystore,
client: &Client, client: &Client,
grant: &AuthorizationGrant, grant: Option<&AuthorizationGrant>,
browser_session: &BrowserSession, browser_session: &BrowserSession,
access_token: Option<&AccessToken>, access_token: Option<&AccessToken>,
last_authentication: Option<&Authentication>, last_authentication: Option<&Authentication>,
@@ -72,8 +72,8 @@ pub(crate) fn generate_id_token(
claims::IAT.insert(&mut claims, now)?; claims::IAT.insert(&mut claims, now)?;
claims::EXP.insert(&mut claims, now + Duration::hours(1))?; claims::EXP.insert(&mut claims, now + Duration::hours(1))?;
if let Some(ref nonce) = grant.nonce { if let Some(nonce) = grant.and_then(|grant| grant.nonce.as_ref()) {
claims::NONCE.insert(&mut claims, nonce.clone())?; claims::NONCE.insert(&mut claims, nonce)?;
} }
if let Some(last_authentication) = last_authentication { if let Some(last_authentication) = last_authentication {
@@ -92,7 +92,7 @@ pub(crate) fn generate_id_token(
claims::AT_HASH.insert(&mut claims, hash_token(&alg, &access_token.access_token)?)?; claims::AT_HASH.insert(&mut claims, hash_token(&alg, &access_token.access_token)?)?;
} }
if let Some(ref code) = grant.code { if let Some(code) = grant.and_then(|grant| grant.code.as_ref()) {
claims::C_HASH.insert(&mut claims, hash_token(&alg, &code.code)?)?; claims::C_HASH.insert(&mut claims, hash_token(&alg, &code.code)?)?;
} }

View File

@@ -21,7 +21,7 @@ use mas_axum_utils::{
http_client_factory::HttpClientFactory, http_client_factory::HttpClientFactory,
sentry::SentryEventID, sentry::SentryEventID,
}; };
use mas_data_model::{AuthorizationGrantStage, Client, Device, TokenType}; use mas_data_model::{AuthorizationGrantStage, Client, Device, DeviceCodeGrantState, TokenType};
use mas_keystore::{Encrypter, Keystore}; use mas_keystore::{Encrypter, Keystore};
use mas_oidc_client::types::scope::ScopeToken; use mas_oidc_client::types::scope::ScopeToken;
use mas_policy::Policy; use mas_policy::Policy;
@@ -40,7 +40,7 @@ use oauth2_types::{
pkce::CodeChallengeError, pkce::CodeChallengeError,
requests::{ requests::{
AccessTokenRequest, AccessTokenResponse, AuthorizationCodeGrant, ClientCredentialsGrant, AccessTokenRequest, AccessTokenResponse, AuthorizationCodeGrant, ClientCredentialsGrant,
GrantType, RefreshTokenGrant, DeviceCodeGrant, GrantType, RefreshTokenGrant,
}, },
scope, scope,
}; };
@@ -123,6 +123,18 @@ pub(crate) enum RouteError {
#[error("failed to load oauth session")] #[error("failed to load oauth session")]
NoSuchOAuthSession, NoSuchOAuthSession,
#[error("device code grant expired")]
DeviceCodeExpired,
#[error("device code grant is still pending")]
DeviceCodePending,
#[error("device code grant was rejected")]
DeviceCodeRejected,
#[error("device code grant was already exchanged")]
DeviceCodeExchanged,
} }
impl IntoResponse for RouteError { impl IntoResponse for RouteError {
@@ -165,7 +177,20 @@ impl IntoResponse for RouteError {
), ),
), ),
), ),
Self::DeviceCodeRejected => (
StatusCode::FORBIDDEN,
Json(ClientError::from(ClientErrorCode::AccessDenied)),
),
Self::DeviceCodeExpired => (
StatusCode::FORBIDDEN,
Json(ClientError::from(ClientErrorCode::ExpiredToken)),
),
Self::DeviceCodePending => (
StatusCode::FORBIDDEN,
Json(ClientError::from(ClientErrorCode::AuthorizationPending)),
),
Self::InvalidGrant Self::InvalidGrant
| Self::DeviceCodeExchanged
| Self::RefreshTokenNotFound | Self::RefreshTokenNotFound
| Self::RefreshTokenInvalid(_) | Self::RefreshTokenInvalid(_)
| Self::SessionInvalid(_) | Self::SessionInvalid(_)
@@ -265,6 +290,20 @@ pub(crate) async fn post(
) )
.await? .await?
} }
AccessTokenRequest::DeviceCode(grant) => {
device_code_grant(
&mut rng,
&clock,
&activity_tracker,
&grant,
&client,
&key_store,
&url_builder,
&site_config,
repo,
)
.await?
}
_ => { _ => {
return Err(RouteError::UnsupportedGrantType); return Err(RouteError::UnsupportedGrantType);
} }
@@ -397,7 +436,7 @@ async fn authorization_code_grant(
url_builder, url_builder,
key_store, key_store,
client, client,
&authz_grant, Some(&authz_grant),
&browser_session, &browser_session,
Some(&access_token), Some(&access_token),
last_authentication.as_ref(), last_authentication.as_ref(),
@@ -575,6 +614,136 @@ async fn client_credentials_grant(
Ok((params, repo)) Ok((params, repo))
} }
async fn device_code_grant(
rng: &mut BoxRng,
clock: &impl Clock,
activity_tracker: &BoundActivityTracker,
grant: &DeviceCodeGrant,
client: &Client,
key_store: &Keystore,
url_builder: &UrlBuilder,
site_config: &SiteConfig,
mut repo: BoxRepository,
) -> Result<(AccessTokenResponse, BoxRepository), RouteError> {
// TODO: Check that the client is allowed to use this grant type
//if !client.grant_types.contains(&GrantType::DeviceCode) {
// return Err(RouteError::UnauthorizedClient);
//}
let grant = repo
.oauth2_device_code_grant()
.find_by_device_code(&grant.device_code)
.await?
.ok_or(RouteError::GrantNotFound)?;
// Check that the client match
if client.id != grant.client_id {
return Err(RouteError::ClientIDMismatch {
expected: grant.client_id,
actual: client.id,
});
}
if grant.expires_at < clock.now() {
return Err(RouteError::DeviceCodeExpired);
}
let browser_session_id = match &grant.state {
DeviceCodeGrantState::Pending => {
return Err(RouteError::DeviceCodePending);
}
DeviceCodeGrantState::Rejected { .. } => {
return Err(RouteError::DeviceCodeRejected);
}
DeviceCodeGrantState::Exchanged { .. } => {
return Err(RouteError::DeviceCodeExchanged);
}
DeviceCodeGrantState::Fulfilled {
browser_session_id, ..
} => browser_session_id,
};
let browser_session = repo
.browser_session()
.lookup(*browser_session_id)
.await?
.ok_or(RouteError::NoSuchBrowserSession)?;
// Start the session
let session = repo
.oauth2_session()
.add_from_browser_session(rng, clock, client, &browser_session, grant.scope)
.await?;
let ttl = site_config.access_token_ttl;
let access_token_str = TokenType::AccessToken.generate(rng);
let access_token = repo
.oauth2_access_token()
.add(rng, clock, &session, access_token_str, Some(ttl))
.await?;
let mut params =
AccessTokenResponse::new(access_token.access_token.clone()).with_expires_in(ttl);
// If the client uses the refresh token grant type, we also generate a refresh token
if client.grant_types.contains(&GrantType::RefreshToken) {
let refresh_token_str = TokenType::RefreshToken.generate(rng);
let refresh_token = repo
.oauth2_refresh_token()
.add(rng, clock, &session, &access_token, refresh_token_str)
.await?;
params = params.with_refresh_token(refresh_token.refresh_token);
}
// If the client asked for an ID token, we generate one
if session.scope.contains(&scope::OPENID) {
let id_token = generate_id_token(
rng,
clock,
url_builder,
key_store,
client,
None,
&browser_session,
Some(&access_token),
None,
)?;
params = params.with_id_token(id_token);
}
// Look for device to provision
for scope in &*session.scope {
if let Some(device) = Device::from_scope_token(scope) {
// Note that we're not waiting for the job to finish, we just schedule it. We
// might get in a situation where the provisioning job is not finished when the
// client does its first request to the Homeserver. This is fine for now, since
// Synapse still provision devices on-the-fly if it doesn't find them in the
// database.
repo.job()
.schedule_job(ProvisionDeviceJob::new(&browser_session.user, &device))
.await?;
}
}
// XXX: there is a potential (but unlikely) race here, where the activity for
// the session is recorded before the transaction is committed. We would have to
// save the repository here to fix that.
activity_tracker
.record_oauth2_session(clock, &session)
.await;
if !session.scope.is_empty() {
// We only return the scope if it's not empty
params = params.with_scope(session.scope);
}
Ok((params, repo))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use hyper::Request; use hyper::Request;
@@ -582,7 +751,7 @@ mod tests {
use mas_router::SimpleRoute; use mas_router::SimpleRoute;
use oauth2_types::{ use oauth2_types::{
registration::ClientRegistrationResponse, registration::ClientRegistrationResponse,
requests::ResponseMode, requests::{DeviceAuthorizationResponse, ResponseMode},
scope::{Scope, OPENID}, scope::{Scope, OPENID},
}; };
use sqlx::PgPool; use sqlx::PgPool;
@@ -1048,6 +1217,191 @@ mod tests {
response.assert_status(StatusCode::OK); response.assert_status(StatusCode::OK);
} }
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_device_code_grant(pool: PgPool) {
init_tracing();
let state = TestState::from_pool(pool).await.unwrap();
// Provision a client
let request =
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
"client_uri": "https://example.com/",
"contacts": ["contact@example.com"],
"token_endpoint_auth_method": "none",
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
"response_types": [],
}));
let response = state.request(request).await;
response.assert_status(StatusCode::CREATED);
let response: ClientRegistrationResponse = response.json();
let client_id = response.client_id;
// Start a device code grant
let request = Request::post(mas_router::OAuth2DeviceAuthorizationEndpoint::PATH).form(
serde_json::json!({
"client_id": client_id,
"scope": "openid",
}),
);
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let device_grant: DeviceAuthorizationResponse = response.json();
// Poll the token endpoint, it should be pending
let request =
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_grant.device_code,
"client_id": client_id,
}));
let response = state.request(request).await;
response.assert_status(StatusCode::FORBIDDEN);
let ClientError { error, .. } = response.json();
assert_eq!(error, ClientErrorCode::AuthorizationPending);
// Let's provision a user and create a browser session for them. This part is hard to
// test with just HTTP requests, so we'll use the repository directly.
let mut repo = state.repository().await.unwrap();
let user = repo
.user()
.add(&mut state.rng(), &state.clock, "alice".to_owned())
.await
.unwrap();
let browser_session = repo
.browser_session()
.add(&mut state.rng(), &state.clock, &user, None)
.await
.unwrap();
// Find the grant
let grant = repo
.oauth2_device_code_grant()
.find_by_user_code(&device_grant.user_code)
.await
.unwrap()
.unwrap();
// And fulfill it
let grant = repo
.oauth2_device_code_grant()
.fulfill(&state.clock, grant, &browser_session)
.await
.unwrap();
repo.save().await.unwrap();
// Now call the token endpoint to get an access token.
let request =
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": grant.device_code,
"client_id": client_id,
}));
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: AccessTokenResponse = response.json();
// Check that the token is valid
assert!(state.is_access_token_valid(&response.access_token).await);
// We advertised the refresh token grant type, so we should have a refresh token
assert!(response.refresh_token.is_some());
// We asked for the openid scope, so we should have an ID token
assert!(response.id_token.is_some());
// Do another grant and make it expire
let request = Request::post(mas_router::OAuth2DeviceAuthorizationEndpoint::PATH).form(
serde_json::json!({
"client_id": client_id,
"scope": "openid",
}),
);
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let device_grant: DeviceAuthorizationResponse = response.json();
// Poll the token endpoint, it should be pending
let request =
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_grant.device_code,
"client_id": client_id,
}));
let response = state.request(request).await;
response.assert_status(StatusCode::FORBIDDEN);
let ClientError { error, .. } = response.json();
assert_eq!(error, ClientErrorCode::AuthorizationPending);
state.clock.advance(Duration::hours(1));
// Poll again, it should be expired
let request =
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_grant.device_code,
"client_id": client_id,
}));
let response = state.request(request).await;
response.assert_status(StatusCode::FORBIDDEN);
let ClientError { error, .. } = response.json();
assert_eq!(error, ClientErrorCode::ExpiredToken);
// Do another grant and reject it
let request = Request::post(mas_router::OAuth2DeviceAuthorizationEndpoint::PATH).form(
serde_json::json!({
"client_id": client_id,
"scope": "openid",
}),
);
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let device_grant: DeviceAuthorizationResponse = response.json();
// Find the grant and reject it
let mut repo = state.repository().await.unwrap();
// Find the grant
let grant = repo
.oauth2_device_code_grant()
.find_by_user_code(&device_grant.user_code)
.await
.unwrap()
.unwrap();
// And reject it
let grant = repo
.oauth2_device_code_grant()
.reject(&state.clock, grant, &browser_session)
.await
.unwrap();
repo.save().await.unwrap();
// Poll the token endpoint, it should be rejected
let request =
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": grant.device_code,
"client_id": client_id,
}));
let response = state.request(request).await;
response.assert_status(StatusCode::FORBIDDEN);
let ClientError { error, .. } = response.json();
assert_eq!(error, ClientErrorCode::AccessDenied);
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_unsupported_grant(pool: PgPool) { async fn test_unsupported_grant(pool: PgPool) {
init_tracing(); init_tracing();

View File

@@ -500,7 +500,7 @@ pub struct ClientCredentialsGrant {
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] #[derive(Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct DeviceCodeGrant { pub struct DeviceCodeGrant {
/// The device verification code, from the device authorization response. /// The device verification code, from the device authorization response.
pub device_code: Option<Scope>, pub device_code: String,
} }
impl fmt::Debug for DeviceCodeGrant { impl fmt::Debug for DeviceCodeGrant {
@@ -559,6 +559,7 @@ pub enum GrantType {
/// [Token Endpoint]: https://www.rfc-editor.org/rfc/rfc6749#section-3.2 /// [Token Endpoint]: https://www.rfc-editor.org/rfc/rfc6749#section-3.2
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "grant_type", rename_all = "snake_case")] #[serde(tag = "grant_type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum AccessTokenRequest { pub enum AccessTokenRequest {
/// A request in the Authorization Code flow. /// A request in the Authorization Code flow.
AuthorizationCode(AuthorizationCodeGrant), AuthorizationCode(AuthorizationCodeGrant),