Skip to content

Commit d15aa79

Browse files
committed
make Okta API client stateless
1 parent c11db59 commit d15aa79

7 files changed

+127
-102
lines changed

src/D2L.Bmx/AwsCredsCreator.cs

+9-9
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ internal class AwsCredsCreator(
1818
BmxConfig config
1919
) {
2020
public async Task<AwsCredentialsInfo> CreateAwsCredsAsync(
21-
AuthenticatedOktaApi oktaApi,
21+
OktaAuthenticatedContext okta,
2222
string? account,
2323
string? role,
2424
int? duration,
@@ -66,8 +66,8 @@ bool cache
6666
// if using cache, avoid calling Okta at all if possible
6767
if( cache && !string.IsNullOrEmpty( account ) && !string.IsNullOrEmpty( role ) ) {
6868
var cachedCredentials = awsCredentialCache.GetCredentials(
69-
org: oktaApi.Org,
70-
user: oktaApi.User,
69+
org: okta.Org,
70+
user: okta.User,
7171
accountName: account,
7272
roleName: role,
7373
duration: duration.Value
@@ -82,7 +82,7 @@ bool cache
8282
}
8383
}
8484

85-
OktaApp[] awsApps = await oktaApi.Api.GetAwsAccountAppsAsync();
85+
OktaApp[] awsApps = await okta.Client.GetAwsAccountAppsAsync();
8686

8787
if( string.IsNullOrEmpty( account ) ) {
8888
if( nonInteractive ) {
@@ -97,7 +97,7 @@ bool cache
9797
app => app.Label.Equals( account, StringComparison.OrdinalIgnoreCase )
9898
) ?? throw new BmxException( $"Account {account} could not be found" );
9999

100-
string loginHtml = await oktaApi.Api.GetPageAsync( selectedAwsApp.LinkUrl );
100+
string loginHtml = await okta.Client.GetPageAsync( selectedAwsApp.LinkUrl );
101101
string samlResponse = HtmlXmlHelper.GetSamlResponseFromLoginPage( loginHtml );
102102
AwsRole[] rolesData = HtmlXmlHelper.GetRolesFromSamlResponse( samlResponse );
103103

@@ -112,8 +112,8 @@ bool cache
112112
// try getting from cache again even if calling Okta is inevitable (we still avoid the AWS call)
113113
if( cache ) {
114114
var cachedCredentials = awsCredentialCache.GetCredentials(
115-
org: oktaApi.Org,
116-
user: oktaApi.User,
115+
org: okta.Org,
116+
user: okta.User,
117117
accountName: account,
118118
roleName: role,
119119
duration: duration.Value
@@ -141,8 +141,8 @@ bool cache
141141

142142
if( cache ) {
143143
awsCredentialCache.SetCredentials(
144-
org: oktaApi.Org,
145-
user: oktaApi.User,
144+
org: okta.Org,
145+
user: okta.User,
146146
accountName: selectedAwsApp.Label,
147147
roleName: selectedRoleData.RoleName,
148148
credentials: credentials

src/D2L.Bmx/Okta/Models/AuthenticateResponse.cs

+2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
using System.Net;
12
using System.Text.Json.Serialization;
23

34
namespace D2L.Bmx.Okta.Models;
45

56
internal abstract record AuthenticateResponse {
67
public record MfaRequired( string StateToken, OktaMfaFactor[] Factors ) : AuthenticateResponse;
78
public record Success( string SessionToken ) : AuthenticateResponse;
9+
public record Failure( HttpStatusCode StatusCode ) : AuthenticateResponse;
810
}
911

1012
internal record AuthenticateResponseRaw(
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,72 @@
11
using System.Net;
2-
using System.Net.Http.Headers;
32
using System.Net.Http.Json;
43
using System.Text.Json;
54
using D2L.Bmx.Okta.Models;
65

76
namespace D2L.Bmx.Okta;
87

9-
internal interface IOktaApi {
10-
void SetOrganization( string organization );
11-
void AddSession( string sessionId );
8+
internal interface IOktaClientFactory {
9+
IOktaAnonymousClient CreateAnonymousClient( string org );
10+
IOktaAuthenticatedClient CreateAuthenticatedClient( string org, string sessionId );
11+
}
12+
13+
internal interface IOktaAnonymousClient {
1214
Task<AuthenticateResponse> AuthenticateAsync( string username, string password );
1315
Task IssueMfaChallengeAsync( string stateToken, string factorId );
1416
Task<AuthenticateResponse> VerifyMfaChallengeResponseAsync(
1517
string stateToken,
1618
string factorId,
17-
string challengeResponse );
19+
string challengeResponse
20+
);
1821
Task<OktaSession> CreateSessionAsync( string sessionToken );
19-
Task<OktaApp[]> GetAwsAccountAppsAsync();
20-
Task<string> GetPageAsync( string samlLoginUrl );
2122
}
2223

23-
internal class OktaApi : IOktaApi {
24-
private readonly CookieContainer _cookieContainer;
25-
private readonly HttpClient _httpClient;
26-
private string? _organization;
24+
internal interface IOktaAuthenticatedClient {
25+
Task<OktaApp[]> GetAwsAccountAppsAsync();
26+
Task<string> GetPageAsync( string url );
27+
}
2728

28-
public OktaApi() {
29-
_cookieContainer = new CookieContainer();
30-
_httpClient = new HttpClient( new HttpClientHandler { CookieContainer = _cookieContainer } ) {
31-
Timeout = TimeSpan.FromSeconds( 30 )
29+
internal class OktaClientFactory : IOktaClientFactory {
30+
IOktaAnonymousClient IOktaClientFactory.CreateAnonymousClient( string org ) {
31+
var httpClient = new HttpClient {
32+
Timeout = TimeSpan.FromSeconds( 30 ),
33+
BaseAddress = GetBaseAddress( org ),
3234
};
33-
_httpClient.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue( "application/json" ) );
35+
return new OktaAnonymousClient( httpClient );
3436
}
3537

36-
void IOktaApi.SetOrganization( string organization ) {
37-
_organization = organization;
38-
if( !organization.Contains( '.' ) ) {
39-
_httpClient.BaseAddress = new Uri( $"https://{organization}.okta.com/api/v1/" );
40-
} else {
41-
_httpClient.BaseAddress = new Uri( $"https://{organization}/api/v1/" );
42-
}
38+
IOktaAuthenticatedClient IOktaClientFactory.CreateAuthenticatedClient( string org, string sessionId ) {
39+
var baseAddress = GetBaseAddress( org );
40+
41+
var cookieContainer = new CookieContainer();
42+
cookieContainer.Add( new Cookie( "sid", sessionId, "/", baseAddress.Host ) );
43+
44+
var httpClient = new HttpClient( new SocketsHttpHandler {
45+
CookieContainer = cookieContainer,
46+
} ) {
47+
Timeout = TimeSpan.FromSeconds( 30 ),
48+
BaseAddress = baseAddress,
49+
};
50+
51+
return new OktaAuthenticatedClient( httpClient );
4352
}
4453

45-
void IOktaApi.AddSession( string sessionId ) {
46-
if( _httpClient.BaseAddress is not null ) {
47-
_cookieContainer.Add( new Cookie( "sid", sessionId, "/", _httpClient.BaseAddress.Host ) );
48-
} else {
49-
throw new InvalidOperationException( "Error adding session: http client base address is not defined" );
50-
}
54+
private static Uri GetBaseAddress( string org ) {
55+
return org.Contains( '.' )
56+
? new Uri( $"https://{org}/api/v1/" )
57+
: new Uri( $"https://{org}.okta.com/api/v1/" );
5158
}
59+
}
5260

53-
async Task<AuthenticateResponse> IOktaApi.AuthenticateAsync( string username, string password ) {
61+
internal class OktaAnonymousClient( HttpClient httpClient ) : IOktaAnonymousClient {
62+
async Task<AuthenticateResponse> IOktaAnonymousClient.AuthenticateAsync( string username, string password ) {
5463
HttpResponseMessage resp;
5564
try {
56-
resp = await _httpClient.PostAsJsonAsync(
65+
resp = await httpClient.PostAsJsonAsync(
5766
"authn",
5867
new AuthenticateRequest( username, password ),
59-
JsonCamelCaseContext.Default.AuthenticateRequest );
68+
JsonCamelCaseContext.Default.AuthenticateRequest
69+
);
6070
} catch( Exception ex ) {
6171
throw new BmxException( "Okta authentication request failed.", ex );
6272
}
@@ -65,7 +75,8 @@ async Task<AuthenticateResponse> IOktaApi.AuthenticateAsync( string username, st
6575
try {
6676
authnResponse = await JsonSerializer.DeserializeAsync(
6777
await resp.Content.ReadAsStreamAsync(),
68-
JsonCamelCaseContext.Default.AuthenticateResponseRaw );
78+
JsonCamelCaseContext.Default.AuthenticateResponseRaw
79+
);
6980
} catch( Exception ex ) {
7081
throw new BmxException( "Okta authentication failed. Okta returned an invalid response", ex );
7182
}
@@ -87,16 +98,36 @@ await resp.Content.ReadAsStreamAsync(),
8798
);
8899
}
89100

90-
string org = _organization ?? "unknown";
91-
throw new BmxException( $"""
92-
Okta authentication for user '{username}' in org '{org}' failed.
93-
Check if org, user, and password is correct.
94-
""" );
101+
return new AuthenticateResponse.Failure( resp.StatusCode );
95102
}
96103

97-
async Task IOktaApi.IssueMfaChallengeAsync( string stateToken, string factorId ) {
104+
async Task<OktaSession> IOktaAnonymousClient.CreateSessionAsync( string sessionToken ) {
105+
HttpResponseMessage resp;
98106
try {
99-
var response = await _httpClient.PostAsJsonAsync(
107+
resp = await httpClient.PostAsJsonAsync(
108+
"sessions",
109+
new CreateSessionRequest( sessionToken ),
110+
JsonCamelCaseContext.Default.CreateSessionRequest );
111+
resp.EnsureSuccessStatusCode();
112+
} catch( Exception ex ) {
113+
throw new BmxException( "Request to create Okta Session failed.", ex );
114+
}
115+
116+
OktaSession? session;
117+
try {
118+
session = await JsonSerializer.DeserializeAsync(
119+
await resp.Content.ReadAsStreamAsync(),
120+
JsonCamelCaseContext.Default.OktaSession );
121+
} catch( Exception ex ) {
122+
throw new BmxException( "Error creating Okta Session. Okta returned an invalid response", ex );
123+
}
124+
125+
return session ?? throw new BmxException( "Error creating Okta Session." );
126+
}
127+
128+
async Task IOktaAnonymousClient.IssueMfaChallengeAsync( string stateToken, string factorId ) {
129+
try {
130+
var response = await httpClient.PostAsJsonAsync(
100131
$"authn/factors/{factorId}/verify",
101132
new IssueMfaChallengeRequest( stateToken ),
102133
JsonCamelCaseContext.Default.IssueMfaChallengeRequest );
@@ -107,7 +138,7 @@ async Task IOktaApi.IssueMfaChallengeAsync( string stateToken, string factorId )
107138
}
108139
}
109140

110-
async Task<AuthenticateResponse> IOktaApi.VerifyMfaChallengeResponseAsync(
141+
async Task<AuthenticateResponse> IOktaAnonymousClient.VerifyMfaChallengeResponseAsync(
111142
string stateToken,
112143
string factorId,
113144
string challengeResponse
@@ -117,7 +148,7 @@ string challengeResponse
117148
PassCode: challengeResponse );
118149
HttpResponseMessage resp;
119150
try {
120-
resp = await _httpClient.PostAsJsonAsync(
151+
resp = await httpClient.PostAsJsonAsync(
121152
$"authn/factors/{factorId}/verify",
122153
request,
123154
JsonCamelCaseContext.Default.VerifyMfaChallengeResponseRequest );
@@ -137,37 +168,15 @@ await resp.Content.ReadAsStreamAsync(),
137168
if( authnResponse?.SessionToken is not null ) {
138169
return new AuthenticateResponse.Success( authnResponse.SessionToken );
139170
}
140-
throw new BmxException( "Error verifying MFA with Okta." );
141-
}
142-
143-
async Task<OktaSession> IOktaApi.CreateSessionAsync( string sessionToken ) {
144-
HttpResponseMessage resp;
145-
try {
146-
resp = await _httpClient.PostAsJsonAsync(
147-
"sessions",
148-
new CreateSessionRequest( sessionToken ),
149-
JsonCamelCaseContext.Default.CreateSessionRequest );
150-
resp.EnsureSuccessStatusCode();
151-
} catch( Exception ex ) {
152-
throw new BmxException( "Request to create Okta Session failed.", ex );
153-
}
154-
155-
OktaSession? session;
156-
try {
157-
session = await JsonSerializer.DeserializeAsync(
158-
await resp.Content.ReadAsStreamAsync(),
159-
JsonCamelCaseContext.Default.OktaSession );
160-
} catch( Exception ex ) {
161-
throw new BmxException( "Error creating Okta Session. Okta returned an invalid response", ex );
162-
}
163-
164-
return session ?? throw new BmxException( "Error creating Okta Session." );
171+
return new AuthenticateResponse.Failure( resp.StatusCode );
165172
}
173+
}
166174

167-
async Task<OktaApp[]> IOktaApi.GetAwsAccountAppsAsync() {
175+
internal class OktaAuthenticatedClient( HttpClient httpClient ) : IOktaAuthenticatedClient {
176+
async Task<OktaApp[]> IOktaAuthenticatedClient.GetAwsAccountAppsAsync() {
168177
OktaApp[]? apps;
169178
try {
170-
apps = await _httpClient.GetFromJsonAsync(
179+
apps = await httpClient.GetFromJsonAsync(
171180
"users/me/appLinks",
172181
JsonCamelCaseContext.Default.OktaAppArray );
173182
} catch( Exception ex ) {
@@ -178,7 +187,7 @@ async Task<OktaApp[]> IOktaApi.GetAwsAccountAppsAsync() {
178187
?? throw new BmxException( "Error retrieving AWS accounts from Okta." );
179188
}
180189

181-
async Task<string> IOktaApi.GetPageAsync( string samlLoginUrl ) {
182-
return await _httpClient.GetStringAsync( samlLoginUrl );
190+
async Task<string> IOktaAuthenticatedClient.GetPageAsync( string url ) {
191+
return await httpClient.GetStringAsync( url );
183192
}
184193
}

0 commit comments

Comments
 (0)