6

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.

6
  • 1
    You're missing the State parameter, which prevents false responses being returned to you. Create a unique identifier for the request being made
    – johnny 5
    Commented Dec 30, 2019 at 14:51
  • @johnny5 would you be able to expand on that a little bit? I made some changes that timur pointed out below and now using the "AddSlack" function instead of AddOauth. It atleast shows that the user is logged in or not now, but I still get the error "The oauth state was missing or invalid"
    – AJ Tatum
    Commented Dec 30, 2019 at 18:22
  • 1
    If you use the AddSlack function and login successfully then you shouldn't be making any oauth requests after unless you're performing a refresh where are you getting this error
    – johnny 5
    Commented Dec 30, 2019 at 18:55
  • Please see the post again after "Massive Update". I have the login functionality working, but I cannot get the Add to Slack button to actually do anything. Not sure what I'm missing...
    – AJ Tatum
    Commented Dec 30, 2019 at 21:44
  • I dont see the add to slack button in your code.
    – johnny 5
    Commented Dec 30, 2019 at 23:23

2 Answers 2

4
+150

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

6
  • Thank you for your response, unfortunately I still get the error "The oauth state was missing or invalid."
    – AJ Tatum
    Commented Dec 30, 2019 at 18:07
  • Quick update, I changed the app to be more like the example (using .AddSlack) and adjusting the rest of the AddAuthentication to look exactly like the example. Now it shows when the user is logged in or not, but I still get the error above when I try to use the Slack provided "Login with Slack" and "Add to Slack" buttons.
    – AJ Tatum
    Commented Dec 30, 2019 at 18:19
  • 1
    Okay, so it seems there's been a similar issue raised with AspNetCore. The answer was to override behaviour in custom middleware handler. I'm not 100% sure this is your case (I can't repro it) but potentially applying workaround from github.com/aspnet/AspNetCore/issues/1871 would resolve your issue?
    – timur
    Commented Dec 30, 2019 at 19:23
  • 1
    I'm also not entirely sure how exactly you "use the Slack" - your code sample only shows oauth setup for login which seems to work for you now?
    – timur
    Commented Dec 30, 2019 at 19:34
  • 1
    yes, i think I've got the problem statement better now. see updated answer
    – timur
    Commented Dec 31, 2019 at 10:27
3

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.

1
  • 1
    From the snippets above your logic looks good. If I were to nitpick however, I would bring up two points to your attention: 1. It seems you are not validating the state parameter on you AddToSlack flow, which seems a bit wasteful since you go through the trouble to generate one. 2. Since the two flows are completely separate, in spirit of SRP design I would consider making each middleware independent and giving it its own set of callback urls, just to avoid confision in the future.
    – timur
    Commented Jan 2, 2020 at 21:07

Not the answer you're looking for? Browse other questions tagged or ask your own question.