-
Notifications
You must be signed in to change notification settings - Fork 5
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
okta: add passwordless dsso support #480
Changes from 32 commits
e9f18ac
a16a9e9
6fc7baa
73d0cdd
c2f13bd
19fa544
4f177b7
aedb835
c14de8e
77a91c4
1b8cfd2
1a8ba2e
b526dbe
d502f1d
28b320a
7633b0f
185b8ff
d9fe294
0080063
ac51994
ff3f8e1
551f6cf
64f5efb
40171e4
a2f903a
88f7517
91d52b6
684f1bd
81a3714
d48c7db
7a68852
6dba5c1
f46d800
fa1fd6d
fc17c78
e479bc8
d87d349
754699e
1054c06
7ec3833
e07c26d
5bf12bd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't look right. If someone's Mac doesn't have Chrome or Edge installed, this would throw. |
||
} | ||
return null; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,6 +2,7 @@ | |
using System.Diagnostics.CodeAnalysis; | ||
using D2L.Bmx.Okta; | ||
using D2L.Bmx.Okta.Models; | ||
using PuppeteerSharp; | ||
|
||
namespace D2L.Bmx; | ||
|
||
|
@@ -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 ) ) { | ||
|
@@ -52,11 +54,21 @@ 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, | ||
oktaClientFactory, | ||
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." ); | ||
} | ||
|
@@ -95,15 +107,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 ); | ||
} | ||
|
||
|
@@ -114,26 +119,142 @@ 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, | ||
IOktaClientFactory oktaClientFactory, | ||
cfbao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
bool nonInteractive, | ||
bool experimentalBypassBrowserSecurity | ||
) { | ||
await using IBrowser? browser = await Browser.LaunchBrowserAsync( experimentalBypassBrowserSecurity ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: for such a side-effect heavy operation (launching a browser), I'd rather not invoke it through a static class. No need to change this now though. I have a couple other nits with this |
||
if( browser is null ) { | ||
return null; | ||
} | ||
|
||
if( !nonInteractive ) { | ||
Console.Error.WriteLine( "Attempting to automatically login using Okta Desktop Single Sign-On." ); | ||
} | ||
using var cancellationTokenSource = new CancellationTokenSource( TimeSpan.FromSeconds( 15 ) ); | ||
var sessionIdTcs = new TaskCompletionSource<string?>( TaskCreationOptions.RunContinuationsAsynchronously ); | ||
string? sessionId; | ||
|
||
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: Could not authenticate with Okta using Desktop Single Sign-On." ); | ||
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 create Okta session through Desktop Single Sign-On. | ||
Check if the org '{orgUrl}' is correct. If running BMX with elevated privileges, | ||
rerun the command with the '--experimental-bypass-browser-security' flag | ||
cfbao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
); | ||
return null; | ||
} catch( TargetClosedException ) { | ||
consoleWriter.WriteWarning( """ | ||
WARNING: Failed to create Okta session through Desktop Single Sign-On as BMX is likely being run | ||
with elevated privileges. Rerun the command with the '--experimental-bypass-browser-security' flag. | ||
cfbao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
); | ||
return null; | ||
gord5500 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} catch( Exception ) { | ||
consoleWriter.WriteWarning( | ||
"WARNING: Unknown error while trying to authenticate with Okta using Desktop Single Sign-On." ); | ||
return null; | ||
} | ||
|
||
if( sessionId is null ) { | ||
return null; | ||
} | ||
cfbao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
var oktaAuthenticatedClient = oktaClientFactory.CreateAuthenticatedClient( orgUrl, sessionId ); | ||
var oktaSession = await oktaAuthenticatedClient.GetCurrentOktaSessionAsync(); | ||
if( !OktaUserMatchesProvided( oktaSession.Login, user ) ) { | ||
consoleWriter.WriteWarning( | ||
"WARNING: Could not create Okta session using Desktop Single Sign-On as provided Okta user " | ||
+ $"'{StripLoginDomain( user )}' does not match user '{StripLoginDomain( oktaSession.Login )}'." ); | ||
return null; | ||
} | ||
|
||
TryCacheOktaSession( user, orgUrl.Host, sessionId, oktaSession.ExpiresAt ); | ||
return oktaAuthenticatedClient; | ||
} | ||
|
||
private static string StripLoginDomain( string email ) { | ||
return email.Contains( '@' ) ? email.Split( '@' )[0] : email; | ||
cfbao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
private static bool OktaUserMatchesProvided( string oktaLogin, string providedUser ) { | ||
cfbao marked this conversation as resolved.
Show resolved
Hide resolved
cfbao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
string adName = StripLoginDomain( oktaLogin ); | ||
string normalizedUser = StripLoginDomain( providedUser ); | ||
return adName.Equals( normalizedUser, StringComparison.OrdinalIgnoreCase ); | ||
} | ||
|
||
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. | ||
""" ); | ||
gord5500 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 ) | ||
gord5500 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
.ToList(); | ||
sessionsToCache.Add( session ); | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just noticed this... best to use
Path.Join
for safe path concatenation.Also, it'd be good to not start
WindowsPartialPaths
with leading (back)slashes, because they're relative (not absolute) paths.