diff --git a/source/src/Slackbot.Net.Endpoints/Configurations/IWorkspaceInstallationHandler.cs b/source/src/Slackbot.Net.Endpoints/Configurations/IWorkspaceInstallationHandler.cs index f946229..a07b1d6 100644 --- a/source/src/Slackbot.Net.Endpoints/Configurations/IWorkspaceInstallationHandler.cs +++ b/source/src/Slackbot.Net.Endpoints/Configurations/IWorkspaceInstallationHandler.cs @@ -16,4 +16,11 @@ public interface IWorkspaceInstallationHandler Task Uninstall(string teamId); } -public record Workspace(string TeamId, string TeamName, string Token, string InstallerUserId = null); +/// The Slack user who installed the app, when Slack reports one. +public record Workspace(string TeamId, string TeamName, string Token, WorkspaceInstaller Installer = null); + +/// +/// The Slack user who installed the app. , and +/// are only set when the install requested the openid user scope (with email and profile). +/// +public record WorkspaceInstaller(string UserId, string Email = null, bool EmailVerified = false, string Name = null); diff --git a/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotCodeTokenExchangeMiddleware.cs b/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotCodeTokenExchangeMiddleware.cs index 37dc904..f9c8683 100644 --- a/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotCodeTokenExchangeMiddleware.cs +++ b/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotCodeTokenExchangeMiddleware.cs @@ -38,7 +38,7 @@ await installationHandler.Install(new Workspace response.Team.Id, response.Team.Name, response.Access_Token, - response.Authed_User?.Id + await Installer(oAuthAccessClient, response.Authed_User, logger) )); var stateTheAppSent = ctx.Request.Query["state"].FirstOrDefault(); @@ -52,6 +52,37 @@ await installationHandler.Install(new Workspace } } + private static async Task Installer(OAuthClient client, OAuthClient.OAuthUser installer, + ILogger logger) + { + if (installer?.Id is null) + { + return null; + } + + if (installer.Access_Token is null || !(installer.Scope ?? "").Split(',').Contains("openid")) + { + return new WorkspaceInstaller(installer.Id); + } + + try + { + var userInfo = await client.OpenIdUserInfo(installer.Access_Token); + if (userInfo is { Ok: true }) + { + return new WorkspaceInstaller(installer.Id, userInfo.Email, userInfo.Email_Verified, userInfo.Name); + } + + logger.LogWarning("openid.connect.userInfo failed for installer {InstallerUserId}: {Error}", installer.Id, userInfo?.Error); + } + catch (Exception e) + { + logger.LogWarning(e, "openid.connect.userInfo failed for installer {InstallerUserId}", installer.Id); + } + + return new WorkspaceInstaller(installer.Id); + } + // `state` is opaque to this library — only the app that sent it knows what it means, so it // rides back to that app's own success page untouched rather than being interpreted here. internal static string SuccessRedirect(string successRedirectUri, string state) => diff --git a/source/src/Slackbot.Net.Endpoints/OAuth/OAuthClient.cs b/source/src/Slackbot.Net.Endpoints/OAuth/OAuthClient.cs index f0701fe..f866659 100644 --- a/source/src/Slackbot.Net.Endpoints/OAuth/OAuthClient.cs +++ b/source/src/Slackbot.Net.Endpoints/OAuth/OAuthClient.cs @@ -1,3 +1,4 @@ +using System.Net.Http.Headers; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -31,6 +32,19 @@ public async Task OAuthAccessV2(OauthAccessV2Request oaut s => logger.LogInformation(s)); } + public async Task OpenIdUserInfo(string userToken) + { + var request = new HttpRequestMessage(HttpMethod.Post, "openid.connect.userInfo"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", userToken); + var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(body, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + } + + internal record OpenIdUserInfoResponse(bool Ok, string Error, string Email, bool Email_Verified, string Name) + : Response(Ok, Error); + internal record OauthAccessV2Request(string Code, string ClientId, string ClientSecret, string RedirectUri); internal record OAuthAccessV2Response( @@ -44,7 +58,7 @@ internal record OAuthAccessV2Response( internal record Team(string Id, string Name); - internal record OAuthUser(string Id); + internal record OAuthUser(string Id, string Scope, string Access_Token); } internal static class HttpClientExtensions diff --git a/source/test/Slackbot.Net.SlackClients.Http.Tests/DistributionRedirectTests.cs b/source/test/Slackbot.Net.SlackClients.Http.Tests/DistributionRedirectTests.cs index ef91c37..696cdf6 100644 --- a/source/test/Slackbot.Net.SlackClients.Http.Tests/DistributionRedirectTests.cs +++ b/source/test/Slackbot.Net.SlackClients.Http.Tests/DistributionRedirectTests.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection; using Slackbot.Net.Abstractions.Hosting; using Slackbot.Net.Endpoints.Hosting; -using Slackbot.Net.Tests.Helpers; namespace Slackbot.Net.Tests; @@ -30,7 +29,7 @@ public async Task TheInstallingUserIsCarriedToTheInstallationHandler() var (_, handler) = await Install(state: null, successRedirectUri: "/success", """{"ok":true,"access_token":"xoxb-token","scope":"chat:write","team":{"id":"T1","name":"Team"},"app_id":"A1","authed_user":{"id":"U1"}}"""); - Assert.Equal("U1", Assert.Single(handler.Installed).InstallerUserId); + Assert.Equal(new WorkspaceInstaller("U1"), Assert.Single(handler.Installed).Installer); } [Fact] @@ -38,7 +37,37 @@ public async Task WithoutAnAuthedUser_TheWorkspaceIsInstalledWithNoInstaller() { var (_, handler) = await Install(state: null, successRedirectUri: "/success"); - Assert.Null(Assert.Single(handler.Installed).InstallerUserId); + Assert.Null(Assert.Single(handler.Installed).Installer); + } + + [Fact] + public async Task AnInstallerGrantingOpenId_IsIdentifiedByTheirSlackProfile() + { + var (_, handler) = await Install(state: null, successRedirectUri: "/success", + OauthAccessResponseWithUserScope("openid,email,profile"), + """{"ok":true,"sub":"U1","email":"installer@example.com","email_verified":true,"name":"Ina Installer"}"""); + + Assert.Equal(new WorkspaceInstaller("U1", "installer@example.com", true, "Ina Installer"), Assert.Single(handler.Installed).Installer); + } + + [Fact] + public async Task AnInstallerNotGrantingOpenId_IsIdentifiedByUserIdOnly() + { + var (_, handler) = await Install(state: null, successRedirectUri: "/success", + OauthAccessResponseWithUserScope("search:read"), + """{"ok":true,"sub":"U1","email":"installer@example.com","email_verified":true,"name":"Ina Installer"}"""); + + Assert.Equal(new WorkspaceInstaller("U1"), Assert.Single(handler.Installed).Installer); + } + + [Fact] + public async Task AFailedProfileLookup_StillInstallsTheWorkspace() + { + var (_, handler) = await Install(state: null, successRedirectUri: "/success", + OauthAccessResponseWithUserScope("openid,email,profile"), + """{"ok":false,"error":"invalid_auth"}"""); + + Assert.Equal(new WorkspaceInstaller("U1"), Assert.Single(handler.Installed).Installer); } [Fact] @@ -62,7 +91,8 @@ public async Task StateIsNeverTheRedirectTarget_WhateverItHolds(string state) } private static async Task<(HttpContext Context, RecordingInstallationHandler Handler)> Install( - string state, string successRedirectUri, string oauthAccessResponse = OauthAccessResponse) + string state, string successRedirectUri, string oauthAccessResponse = OauthAccessResponse, + string userInfoResponse = null) { var services = new ServiceCollection(); services.AddLogging(); @@ -74,7 +104,7 @@ public async Task StateIsNeverTheRedirectTarget_WhateverItHolds(string state) o.SuccessRedirectUri = successRedirectUri; }); services.ConfigureHttpClientDefaults(b => - b.ConfigurePrimaryHttpMessageHandler(() => new StubHttpMessageHandler(oauthAccessResponse))); + b.ConfigurePrimaryHttpMessageHandler(() => new SlackApiStub(oauthAccessResponse, userInfoResponse))); await using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); await using var scope = provider.CreateAsyncScope(); @@ -99,6 +129,23 @@ public async Task StateIsNeverTheRedirectTarget_WhateverItHolds(string state) return (ctx, handler); } + private static string OauthAccessResponseWithUserScope(string userScope) => + $$$"""{"ok":true,"access_token":"xoxb-token","scope":"chat:write","team":{"id":"T1","name":"Team"},"app_id":"A1","authed_user":{"id":"U1","scope":"{{{userScope}}}","access_token":"xoxp-token","token_type":"user"}}"""; + + private sealed class SlackApiStub(string oauthAccessResponse, string userInfoResponse) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var json = request.RequestUri!.AbsolutePath.EndsWith("openid.connect.userInfo") + ? userInfoResponse ?? """{"ok":false,"error":"not_stubbed"}""" + : oauthAccessResponse; + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }); + } + } + private sealed class RecordingInstallationHandler : IWorkspaceInstallationHandler { public List Installed { get; } = [];