Cirreum.Authentication.EntraClaims 1.0.3

Suggested Alternatives

Cirreum.Identity.EntraExternalId

Additional Details

This package is deprecated. All types are marked [Obsolete] and will not receive further updates. See Migration steps in the repo.

The owner has unlisted this package. This could mean that the package is deprecated, has security vulnerabilities or shouldn't be used anymore.
dotnet add package Cirreum.Authentication.EntraClaims --version 1.0.3
                    
NuGet\Install-Package Cirreum.Authentication.EntraClaims -Version 1.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.EntraClaims" Version="1.0.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Cirreum.Authentication.EntraClaims" Version="1.0.3" />
                    
Directory.Packages.props
<PackageReference Include="Cirreum.Authentication.EntraClaims" />
                    
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.EntraClaims --version 1.0.3
                    
#r "nuget: Cirreum.Authentication.EntraClaims, 1.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.EntraClaims@1.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.EntraClaims&version=1.0.3
                    
Install as a Cake Addin
#tool nuget:?package=Cirreum.Authentication.EntraClaims&version=1.0.3
                    
Install as a Cake Tool

Cirreum.Authentication.EntraClaims

NuGet Version NuGet Downloads GitHub Release License .NET

Drop-in Microsoft Entra External ID custom authentication extension handler for ASP.NET Core.

Overview

Cirreum.Authentication.EntraClaims handles onTokenIssuanceStart custom authentication extension callbacks from Microsoft Entra External ID. It validates Entra's bearer token with full OIDC signature verification and delegates access control to your application via a simple provisioner interface — returning custom role claims for the issued token.

Authenticating successfully against Entra does not grant access to your application. Your provisioner decides.

Installation

<PackageReference Include="Cirreum.Authentication.EntraClaims" Version="1.0.0" />

Quick Start

1. Implement a provisioner

public sealed class AppUserProvisioner(
    IUserRepository users,
    IInvitationService invitations
) : IEntraUserProvisioner {

    public async Task<ProvisionResult> ProvisionAsync(
        EntraClaimsContext context,
        CancellationToken cancellationToken = default) {

        var existing = await users.FindByEntraIdAsync(context.EntraUserId, cancellationToken);
        if (existing is not null) {
            return ProvisionResult.Allow(existing.Role);
        }

        var invitation = await invitations.RedeemAsync(context.Email, context.EntraUserId, cancellationToken);
        if (invitation is not null) {
            return ProvisionResult.Allow(invitation.Role);
        }

        return ProvisionResult.Deny();
    }
}

2. Register and map in Program.cs

builder.AddEntraClaims<AppUserProvisioner>();

var app = builder.Build();

// after UseAuthentication / UseAuthorization
app.MapEntraClaims();

3. Configure appsettings.json

{
  "Cirreum": {
    "Authentication": {
      "EntraClaims": {
        "Route": "/auth/entra/claims",
        "ClientId": "<claims-provider-app-client-id>",
        "Issuer": "https://<tenant-id>.ciamlogin.com/<tenant-id>/v2.0",
        "EntraAppId": "99045fe1-7639-4a75-9d4a-577b6ca3810f",
        "MetadataEndpoint": "https://<tenant-id>.ciamlogin.com/<tenant-id>/v2.0/.well-known/openid-configuration",
        "AllowedAppIds": "<client-app-id>"
      }
    }
  }
}
Setting Used by Description
Route Claims endpoint Endpoint path. Defaults to /auth/entra/claims. Must match the Target URL on the Custom Authentication Extension in the Azure Portal.
ClientId Claims endpoint Application (client) ID of your custom claims provider app registration. Validated as the aud claim on the incoming bearer token.
Issuer Both Entra External ID tenant issuer. Must use the tenant ID subdomain format — do not use a domain name. Used as the default enrichment issuer when EnrichmentIssuers is not set.
EntraAppId Claims endpoint Microsoft's authentication events API app ID, validated as appid/azp. Fixed value 99045fe1-7639-4a75-9d4a-577b6ca3810f for all Entra External ID tenants.
MetadataEndpoint Claims endpoint OIDC discovery endpoint for fetching and caching signing keys. Use the tenant ID subdomain format.
AllowedAppIds Claims endpoint Comma- or semicolon-separated list of client application IDs permitted to trigger this endpoint.
EnrichmentIssuers Enrichment Optional. Comma- or semicolon-separated list of token issuers for which IEntraRoleResolver performs a database lookup. Defaults to Issuer when not set. Use for multi-tenant scenarios or standalone enrichment.

For full Azure Portal setup, ngrok local development guidance, and troubleshooting see SETUP.md.


Provisioner Design

IEntraUserProvisioner

Implement IEntraUserProvisioner directly for full control:

public interface IEntraUserProvisioner {
    Task<ProvisionResult> ProvisionAsync(
        EntraClaimsContext context,
        CancellationToken cancellationToken = default);
}

EntraClaimsContext provides:

Property Description
EntraUserId Entra object ID of the authenticating user. Store as ExternalId on your user record.
Email Email address from the Entra payload. May be empty for social identity providers with email sharing disabled.
CorrelationId Request correlation ID for end-to-end tracing.
ClientAppId App ID of the client application the user is signing into.

Return ProvisionResult.Allow("role:name") to permit the login, or ProvisionResult.Deny() to block it.

EntraUserProvisionerBase<TUser>

For the standard invitation-redemption pattern, inherit from EntraUserProvisionerBase<TUser> instead of implementing IEntraUserProvisioner directly. The base class handles the two-path provisioning flow:

  1. Returning user — look up by Entra object ID → if found, issue the stored role
  2. New user via invitation — find, validate, and claim a pending invitation by email → create user record → issue the invitation's role
  3. Everything else → deny

Implement two abstract methods:

public sealed class AppUserProvisioner(AppDbContext db) : EntraUserProvisionerBase<AppUser> {

    protected override Task<AppUser?> FindUserAsync(
        string entraUserId,
        CancellationToken cancellationToken) =>
        db.Users.FirstOrDefaultAsync(u => u.EntraUserId == entraUserId, cancellationToken);

    protected override async Task<AppUser?> RedeemInvitationAsync(
        string email,
        string entraUserId,
        CancellationToken cancellationToken) {
        // Find, validate, claim, and create user atomically in one transaction
        var invitation = await db.Invitations
            .FirstOrDefaultAsync(
                i => i.Email == email.ToLowerInvariant()
                  && i.ClaimedAt == null
                  && i.ExpiresAt > DateTimeOffset.UtcNow,
                cancellationToken);
        if (invitation is null) {
            return null;
        }
        invitation.ClaimedAt = DateTimeOffset.UtcNow;
        invitation.ClaimedByEntraUserId = entraUserId;
        var user = new AppUser { EntraUserId = entraUserId, Roles = invitation.Roles };
        db.Users.Add(user);
        await db.SaveChangesAsync(cancellationToken);
        return user;
    }
}

RedeemInvitationAsync returns null to deny. Returning a user causes ProvisionResult.Allow(user.Roles) to be issued.

Atomicity: Perform the invitation find, expiry check, and claim in a single database transaction to prevent concurrent logins from redeeming the same invitation twice.

IEntraProvisionedUser

Implement on your user entity so EntraUserProvisionerBase<TUser> can read the role:

public record AppUser : IEntraProvisionedUser {
    public string EntraUserId { get; init; } = "";  // IEntraProvisionedUser
    public IReadOnlyList<string> Roles { get; init; } = [];  // IEntraProvisionedUser
    // ... your other fields
}

IEntraPendingInvitation

A modeling guide for your invitation entity. Not used as a generic constraint — documents the fields expected by the invitation pattern:

public record Invitation : IEntraPendingInvitation {
    public string Email { get; init; } = "";
    public string Role { get; init; } = "";
    public DateTimeOffset ExpiresAt { get; init; }
    public DateTimeOffset? ClaimedAt { get; set; }
    public string? ClaimedByEntraUserId { get; set; }
    public DateTimeOffset? RedeemedAt { get; set; }
    // ... your other fields (e.g. CompanyId, InvitedBy)
}

IsExpired is provided as a default interface method (ExpiresAt < DateTimeOffset.UtcNow). Override it if you need additional expiry conditions (e.g. already claimed, already redeemed).


API-Side Role Enrichment

Entra External ID access tokens do not carry roles claims — the custom customRoles claim returned by the provisioner is present in the ID token only. To make role-based authorization work on your API (ASP.NET endpoint policies, Cirreum domain authorization), register the enrichment alongside your normal auth setup:

1. Implement a role resolver

public sealed class AppUserRoleResolver(IUserRepository users) : IEntraRoleResolver {

    public async Task<IReadOnlyList<string>?> ResolveRolesAsync(
        string entraUserId,
        CancellationToken cancellationToken = default) {

        var user = await users.FindByEntraIdAsync(entraUserId, cancellationToken);
        if (user is null) {
            return null;
        }
        return user.Roles;
    }
}

2. Register in Program.cs

builder.AddEntraClaimsEnrichment<AppUserRoleResolver>();

How it works

AddEntraClaimsEnrichment registers an IClaimsTransformation that runs during UseAuthentication(), before UseAuthorization() evaluates endpoint policies:

Bearer token received
  → JWT validated → ClaimsPrincipal built (no roles)
  → EntraClaimsEnricher.TransformAsync()
      → issuer matches Entra External ID tenant
      → resolves role from your database by oid
      → adds ClaimTypes.Role to ClaimsPrincipal
  → UseAuthorization() → RequireRole() passes ✓
  → domain authorization → HasRole() passes ✓

The result is cached in HttpContext.Items — ResolveRoleAsync is called at most once per request regardless of how many times ASP.NET re-evaluates authentication. Workforce and internal users (whose tokens already carry roles claims) are skipped entirely.

IEntraRoleResolver

public interface IEntraRoleResolver {
    Task<IReadOnlyList<string>?> ResolveRolesAsync(
        string entraUserId,
        CancellationToken cancellationToken = default);
}
Parameter Description
entraUserId The user's Entra object ID (oid claim). Matches IEntraProvisionedUser.EntraUserId written during Phase 1.
Returns One or more role strings, or null if the user does not exist. An empty list is treated the same as null — authorization policies that require a role will deny the request.

Two-Phase Onboarding

The provisioner handles Phase 1 only — gating access and issuing the initial role claim. A complete onboarding flow looks like:

Phase 1 — onTokenIssuanceStart (this library)
  ├── RedeemInvitationAsync: mark invitation as Claimed, create user record with Role
  └── Return role → embedded in ID token as customRoles

API calls — role enrichment (this library, AddEntraClaimsEnrichment)
  └── IEntraRoleResolver looks up user by oid → injects role into ClaimsPrincipal
      → ASP.NET endpoint policies and Cirreum domain authorization work immediately ✓

Phase 2 — in-app onboarding endpoint (your application)
  ├── Collect remaining profile data
  └── Mark invitation as Redeemed

Why two phases? The Entra user object may not exist yet when Phase 1 fires. Phase 2 completes the user's profile after they are inside the application.

No Microsoft Graph API calls are required. AddEntraClaimsEnrichment loads the role from your application's own data store — the same record created by the provisioner in Phase 1 — and injects it into the ClaimsPrincipal on every API request. Entra group membership is not needed.

During the Phase 1 → Phase 2 window, onboarding endpoints should use a minimal auth policy (authenticated user, no role requirement) to allow the user through before profile collection is complete.


Request Flow

Entra POST → anonymous endpoint
  1. Validate bearer token (OIDC signature, issuer, audience, lifetime, appid/azp)
  2. Deserialize request payload
  3. Validate CorrelationId and User.Id presence
  4. Verify calling app is in AllowedAppIds
  5. Call IEntraUserProvisioner.ProvisionAsync
     ├── Allowed (with roles) → embed roles in token response → 200
     ├── Denied              → 403
     └── Exception / no roles → 500

Advanced Registration

Factory overloads

builder.AddEntraClaims<AppUserProvisioner>(
    sp => new AppUserProvisioner(sp.GetRequiredService<AppDbContext>()));

builder.AddEntraClaimsEnrichment<AppUserRoleResolver>(
    sp => new AppUserRoleResolver(sp.GetRequiredService<IUserRepository>()));

IServiceCollection overloads (for testing)

services.AddEntraClaims<AppUserProvisioner>(configuration);
services.AddEntraClaimsEnrichment<AppUserRoleResolver>(configuration);

Custom configuration section

builder.AddEntraClaims<AppUserProvisioner>("MyApp:EntraExtension");
builder.AddEntraClaimsEnrichment<AppUserRoleResolver>("MyApp:EntraExtension");

Using both on the same app

// App that hosts the onTokenIssuanceStart endpoint AND validates External ID access tokens
builder.AddEntraClaims<AppUserProvisioner>();
builder.AddEntraClaimsEnrichment<AppUserRoleResolver>();

Both methods share the same configuration section. services.Configure<EntraClaimsOptions> is called by each — this is safe and idempotent.

Standalone enrichment (API only, no claims endpoint)

AddEntraClaimsEnrichment works independently — no AddEntraClaims required. Only Issuer (or EnrichmentIssuers) needs to be configured:

{
  "Cirreum": {
    "Authentication": {
      "EntraClaims": {
        "Issuer": "https://<tenant-id>.ciamlogin.com/<tenant-id>/v2.0"
      }
    }
  }
}
builder.AddEntraClaimsEnrichment<AppUserRoleResolver>();

Multi-tenant enrichment

{
  "Cirreum": {
    "Authentication": {
      "EntraClaims": {
        "EnrichmentIssuers": "https://<tenant-a>.ciamlogin.com/<tenant-a>/v2.0; https://<tenant-b>.ciamlogin.com/<tenant-b>/v2.0"
      }
    }
  }
}

Security

  • Full JWT signature validation using Microsoft's published OIDC signing keys, fetched and cached via ConfigurationManager<OpenIdConnectConfiguration>
  • Issuer, audience, and token lifetime validated on every request
  • appid/azp claim verified against Microsoft's fixed authentication events service app ID
  • Allowed app list restricts which client applications can trigger the endpoint
  • The endpoint is registered as AllowAnonymous — all authentication is performed internally, not by ASP.NET middleware

Contribution Guidelines

  1. Be conservative with new abstractions — the API surface must remain stable and meaningful
  2. Limit dependency expansion — only add foundational, version-stable dependencies
  3. Favor additive, non-breaking changes — breaking changes ripple through the ecosystem
  4. Include thorough unit tests — all primitives and patterns should be independently testable
  5. Document architectural decisions — context and reasoning should be clear for future maintainers
  6. Follow .NET conventions — use established patterns from Microsoft.Extensions.* libraries

Versioning

Follows Semantic Versioning: Major for breaking API changes, Minor for backward-compatible new features, Patch for bug fixes.

License

MIT License — see the LICENSE file for details.


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

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated