| File: Provisioning\Internal\DefaultAzurePrincipalProvider.cs | Web Access |
| Project: src\src\Aspire.Hosting.Azure\Aspire.Hosting.Azure.csproj (Aspire.Hosting.Azure) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; using Azure.Core; namespace Aspire.Hosting.Azure.Provisioning.Internal; /// <summary> /// Default implementation of <see cref="IAzurePrincipalProvider"/>. /// </summary> internal sealed class DefaultAzurePrincipalProvider(ITokenCredentialProvider tokenCredentialProvider) : IAzurePrincipalProvider { // Microsoft Entra reports the token's identity type in the `idtyp` claim: "app" for app-only // (service principal / managed identity / federated workload identity) tokens and "user" for // user-delegated ones. Only "app" needs matching here because every other value — including // "user" and the claim being absent entirely — falls through to the User default below. // See: https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference#payload-claims private const string IdTypApp = "app"; // Values accepted by the `principalType` property on Microsoft.Authorization/roleAssignments. // We don't emit "Group" here because access tokens never represent a group identity directly. private const string PrincipalTypeUser = "User"; private const string PrincipalTypeServicePrincipal = "ServicePrincipal"; public async Task<AzurePrincipal> GetPrincipalAsync(CancellationToken cancellationToken = default) { var credential = tokenCredentialProvider.TokenCredential; var response = await credential.GetTokenAsync(new(["https://graph.windows.net/.default"]), cancellationToken).ConfigureAwait(false); static AzurePrincipal ParseToken(in AccessToken response) { // A JWT is "header.payload.signature". The token credential should always return // that shape, but guard explicitly so a malformed token surfaces as a clear error // instead of a confusing IndexOutOfRangeException deep in the parser. var parts = response.Token.Split('.'); if (parts.Length < 3) { throw new InvalidOperationException( $"The access token returned by the credential is not a valid JWT (expected 3 '.'-separated segments, found {parts.Length})."); } // Decode the JWT payload (the middle segment). JWTs use base64url with stripped // padding (RFC 7515 §2), so swap the URL-safe characters back and re-pad to a length // divisible by four before base64-decoding. Example payload shape: // { "oid":"<guid>","upn":"user@contoso.com","idtyp":"user","iss":"..." } // For app-only (service principal) tokens the `upn` claim is absent and `idtyp` is "app". var part = parts[1]; var convertedToken = part.Replace('_', '/').Replace('-', '+'); switch (part.Length % 4) { case 2: convertedToken += "=="; break; case 3: convertedToken += "="; break; } var bytes = Convert.FromBase64String(convertedToken); // Read claims from the root object only. JWT claims are top-level by definition, but a // claim's *value* can itself be an object or array — Entra emits `_claim_sources` that // way for the groups-overage case, and RFC 8693 delegation tokens nest identity claims // under `act`. A streaming reader that walks every token would treat a nested "oid" or // "idtyp" as if it were a real claim, and last-write-wins would silently swap the // principal these values describe. That matters here because they become the // principalId/principalType of an ARM role assignment, so picking up the wrong one // would grant access to the wrong identity. Microsoft also documents that new claims // may be added without notice, so scope the lookup structurally rather than relying on // today's payloads happening to be flat. using var document = JsonDocument.Parse(bytes); var root = document.RootElement; var oid = GetRootString(root, "oid"); if (!Guid.TryParse(oid, out var principalId)) { throw new InvalidOperationException( "Unable to determine the Azure identity to provision as: the access token returned by " + "the credential does not contain a valid 'oid' (object id) claim."); } // Default to "User" so older tokens — and any flow that omits `idtyp` — keep the // historical behavior of a hardcoded "User" principalType instead of regressing to an // empty value. `idtyp` is an optional claim that Entra only emits for app-only tokens // unless the resource opts in via `include_user_token`, so absence is not evidence of // a user identity; it just means we can't tell and fall back to the previous default. // The comparison is case-insensitive for resilience against future producers that emit // different casing than the lower-case values Entra documents. var isAppOnly = string.Equals(GetRootString(root, "idtyp"), IdTypApp, StringComparison.OrdinalIgnoreCase); var principalType = isAppOnly ? PrincipalTypeServicePrincipal : PrincipalTypeUser; return new AzurePrincipal(principalId, ResolvePrincipalName(root, principalId, isAppOnly), principalType); } return ParseToken(response); } // Resolves the value written to the `principalName` well-known Bicep parameter. // // `upn` is the user principal name; `email` is the fallback for accounts that don't carry one // (for example guests). App-only tokens carry neither, and an empty name is not benign: role // assignment templates feed this straight into resource definitions that reject it. Azure SQL's // principal reconciliation script runs `CREATE USER QUOTENAME(@name) WITH SID = ...`, and // QUOTENAME('') produces the invalid identifier `[]`; Azure Database for PostgreSQL requires a // `principalName` on Microsoft.DBforPostgreSQL/flexibleServers/administrators. See // https://github.com/microsoft/aspire/issues/19487. // // For app-only tokens `app_displayname` carries the display name of the app registration, which // is the name Entra reports for its service principal and the value PostgreSQL expects. It is // gated on `isAppOnly` because on a user-delegated token the same claim names the *client* // application instead of the signed-in identity — "Microsoft Azure CLI" for an `az login` // session — which would be badly wrong as a principal name. // // `oid` is the last resort because it is the only claim guaranteed to be present, so the result // is never empty. Entra documents `app_displayname` as a claim callers must not take a hard // dependency on, and resolving the display name from Microsoft Graph instead is deliberately // avoided: it requires directory read permissions the deployment principal may not hold and adds // a network round trip to every provisioning run. // // See: https://learn.microsoft.com/entra/identity-platform/access-token-claims-reference#payload-claims private static string ResolvePrincipalName(JsonElement root, Guid principalId, bool isAppOnly) { if (GetRootString(root, "upn") is { Length: > 0 } upn) { return upn; } if (GetRootString(root, "email") is { Length: > 0 } email) { return email; } if (isAppOnly && GetRootString(root, "app_displayname") is { Length: > 0 } appDisplayName) { return appDisplayName; } return principalId.ToString(); } // Reads a string claim from the root of the payload. Returns null when the claim is absent or // is not a JSON string, so a structurally unexpected value is treated as "not supplied" rather // than throwing out of the middle of provisioning. private static string? GetRootString(JsonElement root, string claimName) => root.TryGetProperty(claimName, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; }