Identity: Propagating claims to downstream services - c#

I am trying to set up a distributed system with Duende-IdentityServer. In my architecture I am using a BFF (Backend For Front) as an API-GateWay for my client.
When my user is logged in using the BFF I want requests to propagate from the BFF to downstream services. (I am using GraphQL with stitching and schema federation, but I feel that might be irrelevant to the question.) Because I feel that it is important for the downstream services to be in control of the authorization of their data I would like claims received by the BFF to be forwarded to the downstream services. I figured something like attaching a JWT Bearer with the claims would work and was hoping that that way my downstream services wouldn't have to contact the identity server to validate the claims.
I tried a few things, but it is quite easy to get lost in the world that is OAuth2 and OIDC. I can't imagine my use case being that
Here is what I tried so far:
In the BFF:
//program.cs
builder.Services.AddHttpClient(GraphQLSchemas.Identity, c => c.BaseAddress = new Uri("https://localhost:7500/graphql")).AddUserAccessTokenHandler();
builder.Services.AddGraphQLServer()
.AddRemoteSchemasFromRedis("GraphQL", sp => sp.GetRequiredService<ConnectionMultiplexer>())
.ModifyOptions(x => x.RemoveUnreachableTypes = true);
services.AddBff();
services.AddAuthentication(options =>
{
options.DefaultScheme = "Bff-Cookie";
options.DefaultChallengeScheme = "oidc";
options.DefaultSignOutScheme = "oidc";
})
.AddCookie("Bff-Cookie", options =>
{
// set session lifetime
options.ExpireTimeSpan = TimeSpan.FromHours(8);
// sliding or absolute
options.SlidingExpiration = true;
// host prefixed cookie name
options.Cookie.Name = bffOptions.Cookie.Name ;
options.Cookie.Domain = bffOptions.Cookie.Domain;
// strict SameSite handling
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
})
.AddOpenIdConnect("oidc", options =>
{
options.Authority = bffOptions.IdentityServer.Host;
// confidential client using code flow + PKCE
options.ClientId = bffOptions.IdentityServer.ClientId;
options.ResponseType = OpenIdConnectResponseType.Code;
options.ResponseMode = "query";
options.MapInboundClaims = false;
options.GetClaimsFromUserInfoEndpoint = false;
options.SaveTokens = true;
//options.
// request scopes +refresh tokens
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
//options.Scope.Add("Administrator");
options.Scope.Add("roles");
options.Scope.Add("offline_access");
options.ClaimActions.MapJsonKey("role", "role", "role");
options.TokenValidationParameters.RoleClaimType = JwtClaimTypes.Role;
});
/// code omitted for brevity
app.UseBff();
If I log in on the bff these are the claims I get:
However the access_token doesnt reflect this:
So when my HttpClient uses .AddUserAccessTokenHandler();
Only the access_token is passed to my downstream service:
//program.cs
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.Authority = "https://localhost:7500";
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidateAudience = false,
ValidTypes = new[] { "at+jwt" },
NameClaimType = "name",
RoleClaimType = "role"
};
});
//code omitted for brevity
app.MapGraphQL().RequireAuthorization(new AuthorizeAttribute
{
AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme
}).AllowAnonymous();
But as you can see the Role claim etc is not passed.
How can receive the claims in in my downstream service? Preferably without reaching out to the identityserver. (Though it would be kinda nice if the downstream service could validate the jwt sent to it.
P.S. I also tried to follow 2 tutorials that create a ProfileService implementation, but for some reason when i register another profile service with the DI container the login through the bff fails and I havent been able to figure out why yet. A breakpoint in profile service would not be hit.

The claims you see in .NET is the claims from the id_token or from the userinfo endpoint. They are separate from the ones found in the access token. You configure this in IdentityServer.
See my answer at ApiResource vs ApiScope vs IdentityResource for more details about this.
So, the IdentityResource and ApiResource defines what claims can be returned for a given user. Then as this picture shows, those requested claims, are then looked up against the user database and the claims that is found in the database are then returned and used in the ID and access token. as the picture from one of my training classes shows:

As you may know, Authentication is the concern of the gateway and authorization is Domain-specific and is related to downstream services (each one). JWT token should be validated and verified in the gateway and verification/validation process can be ignored in downstream services.
You can pass JWT headers (Authorization: Bearer bla) to the downstream services and they should ignore the validation like this:
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false;
options.SaveToken = false;
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters()
{
ValidateIssuer = false,
ValidateAudience = false,
RequireExpirationTime = false,
ValidateLifetime = false,
SignatureValidator = (jwt, tokenValidationParameters) =>
{
return new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(jwt);
}
};
});
this code makes all of your routes and HTTP request flows to fullfil User object and its Claims and you can take action like this in your controllers:
//You will have claims in your User object even if you mark the action as anonymous.
//[AllowAnonymous]
public async Task Do()
{
this.User.HasClaim("bla")
}
This is because of the protocol and Dotnet supports JWT out of the box and I highly recommend you to forward JWT-formatted headers and do not change the token format/protocol for downstream services.

Related

Add multiple external oidc provides to Duende Identity Server

I passed through the tutorial:
https://docs.duendesoftware.com/identityserver/v6/quickstarts/2_interactive/
And in adittional I tried to add another instance of the Identity Server as another external Identity Provider. After this it just stops working right after starting. No errors, no warnings...
Each one works separately.
Whether who faced it?
Here is how I registered several Identity Providers:
builder.Services.AddAuthentication()
.AddOpenIdConnect("oidc", "Demo IdentityServer", options =>
{
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
options.SignOutScheme = IdentityServerConstants.SignoutScheme;
options.SaveTokens = true;
options.Authority = "https://demo.duendesoftware.com";
options.ClientId = "interactive.confidential";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "role"
};
})
.AddOpenIdConnect("oidc", "My IdentityServer", options =>
{
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
options.SignOutScheme = IdentityServerConstants.SignoutScheme;
options.SaveTokens = true;
options.Authority = "https://localhost:5004";
options.ClientId = "myprovider";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "role"
};
});
One problem is that both handlers are listening on the same callback URLs (the URL's IdentityServer sends requests to the client)
by default they are set to
CallbackPath = new PathString("/signin-oidc");
SignedOutCallbackPath = new PathString("/signout-callback-oidc");
RemoteSignOutPath = new PathString("/signout-oidc");
You need to set them to different paths in each handler.
Also, the schema name should be different "oidc"
But in general, I think it's a bad idea to have your client trust two different Identity providers, I think it's better to only have it trust one.
The alternative is to have your own Identity provider locally, that then trusts various external providers, like this:
Generally, your complexity will be reduced if every service only has to trust one provider. Especially, for the APIs using JwtBearer, they prefer to only have one trusted provider.

ASP.NET Core API Controller Action validation of IndentityServer4 client permissions/claims

I have a scenario where multiple clients have access to the same ASP.NET Core API . I.e. the same scopes. They are, however, not permitted to access the same data. If I could identify which client is accessing the API then it could validate whether it had access rights or not. I assume though that this is a bad approach - though doable in my scenario - because it requires code changes when new clients are added. Is there a way to have clients with bearer tokens having claims that I can read in my controller action? How do I configure this is and how do I access the claims in the API?
I am not familiar with IdentityServer so can't answer to the part about adding claims.
However once the user has claims, you can access it in your api quite simply:
[HttpPost("foo/{bar}")]
public async Task<IActionResult> Foo(string bar)
{
var user = User.FindFirst(ClaimTypes.Name).Value;//use whatever kind of claim type you want.
return await DoSomeWork();
}
Hope this helps.
You can add the client id(aud claim in token) to principle in OnTokenValidated event of AddOpenIdConnect extension .
In your MVC client , modify the OIDC configuration :
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
options.SignInScheme = "Cookies";
options.Authority = "http://localhost:5000";
options.RequireHttpsMetadata = false;
options.ClientId = "mvc";
options.ClientSecret = "secret";
options.ResponseType = "code id_token";
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("api1");
options.Scope.Add("offline_access");
options.Events = new OpenIdConnectEvents
{
OnTokenValidated = ctx =>
{
var clientID = ctx.SecurityToken.Claims.FirstOrDefault(c => c.Type == "aud")?.Value;
var claims = new List<Claim>
{
new Claim("ClientID", clientID)
};
var appIdentity = new ClaimsIdentity(claims);
ctx.Principal.AddIdentity(appIdentity);
return Task.CompletedTask;
},
};
});
Then you could read the client id in controller like :
var clientID = User.Claims.FirstOrDefault(c => c.Type == "ClientID")?.Value;
With using System.Linq; for FirstOrDefault Linq operation .

How add two different tokens in ASP.NET Core Web API

I need the Authorize attribute in our Controller can accept two different tokens.
One token, is provided from one private ADFS, and other token is provided from AzureAd.
Several Ionic clients go to over ADFS, other Ionic clients go to over Azure AD
My dev scenario: ASP.NET Core 2.2 Web API
My actual startup.cs (abbreviated)
ConfigureService()
{
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer((options =>
{
options.Audience = Configuration["Adfs:Audience"];
options.Authority = Configuration["Adfs:Issuer"];
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false
};
}));
}
I need here the other Authentication with AzureAD. How?
The Configure method of Startup.cs
Configure(…)
{
app.UseAuthentication()
}
With this code, only can access the ADFS Token and this users, can obtains result from the controllers. However, the AzureAD user's can't obtain access
I don't know how make this code for double token authorization, and our controllers can response if one token is from ADFS or other token is from AzureAD
You can set multiple JWT Bearer Authentication with different schema name :
services.AddAuthentication()
.AddJwtBearer("ADFS",options =>
{
options.Audience = Configuration["Adfs:Audience"];
options.Authority = Configuration["Adfs:Issuer"];
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false
};
})
.AddJwtBearer("AAD", options =>
{
//AAD jwt validation configuration
});
If you want to make your controller/action to accept two jwt tokens , tokens from AAD or ADFS are ok to access your controller/action , you can make a policy to let both the AAD and ADFS authentication schemes tried to authenticate the request :
services
.AddAuthorization(options =>
{
options.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddAuthenticationSchemes("AAD", "ADFS")
.Build();
});
In addition , if you want to know which schema the token is from , you can check the particular claim in user's identity , or directly add authentication schema value to user claims in events :
options.Events = new Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerEvents
{
OnTokenValidated = (context) =>
{
var claimsIdentity = (ClaimsIdentity)context.Principal.Identity;
//add your custom claims here
claimsIdentity.AddClaim(new Claim("schema", "AAD"));
return Task.FromResult(0);
}
};
And get in action after authentication :
var result = User.Claims.Where(c=>c.Type=="schema").FirstOrDefault().Value;

Getting the identity token (id_token) within redirect URI (MVC Controller)

I'm hoping this is mostly agnostic from Okta (the service we are using for social logins), but I'm having a hard time finding documentation. I'm using .NET Core 2.0+ and my Startup.cs looks like this:
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
//Configuration pulled from appsettings.json by default:
options.ClientId = Configuration["okta:ClientId"];
options.ClientSecret = Configuration["okta:ClientSecret"];
options.Authority = Configuration["okta:Issuer"];
options.CallbackPath = "/authorization-code/callback";
options.ResponseType = OpenIdConnectResponseType.IdToken;
options.SaveTokens = true;
options.UseTokenLifetime = false;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
});
A login form within the site allows you to click 'Login to Facebook' and the process of authenticating via the identity provider takes place. When it is confirmed, it punts back to my defined redirect 'Home/Secure'. When the redirect returns, the id_token is in the URL as an anchor:
https://localhost:5001/Home/Secure#id_token=XXXXXXX
There is also an authorize call that I can see happen that receives a response with the id_token in it as well via the Chrome developer tools console. I'm not as familiar with .NET Core, so I'm having a hard time understanding how I can grab this id_token.
The Request doesn't seem to have the id_token in the Query or QueryString parameters, so I'm not seeing where I can grab it.
Since you are using the OIDC middleware and set SaveTokens to true , you would subsequently be able to retrieve those tokens by calling GetTokenAsync for ID token you want to access ,in controller :
string idToken = await HttpContext.GetTokenAsync("id_token");

GetTokenAsync returns 2 audiences in ASP.NET Core 2.1 using auth0

I'm using ASP.NET Core 2.1 and Auth0.
When I try to retrieve the acces_token to access my own API I use
string accessToken = await HttpContext.GetTokenAsync("access_token");
The strange thing is when I paste the token on https://jwt.io/ it shows that an audience has been added. The thing is that two audiences are not allowed and so the token is not valid. The audience that is added ends with /userinfo
Can someone please explain why there are two audiences in my acces token?
I use the following code in ConfigureServices
// Add authentication services
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect("Auth0", options =>
{
// Set the authority to your Auth0 domain
options.Authority = $"https://{Configuration["Auth0:Domain"]}";
// Configure the Auth0 Client ID and Client Secret
options.ClientId = Configuration["Auth0:ClientId"];
options.ClientSecret = Configuration["Auth0:ClientSecret"];
// Set response type to code
options.ResponseType = "code";
// Configure the scope
options.Scope.Clear();
options.Scope.Add("openid");
// Set the callback path, so Auth0 will call back to http://localhost:5000/signin-auth0
// Also ensure that you have added the URL as an Allowed Callback URL in your Auth0 dashboard
options.CallbackPath = new PathString("/signin-auth0");
// Configure the Claims Issuer to be Auth0
options.ClaimsIssuer = "Auth0";
// Saves tokens to the AuthenticationProperties
options.SaveTokens = true;
options.Events = new OpenIdConnectEvents
{
// handle the logout redirection
OnRedirectToIdentityProviderForSignOut = (context) =>
{
var logoutUri = $"https://{Configuration["Auth0:Domain"]}/v2/logout?client_id={Configuration["Auth0:ClientId"]}";
var postLogoutUri = context.Properties.RedirectUri;
if (!string.IsNullOrEmpty(postLogoutUri))
{
if (postLogoutUri.StartsWith("/"))
{
// transform to absolute
var request = context.Request;
postLogoutUri = request.Scheme + "://" + request.Host + request.PathBase + postLogoutUri;
}
logoutUri += $"&returnTo={ Uri.EscapeDataString(postLogoutUri)}";
}
context.Response.Redirect(logoutUri);
context.HandleResponse();
return Task.CompletedTask;
},
OnRedirectToIdentityProvider = context =>
{
context.ProtocolMessage.SetParameter("audience", "MY_OWN_AUDIENCE_URL");
return Task.FromResult(0);
}
};
});
Can someone please explain why there are two audiences in my acces token?
The second audience is the userinfo endpoint. The userinfo endpoint is part of the OpenID Connect protocol; it exposes the end-user's profile information and is present because of the openid scope.
When Auth0 receives an authorization request, it checks the request's audience and scope parameters. If the audience is a custom API, and if the scope includes openid, then the access_token will include two audiences: one for your custom API, the other for the Auth0 userinfo endpoint.
Here is a supporting quote from https://auth0.com/docs/tokens/access-token
When the audience is set to a custom API and the scope parameter includes the openid value, then the generated Access Token will be a JWT valid for both retrieving the user's profile and for accessing the custom API. The aud claim of this JWT will include two values: YOUR_AUTH0_DOMAIN/userinfo and your custom API's unique identifier.
WORKING
I got it working with the next code placed in ConfigureServices in the Startup class. In the List from Configuration I placed the audience from Auth0 userinfo API and my own API.
// Multiple audiences
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = true,
ValidAudiences = Configuration.GetSection("Auth0:Audiences").Get<List<string>>(),
ValidateLifetime = true
};

Categories