Reputation: 63729
I'm trying to create a sandbox application, using the (legacy) Resource Owner Password flow in IdentityServer4. I've set up a brand new ASP.NET Core 3 project with these packages:
<PackageReference Include="IdentityServer4" Version="3.1.3" />
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.2.0" />
I'm using the following startup sections:
services.AddIdentityServer()
.AddDeveloperSigningCredential()
.AddInMemoryApiResources(new[] { new ApiResource("foo-api") })
.AddInMemoryIdentityResources(new[]
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResources.Email(),
new IdentityResource("role", new[] { JwtClaimTypes.Role }),
})
.AddInMemoryClients(new[]
{
new Client
{
// Don't use RPO if you can prevent it. We use it here
// because it's the easiest way to demo with users.
ClientId = "legacy-rpo",
AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
AllowAccessTokensViaBrowser = false,
RequireClientSecret = false,
AllowedScopes = { "foo-api", "openid", "profile", "email", "role" },
},
})
.AddTestUsers(new List<TestUser>
{
new TestUser
{
SubjectId = "ABC-123",
Username = "john",
Password = "secret",
Claims = new[]
{
new Claim(JwtClaimTypes.Role, "user"),
new Claim(JwtClaimTypes.Email, "[email protected]"),
new Claim("x-domain", "foo") },
},
})
And then I serve a static index.html
file that calls the /connect/token
endpoint like this:
const response = await fetch("/connect/token", {
method: "POST",
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
body: new URLSearchParams({
"grant_type": "password",
"client_id": "legacy-rpo",
"username": "john",
"password": "secret",
// scope omitted should net *all* scopes in IDS4
}),
});
But it returns me an access_token that (decoded) looks like this:
{
"nbf": 1588582642,
"exp": 1588586242,
"iss": "https://localhost:5001",
"aud": "foo-api",
"client_id": "legacy-rpo",
"sub": "ABC-123",
"auth_time": 1588582642,
"idp": "local",
"scope": [
"email",
"openid",
"profile",
"role",
"foo-api"
],
"amr": [
"pwd"
]
}
I'm missing e-mail, role, etc. as top-level entries in the access_token
.
When digging through the source code, I see that the ProfileService for TestUsers should add all requested claims to the token via an extension method. Most questions I found while googling my problem either do what I already do (or tried, see below), or are about other edge cases.
Many other threads also lead to Dominick Baier's post on roles, but there the problem is that the API side doesn't recognize the role. My problem is that the role
isn't included at all in the token.
What I've tried:
"role"
and JwtClaimTypes.Role
in various places.IdentityResources
Footnote about ProfileService
I've tried adding this:
public class ProfileService : TestUserProfileService
{
public ProfileService(TestUserStore users, ILogger<TestUserProfileService> logger)
: base(users, logger)
{ }
public override Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var role = context.Subject.FindFirst(ClaimTypes.Role);
context.IssuedClaims.Add(role);
return base.GetProfileDataAsync(context);
}
public override Task IsActiveAsync(IsActiveContext context)
{
return base.IsActiveAsync(context);
}
}
to the AddIdentityServer()
builder chain:
.AddProfileService<ProfileService>()
but the GetProfileDataAsync(...)
method isn't being hit at all, no breakpoint triggers. So that would suggest that the default TestUserProfileService
would also never be hit, thus explaining the lack of claims in my tokens.
Is this not supported in Password Flow perhaps because it's an OAuth2 and not an OpenID Connect flow?
What am I missing? Do I really need to create a custom ProfileService
to add all these claims? I really felt the default ProfileService
for TestUser
s should do this already??
Upvotes: 2
Views: 1510
Reputation: 63729
I ended up with the following (not sure if it's a solution or a workaround), for what it's worth to future visitors:
new ApiResource("foo-api")
{
Scopes =
{
new Scope("foo-api.with.roles", new[] { "role" }),
}
}
new Client
{
// Don't use RPO if you can prevent it. We use it here
// because it's the easiest way to demo with users.
ClientId = "legacy-rpo",
AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
AllowAccessTokensViaBrowser = false,
RequireClientSecret = false,
AllowedScopes = { "foo-api", "foo-api.with.roles" },
}
new TestUser
{
SubjectId = "EFG-456",
Username = "mary",
Password = "secret",
Claims = { new Claim("role", "editor") },
}
And then retrieving tokens like this:
const response = await fetch("/connect/token", {
method: "POST",
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
body: new URLSearchParams({
"grant_type": "password",
"client_id": "legacy-rpo",
"username": "mary",
"password": "secret",
}),
});
const json = await response.json();
console.log(json);
You should be able to clone my sample-asp-net-core-auth-policies
repository and run it out of the box to see this at work.
Upvotes: 1