@@ -11,7 +11,7 @@ namespace ModelContextProtocol.Protocol.Auth;
1111/// <summary>
1212/// Provides OAuth 2.0 authorization services for MCP clients.
1313/// </summary>
14- internal class AuthorizationService
14+ public class AuthorizationService
1515{
1616 private static readonly HttpClient s_httpClient = new ( )
1717 {
@@ -448,9 +448,166 @@ private static Dictionary<string, string> ParseAuthHeaderParameters(string param
448448 break ;
449449
450450 start = commaPos + 1 ;
451- }
452- }
451+ } }
453452
454453 return result ;
455454 }
455+
456+ /// <summary>
457+ /// Creates an HTTP listener callback for handling OAuth 2.0 authorization code flow.
458+ /// </summary>
459+ /// <param name="openBrowser">A function that opens a browser with the given URL.</param>
460+ /// <param name="hostname">The hostname to listen on. Defaults to "localhost".</param>
461+ /// <param name="listenPort">The port to listen on. Defaults to 8888.</param>
462+ /// <param name="redirectPath">The redirect path for the HTTP listener. Defaults to "/callback".</param>
463+ /// <returns>
464+ /// A function that takes <see cref="ClientMetadata"/> and returns a task that resolves to a tuple containing
465+ /// the redirect URI and the authorization code.
466+ /// </returns>
467+ public static Func < ClientMetadata , Task < ( string RedirectUri , string Code ) > > CreateHttpListenerAuthorizeCallback (
468+ Func < string , Task > openBrowser ,
469+ string hostname = "localhost" ,
470+ int listenPort = 8888 ,
471+ string redirectPath = "/callback" )
472+ {
473+ return async ( ClientMetadata clientMetadata ) =>
474+ {
475+ string redirectUri = $ "http://{ hostname } :{ listenPort } { redirectPath } ";
476+
477+ foreach ( var uri in clientMetadata . RedirectUris )
478+ {
479+ if ( uri . StartsWith ( $ "http://{ hostname } ", StringComparison . OrdinalIgnoreCase ) &&
480+ Uri . TryCreate ( uri , UriKind . Absolute , out var parsedUri ) )
481+ {
482+ redirectUri = uri ;
483+ listenPort = parsedUri . IsDefaultPort ? 80 : parsedUri . Port ;
484+ redirectPath = parsedUri . AbsolutePath ;
485+ break ;
486+ }
487+ }
488+
489+ var authCodeTcs = new TaskCompletionSource < string > ( ) ;
490+ // Ensure the path has a trailing slash for the HttpListener prefix
491+ string listenerPrefix = $ "http://{ hostname } :{ listenPort } { redirectPath } ";
492+ if ( ! listenerPrefix . EndsWith ( "/" ) )
493+ {
494+ listenerPrefix += "/" ;
495+ }
496+
497+ using var listener = new HttpListener ( ) ;
498+ listener . Prefixes . Add ( listenerPrefix ) ;
499+
500+ // Start the listener BEFORE opening the browser
501+ try
502+ {
503+ listener . Start ( ) ;
504+ }
505+ catch ( HttpListenerException ex )
506+ {
507+ throw new McpException ( $ "Failed to start HTTP listener on { listenerPrefix } : { ex . Message } ", McpErrorCode . InvalidRequest ) ;
508+ }
509+
510+ // Create a cancellation token source with a timeout
511+ using var cts = new CancellationTokenSource ( TimeSpan . FromMinutes ( 5 ) ) ;
512+
513+ _ = Task . Run ( async ( ) =>
514+ {
515+ try
516+ {
517+ // GetContextAsync doesn't accept a cancellation token, so we need to handle cancellation manually
518+ var contextTask = listener . GetContextAsync ( ) ;
519+ var completedTask = await Task . WhenAny ( contextTask , Task . Delay ( Timeout . Infinite , cts . Token ) ) ;
520+
521+ if ( completedTask == contextTask )
522+ {
523+ var context = await contextTask ;
524+ var request = context . Request ;
525+ var response = context . Response ;
526+
527+ string ? code = request . QueryString [ "code" ] ;
528+ string ? error = request . QueryString [ "error" ] ;
529+ string html ;
530+ string ? resultCode = null ;
531+
532+ if ( ! string . IsNullOrEmpty ( error ) )
533+ {
534+ html = $ "<html><body><h1>Authorization Failed</h1><p>Error: { WebUtility . HtmlEncode ( error ) } </p></body></html>";
535+ }
536+ else if ( string . IsNullOrEmpty ( code ) )
537+ {
538+ html = "<html><body><h1>Authorization Failed</h1><p>No authorization code received.</p></body></html>" ;
539+ }
540+ else
541+ {
542+ html = "<html><body><h1>Authorization Successful</h1><p>You may now close this window.</p></body></html>" ;
543+ resultCode = code ;
544+ }
545+
546+ try
547+ {
548+ // Send response to browser
549+ byte [ ] buffer = Encoding . UTF8 . GetBytes ( html ) ;
550+ response . ContentType = "text/html" ;
551+ response . ContentLength64 = buffer . Length ;
552+ response . OutputStream . Write ( buffer , 0 , buffer . Length ) ;
553+
554+ // IMPORTANT: Explicitly close the response to ensure it's fully sent
555+ response . Close ( ) ;
556+
557+ // Now that we've finished processing the browser response,
558+ // we can safely signal completion or failure with the auth code
559+ if ( resultCode != null )
560+ {
561+ authCodeTcs . TrySetResult ( resultCode ) ;
562+ }
563+ else if ( ! string . IsNullOrEmpty ( error ) )
564+ {
565+ authCodeTcs . TrySetException ( new McpException ( $ "Authorization failed: { error } ", McpErrorCode . InvalidRequest ) ) ;
566+ }
567+ else
568+ {
569+ authCodeTcs . TrySetException ( new McpException ( "No authorization code received" , McpErrorCode . InvalidRequest ) ) ;
570+ }
571+ }
572+ catch ( Exception ex )
573+ {
574+ authCodeTcs . TrySetException ( new McpException ( $ "Error processing browser response: { ex . Message } ", McpErrorCode . InvalidRequest ) ) ;
575+ }
576+ }
577+ }
578+ catch ( Exception ex )
579+ {
580+ authCodeTcs . TrySetException ( ex ) ;
581+ }
582+ } ) ;
583+
584+ // Now open the browser AFTER the listener is started
585+ if ( ! string . IsNullOrEmpty ( clientMetadata . ClientUri ) )
586+ {
587+ await openBrowser ( clientMetadata . ClientUri ! ) ;
588+ }
589+ else
590+ {
591+ // Stop the listener before throwing
592+ listener . Stop ( ) ;
593+ throw new McpException ( "Client URI is missing in metadata." , McpErrorCode . InvalidRequest ) ;
594+ }
595+
596+ try
597+ {
598+ // Use a timeout to avoid hanging indefinitely
599+ string authCode = await authCodeTcs . Task . WaitAsync ( cts . Token ) ;
600+ return ( redirectUri , authCode ) ;
601+ }
602+ catch ( OperationCanceledException )
603+ {
604+ throw new McpException ( "Authorization timed out after 5 minutes." , McpErrorCode . InvalidRequest ) ;
605+ }
606+ finally
607+ {
608+ // Ensure the listener is stopped when we're done
609+ listener . Stop ( ) ;
610+ }
611+ } ;
612+ }
456613}
0 commit comments