diff --git a/authentication/src/main/kotlin/com/uber/sdk2/auth/exception/AuthException.kt b/authentication/src/main/kotlin/com/uber/sdk2/auth/exception/AuthException.kt index 4aa70fe..f6bbb07 100644 --- a/authentication/src/main/kotlin/com/uber/sdk2/auth/exception/AuthException.kt +++ b/authentication/src/main/kotlin/com/uber/sdk2/auth/exception/AuthException.kt @@ -49,5 +49,7 @@ sealed class AuthException(override val message: String) : RuntimeException(mess internal const val UNKNOWN = "Unknown error occurred" internal const val INVALID_STATE = "State parameter mismatch possible CSRF attack" + + internal const val INVALID_NONCE = "Nonce claim in id_token does not match the sent nonce" } } diff --git a/authentication/src/main/kotlin/com/uber/sdk2/auth/internal/AuthProvider.kt b/authentication/src/main/kotlin/com/uber/sdk2/auth/internal/AuthProvider.kt index 1448b44..c6feb54 100644 --- a/authentication/src/main/kotlin/com/uber/sdk2/auth/internal/AuthProvider.kt +++ b/authentication/src/main/kotlin/com/uber/sdk2/auth/internal/AuthProvider.kt @@ -31,6 +31,7 @@ import com.uber.sdk2.auth.internal.service.AuthService import com.uber.sdk2.auth.internal.sso.SsoLinkFactory import com.uber.sdk2.auth.internal.sso.UniversalSsoLink.Companion.RESPONSE_TYPE import com.uber.sdk2.auth.internal.utils.Base64Util +import com.uber.sdk2.auth.internal.utils.NonceUtil import com.uber.sdk2.auth.request.AuthContext import com.uber.sdk2.auth.request.AuthType import com.uber.sdk2.auth.request.SsoConfig @@ -89,8 +90,13 @@ class AuthProvider( ) return if (tokenResponse.isSuccessful) { - tokenResponse.body()?.let { AuthResult.Success(it) } - ?: AuthResult.Error(AuthException.ClientError("Token request failed with empty response")) + tokenResponse.body()?.let { token -> + val claimNonce = token.idToken?.let { NonceUtil.extractNonceFromIdToken(it) } + if (claimNonce != effectiveNonce) { + return AuthResult.Error(AuthException.ClientError(AuthException.INVALID_NONCE)) + } + AuthResult.Success(token) + } ?: AuthResult.Error(AuthException.ClientError("Token request failed with empty response")) } else { AuthResult.Error( AuthException.ClientError("Token request failed with code: ${tokenResponse.code()}") diff --git a/authentication/src/test/kotlin/com/uber/sdk2/auth/internal/AuthProviderTest.kt b/authentication/src/test/kotlin/com/uber/sdk2/auth/internal/AuthProviderTest.kt index 138b965..53feedc 100644 --- a/authentication/src/test/kotlin/com/uber/sdk2/auth/internal/AuthProviderTest.kt +++ b/authentication/src/test/kotlin/com/uber/sdk2/auth/internal/AuthProviderTest.kt @@ -84,11 +84,12 @@ class AuthProviderTest : RobolectricTestBase() { .thenReturn(Response.success(PARResponse("requestUri", "codeVerifier"))) whenever(codeVerifierGenerator.generateCodeVerifier()).thenReturn("verifier") whenever(codeVerifierGenerator.generateCodeChallenge("verifier")).thenReturn("challenge") - whenever(authService.token(any(), any(), any(), any(), any())) - .thenReturn(Response.success(UberToken(accessToken = "accessToken"))) val authContext = AuthContext(AuthDestination.CrossAppSso(listOf(CrossApp.Rider)), AuthType.PKCE(), null) val authProvider = AuthProvider(activity, authContext, authService, codeVerifierGenerator) + val idToken = buildJwt("""{"sub":"user","nonce":"${authProvider.effectiveNonce}"}""") + whenever(authService.token(any(), any(), any(), any(), any())) + .thenReturn(Response.success(UberToken(accessToken = "accessToken", idToken = idToken))) val result = authProvider.authenticate() verify(ssoLink).execute(any()) verify(authService, never()).loginParRequest(any(), any(), any(), any()) @@ -104,12 +105,13 @@ class AuthProviderTest : RobolectricTestBase() { .thenReturn(Response.success(PARResponse("requestUri", "codeVerifier"))) whenever(codeVerifierGenerator.generateCodeVerifier()).thenReturn("verifier") whenever(codeVerifierGenerator.generateCodeChallenge("verifier")).thenReturn("challenge") - whenever(authService.token(any(), any(), any(), any(), any())) - .thenReturn(Response.success(UberToken(accessToken = "accessToken"))) val prefillInfo = PrefillInfo("email", "firstName", "lastName", "phoneNumber") val authContext = AuthContext(AuthDestination.CrossAppSso(listOf(CrossApp.Rider)), AuthType.PKCE(), prefillInfo) val authProvider = AuthProvider(activity, authContext, authService, codeVerifierGenerator) + val idToken = buildJwt("""{"sub":"user","nonce":"${authProvider.effectiveNonce}"}""") + whenever(authService.token(any(), any(), any(), any(), any())) + .thenReturn(Response.success(UberToken(accessToken = "accessToken", idToken = idToken))) val argumentCaptor = argumentCaptor>() val result = authProvider.authenticate() verify(authService) @@ -371,8 +373,6 @@ class AuthProviderTest : RobolectricTestBase() { whenever(ssoLink.execute(any())).thenReturn("authCode") whenever(codeVerifierGenerator.generateCodeVerifier()).thenReturn("verifier") whenever(codeVerifierGenerator.generateCodeChallenge("verifier")).thenReturn("challenge") - whenever(authService.token(any(), any(), any(), any(), any())) - .thenReturn(Response.success(UberToken(accessToken = "accessToken"))) // Mock PAR request to fail val errorResponse: Response = Response.error(500, mock()) whenever(authService.loginParRequest(any(), any(), any(), any())).thenReturn(errorResponse) @@ -380,6 +380,9 @@ class AuthProviderTest : RobolectricTestBase() { val authContext = AuthContext(AuthDestination.CrossAppSso(listOf(CrossApp.Rider)), AuthType.PKCE(), prefillInfo) val authProvider = AuthProvider(activity, authContext, authService, codeVerifierGenerator) + val idToken = buildJwt("""{"sub":"user","nonce":"${authProvider.effectiveNonce}"}""") + whenever(authService.token(any(), any(), any(), any(), any())) + .thenReturn(Response.success(UberToken(accessToken = "accessToken", idToken = idToken))) val argumentCaptor = argumentCaptor>() val result = authProvider.authenticate() // Verify PAR request was attempted @@ -515,4 +518,115 @@ class AuthProviderTest : RobolectricTestBase() { assertEquals(AuthOptionalConfig(), authContext.options) assertEquals(UriConfig.UberEnvironment.PRODUCTION, authContext.environment) } + + // ---- Nonce validation tests ---- + + @Test + fun `test PKCE with nonce validates id_token nonce claim`() = runTest { + val idToken = buildJwt("""{"sub":"user","nonce":"my-nonce"}""") + whenever(ssoLink.execute(any())).thenReturn("code") + whenever(codeVerifierGenerator.generateCodeVerifier()).thenReturn("verifier") + whenever(codeVerifierGenerator.generateCodeChallenge("verifier")).thenReturn("challenge") + whenever(authService.token(any(), any(), any(), any(), any())) + .thenReturn(Response.success(UberToken(accessToken = "accessToken", idToken = idToken))) + val authContext = + AuthContext( + AuthDestination.CrossAppSso(listOf(CrossApp.Rider)), + AuthType.PKCE(), + nonce = "my-nonce", + ) + val authProvider = AuthProvider(activity, authContext, authService, codeVerifierGenerator) + val result = authProvider.authenticate() + assert(result is AuthResult.Success) + assert((result as AuthResult.Success).uberToken.accessToken == "accessToken") + } + + @Test + fun `test PKCE with nonce mismatch returns error`() = runTest { + val idToken = buildJwt("""{"sub":"user","nonce":"wrong-nonce"}""") + whenever(ssoLink.execute(any())).thenReturn("code") + whenever(codeVerifierGenerator.generateCodeVerifier()).thenReturn("verifier") + whenever(codeVerifierGenerator.generateCodeChallenge("verifier")).thenReturn("challenge") + whenever(authService.token(any(), any(), any(), any(), any())) + .thenReturn(Response.success(UberToken(accessToken = "accessToken", idToken = idToken))) + val authContext = + AuthContext( + AuthDestination.CrossAppSso(listOf(CrossApp.Rider)), + AuthType.PKCE(), + nonce = "expected-nonce", + ) + val authProvider = AuthProvider(activity, authContext, authService, codeVerifierGenerator) + val result = authProvider.authenticate() + assert(result is AuthResult.Error) + assertEquals(AuthException.INVALID_NONCE, (result as AuthResult.Error).authException.message) + } + + @Test + fun `test PKCE with nonce but missing id_token returns error`() = runTest { + whenever(ssoLink.execute(any())).thenReturn("code") + whenever(codeVerifierGenerator.generateCodeVerifier()).thenReturn("verifier") + whenever(codeVerifierGenerator.generateCodeChallenge("verifier")).thenReturn("challenge") + whenever(authService.token(any(), any(), any(), any(), any())) + .thenReturn(Response.success(UberToken(accessToken = "accessToken"))) + val authContext = + AuthContext( + AuthDestination.CrossAppSso(listOf(CrossApp.Rider)), + AuthType.PKCE(), + nonce = "my-nonce", + ) + val authProvider = AuthProvider(activity, authContext, authService, codeVerifierGenerator) + val result = authProvider.authenticate() + assert(result is AuthResult.Error) + assertEquals(AuthException.INVALID_NONCE, (result as AuthResult.Error).authException.message) + } + + @Test + fun `test PKCE always validates id_token nonce even when caller provides no nonce`() = runTest { + whenever(ssoLink.execute(any())).thenReturn("code") + whenever(codeVerifierGenerator.generateCodeVerifier()).thenReturn("verifier") + whenever(codeVerifierGenerator.generateCodeChallenge("verifier")).thenReturn("challenge") + whenever(authService.token(any(), any(), any(), any(), any())) + .thenReturn(Response.success(UberToken(accessToken = "accessToken"))) + val authContext = + AuthContext(AuthDestination.CrossAppSso(listOf(CrossApp.Rider)), AuthType.PKCE(), null) + val authProvider = AuthProvider(activity, authContext, authService, codeVerifierGenerator) + val result = authProvider.authenticate() + // Auto-generated nonce always set; missing id_token cannot satisfy validation + assert(result is AuthResult.Error) + assertEquals(AuthException.INVALID_NONCE, (result as AuthResult.Error).authException.message) + } + + @Test + fun `test PKCE with auto-generated nonce succeeds when id_token nonce matches`() = runTest { + whenever(ssoLink.execute(any())).thenReturn("code") + whenever(codeVerifierGenerator.generateCodeVerifier()).thenReturn("verifier") + whenever(codeVerifierGenerator.generateCodeChallenge("verifier")).thenReturn("challenge") + val authContext = + AuthContext(AuthDestination.CrossAppSso(listOf(CrossApp.Rider)), AuthType.PKCE(), null) + val authProvider = AuthProvider(activity, authContext, authService, codeVerifierGenerator) + val idToken = buildJwt("""{"sub":"user","nonce":"${authProvider.effectiveNonce}"}""") + whenever(authService.token(any(), any(), any(), any(), any())) + .thenReturn(Response.success(UberToken(accessToken = "accessToken", idToken = idToken))) + val result = authProvider.authenticate() + assert(result is AuthResult.Success) + assert((result as AuthResult.Success).uberToken.accessToken == "accessToken") + } + + private fun buildJwt(payloadJson: String): String { + val header = + android.util.Base64.encodeToString( + """{"alg":"RS256","typ":"JWT"}""".toByteArray(), + android.util.Base64.URL_SAFE or + android.util.Base64.NO_WRAP or + android.util.Base64.NO_PADDING, + ) + val payload = + android.util.Base64.encodeToString( + payloadJson.toByteArray(), + android.util.Base64.URL_SAFE or + android.util.Base64.NO_WRAP or + android.util.Base64.NO_PADDING, + ) + return "$header.$payload.fake-signature" + } }