Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Add to Slack in dotnetcore without having Identity Framework error: The oauth state was missing or invalid

I'm trying to create a very simple page for my slackbot so that users can login and register. However, even when using their generated "Login with Slack" button I receive an error "The oauth state was missing or invalid.". The same error happens with "Add to Slack".

I based my code off of https://dotnetthoughts.net/slack-authentication-with-aspnet-core/. Even though it's outdated, it's the only example I could find online. I tried figuring out what I need to change in order to get it to work with the dotnetcore 3 and Slack 2.0, but I've come to my wits end.

In my services, I have the following before calling AddMvc, etc.

services.AddAuthentication(options =>
        options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = "MyAuthCookieName";
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.MaxAge = TimeSpan.FromDays(7);
        options.ExpireTimeSpan = TimeSpan.FromDays(7);

        options.LoginPath = $"/login";
        options.LogoutPath = $"/logout";
        options.AccessDeniedPath = $"/AccessDenied";
        options.SlidingExpiration = true;
        options.ReturnUrlParameter = CookieAuthenticationDefaults.ReturnUrlParameter;
    })
    //.AddSlack(options =>
    //{
    //    options.ClientId = Configuration["Slack:ClientId"];
    //    options.ClientSecret = Configuration["Slack:ClientSecret"];
    //});
    .AddOAuth("Slack", options =>
    {
        options.ClientId = Configuration["Slack:ClientId"];
        options.ClientSecret = Configuration["Slack:ClientSecret"];
        options.CallbackPath = new PathString("/signin-slack");
        options.AuthorizationEndpoint = $"https://slack.com/oauth/authorize";
        options.TokenEndpoint = "https://slack.com/api/oauth.access";
        options.UserInformationEndpoint = "https://slack.com/api/users.identity?token=";
        options.Scope.Add("identity.basic");
        options.Events = new OAuthEvents()
        {
            OnCreatingTicket = async context =>
            {
                var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint + context.AccessToken);
                var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
                response.EnsureSuccessStatusCode();
                var userObject = JObject.Parse(await response.Content.ReadAsStringAsync());
                var user = userObject.SelectToken("user");
                var userId = user.Value<string>("id");

                if (!string.IsNullOrEmpty(userId))
                {
                    context.Identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId, ClaimValueTypes.String, context.Options.ClaimsIssuer));
                }

                var fullName = user.Value<string>("name");
                if (!string.IsNullOrEmpty(fullName))
                {
                    context.Identity.AddClaim(new Claim(ClaimTypes.Name, fullName, ClaimValueTypes.String, context.Options.ClaimsIssuer));
                }
            }
        };
    });

My configure method looks like

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.Map("/login", builder =>
{
    builder.Run(async context =>
    {
        await context.ChallengeAsync("Slack", properties: new AuthenticationProperties { RedirectUri = "/" });
    });
});

app.Map("/logout", builder =>
{
    builder.Run(async context =>
    {
        await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        context.Response.Redirect("/");
    });
});

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
    endpoints.MapRazorPages();
});

Besides the "oauth state was missing on invalid", if in my app I directly go to /login I don't receive the error, but it doesn't appear that I'm logged in as User.Identity.IsAuthenticated is false.

I'm really at a loss, and could use some much appreciated help!

Thank you!

MASSIVE UPDATE

I got the log into slack to work, but I cannot get the Add to Slack button to work.

Here is my new services:

services.AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    })
    .AddCookie(options =>
    {
        options.LoginPath = "/login";
        options.LogoutPath = "/logout";
    })
     .AddSlack(options =>
    {
        options.ClientId = Configuration["Slack:ClientId"];
        options.ClientSecret = Configuration["Slack:ClientSecret"];
        options.CallbackPath =  $"{SlackAuthenticationDefaults.CallbackPath}?state={Guid.NewGuid():N}";
        options.ReturnUrlParameter = new PathString("/");
        options.Events = new OAuthEvents()
        {
            OnCreatingTicket = async context =>
            {
                var request = new HttpRequestMessage(HttpMethod.Get, $"{context.Options.UserInformationEndpoint}?token={context.AccessToken}");
                var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
                response.EnsureSuccessStatusCode();
                var userObject = JObject.Parse(await response.Content.ReadAsStringAsync());
                var user = userObject.SelectToken("user");
                var userId = user.Value<string>("id");

                if (!string.IsNullOrEmpty(userId))
                {
                    context.Identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId, ClaimValueTypes.String, context.Options.ClaimsIssuer));
                }

                var fullName = user.Value<string>("name");
                if (!string.IsNullOrEmpty(fullName))
                {
                    context.Identity.AddClaim(new Claim(ClaimTypes.Name, fullName, ClaimValueTypes.String, context.Options.ClaimsIssuer));
                }
            }
        };
    });

Per @timur,I scraped my app.Map and went with an Authentication Controller:

public class AuthenticationController : Controller
{
    [HttpGet("~/login")]
    public async Task<IActionResult> SignIn()
    {
        return Challenge(new AuthenticationProperties { RedirectUri = "/" }, "Slack");
    }

    [HttpGet("~/signin-slack")]
    public IActionResult SignInSlack()
    {
        return RedirectToPage("/Index");
    }

    [HttpGet("~/logout"), HttpPost("~/logout")]
    public IActionResult SignOut()
    {
        return SignOut(new AuthenticationProperties { RedirectUri = "/" },
            CookieAuthenticationDefaults.AuthenticationScheme);
    }
}

The "Add to Slack" button is provided as is from Slack.

<a href="https://slack.com/oauth/authorize?scope=incoming-webhook,commands,bot&client_id=#############"><img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/[email protected] 2x" /></a>

So, when the use clicks "Login" it logs them in and I get their name, etc. You'll notice in my Authentication Controller I added a function with the path "~/signin-slack" this is because I manually added the "Options.CallbackPath" to add a state parameter. If I remove "Options.CallbackPath", I get an error stating that the oauth state was missing or invalid.

So, I'm not sure what I'm missing here on the Slack side. They make it sound so easy!

Sorry for the long post/update. Thanks for your help.

like image 572
AJ Tatum Avatar asked Dec 28 '19 01:12

AJ Tatum


2 Answers

That same article you mention has a link down below that points to AspNet.Security.OAuth.Providers source repo. That seems to be fairly active, and supports HEAPS of additional oAuth targets including Slack.

I am assuming you've created and configured your slack app. Redirect URL part is of utmost importance there, as it matters whether you specify http or https callback (my example worked only when I went https).

With all above said, I believe the general way to go about implementing it would be to

Install-Package AspNet.Security.OAuth.Slack -Version 3.0.0

and edit your Startup.cs like so:

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(options => { /* your options verbatim */ })
            .AddSlack(options =>
            {
                options.ClientId = "xxx";
                options.ClientSecret = "xxx";
            });
}

I see you opted to map your login/logout routes directly in the Startup class, which might actually be the issue - calls to .Map() branch the request pipeline and therefore you don't hit the same middleware chain you set up earlier), so I went with a separate controller (as per sample app):

public class AuthenticationController : Controller
    {
        [HttpGet("~/signin")]
        public async Task<IActionResult> SignIn()
        {
            // Instruct the middleware corresponding to the requested external identity
            // provider to redirect the user agent to its own authorization endpoint.
            // Note: the authenticationScheme parameter must match the value configured in Startup.cs
            return Challenge(new AuthenticationProperties { RedirectUri = "/" }, "Slack");
        }

        [HttpGet("~/signout"), HttpPost("~/signout")]
        public IActionResult SignOut()
        {
            // Instruct the cookies middleware to delete the local cookie created
            // when the user agent is redirected from the external identity provider
            // after a successful authentication flow (e.g Google or Facebook).
            return SignOut(new AuthenticationProperties { RedirectUri = "/" },
                CookieAuthenticationDefaults.AuthenticationScheme);
        }
    }

Looking at your snippet I however suspect you already installed this nuget package and tried to use it. Which leads me to recommend a few things to check out:

  1. double check your redirect URL in slack app configuration,
  2. check whether your identity.basic scope is actually enabled for your app
  3. try handling login actions in separate controller rather than startup class
  4. ensure your application runs with SSL: **Project properties** -> **Debug** tab -> **Enable SSL** checkbox (if IIS express hosted, otherwise you might need to do a bit of extra work)
  5. check out the sample project, it might give you an idea how your setup is different

UPD: so after some back and forth I was able to get a better view of your issue. I do believe what you are observing is separate to logging in with slack and rather has to do with their app install flow. As you already pointed out, the difference between the "add to slack" flow and user login is - the state parameter is not part of your source URL and therefore is not returned back to you across requests. This is a huge deal for the oAuth handler as it relies on state to validate request integrity and simply fails if state is empty. There's been a discussion on github but the outcome I believe was - you're going to have to skip the validation part yourself. So I inherited from SlackAuthenticationHandler that comes with the nuget package and removed the bits of code that gave me the issue:

    public class SlackNoStateAuthenticationHandler : SlackAuthenticationHandler {
        public SlackNoStateAuthenticationHandler([NotNull] IOptionsMonitor<SlackAuthenticationOptions> options,
            [NotNull] ILoggerFactory logger,
            [NotNull] UrlEncoder encoder,
            [NotNull] ISystemClock clock) : base(options, logger, encoder, clock) { }

        public void GenerateCorrelationIdPublic(AuthenticationProperties properties)
        {
            GenerateCorrelationId(properties);
        }

        protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
        {
            var query = Request.Query;

            var state = query["state"];
            var properties = Options.StateDataFormat.Unprotect(state);

            var error = query["error"];
            if (!StringValues.IsNullOrEmpty(error))
            {
                // Note: access_denied errors are special protocol errors indicating the user didn't
                // approve the authorization demand requested by the remote authorization server.
                // Since it's a frequent scenario (that is not caused by incorrect configuration),
                // denied errors are handled differently using HandleAccessDeniedErrorAsync().
                // Visit https://tools.ietf.org/html/rfc6749#section-4.1.2.1 for more information.
                if (StringValues.Equals(error, "access_denied"))
                {
                    return await HandleAccessDeniedErrorAsync(properties);
                }

                var failureMessage = new StringBuilder();
                failureMessage.Append(error);
                var errorDescription = query["error_description"];
                if (!StringValues.IsNullOrEmpty(errorDescription))
                {
                    failureMessage.Append(";Description=").Append(errorDescription);
                }
                var errorUri = query["error_uri"];
                if (!StringValues.IsNullOrEmpty(errorUri))
                {
                    failureMessage.Append(";Uri=").Append(errorUri);
                }

                return HandleRequestResult.Fail(failureMessage.ToString(), properties);
            }

            var code = query["code"];

            if (StringValues.IsNullOrEmpty(code))
            {
                return HandleRequestResult.Fail("Code was not found.", properties);
            }


            var tokens = await ExchangeCodeAsync(new OAuthCodeExchangeContext(properties, code, BuildRedirectUri(Options.CallbackPath)));

            if (tokens.Error != null)
            {
                return HandleRequestResult.Fail(tokens.Error, properties);
            }

            if (string.IsNullOrEmpty(tokens.AccessToken))
            {
                return HandleRequestResult.Fail("Failed to retrieve access token.", properties);
            }

            var identity = new ClaimsIdentity(ClaimsIssuer);

            if (Options.SaveTokens)
            {
                var authTokens = new List<AuthenticationToken>();

                authTokens.Add(new AuthenticationToken { Name = "access_token", Value = tokens.AccessToken });
                if (!string.IsNullOrEmpty(tokens.RefreshToken))
                {
                    authTokens.Add(new AuthenticationToken { Name = "refresh_token", Value = tokens.RefreshToken });
                }

                if (!string.IsNullOrEmpty(tokens.TokenType))
                {
                    authTokens.Add(new AuthenticationToken { Name = "token_type", Value = tokens.TokenType });
                }

                if (!string.IsNullOrEmpty(tokens.ExpiresIn))
                {
                    int value;
                    if (int.TryParse(tokens.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
                    {
                        // https://www.w3.org/TR/xmlschema-2/#dateTime
                        // https://msdn.microsoft.com/en-us/library/az4se3k1(v=vs.110).aspx
                        var expiresAt = Clock.UtcNow + TimeSpan.FromSeconds(value);
                        authTokens.Add(new AuthenticationToken
                        {
                            Name = "expires_at",
                            Value = expiresAt.ToString("o", CultureInfo.InvariantCulture)
                        });
                    }
                }

                properties.StoreTokens(authTokens);
            }

            var ticket = await CreateTicketAsync(identity, properties, tokens);
            if (ticket != null)
            {
                return HandleRequestResult.Success(ticket);
            }
            else
            {
                return HandleRequestResult.Fail("Failed to retrieve user information from remote server.", properties);
            }
        }
    }

Most of this code is verbatim copy of the relevant source, so you could always make more changes if need be;

Then we need to inject the sensible state parameter into your URL. Assuming you've got a controller and a view:

HomeController

public class HomeController : Controller
    { 
        private readonly IAuthenticationHandlerProvider _handler;

        public HomeController(IAuthenticationHandlerProvider handler)
        {
            _handler = handler;
        }

        public async Task<IActionResult> Index()
        {
            var handler = await _handler.GetHandlerAsync(HttpContext, "Slack") as SlackNoStateAuthenticationHandler; // we'd get the configured instance
            var props = new AuthenticationProperties { RedirectUri = "/" }; // provide some sane defaults
            handler.GenerateCorrelationIdPublic(props); // generate xsrf token and add it into the properties object
            ViewBag.state = handler.Options.StateDataFormat.Protect(props); // and push it into your view.
            return View();
        }
}

Startup.cs

.AddOAuth<SlackAuthenticationOptions, SlackNoStateAuthenticationHandler>(SlackAuthenticationDefaults.AuthenticationScheme, SlackAuthenticationDefaults.DisplayName, options =>
            {
                options.ClientId = "your_id";
                options.ClientSecret = "your_secret";
            });

Index.cshtml

<a href="https://slack.com/oauth/authorize?client_id=<your_id>&scope=identity.basic&[email protected]"><img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/[email protected] 2x"></a>

this allowed me to successfully complete the request, although I'm not entirely sure if doing this will be considered best practice

like image 108
timur Avatar answered Oct 21 '22 05:10

timur


So I figured it out. The login is totally separate from the "Add to Slack" functionality.

So, for logging in I have my services as:

var slackState = Guid.NewGuid().ToString("N");

services.AddAuthentication(options =>
        {
            options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        })
        .AddCookie(options =>
        {
            options.LoginPath = "/login";
            options.LogoutPath = "/logout";
        })
         .AddSlack(options =>
        {
            options.ClientId = Configuration["Slack:ClientId"];
            options.ClientSecret = Configuration["Slack:ClientSecret"];
            options.CallbackPath = $"{SlackAuthenticationDefaults.CallbackPath}?state={slackState}";
            options.ReturnUrlParameter = new PathString("/");
            options.Events = new OAuthEvents()
            {
                OnCreatingTicket = async context =>
                {
                    var request = new HttpRequestMessage(HttpMethod.Get, $"{context.Options.UserInformationEndpoint}?token={context.AccessToken}");
                    var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
                    response.EnsureSuccessStatusCode();
                    var userObject = JObject.Parse(await response.Content.ReadAsStringAsync());
                    var user = userObject.SelectToken("user");
                    var userId = user.Value<string>("id");


                    if (!string.IsNullOrEmpty(userId))
                    {
                        context.Identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId, ClaimValueTypes.String, context.Options.ClaimsIssuer));
                    }

                    var fullName = user.Value<string>("name");
                    if (!string.IsNullOrEmpty(fullName))
                    {
                        context.Identity.AddClaim(new Claim(ClaimTypes.Name, fullName, ClaimValueTypes.String, context.Options.ClaimsIssuer));
                    }
                }
            };
        });

My AuthenticationController now looks like:

public class AuthenticationController : Controller
{
    private readonly ILogger<AuthenticationController> _logger;
    private readonly AppSettings _appSettings;

    public AuthenticationController(ILogger<AuthenticationController> logger, IOptionsMonitor<AppSettings> appSettings)
    {
        _logger = logger;
        _appSettings = appSettings.CurrentValue;
    }

    [HttpGet("~/login")]
    public IActionResult SignIn()
    {
        return Challenge(new AuthenticationProperties { RedirectUri = "/" }, "Slack");
    }

    [HttpGet("~/signin-slack")]
    public async Task<IActionResult> SignInSlack()
    {
        var clientId = _appSettings.Slack.ClientId;
        var clientSecret = _appSettings.Slack.ClientSecret;
        var code = Request.Query["code"];

        SlackAuthRequest slackAuthRequest;
        string responseMessage;

        var requestUrl = $"https://slack.com/api/oauth.access?client_id={clientId}&client_secret={clientSecret}&code={code}";
        var request = new HttpRequestMessage(HttpMethod.Post, requestUrl);
        using (var client = new HttpClient())
        {
            var response = await client.SendAsync(request).ConfigureAwait(false);
            var result = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
            slackAuthRequest = JsonConvert.DeserializeObject<SlackAuthRequest>(result);
        }

        if (slackAuthRequest != null)
        {
            _logger.LogInformation("New installation of StanLeeBot for {TeamName} in {Channel}", slackAuthRequest.TeamName, slackAuthRequest.IncomingWebhook.Channel);

            var webhookUrl = slackAuthRequest.IncomingWebhook.Url;

            var sbmClient = new SbmClient(webhookUrl);
            var message = new Message
            {
                Text = "Hi there from StanLeeBot!"
            };
            await sbmClient.SendAsync(message).ConfigureAwait(false);

            responseMessage = $"Congrats! StanLeeBot has been successfully added to {slackAuthRequest.TeamName} {slackAuthRequest.IncomingWebhook.Channel}";
            return RedirectToPage("/Index", new { message = responseMessage });
        }

        _logger.LogError("Something went wrong making a request to {RequestUrl}", requestUrl);

        responseMessage = "Error: Something went wrong and we were unable to add StanLeeBot to your Slack.";
        return RedirectToPage("/Index", new { message = responseMessage });
    }

    [HttpGet("~/logout"), HttpPost("~/logout")]
    public IActionResult SignOut()
    {
        return SignOut(new AuthenticationProperties { RedirectUri = "/" },
            CookieAuthenticationDefaults.AuthenticationScheme);
    }
}

SmbClient is a Nuget package called SlackBotMessages that is used to send messages. So after the user authenticates, a message is automatically sent to that channel welcoming the user.

Thank you all very much for your help! Let me know what you think or if you see any gotchas.

like image 26
AJ Tatum Avatar answered Oct 21 '22 06:10

AJ Tatum