From 9c6bcff2882fdbc7b00257ff5aac91577f6e6228 Mon Sep 17 00:00:00 2001
From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com>
Date: Mon, 9 Feb 2026 09:17:29 -0800
Subject: [PATCH] expires_on
---
.../Attestation/AttestationClient.cs | 25 ++++---
.../Attestation/AttestationResult.cs | 11 ++--
.../Attestation/AttestationToken.cs | 20 ++++++
.../Attestation/JwtClaimExtractor.cs | 66 +++++++++++++++++++
.../PopKeyAttestor.cs | 2 +-
.../TestAttestationProviders.cs | 3 +-
6 files changed, 111 insertions(+), 16 deletions(-)
create mode 100644 src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationToken.cs
create mode 100644 src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/JwtClaimExtractor.cs
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));
};
}