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
- Ship
ITokenAcquisitionScheme + LegacySchemeAdapter — zero breaking changes
- CDT implements
ITokenAcquisitionScheme directly
- Deprecate
IAuthenticationOperation / IAuthenticationOperation2
- Remove legacy interfaces in a future major version
Acceptance criteria
Related
ITokenAcquisitionScheme— Authentication Scheme RedesignProblem
MSAL's
IAuthenticationOperationcannot 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
Input:
TokenAcquisitionContextMSAL builds this. Schemes consume it. Add properties here to pass new data — no interface changes needed.
Output:
TokenRequestDescriptorThe scheme returns this. MSAL reads it. Add properties here to return new data — no interface changes needed.
Extensibility
TokenAcquisitionContextTokenRequestDescriptorTokenRequestFlagsTelemetryTokenTypeITokenAcquisitionSchemeA 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
Backward compatibility
Old
IAuthenticationOperation/IAuthenticationOperation2wrap automatically viaLegacySchemeAdapter:MSAL changes
AuthScheme/ITokenAcquisitionScheme.csAuthScheme/TokenAcquisitionContext.csAuthScheme/TokenRequestDescriptor.csAuthScheme/TokenRequestFlags.csAuthScheme/TelemetryTokenType.csAuthScheme/LegacySchemeAdapter.csMtlsPopParametersInitializer.csTokenAcquisitionContextTokenClient.csscheme.AcceptsTokenType()ConfidentialClientExecutor.csscheme.Configure(context)AuthenticationResult.csscheme.FormatResultAsync()BearerScheme,MtlsPopScheme,PopSchemeMigration path
ITokenAcquisitionScheme+LegacySchemeAdapter— zero breaking changesITokenAcquisitionSchemedirectlyIAuthenticationOperation/IAuthenticationOperation2Acceptance criteria
ITokenAcquisitionSchemewithConfigure,GetTokenRequestDescriptor,FormatResultAsync,ValidateCachedTokenAsync,AcceptsTokenTypeTokenAcquisitionContextwithMtlsCertificate,ClientId,Authority,RequestedFlags(allinit)TokenRequestDescriptorwithrequired SchemeId,required AuthorizationHeaderPrefixTokenRequestFlagsandTelemetryTokenTypeenumsLegacySchemeAdapterfor backward compatConfigure(context)receiving the certRelated
IAuthenticationOperation→IAuthenticationScheme#5998