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; } = [];