Arduino securely consuming .net Core api? - c#

I have managed to setup my .Net core 2 API to require the user to login when he/she wants to consume it. The code for this is in Startup.cs:
public void ConfigureServices(IServiceCollection services)
{
services.Configure<CookiePolicyOptions>(options =>
{
// This lambda determines whether user consent for non-essential cookies is needed for a given request.
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection")));
services.AddDefaultIdentity<IdentityUser>()
.AddEntityFrameworkStores<ApplicationDbContext>();
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
services.Configure<IdentityOptions>(options =>
{
// Password settings
options.Password.RequireDigit = true;
options.Password.RequiredLength = 8;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = true;
options.Password.RequireLowercase = false;
options.Password.RequiredUniqueChars = 6;
// Lockout settings
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(60);
options.Lockout.MaxFailedAccessAttempts = 10;
options.Lockout.AllowedForNewUsers = true;
// User settings
options.User.RequireUniqueEmail = true;
});
services.ConfigureApplicationCookie(options =>
{
options.Cookie.HttpOnly = true;
options.ExpireTimeSpan = TimeSpan.FromMinutes(60);
options.LoginPath = "/Account/Login";
options.AccessDeniedPath = "/Account/AccessDenied";
options.SlidingExpiration = true;
});
services.AddApplicationServices(Configuration);
services.AddRouting();
services.Configure<RouteOptions>(options => options.LowercaseUrls = true);
services.AddSwagger(Configuration);
}
Now I'm hosting this on Azure and it seems to work. However, I need to ask how I would implement this if I want my Arduino to consume this API? Should I just create a "Arduino user" account and let the arduino post the credentials of this account and save the cookie? Is this a secure approach?

Instead of JWT or Cookies, use a pre-shared secret and some basic crypto. Use these to set custom headers on your http requests to your Azure API from the Arduino.
Make sure to store your pre-shared secret in your database and/or environment variables on your build machine via Azure Key Vault - don't check them into source code.
Use a decorator like this to secure your API controllers:
[UserKeyAuthorize]
public class MyController : Controller
{
// ...
}
Here's the implementation of the attribute+filter:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Enum | AttributeTargets.Interface | AttributeTargets.Delegate)]
public class UserKeyAuthorizeAttribute : TypeFilterAttribute
{
public UserKeyAuthorizeAttribute() : base(typeof(UserKeyFilter))
{
}
public class UserKeyFilter : IAsyncActionFilter
{
public static string CalculateHash(string stringToHash)
{
using (var sha = SHA256.Create())
{
var computedHash = sha.ComputeHash(Encoding.Unicode.GetBytes(stringToHash));
return Convert.ToBase64String(computedHash);
}
}
private const string Hash = "signature";
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// check key
if (!IsUserKeyValid(context.HttpContext.Request))
{
context.Result = new UnauthorizedResult();
}
else
{
await next();
}
}
public static bool IsUserKeyValid(HttpRequest request)
{
var map = new Dictionary<string, string>();
foreach (var header in request.Headers)
{
if (header.Key.StartsWith("x-"))
{
map.Add(header.Key, header.Value);
}
}
var headerSig = "";
foreach (var key in map.OrderBy(x=> x.Key))
{
headerSig = headerSig + "x-" + map[key.Key] + "-";
}
var nonce = request.Headers["nonce"].FirstOrDefault();
headerSig = headerSig + nonce + "-" + Environment.GetEnvironmentVariable("ApiKey");
var hash = CalculateHash(headerSig);
var signature = request.Headers[Hash].FirstOrDefault<string>();
return hash == signature;
}
}
}
On your Arduino, you can then set headers on your api requests, and hash them with the pre-shared key and send the hash as a further header so the server can verify you. https://github.com/simonratner/Arduino-SHA-256 and https://techtutorialsx.com/2016/07/21/esp8266-post-requests/ should help you with the client code, here's what works for me in C#:
public static async Task<HttpResponseMessage> SignedGetRequest(this HttpClient client, string url, Dictionary<string, string> data)
{
var request = new HttpRequestMessage()
{
RequestUri = new Uri(url),
Method = HttpMethod.Get
};
var keys = data.Keys.OrderBy(x => x);
var headerSig = "";
foreach (var key in keys)
{
headerSig = headerSig + "x-" + data[key] + "-";
request.Headers.Add("x-" + key, data[key]);
}
// TODO: Time based nonce hash
var nonce = String.Format("{0:r}", DateTime.Now);
headerSig = headerSig + nonce + "-" + Environment.GetEnvironmentVariable("ApiKey");
var hash = Security.CalculateHash(headerSig);
request.Headers.Add("signature", hash);
request.Headers.Add("nonce", nonce);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return await client.SendAsync(request);
}
You can set any user or app level info in headers like e.g. "x-user-id" to identify individual clients to the server.
For setting the key on Azure so that Environment.GetEnvironmentVariable("ApiKey") resolves, see https://stackoverflow.com/a/34622196

Related

.Net 6 API - Certificate Authentication - Not working 403 response received

I am working on an Azure Function that needs to send a certificate to an API that will authenticate the request and return the data accordingly.
I am successfully getting the certificate from Azure Key Vault as a X509Certificate2. This is making a call (will be making several) to my API I am getting a 403 response.
I have followed this configuration https://learn.microsoft.com/en-us/aspnet/core/security/authentication/certauth?view=aspnetcore-6.0 and my code currently looks like this
-- Program.cs
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<KestrelServerOptions>(options =>
{
options.ConfigureHttpsDefaults(options =>
options.ClientCertificateMode = ClientCertificateMode.AllowCertificate);
});
builder.Services.AddAuthentication(
CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(options =>
{
options.Events = new CertificateAuthenticationEvents
{
OnCertificateValidated = context =>
{
var validationService = context.HttpContext.RequestServices
.GetRequiredService<ICertificateValidationService>();
if (validationService.ValidateCertificate(context.ClientCertificate).Result)
{
var claims = new[]
{
new Claim(
ClaimTypes.NameIdentifier,
context.ClientCertificate.Subject,
ClaimValueTypes.String, context.Options.ClaimsIssuer),
new Claim(
ClaimTypes.Name,
context.ClientCertificate.Subject,
ClaimValueTypes.String, context.Options.ClaimsIssuer)
};
context.Principal = new ClaimsPrincipal(
new ClaimsIdentity(claims, context.Scheme.Name));
context.Success();
}
return Task.CompletedTask;
},
OnAuthenticationFailed = failedContext =>
{
failedContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return Task.CompletedTask;
}
};
});
-- Validation Service
public class CertificateValidationService : ICertificateValidationService
{
private readonly IKeyVaultCertificateService _keyVaultService;
private readonly KeyVaultConfiguration _keyVauyltConfiguration;
public CertificateValidationService(IKeyVaultCertificateService keyVaultService, IOptions<KeyVaultConfiguration> keyVauyltConfiguration)
{
_keyVaultService = keyVaultService;
_keyVauyltConfiguration = keyVauyltConfiguration.Value;
}
public async Task<bool> ValidateCertificate(X509Certificate2 clientCertificate)
{
X509Certificate2 expectedCertificate = await _keyVaultService.GetX509CertificateAsync(_keyVauyltConfiguration.CertificateName);
return clientCertificate.Thumbprint == expectedCertificate.Thumbprint;
}
}
-- Controller
[Authorize(AuthenticationSchemes = CertificateAuthenticationDefaults.AuthenticationScheme)]
public IActionResult GetCount()
{
/// removed
}
When the request is made I am not hitting the method ValidateCertificate and I am just getting a 403 response.
I am having the same when I make a request through Postman and send the certificate in the request there too.
I would be grateful if someone could help me as I am stuck without success right now
--- Edit 1
_client = new HttpClient();
if (_erpConfiguration.UserCertificateAuthentication)
{
X509Certificate2 certificate = _keyVaultCertificateService
.GetX509CertificateAsync(_keyVaultConfiguration.CertificateName).Result;
var handler = new HttpClientHandler();
handler.ClientCertificates.Add(certificate);
_client = new HttpClient(handler);
}
var countHttpResponse = await _client.GetAsync(fullUri);
--- End Edit 1
--- Edit 2
I am getting the certificate from KeyVault like this
public async Task<KeyVaultCertificateWithPolicy> GetCertificateWithPolicyAsync(string certificateName)
{
Response<KeyVaultCertificateWithPolicy>? certificate = await _certificateClient.GetCertificateAsync(certificateName);
return certificate;
}
public async Task<X509Certificate2> GetX509CertificateAsync(string certificateName)
{
KeyVaultCertificateWithPolicy certificate = await GetCertificateWithPolicyAsync(certificateName);
var certContent = certificate.Cer;
return new X509Certificate2(certContent);
}
--- End Edit 2
--- Edit 3
public async Task<X509Certificate2> GetX509CertificateAsync(string certificateName)
{
KeyVaultCertificateWithPolicy certificate = await GetCertificateWithPolicyAsync(certificateName);
// Return a certificate with only the public key if the private key is not exportable.
if (certificate.Policy?.Exportable != true)
{
return new X509Certificate2(certificate.Cer);
}
// Parse the secret ID and version to retrieve the private key.
string[] segments = certificate.SecretId.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 3)
{
throw new InvalidOperationException($"Number of segments is incorrect: {segments.Length}, URI: {certificate.SecretId}");
}
string secretName = segments[1];
string secretVersion = segments[2];
KeyVaultSecret secret = await _secretClient.GetSecretAsync(secretName, secretVersion, CancellationToken.None);
// For PEM, you'll need to extract the base64-encoded message body.
if (!"application/x-pkcs12".Equals(secret.Properties.ContentType,
StringComparison.InvariantCultureIgnoreCase))
{
throw new VerificationException("Unable to validate certificate");
}
byte[] pfx = Convert.FromBase64String(secret.Value);
return new X509Certificate2(pfx);
}
--- End Edit 3

Cookies are not refreshed in .NET Core 3.1

I have an MVC .NET Core 3.1 application that uses Open ID connect for authentication and stores identity & tokens in cookies. The tokens need to be refreshed as they are used in some API requests our application does. I subscribe to ValidatePrincipal event and refresh the tokens there. The request goes OK, but cookies are not updated for some reason.
Startup.cs:
services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
options.OnAppendCookie = (e) =>
{
e.CookieOptions.Domain = <some domain>
};
});
...
.AddCookie(options =>
{
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.Cookie.Domain = <some domain>;
options.Cookie.IsEssential = true;
options.Cookie.Name = <some name>
options.EventsType = typeof(CookieAuthEvents);
})
CookieAuthEvents.cs (constructor, member declarations and logging are omitted):
public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
{
if (context.Principal.Identity.IsAuthenticated)
{
var now = DateTime.UtcNow;
var tokenExpiration = context.Properties.GetTokenExpiration();
if (now > tokenExpiration)
{
await UpdateCookies(context);
}
}
}
private async Task UpdateCookies(CookieValidatePrincipalContext context)
{
var refreshToken = context.Properties.GetTokenValue(OpenIdConnectGrantTypes.RefreshToken);
if (String.IsNullOrEmpty(refreshToken))
{
return;
}
var response = await GetTokenClient().RequestRefreshTokenAsync(refreshToken);
if (!response.IsError)
{
WriteCookies(context, response);
}
else
{
context.RejectPrincipal();
}
}
private void WriteCookies(CookieValidatePrincipalContext context, TokenResponse response)
{
var tokens = new List<AuthenticationToken>
{
new AuthenticationToken
{
Name = OpenIdConnectParameterNames.IdToken,
Value = response.IdentityToken
},
new AuthenticationToken
{
Name = OpenIdConnectParameterNames.AccessToken,
Value = response.AccessToken
},
new AuthenticationToken
{
Name = OpenIdConnectParameterNames.RefreshToken,
Value = response.RefreshToken
},
new AuthenticationToken
{
Name = OpenIdConnectParameterNames.TokenType,
Value = response.TokenType
}
};
var expiresAt = DateTime.UtcNow.AddSeconds(response.ExpiresIn);
tokens.Add(new AuthenticationToken
{
Name = "expires_at",
Value = expiresAt.ToString("o", CultureInfo.InvariantCulture)
});
var newPrincipal = GetNewPrincipal(context);
context.ReplacePrincipal(newPrincipal);
context.Properties.StoreTokens(tokens);
context.ShouldRenew = true;
}
private static ClaimsPrincipal GetNewPrincipal(CookieValidatePrincipalContext context)
{
var claims = context.Principal.Claims.Select(c => new Claim(c.Type, c.Value)).ToList();
var authTimeClaim = claims.FirstOrDefault(claim => claim.Type.Same("auth_time"));
if (authTimeClaim != null)
{
claims.Remove(authTimeClaim);
}
claims.Add(new Claim("auth_time", DateTime.UtcNow.UnixTimestamp().ToString()));
return new ClaimsPrincipal(new ClaimsIdentity(claims, context.Principal.Identity.AuthenticationType));
}
The main problem is this works perfectly fine locally. All the calls are fine, cookies are refreshed correctly. But when the app is run from dev machine (we host it in Azure App Service) RequestRefreshTokenAsync call is successful, but the cookies are not updated therefore all the next calls are made with an old tokens leading to 400 invalid_grant error. So basically what I see in logs is:
RequestRefreshTokenAsync successful. The old refresh_token is used to get a new one
ValidatePrincipal successful. Here the cookies should be rewritten (including a new refresh_token)
Next request - RequestRefreshTokenAsync failed. The old refresh_token is used (even though it's invalid).
I tried playing with cookies configurations and placing semaphores and locks inside ValidatePrincipal method but none of it worked.
Does anyone have an idea what can cause that?

AccessToken from Azure ADB2C in MVC5 application

We are working on a MVC5 web application, that uses OpenIdConnect to authenticate to Azure AD B2C. When a user has authenticated, we would like to be able to acquire accesstokens from Azure AD B2C, in order to use them our API.
This is our Startup.cs-equivalent code:
protected override void ProcessCore(IdentityProvidersArgs args)
{
Assert.ArgumentNotNull(args, nameof(args));
List<B2CConfig> ssoSettings = _ssoConfigurationRepository.GetAllSettings();
foreach (var config in ssoSettings)
{
args.App.UseOpenIdConnectAuthentication(CreateOptionsFromSiteConfig(config));
}
}
private OpenIdConnectAuthenticationOptions CreateOptionsFromSiteConfig(B2CConfig config)
{
OpenIdConnectAuthenticationOptions options = new OpenIdConnectAuthenticationOptions();
options.MetadataAddress = string.Format(_aadInstance, _tenant, config.Policy);
options.AuthenticationType = config.Policy;
options.AuthenticationMode = AuthenticationMode.Passive;
options.RedirectUri = config.AzureReplyUri;
options.PostLogoutRedirectUri = config.LogoutRedirectUri;
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "emails"
};
var identityProvider = GetIdentityProvider();
options.Notifications = new OpenIdConnectAuthenticationNotifications()
{
AuthenticationFailed = AuthenticationFailed,
RedirectToIdentityProvider = notification =>
{
return Task.FromResult(notification.ProtocolMessage.UiLocales = config.UiLocale ?? string.Empty);
},
SecurityTokenValidated = notification =>
{
notification.AuthenticationTicket.Identity.AddClaim(new Claim("idp", "azureadb2c"));
// transform all claims
ClaimsIdentity identity = notification.AuthenticationTicket.Identity;
notification.AuthenticationTicket.Identity.ApplyClaimsTransformations(new TransformationContext(FederatedAuthenticationConfiguration, identityProvider));
return Task.CompletedTask;
}
};
options.ClientId = config.ClientId;
options.Scope = "openid";
options.ResponseType = "id_token";
return options;
}
private Task AuthenticationFailed(AuthenticationFailedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification)
{
notification.HandleResponse();
// Handle the error code that Azure AD B2C throws when trying to reset a password from the login page
// because password reset is not supported by a "sign-up or sign-in policy"
if (notification.ProtocolMessage.ErrorDescription != null && notification.ProtocolMessage.ErrorDescription.Contains("AADB2C90118"))
{
SsoLogger.Warn("User triggered reset password");
notification.Response.Redirect(SsoConfiguration.Routes.ResetPassword);
}
else if (notification.Exception.Message == "access_denied")
{
notification.Response.Redirect("/");
}
else
{
SsoLogger.Warn("AuthenticationFailed", notification.Exception);
notification.Response.Redirect(SsoConfiguration.Routes.LoginError);
}
return Task.FromResult(0);
}
In Asp.Net core it seems like you would call GetTokenAsync on the HttpContext, but that extensionmethod is not available in .NET 4.72.
Can anyone help figuring out, how to retrieve an accesstoken from AzureAD B2C, that can be used in the calls to our WebApi? Or can I just store the accesstoken I get from the SecurityTokenValidated event and use that for all API requests?
This is a possible solution: Is it safe to store an access_token in a user claim for authorization?
Notifications = new OpenIdConnectAuthenticationNotifications
{
SecurityTokenValidated = notification =>
{
var identity = notification.AuthenticationTicket.Identity;
identity.AddClaim(claim: new Claim(type: "auth_token", value:
notification.ProtocolMessage.AccessToken));
return Task.CompletedTask;
}
}
If anyone has a better approach I will gladly accept another answer.

Manually generate IdentityServer4 reference token and save to PersistedGrants table

I have learnt IdentityServer4 for a week and successfully implemented a simple authentication flow with ResourceOwnedPassword flow.
Now, I'm implementing Google authentication with IdentityServer4 by following this tutorial
This is what I am doing:
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
//...
const string connectionString = #"Data Source=.\SQLEXPRESS;database=IdentityServer4.Quickstart.EntityFramework-2.0.0;trusted_connection=yes;";
var migrationsAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;
services.AddIdentityServer()
.AddDeveloperSigningCredential()
.AddProfileService<IdentityServerProfileService>()
.AddResourceOwnerValidator<IdentityResourceOwnerPasswordValidator>()
// this adds the config data from DB (clients, resources)
.AddConfigurationStore(options =>
{
options.ConfigureDbContext = builder =>
{
builder.UseSqlServer(connectionString,
sql => sql.MigrationsAssembly(migrationsAssembly));
};
})
// this adds the operational data from DB (codes, tokens, consents)
.AddOperationalStore(options =>
{
options.ConfigureDbContext = builder =>
builder.UseSqlServer(connectionString,
sql => sql.MigrationsAssembly(migrationsAssembly));
// this enables automatic token cleanup. this is optional.
options.EnableTokenCleanup = true;
options.TokenCleanupInterval = 30;
});
// Add jwt validation.
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddIdentityServerAuthentication(options =>
{
// base-address of your identityserver
options.Authority = "https://localhost:44386";
options.ClaimsIssuer = "https://localhost:44386";
// name of the API resource
options.ApiName = "api1";
options.ApiSecret = "secret";
options.RequireHttpsMetadata = false;
options.SupportedTokens = SupportedTokens.Reference;
});
//...
}
** Google controller (Which is for handling returned token from Google **
public class GLoginController : Controller
{
#region Properties
private readonly IPersistedGrantStore _persistedGrantStore;
private readonly IUserFactory _userFactory;
private readonly IBaseTimeService _baseTimeService;
private readonly ITokenCreationService _tokenCreationService;
private readonly IReferenceTokenStore _referenceTokenStore;
private readonly IBaseEncryptionService _baseEncryptionService;
#endregion
#region Constructor
public GLoginController(IPersistedGrantStore persistedGrantStore,
IBaseTimeService basetimeService,
ITokenCreationService tokenCreationService,
IReferenceTokenStore referenceTokenStore,
IBaseEncryptionService baseEncryptionService,
IUserFactory userFactory)
{
_persistedGrantStore = persistedGrantStore;
_baseTimeService = basetimeService;
_userFactory = userFactory;
_tokenCreationService = tokenCreationService;
_referenceTokenStore = referenceTokenStore;
_baseEncryptionService = baseEncryptionService;
}
#endregion
#region Methods
[HttpGet("login")]
[AllowAnonymous]
public IActionResult Login()
{
var authenticationProperties = new AuthenticationProperties
{
RedirectUri = "/api/google/handle-external-login"
};
return Challenge(authenticationProperties, "Google");
}
[HttpGet("handle-external-login")]
//[Authorize("ExternalCookie")]
[AllowAnonymous]
public async Task<IActionResult> HandleExternalLogin()
{
//Here we can retrieve the claims
var authenticationResult = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
var principal = authenticationResult.Principal;
var emailAddress = principal.FindFirst(ClaimTypes.Email)?.Value;
if (string.IsNullOrEmpty(emailAddress))
return NotFound(new ApiMessageViewModel("Email is not found"));
// Find user by using username.
var loadUserConditions = new LoadUserModel();
loadUserConditions.Usernames = new HashSet<string> { emailAddress };
loadUserConditions.Pagination = new PaginationValueObject(1, 1);
// Find users asynchronously.
var loadUsersResult = await _userFactory.FindUsersAsync(loadUserConditions);
var user = loadUsersResult.FirstOrDefault();
// User is not defined.
if (user == null)
{
user = new User(Guid.NewGuid(), emailAddress);
user.Email = emailAddress;
user.HashedPassword = _baseEncryptionService.Md5Hash("abcde12345-");
user.JoinedTime = _baseTimeService.DateTimeUtcToUnix(DateTime.UtcNow);
user.Kind = UserKinds.Google;
user.Status = UserStatuses.Active;
//await _userFactory.AddUserAsync(user);
}
else
{
// User is not google account.
if (user.Kind != UserKinds.Google)
return Forbid("User is not allowed to access system.");
}
var token = new Token(IdentityServerConstants.TokenTypes.IdentityToken);
var userCredential = new UserCredential(user);
token.Claims = userCredential.GetClaims();
token.AccessTokenType = AccessTokenType.Reference;
token.ClientId = "ro.client";
token.CreationTime = DateTime.UtcNow;
token.Audiences = new[] {"api1"};
token.Lifetime = 3600;
return Ok();
}
#endregion
}
Everything is fine, I can get claims returned from Google OAuth2, find users in database using Google email address and register them if they dont have any acounts.
My question is: How can I use Google OAuth2 claims that I receive in HandleExternalLogin method to generate a Reference Token, save it to PersistedGrants table and return to client.
This means when user access https://localhost:44386/api/google/login, after being redirected to Google consent screen, they can receive access_token, refresh_token that has been generated by IdentityServer4.
Thank you,
In IdentityServer the kind (jwt of reference) of the token is
configurable for each client (application), requested the token.
AccessTokenType.Reference is valid for TokenTypes.AccessToken not
TokenTypes.IdentityToken as in your snippet.
In general it would be simpler to follow the original quickstart and then extend the generic code up to your needs. What I can see now in the snippet above is just your specific stuff and not the default part, responsible for creating the IdSrv session and redirecting back to the client.
If you still like to create a token manually:
inject ITokenService into your controller.
fix the error I mentioned above: TokenTypes.AccessToken instead of TokenTypes.IdentityToken
call var tokenHandle = await TokenService.CreateAccessTokenAsync(token);
tokenHandle is a key in PersistedGrantStore

Cannot redirect to the end session endpoint, the configuration may be missing or invalid OpenIdConnect SignOutAsync

I get an error
Cannot redirect to the end session endpoint, the configuration may be
missing or invalid when signing out.
when I handle signing out
public async Task LogOut()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignOutAsync("oidc");
}
Schemas
services.AddAuthentication(sharedOptions =>
{
sharedOptions.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
sharedOptions.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
sharedOptions.DefaultSignOutScheme = "oidc";
sharedOptions.DefaultChallengeScheme = "oidc";
})
.AddCookie(options =>
{
options.AccessDeniedPath = new PathString("/Access/Unauthorised");
options.Cookie.Name = "MyCookie";
})
.AddOpenIdConnect("oidc", options =>
{
options.ClientId = Configuration["oidc:ClientId"];
options.ClientSecret = Configuration["oidc:ClientSecret"]; // for code flow
options.SignedOutRedirectUri = Configuration["oidc:SignedOutRedirectUri"];
options.Authority = Configuration["oidc:Authority"];
options.ResponseType = OpenIdConnectResponseType.Code;
options.GetClaimsFromUserInfoEndpoint = true;
options.CallbackPath = new PathString("/oidc");
options.Events = new OpenIdConnectEvents()
{
OnRedirectToIdentityProvider = context =>
{
context.ProtocolMessage.SetParameter("pfidpadapterid", Configuration["oidc:PingProtocolMessage"]);
return Task.FromResult(0);
}
};
});
It seems like your authorization server does not support Session Management and Dynamic Registration. When it's supported, the discovery response contains end_session_endpoint. This is not the same as SignedOutRedirectUri, which is used as a final redirection target when the user is logged out on authorization server.
OnRedirectToIdentityProviderForSignOut event provides an option to set the issuer address, which in this case is the logout URI:
options.Events = new OpenIdConnectEvents()
{
options.Events.OnRedirectToIdentityProviderForSignOut = context =>
{
context.ProtocolMessage.IssuerAddress =
GetAbsoluteUri(Configuration["oidc:EndSessionEndpoint"], Configuration["oidc:Authority"]);
return Task.CompletedTask;
};
}
The helper method is used to support both relative and absolute path in the configuration:
private string GetAbsoluteUri(string signoutUri, string authority)
{
var signOutUri = new Uri(signoutUri, UriKind.RelativeOrAbsolute);
var authorityUri = new Uri(authority, UriKind.Absolute);
var uri = signOutUri.IsAbsoluteUri ? signOutUri : new Uri(authorityUri, signOutUri);
return uri.AbsoluteUri;
}
This way the authorization server will get additional parameters in the query string, which it can use e.g. to redirect back to your application.
To fix this handle the OnRedirectToIdentityProviderForSignOut event and specify the Logout endpoint manually:
options.Events = new OpenIdConnectEvents()
{
OnRedirectToIdentityProvider = context =>
{
context.ProtocolMessage.SetParameter("pfidpadapterid", Configuration["oidc:PingProtocolMessage"]);
return Task.FromResult(0);
},
// handle the logout redirection
OnRedirectToIdentityProviderForSignOut = context =>
{
var logoutUri = Configuration["oidc:SignedOutRedirectUri"];
context.Response.Redirect(logoutUri);
context.HandleResponse();
return Task.CompletedTask;
}
};

Categories

Resources