Cirreum.Authentication.External 2.0.3

dotnet add package Cirreum.Authentication.External --version 2.0.3
                    
NuGet\Install-Package Cirreum.Authentication.External -Version 2.0.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Cirreum.Authentication.External" Version="2.0.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Cirreum.Authentication.External" Version="2.0.3" />
                    
Directory.Packages.props
<PackageReference Include="Cirreum.Authentication.External" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Cirreum.Authentication.External --version 2.0.3
                    
#r "nuget: Cirreum.Authentication.External, 2.0.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Cirreum.Authentication.External@2.0.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Cirreum.Authentication.External&version=2.0.3
                    
Install as a Cake Addin
#tool nuget:?package=Cirreum.Authentication.External&version=2.0.3
                    
Install as a Cake Tool

Cirreum Authentication - External (BYOID)

NuGet Version License .NET

Multi-tenant external IdP (BYOID) authentication scheme for the Cirreum framework

Overview

Cirreum.Authentication.External enables a single API to accept JWT bearer tokens from multiple customer Identity Providers (Okta, Auth0, customer Entra tenants, etc.) without federating those IdPs into yours. The customer's existing IdP issues tokens; your API validates them per-tenant using the resolved tenant configuration.

Use this package when your customers want to sign in to your API with their own IdP credentials. Use Cirreum.Authentication.Oidc or Cirreum.Authentication.Entra instead when you have a single, configured-by-you IdP.

How it works

  1. The inbound request carries a tenant indicator — a header (X-Tenant-Slug), a path segment (/tenants/{slug}/...), or a subdomain ({tenant}.api.example.com).
  2. The package's IExternalTenantResolver (your implementation) maps that indicator to the tenant's configuration: OIDC metadata address, valid audiences, etc.
  3. JWKS metadata is fetched from the tenant's .well-known/openid-configuration and cached per JwksCacheDurationMinutes.
  4. The inbound Authorization: Bearer {jwt} is validated against the resolved per-tenant configuration.
  5. On success, the ClaimsPrincipal reflects the tenant's claims.

The dynamic forward resolver picks this scheme (via ExternalAuthenticationSchemeSelector) when both a tenant indicator and a Bearer token are present on the request.

One scheme, many tenants

External is a single-instance provider: it serves every tenant through one scheme, resolving each tenant's issuer at request time. Per-tenant variance belongs in your IExternalTenantResolver, not in additional configured instances — a second enabled instance fails composition with a diagnostic.

As with every Cirreum authentication provider, the configured instance key is the scheme name. That name is what [Authorize(AuthenticationSchemes = ...)] matches and what an IApplicationUserResolver.Scheme must return to be dispatched for External-authenticated requests.

Installation

dotnet add package Cirreum.Authentication.External

Configuration

{
  "Cirreum": {
    "Authentication": {
      "Providers": {
        "External": {
          "Instances": {
            "Byoid": {
              "Enabled": true,
              "TenantIdentifierSource": "Header",
              "TenantHeaderName": "X-Tenant-Slug",
              "JwksCacheDurationMinutes": 60,
              "RequireHttpsMetadata": true,
              "TenantNotFoundBehavior": "Reject",
              "ClockSkewSeconds": 30,
              "DetailedErrors": false,
              "TenantResolverCache": {
                "DurationSeconds": 0
              }
            }
          }
        }
      }
    }
  }
}

The instance key (Byoid above) becomes the scheme name; ExternalDefaults.AuthenticationScheme is that conventional key. Do not set a Scheme value in configuration — the registrar derives it from the key and fails loudly on a mismatch.

Then register your tenant resolver inside the AddAuthentication(...) composition callback:

builder.AddAuthentication(auth => auth
    .AddExternalTenantResolver<MyTenantResolver>());

The resolver is registered scoped by default, so it can consume a scoped store (a DbContext, a unit of work, a repository). A resolver that holds its own cache and takes no scoped dependencies can opt in:

auth.AddExternalTenantResolver<MyCachingResolver>(lifetime: ServiceLifetime.Singleton);

Caching tenant resolution

Your resolver runs on every authenticated request. For a resolver reading tenant rows from a database, that is a round trip per request — JWKS and IdP metadata are already cached, but tenant resolution was not.

Caching is off by default (DurationSeconds: 0), because it widens the window in which a tenant you disabled at the source still authenticates. Enable it by setting a duration:

"TenantResolverCache": {
  "DurationSeconds": 300,
  "NotFoundDurationSeconds": 30,
  "MaxEntries": 1000
}

NotFoundDurationSeconds caches the absence of a tenant, so an unknown slug cannot be used to generate database load. It is deliberately short so a newly created tenant becomes reachable quickly. MaxEntries bounds the cache; entries are keyed on the tenant slug together with the token's issuer and audience, never on the token itself.

Rather than waiting out the duration, close the staleness window by publishing an event wherever your application changes a tenant's configuration — disabling it, rotating its IdP, changing its audience:

await publisher.PublishAsync(
    new ExternalTenantConfigurationChanged(tenantSlug, DateTimeOffset.UtcNow));

The framework invalidates that tenant's cache entry on every replica, provided coordination broadcast is configured. There is no cache interface to implement and nothing to register — publishing the event is the whole integration.

Reshaping the metadata HTTP client

Metadata and signing-key retrieval uses a named client registered with a 10-second timeout. To supply a proxy, a pinned certificate, or different pooling:

builder.Services.AddHttpClient(ExternalDefaults.HttpClientName)
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { /* ... */ });

This affects outbound metadata retrieval only. Token validation is local and unaffected.

Implementing the tenant resolver

public sealed class MyTenantResolver(IDbConnection db) : IExternalTenantResolver {

    public async Task<ExternalTenantConfig?> ResolveAsync(
        ExternalResolutionContext context,
        CancellationToken cancellationToken = default) {

        var row = await db.QueryFirstOrDefaultAsync(
            "SELECT Slug, IsActive, MetadataUrl, Audience FROM Tenants WHERE Slug = @Slug",
            new { Slug = context.TenantSlug });

        if (row is null) {
            return null;
        }

        return new ExternalTenantConfig {
            Slug = row.Slug,
            IsEnabled = row.IsActive,
            MetadataAddress = row.MetadataUrl,
            ValidAudiences = [row.Audience]
        };
    }
}

ExternalResolutionContext also carries the token's issuer and audience, so a resolver can key on those instead of — or alongside — the tenant slug.

Tenants whose IdP doesn't use aud

ValidAudiences must name your API, never a client ID — that is what separates an access token from an ID token, since an ID token's audience is the client that requested sign-in.

Some IdPs don't fit that model. AWS Cognito puts the app client ID in client_id on access tokens and may omit aud entirely, while its ID tokens carry it in aud, so validating aud rejects every access token. Cognito does mark the difference, with a token_use claim that is access or id:

return new ExternalTenantConfig {
    Slug = row.Slug,
    IsEnabled = row.IsActive,
    MetadataAddress = row.MetadataUrl,
    ValidAudiences = [row.AppClientId],
    AudienceClaim = "client_id",
    RequiredClaims = new Dictionary<string, string> { ["token_use"] = "access" }
};

These two are coupled and the framework enforces it. Moving the audience off aud removes the check that distinguishes an access token from an ID token, so a config that sets AudienceClaim without any RequiredClaims is rejected at resolution time and authenticates no one. You can't get the dangerous half on its own.

RequiredClaims works on its own for any IdP that marks token kind with a claim. Values are compared ordinally and case-sensitively; an absent claim fails.

What changed

Selector-based dispatch

ExternalAuthenticationSchemeSelector implements ISchemeSelector at SchemeSelectorPriority.External, ahead of the generic JwtAudienceSchemeSelector, so the stricter "tenant indicator and Bearer both required" probe runs first. The dynamic forward resolver picks External when:

  1. A tenant indicator is present (per configured TenantIdentifierSource)
  2. An Authorization: Bearer header is present

The legacy static ExternalSchemeSelector helper class is retired. Detection logic survives as static methods on the new instance class for apps that compose conflict-detection at startup.

Security considerations

  • Audience is the boundary — provided your API audience is distinct from the client ID. ValidAudiences on the resolved tenant config must name your API, never a client ID. An access token's audience is the API it was issued for; an ID token's audience is the client that requested sign-in, so distinct values are what stop an ID token being replayed against your API as a bearer token. This is the usual arrangement but not a guarantee — an IdP that issues access tokens audienced to the client itself (Entra v1 can) produces both kinds carrying the same aud, and audience validation cannot tell them apart. Where a provider exposes a discriminator, declare it with RequiredClaims or RequireAccessTokenType. Audience validation is mandatory and fails closed: blank and whitespace-only entries are discarded before comparison — so an empty configured audience can never be matched by an empty token audience — and a tenant left with no usable entry rejects every token rather than validating against an empty set.
  • Reserved claims — the handler stamps tenant_slug and auth_scheme itself (see ExternalClaimTypes). A claim of either type arriving in a tenant's token, or produced by a ClaimMappings entry targeting one, is discarded before the resolved value is stamped. Without that, the identity carries two claims of the same type and FindFirst returns the token's.
  • Signing algorithmsValidAlgorithms on the resolved tenant config pins which algorithms are accepted. Left null, any algorithm the tenant's published keys support is accepted.
  • Relocating the audience requires a replacement discriminatorAudienceClaim moves the check off aud for an IdP that carries the audience elsewhere, which also moves it off the access-token/ID-token boundary. RequiredClaims must then supply a claim that restores it. The framework rejects a tenant config that does the first without the second, so this cannot be got wrong by setting one field and forgetting the other.
  • Tenant configuration trust — your IExternalTenantResolver must return only verified, currently-active tenant configurations. When resolution caching is enabled, publish ExternalTenantConfigurationChanged on deactivation rather than waiting out DurationSeconds.
  • HTTPS enforcementRequireHttpsMetadata: true (default) rejects a non-HTTPS metadata address at fetch time. It does not control certificate validation, which always applies; use the named HTTP client above if a development environment needs a custom handler.
  • Clock skewClockSkewSeconds: 30 is a reasonable default; tighten for high-trust tenants.
  • TenantNotFoundBehaviorReject is the safe default; Fallback is only appropriate when your fallback is your own IdP under your control.
  • Token type — set RequireAccessTokenType on the resolved tenant config to require RFC 9068 at+jwt. Opt-in per tenant, because most IdPs — Entra, Cognito, Auth0 — emit plain JWT for access tokens by default, so requiring it of a tenant whose IdP does not stamp it rejects every one of their tokens.
  • Authorized party — populate AllowedClientIds on the resolved tenant config to restrict which of a tenant's client applications may call your API (matched against azp, then client_id).
  • Outbound traffic — token validation is entirely local. The only request made to a tenant's IdP is metadata retrieval, coalesced across concurrent callers and floored at one attempt per five minutes, so a caller presenting invalid tokens cannot generate load on a customer's identity provider through your API.

License

MIT — see LICENSE.


Cirreum Foundation Framework Layered simplicity for modern .NET

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on Cirreum.Authentication.External:

Package Downloads
Cirreum.Runtime.Authentication

App-facing umbrella for the Authentication pillar. Provides AddAuthentication() and the CirreumAuthenticationBuilder type. Transitively references all six Cirreum.Authentication.* schemes (ApiKey, SignedRequest, SessionTicket, OIDC, Entra, External) — apps install this single package to get the full Authentication track.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.0.3 0 8/4/2026
2.0.2 48 7/31/2026
2.0.1 97 7/30/2026
2.0.0 110 7/27/2026
1.1.1 109 7/25/2026
1.1.0 88 7/24/2026
1.0.6 124 7/20/2026
1.0.5 103 7/19/2026
1.0.4 117 7/7/2026
1.0.3 108 7/6/2026
1.0.2 100 7/5/2026
1.0.1 123 7/4/2026
1.0.0 114 7/3/2026