diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs index 7ab4a0fe..79069856 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -218,7 +218,7 @@ async fn get_requester( }; // If there is a user for this session, check that it is not locked - let user_valid = user.as_ref().map_or(false, User::is_valid); + let user_valid = user.as_ref().map_or(true, User::is_valid); if !token.is_valid(clock.now()) || !session.is_valid() || !user_valid { return Err(RouteError::InvalidToken); diff --git a/crates/handlers/src/graphql/tests.rs b/crates/handlers/src/graphql/tests.rs index d6b0103c..4500dd35 100644 --- a/crates/handlers/src/graphql/tests.rs +++ b/crates/handlers/src/graphql/tests.rs @@ -16,8 +16,13 @@ use axum::http::Request; use chrono::Duration; use hyper::StatusCode; use mas_data_model::{AccessToken, Client, TokenType, User}; +use mas_router::SimpleRoute; use mas_storage::{oauth2::OAuth2ClientRepository, RepositoryAccess}; -use oauth2_types::scope::{Scope, ScopeToken, OPENID}; +use oauth2_types::{ + registration::ClientRegistrationResponse, + requests::AccessTokenResponse, + scope::{Scope, ScopeToken, OPENID}, +}; use sqlx::PgPool; use crate::test_utils::{init_tracing, RequestBuilderExt, ResponseExt, TestState}; @@ -349,3 +354,106 @@ async fn test_oauth2_admin(pool: PgPool) { }) ); } + +/// Test that we can query the GraphQL endpoint with a token from a +/// client_credentials grant. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_oauth2_client_credentials(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/", + // XXX: we shouldn't have to specify the redirect URI here, but the policy denies it for now + "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["client_credentials"], + "response_types": [], + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + + let response: ClientRegistrationResponse = response.json(); + let client_id = response.client_id; + let client_secret = response.client_secret.expect("to have a client secret"); + + // Call the token endpoint with an empty scope + let request = Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": "urn:mas:graphql:*", + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let AccessTokenResponse { access_token, .. } = response.json(); + + let request = Request::post("/graphql") + .bearer(&access_token) + .json(serde_json::json!({ + "query": r#" + query { + viewer { + __typename + } + + viewerSession { + __typename + } + } + "#, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty()); + assert_eq!( + response.data, + serde_json::json!({ + "viewer": { + // There is no user associated with the client credentials grant + "__typename": "Anonymous", + }, + "viewerSession": { + // But there is a session + "__typename": "Oauth2Session", + }, + }) + ); + + // Check that we can't do a query once the token is revoked + let request = Request::post(mas_router::OAuth2Revocation::PATH).form(serde_json::json!({ + "token": access_token, + "client_id": client_id, + "client_secret": client_secret, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + // Do the same request again + let request = Request::post("/graphql") + .bearer(&access_token) + .json(serde_json::json!({ + "query": r#" + query { + viewer { + __typename + } + + viewerSession { + __typename + } + } + "#, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::UNAUTHORIZED); +} diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index 4dc1667e..ae9f06d1 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -20,8 +20,10 @@ use mas_axum_utils::{ client_authorization::{ClientAuthorization, CredentialsVerificationError}, http_client_factory::HttpClientFactory, }; -use mas_data_model::{AuthorizationGrantStage, Client, Device}; +use mas_data_model::{AuthorizationGrantStage, Client, Device, TokenType}; use mas_keystore::{Encrypter, Keystore}; +use mas_oidc_client::types::scope::ScopeToken; +use mas_policy::Policy; use mas_router::UrlBuilder; use mas_storage::{ job::{JobRepositoryExt, ProvisionDeviceJob}, @@ -36,7 +38,8 @@ use oauth2_types::{ errors::{ClientError, ClientErrorCode}, pkce::CodeChallengeError, requests::{ - AccessTokenRequest, AccessTokenResponse, AuthorizationCodeGrant, RefreshTokenGrant, + AccessTokenRequest, AccessTokenResponse, AuthorizationCodeGrant, ClientCredentialsGrant, + GrantType, RefreshTokenGrant, }, scope, }; @@ -92,6 +95,9 @@ pub(crate) enum RouteError { #[error("invalid grant")] InvalidGrant, + #[error("policy denied the request")] + DeniedByPolicy(Vec), + #[error("unsupported grant type")] UnsupportedGrantType, @@ -132,6 +138,18 @@ impl IntoResponse for RouteError { StatusCode::UNAUTHORIZED, Json(ClientError::from(ClientErrorCode::UnauthorizedClient)), ), + Self::DeniedByPolicy(violations) => ( + StatusCode::FORBIDDEN, + Json( + ClientError::from(ClientErrorCode::InvalidScope).with_description( + violations + .into_iter() + .map(|violation| violation.msg) + .collect::>() + .join(", "), + ), + ), + ), Self::InvalidGrant | Self::GrantNotFound => ( StatusCode::BAD_REQUEST, Json(ClientError::from(ClientErrorCode::InvalidGrant)), @@ -146,6 +164,7 @@ impl IntoResponse for RouteError { } impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(mas_policy::EvaluationError); impl_from_error_for_route!(super::IdTokenSignatureError); #[tracing::instrument( @@ -163,6 +182,7 @@ pub(crate) async fn post( mut repo: BoxRepository, State(site_config): State, State(encrypter): State, + policy: Policy, client_authorization: ClientAuthorization, ) -> Result { let client = client_authorization @@ -200,6 +220,18 @@ pub(crate) async fn post( AccessTokenRequest::RefreshToken(grant) => { refresh_token_grant(&mut rng, &clock, &grant, &client, &site_config, repo).await? } + AccessTokenRequest::ClientCredentials(grant) => { + client_credentials_grant( + &mut rng, + &clock, + &grant, + &client, + &site_config, + repo, + policy, + ) + .await? + } _ => { return Err(RouteError::UnsupportedGrantType); } @@ -420,6 +452,58 @@ async fn refresh_token_grant( Ok((params, repo)) } +async fn client_credentials_grant( + rng: &mut BoxRng, + clock: &impl Clock, + grant: &ClientCredentialsGrant, + client: &Client, + site_config: &SiteConfig, + mut repo: BoxRepository, + mut policy: Policy, +) -> Result<(AccessTokenResponse, BoxRepository), RouteError> { + // Check that the client is allowed to use this grant type + if !client.grant_types.contains(&GrantType::ClientCredentials) { + return Err(RouteError::UnauthorizedClient); + } + + // Default to an empty scope if none is provided + let scope = grant + .scope + .clone() + .unwrap_or_else(|| std::iter::empty::().collect()); + + // Make the request go through the policy engine + let res = policy + .evaluate_client_credentials_grant(&scope, client) + .await?; + if !res.valid() { + return Err(RouteError::DeniedByPolicy(res.violations)); + } + + // Start the session + let session = repo + .oauth2_session() + .add_from_client_credentials(rng, clock, client, 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, ttl) + .await?; + + let mut params = AccessTokenResponse::new(access_token.access_token).with_expires_in(ttl); + + 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; @@ -767,7 +851,7 @@ mod tests { } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_unsupported_grant(pool: PgPool) { + async fn test_client_credentials(pool: PgPool) { init_tracing(); let state = TestState::from_pool(pool).await.unwrap(); @@ -775,6 +859,7 @@ mod tests { let request = Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({ "client_uri": "https://example.com/", + // XXX: we shouldn't have to specify the redirect URI here, but the policy denies it for now "redirect_uris": ["https://example.com/callback"], "contacts": ["contact@example.com"], "token_endpoint_auth_method": "client_secret_post", @@ -789,7 +874,7 @@ mod tests { let client_id = response.client_id; let client_secret = response.client_secret.expect("to have a client secret"); - // Call the token endpoint with an unsupported grant type + // Call the token endpoint with an empty scope let request = Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({ "grant_type": "client_credentials", @@ -797,6 +882,137 @@ mod tests { "client_secret": client_secret, })); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let response: AccessTokenResponse = response.json(); + assert!(response.refresh_token.is_none()); + assert!(response.expires_in.is_some()); + assert!(response.scope.is_none()); + + // Revoke the token + let request = Request::post(mas_router::OAuth2Revocation::PATH).form(serde_json::json!({ + "token": response.access_token, + "client_id": client_id, + "client_secret": client_secret, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + // We should be allowed to ask for the GraphQL API scope + let request = + Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": "urn:mas:graphql:*" + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let response: AccessTokenResponse = response.json(); + assert!(response.refresh_token.is_none()); + assert!(response.expires_in.is_some()); + assert_eq!(response.scope, Some("urn:mas:graphql:*".parse().unwrap())); + + // Revoke the token + let request = Request::post(mas_router::OAuth2Revocation::PATH).form(serde_json::json!({ + "token": response.access_token, + "client_id": client_id, + "client_secret": client_secret, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + // We should be NOT allowed to ask for the MAS admin scope + let request = + Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": "urn:mas:admin" + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::FORBIDDEN); + + let ClientError { error, .. } = response.json(); + assert_eq!(error, ClientErrorCode::InvalidScope); + + // Now, if we add the client to the admin list in the policy, it should work + let state = { + let mut state = state; + state.policy_factory = crate::test_utils::policy_factory(serde_json::json!({ + "admin_clients": [client_id] + })) + .await + .unwrap(); + state + }; + + let request = + Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": "urn:mas:admin" + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let response: AccessTokenResponse = response.json(); + assert!(response.refresh_token.is_none()); + assert!(response.expires_in.is_some()); + assert_eq!(response.scope, Some("urn:mas:admin".parse().unwrap())); + + // Revoke the token + let request = Request::post(mas_router::OAuth2Revocation::PATH).form(serde_json::json!({ + "token": response.access_token, + "client_id": client_id, + "client_secret": client_secret, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_unsupported_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/", + "redirect_uris": ["https://example.com/callback"], + "contacts": ["contact@example.com"], + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["password"], + "response_types": [], + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + + let response: ClientRegistrationResponse = response.json(); + let client_id = response.client_id; + let client_secret = response.client_secret.expect("to have a client secret"); + + // Call the token endpoint with an unsupported grant type + let request = + Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({ + "grant_type": "password", + "client_id": client_id, + "client_secret": client_secret, + "username": "john", + "password": "hunter2", + })); + let response = state.request(request).await; response.assert_status(StatusCode::BAD_REQUEST); let ClientError { error, .. } = response.json(); diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 844913e4..e5c0bad7 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -63,6 +63,28 @@ pub(crate) fn init_tracing() { .try_init(); } +pub(crate) async fn policy_factory( + data: serde_json::Value, +) -> Result, anyhow::Error> { + let workspace_root = camino::Utf8Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join(".."); + + let file = tokio::fs::File::open(workspace_root.join("policies").join("policy.wasm")).await?; + + let entrypoints = mas_policy::Entrypoints { + register: "register/violation".to_owned(), + client_registration: "client_registration/violation".to_owned(), + authorization_grant: "authorization_grant/violation".to_owned(), + email: "email/violation".to_owned(), + password: "password/violation".to_owned(), + }; + + let policy_factory = PolicyFactory::load(file, data, entrypoints).await?; + let policy_factory = Arc::new(policy_factory); + Ok(policy_factory) +} + #[derive(Clone)] pub(crate) struct TestState { pub pool: PgPool, @@ -116,23 +138,10 @@ impl TestState { let homeserver = MatrixHomeserver::new("example.com".to_owned()); - let file = - tokio::fs::File::open(workspace_root.join("policies").join("policy.wasm")).await?; - - let entrypoints = mas_policy::Entrypoints { - register: "register/violation".to_owned(), - client_registration: "client_registration/violation".to_owned(), - authorization_grant: "authorization_grant/violation".to_owned(), - email: "email/violation".to_owned(), - password: "password/violation".to_owned(), - }; - - let policy_factory = PolicyFactory::load(file, serde_json::json!({}), entrypoints).await?; + let policy_factory = policy_factory(serde_json::json!({})).await?; let homeserver_connection = MockHomeserverConnection::new("example.com"); - let policy_factory = Arc::new(policy_factory); - let http_client_factory = HttpClientFactory::new(10); let site_config = SiteConfig::default(); diff --git a/crates/oauth2-types/src/requests.rs b/crates/oauth2-types/src/requests.rs index cfa0bcf9..aee3525a 100644 --- a/crates/oauth2-types/src/requests.rs +++ b/crates/oauth2-types/src/requests.rs @@ -574,7 +574,7 @@ pub enum AccessTokenRequest { DeviceCode(DeviceCodeGrant), /// An unsupported request. - #[serde(skip, other)] + #[serde(skip_serializing, other)] Unsupported, } diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index 9529942b..a02b410b 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -314,7 +314,7 @@ impl Policy { user: &User, ) -> Result { let input = AuthorizationGrantInput { - user, + user: Some(user), client, scope: &authorization_grant.scope, grant_type: GrantType::AuthorizationCode, @@ -338,7 +338,6 @@ impl Policy { fields( input.scope = %scope, input.client.id = %client.id, - input.user.id = %user.id, ), err, )] @@ -346,10 +345,9 @@ impl Policy { &mut self, scope: &Scope, client: &Client, - user: &User, ) -> Result { let input = AuthorizationGrantInput { - user, + user: None, client, scope, grant_type: GrantType::ClientCredentials, diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index 48adb81e..6ec3b33c 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -107,9 +107,9 @@ pub enum GrantType { pub struct AuthorizationGrantInput<'a> { #[cfg_attr( feature = "jsonschema", - schemars(with = "std::collections::HashMap") + schemars(with = "Option>") )] - pub user: &'a User, + pub user: Option<&'a User>, #[cfg_attr( feature = "jsonschema", diff --git a/crates/storage-pg/.sqlx/query-2e1d7fb3f69d99dc5c879f43d92bc52f9abeb606393aff5440f948632d914708.json b/crates/storage-pg/.sqlx/query-2e1d7fb3f69d99dc5c879f43d92bc52f9abeb606393aff5440f948632d914708.json new file mode 100644 index 00000000..db4f3e8a --- /dev/null +++ b/crates/storage-pg/.sqlx/query-2e1d7fb3f69d99dc5c879f43d92bc52f9abeb606393aff5440f948632d914708.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth2_clients\n ( oauth2_client_id\n , encrypted_client_secret\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , token_endpoint_auth_method\n , jwks\n , jwks_uri\n , is_static\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, TRUE)\n ON CONFLICT (oauth2_client_id)\n DO\n UPDATE SET encrypted_client_secret = EXCLUDED.encrypted_client_secret\n , grant_type_authorization_code = EXCLUDED.grant_type_authorization_code\n , grant_type_refresh_token = EXCLUDED.grant_type_refresh_token\n , grant_type_client_credentials = EXCLUDED.grant_type_client_credentials\n , token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method\n , jwks = EXCLUDED.jwks\n , jwks_uri = EXCLUDED.jwks_uri\n , is_static = TRUE\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "TextArray", + "Bool", + "Bool", + "Bool", + "Text", + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "2e1d7fb3f69d99dc5c879f43d92bc52f9abeb606393aff5440f948632d914708" +} diff --git a/crates/storage-pg/.sqlx/query-34fbe0f0485a9c4060399509f087964c454252b9b111a57c9106cfc3fdc71b8a.json b/crates/storage-pg/.sqlx/query-34fbe0f0485a9c4060399509f087964c454252b9b111a57c9106cfc3fdc71b8a.json deleted file mode 100644 index 53be759a..00000000 --- a/crates/storage-pg/.sqlx/query-34fbe0f0485a9c4060399509f087964c454252b9b111a57c9106cfc3fdc71b8a.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO oauth2_clients\n ( oauth2_client_id\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n , is_static\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, FALSE)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text", - "TextArray", - "Bool", - "Bool", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Jsonb", - "Text", - "Text", - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "34fbe0f0485a9c4060399509f087964c454252b9b111a57c9106cfc3fdc71b8a" -} diff --git a/crates/storage-pg/.sqlx/query-35734c4b54d2f1b2c311806af3e9a592f5f55f4898c6e39eb0d7c1b9cef7ca63.json b/crates/storage-pg/.sqlx/query-59a7b0eb03e45db5bee0aa1447fef2a3c061cf385ccf67acb237b94a0f25a074.json similarity index 74% rename from crates/storage-pg/.sqlx/query-35734c4b54d2f1b2c311806af3e9a592f5f55f4898c6e39eb0d7c1b9cef7ca63.json rename to crates/storage-pg/.sqlx/query-59a7b0eb03e45db5bee0aa1447fef2a3c061cf385ccf67acb237b94a0f25a074.json index 5e6e2241..ece26f14 100644 --- a/crates/storage-pg/.sqlx/query-35734c4b54d2f1b2c311806af3e9a592f5f55f4898c6e39eb0d7c1b9cef7ca63.json +++ b/crates/storage-pg/.sqlx/query-59a7b0eb03e45db5bee0aa1447fef2a3c061cf385ccf67acb237b94a0f25a074.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_client_id\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , contacts\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n FROM oauth2_clients c\n\n WHERE oauth2_client_id = ANY($1::uuid[])\n ", + "query": "\n SELECT oauth2_client_id\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , contacts\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n FROM oauth2_clients c\n\n WHERE oauth2_client_id = ANY($1::uuid[])\n ", "describe": { "columns": [ { @@ -35,66 +35,71 @@ }, { "ordinal": 6, + "name": "grant_type_client_credentials", + "type_info": "Bool" + }, + { + "ordinal": 7, "name": "contacts", "type_info": "TextArray" }, { - "ordinal": 7, + "ordinal": 8, "name": "client_name", "type_info": "Text" }, { - "ordinal": 8, + "ordinal": 9, "name": "logo_uri", "type_info": "Text" }, { - "ordinal": 9, + "ordinal": 10, "name": "client_uri", "type_info": "Text" }, { - "ordinal": 10, + "ordinal": 11, "name": "policy_uri", "type_info": "Text" }, { - "ordinal": 11, + "ordinal": 12, "name": "tos_uri", "type_info": "Text" }, { - "ordinal": 12, + "ordinal": 13, "name": "jwks_uri", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 14, "name": "jwks", "type_info": "Jsonb" }, { - "ordinal": 14, + "ordinal": 15, "name": "id_token_signed_response_alg", "type_info": "Text" }, { - "ordinal": 15, + "ordinal": 16, "name": "userinfo_signed_response_alg", "type_info": "Text" }, { - "ordinal": 16, + "ordinal": 17, "name": "token_endpoint_auth_method", "type_info": "Text" }, { - "ordinal": 17, + "ordinal": 18, "name": "token_endpoint_auth_signing_alg", "type_info": "Text" }, { - "ordinal": 18, + "ordinal": 19, "name": "initiate_login_uri", "type_info": "Text" } @@ -112,6 +117,7 @@ false, false, false, + false, true, true, true, @@ -126,5 +132,5 @@ true ] }, - "hash": "35734c4b54d2f1b2c311806af3e9a592f5f55f4898c6e39eb0d7c1b9cef7ca63" + "hash": "59a7b0eb03e45db5bee0aa1447fef2a3c061cf385ccf67acb237b94a0f25a074" } diff --git a/crates/storage-pg/.sqlx/query-73ea17a71d62bf96f7811b7f57802f5065f0ae831bc8f3c66b5be4a47b37467e.json b/crates/storage-pg/.sqlx/query-73ea17a71d62bf96f7811b7f57802f5065f0ae831bc8f3c66b5be4a47b37467e.json deleted file mode 100644 index 54b14b77..00000000 --- a/crates/storage-pg/.sqlx/query-73ea17a71d62bf96f7811b7f57802f5065f0ae831bc8f3c66b5be4a47b37467e.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO oauth2_clients\n ( oauth2_client_id\n , encrypted_client_secret\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , token_endpoint_auth_method\n , jwks\n , jwks_uri\n , is_static\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, TRUE)\n ON CONFLICT (oauth2_client_id)\n DO\n UPDATE SET encrypted_client_secret = EXCLUDED.encrypted_client_secret\n , grant_type_authorization_code = EXCLUDED.grant_type_authorization_code\n , grant_type_refresh_token = EXCLUDED.grant_type_refresh_token\n , token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method\n , jwks = EXCLUDED.jwks\n , jwks_uri = EXCLUDED.jwks_uri\n , is_static = TRUE\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "TextArray", - "Bool", - "Bool", - "Text", - "Jsonb", - "Text" - ] - }, - "nullable": [] - }, - "hash": "73ea17a71d62bf96f7811b7f57802f5065f0ae831bc8f3c66b5be4a47b37467e" -} diff --git a/crates/storage-pg/.sqlx/query-8cd9e33fb146fd528dbb169b6339d33703b6b719d5f28f1cf232bc81cac0da85.json b/crates/storage-pg/.sqlx/query-8cd9e33fb146fd528dbb169b6339d33703b6b719d5f28f1cf232bc81cac0da85.json new file mode 100644 index 00000000..f30f5680 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-8cd9e33fb146fd528dbb169b6339d33703b6b719d5f28f1cf232bc81cac0da85.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth2_clients\n ( oauth2_client_id\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n , is_static\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, FALSE)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "TextArray", + "Bool", + "Bool", + "Bool", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Jsonb", + "Text", + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8cd9e33fb146fd528dbb169b6339d33703b6b719d5f28f1cf232bc81cac0da85" +} diff --git a/crates/storage-pg/.sqlx/query-e6250ea5c861cd7568999fd8f490daf1407b1b3619e3a05c70b5fe9ccf9aa7b5.json b/crates/storage-pg/.sqlx/query-9697ae6915c7e9268c82a4a2bf7a538515bb2be35c003f49e93641fb1d5ed8af.json similarity index 74% rename from crates/storage-pg/.sqlx/query-e6250ea5c861cd7568999fd8f490daf1407b1b3619e3a05c70b5fe9ccf9aa7b5.json rename to crates/storage-pg/.sqlx/query-9697ae6915c7e9268c82a4a2bf7a538515bb2be35c003f49e93641fb1d5ed8af.json index afcca210..7e196ca7 100644 --- a/crates/storage-pg/.sqlx/query-e6250ea5c861cd7568999fd8f490daf1407b1b3619e3a05c70b5fe9ccf9aa7b5.json +++ b/crates/storage-pg/.sqlx/query-9697ae6915c7e9268c82a4a2bf7a538515bb2be35c003f49e93641fb1d5ed8af.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_client_id\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , contacts\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n FROM oauth2_clients c\n\n WHERE oauth2_client_id = $1\n ", + "query": "\n SELECT oauth2_client_id\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , contacts\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n FROM oauth2_clients c\n\n WHERE oauth2_client_id = $1\n ", "describe": { "columns": [ { @@ -35,66 +35,71 @@ }, { "ordinal": 6, + "name": "grant_type_client_credentials", + "type_info": "Bool" + }, + { + "ordinal": 7, "name": "contacts", "type_info": "TextArray" }, { - "ordinal": 7, + "ordinal": 8, "name": "client_name", "type_info": "Text" }, { - "ordinal": 8, + "ordinal": 9, "name": "logo_uri", "type_info": "Text" }, { - "ordinal": 9, + "ordinal": 10, "name": "client_uri", "type_info": "Text" }, { - "ordinal": 10, + "ordinal": 11, "name": "policy_uri", "type_info": "Text" }, { - "ordinal": 11, + "ordinal": 12, "name": "tos_uri", "type_info": "Text" }, { - "ordinal": 12, + "ordinal": 13, "name": "jwks_uri", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 14, "name": "jwks", "type_info": "Jsonb" }, { - "ordinal": 14, + "ordinal": 15, "name": "id_token_signed_response_alg", "type_info": "Text" }, { - "ordinal": 15, + "ordinal": 16, "name": "userinfo_signed_response_alg", "type_info": "Text" }, { - "ordinal": 16, + "ordinal": 17, "name": "token_endpoint_auth_method", "type_info": "Text" }, { - "ordinal": 17, + "ordinal": 18, "name": "token_endpoint_auth_signing_alg", "type_info": "Text" }, { - "ordinal": 18, + "ordinal": 19, "name": "initiate_login_uri", "type_info": "Text" } @@ -112,6 +117,7 @@ false, false, false, + false, true, true, true, @@ -126,5 +132,5 @@ true ] }, - "hash": "e6250ea5c861cd7568999fd8f490daf1407b1b3619e3a05c70b5fe9ccf9aa7b5" + "hash": "9697ae6915c7e9268c82a4a2bf7a538515bb2be35c003f49e93641fb1d5ed8af" } diff --git a/crates/storage-pg/.sqlx/query-b2b71d12c3a4a7436bbe961c6c57e17a5c5e4105d01184a38d12607d853df802.json b/crates/storage-pg/.sqlx/query-ef45f7ed060951ec7d881f2da2a9454eb32c4620cac1ea13c7186df513692fd6.json similarity index 74% rename from crates/storage-pg/.sqlx/query-b2b71d12c3a4a7436bbe961c6c57e17a5c5e4105d01184a38d12607d853df802.json rename to crates/storage-pg/.sqlx/query-ef45f7ed060951ec7d881f2da2a9454eb32c4620cac1ea13c7186df513692fd6.json index 7217ed0c..5297cf1c 100644 --- a/crates/storage-pg/.sqlx/query-b2b71d12c3a4a7436bbe961c6c57e17a5c5e4105d01184a38d12607d853df802.json +++ b/crates/storage-pg/.sqlx/query-ef45f7ed060951ec7d881f2da2a9454eb32c4620cac1ea13c7186df513692fd6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_client_id\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , contacts\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n FROM oauth2_clients c\n WHERE is_static = TRUE\n ", + "query": "\n SELECT oauth2_client_id\n , encrypted_client_secret\n , application_type\n , redirect_uris\n , grant_type_authorization_code\n , grant_type_refresh_token\n , grant_type_client_credentials\n , contacts\n , client_name\n , logo_uri\n , client_uri\n , policy_uri\n , tos_uri\n , jwks_uri\n , jwks\n , id_token_signed_response_alg\n , userinfo_signed_response_alg\n , token_endpoint_auth_method\n , token_endpoint_auth_signing_alg\n , initiate_login_uri\n FROM oauth2_clients c\n WHERE is_static = TRUE\n ", "describe": { "columns": [ { @@ -35,66 +35,71 @@ }, { "ordinal": 6, + "name": "grant_type_client_credentials", + "type_info": "Bool" + }, + { + "ordinal": 7, "name": "contacts", "type_info": "TextArray" }, { - "ordinal": 7, + "ordinal": 8, "name": "client_name", "type_info": "Text" }, { - "ordinal": 8, + "ordinal": 9, "name": "logo_uri", "type_info": "Text" }, { - "ordinal": 9, + "ordinal": 10, "name": "client_uri", "type_info": "Text" }, { - "ordinal": 10, + "ordinal": 11, "name": "policy_uri", "type_info": "Text" }, { - "ordinal": 11, + "ordinal": 12, "name": "tos_uri", "type_info": "Text" }, { - "ordinal": 12, + "ordinal": 13, "name": "jwks_uri", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 14, "name": "jwks", "type_info": "Jsonb" }, { - "ordinal": 14, + "ordinal": 15, "name": "id_token_signed_response_alg", "type_info": "Text" }, { - "ordinal": 15, + "ordinal": 16, "name": "userinfo_signed_response_alg", "type_info": "Text" }, { - "ordinal": 16, + "ordinal": 17, "name": "token_endpoint_auth_method", "type_info": "Text" }, { - "ordinal": 17, + "ordinal": 18, "name": "token_endpoint_auth_signing_alg", "type_info": "Text" }, { - "ordinal": 18, + "ordinal": 19, "name": "initiate_login_uri", "type_info": "Text" } @@ -110,6 +115,7 @@ false, false, false, + false, true, true, true, @@ -124,5 +130,5 @@ true ] }, - "hash": "b2b71d12c3a4a7436bbe961c6c57e17a5c5e4105d01184a38d12607d853df802" + "hash": "ef45f7ed060951ec7d881f2da2a9454eb32c4620cac1ea13c7186df513692fd6" } diff --git a/crates/storage-pg/migrations/20230904135550_oauth2_client_credentials_grant.sql b/crates/storage-pg/migrations/20230904135550_oauth2_client_credentials_grant.sql index 340b9ab0..1fffbc33 100644 --- a/crates/storage-pg/migrations/20230904135550_oauth2_client_credentials_grant.sql +++ b/crates/storage-pg/migrations/20230904135550_oauth2_client_credentials_grant.sql @@ -13,4 +13,9 @@ -- limitations under the License. -- This makes the user_id in the oauth2_sessions nullable, which allows us to create user-less sessions -ALTER TABLE oauth2_sessions ALTER COLUMN user_id DROP NOT NULL; +ALTER TABLE oauth2_sessions + ALTER COLUMN user_id DROP NOT NULL; + +-- This adds a column to the oauth2_clients to allow them to use the client_credentials flow +ALTER TABLE oauth2_clients + ADD COLUMN grant_type_client_credentials boolean NOT NULL DEFAULT false; \ No newline at end of file diff --git a/crates/storage-pg/src/oauth2/client.rs b/crates/storage-pg/src/oauth2/client.rs index af3dfabd..54c0dec6 100644 --- a/crates/storage-pg/src/oauth2/client.rs +++ b/crates/storage-pg/src/oauth2/client.rs @@ -63,6 +63,7 @@ struct OAuth2ClientLookup { // response_types: Vec, grant_type_authorization_code: bool, grant_type_refresh_token: bool, + grant_type_client_credentials: bool, contacts: Vec, client_name: Option, logo_uri: Option, @@ -126,6 +127,9 @@ impl TryInto for OAuth2ClientLookup { if self.grant_type_refresh_token { grant_types.push(GrantType::RefreshToken); } + if self.grant_type_client_credentials { + grant_types.push(GrantType::ClientCredentials); + } let logo_uri = self.logo_uri.map(|s| s.parse()).transpose().map_err(|e| { DatabaseInconsistencyError::on("oauth2_clients") @@ -293,6 +297,7 @@ impl<'c> OAuth2ClientRepository for PgOAuth2ClientRepository<'c> { , redirect_uris , grant_type_authorization_code , grant_type_refresh_token + , grant_type_client_credentials , contacts , client_name , logo_uri @@ -343,6 +348,7 @@ impl<'c> OAuth2ClientRepository for PgOAuth2ClientRepository<'c> { , redirect_uris , grant_type_authorization_code , grant_type_refresh_token + , grant_type_client_credentials , contacts , client_name , logo_uri @@ -429,6 +435,7 @@ impl<'c> OAuth2ClientRepository for PgOAuth2ClientRepository<'c> { , redirect_uris , grant_type_authorization_code , grant_type_refresh_token + , grant_type_client_credentials , client_name , logo_uri , client_uri @@ -444,7 +451,7 @@ impl<'c> OAuth2ClientRepository for PgOAuth2ClientRepository<'c> { , is_static ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, FALSE) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, FALSE) "#, Uuid::from(id), encrypted_client_secret, @@ -452,6 +459,7 @@ impl<'c> OAuth2ClientRepository for PgOAuth2ClientRepository<'c> { &redirect_uris_array, grant_types.contains(&GrantType::AuthorizationCode), grant_types.contains(&GrantType::RefreshToken), + grant_types.contains(&GrantType::ClientCredentials), client_name, logo_uri.as_ref().map(Url::as_str), client_uri.as_ref().map(Url::as_str), @@ -544,18 +552,20 @@ impl<'c> OAuth2ClientRepository for PgOAuth2ClientRepository<'c> { , redirect_uris , grant_type_authorization_code , grant_type_refresh_token + , grant_type_client_credentials , token_endpoint_auth_method , jwks , jwks_uri , is_static ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, TRUE) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, TRUE) ON CONFLICT (oauth2_client_id) DO UPDATE SET encrypted_client_secret = EXCLUDED.encrypted_client_secret , grant_type_authorization_code = EXCLUDED.grant_type_authorization_code , grant_type_refresh_token = EXCLUDED.grant_type_refresh_token + , grant_type_client_credentials = EXCLUDED.grant_type_client_credentials , token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method , jwks = EXCLUDED.jwks , jwks_uri = EXCLUDED.jwks_uri @@ -566,6 +576,7 @@ impl<'c> OAuth2ClientRepository for PgOAuth2ClientRepository<'c> { &redirect_uris_array, true, true, + true, client_auth_method, jwks_json, jwks_uri.as_ref().map(Url::as_str), @@ -592,7 +603,11 @@ impl<'c> OAuth2ClientRepository for PgOAuth2ClientRepository<'c> { OAuthAuthorizationEndpointResponseType::IdToken, OAuthAuthorizationEndpointResponseType::None, ], - grant_types: Vec::new(), + grant_types: vec![ + GrantType::AuthorizationCode, + GrantType::RefreshToken, + GrantType::ClientCredentials, + ], contacts: Vec::new(), client_name: None, logo_uri: None, @@ -626,6 +641,7 @@ impl<'c> OAuth2ClientRepository for PgOAuth2ClientRepository<'c> { , redirect_uris , grant_type_authorization_code , grant_type_refresh_token + , grant_type_client_credentials , contacts , client_name , logo_uri diff --git a/policies/schema/authorization_grant_input.json b/policies/schema/authorization_grant_input.json index 0d5f3f9d..dbc84555 100644 --- a/policies/schema/authorization_grant_input.json +++ b/policies/schema/authorization_grant_input.json @@ -6,8 +6,7 @@ "required": [ "client", "grant_type", - "scope", - "user" + "scope" ], "properties": { "client": {