1+ using System . Diagnostics ;
12using System . Net . Http . Headers ;
23using System . Text . Json ;
34using 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}
0 commit comments