diff --git a/src/D2L.Bmx/BmxConfig.cs b/src/D2L.Bmx/BmxConfig.cs index c4b310b9..e5a644f4 100644 --- a/src/D2L.Bmx/BmxConfig.cs +++ b/src/D2L.Bmx/BmxConfig.cs @@ -6,5 +6,6 @@ internal record BmxConfig( string? Account, string? Role, string? Profile, - int? Duration + int? Duration, + int? PasswordlessTimeout ); diff --git a/src/D2L.Bmx/BmxConfigProvider.cs b/src/D2L.Bmx/BmxConfigProvider.cs index e62c2030..c48b4f03 100644 --- a/src/D2L.Bmx/BmxConfigProvider.cs +++ b/src/D2L.Bmx/BmxConfigProvider.cs @@ -44,13 +44,25 @@ public BmxConfig GetConfiguration() { duration = configDuration; } + int? passwordlessTimeout = null; + if( !string.IsNullOrEmpty( data.Global["passwordlessTimeout"] ) ) { + if( !int.TryParse( data.Global["passwordlessTimeout"], out int configTimeout ) + || configTimeout < 0 ) { + throw new BmxException( + "Invalid passwordlessTimeout in config." + + " Must be 0 (to disable passwordless authentication) or a positive number of seconds." ); + } + passwordlessTimeout = configTimeout; + } + return new BmxConfig( Org: data.Global["org"], User: data.Global["user"], Account: data.Global["account"], Role: data.Global["role"], Profile: data.Global["profile"], - Duration: duration + Duration: duration, + PasswordlessTimeout: passwordlessTimeout ); } @@ -75,6 +87,9 @@ public void SaveConfiguration( BmxConfig config ) { if( config.Duration.HasValue ) { data.Global["duration"] = $"{config.Duration}"; } + if( config.PasswordlessTimeout.HasValue ) { + data.Global["passwordlessTimeout"] = $"{config.PasswordlessTimeout}"; + } fs.Position = 0; fs.SetLength( 0 ); diff --git a/src/D2L.Bmx/ConfigureHandler.cs b/src/D2L.Bmx/ConfigureHandler.cs index c57ad806..b828524d 100644 --- a/src/D2L.Bmx/ConfigureHandler.cs +++ b/src/D2L.Bmx/ConfigureHandler.cs @@ -9,6 +9,7 @@ public void Handle( string? org, string? user, int? duration, + int? passwordlessTimeout, bool nonInteractive ) { @@ -24,13 +25,18 @@ bool nonInteractive duration = consolePrompter.PromptDuration(); } + if( passwordlessTimeout is null && !nonInteractive ) { + passwordlessTimeout = consolePrompter.PromptPasswordlessTimeout(); + } + BmxConfig config = new( Org: org, User: user, Account: null, Role: null, Profile: null, - Duration: duration + Duration: duration, + PasswordlessTimeout: passwordlessTimeout ); configProvider.SaveConfiguration( config ); Console.WriteLine( "Your configuration has been created. Okta sessions will now also be cached." ); diff --git a/src/D2L.Bmx/ConsolePrompter.cs b/src/D2L.Bmx/ConsolePrompter.cs index 5bd82dee..87ebff9c 100644 --- a/src/D2L.Bmx/ConsolePrompter.cs +++ b/src/D2L.Bmx/ConsolePrompter.cs @@ -9,6 +9,7 @@ internal interface IConsolePrompter { string PromptUser( bool allowEmptyInput ); string PromptPassword(); int? PromptDuration(); + int? PromptPasswordlessTimeout(); string PromptAccount( string[] accounts ); string PromptRole( string[] roles ); OktaMfaFactor SelectMfa( OktaMfaFactor[] mfaOptions ); @@ -63,6 +64,19 @@ string IConsolePrompter.PromptPassword() { return duration; } + int? IConsolePrompter.PromptPasswordlessTimeout() { + Console.Error.Write( $"{ParameterDescriptions.PasswordlessTimeout} " + + "(optional, 0 to disable passwordless authentication, default: 30): " ); + string? input = Console.ReadLine(); + if( input is null || string.IsNullOrWhiteSpace( input ) ) { + return null; + } + if( int.TryParse( input, out int timeout ) && timeout >= 0 ) { + return timeout; + } + return null; + } + string IConsolePrompter.PromptAccount( string[] accounts ) { if( accounts.Length == 0 ) { throw new BmxException( "No AWS account available" ); diff --git a/src/D2L.Bmx/LoginHandler.cs b/src/D2L.Bmx/LoginHandler.cs index df0c253d..d6261968 100644 --- a/src/D2L.Bmx/LoginHandler.cs +++ b/src/D2L.Bmx/LoginHandler.cs @@ -5,7 +5,8 @@ OktaAuthenticator oktaAuth ) { public async Task HandleAsync( string? org, - string? user + string? user, + int? passwordlessTimeout ) { if( !File.Exists( BmxPaths.CONFIG_FILE_NAME ) ) { throw new BmxException( @@ -16,7 +17,8 @@ await oktaAuth.AuthenticateAsync( org, user, nonInteractive: false, - ignoreCache: true + ignoreCache: true, + passwordlessTimeout: passwordlessTimeout ); Console.WriteLine( "Successfully logged in and Okta session has been cached." ); } diff --git a/src/D2L.Bmx/OktaAuthenticator.cs b/src/D2L.Bmx/OktaAuthenticator.cs index 92242a8f..b0fc889f 100644 --- a/src/D2L.Bmx/OktaAuthenticator.cs +++ b/src/D2L.Bmx/OktaAuthenticator.cs @@ -23,7 +23,8 @@ public async Task AuthenticateAsync( string? org, string? user, bool nonInteractive, - bool ignoreCache + bool ignoreCache, + int? passwordlessTimeout ) { var orgSource = ParameterSource.CliArg; if( string.IsNullOrEmpty( org ) && !string.IsNullOrEmpty( config.Org ) ) { @@ -65,19 +66,30 @@ bool ignoreCache OperatingSystem.IsWindows() && browserLauncher.TryGetPathToBrowser( out string? browserPath ) ) { - if( !nonInteractive ) { - Console.Error.WriteLine( "Attempting Okta passwordless authentication..." ); - } - oktaAuthenticated = await GetDssoAuthenticatedClientAsync( - orgUrl, - user, - browserPath - ); - if( oktaAuthenticated is not null ) { - return new( Org: org, User: user, Client: oktaAuthenticated ); - } - if( !nonInteractive ) { - Console.Error.WriteLine( "Falling back to Okta password authentication..." ); + int resolvedTimeout = passwordlessTimeout + ?? config.PasswordlessTimeout + ?? 30; + + if( resolvedTimeout == 0 ) { + if( BmxEnvironment.IsDebug ) { + messageWriter.WriteWarning( "Okta passwordless authentication disabled via configuration" ); + } + } else { + if( !nonInteractive ) { + Console.Error.WriteLine( "Attempting Okta passwordless authentication..." ); + } + oktaAuthenticated = await GetDssoAuthenticatedClientAsync( + orgUrl, + user, + browserPath, + resolvedTimeout + ); + if( oktaAuthenticated is not null ) { + return new( Org: org, User: user, Client: oktaAuthenticated ); + } + if( !nonInteractive ) { + Console.Error.WriteLine( "Falling back to Okta password authentication..." ); + } } } else if( BmxEnvironment.IsDebug ) { messageWriter.WriteWarning( "No suitable browser found for Okta passwordless authentication" ); @@ -115,12 +127,13 @@ private bool TryAuthenticateFromCache( private async Task GetDssoAuthenticatedClientAsync( Uri orgUrl, string user, - string browserPath + string browserPath, + int timeoutSeconds ) { string? sessionId = null; try { - sessionId = await GetSessionIdFromBrowserAsync( browserPath, orgUrl ); + sessionId = await GetSessionIdFromBrowserAsync( browserPath, orgUrl, timeoutSeconds ); } catch( TaskCanceledException ex ) { if( BmxEnvironment.IsDebug ) { messageWriter.WriteWarning( $"Okta passwordless authentication timed out. \n{ex}" ); @@ -158,7 +171,7 @@ The provided Okta user '{providedLogin}' does not match the system configured pa return oktaAuthenticatedClient; } - private async Task GetSessionIdFromBrowserAsync( string browserPath, Uri orgUrl ) { + private async Task GetSessionIdFromBrowserAsync( string browserPath, Uri orgUrl, int timeoutSeconds ) { if( BmxEnvironment.IsDebug ) { messageWriter.WriteWarning( $"Launching browser: {browserPath}" ); } @@ -166,12 +179,13 @@ The provided Okta user '{providedLogin}' does not match the system configured pa var sessionIdTcs = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously ); - // cancel if the total time exceeds 15 seconds, including all page loads and retries - using var cancellationTokenSource = new CancellationTokenSource( TimeSpan.FromSeconds( 15 ) ); + // cancel if the total time exceeds the configured timeout, including all page loads and retries + using var cancellationTokenSource = new CancellationTokenSource( TimeSpan.FromSeconds( timeoutSeconds ) ); cancellationTokenSource.Token.Register( () => sessionIdTcs.TrySetCanceled() ); - // cancel if we can't load the first page for 6 seconds - using var pageTimer = new System.Timers.Timer( TimeSpan.FromSeconds( 6 ) ) { AutoReset = false }; + // cancel if we can't load a page within half the total timeout + using var pageTimer = new System.Timers.Timer( + TimeSpan.FromSeconds( timeoutSeconds / 2.0 ) ) { AutoReset = false }; pageTimer.Elapsed += ( _, _ ) => cancellationTokenSource.Cancel(); pageTimer.Start(); @@ -193,8 +207,6 @@ async Task OnPageLoadAsync() { // reset the per-page timer on every page load lock( pageTimer ) { pageTimer.Stop(); - // we give the first page 6 sec to load, but 3 sec is probably enough for subsequent pages - pageTimer.Interval = 3000; pageTimer.Start(); } diff --git a/src/D2L.Bmx/ParameterDescriptions.cs b/src/D2L.Bmx/ParameterDescriptions.cs index be7f4bc9..5850a092 100644 --- a/src/D2L.Bmx/ParameterDescriptions.cs +++ b/src/D2L.Bmx/ParameterDescriptions.cs @@ -14,6 +14,8 @@ internal static class ParameterDescriptions { public const string NonInteractive = "Run non-interactively without showing any prompts"; public const string CacheAwsCredentials = "Enables Cache for AWS tokens. Implied if '--use-credential-process' is supplied"; + public const string PasswordlessTimeout = + "Timeout for Okta passwordless authentication in seconds"; public const string UseCredentialProcess = """ Write BMX command to AWS profile, so that AWS tools & SDKs using the profile will source credentials from BMX. See https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html. diff --git a/src/D2L.Bmx/PrintHandler.cs b/src/D2L.Bmx/PrintHandler.cs index 15f736fc..815df011 100644 --- a/src/D2L.Bmx/PrintHandler.cs +++ b/src/D2L.Bmx/PrintHandler.cs @@ -14,13 +14,15 @@ public async Task HandleAsync( int? duration, bool nonInteractive, string? format, - bool cacheAwsCredentials + bool cacheAwsCredentials, + int? passwordlessTimeout ) { var oktaContext = await oktaAuth.AuthenticateAsync( org: org, user: user, nonInteractive: nonInteractive, - ignoreCache: false + ignoreCache: false, + passwordlessTimeout: passwordlessTimeout ); var awsCreds = ( await awsCredsCreator.CreateAwsCredsAsync( okta: oktaContext, diff --git a/src/D2L.Bmx/Program.cs b/src/D2L.Bmx/Program.cs index ee0d4318..01c8c78d 100644 --- a/src/D2L.Bmx/Program.cs +++ b/src/D2L.Bmx/Program.cs @@ -18,10 +18,24 @@ name: "--user", description: ParameterDescriptions.User ); +var passwordlessTimeoutOption = new Option( + name: "--passwordless-timeout", + description: ParameterDescriptions.PasswordlessTimeout ); + +passwordlessTimeoutOption.AddValidator( result => { + if( result.Tokens is [Token token, ..] + && int.TryParse( token.Value, out int timeout ) + && timeout < 0 ) { + result.ErrorMessage + = "Passwordless timeout must be 0 (passwordless authentication disabled) or a positive number of seconds"; + } +} ); + // bmx login var loginCommand = new Command( "login", "Log into Okta and save an Okta session" ) { orgOption, userOption, + passwordlessTimeoutOption, }; loginCommand.SetHandler( ( InvocationContext context ) => { var messageWriter = new MessageWriter(); @@ -36,7 +50,8 @@ ) ); return handler.HandleAsync( org: context.ParseResult.GetValueForOption( orgOption ), - user: context.ParseResult.GetValueForOption( userOption ) + user: context.ParseResult.GetValueForOption( userOption ), + passwordlessTimeout: context.ParseResult.GetValueForOption( passwordlessTimeoutOption ) ); } ); @@ -64,6 +79,7 @@ orgOption, userOption, durationOption, + passwordlessTimeoutOption, nonInteractiveOption, }; @@ -75,6 +91,7 @@ org: context.ParseResult.GetValueForOption( orgOption ), user: context.ParseResult.GetValueForOption( userOption ), duration: context.ParseResult.GetValueForOption( durationOption ), + passwordlessTimeout: context.ParseResult.GetValueForOption( passwordlessTimeoutOption ), nonInteractive: context.ParseResult.GetValueForOption( nonInteractiveOption ) ); return Task.CompletedTask; @@ -120,6 +137,7 @@ userOption, nonInteractiveOption, cacheAwsCredentialsOption, + passwordlessTimeoutOption, }; printCommand.SetHandler( ( InvocationContext context ) => { @@ -149,7 +167,8 @@ duration: context.ParseResult.GetValueForOption( durationOption ), nonInteractive: context.ParseResult.GetValueForOption( nonInteractiveOption ), format: context.ParseResult.GetValueForOption( formatOption ), - cacheAwsCredentials: context.ParseResult.GetValueForOption( cacheAwsCredentialsOption ) + cacheAwsCredentials: context.ParseResult.GetValueForOption( cacheAwsCredentialsOption ), + passwordlessTimeout: context.ParseResult.GetValueForOption( passwordlessTimeoutOption ) ); } ); @@ -175,6 +194,7 @@ nonInteractiveOption, cacheAwsCredentialsOption, useCredentialProcessOption, + passwordlessTimeoutOption, }; writeCommand.SetHandler( ( InvocationContext context ) => { @@ -210,7 +230,8 @@ output: context.ParseResult.GetValueForOption( outputOption ), profile: context.ParseResult.GetValueForOption( profileOption ), cacheAwsCredentials: context.ParseResult.GetValueForOption( cacheAwsCredentialsOption ), - useCredentialProcess: context.ParseResult.GetValueForOption( useCredentialProcessOption ) + useCredentialProcess: context.ParseResult.GetValueForOption( useCredentialProcessOption ), + passwordlessTimeout: context.ParseResult.GetValueForOption( passwordlessTimeoutOption ) ); } ); diff --git a/src/D2L.Bmx/WriteHandler.cs b/src/D2L.Bmx/WriteHandler.cs index 2c2d395b..36a00230 100644 --- a/src/D2L.Bmx/WriteHandler.cs +++ b/src/D2L.Bmx/WriteHandler.cs @@ -27,7 +27,8 @@ public async Task HandleAsync( string? output, string? profile, bool cacheAwsCredentials, - bool useCredentialProcess + bool useCredentialProcess, + int? passwordlessTimeout ) { cacheAwsCredentials = cacheAwsCredentials || useCredentialProcess; @@ -35,7 +36,8 @@ bool useCredentialProcess org: org, user: user, nonInteractive: nonInteractive, - ignoreCache: false + ignoreCache: false, + passwordlessTimeout: passwordlessTimeout ); var awsCredsInfo = await awsCredsCreator.CreateAwsCredsAsync( okta: oktaContext,