Skip to content

Commit 2e3bbf2

Browse files
committed
implement OOB token request handling
Added oobResponseAsState extension to process out-of-band authentication API responses. Updated error handling for OOB and token responses, supporting OAuth2 and API errors. Introduced polling context for OOB authentication flow.
1 parent 52d7fd3 commit 2e3bbf2

File tree

13 files changed

+670
-58
lines changed

13 files changed

+670
-58
lines changed

okta-direct-auth/src/androidHostTest/kotlin/com/okta/directauth/DirectAuthenticationFlowImplTest.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.okta.directauth.http.KtorHttpExecutor
44
import com.okta.directauth.model.PrimaryFactor
55
import com.okta.directauth.model.DirectAuthenticationError
66
import com.okta.directauth.model.DirectAuthenticationState
7+
import com.okta.directauth.model.OobChannel
78
import io.ktor.client.HttpClient
89
import kotlinx.coroutines.test.runTest
910
import kotlinx.serialization.SerializationException
@@ -72,4 +73,22 @@ class DirectAuthenticationFlowImplTest {
7273
assertThat((state as DirectAuthenticationError.InternalError).throwable, instanceOf(SerializationException::class.java))
7374
assertThat(flow.authenticationState.value, equalTo(state))
7475
}
76+
77+
@Test
78+
fun start_withOobFactor_returnsOobAuthenticateState() = runTest {
79+
val flow = DirectAuthenticationFlowBuilder.create(issuerUrl, clientId, scope) {
80+
apiExecutor = KtorHttpExecutor(HttpClient(oobAuthenticateResponseMockEngine))
81+
}.getOrThrow()
82+
83+
val state = flow.start("test_user", PrimaryFactor.Oob(OobChannel.PUSH))
84+
85+
assertThat(state, instanceOf(DirectAuthenticationState.OobAuthenticate::class.java))
86+
val oobState = state as DirectAuthenticationState.OobAuthenticate
87+
assertThat(oobState.pollContext.oobCode, equalTo("example_oob_code"))
88+
assertThat(oobState.pollContext.channel, equalTo(OobChannel.PUSH.value))
89+
assertThat(oobState.pollContext.expiresIn, equalTo(120))
90+
assertThat(oobState.pollContext.interval, equalTo(5))
91+
92+
assertThat(flow.authenticationState.value, equalTo(state))
93+
}
7594
}

okta-direct-auth/src/androidHostTest/kotlin/com/okta/directauth/MockEngineUtil.kt

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,26 @@ import io.ktor.utils.io.ByteReadChannel
99

1010
const val TOKEN_RESPONSE_JSON =
1111
"""{"access_token":"example_access_token","token_type":"Bearer","expires_in":3600,"scope":"openid email profile offline_access","refresh_token":"example_refresh_token","id_token":"example_id_token"}"""
12-
const val AUTHORIZATION_PENDING_JSON =
13-
"""{"error":"authorization_pending","error_description":"The user has not yet approved the push notification."}"""
14-
const val OAUTH2_ERROR_JSON =
15-
"""{"error":"invalid_grant","error_description":"The password was invalid."}"""
16-
const val SERVER_ERROR_JSON =
17-
"""{"errorCode":"E00000","errorSummary":"Internal Server Error"}"""
18-
const val MFA_REQUIRED_JSON =
19-
"""{"error":"mfa_required","error_description":"MFA is required for this transaction.","mfa_token":"example_mfa_token"}"""
20-
const val INVALID_MFA_REQUIRED_JSON =
21-
"""{"error":"mfa_required","error_description":"MFA is required for this transaction."}"""
12+
const val AUTHORIZATION_PENDING_JSON = """{"error":"authorization_pending","error_description":"The user has not yet approved the push notification."}"""
13+
const val OAUTH2_ERROR_JSON = """{"error":"invalid_grant","error_description":"The password was invalid."}"""
14+
const val SERVER_ERROR_JSON = """{"errorCode":"E00000","errorSummary":"Internal Server Error"}"""
15+
const val MFA_REQUIRED_JSON = """{"error":"mfa_required","error_description":"MFA is required for this transaction.","mfa_token":"example_mfa_token"}"""
16+
const val INVALID_MFA_REQUIRED_JSON = """{"error":"mfa_required","error_description":"MFA is required for this transaction."}"""
2217
const val UNKNOWN_JSON_TYPE = """{"unknown_json_type":"bad response"}"""
18+
const val OOB_AUTHENTICATE_RESPONSE_JSON = """{"oob_code":"example_oob_code","channel":"push","binding_method":"none","expires_in": 120,"interval":5}"""
19+
const val OOB_AUTHENTICATE_OAUTH2_ERROR_JSON = """{"error":"invalid_request","error_description":"abc is not a valid channel hint"}"""
2320

2421
// Malformed JSON with trailing comma
2522
const val MALFORMED_JSON = """{"access_token": "token","token_type": "Bearer",}"""
2623
const val MALFORMED_JSON_ERROR_CODE = """{"errorCode": "error","errorSummary": "error description",}"""
2724
const val MALFORMED_JSON_ERROR = """{"error": "access_denied","error_description": "error description",}"""
2825

26+
val contentType = headersOf("Content-Type", "application/json")
27+
2928
fun createMockEngine(
3029
jsonResponse: String,
3130
httpStatusCode: HttpStatusCode,
32-
headers: Headers = headersOf("Content-Type", "application/json")
31+
headers: Headers = contentType
3332
): MockEngine = MockEngine {
3433
respond(
3534
content = ByteReadChannel(jsonResponse),
@@ -64,6 +63,12 @@ val emptyResponseOkMockEngine = createMockEngine("", HttpStatusCode.OK)
6463

6564
val malformedJsonOkMockEngine = createMockEngine(MALFORMED_JSON, HttpStatusCode.OK)
6665

66+
val malformedJsonClientMockEngine = createMockEngine(MALFORMED_JSON_ERROR_CODE, HttpStatusCode.TooManyRequests)
67+
6768
val malformedJsonErrorMockEngine = createMockEngine(MALFORMED_JSON_ERROR, HttpStatusCode.BadRequest)
6869

6970
val malformedJsonErrorCodeMockEngine = createMockEngine(MALFORMED_JSON_ERROR_CODE, HttpStatusCode.BadRequest)
71+
72+
val oobAuthenticateResponseMockEngine = createMockEngine(OOB_AUTHENTICATE_RESPONSE_JSON, HttpStatusCode.OK)
73+
74+
val oobAuthenticateOauth2ErrorMockEngine = createMockEngine(OOB_AUTHENTICATE_OAUTH2_ERROR_JSON, HttpStatusCode.BadRequest)
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package com.okta.directauth.http
2+
3+
import com.okta.authfoundation.GrantType
4+
import com.okta.authfoundation.api.http.ApiRequestMethod
5+
import com.okta.authfoundation.api.http.log.AuthFoundationLogger
6+
import com.okta.authfoundation.api.http.log.LogLevel
7+
import com.okta.directauth.model.DirectAuthenticationContext
8+
import com.okta.directauth.model.DirectAuthenticationError
9+
import com.okta.directauth.model.DirectAuthenticationIntent
10+
import com.okta.directauth.model.DirectAuthenticationState
11+
import com.okta.directauth.model.OobChannel
12+
import com.okta.directauth.notJsonMockEngine
13+
import com.okta.directauth.oobAuthenticateOauth2ErrorMockEngine
14+
import com.okta.directauth.oobAuthenticateResponseMockEngine
15+
import com.okta.directauth.serverErrorMockEngine
16+
import com.okta.directauth.unknownJsonTypeMockEngine
17+
import io.ktor.client.HttpClient
18+
import io.ktor.client.engine.mock.MockEngine
19+
import io.ktor.client.engine.mock.respond
20+
import io.ktor.http.HttpHeaders
21+
import io.ktor.http.HttpStatusCode
22+
import io.ktor.http.headersOf
23+
import kotlinx.coroutines.test.runTest
24+
import org.hamcrest.CoreMatchers.equalTo
25+
import org.hamcrest.CoreMatchers.instanceOf
26+
import org.hamcrest.CoreMatchers.nullValue
27+
import org.hamcrest.MatcherAssert.assertThat
28+
import org.junit.Before
29+
import org.junit.Test
30+
31+
class DirectAuthOobAuthenticateRequestTest {
32+
private lateinit var context: DirectAuthenticationContext
33+
34+
@Before
35+
fun setUp() {
36+
context = DirectAuthenticationContext(
37+
issuerUrl = "https://example.okta.com",
38+
clientId = "test_client_id",
39+
scope = listOf("openid", "email", "profile", "offline_access"),
40+
authorizationServerId = "",
41+
clientSecret = "",
42+
grantTypes = listOf(GrantType.Password, GrantType.Otp),
43+
acrValues = emptyList(),
44+
directAuthenticationIntent = DirectAuthenticationIntent.SIGN_IN,
45+
apiExecutor = KtorHttpExecutor(),
46+
logger = object : AuthFoundationLogger {
47+
override fun write(message: String, tr: Throwable?, logLevel: LogLevel) {
48+
// No-op logger for tests
49+
}
50+
},
51+
clock = { 1654041600 }, // 2022-06-01
52+
additionalParameters = mapOf()
53+
)
54+
}
55+
56+
@Test
57+
fun oobAuthenticateRequest_WithDefaultParameters() {
58+
val request = DirectAuthOobAuthenticateRequest(
59+
context = context,
60+
loginHint = "test_user",
61+
oobChannel = OobChannel.PUSH
62+
)
63+
64+
assertThat(request.url(), equalTo("https://example.okta.com/oauth2/v1/oob-authenticate"))
65+
assertThat(request.method(), equalTo(ApiRequestMethod.POST))
66+
assertThat(request.contentType(), equalTo("application/x-www-form-urlencoded"))
67+
assertThat(request.headers(), equalTo(mapOf("Accept" to listOf("application/json"))))
68+
assertThat(request.query(), nullValue())
69+
70+
val formParameters = request.formParameters()
71+
assertThat(formParameters["client_id"], equalTo(listOf("test_client_id")))
72+
assertThat(formParameters["login_hint"], equalTo(listOf("test_user")))
73+
assertThat(formParameters["channel_hint"], equalTo(listOf("push")))
74+
assertThat(formParameters.containsKey("client_secret"), equalTo(false))
75+
}
76+
77+
@Test
78+
fun oobAuthenticateRequest_WithCustomParameters() {
79+
val customContext = context.copy(
80+
authorizationServerId = "aus_test_id",
81+
clientSecret = "test_client_secret",
82+
additionalParameters = mapOf("custom" to "value")
83+
)
84+
85+
val request = DirectAuthOobAuthenticateRequest(
86+
context = customContext,
87+
loginHint = "test_user",
88+
oobChannel = OobChannel.SMS
89+
)
90+
91+
assertThat(request.url(), equalTo("https://example.okta.com/oauth2/aus_test_id/v1/oob-authenticate"))
92+
assertThat(request.query(), equalTo(mapOf("custom" to "value")))
93+
94+
val formParameters = request.formParameters()
95+
assertThat(formParameters["client_id"], equalTo(listOf("test_client_id")))
96+
assertThat(formParameters["login_hint"], equalTo(listOf("test_user")))
97+
assertThat(formParameters["channel_hint"], equalTo(listOf("sms")))
98+
assertThat(formParameters["client_secret"], equalTo(listOf("test_client_secret")))
99+
}
100+
101+
@Test
102+
fun oobAuthenticateRequest_returnsOobAuthenticateStateOnSuccess() = runTest {
103+
val request = DirectAuthOobAuthenticateRequest(context, "test_user", OobChannel.PUSH)
104+
val apiResponse = KtorHttpExecutor(HttpClient(oobAuthenticateResponseMockEngine)).execute(request).getOrThrow()
105+
106+
val state = apiResponse.oobResponseAsState(context)
107+
108+
assertThat(state, instanceOf(DirectAuthenticationState.OobAuthenticate::class.java))
109+
val oobState = state as DirectAuthenticationState.OobAuthenticate
110+
assertThat(oobState.pollContext.oobCode, equalTo("example_oob_code"))
111+
assertThat(oobState.pollContext.channel, equalTo(OobChannel.PUSH.value))
112+
assertThat(oobState.pollContext.expiresIn, equalTo(120))
113+
assertThat(oobState.pollContext.interval, equalTo(5))
114+
assertThat(oobState.pollContext.bindingMethod, equalTo("none"))
115+
assertThat(oobState.pollContext.bindingCode, nullValue())
116+
}
117+
118+
@Test
119+
fun oobAuthenticateRequest_returnsOauth2ErrorStateOnApiError() = runTest {
120+
val request = DirectAuthOobAuthenticateRequest(context, "test_user", OobChannel.PUSH)
121+
val apiResponse = KtorHttpExecutor(HttpClient(oobAuthenticateOauth2ErrorMockEngine)).execute(request).getOrThrow()
122+
123+
val state = apiResponse.oobResponseAsState(context)
124+
125+
assertThat(state, instanceOf(DirectAuthenticationError.HttpError.Oauth2Error::class.java))
126+
val errorState = state as DirectAuthenticationError.HttpError.Oauth2Error
127+
assertThat(errorState.error, equalTo("invalid_request"))
128+
assertThat(errorState.errorDescription, equalTo("abc is not a valid channel hint"))
129+
}
130+
131+
@Test
132+
fun oobAuthenticateRequest_returnsApiErrorStateOnServerError() = runTest {
133+
val request = DirectAuthOobAuthenticateRequest(context, "test_user", OobChannel.PUSH)
134+
val apiResponse = KtorHttpExecutor(HttpClient(serverErrorMockEngine)).execute(request).getOrThrow()
135+
136+
val state = apiResponse.oobResponseAsState(context)
137+
138+
assertThat(state, instanceOf(DirectAuthenticationError.HttpError.ApiError::class.java))
139+
val errorState = state as DirectAuthenticationError.HttpError.ApiError
140+
assertThat(errorState.errorCode, equalTo("E00000"))
141+
assertThat(errorState.errorSummary, equalTo("Internal Server Error"))
142+
assertThat(errorState.httpStatusCode, equalTo(HttpStatusCode.InternalServerError))
143+
}
144+
145+
@Test
146+
fun oobAuthenticateRequest_returnsInternalErrorOnUnsupportedContentType() = runTest {
147+
val request = DirectAuthOobAuthenticateRequest(context, "test_user", OobChannel.PUSH)
148+
val apiResponse = KtorHttpExecutor(HttpClient(notJsonMockEngine)).execute(request).getOrThrow()
149+
150+
val state = apiResponse.oobResponseAsState(context)
151+
152+
assertThat(state, instanceOf(DirectAuthenticationError.InternalError::class.java))
153+
val error = state as DirectAuthenticationError.InternalError
154+
assertThat(error.errorCode, equalTo(UNSUPPORTED_CONTENT_TYPE))
155+
assertThat(error.throwable, instanceOf(IllegalStateException::class.java))
156+
assertThat(error.throwable.message, equalTo("Unsupported content type: text/plain"))
157+
}
158+
159+
@Test
160+
fun oobAuthenticateRequest_unparseableClientErrorResponse() = runTest {
161+
val request = DirectAuthOobAuthenticateRequest(context, "test_user", OobChannel.PUSH)
162+
163+
val apiResponse = KtorHttpExecutor(HttpClient(unknownJsonTypeMockEngine)).execute(request).getOrThrow()
164+
val directAuthState = apiResponse.tokenResponseAsState(context)
165+
166+
assertThat(directAuthState, instanceOf(DirectAuthenticationError.InternalError::class.java))
167+
val error = directAuthState as DirectAuthenticationError.InternalError
168+
assertThat(error.errorCode, equalTo(INVALID_RESPONSE))
169+
assertThat(error.throwable, instanceOf(IllegalStateException::class.java))
170+
assertThat(error.throwable.message, equalTo("No parsable error response body: HTTP ${HttpStatusCode.BadRequest}"))
171+
}
172+
173+
}

okta-direct-auth/src/androidHostTest/kotlin/com/okta/directauth/http/DirectAuthTokenRequestTest.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.okta.directauth.emptyResponseMockEngine
99
import com.okta.directauth.emptyResponseOkMockEngine
1010
import com.okta.directauth.internalServerErrorMockEngine
1111
import com.okta.directauth.invalidMfaRequiredMockEngine
12+
import com.okta.directauth.malformedJsonClientMockEngine
1213
import com.okta.directauth.malformedJsonErrorCodeMockEngine
1314
import com.okta.directauth.malformedJsonErrorMockEngine
1415
import com.okta.directauth.malformedJsonOkMockEngine
@@ -399,6 +400,19 @@ class DirectAuthTokenRequestTest {
399400
assertThat(error.throwable, instanceOf(SerializationException::class.java))
400401
}
401402

403+
@Test
404+
fun request_malformedJsonInClientErrorStatus() = runTest {
405+
val request = DirectAuthTokenRequest.Password(context, "test_user", "test_password")
406+
val apiResponse = KtorHttpExecutor(HttpClient(malformedJsonClientMockEngine)).execute(request).getOrThrow()
407+
val directAuthState = apiResponse.tokenResponseAsState(context)
408+
409+
assertThat(directAuthState, instanceOf(InternalError::class.java))
410+
val error = directAuthState as InternalError
411+
assertThat(error.errorCode, equalTo(UNKNOWN_ERROR))
412+
assertThat(error.description, containsString("Unexpected JSON token at offset"))
413+
assertThat(error.throwable, instanceOf(SerializationException::class.java))
414+
}
415+
402416
@Test
403417
fun request_malformedJsonInDirectAuthenticationErrorResponse() = runTest {
404418
val request = DirectAuthTokenRequest.Password(context, "test_user", "test_password")
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.okta.directauth.model
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.fail
5+
import org.junit.Test
6+
7+
class DirectAuthenticationErrorCodeTest {
8+
@Test
9+
fun `fromString returns correct enum for all valid codes`() {
10+
DirectAuthenticationErrorCode.entries.forEach { expectedEnum ->
11+
val actualEnum = DirectAuthenticationErrorCode.fromString(expectedEnum.code)
12+
assertEquals(
13+
"fromString should return the correct enum for code '${expectedEnum.code}'",
14+
expectedEnum,
15+
actualEnum
16+
)
17+
}
18+
}
19+
20+
@Test
21+
fun `fromString throws IllegalArgumentException for unknown code`() {
22+
val unknownCode = "this_is_not_a_valid_code"
23+
try {
24+
DirectAuthenticationErrorCode.fromString(unknownCode)
25+
fail("Expected IllegalArgumentException was not thrown for code '$unknownCode'")
26+
} catch (e: IllegalArgumentException) {
27+
// This is the expected outcome.
28+
assertEquals("Unknown DirectAuthenticationErrorCode: $unknownCode", e.message)
29+
}
30+
}
31+
32+
@Test
33+
fun `enum codes have correct string values`() {
34+
assertEquals("mfa_required", DirectAuthenticationErrorCode.MFA_REQUIRED.code)
35+
assertEquals("authorization_pending", DirectAuthenticationErrorCode.AUTHORIZATION_PENDING.code)
36+
assertEquals("access_denied", DirectAuthenticationErrorCode.ACCESS_DENIED.code)
37+
assertEquals("invalid_grant", DirectAuthenticationErrorCode.INVALID_GRANT.code)
38+
assertEquals("invalid_request", DirectAuthenticationErrorCode.INVALID_REQUEST.code)
39+
assertEquals("invalid_scope", DirectAuthenticationErrorCode.INVALID_SCOPE.code)
40+
assertEquals("invalid_client", DirectAuthenticationErrorCode.INVALID_CLIENT.code)
41+
assertEquals("unauthorized_client", DirectAuthenticationErrorCode.UNAUTHORIZED_CLIENT.code)
42+
assertEquals("unsupported_grant_type", DirectAuthenticationErrorCode.UNSUPPORTED_GRANT_TYPE.code)
43+
assertEquals("unsupported_response_type", DirectAuthenticationErrorCode.UNSUPPORTED_RESPONSE_TYPE.code)
44+
assertEquals("temporarily_unavailable", DirectAuthenticationErrorCode.TEMPORARILY_UNAVAILABLE.code)
45+
assertEquals("server_error", DirectAuthenticationErrorCode.SERVER_ERROR.code)
46+
}
47+
}
48+

0 commit comments

Comments
 (0)