Add tenant claim to access token using IdentityServer 4 based on acr value - identityserver4

In my scenario a user can be linked to different tenants. A user should login in the context of a tenant. That means i would like the access token to contain a tenant claim type to restrict access to data of that tenant.
When the client application tries to login i specify an acr value to indicate for which tenant to login.
OnRedirectToIdentityProvider = redirectContext => {
if (redirectContext.ProtocolMessage.RequestType == OpenIdConnectRequestType.Authentication) {
redirectContext.ProtocolMessage.AcrValues = "tenant:" + tenantId; // the acr value tenant:{value} is treated special by id4 and is made available in IIdentityServerInteractionService
}
return Task.CompletedTask;
}
The value is received by my identity provider solution and is as well available in the IIdentityServerInteractionService.
The question is now, where can i add a claim to the access token for the requested tenant?
IProfileService
In a IProfileService implementation the only point where acr values would be available is in the IsActiveAsync method when context.Caller == AuthorizeEndpoint in the HttpContext via IHttpContextAccessor.
String acr_values = _context.HttpContext.Request.Query["acr_values"].ToString();
But in IsActiveAsync i can not issue claims.
In the GetProfileDataAsync calls the acr values are not available in the ProfileDataRequestContext nor in the HttpContext. Here i wanted to access acr values when
context.Caller = IdentityServerConstants.ProfileDataCallers.ClaimsProviderAccessToken. If i would have access i could issue the tenant claim.
Further i analyzed CustomTokenRequestValidator, IClaimsService and ITokenService without success. It seems like the root problem is, that the token endpoint does not receive/process acr values. (event though here acr is mentioned)
I have a hard time figure this one out. Any help appreciated. Is it maybe completely wrong what i am trying? After figuring this one out i will have as well to understand how this affects access token refresh.

Since you want the user to login for each tenant (bypassing sso) makes this solution possible.
When logging in, you can add a claim to the local user (IdentityServer) where you store the tenant name:
public async Task<IActionResult> Login(LoginViewModel model, string button)
{
// take returnUrl from the query
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
if (context?.ClientId != null)
{
// acr value Tenant
if (context.Tenant == null)
await HttpContext.SignInAsync(user.Id, user.UserName);
else
await HttpContext.SignInAsync(user.Id, user.UserName, new Claim("tenant", context.Tenant));
When the ProfileService is called you can use the claim and pass it to the access token:
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
// Only add the claim to the access token
if (context.Caller == "ClaimsProviderAccessToken")
{
var tenant = context.Subject.FindFirstValue("tenant");
if (tenant != null)
claims.Add(new Claim("tenant", tenant));
}
The claim is now available in the client.
Problem is, that with single sign-on the local user is assigned to the last used tenant. So you need to make sure the user has to login again, ignoring and overwriting the cookie on IdentityServer.
This is the responsibility from the client, so you can set prompt=login to force a login. But originating from the client you may want to make this the responsibility of the server. In that case you may need to override the interaction response generator.
However, it would make sense to do something like this when you want to add tenant specific claims. But it seems you are only interested in making a distinction between tenants.
In that case I wouldn't use above implementation but move from perspective. I think there's an easier solution where you can keep the ability of SSO.
What if the tenant identifies itself at the resource? IdentityServer is a token provider, so why not create a custom token that contains the information of the tenant. Use extension grants to create an access token that combines tenant and user and restricts access to that combination only.

To provide some code for others who want to use the extension grant validator as one suggested option by the accepted answer.
Take care, the code is quick and dirty and must be properly reviewed.
Here is a similar stackoverflow answer with extension grant validator.
IExtensionGrantValidator
using IdentityServer4.Models;
using IdentityServer4.Validation;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
namespace IdentityService.Logic {
public class TenantExtensionGrantValidator : IExtensionGrantValidator {
public string GrantType => "Tenant";
private readonly ITokenValidator _validator;
private readonly MyUserManager _userManager;
public TenantExtensionGrantValidator(ITokenValidator validator, MyUserManager userManager) {
_validator = validator;
_userManager = userManager;
}
public async Task ValidateAsync(ExtensionGrantValidationContext context) {
String userToken = context.Request.Raw.Get("AccessToken");
String tenantIdRequested = context.Request.Raw.Get("TenantIdRequested");
if (String.IsNullOrEmpty(userToken)) {
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
var result = await _validator.ValidateAccessTokenAsync(userToken).ConfigureAwait(false);
if (result.IsError) {
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
if (Guid.TryParse(tenantIdRequested, out Guid tenantId)) {
var sub = result.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
var claims = result.Claims.ToList();
claims.RemoveAll(x => x.Type == "tenantid");
IEnumerable<Guid> tenantIdsAvailable = await _userManager.GetTenantIds(Guid.Parse(sub)).ConfigureAwait(false);
if (tenantIdsAvailable.Contains(tenantId)) {
claims.Add(new Claim("tenantid", tenantId.ToString()));
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
context.Result = new GrantValidationResult(principal);
return;
}
}
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
}
}
}
Client config
new Client {
ClientId = "tenant.client",
ClientSecrets = { new Secret("xxx".Sha256()) },
AllowedGrantTypes = new [] { "Tenant" },
RequireConsent = false,
RequirePkce = true,
AccessTokenType = AccessTokenType.Jwt,
AllowOfflineAccess = true,
AllowedScopes = new List<String> {
IdentityServerConstants.StandardScopes.OpenId,
},
},
Token exchange in client
I made a razor page which receives as url parameter the requested tenant id, because my test app is a blazor server side app and i had problems to do a sign in with the new token (via _userStore.StoreTokenAsync). Note that i am using IdentityModel.AspNetCore to manage token refresh. Thats why i am using the IUserTokenStore. Otherwise you would have to do httpcontext.signinasync as Here.
public class TenantSpecificAccessTokenModel : PageModel {
private readonly IUserTokenStore _userTokenStore;
public TenantSpecificAccessTokenModel(IUserTokenStore userTokenStore) {
_userTokenStore = userTokenStore;
}
public async Task OnGetAsync() {
Guid tenantId = Guid.Parse(HttpContext.Request.Query["tenantid"]);
await DoSignInForTenant(tenantId);
}
public async Task DoSignInForTenant(Guid tenantId) {
HttpClient client = new HttpClient();
Dictionary<String, String> parameters = new Dictionary<string, string>();
parameters.Add("AccessToken", await HttpContext.GetUserAccessTokenAsync());
parameters.Add("TenantIdRequested", tenantId.ToString());
TokenRequest tokenRequest = new TokenRequest() {
Address = IdentityProviderConfiguration.Authority + "connect/token",
ClientId = "tenant.client",
ClientSecret = "xxx",
GrantType = "Tenant",
Parameters = parameters
};
TokenResponse tokenResponse = await client.RequestTokenAsync(tokenRequest).ConfigureAwait(false);
if (!tokenResponse.IsError) {
await _userTokenStore.StoreTokenAsync(HttpContext.User, tokenResponse.AccessToken, tokenResponse.ExpiresIn, tokenResponse.RefreshToken);
Response.Redirect(Url.Content("~/").ToString());
}
}
}

Related

IdentityServer Refresh Extension Grant

I have implemented an extension grant in my Identity Server instance. The purpose of this is for a mobile app to switch contexts between an authenticated user and a public kiosk type device.
When the user enters this mode, I acquire a new token and include the proper grant type.
I used the IS documentation as a base. Nothing crazy going on here at all, I just add some additional claims to this token to be able to access things in the API the user may otherwise not be set up for.
public class KioskGrantValidator : IExtensionGrantValidator
{
private readonly ITokenValidator _validator;
public KioskGrantValidator(ITokenValidator validator)
{
_validator = validator;
}
public string GrantType => "kiosk";
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;
// I add some custom claims here
List<Claim> newClaims = new()
{
new Claim(ClaimTypes.Name, "kiosk")
}
context.Result = new GrantValidationResult(sub, GrantType, claims: newClaims);
return;
}
}
Now, the question is refreshing this token.
For this grant to work I'm passing in the access token, which expires, eventually causing the ValidateAccessTokenAsync to fail.
Wanted to see what the best way to refresh this token is? Currently the best way I have found is to refresh the original user access token when this one is about to expire, then get a second token with the new grant. This works, but seems maybe unnecessary.
Thanks for any input!

Claim change in Identity Server's ProfileService is not reflected in Authentication Cookie

Using ASP.NET Core 3.1 and Identity Server 4 I am starting the login process from a SPA.
After I login I have the following claims on IS4 app authentication cookie:
sub 1
AspNet.Identity.SecurityStamp RRHNY65RQMUHTXXSIZKT2YORA3QQ2WAC
role Admin
preferred_username info#example.com
name info#example.com
email info#example.com
email_verified true
amr pwd
idp local
auth_time 1598736198
Why does the name claim gets the email as its value?
I then implemented the ProfileService to replace the name claim by the actual user name.
When I check the claims on the spa the replacement was done ...
But on IS4 application the Authentication Cookie still gets the email as name claim.
Why?
Ar the claims correct in the Identity Token?
If it is correct in the ID-token, then you probably have some claims transformation implemented in your ASP.NET Core application.
It seems the problem was how ASP.NET Core 3.1 defines the Claims.
I then implemented UserClaimsPrincipalFactory to update the name claim:
public class UserClaimsPrincipalFactory : UserClaimsPrincipalFactory<User> {
public UserClaimsPrincipalFactory(UserManager<User> userManager, IOptions<IdentityOptions> optionsAccessor) : base(userManager, optionsAccessor) { }
public async override Task<ClaimsPrincipal> CreateAsync(User user) {
ClaimsPrincipal principal = await base.CreateAsync(user);
ClaimsIdentity identity = (ClaimsIdentity)principal.Identity;
Claim claim = identity.FindFirst("name");
if (claim != null)
identity.RemoveClaim(claim);
identity.AddClaim(new Claim("name", user.Name));
return principal;
}
}
And now the Authentication Cookie and the Token have the same claims.
Using IdentityServer4's ProfileService only changes the claims on the token:
public class ProfileService : IProfileService {
private readonly IUserClaimsPrincipalFactory<User> _claimsFactory;
private readonly UserManager<User> _userManager;
public ProfileService(UserManager<User> userManager, IUserClaimsPrincipalFactory<User> claimsFactory) {
_userManager = userManager;
_claimsFactory = claimsFactory;
}
public async Task GetProfileDataAsync(ProfileDataRequestContext context) {
// Not doing any change here
User user = await _userManager.GetUserAsync(context.Subject);
ClaimsPrincipal principal = await _claimsFactory.CreateAsync(user);
context.IssuedClaims = principal.Claims.ToList();
}
public async Task IsActiveAsync(IsActiveContext context) {
User user = await _userManager.GetUserAsync(context.Subject);
context.IsActive = (user != null) && user.Enabled;
}
}
I suppose ProfileService is more useful when not using Asp.Net Core Identity?

Login after signup in identity server4

I am trying to login user as soon as he/she registers.
below is the scenario
1)Registration page is not on identity server.
2)Post user details to Id server from UI for user creation.
3)On successful user creation login the user and redirect.
4)Trying to do it on native app.
I tried it with javascript app but redirection fails with 405 options call.
(tried to redirect to /connect/authorize)
on mobile app, don't want user to login again after signup for UX.
Has anyone implemented such behavior
tried following benfoster
Okay so finally i was able to get it working with authorization code flow
Whenever user signs up generate and store a otp against the newly created user.
send this otp in post response.
use this otp in acr_value e.g acr_values=otp:{{otpvalue}} un:{{username}}
client then redirects to /connect/authorize with the above acr_values
below is the identity server code which handles the otp flow
public class SignupFlowResponseGenerator : AuthorizeInteractionResponseGenerator
{
public readonly IHttpContextAccessor _httpContextAccessor;
public SignupFlowResponseGenerator(ISystemClock clock,
ILogger<AuthorizeInteractionResponseGenerator> logger,
IConsentService consent,
IProfileService profile,
IHttpContextAccessor httpContextAccessor)
: base(clock, logger, consent, profile)
{
_httpContextAccessor = httpContextAccessor;
}
public override async Task<InteractionResponse> ProcessInteractionAsync(ValidatedAuthorizeRequest request, ConsentResponse consent = null)
{
var processOtpRequest = true;
var isAuthenticated = _httpContextAccessor.HttpContext.User.Identity.IsAuthenticated;
// if user is already authenticated then no need to process otp request.
if (isAuthenticated)
{
processOtpRequest = false;
}
// here we only process only the request which have otp
var acrValues = request.GetAcrValues().ToList();
if (acrValues == null || acrValues.Count == 0)
{
processOtpRequest = false;
}
var otac = acrValues.FirstOrDefault(x => x.Contains("otp:"));
var un = acrValues.FirstOrDefault(x => x.Contains("un:"));
if (otac == null || un == null)
{
processOtpRequest = false;
}
if (processOtpRequest)
{
var otp = otac.Split(':')[1];
var username = un.Split(':')[1];
// your logic to get and check opt against the user
// if valid then
if (otp == { { otp from db for user} })
{
// mark the otp as expired so that it cannot be used again.
var claimPrincipal = {{build your principal}};
request.Subject = claimPrincipal ;
await _httpContextAccessor.HttpContext.SignInAsync({{your auth scheme}}, claimPrincipal , null);
return new InteractionResponse
{
IsLogin = false, // as login is false it will not redirect to login page but will give the authorization code
IsConsent = false
};
}
}
return await base.ProcessInteractionAsync(request, consent);
}
}
dont forget to add the following code in startup
services.AddIdentityServer().AddAuthorizeInteractionResponseGenerator<SignupFlowResponseGenerator>()
You can do that by using IdentityServerTools class that IdentityServer4 provide to help issuing a JWT token For a Client OR a User (in your case)
So after the user signs up, you already have all claims needed for generating the token for the user:
including but not limited to: userid, clientid , roles, claims, auth_time, aud, scope.
You most probably need refresh token if you use hybrid flow which is the most suitable one for mobile apps.
In the following example, I am assuming you are using ASP.NET Identity for Users. The IdentityServer4 Code is still applicable regardless what you are using for users management.
public Constructor( UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IClientStore clientStore,
IdentityServerTools identityServerTools,
IRefreshTokenService refreshTokenService)
{// minimized for clarity}
public async Task GenerateToken(ApplicationUser user
)
{
var principal = await _signInManager.CreateUserPrincipalAsync(user);
var claims = new List<Claim>(principal.Claims);
var client = await clientStore.FindClientByIdAsync("client_Id");
// here you should add all additional claims like clientid , aud , scope, auth_time coming from client info
// add client id
claims.Add(new Claim("client_id", client.ClientId));
// add authtime
claims.Add(new Claim("auth_time", $"{(Int32)(DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1))).TotalSeconds}"));
// add audiences
var audiences = client.AllowedScopes.Where(s => s != "offline_access" && s != "openid" && s != "profile");
foreach (var audValue in audiences)
{
claims.Add(new Claim("aud", audValue));
}
// add /resources to aud so the client can get user profile info.
var IdentityServiceSettings = _configuration.GetSection("IdentityService").Get<IdentityServiceConsumeSettings>();
claims.Add(new Claim("aud", $"{IdentityServiceUrl}/resources"));
//scopes for the the what cook user
foreach (var scopeValue in client.AllowedScopes)
{
claims.Add(new Claim("scope", scopeValue));
}
//claims.Add(new Claim("scope", ""));
claims.Add(new Claim("idp", "local"));
var accesstoken = identityServerTools.IssueJwtAsync(100, claims);
var t = new Token
{
ClientId = "client_id",
Claims = claims
};
var refereshToken = refreshTokenService.CreateRefreshTokenAsync(principal, t, client);
}
This is just a code snippet that needs some changes according to your case

Add user claims in identityserver4

I have created identityserver4 project and tried to add more claims after user log in. Here is my code
public async Task<IActionResult> Login(LoginInputModel model)
{
if (ModelState.IsValid)
{
// validate username/password against in-memory store
CoreDb.Models.User user = null;
if (( user = await _userService.VerifyUser(model.Username, model.Password)) != null)
{
// var user = _users.FindByUsername(model.Username);
await _events.RaiseAsync(new UserLoginSuccessEvent(user.Name, user.Id.ToString(), user.Name));
// only set explicit expiration here if user chooses "remember me".
// otherwise we rely upon expiration configured in cookie middleware.
var props = new AuthenticationProperties();
if (AccountOptions.AllowRememberLogin && model.RememberLogin)
{
props.IsPersistent = true;
props.ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration);
};
props.Items.Add("scheme", AccountOptions.WindowsAuthenticationSchemeName);
// issue authentication cookie with subject ID and username
await HttpContext.SignInAsync(user.Id.ToString(), user.Name, "idp", props, _userService.GetUserClaims(user).ToArray());
//IEnumerable<ClaimsIdentity> claimsIdentity = null;
//var claimsIdentity = new ClaimsIdentity(_userService.GetUserClaims(user), CookieAuthenticationDefaults.AuthenticationScheme);
//await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity));
// make sure the returnUrl is still valid, and if so redirect back to authorize endpoint or a local page
if (_interaction.IsValidReturnUrl(model.ReturnUrl) || Url.IsLocalUrl(model.ReturnUrl))
{
return Redirect(model.ReturnUrl);
}
return Redirect("~/");
}
await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials"));
ModelState.AddModelError("", AccountOptions.InvalidCredentialsErrorMessage);
}
However, I do not see my claims for the user when I call my api in requirement handler although my user id is there. What is an appropriate way to add user claims?
The claims must be added to the response context. If you are using aspnetidentity then the following approach will work for you
Ensure to include ProfileService implementation and hook up to IdentityServer at ConfigureServices
.AddProfileService<ProfileService>();
private readonly IUserClaimsPrincipalFactory<ApplicationUser> claimsFactory;
In the GetProfileDataAsync, you can include your new claims
/// <summary>
/// This method is called whenever claims about the user are requested (e.g. during token creation or via the userinfo endpoint)
/// </summary>
/// <param name="context">The context.</param>
/// <returns></returns>
public override async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var sub = context.Subject.GetSubjectId();
var user = await userManager.FindByIdAsync(sub);
var principal = await claimsFactory.CreateAsync(user);
//context.AddFilteredClaims(principal.Claims);
context.IssuedClaims.AddRange(principal.Claims);
context.IssuedClaims.Add(new Claim("IP Address", coreOperations.GetCurrentRequestIPAddress()));
}
As Jay already mentioned, you can write your own ProfileService. If you don't use aspnetidentity you can just add the Claims in the Login method and add these lines to the GetProfileDataAsync Method of your ProfileService:
List<Claim> claims = context.Subject.Claims.Where(x => x.Type == JwtClaimTypes.Role).ToList();
context.IssuedClaims.AddRange(claims);

Asp.net core token based claims authentication with OpenIdConnect and angularjs: Bearer was forbidden

I'm using Asp.net core rc2 with OpenIdConnectServer. I'm using angular 1.x with augular-oauth2. After a few days, my error has digressed to
Microsoft.AspNetCore.Hosting.Internal.WebHost:Information: Request starting HTTP/1.1 GET http://localhost:54275/api/Account/Username
Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerMiddleware:Information: Successfully validated the token.
Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerMiddleware:Information: HttpContext.User merged via AutomaticAuthentication from authenticationScheme: Bearer.
Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerMiddleware:Information: AuthenticationScheme: Bearer was successfully authenticated.
Microsoft.AspNetCore.Authorization.DefaultAuthorizationService:Information: Authorization failed for user: .
Microsoft.AspNetCore.Mvc.Internal.ControllerActionInvoker:Warning: Authorization failed for the request at filter 'Microsoft.AspNetCore.Mvc.Authorization.AuthorizeFilter'.
Microsoft.AspNetCore.Mvc.ChallengeResult:Information: Executing ChallengeResult with authentication schemes (Bearer).
Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerMiddleware:Information: AuthenticationScheme: Bearer was forbidden.
My ConfigureServices consists of
services.AddAuthorization(options =>
{
options.AddPolicy("UsersOnly", policy =>
{
policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme);
policy.RequireClaim("role");
});
});
My configure has
app.UseWhen(context => context.Request.Path.StartsWithSegments(new PathString("/api")), branch =>
{
branch.UseJwtBearerAuthentication(new JwtBearerOptions
{
AutomaticAuthenticate = true,
AutomaticChallenge = true,
RequireHttpsMetadata = false,
Audience = "http://localhost:54275/",
Authority = "http://localhost:54275/",
TokenValidationParameters = new TokenValidationParameters
{
ValidAudience = "client1",
//ValidAudiences = new List<string> { "", "empty", "null"}
}
});
});
app.UseOpenIdConnectServer(options =>
{
options.AuthenticationScheme = OpenIdConnectServerDefaults.AuthenticationScheme;
options.Provider = new SimpleAuthorizationServerProvider();
options.AccessTokenHandler = new JwtSecurityTokenHandler();
options.ApplicationCanDisplayErrors = true;
options.AllowInsecureHttp = true;
options.TokenEndpointPath = new PathString("/oauth2/token");
options.LogoutEndpointPath = new PathString("/oauth2/logout");
options.RevocationEndpointPath = new PathString("/oauth2/revoke");
options.UseJwtTokens();
//options.AccessTokenLifetime = TimeSpan.FromHours(1);
});
My authorize attribute is defined on the Controller as
[Authorize(Policy = "UsersOnly", ActiveAuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme), Route("api/Account")]
I store the token as a cookie and attach it to requests using an http interceptor in angular.
I generate the token with
public override async Task GrantResourceOwnerCredentials(GrantResourceOwnerCredentialsContext context)
{
// validate user credentials (demo mode)
// should be stored securely (salted, hashed, iterated)
using (var con = new SqlConnection(ConnectionManager.GetDefaultConnectionString()))
{
if (!Hashing.ValidatePassword(context.Password, await con.ExecuteScalarAsync<string>("SELECT PassHash FROM dbo.Users WHERE Username = #UserName", new { context.UserName })))
{
context.Reject(
error: "bad_userpass",
description: "UserName/Password combination was invalid."
);
return;
}
// create identity
var id = new ClaimsIdentity(context.Options.AuthenticationScheme);
id.AddClaim(new Claim("sub", context.UserName));
id.AddClaim(new Claim("role", "user"));
// create metadata to pass on to refresh token provider
var props = new AuthenticationProperties(new Dictionary<string, string>
{
{"as:client_id", context.ClientId}
});
var ticket = new AuthenticationTicket(new ClaimsPrincipal(id), props,
context.Options.AuthenticationScheme);
ticket.SetAudiences("client1");
//ticket.SetScopes(OpenIdConnectConstants.Scopes.OpenId, OpenIdConnectConstants.Scopes.Email, OpenIdConnectConstants.Scopes.Profile, "api-resource-controller");
context.Validate(ticket);
}
}
I've spent the last three days on this problem and I realize that at this point I'm probably missing something obvious due to lack of sleep. Any help would be appreciated.
The error you're seeing is likely caused by 2 factors:
You're not attaching an explicit destination to your custom role claim so it will never be serialized in the access token. You can find more information about this security feature on this other SO post.
policy.RequireClaim("role"); might not work OTB, as IdentityModel uses an internal mapping that converts well-known JWT claims to their ClaimTypes equivalent: here, role will be likely replaced by http://schemas.microsoft.com/ws/2008/06/identity/claims/role (ClaimTypes.Role). I'd recommend using policy.RequireRole("user") instead.
It's also worth noting that manually storing the client_id is not necessary as it's already done for you by the OpenID Connect server middleware.
You can retrieve it using ticket.GetPresenters(), that returns the list of authorized presenters (here, the client identifier). Note that it also automatically ensures a refresh token issued to a client A can't be used by a client B, so you don't have to do this check in your own code.

Resources