aspnet-contrib / AspNet.Security.OAuth.Providers

OAuth 2.0 social authentication providers for ASP.NET Core
Apache License 2.0
2.37k stars 536 forks source link

Discord Oauth: How to get accessToken from User object after OAuh flow, when previously using Identity.Type "Identity.Application" ? #712

Closed tuugen closed 1 year ago

tuugen commented 1 year ago

Provider name : Discord

Hello! Is there a way to acquire the access token attached to the User? I have successfully logged into my Oauth app with the 'guilds' permission. I would now like to add a button on a MVC razor page to list the guilds I am part of.

my discord config is as such

.AddDiscord(options =>
    {
        //https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers/blob/dev/src/AspNet.Security.OAuth.Discord/DiscordAuthenticationOptions.cs
        options.ClientId = builder.Configuration["Authentication:Discord:ClientId"];
        options.ClientSecret = builder.Configuration["Authentication:Discord:ClientSecret"];
        options.ClaimActions.MapCustomJson("urn:discord:avatar:url", user => {
                    var result = string.Format(
                        CultureInfo.InvariantCulture,
                        "https://cdn.discordapp.com/avatars/{0}/{1}.{2}",
                        user.GetString("id"),
                        user.GetString("avatar"),
                        user.GetString("avatar").StartsWith("a_") ? "gif" : "png");
                    Console.WriteLine("Got avatar url: " + result);
                    return result;
                    });
        //https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes
        options.Scope.Add("guilds");
        Console.WriteLine("Scope is: " + String.Join("-",options.Scope));

         options.Events.OnCreatingTicket = ctx =>
    {
        //Through intellisense I randomly stumbled upon being able to get accesToken here... but is there a more
        //elegant way to access it _after this step_ ? 
        Console.WriteLine("Got access token: " + ctx.AccessToken.ToString());
//do I have to somehow manually attach this ctx.AccessToken to a user object here? Does the AspNet.Security.Oauth.Providers framework provide some mechanism to access it other than this?
        return Task.CompletedTask;
    };

    });

however, when I try and access the claims on a index.cshtml page (using the MVC.Client boilerplate in this project...)


        <p>
            @foreach (var claim in User.Claims)
            {
                <div><code>@claim.Type</code>: <strong>@claim.Value</strong></div>
            }
        </p>

I only get the following

http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier: <some-id>
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name: my-email@gmail.com
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress: my-email@gmail.com
AspNet.Identity.SecurityStamp: <some-long-stamp>
http://schemas.microsoft.com/ws/2008/06/identity/claims/authenticationmethod: Discord

Apologies If there is documentation somewhere describing all of this, I just can't find it.

martincostello commented 1 year ago

You could try setting SaveTokens to true and then accessing it from the authentication properties.

Otherwise the callback event is the way to get access to the raw API payload using the User property of the event context.

tuugen commented 1 year ago

You could try setting SaveTokens to true and then accessing it from the authentication properties.

Otherwise the callback event is the way to get access to the raw API payload using the User property of the event context.

Thanks for the suggestion @martincostello , heres what I tried:

So I added the SaveTokens flag...

.AddDiscord(options =>
    {
        options.ClientId = builder.Configuration["Authentication:Discord:ClientId"];
        options.ClientSecret = builder.Configuration["Authentication:Discord:ClientSecret"];
        options.SaveTokens = true;
        ...

    options.Events.OnCreatingTicket =  async (ctx) =>
    {
//example 'TicketCreated' taken from https://docs.microsoft.com/en-us/aspnet/core/security/authentication/social/additional-claims?view=aspnetcore-6.0#map-user-data-keys-and-create-claims

        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList();
        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated",
            Value = DateTime.UtcNow.ToString()
        });
        //remove accessToken code for now.. just try and get placeholder 'TicketCreated' Visible/accessible from outside this function....

        ctx.Properties.StoreTokens(tokens);
    };

So after logging in , I put this code in my index.cshtml.cs razor page:

   //inside index.cshtml.cs
    public async void  OnGet()
    {
        Console.WriteLine("got properties: ");

        //https://stackoverflow.com/a/73301094/5198805
        AuthenticationProperties props = HttpContext.Features.Get<IAuthenticateResultFeature>()?.AuthenticateResult?.Properties;

        if(props != null){
            foreach(var item in props.Items){
                Console.WriteLine(item.Key + " - " + item.Value);
            }
        }
   }

which gives

got properties:
.issued - Mon, 29 Aug 2022 06:53:34 GMT
.expires - Mon, 29 Aug 2022 06:58:34 GMT

I was expecting it would have some Discord related stuff... atleast the TicketCreated set above?

Not sure if this is related but in my program.cs I am setting the user with builder.Services.AddDefaultIdentity<MyExtendedUserClass> whose descriptoin reads:

Adds a set of common identity services to the application, including a default UI, token providers, and configures authentication to use identity cookies.
martincostello commented 1 year ago

To use the tokens elsewhere I think you want to use the HttpContext.GetTokenAsync(string) method similar to this code of my own that uses the GitHub API and authenticates as the logged in user.

https://github.com/martincostello/dependabot-helper/blob/2268c316a736b5daffe52a93d681642ed8ad1ced/src/DependabotHelper/AuthenticationEndpoints.cs#L63

https://github.com/martincostello/dependabot-helper/blob/2268c316a736b5daffe52a93d681642ed8ad1ced/src/DependabotHelper/AuthenticationEndpoints.cs#L115

tuugen commented 1 year ago

Ok, well I cloned the sample project again, added in the few lines for SaveToken = true and to print out access_token. It works there... but for some reason in my already established project ... the access_token is empty:

IN sample MVC.Client HomeController

public async Task<ActionResult> IndexAsync()
    {
        Console.WriteLine("is Authenticated? " + User?.Identity?.IsAuthenticated);
        Console.WriteLine("Authenticated Identity Type: " + User?.Identity?.AuthenticationType);

        Console.WriteLine("fetching access token..");
        var token = await HttpContext.GetTokenAsync("access_token");
        Console.WriteLine("got token: " + token);
        return View();
    }

results in (token replaced by XXXXXXXXXXXXX)

is Authenticated? True
Authenticated Identity Type: Discord
fetching access token..
got token: XXXXXXXXXXXXXXXXXXXXXXX       

However, in my own project...

//in controller

  public async void  OnGet()
    {
        Console.WriteLine("is Authenticated? " + User.Identity.IsAuthenticated);
        Console.WriteLine("Authenticated Identity Type: " + User.Identity.AuthenticationType);
        Console.WriteLine("fetching access token..");
        var token = await HttpContext.GetTokenAsync("access_token");
        Console.WriteLine("got token: " + token);

        Console.WriteLine("fetching explicit discord access token..");
        var discordToken = await HttpContext.GetTokenAsync("Discord", "access_token");
        Console.WriteLine("got discord token: " + discordToken);

    }

output:

is Authenticated? True
Authenticated Identity Type: Identity.Application

got token:
fetching explicit discord access token..
got discord token:
info: AspNet.Security.OAuth.Discord.DiscordAuthenticationHandler[7]
      Discord was not authenticated. Failure message: Not authenticated

I Think I found the issue... The Authenticated Identity Type int he sample is "Discord", but in my own project it is "Identity.Application". I am trying to graft on Discord OAuth2.0 As an optional login path, onto an existing Microsoft.Identity app I built using their tutorial docs.

Is there some way to reconcile these differences?

martincostello commented 1 year ago

I'm afraid I don't know the answer to that one, and I think your problem is out of the scope of our repo.

I think your problem is going to be at a generic level with any OAuth provider (we build on top of Microsoft's OAuth implementation, OAuthHandler) and MS Identity, rather than any problem with the Discord provider specifically.

Tratcher commented 1 year ago

That's because you exchange the discord identity for a local identity. The discord tokens can be stored in the local user database. @haok, where are the docs for that? All I can find is https://docs.microsoft.com/en-us/aspnet/core/security/authentication/customize-identity-model?view=aspnetcore-6.0#entity-types

HaoK commented 1 year ago

This one is probably the one you are looking for with access tokens: https://docs.microsoft.com/en-us/aspnet/core/security/authentication/social/additional-claims?view=aspnetcore-6.0#save-the-access-token

tuugen commented 1 year ago

This one is probably the one you are looking for with access tokens: https://docs.microsoft.com/en-us/aspnet/core/security/authentication/social/additional-claims?view=aspnetcore-6.0#save-the-access-token

Hmmm. I've implemented that example by scaffolding out that portion via dotnet aspnet-codegenerator identity --dbContext ApplicationDbContext --files Account.ExternalLogin however it seems their provided code in that link (that you copy paste into the scaffold file...) only runs on initial user/account creation via ExternalLogin, and sets a Claim in the database. (I may be mistaken here... but i tihnk normal login goes OnPost -> OnGetCallbackAsync , while, OnPostConfirmationAsync, the place where they say to put all the new code, is only run on initial creation, hence the code about email account confirmation at the end of it)

The issue I have here is that I can't seem to get the accessToken of discord/google/etc... from my razor page when calling as such:

        Console.WriteLine("is Authenticated? " + User.Identity.IsAuthenticated);
        Console.WriteLine("Authenticated Identity Name: " + User.Identity.Name);
        Console.WriteLine("Authenticated Identity Type: " + User.Identity.AuthenticationType);

       var discordToken = await HttpContext.GetTokenAsync("Discord", "access_token");
        Console.WriteLine("got discord token: " + discordToken);

        var googleToken = await HttpContext.GetTokenAsync("Google", "access_token");
        Console.WriteLine("got google token: " + googleToken);

generates

Authenticated Identity Name: <my-email>@gmail.com
Authenticated Identity Type: Identity.Application

got discord token:
got google token:
info: AspNet.Security.OAuth.Discord.DiscordAuthenticationHandler[7]
      Discord was not authenticated. Failure message: Not authenticated

however, I logged in with discord and had it print out all the accessToken stuff in the Event.ctx callback. Its in the aspnet program... just not able to expose it in controller/pages... hmmm.

Perhaps if there a boilerplate reference you guys have that uses the aspnet-contrib/AspNet.Security.OAuth.Providers alongside the built in microsoft.indentity... I could build off that?

e.g. if MVC.Client had a sibling project using the dotnet new webapp --auth Individual -o WebApp1 template as a base, given in their identity section tutorial https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity?view=aspnetcore-6.0&tabs=visual-studio

In the meantime, I'll try a workaround using the basic OAuth primitives like @martincostello mentioned.

tuugen commented 1 year ago

That's because you exchange the discord identity for a local identity. The discord tokens can be stored in the local user database. @HaoK, where are the docs for that?

I suppose as a workaround I can also do this , yes. https://security.stackexchange.com/questions/72475/should-we-store-accesstoken-in-our-database-for-oauth2

I just wish it was there in memory to use calling await HttpContext.GetTokenAsync("Discord", "access_token"); seems like a 'usecase' alot of people will have? (Using a normal microsoft.identity auth system and then adding on the OAuth.Providers repo for quick access_token access/integration)

tuugen commented 1 year ago

Ok HUGE,

I found that by adding

await HttpContext.SignInAsync(info.Principal, info.AuthenticationProperties); to OnGetCallbackAsync in Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs as such, allows the await HttpContext.GetTokenAsync("Discord", "access_token"); to work inside any arbitary razor page file.


//ExternalLogin.cshtml.cs    generated by dotnet aspnet-codegenerator identity --dbContext ApplicationDbContext --files Account.ExternalLogin
public async Task<IActionResult> OnGetCallbackAsync(string returnUrl = null, string remoteError = null)
        {
            returnUrl = returnUrl ?? Url.Content("~/");
            if (remoteError != null)
            {
                ErrorMessage = $"Error from external provider: {remoteError}";
                return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
            }
            var info = await _signInManager.GetExternalLoginInfoAsync();
            if (info == null)
            {
                ErrorMessage = "Error loading external login information.";
                return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
            }

            Console.WriteLine("====Signing In Async");
            // Sign in the user with this external login provider if the user already has a login.
            var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);

            if (result.Succeeded)
            {        
                Console.ForegroundColor = ConsoleColor.Red;
                Console.WriteLine("AuthenticaionProperties set after logging in: ");
                Console.ResetColor();
                foreach(var item in info.AuthenticationProperties.Items){
                    Console.WriteLine(item.Key + " - " + item.Value);
                }

                //The Missing Piece!
                await HttpContext.SignInAsync(info.Principal, info.AuthenticationProperties);

                _logger.LogInformation("{Name} logged in with {LoginProvider} provider.", info.Principal.Identity.Name, info.LoginProvider);
                return LocalRedirect(returnUrl);
            }
            if (result.IsLockedOut)
            {
                return RedirectToPage("./Lockout");
            }
            else
            {
                // If the user does not have an account, then ask the user to create an account.
                ReturnUrl = returnUrl;
                ProviderDisplayName = info.ProviderDisplayName;
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
                {
                    Input = new InputModel
                    {
                        Email = info.Principal.FindFirstValue(ClaimTypes.Email)
                    };
                }
                return Page();
            }
        }

For now this will work. closing!