Skip to content

Commit 4371da3

Browse files
authored
Incorporating Jenny's fixes for the OBO flow (#98)
* Incorporating Jenny's fixes for the OBO flow so that the token cache is keyed by the acccess token in the case of a Web API. Removes a lot of code, uses the recommended patterns Also some renaming to clarify * Addressing PR feedback: - from Kalyan/Tiago: renaming the Item used by StoreTokenUsedToCallWebAPI and GetTokenUsedToCallWebAPI from "token" to "JwtSecurityTokenUsedToCallWebAPI" - from Mark: getting rid of the SetTokenCacheKey/GetUserTokenCacheKey. - simplifying TokenAcquisition.GetAccessTokenOnBehalfOfUserAsync Improving the comments on AcquireToken.GetAccessTokenOnBehalfOfUserAsync * Adressing PR comment * Fixing a typo
1 parent a1be244 commit 4371da3

File tree

5 files changed

+80
-208
lines changed

5 files changed

+80
-208
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using Microsoft.AspNetCore.Http;
2+
using System.IdentityModel.Tokens.Jwt;
3+
4+
namespace Microsoft.Identity.Web
5+
{
6+
public static class HttpContextExtensions
7+
{
8+
/// <summary>
9+
/// Keep the validated token associated with the Http request
10+
/// </summary>
11+
/// <param name="httpContext">Http context</param>
12+
/// <param name="token">Token to preserve after the token is validated so that
13+
/// it can be used in the actions</param>
14+
public static void StoreTokenUsedToCallWebAPI(this HttpContext httpContext, JwtSecurityToken token)
15+
{
16+
httpContext.Items.Add("JwtSecurityTokenUsedToCallWebAPI", token);
17+
}
18+
19+
/// <summary>
20+
/// Get the parsed information about the token used to call the Web API
21+
/// </summary>
22+
/// <param name="httpContext">Http context associated with the current request</param>
23+
/// <returns><see cref="JwtSecurityToken"/> used to call the Web API</returns>
24+
public static JwtSecurityToken GetTokenUsedToCallWebAPI(this HttpContext httpContext)
25+
{
26+
return httpContext.Items["JwtSecurityTokenUsedToCallWebAPI"] as JwtSecurityToken;
27+
}
28+
}
29+
}

Microsoft.Identity.Web/ITokenAcquisition.cs

Lines changed: 0 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -56,63 +56,6 @@ public interface ITokenAcquisition
5656
/// <returns>An access token to call on behalf of the user, the downstream API characterized by its scopes</returns>
5757
Task<string> GetAccessTokenOnBehalfOfUserAsync(IEnumerable<string> scopes, string tenantId = null);
5858

59-
/// <summary>
60-
/// In a Web API, adds to the MSAL.NET cache, the account of the user for which a bearer token was received when the Web API was called.
61-
/// An access token and a refresh token are added to the cache, so that they can then be used to acquire another token on-behalf-of the
62-
/// same user in order to call to downstream APIs.
63-
/// </summary>
64-
/// <param name="tokenValidationContext">Token validation context passed to the handler of the OnTokenValidated event
65-
/// for the JwtBearer middleware</param>
66-
/// <param name="scopes">[Optional] scopes to pre-request for a downstream API</param>
67-
/// <example>
68-
/// From the configuration of the Authentication of the ASP.NET Core Web API (for example in the Startup.cs file)
69-
/// <code>JwtBearerOptions option;</code>
70-
///
71-
/// Subscribe to the token validated event:
72-
/// <code>
73-
/// options.Events = new JwtBearerEvents();
74-
/// options.Events.OnTokenValidated = async context =>
75-
/// {
76-
/// var tokenAcquisition = context.HttpContext.RequestServices.GetRequiredService&lt;ITokenAcquisition&gt;();
77-
/// tokenAcquisition.AddAccountToCacheFromJwt(context);
78-
/// };
79-
/// </code>
80-
/// </example>
81-
Task AddAccountToCacheFromJwtAsync(
82-
AspNetCore.Authentication.JwtBearer.TokenValidatedContext tokenValidationContext,
83-
IEnumerable<string> scopes = null);
84-
85-
/// <summary>
86-
/// [not recommended] In a Web App, adds, to the MSAL.NET cache, the account of the user authenticating to the Web App.
87-
/// An On-behalf-of token is added to the cache, so that it can then be used to acquire another token on-behalf-of the
88-
/// same user in order for the Web App to call a Web APIs.
89-
/// </summary>
90-
/// <param name="tokenValidationContext">Token validation context passed to the handler of the OnTokenValidated event
91-
/// for the OpenIdConnect middleware</param>
92-
/// <param name="scopes">[Optional] scopes to pre-request for a downstream API</param>
93-
/// <remarks>In a Web App, it's preferable to not request an access token, but only a code, and use the <see cref="AddAccountToCacheFromAuthorizationCodeAsync"/></remarks>
94-
/// <example>
95-
/// From the configuration of the Authentication of the ASP.NET Core Web API:
96-
/// <code>OpenIdConnectOptions options;</code>
97-
///
98-
/// Subscribe to the token validated event:
99-
/// <code>
100-
/// options.Events.OnAuthorizationCodeReceived = OnTokenValidated;
101-
/// </code>
102-
///
103-
/// And then in the OnTokenValidated method, call <see cref="AddAccountToCacheFromJwt(OpenIdConnect.TokenValidatedContext)"/>:
104-
/// <code>
105-
/// private async Task OnTokenValidated(TokenValidatedContext context)
106-
/// {
107-
/// var tokenAcquisition = context.HttpContext.RequestServices.GetRequiredService&lt;ITokenAcquisition&gt;();
108-
/// _tokenAcquisition.AddAccountToCache(tokenValidationContext);
109-
/// }
110-
/// </code>
111-
/// </example>
112-
Task AddAccountToCacheFromJwtAsync(
113-
TokenValidatedContext tokenValidationContext,
114-
IEnumerable<string> scopes = null);
115-
11659
/// <summary>
11760
/// Removes the account associated with context.HttpContext.User from the MSAL.NET cache
11861
/// </summary>

Microsoft.Identity.Web/TokenAcquisition.cs

Lines changed: 42 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,21 @@ public async Task AddAccountToCacheFromAuthorizationCodeAsync(AuthorizationCodeR
146146

147147
/// <summary>
148148
/// Typically used from a Web App or WebAPI controller, this method retrieves an access token
149-
/// for a downstream API using the <a href='https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow'>on-behalf-of flow</a>
150-
/// for the user account that is ascertained from claims are provided in the <see cref="HttpContext.User"/> instance of the <paramref name="context"/> parameter
149+
/// for a downstream API using;
150+
/// 1) the token cache (for Web Apps and Web APis) if a token exists in the cache
151+
/// 2) or the <a href='https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow'>on-behalf-of flow</a>
152+
/// in Web APIs, for the user account that is ascertained from claims are provided in the <see cref="HttpContext.User"/>
153+
/// instance of the current HttpContext
151154
/// </summary>
152155
/// <param name="scopes">Scopes to request for the downstream API to call</param>
153156
/// <param name="tenant">Enables overriding of the tenant/account for the same identity. This is useful in the
154157
/// cases where a given account is guest in other tenants, and you want to acquire tokens for a specific tenant, like where the user is a guest in</param>
155158
/// <returns>An access token to call the downstream API and populated with this downstream Api's scopes</returns>
159+
/// <remarks>Calling this method from a Web API supposes that you have previously called,
160+
/// in a method called by JwtBearerOptions.Events.OnTokenValidated, the HttpContextExtensions.StoreTokenUsedToCallWebAPI method
161+
/// passing the validated token (as a JwtSecurityToken). Calling it from a Web App supposes that
162+
/// you have previously called AddAccountToCacheFromAuthorizationCodeAsync from a method called by
163+
/// OpenIdConnectOptions.Events.OnAuthorizationCodeReceived</remarks>
156164
public async Task<string> GetAccessTokenOnBehalfOfUserAsync(
157165
IEnumerable<string> scopes,
158166
string tenant = null)
@@ -162,103 +170,44 @@ public async Task<string> GetAccessTokenOnBehalfOfUserAsync(
162170
throw new ArgumentNullException(nameof(scopes));
163171
}
164172

165-
// Use MSAL to get the right token to call the API
166173
var application = GetOrBuildConfidentialClientApplication();
174+
string accessToken;
167175

168-
// Case of a lazy OBO
169-
Claim jwtClaim = CurrentHttpContext.User.FindFirst("jwt");
170-
if (jwtClaim != null)
176+
try
171177
{
172-
(CurrentHttpContext.User.Identity as ClaimsIdentity).RemoveClaim(jwtClaim);
173-
var result = await application
174-
.AcquireTokenOnBehalfOf(scopes.Except(_scopesRequestedByMsalNet), new UserAssertion(jwtClaim.Value))
175-
.ExecuteAsync()
178+
accessToken = await GetAccessTokenOnBehalfOfUserFromCacheAsync(application, CurrentHttpContext.User, scopes, tenant)
176179
.ConfigureAwait(false);
177-
return result.AccessToken;
178180
}
179-
else
181+
catch(MsalUiRequiredException ex)
180182
{
181-
return await GetAccessTokenOnBehalfOfUserAsync(application, CurrentHttpContext.User, scopes, tenant).ConfigureAwait(false);
182-
}
183-
}
183+
// GetAccessTokenOnBehalfOfUserAsync is an abstraction that can be called from a Web App or a Web API
184+
// to get a token for a Web API on behalf of the user, but not necessarily with the on behalf of OAuth2.0
185+
// flow as this one only applies to Web APIs.
186+
JwtSecurityToken validatedToken = CurrentHttpContext.GetTokenUsedToCallWebAPI();
184187

185-
/// <summary>
186-
/// In a Web API, adds to the MSAL.NET cache, the account of the user for which a bearer token was received when the Web API was called.
187-
/// An access token and a refresh token are added to the cache, so that they can then be used to acquire another token on-behalf-of the
188-
/// same user in order to call to downstream APIs.
189-
/// </summary>
190-
/// <param name="tokenValidatedContext">Token validation context passed to the handler of the OnTokenValidated event
191-
/// for the JwtBearer middleware</param>
192-
/// <param name="scopes">[Optional] scopes to pre-request for a downstream API</param>
193-
/// <example>
194-
/// From the configuration of the Authentication of the ASP.NET Core Web API (for example in the Startup.cs file)
195-
/// <code>JwtBearerOptions option;</code>
196-
///
197-
/// Subscribe to the token validated event:
198-
/// <code>
199-
/// options.Events = new JwtBearerEvents();
200-
/// options.Events.OnTokenValidated = async context =>
201-
/// {
202-
/// var tokenAcquisition = context.HttpContext.RequestServices.GetRequiredService&lt;ITokenAcquisition&gt;();
203-
/// tokenAcquisition.AddAccountToCacheFromJwt(context);
204-
/// };
205-
/// </code>
206-
/// </example>
207-
public Task AddAccountToCacheFromJwtAsync(
208-
Microsoft.AspNetCore.Authentication.JwtBearer.TokenValidatedContext tokenValidatedContext,
209-
IEnumerable<string> scopes)
210-
{
211-
if (tokenValidatedContext == null)
212-
{
213-
throw new ArgumentNullException(nameof(tokenValidatedContext));
214-
}
215-
216-
return AddAccountToCacheFromJwtAsync(
217-
scopes,
218-
tokenValidatedContext.SecurityToken as JwtSecurityToken,
219-
tokenValidatedContext.Principal);
220-
}
188+
// Case of Web APIs: we need to do an on-behalf-of flow
189+
if (validatedToken != null)
190+
{
191+
// In the case the token is a JWE (encrypted token), we use the decrypted token.
192+
string tokenUsedToCallTheWebApi = validatedToken.InnerToken == null ? validatedToken.RawData
193+
: validatedToken.InnerToken.RawData;
194+
var result = await application
195+
.AcquireTokenOnBehalfOf(scopes.Except(_scopesRequestedByMsalNet),
196+
new UserAssertion(tokenUsedToCallTheWebApi))
197+
.ExecuteAsync()
198+
.ConfigureAwait(false);
199+
accessToken = result.AccessToken;
200+
}
221201

222-
/// <summary>
223-
/// [not recommended] In a Web App, adds, to the MSAL.NET cache, the account of the user authenticating to the Web App.
224-
/// An On-behalf-of token is added to the cache, so that it can then be used to acquire another token on-behalf-of the
225-
/// same user in order for the Web App to call a Web APIs.
226-
/// </summary>
227-
/// <param name="tokenValidatedContext">Token validation context passed to the handler of the OnTokenValidated event
228-
/// for the OpenIdConnect middleware</param>
229-
/// <param name="scopes">[Optional] scopes to pre-request for a downstream API</param>
230-
/// <remarks>In a Web App, it's preferable to not request an access token, but only a code, and use the <see cref="AddAccountToCacheFromAuthorizationCodeAsync"/></remarks>
231-
/// <example>
232-
/// From the configuration of the Authentication of the ASP.NET Core Web API:
233-
/// <code>OpenIdConnectOptions options;</code>
234-
///
235-
/// Subscribe to the token validated event:
236-
/// <code>
237-
/// options.Events.OnAuthorizationCodeReceived = OnTokenValidated;
238-
/// </code>
239-
///
240-
/// And then in the OnTokenValidated method, call <see cref="AddAccountToCacheFromJwtAsync(OpenIdConnect.TokenValidatedContext, IEnumerable&lt;string&gt;)"/>:
241-
/// <code>
242-
/// private async Task OnTokenValidated(TokenValidatedContext context)
243-
/// {
244-
/// var tokenAcquisition = context.HttpContext.RequestServices.GetRequiredService&lt;ITokenAcquisition&gt;();
245-
/// _tokenAcquisition.AddAccountToCacheFromJwt(tokenValidationContext);
246-
/// }
247-
/// </code>
248-
/// </example>
249-
public Task AddAccountToCacheFromJwtAsync(
250-
AspNetCore.Authentication.OpenIdConnect.TokenValidatedContext tokenValidatedContext, // JwtBearer.TokenValidatedContext also exists
251-
IEnumerable<string> scopes = null)
252-
{
253-
if (tokenValidatedContext == null)
254-
{
255-
throw new ArgumentNullException(nameof(tokenValidatedContext));
202+
// Case of the Web App: we let the the MsalUiRequiredException be caught by the
203+
// AuthorizeForScopesAttribute exception filter so that the user can consent, do 2FA, etc ...
204+
else
205+
{
206+
throw;
207+
}
256208
}
257209

258-
return AddAccountToCacheFromJwtAsync(
259-
scopes,
260-
tokenValidatedContext.SecurityToken,
261-
tokenValidatedContext.Principal);
210+
return accessToken;
262211
}
263212

264213
/// <summary>
@@ -340,15 +289,15 @@ private IConfidentialClientApplication BuildConfidentialClientApplication()
340289
/// <param name="scopes">Scopes for the downstream API to call</param>
341290
/// <param name="tenant">(optional) Specific tenant for which to acquire a token to access the scopes
342291
/// on behalf of the user described in the claimsPrincipal</param>
343-
private async Task<string> GetAccessTokenOnBehalfOfUserAsync(
292+
private async Task<string> GetAccessTokenOnBehalfOfUserFromCacheAsync(
344293
IConfidentialClientApplication application,
345294
ClaimsPrincipal claimsPrincipal,
346295
IEnumerable<string> scopes,
347296
string tenant)
348297
{
349298
string accountIdentifier = claimsPrincipal.GetMsalAccountId();
350299
string loginHint = claimsPrincipal.GetLoginHint();
351-
return await GetAccessTokenOnBehalfOfUserAsync(application, accountIdentifier, scopes, loginHint, tenant).ConfigureAwait(false);
300+
return await GetAccessTokenOnBehalfOfUserFromCacheAsync(application, accountIdentifier, scopes, loginHint, tenant).ConfigureAwait(false);
352301
}
353302

354303
/// <summary>
@@ -360,7 +309,7 @@ private async Task<string> GetAccessTokenOnBehalfOfUserAsync(
360309
/// <param name="scopes">Scopes for the downstream API to call</param>
361310
/// <param name="loginHint"></param>
362311
/// <param name="tenant"></param>
363-
private async Task<string> GetAccessTokenOnBehalfOfUserAsync(
312+
private async Task<string> GetAccessTokenOnBehalfOfUserFromCacheAsync(
364313
IConfidentialClientApplication application,
365314
string accountIdentifier,
366315
IEnumerable<string> scopes,
@@ -410,43 +359,6 @@ private async Task<string> GetAccessTokenOnBehalfOfUserAsync(
410359
return result.AccessToken;
411360
}
412361

413-
/// <summary>
414-
/// Adds an account to the token cache from a JWT token and other parameters related to the token cache implementation
415-
/// </summary>
416-
private async Task AddAccountToCacheFromJwtAsync(IEnumerable<string> scopes, JwtSecurityToken jwtToken, ClaimsPrincipal principal)
417-
{
418-
try
419-
{
420-
UserAssertion userAssertion;
421-
IEnumerable<string> requestedScopes;
422-
if (jwtToken != null)
423-
{
424-
// In encrypted tokens, the decrypted token is in the InnerToken
425-
string rawData = (jwtToken.InnerToken != null) ? jwtToken.InnerToken.RawData : jwtToken.RawData;
426-
userAssertion = new UserAssertion(rawData, "urn:ietf:params:oauth:grant-type:jwt-bearer");
427-
requestedScopes = scopes ?? jwtToken.Audiences.Select(a => $"{a}/.default");
428-
}
429-
else
430-
{
431-
throw new ArgumentOutOfRangeException("tokenValidationContext.SecurityToken should be a JWT Token");
432-
// TODO: Understand if we could support other kind of client assertions (SAML);
433-
}
434-
435-
var application = GetOrBuildConfidentialClientApplication();
436-
437-
// .Result to make sure that the cache is filled-in before the controller tries to get access tokens
438-
var result = await application
439-
.AcquireTokenOnBehalfOf(requestedScopes.Except(_scopesRequestedByMsalNet), userAssertion)
440-
.ExecuteAsync()
441-
.ConfigureAwait(false);
442-
}
443-
catch (MsalException ex)
444-
{
445-
Debug.WriteLine(ex.Message);
446-
throw;
447-
}
448-
}
449-
450362
/// <summary>
451363
/// Used in Web APIs (which therefore cannot have an interaction with the user).
452364
/// Replies to the client through the HttpReponse by sending a 403 (forbidden) and populating wwwAuthenticateHeaders so that
@@ -501,4 +413,4 @@ private static bool AcceptedTokenVersionMismatch(MsalUiRequiredException msalSev
501413
return (msalSeviceException.Message.Contains("AADSTS50013"));
502414
}
503415
}
504-
}
416+
}

Microsoft.Identity.Web/TokenCacheProviders/MsalAbstractTokenCacheProvider.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.AspNetCore.Http;
66
using Microsoft.Extensions.Options;
77
using Microsoft.Identity.Client;
8+
using System.IdentityModel.Tokens.Jwt;
89
using System.Threading.Tasks;
910

1011
namespace Microsoft.Identity.Web.TokenCacheProviders
@@ -69,7 +70,12 @@ private string CacheKey
6970
}
7071
else
7172
{
72-
return _httpContextAccessor.HttpContext.User.GetMsalAccountId();
73+
// In the case of Web Apps, the cache key is the user account Id, and the expectation is that AcquireTokenSilent
74+
// should return a token otherwise this might require a challenge
75+
// In the case Web APIs, the token cache key is a hash of the access token used to call the Web API
76+
JwtSecurityToken jwtSecurityToken = _httpContextAccessor.HttpContext.GetTokenUsedToCallWebAPI();
77+
return (jwtSecurityToken != null) ? jwtSecurityToken.RawSignature
78+
: _httpContextAccessor.HttpContext.User.GetMsalAccountId();
7379
}
7480
}
7581

0 commit comments

Comments
 (0)