mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
c59b366560
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>
232 lines
11 KiB
C#
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:<client-id></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))
|
|
);
|
|
}
|
|
}
|