Table of Contents

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 aud values must be found in OpenId.Settings:Audiences, otherwise the sign-in fails with Invalid audience. The comparison is a substring search in the space-separated list of the configured audiences (see the warning below);
  • when ValidateAuthority is true (the default), its iss claim must be exactly DefaultAuthority:Issuer when it is set, or the issuer of the provider's metadata otherwise, otherwise the sign-in fails with Invalid 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: false turns 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.ValidateAudience in code) disables only the Arc4u check of the access token. With the audiences kept in OpenId.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.

See also