Issue
I'm attempting to recreate in Blazor WASM a login scenario originally developed in an Angular SPA wherein I use an HttpIntercepter to catch any 401 responses, pop open a login window which redirects to our ADFS login, then closes and returns the login information and retries the failed (401) request. Here's what it looks like in Angular:
Angular LoginInterceptor
export class LoginInterceptor implements HttpInterceptor {
  constructor(private loginService: LoginService) { }
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(req).pipe(
      catchError((errorResponse: HttpErrorResponse) => {
        switch (errorResponse.status) {
          case 401:
            {
              console.log("Unauthorized");
              // call the LoginService's openLoginWindow and wait for it to complete
              return this.loginService.openLoginWindow().pipe(
                mergeMap((result) => {
                  if (result) {
                    // retry the request again
                    return next.handle(req);
                  }
                })
              );
            }
          default:
            break;
        }
        throw errorResponse;
      })
    ) as Observable<HttpEvent<any>>;
  }
}
Angular LoginService
export class LoginService {
  loginWindow: Window;
  userName: BehaviorSubject<string> = new BehaviorSubject(null);
  private windowsMessageObservable: Observable<MessageEvent>;
  constructor() {
    // Handle the Window.OnMessage event which listens for a successful login message in the new window
    this.windowsMessageObservable = fromEvent<MessageEvent>(window, 'message');
  }
  openLoginWindow() {
    // Open the new window
    this.loginWindow = window.open("/SSOSignIn", 'loginWindow');
    // Return an observable that fires when the login message is received
    const signInObservable = new Observable<boolean>(obs => {
      this.windowsMessageObservable.subscribe(evt => {
        if (evt.origin === location.origin) {
          if (evt.data?.type === 'signIn') {
            this.userName.next(evt.data.name);            
            obs.next(true)
          }
        }
      });
    });
    return signInObservable;
  }
}
This works great in Angular. When a page loads or if a login expires, a request for data fails with 401, gets intercepted, pops up the login window which closes automatically after SSO completes, and the request seamlessly retries without having to reload or reclick a button, but in Blazor/C#, I can't seem to wrap my head around how I can retry the original request since we're not dealing with observables.
In Blazor/C#, as far as I can tell, the concept of HttpInterceptors are implemented using DelegatingHandlers.  I've created a handler which pops open the login window and signs in, but I don't know a good way to hold off retrying and returning the response until after the login completes.  Here's my handler:
namespace BlazorPlayground.Client.Handlers
{
    public class UnauthorizedMessageHandler : DelegatingHandler, IDisposable
    {
        public UnauthorizedMessageHandler(IJSRuntime iJSRuntime)
        {
            JS = iJSRuntime;
        }
        
        private IJSRuntime JS { get; set; }
        protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            var response = await base.SendAsync(request, cancellationToken);
            if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
            {
                // This opens a new window but the operation continues immediately after.
                // Need to somehow wait for login to complete here so I can retry request
                await JS.InvokeVoidAsync("openUrl", "/SSOSignIn", "_blank");
            }
            return response;
        }
    }
}
Any ideas? Basically I need this SendAsync code to wait for a JS Window.Message event before completing and returning a response.
Solution
Ok I figured out a working solution.  The basic concept I came up with was: create an await-able task that completes when the function is invoked from JS.  The key behind this is to use the TaskCompletionSource<> which allows you to wait for it to be marked complete from anywhere.  In my case I'm waiting for the TaskCompletionSource<> to complete when my Window.Message event handler invokes my C# ReceiveMessage method.  Here's my interceptor:
    public class UnauthorizedMessageHandler : DelegatingHandler, IDisposable
    {
        private DotNetObjectReference<UnauthorizedMessageHandler> objRef;
        private TaskCompletionSource<string> tcs;
        public UnauthorizedMessageHandler(IJSRuntime iJSRuntime)
        {
            JS = iJSRuntime;
        }
        
        private IJSRuntime JS { get; set; }
        protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            var response = await base.SendAsync(request, cancellationToken);
            if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
            {
                // Create a reference to this object so ReceiveMessage can be invoked from JS
                objRef = DotNetObjectReference.Create(this);
                // This allows us to wait for another method to complete before we continue
                tcs = new TaskCompletionSource<string>();
                // Open up the sign-in page
                await JS.InvokeVoidAsync("openUrl", "/SSOSignIn", "_blank", objRef);
                // Wait until ReceiveMessage is fired off
                var message = await tcs.Task;
                // Retry the original request
                response = await base.SendAsync(request, cancellationToken);
            }
            return response;
        }
        [JSInvokable]
        public void ReceiveMessage(string message)
        {
            // Get the message from JS and return it to the awaitable task
            tcs.TrySetResult(message);
        }
    }
Here's my javascript
var windowMessageObjRef = null;
window.addEventListener('message', (evt) => {
    // Make sure the message came from us
    // Need to add checks to make sure it's got the data we expect
    if (evt.origin === location.origin) {
        // Check to make sure we have a reference to our DotNet interop object
        if (windowMessageObjRef) {
            // Send the name of the person who logged in
            console.log('Invoking ReceiveMessage with data ' + evt.data.name);
            windowMessageObjRef.invokeMethodAsync('ReceiveMessage', evt.data.name);
        }
    }
});
function openUrl(url, target, objRef) {
    if (objRef) {
        windowMessageObjRef = objRef;
    }
    console.log("Opening " + url + " with target " + target);
    window.open(url, target);
}
Since this is a SPA application I don't want to leave the original page so my SSOSignIn page is popped opened in a new tab/window which just fires off the login challenge which redirects to ADFS and returns us to the SSOComplete page:
public class SSOSignInModel : PageModel
{
    public ChallengeResult OnGet()
    {
        return Challenge(new AuthenticationProperties
        {
            RedirectUri = "/SSOComplete"
        });
    }
}
And the SSOComplete page posts the message to the opener (SPA) window with the name of the signed-in user and then closes itself.
<html>
<head>
    <title>Redirecting to sign-in...</title>
</head>
<body>
    <script type="text/javascript">
        (function () {
            var message = {
                type: 'signIn',
                success: true,
                name: '@User.Identity.Name'
            };
            window.opener.postMessage(message, location.origin);
            window.close();
        })();
    </script>
</body>
</html>
Now I have the ability in Blazor to automatically pop up the signin window and retry the original request after the sign-in completes in Blazor without having to reload my SPA. I'm gonna go take a nap now.
Answered By - Zach
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.