Skip to content

Commit 0b6fd53

Browse files
committed
[C#] feat: Implement GetTokenOrStartSignInAsync method public interface (#987)
## Linked issues closes: #894 (issue number) ## Details - Rename `TeamsAIAuthException` to `AuthException` - Rename `TeamsAIAuthReason` to `AuthExceptionReason` - Implement helper methods in `AuthUtilities` - Implement `GetTokenOrStartSignInAsync`. - Add `IsUserSignedIn` to `IAuthentication` interface - this is needed so it can be called in the `Application` class. - Make `AuthenticationManager._default` private property public `Default`. ## 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 (we use [TypeDoc](https://typedoc.org/) to document our code) - 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
1 parent a3ed86f commit 0b6fd53

File tree

16 files changed

+358
-69
lines changed

16 files changed

+358
-69
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using Microsoft.Bot.Builder;
2+
using Microsoft.Teams.AI.State;
3+
using Microsoft.Teams.AI.Tests.TestUtils;
4+
5+
namespace Microsoft.Teams.AI.Tests.Application.Authentication
6+
{
7+
public class AuthUtilitiesTest
8+
{
9+
[Fact]
10+
public async void Test_SetTokenInState()
11+
{
12+
// Arrange
13+
TurnContext context = TurnStateConfig.CreateConfiguredTurnContext();
14+
TurnState state = await TurnStateConfig.GetTurnStateWithConversationStateAsync(context);
15+
string settingName = "settingName";
16+
string token = "token";
17+
18+
// Act
19+
AuthUtilities.SetTokenInState(state, settingName, token);
20+
21+
// Assert
22+
Assert.True(state.Temp.AuthTokens.ContainsKey(settingName));
23+
}
24+
25+
[Fact]
26+
public async void Test_DeleteTokenFromState()
27+
{
28+
// Arrange
29+
TurnContext context = TurnStateConfig.CreateConfiguredTurnContext();
30+
TurnState state = await TurnStateConfig.GetTurnStateWithConversationStateAsync(context);
31+
string settingName = "settingName";
32+
string token = "token";
33+
34+
// Act
35+
state.Temp.AuthTokens[settingName] = token;
36+
AuthUtilities.DeleteTokenFromState(state, settingName);
37+
38+
// Assert
39+
Assert.False(state.Temp.AuthTokens.ContainsKey(settingName));
40+
}
41+
42+
[Fact]
43+
public async void Test_UserInSignInFlow()
44+
{
45+
// Arrange
46+
TurnContext context = TurnStateConfig.CreateConfiguredTurnContext();
47+
TurnState state = await TurnStateConfig.GetTurnStateWithConversationStateAsync(context);
48+
string settingName = "settingName";
49+
50+
// Act
51+
state.User.Set(AuthUtilities.IS_SIGNED_IN_KEY, settingName);
52+
string? response = AuthUtilities.UserInSignInFlow(state);
53+
54+
// Assert
55+
Assert.True(response == settingName);
56+
57+
}
58+
59+
[Fact]
60+
public async void Test_SetUserInSignInFlow()
61+
{
62+
// Arrange
63+
TurnContext context = TurnStateConfig.CreateConfiguredTurnContext();
64+
TurnState state = await TurnStateConfig.GetTurnStateWithConversationStateAsync(context);
65+
string settingName = "settingName";
66+
67+
// Act
68+
AuthUtilities.SetUserInSignInFlow(state, settingName);
69+
70+
// Assert
71+
Assert.True(state.User.Get<string>(AuthUtilities.IS_SIGNED_IN_KEY) == settingName);
72+
}
73+
74+
[Fact]
75+
public async void Test_DeleteUserInSignInFlow()
76+
{
77+
// Arrange
78+
TurnContext context = TurnStateConfig.CreateConfiguredTurnContext();
79+
TurnState state = await TurnStateConfig.GetTurnStateWithConversationStateAsync(context);
80+
string settingName = "settingName";
81+
82+
// Act
83+
state.User.Set(AuthUtilities.IS_SIGNED_IN_KEY, settingName);
84+
AuthUtilities.DeleteUserInSignInFlow(state);
85+
86+
// Assert
87+
Assert.False(state.User.ContainsKey(AuthUtilities.IS_SIGNED_IN_KEY));
88+
}
89+
}
90+
}

dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/Bot/BotAuthenticationBaseTests.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public override Task<DialogTurnResult> ContinueDialog(ITurnContext context, TSta
2525
{
2626
if (_throwExceptionWhenContinue)
2727
{
28-
throw new TeamsAIAuthException("mocked error");
28+
throw new AuthException("mocked error");
2929
}
3030
return Task.FromResult(_continueDialogResult);
3131
}
@@ -161,7 +161,7 @@ public async void Test_HandleSignInActivity_Complete()
161161
messageText = context.Activity.Text;
162162
return Task.CompletedTask;
163163
});
164-
botAuth.OnUserSignInFailure((context, state, exception) => { throw new TeamsAIAuthException("sign in failure handler should not be called"); });
164+
botAuth.OnUserSignInFailure((context, state, exception) => { throw new AuthException("sign in failure handler should not be called"); });
165165

166166
// act
167167
await botAuth.HandleSignInActivity(context, state, new CancellationToken());
@@ -179,8 +179,8 @@ public async void Test_HandleSignInActivity_CompleteWithoutToken()
179179
var botAuth = new MockedBotAuthentication<TurnState>(app, "test", continueDialogResult: new DialogTurnResult(DialogTurnStatus.Complete));
180180
var context = MockTurnContext();
181181
var state = await TurnStateConfig.GetTurnStateWithConversationStateAsync(context);
182-
TeamsAIAuthException? authException = null;
183-
botAuth.OnUserSignInSuccess((context, state) => { throw new TeamsAIAuthException("sign in success handler should not be called"); });
182+
AuthException? authException = null;
183+
botAuth.OnUserSignInSuccess((context, state) => { throw new AuthException("sign in success handler should not be called"); });
184184
botAuth.OnUserSignInFailure((context, state, exception) => { authException = exception; return Task.CompletedTask; });
185185

186186
// act
@@ -199,8 +199,8 @@ public async void Test_HandleSignInActivity_ThrowException()
199199
var botAuth = new MockedBotAuthentication<TurnState>(app, "test", throwExceptionWhenContinue: true);
200200
var context = MockTurnContext();
201201
var state = await TurnStateConfig.GetTurnStateWithConversationStateAsync(context);
202-
TeamsAIAuthException? authException = null;
203-
botAuth.OnUserSignInSuccess((context, state) => { throw new TeamsAIAuthException("sign in success handler should not be called"); });
202+
AuthException? authException = null;
203+
botAuth.OnUserSignInSuccess((context, state) => { throw new AuthException("sign in success handler should not be called"); });
204204
botAuth.OnUserSignInFailure((context, state, exception) => { authException = exception; return Task.CompletedTask; });
205205

206206
// act

dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/MessageExtensions/MessageExtensionsAuthenticationBaseTests.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
namespace Microsoft.Teams.AI.Tests.Application.Authentication.MessageExtensions
99
{
10-
internal class MockedMessageExtensionsAuthentication : MessageExtensionsAuthenticationBase
10+
internal sealed class MockedMessageExtensionsAuthentication : MessageExtensionsAuthenticationBase
1111
{
1212
private TokenResponse? _tokenExchangeResponse;
1313
private TokenResponse? _signInResponse;
@@ -27,7 +27,7 @@ public override Task<TokenResponse> HandleUserSignIn(ITurnContext context, strin
2727
{
2828
if (_signInResponse == null)
2929
{
30-
throw new TeamsAIAuthException("HandlerUserSignIn failed");
30+
throw new AuthException("HandlerUserSignIn failed");
3131
}
3232
return Task.FromResult(_signInResponse);
3333
}
@@ -36,18 +36,18 @@ public override Task<TokenResponse> HandleSsoTokenExchange(ITurnContext context)
3636
{
3737
if (_tokenExchangeResponse == null)
3838
{
39-
throw new TeamsAIAuthException("HandleSsoTokenExchange failed");
39+
throw new AuthException("HandleSsoTokenExchange failed");
4040
}
4141
return Task.FromResult(_tokenExchangeResponse);
4242
}
4343
}
4444

45-
internal class TokenExchangeRequest
45+
internal sealed class TokenExchangeRequest
4646
{
4747
public Authentication? authentication { get; set; }
4848
}
4949

50-
internal class Authentication
50+
internal sealed class Authentication
5151
{
5252
public string? token { get; set; }
5353
}

dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/MockedAuthentication.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,17 @@ public void Initialize(Application<TState> app, string name, IStorage? storage =
2323
return;
2424
}
2525

26+
public Task<string?> IsUserSignedInAsync(ITurnContext turnContext, CancellationToken cancellationToken = default)
27+
{
28+
return Task.FromResult<string?>(null);
29+
}
30+
2631
public Task<bool> IsValidActivityAsync(ITurnContext context)
2732
{
2833
return Task.FromResult(_validActivity);
2934
}
3035

31-
public IAuthentication<TState> OnUserSignInFailure(Func<ITurnContext, TState, TeamsAIAuthException, Task> handler)
36+
public IAuthentication<TState> OnUserSignInFailure(Func<ITurnContext, TState, AuthException, Task> handler)
3237
{
3338
return this;
3439
}

dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI/Application/Application.cs

Lines changed: 103 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Microsoft.Bot.Schema;
55
using Microsoft.Bot.Schema.Teams;
66
using Microsoft.Teams.AI.AI;
7+
using Microsoft.Teams.AI.Exceptions;
78
using Microsoft.Teams.AI.State;
89
using Microsoft.Teams.AI.Utilities;
910
using System.Collections.Concurrent;
@@ -30,6 +31,8 @@ public class Application<TState> : IBot
3031
private static readonly string CONFIG_SUBMIT_INVOKE_NAME = "config/submit";
3132

3233
private readonly AI<TState>? _ai;
34+
private readonly AuthenticationManager<TState>? _authentication;
35+
3336
private readonly int _typingTimerDelay = 1000;
3437
private TypingTimer? _typingTimer;
3538

@@ -85,7 +88,7 @@ public Application(ApplicationOptions<TState> options)
8588
pair.Value.Initialize(this, pair.Key, options.Storage);
8689
}
8790

88-
Authentication = new AuthenticationManager<TState>(options.Authentication);
91+
_authentication = new AuthenticationManager<TState>(options.Authentication);
8992
if (options.Authentication.AutoSignIn != null)
9093
{
9194
_startSignIn = options.Authentication.AutoSignIn;
@@ -120,7 +123,19 @@ public Application(ApplicationOptions<TState> options)
120123
/// <summary>
121124
/// Accessing authentication specific features.
122125
/// </summary>
123-
public AuthenticationManager<TState>? Authentication { get; }
126+
public AuthenticationManager<TState> Authentication
127+
{
128+
129+
get
130+
{
131+
if (_authentication == null)
132+
{
133+
throw new ArgumentException("The Application.Authentication property is unavailable because no authentication options were configured.");
134+
}
135+
136+
return _authentication;
137+
}
138+
}
124139

125140
/// <summary>
126141
/// Fluent interface for accessing AI specific features.
@@ -842,21 +857,36 @@ private async Task _OnTurnAsync(ITurnContext turnContext, CancellationToken canc
842857

843858
await turnState!.LoadStateAsync(storage, turnContext);
844859

860+
// If user is in sign in flow, return the authentication setting name
861+
string? settingName = AuthUtilities.UserInSignInFlow(turnState);
862+
bool shouldStartSignIn = _startSignIn != null && await _startSignIn(turnContext, cancellationToken);
863+
845864
// Sign the user in
846-
if (Authentication != null && _startSignIn != null && await _startSignIn(turnContext, cancellationToken))
865+
if (this._authentication != null && (shouldStartSignIn || settingName != null))
847866
{
848-
// Should skip activity that does not support sign-in
849-
if (await Authentication.IsValidActivityAsync(turnContext))
867+
if (settingName == null)
850868
{
851-
SignInResponse response = await Authentication.SignUserInAsync(turnContext, turnState);
852-
if (response.Status == SignInStatus.Pending)
853-
{
854-
// Requires user action, save state and stop processing current activity
855-
await turnState.SaveStateAsync(turnContext, storage);
856-
return;
857-
}
869+
settingName = this._authentication.Default;
870+
}
871+
872+
SignInResponse response = await this._authentication.SignUserInAsync(turnContext, turnState, settingName);
873+
874+
if (response.Status == SignInStatus.Complete)
875+
{
876+
AuthUtilities.DeleteUserInSignInFlow(turnState);
877+
}
878+
879+
if (response.Status == SignInStatus.Pending)
880+
{
881+
// Requires user action, save state and stop processing current activity
882+
await turnState.SaveStateAsync(turnContext, storage);
883+
return;
884+
}
858885

859-
// Sign-in success, continue processing current activity
886+
if (response.Status == SignInStatus.Error && response.Cause != AuthExceptionReason.InvalidActivity)
887+
{
888+
AuthUtilities.DeleteUserInSignInFlow(turnState);
889+
throw new TeamsAIException("An error occured when trying to sign in.", response.Error!);
860890
}
861891
}
862892

@@ -929,6 +959,66 @@ private async Task _OnTurnAsync(ITurnContext turnContext, CancellationToken canc
929959
}
930960
}
931961

962+
/// <summary>
963+
/// If the user is signed in, get the access token. If not, triggers the sign in flow for the provided authentication setting name
964+
/// and returns.In this case, the bot should end the turn until the sign in flow is completed.
965+
/// </summary>
966+
/// <remarks>
967+
/// Use this method to get the access token for a user that is signed in to the bot.
968+
/// If the user isn't signed in, this method starts the sign-in flow.
969+
/// The bot should end the turn in this case until the sign-in flow completes and the user is signed in.
970+
/// </remarks>
971+
/// <param name="turnContext"> The turn context.</param>
972+
/// <param name="turnState">The turn state.</param>
973+
/// <param name="settingName">The name of the authentication setting.</param>
974+
/// <param name="cancellationToken">The cancellation token.</param>
975+
/// <returns>The access token for the user if they are signed, otherwise null.</returns>
976+
/// <exception cref="TeamsAIException"></exception>
977+
public async Task<string?> GetTokenOrStartSignInAsync(ITurnContext turnContext, TState turnState, string settingName, CancellationToken cancellationToken = default)
978+
{
979+
string? token = await Authentication.Get(settingName).IsUserSignedInAsync(turnContext, cancellationToken);
980+
981+
if (token != null)
982+
{
983+
AuthUtilities.SetTokenInState(turnState, settingName, token);
984+
AuthUtilities.DeleteUserInSignInFlow(turnState);
985+
return token;
986+
}
987+
988+
// User is currently not in sign in flow
989+
if (AuthUtilities.UserInSignInFlow(turnState) == null)
990+
{
991+
AuthUtilities.SetUserInSignInFlow(turnState, settingName);
992+
}
993+
else
994+
{
995+
AuthUtilities.DeleteUserInSignInFlow(turnState);
996+
throw new TeamsAIException("Invalid sign in flow state. Cannot start sign in when already started");
997+
}
998+
999+
SignInResponse response = await Authentication.SignUserInAsync(turnContext, turnState, settingName);
1000+
1001+
if (response.Status == SignInStatus.Error)
1002+
{
1003+
string message = response.Error!.ToString();
1004+
if (response.Cause == AuthExceptionReason.InvalidActivity)
1005+
{
1006+
message = $"User is not signed in and cannot start sign in flow for this activity: {response.Error}";
1007+
}
1008+
1009+
throw new TeamsAIException($"Error occured while trying to authenticate user: {message}");
1010+
}
1011+
1012+
if (response.Status == SignInStatus.Complete)
1013+
{
1014+
AuthUtilities.DeleteUserInSignInFlow(turnState);
1015+
return turnState.Temp.AuthTokens[settingName];
1016+
}
1017+
1018+
// response.Status == SignInStatus.Pending
1019+
return null;
1020+
}
1021+
9321022
/// <summary>
9331023
/// Convert original handler to proactive conversation.
9341024
/// </summary>

0 commit comments

Comments
 (0)