Creating your own OpenID Connect server with ASOS: implementing the resource owner password credentials grant

Implementing the resource owner password credentials grant (abbreviated ROPC for brevity) is quite easy with ASOS as the only thing you have to do is to provide your own implementation of ValidateTokenRequest and HandleTokenRequest.

But to properly implement these events, you first need to determine what's the best client authentication policy for your application.


Implementing ValidateTokenRequest to validate the grant type and the client application credentials

When implementing flows using backchannel communication (i.e resource owner password credentials grant, client credentials grant, authorization code flow or refresh token grant), the ValidateTokenRequest event must be overridden to validate the token request.

So, what are you supposed to validate in this event? Mainly two things:

  • The grant type: in most cases, you'll likely want to restrict the grants a client application is allowed to use (e.g resource owner password credentials only): ValidateTokenRequest is the best place for that.

It should be noted that ASOS doesn't validate the grant_type value, that can even contain a custom value for extension grants: if you only want to support standard grants, it's up to you to reject the token request by calling context.Reject().

IsAuthorizationCodeGrantType(), IsRefreshTokenGrantType(), IsPasswordGrantType() and IsClientCredentialsGrantType() can be used for this exact purpose.

  • The client credentials (client_id/client_secret): the OAuth2 specification explicitly states that confidential applications (i.e applications that are able to keep their credentials secret, like server-side apps) must authenticate when using the token endpoint. This security measure is extremely important as it's the only way to prevent malicious applications from retrieving an access token on behalf of a legitimate confidential application.

Contrary to popular belief, client authentication is never mandatory when using the token endpoint (except for the client credentials grant), which means that public applications like JS or mobile apps are allowed to use the resource owner password grant without having to send their credentials.

In practice, it's up to you to decide whether your token endpoint should accept unauthenticated requests or not, depending on the type of client you'll use.

No big surprise here: while a few rules apply to most implementations (e.g brute force countermeasures), the exact implementation of this event will mainly depend on your specific requirements:

  • Do you need to support multiple grant types, or just the resource owner password credentials grant?

  • Do you need to support public clients? If you plan to use ROPC with JS or mobile applications, you won't be able to make client authentication mandatory, since these applications cannot safely store their credentials. Conversely, if you only target public apps, rolling your own client authentication policy is likely to be pointless.

When supporting both public and confidential clients, the recommended approach is to skip client authentication when client_id is missing and validate it when present, to make sure counterfeit applications cannot impersonate confidential applications.

  • Do you have multiple clients? If you have more than one client application, you'll probably want to store the application details in a database instead of hardcoding them in the provider class.

Implementing a policy skipping client authentication (for JS/mobile apps-only scenarios)

Using the resource owner password credentials grant with SPA apps is a very popular scenario. Since these apps can't store their credentials in a safe place, client authentication cannot be enforced.

Here's how you could implement ValidateTokenRequest to avoid making client authentication mandatory:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public override Task ValidateTokenRequest(ValidateTokenRequestContext context)
{
// Reject the token requests that don't use grant_type=password or grant_type=refresh_token.
if (!context.Request.IsPasswordGrantType() && !context.Request.IsRefreshTokenGrantType())
{
context.Reject(
error: OpenIdConnectConstants.Errors.UnsupportedGrantType,
description: "Only the resource owner password credentials and refresh token " +
"grants are accepted by this authorization server");
return Task.FromResult(0);
}
// Since there's only one application and since it's a public client
// (i.e a client that cannot keep its credentials private), call Skip()
// to inform the server the request should be accepted without
// enforcing client authentication.
context.Skip();
return Task.FromResult(0);
}

Though additional checks can be added (e.g Origin header validation), you have no way to ensure that the caller sending the token request is really a trusted application when skipping client authentication: keep in mind that access tokens may be issued to unauthorized parties impersonating legitimate applications.

When client authentication is not enforced, the resource owner password credentials grant offers the same security level as the implicit flow and shares a similar threat model.


Implementing a policy requiring client authentication (for server-side apps-only scenarios)

This scenario is the exact opposite of the previous one: when targeting confidential applications, you MUST enforce client authentication to prevent client impersonation by unauthorized parties and the token request must be rejected if the client credentials are missing or invalid.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public override Task ValidateTokenRequest(ValidateTokenRequestContext context)
{
// Reject the token request that don't use grant_type=password or grant_type=refresh_token.
if (!context.Request.IsPasswordGrantType() && !context.Request.IsRefreshTokenGrantType())
{
context.Reject(
error: OpenIdConnectConstants.Errors.UnsupportedGrantType,
description: "Only resource owner password credentials and refresh token " +
"are accepted by this authorization server");
return Task.FromResult(0);
}
// Reject the token request if client_id or client_secret is missing.
if (string.IsNullOrEmpty(context.ClientId) || string.IsNullOrEmpty(context.ClientSecret))
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidRequest,
description: "Missing credentials: ensure that your credentials were correctly " +
"flowed in the request body or in the authorization header");
return Task.FromResult(0);
}
// Note: to mitigate brute force attacks, you SHOULD strongly consider applying
// a key derivation function like PBKDF2 to slow down the secret validation process.
// You SHOULD also consider using a time-constant comparer to prevent timing attacks.
// For that, you can use the CryptoHelper library developed by @henkmollema:
// https://github.com/henkmollema/CryptoHelper. If you don't need .NET Core support,
// SecurityDriven.NET/inferno is a rock-solid alternative: http://securitydriven.net/inferno/
if (string.Equals(context.ClientId, "client_id", StringComparison.Ordinal) &&
string.Equals(context.ClientSecret, "client_secret", StringComparison.Ordinal))
{
context.Validate();
return Task.FromResult(0);
}
// Note: if Validate() is not explicitly called,
// the request is automatically rejected.
return Task.FromResult(0);
}

Implementing an hybrid policy supporting both public and confidential applications

In a few cases, you'll want to support both public clients (e.g mobile apps) and confidential applications (e.g MVC clients). For that, you'll need to implement an hybrid policy, supporting both types of clients:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
public override async Task ValidateTokenRequest(ValidateTokenRequestContext context)
{
var database = context.HttpContext.RequestServices.GetRequiredService<ApplicationContext>();
// Reject the token request that don't use grant_type=password or grant_type=refresh_token.
if (!context.Request.IsPasswordGrantType() && !context.Request.IsRefreshTokenGrantType())
{
context.Reject(
error: OpenIdConnectConstants.Errors.UnsupportedGrantType,
description: "Only resource owner password credentials and refresh token " +
"are accepted by this authorization server");
return;
}
// Skip client authentication if the client identifier is missing.
// Note: ASOS will automatically ensure that the calling application
// cannot use an authorization code or a refresh token if it's not
// the intended audience, even if client authentication was skipped.
if (string.IsNullOrEmpty(context.ClientId))
{
context.Skip();
return;
}
// Retrieve the application details corresponding to the requested client_id.
var application = await (from entity in database.Applications
where entity.ApplicationID == context.ClientId
select entity).SingleOrDefaultAsync(context.HttpContext.RequestAborted);
if (application == null)
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidClient,
description: "Application not found in the database: ensure that your client_id is correct.");
return;
}
if (application.Type == ApplicationType.Public)
{
// Reject tokens requests containing a client_secret
// if the client application is not confidential.
if (!string.IsNullOrEmpty(context.ClientSecret))
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidRequest,
description: "Public clients are not allowed to send a client_secret.");
return;
}
// If client authentication cannot be enforced, call context.Skip() to inform
// the OpenID Connect server middleware that the caller cannot be fully trusted.
context.Skip();
return;
}
// Confidential applications MUST authenticate
// to protect them from impersonation attacks.
if (string.IsNullOrEmpty(context.ClientSecret))
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidClient,
description: "Missing credentials: ensure that you specified a client_secret.");
return;
}
// Note: to mitigate brute force attacks, you SHOULD strongly consider applying
// a key derivation function like PBKDF2 to slow down the secret validation process.
// You SHOULD also consider using a time-constant comparer to prevent timing attacks.
// For that, you can use the CryptoHelper library developed by @henkmollema:
// https://github.com/henkmollema/CryptoHelper. If you don't need .NET Core support,
// SecurityDriven.NET/inferno is a rock-solid alternative: http://securitydriven.net/inferno/
if (!string.Equals(context.ClientSecret, application.Secret, StringComparison.Ordinal))
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidClient,
description: "Invalid credentials: ensure that you specified a correct client_secret.");
return;
}
context.Validate();
}

Implementing HandleTokenRequest to issue an authentication ticket containing the user claims

HandleTokenRequest is the event responsible of processing the token request and preparing the authentication ticket used to serialize the access token.

It's important to note that HandleTokenRequest is invoked for every token request. User implementations should only process token requests that use a supported grant_type (e.g password) and let ASOS automatically handle or reject the other grants (e.g authorization_code or refresh_token).

Like ValidateTokenRequest, the exact implementation of HandleTokenRequest will mainly depend on your application, and specially on the membership stack you're using. That said, a few generic rules apply to all implementations:

  • Brute force countermeasures MUST be implemented, as required by the OAuth2 specification. This is usally done by using key derivation (ideally with a large number of iterations, to slow down the authentication process) and by implementing account lockout.
  • The token request MUST be rejected if the user account is configured to require two-factor authentication as the resource owner password credentials grant cannot be used in this case (at least, not in a standard way).
  • Implementations SHOULD avoid revealing whether the username is valid or not, for privacy reasons.

Here's an example of how you can leverage ASP.NET Core Identity (previously known as ASP.NET Identity 3) to implement HandleTokenRequest:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
public override async Task HandleTokenRequest(HandleTokenRequestContext context)
{
// Resolve ASP.NET Core Identity's user manager from the DI container.
var manager = context.HttpContext.RequestServices.GetRequiredService<UserManager<ApplicationUser>>();
// Only handle grant_type=password requests and let ASOS
// process grant_type=refresh_token requests automatically.
if (context.Request.IsPasswordGrantType())
{
var user = await manager.FindByNameAsync(context.Request.Username);
if (user == null)
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidGrant,
description: "Invalid credentials.");
return;
}
// Ensure the user is allowed to sign in.
if (!await manager.CanSignInAsync(user))
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidGrant,
description: "The specified user is not allowed to sign in.");
return;
}
// Reject the token request if two-factor authentication has been enabled by the user.
if (manager.SupportsUserTwoFactor && await manager.GetTwoFactorEnabledAsync(user))
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidGrant,
description: "Two-factor authentication is required for this account.");
return;
}
// Ensure the user is not already locked out.
if (manager.SupportsUserLockout && await manager.IsLockedOutAsync(user))
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidGrant,
description: "Invalid credentials.");
return;
}
// Ensure the password is valid.
if (!await manager.CheckPasswordAsync(user, context.Request.Password))
{
if (manager.SupportsUserLockout)
{
await manager.AccessFailedAsync(user);
}
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidGrant,
description: "Invalid credentials.");
return;
}
if (manager.SupportsUserLockout)
{
await manager.ResetAccessFailedCountAsync(user);
}
var identity = new ClaimsIdentity(context.Options.AuthenticationScheme);
// Note: the subject claim is always included in both identity and
// access tokens, even if an explicit destination is not specified.
identity.AddClaim(OpenIdConnectConstants.Claims.Subject, await manager.GetUserId(user));
// When adding custom claims, you MUST specify one or more destinations.
// Read "part 7" for more information about custom claims and scopes.
identity.AddClaim("username", await manager.GetUserNameAsync(user),
OpenIdConnectConstants.Destinations.AccessToken,
OpenIdConnectConstants.Destinations.IdentityToken);
// Create a new authentication ticket holding the user identity.
var ticket = new AuthenticationTicket(
new ClaimsPrincipal(identity),
new AuthenticationProperties(),
context.Options.AuthenticationScheme);
// Set the list of scopes granted to the client application.
ticket.SetScopes(
/* openid: */ OpenIdConnectConstants.Scopes.OpenId,
/* email: */ OpenIdConnectConstants.Scopes.Email,
/* profile: */ OpenIdConnectConstants.Scopes.Profile);
// Set the resource servers the access token should be issued for.
ticket.SetResources("resource_server");
context.Validate(ticket);
}
}

Next part: Implementing the authorization code and implicit flows.