Skip to content

Release v2.0.0

Choose a tag to compare

@hyspdrt hyspdrt released this 27 Jul 20:52

Cirreum.Authentication.External 2.0.0

A security and stewardship release for the BYOID scheme, plus tenant-resolution caching and a seam
for IdPs that do not fit the standard audience model.

Full detail and step-by-step upgrade instructions: docs/MIGRATION-v2.md.

Check these two before deploying

Both can break a running deployment on upgrade with no code change on your side, because they
change how tenant IdP metadata is fetched.

1. RequireHttpsMetadata now enforces HTTPS. It was never passed to the configuration manager,
so an http:// metadata address was fetched whether the flag was true or false. Confirm every
tenant record's MetadataAddress uses https://, or set RequireHttpsMetadata: false on instances
that legitimately reach an HTTP endpoint.

2. RequireHttpsMetadata: false no longer disables certificate validation. That is what the flag
actually did before — it installed DangerousAcceptAnyServerCertificateValidator, so a setting a
developer flipped to reach a local IdP silently accepted any certificate in whatever environment the
configuration reached. Certificate validation now always applies. A development environment that
needs a custom handler reconfigures ExternalDefaults.HttpClientName in code, scoped to that
environment.

Security

A tenant's token can no longer shadow the claims the framework stamps. tenant_slug and
auth_scheme were appended to an identity that might already carry them — from the token, or from a
ClaimMappings entry targeting them, since the mapping target was unrestricted. Two claims of the
same type meant FindFirst returned the token's, because it was added first. On the multi-tenant
boundary that is a tenant-spoofing primitive. Both are now reserved and discarded from the incoming
identity before the resolved values are stamped. Present in every 1.x version.

Other breaking changes

  • ExternalResolutionContext.TokenAudience is now TokenAudiences (IReadOnlyList<string>),
    because aud may be an array. Use .Contains(...) where you compared a scalar.
  • AllowedClientIds is matched case-sensitively. Client IDs are opaque identifiers; folding case
    could only widen the accepted set. Confirm your tenant records match the case your IdP issues.
  • AddExternalTenantResolver<T>() lost its configure callback. The options type behind it
    shipped with no members and nothing ever read the callback, so no behaviour is lost. Delete the
    argument.
  • ExternalConfigurationManager's constructor takes an IHttpClientFactory. Only affects code
    constructing the type directly; resolving IExternalConfigurationManager from the container is
    unaffected.
  • The idp_type claim is no longer stamped. It derived from ClaimsHelper.ResolveProvider,
    removed in Cirreum.Kernel 2.0.0. The auth_scheme claim is unchanged.

Loosened — no action needed

Two token pre-checks were removed. Both only ever rejected, so nothing that previously succeeded now
fails.

A missing typ header no longer rejects the token — typ is optional under RFC 7519 and omitting it
is legal, so requiring it turned away valid tokens from IdPs you do not control. The
typ == "id_token" check is gone because no IdP emits that value; it never fired.

An ID token presented as an access token is still rejected — by audience validation, which is
mandatory and fails closed. Worth confirming for your tenants: ValidAudiences 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. For tenants whose IdP does not work that way, see below.

New

A seam for IdPs that do not use aud

ExternalTenantConfig gains AudienceClaim and RequiredClaims.

AWS Cognito is the case that motivates them: its access tokens carry the app client ID in
client_id and may have no aud at all, while its ID tokens carry it in aud. Validating aud
therefore rejects every Cognito access token, and the audience does not separate the two kinds for
that vendor at all. Cognito does mark the difference, with a token_use claim that is access or
id:

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

The two are coupled by the framework, not by documentation. Moving the audience off aud also
moves it off the check that distinguishes an access token from an ID token, so a configuration that
does the first without supplying a claim that restores the second is rejected at resolution time and
authenticates no one. The dangerous half cannot be selected alone.

Both are plain data on the tenant record, so a tenant of this shape is a database row rather than a
code path — no vendor is named anywhere in the framework. RequiredClaims is usable on its own for
any IdP that marks token kind with a claim of its own.

Tenant-resolution caching

IExternalTenantResolver runs on every authenticated request, so a resolver reading tenant rows from
a database made a round trip per request while JWKS and metadata were already cached. Off by
default — caching widens the window in which a tenant disabled at the source still authenticates.
Enable with TenantResolverCache.DurationSeconds.

To close that window rather than wait it out, publish ExternalTenantConfigurationChanged when a
tenant's configuration changes; the framework invalidates that tenant's entry on every replica. There
is no cache interface to implement.

A named HTTP client for metadata retrieval

ExternalDefaults.HttpClientName, registered with a 10-second timeout. HttpClient defaults to 100
seconds, long enough that a tenant IdP which stops responding holds the authenticating request open
instead of failing it. Reconfigure the named client to supply a proxy, a pinned certificate, or
different pooling.

Fixed

  • An array-valued aud was read as no audience at all. aud may be a string or an array
    (RFC 7519 §4.1.3), and reading an array as a string yields a single coerced value rather than
    failing — so a multi-audience token reached the resolver with a partial audience, and a tenant
    whose AudienceClaim named an array-valued claim had valid tokens rejected.
  • A malformed token could escape as an unhandled exception. The pre-read sits ahead of the
    validation try, and ReadJsonWebToken can throw on input that CanReadToken accepts.
  • A blank configured audience matched a blank token audience. ValidAudiences is required, but
    nothing stopped it holding an empty string — and a token presenting an empty audience then compared
    equal, turning a missing configuration into an acceptance. Blank and whitespace-only entries are
    now discarded before comparison, and a tenant left with no usable entry authenticates no one.
    Present in every 1.x version.
  • A token carrying no aud claim returned 500 instead of 401. The pre-read used
    GetPayloadValue, which throws when the claim is absent, so the exception escaped
    HandleAuthenticateAsync rather than failing authentication. Omitting aud is legal, and it is
    what AWS Cognito issues — this defect is present in every 1.x version.
  • Tenant-cache settings never reached the handler's options. The registrar built one options
    instance for the extractor, scheme selector and cache, and AddScheme configured a second for the
    handler, kept in step by a hand-written property-by-property copy that had fallen three properties
    behind. There is now one instance, so the copy — and the class of defect — is gone.
  • ExternalAuthenticationInstanceSettings documented its configuration section as the pre-1.0
    Cirreum:Authorization:... path.
  • DetailedErrors lost its documentation when properties were inserted between its doc comment and
    its declaration.