How to convert Azure OpenIdConnect OWIN Middleware Cookie Auth to JavaScript JWT for SPA application? - authentication

My ASP.NET MVC Core application uses OWIN Middleware along with the following modules to perform OpenIdConnect authentication against Azure AD:
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Microsoft.Azure.ActiveDirectory.GraphClient;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Azure.ActiveDirectory.GraphClient.Extensions;
The OWIN Middleware performs a bunch of tasks including
Fetching Azure AD Groups and Roles via Azure Graph API
Fetching User Profile Data from Database
Creating Claims from steps 1 & 2
Issuing cookie
The Middleware automatically handles Refresh tokens
The Middleware caches the token in the database and able to retrieve via a mechanism AcquireTokenSilentAsync for Graph client.
The MVC application serves a single Razor view and from that point onward, I am using Aurelia JavasScript framework (could easily be Angular, Knockout, React, not important) which only performs API requests to my Api Controller via AJAX.
So my question is how to convert all these authentication and authorization steps handled on the server to JWT based authentication on the client against Azure AD?
Admittedly, my question is fairly naive as there is substantial work being performed by OWIN Middleware components in the code below. So I am looking for a starting point, helper libraries and feasibility. I don't feel confident removing all the middleware code and server side authentication until I am confident this flow can be replicated using AJAX and JWT authentication.
I have done some research and the answer may involve the following
adal.js
JWT middleware in ASP.NET Core
HTML Web Storage
Azure AD Graph REST API (instead of C# Graph Client)
Here is the current OWIN Middleware code performing OpenIdConnect authentication against Azure AD on the server:
app.UseCookieAuthentication();
app.UseOpenIdConnectAuthentication(new OpenIdConnectOptions
{
ClientId = Configuration["Authentication:AzureAd:ClientId"],
ClientSecret = Configuration["Authentication:AzureAd:ClientSecret"],
Authority = Configuration["Authentication:AzureAd:AADInstance"] + Configuration["Authentication:AzureAd:TenantId"],
CallbackPath = Configuration["Authentication:AzureAd:CallbackPath"],
ResponseType = OpenIdConnectResponseType.CodeIdToken,
Events = new OpenIdConnectEvents()
{
OnAuthorizationCodeReceived = async (context) =>
{
var code = context.TokenEndpointRequest.Code;
var identity = context.Ticket.Principal.Identity as ClaimsIdentity;
userObjectID = identity.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value;
signedInUserID = identity.FindFirst(ClaimTypes.NameIdentifier).Value;
ClientCredential credential =
new ClientCredential(
Configuration["Authentication:AzureAd:ClientId"],
Configuration["Authentication:AzureAd:ClientSecret"]);
var authority = Configuration["Authentication:AzureAd:AADInstance"]
+ Configuration["Authentication:AzureAd:TenantId"];
AuthenticationContext authContext =
new AuthenticationContext(authority, new ADALTokenCacheService(signedInUserID, Configuration));
await authContext.AcquireTokenByAuthorizationCodeAsync(
context.TokenEndpointRequest.Code,
new Uri(context.TokenEndpointRequest.RedirectUri, UriKind.RelativeOrAbsolute),
credential,
Configuration["Authentication:AzureAd:GraphResource"]);
context.HandleCodeRedemption();
ActiveDirectoryClient activeDirectoryClient = GetActiveDirectoryClient();
// Get currently logged in User from Graph
IPagedCollection<IUser> users = await activeDirectoryClient.Users.Where(u => u.ObjectId.Equals(userObjectID)).ExecuteAsync();
IUser user = users.CurrentPage.ToList().First();
// Get User's AD Groups
IEnumerable<string> userGroupIds = await user.GetMemberGroupsAsync(false);
List<string> userGroupIdList = userGroupIds.ToList();
// Transform User's AD Groups into Claims
foreach (var groupObjectId in userGroupIdList)
{
var group = await activeDirectoryClient.Groups.GetByObjectId(groupObjectId).ExecuteAsync();
Claim newClaim = new Claim(
CustomClaimValueTypes.ADGroup,
group.DisplayName,
ClaimValueTypes.String,
"AAD GRAPH");
((ClaimsIdentity)(context.Ticket.Principal.Identity)).AddClaim(newClaim);
}
// Get User's Application permissions from Database
upn = identity.FindFirst(ClaimTypes.Upn).Value;
DbContext db =
new DbContext(Configuration["ConnectionStrings:DefaultConnection"]);
if (db.PortalUsers.FirstOrDefault(b => (b.UPN == upn)) == null)
{
throw new System.IdentityModel.Tokens.SecurityTokenValidationException("You are not registered to use this application.");
}
var applications = from permissions in db.PortalPermissions
where permissions.PortalUser.UPN == upn
//orderby permissions.Application.SortOrder ascending
select permissions.PortalApplication;
// Transform User's Application permissions into Claims
foreach (var application in applications)
{
Claim newClaim = new Claim(
CustomClaimValueTypes.Application,
application.Name,
ClaimValueTypes.String,
"DATABASE");
((ClaimsIdentity)(context.Ticket.Principal.Identity)).AddClaim(newClaim);
}
},
OnRemoteFailure = (context) =>
{
if (context.Failure.Message == "You are not registered to use this application.")
{
context.Response.Redirect("/AuthenticationError");
}
else
{
context.Response.Redirect("/Error");
}
context.HandleResponse();
return Task.FromResult(0);
}
}
});
app.UseFileServer(new FileServerOptions
{
EnableDefaultFiles = true,
EnableDirectoryBrowsing = false
});
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Start}/{id?}");
});
}
private ActiveDirectoryClient GetActiveDirectoryClient()
{
Uri servicePointUri = new Uri(Configuration["Authentication:AzureAd:GraphResource"]);
Uri serviceRoot = new Uri(servicePointUri, Configuration["Authentication:AzureAd:TenantId"]);
ActiveDirectoryClient activeDirectoryClient = new ActiveDirectoryClient(
serviceRoot, async () => await GetTokenForApplicationAsync());
return activeDirectoryClient;
}
private async Task<string> GetTokenForApplicationAsync()
{
ClientCredential clientCredential =
new ClientCredential(
Configuration["Authentication:AzureAd:ClientId"],
Configuration["Authentication:AzureAd:ClientSecret"]);
AuthenticationContext authenticationContext =
new AuthenticationContext(
Configuration["Authentication:AzureAd:AADInstance"] +
Configuration["Authentication:AzureAd:TenantId"],
new ADALTokenCacheService(signedInUserID, Configuration));
AuthenticationResult authenticationResult = await authenticationContext.AcquireTokenSilentAsync(
Configuration["Authentication:AzureAd:GraphResource"],
clientCredential,
new UserIdentifier(userObjectID, UserIdentifierType.UniqueId));
return authenticationResult.AccessToken;
}

The MVC application serves a single Razor view and from that point onward, I am using Aurelia JavasScript framework (could easily be Angular, Knockout, React, not important) which only performs API requests to my Api Controller via AJAX.
Did you mean that the ASP.NET MVC Core application will protect the the API controller by both cookies and bearer token? And the Aurelia JavasScript framework will perform the AJAX request to the API control using the bearer token?
If I understood correctly, you need to register another native app on the Azure portal for authentication for the app which using Aurelia JavaScript framework(as same as the SPA call web API which protected by Azure AD here).
And for the existing ASP.NET MVC Core application to support the token authentication, we need to add the JWT token middler ware.
And if the web API which publish for your SPA application want to call other resource, we also need to check authentciation methoed.
For example, if we call the web API with token(the audince of token should be the app id uri of you ASP.Net MVC core application), and the web API need to exhcange this token for the target resource using the flow described Delegated User Identity with OAuth 2.0 On-Behalf-Of Draft Specification to call the another web API.
Update
app.UseOpenIdConnectAuthentication(new OpenIdConnectOptions
{
ClientId = ClientId,
Authority = Authority,
PostLogoutRedirectUri = Configuration["AzureAd:PostLogoutRedirectUri"],
ResponseType = OpenIdConnectResponseType.CodeIdToken,
GetClaimsFromUserInfoEndpoint = false,
Events = new OpenIdConnectEvents
{
OnRemoteFailure = OnAuthenticationFailed,
OnAuthorizationCodeReceived = OnAuthorizationCodeReceived,
OnTokenValidated= context => {
(context.Ticket.Principal.Identity as ClaimsIdentity).AddClaim(new Claim("AddByMyWebApp", "ClaimValue"));
return Task.FromResult(0);
}
}
});

Related

.Net Core 3 UseOpenIdConnectAuthentication

We have a legacy .net 4 web application. We use AAD to authenticate a user. Then we used the below code to run immediately after authentication
to do things like get a users data or configure permissions.
We also already have user and role tables that are maintained.
Now, we have to re-creating this application using ASP.net Core 3. We also want to keep our existing user and role tables.
1: What is the equivalent of "UseOpenIdConnectAuthentication" for executing code after a user has been authenticated? In .net core 3?
2: How can we add a claim based on the authenticated user in .net core 3?
--startup.cs for legacy .Net 4 app
public void Configuration(IAppBuilder app)
{
app.UseCookieAuthentication(new CookieAuthenticationOptions
...
app.UseOpenIdConnectAuthentication(
new OpenIdConnectAuthenticationOptions
{
ClientId = clientId,
Authority = authority,
RedirectUri = redirectUri,
...
Notifications = new OpenIdConnectAuthenticationNotifications
{
AuthenticationFailed = OnAuthenticationFailed,
SecurityTokenValidated = notification =>
{
//user is authenticated
//Do stuff here to set their permissions, get data, etc...
Does this what you want?
services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(options =>
{
options.Events.OnTokenValidated = async context =>
{
options.TokenValidationParameters.ValidAudiences = new List<string> { "aud1"};
};
});

Using OpenIdConnect and JwtBearer authentication together

My ASP.NET Core 2.2 web site uses OpenIdConnect to connect to an external OIDC provider for authentication. So we get a redirect to the provider, you log in, it returns back to the site. All of this is handled server side and works great.
Our web site uses javascript to communicate to the API backend, which is decorated with an [Authorize] attribute.
Now, we want to share our API with another application. I am having trouble understanding how to make it work with the existing OpenIdConnect provider. If I use Postman to make a call, I add a Bearer token but I am returned the log in screen for my OIDC provider. I understand this, it's set up for OIDC.
I have tried using AddJwtBearer with [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] set on my Controller and when I do that I can successfully pass in a Bearer token and call my API from Postman but I can't make a call from a javascript page on my site.
So the ultimate question is, how do I configure this site so that I can call an API from my site itself with OIDC and also call from an external app using a Bearer token?
The code looks like this:
public void ConfigureServices(IServiceCollection services)
{
var authbuilder = services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
});
authbuilder.AddCookie(p =>
{
p.SlidingExpiration = true;
p.Events.OnSigningIn = (context) =>
{
context.CookieOptions.Expires = DateTimeOffset.UtcNow.AddHours(14);
return Task.CompletedTask;
};
});
authbuilder.AddOpenIdConnect(options =>
{
options.Authority = Configuration["OpenIdConnectSettings:AuthorityUrl"];
options.ClientSecret = Configuration["OpenIdConnectSettings:ClientSecret"];
options.ClientId = Configuration["OpenIdConnectSettings:ClientId"];
}
options.SaveTokens = true;
options.ResponseType = OpenIdConnectResponseType.CodeIdToken;
options.GetClaimsFromUserInfoEndpoint = true;
options.Events = new OpenIdConnectEvents
{
OnTokenValidated = context =>
{
// validation logic omitted
}
EDIT: Any external apps will also use the same OIDC provider. It's just a matter of getting my API Controllers to work from the web site itself and being called from another app that uses the same OIDC provider.
EDIT: I think I may have gotten it to work by adding this code:
services.AddAuthorization(options =>
{
var defaultAuthorizationPolicyBuilder = new AuthorizationPolicyBuilder(
JwtBearerDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme);
defaultAuthorizationPolicyBuilder = defaultAuthorizationPolicyBuilder.RequireAuthenticatedUser();
options.DefaultPolicy = defaultAuthorizationPolicyBuilder.Build();
});
if the other application is a service/application (not a human facing client) then that application needs to register as a client in your OIDC provider and then preferably use the client credentials flow, to get a token from your OIDC provider and pass it to the API.
The API only trusts access tokens from your OIDC provider (the token issuer), so the application needs to authenticate against the app and to get a proper access token.

Obtaining AzureAD Refresh token in asp.net core 2.2 mvc app

I created a asp.net core 2.2 mvc app with the default template that has authentication through work or school account added to it with the following configuration:
var azureAD = config.GetSection("AzureAD").Get<AzureADSettings>();
if (azureAD.Enabled)
{
services.AddAuthentication(AzureADDefaults.AuthenticationScheme).AddAzureAD(options =>
{
config.Bind("AzureAd", options);
});
services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, options =>
{
options.ResponseType = azureAD.ResponseType; //"token id_token"
options.Resource = azureAD.Resource; //"resource link, dynamics in my case"
options.SaveTokens = azureAD.SaveTokens; //"true"
options.Scope.Add(azureAD.Scope); //"offline_access"
});
}
In my controller I get the access token using the following code:
var accessToken = await _httpContext.GetTokenAsync("access_token");
I then use this access token in the ODataLibrary to access data from Dynamics 365 Finance and Operations:
_dataServiceContext.SendingRequest2 += new EventHandler<SendingRequest2EventArgs>(async delegate (object sender, SendingRequest2EventArgs e)
{
var accessToken = await dynamicsToken.GetAccessToken();
e.RequestMessage.SetHeader(OAuthHelper.OAuthHeader, $"Bearer {accessToken}");
});
It worked fine at first but then I started getting 401 Unauthorized status codes and I assume this is because the access token expired so I tried to get a new one using the refresh token:
var accessToken = await _httpContext.GetTokenAsync("access_token");
var tokenHandler = new JwtSecurityTokenHandler();
var jwtSecurityToken = tokenHandler.ReadJwtToken(accessToken);
if (jwtSecurityToken.ValidTo < DateTime.UtcNow.AddHours(1))
{
using (var httpClient = new HttpClient())
{
var formContent = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("grant_type", "refresh_token"),
new KeyValuePair<string, string>("client_id", "client id from azure ad"),
new KeyValuePair<string, string>("client_secret", "client secret from azure ad"),
new KeyValuePair<string, string>("resource", "resource link"),
new KeyValuePair<string, string>("scope", "offline_access"),
new KeyValuePair<string, string>("refresh_token", await _httpContext.GetTokenAsync("refresh_token"))
});
var response = await httpClient.PostAsync("https://login.microsoftonline.com/common/oauth2/token", formContent);
response.EnsureSuccessStatusCode();
var newAccessToken = await response.Content.ReadAsStringAsync();
return newAccessToken;
}
}
else
{
return await _httpContext.GetTokenAsync("access_token");
}
But _httpContext.GetTokenAsync("refresh_token") just returns null. Did I not configure it properly or what is the reason I'm not getting a refresh token?
You are using the Implicit grant flow as response type is token id_token .
Your ID token and access token immediately returned to your client app from the authorize endpoint without having to make a second request to the authorize endpoint.
The implicit grant allows the app to get tokens from Microsoft identity platform without performing a backend server credential exchange , which is common used in AngularJS, Ember.js, React.js, and so on . The security characteristics of these apps are significantly different from traditional server-based web applications. Also the implicit grant does not provide refresh tokens.
If using asp.net core , it's recommended to use the authorization code flow . You can find the code sample from:
https://github.com/Azure-Samples/active-directory-dotnet-webapp-webapi-openidconnect-aspnetcore
Note : Above sample is for Azure AD v1.0. If you are looking for an Azure AD v2.0 sample (to sign-in users with Work and School accounts and Microsoft Personal accounts, please look at code samples .

How do I acquire the right token for a Web API from within an Azure Function App or Javascript?

We have a web service which requires authentication before use. When you type in the URL of the Web Service directly in the browser, everything works fine. However, if you were to try and call this very same service from Javascript, it doesn't work because authentication has yet to happen.
I've tried calling getAccessTokenAsync (this is part of the OfficeJS libray) but ended up getting one of those 1300x errors. Also, since this call is still in Preview I would like to avoid it.
The code below gets invoked when you enter the URL to the webservice directly in the browser windows. You're authenticated and everything works fine.
I just don't know how to do the equivalent authentication from within an Azure Function App, or Javascript (from a Web-Add-In)
public partial class AuthStartup
{
public void Configuration(IAppBuilder app)
{
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=316888
// This part is for web sso so web pages can consume the API without obtaining a token
app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
app.UseCookieAuthentication(new CookieAuthenticationOptions());
app.UseWsFederationAuthentication(
new WsFederationAuthenticationOptions
{
// http://www.cloudidentity.com/blog/2014/11/17/skipping-the-home-realm-discovery-page-in-azure-ad/
Notifications = new WsFederationAuthenticationNotifications
{
RedirectToIdentityProvider = (context) =>
{
context.ProtocolMessage.Whr = "ourcompany.com";// similar effect to domain_hint from client so users never see the "choose account" prompt
return Task.FromResult(0);
}
},
MetadataAddress = ConfigurationManager.AppSettings["ida:MetadataAddress"],
Wtrealm = ConfigurationManager.AppSettings["ida:Audience"],
// this part is needed so that cookie and token auth can coexist
TokenValidationParameters = new TokenValidationParameters
{
ValidAudiences = new string[] { $"spn:{ConfigurationManager.AppSettings["ida:Audience"]}" }
}
});
// This part is for bearer token authentication
app.UseWindowsAzureActiveDirectoryBearerAuthentication(
new WindowsAzureActiveDirectoryBearerAuthenticationOptions
{
Tenant = ConfigurationManager.AppSettings["ida:Tenant"],
TokenValidationParameters = new TokenValidationParameters
{
ValidAudience = ConfigurationManager.AppSettings["ida:Audience"]
},
MetadataAddress = ConfigurationManager.AppSettings["ida:MetadataAddress"],
});
}
}

Azure mobile apps Custom + Facebook authentication with Xamarin.Forms

I'm working on a Xamarin Forms mobile app with .NET backend. I followed this guide and successfully set up custom authentications with one change in Startup.cs:
app.UseAppServiceAuthentication(new AppServiceAuthenticationOptions
{
SigningKey = Environment.GetEnvironmentVariable("WEBSITE_AUTH_SIGNING_KEY"),
ValidAudiences = new[] { Identifiers.Environment.ApiUrl },
ValidIssuers = new[] { Identifiers.Environment.ApiUrl },
TokenHandler = config.GetAppServiceTokenHandler()
});
Without "if (string.IsNullOrEmpty(settings.HostName))". Otherwise I am always getting unauthorized for all requests after login.
Server project:
Auth controller
public class ClubrAuthController : ApiController
{
private readonly ClubrContext dbContext;
private readonly ILoggerService loggerService;
public ClubrAuthController(ILoggerService loggerService)
{
this.loggerService = loggerService;
dbContext = new ClubrContext();
}
public async Task<IHttpActionResult> Post(LoginRequest loginRequest)
{
var user = await dbContext.Users.FirstOrDefaultAsync(x => x.Email == loginRequest.username);
if (user == null)
{
user = await CreateUser(loginRequest);
}
var token = GetAuthenticationTokenForUser(user.Email);
return Ok(new
{
authenticationToken = token.RawData,
user = new { userId = loginRequest.username }
});
}
private JwtSecurityToken GetAuthenticationTokenForUser(string userEmail)
{
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, userEmail)
};
var secretKey = Environment.GetEnvironmentVariable("WEBSITE_AUTH_SIGNING_KEY");
var audience = Identifiers.Environment.ApiUrl;
var issuer = Identifiers.Environment.ApiUrl;
var token = AppServiceLoginHandler.CreateToken(
claims,
secretKey,
audience,
issuer,
TimeSpan.FromHours(24)
);
return token;
}
}
Startup.cs
ConfigureMobileAppAuth(app, config, container);
app.UseWebApi(config);
}
private void ConfigureMobileAppAuth(IAppBuilder app, HttpConfiguration config, IContainer container)
{
config.Routes.MapHttpRoute("ClubrAuth", ".auth/login/ClubrAuth", new { controller = "ClubrAuth" });
app.UseAppServiceAuthentication(new AppServiceAuthenticationOptions
{
SigningKey = Environment.GetEnvironmentVariable("WEBSITE_AUTH_SIGNING_KEY"),
ValidAudiences = new[] { Identifiers.Environment.ApiUrl },
ValidIssuers = new[] { Identifiers.Environment.ApiUrl },
TokenHandler = config.GetAppServiceTokenHandler()
});
}
Client project:
MobileServiceUser user = await MobileClient.LoginAsync(loginProvider, jtoken);
Additionally I configured Facebook provider in azure portal like described here.
But it works only when I comment out app.UseAppServiceAuthentication(new AppServiceAuthenticationOptions(){...}); in Startup.cs.
What I am missing to make both types of authentication works at the same time?
Since you have App Service Authentication/Authorization enabled, that will already validate the token. It assumes things about your token structure, such as having the audience and issuer be the same as your app URL (as a default).
app.UseAppServiceAuthentication() will also validate the token, as it is meant for local development. So in your example, the token will be validated twice. Aside from the potential performance impact, this is generally fine. However, that means the tokens must pass validation on both layers, and I suspect that this is not the case, hence the error.
One way to check this is to inspect the tokens themselves. Set a breakpoint in your client app and grab the token you get from LoginAsync(), which will be part of that user object. Then head to a service like http://jwt.io to see what the token contents look like. I suspect that the Facebook token will have a different aud and iss claim than the Identifiers.Environment.ApiUrl you are configuring for app.UseAppServiceAuthentication(), while the custom token probably would match it since you're using that value in your first code snippet.
If that holds true, than you should be in a state where both tokens are failing. The Facebook token would pass the hosted validation but fail on the local middleware, while the custom token would fail the hosted validation but pass the local middleware.
The simplest solution here is to remove app.UseAppServiceAuthentication() when hosting in the cloud. You will also need to make sure that your call to CreateToken() uses the cloud-based URL as the audience and issuer.
For other folks that find this issue
The documentation for custom authentication can be found here.
A general overview of App Service Authentication / Authorization can be found here.
The code you reference is only for local deployments. For Azure deployments, you need to turn on App Service Authentication / Authorization - even if you don't configure an auth provider (which you wouldn't in the case of custom auth).
Check out Chapter 2 of my book - http://aka.ms/zumobook