From 1dccae3fadcf1ec9546106db282cc06dccd9eb1e Mon Sep 17 00:00:00 2001 From: John Korsnes Date: Tue, 29 Sep 2026 14:19:33 +0200 Subject: [PATCH 1/2] feat: pass the installer's Slack profile when the install grants openid When the install's user token carries the openid scope, look up openid.connect.userInfo and hand the email, verified flag and name to the installation handler. Installs without openid make no extra call, and a failed lookup still installs the workspace. --- .../IWorkspaceInstallationHandler.cs | 8 ++- .../SlackbotCodeTokenExchangeMiddleware.cs | 29 +++++++++- .../OAuth/OAuthClient.cs | 16 +++++- .../DistributionRedirectTests.cs | 55 ++++++++++++++++++- 4 files changed, 102 insertions(+), 6 deletions(-) diff --git a/source/src/Slackbot.Net.Endpoints/Configurations/IWorkspaceInstallationHandler.cs b/source/src/Slackbot.Net.Endpoints/Configurations/IWorkspaceInstallationHandler.cs index f946229..2039a73 100644 --- a/source/src/Slackbot.Net.Endpoints/Configurations/IWorkspaceInstallationHandler.cs +++ b/source/src/Slackbot.Net.Endpoints/Configurations/IWorkspaceInstallationHandler.cs @@ -16,4 +16,10 @@ public interface IWorkspaceInstallationHandler Task Uninstall(string teamId); } -public record Workspace(string TeamId, string TeamName, string Token, string InstallerUserId = null); +/// +/// The installing user's Slack profile. Only set when the install requested the openid user scope +/// (together with email and profile for those claims). +/// +public record Workspace(string TeamId, string TeamName, string Token, string InstallerUserId = null, InstallerIdentity Installer = null); + +public record InstallerIdentity(string Email, bool EmailVerified, string Name); diff --git a/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotCodeTokenExchangeMiddleware.cs b/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotCodeTokenExchangeMiddleware.cs index 37dc904..e1cb885 100644 --- a/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotCodeTokenExchangeMiddleware.cs +++ b/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotCodeTokenExchangeMiddleware.cs @@ -38,7 +38,8 @@ await installationHandler.Install(new Workspace response.Team.Id, response.Team.Name, response.Access_Token, - response.Authed_User?.Id + response.Authed_User?.Id, + await InstallerIdentity(oAuthAccessClient, response.Authed_User, logger) )); var stateTheAppSent = ctx.Request.Query["state"].FirstOrDefault(); @@ -52,6 +53,32 @@ await installationHandler.Install(new Workspace } } + private static async Task InstallerIdentity(OAuthClient client, OAuthClient.OAuthUser installer, + ILogger logger) + { + if (installer?.Access_Token is null || !(installer.Scope ?? "").Split(',').Contains("openid")) + { + return null; + } + + try + { + var userInfo = await client.OpenIdUserInfo(installer.Access_Token); + if (userInfo is { Ok: true }) + { + return new InstallerIdentity(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 null; + } + // `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..10fe914 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; @@ -41,6 +40,38 @@ public async Task WithoutAnAuthedUser_TheWorkspaceIsInstalledWithNoInstaller() Assert.Null(Assert.Single(handler.Installed).InstallerUserId); } + [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 InstallerIdentity("installer@example.com", true, "Ina Installer"), Assert.Single(handler.Installed).Installer); + } + + [Fact] + public async Task AnInstallerNotGrantingOpenId_HasNoIdentity() + { + 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.Null(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"}"""); + + var workspace = Assert.Single(handler.Installed); + Assert.Equal("U1", workspace.InstallerUserId); + Assert.Null(workspace.Installer); + } + [Fact] public async Task StateRidesBackToTheSuccessPageAsAQueryParameter() { @@ -62,7 +93,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 +106,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 +131,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; } = []; From 82041ac258629d945c564c168b871f3623f5baca Mon Sep 17 00:00:00 2001 From: John Korsnes Date: Tue, 29 Sep 2026 14:22:14 +0200 Subject: [PATCH 2/2] refactor!: fold the installer's user id into WorkspaceInstaller Workspace.InstallerUserId (9.0.4) is replaced by Workspace.Installer, which carries the user id whenever Slack reports an installer and the profile only when openid was granted. --- .../IWorkspaceInstallationHandler.cs | 13 +++++++------ .../SlackbotCodeTokenExchangeMiddleware.cs | 16 ++++++++++------ .../DistributionRedirectTests.cs | 14 ++++++-------- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/source/src/Slackbot.Net.Endpoints/Configurations/IWorkspaceInstallationHandler.cs b/source/src/Slackbot.Net.Endpoints/Configurations/IWorkspaceInstallationHandler.cs index 2039a73..a07b1d6 100644 --- a/source/src/Slackbot.Net.Endpoints/Configurations/IWorkspaceInstallationHandler.cs +++ b/source/src/Slackbot.Net.Endpoints/Configurations/IWorkspaceInstallationHandler.cs @@ -16,10 +16,11 @@ public interface IWorkspaceInstallationHandler Task Uninstall(string teamId); } -/// -/// The installing user's Slack profile. Only set when the install requested the openid user scope -/// (together with email and profile for those claims). -/// -public record Workspace(string TeamId, string TeamName, string Token, string InstallerUserId = null, InstallerIdentity Installer = null); +/// The Slack user who installed the app, when Slack reports one. +public record Workspace(string TeamId, string TeamName, string Token, WorkspaceInstaller Installer = null); -public record InstallerIdentity(string Email, bool EmailVerified, string Name); +/// +/// 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 e1cb885..f9c8683 100644 --- a/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotCodeTokenExchangeMiddleware.cs +++ b/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotCodeTokenExchangeMiddleware.cs @@ -38,8 +38,7 @@ await installationHandler.Install(new Workspace response.Team.Id, response.Team.Name, response.Access_Token, - response.Authed_User?.Id, - await InstallerIdentity(oAuthAccessClient, response.Authed_User, logger) + await Installer(oAuthAccessClient, response.Authed_User, logger) )); var stateTheAppSent = ctx.Request.Query["state"].FirstOrDefault(); @@ -53,20 +52,25 @@ await InstallerIdentity(oAuthAccessClient, response.Authed_User, logger) } } - private static async Task InstallerIdentity(OAuthClient client, OAuthClient.OAuthUser installer, + private static async Task Installer(OAuthClient client, OAuthClient.OAuthUser installer, ILogger logger) { - if (installer?.Access_Token is null || !(installer.Scope ?? "").Split(',').Contains("openid")) + 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 InstallerIdentity(userInfo.Email, userInfo.Email_Verified, userInfo.Name); + 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); @@ -76,7 +80,7 @@ private static async Task InstallerIdentity(OAuthClient clien logger.LogWarning(e, "openid.connect.userInfo failed for installer {InstallerUserId}", installer.Id); } - return null; + return new WorkspaceInstaller(installer.Id); } // `state` is opaque to this library — only the app that sent it knows what it means, so it diff --git a/source/test/Slackbot.Net.SlackClients.Http.Tests/DistributionRedirectTests.cs b/source/test/Slackbot.Net.SlackClients.Http.Tests/DistributionRedirectTests.cs index 10fe914..696cdf6 100644 --- a/source/test/Slackbot.Net.SlackClients.Http.Tests/DistributionRedirectTests.cs +++ b/source/test/Slackbot.Net.SlackClients.Http.Tests/DistributionRedirectTests.cs @@ -29,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] @@ -37,7 +37,7 @@ 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] @@ -47,17 +47,17 @@ public async Task AnInstallerGrantingOpenId_IsIdentifiedByTheirSlackProfile() OauthAccessResponseWithUserScope("openid,email,profile"), """{"ok":true,"sub":"U1","email":"installer@example.com","email_verified":true,"name":"Ina Installer"}"""); - Assert.Equal(new InstallerIdentity("installer@example.com", true, "Ina Installer"), Assert.Single(handler.Installed).Installer); + Assert.Equal(new WorkspaceInstaller("U1", "installer@example.com", true, "Ina Installer"), Assert.Single(handler.Installed).Installer); } [Fact] - public async Task AnInstallerNotGrantingOpenId_HasNoIdentity() + 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.Null(Assert.Single(handler.Installed).Installer); + Assert.Equal(new WorkspaceInstaller("U1"), Assert.Single(handler.Installed).Installer); } [Fact] @@ -67,9 +67,7 @@ public async Task AFailedProfileLookup_StillInstallsTheWorkspace() OauthAccessResponseWithUserScope("openid,email,profile"), """{"ok":false,"error":"invalid_auth"}"""); - var workspace = Assert.Single(handler.Installed); - Assert.Equal("U1", workspace.InstallerUserId); - Assert.Null(workspace.Installer); + Assert.Equal(new WorkspaceInstaller("U1"), Assert.Single(handler.Installed).Installer); } [Fact]