diff --git a/docs/design/credential-architecture.md b/docs/design/credential-architecture.md new file mode 100644 index 000000000..7a96ef884 --- /dev/null +++ b/docs/design/credential-architecture.md @@ -0,0 +1,288 @@ +# Credential Architecture in Microsoft.Identity.Web + +This document explains how credentials are loaded, resolved, and wired to MSAL at runtime. +It is intended for contributors and anyone extending the credential system. + +For user-facing guidance on which credential to pick, see +[credentials-README.md](../authentication/credentials/credentials-README.md). + +--- + +## High-Level Flow + +```mermaid +flowchart TD + Config["appsettings.json
ClientCredentials: [ { SourceType: ... }, ... ]"] + + Config -->|"bound to MergedOptions"| Provider + + Provider["CredentialsProvider (Selection / Fallback)

For each CredentialDescription in ClientCredentials[]:
1. Skip if credential.Skip == true
2. Call DefaultCredentialsLoader.LoadCredentialsIfNeededAsync()
3. If loader throws → loader sets Skip=true on itself;
   CredentialsProvider catches, tries next credential
4. First success → return credential"] + + Provider -->|"resolved CredentialDescription"| Wiring + + Wiring["WithClientCredentialsAsync()

Maps credential to MSAL builder:
• Certificate → builder.WithCertificate(cert)
• Secret → builder.WithClientSecret(secret)
• SignedAssertion → builder.WithClientAssertion(func)
• TokenBinding+cert → WithCertificate(cert)
• TokenBinding+assertion → GetSignedAssertionWithBinding"] + + Wiring --> MSAL + + MSAL["MSAL ConfidentialClientApplication

AcquireTokenForClient(scopes)
  .WithMtlsProofOfPossession() ← only if isTokenBinding
  .ExecuteAsync()

Returns: TokenType = Bearer | mtls_pop"] +``` + +--- + +## Key Interfaces + +| Interface | Role | Defined in | Implemented in | +|-----------|------|-----------|---------------| +| `ICredentialsLoader` | Per-credential load (semaphore, caching) | `Microsoft.Identity.Abstractions` | `Microsoft.Identity.Web.Certificate` | +| `ICredentialSourceLoader` | Leaf — knows how to load one credential type | `Microsoft.Identity.Abstractions` | `Microsoft.Identity.Web.Certificate` | +| `ICredentialsProvider` | Selection/fallback — iterates `ClientCredentials[]`, skips failures, returns first success | `Microsoft.Identity.Web.TokenAcquisition` | `Microsoft.Identity.Web.TokenAcquisition` | +| `ICustomSignedAssertionProvider` | Extension point — plug-in a custom assertion generator (e.g. OIDC FIC) | `Microsoft.Identity.Abstractions` | Various (e.g. `Microsoft.Identity.Web.OidcFIC`) | + +--- + +## Built-in Credential Source Loaders + +| CredentialSource enum | Loader class | What it produces | +|-----------------------|--------------|-----------------| +| `KeyVault` | `KeyVaultCertificateLoader` | X509Certificate2 from Azure Key Vault | +| `Path` | `FromPathCertificateLoader` | X509Certificate2 from PFX file on disk | +| `StoreWithThumbprint` | `StoreWithThumbprintCertificateLoader` | X509Certificate2 from Windows cert store | +| `StoreWithDistinguishedName` | `StoreWithDistinguishedNameCertificateLoader` | X509Certificate2 from Windows cert store | +| `StoreWithSubjectName` | `StoreWithSubjectNameCertificateLoader` | X509Certificate2 from Windows cert store | +| `Base64Encoded` | `Base64EncodedCertificateLoader` | X509Certificate2 from inline base64 | +| `SignedAssertionFromManagedIdentity` | `SignedAssertionFromManagedIdentityCredentialLoader` | `ManagedIdentityClientAssertion` (FIC) | +| `SignedAssertionFilePath` | `SignedAssertionFilePathCredentialsLoader` | Assertion from Kubernetes projected token | +| `CustomSignedAssertion` | *(resolved via `ICustomSignedAssertionProvider`)* | Custom assertion (e.g. OIDC FIC) | + +--- + +## CredentialDescription Lifecycle + +```mermaid +flowchart LR + Initial["Initial
CachedValue = null
Skip = false
Certificate = null"] + Loaded["Loaded
CachedValue = cert/assertion
Certificate = X509"] + Skipped["Skipped
Skip = true
CachedValue = null
CredentialsProvider moves to next"] + + Initial -->|"LoadIfNeededAsync() succeeds"| Loaded + Initial -->|"LoadIfNeededAsync() throws"| Skipped +``` + +**Architectural rule:** A loader may set `Skip = true` on *itself* (the credential it was asked to load) +when it cannot operate (e.g., MI not available locally). A loader must NOT set `Skip` on +other credentials in the collection — that is the orchestrator's responsibility. + +--- + +## Token Binding (Bearer vs mTLS PoP) + +Token binding determines the **token type** requested from Entra ID. It is orthogonal to +the credential type (how you authenticate the app). + +### How `isTokenBinding` is determined + +```csharp +// In TokenAcquisition.GetAuthenticationResultForAppAsync(): +bool isTokenBinding = tokenAcquisitionOptions?.ExtraParameters?.TryGetValue( + TokenBindingParameterName, out var isTokenBindingObject) == true + && isTokenBindingObject is bool isTokenBindingValue + && isTokenBindingValue; +``` + +`TokenBindingParameterName` is the constant `"IsTokenBinding"`. This is set by +`DefaultAuthorizationHeaderProvider` when the caller specifies +`ProtocolScheme = "MTLS_POP"` in `AuthorizationHeaderProviderOptions`. + +### Bearer vs mTLS PoP — what changes + +| Aspect | Bearer (`isTokenBinding = false`) | mTLS PoP (`isTokenBinding = true`) | +|--------|------|----------| +| CCA cache key | `{clientId}_{authority}` | `{clientId}_{authority}-tokenBinding` | +| Credential wiring | Normal (cert, secret, or assertion) | Must be cert or assertion with `SupportsTokenBinding` | +| MSAL request | `.AcquireTokenForClient(scopes)` | `.AcquireTokenForClient(scopes).WithMtlsProofOfPossession()` | +| Token type returned | `Bearer` | `mtls_pop` | +| Auth header format | `Bearer ` | `MTLS_POP ` | + +### Managed Identity path + +For MI, token binding works differently — no CCA is involved: + +```csharp +var miBuilder = managedIdApp.AcquireTokenForManagedIdentity(scope); +if (isTokenBinding) +{ + miBuilder = miBuilder + .WithMtlsProofOfPossession() + .WithAttestationSupport(); +} +``` + +MSAL handles the mTLS handshake with IMDS v2 internally. + +--- + +## Credential Wiring to MSAL — Detail + +`ConfidentialClientApplicationBuilderExtension.WithClientCredentialsAsync()` maps +the resolved credential to MSAL's builder API: + +``` +isTokenBinding = true? +├── credential.Certificate != null +│ └── builder.WithCertificate(cert) +│ (MSAL uses cert for mTLS + PoP in the token request) +│ +├── credential is SignedAssertion + SupportsTokenBinding +│ └── builder.WithClientAssertion(GetSignedAssertionWithBindingAsync) +│ (provider returns assertion + binding cert for FIC mTLS PoP) +│ +└── else → throw InvalidOperationException (IDW10115) + "Token binding requires either a signing certificate or a binding-aware + signed assertion (e.g., from a managed identity supporting mTLS PoP). + The loaded credential provides neither." + +isTokenBinding = false? +├── CredentialType.Certificate +│ ├── UseBoundCredential? → builder.WithCertificate(cert, SendCertificateOverMtls) +│ └── else → builder.WithCertificate(cert) +│ +├── CredentialType.SignedAssertion +│ └── builder.WithClientAssertion(GetSignedAssertionAsync) +│ +├── CredentialType.Secret +│ └── builder.WithClientSecret(secret) +│ +└── null → builder (no credential — will fail at token acquisition time) +``` + +--- + +## Extension Points + +### Adding a new credential source + +1. Implement `ICredentialSourceLoader`: + ```csharp + public class MyCredentialLoader : ICredentialSourceLoader + { + public CredentialSource CredentialSource => CredentialSource.MySource; + public Task LoadIfNeededAsync( + CredentialDescription desc, + CredentialSourceLoaderParameters? params) { ... } + } + ``` +2. Register via DI — `DefaultCredentialsLoader` accepts `IEnumerable` + and merges them with built-in loaders. + +### Adding a custom signed assertion provider (e.g. OIDC FIC) + +1. Implement `ICustomSignedAssertionProvider`: + ```csharp + public class OidcIdpSignedAssertionLoader : ICustomSignedAssertionProvider + { + public string Name => "OidcIdpSignedAssertion"; + public Task GetSignedAssertionAsync(/* ... */) { ... } + } + ``` +2. Register via DI (e.g. `services.AddOidcFic()` or `services.AddFmiSignedAssertion()`) — + the orchestrator finds it when `SourceType == CustomSignedAssertion` + and `CustomSignedAssertionProviderName` matches. + + Built-in custom assertion providers: + - **OIDC FIC** (`OidcIdpSignedAssertionLoader`, registered via `services.AddOidcFic()`) — federated identity via an OIDC IdP + - **FMI** (registered via `services.AddFmiSignedAssertion()`) — federated managed identity (FMI-path) assertions + +--- + +## Credential Fallback Chain + +`ClientCredentials` is an **ordered array**. The orchestrator tries each in sequence: + +```json +{ + "ClientCredentials": [ + { "SourceType": "SignedAssertionFromManagedIdentity", "ManagedIdentityClientId": "..." }, + { "SourceType": "KeyVault", "KeyVaultUrl": "...", "KeyVaultCertificateName": "..." }, + { "SourceType": "ClientSecret", "ClientSecret": "dev-only-secret" } + ] +} +``` + +At runtime: +1. Try MI assertion → works in Azure ✅ +2. Try KeyVault cert → works with network access ✅ +3. Try client secret → dev fallback ✅ + +First successful load wins. Failed credentials where the loader sets `Skip = true` +remain skipped on subsequent requests (CredentialsProvider checks `if (!credential.Skip)`). +To reset, call `ResetCredentials()` which clears both `CachedValue` and `Skip`. +For credentials that failed without setting Skip (rare), the `CachedValue == null` check +allows a natural retry on the next request. + +--- + +## Diagrams: End-to-End Token Acquisition + +### Scenario: FIC + Managed Identity requesting a Bearer token + +```mermaid +sequenceDiagram + participant App + participant AuthHeaderProvider as DefaultAuthorizationHeaderProvider + participant TA as TokenAcquisition + participant CP as CredentialsProvider + participant Loader as MI CredentialLoader + participant MSAL as MSAL CCA + + App->>AuthHeaderProvider: CreateAuthorizationHeaderForApp(scopes) + Note over AuthHeaderProvider: ProtocolScheme not set
isTokenBinding = false + + AuthHeaderProvider->>TA: GetAuthenticationResultForAppAsync() + TA->>TA: GetOrBuildConfidentialClientApplicationAsync(isTokenBinding=false) + TA->>CP: GetCredentialAsync() + CP->>Loader: LoadIfNeededAsync() + Loader->>Loader: Create ManagedIdentityClientAssertion
Trial call GetSignedAssertionAsync() + Loader-->>CP: credential (CredentialType=SignedAssertion) + CP-->>TA: resolved credential + TA->>MSAL: builder.WithClientAssertion(GetSignedAssertionAsync) + TA->>MSAL: AcquireTokenForClient(scopes).ExecuteAsync() + MSAL-->>App: Bearer token +``` + +### Scenario: Certificate requesting an mTLS PoP token + +```mermaid +sequenceDiagram + participant App + participant AuthHeaderProvider as DefaultAuthorizationHeaderProvider + participant TA as TokenAcquisition + participant CP as CredentialsProvider + participant Loader as Certificate Loader + participant MSAL as MSAL CCA + + App->>AuthHeaderProvider: CreateAuthorizationHeaderForApp(scopes,
ProtocolScheme="MTLS_POP") + Note over AuthHeaderProvider: Sets ExtraParameters
["IsTokenBinding"] = true + + AuthHeaderProvider->>TA: GetAuthenticationResultForAppAsync() + Note over TA: isTokenBinding = true
cache key += "-tokenBinding" + TA->>CP: GetCredentialAsync() + CP->>Loader: LoadIfNeededAsync() + Loader-->>CP: credential (Certificate != null) + CP-->>TA: resolved credential + TA->>MSAL: builder.WithCertificate(cert) + TA->>MSAL: AcquireTokenForClient(scopes)
.WithMtlsProofOfPossession()
.ExecuteAsync() + Note over MSAL: Performs mTLS handshake
+ PoP token minting + MSAL-->>App: mtls_pop token +``` + +--- + +## Summary Table: Credential Type × Token Type + +| Credential Source | Bearer token | mTLS PoP token | +|-------------------|:---:|:---:| +| Certificate (KeyVault, Store, Path, Base64) | ✅ `WithCertificate` | ✅ `WithCertificate` + `WithMtlsProofOfPossession` | +| Client Secret | ✅ `WithClientSecret` | ❌ Not supported (no private key for binding) | +| FIC via MI (`SignedAssertionFromManagedIdentity`) | ✅ `WithClientAssertion` | ✅ if `SupportsTokenBinding` (via `GetSignedAssertionWithBindingAsync`) | +| OIDC FIC (`CustomSignedAssertion`) | ✅ `WithClientAssertion` | ❌ Not yet (needs binding cert) | +| Managed Identity (direct, not FIC) | ✅ `AcquireTokenForMI` | ✅ `WithMtlsProofOfPossession` + `WithAttestationSupport` |