From 7d9d97a006fd5104c4dc156b91cfe98d3f213b79 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 8 Dec 2023 16:11:20 +0100 Subject: [PATCH] Implement the device access token request --- .../src/oauth2/authorization/complete.rs | 2 +- crates/handlers/src/oauth2/discovery.rs | 1 + crates/handlers/src/oauth2/mod.rs | 8 +- crates/handlers/src/oauth2/token.rs | 362 +++++++++++++++++- crates/oauth2-types/src/requests.rs | 3 +- 5 files changed, 366 insertions(+), 10 deletions(-) diff --git a/crates/handlers/src/oauth2/authorization/complete.rs b/crates/handlers/src/oauth2/authorization/complete.rs index c0036e9a..7e4de829 100644 --- a/crates/handlers/src/oauth2/authorization/complete.rs +++ b/crates/handlers/src/oauth2/authorization/complete.rs @@ -280,7 +280,7 @@ pub(crate) async fn complete( url_builder, &key_store, client, - &grant, + Some(&grant), browser_session, None, Some(&valid_authentication), diff --git a/crates/handlers/src/oauth2/discovery.rs b/crates/handlers/src/oauth2/discovery.rs index 374817bd..9a883209 100644 --- a/crates/handlers/src/oauth2/discovery.rs +++ b/crates/handlers/src/oauth2/discovery.rs @@ -90,6 +90,7 @@ pub(crate) async fn get( GrantType::AuthorizationCode, GrantType::RefreshToken, GrantType::ClientCredentials, + GrantType::DeviceCode, ]); let token_endpoint_auth_methods_supported = client_auth_methods_supported.clone(); diff --git a/crates/handlers/src/oauth2/mod.rs b/crates/handlers/src/oauth2/mod.rs index 3001a803..8f220c6e 100644 --- a/crates/handlers/src/oauth2/mod.rs +++ b/crates/handlers/src/oauth2/mod.rs @@ -59,7 +59,7 @@ pub(crate) fn generate_id_token( url_builder: &UrlBuilder, key_store: &Keystore, client: &Client, - grant: &AuthorizationGrant, + grant: Option<&AuthorizationGrant>, browser_session: &BrowserSession, access_token: Option<&AccessToken>, last_authentication: Option<&Authentication>, @@ -72,8 +72,8 @@ pub(crate) fn generate_id_token( claims::IAT.insert(&mut claims, now)?; claims::EXP.insert(&mut claims, now + Duration::hours(1))?; - if let Some(ref nonce) = grant.nonce { - claims::NONCE.insert(&mut claims, nonce.clone())?; + if let Some(nonce) = grant.and_then(|grant| grant.nonce.as_ref()) { + claims::NONCE.insert(&mut claims, nonce)?; } 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)?)?; } - 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)?)?; } diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index 887a5d46..879964b4 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -21,7 +21,7 @@ use mas_axum_utils::{ http_client_factory::HttpClientFactory, 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_oidc_client::types::scope::ScopeToken; use mas_policy::Policy; @@ -40,7 +40,7 @@ use oauth2_types::{ pkce::CodeChallengeError, requests::{ AccessTokenRequest, AccessTokenResponse, AuthorizationCodeGrant, ClientCredentialsGrant, - GrantType, RefreshTokenGrant, + DeviceCodeGrant, GrantType, RefreshTokenGrant, }, scope, }; @@ -123,6 +123,18 @@ pub(crate) enum RouteError { #[error("failed to load oauth session")] 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 { @@ -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::DeviceCodeExchanged | Self::RefreshTokenNotFound | Self::RefreshTokenInvalid(_) | Self::SessionInvalid(_) @@ -265,6 +290,20 @@ pub(crate) async fn post( ) .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); } @@ -397,7 +436,7 @@ async fn authorization_code_grant( url_builder, key_store, client, - &authz_grant, + Some(&authz_grant), &browser_session, Some(&access_token), last_authentication.as_ref(), @@ -575,6 +614,136 @@ async fn client_credentials_grant( 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)] mod tests { use hyper::Request; @@ -582,7 +751,7 @@ mod tests { use mas_router::SimpleRoute; use oauth2_types::{ registration::ClientRegistrationResponse, - requests::ResponseMode, + requests::{DeviceAuthorizationResponse, ResponseMode}, scope::{Scope, OPENID}, }; use sqlx::PgPool; @@ -1048,6 +1217,191 @@ mod tests { 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")] async fn test_unsupported_grant(pool: PgPool) { init_tracing(); diff --git a/crates/oauth2-types/src/requests.rs b/crates/oauth2-types/src/requests.rs index d28c0202..76b16946 100644 --- a/crates/oauth2-types/src/requests.rs +++ b/crates/oauth2-types/src/requests.rs @@ -500,7 +500,7 @@ pub struct ClientCredentialsGrant { #[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct DeviceCodeGrant { /// The device verification code, from the device authorization response. - pub device_code: Option, + pub device_code: String, } impl fmt::Debug for DeviceCodeGrant { @@ -559,6 +559,7 @@ pub enum GrantType { /// [Token Endpoint]: https://www.rfc-editor.org/rfc/rfc6749#section-3.2 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(tag = "grant_type", rename_all = "snake_case")] +#[non_exhaustive] pub enum AccessTokenRequest { /// A request in the Authorization Code flow. AuthorizationCode(AuthorizationCodeGrant),