Introducing native applications, per-client token lifetimes and client assertions support in OpenIddict 5.0 preview1

Earlier today, the first OpenIddict 5.0 preview was pushed to NuGet.org 🎉

As always, all the changes can be found on GitHub but here's a recap of the most interesting ones:

What's new?

This major release introduces breaking changes that require applying migrations to update the database schema. As such it is not recommended to use it in production or in tandem with an existing deployment using OpenIddict 4.x.

Relaxed redirect_uri comparisons for native applications

While the OAuth 2.0 and OpenID Connect specifications explicitly mandated the use of the "simple string comparison" logic – put simply, a byte-by-byte comparison – newer specifications like RFC8252 (aka OAuth 2.0 for Native Apps) relaxed this policy for mobile and desktop applications to allow using dynamic ports chosen at runtime.

While OpenIddict already allowed customizing the comparison policy by overriding OpenIddictApplicationManager.ValidateRedirectUriAsync(), built-in support for native applications was not a thing.

OpenIddict 5.0 changes that by introducing a new ApplicationType property for the application entity. This property is always set to web by default, but can be explicitly set to native to opt in the relaxed comparison policy that ignores ports for local redirect_uris:

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
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
// Note: the application must be registered as a native application to force OpenIddict
// to apply a relaxed redirect_uri validation policy that allows specifying a random port.
ApplicationType = ApplicationTypes.Native,
ClientId = "console",
ClientType = ClientTypes.Public,
ConsentType = ConsentTypes.Systematic,
DisplayName = "Console client application",
RedirectUris =
{
// Note: the port must not be explicitly specified as it is selected
// dynamically at runtime by the OpenIddict client system integration.
new Uri("http://localhost/callback/login/local")
},
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Device,
Permissions.Endpoints.Token,
Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.DeviceCode,
Permissions.GrantTypes.RefreshToken,
Permissions.ResponseTypes.Code,
Permissions.Scopes.Email,
Permissions.Scopes.Profile,
Permissions.Scopes.Roles,
Permissions.Prefixes.Scope + "demo_api"
},
Requirements =
{
Requirements.Features.ProofKeyForCodeExchange
}
});

To avoid ambiguities, the existing OpenIddictApplicationDescriptor.Type property (and the properties of the same name in OpenIddictEntityFrameworkApplication, OpenIddictEntityFrameworkCoreApplication and OpenIddictMongoDbApplication) have been replaced by ClientType.

Users migrating to 5.0 will be invited to update their database schema to include the new ApplicationType property and the renamed ClientType member.

Per-client token lifetimes

OpenIddict already supported overriding the default token lifetimes dynamically using the SetAccessTokenLifetime(...) extension but being able to use a static value per client was a popular request.

To support that, OpenIddict 5.0 introduces a new Settings property in OpenIddictApplicationDescriptor (and in the EF Core/EF 6/MongoDB entities) and exposes 6 built-in settings that allow controlling the lifetime of access tokens, authorization codes, device codes, identity tokens, refresh tokens and user codes.

Here's an example that sets a static access token lifetime for a specific client:

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
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
ApplicationType = ApplicationTypes.Native,
ClientId = "postman",
ClientType = ClientTypes.Public,
ConsentType = ConsentTypes.Systematic,
DisplayName = "Postman",
RedirectUris =
{
new Uri("https://oauth.pstmn.io/v1/callback")
},
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Device,
Permissions.Endpoints.Token,
Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.DeviceCode,
Permissions.GrantTypes.Password,
Permissions.GrantTypes.RefreshToken,
Permissions.ResponseTypes.Code,
Permissions.Scopes.Email,
Permissions.Scopes.Profile,
Permissions.Scopes.Roles
},
Settings =
{
// Use a shorter access token lifetime for tokens issued to the Postman application.
[Settings.TokenLifetimes.AccessToken] = TimeSpan.FromMinutes(10).ToString("c", CultureInfo.InvariantCulture)
}
});

Even if a static token lifetime was attached to the client registration, the token lifetime can always be dynamically overridden using the SetAccessTokenLifetime(...) extension.

Client assertions

OpenIddict 4.x already supported client assertions, but only in the client stack (this feature was introduced in 4.0 as it was required for the Sign in with Apple integration).

OpenIddict 5.0 finalizes the implementation of client assertions by updating the server and validation stacks to support private_key_jwt for all the authenticated API endpoints (device authorization, token, introspection and revocation endpoints).

To use client assertions instead of client secrets, two things must be done:

Attach a JSON Web Key Set containing at least one signing key to the client registration

For that, a public key/private key pair must be generated first: the public key will be attached to the application entry and used by the server to validate the client assertions while the private key will be only known by the client and used to dynamically generate new client assertions.

To generate an ECDSA private/public key pair, use the following snippet:

1
2
3
4
5
6
7
8
9
using var algorithm = ECDsa.Create(ECCurve.NamedCurves.nistP256);

Console.WriteLine("Public key:");
Console.WriteLine(algorithm.ExportSubjectPublicKeyInfoPem());

Console.WriteLine();

Console.WriteLine("Private key:");
Console.WriteLine(algorithm.ExportECPrivateKeyPem());

Then, attach a JsonWebKeySet instance to OpenIddictApplicationDescriptor.JsonWebKeySet:

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
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
ApplicationType = ApplicationTypes.Web,
ClientId = "mvc",
ClientType = ClientTypes.Confidential,
ConsentType = ConsentTypes.Explicit,
DisplayName = "MVC client application",
JsonWebKeySet = new JsonWebKeySet
{
Keys =
{
// Instead of sending a client secret, this application authenticates by
// generating client assertions that are signed using an ECDSA signing key.
//
// Note: while the client needs access to the private key, the server only needs
// to know the public key to be able to validate the client assertions it receives.
JsonWebKeyConverter.ConvertFromECDsaSecurityKey(GetECDsaSigningKey($"""
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEI23kaVsRRAWIez/pqEZOByJFmlXd
a6iSQ4QqcH23Ir8aYPPX5lsVnBsExNsl7SOYOiIhgTaX6+PTS7yxTnmvSw==
-----END PUBLIC KEY-----
"""))
}
},
RedirectUris =
{
new Uri("https://localhost:44381/callback/login/local")
},
PostLogoutRedirectUris =
{
new Uri("https://localhost:44381/callback/logout/local")
},
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Logout,
Permissions.Endpoints.Token,
Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.RefreshToken,
Permissions.ResponseTypes.Code,
Permissions.Scopes.Email,
Permissions.Scopes.Profile,
Permissions.Scopes.Roles,
Permissions.Prefixes.Scope + "demo_api"
},
Requirements =
{
Requirements.Features.ProofKeyForCodeExchange
}
});

static ECDsaSecurityKey GetECDsaSigningKey(ReadOnlySpan<char> key)
{
var algorithm = ECDsa.Create();
algorithm.ImportFromPem(key);

return new ECDsaSecurityKey(algorithm);
}

Attach the signing key to the client registration or the validation options

To use client assertions with the OpenIddict client, the private signing key must be attached to the client registration:

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
services.AddOpenIddict()
.AddClient(options =>
{
// ...

// Add a client registration matching the client application definition in the server project.
options.AddRegistration(new OpenIddictClientRegistration
{
Issuer = new Uri("https://localhost:44395/", UriKind.Absolute),
ProviderName = "Local",

ClientId = "mvc",
Scopes = { Scopes.Email, Scopes.Profile, Scopes.OfflineAccess, "demo_api" },

RedirectUri = new Uri("callback/login/local", UriKind.Relative),
PostLogoutRedirectUri = new Uri("callback/logout/local", UriKind.Relative),

// Instead of sending a client secret, this application authenticates by
// generating client assertions that are signed using a private signing key.
//
// As such, no client secret is set, but an ECDSA key is registered and used by
// the OpenIddict client to automatically generate client assertions when needed.
//
// Note: while the server only needs access to the public key, the client needs
// to know the private key to be able to generate and sign the client assertions.
SigningCredentials =
{
new SigningCredentials(GetECDsaSigningKey($"""
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIMGxf/eMzKuW2F8KKWPJo3bwlrO68rK5+xCeO1atwja2oAoGCCqGSM49
AwEHoUQDQgAEI23kaVsRRAWIez/pqEZOByJFmlXda6iSQ4QqcH23Ir8aYPPX5lsV
nBsExNsl7SOYOiIhgTaX6+PTS7yxTnmvSw==
-----END EC PRIVATE KEY-----
"""), SecurityAlgorithms.EcdsaSha256, SecurityAlgorithms.Sha256)
}
});

static ECDsaSecurityKey GetECDsaSigningKey(ReadOnlySpan<char> key)
{
var algorithm = ECDsa.Create();
algorithm.ImportFromPem(key);

return new ECDsaSecurityKey(algorithm);
}
});

To use client assertions in introspection requests sent by the OpenIddict validation handler, the private signing key must be attached to the options:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
services.AddOpenIddict()
.AddValidation(options =>
{
// ...

options.AddSigningKey(GetECDsaSigningKey($"""
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIMGxf/eMzKuW2F8KKWPJo3bwlrO68rK5+xCeO1atwja2oAoGCCqGSM49
AwEHoUQDQgAEI23kaVsRRAWIez/pqEZOByJFmlXda6iSQ4QqcH23Ir8aYPPX5lsV
nBsExNsl7SOYOiIhgTaX6+PTS7yxTnmvSw==
-----END EC PRIVATE KEY-----
"""));

static ECDsaSecurityKey GetECDsaSigningKey(ReadOnlySpan<char> key)
{
var algorithm = ECDsa.Create();
algorithm.ImportFromPem(key);

return new ECDsaSecurityKey(algorithm);
}
});

Both a client secret and one or more signing keys can be assigned to a client application: in this case, the client will be able to use both authentication methods. To force a client application to use client assertions, you can remove its client secret.

The less secure client_secret_jwt is deliberately not supported, as OpenIddict always "hashes" the client secret using PBKDF before storing it in the database: once a client is created, OpenIddict no longer has access to the original client secret and thus cannot use it to determine whether a JWT client assertion was signed using the correct client secret or not.

What's next?

OpenIddict 5.0 RTM is expected to ship mid-December. Until then, it's likely additional previews will be released to gather precious feedback from the community 😀