Creating your own OpenID Connect server with ASOS: creating your own authorization provider

ASOS leverages the same events model as the rest of the ASP.NET Core security stack: often hard to understand for beginners, this pattern (inherited from OWIN/Katana) proved to be extremely powerful by offering full flexibility on the request processing.

To help make things clearer before trying to implement a concrete flow, here's a quick overview of how it works with ASOS:


OpenIdConnectServerProvider and the events model

OpenIdConnectServerProvider is ASOS' main extensibility hook: its methods (named events or notifications) are invoked by OpenIdConnectServerHandler for every OpenID Connect request to give you a chance to control how the request is handled. Depending on the flows you want to support, you'll need to implement different events.

You have 2 options to create your own provider:

  • Directly instantiante an OpenIdConnectServerProvider and use inline delegates. This approach is perfect when implementing a simple server that mainly relies on hardcoded values.
1
2
3
4
5
6
7
8
9
10
11
12
13
app.UseOpenIdConnectServer(options =>
{
options.Provider = new OpenIdConnectServerProvider
{
// Implement OnValidateAuthorizationRequest to
// support interactive flows (code/implicit/hybrid).
OnValidateAuthorizationRequest = async context => { ... },

// Implement OnValidateTokenRequest to support flows using the token endpoint
// (code/refresh token/password/client credentials/custom grant).
OnValidateTokenRequest = async context => { ... }
};
});

You can also directly set the events properties without having to manually instantiate a OpenIdConnectServerProvider, as ASOS always registers a default OpenIdConnectServerProvider instance for you:

1
2
3
4
5
6
7
8
9
10
app.UseOpenIdConnectServer(options =>
{
// Implement OnValidateAuthorizationRequest to
// support interactive flows (code/implicit/hybrid).
options.Provider.OnValidateAuthorizationRequest = async context => { ... };

// Implement OnValidateTokenRequest to support flows using the token endpoint
// (code/refresh token/password/client credentials/custom grant).
options.Provider.OnValidateTokenRequest = async context => { ... };
});
  • Create your own subclass of OpenIdConnectServerProvider and override the virtual methods you want to implement. This is clearly the best approach when implementing a more complex authorization server.
1
2
3
4
5
6
7
8
9
public sealed class AuthorizationProvider : OpenIdConnectServerProvider
{
// Implement OnValidateAuthorizationRequest to support interactive flows (code/implicit/hybrid).
public override async Task ValidateAuthorizationRequest(ValidateAuthorizationRequestContext context) { ... }

// Implement OnValidateTokenRequest to support flows using the token endpoint
// (code/refresh token/password/client credentials/custom grant).
public override async Task ValidateTokenRequest(ValidateTokenRequestContext context) { ... }
}
1
2
3
4
app.UseOpenIdConnectServer(options =>
{
options.Provider = new AuthorizationProvider();
});

It's important to note that the authorization provider is always a singleton: don't try to inject scoped dependencies in its constructor. To resolve scoped dependencies (e.g an Entity Framework DbContext), use the context.HttpContext.RequestServices property to access the scoped container.

You can read this thread for more information about this limitation/design choice, which is not specific to ASOS and impacts all the security middleware sharing the same events model. It might be fixed in a future version, though.


Working with the different categories of events

ASOS has 5 different categories of events:

  • The events called to extract or restore an OpenID Connect request from an HTTP request (e.g ExtractAuthorizationRequest).
  • The events responsible for validating requests (e.g ValidateAuthorizationRequest).
  • The events handling requests (e.g HandleAuthorizationRequest).
  • The events that can be used to alter or replace the response before it is returned to the caller (e.g ApplyAuthorizationResponse).
  • The events in charge of serializing and deserializing tokens (e.g SerializeAccessToken).

Request extraction events

Immediately after validating the HTTP method and extracting the request parameters from the query string or from the request form (depending on the endpoint type), ASOS invokes one of the Extract*Request events to give you a chance to manually replace, restore or alter the request before it is validated.

For instance, ExtractAuthorizationRequest can be used to restore an OpenID Connect authorization request from the user session, which can be useful if you need to save POST or large GET authorization requests before redirecting the user to an external provider:

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
public override Task ExtractAuthorizationRequest(ExtractAuthorizationRequestContext context)
{
// If a request_id parameter can be found in the authorization request,
// restore the complete authorization request stored in the user session.
if (!string.IsNullOrEmpty(context.Request.RequestId))
{
var payload = context.HttpContext.Session.Get(context.Request.RequestId);
if (payload == null)
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidRequest,
description: "Invalid request: timeout expired.");

return Task.FromResult(0);
}

// Restore the authorization request parameters from the serialized payload.
using (var reader = new BsonReader(new MemoryStream(payload)))
{
foreach (var parameter in JObject.Load(reader))
{
// Avoid overriding the current request parameters.
if (context.Request.HasParameter(parameter.Key))
{
continue;
}

context.Request.SetParameter(parameter.Key, parameter.Value);
}
}
}

return Task.FromResult(0);
}

Another concrete use case is when you have to support non-standard clients that don't send the parameters required by the OAuth 2.0/OIDC specifications, as ExtractAuthorizationRequest can be used to remove, replace or even add a missing parameter before ASOS starts validating the request:

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 async Task ExtractAuthorizationRequest(ExtractAuthorizationRequestContext context)
{
var database = context.HttpContext.RequestServices.GetRequiredService<ApplicationContext>();

// If the mandatory response_type parameter is missing, infer it from
// the client application type corresponding to the client_id parameter.
if (!string.IsNullOrEmpty(context.Request.ClientId) &&
string.IsNullOrEmpty(context.Request.ResponseType))
{
// Retrieve the application details corresponding to the requested client_id.
var application = await (from entity in database.Applications
where entity.ApplicationID == context.Request.ClientId
select entity).SingleOrDefaultAsync();

if (application != null)
{
// If the application is a JS app, use the implicit flow. Else, use the code flow.
context.Request.ResponseType = application.Type == ApplicationType.JavaScript ?
OpenIdConnectConstants.ResponseTypes.Token :
OpenIdConnectConstants.ResponseTypes.Code;
}
}
}

Request validation events

Implementing validation events is generally required to allow ASOS to process OpenID Connect requests. It's particularly true with ValidateAuthorizationRequest and ValidateTokenRequest, that must be implemented to support interactive and non-interactive flows.

To allow full flexibility, ASOS always gives you 2 or 3 options, depending on the exact event you're implementing:

  • Validate the request: it's typically what you'll want to do after checking that the request was fully valid (e.g the client application was allowed to use the requested grant type and its client credentials were valid).
1
context.Validate();

When implementing ValidateTokenRequest, context.Validate() shouldn't be called for public applications like JS, mobile or desktop apps. If you want to make client authentication optional, consider using context.Skip() instead, as explained below.

  • Reject the request: when the request doesn't meet your specific requirements (e.g the client credentials are missing or invalid), you can reject it with an error code and a description explaining why the request was rejected.
1
2
3
context.Reject(
error: OpenIdConnectConstants.Errors.UnauthorizedClient,
description: "This client application is not allowed to use the implicit flow.");
  • Skip validation: under certain circumstances, ASOS allows you to skip request validation. Calling context.Skip() informs ASOS that the request was not fully validated (e.g because the client credentials were missing) but should be accepted nevertheless.
1
context.Skip();

At the time of writing, only ValidateIntrospectionRequest, ValidateRevocationRequest and ValidateTokenRequest allow using context.Skip(), to make client authentication optional.

Though particularly useful when using the resource owner password credentials grant with JS applications, that's something you should avoid when dealing with confidential applications using the authorization code flow, as it drastically reduces the overall security level.

Here's an example of how ValidateTokenRequest can be implemented to reject specific grant types while allowing all your client applications to use the token endpoint without having to authenticate:

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 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);
}

// 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);
}

More samples can be found in the next part, that explains how to implement the ValidateTokenRequest event to support the resource owner password credentials grant with different scenarios.


Request handling events

Implementing these events is generally not required, but can be useful to control how ASOS handles a request. Similarly to what the security middleware built in ASP.NET Core offer, you have 3 options to control the request processing:

  • Let ASOS determine how the request will be processed: in most cases, you'll simply want to add your own logic determining what will be returned to the caller and let ASOS handle the rest of the request. For instance, you may want to implement the HandleUserinfoRequest event to update, augment, replace or remove the default claims returned by the userinfo endpoint:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public override Task HandleUserinfoRequest(HandleUserinfoRequestContext context)
{
// You can retrieve the claims stored in the access token extracted
// from the userinfo request by accessing the authentication ticket.
var principal = context.Ticket.Principal;

// Set family_name, given_name, birth_date using custom claims:
context.FamilyName = principal.FindClaim("custom:last_name")?.Value;
context.GivenName = principal.FindClaim("custom:first_name")?.Value;
context.BirthDate = principal.FindClaim("custom:birth_date")?.Value;

// Only expose "custom-claim" if "custom-scope" was granted by the resource owner.
if (context.Ticket.HasScope("custom-scope"))
{
context.Claims["custom-claim"] = "claim-value";
}

return Task.FromResult(0)
}
  • Handle the request manually: by calling context.HandleRequest(), you can inform ASOS that its default logic should not be executed and that the request should terminate immediately after invoking your event handler. In doing so, you take full control over the response: you can return a custom status code, render a HTML page or even send back a JSON payload by directly writing to the HTTP response stream.

Here's an example implementing HandleAuthorizationRequest to immediately return a token to the client application without displaying a consent page or relying on ASP.NET Core MVC to render it (if the user is not already logged in, ChallengeAsync is immediately called to redirect him/her to Google's authorization endpoint):

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
public override async Task HandleAuthorizationRequest(HandleAuthorizationRequestContext context)
{
// Retrieve the principal extracted from the local cookie. If user is not already logged in, a null value is returned.
var principal = await context.HttpContext.Authentication.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
if (principal == null)
{
// Redirect the user to the Google authorization/authentication page.
await context.HttpContext.Authentication.ChallengeAsync(GoogleDefaults.AuthenticationScheme);

// Mark the response as handled to skip the default request processing.
context.HandleResponse();
return;
}

var identity = new ClaimsIdentity(context.Options.AuthenticationScheme);
identity.AddClaim(OpenIdConnectConstants.Claims.Subject, "3B181511-9C18-4EEB-A80E-9E48BB0E0872");

// Create a new authentication ticket holding the user identity.
var ticket = new AuthenticationTicket(
new ClaimsPrincipal(identity),
new AuthenticationProperties(),
context.Options.AuthenticationScheme);

// Call SignInAsync to create and return a new authorization response containing the serialized code/tokens.
// The user will be automatically redirected back to the client application with the authorization code/access token.
await context.HttpContext.Authentication.SignInAsync(ticket.AuthenticationScheme, ticket.Principal, ticket.Properties);

// Mark the response as handled to skip the default request processing.
context.HandleResponse();
}
  • Skip the default logic and delegate the request handling to the next middleware in the pipeline: when calling context.SkipToNextMiddleware(), ASOS is informed that the default request processing should not be applied.

Unlike context.HandleResponse(), context.SkipToNextMiddleware() doesn't immediately stop the request processing. Instead, the next middleware in the pipeline (i.e all the middleware registered after app.UseOpenIdConnectServer()) are invoked to give them a chance to handle the request.

A common use case is when you want to handle the userinfo request in your own API controller instead of handling it at the middleware level:

1
2
3
4
5
6
7
8
9
10
public override Task HandleUserinfoRequest(HandleUserinfoRequestContext context)
{
// Note: by default, the OpenID Connect server middleware automatically handles
// userinfo requests and directly writes the JSON response to the response stream.
// Calling context.SkipToNextMiddleware() bypasses the default request processing
// and delegates it to a custom ASP.NET Core MVC controller (UserinfoController).
context.SkipToNextMiddleware();

return Task.FromResult(0);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class UserinfoController : Controller
{
// Specify ActiveAuthenticationSchemes = "Bearer" to ensure the principal extracted from
// the access token sent by the client application is correctly attached to the HTTP context.
[Authorize(ActiveAuthenticationSchemes = OAuthValidationDefaults.AuthenticationScheme)]
[HttpGet("~/connect/userinfo")]
public IActionResult Get()
{
return Json(new
{
sub = User.GetClaim(OpenIdConnectConstants.Claims.Subject),
name = User.GetClaim(OpenIdConnectConstants.Claims.Name)
});
}
}

Response events

Similarly to how the request handling events work, the Apply*Response events give you a chance to control how the OpenID Connect responses are serialized and applied just before they are returned to the caller: you can call context.HandleResponse() to inform ASOS that the response should be processed using your own logic or context.SkipToNextMiddleware() to bypass the default response logic and to invoke the next middleware.

Here's an implementation of ApplyTokenResponse that adds a custom parameter to the token response before returning it:

Note that this practice is usually discouraged when using it as a way to flow user attributes. Instead, consider storing them as claims in the identity token.

1
2
3
4
5
6
7
8
9
10
public override Task ApplyTokenResponse(ApplyTokenResponseContext context)
{
// Only add the custom parameter if the response indicates a successful response.
if (string.IsNullOrEmpty(context.Error))
{
context.Response["custom-parameter"] = "value";
}

return Task.FromResult(0);
}

Next part: Implementing the resource owner password credentials grant.