diff --git a/Cargo.lock b/Cargo.lock index 52cc04ba..2114ddd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3072,6 +3072,7 @@ dependencies = [ "tower", "tower-http", "tracing", + "tracing-subscriber", "ulid", "url", "zeroize", diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index 552db7d9..12c479b3 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -76,8 +76,11 @@ oauth2-types = { path = "../oauth2-types" } [dev-dependencies] indoc = "2.0.0" insta = "1.26.0" +tracing-subscriber = "0.3.16" [features] +default = ["webpki-roots"] + # Use the native root certificates native-roots = ["mas-axum-utils/native-roots", "mas-http/native-roots"] # Use the webpki root certificates diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 866d49c4..1ab8af34 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -367,6 +367,7 @@ where #[cfg(test)] async fn test_state(pool: sqlx::PgPool) -> Result { use mas_email::MailTransport; + use mas_keystore::{JsonWebKey, JsonWebKeySet, PrivateKey}; use crate::passwords::Hasher; @@ -378,8 +379,13 @@ async fn test_state(pool: sqlx::PgPool) -> Result { let templates = Templates::load(workspace_root.join("templates"), url_builder.clone()).await?; - // TODO: add test keys to the store - let key_store = Keystore::default(); + // TODO: add more test keys to the store + let rsa = + PrivateKey::load_pem(include_str!("../../keystore/tests/keys/rsa.pkcs1.pem")).unwrap(); + let rsa = JsonWebKey::new(rsa).with_kid("test-rsa"); + + let jwks = JsonWebKeySet::new(vec![rsa]); + let key_store = Keystore::new(jwks); let encrypter = Encrypter::new(&[0x42; 32]); diff --git a/crates/handlers/src/oauth2/revoke.rs b/crates/handlers/src/oauth2/revoke.rs index b97df01c..ed9aa0b2 100644 --- a/crates/handlers/src/oauth2/revoke.rs +++ b/crates/handlers/src/oauth2/revoke.rs @@ -198,3 +198,178 @@ pub(crate) async fn post( Ok(()) } + +#[cfg(test)] +mod tests { + use hyper::{ + header::{AUTHORIZATION, CONTENT_TYPE}, + Request, + }; + use mas_data_model::AuthorizationCode; + use mas_router::SimpleRoute; + use mas_storage::{RepositoryAccess, RepositoryTransaction, SystemClock}; + use mas_storage_pg::PgRepository; + use oauth2_types::{ + registration::ClientRegistrationResponse, + requests::{AccessTokenResponse, ResponseMode}, + scope::{Scope, OPENID}, + }; + use rand::SeedableRng; + use sqlx::PgPool; + use tower::{Service, ServiceExt}; + + use super::*; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_revoke_access_token(pool: PgPool) { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_test_writer() + .init(); + + let clock = SystemClock::default(); + let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42); + + let state = crate::test_state(pool.clone()).await.unwrap(); + let mut app = crate::api_router().with_state(state); + + let request = Request::post(mas_router::OAuth2RegistrationEndpoint::PATH) + .header(CONTENT_TYPE, "application/json") + .body( + serde_json::json!({ + "client_uri": "https://example.com/", + "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], + "token_endpoint_auth_method": "client_secret_post", + "response_types": ["code"], + "grant_types": ["authorization_code"], + }) + .to_string(), + ) + .unwrap(); + + let response = app.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + + let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); + let client_registration: ClientRegistrationResponse = + serde_json::from_slice(&body).unwrap(); + + let client_id = client_registration.client_id; + let client_secret = client_registration.client_secret.unwrap(); + + // Let's provision a user and create a session for them. This part is hard to + // test with just HTTP requests, so we'll use the repository directly. + let mut repo = PgRepository::from_pool(&pool).await.unwrap(); + + let user = repo + .user() + .add(&mut rng, &clock, "alice".to_owned()) + .await + .unwrap(); + + let browser_session = repo + .browser_session() + .add(&mut rng, &clock, &user) + .await + .unwrap(); + + // Lookup the client in the database. + let client = repo + .oauth2_client() + .find_by_client_id(&client_id) + .await + .unwrap() + .unwrap(); + + // Start a grant + let grant = repo + .oauth2_authorization_grant() + .add( + &mut rng, + &clock, + &client, + "https://example.com/redirect".parse().unwrap(), + Scope::from_iter([OPENID]), + Some(AuthorizationCode { + code: "thisisaverysecurecode".to_owned(), + pkce: None, + }), + Some("state".to_owned()), + Some("nonce".to_owned()), + None, + ResponseMode::Query, + true, + false, + ) + .await + .unwrap(); + + let session = repo + .oauth2_session() + .create_from_grant(&mut rng, &clock, &grant, &browser_session) + .await + .unwrap(); + + let grant = repo + .oauth2_authorization_grant() + .fulfill(&clock, &session, grant) + .await + .unwrap(); + + Box::new(repo).save().await.unwrap(); + + // Now call the token endpoint to get an access token. + let request = Request::post(mas_router::OAuth2TokenEndpoint::PATH) + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .body( + format!( + "grant_type=authorization_code&code={code}&redirect_uri={redirect_uri}&client_id={client_id}&client_secret={client_secret}", + code = grant.code.unwrap().code, + redirect_uri = grant.redirect_uri, + ), + ) + .unwrap(); + + let response = app.ready().await.unwrap().call(request).await.unwrap(); + let status = response.status(); + assert_eq!(status, StatusCode::OK); + + let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); + let token: AccessTokenResponse = serde_json::from_slice(&body).unwrap(); + + // Let's call the userinfo endpoint to make sure we can access it. + let request = Request::get(mas_router::OidcUserinfo::PATH) + .header(AUTHORIZATION, format!("Bearer {}", token.access_token)) + .body(String::new()) + .unwrap(); + + let response = app.ready().await.unwrap().call(request).await.unwrap(); + let status = response.status(); + assert_eq!(status, StatusCode::OK); + + // Now let's revoke the access token. + let request = Request::post(mas_router::OAuth2Revocation::PATH) + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(format!( + "token={token}&token_type_hint=access_token&client_id={client_id}&client_secret={client_secret}", + token = token.access_token + )) + .unwrap(); + + let response = app.ready().await.unwrap().call(request).await.unwrap(); + let status = response.status(); + assert_eq!(status, StatusCode::OK); + + // Call the userinfo endpoint again to make sure we can't access it anymore. + let request = Request::get(mas_router::OidcUserinfo::PATH) + .header(AUTHORIZATION, format!("Bearer {}", token.access_token)) + .body(String::new()) + .unwrap(); + + let response = app.ready().await.unwrap().call(request).await.unwrap(); + let status = response.status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + // TODO: test refreshing the access token, test refresh token revocation + } +}