Current we are using JWT tokens to authenticate (which works) but I would like to use reference tokens.
Currently our config is like this:
public static class Config
{
/// <summary>
/// Configures identity server
/// </summary>
public static void ConfigureIdentityServer(this IAppBuilder app, CormarConfig config)
{
// Create our options
var identityServerOptions = new IdentityServerOptions
{
SiteName = "Cormar API",
SigningCertificate = LoadCertificate(),
IssuerUri = "https://cormarapi-test.azurewebsites.net",
// Not needed
LoggingOptions = new LoggingOptions
{
EnableHttpLogging = true,
EnableWebApiDiagnostics = true,
EnableKatanaLogging = true,
WebApiDiagnosticsIsVerbose = true
},
// In membory crap just to get going
Factory = new IdentityServerServiceFactory().Configure(config),
// Disable when live
EnableWelcomePage = true
};
// Setup our auth path
app.Map("/identity", idsrvApp =>
{
idsrvApp.UseIdentityServer(identityServerOptions);
});
}
/// <summary>
/// Configures the identity server to use token authentication
/// </summary>
public static void ConfigureIdentityServerTokenAuthentication(this IAppBuilder app, HttpConfiguration config)
{
app.UseIdentityServerBearerTokenAuthentication(new IdentityServerBearerTokenAuthenticationOptions
{
Authority = "https://cormarapi-test.azurewebsites.net/identity",
DelayLoadMetadata = true,
ValidationMode = ValidationMode.Local,
RequiredScopes = new[] { "api" },
ClientId = "api",
ClientSecret = "not_my_secret"
});
AntiForgeryConfig.UniqueClaimTypeIdentifier = IdentityServer3.Core.Constants.ClaimTypes.Subject;
JwtSecurityTokenHandler.InboundClaimTypeMap = new Dictionary<string, string>();
}
/// <summary>
/// Loads the certificate
/// </summary>
/// <returns></returns>
private static X509Certificate2 LoadCertificate()
{
var certPath = $"{ AppDomain.CurrentDomain.BaseDirectory }App_Data\\idsrv3test.pfx";
var certStore = new X509Store(StoreName.My, StoreLocation.CurrentUser);
certStore.Open(OpenFlags.ReadOnly);
var certCollection = certStore.Certificates.Find(X509FindType.FindByThumbprint, "3A1AFB6E1DC5C3F341E63651542C740DA4148866", false);
certStore.Close();
// If we are on azure, get the actual self signed certificate, otherwise return the test one
return certCollection.Count > 0 ? certCollection[0] : new X509Certificate2(certPath, "idsrv3test");
}
/// <summary>
/// Configure the identity service factory with custom services
/// </summary>
/// <returns></returns>
private static IdentityServerServiceFactory Configure(this IdentityServerServiceFactory factory, CormarConfig config)
{
var serviceOptions = new EntityFrameworkServiceOptions { ConnectionString = config.SqlConnectionString };
factory.RegisterOperationalServices(serviceOptions);
factory.RegisterConfigurationServices(serviceOptions);
factory.CorsPolicyService = new Registration<ICorsPolicyService>(new DefaultCorsPolicyService { AllowAll = true }); // Allow all domains to access authentication
factory.Register(new Registration<DbContext>(dr => dr.ResolveFromAutofacOwinLifetimeScope<DbContext>()));
factory.UserService = new Registration<IUserService>(dr => dr.ResolveFromAutofacOwinLifetimeScope<IUserService>());
factory.ClientStore = new Registration<IClientStore>(dr => dr.ResolveFromAutofacOwinLifetimeScope<IClientStore>());
factory.ScopeStore = new Registration<IScopeStore>(dr => dr.ResolveFromAutofacOwinLifetimeScope<IScopeStore>());
return factory;
}
}
As you can see, I have added the ClientId and ClientSecret to IdentityServerBearerTokenAuthenticationOptions.
If I set my client's AccessTokenType to reference and try to get a reference token, it works and I get a response like this:
"access_token": "631783604e9c35e6b401605fe4809075",
"expires_in": 3600,
"token_type": "Bearer"
But if I then try to access an resource on my server, I get a 401 unauthorized error.
If I swap back to a JWT AccessTokenType I can authenticate and then access my resource with no issues.
As a note, I had set the ClientSecret and the ScopeSecret to the same value, so I would expect it to work.
Am I forgetting to do something?
You cannot locally verify a token when using the reference token type. Since it's unstructured data, with no digitally verifiable signature, your API needs to check the token with IdentityServer.
To do this, change your ValidationMode to ValidationMode.ValidationEndpoint or ValidationMode.Both
From the docs:
ValidationMode can be either set to Local (JWTs only), ValidationEndpoint (JWTs and reference tokens using the validation endpoint - and Both for JWTs locally and reference tokens using the validation endpoint (defaults to Both). - https://identityserver.github.io/Documentation/docsv2/consuming/options.html
Related
I have an MVC application and one exposed API Endpoint. I authenticated my MVC application with the defaults from Identity Core, I use User.FindFirstValue(ClaimTypes.NameIdentifier) to find if a certain user is logged in, etc.
For my API Endpoint, I use JWT authentication below is the configuration code for JWT:
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(jwt =>
{
var key = Encoding.ASCII.GetBytes(Configuration["Jwt:Secret"]);
jwt.SaveToken = true;
jwt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = false,
ValidateAudience = false,
RequireExpirationTime = false,
ValidateLifetime = true
};
});
Here is the controller for token request:
[HttpPost]
[Route("token")]
public async Task<IActionResult> Token([FromBody] UserLoginRequest user)
{
if (ModelState.IsValid)
{
var existingUser = await _userManager.FindByEmailAsync(user.Email);
if (existingUser == null)
{
return BadRequest();
}
var isCorrect = await _userManager.CheckPasswordAsync(existingUser, user.Password);
if (isCorrect)
{
var jwtToken = _identityService.GenerateJwtToken(existingUser);
return Ok(new RegistrationResponse()
{
Result = true,
Token = jwtToken
});
}
else
{
return BadRequest();
}
}
return BadRequest();
}
On my MVC controllers, I use [Authorize]
On my API Endpoint i use [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
My GenerateJWTToken method:
public string GenerateJwtToken(IdentityUser user)
{
var jwtTokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_jwtConfig.Secret);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[]
{
new Claim("Id", user.Id),
new Claim(JwtRegisteredClaimNames.Sub, user.Email),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
}),
Expires = DateTime.UtcNow.AddHours(6),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha512Signature)
};
var token = jwtTokenHandler.CreateToken(tokenDescriptor);
var jwtToken = jwtTokenHandler.WriteToken(token);
return jwtToken;
}
}
But obviously, this solution fails to function because once I start my MVC Application and try to log in, I get redirected back to Index and I'm still unauthorized. And vice versa with the API, when I make a Postman call, I get the token, and when I try to call my Bookmarks Controller to query user's bookmarks I get zero, although there are bookmarks for that certain user.
Any ideas on how could I make this work would be welcomed.
Inside my JWT Token Generator, I get details of the user I would like to store as claims such as the username, which can be used to identify the user.
public static class JwtTokenExtensions
{
/// <summary>
/// Generates a JWT Bearer token containing the users email
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
public static string GenerateJwtToken(this Identity user)
{
// Set our token claims
Claim[] claims = {
// Unique ID for this token
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
new(JwtRegisteredClaimNames.Email, user.Email),
// The username using the Identity name so it fills out the HttpContext.User.Identity.Name value
new(ClaimsIdentity.DefaultNameClaimType, user.UserName),
// Add user Id so that UserManager.GetUserAsync can find the user based on Id
new Claim(ClaimTypes.NameIdentifier, user.Id)
};
// Create the credentials used to generate the token
SigningCredentials credentials =
new SigningCredentials(SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Jwt:SecretKey"])),
SecurityAlgorithms.HmacSha256);
// Generate the Jwt Token that lasts for an hour before expiring
JwtSecurityToken token =
new JwtSecurityToken
(Configuration["Jwt:Issuer"],
Configuration["Jwt:Audience"],
claims:claims,
signingCredentials:credentials,
expires: DateTime.Now.AddHours(1));
// Return the generated token.
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
Inside the api controllers with JWT authorization, I can get the user via the HttpContext
var user = await _userManager.GetUserAsync(HttpContext.User);
In the controller for your token request, try adding [AllowAnonymous], like this:
[AllowAnonymous]
[HttpPost]
[Route("token")]
public async Task<IActionResult> Token([FromBody] UserLoginRequest user)
{
// snip...
}
I am using identity server 4 for authentication using grant type as 'ResourceOwnerPassword'. I am able to authenticate the user but not able to get claims related to user. So how can I get those ?
Below is my code
Client
Startup.cs
app.UseIdentityServerAuthentication(new IdentityServerAuthenticationOptions
{
Authority = "http://localhost:5000",
RequireHttpsMetadata = false,
ApiName = "api1"
});
Controller
public async Task<IActionResult> Authentication(LoginViewModel model)
{
var disco = await DiscoveryClient.GetAsync("http://localhost:5000");
// request token
var tokenClient = new TokenClient(disco.TokenEndpoint, "ro.client", "secret");
var tokenResponse = await tokenClient.RequestResourceOwnerPasswordAsync(model.Email, model.Password, "api1");
if (tokenResponse.IsError)
{
Console.WriteLine(tokenResponse.Error);
}
// Here I am not getting the claims, it is coming Forbidden
var extraClaims = new UserInfoClient(disco.UserInfoEndpoint);
var identityClaims = await extraClaims.GetAsync(tokenResponse.AccessToken);
if (!tokenResponse.IsError)
{
Console.WriteLine(identityClaims.Json);
}
Console.WriteLine(tokenResponse.Json);
Console.WriteLine("\n\n");
}
Server
Startup.cs
services.AddIdentityServer()
.AddTemporarySigningCredential()
.AddInMemoryPersistedGrants()
.AddInMemoryIdentityResources(Config.GetIdentityResources())
.AddInMemoryApiResources(Config.GetApiResources())
.AddInMemoryClients(Config.GetClients(Configuration))
.AddAspNetIdentity<ApplicationUser>()
.AddProfileService<IdentityProfileService>()
.AddResourceOwnerValidator<ResourceOwnerPasswordValidator>();
Config.cs
public static IEnumerable<Client> GetClients(IConfigurationRoot Configuration)
{
// client credentials client
return new List<Client>
{
// resource owner password grant client
new Client
{
ClientId = "ro.client",
AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
ClientSecrets =
{
new Secret("secret".Sha256())
},
AlwaysSendClientClaims = true,
AlwaysIncludeUserClaimsInIdToken = true,
AccessTokenType = AccessTokenType.Jwt
}
};
}
public static IEnumerable<ApiResource> GetApiResources()
{
return new List<ApiResource>
{
new ApiResource("api1", "My API")
};
}
But when I check my access token in jwt.io there I can see the claims But why I am not able to get in the controller ?
Any help on this appreciated !
You can call the UserInfoEndpoint, as per your example, but you can also get additional claims if you define your ApiResource as requiring them.
For example, rather than just defining your ApiResource like you are:
new ApiResource("api1", "My API")
You can use the expanded format and define what UserClaims you'd like to have when getting an access token for this scope.
For example:
new ApiResource
{
Name = "api1",
ApiSecrets = { new Secret(*some secret*) },
UserClaims = {
JwtClaimTypes.Email,
JwtClaimTypes.PhoneNumber,
JwtClaimTypes.GivenName,
JwtClaimTypes.FamilyName,
JwtClaimTypes.PreferredUserName
},
Description = "My API",
DisplayName = "MyApi1",
Enabled = true,
Scopes = { new Scope("api1") }
}
Then in your own implementation of the IProfileService you will find that calls to GetProfileDataAsync have a list of what claims are requested in the context (ProfileDataRequestContext.RequestedClaimTypes). Given that list of what's been asked for, you can then add any claims you like - however you like - to the context.IssuedClaims that you return from that method. These will then be a part of the access token.
If you only want certain claims by specifically calling the UserInfo endpoint though, you'll want to create an IdentityResource definition and have that scope included as part of your original token request.
For example:
new IdentityResource
{
Name = "MyIdentityScope",
UserClaims = {
JwtClaimTypes.EmailVerified,
JwtClaimTypes.PhoneNumberVerified
}
}
But your first problem is following the other answer here so you don't get 'forbidden' as the response to the UserInfo endpoint!
Try sending the token along the request, when calling the UserInfoEndpoint. Try this:
var userInfoClient = new UserInfoClient(doc.UserInfoEndpoint, token);
var response = await userInfoClient.GetAsync();
var claims = response.Claims;
official docs
In ASP.NET MVC app, I am trying to implement authentication against external OIDC service. For my testing I am using IdentityServer3 (https://identityserver.github.io/Documentation/) and public OIDC demo server: https://mitreid.org/
I cloned this sample from GitHub: https://github.com/IdentityServer/IdentityServer3.Samples/tree/master/source/MVC%20Authentication
Then added the following code to register the public OIDC server as external login provider:
private void ConfigureIdentityProviders(IAppBuilder app, string signInAsType)
{
app.UseOpenIdConnectAuthentication(
new OpenIdConnectAuthenticationOptions
{
AuthenticationType = "<AuthTypeName>",
Authority = "https://mitreid.org/",
Caption = "MIT Test Server",
ClientId = "<Client Id>",
ClientSecret = "<Client Secret>",
RedirectUri = "https://localhost:44319/", //NOT SURE WHAT TO PUT HERE
ResponseType = "code",
Scope = "openid email profile",
SignInAsAuthenticationType = signInAsType
});
}
The code works, i get the option to login via external OIDC server. The browser redirects to the external server login page and when login and password is entered, the consent page is shown. However, after the browser navigates back to https://localhost:44319/ the user is not authenticated - User.Identity.IsAuthenticated is false.
Question: What should be correct value of RedirectUri property? Does OpenIdConnect middleware have capability to parse the authantication info passed in from external server or it must be coded manually? Is there any sample code how to do this?
I was studying the code and debugging quite a few hours (I am new to this) and I learned that:
This problem is related to OpenIdConnect OWIN Middleware implemented by Microsoft (https://github.com/aspnet/AspNetKatana/tree/dev/src/Microsoft.Owin.Security.OpenIdConnect).
The middleware from Microsoft expect that the OIDC server sends the message using HTTP POST, but the MIT server does HTTP GET
The middleware from Microsoft expect that there is id token along with code in the message obtained from OIDC server, but the MIT server sends only the code.
Looks like the RedirectUri can be any path under /identity because the middleware method AuthenticateCoreAsync() is hit on every request and it does compare request path to configured Options.CallbackPath (which is set from RedirectURI)
So I just had to implement the standard authorization code flow - exchange the code for id token, get claims, create authentication ticket and redirect to IdentityServer /identity/callback endpoint. When I've done this, everything started working. IdentityServer is awesome!
I inherited new set of classes from OpenIdConnect middleware and did override some methods. The key method is async Task<AuthenticationTicket> AuthenticateCoreAsync() in OpenIdConnectAuthenticationHandler. I pasted the code below in case it would help to someone.
public class CustomOidcHandler : OpenIdConnectAuthenticationHandler
{
private const string HandledResponse = "HandledResponse";
private readonly ILogger _logger;
private OpenIdConnectConfiguration _configuration;
public CustomOidcHandler(ILogger logger) : base(logger)
{
_logger = logger;
}
/// <summary>
/// Invoked to process incoming authentication messages.
/// </summary>
/// <returns>An <see cref="AuthenticationTicket"/> if successful.</returns>
protected override async Task<AuthenticationTicket> AuthenticateCoreAsync()
{
// Allow login to be constrained to a specific path. Need to make this runtime configurable.
if (Options.CallbackPath.HasValue && Options.CallbackPath != (Request.PathBase + Request.Path))
return null;
OpenIdConnectMessage openIdConnectMessage = null;
if (string.Equals(Request.Method, "GET", StringComparison.OrdinalIgnoreCase))
openIdConnectMessage = new OpenIdConnectMessage(Request.Query);
if (openIdConnectMessage == null)
return null;
ExceptionDispatchInfo authFailedEx = null;
try
{
return await CreateAuthenticationTicket(openIdConnectMessage).ConfigureAwait(false);
}
catch (Exception exception)
{
// We can't await inside a catch block, capture and handle outside.
authFailedEx = ExceptionDispatchInfo.Capture(exception);
}
if (authFailedEx != null)
{
_logger.WriteError("Exception occurred while processing message: ", authFailedEx.SourceException);
// Refresh the configuration for exceptions that may be caused by key rollovers. The user can also request a refresh in the notification.
if (Options.RefreshOnIssuerKeyNotFound && authFailedEx.SourceException.GetType() == typeof(SecurityTokenSignatureKeyNotFoundException))
Options.ConfigurationManager.RequestRefresh();
var authenticationFailedNotification = new AuthenticationFailedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions>(Context, Options)
{
ProtocolMessage = openIdConnectMessage,
Exception = authFailedEx.SourceException
};
await Options.Notifications.AuthenticationFailed(authenticationFailedNotification).ConfigureAwait(false);
if (authenticationFailedNotification.HandledResponse)
return GetHandledResponseTicket();
if (authenticationFailedNotification.Skipped)
return null;
authFailedEx.Throw();
}
return null;
}
private async Task<AuthenticationTicket> CreateAuthenticationTicket(OpenIdConnectMessage openIdConnectMessage)
{
var messageReceivedNotification =
new MessageReceivedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions>(Context, Options)
{
ProtocolMessage = openIdConnectMessage
};
await Options.Notifications.MessageReceived(messageReceivedNotification).ConfigureAwait(false);
if (messageReceivedNotification.HandledResponse)
{
return GetHandledResponseTicket();
}
if (messageReceivedNotification.Skipped)
{
return null;
}
// runtime always adds state, if we don't find it OR we failed to 'unprotect' it this is not a message we
// should process.
AuthenticationProperties properties = GetPropertiesFromState(openIdConnectMessage.State);
if (properties == null)
{
_logger.WriteWarning("The state field is missing or invalid.");
return null;
}
// devs will need to hook AuthenticationFailedNotification to avoid having 'raw' runtime errors displayed to users.
if (!string.IsNullOrWhiteSpace(openIdConnectMessage.Error))
{
throw new OpenIdConnectProtocolException(
string.Format(CultureInfo.InvariantCulture,
openIdConnectMessage.Error,
"Exception_OpenIdConnectMessageError", openIdConnectMessage.ErrorDescription ?? string.Empty,
openIdConnectMessage.ErrorUri ?? string.Empty));
}
// tokens.Item1 contains id token
// tokens.Item2 contains access token
Tuple<string, string> tokens = await GetTokens(openIdConnectMessage.Code, Options)
.ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(openIdConnectMessage.IdToken))
openIdConnectMessage.IdToken = tokens.Item1;
var securityTokenReceivedNotification =
new SecurityTokenReceivedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions>(Context,
Options)
{
ProtocolMessage = openIdConnectMessage,
};
await Options.Notifications.SecurityTokenReceived(securityTokenReceivedNotification).ConfigureAwait(false);
if (securityTokenReceivedNotification.HandledResponse)
return GetHandledResponseTicket();
if (securityTokenReceivedNotification.Skipped)
return null;
if (_configuration == null)
_configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.Request.CallCancelled)
.ConfigureAwait(false);
// Copy and augment to avoid cross request race conditions for updated configurations.
TokenValidationParameters tvp = Options.TokenValidationParameters.Clone();
IEnumerable<string> issuers = new[] {_configuration.Issuer};
tvp.ValidIssuers = tvp.ValidIssuers?.Concat(issuers) ?? issuers;
tvp.IssuerSigningTokens = tvp.IssuerSigningTokens?.Concat(_configuration.SigningTokens) ?? _configuration.SigningTokens;
SecurityToken validatedToken;
ClaimsPrincipal principal =
Options.SecurityTokenHandlers.ValidateToken(openIdConnectMessage.IdToken, tvp, out validatedToken);
ClaimsIdentity claimsIdentity = principal.Identity as ClaimsIdentity;
var claims = await GetClaims(tokens.Item2).ConfigureAwait(false);
AddClaim(claims, claimsIdentity, "sub", ClaimTypes.NameIdentifier, Options.AuthenticationType);
AddClaim(claims, claimsIdentity, "given_name", ClaimTypes.GivenName);
AddClaim(claims, claimsIdentity, "family_name", ClaimTypes.Surname);
AddClaim(claims, claimsIdentity, "preferred_username", ClaimTypes.Name);
AddClaim(claims, claimsIdentity, "email", ClaimTypes.Email);
// claims principal could have changed claim values, use bits received on wire for validation.
JwtSecurityToken jwt = validatedToken as JwtSecurityToken;
AuthenticationTicket ticket = new AuthenticationTicket(claimsIdentity, properties);
if (Options.ProtocolValidator.RequireNonce)
{
if (String.IsNullOrWhiteSpace(openIdConnectMessage.Nonce))
openIdConnectMessage.Nonce = jwt.Payload.Nonce;
// deletes the nonce cookie
RetrieveNonce(openIdConnectMessage);
}
// remember 'session_state' and 'check_session_iframe'
if (!string.IsNullOrWhiteSpace(openIdConnectMessage.SessionState))
ticket.Properties.Dictionary[OpenIdConnectSessionProperties.SessionState] = openIdConnectMessage.SessionState;
if (!string.IsNullOrWhiteSpace(_configuration.CheckSessionIframe))
ticket.Properties.Dictionary[OpenIdConnectSessionProperties.CheckSessionIFrame] =
_configuration.CheckSessionIframe;
if (Options.UseTokenLifetime)
{
// Override any session persistence to match the token lifetime.
DateTime issued = jwt.ValidFrom;
if (issued != DateTime.MinValue)
{
ticket.Properties.IssuedUtc = issued.ToUniversalTime();
}
DateTime expires = jwt.ValidTo;
if (expires != DateTime.MinValue)
{
ticket.Properties.ExpiresUtc = expires.ToUniversalTime();
}
ticket.Properties.AllowRefresh = false;
}
var securityTokenValidatedNotification =
new SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions>(Context,
Options)
{
AuthenticationTicket = ticket,
ProtocolMessage = openIdConnectMessage,
};
await Options.Notifications.SecurityTokenValidated(securityTokenValidatedNotification).ConfigureAwait(false);
if (securityTokenValidatedNotification.HandledResponse)
{
return GetHandledResponseTicket();
}
if (securityTokenValidatedNotification.Skipped)
{
return null;
}
// Flow possible changes
ticket = securityTokenValidatedNotification.AuthenticationTicket;
// there is no hash of the code (c_hash) in the jwt obtained from the server
// I don't know how to perform the validation using ProtocolValidator without the hash
// that is why the code below is commented
//var protocolValidationContext = new OpenIdConnectProtocolValidationContext
//{
// AuthorizationCode = openIdConnectMessage.Code,
// Nonce = nonce
//};
//Options.ProtocolValidator.Validate(jwt, protocolValidationContext);
if (openIdConnectMessage.Code != null)
{
var authorizationCodeReceivedNotification = new AuthorizationCodeReceivedNotification(Context, Options)
{
AuthenticationTicket = ticket,
Code = openIdConnectMessage.Code,
JwtSecurityToken = jwt,
ProtocolMessage = openIdConnectMessage,
RedirectUri =
ticket.Properties.Dictionary.ContainsKey(OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey)
? ticket.Properties.Dictionary[OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey]
: string.Empty,
};
await Options.Notifications.AuthorizationCodeReceived(authorizationCodeReceivedNotification)
.ConfigureAwait(false);
if (authorizationCodeReceivedNotification.HandledResponse)
{
return GetHandledResponseTicket();
}
if (authorizationCodeReceivedNotification.Skipped)
{
return null;
}
// Flow possible changes
ticket = authorizationCodeReceivedNotification.AuthenticationTicket;
}
return ticket;
}
private static void AddClaim(IEnumerable<Tuple<string, string>> claims, ClaimsIdentity claimsIdentity, string key, string claimType, string issuer = null)
{
string subject = claims
.Where(it => it.Item1 == key)
.Select(x => x.Item2).SingleOrDefault();
if (!string.IsNullOrWhiteSpace(subject))
claimsIdentity.AddClaim(
new System.Security.Claims.Claim(claimType, subject, ClaimValueTypes.String, issuer));
}
private async Task<Tuple<string, string>> GetTokens(string authorizationCode, OpenIdConnectAuthenticationOptions options)
{
// exchange authorization code at authorization server for an access and refresh token
Dictionary<string, string> post = null;
post = new Dictionary<string, string>
{
{"client_id", options.ClientId},
{"client_secret", options.ClientSecret},
{"grant_type", "authorization_code"},
{"code", authorizationCode},
{"redirect_uri", options.RedirectUri}
};
string content;
using (var client = new HttpClient())
{
var postContent = new FormUrlEncodedContent(post);
var response = await client.PostAsync(options.Authority.TrimEnd('/') + "/token", postContent)
.ConfigureAwait(false);
content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
// received tokens from authorization server
var json = JObject.Parse(content);
var accessToken = json["access_token"].ToString();
string idToken = null;
if (json["id_token"] != null)
idToken = json["id_token"].ToString();
return new Tuple<string, string>(idToken, accessToken);
}
private async Task<IEnumerable<Tuple<string, string>>> GetClaims(string accessToken)
{
string userInfoEndpoint = Options.Authority.TrimEnd('/') + "/userinfo";
var userInfoClient = new UserInfoClient(new Uri(userInfoEndpoint), accessToken);
var userInfoResponse = await userInfoClient.GetAsync().ConfigureAwait(false);
var claims = userInfoResponse.Claims;
return claims;
}
private static AuthenticationTicket GetHandledResponseTicket()
{
return new AuthenticationTicket(null, new AuthenticationProperties(new Dictionary<string, string>() { { HandledResponse, "true" } }));
}
private AuthenticationProperties GetPropertiesFromState(string state)
{
// assume a well formed query string: <a=b&>OpenIdConnectAuthenticationDefaults.AuthenticationPropertiesKey=kasjd;fljasldkjflksdj<&c=d>
int startIndex = 0;
if (string.IsNullOrWhiteSpace(state) || (startIndex = state.IndexOf("OpenIdConnect.AuthenticationProperties", StringComparison.Ordinal)) == -1)
{
return null;
}
int authenticationIndex = startIndex + "OpenIdConnect.AuthenticationProperties".Length;
if (authenticationIndex == -1 || authenticationIndex == state.Length || state[authenticationIndex] != '=')
{
return null;
}
// scan rest of string looking for '&'
authenticationIndex++;
int endIndex = state.Substring(authenticationIndex, state.Length - authenticationIndex).IndexOf("&", StringComparison.Ordinal);
// -1 => no other parameters are after the AuthenticationPropertiesKey
if (endIndex == -1)
{
return Options.StateDataFormat.Unprotect(Uri.UnescapeDataString(state.Substring(authenticationIndex).Replace('+', ' ')));
}
else
{
return Options.StateDataFormat.Unprotect(Uri.UnescapeDataString(state.Substring(authenticationIndex, endIndex).Replace('+', ' ')));
}
}
}
public static class CustomOidcAuthenticationExtensions
{
/// <summary>
/// Adds the <see cref="OpenIdConnectAuthenticationMiddleware"/> into the OWIN runtime.
/// </summary>
/// <param name="app">The <see cref="IAppBuilder"/> passed to the configuration method</param>
/// <param name="openIdConnectOptions">A <see cref="OpenIdConnectAuthenticationOptions"/> contains settings for obtaining identities using the OpenIdConnect protocol.</param>
/// <returns>The updated <see cref="IAppBuilder"/></returns>
public static IAppBuilder UseCustomOidcAuthentication(this IAppBuilder app, OpenIdConnectAuthenticationOptions openIdConnectOptions)
{
if (app == null)
throw new ArgumentNullException(nameof(app));
if (openIdConnectOptions == null)
throw new ArgumentNullException(nameof(openIdConnectOptions));
return app.Use(typeof(CustomOidcMiddleware), app, openIdConnectOptions);
}
}
and in Startup.cs
public class Startup
{
....
public void Configuration(IAppBuilder app)
{
....
private void ConfigureIdentityProviders(IAppBuilder app, string signInAsType)
{
app.UseCustomOidcAuthentication(
new OpenIdConnectAuthenticationOptions
{
AuthenticationType = "<name>",
Authority = "<OIDC server url>",
Caption = "<caption>",
ClientId = "<client id>",
ClientSecret = "<client secret>",
// might be https://localhost:44319/identity/<anything>
RedirectUri = "https://localhost:44319/identity/signin-customoidc",
ResponseType = "code",
Scope = "openid email profile address phone",
SignInAsAuthenticationType = signInAsType
}
);
}
....
}
....
}
I am working on a project where a third party provider will act as an Oauth2 based Authorization Server. An Asp.net MVC 5 based client which will send the user to the authorization server to authenticate (using login / password) and the auth server will return an access token back to the MVC client. Any further calls to resource servers (APIs) will be made using the access token.
To achieve this I am using Microsoft.Owin.Security.OpenIdConnect and the UseOpenIdConnectAuthentication extension. I am able to successfully redirect and get the access token from the auth server but the client is not creating an Authentication Cookie. Every time I try to access a secured page, I get the callback page with access token.
What am I missing here? My current code is below.
The secured controller action:
namespace MvcWebApp.Controllers
{
public class SecuredController : Controller
{
// GET: Secured
[Authorize]
public ActionResult Index()
{
return View((User as ClaimsPrincipal).Claims);
}
}
}
The Startup Class:
public class Startup
{
public void Configuration(IAppBuilder app)
{
app.SetDefaultSignInAsAuthenticationType("ClientCookie");
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationMode = AuthenticationMode.Active,
AuthenticationType = "ClientCookie",
CookieName = CookieAuthenticationDefaults.CookiePrefix + "ClientCookie",
ExpireTimeSpan = TimeSpan.FromMinutes(5)
});
// ***************************************************************************
// Approach 1 : ResponseType = "id_token token"
// ***************************************************************************
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
AuthenticationMode = AuthenticationMode.Active,
AuthenticationType = OpenIdConnectAuthenticationDefaults.AuthenticationType,
SignInAsAuthenticationType = app.GetDefaultSignInAsAuthenticationType(),
Authority = "https://thirdparty.com.au/oauth2",
ClientId = "_Th4GVMa0JSrJ8RKcZrzbcexk5ca",
ClientSecret = "a3GVJJbLHkrn9nJRj3IGNvk5eGQa",
RedirectUri = "http://mvcwebapp.local/",
ResponseType = "id_token token",
Scope = "openid",
Configuration = new OpenIdConnectConfiguration
{
AuthorizationEndpoint = "https://thirdparty.com.au/oauth2/authorize",
TokenEndpoint = "https://thirdparty.com.au/oauth2/token",
UserInfoEndpoint = "https://thirdparty.com.au/oauth2/userinfo",
},
Notifications = new OpenIdConnectAuthenticationNotifications
{
SecurityTokenValidated = n =>
{
var token = n.ProtocolMessage.AccessToken;
// persist access token in cookie
if (!string.IsNullOrEmpty(token))
{
n.AuthenticationTicket.Identity.AddClaim(
new Claim("access_token", token));
}
return Task.FromResult(0);
},
AuthenticationFailed = notification =>
{
if (string.Equals(notification.ProtocolMessage.Error, "access_denied", StringComparison.Ordinal))
{
notification.HandleResponse();
notification.Response.Redirect("/");
}
return Task.FromResult<object>(null);
}
}
});
// ***************************************************************************
// Approach 2 : ResponseType = "code"
// ***************************************************************************
//app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
//{
// AuthenticationMode = AuthenticationMode.Active,
// AuthenticationType = OpenIdConnectAuthenticationDefaults.AuthenticationType,
// SignInAsAuthenticationType = app.GetDefaultSignInAsAuthenticationType(),
// Authority = "https://thirdparty.com.au/oauth2",
// ClientId = "_Th4GVMa0JSrJ8RKcZrzbcexk5ca",
// ClientSecret = "a3GVJJbLHkrn9nJRj3IGNvk5eGQa",
// RedirectUri = "http://mvcwebapp.local/",
// ResponseType = "code",
// Scope = "openid",
// Configuration = new OpenIdConnectConfiguration
// {
// AuthorizationEndpoint = "https://thirdparty.com.au/oauth2/authorize",
// TokenEndpoint = "https://thirdparty.com.au/oauth2/token",
// UserInfoEndpoint = "https://thirdparty.com.au/oauth2/userinfo",
// },
// Notifications = new OpenIdConnectAuthenticationNotifications
// {
// AuthorizationCodeReceived = async (notification) =>
// {
// using (var client = new HttpClient())
// {
// var configuration = await notification.Options.ConfigurationManager.GetConfigurationAsync(notification.Request.CallCancelled);
// var request = new HttpRequestMessage(HttpMethod.Get, configuration.TokenEndpoint);
// request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
// {
// {OpenIdConnectParameterNames.ClientId, notification.Options.ClientId},
// {OpenIdConnectParameterNames.ClientSecret, notification.Options.ClientSecret},
// {OpenIdConnectParameterNames.Code, notification.ProtocolMessage.Code},
// {OpenIdConnectParameterNames.GrantType, "authorization_code"},
// {OpenIdConnectParameterNames.ResponseType, "token"},
// {OpenIdConnectParameterNames.RedirectUri, notification.Options.RedirectUri}
// });
// var response = await client.SendAsync(request, notification.Request.CallCancelled);
// response.EnsureSuccessStatusCode();
// var payload = JObject.Parse(await response.Content.ReadAsStringAsync());
// // Add the access token to the returned ClaimsIdentity to make it easier to retrieve.
// notification.AuthenticationTicket.Identity.AddClaim(new Claim(
// type: OpenIdConnectParameterNames.AccessToken,
// value: payload.Value<string>(OpenIdConnectParameterNames.AccessToken)));
// }
// }
// }
//});
}
}
TL;DR: use ResponseType = "id_token token" and it should work.
In OpenID Connect, response_type=token is not considered as a legal value: http://openid.net/specs/openid-connect-core-1_0.html#Authentication.
Sometimes implemented for backward compatibility reasons, response_type=token is not supported by the OIDC middleware developed by MSFT: an exception is always thrown when no id_token is returned by the OpenID Connect provider (which also excludes the valid code flow). You can find more information on this other SO post.
(remark: in SecurityTokenValidated, you're replacing the ticket created by the OIDC middleware using n.AuthenticationTicket = new AuthenticationTicket(...): it's not the recommended approach and will result in a ClaimsIdentity missing the essential claims. You should consider removing the assignation and simply add new claims like you do for the access_token claim)
I'm trying to upgrade my MVC website to use the new OpenID Connect standard. The OWIN middleware seems to be pretty robust, but unfortunately only supports the
"form_post" response type. This means that Google isn't compatible, as it returns all the tokens in a the url after a "#", so they never reach the server and never trigger the middleware.
I've tried to trigger the response handlers in the middleware myself, but that doesn't seem to work at all, so I've got a simply javascript file that parses out the returned claims and POSTs them to a controller action for processing.
Problem is, even when I get them on the server side I can't parse them correctly. The error I get looks like this:
IDX10500: Signature validation failed. Unable to resolve
SecurityKeyIdentifier: 'SecurityKeyIdentifier
(
IsReadOnly = False,
Count = 1,
Clause[0] = System.IdentityModel.Tokens.NamedKeySecurityKeyIdentifierClause
),
token: '{
"alg":"RS256",
"kid":"073a3204ec09d050f5fd26460d7ddaf4b4ec7561"
}.
{
"iss":"accounts.google.com",
"sub":"100330116539301590598",
"azp":"1061880999501-b47blhmmeprkvhcsnqmhfc7t20gvlgfl.apps.googleusercontent.com",
"nonce":"7c8c3656118e4273a397c7d58e108eb1",
"email_verified":true,
"aud":"1061880999501-b47blhmmeprkvhcsnqmhfc7t20gvlgfl.apps.googleusercontent.com",
"iat":1429556543,"exp\":1429560143
}'."
}
My token verification code follows the example outlined by the good people developing IdentityServer
private async Task<IEnumerable<Claim>> ValidateIdentityTokenAsync(string idToken, string state)
{
// New Stuff
var token = new JwtSecurityToken(idToken);
var jwtHandler = new JwtSecurityTokenHandler();
byte[][] certBytes = getGoogleCertBytes();
for (int i = 0; i < certBytes.Length; i++)
{
var certificate = new X509Certificate2(certBytes[i]);
var certToken = new X509SecurityToken(certificate);
// Set up token validation
var tokenValidationParameters = new TokenValidationParameters();
tokenValidationParameters.ValidAudience = googleClientId;
tokenValidationParameters.IssuerSigningToken = certToken;
tokenValidationParameters.ValidIssuer = "accounts.google.com";
try
{
// Validate
SecurityToken jwt;
var claimsPrincipal = jwtHandler.ValidateToken(idToken, tokenValidationParameters, out jwt);
if (claimsPrincipal != null)
{
// Valid
idTokenStatus = "Valid";
}
}
catch (Exception e)
{
if (idTokenStatus != "Valid")
{
// Invalid?
}
}
}
return token.Claims;
}
private byte[][] getGoogleCertBytes()
{
// The request will be made to the authentication server.
WebRequest request = WebRequest.Create(
"https://www.googleapis.com/oauth2/v1/certs"
);
StreamReader reader = new StreamReader(request.GetResponse().GetResponseStream());
string responseFromServer = reader.ReadToEnd();
String[] split = responseFromServer.Split(':');
// There are two certificates returned from Google
byte[][] certBytes = new byte[2][];
int index = 0;
UTF8Encoding utf8 = new UTF8Encoding();
for (int i = 0; i < split.Length; i++)
{
if (split[i].IndexOf(beginCert) > 0)
{
int startSub = split[i].IndexOf(beginCert);
int endSub = split[i].IndexOf(endCert) + endCert.Length;
certBytes[index] = utf8.GetBytes(split[i].Substring(startSub, endSub).Replace("\\n", "\n"));
index++;
}
}
return certBytes;
}
I know that Signature validation isn't completely necessary for JWTs but I haven't the slightest idea how to turn it off. Any ideas?
I thought I'd post my slightly improved version which uses JSON.Net to parse Googles' X509 Certificates and matches the key to use based on the "kid" (key-id). This is a bit more efficient than trying each certificate, since asymmetric crypto is usually quite expensive.
Also removed out-dated WebClient and manual string parsing code:
static Lazy<Dictionary<string, X509Certificate2>> Certificates = new Lazy<Dictionary<string, X509Certificate2>>( FetchGoogleCertificates );
static Dictionary<string, X509Certificate2> FetchGoogleCertificates()
{
using (var http = new HttpClient())
{
var json = http.GetStringAsync( "https://www.googleapis.com/oauth2/v1/certs" ).Result;
var dictionary = JsonConvert.DeserializeObject<Dictionary<string, string>>( json );
return dictionary.ToDictionary( x => x.Key, x => new X509Certificate2( Encoding.UTF8.GetBytes( x.Value ) ) );
}
}
JwtSecurityToken ValidateIdentityToken( string idToken )
{
var token = new JwtSecurityToken( idToken );
var jwtHandler = new JwtSecurityTokenHandler();
var certificates = Certificates.Value;
try
{
// Set up token validation
var tokenValidationParameters = new TokenValidationParameters();
tokenValidationParameters.ValidAudience = _clientId;
tokenValidationParameters.ValidIssuer = "accounts.google.com";
tokenValidationParameters.IssuerSigningTokens = certificates.Values.Select( x => new X509SecurityToken( x ) );
tokenValidationParameters.IssuerSigningKeys = certificates.Values.Select( x => new X509SecurityKey( x ) );
tokenValidationParameters.IssuerSigningKeyResolver = ( s, securityToken, identifier, parameters ) =>
{
return identifier.Select( x =>
{
if (!certificates.ContainsKey( x.Id ))
return null;
return new X509SecurityKey( certificates[ x.Id ] );
} ).First( x => x != null );
};
SecurityToken jwt;
var claimsPrincipal = jwtHandler.ValidateToken( idToken, tokenValidationParameters, out jwt );
return (JwtSecurityToken)jwt;
}
catch (Exception ex)
{
_trace.Error( typeof( GoogleOAuth2OpenIdHybridClient ).Name, ex );
return null;
}
}
The problem is the kid in the JWT whose value is the key identifier of the key was used to sign the JWT. Since you construct an array of certificates manually from the JWKs URI, you lose the key identifier information. The validation procedure however requires it.
You'll need to set tokenValidationParameters.IssuerSigningKeyResolver to a function that will return the same key that you set above in tokenValidationParameters.IssuerSigningToken. The purpose of this delegate is to instruct the runtime to ignore any 'matching' semantics and just try the key.
See this article for more information: JwtSecurityTokenHandler 4.0.0 Breaking Changes?
Edit: the code:
tokenValidationParameters.IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) => { return new X509SecurityKey(certificate); };
The folks at Microsoft posted code sample for Azure V2 B2C Preview endpoint that support OpenId Connect. See here, with the helper class OpenIdConnectionCachingSecurityTokenProvider the code is simplified as follows:
app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions
{
AccessTokenFormat = new JwtFormat(new TokenValidationParameters
{
ValidAudiences = new[] { googleClientId },
}, new OpenIdConnectCachingSecurityTokenProvider("https://accounts.google.com/.well-known/openid-configuration"))});
This class is necessary because the OAuthBearer Middleware does not leverage. The OpenID Connect metadata endpoint exposed by the STS by default.
public class OpenIdConnectCachingSecurityTokenProvider : IIssuerSecurityTokenProvider
{
public ConfigurationManager<OpenIdConnectConfiguration> _configManager;
private string _issuer;
private IEnumerable<SecurityToken> _tokens;
private readonly string _metadataEndpoint;
private readonly ReaderWriterLockSlim _synclock = new ReaderWriterLockSlim();
public OpenIdConnectCachingSecurityTokenProvider(string metadataEndpoint)
{
_metadataEndpoint = metadataEndpoint;
_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(metadataEndpoint);
RetrieveMetadata();
}
/// <summary>
/// Gets the issuer the credentials are for.
/// </summary>
/// <value>
/// The issuer the credentials are for.
/// </value>
public string Issuer
{
get
{
RetrieveMetadata();
_synclock.EnterReadLock();
try
{
return _issuer;
}
finally
{
_synclock.ExitReadLock();
}
}
}
/// <summary>
/// Gets all known security tokens.
/// </summary>
/// <value>
/// All known security tokens.
/// </value>
public IEnumerable<SecurityToken> SecurityTokens
{
get
{
RetrieveMetadata();
_synclock.EnterReadLock();
try
{
return _tokens;
}
finally
{
_synclock.ExitReadLock();
}
}
}
private void RetrieveMetadata()
{
_synclock.EnterWriteLock();
try
{
OpenIdConnectConfiguration config = _configManager.GetConfigurationAsync().Result;
_issuer = config.Issuer;
_tokens = config.SigningTokens;
}
finally
{
_synclock.ExitWriteLock();
}
}
}
Based on the answer from Johannes Rudolph I post my solution.
There is a compiler error in IssuerSigningKeyResolver Delegate which I had to solve.
This is my working code now:
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;
namespace QuapiNet.Service
{
public class JwtTokenValidation
{
public async Task<Dictionary<string, X509Certificate2>> FetchGoogleCertificates()
{
using (var http = new HttpClient())
{
var response = await http.GetAsync("https://www.googleapis.com/oauth2/v1/certs");
var dictionary = await response.Content.ReadAsAsync<Dictionary<string, string>>();
return dictionary.ToDictionary(x => x.Key, x => new X509Certificate2(Encoding.UTF8.GetBytes(x.Value)));
}
}
private string CLIENT_ID = "xxxxx.apps.googleusercontent.com";
public async Task<ClaimsPrincipal> ValidateToken(string idToken)
{
var certificates = await this.FetchGoogleCertificates();
TokenValidationParameters tvp = new TokenValidationParameters()
{
ValidateActor = false, // check the profile ID
ValidateAudience = true, // check the client ID
ValidAudience = CLIENT_ID,
ValidateIssuer = true, // check token came from Google
ValidIssuers = new List<string> { "accounts.google.com", "https://accounts.google.com" },
ValidateIssuerSigningKey = true,
RequireSignedTokens = true,
IssuerSigningKeys = certificates.Values.Select(x => new X509SecurityKey(x)),
IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
{
return certificates
.Where(x => x.Key.ToUpper() == kid.ToUpper())
.Select(x => new X509SecurityKey(x.Value));
},
ValidateLifetime = true,
RequireExpirationTime = true,
ClockSkew = TimeSpan.FromHours(13)
};
JwtSecurityTokenHandler jsth = new JwtSecurityTokenHandler();
SecurityToken validatedToken;
ClaimsPrincipal cp = jsth.ValidateToken(idToken, tvp, out validatedToken);
return cp;
}
}
}