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

okta: add passwordless dsso support #480

Merged
merged 42 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
42 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
e07c26d
path join and don't throw if no browser found on linux / mac
gord5500 Sep 24, 2024
5bf12bd
mend
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;
Copy link
Member

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.

if( File.Exists( path ) ) {
return path;
}
}
}
}
} else if( OperatingSystem.IsMacOS() ) {
return MacPaths.First( File.Exists );
} else if( OperatingSystem.IsLinux() ) {
return LinuxPaths.First( File.Exists );
Copy link
Member

Choose a reason for hiding this comment

The 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.
.FirstOrDefault should work.

}
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
153 changes: 137 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,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." );
}
Expand Down Expand Up @@ -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 );
}

Expand All @@ -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 );
Copy link
Member

Choose a reason for hiding this comment

The 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.
A class-interface combo with it passed as an explicit dependency through the constructor parameter the DI way would be nice.

No need to change this now though. I have a couple other nits with this OktaAuthenticator class's structure overall. Will perhaps refactor it sometime.

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 );

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 running with elevated permissions for Okta Desktop Single Sign-On";
gord5500 marked this conversation as resolved.
Show resolved Hide resolved
}
Loading
Loading