Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Check for elevated permissions with automatic login #482

Merged
merged 50 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
e9f18ac
okta: add dsso authentication
gord5500 Sep 17, 2024
a16a9e9
add cancellation token to newpageasync
gord5500 Sep 17, 2024
6fc7baa
tweak warning messages
gord5500 Sep 17, 2024
73d0cdd
Update src/D2L.Bmx/OktaAuthenticator.cs
gord5500 Sep 17, 2024
c2f13bd
var name tweak
gord5500 Sep 17, 2024
19fa544
tweak warning message for non matching user
gord5500 Sep 17, 2024
4f177b7
mend
gord5500 Sep 17, 2024
aedb835
change reload signin page
gord5500 Sep 17, 2024
c14de8e
add passwordless option that defaults to false
gord5500 Sep 17, 2024
77a91c4
mend
gord5500 Sep 17, 2024
1b8cfd2
tweak warning message
gord5500 Sep 17, 2024
1a8ba2e
set browser as headless based on bmx_debug env variable
gord5500 Sep 17, 2024
b526dbe
readd path check
gord5500 Sep 17, 2024
d502f1d
default browser to edge for windows
gord5500 Sep 17, 2024
28b320a
headless is always true regardless of bmx_debug
gord5500 Sep 17, 2024
7633b0f
make no-sandbox option scarier
gord5500 Sep 17, 2024
185b8ff
abort if not on vpn
gord5500 Sep 17, 2024
d9fe294
normalize okta org and check users route
gord5500 Sep 17, 2024
0080063
remove passwordless flag
gord5500 Sep 17, 2024
ac51994
mend
gord5500 Sep 17, 2024
ff3f8e1
adjust parameter name for experimental
gord5500 Sep 17, 2024
551f6cf
rename okta session function
gord5500 Sep 17, 2024
64f5efb
redo org check
gord5500 Sep 18, 2024
40171e4
Update src/D2L.Bmx/Browser.cs
gord5500 Sep 18, 2024
a2f903a
nits
gord5500 Sep 18, 2024
88f7517
more nits
gord5500 Sep 19, 2024
91d52b6
readd user email strip check
gord5500 Sep 19, 2024
684f1bd
mend
gord5500 Sep 19, 2024
81a3714
mend
gord5500 Sep 19, 2024
d48c7db
deal in uris instread of string for orgs
gord5500 Sep 19, 2024
7a68852
remove OktaHomeResponse model
gord5500 Sep 19, 2024
6dba5c1
rename to orgUrl
gord5500 Sep 19, 2024
f46d800
dont mention sso
gord5500 Sep 20, 2024
fa1fd6d
simplify login name check
gord5500 Sep 20, 2024
fc17c78
Update src/D2L.Bmx/ParameterDescriptions.cs
gord5500 Sep 24, 2024
e479bc8
Update src/D2L.Bmx/OktaAuthenticator.cs
gord5500 Sep 24, 2024
d87d349
Update src/D2L.Bmx/OktaAuthenticator.cs
gord5500 Sep 24, 2024
754699e
Update src/D2L.Bmx/OktaAuthenticator.cs
gord5500 Sep 24, 2024
1054c06
Update src/D2L.Bmx/OktaAuthenticator.cs
gord5500 Sep 24, 2024
7ec3833
don't pass the client factory
gord5500 Sep 24, 2024
de1e41f
check elevated permissions on experimental flag
gord5500 Sep 24, 2024
96a9e7d
scarier message
gord5500 Sep 24, 2024
96177a7
fix libc call
ArckosLiam Sep 24, 2024
5b05ca9
format
gord5500 Sep 24, 2024
afef184
wording
gord5500 Sep 24, 2024
c956f2a
don't kill on bad setup
gord5500 Sep 24, 2024
9d717e8
mend
gord5500 Sep 24, 2024
e47e88b
mend
gord5500 Sep 24, 2024
7063367
Merge branch 'main' into check_for_elevated_permission
gord5500 Sep 24, 2024
521a53f
reword the warning message
gord5500 Sep 24, 2024
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
62 changes: 62 additions & 0 deletions src/D2L.Bmx/Browser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using PuppeteerSharp;

namespace D2L.Bmx;

public static class Browser {

// https://github.com/microsoft/playwright/blob/6763d5ab6bd20f1f0fc879537855a26c7644a496/packages/playwright-core/src/server/registry/index.ts#L630
private static readonly string[] WindowsEnvironmentVariables = [
"LOCALAPPDATA",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
];

// https://github.com/microsoft/playwright/blob/6763d5ab6bd20f1f0fc879537855a26c7644a496/packages/playwright-core/src/server/registry/index.ts#L457-L459
private static readonly string[] WindowsPartialPaths = [
"\\Microsoft\\Edge\\Application\\msedge.exe",
"\\Google\\Chrome\\Application\\chrome.exe",
];
private static readonly string[] MacPaths = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
];
private static readonly string[] LinuxPaths = [
"/opt/google/chrome/chrome",
"/opt/microsoft/msedge/msedge",
];

public static async Task<IBrowser?> LaunchBrowserAsync( bool noSandbox = false ) {
string? browserPath = GetPathToBrowser();
if( browserPath is null ) {
return null;
}

var launchOptions = new LaunchOptions {
ExecutablePath = browserPath,
Args = noSandbox ? ["--no-sandbox"] : []
};

return await Puppeteer.LaunchAsync( launchOptions );
}

private static string? GetPathToBrowser() {
if( OperatingSystem.IsWindows() ) {
foreach( string windowsPartialPath in WindowsPartialPaths ) {
foreach( string environmentVariable in WindowsEnvironmentVariables ) {
string? prefix = Environment.GetEnvironmentVariable( environmentVariable );
if( prefix is not null ) {
string path = prefix + windowsPartialPath;
if( File.Exists( path ) ) {
return path;
}
}
}
}
} else if( OperatingSystem.IsMacOS() ) {
return MacPaths.First( File.Exists );
} else if( OperatingSystem.IsLinux() ) {
return LinuxPaths.First( File.Exists );
}
return null;
}
}
1 change: 1 addition & 0 deletions src/D2L.Bmx/D2L.Bmx.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<PackageReference Include="AWSSDK.SecurityToken" Version="3.7.300.35" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.57" />
<PackageReference Include="ini-parser-netstandard" Version="2.5.2" />
<PackageReference Include="PuppeteerSharp" Version="20.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>

Expand Down
11 changes: 9 additions & 2 deletions src/D2L.Bmx/LoginHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,21 @@ OktaAuthenticator oktaAuth
) {
public async Task HandleAsync(
string? org,
string? user
string? user,
bool bypassBrowserSecurity
) {
if( !File.Exists( BmxPaths.CONFIG_FILE_NAME ) ) {
throw new BmxException(
"BMX global config file not found. Okta sessions will not be saved. Please run `bmx configure` first."
);
}
await oktaAuth.AuthenticateAsync( org, user, nonInteractive: false, ignoreCache: true );
await oktaAuth.AuthenticateAsync(
org,
user,
nonInteractive: false,
ignoreCache: true,
bypassBrowserSecurity: bypassBrowserSecurity
);
Console.WriteLine( "Successfully logged in and Okta session has been cached." );
}
}
1 change: 1 addition & 0 deletions src/D2L.Bmx/Okta/Models/OktaSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ namespace D2L.Bmx.Okta.Models;

internal record OktaSession(
string Id,
string Login,
string UserId,
DateTimeOffset CreatedAt,
DateTimeOffset ExpiresAt
Expand Down
32 changes: 22 additions & 10 deletions src/D2L.Bmx/Okta/OktaClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
namespace D2L.Bmx.Okta;

internal interface IOktaClientFactory {
IOktaAnonymousClient CreateAnonymousClient( string org );
IOktaAuthenticatedClient CreateAuthenticatedClient( string org, string sessionId );
IOktaAnonymousClient CreateAnonymousClient( Uri orgUrl );
IOktaAuthenticatedClient CreateAuthenticatedClient( Uri orgUrl, string sessionId );
}

internal interface IOktaAnonymousClient {
Expand All @@ -23,20 +23,21 @@ string challengeResponse

internal interface IOktaAuthenticatedClient {
Task<OktaApp[]> GetAwsAccountAppsAsync();
Task<OktaSession> GetCurrentOktaSessionAsync();
Task<string> GetPageAsync( string url );
}

internal class OktaClientFactory : IOktaClientFactory {
IOktaAnonymousClient IOktaClientFactory.CreateAnonymousClient( string org ) {
IOktaAnonymousClient IOktaClientFactory.CreateAnonymousClient( Uri orgUrl ) {
var httpClient = new HttpClient {
Timeout = TimeSpan.FromSeconds( 30 ),
BaseAddress = GetBaseAddress( org ),
BaseAddress = GetApiBaseAddress( orgUrl ),
};
return new OktaAnonymousClient( httpClient );
}

IOktaAuthenticatedClient IOktaClientFactory.CreateAuthenticatedClient( string org, string sessionId ) {
var baseAddress = GetBaseAddress( org );
IOktaAuthenticatedClient IOktaClientFactory.CreateAuthenticatedClient( Uri orgUrl, string sessionId ) {
var baseAddress = GetApiBaseAddress( orgUrl );

var cookieContainer = new CookieContainer();
cookieContainer.Add( new Cookie( "sid", sessionId, "/", baseAddress.Host ) );
Expand All @@ -51,10 +52,8 @@ IOktaAuthenticatedClient IOktaClientFactory.CreateAuthenticatedClient( string or
return new OktaAuthenticatedClient( httpClient );
}

private static Uri GetBaseAddress( string org ) {
return org.Contains( '.' )
? new Uri( $"https://{org}/api/v1/" )
: new Uri( $"https://{org}.okta.com/api/v1/" );
private static Uri GetApiBaseAddress( Uri orgBaseAddresss ) {
return new Uri( orgBaseAddresss, "api/v1/" );
}
}

Expand Down Expand Up @@ -187,6 +186,19 @@ async Task<OktaApp[]> IOktaAuthenticatedClient.GetAwsAccountAppsAsync() {
?? throw new BmxException( "Error retrieving AWS accounts from Okta." );
}

async Task<OktaSession> IOktaAuthenticatedClient.GetCurrentOktaSessionAsync() {
OktaSession? session;
try {
session = await httpClient.GetFromJsonAsync(
"sessions/me",
JsonCamelCaseContext.Default.OktaSession );
} catch( Exception ex ) {
throw new BmxException( "Request to retrieve session from Okta failed.", ex );
}

return session ?? throw new BmxException( "Error retrieving session from Okta." );
}

async Task<string> IOktaAuthenticatedClient.GetPageAsync( string url ) {
return await httpClient.GetStringAsync( url );
}
Expand Down
141 changes: 125 additions & 16 deletions src/D2L.Bmx/OktaAuthenticator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Diagnostics.CodeAnalysis;
using D2L.Bmx.Okta;
using D2L.Bmx.Okta.Models;
using PuppeteerSharp;

namespace D2L.Bmx;

Expand All @@ -22,7 +23,8 @@ public async Task<OktaAuthenticatedContext> AuthenticateAsync(
string? org,
string? user,
bool nonInteractive,
bool ignoreCache
bool ignoreCache,
bool bypassBrowserSecurity
) {
var orgSource = ParameterSource.CliArg;
if( string.IsNullOrEmpty( org ) && !string.IsNullOrEmpty( config.Org ) ) {
Expand Down Expand Up @@ -52,11 +54,20 @@ bool ignoreCache
consoleWriter.WriteParameter( ParameterDescriptions.User, user, userSource );
}

var oktaAnonymous = oktaClientFactory.CreateAnonymousClient( org );
var orgUrl = GetOrgBaseAddress( org );
var oktaAnonymous = oktaClientFactory.CreateAnonymousClient( orgUrl );

if( !ignoreCache && TryAuthenticateFromCache( org, user, oktaClientFactory, out var oktaAuthenticated ) ) {
if( !ignoreCache && TryAuthenticateFromCache( orgUrl, user, oktaClientFactory, out var oktaAuthenticated ) ) {
return new OktaAuthenticatedContext( Org: org, User: user, Client: oktaAuthenticated );
}
if( await GetDssoAuthenticatedClientAsync(
orgUrl,
user,
nonInteractive,
bypassBrowserSecurity ) is { } oktaDssoAuthenticated
) {
return new OktaAuthenticatedContext( Org: org, User: user, Client: oktaDssoAuthenticated );
}
if( nonInteractive ) {
throw new BmxException( "Okta authentication failed. Please run `bmx login` first." );
}
Expand Down Expand Up @@ -95,15 +106,8 @@ mfaFactor is OktaMfaQuestionFactor // Security question factor is a static value
if( authnResponse is AuthenticateResponse.Success successInfo ) {
var sessionResp = await oktaAnonymous.CreateSessionAsync( successInfo.SessionToken );

oktaAuthenticated = oktaClientFactory.CreateAuthenticatedClient( org, sessionResp.Id );
if( File.Exists( BmxPaths.CONFIG_FILE_NAME ) ) {
CacheOktaSession( user, org, sessionResp.Id, sessionResp.ExpiresAt );
} else {
consoleWriter.WriteWarning( """
No config file found. Your Okta session will not be cached.
Consider running `bmx configure` if you own this machine.
""" );
}
oktaAuthenticated = oktaClientFactory.CreateAuthenticatedClient( orgUrl, sessionResp.Id );
TryCacheOktaSession( user, orgUrl.Host, sessionResp.Id, sessionResp.ExpiresAt );
return new OktaAuthenticatedContext( Org: org, User: user, Client: oktaAuthenticated );
}

Expand All @@ -114,26 +118,131 @@ No config file found. Your Okta session will not be cached.
throw new UnreachableException( $"Unexpected response type: {authnResponse.GetType()}" );
}

private static Uri GetOrgBaseAddress( string org ) {
return org.Contains( '.' )
? new Uri( $"https://{org}/" )
: new Uri( $"https://{org}.okta.com/" );
}

private bool TryAuthenticateFromCache(
string org,
Uri orgBaseAddress,
string user,
IOktaClientFactory oktaClientFactory,
[NotNullWhen( true )] out IOktaAuthenticatedClient? oktaAuthenticated
) {
string? sessionId = GetCachedOktaSessionId( user, org );
string? sessionId = GetCachedOktaSessionId( user, orgBaseAddress.Host );
if( string.IsNullOrEmpty( sessionId ) ) {
oktaAuthenticated = null;
return false;
}

oktaAuthenticated = oktaClientFactory.CreateAuthenticatedClient( org, sessionId );
oktaAuthenticated = oktaClientFactory.CreateAuthenticatedClient( orgBaseAddress, sessionId );
return true;
}

private async Task<IOktaAuthenticatedClient?> GetDssoAuthenticatedClientAsync(
Uri orgUrl,
string user,
bool nonInteractive,
bool experimentalBypassBrowserSecurity
) {
await using IBrowser? browser = await Browser.LaunchBrowserAsync( experimentalBypassBrowserSecurity );
if( browser is null ) {
return null;
}

if( !nonInteractive ) {
Console.Error.WriteLine( "Attempting to automatically sign in to Okta." );
}
using var cancellationTokenSource = new CancellationTokenSource( TimeSpan.FromSeconds( 15 ) );
var sessionIdTcs = new TaskCompletionSource<string?>( TaskCreationOptions.RunContinuationsAsynchronously );
string? sessionId = null;

try {
using var page = await browser.NewPageAsync().WaitAsync( cancellationTokenSource.Token );
int attempt = 1;

page.Load += ( _, _ ) => _ = GetSessionCookieAsync();
await page.GoToAsync( orgUrl.AbsoluteUri ).WaitAsync( cancellationTokenSource.Token );
sessionId = await sessionIdTcs.Task;

async Task GetSessionCookieAsync() {
var url = new Uri( page.Url );
if( url.Host == orgUrl.Host ) {
string title = await page.GetTitleAsync().WaitAsync( cancellationTokenSource.Token );
// DSSO can sometimes takes more than one attempt.
// If the path is '/' with 'sign in' in the title, it means DSSO is not available and we should stop retrying.
if( title.Contains( "sign in", StringComparison.OrdinalIgnoreCase ) ) {
if( attempt < 3 && url.AbsolutePath != "/" ) {
attempt++;
await page.GoToAsync( orgUrl.AbsoluteUri ).WaitAsync( cancellationTokenSource.Token );
} else {
consoleWriter.WriteWarning(
"WARNING: Failed to authenticate with Okta when trying to automatically sign in" );
sessionIdTcs.SetResult( null );
}
return;
}
}
var cookies = await page.GetCookiesAsync( orgUrl.AbsoluteUri ).WaitAsync( cancellationTokenSource.Token );
if( Array.Find( cookies, c => c.Name == "sid" )?.Value is string sid ) {
sessionIdTcs.SetResult( sid );
}
}
} catch( TaskCanceledException ) {
consoleWriter.WriteWarning( $"""
WARNING: Timed out when trying to automatically sign in to Okta. Check if the org '{orgUrl}' is correct.
If you have to run BMX with elevated privileges, and aren't concerned with the security of {orgUrl.Host},
consider running the command again with the '--experimental-bypass-browser-security' flag.
"""
);
} catch( TargetClosedException ) {
consoleWriter.WriteWarning( """
WARNING: Failed to automatically sign in to Okta as BMX is likely being run with elevated privileges.
If you have to run BMX with elevated privileges, and aren't concerned with the security of {orgUrl.Host},
consider running the command again with the '--experimental-bypass-browser-security' flag.
"""
);
} catch( Exception ) {
consoleWriter.WriteWarning(
"WARNING: Unknown error occurred while trying to automatically sign in with Okta." );
}

if( sessionId is null ) {
return null;
}

var oktaAuthenticatedClient = oktaClientFactory.CreateAuthenticatedClient( orgUrl, sessionId );
var oktaSession = await oktaAuthenticatedClient.GetCurrentOktaSessionAsync();
string sessionLogin = oktaSession.Login.Split( "@" )[0];
string providedLogin = user.Split( "@" )[0];
if( !sessionLogin.Equals( providedLogin, StringComparison.OrdinalIgnoreCase ) ) {
consoleWriter.WriteWarning(
"WARNING: Could not automatically sign in to Okta as provided Okta user "
+ $"'{sessionLogin}' does not match user '{providedLogin}'." );
return null;
}

TryCacheOktaSession( user, orgUrl.Host, sessionId, oktaSession.ExpiresAt );
return oktaAuthenticatedClient;
}

private bool TryCacheOktaSession( string userId, string org, string sessionId, DateTimeOffset expiresAt ) {
if( File.Exists( BmxPaths.CONFIG_FILE_NAME ) ) {
CacheOktaSession( userId, org, sessionId, expiresAt );
return true;
}
consoleWriter.WriteWarning( """
No config file found. Your Okta session will not be cached.
Consider running `bmx configure` if you own this machine.
""" );
return false;
}

private void CacheOktaSession( string userId, string org, string sessionId, DateTimeOffset expiresAt ) {
var session = new OktaSessionCache( userId, org, sessionId, expiresAt );
var sessionsToCache = ReadOktaSessionCacheFile();
sessionsToCache = sessionsToCache.Where( session => session.UserId != userId && session.Org != org )
sessionsToCache = sessionsToCache.Where( session => session.UserId != userId || session.Org != org )
.ToList();
sessionsToCache.Add( session );

Expand Down
3 changes: 3 additions & 0 deletions src/D2L.Bmx/ParameterDescriptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ internal static class ParameterDescriptions {
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.
""";

public const string ExperimentalBypassBrowserSecurity
= "Disable Chromium sandbox when automatically signing into Okta";
}
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,
bool bypassBrowserSecurity
) {
var oktaContext = await oktaAuth.AuthenticateAsync(
org: org,
user: user,
nonInteractive: nonInteractive,
ignoreCache: false
ignoreCache: false,
bypassBrowserSecurity: bypassBrowserSecurity
);
var awsCreds = ( await awsCredsCreator.CreateAwsCredsAsync(
okta: oktaContext,
Expand Down
Loading
Loading