Jeroen
Jeroen

Reputation: 63729

IdentityServer4 client for Password Flow not including TestUser claims in access token

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:


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 TestUsers should do this already??

Upvotes: 2

Views: 1510

Answers (1)

Jeroen
Jeroen

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

Related Questions