Skip to content

Commit 10292c9

Browse files
Chore/improve maskinporten client tests (#1410)
--------- Co-authored-by: Camilla Marie Dalan <camillamdalan@gmail.com>
1 parent efb8e91 commit 10292c9

5 files changed

Lines changed: 260 additions & 169 deletions

File tree

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
3030
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" />
3131
<PackageVersion Include="Microsoft.CodeAnalysis.VisualBasic.Workspaces" Version="4.14.0" />
32-
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="9.6.0" />
32+
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="9.7.0" />
3333
<PackageVersion Include="Microsoft.Extensions.Diagnostics.Testing" Version="8.10.0" />
3434
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.10.0" />
3535
<PackageVersion Include="Microsoft.FeatureManagement.AspNetCore" Version="4.1.0" />

src/Altinn.App.Core/Features/Maskinporten/MaskinportenClient.cs

Lines changed: 140 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics;
12
using System.Net.Http.Headers;
23
using System.Text.Json;
34
using Altinn.App.Core.Configuration;
@@ -86,56 +87,7 @@ public async Task<JwtToken> GetAccessToken(
8687
CancellationToken cancellationToken = default
8788
)
8889
{
89-
string formattedScopes = FormattedScopes(scopes);
90-
string cacheKey = $"{_maskinportenCacheKeySalt}_{formattedScopes}";
91-
DateTimeOffset referenceTime = _timeProvider.GetUtcNow();
92-
93-
_logger.LogDebug("Retrieving Maskinporten token for scopes: {Scopes}", formattedScopes);
94-
using var activity = _telemetry?.StartGetAccessTokenActivity(Variant, Settings.ClientId, formattedScopes);
95-
96-
var result = await _tokenCache.GetOrCreateAsync(
97-
cacheKey,
98-
(Self: this, FormattedScopes: formattedScopes, ReferenceTime: referenceTime),
99-
static async (state, cancellationToken) =>
100-
{
101-
state.Self._logger.LogDebug("Token is not in cache, generating new");
102-
103-
JwtToken token = await state.Self.HandleMaskinportenAuthentication(
104-
state.FormattedScopes,
105-
cancellationToken
106-
);
107-
108-
var expiresIn = state.Self.GetTokenExpiryWithMargin(token);
109-
if (expiresIn <= TimeSpan.Zero)
110-
{
111-
throw new MaskinportenTokenExpiredException(
112-
$"Access token cannot be used because it has a calculated expiration in the past (taking into account a margin of {TokenExpirationMargin} seconds): {token}"
113-
);
114-
}
115-
116-
return new TokenCacheEntry(Token: token, ExpiresIn: expiresIn, HasSetExpiration: false);
117-
},
118-
cancellationToken: cancellationToken,
119-
options: _defaultCacheExpiration
120-
);
121-
122-
if (result.HasSetExpiration is false)
123-
{
124-
_logger.LogDebug("Updating token cache with appropriate expiration");
125-
result = result with { HasSetExpiration = true };
126-
await _tokenCache.SetAsync(
127-
cacheKey,
128-
result,
129-
options: CacheExpiryFactory(result.ExpiresIn),
130-
cancellationToken: cancellationToken
131-
);
132-
}
133-
else
134-
{
135-
_logger.LogDebug("Token retrieved from cache: {Token}", result.Token);
136-
_telemetry?.RecordMaskinportenTokenRequest(Telemetry.Maskinporten.RequestResult.Cached);
137-
}
138-
90+
var result = await GetOrCreateTokenFromCache(TokenAuthority.Maskinporten, scopes, cancellationToken);
13991
return result.Token;
14092
}
14193

@@ -145,42 +97,43 @@ public async Task<JwtToken> GetAltinnExchangedToken(
14597
CancellationToken cancellationToken = default
14698
)
14799
{
148-
string formattedScopes = FormattedScopes(scopes);
149-
string cacheKey = $"{_altinnCacheKeySalt}_{formattedScopes}";
150-
151-
_logger.LogDebug("Retrieving Altinn token for scopes: {Scopes}", formattedScopes);
152-
using var activity = _telemetry?.StartGetAltinnExchangedAccessTokenActivity(
153-
Variant,
154-
Settings.ClientId,
155-
formattedScopes
156-
);
100+
var result = await GetOrCreateTokenFromCache(TokenAuthority.AltinnTokenExchange, scopes, cancellationToken);
101+
return result.Token;
102+
}
157103

158-
var result = await _tokenCache.GetOrCreateAsync(
159-
cacheKey,
160-
(Self: this, Scopes: scopes),
161-
static async (state, cancellationToken) =>
162-
{
163-
state.Self._logger.LogDebug("Token is not in cache, generating new");
164-
JwtToken maskinportenToken = await state.Self.GetAccessToken(state.Scopes, cancellationToken);
165-
JwtToken altinnToken = await state.Self.HandleMaskinportenAltinnTokenExchange(
166-
maskinportenToken,
167-
cancellationToken
168-
);
104+
/// <summary>
105+
/// <para>Retrieves a token from the cache, or creates a new one if it does not exist.</para>
106+
/// Based on the supplied <see cref="TokenAuthority"/>, either <see cref="MaskinportenTokenFactory"/> or <see cref="AltinnTokenFactory"/>
107+
/// will be invoked to create new tokens.
108+
/// </summary>
109+
internal async Task<TokenCacheEntry> GetOrCreateTokenFromCache(
110+
TokenAuthority authority,
111+
IEnumerable<string> scopes,
112+
CancellationToken cancellationToken = default
113+
)
114+
{
115+
string formattedScopes = GetFormattedScopes(scopes);
116+
string cacheKey = GetCacheKey(authority, formattedScopes);
169117

170-
var expiresIn = state.Self.GetTokenExpiryWithMargin(altinnToken);
171-
if (expiresIn <= TimeSpan.Zero)
172-
{
173-
throw new MaskinportenTokenExpiredException(
174-
$"Access token cannot be used because it has a calculated expiration in the past (taking into account a margin of {TokenExpirationMargin} seconds): {altinnToken}"
175-
);
176-
}
118+
_logger.LogDebug("Retrieving {Authority} token for scopes: {Scopes}", authority, formattedScopes);
119+
using var activity = TelemetryStartActivityFactory(authority, Variant, Settings.ClientId, formattedScopes);
177120

178-
return new TokenCacheEntry(Token: altinnToken, ExpiresIn: expiresIn, HasSetExpiration: false);
179-
},
121+
// We are making some binary assumptions below, so lets guard against future expansion of the TokenAuthority enum.
122+
if (authority is not (TokenAuthority.Maskinporten or TokenAuthority.AltinnTokenExchange))
123+
throw new ArgumentException($"Unknown token authority {authority}", nameof(authority));
124+
125+
Func<CacheFactoryState, CancellationToken, ValueTask<TokenCacheEntry>> tokenFactory =
126+
authority == TokenAuthority.Maskinporten ? MaskinportenTokenFactory : AltinnTokenFactory;
127+
128+
var result = await _tokenCache.GetOrCreateAsync(
129+
cacheKey,
130+
new CacheFactoryState(this, formattedScopes),
131+
tokenFactory,
180132
cancellationToken: cancellationToken,
181133
options: _defaultCacheExpiration
182134
);
183135

136+
// Newly created token: Set the cache expiration and return result. Metrics are recorded in the factory methods.
184137
if (result.HasSetExpiration is false)
185138
{
186139
_logger.LogDebug("Updating token cache with appropriate expiration");
@@ -191,14 +144,19 @@ await _tokenCache.SetAsync(
191144
options: CacheExpiryFactory(result.ExpiresIn),
192145
cancellationToken: cancellationToken
193146
);
147+
148+
return result;
194149
}
150+
151+
// Token retrieved from cache: Handle some metrics and return the result.
152+
_logger.LogDebug("Token retrieved from cache: {Token}", result.Token);
153+
154+
if (authority == TokenAuthority.Maskinporten)
155+
_telemetry?.RecordMaskinportenTokenRequest(Telemetry.Maskinporten.RequestResult.Cached);
195156
else
196-
{
197-
_logger.LogDebug("Token retrieved from cache: {Token}", result.Token);
198157
_telemetry?.RecordMaskinportenAltinnTokenExchangeRequest(Telemetry.Maskinporten.RequestResult.Cached);
199-
}
200158

201-
return result.Token;
159+
return result;
202160
}
203161

204162
/// <summary>
@@ -224,17 +182,13 @@ private async Task<JwtToken> HandleMaskinportenAuthentication(
224182
await payload.ReadAsStringAsync(cancellationToken)
225183
);
226184

227-
string tokenAuthority = Settings.Authority.Trim('/');
185+
string tokenUri = Settings.Authority.Trim('/') + "/token";
228186
using HttpClient client = _httpClientFactory.CreateClient();
229-
using HttpResponseMessage response = await client.PostAsync(
230-
$"{tokenAuthority}/token",
231-
payload,
232-
cancellationToken
233-
);
187+
using HttpResponseMessage response = await client.PostAsync(tokenUri, payload, cancellationToken);
234188

235189
MaskinportenTokenResponse tokenResponse = await ParseServerResponse(response, cancellationToken);
236190

237-
_logger.LogDebug("Token retrieved successfully: {Token}", tokenResponse);
191+
_logger.LogDebug("Token retrieved successfully from remote: {Token}", tokenResponse);
238192
_telemetry?.RecordMaskinportenTokenRequest(Telemetry.Maskinporten.RequestResult.New);
239193

240194
return tokenResponse.AccessToken;
@@ -269,10 +223,10 @@ private async Task<JwtToken> HandleMaskinportenAltinnTokenExchange(
269223
maskinportenToken
270224
);
271225

226+
string exchangeUri = _platformSettings.ApiAuthenticationEndpoint.TrimEnd('/') + "/exchange/maskinporten";
272227
using HttpClient client = _httpClientFactory.CreateClient();
273-
string url = _platformSettings.ApiAuthenticationEndpoint.TrimEnd('/') + "/exchange/maskinporten";
274228

275-
using var request = new HttpRequestMessage(HttpMethod.Get, url);
229+
using var request = new HttpRequestMessage(HttpMethod.Get, exchangeUri);
276230
request.Headers.TryAddWithoutValidation(
277231
General.SubscriptionKeyHeaderName,
278232
_platformSettings.SubscriptionKey
@@ -288,7 +242,7 @@ private async Task<JwtToken> HandleMaskinportenAltinnTokenExchange(
288242
string tokenResponse = await response.Content.ReadAsStringAsync(cancellationToken);
289243
JwtToken token = JwtToken.Parse(tokenResponse);
290244

291-
_logger.LogDebug("Token retrieved successfully: {Token}", token);
245+
_logger.LogDebug("Token retrieved successfully from remote: {Token}", token);
292246
_telemetry?.RecordMaskinportenAltinnTokenExchangeRequest(Telemetry.Maskinporten.RequestResult.New);
293247

294248
return token;
@@ -320,7 +274,7 @@ internal string GenerateJwtGrant(string formattedScopes)
320274
catch (OptionsValidationException e)
321275
{
322276
throw new MaskinportenConfigurationException(
323-
$"Error reading MaskinportenSettings from the current app configuration",
277+
"Error reading MaskinportenSettings from the current app configuration",
324278
e
325279
);
326280
}
@@ -350,16 +304,14 @@ internal string GenerateJwtGrant(string formattedScopes)
350304
/// as per <a href="https://docs.digdir.no/docs/Maskinporten/maskinporten_guide_apikonsument#5-be-om-token">the docs</a>.</p>
351305
/// </summary>
352306
/// <param name="jwtAssertion">The JWT token generated by <see cref="GenerateJwtGrant"/>.</param>
353-
internal static FormUrlEncodedContent AuthenticationPayloadFactory(string jwtAssertion)
354-
{
355-
return new FormUrlEncodedContent(
307+
internal static FormUrlEncodedContent AuthenticationPayloadFactory(string jwtAssertion) =>
308+
new(
356309
new Dictionary<string, string>
357310
{
358311
["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer",
359312
["assertion"] = jwtAssertion,
360313
}
361314
);
362-
}
363315

364316
/// <summary>
365317
/// Parses the Maskinporten server response and deserializes the JSON body.
@@ -408,25 +360,107 @@ internal static async Task<MaskinportenTokenResponse> ParseServerResponse(
408360
}
409361
}
410362

363+
/// <summary>
364+
/// Generates a cache key for the supplied authority and scopes, in the format of `{salt}_{formattedScopes}`.
365+
/// </summary>
366+
internal string GetCacheKey(TokenAuthority authority, string formattedScopes)
367+
{
368+
var salt = authority switch
369+
{
370+
TokenAuthority.Maskinporten => _maskinportenCacheKeySalt,
371+
TokenAuthority.AltinnTokenExchange => _altinnCacheKeySalt,
372+
_ => throw new ArgumentException($"Unknown token authority {authority}", nameof(authority)),
373+
};
374+
375+
return $"{salt}_{formattedScopes}";
376+
}
377+
411378
/// <summary>
412379
/// Formats a list of scopes according to the expected formatting (space-delimited).
413380
/// See <a href="https://docs.digdir.no/docs/Maskinporten/maskinporten_guide_apikonsument#5-be-om-token">the docs</a> for more information.
414381
/// </summary>
415382
/// <param name="scopes">A collection of scopes.</param>
416383
/// <returns>A single string containing the supplied scopes.</returns>
417-
internal static string FormattedScopes(IEnumerable<string> scopes) => string.Join(" ", scopes);
384+
internal static string GetFormattedScopes(IEnumerable<string> scopes) => string.Join(" ", scopes);
418385

419-
private static HybridCacheEntryOptions CacheExpiryFactory(TimeSpan localExpiry, TimeSpan? overallExpiry = null)
420-
{
421-
return new HybridCacheEntryOptions
386+
private TimeSpan GetTokenExpiryWithMargin(JwtToken token) =>
387+
token.ExpiresAt - _timeProvider.GetUtcNow() - TokenExpirationMargin;
388+
389+
private static HybridCacheEntryOptions CacheExpiryFactory(TimeSpan localExpiry, TimeSpan? overallExpiry = null) =>
390+
new() { LocalCacheExpiration = localExpiry, Expiration = overallExpiry ?? localExpiry };
391+
392+
/// <summary>
393+
/// This method simply forwards the activity request to the telemetry service and returns the resulting instance.
394+
/// </summary>
395+
private Activity? TelemetryStartActivityFactory(
396+
TokenAuthority authority,
397+
string variant,
398+
string clientId,
399+
string formattedScopes
400+
) =>
401+
authority switch
422402
{
423-
LocalCacheExpiration = localExpiry,
424-
Expiration = overallExpiry ?? localExpiry,
403+
TokenAuthority.Maskinporten => _telemetry?.StartGetAccessTokenActivity(variant, clientId, formattedScopes),
404+
TokenAuthority.AltinnTokenExchange => _telemetry?.StartGetAltinnExchangedAccessTokenActivity(
405+
variant,
406+
clientId,
407+
formattedScopes
408+
),
409+
_ => throw new ArgumentException($"Unknown token authority {authority}", nameof(authority)),
425410
};
411+
412+
/// <summary>
413+
/// Factory method for creating a new Maskinporten token, in the context of <see cref="GetOrCreateTokenFromCache"/>.
414+
/// This is mainly a wrapper for <see cref="HandleMaskinportenAuthentication"/> with some additional cache-specific logic.
415+
/// </summary>
416+
private static async ValueTask<TokenCacheEntry> MaskinportenTokenFactory(
417+
CacheFactoryState state,
418+
CancellationToken cancellationToken
419+
)
420+
{
421+
state.Self._logger.LogDebug("Token is not in cache, generating new");
422+
423+
JwtToken token = await state.Self.HandleMaskinportenAuthentication(state.FormattedScopes, cancellationToken);
424+
425+
var expiresIn = state.Self.GetTokenExpiryWithMargin(token);
426+
if (expiresIn <= TimeSpan.Zero)
427+
{
428+
throw new MaskinportenTokenExpiredException(
429+
$"Access token cannot be used because it has a calculated expiration in the past (taking into account a margin of {TokenExpirationMargin} seconds): {token}"
430+
);
431+
}
432+
433+
return new TokenCacheEntry(Token: token, ExpiresIn: expiresIn, HasSetExpiration: false);
426434
}
427435

428-
private TimeSpan GetTokenExpiryWithMargin(JwtToken token)
436+
/// <summary>
437+
/// Factory method for creating a new Altinn-exchanged token, in the context of <see cref="GetOrCreateTokenFromCache"/>
438+
/// This is mainly a wrapper for <see cref="GetAccessToken"/> + <see cref="HandleMaskinportenAltinnTokenExchange"/>
439+
/// with some additional cache-specific logic.
440+
/// </summary>
441+
/// <remarks><see cref="GetAccessToken"/> itself may or may not return a cached response.</remarks>
442+
private static async ValueTask<TokenCacheEntry> AltinnTokenFactory(
443+
CacheFactoryState state,
444+
CancellationToken cancellationToken
445+
)
429446
{
430-
return token.ExpiresAt - _timeProvider.GetUtcNow() - TokenExpirationMargin;
447+
state.Self._logger.LogDebug("Token is not in cache, generating new");
448+
JwtToken maskinportenToken = await state.Self.GetAccessToken([state.FormattedScopes], cancellationToken);
449+
JwtToken altinnToken = await state.Self.HandleMaskinportenAltinnTokenExchange(
450+
maskinportenToken,
451+
cancellationToken
452+
);
453+
454+
var expiresIn = state.Self.GetTokenExpiryWithMargin(altinnToken);
455+
if (expiresIn <= TimeSpan.Zero)
456+
{
457+
throw new MaskinportenTokenExpiredException(
458+
$"Access token cannot be used because it has a calculated expiration in the past (taking into account a margin of {TokenExpirationMargin} seconds): {altinnToken}"
459+
);
460+
}
461+
462+
return new TokenCacheEntry(Token: altinnToken, ExpiresIn: expiresIn, HasSetExpiration: false);
431463
}
464+
465+
private sealed record CacheFactoryState(MaskinportenClient Self, string FormattedScopes);
432466
}

test/Altinn.App.Core.Tests/Features/Maskinporten/Delegates/MaskinportenDelegatingHandlerTest.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,14 @@ public static class MaskinportenDelegatingHandlerTestExtensions
5555
{
5656
public static JwtToken GetMaskinportenToken(this IEnumerable<string> scopes)
5757
{
58-
return TestAuthentication.GetMaskinportenToken(scope: MaskinportenClient.FormattedScopes(scopes)).AccessToken;
58+
return TestAuthentication
59+
.GetMaskinportenToken(scope: MaskinportenClient.GetFormattedScopes(scopes))
60+
.AccessToken;
5961
}
6062

6163
public static JwtToken GetAltinnExchangedToken(this IEnumerable<string> scopes)
6264
{
63-
var token = TestAuthentication.GetOrgAuthentication(scope: MaskinportenClient.FormattedScopes(scopes)).Token;
65+
var token = TestAuthentication.GetOrgAuthentication(scope: MaskinportenClient.GetFormattedScopes(scopes)).Token;
6466

6567
return JwtToken.Parse(token);
6668
}

0 commit comments

Comments
 (0)