Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Extensions/ClaimsPrincipalExtensions.cs
T
Oliver Kieselbach c59b366560 feat(auth): client-app binding for delegated tokens - measured always, enforced behind a switch (D-279)
A user token obtained by an application other than the platform's own
registrations (for example a self-hosted AI client using on-behalf-of) was
accepted with the user's full role; only audience, issuer, signature and
lifetime were checked. Such tokens are now classified on every request.

- ClientAppBindingMiddleware (after authentication, before policy enforcement)
  records the request dimensions ClientAppBinding (registered | unregistered |
  unidentified) and ClientAppId, plus a throttled Warning.
- AdminConfiguration.EnforceClientAppBinding (default off; Admin > Settings >
  Global): when on, a foreign client is refused with 403 ClientAppNotRegistered
  unless the application is an enabled member of the caller's tenant (the
  app:<client-id> row a service principal uses). An admitted caller keeps their
  identity but gets the application caps: at most Viewer, no platform role,
  DelegatedReader on managed tenants, roleless tiers closed except auth/mcp.
- AdminIdentity.IsCapped (application or admitted foreign client) is what the
  global, delegated and member-role resolvers switch on; RequestContext carries it.
- Own clients = the audience trust set plus EntraId:TrustedClientAppIds, a
  client-only trust for the local development registration that never widens
  the accepted audiences.
- Tests: classification, unforgeable cap marker, middleware in both modes, caps
  through the real policy middleware, pipeline order; manifests and wire types
  regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:28:21 +02:00

232 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using Microsoft.Azure.Functions.Worker;
namespace AutopilotMonitor.Functions.Extensions;
/// <summary>
/// Extension methods for extracting user information from JWT token claims
/// </summary>
public static class ClaimsPrincipalExtensions
{
/// <summary>
/// Extracts the ClaimsPrincipal from the FunctionContext
/// </summary>
public static ClaimsPrincipal? GetUser(this FunctionContext context)
{
// Azure Functions Isolated Worker: Try FunctionContext.Items first
// This is set by AuthenticationMiddleware and is more reliable than httpContext.User
if (context.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
&& principalObj is ClaimsPrincipal principal)
{
return principal;
}
// Fallback to HTTP context (may not work reliably in isolated worker)
var httpContext = context.GetHttpContext();
return httpContext?.User;
}
/// <summary>
/// Gets the tenant ID (tid) from the token claims
/// This is the tenant ID of the user's Azure AD tenant, not your tenant!
/// Supports both v1.0 and v2.0 token formats
/// </summary>
public static string? GetTenantId(this ClaimsPrincipal principal)
{
return principal.FindFirst("tid")?.Value
?? principal.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value;
}
/// <summary>
/// The caller's principal key — the value every role table is keyed on (see
/// <see cref="AutopilotMonitor.Shared.Constants.PrincipalKeys"/>). For a person it is the UPN
/// (<c>upn</c>, falling back to <c>preferred_username</c>; v1.0 and v2.0 shapes); for an application
/// principal (an app-only token, see <see cref="IsApplicationPrincipal"/>) it is
/// <c>app:&lt;client-id&gt;</c>, so a service flows through membership, whitelist, delegation, audit and
/// throttling exactly like a person without any consumer having to know the difference.
/// <para>
/// Display / audit / row-key use only. The UPN claims are mutable and reusable across tenants, so no
/// cross-tenant authorization is keyed on this value alone — role resolution takes the full
/// <see cref="Security.AdminIdentity"/> (key + tid + oid) and verifies it against the identity binding.
/// </para>
/// </summary>
public static string? GetUserPrincipalName(this ClaimsPrincipal principal)
{
var upn = principal.FindFirst("upn")?.Value
?? principal.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn")?.Value
?? principal.FindFirst("preferred_username")?.Value;
if (upn != null)
return upn;
var applicationId = principal.IsApplicationPrincipal() ? principal.GetApplicationId() : null;
return applicationId == null ? null : AutopilotMonitor.Shared.Constants.PrincipalKeys.ForApplication(applicationId);
}
/// <summary>
/// True for an app-only token (client-credentials, e.g. a workload with a federated credential): Entra
/// stamps <c>idtyp = app</c> on access tokens for this API (optional claim on the app registration).
/// A token without the claim is a user token by definition — the classification fails closed to
/// "person", who then needs a UPN like everyone else.
/// </summary>
public static bool IsApplicationPrincipal(this ClaimsPrincipal principal)
=> string.Equals(principal.FindFirst("idtyp")?.Value, "app", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// True for a delegated token from a foreign client application that the client-app binding admitted
/// (<see cref="Security.ClientAppBinding"/>): the person keeps their identity, but is capped like an
/// application principal.
/// </summary>
public static bool IsClientCapped(this ClaimsPrincipal principal)
=> Security.ClientAppBinding.IsCapped(principal);
/// <summary>Application principal or admitted foreign client: the callers every application cap applies to.</summary>
public static bool IsCappedPrincipal(this ClaimsPrincipal principal)
=> principal.IsApplicationPrincipal() || principal.IsClientCapped();
/// <summary>
/// The caller's rate-limit budget class (<see cref="Security.ThrottleSurface"/>), from signed claims
/// only: <c>appidacr</c> (v1.0) / <c>azpacr</c> (v2.0) say how the CLIENT authenticated — <c>0</c> is a
/// public client (the portal SPA, PKCE), <c>1</c> a client secret, <c>2</c> a certificate (the MCP
/// server and server-side integrations). An app-only token, any other value and a missing claim
/// fail closed to <see cref="Security.ThrottleSurface.Integration"/>, the stricter budget.
/// </summary>
public static Security.ThrottleSurface GetThrottleSurface(this ClaimsPrincipal principal)
{
if (principal.IsApplicationPrincipal())
return Security.ThrottleSurface.Integration;
var acr = principal.FindFirst("appidacr")?.Value ?? principal.FindFirst("azpacr")?.Value;
return string.Equals(acr?.Trim(), "0", StringComparison.Ordinal)
? Security.ThrottleSurface.Portal
: Security.ThrottleSurface.Integration;
}
/// <summary>
/// The calling application's Entra application (client) id: <c>appid</c> on v1.0 tokens, <c>azp</c> on
/// v2.0 tokens. Null when the token names no client (never the case for a token Entra issued).
/// </summary>
public static string? GetApplicationId(this ClaimsPrincipal principal)
{
var id = principal.FindFirst("appid")?.Value ?? principal.FindFirst("azp")?.Value;
return string.IsNullOrWhiteSpace(id) ? null : id.Trim().ToLowerInvariant();
}
/// <summary>
/// Stable per-caller identity for THROTTLING keys — never for authorization or audit.
/// <para>
/// Prefers the object id (<c>oid</c>): it is unique per Entra account, so two accounts in different
/// tenants that happen to share a UPN string (the API accepts tokens from any tenant) get separate
/// buckets instead of throttling each other. Falls back to the UPN, then to the remaining token
/// subject claims (<c>sub</c> → <c>appid</c>/<c>azp</c>) because a valid token need not carry
/// a user identity at all: AuthenticationMiddleware validates issuer/audience/lifetime/signature
/// but requires no <c>upn</c>, so an app-only (client-credentials) token authenticates fine and
/// reaches every AuthenticatedUser route. Without this fallback such a caller would present an
/// empty identity and skip per-user rate limiting entirely.
/// </para>
/// <para>
/// Both the raw and the WS-Fed-mapped claim names are checked: JwtSecurityTokenHandler runs with
/// inbound claim mapping ON, so <c>oid</c>/<c>sub</c> may arrive under their schema URIs.
/// </para>
/// Returns null only for a principal carrying no identifying claim whatsoever; callers must
/// decide fail-closed what that means (see <c>RequestContext.CallerId</c>).
/// </summary>
public static string? GetCallerId(this ClaimsPrincipal principal)
{
return principal.GetObjectId()
?? principal.GetUserPrincipalName()
?? principal.FindFirst("sub")?.Value
?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? principal.FindFirst("appid")?.Value
?? principal.FindFirst("azp")?.Value;
}
/// <summary>
/// Gets the user's display name from token claims
/// Supports both v1.0 and v2.0 token formats
/// </summary>
public static string? GetDisplayName(this ClaimsPrincipal principal)
{
return principal.FindFirst("name")?.Value
?? principal.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name")?.Value;
}
/// <summary>
/// Gets the user's email address from token claims
/// Supports both v1.0 and v2.0 token formats
/// </summary>
public static string? GetEmail(this ClaimsPrincipal principal)
{
return principal.FindFirst("email")?.Value
?? principal.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress")?.Value;
}
/// <summary>
/// Gets the user's object ID (oid) from token claims
/// This is the unique identifier for the user in their Azure AD tenant
/// Supports both v1.0 and v2.0 token formats
/// </summary>
public static string? GetObjectId(this ClaimsPrincipal principal)
{
return principal.FindFirst("oid")?.Value
?? principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value;
}
/// <summary>
/// Gets the application (client) ID that the token was issued for
/// </summary>
public static string? GetAudience(this ClaimsPrincipal principal)
{
return principal.FindFirst("aud")?.Value;
}
/// <summary>
/// Gets the issuer of the token
/// </summary>
public static string? GetIssuer(this ClaimsPrincipal principal)
{
return principal.FindFirst("iss")?.Value;
}
/// <summary>
/// Checks if the principal has a valid tenant ID claim
/// </summary>
public static bool HasTenantId(this ClaimsPrincipal principal)
{
var tenantId = principal.GetTenantId();
return !string.IsNullOrEmpty(tenantId);
}
/// <summary>
/// Gets all Entra ID app-role values assigned to the user (the "roles" claim emitted by the
/// application's Enterprise App). Returns an empty list when no app roles are present.
/// Reads both the raw "roles"/"role" claims and the mapped <see cref="ClaimTypes.Role"/> URI
/// to stay robust regardless of JwtSecurityTokenHandler inbound claim mapping. App roles are
/// multi-valued; duplicates are removed case-insensitively.
/// </summary>
public static IReadOnlyList<string> GetAppRoles(this ClaimsPrincipal principal)
{
return principal.Claims
.Where(c => c.Type == "roles" || c.Type == "role" || c.Type == ClaimTypes.Role)
.Select(c => c.Value)
.Where(v => !string.IsNullOrWhiteSpace(v))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// <summary>
/// Gets all claims as a dictionary for debugging purposes
/// </summary>
public static Dictionary<string, string> GetAllClaims(this ClaimsPrincipal principal)
{
return principal.Claims
.GroupBy(c => c.Type)
.ToDictionary(
g => g.Key,
g => string.Join(", ", g.Select(c => c.Value))
);
}
}