I have an application that currently uses the resource owner password grant type to allow users to log in via a single page application. The identity server is hosted in the same project as the Web API currently. However, we would like to add the ability for a user to register / log in using their Google account. Currently, the user data is stored in tables and managed by ASP.NET Core Identity.
Is there a way to have both the resource owner password grant type available in the application for users who are 'local' but also enable third party authentication via Google? Currently, we hit the Identity Server token endpoint with a username and password and store the token in the browser. It's then passed to any endpoint that requires authorization. Would this same flow still work when integrating Google authentication and retrieving the Google token?
All the credit goes to Behrooz Dalvandi for this amazing post.
The solution to this problem is to create a custom grant and implement IExtensionGrantValidator.
public class GoogleGrant : IExtensionGrantValidator
{
private readonly IGoogleService _googleService;
private readonly IAccountService _accountService;
public GoogleGrant(IGoogleService googleService, IAccountService accountService)
{
_googleService = googleService;
_accountService = accountService;
}
public string GrantType
{
get
{
return "google_auth";
}
}
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
var userToken = context.Request.Raw.Get("id_token");
if (string.IsNullOrEmpty(userToken))
{
//You may want to add some claims here.
context.Result = new GrantValidationResult(TokenErrors.InvalidGrant, null);
return;
}
//Validate ID token
GoogleJsonWebSignature.Payload idTokenData = await _googleService.ParseGoogleIdToken(userToken);
if (idTokenData != null)
{
//Get user from the database.
ApplicationUser user = await _accountService.FindByEmail(idTokenData.Email);
if(user != null)
{
context.Result = new GrantValidationResult(user.Id, "google");
return;
}
else
{
return;
}
}
else
{
return;
}
}
}
Configure this validator in the Startup
var builder = services.AddIdentityServer()
.AddInMemoryIdentityResources(Config.Ids)
.AddInMemoryApiResources(Config.Apis)
.AddInMemoryClients(Config.Clients)
.AddResourceOwnerValidator<CustomResourceOwnerPasswordValidator>()
.AddExtensionGrantValidator<GoogleGrant>();//Custom validator.
And last but not the least.
Add below code in the config file. Notice the 'google_auth' grant type.
new Client
{
ClientId = "resourceownerclient",
AllowedGrantTypes = GrantTypes.ResourceOwnerPasswordAndClientCredentials.Append("google_auth").ToList(),
AccessTokenType = AccessTokenType.Jwt,
AccessTokenLifetime = 3600,
IdentityTokenLifetime = 3600,
UpdateAccessTokenClaimsOnRefresh = true,
SlidingRefreshTokenLifetime = 30,
AllowOfflineAccess = true,
RefreshTokenExpiration = TokenExpiration.Absolute,
RefreshTokenUsage = TokenUsage.OneTimeOnly,
AlwaysSendClientClaims = true,
Enabled = true,
ClientSecrets= new List<Secret> { new Secret("dataEventRecordsSecret".Sha256()) },
AllowedScopes = {
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email,
IdentityServerConstants.StandardScopes.OfflineAccess,
"api1"
}
}
Related
Questions
First question, what determines if an sid claim is emitted from identityserver?
Second question, do I even need an sid? I currently have it included because it was in the sample..
Backstory
I have one website that uses IdentityServer4 for authentication and one website that doesn't. I've cobbled together a solution that allows a user to log into the non-identityserver4 site and click a link that uses one-time-access codes to automatically log into the identityserver4 site. Everything appears to work except the sid claim isn't passed along from identityserver to the site secured by identityserver when transiting from the non-identityserver site. If I log directly into the identityserver4 secured site the sid is included in the claims. Code is adapted from examples of automatically logging in after registration and/or impersonation work flows.
Here is the code:
One time code login process in identityserver4
public class CustomAuthorizeInteractionResponseGenerator : AuthorizeInteractionResponseGenerator
{
...
//https://stackoverflow.com/a/51466043/391994
public override async Task<InteractionResponse> ProcessInteractionAsync(ValidatedAuthorizeRequest request,
ConsentResponse consent = null)
{
string oneTimeAccessToken = request.GetAcrValues().FirstOrDefault(x => x.Split(':')[0] == "otac");
string clientId = request.ClientId;
//handle auto login handoff
if (!string.IsNullOrWhiteSpace(oneTimeAccessToken))
{
//https://benfoster.io/blog/identity-server-post-registration-sign-in/
oneTimeAccessToken = oneTimeAccessToken.Split(':')[1];
OneTimeCodeContract details = await GetOTACFromDatabase(oneTimeAccessToken);
if (details.IsValid)
{
UserFormContract user = await GetPersonUserFromDatabase(details.PersonId);
if (user != null)
{
string subjectId = await GetClientSubjectIdAsync(clientId, user.AdUsername);
var iduser = new IdentityServerUser(subjectId)
{
DisplayName = user.AdUsername,
AuthenticationTime = DateTime.Now,
IdentityProvider = "local",
};
request.Subject = iduser.CreatePrincipal();
//revoke token
bool? success = await InvalidateTokenInDatabase(oneTimeAccessToken);
if (success.HasValue && !success.Value)
{
Log.Debug($"Revoke failed for {oneTimeAccessToken} it should expire at {details.ExpirationDate}");
}
//https://stackoverflow.com/a/56237859/391994
//sign them in
await _httpContextAccessor.HttpContext.SignInAsync(IdentityServerConstants.DefaultCookieAuthenticationScheme, request.Subject, null);
return new InteractionResponse
{
IsLogin = false,
IsConsent = false,
};
}
}
}
return await base.ProcessInteractionAsync(request, consent);
}
}
Normal Login flow when logging directly into identityserver4 secured site (from sample)
public class AccountController : Controller
{
/// <summary>
/// Handle postback from username/password login
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginInputModel model)
{
Log.Information($"login request from: {Request.HttpContext.Connection.RemoteIpAddress.ToString()}");
if (ModelState.IsValid)
{
// validate username/password against in-memory store
if (await _userRepository.ValidateCredentialsAsync(model.Username, model.Password))
{
AuthenticationProperties props = null;
// only set explicit expiration here if persistent.
// otherwise we reply upon expiration configured in cookie middleware.
if (AccountOptions.AllowRememberLogin && model.RememberLogin)
{
props = new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration)
};
};
var clientId = await _account.GetClientIdAsync(model.ReturnUrl);
// issue authentication cookie with subject ID and username
var user = await _userRepository.FindByUsernameAsync(model.Username, clientId);
var iduser = new IdentityServerUser(user.SubjectId)
{
DisplayName = user.UserName
};
await HttpContext.SignInAsync(iduser, props);
// make sure the returnUrl is still valid, and if yes - redirect back to authorize endpoint
if (_interaction.IsValidReturnUrl(model.ReturnUrl))
{
return Redirect(model.ReturnUrl);
}
return Redirect("~/");
}
ModelState.AddModelError("", AccountOptions.InvalidCredentialsErrorMessage);
}
// something went wrong, show form with error
var vm = await _account.BuildLoginViewModelAsync(model);
return View(vm);
}
}
AuthorizationCodeReceived in identityserver4 secured site
public partial class Startup
{
public void Configuration(IAppBuilder app)
{
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
Notifications = new OpenIdConnectAuthenticationNotifications
{
AuthorizationCodeReceived = async n =>
{
// use the code to get the access and refresh token
var tokenClient = new TokenClient(
tokenEndpoint,
electionClientId,
electionClientSecret);
var tokenResponse = await tokenClient.RequestAuthorizationCodeAsync(
n.Code, n.RedirectUri);
if (tokenResponse.IsError)
{
throw new Exception(tokenResponse.Error);
}
// use the access token to retrieve claims from userinfo
var userInfoClient = new UserInfoClient(
new Uri(userInfoEndpoint).ToString());
var userInfoResponse = await userInfoClient.GetAsync(tokenResponse.AccessToken);
Claim subject = userInfoResponse.Claims.Where(x => x.Type == "sub").FirstOrDefault();
// create new identity
var id = new ClaimsIdentity(n.AuthenticationTicket.Identity.AuthenticationType);
id.AddClaims(GetRoles(subject.Value, tokenClient, apiResourceScope, apiBasePath));
var transformedClaims = StartupHelper.TransformClaims(userInfoResponse.Claims);
id.AddClaims(transformedClaims);
id.AddClaim(new Claim("access_token", tokenResponse.AccessToken));
id.AddClaim(new Claim("expires_at", DateTime.Now.AddSeconds(tokenResponse.ExpiresIn).ToLocalTime().ToString()));
id.AddClaim(new Claim("refresh_token", tokenResponse.RefreshToken));
id.AddClaim(new Claim("id_token", n.ProtocolMessage.IdToken));
THIS FAILS -> id.AddClaim(new Claim("sid", n.AuthenticationTicket.Identity.FindFirst("sid").Value));
n.AuthenticationTicket = new AuthenticationTicket(
new ClaimsIdentity(id.Claims, n.AuthenticationTicket.Identity.AuthenticationType, "name", "role"),
n.AuthenticationTicket.Properties);
},
}
});
}
}
Questions again if you don't want to scroll back up
First question, what determines if an sid claim is emitted from identityserver?
Second question, do I even need an sid? I currently have it included because it was in the sample..
I created a C# console application to send email using Microsoft Graph API. On adding Mail.Send Application Permission, it works fine. But, because of company requirements, I was asked to use Mail.Send Delegated Permission instead and with that permission I don't see it working and I see this error:
Are there any steps I should consider doing after adding Mail.Send Delegated Permission in order to get this working?
Here is my code:
static void Main(string[] args)
{
// Azure AD APP
string clientId = "<client Key Here>";
string tenantID = "<tenant key here>";
string clientSecret = "<client secret here>";
Task<GraphServiceClient> callTask = Task.Run(() => SendEmail(clientId, tenantID, clientSecret));
// Wait for it to finish
callTask.Wait();
// Get the result
var astr = callTask;
}
public static async Task<GraphServiceClient> SendEmail(string clientId, string tenantID, string clientSecret)
{
var confidentialClientApplication = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithTenantId(tenantID)
.WithClientSecret(clientSecret)
.Build();
var authProvider = new ClientCredentialProvider(confidentialClientApplication);
var graphClient = new GraphServiceClient(authProvider);
var message = new Message
{
Subject = subject,
Body = new ItemBody
{
ContentType = BodyType.Text,
Content = content
},
ToRecipients = new List<Recipient>()
{
new Recipient
{
EmailAddress = new EmailAddress { Address = recipientAddress }
}
}
};
var saveToSentItems = true;
await _graphClient.Users[<userprincipalname>]
.SendMail(message, saveToSentItems)
.Request()
.PostAsync();
return graphClient;
}
UPDATE:
Based on below answer, I updated code as follows:
var publicClientApplication = PublicClientApplicationBuilder
.Create("<client-id>")
.WithTenantId("<tenant-id>")
.Build();
var authProvider = new UsernamePasswordProvider(publicClientApplication);
var secureString = new NetworkCredential("", "<password>").SecurePassword;
User me = await graphClient.Me.Request()
.WithUsernamePassword("<username>", secureString)
.GetAsync();
I enabled "Allow public client flows" to fix an exception.
And now I see another exception: Insufficient privileges to complete the operation.
What am I missing?
UPDATE: Currently I see this exception with no changes in the code:
The code you provided shows you use client credential flow to do the authentication. When you use Mail.Send Application permission, use client credential flow is ok. But if you use Mail.Send Delegated permission, we can not use client credential. You should use username/password flow to do authentication.
=================================Update===================================
Below is my code:
using Microsoft.Graph;
using Microsoft.Graph.Auth;
using Microsoft.Identity.Client;
using System;
using System.Collections.Generic;
using System.Security;
namespace ConsoleApp34
{
class Program
{
static async System.Threading.Tasks.Task Main(string[] args)
{
Console.WriteLine("Hello World!");
var publicClientApplication = PublicClientApplicationBuilder
.Create("client id")
.WithTenantId("tenant id")
.Build();
string[] scopes = new string[] { "mail.send" };
UsernamePasswordProvider authProvider = new UsernamePasswordProvider(publicClientApplication, scopes);
GraphServiceClient graphClient = new GraphServiceClient(authProvider);
var message = new Message
{
Subject = "Meet for lunch?",
Body = new ItemBody
{
ContentType = BodyType.Text,
Content = "The new cafeteria is open."
},
ToRecipients = new List<Recipient>()
{
new Recipient
{
EmailAddress = new EmailAddress
{
Address = "to email address"
}
}
}
};
var securePassword = new SecureString();
foreach (char c in "your password")
securePassword.AppendChar(c);
var saveToSentItems = true;
await graphClient.Me
.SendMail(message, saveToSentItems)
.Request().WithUsernamePassword("your email", securePassword)
.PostAsync();
}
}
}
The reason for your error message Insufficient privileges to complete the operation is you use the code:
User me = await graphClient.Me.Request()
.WithUsernamePassword("<username>", secureString)
.GetAsync();
This code is used to get the user(me)'s information but not send email, you haven't added the permission to the app. So it will show Insufficient privileges to complete the operation. Please remove this code and use the code block in my code instead:
await graphClient.Me.SendMail(message, saveToSentItems)
.Request().WithUsernamePassword("your email", securePassword)
.PostAsync();
==============================Update2====================================
I'm using IdentityServer4 Tools to manually create a token:
var token = await _tools.IssueClientJwtAsync(
clientId: "client_id",
lifetime: lifetimeInSeconds,
audiences: new[] { TokenHelper.Audience },
additionalClaims:new [] { new Claim("some_id", "1234") }
);
I wonder if there is a way (using what IdentityServer4 already have) to manually decode and validate the token.
To decode the token right now I'm using JwtSecurityTokenHandler (System.IdentityModel.Tokens.Jwt):
var handler = new JwtSecurityTokenHandler();
var tokenDecoded = handler.ReadJwtToken(token);
It is quite simple so I'm happy to keep this if IdentityServer4 doesn't have an equivalent.
What is more important is the validation of the token. I found and adapt this example that does the job. Here the code from Github:
const string auth0Domain = "https://jerrie.auth0.com/"; // Your Auth0 domain
const string auth0Audience = "https://rs256.test.api"; // Your API Identifier
const string testToken = ""; // Obtain a JWT to validate and put it in here
// Download the OIDC configuration which contains the JWKS
// NB!!: Downloading this takes time, so do not do it very time you need to validate a token, Try and do it only once in the lifetime
// of your application!!
IConfigurationManager<OpenIdConnectConfiguration> configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>($"{auth0Domain}.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever());
OpenIdConnectConfiguration openIdConfig = AsyncHelper.RunSync(async () => await configurationManager.GetConfigurationAsync(CancellationToken.None));
// Configure the TokenValidationParameters. Assign the SigningKeys which were downloaded from Auth0.
// Also set the Issuer and Audience(s) to validate
TokenValidationParameters validationParameters =
new TokenValidationParameters
{
ValidIssuer = auth0Domain,
ValidAudiences = new[] { auth0Audience },
IssuerSigningKeys = openIdConfig.SigningKeys
};
// Now validate the token. If the token is not valid for any reason, an exception will be thrown by the method
SecurityToken validatedToken;
JwtSecurityTokenHandler handler = new JwtSecurityTokenHandler();
var user = handler.ValidateToken(testToken, validationParameters, out validatedToken);
// The ValidateToken method above will return a ClaimsPrincipal. Get the user ID from the NameIdentifier claim
// (The sub claim from the JWT will be translated to the NameIdentifier claim)
Console.WriteLine($"Token is validated. User Id {user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value}");
The code above is doing the job. I just wonder if IdentityServer4 has already something "simpler" that just does the token validation as the code above does.
What you are trying to do is called token delegation,
you can implement it using Extension Grants on IDS. Here is sample code from docs
public class DelegationGrantValidator : IExtensionGrantValidator
{
private readonly ITokenValidator _validator;
public DelegationGrantValidator(ITokenValidator validator)
{
_validator = validator;
}
public string GrantType => "delegation";
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
var userToken = context.Request.Raw.Get("token");
if (string.IsNullOrEmpty(userToken))
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
var result = await _validator.ValidateAccessTokenAsync(userToken);
if (result.IsError)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
// get user's identity
var sub = result.Claims.FirstOrDefault(c => c.Type == "sub").Value;
//Generate a new token manually if needed
//Call another API is needed
context.Result = new GrantValidationResult(sub, GrantType);
return;
}
}
Token validation is done using ITokenValidator in above code, you can use this validator in manual validation as well.
Here is another example.
I am not able to change the password of the logged in Azure AD B2C user.
I have Azure B2C tenant which is used for dev and QA.Also, i have two applications something-Local and something-QA used for DEV and QA respectively in Azure B2C as shown below and I have verified the settings of both the apps they are same
Below are the configurations of the applications
Here is my code which is used for B2C connection
private OpenIdConnectAuthenticationOptions CreateOptionsFromPolicy(string policy)
{
return new OpenIdConnectAuthenticationOptions
{
// For each policy, give OWIN the policy-specific metadata address, and
// set the authentication type to the id of the policy
// meta data
MetadataAddress = "https://login.microsoftonline.com/" + "mytenant" + "/v2.0/.well-known/openid-configuration?p=" + policy,
AuthenticationType = policy,
// These are standard OpenID Connect parameters, with values pulled from web.config
ClientId = AzureAdConfig.ClientId,
Notifications = new OpenIdConnectAuthenticationNotifications
{
AuthorizationCodeReceived = OnAuthorizationCodeReceived,
AuthenticationFailed = OnAuthenticationFailed,
SecurityTokenValidated = OnSecurityTokenValidated,
RedirectToIdentityProvider = OnRedirectToIdentityProvider,
},
Scope = "openid",
ResponseType = "id_token",
// This piece is optional - it is used for displaying the user's name in the navigation bar.
TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
}
};
}
in the above code the ClientID used for QA and Dev are different.
Below is the code used to change the user password using graph API.
public async Task<HttpResponseMessage> ChangePassword(string currentPassword, string newPassword)
{
string userId = ClaimValues.ObjectIdentifier();
var adUser = _activeDirectoryClient.Users
.Where(u => u.ObjectId.Equals(userId))
.ExecuteAsync().Result.CurrentPage.FirstOrDefault();
string upn = adUser.UserPrincipalName;
var client = new HttpClient();
string uriString = "https://login.microsoftonline.com/"+ AzureAdConfig.Tenant + "/oauth2/token";
Uri requestUri = new Uri(uriString);
string requestString = "resource=https%3a%2f%2fgraph.windows.net&client_id=" + AzureAdConfig.AppId + "&grant_type=password&username=" + upn + "&password=" + currentPassword + "&client_secret=" + AzureAdConfig.AppKey;
var tokenResult = await client.PostAsync(requestUri, new StringContent(requestString, Encoding.UTF8, "application/x-www-form-urlencoded"));
if (tokenResult.IsSuccessStatusCode)
{
var stringResult = await tokenResult.Content.ReadAsStringAsync();
GraphApiTokenResult objectResult = JsonConvert.DeserializeObject<GraphApiTokenResult>(stringResult);
client = new HttpClient();
string requestUrl = AzureAdConfig.GraphResourceId + AzureAdConfig.Tenant + "/me/changePassword?" + AzureAdConfig.GraphVersion;
Uri graphUri = new Uri(requestUrl);
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", objectResult.access_token);
requestString = JsonConvert.SerializeObject(new
{
currentPassword = currentPassword,
newPassword = newPassword
});
var response = await client.PostAsync(graphUri, new StringContent(requestString, Encoding.UTF8, "application/json"));
return response;
}
else
{
return tokenResult;
}
}
Also, i wanted to understand what is the difference between Application Registrations in Azure Active directory service of azure and the Application in Azure AD B2C of azure?
Thanks in advance
To change user password by using Azure AD Graph API, first you should be a global administrator in your tenant, and then you could use PATCH https://graph.windows.net/myorganization/users/{user_id}?api-version and then update.
{
"passwordProfile": {
"password": "value",
"forceChangePasswordNextLogin": false
}
}
Also, i wanted to understand what is the difference between
Application Registrations in Azure Active directory service of azure
and the Application in Azure AD B2C of azure?
You can know about this from the difference between Azure AD tenant and Azure AD B2C tenant from here.
Hope it can help you.
As one of my requirements, I am supposed to connect the IdentitySever with an Active Directory with existing users and claims. So far I managed to create an App Registration in the Azure Portal. So I have an Appication ID and also configured an API Key. Further, I have a list of Endpoints:
https://login.windows.net/{ad_guid}/federationmetadata/2007-06/federationmetadata.xml
https://login.windows.net/{ad_guid}/wsfed
https://login.windows.net/{ad_guid}/saml2
https://login.windows.net/{ad_guid}/saml2
https://graph.windows.net/{ad_guid}
https://login.windows.net/{ad_guid}/oauth2/token
https://login.windows.net/{ad_guid}/oauth2/authorize
I can get the OpenID configuration with
https://login.windows.net/{ad_guid}/.well-known/openid-configuration
According to the documentation from Microsoft I should now configure the endpoint like this:
app.SetDefaultSignInAsAuthenticationType(
CookieAuthenticationDefaults.AuthenticationType);
app.UseCookieAuthentication(new CookieAuthenticationOptions());
var uri = "https://login.windows.net/{0}";
var instance = configuration["AzureAD:Instance"];
var authority = string.Format(CultureInfo.InvariantCulture, uri, instance);
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
DisplayName = "Azure Active Directory",
AuthenticationScheme = "AzureAD",
SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme,
ClientId = configuration["AzureAD:AppId"],
Authority = authority,
Scope = {"openid", "email"}
});
For some reason this is not working. Any ideas what I might have missed?
apparently, I had it almost right. here is my solution:
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationScheme =
IdentityServerConstants.DefaultCookieAuthenticationScheme,
AutomaticAuthenticate = true,
AutomaticChallenge = true
});
public static OpenIdConnectOptions CreateAzureAdOptions(X509Certificate2 certificate2, IConfiguration configuration)
{
return new OpenIdConnectOptions
{
DisplayName = "Azure Active Directory",
AuthenticationScheme = "Azure",
ClientId = configuration["OpenId:AzureAD:AppId"],
Authority = string.Format(CultureInfo.InvariantCulture, "https://login.windows.net/{0}", configuration["OpenId:AzureAD:Instance"]),
TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false
},
// https://learn.microsoft.com/en-us/azure/active-directory/develop/active-directory-token-and-claims
Scope = {"openid", "email", "roles", "groups"},
Events = new OpenIdConnectEvents
{
OnRemoteFailure = context => HandleRemoteFailure(context)
},
SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme
};
}
private static Task HandleRemoteFailure(FailureContext context)
{
Log.Error(context.Failure, "Azure AD Remote Failure");
context.Response.Redirect("/accessdenied");
context.HandleResponse();
return Task.FromResult(0);
}