Skip to content

Commit 6013281

Browse files
authored
Expose expires on in attestation token #5739 (#5741)
expires_on
1 parent be00ea2 commit 6013281

6 files changed

Lines changed: 111 additions & 16 deletions

File tree

src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationClient.cs

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace Microsoft.Identity.Client.KeyAttestation.Attestation
99
{
1010
/// <summary>
1111
/// Managed façade for <c>AttestationClientLib.dll</c>. Holds initialization state,
12-
/// does ref-count hygiene on <see cref="SafeNCryptKeyHandle"/>, and returns a JWT.
12+
/// does ref-count hygiene on <see cref="SafeNCryptKeyHandle"/>, and returns a JWT with expiry information.
1313
/// </summary>
1414
internal sealed class AttestationClient : IDisposable
1515
{
@@ -37,14 +37,14 @@ public AttestationClient()
3737
}
3838

3939
/// <summary>
40-
/// Calls the native <c>AttestKeyGuardImportKey</c> and returns a structured result.
40+
/// Calls the native <c>AttestKeyGuardImportKey</c> and returns a structured result with expiry information.
4141
/// </summary>
4242
public AttestationResult Attest(string endpoint,
4343
SafeNCryptKeyHandle keyHandle,
4444
string clientId)
4545
{
4646
if (!_initialized)
47-
return new(AttestationStatus.NotInitialized, null, -1,
47+
return new(AttestationStatus.NotInitialized, null, null, -1,
4848
"Native library not initialized.");
4949

5050
IntPtr buf = IntPtr.Zero;
@@ -58,33 +58,38 @@ public AttestationResult Attest(string endpoint,
5858
endpoint, null, null, keyHandle, out buf, clientId);
5959

6060
if (rc != 0)
61-
return new(AttestationStatus.NativeError, null, rc, null);
61+
return new(AttestationStatus.NativeError, null, null, rc, null);
6262

6363
if (buf == IntPtr.Zero)
64-
return new(AttestationStatus.TokenEmpty, null, 0,
64+
return new(AttestationStatus.TokenEmpty, null, null, 0,
6565
"rc==0 but token buffer was null.");
6666

6767
string jwt = Marshal.PtrToStringAnsi(buf)!;
68-
return new(AttestationStatus.Success, jwt, 0, null);
68+
69+
// Extract expiry from JWT payload
70+
JwtClaimExtractor.TryExtractExpirationClaim(jwt, out DateTimeOffset expiresOn);
71+
72+
var token = new AttestationToken(jwt, expiresOn);
73+
return new(AttestationStatus.Success, token, jwt, 0, null);
6974
}
7075
catch (DllNotFoundException ex)
7176
{
72-
return new(AttestationStatus.Exception, null, -1,
77+
return new(AttestationStatus.Exception, null, null, -1,
7378
$"Native DLL not found: {ex.Message}");
7479
}
7580
catch (BadImageFormatException ex)
7681
{
77-
return new(AttestationStatus.Exception, null, -1,
82+
return new(AttestationStatus.Exception, null, null, -1,
7883
$"Architecture mismatch (x86/x64) or corrupted DLL: {ex.Message}");
7984
}
8085
catch (SEHException ex)
8186
{
82-
return new(AttestationStatus.Exception, null, -1,
87+
return new(AttestationStatus.Exception, null, null, -1,
8388
$"Native library raised SEHException: {ex.Message}");
8489
}
8590
catch (Exception ex)
8691
{
87-
return new(AttestationStatus.Exception, null, -1, ex.Message);
92+
return new(AttestationStatus.Exception, null, null, -1, ex.Message);
8893
}
8994
finally
9095
{

src/client/Microsoft.Identity.Client.KeyAttestation/Attestation/AttestationResult.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,24 @@ namespace Microsoft.Identity.Client.KeyAttestation.Attestation
77
/// AttestationResult is the result of an attestation operation.
88
/// </summary>
99
/// <param name="Status">High-level outcome category.</param>
10-
/// <param name="Jwt">JWT on success; null otherwise (caller may pass null).</param>
10+
/// <param name="Token">Structured attestation token with expiry on success; null otherwise (caller may pass null).</param>
11+
/// <param name="Jwt">JWT on success; null otherwise (caller may pass null). Retained for backward compatibility.</param>
1112
/// <param name="NativeErrorCode">Raw native return code (0 on success).</param>
1213
/// <param name="ErrorMessage">Optional descriptive text for non-success cases.</param>
1314
/// <remarks>
1415
/// This is a positional record. The compiler synthesizes init-only auto-properties:
1516
/// public AttestationStatus Status { get; init; }
16-
/// public string Jwt { get; init; }
17-
/// public int NativeErrorCode { get; init; }
18-
/// public string ErrorMessage { get; init; }
17+
/// public AttestationToken Token { get; init; }
18+
/// public string Jwt { get; init; }
19+
/// public int NativeErrorCode { get; init; }
20+
/// public string ErrorMessage { get; init; }
1921
/// Because they are init-only, values are fixed after construction; to "modify" use a 'with'
2022
/// expression, e.g.: var updated = result with { Jwt = newJwt };
2123
/// The netstandard2.0 target relies on the IsExternalInit shim (see IsExternalInit.cs) to enable 'init'.
2224
/// </remarks>
2325
internal sealed record AttestationResult(
2426
AttestationStatus Status,
27+
AttestationToken Token,
2528
string Jwt,
2629
int NativeErrorCode,
2730
string ErrorMessage);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
6+
namespace Microsoft.Identity.Client.KeyAttestation.Attestation
7+
{
8+
/// <summary>
9+
/// Represents a successfully attested token with structured metadata.
10+
/// </summary>
11+
/// <param name="Token">The raw JWT string.</param>
12+
/// <param name="ExpiresOn">The expiration time of the token (UTC).</param>
13+
/// <remarks>
14+
/// Internal use only. This record enables the library to access token expiry
15+
/// without requiring callers to manually decode the JWT.
16+
/// </remarks>
17+
internal sealed record AttestationToken(
18+
string Token,
19+
DateTimeOffset ExpiresOn);
20+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using Microsoft.Identity.Client.Utils;
7+
8+
namespace Microsoft.Identity.Client.KeyAttestation.Attestation
9+
{
10+
/// <summary>
11+
/// JWT claim extractor leveraging MSAL's existing helper utilities.
12+
/// </summary>
13+
internal static class JwtClaimExtractor
14+
{
15+
/// <summary>
16+
/// Extracts the 'exp' (expiration) claim from a JWT payload.
17+
/// </summary>
18+
/// <param name="jwt">The JWT string (format: header.payload.signature).</param>
19+
/// <param name="expiresOn">The parsed expiration time in UTC, or DateTimeOffset.MinValue on failure.</param>
20+
/// <returns>True if the exp claim was successfully extracted; false otherwise.</returns>
21+
internal static bool TryExtractExpirationClaim(string jwt, out DateTimeOffset expiresOn)
22+
{
23+
expiresOn = DateTimeOffset.MinValue;
24+
25+
try
26+
{
27+
// Split JWT into parts
28+
var parts = jwt.Split('.');
29+
if (parts.Length < 2)
30+
return false;
31+
32+
// Use MSAL's Base64UrlHelpers to decode the payload (handles padding automatically)
33+
string payloadJson = Base64UrlHelpers.Decode(parts[1]);
34+
35+
if (string.IsNullOrEmpty(payloadJson))
36+
return false;
37+
38+
// Use MSAL's JsonHelper to parse JSON (handles both System.Text.Json and Newtonsoft)
39+
var claims = JsonHelper.DeserializeFromJson<Dictionary<string, object>>(payloadJson);
40+
41+
if (claims == null || !claims.TryGetValue("exp", out object expObj))
42+
return false;
43+
44+
// Parse the exp claim (Unix timestamp in seconds)
45+
if (expObj is long expLong)
46+
{
47+
expiresOn = DateTimeOffset.FromUnixTimeSeconds(expLong);
48+
return true;
49+
}
50+
51+
// Handle case where exp comes as string (defensive)
52+
if (expObj is string expStr && long.TryParse(expStr, out long parsedExp))
53+
{
54+
expiresOn = DateTimeOffset.FromUnixTimeSeconds(parsedExp);
55+
return true;
56+
}
57+
}
58+
catch
59+
{
60+
// Silently fail: malformed JWT or parsing error
61+
}
62+
63+
return false;
64+
}
65+
}
66+
}

src/client/Microsoft.Identity.Client.KeyAttestation/PopKeyAttestor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public static Task<AttestationResult> AttestCredentialGuardAsync(
7070
catch (Exception ex)
7171
{
7272
// Map any managed exception to AttestationStatus.Exception for consistency.
73-
return new AttestationResult(AttestationStatus.Exception, string.Empty, -1, ex.Message);
73+
return new AttestationResult(AttestationStatus.Exception, null, string.Empty, -1, ex.Message);
7474
}
7575
}, cancellationToken);
7676
}

tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/TestAttestationProviders.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ public static Func<string, SafeHandle, string, CancellationToken, Task<Attestati
2323
return (attestationEndpoint, keyHandle, clientId, cancellationToken) =>
2424
{
2525
var fakeJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.fake.attestation.sig";
26-
return Task.FromResult(new AttestationResult(AttestationStatus.Success, fakeJwt, 0, string.Empty));
26+
var token = new AttestationToken(fakeJwt, DateTimeOffset.UtcNow.AddHours(1));
27+
return Task.FromResult(new AttestationResult(AttestationStatus.Success, token, fakeJwt, 0, string.Empty));
2728
};
2829
}
2930

0 commit comments

Comments
 (0)