From 5a8404827fda121a9a603b1a7723ac4eeff79aa5 Mon Sep 17 00:00:00 2001 From: Partha Sirker Date: Thu, 25 Jun 2026 23:35:50 -0700 Subject: [PATCH 1/2] [auth] Validate nonce claim in id_token after PKCE token exchange --- .../uber/sdk2/auth/exception/AuthException.kt | 2 + .../uber/sdk2/auth/internal/AuthProvider.kt | 13 ++- .../sdk2/auth/internal/AuthProviderTest.kt | 94 +++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) 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..89ef8d8 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,16 @@ 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 sentNonce = authContext.nonce + if (sentNonce != null) { + val claimNonce = token.idToken?.let { NonceUtil.extractNonceFromIdToken(it) } + if (claimNonce != sentNonce) { + 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..4208374 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 @@ -515,4 +515,98 @@ 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 without nonce skips id_token validation`() = 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() + 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" + } } From 1aecbd3fa71d215973a1a73363f3e0b3f8d21cac Mon Sep 17 00:00:00 2001 From: Partha Sirker Date: Fri, 26 Jun 2026 11:11:39 -0700 Subject: [PATCH 2/2] Remove null guard: nonce validation always runs (effectiveNonce always set) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-nonce (#279) guarantees effectiveNonce is never null, so the 'if (sentNonce != null)' guard is no longer needed. Validation is now unconditional — any PKCE response without an id_token nonce claim that matches effectiveNonce is rejected. Update existing PKCE tests to provide a matching id_token and add two new tests covering the always-validated path. Co-Authored-By: Claude --- .../uber/sdk2/auth/internal/AuthProvider.kt | 9 ++--- .../sdk2/auth/internal/AuthProviderTest.kt | 34 +++++++++++++++---- 2 files changed, 30 insertions(+), 13 deletions(-) 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 89ef8d8..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 @@ -91,12 +91,9 @@ class AuthProvider( return if (tokenResponse.isSuccessful) { tokenResponse.body()?.let { token -> - val sentNonce = authContext.nonce - if (sentNonce != null) { - val claimNonce = token.idToken?.let { NonceUtil.extractNonceFromIdToken(it) } - if (claimNonce != sentNonce) { - return AuthResult.Error(AuthException.ClientError(AuthException.INVALID_NONCE)) - } + 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")) 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 4208374..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 @@ -578,7 +581,7 @@ class AuthProviderTest : RobolectricTestBase() { } @Test - fun `test PKCE without nonce skips id_token validation`() = runTest { + 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") @@ -588,6 +591,23 @@ class AuthProviderTest : RobolectricTestBase() { 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") }