Skip to content

Commit

Permalink
[C#] feat: Add enableSso property to toggle SSO in user auth scenarios (
Browse files Browse the repository at this point in the history
#1236)

## Linked issues

closes: #1194 #991

## Details
* Corresponding JS PR: #1232 for implementation details.

## Attestation Checklist

- [x] My code follows the style guidelines of this project

- I have checked for/fixed spelling, linting, and other errors
- I have commented my code for clarity
- I have made corresponding changes to the documentation (updating the
doc strings in the code is sufficient)
- My changes generate no new warnings
- I have added tests that validates my changes, and provides sufficient
test coverage. I have tested with:
  - Local testing
  - E2E testing in Teams
- New and existing unit tests pass locally with my changes

---------

Co-authored-by: Corina <[email protected]>
  • Loading branch information
singhk97 and corinagum authored Feb 7, 2024
1 parent 805c133 commit 09ebba1
Show file tree
Hide file tree
Showing 13 changed files with 369 additions and 32 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Integration.AspNet.Core;
using Microsoft.Bot.Connector;
using Microsoft.Bot.Connector.Authentication;
using Microsoft.Bot.Schema;
using Microsoft.Teams.AI.State;
using Microsoft.Teams.AI.Tests.TestUtils;
using Moq;

namespace Microsoft.Teams.AI.Tests.Application.Authentication.Bot
{
internal class TestOAuthBotAuthentication : OAuthBotAuthentication<TurnState>

Check warning on line 12 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/Bot/OAuthBotAuthenticationTests.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint / Build/Test/Lint (7.0)

Type 'TestOAuthBotAuthentication' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 12 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/Bot/OAuthBotAuthenticationTests.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint (6.0)

Type 'TestOAuthBotAuthentication' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 12 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/Bot/OAuthBotAuthenticationTests.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint (7.0)

Type 'TestOAuthBotAuthentication' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 12 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/Bot/OAuthBotAuthenticationTests.cs

View workflow job for this annotation

GitHub Actions / Analyze

Type 'TestOAuthBotAuthentication' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)
{
public TestOAuthBotAuthentication(Application<TurnState> app, OAuthSettings oauthSettings, string settingName, IStorage? storage = null) : base(app, oauthSettings, settingName, storage)
{
}

protected override Task<SignInResource> GetSignInResourceAsync(ITurnContext context, string connectionName, CancellationToken cancellationToken = default)
{
return Task.FromResult(new SignInResource()
{
SignInLink = "signInLink",
TokenExchangeResource = new TokenExchangeResource()
{
Id = "id",
Uri = "uri"
},
TokenPostResource = new TokenPostResource()
{
SasUrl = "sasUrl",
}
});
}
}

public class OAuthBotAuthenticationTests
{
[Fact]
public async void Test_CreateOAuthCard_WithSSOEnabled()
{
// Arrange
IActivity sentActivity;
var testAdapter = new SimpleAdapter((activity) => sentActivity = activity);
var turnContext = TurnStateConfig.CreateConfiguredTurnContext();

var turnState = await TurnStateConfig.GetTurnStateWithConversationStateAsync(turnContext);
var app = new TestApplication(new() { Adapter = testAdapter });
var authSettings = new OAuthSettings() {
ConnectionName = "connectionName",
Title = "title",
Text = "text",
EnableSso = true
};

var botAuth = new TestOAuthBotAuthentication(app, authSettings, "connectionName");

// Act
var result = await botAuth.CreateOAuthCard(turnContext);

// Assert
var card = result.Content as OAuthCard;
Assert.NotNull(card);
Assert.Equal(card.Text, authSettings.Text);
Assert.Equal(card.ConnectionName, authSettings.ConnectionName);
Assert.Equal(card.Buttons[0].Title, authSettings.Title);
Assert.Equal(card.Buttons[0].Text, authSettings.Text);
Assert.Equal(card.Buttons[0].Type, "signin");
Assert.Equal(card.Buttons[0].Value, "signInLink");
Assert.NotNull(card.TokenExchangeResource);
Assert.Equal(card.TokenExchangeResource.Id, "id");
Assert.Equal(card.TokenExchangeResource.Uri, "uri");
Assert.NotNull(card.TokenPostResource);
Assert.Equal(card.TokenPostResource.SasUrl, "sasUrl");
}

[Fact]
public async void Test_CreateOAuthCard_WithoutSSO()
{
// Arrange
IActivity sentActivity;
var testAdapter = new SimpleAdapter((activity) => sentActivity = activity);
var turnContext = TurnStateConfig.CreateConfiguredTurnContext();

var turnState = await TurnStateConfig.GetTurnStateWithConversationStateAsync(turnContext);
var app = new TestApplication(new() { Adapter = testAdapter });
var authSettings = new OAuthSettings()
{
ConnectionName = "connectionName",
Title = "title",
Text = "text",
EnableSso = false
};

var botAuth = new TestOAuthBotAuthentication(app, authSettings, "connectionName");

// Act
var result = await botAuth.CreateOAuthCard(turnContext);

// Assert
var card = result.Content as OAuthCard;
Assert.NotNull(card);
Assert.Equal(card.Text, authSettings.Text);
Assert.Equal(card.ConnectionName, authSettings.ConnectionName);
Assert.Equal(card.Buttons[0].Title, authSettings.Title);
Assert.Equal(card.Buttons[0].Text, authSettings.Text);
Assert.Equal(card.Buttons[0].Type, "signin");
Assert.Equal(card.Buttons[0].Value, "signInLink");
Assert.Null(card.TokenExchangeResource);
Assert.NotNull(card.TokenPostResource);
Assert.Equal(card.TokenPostResource.SasUrl, "sasUrl");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ public override Task<TokenResponse> HandleSsoTokenExchange(ITurnContext context)
}
return Task.FromResult(_tokenExchangeResponse);
}

public override bool IsSsoSignIn(ITurnContext context)
{
return context.Activity.Name == MessageExtensionsInvokeNames.QUERY_INVOKE_NAME;
}
}

internal sealed class TokenExchangeRequest
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Microsoft.Bot.Builder;
using Microsoft.Bot.Schema;
using Microsoft.Teams.AI.Tests.TestUtils;

namespace Microsoft.Teams.AI.Tests.Application.Authentication.MessageExtensions
{
public class OAuthMessageExtensionsTests
{
[Fact]
public void Test_isSsoSignIn_False()
{
// Arrange
var turnContext = new TurnContext(new NotImplementedAdapter(), new Activity()
{
Type = ActivityTypes.Invoke,
Name = "composeExtension/query",
});
var oauthSettings = new OAuthSettings() { EnableSso = false };
var messageExtensionsAuth = new OAuthMessageExtensionsAuthentication(oauthSettings);

// Act
var result = messageExtensionsAuth.IsSsoSignIn(turnContext);

// Assert
Assert.False(result);
}

[Fact]
public void Test_isSsoSignIn_True()
{
// Arrange
var turnContext = new TurnContext(new NotImplementedAdapter(), new Activity()
{
Type = ActivityTypes.Invoke,
Name = "composeExtension/query",
});
var oauthSettings = new OAuthSettings() { EnableSso = true };
var messageExtensionsAuth = new OAuthMessageExtensionsAuthentication(oauthSettings);

// Act
var result = messageExtensionsAuth.IsSsoSignIn(turnContext);

// Assert
Assert.True(result);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using Microsoft.Bot.Builder;
using Microsoft.Bot.Schema;
using Microsoft.Teams.AI.State;
using Microsoft.Teams.AI.Tests.TestUtils;

namespace Microsoft.Teams.AI.Tests.Application.Authentication
{
public class OAuthAuthenticationTests
{
[Fact]
public async void Test_IsUserSignedIn_ReturnsTokenString()
{
// Arrange
var turnContext = TurnStateConfig.CreateConfiguredTurnContext();
var oauthSettings = new OAuthSettings() { ConnectionName = "connectionName" };
var app = new TestApplication(new()
{
Adapter = new SimpleAdapter()
});
var tokenResponse = new TokenResponse()
{
Token = "validToken",
Expiration = "validExpiration",
ConnectionName = "connectionName",
};
var auth = new TestOAuthAuthentication(tokenResponse, app, "name", oauthSettings, null);


// Act
var result = await auth.IsUserSignedInAsync(turnContext);

// Assert
Assert.NotNull(result);
Assert.True(result == "validToken");
}

[Fact]
public async void Test_IsUserSignedIn_ReturnsNull()
{
// Arrange
var turnContext = TurnStateConfig.CreateConfiguredTurnContext();
var oauthSettings = new OAuthSettings() { ConnectionName = "connectionName" };
var app = new TestApplication(new()
{
Adapter = new SimpleAdapter()
});
var tokenResponse = new TokenResponse()
{
Token = "", // Empty token
Expiration = "",
ConnectionName = "connectionName",
};
var auth = new TestOAuthAuthentication(tokenResponse, app, "name", oauthSettings, null);


// Act
var result = await auth.IsUserSignedInAsync(turnContext);

// Assert
Assert.Null(result);
}
}

public class TestOAuthAuthentication : OAuthAuthentication<TurnState>
{
private TokenResponse _tokenResponse;

internal TestOAuthAuthentication(TokenResponse tokenResponse, Application<TurnState> app, string name, OAuthSettings settings, IStorage? storage) : base(settings, new OAuthMessageExtensionsAuthentication(settings), new OAuthBotAuthentication<TurnState>(app, settings, name, storage))
{
_tokenResponse = tokenResponse;
}

internal TestOAuthAuthentication(TokenResponse tokenResponse, OAuthSettings settings, OAuthMessageExtensionsAuthentication messageExtensionAuth, OAuthBotAuthentication<TurnState> botAuthentication) : base(settings, messageExtensionAuth, botAuthentication)
{
_tokenResponse = tokenResponse;
}

protected override Task<TokenResponse> GetUserToken(ITurnContext context, string connectionName, CancellationToken cancellationToken = default)
{
return Task.FromResult(_tokenResponse);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;
using Microsoft.Teams.AI.Application.Authentication.Bot;
using Microsoft.Teams.AI.State;

Expand All @@ -11,19 +12,22 @@ namespace Microsoft.Teams.AI
internal class OAuthBotAuthentication<TState> : BotAuthenticationBase<TState>
where TState : TurnState, new()
{
private OAuthPrompt _oauthPrompt;
private readonly OAuthPrompt _oauthPrompt;
private readonly OAuthSettings _oauthSettings;

/// <summary>
/// Initializes the class
/// </summary>
/// <param name="app">The application instance</param>
/// <param name="oauthPromptSettings">The OAuth prompt settings</param>
/// <param name="oauthSettings">The OAuth prompt settings</param>
/// <param name="settingName">The name of current authentication handler</param>
/// <param name="storage">The storage to save turn state</param>
public OAuthBotAuthentication(Application<TState> app, OAuthPromptSettings oauthPromptSettings, string settingName, IStorage? storage = null) : base(app, settingName, storage)
public OAuthBotAuthentication(Application<TState> app, OAuthSettings oauthSettings, string settingName, IStorage? storage = null) : base(app, settingName, storage)
{
this._oauthSettings = oauthSettings;

// Create OAuthPrompt
this._oauthPrompt = new OAuthPrompt("OAuthPrompt", oauthPromptSettings);
this._oauthPrompt = new OAuthPrompt("OAuthPrompt", this._oauthSettings);

// Handles deduplication of token exchange event when using SSO with Bot Authentication
app.Adapter.Use(new FilteredTeamsSSOTokenExchangeMiddleware(storage ?? new MemoryStorage(), settingName));
Expand Down Expand Up @@ -57,7 +61,14 @@ public override async Task<DialogTurnResult> RunDialog(ITurnContext context, TSt
DialogTurnResult results = await dialogContext.ContinueDialogAsync(cancellationToken);
if (results.Status == DialogTurnStatus.Empty)
{
results = await dialogContext.BeginDialogAsync(this._oauthPrompt.Id, null, cancellationToken);
Attachment card = await this.CreateOAuthCard(context, cancellationToken);
Activity messageActivity = (Activity)MessageFactory.Attachment(card);
PromptOptions options = new()
{
Prompt = messageActivity,
};

results = await dialogContext.BeginDialogAsync(this._oauthPrompt.Id, options, cancellationToken);
}
return results;
}
Expand All @@ -66,8 +77,47 @@ private async Task<DialogContext> CreateDialogContextAsync(ITurnContext context,
{
IStatePropertyAccessor<DialogState> accessor = new TurnStateProperty<DialogState>(state, "conversation", dialogStateProperty);
DialogSet dialogSet = new(accessor);
dialogSet.Add(_oauthPrompt);
dialogSet.Add(this._oauthPrompt);
return await dialogSet.CreateContextAsync(context, cancellationToken);
}

public async Task<Attachment> CreateOAuthCard(ITurnContext context, CancellationToken cancellationToken = default)
{
SignInResource signInResource = await GetSignInResourceAsync(context, this._oauthSettings.ConnectionName, cancellationToken);
string? link = signInResource.SignInLink;
TokenExchangeResource? tokenExchangeResource = null;

if (this._oauthSettings.EnableSso == true)
{
tokenExchangeResource = signInResource.TokenExchangeResource;
}

return new Attachment
{
ContentType = OAuthCard.ContentType,
Content = new OAuthCard
{
Text = this._oauthSettings.Text,
ConnectionName = this._oauthSettings.ConnectionName,
Buttons = new[]
{
new CardAction
{
Title = this._oauthSettings.Title,
Text = this._oauthSettings.Text,
Type = "signin",
Value = link
},
},
TokenExchangeResource = tokenExchangeResource,
TokenPostResource = signInResource.TokenPostResource
},
};
}

protected async virtual Task<SignInResource> GetSignInResourceAsync(ITurnContext context, string connectionName, CancellationToken cancellationToken = default)
{
return await UserTokenClientWrapper.GetSignInResourceAsync(context, this._oauthSettings.ConnectionName, cancellationToken);
}
}
}
Loading

0 comments on commit 09ebba1

Please sign in to comment.