diff --git a/src/main/kotlin/com/workos/WorkOS.kt b/src/main/kotlin/com/workos/WorkOS.kt index 4ef78ec2..64d250a9 100644 --- a/src/main/kotlin/com/workos/WorkOS.kt +++ b/src/main/kotlin/com/workos/WorkOS.kt @@ -13,6 +13,7 @@ import com.workos.common.exceptions.UnauthorizedException import com.workos.common.exceptions.UnprocessableEntityException import com.workos.common.http.BadRequestExceptionResponse import com.workos.common.http.GenericErrorResponse +import com.workos.common.http.OAuthErrorResponse import com.workos.common.http.RequestConfig import com.workos.common.http.UnprocessableEntityExceptionResponse import com.workos.directorysync.DirectorySyncApi @@ -359,12 +360,18 @@ class WorkOS( } private fun handleResponseError(response: Response, payload: String) { - val requestId = response.header("X-Request-ID").first() + val requestId = response.header("X-Request-ID").firstOrNull() ?: "unknown" when (val status = response.statusCode) { 400 -> { - val responseData = mapper.readValue(payload, BadRequestExceptionResponse::class.java) - throw BadRequestException(responseData.message, responseData.code, responseData.errors, requestId) + val jsonNode = mapper.readTree(payload) + if (jsonNode.has("error") && (jsonNode.has("error_description") || !jsonNode.has("message"))) { + val oauthError = mapper.treeToValue(jsonNode, OAuthErrorResponse::class.java) + throw BadRequestException(oauthError.errorDescription, oauthError.error, null, requestId) + } else { + val responseData = mapper.treeToValue(jsonNode, BadRequestExceptionResponse::class.java) + throw BadRequestException(responseData.message, responseData.code, responseData.errors, requestId) + } } 401 -> { diff --git a/src/main/kotlin/com/workos/common/http/OAuthErrorResponse.kt b/src/main/kotlin/com/workos/common/http/OAuthErrorResponse.kt new file mode 100644 index 00000000..58962aa8 --- /dev/null +++ b/src/main/kotlin/com/workos/common/http/OAuthErrorResponse.kt @@ -0,0 +1,17 @@ +package com.workos.common.http + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Error response format for OAuth/Authentication endpoints. + * These endpoints return errors in the OAuth 2.0 standard format. + */ +internal data class OAuthErrorResponse +@JsonCreator constructor( + @JsonProperty("error") + val error: String? = null, + + @JsonProperty("error_description") + val errorDescription: String? = null +) \ No newline at end of file diff --git a/src/test/kotlin/com/workos/test/user_management/UserManagementApiTest.kt b/src/test/kotlin/com/workos/test/user_management/UserManagementApiTest.kt index 7a4f48a9..9f9db9c4 100644 --- a/src/test/kotlin/com/workos/test/user_management/UserManagementApiTest.kt +++ b/src/test/kotlin/com/workos/test/user_management/UserManagementApiTest.kt @@ -1,5 +1,6 @@ package com.workos.test.usermanagement +import com.workos.common.exceptions.BadRequestException import com.workos.common.models.ListMetadata import com.workos.common.models.Order import com.workos.test.TestBase @@ -494,6 +495,55 @@ class UserManagementApiTest : TestBase() { assertEquals(listOf("email", "profile"), response.oauthTokens?.scopes) } + @Test + fun authenticateWithCodeShouldHandleOAuthErrorResponse() { + // Tests fix for issue #287: OAuth error responses have different format + stubResponse( + "/user_management/authenticate", + """{ + "error": "invalid_grant", + "error_description": "The code 'INVALID_CODE' has expired or is invalid." + }""", + responseStatus = 400 + ) + + val exception = assertThrows(BadRequestException::class.java) { + workos.userManagement.authenticateWithCode( + "client_123", + "INVALID_CODE", + null + ) + } + + // OAuth errors should map error_description to message and error to code + assertEquals("The code 'INVALID_CODE' has expired or is invalid.", exception.message) + assertEquals("invalid_grant", exception.code) + assertEquals(null, exception.errors) + } + + @Test + fun authenticateWithCodeShouldHandleOAuthErrorWithoutDescription() { + // OAuth spec allows error_description to be optional + stubResponse( + "/user_management/authenticate", + """{ + "error": "invalid_client" + }""", + responseStatus = 400 + ) + + val exception = assertThrows(BadRequestException::class.java) { + workos.userManagement.authenticateWithCode( + "client_123", + "SOME_CODE", + null + ) + } + + assertEquals("invalid_client", exception.code) + assertEquals(null, exception.message) + } + @Test fun authenticateWithPasswordShouldReturnAuthenticationResponse() { val workos = createWorkOSClient()