Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/D2L.Bmx/BmxConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ internal record BmxConfig(
string? Account,
string? Role,
string? Profile,
int? Duration
int? Duration,
int? PasswordlessTimeout
);
17 changes: 16 additions & 1 deletion src/D2L.Bmx/BmxConfigProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

Expand All @@ -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 );
Expand Down
8 changes: 7 additions & 1 deletion src/D2L.Bmx/ConfigureHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public void Handle(
string? org,
string? user,
int? duration,
int? passwordlessTimeout,
bool nonInteractive
) {

Expand All @@ -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." );
Expand Down
14 changes: 14 additions & 0 deletions src/D2L.Bmx/ConsolePrompter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -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" );
Expand Down
6 changes: 4 additions & 2 deletions src/D2L.Bmx/LoginHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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." );
}
Expand Down
58 changes: 36 additions & 22 deletions src/D2L.Bmx/OktaAuthenticator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ public async Task<OktaAuthenticatedContext> 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 ) ) {
Expand Down Expand Up @@ -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" );
Expand Down Expand Up @@ -115,12 +127,13 @@ private bool TryAuthenticateFromCache(
private async Task<IOktaAuthenticatedClient?> 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}" );
Expand Down Expand Up @@ -158,20 +171,21 @@ The provided Okta user '{providedLogin}' does not match the system configured pa
return oktaAuthenticatedClient;
}

private async Task<string?> GetSessionIdFromBrowserAsync( string browserPath, Uri orgUrl ) {
private async Task<string?> GetSessionIdFromBrowserAsync( string browserPath, Uri orgUrl, int timeoutSeconds ) {
if( BmxEnvironment.IsDebug ) {
messageWriter.WriteWarning( $"Launching browser: {browserPath}" );
}
await using var browser = await browserLauncher.LaunchAsync( browserPath );

var sessionIdTcs = new TaskCompletionSource<string?>( 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 the first page within a derived timeout
using var pageTimer = new System.Timers.Timer(
TimeSpan.FromSeconds( Math.Max( 6, timeoutSeconds / 2.0 ) ) ) { AutoReset = false };
pageTimer.Elapsed += ( _, _ ) => cancellationTokenSource.Cancel();
pageTimer.Start();

Expand All @@ -194,7 +208,7 @@ async Task OnPageLoadAsync() {
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.Interval = Math.Max( 3, timeoutSeconds / 2.0 ) * 1000;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thinking about these page timeouts again...
I wonder if the 6 vs 3 timeouts minimums are even meaningful now.
Can maybe just set a single page that's half the total timeout when the Timer is created, and not change the Interval here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure I've got it now to just always be set to half the total timeout

pageTimer.Start();
}

Expand Down
2 changes: 2 additions & 0 deletions src/D2L.Bmx/ParameterDescriptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions src/D2L.Bmx/PrintHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 24 additions & 3 deletions src/D2L.Bmx/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,24 @@
name: "--user",
description: ParameterDescriptions.User );

var passwordlessTimeoutOption = new Option<int?>(
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();
Expand All @@ -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 )
);
} );

Expand Down Expand Up @@ -64,6 +79,7 @@
orgOption,
userOption,
durationOption,
passwordlessTimeoutOption,
nonInteractiveOption,
};

Expand All @@ -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;
Expand Down Expand Up @@ -120,6 +137,7 @@
userOption,
nonInteractiveOption,
cacheAwsCredentialsOption,
passwordlessTimeoutOption,
};

printCommand.SetHandler( ( InvocationContext context ) => {
Expand Down Expand Up @@ -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 )
);
} );

Expand All @@ -175,6 +194,7 @@
nonInteractiveOption,
cacheAwsCredentialsOption,
useCredentialProcessOption,
passwordlessTimeoutOption,
};

writeCommand.SetHandler( ( InvocationContext context ) => {
Expand Down Expand Up @@ -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 )
);
} );

Expand Down
6 changes: 4 additions & 2 deletions src/D2L.Bmx/WriteHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,17 @@ public async Task HandleAsync(
string? output,
string? profile,
bool cacheAwsCredentials,
bool useCredentialProcess
bool useCredentialProcess,
int? passwordlessTimeout
) {
cacheAwsCredentials = cacheAwsCredentials || useCredentialProcess;

var oktaContext = await oktaAuth.AuthenticateAsync(
org: org,
user: user,
nonInteractive: nonInteractive,
ignoreCache: false
ignoreCache: false,
passwordlessTimeout: passwordlessTimeout
);
var awsCredsInfo = await awsCredsCreator.CreateAwsCredsAsync(
okta: oktaContext,
Expand Down
Loading