From 37944135757fcc5fac87ea1d5a12a198f0269926 Mon Sep 17 00:00:00 2001 From: John Korsnes Date: Tue, 28 Sep 2021 15:55:37 +0200 Subject: [PATCH] Adds distribution OAuth2 flow --- Samples/HelloWorld/Program.cs | 2 + .../Configurations/ITokenStore.cs | 5 +- .../Hosting/IAppBuilderExtensions.cs | 13 +++ .../Hosting/ServiceCollectionExtensions.cs | 28 ++++- .../SlackbotCodeTokenExchangeMiddleware.cs | 60 ++++++++++ .../OAuth/OAuthClient.cs | 106 ++++++++++++++++++ .../Slackbot.Net.Endpoints.csproj | 2 +- .../Slackbot.Net.Shared.csproj | 2 +- .../Slackbot.Net.SlackClients.Http.csproj | 2 +- 9 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotCodeTokenExchangeMiddleware.cs create mode 100644 source/src/Slackbot.Net.Endpoints/OAuth/OAuthClient.cs diff --git a/Samples/HelloWorld/Program.cs b/Samples/HelloWorld/Program.cs index d7340c7..9843a30 100644 --- a/Samples/HelloWorld/Program.cs +++ b/Samples/HelloWorld/Program.cs @@ -1,3 +1,4 @@ +using System.Threading.Tasks; using Slackbot.Net.Endpoints.Hosting; using Slackbot.Net.Endpoints.Authentication; using Slackbot.Net.Endpoints.Abstractions; @@ -38,4 +39,5 @@ class TokenStore : ITokenStore public Task> GetTokens() => Task.FromResult(new [] { SlackToken }.AsEnumerable()); public Task GetTokenByTeamId(string teamId) => Task.FromResult(SlackToken); public Task Delete(string token) => throw new NotImplementedException("Single workspace app"); + public Task Insert(Workspace slackTeam) => throw new NotImplementedException("Single workspace app"); } diff --git a/source/src/Slackbot.Net.Endpoints/Configurations/ITokenStore.cs b/source/src/Slackbot.Net.Endpoints/Configurations/ITokenStore.cs index 8bdd38b..5cf05cd 100644 --- a/source/src/Slackbot.Net.Endpoints/Configurations/ITokenStore.cs +++ b/source/src/Slackbot.Net.Endpoints/Configurations/ITokenStore.cs @@ -11,5 +11,8 @@ public interface ITokenStore Task> GetTokens(); Task GetTokenByTeamId(string teamId); Task Delete(string token); + Task Insert(Workspace slackTeam); } -} \ No newline at end of file + + public record Workspace(string TeamId, string TeamName, string Scope, string Token); +} diff --git a/source/src/Slackbot.Net.Endpoints/Hosting/IAppBuilderExtensions.cs b/source/src/Slackbot.Net.Endpoints/Hosting/IAppBuilderExtensions.cs index 52a2f3e..bcfe1f2 100644 --- a/source/src/Slackbot.Net.Endpoints/Hosting/IAppBuilderExtensions.cs +++ b/source/src/Slackbot.Net.Endpoints/Hosting/IAppBuilderExtensions.cs @@ -20,6 +20,19 @@ public static IApplicationBuilder UseSlackbot(this IApplicationBuilder app, bool return app; } + + /// + /// NB! The path you run this middleware must: + /// - match redirect_uri in your 1st redirect to Slack + /// - be a valid redirect_uri in your Slack app configuration + /// + /// + /// + public static IApplicationBuilder UseSlackbotDistribution(this IApplicationBuilder app) + { + app.UseMiddleware(); + return app; + } } } diff --git a/source/src/Slackbot.Net.Endpoints/Hosting/ServiceCollectionExtensions.cs b/source/src/Slackbot.Net.Endpoints/Hosting/ServiceCollectionExtensions.cs index da9783c..c44fa0a 100644 --- a/source/src/Slackbot.Net.Endpoints/Hosting/ServiceCollectionExtensions.cs +++ b/source/src/Slackbot.Net.Endpoints/Hosting/ServiceCollectionExtensions.cs @@ -1,6 +1,9 @@ +using System; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Slackbot.Net.Abstractions.Hosting; using Slackbot.Net.Endpoints.Abstractions; +using Slackbot.Net.Endpoints.OAuth; namespace Slackbot.Net.Endpoints.Hosting { @@ -12,5 +15,28 @@ public static ISlackbotHandlersBuilder AddSlackBotEvents(this IServiceCollect services.AddSingleton(); return new SlackBotHandlersBuilder(services); } + + public static IServiceCollection AddSlackbotDistribution(this IServiceCollection services, Action action) + { + services.Configure(action); + services.AddHttpClient((s, c) => + { + c.BaseAddress = new Uri("https://slack.com/api/"); + c.Timeout = TimeSpan.FromSeconds(15); + }); + return services; + } + } + + public class OAuthOptions + { + public OAuthOptions() + { + OnSuccess = (_,_,_) => Task.CompletedTask; + } + public string CLIENT_ID { get; set; } + public string CLIENT_SECRET { get; set; } + public string SuccessRedirectUri { get; set; } = "/success?default=1"; + public Func OnSuccess { get; set; } } -} \ No newline at end of file +} diff --git a/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotCodeTokenExchangeMiddleware.cs b/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotCodeTokenExchangeMiddleware.cs new file mode 100644 index 0000000..eefa39e --- /dev/null +++ b/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotCodeTokenExchangeMiddleware.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Slackbot.Net.Abstractions.Hosting; +using Slackbot.Net.Endpoints.Hosting; +using Slackbot.Net.Endpoints.OAuth; + +namespace Slackbot.Net.Endpoints.Middlewares +{ + internal class SlackbotCodeTokenExchangeMiddleware + { + private readonly RequestDelegate _next; + + public SlackbotCodeTokenExchangeMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task Invoke(HttpContext ctx, OAuthClient oAuthAccessClient, IServiceProvider provider, IOptions options, ITokenStore slackTeamRepository, ILogger logger) + { + logger.LogInformation("Installing!"); + var redirect_uri = new Uri($"{ctx.Request.Scheme}://{ctx.Request.Host.Value.ToString()}{ctx.Request.PathBase.Value}"); + var code = ctx.Request.Query["code"].FirstOrDefault(); + + if(code == null) + await _next(ctx); + + var response = await oAuthAccessClient.OAuthAccessV2(new OAuthClient.OauthAccessV2Request( + code, + options.Value.CLIENT_ID, + options.Value.CLIENT_SECRET, + redirect_uri.ToString() + )); + + if (response.Ok) + { + logger.LogInformation($"Oauth response! ok:{response.Ok}"); + await slackTeamRepository.Insert(new Workspace + ( + response.Team.Id, + response.Team.Name, + response.Scope, + response.Access_Token + )); + await options.Value.OnSuccess(response.Team.Id, response.Team.Name, provider); + + ctx.Response.Redirect(options.Value.SuccessRedirectUri); + } + else + { + logger.LogError($"Bad Oauth response! {response}"); + ctx.Response.StatusCode = StatusCodes.Status500InternalServerError; + await ctx.Response.WriteAsync(response.Error); + } + } + } +} diff --git a/source/src/Slackbot.Net.Endpoints/OAuth/OAuthClient.cs b/source/src/Slackbot.Net.Endpoints/OAuth/OAuthClient.cs new file mode 100644 index 0000000..986142f --- /dev/null +++ b/source/src/Slackbot.Net.Endpoints/OAuth/OAuthClient.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Slackbot.Net.Endpoints.OAuth +{ + internal record Response(bool Ok, string Error); + + internal class OAuthClient + { + private readonly HttpClient _client; + private readonly ILogger _logger; + + internal record OauthAccessV2Request(string Code, string ClientId, string ClientSecret, string RedirectUri); + + internal record OAuthAccessV2Response(string Access_Token, string Scope, Team Team, string App_Id, OAuthUser Authed_User, bool Ok, string Error) : Response(Ok, Error); + internal record Team(string Id, string Name); + internal record OAuthUser(string User_Id, string App_Home); + + + public OAuthClient(HttpClient client, ILogger logger) + { + _client = client; + _logger = logger; + } + + public async Task OAuthAccessV2(OauthAccessV2Request oauthAccessRequest) + { + var parameters = new List> + { + new("code", oauthAccessRequest.Code) + }; + + if (!string.IsNullOrEmpty(oauthAccessRequest.RedirectUri)) + { + parameters.Add(new KeyValuePair("redirect_uri", oauthAccessRequest.RedirectUri)); + } + + if (!string.IsNullOrEmpty(oauthAccessRequest.ClientId)) + { + parameters.Add(new KeyValuePair("client_id", oauthAccessRequest.ClientId)); + } + + if (!string.IsNullOrEmpty(oauthAccessRequest.ClientSecret)) + { + parameters.Add(new KeyValuePair("client_secret", oauthAccessRequest.ClientSecret)); + } + + return await _client.PostParametersAsForm(parameters,"oauth.v2.access", s => _logger.LogInformation(s)); + } + } + + internal static class HttpClientExtensions + { + internal static async Task PostParametersAsForm(this HttpClient httpClient, IEnumerable> parameters, string api, Action logger = null) where T: Response + { + var request = new HttpRequestMessage(HttpMethod.Post, api); + + if (parameters != null && parameters.Any()) + { + var formUrlEncodedContent = new FormUrlEncodedContent(parameters); + var requestContent = await formUrlEncodedContent.ReadAsStringAsync(); + var httpContent = new StringContent(requestContent, Encoding.UTF8, "application/x-www-form-urlencoded"); + httpContent.Headers.ContentType.CharSet = string.Empty; + request.Content = httpContent; + } + + var response = await httpClient.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + logger?.Invoke($"{response.StatusCode} \n {responseContent}"); + } + + response.EnsureSuccessStatusCode(); + + var resObj = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + if(!resObj.Ok) + throw new SlackApiException(error: $"{resObj.Error}", responseContent:responseContent); + + return resObj; + } + } + + internal class SlackApiException : Exception + { + public string Error { get; } + public string ResponseContent { get; } + + public SlackApiException(string error, string responseContent): base(responseContent) + { + Error = error; + ResponseContent = responseContent; + } + } + + +} diff --git a/source/src/Slackbot.Net.Endpoints/Slackbot.Net.Endpoints.csproj b/source/src/Slackbot.Net.Endpoints/Slackbot.Net.Endpoints.csproj index 5d93435..75204be 100644 --- a/source/src/Slackbot.Net.Endpoints/Slackbot.Net.Endpoints.csproj +++ b/source/src/Slackbot.Net.Endpoints/Slackbot.Net.Endpoints.csproj @@ -1,7 +1,7 @@ - netcoreapp3.1;net5.0;net6.0 + net5.0;net6.0 Slackbot.Net.Endpoints Slackbot.Net.Endpoints John Korsnes diff --git a/source/src/Slackbot.Net.Shared/Slackbot.Net.Shared.csproj b/source/src/Slackbot.Net.Shared/Slackbot.Net.Shared.csproj index 1064977..399294c 100644 --- a/source/src/Slackbot.Net.Shared/Slackbot.Net.Shared.csproj +++ b/source/src/Slackbot.Net.Shared/Slackbot.Net.Shared.csproj @@ -1,7 +1,7 @@ - netcoreapp3.1;net5.0 + net5.0;net6.0 diff --git a/source/src/Slackbot.Net.SlackClients.Http/Slackbot.Net.SlackClients.Http.csproj b/source/src/Slackbot.Net.SlackClients.Http/Slackbot.Net.SlackClients.Http.csproj index 278a0be..dd85b6d 100644 --- a/source/src/Slackbot.Net.SlackClients.Http/Slackbot.Net.SlackClients.Http.csproj +++ b/source/src/Slackbot.Net.SlackClients.Http/Slackbot.Net.SlackClients.Http.csproj @@ -1,7 +1,7 @@ - netcoreapp3.1;net5.0;net6.0 + net5.0;net6.0 Slackbot.Net.SlackClients.Http Slackbot.Net.SlackClients.Http John Korsnes