From 19259e79f4387e7bbd1e5286b2c24a727421f3c8 Mon Sep 17 00:00:00 2001 From: Partha Sirker Date: Wed, 24 Jun 2026 12:37:26 -0700 Subject: [PATCH] [auth] Validate nonce claim in id_token after PKCE token exchange When a caller supplies a nonce via AuthContext, the SDK now validates the nonce claim in the returned id_token after PKCE token exchange. If the id_token is missing or the nonce claim does not match the sent value, authentication fails with INVALID_NONCE. When no nonce is supplied, validation is skipped (backward compatible). Matches the iOS SDK behavior from uber-ios-sdk PR #337. Co-Authored-By: Claude --- .../uber/sdk2/auth/exception/AuthException.kt | 2 + .../uber/sdk2/auth/internal/AuthProvider.kt | 13 ++- .../sdk2/auth/internal/utils/NonceUtil.kt | 44 +++++++++ .../com/uber/sdk2/auth/response/UberToken.kt | 1 + .../sdk2/auth/internal/AuthProviderTest.kt | 94 +++++++++++++++++++ .../sdk2/auth/internal/utils/NonceUtilTest.kt | 75 +++++++++++++++ 6 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 authentication/src/main/kotlin/com/uber/sdk2/auth/internal/utils/NonceUtil.kt create mode 100644 authentication/src/test/kotlin/com/uber/sdk2/auth/internal/utils/NonceUtilTest.kt 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 fab997b..df8b052 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 @@ -86,8 +87,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/main/kotlin/com/uber/sdk2/auth/internal/utils/NonceUtil.kt b/authentication/src/main/kotlin/com/uber/sdk2/auth/internal/utils/NonceUtil.kt new file mode 100644 index 0000000..53ae708 --- /dev/null +++ b/authentication/src/main/kotlin/com/uber/sdk2/auth/internal/utils/NonceUtil.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2024 Uber Technologies, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.uber.sdk2.auth.internal.utils + +import android.util.Base64 +import org.json.JSONObject + +object NonceUtil { + + /** + * Extracts the `nonce` claim from a JWT id_token by decoding the payload segment (without + * verifying the signature). Returns null if the token is malformed or contains no nonce claim. + */ + fun extractNonceFromIdToken(idToken: String): String? { + val parts = idToken.split(".") + if (parts.size != 3) return null + return try { + val payload = Base64.decode(parts[1], Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + val json = JSONObject(String(payload, Charsets.UTF_8)) + json.optString("nonce", null) + } catch (_: Exception) { + null + } + } +} diff --git a/authentication/src/main/kotlin/com/uber/sdk2/auth/response/UberToken.kt b/authentication/src/main/kotlin/com/uber/sdk2/auth/response/UberToken.kt index c3a52e8..8d9b176 100644 --- a/authentication/src/main/kotlin/com/uber/sdk2/auth/response/UberToken.kt +++ b/authentication/src/main/kotlin/com/uber/sdk2/auth/response/UberToken.kt @@ -33,4 +33,5 @@ data class UberToken( @Json(name = "refresh_token") val refreshToken: String? = null, @Json(name = "expires_in") val expiresIn: Long? = null, @Json(name = "scope") val scope: String? = null, + @Json(name = "id_token") val idToken: String? = null, ) : Parcelable 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 37fef68..a14f046 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 @@ -445,4 +445,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" + } } diff --git a/authentication/src/test/kotlin/com/uber/sdk2/auth/internal/utils/NonceUtilTest.kt b/authentication/src/test/kotlin/com/uber/sdk2/auth/internal/utils/NonceUtilTest.kt new file mode 100644 index 0000000..e163a7a --- /dev/null +++ b/authentication/src/test/kotlin/com/uber/sdk2/auth/internal/utils/NonceUtilTest.kt @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2024 Uber Technologies, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.uber.sdk2.auth.internal.utils + +import com.uber.sdk2.auth.RobolectricTestBase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NonceUtilTest : RobolectricTestBase() { + + 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" + } + + @Test + fun `extractNonceFromIdToken returns nonce when present`() { + val jwt = buildJwt("""{"sub":"user123","nonce":"my-nonce-value"}""") + assertEquals("my-nonce-value", NonceUtil.extractNonceFromIdToken(jwt)) + } + + @Test + fun `extractNonceFromIdToken returns null when nonce is absent`() { + val jwt = buildJwt("""{"sub":"user123"}""") + assertNull(NonceUtil.extractNonceFromIdToken(jwt)) + } + + @Test + fun `extractNonceFromIdToken returns null for malformed JWT`() { + assertNull(NonceUtil.extractNonceFromIdToken("not-a-jwt")) + } + + @Test + fun `extractNonceFromIdToken returns null for two-segment token`() { + assertNull(NonceUtil.extractNonceFromIdToken("header.payload")) + } + + @Test + fun `extractNonceFromIdToken returns null for invalid base64 payload`() { + assertNull(NonceUtil.extractNonceFromIdToken("header.!!!invalid!!!.sig")) + } +}