77using Microsoft . EntityFrameworkCore ;
88using Microsoft . OpenApi ;
99using MQTTnet . Extensions . ManagedClient ;
10+ using Npgsql ;
1011
1112namespace api . Configurations ;
1213
1314public static class CustomServiceConfigurations
1415{
16+ private const string AzurePostgresScope =
17+ "https://ossrdbms-aad.database.windows.net/.default" ;
18+
1519 /// <summary>
1620 /// Build a <see cref="TokenCredential"/> for authenticating against Azure resources.
1721 ///
@@ -253,17 +257,51 @@ public static TokenCredential CreateRuntimeCredential(IConfiguration config)
253257 return new ChainedTokenCredential ( [ .. credentials ] ) ;
254258 }
255259
260+ /// <summary>
261+ /// Configure the database connection for the application.
262+ ///
263+ /// When <c>Database:UseInMemoryDatabase</c> is <c>true</c>, an in-memory SQLite database
264+ /// is used (local development).
265+ ///
266+ /// Otherwise, the method reads <c>Database:AllowedAuthMethods</c> — an ordered list whose
267+ /// entries may be <c>"AppRegIdentity"</c> and/or <c>"ConnectionString"</c> (case-insensitive).
268+ /// Methods are tried in the order specified; the first one that succeeds wins.
269+ ///
270+ /// <list type="bullet">
271+ /// <item>
272+ /// <term>AppRegIdentity</term>
273+ /// <description>
274+ /// Acquires an Entra ID (Azure AD) access token via <see cref="CreateRuntimeCredential"/>
275+ /// and connects to PostgreSQL using <c>UsePeriodicPasswordProvider</c> (token refreshed
276+ /// every 55 minutes). Requires <c>Database:Server</c>, <c>Database:PostgresDatabase</c>
277+ /// and <c>Database:User</c> to be configured.
278+ /// </description>
279+ /// </item>
280+ /// <item>
281+ /// <term>ConnectionString</term>
282+ /// <description>
283+ /// Uses a traditional connection string from <c>Database:postgresConnectionString</c>
284+ /// (typically loaded from Azure Key Vault).
285+ /// </description>
286+ /// </item>
287+ /// </list>
288+ ///
289+ /// If the list is empty or absent, defaults to <c>["ConnectionString"]</c> for backward
290+ /// compatibility.
291+ /// </summary>
256292 public static IServiceCollection ConfigureDatabase (
257293 this IServiceCollection services ,
258294 IConfiguration configuration
259295 )
260296 {
297+ Console . WriteLine ( "Configuring Database..." ) ;
261298 bool useInMemoryDatabase = configuration
262299 . GetSection ( "Database" )
263300 . GetValue < bool > ( "UseInMemoryDatabase" ) ;
264301
265302 if ( useInMemoryDatabase )
266303 {
304+ Console . WriteLine ( "Using InMemory Database" ) ;
267305 DbContextOptionsBuilder dbBuilder = new DbContextOptionsBuilder < SaraDbContext > ( ) ;
268306 string sqlConnectionString = new SqliteConnectionStringBuilder
269307 {
@@ -290,24 +328,231 @@ IConfiguration configuration
290328 }
291329 else
292330 {
293- string ? connection = configuration [ "Database:postgresConnectionString" ] ;
294- // Setting splitting behavior explicitly to avoid warning
295- services . AddDbContext < SaraDbContext > (
296- options =>
297- options . UseNpgsql (
298- connection ,
299- o =>
300- {
301- o . UseQuerySplittingBehavior ( QuerySplittingBehavior . SingleQuery ) ;
302- o . EnableRetryOnFailure ( ) ;
303- }
304- ) ,
305- ServiceLifetime . Transient
331+ string [ ] allowedDbAuthMethods =
332+ configuration . GetSection ( "Database:AllowedAuthMethods" ) . Get < string [ ] > ( ) ?? [ ] ;
333+ if ( allowedDbAuthMethods . Length == 0 )
334+ {
335+ allowedDbAuthMethods = [ "ConnectionString" ] ;
336+ }
337+
338+ Console . WriteLine (
339+ $ "Database auth methods to try (in order): { string . Join ( ", " , allowedDbAuthMethods ) } "
306340 ) ;
341+
342+ var errors = new List < ( string method , Exception ex ) > ( ) ;
343+ bool configured = false ;
344+
345+ foreach ( string method in allowedDbAuthMethods )
346+ {
347+ if ( configured )
348+ break ;
349+
350+ if ( string . Equals ( method , "AppRegIdentity" , StringComparison . OrdinalIgnoreCase ) )
351+ {
352+ try
353+ {
354+ Console . WriteLine (
355+ "Trying AppRegIdentity (Entra ID token) for PostgreSQL..."
356+ ) ;
357+ ConfigureDatabaseWithAppRegIdentity ( services , configuration ) ;
358+ Console . WriteLine ( "AppRegIdentity configured successfully." ) ;
359+ configured = true ;
360+ }
361+ catch ( Exception ex )
362+ {
363+ Console . WriteLine (
364+ $ "AppRegIdentity failed: { ex . GetType ( ) . Name } : { ex . Message } "
365+ ) ;
366+ errors . Add ( ( "AppRegIdentity" , ex ) ) ;
367+ }
368+ }
369+ else if (
370+ string . Equals ( method , "ConnectionString" , StringComparison . OrdinalIgnoreCase )
371+ )
372+ {
373+ try
374+ {
375+ Console . WriteLine (
376+ "Trying ConnectionString (Key Vault) for PostgreSQL..."
377+ ) ;
378+ ConfigureDatabaseWithConnectionString ( services , configuration ) ;
379+ Console . WriteLine ( "ConnectionString configured successfully." ) ;
380+ configured = true ;
381+ }
382+ catch ( Exception ex )
383+ {
384+ Console . WriteLine (
385+ $ "ConnectionString failed: { ex . GetType ( ) . Name } : { ex . Message } "
386+ ) ;
387+ errors . Add ( ( "ConnectionString" , ex ) ) ;
388+ }
389+ }
390+ else
391+ {
392+ Console . WriteLine (
393+ $ "Unknown database auth method '{ method } ' in Database:AllowedAuthMethods; "
394+ + "expected 'AppRegIdentity' or 'ConnectionString'."
395+ ) ;
396+ }
397+ }
398+
399+ if ( ! configured )
400+ {
401+ var summary = string . Join (
402+ "; " ,
403+ errors . Select ( e => $ "{ e . method } : { e . ex . GetType ( ) . Name } : { e . ex . Message } ")
404+ ) ;
405+ throw new InvalidOperationException (
406+ "All database authentication methods failed. "
407+ + $ "Tried: { string . Join ( ", " , allowedDbAuthMethods ) } . Details: { summary } "
408+ ) ;
409+ }
307410 }
411+
308412 return services ;
309413 }
310414
415+ /// <summary>
416+ /// Configure PostgreSQL using Entra ID (Azure AD) token-based authentication via the
417+ /// app registration identity. The token is used as the PostgreSQL password and refreshed
418+ /// periodically via <c>UsePeriodicPasswordProvider</c>.
419+ /// </summary>
420+ private static void ConfigureDatabaseWithAppRegIdentity (
421+ IServiceCollection services ,
422+ IConfiguration configuration
423+ )
424+ {
425+ var server =
426+ configuration [ "Database:Server" ]
427+ ?? throw new InvalidOperationException (
428+ "Database:Server is required for AppRegIdentity auth."
429+ ) ;
430+ var postgresDb =
431+ configuration [ "Database:PostgresDatabase" ]
432+ ?? throw new InvalidOperationException (
433+ "Database:PostgresDatabase is required for AppRegIdentity auth."
434+ ) ;
435+ var dbUser =
436+ configuration [ "Database:User" ]
437+ ?? throw new InvalidOperationException (
438+ "Database:User is required for AppRegIdentity auth."
439+ ) ;
440+
441+ var credential = CreateRuntimeCredential ( configuration ) ;
442+
443+ // Probe: acquire an initial token to verify connectivity before registering the DbContext.
444+ Console . WriteLine ( "Requesting Entra ID token via credential..." ) ;
445+ var tokenRequestContext = new TokenRequestContext ( [ AzurePostgresScope ] ) ;
446+ using var cts = new CancellationTokenSource ( TimeSpan . FromSeconds ( 3 ) ) ;
447+ AccessToken token ;
448+ try
449+ {
450+ token = credential . GetToken ( tokenRequestContext , cts . Token ) ;
451+ }
452+ catch ( OperationCanceledException oce )
453+ {
454+ throw new TimeoutException (
455+ "Timed out acquiring Entra ID token for PostgreSQL." ,
456+ oce
457+ ) ;
458+ }
459+ Console . WriteLine ( "Entra ID token acquired successfully." ) ;
460+
461+ var baseConnString = new NpgsqlConnectionStringBuilder
462+ {
463+ Host = $ "{ server } .postgres.database.azure.com",
464+ Database = postgresDb ,
465+ Username = dbUser ,
466+ SslMode = SslMode . VerifyFull ,
467+ } . ToString ( ) ;
468+
469+ int databaseTimeout = GetDatabaseTimeout ( configuration ) ;
470+
471+ services . AddDbContext < SaraDbContext > (
472+ options =>
473+ options . UseNpgsql (
474+ baseConnString ,
475+ o =>
476+ {
477+ o . ConfigureDataSource ( ds =>
478+ {
479+ var dsCredential = CreateRuntimeCredential ( configuration ) ;
480+ ds . UsePeriodicPasswordProvider (
481+ async ( _ , ct ) =>
482+ {
483+ using var tokenCts = new CancellationTokenSource (
484+ TimeSpan . FromSeconds ( 5 )
485+ ) ;
486+ var accessToken = await dsCredential . GetTokenAsync (
487+ new TokenRequestContext ( [ AzurePostgresScope ] ) ,
488+ CancellationTokenSource
489+ . CreateLinkedTokenSource ( ct , tokenCts . Token )
490+ . Token
491+ ) ;
492+ return accessToken . Token ;
493+ } ,
494+ successRefreshInterval : TimeSpan . FromMinutes ( 55 ) ,
495+ failureRefreshInterval : TimeSpan . FromSeconds ( 5 )
496+ ) ;
497+ } ) ;
498+ o . UseQuerySplittingBehavior ( QuerySplittingBehavior . SingleQuery ) ;
499+ o . EnableRetryOnFailure ( ) ;
500+ o . CommandTimeout ( databaseTimeout ) ;
501+ }
502+ ) ,
503+ ServiceLifetime . Transient
504+ ) ;
505+ }
506+
507+ /// <summary>
508+ /// Configure PostgreSQL using a traditional connection string (typically loaded from
509+ /// Azure Key Vault via the <c>Database:postgresConnectionString</c> configuration key).
510+ /// </summary>
511+ private static void ConfigureDatabaseWithConnectionString (
512+ IServiceCollection services ,
513+ IConfiguration configuration
514+ )
515+ {
516+ string ? connection = configuration [ "Database:postgresConnectionString" ] ;
517+ if ( string . IsNullOrEmpty ( connection ) )
518+ {
519+ throw new InvalidOperationException (
520+ "Database:postgresConnectionString is empty or missing. "
521+ + "Ensure the connection string is loaded (e.g. from Azure Key Vault)."
522+ ) ;
523+ }
524+
525+ int databaseTimeout = GetDatabaseTimeout ( configuration ) ;
526+
527+ // Setting splitting behavior explicitly to avoid warning
528+ services . AddDbContext < SaraDbContext > (
529+ options =>
530+ options . UseNpgsql (
531+ connection ,
532+ o =>
533+ {
534+ o . UseQuerySplittingBehavior ( QuerySplittingBehavior . SingleQuery ) ;
535+ o . EnableRetryOnFailure ( ) ;
536+ o . CommandTimeout ( databaseTimeout ) ;
537+ }
538+ ) ,
539+ ServiceLifetime . Transient
540+ ) ;
541+ }
542+
543+ private static int GetDatabaseTimeout ( IConfiguration configuration )
544+ {
545+ var timeoutValue = configuration [ "Database:Timeout" ] ;
546+ if (
547+ ! string . IsNullOrEmpty ( timeoutValue )
548+ && int . TryParse ( timeoutValue , out var parsedTimeout )
549+ )
550+ {
551+ return parsedTimeout ;
552+ }
553+ return 30 ;
554+ }
555+
311556 public static IServiceCollection ConfigureSwagger (
312557 this IServiceCollection services ,
313558 IConfiguration configuration
0 commit comments