1use std::net::TcpListener;
2use std::sync::{Arc, Mutex};
3
4use base64::Engine;
5use serde::Serialize;
6use sha2::{Digest, Sha256};
7
8use crate::mcp_auth::{OAuthAuthorizationServerMetadata, OAuthClientAuthMode};
9
10#[derive(Clone, Debug, Default)]
12pub struct BeginAuthorization {
13 pub server_url: String,
14 pub redirect_uri: String,
15 pub mode: Option<OAuthClientAuthMode>,
16 pub client_id: Option<String>,
17 pub client_secret: Option<String>,
18 pub static_secret_id: Option<String>,
19 pub scopes: Option<String>,
20}
21
22#[derive(Clone, Debug)]
24pub struct ImportStoredToken {
25 pub server_url: String,
26 pub access_token: String,
27 pub refresh_token: Option<String>,
28 pub expires_at_unix: Option<i64>,
29 pub token_endpoint: Option<String>,
30 pub client_id: String,
31 pub client_secret: Option<String>,
32 pub token_endpoint_auth_method: Option<String>,
33 pub scopes: Option<String>,
34}
35
36#[derive(Clone, Debug, Serialize)]
38pub struct PendingAuthorization {
39 pub authorize_url: String,
40 pub state: String,
41 pub redirect_uri: String,
42 pub resource: String,
44 pub issuer: String,
46}
47
48#[derive(Clone, Debug, Default)]
55pub enum AuthorizationCallback {
56 #[default]
57 Exact,
58 Loopback(Arc<LoopbackCallback>),
59}
60
61#[derive(Debug)]
65pub struct LoopbackCallback {
66 requested_redirect_uri: String,
67 acquired: Mutex<Option<AcquiredLoopbackCallback>>,
68}
69
70#[derive(Debug)]
71struct AcquiredLoopbackCallback {
72 listener: TcpListener,
73 redirect_uri: String,
74}
75
76impl LoopbackCallback {
77 pub fn new(requested_redirect_uri: impl Into<String>) -> Self {
78 Self {
79 requested_redirect_uri: requested_redirect_uri.into(),
80 acquired: Mutex::new(None),
81 }
82 }
83
84 pub fn take_listener(&self) -> Result<(TcpListener, String), String> {
87 let acquired = self
88 .acquired
89 .lock()
90 .unwrap_or_else(|poison| poison.into_inner())
91 .take()
92 .ok_or_else(|| {
93 "MCP OAuth loopback callback was not acquired before listener handoff".to_string()
94 })?;
95 Ok((acquired.listener, acquired.redirect_uri))
96 }
97
98 pub(super) fn acquire(&self, policy: &OAuthRedirectPolicy) -> Result<String, String> {
99 let mut acquired = self
100 .acquired
101 .lock()
102 .unwrap_or_else(|poison| poison.into_inner());
103 if let Some(existing) = acquired.as_ref() {
104 policy.validate_effective_redirect(&existing.redirect_uri)?;
105 return Ok(existing.redirect_uri.clone());
106 }
107 if self.requested_redirect_uri != policy.requested_redirect_uri {
108 return Err(format!(
109 "MCP OAuth callback requested `{}` but redirect policy selected `{}` for client mode `{}`",
110 self.requested_redirect_uri,
111 policy.requested_redirect_uri,
112 policy.client_mode.as_str()
113 ));
114 }
115 let callback = acquire_loopback_callback(policy)?;
116 let redirect_uri = callback.redirect_uri.clone();
117 *acquired = Some(callback);
118 Ok(redirect_uri)
119 }
120}
121
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub(super) enum CallbackCapabilities {
124 Exact,
125 LoopbackWithEphemeralPort,
126}
127
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub(super) struct OAuthRedirectPolicy {
130 pub(super) requested_redirect_uri: String,
131 pub(super) client_mode: OAuthClientAuthMode,
132 pub(super) allow_ephemeral_port: bool,
133}
134
135impl OAuthRedirectPolicy {
136 fn validate_effective_redirect(&self, effective: &str) -> Result<(), String> {
137 if effective == self.requested_redirect_uri {
138 return Ok(());
139 }
140 let requested = validate_loopback_redirect_uri(&self.requested_redirect_uri)?;
141 let effective_url = validate_loopback_redirect_uri(effective)?;
142 let same_except_port = requested.scheme() == effective_url.scheme()
143 && requested.host_str() == effective_url.host_str()
144 && requested.path() == effective_url.path()
145 && requested.query() == effective_url.query();
146 if self.allow_ephemeral_port && same_except_port {
147 return Ok(());
148 }
149 Err(redirect_compatibility_error(
150 &self.requested_redirect_uri,
151 self.client_mode,
152 "the callback surface acquired a different redirect URI",
153 self.allow_ephemeral_port,
154 ))
155 }
156}
157
158pub(super) fn select_redirect_policy(
165 metadata: &OAuthAuthorizationServerMetadata,
166 client_mode: OAuthClientAuthMode,
167 requested_redirect_uri: &str,
168 capabilities: CallbackCapabilities,
169) -> Result<OAuthRedirectPolicy, String> {
170 if capabilities == CallbackCapabilities::Exact {
171 return Ok(OAuthRedirectPolicy {
172 requested_redirect_uri: requested_redirect_uri.to_string(),
173 client_mode,
174 allow_ephemeral_port: false,
175 });
176 }
177
178 let parsed = validate_loopback_redirect_uri(requested_redirect_uri).map_err(|error| {
179 redirect_compatibility_error(requested_redirect_uri, client_mode, &error, false)
180 })?;
181 let explicitly_ephemeral = parsed.port() == Some(0);
182 let mode_allows_ephemeral = match client_mode {
183 OAuthClientAuthMode::Dcr => metadata.registration_endpoint.is_some(),
184 OAuthClientAuthMode::Cimd => metadata.client_id_metadata_document_supported,
185 OAuthClientAuthMode::Byo => explicitly_ephemeral,
186 OAuthClientAuthMode::Static => false,
187 };
188 Ok(OAuthRedirectPolicy {
189 requested_redirect_uri: requested_redirect_uri.to_string(),
190 client_mode,
191 allow_ephemeral_port: mode_allows_ephemeral,
192 })
193}
194
195fn validate_loopback_redirect_uri(redirect_uri: &str) -> Result<url::Url, String> {
196 let parsed = url::Url::parse(redirect_uri)
197 .map_err(|error| format!("redirect URI is invalid: {error}"))?;
198 if parsed.scheme() != "http" {
199 return Err("loopback callbacks require the `http` scheme".to_string());
200 }
201 if !parsed.username().is_empty()
202 || parsed.password().is_some()
203 || parsed.fragment().is_some()
204 || parsed.query().is_some()
205 {
206 return Err(
207 "loopback redirect URI must not contain credentials, a query, or a fragment"
208 .to_string(),
209 );
210 }
211 let host = parsed
212 .host_str()
213 .ok_or_else(|| "loopback redirect URI must include a host".to_string())?;
214 let is_loopback = host.eq_ignore_ascii_case("localhost")
215 || host
216 .parse::<std::net::IpAddr>()
217 .is_ok_and(|address| address.is_loopback());
218 if !is_loopback {
219 return Err(format!(
220 "loopback redirect URI host `{host}` is not a loopback address"
221 ));
222 }
223 Ok(parsed)
224}
225
226fn acquire_loopback_callback(
227 policy: &OAuthRedirectPolicy,
228) -> Result<AcquiredLoopbackCallback, String> {
229 let requested = validate_loopback_redirect_uri(&policy.requested_redirect_uri)?;
230 match bind_loopback_uri(requested.clone()) {
231 Ok(callback) => Ok(callback),
232 Err(requested_error) if policy.allow_ephemeral_port && requested.port() != Some(0) => {
233 let mut fallback = requested;
234 fallback.set_port(Some(0)).map_err(|()| {
235 redirect_compatibility_error(
236 &policy.requested_redirect_uri,
237 policy.client_mode,
238 "could not construct an ephemeral loopback redirect URI",
239 true,
240 )
241 })?;
242 bind_loopback_uri(fallback).map_err(|fallback_error| {
243 redirect_compatibility_error(
244 &policy.requested_redirect_uri,
245 policy.client_mode,
246 &format!(
247 "requested port bind failed ({requested_error}); operating-system port fallback also failed ({fallback_error})"
248 ),
249 true,
250 )
251 })
252 }
253 Err(error) => Err(redirect_compatibility_error(
254 &policy.requested_redirect_uri,
255 policy.client_mode,
256 &format!("callback listener bind failed: {error}"),
257 policy.allow_ephemeral_port,
258 )),
259 }
260}
261
262fn bind_loopback_uri(mut redirect_uri: url::Url) -> Result<AcquiredLoopbackCallback, String> {
263 let host = redirect_uri
264 .host_str()
265 .ok_or_else(|| "redirect URI must include a host".to_string())?;
266 let port = redirect_uri
267 .port_or_known_default()
268 .ok_or_else(|| "redirect URI must resolve to a callback port".to_string())?;
269 let listener = TcpListener::bind((host, port)).map_err(|error| error.to_string())?;
270 listener
271 .set_nonblocking(false)
272 .map_err(|error| format!("failed to configure callback listener: {error}"))?;
273 let actual_port = listener
274 .local_addr()
275 .map_err(|error| format!("failed to read callback listener address: {error}"))?
276 .port();
277 redirect_uri
278 .set_port(Some(actual_port))
279 .map_err(|()| "failed to record callback listener port".to_string())?;
280 Ok(AcquiredLoopbackCallback {
281 listener,
282 redirect_uri: redirect_uri.to_string(),
283 })
284}
285
286pub(super) fn redirect_compatibility_error(
287 redirect_uri: &str,
288 client_mode: OAuthClientAuthMode,
289 detail: &str,
290 ephemeral_permitted: bool,
291) -> String {
292 redirect_compatibility_error_for_mode(
293 redirect_uri,
294 client_mode.as_str(),
295 detail,
296 ephemeral_permitted,
297 )
298}
299
300pub(super) fn redirect_compatibility_error_for_mode(
301 redirect_uri: &str,
302 client_mode: &str,
303 detail: &str,
304 ephemeral_permitted: bool,
305) -> String {
306 let restriction = if ephemeral_permitted {
307 "the authorization server or local callback environment may restrict arbitrary loopback ports"
308 } else {
309 "this client mode may require the exact preregistered redirect URI and port"
310 };
311 format!(
312 "MCP OAuth redirect URI `{redirect_uri}` is unavailable for client mode `{client_mode}`: {detail}; likely compatibility restriction: {restriction}"
313 )
314}
315
316pub(super) fn generate_pkce_pair() -> (String, String) {
317 let verifier = random_hex(32);
318 let digest = Sha256::digest(verifier.as_bytes());
319 let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
320 (verifier, challenge)
321}
322
323pub(super) fn random_hex(bytes: usize) -> String {
324 (0..bytes)
325 .map(|_| format!("{:02x}", rand::random::<u8>()))
326 .collect()
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use crate::mcp_auth::OAuthClientAuthSelection;
333 use tokio::sync::Mutex as AsyncMutex;
334
335 async fn read_http_request(stream: &mut tokio::net::TcpStream) -> (String, Vec<u8>) {
336 let mut request = Vec::new();
337 let mut buffer = [0_u8; 4096];
338 loop {
339 let read = tokio::io::AsyncReadExt::read(stream, &mut buffer)
340 .await
341 .unwrap();
342 if read == 0 {
343 break;
344 }
345 request.extend_from_slice(&buffer[..read]);
346 if let Some(headers_end) = request.windows(4).position(|window| window == b"\r\n\r\n") {
347 let headers = String::from_utf8_lossy(&request[..headers_end]);
348 let content_length = headers
349 .lines()
350 .find_map(|line| {
351 line.to_ascii_lowercase()
352 .strip_prefix("content-length:")
353 .and_then(|value| value.trim().parse::<usize>().ok())
354 })
355 .unwrap_or_default();
356 if request.len() >= headers_end + 4 + content_length {
357 let target = headers
358 .lines()
359 .next()
360 .and_then(|line| line.split_whitespace().nth(1))
361 .unwrap()
362 .to_string();
363 return (target, request[headers_end + 4..].to_vec());
364 }
365 }
366 }
367 panic!("incomplete HTTP request");
368 }
369
370 async fn write_http_response(
371 stream: &mut tokio::net::TcpStream,
372 status: &str,
373 headers: &[(&str, String)],
374 body: &str,
375 ) {
376 let mut response = format!(
377 "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n",
378 body.len()
379 );
380 for (name, value) in headers {
381 response.push_str(&format!("{name}: {value}\r\n"));
382 }
383 response.push_str("\r\n");
384 response.push_str(body);
385 tokio::io::AsyncWriteExt::write_all(stream, response.as_bytes())
386 .await
387 .unwrap();
388 }
389
390 #[test]
391 fn pkce_challenge_is_s256_of_verifier() {
392 let (verifier, challenge) = generate_pkce_pair();
393 let expected = base64::engine::general_purpose::URL_SAFE_NO_PAD
394 .encode(Sha256::digest(verifier.as_bytes()));
395 assert_eq!(challenge, expected);
396 assert_eq!(verifier.len(), 64);
397 }
398
399 fn test_metadata() -> OAuthAuthorizationServerMetadata {
400 OAuthAuthorizationServerMetadata {
401 issuer: "https://auth.example".to_string(),
402 authorization_endpoint: "https://auth.example/authorize".to_string(),
403 token_endpoint: "https://auth.example/token".to_string(),
404 registration_endpoint: None,
405 token_endpoint_auth_methods_supported: vec!["none".to_string()],
406 code_challenge_methods_supported: vec!["S256".to_string()],
407 scopes_supported: Vec::new(),
408 client_id_metadata_document_supported: false,
409 authorization_response_iss_parameter_supported: false,
410 extra: Default::default(),
411 }
412 }
413
414 #[test]
415 fn exact_match_preregistered_redirect_does_not_change_ports() {
416 let blocker = TcpListener::bind("127.0.0.1:0").unwrap();
417 let redirect_uri = format!(
418 "http://127.0.0.1:{}/oauth/callback",
419 blocker.local_addr().unwrap().port()
420 );
421 let policy = select_redirect_policy(
422 &test_metadata(),
423 OAuthClientAuthMode::Byo,
424 &redirect_uri,
425 CallbackCapabilities::LoopbackWithEphemeralPort,
426 )
427 .unwrap();
428 assert!(!policy.allow_ephemeral_port);
429
430 let error = acquire_loopback_callback(&policy).unwrap_err();
431 assert!(error.contains(&redirect_uri), "{error}");
432 assert!(error.contains("client mode `byo`"), "{error}");
433 assert!(error.contains("exact preregistered"), "{error}");
434 }
435
436 #[test]
437 fn dynamic_registration_falls_back_after_fixed_port_conflict() {
438 let blocker = TcpListener::bind("127.0.0.1:0").unwrap();
439 let blocked_port = blocker.local_addr().unwrap().port();
440 let redirect_uri = format!("http://127.0.0.1:{blocked_port}/oauth/callback");
441 let mut metadata = test_metadata();
442 metadata.registration_endpoint = Some("https://auth.example/register".to_string());
443 let policy = select_redirect_policy(
444 &metadata,
445 OAuthClientAuthMode::Dcr,
446 &redirect_uri,
447 CallbackCapabilities::LoopbackWithEphemeralPort,
448 )
449 .unwrap();
450
451 let acquired = acquire_loopback_callback(&policy).unwrap();
452 let effective = url::Url::parse(&acquired.redirect_uri).unwrap();
453 assert_ne!(effective.port(), Some(blocked_port));
454 assert_ne!(effective.port(), Some(0));
455 assert_eq!(effective.path(), "/oauth/callback");
456 }
457
458 #[test]
459 fn cimd_loopback_policy_accepts_operating_system_port() {
460 let mut metadata = test_metadata();
461 metadata.client_id_metadata_document_supported = true;
462 let policy = select_redirect_policy(
463 &metadata,
464 OAuthClientAuthMode::Cimd,
465 "http://127.0.0.1:0/oauth/callback",
466 CallbackCapabilities::LoopbackWithEphemeralPort,
467 )
468 .unwrap();
469 let acquired = acquire_loopback_callback(&policy).unwrap();
470 let effective = url::Url::parse(&acquired.redirect_uri).unwrap();
471 assert_ne!(effective.port(), Some(0));
472 policy
473 .validate_effective_redirect(&acquired.redirect_uri)
474 .unwrap();
475 }
476
477 #[test]
478 fn protocol_host_exact_redirect_is_preserved() {
479 let policy = select_redirect_policy(
480 &test_metadata(),
481 OAuthClientAuthMode::Byo,
482 "burin-labs://oauth/callback",
483 CallbackCapabilities::Exact,
484 )
485 .unwrap();
486 assert_eq!(policy.requested_redirect_uri, "burin-labs://oauth/callback");
487 assert!(!policy.allow_ephemeral_port);
488 }
489
490 #[tokio::test]
491 async fn strict_server_observes_acquired_redirect_and_oauth_bindings() {
492 let authorization_server = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
493 let issuer = format!("http://{}", authorization_server.local_addr().unwrap());
494 let registered_redirect = Arc::new(AsyncMutex::new(None::<String>));
495 let observed_redirect = registered_redirect.clone();
496 let server_issuer = issuer.clone();
497 let server = tokio::spawn(async move {
498 for _ in 0..4 {
499 let (mut stream, _) = authorization_server.accept().await.unwrap();
500 let (target, body) = read_http_request(&mut stream).await;
501 match target.as_str() {
502 "/mcp" => {
503 write_http_response(
504 &mut stream,
505 "401 Unauthorized",
506 &[(
507 "WWW-Authenticate",
508 format!(
509 "Bearer resource_metadata=\"{server_issuer}/.well-known/oauth-protected-resource/mcp\""
510 ),
511 )],
512 "",
513 )
514 .await;
515 }
516 "/.well-known/oauth-protected-resource/mcp" => {
517 let response = serde_json::json!({
518 "resource": format!("{server_issuer}/mcp"),
519 "authorization_servers": [&server_issuer],
520 "scopes_supported": ["mcp.read"]
521 })
522 .to_string();
523 write_http_response(
524 &mut stream,
525 "200 OK",
526 &[("Content-Type", "application/json".to_string())],
527 &response,
528 )
529 .await;
530 }
531 "/.well-known/oauth-authorization-server" => {
532 let response = serde_json::json!({
533 "issuer": &server_issuer,
534 "authorization_endpoint": format!("{server_issuer}/authorize"),
535 "token_endpoint": format!("{server_issuer}/token"),
536 "registration_endpoint": format!("{server_issuer}/register"),
537 "token_endpoint_auth_methods_supported": ["none"],
538 "code_challenge_methods_supported": ["S256"]
539 })
540 .to_string();
541 write_http_response(
542 &mut stream,
543 "200 OK",
544 &[("Content-Type", "application/json".to_string())],
545 &response,
546 )
547 .await;
548 }
549 "/register" => {
550 let registration: serde_json::Value =
551 serde_json::from_slice(&body).unwrap();
552 *observed_redirect.lock().await = registration["redirect_uris"][0]
553 .as_str()
554 .map(str::to_string);
555 write_http_response(
556 &mut stream,
557 "201 Created",
558 &[("Content-Type", "application/json".to_string())],
559 r#"{"client_id":"strict-client","token_endpoint_auth_method":"none"}"#,
560 )
561 .await;
562 }
563 _ => panic!("unexpected strict-server request target: {target}"),
564 }
565 }
566 });
567
568 let blocker = TcpListener::bind("127.0.0.1:0").unwrap();
569 let requested_redirect = format!(
570 "http://127.0.0.1:{}/oauth/callback",
571 blocker.local_addr().unwrap().port()
572 );
573 let callback = Arc::new(LoopbackCallback::new(&requested_redirect));
574 let pending = super::super::begin_authorization_with_callback(
575 BeginAuthorization {
576 server_url: format!("{issuer}/mcp"),
577 redirect_uri: requested_redirect.clone(),
578 mode: Some(OAuthClientAuthMode::Dcr),
579 ..BeginAuthorization::default()
580 },
581 &AuthorizationCallback::Loopback(callback.clone()),
582 )
583 .await
584 .unwrap();
585 let (_listener, effective_redirect) = callback.take_listener().unwrap();
586 server.await.unwrap();
587
588 assert_ne!(effective_redirect, requested_redirect);
589 assert_eq!(pending.redirect_uri, effective_redirect);
590 assert_eq!(
591 registered_redirect.lock().await.as_deref(),
592 Some(effective_redirect.as_str())
593 );
594 assert_eq!(pending.resource, format!("{issuer}/mcp"));
595 assert_eq!(pending.issuer, issuer);
596 let authorize_url = url::Url::parse(&pending.authorize_url).unwrap();
597 let query = authorize_url
598 .query_pairs()
599 .collect::<std::collections::HashMap<_, _>>();
600 assert_eq!(
601 query.get("redirect_uri").map(|value| value.as_ref()),
602 Some(effective_redirect.as_str())
603 );
604 assert_eq!(
605 query.get("resource").map(|value| value.as_ref()),
606 Some(pending.resource.as_str())
607 );
608 assert_eq!(
609 query.get("state").map(|value| value.as_ref()),
610 Some(pending.state.as_str())
611 );
612 assert_eq!(
613 query
614 .get("code_challenge_method")
615 .map(|value| value.as_ref()),
616 Some("S256")
617 );
618 assert!(query.contains_key("code_challenge"));
619 }
620
621 #[tokio::test]
622 async fn dynamic_registration_reregisters_effective_redirect_after_port_change() {
623 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
624 let endpoint = format!("http://{}/register", listener.local_addr().unwrap());
625 let registered = Arc::new(AsyncMutex::new(Vec::<String>::new()));
626 let server_registered = registered.clone();
627 let server = tokio::spawn(async move {
628 for index in 0..2 {
629 let (mut stream, _) = listener.accept().await.unwrap();
630 let mut request = Vec::new();
631 let mut buffer = [0_u8; 4096];
632 loop {
633 let read = tokio::io::AsyncReadExt::read(&mut stream, &mut buffer)
634 .await
635 .unwrap();
636 if read == 0 {
637 break;
638 }
639 request.extend_from_slice(&buffer[..read]);
640 if let Some(headers_end) =
641 request.windows(4).position(|window| window == b"\r\n\r\n")
642 {
643 let headers = String::from_utf8_lossy(&request[..headers_end]);
644 let content_length = headers
645 .lines()
646 .find_map(|line| {
647 line.to_ascii_lowercase()
648 .strip_prefix("content-length:")
649 .and_then(|value| value.trim().parse::<usize>().ok())
650 })
651 .unwrap_or_default();
652 if request.len() >= headers_end + 4 + content_length {
653 break;
654 }
655 }
656 }
657 let body_start = request
658 .windows(4)
659 .position(|window| window == b"\r\n\r\n")
660 .unwrap()
661 + 4;
662 let body: serde_json::Value =
663 serde_json::from_slice(&request[body_start..]).unwrap();
664 server_registered
665 .lock()
666 .await
667 .push(body["redirect_uris"][0].as_str().unwrap().to_string());
668 let response_body = format!(
669 r#"{{"client_id":"client-{index}","token_endpoint_auth_method":"none"}}"#
670 );
671 let response = format!(
672 "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
673 response_body.len(),
674 response_body
675 );
676 tokio::io::AsyncWriteExt::write_all(&mut stream, response.as_bytes())
677 .await
678 .unwrap();
679 }
680 });
681
682 let mut metadata = test_metadata();
683 metadata.registration_endpoint = Some(endpoint);
684 for redirect_uri in [
685 "http://127.0.0.1:49152/oauth/callback",
686 "http://127.0.0.1:49153/oauth/callback",
687 ] {
688 super::super::resolve_selected_client(
689 &metadata,
690 OAuthClientAuthSelection {
691 mode: OAuthClientAuthMode::Dcr,
692 client_id: None,
693 },
694 None,
695 redirect_uri,
696 Some("mcp.read"),
697 )
698 .await
699 .unwrap();
700 }
701 server.await.unwrap();
702 assert_eq!(
703 *registered.lock().await,
704 vec![
705 "http://127.0.0.1:49152/oauth/callback",
706 "http://127.0.0.1:49153/oauth/callback"
707 ]
708 );
709 }
710}