Blazor Authentication with JSON Web Tokens

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
Over the last couple of videos, we've been looking  at how to make an Angular front end that can log   in to an ASP.NET web service using JWTs - JSON  Web Tokens. And in this video, I'm going to do   exactly the same thing in terms of front end. But  this time, I'm going to do it in Blazor so that   we've got .NET, C# on the front end, as well as  on the backend server. And I've done quite a lot   of work in putting that together. So if we just  take a look at the application we've already got,   then it's got really just like we saw in the  Angular. We can list all the reviews, we can list   a summary of reviews, and I've got a login page that  … at the moment it’s just set up the form, but it   doesn't actually do anything. That's what we're  going to look at in this video. And at the moment,   if we look at the server, we can see that I have  cheated that. I have turned off the authorization.   So let's change that, so now we do need to login,  and let that run up. And so now what we'll see   is if I try to look at all the reviews, we get an  error ‘Failed to retrieve data’ in both cases. So   we've got to do the login. So let's take a look at  the code that's making all of that work. And so if   we just take a quick look round, you can see what  I've got is a Reviews Razor component. And that's   where we've got the <table>, loops through all the  reviews in the list. And you can see that we're   using this repository that's been injected, you  can see up there, and the repository that you can   see there uses an HttpClientFactory to get hold of  the HttpClient. And we've configured that in the   Program here. So that's where we've configured  the client just called ‘ServerApi’ and given   it the base address, which is coming from the  appsettings file. Just one quick thing to notice   about the appsettings file, when you're doing in  Blazor Web Assembly, it has to go in the wwwroot,   so we can see it up there, and that's where  we're defining the address of the backend server.   But it has to go there, because that's been  downloaded and read by the code in the browser,   it's not being read on the server, like you'd  have in a normal appsettings file. So that's   just one slight thing to notice. But that's where  we getting that from. And then the ReviewRepository uses   that to get the data from JSON - either all the  reviews or gets the summaries. That's what gets   put back into the Reviews.razor. And then whether  we're looking at summaries or at all the reviews,   is handled by the fact that we've got the  AllReviews that simply loads up reviews with   the ‘style’ of ‘all’, or we've got ReviewSummary  that loads up with the ‘style’ of ‘summary’. So   that's how we distinguish those. So that's the  base application we've got. And then of course,   we've got the login, which, as I say, is just  the form. Then we've got the ‘Submit’ method,   but we haven't done anything there. So let's get going  on that. And the first thing we need to do   is to put in an AuthenticationService - so  very much like we did in Angular. So we'll   go to Services and we'll add in there a new  class that we’ll call AuthenticationService.   And then let's put some useful methods in there.  So the first thing we're going to have in there   is the Login. And so we're going to have this as  a ‘public’. It's going to have to be asynchronous,   for reasons we’ll see in a moment. So we'll have  an ‘async Task’, and if you remember the way we've   done this before, we want the login simply  to return a ‘DateTime’ which is going to be   the time at which the JWT expires. And we'll call  this ‘LoginAsync’ because it's an async function.   And that's going to take a ‘LoginModel’, which  I've already defined. That's the model that we're   using in the form inside the Login component and  we can see that just contains the username and   the password, you can see there. So that's what  we'll have coming in there. And then what do we   do with that? Well, we're going to use, again,  the HttpClient that we're going to get from the   HttpClientFactory. So let's set that up. So what  I'm going to do is have a constructor and that's   going to take in this IHttpClientFactory, that  we’ll call ‘factory’. And then we'll have a member   of type HttpClient and we can set that up by just  saying ‘_httpClient = factory.CreateClient’ and   then remember, in our Program, we had named the  particular client - so we’re allowed multiple   clients doing it this way - but we're going to go  for that ‘ServerApi’ that we have there. And so   that's created our client. And then in here, the  very first thing we'll do is we'll say ‘var’ and   then we'll call this ‘response = await’ and then  on that '_httpClient', we're going to remember that   this is a POST, so we’ll do a ‘PostAsync’  and that's going to go within the site to   ‘api/authentication/login’. And then it's going to  take a body which is going to be that LoginModel.   So the nice thing about that, that exactly matches  the LoginModel that we are receiving on the other   side, because these are both C# now. And so  we'll just use this thing called ‘JsonContent’,   which allows us to convert things to JSON.  We'll just get hold of the namespace for   that and then on that we say ‘.Create’ and  we pass in the ‘model’ that we're going to   have in there. So that's what that call there  is going to look like. Then having done that,   we've got to just put in a guard clause. So we're  just going to say ‘if’ and then it's saying if   the response is successful, but actually, we want  to get out, so we're going to put a ‘!’ on there.   And then we can simply do a ‘throw’. And then  we'll have a ‘new UnauthorisedAccessException’   and then just simple message ‘LoginFailed’. But  if it succeeds, we then need to get hold of the   content of that. So now we're going to say ‘var  content =’ another ‘await’ and then we're going   to say ‘response.Content’. We're not going to  ‘ReadAsString’, we're going to ‘ReadFromJsonAsync’   and then at that point, we're going to need to  get the model for the data that's coming back   when we log in. Now, conveniently, we've already  got that because that's exactly the same data   structure as is being sent from the server. So we  look here, we've got a class called LoginResponse,   which is what it's sending when we do the login.  And this is one of the really nice things about   doing this with Blazor: you've got C# on both  ends, so you can use exactly the same code. Now   we're kind of assuming here that we're developing  the Blazor front end completely separately from   the back end - different departments, different  organisations even - so we don't actually have the   code shared. We could if we wanted to actually  just share the DLL that contains this and then   exactly the same code would be working on both  the front and the back end. But I'm going to   just kind of cheat that and just do a copy and  paste. So if we copy that, and then if we go back   to the client, and what we'll do is in the Models  we'll add a new class, call that ‘LoginResponse’   and then just paste in the code that we had. So  now we've got exactly the same on the front end   and the back end. Now let's go back here and  finish this off. So that's going to be our   ‘LoginResponse’ in there. And then again  that could fail, so we're just going to   do ‘if (content == null)’ then we'll ‘throw’  a ‘new InvalidDataException’ - not going to   worry too much about the details of that. And  then finally, having done all that, we need to,   from that content, get hold of the JWT and store  it in session storage. And then we hit a problem,   because session storage is not something that is  available through Web Assembly and therefore not   directly available in Blazor. We would have to use  JavaScript interop to get hold of that. And that's   something we looked at in an earlier video, so  we could write that all directly for ourselves   with JavaScript interop. But fortunately, like  with so many things you need to do with interop,   it's already being provided in a NuGet package.  So we'll add that in there to save us a bit of   effort and save as reinventing the wheel. So if we  just get hold of the NuGet packages here, and then   we're going to go for thing called ‘Blazored’ and  you can see that ‘Blazored.Session’ – there’s also   one for local storage, but we're going to get the  session storage one. And that just wraps up all   that JavaScript interop for us so we don't have  to worry too much about doing that for ourselves.   So now in here, what we can do is we're going  to have to inject that. So up here, we're also   going to have a ‘private ISessionStorageService’  that we've just got from what we added as a NuGet   package there. And then let's just add that  into the constructor. And so now we've got   both of those created in the constructor. And that  means that now down here, I can say again ‘await’.   So you'll notice this access to the session  storage is asynchronous - we didn't have to   worry about that when we're doing it in Angular.  The reason being, it's not session storage itself   that's a synchronous, it's always when you do  JavaScript interop it's a synchronous. So that's   why that's got to be like this. And so on that  one, we can just do the ‘SetItemAsync’ and then   we're going to need a key. So let's set that up as  well. So up here, let's say ‘private const string’   and then ‘JWT_KEY =’ and then typical thing we  may do here, do a ‘nameof (JWT_KEY)’, so it just sets   that constant to a string which is its own name.  And so that's what we do in the SetItemAsync’.   So we pass in the ‘JWT_KEY’ and then not the  full content, just the content and then the   JSON Web Token. And then finally, remember, we're  returning a DateTime here, so we ‘return’ and then   ‘content.Expiration’. So that's that first  bit done. But while we're in here, let's do   a few other bits and pieces. Let's also have a  method that allows us to get hold of that JSON   Web Token. And in fact, what I'm going to do here  to save that call to the session storage every   time we want to get hold of it, I'm also going  to cache it inside the AuthenticationService.   So we're going to have a ‘private string?’  that I'm just going to call ‘_jwtCache’,   and so that will default to null. And then I'm  going to have a ‘public async’ and this is going   to return a ‘ValueTask’. We can do that for a  little bit of a performance benefit and I did   a video on that quite a while ago, if you want  to take a look at that. And it's going to return   a ‘string’ - that's going to be the JWT. We're  going to call this ‘GetJwtAsync’ and then the   first thing we do is we check to see whether we've  got it cached. So we can say ‘if (string.IsNullOr…’   I don't think we want ‘Whitespace’ there, we're  just worried about ‘Empty’ in that case. But if   we haven't got anything in the _jwtCache, then  we're going to say, ‘_jwtCache =’ and again we've   got to now make the call to the session storage.  So ‘_sessionStorageService’ and that's going to   be the ‘GetItemAsync’. That one is going to be  type ‘string’, and it just takes that ‘JWT_KEY’.   So that's what we've got there. So simple  enough, if it's not cached, we get hold of it,   and then return the cached version, whether  that's been there for a while or just been   fetched. Next thing I want to do here is have a  logout, so let's put that in as well. So again,   it's going to be ‘public async’ nothing particular  to return so we just have the ‘Task’ there. We'll   call it ‘LogoutAsync’ and just like we saw  before, all we need to do is we simply need   to remove it from the session storage. So we'll  say ‘await _sessionStorage’ and then we've got   the ‘RemoveItemAsync’ and again passing the key.  But then also, we've got to remember to clear the   cache. So we'll say ‘_jwtCache = null’. Let's  get everything done in here while we're here,   rather than jumping back later on. So there  was another thing we wanted to do, remember,   which is get hold of the username from the JWT  so that we can display it in a friendly way. So   let's put that in there. And what I'm going to do,  I'm going to write firstly a private method for   this. So ‘private static string’ and it's going  to be called ‘GetUsername’ and so the idea is,   we're going to pass in the JWT and  get the username out of that. Now,   when I did this in Angular, I did this in quite a  low level way, using just the standard JavaScript   ways of decoding the Base64 and that sort of thing.  I could do that here, but it's a bit more fiddly,   and also there is a library already available that  we've actually used, that is there for exactly   this sort of thing. So what I'm going to do here,  I can simply say ‘var jwt =’ and then ‘new’ and   then a thing called a ‘JwtSecurityToken’ - which  I need to get hold of the package for, so let's   just instal that - and then pass in the textual  form of it, so the token we've got there. And that   will generate the actual token object. And we've  seen that before, as I mentioned, because again,   if we go and have a look at the backend code, and  if we take a look in the AuthenticationController,   then that was exactly what we did when we  logged in. So when we logged in, remember,   we created this JwtSecurityToken and passed in all  of the bits and pieces directly - the claims and   so forth. But now we're creating exactly the same  thing, but in the reverse way from the JWT string   we have. So that's got that there. And then we  can simply read off the claim that we want. So   we can say ‘return’ and then ‘jwt.Claims.’ and  then we'll just go for the ‘First’ that matches   the particular thing we want, that's going to  be ‘claim => claim.Type == ClaimTypes.Name’.   And then we have to get the ‘Value’ of that. So  that's done our GetUsername. And then there's one   last thing we've got to do, we need to from this  AuthenticationService be able to inform people   as to when the status of login and logout has  changed. So what I'm going to do finally, is put   on here a ‘public event Action’ and then it's just  going to have a ‘string?’ string as a parameter,   and we'll call this ‘LoginChange’. So whenever the  login status change, we're going to send this off,   and that string is going to contain the username  or null if the person's logged out. And then we   just need to fire those, so we fire them in two  circumstances. On the logout we're going to say   ‘LoginChange?.Invoke’ and then we pass ‘null’  there because we're logged out. On the login we're   going to do the same thing and that's where we're  going to call that ‘GetUsername’ and pass in the   ‘content.JwtToken’. And so that's going to  give us the behaviour there. So that's our   AuthenticationService fully put together. Let's  just also on there, give it an interface because   this is going to have to be injectable. So do an  extract interface, all the public things we want   to go in there. And then if we go to our Program,  then in here, we need to do the configuration. And   so we need to do a couple of things. We need  to do a ‘builder.Services’ and then here I'm   going to do an ‘AddSingleton’ because I want  that AuthenticationService to be a singleton,   because remember it had that cached data for  the JWT. And if we didn't make it a singleton,   then we would sometimes lose that. So  better to have it like that. So that's   going to be ‘IAuthenticationService’ and  then ‘AuthenticationService’. And that's   all on there. But also, we need to configure  the injection of that session storage object.   And so we'll do another one, ‘builder.Services.’  and then we can do ‘AddBlazoredSessionStorage’   and then this one, again, has to be a  singleton. So there's actually a method   there called ‘AsSingleton’. And I'll just get to  get hold of the namespace for that. So there we've   got it from BlazoredSessionStorage. So you could  either go for ‘AddBlazoredSessionStorage’, but   that will be scoped, which wouldn't be compatible  with that being a singleton. So we go for it as a   singleton. And then next thing we're going to do  is in the Login component, we've actually got to   make use of that. So there we've got our Task  with SubmitAsync and so what I'm going to do in   here is, let's have a ‘private DateTime?’  and that's going to be our ‘expiration’.   And then we're going to have a ‘private string?’  that we’ll call ‘errorMessage’. And then in the   SubmitAsync we'll put in ‘try’ block. And we’ll  now make use of our AuthenticationService,   which we need to inject. So up here, let's say  ‘@inject’ and then ‘IAuthenticationService’.   Get hold of the namespace for that. And then  we'll just call that ‘AuthenticationService’.   And then back down here, we'll make use of  that that. We’ll say ‘expiration = await’   and then ‘AuthenticationService.’ and then  ‘LoginAsync’. And we can see we already had,   remember, the LoginModel that was bound on  to the form, so we can just pass our ‘model’   in there. And that will give us our expiration.  And if that works successfully, then we can set   the ‘errorMessage’ to ‘null’. On the other hand,  if something goes wrong - remember, if anything   went wrong with the login, we were throwing an  exception - so we'll catch an exception here   and we'll set the ‘expiration’ to  ‘null’ and we'll set the ‘errorMessage’   to ‘ex.Message’. Then we've just got to do some  binding to put that expiration and errorMessage on   the form. So what we'll do is down the bottom, I'm  going to say at ‘if (expiration is not null)’ then   we'll just put in a paragraph, ‘You are logged in  until’ and then ‘@expiration?.ToLongTimeString()’.   And so that we'll put that information in  there. And then we'll also have an ‘@if   (errorMessage is not null)’, then we'll simply  have a paragraph we use a ‘class’ that I'm   already using elsewhere for ‘error’. And then  in there, we can just put ‘@errorMessage’. And   the last thing I'll do - just like we had before  in the Angular - I'm going to say that ‘disabled’   if we don't have an ‘expiration,’ so we'll say  ‘@(expiration is not null)’. And that will just grey it out when we're finished. So that should be the  basic login all working. So let's run that up.   And obviously it can’t retrieve the data. Will go  to the login. Let's try it with some invalid login   data and click on that, give it a second, and then  we get the ‘Login failed’, so that's working. Now   let's try the correct login. So ‘Jasper Kent’ and  then ‘Pa$$w0rd’. Login for that onea And we're   getting ‘You are logged in until’ three hours  from now, because that's the time we set. And   we can just verify that this worked correctly,  because if we look in the session storage there,   we've got our JWT_KEY with the token in there. So  that's all working. But what we haven't done yet,   obviously, is passed that back. So we're still  getting ‘Failed to retrieve data’ on the all   reviews and summaries. So let's do that next.  Now the way we do that in Angular was to use   what Angular have called an HTTP interceptor. In  Blazor - very similar concept - but it's called   an HTTP Handler. So let's put that in there now.  So let's put in a new folder called ‘Handlers’.   And then in there, I'm going to add a  class called ‘AuthenticationHandler’.   And then what we need to do  in here, we derive this from   a standard class called ‘DelegatingHandler’.  And so that's what we have there. Then we're   going to need a couple of things injected in  here. So we're going to need to have a ‘private   readonly’ and then we're going to use the  ‘AuthenticationService’ that I've already written.   So pop that in there. And then we can  generate the constructor for that one.   And then also, we're going to need  some configuration information. So I'm   going to have a ‘private readonly’ and then  ‘IConfiguration’, and we'll add that to the   existing constructor. So that's those two setup.  Then what we need to do is we do an ‘override’   of a method from that delegating handler, and that  is going to be the ‘SendAsync’. So we could do either   ‘Send’ or ‘SendAsync’ - we are going to have  to do some asynchronous code in here, so that's   what we're going to have. And then again, if you  remember what we did with the Angular interceptor,   there are a couple of safety checks we needed  to put in here. We need to make sure that we've   got a JSON Web Token - so that we are logged in.  But also, we need to make sure that this request   is being sent to the server for which that  JWT applies, not to any other, because this   will intercept all of the requests that are being  sent. So first thing to do, we just say ‘var jwt =   await’ and then on our ‘_authenticationService’  we've got that we wrote earlier,   the ‘GetJwtAsync’. So that's easy enough. And then  also, we're going to say ‘var isToServer’, so this   is going to check whether we're going back to the  correct server. And on this, we're going to say,   ‘request’ - so we can see, we've got a request  coming in here, so this is the request that   we want to add extra information to the header  in the end. So we're going to look at that and   we're going to look at the URI and we're going to  check that the AbsoluteUri’ from that ‘StartsWith’   and then we want to make sure that it's got the  address of what we're looking at. And remember,   we had that address, because we have that in our  appsettings. So that was just the development one   we have, but that's what we want to get a hold  of. And that's why I needed to do inject the   configuration. So what we're then going to do is  in here, we'll say ‘_configuration’ and then we'll   just look up that ‘ServerUrl’. And we're going  to have a few situations of nulls coming through   there, so I'm going to put a ‘?? ""’ and also a  ‘?? false’ there. And that will mean basically   that we've got true or false if it is to the  server just by looking at what's happening there.   Then we need to check that both of those are  true. So check it ‘isToServer’ but also that   we've got something in that ‘jwt’. And if  that's all true, then we can finally put   the JWT into the authorization header of the  request that we're sending. So finally, we can   say ‘request.Headers.Authorization = new’ and then  it's going to be ‘AuthenticationHeaderValue’ - get   rid of that namespace and pop it at the top  - and then that takes the ‘Bearer’ that we've   been seeing all over the place for this and then  finally, we can pass in that ‘jwt’. And then once   again, very much like the Angular interceptors,  these things can be chained together, if you've   got more than one thing you want to do. We don't  actually have that, but it means we're going to   call the base class, which will hand it on to any  subsequent handlers we've got. And then we just   ‘await’ that. And that should be that. So we've  written our AuthenticationHandler. Now, we just   need to make sure that that is used. And so we go  to the Program and here where we had set up the   HttpFactory. So that's where that was configured.  On the end of that, I'm going to say ‘.’ and then   we'll just say ‘AddHttpMessageHandler’ and then  the ‘AuthenticationHandler' that we just written.   But that also itself needs to be registered  for injection, so we're going to say   ‘builder.Services’ and this one ‘Transient’  is going to be good enough. And then we can   just put in the ‘AuthenticationHandler’ that  we've got there. So that should have configured   that. And so now, without needing to change the  actual ReviewRepository or anything like that,   when the ReviewRepository makes its requests  through the HttpClient, then it will go through   that handler, and it will add the JWT. So  let's run that up and see what happens.   And we can see that we're getting an error. And  if we bring up the console and look at that error,   it is not a very easy one to see what's going  on. And when I hit this first time, it took me   quite a while to work out what's going on. But the  problem is, we've actually put together a cyclic   dependency in terms of our dependency injection,  because if you look at what happens when we,   let's say, create our AuthenticationService,  the AuthenticationService in its constructor,   creates an HttpClient. But when we create  an HttpClient, because we've just added   that AuthenticationHandler, we create an  AuthenticationHandler. And when we have an   AuthenticationHandler, in its constructor  we inject the AuthenticationService. And   we're already in the constructor of the  AuthenticationService at this point,   so we're in real trouble. So that's actually  why we had to do this with an HttpClientFactory,   not just the plain old HttpClient, so that we  can defer that creation. Because what I'm going   to do is go into the AuthenticationService. And  rather than using the factory to create the client   directly in the constructor, I'm actually going  to make it so that what we're going to store is   the HttpClientFactory. So we'll just call that  ‘_factory’. And then in here, we're just going   to say ‘_factory = factory’. And then the bit  of code that actually created the HttpClient,   we're not going to have in the constructor because  that's what was causing the problem. We're only   going to have it when we actually need it, which  was down here in the LoginAsync, which will   happen a lot later on if it happens at all. So  that's where we're going to create an HttpClient,   just as and when we need it. And then that should  solve the problem. So now what we're going to see   if we run it up, not logged in at the moment,  but if we log in, so we'll put that in there,   log in, ‘Logged in’ okay. And if we now go to all  reviews, or summary, we can see that that is now   valid, because that AuthenticationHandler has  put the JWT in the header, it's being received,   and everything's working fine. Okay, one last  thing to do, we've got to do the logout. And   we've got to do the message welcoming the  user that we did in Angular. So let's just   finish it off like that. So what I'm going to  do now is add another component. So in Pages,   let's add a Razor component and I'm simply going  to call this User. And this is going to make use,   once again, of the AuthenticationService. So  we'll do an ‘@inject IAuthenticationService’,   get hold of a namespace and  call it ‘AuthenticationService’.   And then in the code, we will have a ‘private  string?’, which is going to be ‘username’   and a ‘private bool’ called ‘isLoggeIn’ which  is simply going to say we're logged in if we've   got a valid username, so ‘username is not null’.  Okay, so that's simple enough. We're then going   to have to have an override of ‘OnInitialized’ -  don't need to do anything async here, so that will   make life a bit simpler. But here, we're going  to subscribe to that event that I put in the   AuthenticationService. So ‘AuthenticationService’  and then ‘.’ and then remember we've got this   ‘LoginChange’. And so we're going to put a handler  on that. Remember, that gives us the username,   so we'll just call that ‘name’ as the parameter.  And then what we're going to do is just say   ‘username = name’. One slight problem here,  something you'll often encounter when you're using   Blazor, if you do something like that changing  one of the fields in response to an event,   the Blazor system itself is not going to know that  that's made a change, and therefore it's not going   to refresh the DOM and give us the new display.  And whenever you're in that sort of situation,   you're going to need to do a ‘StateHasChanged’.  So calling a method on the Component base class   to do that. Normally, I would try and get away  with that and see if it works, but very often   you'll see it's not working and I know this isn’t  going to work without that StateHasChanged. So   that's what we've got. And then the other thing  we'll put in here is we will have a ‘private async   Task’ that we're going to call ‘LogoutAsync’. And  that's where we're going to call the logout on   the AuthenticationService. So we'll ‘await’ and then  ‘AuthenticationService. LogoutAsync’. So that was   the method that we'd written earlier as well. So  that's now used all of the features we had in the   service. Let's just put some markup in for that.  It's going to be pretty simple. We're going to say   ‘@if (isLoggedIn)’, so if we're logged in, we'll  have a ‘<span>’ that's going to say ‘Welcome’ and   then let's put in that ‘username’. And then also  we'll put in an anchor that we’ll call ‘Logout’   and that's simply going to call  the logout that we've just written,   so there's our ‘LogoutAsync’. So that's it  for logged in. And if we're not logged in,   then we're going to need to just have the menu  item that takes us to the login. So if we go to   our layout page, so there's our MainLayout. So  that's what the menus currently looking like. So   let's cut that. Let's pop in our User component  at that point, because that's where we want it   to go. And then back in here, we'll just paste in  that Navlink for login that we've got there.   And so now that should be everything working. So if we  run that up … so we're not logged in. We'll go to   the login page, put in the correct details, click  on login. And then you can see we've got ‘Welcome   Jasper Kent’. We've got the ‘Logout’ button.  At the moment we're logged in, so we can see   all those things. We click ‘Logout’, we go back  there, and now we’re getting the error when we   go in there. So exactly the same functionality as  we had in the Angular example last time, but with   some slightly different techniques because of the  different ways that things are done in Blazor and   in C# compared with Angular and TypeScript. Okay,  so we've seen two front ends there. Next time,   it's going to be a different front end - different  technology just to see how all of these work - and   so we're going to be doing that in React.  So I hope you enjoyed that one. If you did,   do click like and to keep up with the next one,  do click subscribe, and I'll see you next time.
Info
Channel: Coding Tutorials
Views: 1,289
Rating: undefined out of 5
Keywords: JWT, JSON Web Tokens, Blazored, HttpClientFactory, Captioned
Id: GTXvMu6Ex-o
Channel Id: undefined
Length: 31min 19sec (1879 seconds)
Published: Fri Aug 04 2023
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.