diff --git a/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationClient.cs b/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationClient.cs index 9d75c6fce8..be1ece3426 100644 --- a/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationClient.cs +++ b/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationClient.cs @@ -9,7 +9,7 @@ namespace Microsoft.Identity.Client.KeyAttestation.Attestation { /// /// Managed façade for AttestationClientLib.dll. Holds initialization state, - /// does ref-count hygiene on , and returns a JWT. + /// does ref-count hygiene on , and returns a JWT with expiry information. /// internal sealed class AttestationClient : IDisposable { @@ -37,14 +37,14 @@ public AttestationClient() } /// - /// Calls the native AttestKeyGuardImportKey and returns a structured result. + /// Calls the native AttestKeyGuardImportKey and returns a structured result with expiry information. /// public AttestationResult Attest(string endpoint, SafeNCryptKeyHandle keyHandle, string clientId) { if (!_initialized) - return new(AttestationStatus.NotInitialized, null, -1, + return new(AttestationStatus.NotInitialized, null, null, -1, "Native library not initialized."); IntPtr buf = IntPtr.Zero; @@ -58,33 +58,38 @@ public AttestationResult Attest(string endpoint, endpoint, null, null, keyHandle, out buf, clientId); if (rc != 0) - return new(AttestationStatus.NativeError, null, rc, null); + return new(AttestationStatus.NativeError, null, null, rc, null); if (buf == IntPtr.Zero) - return new(AttestationStatus.TokenEmpty, null, 0, + return new(AttestationStatus.TokenEmpty, null, null, 0, "rc==0 but token buffer was null."); string jwt = Marshal.PtrToStringAnsi(buf)!; - return new(AttestationStatus.Success, jwt, 0, null); + + // Extract expiry from JWT payload + JwtClaimExtractor.TryExtractExpirationClaim(jwt, out DateTimeOffset expiresOn); + + var token = new AttestationToken(jwt, expiresOn); + return new(AttestationStatus.Success, token, jwt, 0, null); } catch (DllNotFoundException ex) { - return new(AttestationStatus.Exception, null, -1, + return new(AttestationStatus.Exception, null, null, -1, $"Native DLL not found: {ex.Message}"); } catch (BadImageFormatException ex) { - return new(AttestationStatus.Exception, null, -1, + return new(AttestationStatus.Exception, null, null, -1, $"Architecture mismatch (x86/x64) or corrupted DLL: {ex.Message}"); } catch (SEHException ex) { - return new(AttestationStatus.Exception, null, -1, + return new(AttestationStatus.Exception, null, null, -1, $"Native library raised SEHException: {ex.Message}"); } catch (Exception ex) { - return new(AttestationStatus.Exception, null, -1, ex.Message); + return new(AttestationStatus.Exception, null, null, -1, ex.Message); } finally { diff --git a/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationResult.cs b/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationResult.cs index 930ebde5e6..9632fc1ecb 100644 --- a/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationResult.cs +++ b/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationResult.cs @@ -7,21 +7,24 @@ namespace Microsoft.Identity.Client.KeyAttestation.Attestation /// AttestationResult is the result of an attestation operation. /// /// High-level outcome category. - /// JWT on success; null otherwise (caller may pass null). + /// Structured attestation token with expiry on success; null otherwise (caller may pass null). + /// JWT on success; null otherwise (caller may pass null). Retained for backward compatibility. /// Raw native return code (0 on success). /// Optional descriptive text for non-success cases. /// /// This is a positional record. The compiler synthesizes init-only auto-properties: /// public AttestationStatus Status { get; init; } - /// public string Jwt { get; init; } - /// public int NativeErrorCode { get; init; } - /// public string ErrorMessage { get; init; } + /// public AttestationToken Token { get; init; } + /// public string Jwt { get; init; } + /// public int NativeErrorCode { get; init; } + /// public string ErrorMessage { get; init; } /// Because they are init-only, values are fixed after construction; to "modify" use a 'with' /// expression, e.g.: var updated = result with { Jwt = newJwt }; /// The netstandard2.0 target relies on the IsExternalInit shim (see IsExternalInit.cs) to enable 'init'. /// internal sealed record AttestationResult( AttestationStatus Status, + AttestationToken Token, string Jwt, int NativeErrorCode, string ErrorMessage); diff --git a/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationToken.cs b/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationToken.cs new file mode 100644 index 0000000000..9a35f1fef0 --- /dev/null +++ b/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationToken.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.Identity.Client.KeyAttestation.Attestation +{ + /// + /// Represents a successfully attested token with structured metadata. + /// + /// The raw JWT string. + /// The expiration time of the token (UTC). + /// + /// Internal use only. This record enables the library to access token expiry + /// without requiring callers to manually decode the JWT. + /// + internal sealed record AttestationToken( + string Token, + DateTimeOffset ExpiresOn); +} diff --git a/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/JwtClaimExtractor.cs b/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/JwtClaimExtractor.cs new file mode 100644 index 0000000000..5adbddd7e4 --- /dev/null +++ b/src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/JwtClaimExtractor.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Microsoft.Identity.Client.Utils; + +namespace Microsoft.Identity.Client.KeyAttestation.Attestation +{ + /// + /// JWT claim extractor leveraging MSAL's existing helper utilities. + /// + internal static class JwtClaimExtractor + { + /// + /// Extracts the 'exp' (expiration) claim from a JWT payload. + /// + /// The JWT string (format: header.payload.signature). + /// The parsed expiration time in UTC, or DateTimeOffset.MinValue on failure. + /// True if the exp claim was successfully extracted; false otherwise. + internal static bool TryExtractExpirationClaim(string jwt, out DateTimeOffset expiresOn) + { + expiresOn = DateTimeOffset.MinValue; + + try + { + // Split JWT into parts + var parts = jwt.Split('.'); + if (parts.Length < 2) + return false; + + // Use MSAL's Base64UrlHelpers to decode the payload (handles padding automatically) + string payloadJson = Base64UrlHelpers.Decode(parts[1]); + + if (string.IsNullOrEmpty(payloadJson)) + return false; + + // Use MSAL's JsonHelper to parse JSON (handles both System.Text.Json and Newtonsoft) + var claims = JsonHelper.DeserializeFromJson>(payloadJson); + + if (claims == null || !claims.TryGetValue("exp", out object expObj)) + return false; + + // Parse the exp claim (Unix timestamp in seconds) + if (expObj is long expLong) + { + expiresOn = DateTimeOffset.FromUnixTimeSeconds(expLong); + return true; + } + + // Handle case where exp comes as string (defensive) + if (expObj is string expStr && long.TryParse(expStr, out long parsedExp)) + { + expiresOn = DateTimeOffset.FromUnixTimeSeconds(parsedExp); + return true; + } + } + catch + { + // Silently fail: malformed JWT or parsing error + } + + return false; + } + } +} diff --git a/src/client/Microsoft.Identity.Client.KeyAttestation/PopKeyAttestor.cs b/src/client/Microsoft.Identity.Client.KeyAttestation/PopKeyAttestor.cs index 6ef7a464b9..b2ecbeff8e 100644 --- a/src/client/Microsoft.Identity.Client.KeyAttestation/PopKeyAttestor.cs +++ b/src/client/Microsoft.Identity.Client.KeyAttestation/PopKeyAttestor.cs @@ -70,7 +70,7 @@ public static Task AttestCredentialGuardAsync( catch (Exception ex) { // Map any managed exception to AttestationStatus.Exception for consistency. - return new AttestationResult(AttestationStatus.Exception, string.Empty, -1, ex.Message); + return new AttestationResult(AttestationStatus.Exception, null, string.Empty, -1, ex.Message); } }, cancellationToken); } diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/TestAttestationProviders.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/TestAttestationProviders.cs index 5f133793e3..507511fd73 100644 --- a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/TestAttestationProviders.cs +++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/TestAttestationProviders.cs @@ -23,7 +23,8 @@ public static Func { var fakeJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.fake.attestation.sig"; - return Task.FromResult(new AttestationResult(AttestationStatus.Success, fakeJwt, 0, string.Empty)); + var token = new AttestationToken(fakeJwt, DateTimeOffset.UtcNow.AddHours(1)); + return Task.FromResult(new AttestationResult(AttestationStatus.Success, token, fakeJwt, 0, string.Empty)); }; }