[ee] feat(scim): OAuth2 client-credentials grant for SCIM provisioning

Support OAuth 2.0 client-credentials authentication for SCIM provisioning
(e.g. Microsoft Entra ID) alongside the existing static bearer token, so
identity providers can use short-lived, rotatable access tokens.

Backend (EE companion PR modifies scim_ee.rs):
- Unauthenticated token endpoint POST /api/scim_token/token issues a
  short-lived scope:scim JWT via the client-credentials grant.
- has_scim_token validates SCIM JWTs (when OAuth is configured) in addition
  to the static token — fully backward compatible.
- Super-admin config endpoints (generate/rotate/disable) store the client
  secret hashed (SHA-256). New scim_oauth global setting is agent-worker
  blocked and live-reloaded.

Frontend:
- OAuth 2.0 client-credentials section in the SCIM/SAML instance settings:
  enable toggle, generate-secret (shown once), copyable token endpoint and
  client ID, regenerate/disable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alexander Petric
2026-07-14 10:36:08 +00:00
parent 710a13a59d
commit a89fcf72fa
10 changed files with 294 additions and 13 deletions
+8
View File
@@ -203,6 +203,10 @@ lazy_static::lazy_static! {
pub static ref REQUEST_SIZE_LIMIT: Arc<RwLock<usize>> = Arc::new(RwLock::new(DEFAULT_BODY_LIMIT));
pub static ref SCIM_TOKEN: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
// Raw JSON of the SCIM OAuth2 client-credentials config (client_id +
// hashed client_secret + optional token TTL). Parsed lazily at the token
// endpoint; `None` disables the OAuth grant entirely (see has_scim_token).
pub static ref SCIM_OAUTH_CONFIG: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
pub static ref SAML_METADATA: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
@@ -748,6 +752,10 @@ pub async fn run_server(
scim_oss::global_service()
.route_layer(axum::middleware::from_fn(has_scim_token)),
)
// Unauthenticated: the OAuth2 client-credentials token endpoint
// authenticates itself via client_id/client_secret, so it must
// NOT sit behind has_scim_token.
.nest("/scim_token", scim_oss::token_service())
.nest("/tokens", token::global_service())
.nest("/concurrency_groups", concurrency_groups::global_service())
.nest("/scripts_u", scripts::global_unauthed_service())
+5
View File
@@ -18,6 +18,11 @@ pub fn global_service() -> Router {
Router::new().route("/ee", get(ee))
}
#[cfg(not(feature = "private"))]
pub fn token_service() -> Router {
Router::new().route("/ee", get(ee))
}
#[cfg(not(feature = "private"))]
pub async fn ee() -> String {
return "Enterprise Edition".to_string();