diff --git a/integration-tests/tests/get_token.rs b/integration-tests/tests/get_token.rs index 386f97b..102ff4a 100644 --- a/integration-tests/tests/get_token.rs +++ b/integration-tests/tests/get_token.rs @@ -353,13 +353,12 @@ async fn remote_token() { assert_eq!(response["url"].as_str(), Some(sfu.url())); let jwt = response["jwt"].as_str().unwrap_or_default(); - // The JWT should grant joining, subscribing and updating the - // participant's own metadata, but not publishing, since the homeserver - // is remote. + // Remote verified users may publish, but the service must not create a + // room for them. Room creation remains gated on the full-access list. let claims = decode_livekit_jwt(jwt); assert_eq!(claims["iss"].as_str(), Some(LIVEKIT_KEY)); assert_eq!(claims["video"]["roomJoin"].as_bool(), Some(true)); - assert_eq!(claims["video"]["canPublish"].as_bool(), Some(false)); + assert_eq!(claims["video"]["canPublish"].as_bool(), Some(true)); assert_eq!(claims["video"]["canSubscribe"].as_bool(), Some(true)); assert_eq!( claims["video"]["canUpdateOwnMetadata"].as_bool(), diff --git a/integration-tests/tests/sfu_get.rs b/integration-tests/tests/sfu_get.rs index 5a08445..2cc8672 100644 --- a/integration-tests/tests/sfu_get.rs +++ b/integration-tests/tests/sfu_get.rs @@ -323,7 +323,7 @@ async fn remote_token() { let claims = decode_livekit_jwt(jwt); assert_eq!(claims["iss"].as_str(), Some(LIVEKIT_KEY)); assert_eq!(claims["video"]["roomJoin"].as_bool(), Some(true)); - assert_eq!(claims["video"]["canPublish"].as_bool(), Some(false)); + assert_eq!(claims["video"]["canPublish"].as_bool(), Some(true)); assert_eq!(claims["video"]["canSubscribe"].as_bool(), Some(true)); assert_eq!( claims["sub"].as_str(), diff --git a/src/handler.rs b/src/handler.rs index 2797194..7910e1e 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -978,7 +978,11 @@ impl Handler { &lk_room_alias, &lk_identity, &matrix_id, - is_full_access_user, + // Publication is an authenticated participation permission, not a + // room-creation privilege. OpenID and (when configured) membership + // checks above must still succeed. Keep creation/delegation gated + // below and LiveKit room.auto_create=false on the SFU. + true, )?; if is_full_access_user { @@ -1058,7 +1062,11 @@ impl Handler { &lk_room_alias, &lk_identity, &matrix_id, - is_full_access_user, + // Publication is an authenticated participation permission, not a + // room-creation privilege. OpenID and (when configured) membership + // checks above must still succeed. Keep creation/delegation gated + // below and LiveKit room.auto_create=false on the SFU. + true, )?; if is_full_access_user { diff --git a/src/handler_tests.rs b/src/handler_tests.rs index 54d1650..e6c943c 100644 --- a/src/handler_tests.rs +++ b/src/handler_tests.rs @@ -5403,3 +5403,87 @@ async fn test_membership_skipped_unless_hs_is_the_only_full_access_homeserver() handler.close().await; } } + +// iscg standalone publication policy regression tests. The full-access list +// governs room creation/delegation, never whether a verified participant speaks. +#[tokio::test] +async fn test_iscg_standalone_publication_does_not_grant_creation() { + for path in ["/sfu/get", "/get_token"] { + for server in ["example.com", "astr.to", "unrelated.example"] { + let sub = format!("@user:{server}"); + let created = Arc::new(AtomicUsize::new(0)); + let counter = created.clone(); + let deps = HandlerTestDeps { + exchange_openid_userinfo_fn: exchange_openid_userinfo_ok(&sub), + create_livekit_room_fn: Some(Box::new(move |_, _, _| { + counter.fetch_add(1, Ordering::SeqCst); + Ok(()) + })), + ..Default::default() + }; + let handler = new_get_token_handler(deps); + let mut req = valid_sfu_request(); + req.openid_token.matrix_server_name = server.into(); + req.member.claimed_user_id = sub.clone(); + let body = if path == "/get_token" { + serde_json::to_string(&req).unwrap() + } else { + serde_json::to_string(&LegacySfuRequest { + room: req.room_id.clone(), + openid_token: req.openid_token.clone(), + device_id: req.member.claimed_device_id.clone(), + ..Default::default() + }).unwrap() + }; + let resp = send_request(&handler, post_json(path, body)).await; + assert_eq!(resp.status(), StatusCode::OK, "{path} {server}"); + let result: SfuResponse = serde_json::from_slice(&body_bytes(resp).await).unwrap(); + let claims = parse_jwt_claims(&result.jwt, &handler.livekit_auth.secret); + assert_eq!(claims["video"]["canPublish"], true, "{path} {server}"); + assert_eq!(claims["video"]["canSubscribe"], true); + assert_eq!(claims["video"]["roomJoin"], true); + for privilege in ["roomCreate", "roomAdmin", "roomList", "roomRecord", "ingressAdmin"] { + assert_ne!(claims["video"][privilege], true, "{path}: {privilege}"); + } + let want_room = livekit_room_alias_for(&req.room_id, &req.slot_id); + assert_eq!(claims["video"]["room"], want_room.0); + assert_eq!(created.load(Ordering::SeqCst), usize::from(server == "example.com")); + assert_eq!(handler.is_full_access_user(server), server == "example.com"); + handler.close().await; + } + } +} + +#[tokio::test] +async fn test_iscg_publication_still_requires_valid_openid() { + for path in ["/sfu/get", "/get_token"] { + for forged_subject in [false, true] { + let deps = HandlerTestDeps { + exchange_openid_userinfo_fn: if forged_subject { + exchange_openid_userinfo_ok("@victim:example.com") + } else { + Some(Box::new(|_| Err("invalid or expired token".into()))) + }, + // An accidental room creation or membership request panics. + ..Default::default() + }; + let handler = new_get_token_handler(deps); + let mut req = valid_sfu_request(); + req.openid_token.matrix_server_name = "astr.to".into(); + req.member.claimed_user_id = "@user:astr.to".into(); + let body = if path == "/get_token" { + serde_json::to_string(&req).unwrap() + } else { + serde_json::to_string(&LegacySfuRequest { + room: req.room_id, + openid_token: req.openid_token, + device_id: "DEVICE".into(), + ..Default::default() + }).unwrap() + }; + let resp = send_request(&handler, post_json(path, body)).await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{path} forged={forged_subject}"); + handler.close().await; + } + } +} diff --git a/src/main.rs b/src/main.rs index 2a057d6..7e7ebfd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,6 +49,10 @@ async fn main() { } } + info!( + "iscg standalone policy v1: verified users may publish; room creation and delayed-leave delegation remain full-access only" + ); + let config = match parse_config() { Ok(config) => config, Err(err) => {