Skip to content

Design Proposal - Redesign IAuthenticationOperationIAuthenticationScheme #5998

Description

@gladjohn

ITokenAcquisitionScheme — Authentication Scheme Redesign

Problem

MSAL's IAuthenticationOperation cannot receive runtime context from MSAL, requires new interface versions for every capability, hardcodes token type validation, and replaces operations instead of composing them. This blocks CDT + mTLS POP and will block every future composite token scenario.

Design

Interface

/// <summary>
/// Defines how MSAL acquires, formats, and caches tokens for a specific
/// authentication scheme (Bearer, mTLS POP, CDT, CDT+mTLS POP, SHR POP, etc.).
///
/// Instances are per-acquisition. MSAL creates a new instance per token request
/// and will not reuse them. Implementations are not required to be thread-safe.
/// </summary>
public interface ITokenAcquisitionScheme
{
    /// <summary>
    /// MSAL provides runtime context. The scheme reads what it needs
    /// and configures itself. Called once before the token request.
    /// </summary>
    void Configure(TokenAcquisitionContext context);

    /// <summary>
    /// The scheme tells MSAL what to send to ESTS and how to cache/validate.
    /// Called after Configure.
    /// </summary>
    TokenRequestDescriptor GetTokenRequestDescriptor(TokenAcquisitionContext context);

    /// <summary>
    /// Transforms the AuthenticationResult after token acquisition
    /// (e.g., wrap in CDT envelope, set BindingCertificate).
    /// </summary>
    Task FormatResultAsync(AuthenticationResult result, CancellationToken ct = default);

    /// <summary>
    /// Validates whether a cached token is still usable.
    /// MUST NOT perform network I/O.
    /// </summary>
    Task<bool> ValidateCachedTokenAsync(MsalCacheValidationData data, CancellationToken ct = default);

    /// <summary>
    /// Whether the response token type from ESTS is acceptable.
    /// </summary>
    bool AcceptsTokenType(string tokenType);
}

Input: TokenAcquisitionContext

MSAL builds this. Schemes consume it. Add properties here to pass new data — no interface changes needed.

/// <summary>
/// Runtime context from MSAL to the scheme. Owned by MSAL.
/// Schemes must not retain a reference past FormatResultAsync.
/// </summary>
public sealed class TokenAcquisitionContext
{
    /// <summary>
    /// The mTLS certificate from WithMtlsProofOfPossession(), if any.
    /// Owned by the caller — MSAL does not dispose it.
    /// </summary>
    public X509Certificate2? MtlsCertificate { get; init; }

    /// <summary>
    /// The client ID of the application.
    /// </summary>
    public string? ClientId { get; init; }

    /// <summary>
    /// The authority being used.
    /// </summary>
    public string? Authority { get; init; }

    /// <summary>
    /// Flags indicating which features the caller requested.
    /// </summary>
    public TokenRequestFlags RequestedFlags { get; init; }
}

[Flags]
public enum TokenRequestFlags
{
    None = 0,
    MtlsProofOfPossession = 1,
    SignedHttpRequest = 2,
}

Output: TokenRequestDescriptor

The scheme returns this. MSAL reads it. Add properties here to return new data — no interface changes needed.

public sealed class TokenRequestDescriptor
{
    /// <summary>
    /// Scheme identifier for caching and telemetry. Required — no default.
    /// </summary>
    public required string SchemeId { get; init; }

    /// <summary>
    /// Authorization header prefix. E.g., "Bearer", "mtls_pop".
    /// </summary>
    public required string AuthorizationHeaderPrefix { get; init; }

    /// <summary>
    /// Key ID for cache binding. Null if not key-bound.
    /// </summary>
    public string? KeyId { get; init; }

    /// <summary>
    /// Extra body parameters for the /token request.
    /// </summary>
    public IDictionary<string, string> TokenRequestParams { get; } = new Dictionary<string, string>();

    /// <summary>
    /// Additional response parameters to persist in the cache.
    /// </summary>
    public IList<string> AdditionalCacheParameters { get; } = new List<string>();

    /// <summary>
    /// Telemetry token type value.
    /// </summary>
    public TelemetryTokenType TelemetryTokenType { get; init; } = TelemetryTokenType.Bearer;
}

public enum TelemetryTokenType
{
    Unspecified = 0,
    Bearer = 1,
    Pop = 2,
    SshCert = 3,
    External = 4,
    Extension = 5,
    MtlsPop = 6,
}

Extensibility

Need Where Interface changes?
New data from MSAL to scheme Add property to TokenAcquisitionContext ❌ No
New output from scheme to MSAL Add property to TokenRequestDescriptor ❌ No
New caller feature flag Add value to TokenRequestFlags ❌ No
New telemetry type Add value to TelemetryTokenType ❌ No
New lifecycle phase Add method to ITokenAcquisitionScheme ✅ Yes — rare

A new lifecycle phase has only been needed twice in 3+ years. With input/output objects, even those wouldn't have required an interface change.

CDT + mTLS POP

internal class CdtTokenAcquisitionScheme : ITokenAcquisitionScheme
{
    private CdtCryptoProvider _cryptoProvider = new();
    private X509Certificate2? _mtlsCert;

    public void Configure(TokenAcquisitionContext context)
    {
        if (context.MtlsCertificate is not null)
        {
            _mtlsCert = context.MtlsCertificate;
            _cryptoProvider = new CdtCryptoProvider(_mtlsCert);
        }
    }

    public TokenRequestDescriptor GetTokenRequestDescriptor(TokenAcquisitionContext context)
    {
        bool isMtlsPop = _mtlsCert is not null;

        var descriptor = new TokenRequestDescriptor
        {
            SchemeId = isMtlsPop ? "mtls_pop" : "bearer",
            AuthorizationHeaderPrefix = isMtlsPop ? "mtls_pop" : "Bearer",
            KeyId = _cryptoProvider.KeyId,
            TelemetryTokenType = TelemetryTokenType.Extension,
        };

        descriptor.TokenRequestParams["req_ds_cnf"] = _cryptoProvider.CnfValue;
        if (isMtlsPop)
            descriptor.TokenRequestParams["token_type"] = "mtls_pop";

        descriptor.AdditionalCacheParameters.Add("xms_ds_nonce");
        descriptor.AdditionalCacheParameters.Add("xms_ds_enc");

        return descriptor;
    }

    public bool AcceptsTokenType(string tokenType)
    {
        string expected = _mtlsCert is not null ? "mtls_pop" : "bearer";
        return string.Equals(tokenType, expected, StringComparison.OrdinalIgnoreCase);
    }

    public Task FormatResultAsync(AuthenticationResult result, CancellationToken ct)
    {
        var nonce = result.AdditionalResponseParameters["xms_ds_nonce"];
        var constraintToken = CreateConstraintsJwt(nonce);
        result.AccessToken = CreateCdtJwt(result.AccessToken, constraintToken);

        if (_mtlsCert is not null)
            result.BindingCertificate = _mtlsCert;

        return Task.CompletedTask;
    }

    public Task<bool> ValidateCachedTokenAsync(MsalCacheValidationData data, CancellationToken ct)
        => Task.FromResult(true);
}

Backward compatibility

Old IAuthenticationOperation / IAuthenticationOperation2 wrap automatically via LegacySchemeAdapter:

internal class LegacySchemeAdapter : ITokenAcquisitionScheme
{
    private readonly IAuthenticationOperation _legacy;

    public void Configure(TokenAcquisitionContext context) { }

    public TokenRequestDescriptor GetTokenRequestDescriptor(TokenAcquisitionContext context)
    {
        var d = new TokenRequestDescriptor
        {
            SchemeId = _legacy.AccessTokenType,
            AuthorizationHeaderPrefix = _legacy.AuthorizationHeaderPrefix,
            KeyId = _legacy.KeyId,
            TelemetryTokenType = MapLegacyTelemetryTokenType(_legacy.TelemetryTokenType),
        };

        foreach (var kv in _legacy.GetTokenRequestParams())
            d.TokenRequestParams[kv.Key] = kv.Value;

        return d;
    }

    private static TelemetryTokenType MapLegacyTelemetryTokenType(int legacy) => legacy switch
    {
        1 => TelemetryTokenType.Bearer,
        2 => TelemetryTokenType.Pop,
        3 => TelemetryTokenType.SshCert,
        4 => TelemetryTokenType.External,
        5 => TelemetryTokenType.Extension,
        6 => TelemetryTokenType.MtlsPop,
        _ => TelemetryTokenType.Unspecified,
    };

    public bool AcceptsTokenType(string tokenType)
        => string.Equals(tokenType, _legacy.AccessTokenType, StringComparison.OrdinalIgnoreCase);

    public async Task FormatResultAsync(AuthenticationResult result, CancellationToken ct)
    {
        if (_legacy is IAuthenticationOperation2 async2)
            await async2.FormatResultAsync(result, ct).ConfigureAwait(false);
        else
            _legacy.FormatResult(result);
    }

    public Task<bool> ValidateCachedTokenAsync(MsalCacheValidationData data, CancellationToken ct)
    {
        if (_legacy is IAuthenticationOperation2 async2)
            return async2.ValidateCachedTokenAsync(data);
        return Task.FromResult(true);
    }
}

MSAL changes

File Change
AuthScheme/ITokenAcquisitionScheme.cs New interface
AuthScheme/TokenAcquisitionContext.cs Input object
AuthScheme/TokenRequestDescriptor.cs Output object
AuthScheme/TokenRequestFlags.cs Flags enum
AuthScheme/TelemetryTokenType.cs Telemetry enum
AuthScheme/LegacySchemeAdapter.cs Wraps old IAuthenticationOperation
MtlsPopParametersInitializer.cs Populates TokenAcquisitionContext
TokenClient.cs Uses scheme.AcceptsTokenType()
ConfidentialClientExecutor.cs Calls scheme.Configure(context)
AuthenticationResult.cs Calls scheme.FormatResultAsync()
Built-in schemes BearerScheme, MtlsPopScheme, PopScheme

Migration path

  1. Ship ITokenAcquisitionScheme + LegacySchemeAdapter — zero breaking changes
  2. CDT implements ITokenAcquisitionScheme directly
  3. Deprecate IAuthenticationOperation / IAuthenticationOperation2
  4. Remove legacy interfaces in a future major version

Acceptance criteria

  • ITokenAcquisitionScheme with Configure, GetTokenRequestDescriptor, FormatResultAsync, ValidateCachedTokenAsync, AcceptsTokenType
  • TokenAcquisitionContext with MtlsCertificate, ClientId, Authority, RequestedFlags (all init)
  • TokenRequestDescriptor with required SchemeId, required AuthorizationHeaderPrefix
  • TokenRequestFlags and TelemetryTokenType enums
  • LegacySchemeAdapter for backward compat
  • CDT + mTLS POP works via Configure(context) receiving the cert
  • All existing tests pass unchanged
  • No breaking changes

Related

Metadata

Metadata

Fields

No fields configured for Feature.

Projects

Status
Committed High Priority

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions