Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,11 @@ public interface IWorkspaceInstallationHandler
Task Uninstall(string teamId);
}

public record Workspace(string TeamId, string TeamName, string Token, string InstallerUserId = null);
/// <param name="Installer">The Slack user who installed the app, when Slack reports one.</param>
public record Workspace(string TeamId, string TeamName, string Token, WorkspaceInstaller Installer = null);

/// <summary>
/// The Slack user who installed the app. <see cref="Email"/>, <see cref="EmailVerified"/> and <see cref="Name"/>
/// are only set when the install requested the <c>openid</c> user scope (with <c>email</c> and <c>profile</c>).
/// </summary>
public record WorkspaceInstaller(string UserId, string Email = null, bool EmailVerified = false, string Name = null);
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -52,6 +52,37 @@ await installationHandler.Install(new Workspace
}
}

private static async Task<WorkspaceInstaller> 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) =>
Expand Down
16 changes: 15 additions & 1 deletion source/src/Slackbot.Net.Endpoints/OAuth/OAuthClient.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -31,6 +32,19 @@ public async Task<OAuthAccessV2Response> OAuthAccessV2(OauthAccessV2Request oaut
s => logger.LogInformation(s));
}

public async Task<OpenIdUserInfoResponse> 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<OpenIdUserInfoResponse>(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(
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -30,15 +29,45 @@ 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]
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]
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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<HttpResponseMessage> 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<Workspace> Installed { get; } = [];
Expand Down
Loading