Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integration with .NET Identity #2225

Open
fracampit opened this issue Oct 7, 2022 · 34 comments
Open

Integration with .NET Identity #2225

fracampit opened this issue Oct 7, 2022 · 34 comments
Assignees
Labels
priority: p3 Desirable enhancement or fix. May not be included in next release. type: feature request ‘Nice-to-have’ improvement, new feature or different behavior or design.

Comments

@fracampit
Copy link

fracampit commented Oct 7, 2022

Is your feature request related to a problem? Please describe.
There seem to be no built-in way to use the authentication (access_token) you get from an external login (Google) with .NET Identity with Google API.

The best I can find is essentially "don't use Identity, just use OpenIdConnect". Unfortunately, the system I am working on relies on other features that Identity offers, which forces me to having to stick with Identity.
Moreover, I have realised that the GoogleOpenIdConnect authentication returns an authorized user as long as you are logged in to Google on the browser, meaning that I cannot have an "Identity login" at the same time.
I have a set of existing Identity users, all with an external Google login. These users are then assigned roles by the application. Using GoogleOpenIdConnect would prevent me to keep using those roles as far as I understand.

Describe the solution you'd like
Be able to use IGoogleAuthProvider with Microsoft Identity and Google external login (.AddGoogle()), like when you use .AddGoogleOpenIdConnect.

Describe alternatives you've considered

  • Manually intercept the access_token received during the .NET Identity external login and use that to initialize Google API services.
    This works, but persisting the token is a problem: implementing a solution that handles security, multi users, caching, refreshing etc would require too many resources

  • Use .AddGoogleOpenIdConnect in conjunction with .AddGoogle.
    This doesn't work as the two login seem to conflict with each other - I am logged out of one when I call login with the other.

Additional Context
I am working on a .NET6 Website. This website will have multiple users and will allow them to upload videos to either their Drive account or their Youtube channel.
I can get this to work easily when running locally as I can use the 'Installed Applications' way of authenticating. I cannot get this to work when deployed on a server.

@fracampit fracampit added priority: p3 Desirable enhancement or fix. May not be included in next release. type: feature request ‘Nice-to-have’ improvement, new feature or different behavior or design. labels Oct 7, 2022
@fracampit
Copy link
Author

fracampit commented Oct 7, 2022

I have also tried the following:

  • Remove .AddGoogle and add .AddGoogleOpenIdConnect
  • Set googleOptions.SignInScheme = IdentityConstants.ExternalScheme;
builder.Services.AddDefaultIdentity<ApplicationUser>(options =>
  {
      options.SignIn.RequireConfirmedAccount = true;
      options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30);
      options.User.RequireUniqueEmail = true;
      options.User.AllowedUserNameCharacters += "(";
      options.User.AllowedUserNameCharacters += ")";
      options.User.AllowedUserNameCharacters += " ";
  })
  .AddRoles<IdentityRole>()
  .AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddHttpContextAccessor();

builder.Services
  .AddAuthentication()
  .AddCookie()
  .AddGoogleOpenIdConnect(googleOptions =>
  {
      googleOptions.SignInScheme = IdentityConstants.ExternalScheme;
      googleOptions.ClientId = googleAuthCreds.ClientId;
      googleOptions.ClientSecret = googleAuthCreds.ClientSecret;
      googleOptions.Scope.Add(DriveService.ScopeConstants.DriveFile);
      googleOptions.Scope.Add(YouTubeService.ScopeConstants.YoutubeUpload);
      googleOptions.SaveTokens = true;
  });

With the above, I am able to signin to my Identity db using GoogleOpenIdConnect as an external login.
At this point, I would expect IGoogleAuthProvider to provide valid credentials when calling GetCredentialAsync(). However, I get Cannot get credential when not authenticated when trying that.

My theory is that setting googleOptions.SignInScheme = IdentityConstants.ExternalScheme; prevents OpenIdConnect from storing the fact that the user is authenticated, which then prevents IGoogleAuthProvider.GetCredentialAsync() from working.

Also tried to follow the Sample code, which has a method in the HomeController to retrieve the tokens:

var auth1 = httpContextAccessor.HttpContext?.AuthenticateAsync().Result;
var idToken = auth1?.Properties?.GetTokenValue(OpenIdConnectParameterNames.IdToken);
string idTokenValid, idTokenIssued, idTokenExpires;
try
{
    var payload = GoogleJsonWebSignature.ValidateAsync(idToken).Result;
    idTokenValid = "true";
    idTokenIssued = new DateTime(1970, 1, 1).AddSeconds(payload.IssuedAtTimeSeconds.Value).ToString();
    idTokenExpires = new DateTime(1970, 1, 1).AddSeconds(payload.ExpirationTimeSeconds.Value).ToString();
}
catch (Exception e)
{
    idTokenValid = $"false: {e.Message}";
    idTokenIssued = "invalid";
    idTokenExpires = "invalid";
}
var accessToken = auth1.Properties.GetTokenValue(OpenIdConnectParameterNames.AccessToken);
var refreshToken = auth1.Properties.GetTokenValue(OpenIdConnectParameterNames.RefreshToken);
var accessTokenExpiresAt = auth1.Properties.GetTokenValue("expires_at");

auth1?.Properties?.GetTokenValue(OpenIdConnectParameterNames.IdToken); returns null - (auth1?.Properties?.GetTokens() doesn't return any tokens`)

@amanda-tarafa
Copy link
Contributor

I'm looking at this, I'll reply here as soon as I know more. I'm hoping I have some more info today.

@LindaLawton
Copy link
Collaborator

I have been faced with the exact same issues as @fracampit and have not been able to find a solution

I would like to chime in and state as that it would be awesome if we could store the refresh token returned as part of the user data in the database. This way the backend system could use the refresh token for automated tasks.

I have also been fighting with this exact same issue for months and have not been able to solve it.

@fracampit
Copy link
Author

In case it helps, this is very close but uses .AddOpenIdConnect: https://stackoverflow.com/questions/66373175/oidc-together-with-asp-net-core-identity

@amanda-tarafa
Copy link
Contributor

I've been looking at this most of today. I'm pretty sure I tested something very similar to what's described in the SO question and it didn't fully worked. I'll check my notes tomorrow and retest if necessary.
I'll come back here with any significant updates.

@amanda-tarafa
Copy link
Contributor

I've made some advances in understanding what's happening, but I'm not fully there yet.
I have some other unrelated things I need to look at, I will come back to this next week and will update when I know more.

@amanda-tarafa
Copy link
Contributor

Quick update: I didn't have time to look into this again this week, nor will I be able to do so next week.
I'll combe back to this on the first week of November.

@fracampit
Copy link
Author

@amanda-tarafa Have you had a chance to get back on this?

@LindaLawton
Copy link
Collaborator

I was wondering the something last week. I'm really excited to see this working.

@amanda-tarafa
Copy link
Contributor

No, I'm sorry, a couple higher priority issues have come my way, and this one has unfortunately dropped on the priority list.

For expectations: I would have though that this should work out of the box, and it doesn't. I understand why it doesn't but I haven't figured out how to work around it. I'm hoping it's a matter of configuration or slight code twaeking, but I might be wrong. I'll be able to dedicate one day to this issue, possibly next week, but not much more than that at the moment. If my hopes are met, and the fix is small, I'll bring it to conclusion, but else, we'll treat this as a low priority feature request and add it to our normal planning cycle etc. I'm conscious this is not what you wanted to hear, and I'm sorry about that. And hopefully, I can get it to work easily.

@LindaLawton
Copy link
Collaborator

LindaLawton commented Nov 14, 2022

Im a little confused as to how this could be a low priority feature request. Asp .net support is on the list of supported frameworks. So its defiantly not a feature request, if its something that's supported.

As has been shown in this thread Microsoft identity and the Google apis .net client authorization mechanisms area not compatible. For some reason this library has added identity into the mix when it should be purely authorization. Not user authencation.

IMO this is a major bug in this library that by authorizing a user. You also log them out of the application itself. I have had serval clients over they last two years who are using standard Microsoft identity and trying to add the use of Oauth2 to a Google api using this library. In both instances I had to drop this library completely and create it from scratch. Which basically means this library is not usable for Asp .Net and maybe that should be removed from supported frameworks and add it to the list of authorization frameworks we do not support. Unity, Xamarin ...

This library has always been an authorization library as it should be.

  1. remove authencation aspect
  2. stop clobbering the identity cookies.
  3. return the refresh token to us so that we can create our own implementation of Idatastore or something to deal with token storage.

I still have not even figured out how to get the refresh token out of the cookie properly after authorization for storage..

@amanda-tarafa
Copy link
Contributor

Asp .net support is on the list of supported frameworks.

Supporting ASP.NET Core does not imply supporting ASP.NET Identity, and we don't claim to or was this ever a requisite for Google.Apis.Auth.AspNetCore or Google.Apis.Auth.AspNetCore3.

In general, and across the many libraries, we don't support each API or specific framework defined by .NET or by ASP.NET, and definetely we don't intent to support them all. As an example of this, we only recently added some .NET DI support to the gRPC libraries in https://github.com/googleapis/google-cloud-dotnet.

For some reason this library has added identity into the mix when it should be purely authorization. Not user authencation.

Not sure what you mean here, surely there cannot be authorization without authentication. Say, even if some users were to use something else for authentication (like ASP.NET Identity), and we could hop into that for all cases, we wouldn't want to force all users to always need extra dependencies for authentication. The Google.Apis.Auth.AspNetCore packages are desgined to be used on their own.

IMO this is a major bug in this library that by authorizing a user. You also log them out of the application itself.

It's not a bug, it's the sympton of the suspected imcompatibility. But again, being compatible with ASP.NET Identity has never been a requisite of the Google.Apis.Auth.AspNetCore packages.

Which basically means this library is not usable for Asp .Net and maybe that should be removed from supported frameworks and add it to the list of authorization frameworks we do not support. Unity, Xamarin ...

No, the suspected incompatibility is between Google.Apis.Auth.AspNetCore packages and ASP.NET Identity, not with ASP.NET Core. Supporting ASP.NET Core does not imply supporting ASP.NET Identity.

  1. remove authencation aspect

We can hardly do that, as we cannot perform authorization without authentication.

  1. stop clobbering the identity cookies.

If by clobbering you mean overwriting or reusing, we are not. That's precisely why my initial inclination was that this should work out of the box. What's happening is that only one of the two cookies is ever saved, depending on who's configured to do what, and I still don't understand why or how to work around it.

  1. return the refresh token to us so that we can create our own implementation of Idatastore or something to deal with token storage.
    I still have not even figured out how to get the refresh token out of the cookie properly after authorization for storage..

See here for how to obtain the refresh token every time it is issued: #1725 (comment)

All of the above said, the ideal outcome for us is that we can make the Google.Apis.Auth.AspNetCore packages fully supportive of ASP.NET Identity, while maintaining their capacity of being used on their own and without introducing any breaking changes. I still hope that can be achieved through configuration or even maybe some minor code tweaking. If that's the case, then we'll get this sorted before end of year (potentially sooner).

But if for making the packages compatible we need to make significant code changes, or even, if we need to produce entirely new packages (which is a posibility), then this feature request will go into our normal planning cycle and likely with a medium to low priority. I appreciate this is not what you want to hear, but it is the reality, and I'd prefer expectations to be reasonable, rather than have dissapointments later.

I'll come back to this thread with an update once I've had the chance to put some time into it, I'm planning that to be early next week.

@amanda-tarafa
Copy link
Contributor

Quick update: I've made some advances yesterday, but not quite there yet. I'll come back to this issue later this week or early the next for a last "quick" attempt at getting things to work. Else, we'll plan to look into this as a feature request for next year.

@amanda-tarafa
Copy link
Contributor

Quick update: I haven't been able to come back to this issue. I'll do my best to come back to this next week, and I'll report back.

@fracampit
Copy link
Author

@amanda-tarafa Thanks for keeping this under your radar. Any help is much appreciated.

@amanda-tarafa
Copy link
Contributor

@fracampit Thank you for your patietence.
I don't think I can get back to this unitl January, but I'm still hoping I can get it to workg without huge changes. I'll come back here when I know more, and as long as the issue is open, it is in our radar!

@jboiss
Copy link

jboiss commented Jan 10, 2023

I have found only one methodology for simultaneously working with this library and Identity in an ASP.NET Core 7 project. After multiple failed strategies, conflicting cookies and authentication loops, this seems to be the least "hackish" way to achieve integration:

  1. Use AddGoogleOpenIdConnect and not AddGoogle in Program.cs.
  2. When signing in or registering with Identity and Google, persist the tokens to the database with _signInManager.UpdateExternalAuthenticationTokensAsync(info);.
  3. Do not use the service decorators from this library such as [GoogleScopedAuthorize(DriveService.ScopeConstants.DriveReadonly)] or inject
    [FromServices] IGoogleAuthProvider auth in your methods. These are used in almost every example online -- and while they seem to work at first, they conflict with Identity. You will be prompted to re-authenticate every time you include these even when you have already authenticated.
  4. Interact with Google services using the GoogleAuthorizationCodeFlow with the db-persisted OAuth refresh token. If the token expires or becomes invalid, you will receive an exception. Wrap these interactions with a try/catch, and redirect to Google to request fresh tokens in the case of an exception using ChallengeResult.

var refresh_token = await _userManager.GetAuthenticationTokenAsync( user, "GoogleOpenIdConnect", "refresh_token" );

Less than ideal -- but from a user perspective, this results in expected OAuth behaviour. The complexity of ASP.NET Identity makes the current state of affairs understandable in a library that is ultimately in maintenance mode. Devs will require a better supported option in the future from Google for ASP.NET core. Google is obviously aware of this since their previous C# tutorials now redirect to Javascript, and C# is no longer listed as one of the supported languages.

@LindaLawton
Copy link
Collaborator

@jboiss where you able to add additional scopes without using GoogleScopedAuthorize?

@jboiss
Copy link

jboiss commented Jan 12, 2023

@LindaLawton Yes, by passing the refresh token stored in the AspNetUserTokens table to the GoogleAuthorizationCodeFlow method. For example, to use the Google Calendar service:

public CalendarService Authorize(string refresh_token)
{
    TokenResponse tokenReponse = new TokenResponse() { 
        RefreshToken = refresh_token 
    };
    
    var userCredential = new UserCredential(
       new GoogleAuthorizationCodeFlow(
           new GoogleAuthorizationCodeFlow.Initializer()
           {
                ClientSecrets = new ClientSecrets()
                {
                    ClientId = _configuration["Authentication:Google:ClientId"],
                    ClientSecret = _configuration["Authentication:Google:ClientSecret"]
                }
           }),
       "user",
       tokenReponse);

    return new CalendarService(new BaseClientService.Initializer
    {
        HttpClientInitializer = userCredential
    });
}

// In the Controller:

[Authorize]
[Route("tokens")]
public async Task<IActionResult> GoogleTokens(
    [FromServices] IGoogleCalendar googleCalendar
)
{
    try {
        User user = await _userManager.GetUserAsync(HttpContext.User);
        var refresh_token = await _userManager.GetAuthenticationTokenAsync(
            user,
            GoogleOpenIdConnectDefaults.AuthenticationScheme,
            "refresh_token"
        );
        var service = googleCalendar.Authorize(refresh_token);
        var calendars = await service.CalendarList.List().ExecuteAsync();

        return new JsonResult(calendars);

    } catch(Exception) {
        var properties = _signInManager.ConfigureExternalAuthenticationProperties(
            GoogleOpenIdConnectDefaults.AuthenticationScheme, 
            "/authorize"
        );

        return new ChallengeResult(
            GoogleOpenIdConnectDefaults.AuthenticationScheme,
            properties
        );
    }
}`

@amanda-tarafa
Copy link
Contributor

amanda-tarafa commented Jan 12, 2023

After a few more hours attempting to make Google.Apis.Auth.AspNetCore3 with .NET Indentity seamlessly, I haven't been able to.

  • During H1 2023 we plan to dedicate some deep diving time to better understand how integration between Google.Apis.Auth and .NET Identity should happen, and what effort it would require to implement.
  • Depending on the findings from the point above, implementation may happen on H1 2023, or H2 2023 (most likely), or it may be postponed indefinetely. I'm conscious this is not what you'd prefer to hear.

I'll come back to this thread as I know more.

@amanda-tarafa
Copy link
Contributor

@jboiss I did wanted to ask you about this:

Google is obviously aware of this since their previous C# tutorials now redirect to Javascript, and C# is no longer listed as one of the supported languages.

Can you please share the previous C# tutorials URLs (the ones that now redirect to Javascript) and also where is it that C# is no longer listed as one of the supported languages? Thanks!

@jboiss
Copy link

jboiss commented Jan 12, 2023

@amanda-tarafa No problem. The previous guide can be viewed at the Web Archive which now redirects to the Javascript Quickstart. On the left hand navigation there is no longer a link to C# examples (which was previously listed as .NET between Java and Node.js menu items.)

In the guide to server side apps, .NET is listed on the left hand menu, but is absent from the language tabs in the documentation body, which includes examples for PHP, Python, Ruby, Node.js and HTTP/REST.

@LindaLawton
Copy link
Collaborator

LindaLawton commented Jan 13, 2023

@jboiss the calendar example uses GoogleWebAuthorizationBroker.AuthorizeAsync which is for installed app not web apps. So its probably just a bug. Installed apps work fine.

All of the Google samples and i mean all even node.js php and phyton they all use installed app samples none of the official QuickStart's have ever been web related except maybe JavaScript and even a few of them used to tell you to create installed credentials.

The only docs we have for this library with web is -> web-applications-asp.net-core-3

@amanda-tarafa i'm sad to hear of your conclusion but it is the same conclusion that i came to about three years ago. So if anything it makes me feel better that i wasn't wrong. I would love to see an overhaul of the auth system in this library to enable things like Xamarin, Maui, UWP, web. Its strange that we would only support installed apps, and service accounts. May i suggest editing the readme to reelect that Web is not supported.

@amanda-tarafa
Copy link
Contributor

@jboiss A few notes on those links and overall C# support across Google.

The previous guide can be viewed at the Web Archive which now redirects to the Javascript Quickstart.

The missing quickstart is specifically for the Google.Apis.Calendar.v3 library. The authentication/authorization code there is still very much supported and I can guarantee you that it will work, 100%. The whole quickstart is demonstrating client side applications, which couldn't make (meaningful) use of Google.Apis.Auth.AspNetCore3 or ASP.NET Core Identity. It's really unrelated to this issue. I don't know why the quickstart was removed, I'll follow up with the Calendar team. But again, the auth code there is still very much supported.

The documentation for many other Google products includes samples, quickstarts and tutorials for C#. Here KMS as one random example: https://cloud.google.com/kms/docs/reference/libraries.

In the guide to server side apps, .NET is listed on the left hand menu, but is absent from the language tabs in the documentation body, which includes examples for PHP, Python, Ruby, Node.js and HTTP/REST.

There are also no examples for Java and Go. This is a bug in the documentation, but I can also guarantee 100% that these three languages (C#, Java and Go) are very well supported for server-side OAuth.

So in general:

  • .NET/C# is very much supported at Google, and in particular for OAuth and OIDC.
  • Lack of samples in some languages for some products most likely signals an issue with documentation instead of lack of support for the language. We are very conscious of uneven sample/documentation coverage and we are actively working on addressing that.
  • Auth at Google documentation in general, and .NET Auth at Google documentation in particular have plenty of room for improvement. We acknowledge it is not complete and it's fragmented. We also have plans for addressing this.
  • What we are addressing in this issue is the lack of integration between Google .NET's Auth libraries and ASP.NET Core Identity. We would very much like to provide this integration seamlessly and we are going to explore how best to do this, and implement it depending on the result of that exploration. But supporting ASP.NET Core Identity is a desirable but not required aspect of supporting .NET/ASP.NET Core/C# (as is supporting each and every .NET API)

Some relevant documentation:

@LindaLawton
Copy link
Collaborator

@amanda-tarafa if you start doing to much documentation you will put me out of a job. Dont worry about it. Remember I take requests if you get flooded with requests for something special let me know. I am happy to write them for you.

@jboiss
Copy link

jboiss commented Jan 13, 2023

@amanda-tarafa Understood, thank you for your efforts. I was able to piece together what I needed thanks to Google documentation from various libraries. Since this library is in maintenance mode according to the Github Readme and ASP.NET Core has been somewhat of a moving target, I figured that a new solution was probably in the works.

@LindaLawton I was trying to demonstrate how I added a scope for a service without conflict -- in this case, the Google Calendar API.

@TimeTrx
Copy link

TimeTrx commented Jan 16, 2023

So in general:

.NET/C# is very much supported at Google, and in particular for OAuth and OIDC.
Lack of samples in some languages for some products most likely signals an issue with documentation instead of lack of support for the language. We are very conscious of uneven sample/documentation coverage and we are actively working on addressing that.
Auth at Google documentation in general, and .NET Auth at Google documentation in particular have plenty of room for improvement. We acknowledge it is not complete and it's fragmented. We also have plans for addressing this.
What we are addressing in this issue is the lack of integration between Google .NET's Auth libraries and ASP.NET Core Identity. We would very much like to provide this integration seamlessly and we are going to explore how best to do this, and implement it depending on the result of that exploration. But supporting ASP.NET Core Identity is a desirable but not required aspect of supporting .NET/ASP.NET Core/C# (as is supporting each and every .NET API)

Thanks for the clarification, I saw the change on the developers site that the admin-sdk got renamed to admin console taking most of the code samples down with C# and nearly had a heart attack.

Looking forward to any updates on this identity integration.

@sagarvadodaria
Copy link

sagarvadodaria commented May 18, 2023

Its been a while now and just wondering if there is any progress in this regard. Really looking forward to have this supported. I defintely dont want to write anything from scratch like @LindaLawton had to. If its slotted for the near future, I would rather wait. @amanda-tarafa if you have any clarity on timeframe?

@amanda-tarafa
Copy link
Contributor

@sagarvadodaria we still don't have clarity on timeframe. And to be upfront it is unlikely that we'll have that clarity this semester. This is not forgotten, and something that we very much want to support, but it's not high on our priorities at the moment. I understand this is not what you want to hear.
I'll try to put it on the plate again for H2 and see what we can do. I cannot commit to any timeframes until we have a better understanding on what exactly needs to be done, whether we can just tweak the current Google.Apis.Auth.AspNetCore3 library to integrate with .NET Identity or we have to implement something from scratch.

@sagarvadodaria
Copy link

@sagarvadodaria we still don't have clarity on timeframe. And to be upfront it is unlikely that we'll have that clarity this semester. This is not forgotten, and something that we very much want to support, but it's not high on our priorities at the moment. I understand this is not what you want to hear. I'll try to put it on the plate again for H2 and see what we can do. I cannot commit to any timeframes until we have a better understanding on what exactly needs to be done, whether we can just tweak the current Google.Apis.Auth.AspNetCore3 library to integrate with .NET Identity or we have to implement something from scratch.

I understand, thanks for your prompt response.

@LindaLawton
Copy link
Collaborator

Its been almost a year thought we should loop back and check the status on this.

@amanda-tarafa
Copy link
Contributor

Still in our backlog, but with no planned ETA. I'm sorry this is not what you want to hear, but it's the reality given other work that we are doing, and resource constraints etc.

@LindaLawton
Copy link
Collaborator

Thats fine no worries think I have been waiting for this for going on six or seven years. there are work arounds.

@fracampit
Copy link
Author

fracampit commented Nov 3, 2024

I finally came back to this problem after about two years and, with the help of AI, I was able to put together something that works.

For context, I'm working on a .NET 8 Razor Pages website. I am using Microsoft.Identity with AWS DynamoDB.

In my Program.cs, I added a new scheme for YouTube uploads:

builder.Services.AddDefaultIdentity<ApplicationUser>(options =>
    {
        options.SignIn.RequireConfirmedAccount = true;
        // more options
    })
    .AddRoles<DynamoDbRole>()
    .AddDynamoDbStores()
    .SetBillingMode(BillingMode.PROVISIONED)
    .SetDefaultTableName(tableName);

builder.Services
    .AddAuthentication()
    .AddGoogle(googleOptions =>
    {
        googleOptions.ClientId = googleAuth.ClientId;
        googleOptions.ClientSecret = googleAuth.ClientSecret;
    })
    // Add a new scheme for YouTube
    .AddGoogle("YouTube", youtubeOptions =>
    {
        youtubeOptions.ClientId = googleAuth.ClientId;
        youtubeOptions.ClientSecret = googleAuth.ClientSecret;
        youtubeOptions.CallbackPath = "/signin-google-youtube";
        youtubeOptions.Scope.Add("https://www.googleapis.com/auth/youtube.upload");
        youtubeOptions.AccessType = "offline"; // Request a refresh token
        youtubeOptions.SaveTokens = true;
        youtubeOptions.SignInScheme = IdentityConstants.ExternalScheme;
    });

I then created a controller to handle the authentication to YouTube:

public class YouTubeController(UserManager<ApplicationUser> userManager,
    ILogger<YoutubeVideoUploader> logger, IConfiguration configuration) : Controller
{
    [HttpGet]
    public IActionResult ConnectYouTube()
    {
        var properties = new AuthenticationProperties
        {
            RedirectUri = Url.Action("YouTubeAuthCallback", "YouTube")
        };

        // Specify the authentication scheme
        return Challenge(properties, "YouTube");
    }
    
    [HttpGet]
    public async Task<IActionResult> IsYouTubeLinked()
    {
        var user = await userManager.GetUserAsync(User);
        if (user == null)
        {
            return Unauthorized();
        }

        var accessToken = userManager.GetAuthenticationTokenAsync(user, "YouTube", "access_token").Result;
        return Ok(!string.IsNullOrEmpty(accessToken));
    }

    [HttpGet]
    public async Task<IActionResult> YouTubeAuthCallback()
    {
        var authenticateResult = await HttpContext.AuthenticateAsync(IdentityConstants.ExternalScheme);

        if (!authenticateResult.Succeeded)
        {
            return Redirect("/Identity/Account/Manage/YouTubeLink");
        }

        // Retrieve tokens
        var accessToken = authenticateResult.Properties.GetTokenValue("access_token");
        var refreshToken = authenticateResult.Properties.GetTokenValue("refresh_token");
        var expiresAt = authenticateResult.Properties.GetTokenValue("expires_at");

        // Get the current user
        var user = await userManager.GetUserAsync(User);
        if (user == null)
        {
            return Unauthorized();
        }

        // Store tokens securely
        await userManager.SetAuthenticationTokenAsync(user, "YouTube", "access_token", accessToken);
        await userManager.SetAuthenticationTokenAsync(user, "YouTube", "refresh_token", refreshToken);
        await userManager.SetAuthenticationTokenAsync(user, "YouTube", "expires_at", expiresAt);

        // Sign out of the temporary "YouTube" authentication
        await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);

        return Redirect("/Identity/Account/Manage/YouTubeLink");
    }
    
    [HttpPost]
    public async Task<IActionResult> RemoveYouTubeLink()
    {
        var user = await userManager.GetUserAsync(User);
        if (user == null)
        {
            return Unauthorized();
        }

        // Remove YouTube tokens
        await userManager.RemoveAuthenticationTokenAsync(user, "YouTube", "access_token");
        await userManager.RemoveAuthenticationTokenAsync(user, "YouTube", "refresh_token");
        await userManager.RemoveAuthenticationTokenAsync(user, "YouTube", "expires_at");
        
        TempData["SuccessMessage"] = "YouTube account link has been removed successfully.";
        return Redirect("/Identity/Account/Manage/YouTubeLink");
    }

    [HttpPost]
    public async Task<IActionResult> UploadVideo(IFormFile videoFile)
    {
        if (videoFile.Length == 0)
        {
            return BadRequest("Please select a video file to upload.");
        }
        
        var user = await userManager.GetUserAsync(User);
        if (user == null)
        {
            return Unauthorized();
        }
        
        // redacted code to get google credentials

        var uploader = new YoutubeVideoUploader(
            userManager,
            user,
            clientId, clientSecret);
        
        // Convert the uploaded file to a MemoryStream
        using var memoryStream = new MemoryStream();
        await videoFile.CopyToAsync(memoryStream);

        // Proceed to upload the video
        try
        {
            await uploader.Upload(REDACTED PARAMETERS);
        }
        catch (Exception ex)
        {
            // redacted
        }
    }
}

This controller is used to link the YouTube account, and to upload videos to YouTube (via an helper class).

I created a new page in the Identity pages that manage the user account to link its YouTube account:

@page
@model YouTubeLinkModel
@{
    ViewData["Title"] = "Link YouTube Account";
    ViewData["ActivePage"] = ManageNavPages.LinkYouTubeAccount;
}

<partial name="_StatusMessage" for="StatusMessage"/>
@if (!Model.AlreadyLinked)
{
    <h4>Link your YouTube account.</h4>
    <hr/>
    <!-- To connect YouTube account -->
    <a asp-controller="YouTube" asp-action="ConnectYouTube" class="btn btn-primary">Connect</a>
}
else
{
    <h4>Link your YouTube account.</h4>
    <hr/>
    <table class="table">
        <tbody>
        <tr>
            <td>
                <p>Your YouTube account is connected!</p>
            </td>
            <td>
                <form asp-action="RemoveYouTubeLink" asp-controller="YouTube" method="post">
                    <button type="submit" class="btn btn-danger">Remove</button>
                </form>
            </td>
        </tr>
        </tbody>
    </table>
    
}

public class YouTubeLinkModel(UserManager<ApplicationUser> userManager) : PageModel
{
    public bool AlreadyLinked { get; set; }
    [TempData] public string StatusMessage { get; set; }

    public async Task<IActionResult> OnGetAsync()
    {
        var user = await userManager.GetUserAsync(User);
        if (user == null)
        {
            return NotFound("Unable to load user.");
        }
        
        var accessToken = await userManager.GetAuthenticationTokenAsync(user, "YouTube", "access_token");
        AlreadyLinked = !string.IsNullOrEmpty(accessToken);
        
        return Page();
    }
}

I created a helper class to do the actual upload:

public class YoutubeVideoUploader
{
    private readonly ILogger<YoutubeVideoUploader> _logger;
    private readonly YouTubeService _youTubeService;
    private readonly Guid _userId;
    private static readonly string[] Initializer = ["https://www.googleapis.com/auth/youtube.upload"];

    public YoutubeVideoUploader(
        ILogger<YoutubeVideoUploader> logger,
        UserManager<ApplicationUser> userManager,
        ApplicationUser user,
        string clientId,
        string clientSecret)
    {
        _logger = logger;
        _userId = Guid.Parse(user.Id);

        // Get valid credentials and refresh if necessary
        var credential = GetValidUserCredential(userManager, user, clientId, clientSecret).Result;

        _youTubeService = new YouTubeService(new BaseClientService.Initializer
        {
            HttpClientInitializer = credential,
            ApplicationName = "LingLinger"
        });
    }

    public async Task Upload(/* redacted parameters */)
    {
        var video = new Video
        {
            Snippet = new VideoSnippet
            {
                Title = /* REDACTED */,
                Description = /* REDACTED */,
                Tags = /* REDACTED */,
                CategoryId = /* REDACTED */
            },
            Status = new VideoStatus
            {
                PrivacyStatus = /* REDACTED */
            }
        };

        using var ms = new MemoryStream(file.Content);
        var videosInsertRequest = _youTubeService.Videos.Insert(video, "snippet,status", ms, "video/*");

        _logger.LogDebug("Starting Youtube upload");
        var uploadResult = await videosInsertRequest.UploadAsync();

        if (uploadResult.Status == UploadStatus.Completed)
        {
            _logger.LogDebug("Upload completed");
        }
        else if (uploadResult.Status == UploadStatus.Failed)
        {
            _logger.LogError("Upload failed: {Exception}", uploadResult.Exception);
            throw uploadResult.Exception;
        }
    }

    private async Task<UserCredential> GetValidUserCredential(UserManager<ApplicationUser> userManager,
        ApplicationUser user, string clientId, string clientSecret)
    {
        var accessToken = await userManager.GetAuthenticationTokenAsync(user, "YouTube", "access_token");
        var refreshToken = await userManager.GetAuthenticationTokenAsync(user, "YouTube", "refresh_token");
        var expiresAt = await userManager.GetAuthenticationTokenAsync(user, "YouTube", "expires_at");

        if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken) || string.IsNullOrEmpty(expiresAt))
        {
            throw new Exception("YouTube access not granted or tokens are missing.");
        }

        var tokenResponse = new TokenResponse
        {
            AccessToken = accessToken,
            RefreshToken = refreshToken,
            ExpiresInSeconds = (long)(DateTime.Parse(expiresAt) - DateTime.UtcNow).TotalSeconds
        };

        if (DateTime.UtcNow < DateTime.Parse(expiresAt))
            return new UserCredential(new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer
            {
                ClientSecrets = new ClientSecrets { ClientId = clientId, ClientSecret = clientSecret },
                Scopes = Initializer
            }), user.Id, tokenResponse);
        
        // Refresh the token
        var secrets = new ClientSecrets
        {
            ClientId = clientId,
            ClientSecret = clientSecret
        };

        var flow = new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer
        {
            ClientSecrets = secrets,
            Scopes = Initializer,
            DataStore = new EFTokenStore(userManager,
                user) // Ensure you have this store implemented for saving tokens
        });

        var credential = new UserCredential(flow, user.Id, tokenResponse);
        var refreshResult = await credential.RefreshTokenAsync(CancellationToken.None);

        if (!refreshResult) return credential;
            
        await userManager.SetAuthenticationTokenAsync(user, "YouTube", "access_token",
            credential.Token.AccessToken);
        await userManager.SetAuthenticationTokenAsync(user, "YouTube", "expires_at",
            DateTime.UtcNow.AddSeconds(credential.Token.ExpiresInSeconds!.Value).ToString("o"));

        return credential;
    }
}

I also had to implement a custom token store to save the tokens for YouTube:

public class EFTokenStore : IDataStore
{
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly ApplicationUser _user;

    public EFTokenStore(UserManager<ApplicationUser> userManager, ApplicationUser user)
    {
        _userManager = userManager;
        _user = user;
    }

    public async Task StoreAsync<T>(string key, T value)
    {
        if (value is TokenResponse token)
        {
            await _userManager.SetAuthenticationTokenAsync(_user, "YouTube", "access_token", token.AccessToken);
            await _userManager.SetAuthenticationTokenAsync(_user, "YouTube", "refresh_token", token.RefreshToken);
            var expiresAt = DateTime.UtcNow.AddSeconds(token.ExpiresInSeconds ?? 3600).ToString("o");
            await _userManager.SetAuthenticationTokenAsync(_user, "YouTube", "expires_at", expiresAt);
        }
    }

    public async Task<T> GetAsync<T>(string key)
    {
        var accessToken = await _userManager.GetAuthenticationTokenAsync(_user, "YouTube", "access_token");
        var refreshToken = await _userManager.GetAuthenticationTokenAsync(_user, "YouTube", "refresh_token");
        var expiresAt = await _userManager.GetAuthenticationTokenAsync(_user, "YouTube", "expires_at");

        if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
            return default;

        var tokenResponse = new TokenResponse
        {
            AccessToken = accessToken,
            RefreshToken = refreshToken,
            ExpiresInSeconds = (long)(DateTime.Parse(expiresAt) - DateTime.UtcNow).TotalSeconds
        };

        return (T)(object)tokenResponse;
    }

    public Task ClearAsync() => Task.CompletedTask;
    public Task DeleteAsync<T>(string key) => Task.CompletedTask;
}

The setup above allows users to link their YouTube account to the website, even if they have not logged in using Google as a provider. They can still use Google as a login provider, and it does not affect the YouTube functionality.

The setup above should apply to any other Google API.

I really hope this helps somebody! 🙂

P.S @LindaLawton I would be curious to know if the work arounds you mentioned look anything like what I have done above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
priority: p3 Desirable enhancement or fix. May not be included in next release. type: feature request ‘Nice-to-have’ improvement, new feature or different behavior or design.
Projects
None yet
Development

No branches or pull requests

6 participants