OpenID Connect and cookie
Use AddOidcAuthentication for an application used from a browser: a server-rendered site, Blazor Server,
or a backend for frontend. Unauthenticated users are redirected to the identity provider, the session is kept
in a cookie, and the user's access token is refreshed before it expires, so the application can call other
APIs on behalf of the user. Each request gets an AppPrincipal. Start
with the overview for the services every scenario registers.
How it works
The following diagram shows the sign-in and a later request.
sequenceDiagram
participant B as Browser
participant A as Application
participant I as Identity provider
B->>A: GET /page
A-->>B: 302 to the authorize endpoint (code flow, PKCE)
B->>I: Sign in
I-->>B: Form post to /signin-oidc (code)
B->>A: POST /signin-oidc
A->>I: Token request (code, client secret, code verifier)
I-->>A: id token, access token, refresh token
Note over A: Audience and issuer of the access token checked
A-->>B: Session cookie, 302 to /page
B->>A: GET /page with the cookie
Note over A: Access token refreshed when it expires in less than ForceRefreshTimeoutTimeSpan
- The authorization code flow with PKCE is used; the response mode is always
form_post. - The tokens are saved in the authentication ticket. The ticket is stored in a cache and the cookie only holds its key.
- The identity of the user has the authentication type
Cookies.
Configuration
AddOidcAuthentication reads the Authentication
section (parameter authenticationSectionName) into
OidcAuthenticationSectionOptions, then the sections it points to.
appsettings.json
{
"Application.configuration": { "ApplicationName": "MyApp" },
"Caching": {
"Default": "Volatile",
"Caches": [
{
"Name": "Volatile",
"Kind": "Memory",
"IsAutoStart": true,
"Settings": { "SizeLimitInMB": 10 }
}
]
},
"Authentication": {
"DefaultAuthority": { "Url": "https://login.example.com/my-tenant/v2.0" },
"CookieName": ".MyApp.Cookies",
"OpenId.Settings": {
"ClientId": "my-client-id",
"ClientSecret": "<client-secret>",
"Audiences": [ "api://my-api" ],
"Scopes": [ "openid", "profile", "offline_access", "api://my-api/access" ]
},
"DataProtection": {
"EncryptionCertificate": { "Store": { "Name": "MyAppCertificate" } },
"CacheStore": { "CacheKey": "DataProtection", "CacheName": "Volatile" }
},
"TokenCache": { "CacheName": "Volatile" }
}
}
Important
A memory cache is enough to try the scenario. In production, store the data protection keys and the tickets in a cache shared by all instances and kept across restarts (Redis, SQL Server, Dapr), otherwise users lose their session when the application restarts or when a request reaches another instance.
OpenID settings
The Authentication:OpenId.Settings section (OpenIdSettingsOption):
| Key | Type | Default | Description |
|---|---|---|---|
ClientId |
string | none (required) | Client id of the application in the identity provider. |
ClientSecret |
string | none | Client secret, sent to the token endpoint. Keep it out of appsettings.json (user secrets, environment variables or an encrypted value). |
Scopes |
string array | none (required) | Scopes requested. Nothing is added for you: include openid, and the scope that makes your provider issue a refresh token (usually offline_access). |
Audiences |
string array | none (required while ValidateAudience is true) |
Accepted audiences of the access token returned by the token endpoint. The id token is validated against ClientId by ASP.NET Core. |
ValidateAudience |
bool | true |
Audience validation, see Disable the audience check. |
Authority |
object | the default authority | Authority used for these settings. The metadata is still read from DefaultAuthority. |
ProviderId |
string | Oidc |
Key of the token provider that returns the user's access token. |
AuthorizationEndpoint |
object | empty | Extra parameters added to the authorize request, see Send extra parameters. |
TokenEndpoint |
object | empty | Extra parameters added to the token and refresh requests. |
A missing ClientId, Scopes or Audiences throws MissingFieldException at startup.
Authentication section
The other keys of the Authentication section:
| Key | Type | Default | Description |
|---|---|---|---|
DefaultAuthority |
object | none (required) | The identity provider, see Configuration. |
CookieName |
string | none (required) | Name of the session cookie. Use a name specific to the application. |
CallbackPath |
string | /signin-oidc |
Path the identity provider posts the code to. Register https://<host><CallbackPath> as redirect URI. |
ResponseType |
string | code |
OpenID Connect response type. |
AuthenticationMethod |
string | RedirectGet |
How the browser is sent to the identity provider: RedirectGet (302) or FormPost (an HTML page that posts the request). |
ForceRefreshTimeoutTimeSpan |
TimeSpan | 00:05:00 |
The access token is refreshed when it expires in less than this value. |
RefreshTokenLifetime |
TimeSpan | 90.00:00:00 |
Assumed lifetime of the refresh token. It is also the MaxAge of the cookie. |
AuthenticationTicketTtl |
TimeSpan | 7.00:00:00 |
Sliding lifetime of the session. The cookie expires after the shorter of this value and RefreshTokenLifetime. |
DefaultKeyLifetime |
TimeSpan | 365.00:00:00 |
Lifetime of the data protection keys. |
NameClaimType |
string | name |
Claim type of Identity.Name. |
RoleClaimType |
string | role |
Claim type used as role claim by the handler. The AppPrincipal roles come from the Arc4u authorization, see Claims and authorization. |
CertSecurityKeyPath |
string | none | Section describing a certificate used as issuer signing key. |
ApplicationNameSectionPath |
string | Application.configuration:ApplicationName |
Configuration key holding the application name that isolates the data protection keys. |
OpenIdSettingsSectionPath |
string | Authentication:OpenId.Settings |
Section of the OpenID settings. |
CertificateSectionPath |
string | Authentication:DataProtection:EncryptionCertificate |
Certificate that encrypts the data protection keys. |
DataProtectionSectionPath |
string | Authentication:DataProtection:CacheStore |
Cache that stores the data protection keys. |
AuthenticationCacheTicketStorePath |
string | Authentication:AuthenticationCacheTicketStore |
Cache that stores the tickets. |
TokenCacheSectionPath |
string | Authentication:TokenCache |
Token cache. CacheName is required. |
ClaimsIdentifierSectionPath |
string | Authentication:ClaimsIdentifier |
Claim types that identify a user. |
ClaimsFillerSectionPath |
string | Authentication:ClaimsMiddleWare:ClaimsFiller |
Extra claims options. |
DomainMappingsSectionPath |
string | Authentication:DomainsMapping |
Domain mapping of the user profile. |
ValidateAudience and ValidateAuthority (both true by default) are also read from this section and copied to
OidcAuthenticationOptions, see Disable the audience check.
Data protection certificate
The data protection keys, which encrypt the cookie, are stored in the cache named by
Authentication:DataProtection:CacheStore:CacheName under the key CacheKey, and are encrypted with a
certificate. Both CacheKey and CacheName are required. The certificate is read from the store or from PEM
files:
| Form | Keys | Description |
|---|---|---|
Store |
Name (required), FindType (default FindBySubjectName), Location (default LocalMachine), StoreName (default My) |
A certificate of the operating system store. |
File |
Cert, Key |
Paths of the PEM certificate and of its PEM private key, for example a Kubernetes secret mounted as a volume. |
{
"Authentication": {
"DataProtection": {
"EncryptionCertificate": {
"File": { "Cert": "/app/certs/tls.crt", "Key": "/app/certs/tls.key" }
},
"CacheStore": { "CacheKey": "DataProtection", "CacheName": "Volatile" }
}
}
}
When both are present, Store is used. A certificate that cannot be found throws at startup.
Ticket store
The Authentication:AuthenticationCacheTicketStore section is optional:
| Key | Type | Default | Description |
|---|---|---|---|
CacheName |
string | Default |
Cache that stores the tickets. When no cache has this name, the default cache is used. |
KeyPrefix |
string | AuthSessionStore- |
Prefix of the cache keys. |
Code
using Arc4u.OAuth2.Extensions;
using Arc4u.OAuth2.Token;
using Arc4u.OAuth2.TokenProviders;
// ... the Arc4u services of the overview ...
builder.Services.AddTransient<ITokenRefreshProvider, RefreshTokenProvider>();
builder.Services.AddKeyedTransient<ITokenProvider, OidcTokenProvider>(OidcTokenProvider.ProviderName);
builder.Services.AddOidcAuthentication(builder.Configuration);
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
RefreshTokenProvider refreshes the session tokens, and OidcTokenProvider gives the user's access token
to the claims filler and to the code that calls other APIs. Besides the handlers, AddOidcAuthentication
registers the token cache, the claims filler options, the domain mapping, the on-behalf-of settings
(Authentication:OnBehalfOf) and the options of the bearer injector.
Token checks
On the token response, StandardOpenIdConnectEvents checks the access token:
- one of its
audvalues must be found inOpenId.Settings:Audiences, otherwise the sign-in fails withInvalid audience. The comparison is a substring search in the space-separated list of the configured audiences (see the warning below); - when
ValidateAuthorityistrue(the default), itsissclaim must be exactlyDefaultAuthority:Issuerwhen it is set, or theissuerof the provider's metadata otherwise, otherwise the sign-in fails withInvalid authority. Bearer tokens of hybrid authentication follow the same rule.
The access token must be a JWT. The id token is validated by ASP.NET Core: signature, lifetime, nonce, and an
audience equal to ClientId or to one of OpenId.Settings:Audiences.
Warning
Known issue: because the audience check of the access token is a substring search, an aud that is part of a
configured audience is accepted. With "Audiences": [ "api://my-api" ], access tokens for api://my,
my-api or even i are accepted. The same events are used by AddHybridAuthentication. Configure audiences
that no other audience issued by your provider is a part of, or add an exact check as shown below.
The following events class checks the audiences exactly, then delegates everything to the Arc4u events.
StandardOpenIdConnectEvents is sealed, so it is wrapped rather than derived:
// ExactAudienceOpenIdConnectEvents.cs
using System.IdentityModel.Tokens.Jwt;
using Arc4u.Configuration;
using Arc4u.OAuth2.Events;
using Arc4u.OAuth2.Token;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options;
public sealed class ExactAudienceOpenIdConnectEvents(
StandardOpenIdConnectEvents inner,
IOptionsMonitor<SimpleKeyValueSettings> settings) : OpenIdConnectEvents
{
public override Task TokenResponseReceived(TokenResponseReceivedContext context)
{
var accessToken = context.TokenEndpointResponse.AccessToken;
var openIdSettings = settings.Get(Arc4u.OAuth2.Constants.CookiesAuthenticationType).Values;
if (!string.IsNullOrWhiteSpace(accessToken) &&
openIdSettings.TryGetValue(TokenKeys.Audiences, out var audiences))
{
var allowed = audiences.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (!new JwtSecurityToken(accessToken).Audiences.Any(aud => allowed.Contains(aud, StringComparer.Ordinal)))
{
context.Fail("Invalid audience");
return Task.CompletedTask;
}
}
return inner.TokenResponseReceived(context);
}
public override Task RedirectToIdentityProvider(RedirectContext context) => inner.RedirectToIdentityProvider(context);
public override Task AuthorizationCodeReceived(AuthorizationCodeReceivedContext context) => inner.AuthorizationCodeReceived(context);
public override Task AuthenticationFailed(AuthenticationFailedContext context) => inner.AuthenticationFailed(context);
public override Task AccessDenied(AccessDeniedContext context) => inner.AccessDenied(context);
public override Task RemoteFailure(RemoteFailureContext context) => inner.RemoteFailure(context);
}
Register it before AddOidcAuthentication (or AddHybridAuthentication), which only adds its own events when
none is registered:
using Arc4u.OAuth2.Events;
using Arc4u.OAuth2.Extensions;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
builder.Services.AddTransient<StandardOpenIdConnectEvents>();
builder.Services.AddTransient<OpenIdConnectEvents, ExactAudienceOpenIdConnectEvents>();
builder.Services.AddOidcAuthentication(builder.Configuration);
Session and refresh
The session cookie is HttpOnly, Secure (always), SameSite=Lax and essential. Its expiration slides,
and it expires after the shorter of AuthenticationTicketTtl and RefreshTokenLifetime.
On each request, StandardCookieEvents reads the tokens of the ticket. When the
access token expires in less than ForceRefreshTimeoutTimeSpan, it calls the token endpoint with the refresh
token and renews the cookie. If there is no refresh token, or the refresh fails, the user is signed out and
the next request starts a new sign-in.
Common scenarios
Send extra parameters to the identity provider
Some providers need parameters that OpenID Connect does not define, for example a resource or an
audience. Add them as key/value pairs; they are sent with every authorize request, and the TokenEndpoint
ones with every token request (the refresh requests receive both lists):
{
"Authentication": {
"OpenId.Settings": {
"AuthorizationEndpoint": { "resource": "urn:my-api" },
"TokenEndpoint": { "audience": "my-audience" }
}
}
}
To read them from other sections, call AddAuthenticationApiContext(configuration, section) before
AddOidcAuthentication, with an ApiExtraContextAuthenticationSectionOption whose
AuthorizationEndpointSectionPath and TokenEndpointSectionPath name your sections: the first registration
wins.
Run behind a TLS-terminating proxy
In Kubernetes, the ingress often ends TLS and forwards plain HTTP. ASP.NET Core then builds the redirect URI
with http://, which the identity provider rejects. StandardOpenIdConnectEvents replaces http:// with
https:// in the redirect URI, except for http://localhost
(#131).
The host of the redirect URI still comes from the request. When the proxy changes the host, forward it with the ASP.NET Core forwarded headers middleware (general ASP.NET Core guidance, see Configure ASP.NET Core to work with proxy servers).
Disable the audience check
Some providers issue access tokens without your audience (Keycloak, by default). Two switches control the audience checks:
OpenId.Settings:ValidateAudience: falseturns off the audience validation of the id token by the OpenID Connect handler, and removes the audience list from the settings. Without an audience list, the Arc4u check of the access token is skipped too.Authentication:ValidateAudience: false(OidcAuthenticationOptions.ValidateAudiencein code) disables only the Arc4u check of the access token. With the audiences kept inOpenId.Settings, the id token check stays.
{
"Authentication": {
"ValidateAudience": false,
"OpenId.Settings": { "ValidateAudience": false }
}
}
In the same way, Authentication:ValidateAuthority: false disables the issuer check of the access token. Both
values are applied by every overload of AddOidcAuthentication and AddHybridAuthentication
(#220). Prefer configuring an audience in the identity provider
when it is possible.
Configure from code
AddOidcAuthentication(Action<OidcAuthenticationOptions>) takes the options directly and applies
ValidateAudience and ValidateAuthority. OpenIdSettingsOptions, DataProtectionCertificate,
DataProtectionCacheStoreOption, ClaimsIdentifierOptions and DefaultAuthority are required. It does not
read any configuration section: register the token cache and the other options yourself.
using System.Security.Cryptography.X509Certificates;
using Arc4u.OAuth2.Extensions;
using Arc4u.OAuth2.Options;
var certificate = X509Certificate2.CreateFromPemFile("/app/certs/tls.crt", "/app/certs/tls.key");
builder.Services.AddTokenCache(options => options.CacheName = "Volatile");
builder.Services.AddOidcAuthentication(options =>
{
options.DefaultAuthority = new AuthorityOptions(new Uri("https://login.example.com/my-tenant/v2.0"), null, null, null);
options.CookieName = ".MyApp.Cookies";
options.ApplicationName = "MyApp";
options.DataProtectionCertificate = certificate;
options.DataProtectionCacheStoreOption = store => { store.CacheKey = "DataProtection"; store.CacheName = "Volatile"; };
options.ClaimsIdentifierOptions = identifiers => identifiers.Add("oid");
options.OpenIdSettingsOptions = settings =>
{
settings.ClientId = "my-client-id";
settings.ClientSecret = builder.Configuration["MyApp:ClientSecret"]!;
settings.Audiences.Add("api://my-api");
settings.Scopes.AddRange(["openid", "profile", "offline_access", "api://my-api/access"]);
};
});
Use the user's access token
In a cookie session the access token is in the BootstrapContext of the identity, as with JWT bearer: on each
request, the cookie authentication puts it there after refreshing it when it expires in less than
ForceRefreshTimeoutTimeSpan. It is not refreshed again during the request. For long-running work, resolve the
keyed ITokenProvider named Oidc instead: it refreshes the token when needed. To also put the token in the
Authorization header of cookie-authenticated requests, so code written for bearer tokens works unchanged, add
app.UseOpenIdBearerInjector() after UseAuthentication(). When on-behalf-of
settings named Obo_for_OpenId exist (Authentication:OnBehalfOf:Obo_for_OpenId), the injector puts an
on-behalf-of token obtained with the Obo provider instead of the user's token. Its options are registered by
the configuration overloads; with the code overload, call builder.Services.AddOpenIdBearerInjector(). Calling other
APIs with this token, directly or on behalf of the user, is covered in
Client authentication.
Troubleshooting
"There was an issue during the request: Invalid audience."
The access token returned by the token endpoint has no audience listed in OpenId.Settings:Audiences. Request
a scope of your API so that the token is issued for it, and add its audience. The page is returned with a 200
status.
"There was an issue during the request: Invalid authority."
The iss claim of the access token differs from DefaultAuthority:Issuer when it is set, or the issuer of the provider's metadata otherwise, for example
because the provider issues access tokens with another issuer than its metadata (see
Identity providers). Set DefaultAuthority:Issuer to the iss claim of your access
tokens; the log entry of StandardOpenIdConnectEvents gives both values.
"You are not authenticated." with a 500 after the sign-in
The sign-in failed with an exception; the log contains it. A KeyNotFoundException for the key Audiences
with OpenId.Settings:ValidateAudience: false comes from an older version
(#220): upgrade.
The user is signed out after the lifetime of the access token
The provider returned no refresh token, so the session ends when the access token is about to expire (often
after one hour). Add the
scope that issues a refresh token (usually offline_access) and allow it for the client in the provider.
The identity provider rejects the redirect URI
The redirect URI is https://<host><CallbackPath>. Register exactly that value, and see
Run behind a TLS-terminating proxy when the application sees http or
another host.