Creating your own OpenID Connect server with ASOS: implementing the authorization code and implicit flows

To support interactive flows like the authorization code or the implicit flows, the ValidateAuthorizationRequest event must be implemented to validate the authorization request sent by the client application.


Implementing ValidateAuthorizationRequest to validate response_type, client_id and redirect_uri

To support interactive flows, you must implement ValidateAuthorizationRequest to validate the client_id and the redirect_uri parameters provided by the client application to ensure they correspond to a registered client.

Ideally, the response_type parameter should also be validated to ensure that a client_id corresponding to a confidential application cannot be used with the implicit/hybrid flow to prevent downgrade attacks.

In pure OAuth2, redirect_uri was not mandatory but is now required by the OpenID Connect specification. To support legacy clients, ASOS doesn't reject authorization requests missing the redirect_uri parameter if the openid scope is not present, but in this case, it's up to you to call context.Validate(...) with the redirect_uri the user agent should be redirected to. If you don't need to support such clients, consider rejecting the authorization requests that don't specify a redirect_uri.

While the OpenID Connect specification explicitly states that the redirect_uri MUST exactly match one of the callback URLs associated with the client application, you're actually free to implement a relaxed comparison policy to support advanced scenarios (e.g domain-only/subdomain comparison or wildcard support): use this ability with extreme caution to avoid introducing an open redirect vulnerability.

Nothing surprising: the exact implementation of ValidateAuthorizationRequest will depend on the flows you want to support (e.g authorization code/implicit/hybrid) and on how you store your application details (e.g hardcoded or in a database).

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
public override async Task ValidateAuthorizationRequest(ValidateAuthorizationRequestContext context)
{
var database = context.HttpContext.RequestServices.GetRequiredService<ApplicationContext>();
// Note: the OpenID Connect server middleware supports the authorization code,
// implicit/hybrid and custom flows but this authorization provider only accepts
// response_type=code authorization requests. You may consider relaxing it to support
// the implicit or hybrid flows. In this case, consider adding checks rejecting
// implicit/hybrid authorization requests when the client is a confidential application.
if (!context.Request.IsAuthorizationCodeFlow())
{
context.Reject(
error: OpenIdConnectConstants.Errors.UnsupportedResponseType,
description: "Only the authorization code flow is supported by this server.");
return;
}
// Note: redirect_uri is not required for pure OAuth2 requests
// but this provider uses a stricter policy making it mandatory,
// as required by the OpenID Connect core specification.
// See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest.
if (string.IsNullOrEmpty(context.RedirectUri))
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidRequest,
description: "The required redirect_uri parameter was missing.");
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;
}
// Note: the comparison doesn't need to be time-constant as the
// callback URL stored in the database is not a secret value.
if (!string.Equals(context.RedirectUri, application.RedirectUri, StringComparison.Ordinal))
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidClient,
description: "Invalid redirect_uri.");
return;
}
context.Validate();
}

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

Similarly to the resource owner password credentials grant, the ValidateTokenRequest event must be implemented when using the authorization code flow, as it relies on the token endpoint to get a new access token.

Since the same concerns apply here (including grant type and client authentication validation), don't hesitate to (re)read the previous post if you're unsure how you're supposed to implement the ValidateTokenRequest event.

For instance, here's what you'll typically do for a mobile application, for which client authentication cannot be enforced:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public override Task ValidateTokenRequest(ValidateTokenRequestContext context)
{
// Reject the token requests that don't use
// grant_type=authorization_code or grant_type=refresh_token.
if (!context.Request.IsAuthorizationCodeGrantType() &&
!context.Request.IsRefreshTokenGrantType())
{
context.Reject(
error: OpenIdConnectConstants.Errors.UnsupportedGrantType,
description: "Only the authorization code 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);
}

As mentioned in the introduction post, ASOS doesn't come with a consent page and it's up to the implementer to provide one if necessary.

This can be done using the framework of your choice: ASP.NET Core MVC, Nancy or any other OWIN-compatible framework. Since MVC is by far the most popular framework, I'll only demonstrate how you can implement your own consent form using MVC controllers, but you can also find a sample using the OWIN/Katana version of ASOS with Nancy in the GitHub repository.

This is probably the most critical step when creating your own identity server as the consent page is an important attack vector: to prevent clickjacking/cursorjacking and cross-site request forgery attacks, you MUST implement appropriate countermeasures (e.g framekillers scripts, X-Frame-Options, Content-Security-Policy and antiforgery tokens).

In MVC, cross-site request forgery and clickjacking attacks are usually mitigated using the [ValidateAntiforgeryToken] attribute, that uses the whole new Antiforgery stack under the hood.

The Authorize action represents the initial step of the authorization process: it's the first page the user will be redirected to by the client application and where he/she will be invited to accept or reject the authorization request.

In most cases, you'll likely want to ensure the user is logged in and registered before displaying a consent form, but merging the login form and the consent form is also possible: you're only limited by your imagination.

Of course, you're responsible of providing the required infrastructure needed to log your users in, which can be easily implemented using ASP.NET Core Identity and the AccountController that comes with the default Visual Studio templates and supports both local and external authentication.

Here's a simple example using an Authorize action and 2 Razor views:

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
[Authorize, HttpGet("~/connect/authorize")]
public async Task<IActionResult> Authorize(CancellationToken cancellationToken)
{
// Extract the authorization request from the ASP.NET context.
var request = HttpContext.GetOpenIdConnectRequest();
// Note: ASOS implicitly ensures that an application corresponds to the client_id
// specified in the authorization request by calling ValidateAuthorizationRequest.
// In theory, this null check shouldn't be needed, but a race condition could occur
// if you manually removed the application from the database after the initial check.
var application = await (from entity in database.Applications
where entity.ApplicationID == request.ClientId
select entity).SingleOrDefaultAsync(cancellationToken);
if (application == null)
{
return View("Error", new ErrorViewModel
{
Error = OpenIdConnectConstants.Errors.InvalidClient,
ErrorDescription = "Details concerning the calling client " +
"application cannot be found in the database"
});
}
return View(new AuthorizeViewModel
{
ApplicationName = application.DisplayName,
Parameters = request.GetParameters(),
Scope = request.Scope
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class AuthorizeViewModel
{
[Display(Name = "Application")]
public string ApplicationName { get; set; }
[BindNever]
public IDictionary<string, string> Parameters { get; set; }
[Display(Name = "Scope")]
public string Scope { get; set; }
}
public class ErrorViewModel
{
[Display(Name = "Error")]
public string Error { get; set; }
[Display(Name = "Description")]
public string ErrorDescription { get; set; }
}
Authorize.cshtml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@model AuthorizeViewModel
<div class="jumbotron">
<h1>Authorization</h1>
<p class="lead text-left">Do you want to grant <strong>@Model.ApplicationName</strong> access to your data? (scopes requested: @Model.Scope)</p>
<form method="post">
@Html.AntiForgeryToken()
@foreach (var parameter in Model.Parameters)
{
<input type="hidden" name="@parameter.Key" value="@parameter.Value" />
}
<input formaction="@Url.Action("Accept")" class="btn btn-lg btn-success" name="Authorize" type="submit" value="Yes" />
<input formaction="@Url.Action("Deny")" class="btn btn-lg btn-danger" name="Deny" type="submit" value="No" />
</form>
</div>
Error.cshtml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@model ErrorViewModel
<div class="jumbotron">
<h2>Ooooops, something went really bad! :(</h2>
<p class="lead text-left">
@if (!string.IsNullOrEmpty(Model.Error))
{
<strong>@Model.Error</strong>
}
@if (!string.IsNullOrEmpty(Model.ErrorDescription))
{
<small>@Model.ErrorDescription</small>
}
</p>
</div>

When the user approves the authorization request, the only thing you have to have to do is create a ClaimsIdentity containing the user claims and call ControllerBase.SignIn to inform the OpenID Connect server middleware that a successful authorization response should be returned to the client application.

This step is very similar to how you implemented HandleTokenRequest in the previous post:

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
[Authorize, HttpPost("~/connect/authorize/accept"), ValidateAntiForgeryToken]
public async Task<IActionResult> Accept(CancellationToken cancellationToken)
{
var request = HttpContext.GetOpenIdConnectRequest();
// Create a new ClaimsIdentity containing the claims that
// will be used to create an id_token, a token or a code.
var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationScheme);
// Copy the unique identifier associated with the logged-in user to the new identity.
// Note: the subject is always included in both identity and access tokens,
// even if an explicit destination is not explicitly specified.
identity.AddClaim(OpenIdConnectConstants.Claims.Subject,
User.GetClaim(OpenIdConnectConstants.Claims.Subject));
var application = await (from entity in database.Applications
where entity.ApplicationID == request.ClientId
select entity).SingleOrDefaultAsync(cancellationToken);
if (application == null)
{
return View("Error", new ErrorViewModel
{
Error = OpenIdConnectConstants.Errors.InvalidClient,
ErrorDescription = "Details concerning the calling client " +
"application cannot be found in the database"
});
}
// Create a new authentication ticket holding the user identity.
var ticket = new AuthenticationTicket(
new ClaimsPrincipal(identity),
new AuthenticationProperties(),
OpenIdConnectServerDefaults.AuthenticationScheme);
// Set the list of scopes granted to the client application.
// Note: this sample always grants the "openid", "email" and "profile" scopes
// when they are requested by the client application: a real world application
// would probably display a form allowing to select the scopes to grant.
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");
// Returning a SignInResult will ask ASOS to serialize the specified identity
// to build appropriate tokens. You should always make sure the identities
// you return contain the OpenIdConnectConstants.Claims.Subject claim. In this sample,
// the identity always contains the name identifier returned by the external provider.
return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
}

Creating a Deny action to allow rejecting the authorization request

Rejecting an authorization request couldn't be simpler with ASOS: call Forbid(OpenIdConnectServerDefaults.AuthenticationScheme) to return a ForbidResult and ASOS will immediately redirect the user agent to the client application.

1
2
3
4
5
6
7
8
9
[Authorize]
[HttpPost("~/connect/authorize/deny")]
[ValidateAntiForgeryToken]
public IActionResult Deny()
{
// Notify ASOS that the authorization grant has been denied by the resource owner.
// The user agent will be redirected to the client application as part of this call.
return Forbid(OpenIdConnectServerDefaults.AuthenticationScheme);
}

Implementing MatchEndpoint to dynamically determine the request type

By default, ASOS only handles the HTTP requests whose path exactly matches one of the pre-defined endpoints registered in the OpenID Connect server options.

You can override the default endpoint selection routine by implementing the MatchEndpoint event, which can be particularly useful to extract authorization requests from subpaths like /connect/authorize/accept and /connect/authorize/deny, that would be ignored otherwise.

1
2
3
4
5
6
7
8
9
10
11
12
13
public override Task MatchEndpoint(MatchEndpointContext context)
{
// Note: by default, the OIDC server middleware only handles authorization requests made to
// AuthorizationEndpointPath. This handler uses a more relaxed policy that allows extracting
// authorization requests received at /connect/authorize/accept and /connect/authorize/deny.
if (context.Options.AuthorizationEndpointPath.HasValue &&
context.Request.Path.StartsWithSegments(context.Options.AuthorizationEndpointPath))
{
context.MatchesAuthorizationEndpoint();
}
return Task.FromResult(0);
}

Next part: Adding custom claims and granting scopes.