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.