From 9d006290f30b3bdbbe0de39b65eb0a01759d1ae0 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Tue, 13 Jan 2026 17:49:07 +0100 Subject: [PATCH 01/28] feat: add fpnv branch --- .../com/google/firebase/fpnv/FirebasePnv.java | 82 ++++++ .../firebase/fpnv/FirebasePnvErrorCode.java | 28 ++ .../firebase/fpnv/FirebasePnvException.java | 44 ++++ .../firebase/fpnv/FirebasePnvToken.java | 80 ++++++ .../internal/FirebasePnvTokenVerifier.java | 200 ++++++++++++++ .../fpnv/FirebasePnvErrorCodeTest.java | 34 +++ .../google/firebase/fpnv/FirebasePnvTest.java | 109 ++++++++ .../firebase/fpnv/FpnvTokenVerifierTest.java | 244 ++++++++++++++++++ 8 files changed, 821 insertions(+) create mode 100644 src/main/java/com/google/firebase/fpnv/FirebasePnv.java create mode 100644 src/main/java/com/google/firebase/fpnv/FirebasePnvErrorCode.java create mode 100644 src/main/java/com/google/firebase/fpnv/FirebasePnvException.java create mode 100644 src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java create mode 100644 src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java create mode 100644 src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java create mode 100644 src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java create mode 100644 src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnv.java b/src/main/java/com/google/firebase/fpnv/FirebasePnv.java new file mode 100644 index 000000000..e963289be --- /dev/null +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnv.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.fpnv; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.fpnv.internal.FirebasePnvTokenVerifier; +import com.google.firebase.internal.FirebaseService; + +/** + * This class is the entry point for the Firebase Phone Number Verification (FPNV) service. + * + *

You can get an instance of {@link FirebasePnv} via {@link #getInstance()}, + * or {@link #getInstance(FirebaseApp)} and then use it. + */ +public final class FirebasePnv { + private static final String SERVICE_ID = FirebasePnv.class.getName(); + private final FirebasePnvTokenVerifier tokenVerifier; + + private FirebasePnv(FirebaseApp app) { + this.tokenVerifier = new FirebasePnvTokenVerifier(app); + } + + /** + * Gets the {@link FirebasePnv} instance for the default {@link FirebaseApp}. + * + * @return The {@link FirebasePnv} instance for the default {@link FirebaseApp}. + */ + public static FirebasePnv getInstance() { + return getInstance(FirebaseApp.getInstance()); + } + + /** + * Gets the {@link FirebasePnv} instance for the specified {@link FirebaseApp}. + * + * @return The {@link FirebasePnv} instance for the specified {@link FirebaseApp}. + */ + public static synchronized FirebasePnv getInstance(FirebaseApp app) { + FirebaseFpnvService service = ImplFirebaseTrampolines.getService(app, SERVICE_ID, + FirebaseFpnvService.class); + if (service == null) { + service = ImplFirebaseTrampolines.addService(app, new FirebaseFpnvService(app)); + } + return service.getInstance(); + } + + /** + * Verifies a Firebase Phone Number Verification token (FPNV JWT). + * + * @param fpnvJwt The FPNV JWT string to verify. + * @return A verified {@link FirebasePnvToken}. + * @throws FirebasePnvException If verification fails. + */ + public FirebasePnvToken verifyToken(String fpnvJwt) throws FirebasePnvException { + return this.tokenVerifier.verifyToken(fpnvJwt); + } + + private static class FirebaseFpnvService extends FirebaseService { + FirebaseFpnvService(FirebaseApp app) { + super(SERVICE_ID, new FirebasePnv(app)); + } + } +} + + + + + diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvErrorCode.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvErrorCode.java new file mode 100644 index 000000000..b5dc1fac4 --- /dev/null +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvErrorCode.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.fpnv; + +/** + * Error codes that are used in {@link FirebasePnv}. + */ +public enum FirebasePnvErrorCode { + INVALID_ARGUMENT, + INVALID_TOKEN, + TOKEN_EXPIRED, + INTERNAL_ERROR, + SERVICE_ERROR, +} diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java new file mode 100644 index 000000000..b2cd80b0f --- /dev/null +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.fpnv; + +/** + * Generic exception related to Firebase Phone Number Verification. + * Check the error code and message for more + * details. + */ +public class FirebasePnvException extends RuntimeException { + private final FirebasePnvErrorCode errorCode; + + /** + * Exception that created from {@link FirebasePnvErrorCode} and {@link String} message. + * + * @param authErrorCode {@link FirebasePnvErrorCode} + * @param message {@link String} + */ + public FirebasePnvException( + FirebasePnvErrorCode authErrorCode, + String message + ) { + super(message); + this.errorCode = authErrorCode; + } + + public FirebasePnvErrorCode getFpnvErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java new file mode 100644 index 000000000..2ed6faede --- /dev/null +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.fpnv; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.collect.ImmutableMap; +import java.util.List; +import java.util.Map; + +/** + * Represents a verified Firebase Phone Number Verification token. + */ +public class FirebasePnvToken { + private final Map claims; + + public FirebasePnvToken(Map claims) { + // this.claims = claims != null ? Collections.unmodifiableMap(claims) : Collections.emptyMap(); + checkArgument(claims != null && claims.containsKey("sub"), + "Claims map must at least contain sub"); + this.claims = ImmutableMap.copyOf(claims); + } + + /** + * Returns the issuer identifier for the issuer of the response. + */ + public String getIssuer() { + return (String) claims.get("iss"); + } + + /** + * Returns the phone number of the user. + * This corresponds to the 'sub' claim in the JWT. + */ + public String getPhoneNumber() { + return (String) claims.get("sub"); + } + + /** + * Returns the audience for which this token is intended. + */ + public List getAudience() { + return (List) claims.get("aud"); + } + + /** + * Returns the expiration time in seconds since the Unix epoch. + */ + public long getExpirationTime() { + return (long) claims.get("exp"); + } + + /** + * Returns the issued-at time in seconds since the Unix epoch. + */ + public long getIssuedAt() { + return (long) claims.get("iat"); + } + + /** + * Returns the entire map of claims. + */ + public Map getClaims() { + return claims; + } +} diff --git a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java new file mode 100644 index 000000000..31c6d213c --- /dev/null +++ b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java @@ -0,0 +1,200 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.fpnv.internal; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Strings; +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.fpnv.FirebasePnvErrorCode; +import com.google.firebase.fpnv.FirebasePnvException; +import com.google.firebase.fpnv.FirebasePnvToken; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import com.nimbusds.jwt.proc.ExpiredJWTException; +import java.net.MalformedURLException; +import java.net.URL; +import java.text.ParseException; +import java.util.Date; +import java.util.Objects; + +/** + * Internal class to verify FPNV tokens. + */ +public class FirebasePnvTokenVerifier { + private static final String FPNV_JWKS_URL = "https://fpnv.googleapis.com/v1beta/jwks"; + private static final String HEADER_TYP = "JWT"; + + private final String projectId; + private final DefaultJWTProcessor jwtProcessor; + + /** + * Create {@link FirebasePnvTokenVerifier} for internal purposes. + * + * @param app The {@link FirebaseApp} to get a FirebaseAuth instance for. + */ + public FirebasePnvTokenVerifier(FirebaseApp app) { + this.projectId = getProjectId(app); + this.jwtProcessor = createJwtProcessor(); + } + + /** + * Main method that do. + * - Explicitly verify the header + * - Verify Signature and Structure + * - Verify Claims (Issuer, Audience, Expiration) + * - Construct Token Object + * + * @param token String input data + * @return {@link FirebasePnvToken} + * @throws FirebasePnvException Can throw {@link FirebasePnvException} + */ + public FirebasePnvToken verifyToken(String token) throws FirebasePnvException { + checkArgument(!Strings.isNullOrEmpty(token), "FPNV token must not be null or empty"); + + try { + // Parse the token first to inspect header + SignedJWT signedJwt = SignedJWT.parse(token); + + // Explicitly verify the header (alg & kid) + verifyHeader(signedJwt.getHeader()); + + // Verify Signature and Structure + JWTClaimsSet claims = jwtProcessor.process(signedJwt, null); + + // Verify Claims (Issuer, Audience, Expiration) + verifyClaims(claims); + + // Construct Token Object + return new FirebasePnvToken(claims.getClaims()); + } catch (ParseException e) { + throw new FirebasePnvException( + FirebasePnvErrorCode.INVALID_TOKEN, + "Failed to parse JWT token: " + e.getMessage() + ); + } catch (ExpiredJWTException e) { + throw new FirebasePnvException(FirebasePnvErrorCode.TOKEN_EXPIRED, "FPNV token has expired."); + } catch (BadJOSEException | JOSEException e) { + throw new FirebasePnvException(FirebasePnvErrorCode.SERVICE_ERROR, + "Chek your project: " + projectId + ". " + + e.getMessage() + ); + } + } + + private void verifyHeader(JWSHeader header) throws FirebasePnvException { + // Check Algorithm (alg) + if (!JWSAlgorithm.ES256.equals(header.getAlgorithm())) { + throw new FirebasePnvException( + FirebasePnvErrorCode.INVALID_ARGUMENT, + "FPNV has incorrect 'algorithm'. Expected " + JWSAlgorithm.ES256.getName() + + " but got " + header.getAlgorithm()); + } + + // Check Key ID (kid) + if (Strings.isNullOrEmpty(header.getKeyID())) { + throw new FirebasePnvException( + FirebasePnvErrorCode.INVALID_ARGUMENT, + "FPNV has no 'kid' claim." + ); + } + // Check Typ (typ) + if (Objects.isNull(header.getType()) || !HEADER_TYP.equals(header.getType().getType())) { + throw new FirebasePnvException( + FirebasePnvErrorCode.INVALID_ARGUMENT, + "FPNV has incorrect 'typ'. Expected " + HEADER_TYP + + " but got " + header.getType() + ); + } + + } + + private void verifyClaims(JWTClaimsSet claims) throws FirebasePnvException { + // Verify Issuer + String issuer = claims.getIssuer(); + + if (Strings.isNullOrEmpty(issuer)) { + throw new FirebasePnvException(FirebasePnvErrorCode.INVALID_ARGUMENT, + "Invalid issuer. Expected: " + issuer); + } + + // Verify Audience + if (claims.getAudience() == null + || claims.getAudience().isEmpty() + || !claims.getAudience().contains(issuer) + ) { + throw new FirebasePnvException(FirebasePnvErrorCode.INVALID_TOKEN, + "Invalid audience. Expected to contain: " + + issuer + " but found: " + claims.getAudience() + ); + } + + // Verify Subject for emptiness / null + if (Strings.isNullOrEmpty(claims.getSubject())) { + throw new FirebasePnvException( + FirebasePnvErrorCode.INVALID_TOKEN, + "Token has an empty 'sub' (phone number)." + ); + } + + // TODO: i guess this is redundant + // jwtProcessor.process did this already + // Verify Expiration + Date now = new Date(); + Date exp = claims.getExpirationTime(); + if (exp == null || now.after(exp)) { + throw new FirebasePnvException(FirebasePnvErrorCode.TOKEN_EXPIRED, "Token has expired."); + } + } + + private DefaultJWTProcessor createJwtProcessor() { + DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); + try { + // Use JWKSourceBuilder instead of deprecated RemoteJWKSet + JWKSource keySource = JWKSourceBuilder + .create(new URL(FPNV_JWKS_URL)) + .retrying(true) // Helper to retry on transient network errors + .build(); + + JWSKeySelector keySelector = + new JWSVerificationKeySelector<>(JWSAlgorithm.ES256, keySource); + processor.setJWSKeySelector(keySelector); + } catch (MalformedURLException e) { + throw new RuntimeException("Invalid JWKS URL", e); + } + return processor; + } + + private String getProjectId(FirebaseApp app) { + String projectId = ImplFirebaseTrampolines.getProjectId(app); + if (Strings.isNullOrEmpty(projectId)) { + throw new IllegalArgumentException("Project ID is required in FirebaseOptions."); + } + return projectId; + } +} diff --git a/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java b/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java new file mode 100644 index 000000000..6bc35c911 --- /dev/null +++ b/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.fpnv; + +import static org.junit.Assert.*; + +import org.junit.Test; + + +public class FirebasePnvErrorCodeTest { + @Test + public void testEnum() { + // Assert that all values exist + assertNotNull(FirebasePnvErrorCode.valueOf("INVALID_ARGUMENT")); + assertNotNull(FirebasePnvErrorCode.valueOf("INVALID_TOKEN")); + assertNotNull(FirebasePnvErrorCode.valueOf("TOKEN_EXPIRED")); + assertNotNull(FirebasePnvErrorCode.valueOf("INTERNAL_ERROR")); + assertNotNull(FirebasePnvErrorCode.valueOf("SERVICE_ERROR")); + } +} \ No newline at end of file diff --git a/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java b/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java new file mode 100644 index 000000000..5144aa6d5 --- /dev/null +++ b/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.fpnv; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.TestOnlyImplFirebaseTrampolines; +import com.google.firebase.fpnv.internal.FirebasePnvTokenVerifier; +import com.google.firebase.internal.FirebaseProcessEnvironment; +import com.google.firebase.testing.ServiceAccount; +import com.google.firebase.testing.TestUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.lang.reflect.Field; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class FirebasePnvTest { + private static final FirebaseOptions firebaseOptions = FirebaseOptions.builder() + .setCredentials(TestUtils.getCertCredential(ServiceAccount.OWNER.asStream())) + .build(); + + @Mock + private FirebasePnvTokenVerifier mockVerifier; + + private FirebasePnv firebasePnv; + + @Before + public void setUp() throws Exception { + // noinspection resource + MockitoAnnotations.openMocks(this); + + // Initialize Fpnv + FirebaseApp.initializeApp(firebaseOptions); + firebasePnv = FirebasePnv.getInstance(); + + // Inject the mock verifier + Field verifierField = FirebasePnv.class.getDeclaredField("tokenVerifier"); + verifierField.setAccessible(true); + verifierField.set(firebasePnv, mockVerifier); + } + + @After + public void tearDown() { + FirebaseProcessEnvironment.clearCache(); + TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); + } + + @Test + public void testGetInstance() { + FirebasePnv firebasePnv = FirebasePnv.getInstance(); + assertNotNull(firebasePnv); + assertSame(firebasePnv, FirebasePnv.getInstance()); + } + + @Test + public void testGetInstanceForApp() { + FirebaseApp app = FirebaseApp.initializeApp(firebaseOptions, "testGetInstanceForApp"); + FirebasePnv firebasePnv = FirebasePnv.getInstance(app); + assertNotNull(firebasePnv); + assertSame(firebasePnv, FirebasePnv.getInstance(app)); + } + + @Test + public void testVerifyToken_DelegatesToVerifier() { + String testToken = "test.fpnv.token"; + FirebasePnvToken expectedToken = mock(FirebasePnvToken.class); + + when(mockVerifier.verifyToken(testToken)).thenReturn(expectedToken); + + FirebasePnvToken result = firebasePnv.verifyToken(testToken); + + assertEquals(expectedToken, result); + verify(mockVerifier, times(1)).verifyToken(testToken); + } + + @Test + public void testVerifyToken_PropagatesException() { + String testToken = "bad.token"; + FirebasePnvException error = new FirebasePnvException(FirebasePnvErrorCode.INVALID_TOKEN, "Bad token"); + + when(mockVerifier.verifyToken(testToken)).thenThrow(error); + + FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + FirebasePnv.getInstance().verifyToken(testToken) + ); + assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); + } +} \ No newline at end of file diff --git a/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java b/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java new file mode 100644 index 000000000..cfa4b6aa3 --- /dev/null +++ b/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java @@ -0,0 +1,244 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.fpnv; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.TestOnlyImplFirebaseTrampolines; +import com.google.firebase.fpnv.internal.FirebasePnvTokenVerifier; +import com.google.firebase.internal.FirebaseProcessEnvironment; +import com.google.firebase.testing.ServiceAccount; +import com.google.firebase.testing.TestUtils; +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.lang.reflect.Field; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Arrays; +import java.util.Date; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +public class FpnvTokenVerifierTest { + private static final FirebaseOptions firebaseOptions = FirebaseOptions.builder() + .setCredentials(TestUtils.getCertCredential(ServiceAccount.OWNER.asStream())) + .build(); + private static final String PROJECT_ID = "test-project-123"; + private static final String ISSUER = "https://fpnv.googleapis.com/projects/" + PROJECT_ID; + private static final String[] AUD = new String[]{ + ISSUER, + "https://google.com/projects/" + }; + + @Mock + private DefaultJWTProcessor mockJwtProcessor; + + private FirebasePnvTokenVerifier verifier; + private KeyPair rsaKeyPair; + private ECKey ecKey; + private JWSHeader header; + + @Before + public void setUp() throws Exception { + // noinspection resource + MockitoAnnotations.openMocks(this); + + // Generate a real RSA key pair for signing test tokens + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048); + rsaKeyPair = gen.generateKeyPair(); + + ecKey = new ECKeyGenerator(Curve.P_256).keyID("ec-key-id").generate(); + + // Initialize Verifier and inject mock processor + FirebaseApp firebaseApp = FirebaseApp.initializeApp(firebaseOptions); + verifier = new FirebasePnvTokenVerifier(firebaseApp); + + Field processorField = FirebasePnvTokenVerifier.class.getDeclaredField("jwtProcessor"); + processorField.setAccessible(true); + processorField.set(verifier, mockJwtProcessor); + + // Create a valid ES256 token + header = new JWSHeader.Builder(JWSAlgorithm.ES256) + .keyID(ecKey.getKeyID()) + .type(JOSEObjectType.JWT) + .build(); + } + + @After + public void tearDown() { + FirebaseProcessEnvironment.clearCache(); + TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); + } + + // --- Helper to create a signed JWT string --- + private String createToken(JWSHeader header, JWTClaimsSet claims) throws Exception { + SignedJWT jwt = new SignedJWT(header, claims); + + // Sign based on the algorithm in the header + if (JWSAlgorithm.RS256.equals(header.getAlgorithm())) { + jwt.sign(new RSASSASigner(rsaKeyPair.getPrivate())); + } else if (JWSAlgorithm.HS256.equals(header.getAlgorithm())) { + jwt.sign(new MACSigner("12345678901234567890123456789012")); // 32-byte secret + } else if (JWSAlgorithm.ES256.equals(header.getAlgorithm())) { + jwt.sign(new ECDSASigner(ecKey.toECPrivateKey())); + } + + return jwt.serialize(); + } + + @Test + public void testVerifyToken_Success() throws Exception { + Date now = new Date(); + Date exp = new Date(now.getTime() + 3600 * 1000); // 1 hour valid + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(Arrays.asList(AUD)) + .subject("+15551234567") + .issueTime(now) + .expirationTime(exp) + .build(); + + String tokenString = createToken(header, claims); + + // 1. Mock the processor to return these claims (skipping real signature verification) + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(claims); + + // 2. Execute + FirebasePnvToken result = verifier.verifyToken(tokenString); + + // 3. Verify + assertNotNull(result); + assertEquals("+15551234567", result.getPhoneNumber()); + assertEquals(ISSUER, result.getIssuer()); + } + + @Test + public void testVerifyToken_Header_WrongAlgorithm() throws Exception { + // Create token with HS256 (HMAC) instead of ES256 + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.HS256).build(); + JWTClaimsSet claims = new JWTClaimsSet.Builder().build(); + + String tokenString = createToken(header, claims); + + // Should fail at header check, before reaching the processor + FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePnvErrorCode.INVALID_ARGUMENT, e.getFpnvErrorCode()); + assertTrue(e.getMessage().contains("algorithm")); + } + + @Test + public void testVerifyToken_Header_MissingKeyId() throws Exception { + // ES256 but missing 'kid' + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256).build(); + JWTClaimsSet claims = new JWTClaimsSet.Builder().build(); + + String tokenString = createToken(header, claims); + + FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePnvErrorCode.INVALID_ARGUMENT, e.getFpnvErrorCode()); + assertTrue(e.getMessage().contains("FPNV has no 'kid' claim")); + } + + @Test + public void testVerifyToken_Claims_Expired() throws Exception { + Date past = new Date(System.currentTimeMillis() - 10000); // Expired + + JWTClaimsSet expiredClaims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(ISSUER) + .subject("+1555") + .expirationTime(past) + .build(); + + String tokenString = createToken(header, expiredClaims); + + // Mock processor returning the expired claims + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(expiredClaims); + + FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePnvErrorCode.TOKEN_EXPIRED, e.getFpnvErrorCode()); + } + + @Test + public void testVerifyToken_Claims_WrongAudience() throws Exception { + JWTClaimsSet badClaims = new JWTClaimsSet.Builder() + .issuer("https://wrong.com") // Wrong issuer + .audience(ISSUER) + .subject("+1555") + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + String tokenString = createToken(header, badClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims); + + FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); + assertTrue(e.getMessage().contains("Invalid audience.")); + } + + @Test + public void testVerifyToken_Claims_NoSubject() throws Exception { + JWTClaimsSet noSubClaims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(ISSUER) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); // Missing subject + + String tokenString = createToken(header, noSubClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(noSubClaims); + + FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); + assertTrue(e.getMessage().contains("Token has an empty 'sub' (phone number)")); + } +} From 0fdfa4a058d62d625f5655a724a670fc76303632 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Tue, 13 Jan 2026 18:02:43 +0100 Subject: [PATCH 02/28] feat: update pom file --- pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pom.xml b/pom.xml index a642c1936..4617ffb0a 100644 --- a/pom.xml +++ b/pom.xml @@ -446,6 +446,11 @@ httpclient5 5.3.1 + + com.nimbusds + nimbus-jose-jwt + 10.6 + From c8b102657c1345e4f461542e6121ba3b1c465281 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Wed, 14 Jan 2026 19:34:36 +0100 Subject: [PATCH 03/28] chore: resolve comments --- .../firebase/fpnv/FirebasePnvException.java | 10 +++--- .../firebase/fpnv/FirebasePnvToken.java | 13 ++++--- .../internal/FirebasePnvTokenVerifier.java | 11 +----- .../fpnv/FirebasePnvErrorCodeTest.java | 2 +- .../google/firebase/fpnv/FirebasePnvTest.java | 25 ++++++++----- .../firebase/fpnv/FpnvTokenVerifierTest.java | 36 ++++++++++--------- 6 files changed, 51 insertions(+), 46 deletions(-) diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java index b2cd80b0f..3a390fca8 100644 --- a/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java @@ -21,24 +21,24 @@ * Check the error code and message for more * details. */ -public class FirebasePnvException extends RuntimeException { +public class FirebasePnvException extends Exception { private final FirebasePnvErrorCode errorCode; /** * Exception that created from {@link FirebasePnvErrorCode} and {@link String} message. * - * @param authErrorCode {@link FirebasePnvErrorCode} + * @param errorCode {@link FirebasePnvErrorCode} * @param message {@link String} */ public FirebasePnvException( - FirebasePnvErrorCode authErrorCode, + FirebasePnvErrorCode errorCode, String message ) { super(message); - this.errorCode = authErrorCode; + this.errorCode = errorCode; } public FirebasePnvErrorCode getFpnvErrorCode() { return errorCode; } -} +} \ No newline at end of file diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java index 2ed6faede..757e0c429 100644 --- a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java @@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkArgument; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import java.util.List; import java.util.Map; @@ -54,21 +55,25 @@ public String getPhoneNumber() { * Returns the audience for which this token is intended. */ public List getAudience() { - return (List) claims.get("aud"); + Object audience = claims.get("aud"); + if (audience instanceof String) { + return ImmutableList.of((String) audience); + } + return (List) audience; } /** * Returns the expiration time in seconds since the Unix epoch. */ public long getExpirationTime() { - return (long) claims.get("exp"); + return ((java.util.Date) claims.get("exp")).getTime(); } /** * Returns the issued-at time in seconds since the Unix epoch. */ public long getIssuedAt() { - return (long) claims.get("iat"); + return ((java.util.Date) claims.get("iat")).getTime(); } /** @@ -77,4 +82,4 @@ public long getIssuedAt() { public Map getClaims() { return claims; } -} +} \ No newline at end of file diff --git a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java index 31c6d213c..1aa00c9b9 100644 --- a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java +++ b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java @@ -101,7 +101,7 @@ public FirebasePnvToken verifyToken(String token) throws FirebasePnvException { throw new FirebasePnvException(FirebasePnvErrorCode.TOKEN_EXPIRED, "FPNV token has expired."); } catch (BadJOSEException | JOSEException e) { throw new FirebasePnvException(FirebasePnvErrorCode.SERVICE_ERROR, - "Chek your project: " + projectId + ". " + "Check your project: " + projectId + ". " + e.getMessage() ); } @@ -161,15 +161,6 @@ private void verifyClaims(JWTClaimsSet claims) throws FirebasePnvException { "Token has an empty 'sub' (phone number)." ); } - - // TODO: i guess this is redundant - // jwtProcessor.process did this already - // Verify Expiration - Date now = new Date(); - Date exp = claims.getExpirationTime(); - if (exp == null || now.after(exp)) { - throw new FirebasePnvException(FirebasePnvErrorCode.TOKEN_EXPIRED, "Token has expired."); - } } private DefaultJWTProcessor createJwtProcessor() { diff --git a/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java b/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java index 6bc35c911..f7f519827 100644 --- a/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java +++ b/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java @@ -16,7 +16,7 @@ package com.google.firebase.fpnv; -import static org.junit.Assert.*; +import static org.junit.Assert.assertNotNull; import org.junit.Test; diff --git a/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java b/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java index 5144aa6d5..6be4ceea3 100644 --- a/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java +++ b/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java @@ -16,6 +16,15 @@ package com.google.firebase.fpnv; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; import com.google.firebase.TestOnlyImplFirebaseTrampolines; @@ -23,18 +32,13 @@ import com.google.firebase.internal.FirebaseProcessEnvironment; import com.google.firebase.testing.ServiceAccount; import com.google.firebase.testing.TestUtils; +import java.lang.reflect.Field; import org.junit.After; import org.junit.Before; -import org.junit.Ignore; import org.junit.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import java.lang.reflect.Field; - -import static org.junit.Assert.*; -import static org.mockito.Mockito.*; - public class FirebasePnvTest { private static final FirebaseOptions firebaseOptions = FirebaseOptions.builder() .setCredentials(TestUtils.getCertCredential(ServiceAccount.OWNER.asStream())) @@ -82,7 +86,7 @@ public void testGetInstanceForApp() { } @Test - public void testVerifyToken_DelegatesToVerifier() { + public void testVerifyToken_DelegatesToVerifier() throws FirebasePnvException { String testToken = "test.fpnv.token"; FirebasePnvToken expectedToken = mock(FirebasePnvToken.class); @@ -95,9 +99,12 @@ public void testVerifyToken_DelegatesToVerifier() { } @Test - public void testVerifyToken_PropagatesException() { + public void testVerifyToken_PropagatesException() throws FirebasePnvException { String testToken = "bad.token"; - FirebasePnvException error = new FirebasePnvException(FirebasePnvErrorCode.INVALID_TOKEN, "Bad token"); + FirebasePnvException error = new FirebasePnvException( + FirebasePnvErrorCode.INVALID_TOKEN, + "Bad token" + ); when(mockVerifier.verifyToken(testToken)).thenThrow(error); diff --git a/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java b/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java index cfa4b6aa3..bab502dca 100644 --- a/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java +++ b/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java @@ -16,6 +16,13 @@ package com.google.firebase.fpnv; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; import com.google.firebase.TestOnlyImplFirebaseTrampolines; @@ -36,27 +43,23 @@ import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; import com.nimbusds.jwt.proc.DefaultJWTProcessor; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - +import com.nimbusds.jwt.proc.ExpiredJWTException; import java.lang.reflect.Field; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.util.Arrays; import java.util.Date; - -import static org.junit.Assert.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; public class FpnvTokenVerifierTest { private static final FirebaseOptions firebaseOptions = FirebaseOptions.builder() .setCredentials(TestUtils.getCertCredential(ServiceAccount.OWNER.asStream())) .build(); - private static final String PROJECT_ID = "test-project-123"; + private static final String PROJECT_ID = "mock-project-id"; private static final String ISSUER = "https://fpnv.googleapis.com/projects/" + PROJECT_ID; private static final String[] AUD = new String[]{ ISSUER, @@ -182,19 +185,18 @@ public void testVerifyToken_Header_MissingKeyId() throws Exception { @Test public void testVerifyToken_Claims_Expired() throws Exception { - Date past = new Date(System.currentTimeMillis() - 10000); // Expired - - JWTClaimsSet expiredClaims = new JWTClaimsSet.Builder() + JWTClaimsSet claims = new JWTClaimsSet.Builder() .issuer(ISSUER) .audience(ISSUER) .subject("+1555") - .expirationTime(past) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) .build(); - String tokenString = createToken(header, expiredClaims); + String tokenString = createToken(header, claims); + ExpiredJWTException error = new ExpiredJWTException("Bad token"); // Mock processor returning the expired claims - when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(expiredClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> verifier.verifyToken(tokenString) From e504376bf215cd8ffdb63d5d4d583ef5a6612b9b Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Wed, 14 Jan 2026 20:04:33 +0100 Subject: [PATCH 04/28] chore: resolve comments --- .../firebase/fpnv/FirebasePnvException.java | 20 +++++++++++++++++-- .../firebase/fpnv/FirebasePnvToken.java | 9 +++++++-- .../internal/FirebasePnvTokenVerifier.java | 14 +++++++++---- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java index 3a390fca8..db16f547d 100644 --- a/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java @@ -24,6 +24,23 @@ public class FirebasePnvException extends Exception { private final FirebasePnvErrorCode errorCode; + /** + * Exception that created from {@link FirebasePnvErrorCode}, + * {@link String} message and {@link Throwable} cause. + * + * @param errorCode {@link FirebasePnvErrorCode} + * @param message {@link String} + * @param cause {@link Throwable} + */ + public FirebasePnvException( + FirebasePnvErrorCode errorCode, + String message, + Throwable cause + ) { + super(message, cause); + this.errorCode = errorCode; + } + /** * Exception that created from {@link FirebasePnvErrorCode} and {@link String} message. * @@ -34,8 +51,7 @@ public FirebasePnvException( FirebasePnvErrorCode errorCode, String message ) { - super(message); - this.errorCode = errorCode; + this(errorCode, message, null); } public FirebasePnvErrorCode getFpnvErrorCode() { diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java index 757e0c429..e54b1eb6d 100644 --- a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java @@ -30,7 +30,6 @@ public class FirebasePnvToken { private final Map claims; public FirebasePnvToken(Map claims) { - // this.claims = claims != null ? Collections.unmodifiableMap(claims) : Collections.emptyMap(); checkArgument(claims != null && claims.containsKey("sub"), "Claims map must at least contain sub"); this.claims = ImmutableMap.copyOf(claims); @@ -58,8 +57,14 @@ public List getAudience() { Object audience = claims.get("aud"); if (audience instanceof String) { return ImmutableList.of((String) audience); + } else if (audience instanceof List) { + // The nimbus-jose-jwt library should provide a List, but we copy it + // to an immutable list for safety and to prevent modification. + @SuppressWarnings("unchecked") + List audienceList = (List) audience; + return ImmutableList.copyOf(audienceList); } - return (List) audience; + return ImmutableList.of(); } /** diff --git a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java index 1aa00c9b9..a3cf78e2e 100644 --- a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java +++ b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java @@ -95,14 +95,20 @@ public FirebasePnvToken verifyToken(String token) throws FirebasePnvException { } catch (ParseException e) { throw new FirebasePnvException( FirebasePnvErrorCode.INVALID_TOKEN, - "Failed to parse JWT token: " + e.getMessage() + "Failed to parse JWT token: " + e.getMessage(), + e ); } catch (ExpiredJWTException e) { - throw new FirebasePnvException(FirebasePnvErrorCode.TOKEN_EXPIRED, "FPNV token has expired."); + throw new FirebasePnvException( + FirebasePnvErrorCode.TOKEN_EXPIRED, + "FPNV token has expired.", + e + ); } catch (BadJOSEException | JOSEException e) { throw new FirebasePnvException(FirebasePnvErrorCode.SERVICE_ERROR, "Check your project: " + projectId + ". " - + e.getMessage() + + e.getMessage(), + e ); } } @@ -140,7 +146,7 @@ private void verifyClaims(JWTClaimsSet claims) throws FirebasePnvException { if (Strings.isNullOrEmpty(issuer)) { throw new FirebasePnvException(FirebasePnvErrorCode.INVALID_ARGUMENT, - "Invalid issuer. Expected: " + issuer); + "FPNV token has no 'iss' (issuer) claim."); } // Verify Audience From 025e377c8a77cd375f0b05576e6bb65e506e9f96 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Thu, 15 Jan 2026 11:48:49 +0100 Subject: [PATCH 05/28] feat: update code and tests --- .../firebase/fpnv/FirebasePnvException.java | 2 +- .../firebase/fpnv/FirebasePnvToken.java | 12 +- .../internal/FirebasePnvTokenVerifier.java | 27 +++-- .../fpnv/FirebasePnvErrorCodeTest.java | 2 +- .../google/firebase/fpnv/FirebasePnvTest.java | 2 +- .../firebase/fpnv/FpnvTokenVerifierTest.java | 107 +++++++++++++++--- 6 files changed, 123 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java index db16f547d..5e037ead8 100644 --- a/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java @@ -57,4 +57,4 @@ public FirebasePnvException( public FirebasePnvErrorCode getFpnvErrorCode() { return errorCode; } -} \ No newline at end of file +} diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java index e54b1eb6d..05627ac65 100644 --- a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java @@ -20,6 +20,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.nimbusds.jwt.JWTClaimsSet; import java.util.List; import java.util.Map; @@ -29,6 +30,11 @@ public class FirebasePnvToken { private final Map claims; + /** + * Create an instance of {@link FirebasePnvToken} from {@link JWTClaimsSet} claims. + * + * @param claims Map claims. + */ public FirebasePnvToken(Map claims) { checkArgument(claims != null && claims.containsKey("sub"), "Claims map must at least contain sub"); @@ -71,14 +77,14 @@ public List getAudience() { * Returns the expiration time in seconds since the Unix epoch. */ public long getExpirationTime() { - return ((java.util.Date) claims.get("exp")).getTime(); + return ((java.util.Date) claims.get("exp")).getTime() / 1000L; } /** * Returns the issued-at time in seconds since the Unix epoch. */ public long getIssuedAt() { - return ((java.util.Date) claims.get("iat")).getTime(); + return ((java.util.Date) claims.get("iat")).getTime() / 1000L; } /** @@ -87,4 +93,4 @@ public long getIssuedAt() { public Map getClaims() { return claims; } -} \ No newline at end of file +} diff --git a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java index a3cf78e2e..63c6fca4f 100644 --- a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java +++ b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java @@ -40,7 +40,6 @@ import java.net.MalformedURLException; import java.net.URL; import java.text.ParseException; -import java.util.Date; import java.util.Objects; /** @@ -64,11 +63,11 @@ public FirebasePnvTokenVerifier(FirebaseApp app) { } /** - * Main method that do. - * - Explicitly verify the header - * - Verify Signature and Structure - * - Verify Claims (Issuer, Audience, Expiration) - * - Construct Token Object + * Main method that performs the following verification steps: + * - Explicitly verifies the header + * - Verifies signature and structure + * - Verifies claims (e.g. issuer, audience, expiration) + * - Constructs a token object upon successful verification * * @param token String input data * @return {@link FirebasePnvToken} @@ -104,10 +103,18 @@ public FirebasePnvToken verifyToken(String token) throws FirebasePnvException { "FPNV token has expired.", e ); - } catch (BadJOSEException | JOSEException e) { - throw new FirebasePnvException(FirebasePnvErrorCode.SERVICE_ERROR, + } catch (BadJOSEException e) { + throw new FirebasePnvException( + FirebasePnvErrorCode.INVALID_TOKEN, "Check your project: " + projectId + ". " - + e.getMessage(), + + "FPNV token is invalid: " + e.getMessage(), + e + ); + } catch (JOSEException e) { + throw new FirebasePnvException( + FirebasePnvErrorCode.INTERNAL_ERROR, + "Check your project: " + projectId + ". " + + "Failed to verify FPNV token signature: " + e.getMessage(), e ); } @@ -121,7 +128,6 @@ private void verifyHeader(JWSHeader header) throws FirebasePnvException { "FPNV has incorrect 'algorithm'. Expected " + JWSAlgorithm.ES256.getName() + " but got " + header.getAlgorithm()); } - // Check Key ID (kid) if (Strings.isNullOrEmpty(header.getKeyID())) { throw new FirebasePnvException( @@ -141,6 +147,7 @@ private void verifyHeader(JWSHeader header) throws FirebasePnvException { } private void verifyClaims(JWTClaimsSet claims) throws FirebasePnvException { + checkArgument(!Objects.isNull(claims), "JWTClaimsSet claims must not be null"); // Verify Issuer String issuer = claims.getIssuer(); diff --git a/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java b/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java index f7f519827..d833710c9 100644 --- a/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java +++ b/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java @@ -31,4 +31,4 @@ public void testEnum() { assertNotNull(FirebasePnvErrorCode.valueOf("INTERNAL_ERROR")); assertNotNull(FirebasePnvErrorCode.valueOf("SERVICE_ERROR")); } -} \ No newline at end of file +} diff --git a/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java b/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java index 6be4ceea3..4d2195f9d 100644 --- a/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java +++ b/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java @@ -113,4 +113,4 @@ public void testVerifyToken_PropagatesException() throws FirebasePnvException { ); assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); } -} \ No newline at end of file +} diff --git a/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java b/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java index bab502dca..6fd215fc9 100644 --- a/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java +++ b/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java @@ -30,6 +30,7 @@ import com.google.firebase.internal.FirebaseProcessEnvironment; import com.google.firebase.testing.ServiceAccount; import com.google.firebase.testing.TestUtils; +import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JOSEObjectType; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.JWSHeader; @@ -39,6 +40,7 @@ import com.nimbusds.jose.jwk.Curve; import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jose.proc.BadJOSEException; import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; @@ -56,10 +58,11 @@ import org.mockito.MockitoAnnotations; public class FpnvTokenVerifierTest { + private static final String PROJECT_ID = "mock-project-id"; private static final FirebaseOptions firebaseOptions = FirebaseOptions.builder() + .setProjectId(PROJECT_ID) .setCredentials(TestUtils.getCertCredential(ServiceAccount.OWNER.asStream())) .build(); - private static final String PROJECT_ID = "mock-project-id"; private static final String ISSUER = "https://fpnv.googleapis.com/projects/" + PROJECT_ID; private static final String[] AUD = new String[]{ ISSUER, @@ -73,6 +76,7 @@ public class FpnvTokenVerifierTest { private KeyPair rsaKeyPair; private ECKey ecKey; private JWSHeader header; + private JWTClaimsSet claims; @Before public void setUp() throws Exception { @@ -99,6 +103,15 @@ public void setUp() throws Exception { .keyID(ecKey.getKeyID()) .type(JOSEObjectType.JWT) .build(); + + // Create a valid JWTClaimsSet + claims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(Arrays.asList(AUD)) + .subject("+15551234567") + .issueTime(new Date()) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); } @After @@ -124,18 +137,15 @@ private String createToken(JWSHeader header, JWTClaimsSet claims) throws Excepti } @Test - public void testVerifyToken_Success() throws Exception { - Date now = new Date(); - Date exp = new Date(now.getTime() + 3600 * 1000); // 1 hour valid - - JWTClaimsSet claims = new JWTClaimsSet.Builder() - .issuer(ISSUER) - .audience(Arrays.asList(AUD)) - .subject("+15551234567") - .issueTime(now) - .expirationTime(exp) - .build(); + public void testVerifyToken_NullOrEmptyToken() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> + verifier.verifyToken("") + ); + assertTrue(e.getMessage().contains("FPNV token must not be null")); + } + @Test + public void testVerifyToken_Success() throws Exception { String tokenString = createToken(header, claims); // 1. Mock the processor to return these claims (skipping real signature verification) @@ -167,6 +177,25 @@ public void testVerifyToken_Header_WrongAlgorithm() throws Exception { assertTrue(e.getMessage().contains("algorithm")); } + @Test + public void testVerifyToken_Header_WrongTyp() throws Exception { + JWSHeader header = new JWSHeader + .Builder(JWSAlgorithm.ES256) + .keyID(ecKey.getKeyID()) + .type(JOSEObjectType.JOSE) + .build(); + JWTClaimsSet claims = new JWTClaimsSet.Builder().build(); + + String tokenString = createToken(header, claims); + + FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePnvErrorCode.INVALID_ARGUMENT, e.getFpnvErrorCode()); + assertTrue(e.getMessage().contains("has incorrect 'typ'")); + } + @Test public void testVerifyToken_Header_MissingKeyId() throws Exception { // ES256 but missing 'kid' @@ -195,7 +224,6 @@ public void testVerifyToken_Claims_Expired() throws Exception { String tokenString = createToken(header, claims); ExpiredJWTException error = new ExpiredJWTException("Bad token"); - // Mock processor returning the expired claims when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> @@ -243,4 +271,57 @@ public void testVerifyToken_Claims_NoSubject() throws Exception { assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); assertTrue(e.getMessage().contains("Token has an empty 'sub' (phone number)")); } + + @Test + public void testVerifyToken_ParseException() { + FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + verifier.verifyToken(" ") + ); + assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); + assertTrue(e.getMessage().contains("Failed to parse JWT token")); + } + + @Test + public void testVerifyToken_BadJOSEException() throws Exception { + String tokenString = createToken(header, claims); + String errorMessage = "BadJOSEException"; + BadJOSEException error = new BadJOSEException(errorMessage); + + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); + + FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); + assertEquals( + "Check your project: " + + PROJECT_ID + + ". FPNV token is invalid: " + + errorMessage, + e.getMessage() + ); + } + + @Test + public void testVerifyToken_JOSEException() throws Exception { + String tokenString = createToken(header, claims); + String errorMessage = "JOSEException"; + JOSEException error = new JOSEException(errorMessage); + + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); + + FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePnvErrorCode.INTERNAL_ERROR, e.getFpnvErrorCode()); + assertEquals( + "Check your project: " + + PROJECT_ID + + ". Failed to verify FPNV token signature: " + + errorMessage, + e.getMessage() + ); + } } From e935445214fedb69ee5178abe7b0af0bfed2b73d Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Fri, 16 Jan 2026 15:04:53 +0100 Subject: [PATCH 06/28] chore: resolve comments --- .../com/google/firebase/fpnv/FirebasePnvToken.java | 6 +++--- .../fpnv/internal/FirebasePnvTokenVerifier.java | 13 +++---------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java index 05627ac65..5486ea312 100644 --- a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java @@ -31,9 +31,9 @@ public class FirebasePnvToken { private final Map claims; /** - * Create an instance of {@link FirebasePnvToken} from {@link JWTClaimsSet} claims. + * Create an instance of {@link FirebasePnvToken} from a map of JWT claims. * - * @param claims Map claims. + * @param claims A map of JWT claims. */ public FirebasePnvToken(Map claims) { checkArgument(claims != null && claims.containsKey("sub"), @@ -91,6 +91,6 @@ public long getIssuedAt() { * Returns the entire map of claims. */ public Map getClaims() { - return claims; + return ImmutableMap.copyOf(claims); } } diff --git a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java index 63c6fca4f..319f89594 100644 --- a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java +++ b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java @@ -77,19 +77,12 @@ public FirebasePnvToken verifyToken(String token) throws FirebasePnvException { checkArgument(!Strings.isNullOrEmpty(token), "FPNV token must not be null or empty"); try { - // Parse the token first to inspect header SignedJWT signedJwt = SignedJWT.parse(token); - - // Explicitly verify the header (alg & kid) verifyHeader(signedJwt.getHeader()); - // Verify Signature and Structure JWTClaimsSet claims = jwtProcessor.process(signedJwt, null); - - // Verify Claims (Issuer, Audience, Expiration) verifyClaims(claims); - // Construct Token Object return new FirebasePnvToken(claims.getClaims()); } catch (ParseException e) { throw new FirebasePnvException( @@ -122,7 +115,7 @@ public FirebasePnvToken verifyToken(String token) throws FirebasePnvException { private void verifyHeader(JWSHeader header) throws FirebasePnvException { // Check Algorithm (alg) - if (!JWSAlgorithm.ES256.equals(header.getAlgorithm())) { + if (!header.getAlgorithm().equals(JWSAlgorithm.ES256)) { throw new FirebasePnvException( FirebasePnvErrorCode.INVALID_ARGUMENT, "FPNV has incorrect 'algorithm'. Expected " + JWSAlgorithm.ES256.getName() @@ -135,8 +128,8 @@ private void verifyHeader(JWSHeader header) throws FirebasePnvException { "FPNV has no 'kid' claim." ); } - // Check Typ (typ) - if (Objects.isNull(header.getType()) || !HEADER_TYP.equals(header.getType().getType())) { + // Check Type (typ) + if (Objects.isNull(header.getType()) || !header.getType().toString().equals(HEADER_TYP)) { throw new FirebasePnvException( FirebasePnvErrorCode.INVALID_ARGUMENT, "FPNV has incorrect 'typ'. Expected " + HEADER_TYP From 0ba049503381402bac0ad645ab75b8aaec294139 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Fri, 16 Jan 2026 15:23:59 +0100 Subject: [PATCH 07/28] feat: update FirebasePnvException --- .../firebase/fpnv/FirebasePnvException.java | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java index 5e037ead8..e60ab8359 100644 --- a/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java @@ -16,12 +16,15 @@ package com.google.firebase.fpnv; +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseException; + /** * Generic exception related to Firebase Phone Number Verification. * Check the error code and message for more * details. */ -public class FirebasePnvException extends Exception { +public class FirebasePnvException extends FirebaseException { private final FirebasePnvErrorCode errorCode; /** @@ -37,7 +40,7 @@ public FirebasePnvException( String message, Throwable cause ) { - super(message, cause); + super(mapToFirebaseError(errorCode), message, cause); this.errorCode = errorCode; } @@ -57,4 +60,23 @@ public FirebasePnvException( public FirebasePnvErrorCode getFpnvErrorCode() { return errorCode; } + + private static ErrorCode mapToFirebaseError(FirebasePnvErrorCode code) { + if (code == null) { + return ErrorCode.INTERNAL; + } + switch (code) { + case INVALID_ARGUMENT: + return ErrorCode.INVALID_ARGUMENT; + case TOKEN_EXPIRED: + case INVALID_TOKEN: + return ErrorCode.UNAUTHENTICATED; + case SERVICE_ERROR: + return ErrorCode.UNAVAILABLE; + case INTERNAL_ERROR: + default: + return ErrorCode.INTERNAL; + } + } } + From 04dab1e512cac10dbba94d0d30892722f96f41f3 Mon Sep 17 00:00:00 2001 From: boikoa-gl Date: Fri, 16 Jan 2026 15:30:42 +0100 Subject: [PATCH 08/28] Update src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java index 5486ea312..50ac2f239 100644 --- a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java @@ -91,6 +91,6 @@ public long getIssuedAt() { * Returns the entire map of claims. */ public Map getClaims() { - return ImmutableMap.copyOf(claims); + return claims; } } From 8c9f1d97180a683418be1888dc689b5b25d73b3f Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Fri, 16 Jan 2026 15:38:01 +0100 Subject: [PATCH 09/28] chore: resolve robot comments --- .../firebase/fpnv/internal/FirebasePnvTokenVerifier.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java index 319f89594..a7a318c74 100644 --- a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java +++ b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java @@ -25,6 +25,7 @@ import com.google.firebase.fpnv.FirebasePnvException; import com.google.firebase.fpnv.FirebasePnvToken; import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.JWSHeader; import com.nimbusds.jose.jwk.source.JWKSource; @@ -129,7 +130,7 @@ private void verifyHeader(JWSHeader header) throws FirebasePnvException { ); } // Check Type (typ) - if (Objects.isNull(header.getType()) || !header.getType().toString().equals(HEADER_TYP)) { + if (!JOSEObjectType.JWT.equals(header.getType())) { throw new FirebasePnvException( FirebasePnvErrorCode.INVALID_ARGUMENT, "FPNV has incorrect 'typ'. Expected " + HEADER_TYP From 91bb749a70b52e309c819102c1c31e7187fcb20d Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Tue, 10 Feb 2026 08:25:48 +0100 Subject: [PATCH 10/28] chore: add test coverage --- .../firebase/fpnv/FirebasePnvToken.java | 1 - .../internal/FirebasePnvTokenVerifier.java | 21 ++- .../google/firebase/fpnv/FirebasePnvTest.java | 33 ++++ .../firebase/fpnv/FirebasePnvTokenTest.java | 90 +++++++++ .../{ => internal}/FpnvTokenVerifierTest.java | 171 +++++++++++++++++- 5 files changed, 301 insertions(+), 15 deletions(-) create mode 100644 src/test/java/com/google/firebase/fpnv/FirebasePnvTokenTest.java rename src/test/java/com/google/firebase/fpnv/{ => internal}/FpnvTokenVerifierTest.java (59%) diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java index 50ac2f239..44165eeca 100644 --- a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java @@ -20,7 +20,6 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import com.nimbusds.jwt.JWTClaimsSet; import java.util.List; import java.util.Map; diff --git a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java index a7a318c74..a0af1f4e6 100644 --- a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java +++ b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java @@ -151,8 +151,7 @@ private void verifyClaims(JWTClaimsSet claims) throws FirebasePnvException { } // Verify Audience - if (claims.getAudience() == null - || claims.getAudience().isEmpty() + if (claims.getAudience().isEmpty() || !claims.getAudience().contains(issuer) ) { throw new FirebasePnvException(FirebasePnvErrorCode.INVALID_TOKEN, @@ -174,10 +173,7 @@ private DefaultJWTProcessor createJwtProcessor() { DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); try { // Use JWKSourceBuilder instead of deprecated RemoteJWKSet - JWKSource keySource = JWKSourceBuilder - .create(new URL(FPNV_JWKS_URL)) - .retrying(true) // Helper to retry on transient network errors - .build(); + JWKSource keySource = createKeySource(); JWSKeySelector keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.ES256, keySource); @@ -188,6 +184,19 @@ private DefaultJWTProcessor createJwtProcessor() { return processor; } + /** + * Helper JWKSourceBuilder. + * + * @return an instance of JWKSource + * @throws MalformedURLException if URL is invalid + */ + protected JWKSource createKeySource() throws MalformedURLException { + return JWKSourceBuilder + .create(new URL(FPNV_JWKS_URL)) + .retrying(true) // Helper to retry on transient network errors + .build(); + } + private String getProjectId(FirebaseApp app) { String projectId = ImplFirebaseTrampolines.getProjectId(app); if (Strings.isNullOrEmpty(projectId)) { diff --git a/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java b/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java index 4d2195f9d..352bf1900 100644 --- a/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java +++ b/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java @@ -18,6 +18,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.mock; @@ -113,4 +114,36 @@ public void testVerifyToken_PropagatesException() throws FirebasePnvException { ); assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); } + + @Test + public void testVerifyToken_PropagatesException_Service_Error() throws FirebasePnvException { + String testToken = "SERVICE_ERROR"; + FirebasePnvException error = new FirebasePnvException( + FirebasePnvErrorCode.SERVICE_ERROR, + "SERVICE_ERROR" + ); + + when(mockVerifier.verifyToken(testToken)).thenThrow(error); + + FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + FirebasePnv.getInstance().verifyToken(testToken) + ); + assertEquals(FirebasePnvErrorCode.SERVICE_ERROR, e.getFpnvErrorCode()); + } + + @Test + public void testVerifyToken_PropagatesException_Internal_Error() throws FirebasePnvException { + String testToken = "INTERNAL"; + FirebasePnvException error = new FirebasePnvException( + null, + "INTERNAL" + ); + + when(mockVerifier.verifyToken(testToken)).thenThrow(error); + + FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + FirebasePnv.getInstance().verifyToken(testToken) + ); + assertNull(null, e.getFpnvErrorCode()); + } } diff --git a/src/test/java/com/google/firebase/fpnv/FirebasePnvTokenTest.java b/src/test/java/com/google/firebase/fpnv/FirebasePnvTokenTest.java new file mode 100644 index 000000000..53615cd5f --- /dev/null +++ b/src/test/java/com/google/firebase/fpnv/FirebasePnvTokenTest.java @@ -0,0 +1,90 @@ +package com.google.firebase.fpnv; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.common.collect.ImmutableList; +import com.google.firebase.TestOnlyImplFirebaseTrampolines; +import com.google.firebase.internal.FirebaseProcessEnvironment; +import com.nimbusds.jwt.JWTClaimsSet; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import org.junit.After; +import org.junit.Test; + + + +public class FirebasePnvTokenTest { + private static final String PROJECT_ID = "mock-project-id-1"; + private static final String ISSUER = "https://fpnv.googleapis.com/projects/" + PROJECT_ID; + private final String subject = "+15551234567"; + + @After + public void tearDown() { + FirebaseProcessEnvironment.clearCache(); + TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); + } + + @Test + public void test_Audience_Empty() { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .subject(subject) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + FirebasePnvToken firebasePnvToken = new FirebasePnvToken(claims.getClaims()); + + assertNotNull(firebasePnvToken); + assertEquals(ImmutableList.of(), firebasePnvToken.getAudience()); + } + + @Test + public void test_Audience_List() { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .subject(subject) + .audience(ImmutableList.of()) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + FirebasePnvToken firebasePnvToken = new FirebasePnvToken(claims.getClaims()); + + assertNotNull(firebasePnvToken); + assertEquals(ImmutableList.of(), firebasePnvToken.getAudience()); + } + + @Test + public void test_Audience_String() { + Map claims = new HashMap(); + claims.put("sub", subject); + claims.put("aud", ISSUER); + + + FirebasePnvToken firebasePnvToken = new FirebasePnvToken(claims); + + assertNotNull(firebasePnvToken); + assertEquals(ImmutableList.of(ISSUER), firebasePnvToken.getAudience()); + } + + @Test + public void test_No_Sub() { + Map claims = new HashMap(); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> + new FirebasePnvToken(claims) + ); + assertTrue(e.getMessage().contains("Claims map must at least contain sub")); + } + + @Test + public void test_Null_Sub() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> + new FirebasePnvToken(null) + ); + assertTrue(e.getMessage().contains("Claims map must at least contain sub")); + } +} diff --git a/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java b/src/test/java/com/google/firebase/fpnv/internal/FpnvTokenVerifierTest.java similarity index 59% rename from src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java rename to src/test/java/com/google/firebase/fpnv/internal/FpnvTokenVerifierTest.java index 6fd215fc9..751f6b9c9 100644 --- a/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java +++ b/src/test/java/com/google/firebase/fpnv/internal/FpnvTokenVerifierTest.java @@ -14,19 +14,23 @@ * limitations under the License. */ -package com.google.firebase.fpnv; +package com.google.firebase.fpnv.internal; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; import com.google.firebase.TestOnlyImplFirebaseTrampolines; -import com.google.firebase.fpnv.internal.FirebasePnvTokenVerifier; +import com.google.firebase.fpnv.FirebasePnvErrorCode; +import com.google.firebase.fpnv.FirebasePnvException; +import com.google.firebase.fpnv.FirebasePnvToken; import com.google.firebase.internal.FirebaseProcessEnvironment; import com.google.firebase.testing.ServiceAccount; import com.google.firebase.testing.TestUtils; @@ -46,10 +50,16 @@ import com.nimbusds.jwt.SignedJWT; import com.nimbusds.jwt.proc.DefaultJWTProcessor; import com.nimbusds.jwt.proc.ExpiredJWTException; + +import java.io.ByteArrayInputStream; import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.MalformedURLException; +import java.nio.charset.StandardCharsets; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.util.Arrays; +import java.util.Collections; import java.util.Date; import org.junit.After; import org.junit.Before; @@ -58,7 +68,7 @@ import org.mockito.MockitoAnnotations; public class FpnvTokenVerifierTest { - private static final String PROJECT_ID = "mock-project-id"; + private static final String PROJECT_ID = "mock-project-id-2"; private static final FirebaseOptions firebaseOptions = FirebaseOptions.builder() .setProjectId(PROJECT_ID) .setCredentials(TestUtils.getCertCredential(ServiceAccount.OWNER.asStream())) @@ -77,6 +87,9 @@ public class FpnvTokenVerifierTest { private ECKey ecKey; private JWSHeader header; private JWTClaimsSet claims; + private final String subject = "+15551234567"; + private final Date issueTime = new Date(); + private final Date expirationTime = new Date(System.currentTimeMillis() + 10000); @Before public void setUp() throws Exception { @@ -108,9 +121,9 @@ public void setUp() throws Exception { claims = new JWTClaimsSet.Builder() .issuer(ISSUER) .audience(Arrays.asList(AUD)) - .subject("+15551234567") - .issueTime(new Date()) - .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .subject(subject) + .issueTime(issueTime) + .expirationTime(expirationTime) .build(); } @@ -156,8 +169,12 @@ public void testVerifyToken_Success() throws Exception { // 3. Verify assertNotNull(result); - assertEquals("+15551234567", result.getPhoneNumber()); + assertEquals(subject, result.getPhoneNumber()); + assertEquals(issueTime.getTime() / 1000L, result.getIssuedAt()); + assertEquals(expirationTime.getTime() / 1000L, result.getExpirationTime()); + assertEquals(Arrays.asList(AUD), result.getAudience()); assertEquals(ISSUER, result.getIssuer()); + assertEquals(ISSUER, result.getClaims().get("iss")); } @Test @@ -212,6 +229,39 @@ public void testVerifyToken_Header_MissingKeyId() throws Exception { assertTrue(e.getMessage().contains("FPNV has no 'kid' claim")); } + @Test + public void testVerifyToken_Claims_Null() throws Exception { + JWTClaimsSet noSubClaims = new JWTClaimsSet.Builder() + .build(); + + String tokenString = createToken(header, noSubClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(null); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertTrue(e.getMessage().contains("JWTClaimsSet claims must not be null")); + } + + @Test + public void testVerifyToken_Claims_NoIssuer() throws Exception { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .audience(ISSUER) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + String tokenString = createToken(header, claims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(claims); + + FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePnvErrorCode.INVALID_ARGUMENT, e.getFpnvErrorCode()); + assertTrue(e.getMessage().contains("FPNV token has no 'iss' (issuer) claim.")); + } + @Test public void testVerifyToken_Claims_Expired() throws Exception { JWTClaimsSet claims = new JWTClaimsSet.Builder() @@ -238,7 +288,7 @@ public void testVerifyToken_Claims_WrongAudience() throws Exception { JWTClaimsSet badClaims = new JWTClaimsSet.Builder() .issuer("https://wrong.com") // Wrong issuer .audience(ISSUER) - .subject("+1555") + .subject(subject) .expirationTime(new Date(System.currentTimeMillis() + 10000)) .build(); @@ -253,6 +303,26 @@ public void testVerifyToken_Claims_WrongAudience() throws Exception { assertTrue(e.getMessage().contains("Invalid audience.")); } + @Test + public void testVerifyToken_Claims_EmptyAudience() throws Exception { + JWTClaimsSet badClaims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(Collections.emptyList()) + .subject(subject) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + String tokenString = createToken(header, badClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims); + + FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); + assertTrue(e.getMessage().contains("Invalid audience. Expected to contain: ")); + } + @Test public void testVerifyToken_Claims_NoSubject() throws Exception { JWTClaimsSet noSubClaims = new JWTClaimsSet.Builder() @@ -324,4 +394,89 @@ public void testVerifyToken_JOSEException() throws Exception { e.getMessage() ); } + + @Test + public void testVerifyToken_EmptyProjectId() { + String jsonString = "{\n" + + " \"type\": \"service_account\",\n" + + " \"project_id\": \"\",\n" + + " \"private_key_id\": \"mock-key-id-1\",\n" + + " \"private_key\": \"-----BEGIN PRIVATE KEY-----\\n" + + "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDrqbYkSM6sixYX" + + "\\ngClj447vB/04RwUFykc54ntbyvbymUOJgyAUJLNjEIig60OIXpvdwt/xzyxvmns4" + + "\\nivbmWxJpANBDUziUt7AwLkYAEQxkfcP72PFiSGNkFPrxzZWEGcK3E4slaEe6xdFa" + + "\\n0AuefIcDSwIMmRP7+20unThJw1jCG4rQTbnuEwM/4U5mK1nXC3s3mzf8p9IHZ5Xi" + + "\\nEBBxKWY1c9Ly6VNwDR7xxh8sLfEJmG57C+iJRZLUloAWqQlnRM0vK5Z6MmMwnSpZ" + + "\\nW7KgYEl13WEMhR4ZaCZ5Gy5O+5x4Do363459obDbWK67grcx/qtFnyQq8HVDKyI9" + + "\\nJZpVpwR1AgMBAAECggEAa8AcHLkBbljlz/b0dcydFOO1Pt8SB9S1/lx0hMLnaIL1" + + "\\nI1HGAA/LyZbMsa8AIMEJSTsKA9jy+1BJ2M+JFkg7wbDyiGXrr+vQ7iaqMOuam/P5" + + "\\nARTvQT3R2/fPyXFzVIQmyGhyLbdhXJ+IGpqXRW6wmKvaEwKG5abPBAo0q11bHtxy" + + "\\nUV9RMXiW6cvzqgkthb7lO3k1ae4s+juiCPZFFpgTT9LkHYxf0XkpAZCvdUJlmf+B" + + "\\ngc6bgobtN/zQ3l2hjGHFnNFhaQtNzd2xGcAuAR+BmoOx37YIn7ddYtm4RUgKnjZm" + + "\\nFesOC8YumD1S2ioHsXXCb+BXVrARJTTFxIFboiVGnQKBgQD67nXZfsuKXE/BPh/X" + + "\\nMMDjtcoYf4T++3BNe01I69fnfB4DAQ5yQ1dA7MTe7tQUO99e5OzhZJhAMsQYc82D" + + "\\nLodOpYAeQCa0wN8eYuw5PAIe5G0+62bwNIy9WljcePQl2nkl4rU7fFZLu6yERvRQ" + + "\\nA+kn5Dx+wyVYTvDLeE13x3DoVwKBgQDwbEySxyPtxmuDPw2s8rdW9ZVYs71hzULu" + + "\\nc9RaPpzSdSzOEewgGOygL3wcqENcU3nT3baMlqZEp/BIL8z1bf8UzQRGebimfWG8" + + "\\nlUL1BzLjZMnGXMA7+bhL+iQ98E5BBXHC7I8ir4Qej5235N4UPvqTuhNCisiGod8F" + + "\\nE1ScFGSqEwKBgQCzv9HHxR5EtK+k+72PRqtF8tkcB2zbwn3F4wePrvHwLmbJPB5/" + + "\\nF2IPbgvwriBZhjISJebR5l9xzWvPIFUdHV1rpv5JrSaM4IRzneUdcrEKNBNVuQb6" + + "\\nFoqisW9qL3KlEwUpcGbmf8DJa1y/PJySHNsN6l6zZ1L/GT1AY6MKpGFq7QKBgQCw" + + "\\nvNw5lhzqYU+Npt91wONYEKaeE1tntw253vo+8QI1kB/EyNYM7mWch+uz4VnLWC4Z" + + "\\nukXE6cYGeHIhjsobraWzc9btu/MqqMcda5hSKd2V3fSaVnqWXEfHynWz9qCAGfF7" + + "\\n+oxqUh5MnQSzN5KtzXJFAKfB5eXtWrdossIjDrbFcwKBgQDOUO39/wRP781pf8vV" + + "\\naEzklwT64QlbgqK5iBntKvQLTy3xPMqtzJd2RGfTwgMQ6G2PV6W4WHKj9bTpujcM" + + "\\nxk7rLcIEXovagJC82ZCGujo5joJ3fam9/q9I5ju5xw13yMOHyeyzsErCpSP/Xr8f" + + "\\nr5uOncBw2twGqOZ+FlQtCdE1Dg==\\n-----END PRIVATE KEY-----\\n\",\n" + + " \"client_email\": \"mock-project-id-none@mock-project-id.iam.gserviceaccount.com\",\n" + + " \"client_id\": \"1234567890\",\n" + + " \"auth_uri\": \"https://accounts.google.com/o/oauth2/auth\",\n" + + " \"token_uri\": \"https://accounts.google.com/o/oauth2/token\",\n" + + " \"auth_provider_x509_cert_url\": \"https://www.googleapis.com/oauth2/v1/certs\",\n" + + " \"client_x509_cert_url\": \"https://www.googleapis.com/robot/v1/metadata/x509/mock-project-id-none%40mock-project-id.iam.gserviceaccount.com\"\n" + + "}"; + + FirebaseOptions localFirebaseOptions = FirebaseOptions.builder() + .setCredentials(TestUtils.getCertCredential( + new ByteArrayInputStream( + jsonString.getBytes(StandardCharsets.UTF_8) + ) + ) + ).build(); + + // Initialize Verifier and inject mock processor + FirebaseApp firebaseApp = FirebaseApp.initializeApp(localFirebaseOptions, "second"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> + new FirebasePnvTokenVerifier(firebaseApp) + ); + + assertEquals("Project ID is required in FirebaseOptions.", e.getMessage()); + } + + @Test + public void testCreateJwtProcessor_HandlesException() throws Exception { + FirebaseApp firebaseApp = FirebaseApp.initializeApp(firebaseOptions, "third"); + // 1. Create the spy + FirebasePnvTokenVerifier original = new FirebasePnvTokenVerifier(firebaseApp); + FirebasePnvTokenVerifier spyClass = spy(original); + + // 2. Force the protected method to throw the checked exception + doThrow(new MalformedURLException("Simulated bad URL")) + .when(spyClass).createKeySource(); + + // 3. Invoke and catch + Method method = FirebasePnvTokenVerifier.class.getDeclaredMethod("createJwtProcessor"); + method.setAccessible(true); + + try { + method.invoke(spyClass); + } catch (Exception e) { + Throwable cause = e.getCause(); + assertEquals(RuntimeException.class, cause.getClass()); + assertEquals("Invalid JWKS URL", cause.getMessage()); + assertTrue(cause.getCause() instanceof MalformedURLException); + } + } + } From 929335b44b13553f0658ef273af5817ba881f3e3 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 5 May 2026 14:14:19 -0400 Subject: [PATCH 11/28] rename fpnv to fully qualified name --- .../com/google/firebase/fpnv/FirebasePnv.java | 82 ------------ .../FirebasePhoneNumberVerification.java | 78 +++++++++++ ...basePhoneNumberVerificationErrorCode.java} | 6 +- ...basePhoneNumberVerificationException.java} | 30 ++--- ...FirebasePhoneNumberVerificationToken.java} | 10 +- ...PhoneNumberVerificationTokenVerifier.java} | 112 +++++++--------- ...PhoneNumberVerificationErrorCodeTest.java} | 16 +-- .../FirebasePhoneNumberVerificationTest.java} | 73 +++++----- ...basePhoneNumberVerificationTokenTest.java} | 50 ++++--- ...eNumberVerificationTokenVerifierTest.java} | 125 +++++++----------- 10 files changed, 263 insertions(+), 319 deletions(-) delete mode 100644 src/main/java/com/google/firebase/fpnv/FirebasePnv.java create mode 100644 src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java rename src/main/java/com/google/firebase/{fpnv/FirebasePnvErrorCode.java => phonenumberverification/FirebasePhoneNumberVerificationErrorCode.java} (79%) rename src/main/java/com/google/firebase/{fpnv/FirebasePnvException.java => phonenumberverification/FirebasePhoneNumberVerificationException.java} (61%) rename src/main/java/com/google/firebase/{fpnv/FirebasePnvToken.java => phonenumberverification/FirebasePhoneNumberVerificationToken.java} (87%) rename src/main/java/com/google/firebase/{fpnv/internal/FirebasePnvTokenVerifier.java => phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java} (55%) rename src/test/java/com/google/firebase/{fpnv/FirebasePnvErrorCodeTest.java => phonenumberverification/FirebasePhoneNumberVerificationErrorCodeTest.java} (56%) rename src/test/java/com/google/firebase/{fpnv/FirebasePnvTest.java => phonenumberverification/FirebasePhoneNumberVerificationTest.java} (53%) rename src/test/java/com/google/firebase/{fpnv/FirebasePnvTokenTest.java => phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java} (56%) rename src/test/java/com/google/firebase/{fpnv/internal/FpnvTokenVerifierTest.java => phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java} (76%) diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnv.java b/src/main/java/com/google/firebase/fpnv/FirebasePnv.java deleted file mode 100644 index e963289be..000000000 --- a/src/main/java/com/google/firebase/fpnv/FirebasePnv.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.fpnv; - -import com.google.firebase.FirebaseApp; -import com.google.firebase.ImplFirebaseTrampolines; -import com.google.firebase.fpnv.internal.FirebasePnvTokenVerifier; -import com.google.firebase.internal.FirebaseService; - -/** - * This class is the entry point for the Firebase Phone Number Verification (FPNV) service. - * - *

You can get an instance of {@link FirebasePnv} via {@link #getInstance()}, - * or {@link #getInstance(FirebaseApp)} and then use it. - */ -public final class FirebasePnv { - private static final String SERVICE_ID = FirebasePnv.class.getName(); - private final FirebasePnvTokenVerifier tokenVerifier; - - private FirebasePnv(FirebaseApp app) { - this.tokenVerifier = new FirebasePnvTokenVerifier(app); - } - - /** - * Gets the {@link FirebasePnv} instance for the default {@link FirebaseApp}. - * - * @return The {@link FirebasePnv} instance for the default {@link FirebaseApp}. - */ - public static FirebasePnv getInstance() { - return getInstance(FirebaseApp.getInstance()); - } - - /** - * Gets the {@link FirebasePnv} instance for the specified {@link FirebaseApp}. - * - * @return The {@link FirebasePnv} instance for the specified {@link FirebaseApp}. - */ - public static synchronized FirebasePnv getInstance(FirebaseApp app) { - FirebaseFpnvService service = ImplFirebaseTrampolines.getService(app, SERVICE_ID, - FirebaseFpnvService.class); - if (service == null) { - service = ImplFirebaseTrampolines.addService(app, new FirebaseFpnvService(app)); - } - return service.getInstance(); - } - - /** - * Verifies a Firebase Phone Number Verification token (FPNV JWT). - * - * @param fpnvJwt The FPNV JWT string to verify. - * @return A verified {@link FirebasePnvToken}. - * @throws FirebasePnvException If verification fails. - */ - public FirebasePnvToken verifyToken(String fpnvJwt) throws FirebasePnvException { - return this.tokenVerifier.verifyToken(fpnvJwt); - } - - private static class FirebaseFpnvService extends FirebaseService { - FirebaseFpnvService(FirebaseApp app) { - super(SERVICE_ID, new FirebasePnv(app)); - } - } -} - - - - - diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java new file mode 100644 index 000000000..eceb878ee --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java @@ -0,0 +1,78 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.phonenumberverification; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.internal.FirebaseService; +import com.google.firebase.phonenumberverification.internal.FirebasePhoneNumberVerificationTokenVerifier; + +/** + * This class is the entry point for the Firebase Phone Number Verification service. + * + *

You can get an instance of {@link FirebasePhoneNumberVerification} via {@link #getInstance()}, + * or {@link #getInstance(FirebaseApp)} and then use it. + */ +public final class FirebasePhoneNumberVerification { + private static final String SERVICE_ID = FirebasePhoneNumberVerification.class.getName(); + private final FirebasePhoneNumberVerificationTokenVerifier tokenVerifier; + + private FirebasePhoneNumberVerification(FirebaseApp app) { + this.tokenVerifier = new FirebasePhoneNumberVerificationTokenVerifier(app); + } + + /** + * Gets the {@link FirebasePhoneNumberVerification} instance for the default {@link FirebaseApp}. + * + * @return The {@link FirebasePhoneNumberVerification} instance for the default {@link FirebaseApp}. + */ + public static FirebasePhoneNumberVerification getInstance() { + return getInstance(FirebaseApp.getInstance()); + } + + /** + * Gets the {@link FirebasePhoneNumberVerification} instance for the specified {@link FirebaseApp}. + * + * @return The {@link FirebasePhoneNumberVerification} instance for the specified {@link FirebaseApp}. + */ + public static synchronized FirebasePhoneNumberVerification getInstance(FirebaseApp app) { + FirebasePhoneNumberVerificationService service = ImplFirebaseTrampolines.getService(app, SERVICE_ID, + FirebasePhoneNumberVerificationService.class); + if (service == null) { + service = ImplFirebaseTrampolines.addService(app, new FirebasePhoneNumberVerificationService(app)); + } + return service.getInstance(); + } + + /** + * Verifies a Firebase Phone Number Verification token (JWT). + * + * @param phoneNumberVerificationJwt The JWT string to verify. + * @return A verified {@link FirebasePhoneNumberVerificationToken}. + * @throws FirebasePhoneNumberVerificationException If verification fails. + */ + public FirebasePhoneNumberVerificationToken verifyToken(String phoneNumberVerificationJwt) + throws FirebasePhoneNumberVerificationException { + return this.tokenVerifier.verifyToken(phoneNumberVerificationJwt); + } + + private static class FirebasePhoneNumberVerificationService extends FirebaseService { + FirebasePhoneNumberVerificationService(FirebaseApp app) { + super(SERVICE_ID, new FirebasePhoneNumberVerification(app)); + } + } +} diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvErrorCode.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCode.java similarity index 79% rename from src/main/java/com/google/firebase/fpnv/FirebasePnvErrorCode.java rename to src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCode.java index b5dc1fac4..056126c15 100644 --- a/src/main/java/com/google/firebase/fpnv/FirebasePnvErrorCode.java +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCode.java @@ -14,12 +14,12 @@ * limitations under the License. */ -package com.google.firebase.fpnv; +package com.google.firebase.phonenumberverification; /** - * Error codes that are used in {@link FirebasePnv}. + * Error codes that are used in {@link FirebasePhoneNumberVerification}. */ -public enum FirebasePnvErrorCode { +public enum FirebasePhoneNumberVerificationErrorCode { INVALID_ARGUMENT, INVALID_TOKEN, TOKEN_EXPIRED, diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java similarity index 61% rename from src/main/java/com/google/firebase/fpnv/FirebasePnvException.java rename to src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java index e60ab8359..f5710f2f3 100644 --- a/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java @@ -14,29 +14,28 @@ * limitations under the License. */ -package com.google.firebase.fpnv; +package com.google.firebase.phonenumberverification; import com.google.firebase.ErrorCode; import com.google.firebase.FirebaseException; /** * Generic exception related to Firebase Phone Number Verification. - * Check the error code and message for more - * details. + * Check the error code and message for more details. */ -public class FirebasePnvException extends FirebaseException { - private final FirebasePnvErrorCode errorCode; +public class FirebasePhoneNumberVerificationException extends FirebaseException { + private final FirebasePhoneNumberVerificationErrorCode errorCode; /** - * Exception that created from {@link FirebasePnvErrorCode}, + * Exception that created from {@link FirebasePhoneNumberVerificationErrorCode}, * {@link String} message and {@link Throwable} cause. * - * @param errorCode {@link FirebasePnvErrorCode} + * @param errorCode {@link FirebasePhoneNumberVerificationErrorCode} * @param message {@link String} * @param cause {@link Throwable} */ - public FirebasePnvException( - FirebasePnvErrorCode errorCode, + public FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode errorCode, String message, Throwable cause ) { @@ -45,23 +44,23 @@ public FirebasePnvException( } /** - * Exception that created from {@link FirebasePnvErrorCode} and {@link String} message. + * Exception that created from {@link FirebasePhoneNumberVerificationErrorCode} and {@link String} message. * - * @param errorCode {@link FirebasePnvErrorCode} + * @param errorCode {@link FirebasePhoneNumberVerificationErrorCode} * @param message {@link String} */ - public FirebasePnvException( - FirebasePnvErrorCode errorCode, + public FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode errorCode, String message ) { this(errorCode, message, null); } - public FirebasePnvErrorCode getFpnvErrorCode() { + public FirebasePhoneNumberVerificationErrorCode getPhoneNumberVerificationErrorCode() { return errorCode; } - private static ErrorCode mapToFirebaseError(FirebasePnvErrorCode code) { + private static ErrorCode mapToFirebaseError(FirebasePhoneNumberVerificationErrorCode code) { if (code == null) { return ErrorCode.INTERNAL; } @@ -79,4 +78,3 @@ private static ErrorCode mapToFirebaseError(FirebasePnvErrorCode code) { } } } - diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java similarity index 87% rename from src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java rename to src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java index 44165eeca..147a57cfd 100644 --- a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.firebase.fpnv; +package com.google.firebase.phonenumberverification; import static com.google.common.base.Preconditions.checkArgument; @@ -26,15 +26,15 @@ /** * Represents a verified Firebase Phone Number Verification token. */ -public class FirebasePnvToken { +public class FirebasePhoneNumberVerificationToken { private final Map claims; /** - * Create an instance of {@link FirebasePnvToken} from a map of JWT claims. + * Create an instance of {@link FirebasePhoneNumberVerificationToken} from a map of JWT claims. * * @param claims A map of JWT claims. */ - public FirebasePnvToken(Map claims) { + public FirebasePhoneNumberVerificationToken(Map claims) { checkArgument(claims != null && claims.containsKey("sub"), "Claims map must at least contain sub"); this.claims = ImmutableMap.copyOf(claims); @@ -63,8 +63,6 @@ public List getAudience() { if (audience instanceof String) { return ImmutableList.of((String) audience); } else if (audience instanceof List) { - // The nimbus-jose-jwt library should provide a List, but we copy it - // to an immutable list for safety and to prevent modification. @SuppressWarnings("unchecked") List audienceList = (List) audience; return ImmutableList.copyOf(audienceList); diff --git a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java similarity index 55% rename from src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java rename to src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java index a0af1f4e6..e23cdedcc 100644 --- a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java +++ b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java @@ -14,16 +14,16 @@ * limitations under the License. */ -package com.google.firebase.fpnv.internal; +package com.google.firebase.phonenumberverification.internal; import static com.google.common.base.Preconditions.checkArgument; import com.google.common.base.Strings; import com.google.firebase.FirebaseApp; import com.google.firebase.ImplFirebaseTrampolines; -import com.google.firebase.fpnv.FirebasePnvErrorCode; -import com.google.firebase.fpnv.FirebasePnvException; -import com.google.firebase.fpnv.FirebasePnvToken; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationErrorCode; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationException; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationToken; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JOSEObjectType; import com.nimbusds.jose.JWSAlgorithm; @@ -44,9 +44,9 @@ import java.util.Objects; /** - * Internal class to verify FPNV tokens. + * Internal class to verify Firebase Phone Number Verification tokens. */ -public class FirebasePnvTokenVerifier { +public class FirebasePhoneNumberVerificationTokenVerifier { private static final String FPNV_JWKS_URL = "https://fpnv.googleapis.com/v1beta/jwks"; private static final String HEADER_TYP = "JWT"; @@ -54,28 +54,24 @@ public class FirebasePnvTokenVerifier { private final DefaultJWTProcessor jwtProcessor; /** - * Create {@link FirebasePnvTokenVerifier} for internal purposes. + * Create {@link FirebasePhoneNumberVerificationTokenVerifier} for internal purposes. * - * @param app The {@link FirebaseApp} to get a FirebaseAuth instance for. + * @param app The {@link FirebaseApp} to get a project ID from. */ - public FirebasePnvTokenVerifier(FirebaseApp app) { + public FirebasePhoneNumberVerificationTokenVerifier(FirebaseApp app) { this.projectId = getProjectId(app); this.jwtProcessor = createJwtProcessor(); } /** - * Main method that performs the following verification steps: - * - Explicitly verifies the header - * - Verifies signature and structure - * - Verifies claims (e.g. issuer, audience, expiration) - * - Constructs a token object upon successful verification + * Main method that performs token verification steps. * * @param token String input data - * @return {@link FirebasePnvToken} - * @throws FirebasePnvException Can throw {@link FirebasePnvException} + * @return {@link FirebasePhoneNumberVerificationToken} + * @throws FirebasePhoneNumberVerificationException If verification fails */ - public FirebasePnvToken verifyToken(String token) throws FirebasePnvException { - checkArgument(!Strings.isNullOrEmpty(token), "FPNV token must not be null or empty"); + public FirebasePhoneNumberVerificationToken verifyToken(String token) throws FirebasePhoneNumberVerificationException { + checkArgument(!Strings.isNullOrEmpty(token), "Firebase Phone Number Verification token must not be null or empty"); try { SignedJWT signedJwt = SignedJWT.parse(token); @@ -84,86 +80,76 @@ public FirebasePnvToken verifyToken(String token) throws FirebasePnvException { JWTClaimsSet claims = jwtProcessor.process(signedJwt, null); verifyClaims(claims); - return new FirebasePnvToken(claims.getClaims()); + return new FirebasePhoneNumberVerificationToken(claims.getClaims()); } catch (ParseException e) { - throw new FirebasePnvException( - FirebasePnvErrorCode.INVALID_TOKEN, + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, "Failed to parse JWT token: " + e.getMessage(), e ); } catch (ExpiredJWTException e) { - throw new FirebasePnvException( - FirebasePnvErrorCode.TOKEN_EXPIRED, - "FPNV token has expired.", + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.TOKEN_EXPIRED, + "Firebase Phone Number Verification token has expired.", e ); } catch (BadJOSEException e) { - throw new FirebasePnvException( - FirebasePnvErrorCode.INVALID_TOKEN, + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, "Check your project: " + projectId + ". " - + "FPNV token is invalid: " + e.getMessage(), + + "Firebase Phone Number Verification token is invalid: " + e.getMessage(), e ); } catch (JOSEException e) { - throw new FirebasePnvException( - FirebasePnvErrorCode.INTERNAL_ERROR, + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INTERNAL_ERROR, "Check your project: " + projectId + ". " - + "Failed to verify FPNV token signature: " + e.getMessage(), + + "Failed to verify Firebase Phone Number Verification token signature: " + e.getMessage(), e ); } } - private void verifyHeader(JWSHeader header) throws FirebasePnvException { - // Check Algorithm (alg) + private void verifyHeader(JWSHeader header) throws FirebasePhoneNumberVerificationException { if (!header.getAlgorithm().equals(JWSAlgorithm.ES256)) { - throw new FirebasePnvException( - FirebasePnvErrorCode.INVALID_ARGUMENT, - "FPNV has incorrect 'algorithm'. Expected " + JWSAlgorithm.ES256.getName() + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + "Firebase Phone Number Verification token has incorrect 'algorithm'. Expected " + JWSAlgorithm.ES256.getName() + " but got " + header.getAlgorithm()); } - // Check Key ID (kid) if (Strings.isNullOrEmpty(header.getKeyID())) { - throw new FirebasePnvException( - FirebasePnvErrorCode.INVALID_ARGUMENT, - "FPNV has no 'kid' claim." + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + "Firebase Phone Number Verification token has no 'kid' claim." ); } - // Check Type (typ) if (!JOSEObjectType.JWT.equals(header.getType())) { - throw new FirebasePnvException( - FirebasePnvErrorCode.INVALID_ARGUMENT, - "FPNV has incorrect 'typ'. Expected " + HEADER_TYP + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + "Firebase Phone Number Verification token has incorrect 'typ'. Expected " + HEADER_TYP + " but got " + header.getType() ); } - } - private void verifyClaims(JWTClaimsSet claims) throws FirebasePnvException { + private void verifyClaims(JWTClaimsSet claims) throws FirebasePhoneNumberVerificationException { checkArgument(!Objects.isNull(claims), "JWTClaimsSet claims must not be null"); - // Verify Issuer String issuer = claims.getIssuer(); if (Strings.isNullOrEmpty(issuer)) { - throw new FirebasePnvException(FirebasePnvErrorCode.INVALID_ARGUMENT, - "FPNV token has no 'iss' (issuer) claim."); + throw new FirebasePhoneNumberVerificationException(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + "Firebase Phone Number Verification token has no 'iss' (issuer) claim."); } - // Verify Audience - if (claims.getAudience().isEmpty() - || !claims.getAudience().contains(issuer) - ) { - throw new FirebasePnvException(FirebasePnvErrorCode.INVALID_TOKEN, - "Invalid audience. Expected to contain: " - + issuer + " but found: " + claims.getAudience() + if (claims.getAudience().isEmpty() || !claims.getAudience().contains(issuer)) { + throw new FirebasePhoneNumberVerificationException(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Invalid audience. Expected to contain: " + issuer + " but found: " + claims.getAudience() ); } - // Verify Subject for emptiness / null if (Strings.isNullOrEmpty(claims.getSubject())) { - throw new FirebasePnvException( - FirebasePnvErrorCode.INVALID_TOKEN, + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, "Token has an empty 'sub' (phone number)." ); } @@ -172,9 +158,7 @@ private void verifyClaims(JWTClaimsSet claims) throws FirebasePnvException { private DefaultJWTProcessor createJwtProcessor() { DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); try { - // Use JWKSourceBuilder instead of deprecated RemoteJWKSet JWKSource keySource = createKeySource(); - JWSKeySelector keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.ES256, keySource); processor.setJWSKeySelector(keySelector); @@ -184,16 +168,10 @@ private DefaultJWTProcessor createJwtProcessor() { return processor; } - /** - * Helper JWKSourceBuilder. - * - * @return an instance of JWKSource - * @throws MalformedURLException if URL is invalid - */ protected JWKSource createKeySource() throws MalformedURLException { return JWKSourceBuilder .create(new URL(FPNV_JWKS_URL)) - .retrying(true) // Helper to retry on transient network errors + .retrying(true) .build(); } diff --git a/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCodeTest.java similarity index 56% rename from src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java rename to src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCodeTest.java index d833710c9..c9f02ac23 100644 --- a/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java +++ b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCodeTest.java @@ -14,21 +14,19 @@ * limitations under the License. */ -package com.google.firebase.fpnv; +package com.google.firebase.phonenumberverification; import static org.junit.Assert.assertNotNull; import org.junit.Test; - -public class FirebasePnvErrorCodeTest { +public class FirebasePhoneNumberVerificationErrorCodeTest { @Test public void testEnum() { - // Assert that all values exist - assertNotNull(FirebasePnvErrorCode.valueOf("INVALID_ARGUMENT")); - assertNotNull(FirebasePnvErrorCode.valueOf("INVALID_TOKEN")); - assertNotNull(FirebasePnvErrorCode.valueOf("TOKEN_EXPIRED")); - assertNotNull(FirebasePnvErrorCode.valueOf("INTERNAL_ERROR")); - assertNotNull(FirebasePnvErrorCode.valueOf("SERVICE_ERROR")); + assertNotNull(FirebasePhoneNumberVerificationErrorCode.valueOf("INVALID_ARGUMENT")); + assertNotNull(FirebasePhoneNumberVerificationErrorCode.valueOf("INVALID_TOKEN")); + assertNotNull(FirebasePhoneNumberVerificationErrorCode.valueOf("TOKEN_EXPIRED")); + assertNotNull(FirebasePhoneNumberVerificationErrorCode.valueOf("INTERNAL_ERROR")); + assertNotNull(FirebasePhoneNumberVerificationErrorCode.valueOf("SERVICE_ERROR")); } } diff --git a/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTest.java similarity index 53% rename from src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java rename to src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTest.java index 352bf1900..f6b6ebe57 100644 --- a/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java +++ b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.firebase.fpnv; +package com.google.firebase.phonenumberverification; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -29,8 +29,8 @@ import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; import com.google.firebase.TestOnlyImplFirebaseTrampolines; -import com.google.firebase.fpnv.internal.FirebasePnvTokenVerifier; import com.google.firebase.internal.FirebaseProcessEnvironment; +import com.google.firebase.phonenumberverification.internal.FirebasePhoneNumberVerificationTokenVerifier; import com.google.firebase.testing.ServiceAccount; import com.google.firebase.testing.TestUtils; import java.lang.reflect.Field; @@ -40,29 +40,26 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; -public class FirebasePnvTest { +public class FirebasePhoneNumberVerificationTest { private static final FirebaseOptions firebaseOptions = FirebaseOptions.builder() .setCredentials(TestUtils.getCertCredential(ServiceAccount.OWNER.asStream())) .build(); @Mock - private FirebasePnvTokenVerifier mockVerifier; + private FirebasePhoneNumberVerificationTokenVerifier mockVerifier; - private FirebasePnv firebasePnv; + private FirebasePhoneNumberVerification firebasePhoneNumberVerification; @Before public void setUp() throws Exception { - // noinspection resource MockitoAnnotations.openMocks(this); - // Initialize Fpnv FirebaseApp.initializeApp(firebaseOptions); - firebasePnv = FirebasePnv.getInstance(); + firebasePhoneNumberVerification = FirebasePhoneNumberVerification.getInstance(); - // Inject the mock verifier - Field verifierField = FirebasePnv.class.getDeclaredField("tokenVerifier"); + Field verifierField = FirebasePhoneNumberVerification.class.getDeclaredField("tokenVerifier"); verifierField.setAccessible(true); - verifierField.set(firebasePnv, mockVerifier); + verifierField.set(firebasePhoneNumberVerification, mockVerifier); } @After @@ -73,77 +70,77 @@ public void tearDown() { @Test public void testGetInstance() { - FirebasePnv firebasePnv = FirebasePnv.getInstance(); - assertNotNull(firebasePnv); - assertSame(firebasePnv, FirebasePnv.getInstance()); + FirebasePhoneNumberVerification instance = FirebasePhoneNumberVerification.getInstance(); + assertNotNull(instance); + assertSame(instance, FirebasePhoneNumberVerification.getInstance()); } @Test public void testGetInstanceForApp() { FirebaseApp app = FirebaseApp.initializeApp(firebaseOptions, "testGetInstanceForApp"); - FirebasePnv firebasePnv = FirebasePnv.getInstance(app); - assertNotNull(firebasePnv); - assertSame(firebasePnv, FirebasePnv.getInstance(app)); + FirebasePhoneNumberVerification instance = FirebasePhoneNumberVerification.getInstance(app); + assertNotNull(instance); + assertSame(instance, FirebasePhoneNumberVerification.getInstance(app)); } @Test - public void testVerifyToken_DelegatesToVerifier() throws FirebasePnvException { - String testToken = "test.fpnv.token"; - FirebasePnvToken expectedToken = mock(FirebasePnvToken.class); + public void testVerifyToken_DelegatesToVerifier() throws FirebasePhoneNumberVerificationException { + String testToken = "test.token"; + FirebasePhoneNumberVerificationToken expectedToken = mock(FirebasePhoneNumberVerificationToken.class); when(mockVerifier.verifyToken(testToken)).thenReturn(expectedToken); - FirebasePnvToken result = firebasePnv.verifyToken(testToken); + FirebasePhoneNumberVerificationToken result = firebasePhoneNumberVerification.verifyToken(testToken); assertEquals(expectedToken, result); verify(mockVerifier, times(1)).verifyToken(testToken); } @Test - public void testVerifyToken_PropagatesException() throws FirebasePnvException { + public void testVerifyToken_PropagatesException() throws FirebasePhoneNumberVerificationException { String testToken = "bad.token"; - FirebasePnvException error = new FirebasePnvException( - FirebasePnvErrorCode.INVALID_TOKEN, + FirebasePhoneNumberVerificationException error = new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, "Bad token" ); when(mockVerifier.verifyToken(testToken)).thenThrow(error); - FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> - FirebasePnv.getInstance().verifyToken(testToken) + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> + FirebasePhoneNumberVerification.getInstance().verifyToken(testToken) ); - assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, e.getPhoneNumberVerificationErrorCode()); } @Test - public void testVerifyToken_PropagatesException_Service_Error() throws FirebasePnvException { + public void testVerifyToken_PropagatesException_Service_Error() throws FirebasePhoneNumberVerificationException { String testToken = "SERVICE_ERROR"; - FirebasePnvException error = new FirebasePnvException( - FirebasePnvErrorCode.SERVICE_ERROR, + FirebasePhoneNumberVerificationException error = new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.SERVICE_ERROR, "SERVICE_ERROR" ); when(mockVerifier.verifyToken(testToken)).thenThrow(error); - FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> - FirebasePnv.getInstance().verifyToken(testToken) + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> + FirebasePhoneNumberVerification.getInstance().verifyToken(testToken) ); - assertEquals(FirebasePnvErrorCode.SERVICE_ERROR, e.getFpnvErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.SERVICE_ERROR, e.getPhoneNumberVerificationErrorCode()); } @Test - public void testVerifyToken_PropagatesException_Internal_Error() throws FirebasePnvException { + public void testVerifyToken_PropagatesException_Internal_Error() throws FirebasePhoneNumberVerificationException { String testToken = "INTERNAL"; - FirebasePnvException error = new FirebasePnvException( + FirebasePhoneNumberVerificationException error = new FirebasePhoneNumberVerificationException( null, "INTERNAL" ); when(mockVerifier.verifyToken(testToken)).thenThrow(error); - FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> - FirebasePnv.getInstance().verifyToken(testToken) + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> + FirebasePhoneNumberVerification.getInstance().verifyToken(testToken) ); - assertNull(null, e.getFpnvErrorCode()); + assertNull(e.getPhoneNumberVerificationErrorCode()); } } diff --git a/src/test/java/com/google/firebase/fpnv/FirebasePnvTokenTest.java b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java similarity index 56% rename from src/test/java/com/google/firebase/fpnv/FirebasePnvTokenTest.java rename to src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java index 53615cd5f..ab556eb2a 100644 --- a/src/test/java/com/google/firebase/fpnv/FirebasePnvTokenTest.java +++ b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java @@ -1,4 +1,20 @@ -package com.google.firebase.fpnv; +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.phonenumberverification; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -12,13 +28,10 @@ import java.util.Date; import java.util.HashMap; import java.util.Map; - import org.junit.After; import org.junit.Test; - - -public class FirebasePnvTokenTest { +public class FirebasePhoneNumberVerificationTokenTest { private static final String PROJECT_ID = "mock-project-id-1"; private static final String ISSUER = "https://fpnv.googleapis.com/projects/" + PROJECT_ID; private final String subject = "+15551234567"; @@ -37,10 +50,10 @@ public void test_Audience_Empty() { .expirationTime(new Date(System.currentTimeMillis() + 10000)) .build(); - FirebasePnvToken firebasePnvToken = new FirebasePnvToken(claims.getClaims()); + FirebasePhoneNumberVerificationToken token = new FirebasePhoneNumberVerificationToken(claims.getClaims()); - assertNotNull(firebasePnvToken); - assertEquals(ImmutableList.of(), firebasePnvToken.getAudience()); + assertNotNull(token); + assertEquals(ImmutableList.of(), token.getAudience()); } @Test @@ -52,30 +65,29 @@ public void test_Audience_List() { .expirationTime(new Date(System.currentTimeMillis() + 10000)) .build(); - FirebasePnvToken firebasePnvToken = new FirebasePnvToken(claims.getClaims()); + FirebasePhoneNumberVerificationToken token = new FirebasePhoneNumberVerificationToken(claims.getClaims()); - assertNotNull(firebasePnvToken); - assertEquals(ImmutableList.of(), firebasePnvToken.getAudience()); + assertNotNull(token); + assertEquals(ImmutableList.of(), token.getAudience()); } @Test public void test_Audience_String() { - Map claims = new HashMap(); + Map claims = new HashMap<>(); claims.put("sub", subject); claims.put("aud", ISSUER); + FirebasePhoneNumberVerificationToken token = new FirebasePhoneNumberVerificationToken(claims); - FirebasePnvToken firebasePnvToken = new FirebasePnvToken(claims); - - assertNotNull(firebasePnvToken); - assertEquals(ImmutableList.of(ISSUER), firebasePnvToken.getAudience()); + assertNotNull(token); + assertEquals(ImmutableList.of(ISSUER), token.getAudience()); } @Test public void test_No_Sub() { - Map claims = new HashMap(); + Map claims = new HashMap<>(); IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> - new FirebasePnvToken(claims) + new FirebasePhoneNumberVerificationToken(claims) ); assertTrue(e.getMessage().contains("Claims map must at least contain sub")); } @@ -83,7 +95,7 @@ public void test_No_Sub() { @Test public void test_Null_Sub() { IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> - new FirebasePnvToken(null) + new FirebasePhoneNumberVerificationToken(null) ); assertTrue(e.getMessage().contains("Claims map must at least contain sub")); } diff --git a/src/test/java/com/google/firebase/fpnv/internal/FpnvTokenVerifierTest.java b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java similarity index 76% rename from src/test/java/com/google/firebase/fpnv/internal/FpnvTokenVerifierTest.java rename to src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java index 751f6b9c9..70aef4c3e 100644 --- a/src/test/java/com/google/firebase/fpnv/internal/FpnvTokenVerifierTest.java +++ b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.firebase.fpnv.internal; +package com.google.firebase.phonenumberverification.internal; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -28,10 +28,10 @@ import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; import com.google.firebase.TestOnlyImplFirebaseTrampolines; -import com.google.firebase.fpnv.FirebasePnvErrorCode; -import com.google.firebase.fpnv.FirebasePnvException; -import com.google.firebase.fpnv.FirebasePnvToken; import com.google.firebase.internal.FirebaseProcessEnvironment; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationErrorCode; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationException; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationToken; import com.google.firebase.testing.ServiceAccount; import com.google.firebase.testing.TestUtils; import com.nimbusds.jose.JOSEException; @@ -50,7 +50,6 @@ import com.nimbusds.jwt.SignedJWT; import com.nimbusds.jwt.proc.DefaultJWTProcessor; import com.nimbusds.jwt.proc.ExpiredJWTException; - import java.io.ByteArrayInputStream; import java.lang.reflect.Field; import java.lang.reflect.Method; @@ -67,7 +66,7 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; -public class FpnvTokenVerifierTest { +public class FirebasePhoneNumberVerificationTokenVerifierTest { private static final String PROJECT_ID = "mock-project-id-2"; private static final FirebaseOptions firebaseOptions = FirebaseOptions.builder() .setProjectId(PROJECT_ID) @@ -82,42 +81,37 @@ public class FpnvTokenVerifierTest { @Mock private DefaultJWTProcessor mockJwtProcessor; - private FirebasePnvTokenVerifier verifier; + private FirebasePhoneNumberVerificationTokenVerifier verifier; private KeyPair rsaKeyPair; private ECKey ecKey; private JWSHeader header; private JWTClaimsSet claims; - private final String subject = "+15551234567"; + private final String subject = "+15551234567"; private final Date issueTime = new Date(); private final Date expirationTime = new Date(System.currentTimeMillis() + 10000); @Before public void setUp() throws Exception { - // noinspection resource MockitoAnnotations.openMocks(this); - // Generate a real RSA key pair for signing test tokens KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); gen.initialize(2048); rsaKeyPair = gen.generateKeyPair(); ecKey = new ECKeyGenerator(Curve.P_256).keyID("ec-key-id").generate(); - // Initialize Verifier and inject mock processor FirebaseApp firebaseApp = FirebaseApp.initializeApp(firebaseOptions); - verifier = new FirebasePnvTokenVerifier(firebaseApp); + verifier = new FirebasePhoneNumberVerificationTokenVerifier(firebaseApp); - Field processorField = FirebasePnvTokenVerifier.class.getDeclaredField("jwtProcessor"); + Field processorField = FirebasePhoneNumberVerificationTokenVerifier.class.getDeclaredField("jwtProcessor"); processorField.setAccessible(true); processorField.set(verifier, mockJwtProcessor); - // Create a valid ES256 token header = new JWSHeader.Builder(JWSAlgorithm.ES256) .keyID(ecKey.getKeyID()) .type(JOSEObjectType.JWT) .build(); - // Create a valid JWTClaimsSet claims = new JWTClaimsSet.Builder() .issuer(ISSUER) .audience(Arrays.asList(AUD)) @@ -133,15 +127,13 @@ public void tearDown() { TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); } - // --- Helper to create a signed JWT string --- private String createToken(JWSHeader header, JWTClaimsSet claims) throws Exception { SignedJWT jwt = new SignedJWT(header, claims); - // Sign based on the algorithm in the header if (JWSAlgorithm.RS256.equals(header.getAlgorithm())) { jwt.sign(new RSASSASigner(rsaKeyPair.getPrivate())); } else if (JWSAlgorithm.HS256.equals(header.getAlgorithm())) { - jwt.sign(new MACSigner("12345678901234567890123456789012")); // 32-byte secret + jwt.sign(new MACSigner("12345678901234567890123456789012")); } else if (JWSAlgorithm.ES256.equals(header.getAlgorithm())) { jwt.sign(new ECDSASigner(ecKey.toECPrivateKey())); } @@ -154,20 +146,17 @@ public void testVerifyToken_NullOrEmptyToken() { IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> verifier.verifyToken("") ); - assertTrue(e.getMessage().contains("FPNV token must not be null")); + assertTrue(e.getMessage().contains("Firebase Phone Number Verification token must not be null")); } @Test public void testVerifyToken_Success() throws Exception { String tokenString = createToken(header, claims); - // 1. Mock the processor to return these claims (skipping real signature verification) when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(claims); - // 2. Execute - FirebasePnvToken result = verifier.verifyToken(tokenString); + FirebasePhoneNumberVerificationToken result = verifier.verifyToken(tokenString); - // 3. Verify assertNotNull(result); assertEquals(subject, result.getPhoneNumber()); assertEquals(issueTime.getTime() / 1000L, result.getIssuedAt()); @@ -179,25 +168,22 @@ public void testVerifyToken_Success() throws Exception { @Test public void testVerifyToken_Header_WrongAlgorithm() throws Exception { - // Create token with HS256 (HMAC) instead of ES256 JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.HS256).build(); JWTClaimsSet claims = new JWTClaimsSet.Builder().build(); String tokenString = createToken(header, claims); - // Should fail at header check, before reaching the processor - FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> verifier.verifyToken(tokenString) ); - assertEquals(FirebasePnvErrorCode.INVALID_ARGUMENT, e.getFpnvErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, e.getPhoneNumberVerificationErrorCode()); assertTrue(e.getMessage().contains("algorithm")); } @Test public void testVerifyToken_Header_WrongTyp() throws Exception { - JWSHeader header = new JWSHeader - .Builder(JWSAlgorithm.ES256) + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256) .keyID(ecKey.getKeyID()) .type(JOSEObjectType.JOSE) .build(); @@ -205,34 +191,32 @@ public void testVerifyToken_Header_WrongTyp() throws Exception { String tokenString = createToken(header, claims); - FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> verifier.verifyToken(tokenString) ); - assertEquals(FirebasePnvErrorCode.INVALID_ARGUMENT, e.getFpnvErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, e.getPhoneNumberVerificationErrorCode()); assertTrue(e.getMessage().contains("has incorrect 'typ'")); } @Test public void testVerifyToken_Header_MissingKeyId() throws Exception { - // ES256 but missing 'kid' JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256).build(); JWTClaimsSet claims = new JWTClaimsSet.Builder().build(); String tokenString = createToken(header, claims); - FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> verifier.verifyToken(tokenString) ); - assertEquals(FirebasePnvErrorCode.INVALID_ARGUMENT, e.getFpnvErrorCode()); - assertTrue(e.getMessage().contains("FPNV has no 'kid' claim")); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("Firebase Phone Number Verification token has no 'kid' claim")); } @Test public void testVerifyToken_Claims_Null() throws Exception { - JWTClaimsSet noSubClaims = new JWTClaimsSet.Builder() - .build(); + JWTClaimsSet noSubClaims = new JWTClaimsSet.Builder().build(); String tokenString = createToken(header, noSubClaims); when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(null); @@ -254,12 +238,12 @@ public void testVerifyToken_Claims_NoIssuer() throws Exception { String tokenString = createToken(header, claims); when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(claims); - FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> verifier.verifyToken(tokenString) ); - assertEquals(FirebasePnvErrorCode.INVALID_ARGUMENT, e.getFpnvErrorCode()); - assertTrue(e.getMessage().contains("FPNV token has no 'iss' (issuer) claim.")); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("Firebase Phone Number Verification token has no 'iss' (issuer) claim.")); } @Test @@ -276,17 +260,17 @@ public void testVerifyToken_Claims_Expired() throws Exception { when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); - FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> verifier.verifyToken(tokenString) ); - assertEquals(FirebasePnvErrorCode.TOKEN_EXPIRED, e.getFpnvErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.TOKEN_EXPIRED, e.getPhoneNumberVerificationErrorCode()); } @Test public void testVerifyToken_Claims_WrongAudience() throws Exception { JWTClaimsSet badClaims = new JWTClaimsSet.Builder() - .issuer("https://wrong.com") // Wrong issuer + .issuer("https://wrong.com") .audience(ISSUER) .subject(subject) .expirationTime(new Date(System.currentTimeMillis() + 10000)) @@ -295,11 +279,11 @@ public void testVerifyToken_Claims_WrongAudience() throws Exception { String tokenString = createToken(header, badClaims); when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims); - FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> verifier.verifyToken(tokenString) ); - assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, e.getPhoneNumberVerificationErrorCode()); assertTrue(e.getMessage().contains("Invalid audience.")); } @@ -315,11 +299,11 @@ public void testVerifyToken_Claims_EmptyAudience() throws Exception { String tokenString = createToken(header, badClaims); when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims); - FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> verifier.verifyToken(tokenString) ); - assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, e.getPhoneNumberVerificationErrorCode()); assertTrue(e.getMessage().contains("Invalid audience. Expected to contain: ")); } @@ -329,25 +313,25 @@ public void testVerifyToken_Claims_NoSubject() throws Exception { .issuer(ISSUER) .audience(ISSUER) .expirationTime(new Date(System.currentTimeMillis() + 10000)) - .build(); // Missing subject + .build(); String tokenString = createToken(header, noSubClaims); when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(noSubClaims); - FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> verifier.verifyToken(tokenString) ); - assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, e.getPhoneNumberVerificationErrorCode()); assertTrue(e.getMessage().contains("Token has an empty 'sub' (phone number)")); } @Test public void testVerifyToken_ParseException() { - FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> verifier.verifyToken(" ") ); - assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, e.getPhoneNumberVerificationErrorCode()); assertTrue(e.getMessage().contains("Failed to parse JWT token")); } @@ -359,18 +343,12 @@ public void testVerifyToken_BadJOSEException() throws Exception { when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); - FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> verifier.verifyToken(tokenString) ); - assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode()); - assertEquals( - "Check your project: " - + PROJECT_ID - + ". FPNV token is invalid: " - + errorMessage, - e.getMessage() - ); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("Firebase Phone Number Verification token is invalid:")); } @Test @@ -381,18 +359,12 @@ public void testVerifyToken_JOSEException() throws Exception { when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); - FirebasePnvException e = assertThrows(FirebasePnvException.class, () -> + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> verifier.verifyToken(tokenString) ); - assertEquals(FirebasePnvErrorCode.INTERNAL_ERROR, e.getFpnvErrorCode()); - assertEquals( - "Check your project: " - + PROJECT_ID - + ". Failed to verify FPNV token signature: " - + errorMessage, - e.getMessage() - ); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INTERNAL_ERROR, e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("Failed to verify Firebase Phone Number Verification token signature:")); } @Test @@ -444,11 +416,10 @@ public void testVerifyToken_EmptyProjectId() { ) ).build(); - // Initialize Verifier and inject mock processor FirebaseApp firebaseApp = FirebaseApp.initializeApp(localFirebaseOptions, "second"); IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> - new FirebasePnvTokenVerifier(firebaseApp) + new FirebasePhoneNumberVerificationTokenVerifier(firebaseApp) ); assertEquals("Project ID is required in FirebaseOptions.", e.getMessage()); @@ -457,16 +428,13 @@ public void testVerifyToken_EmptyProjectId() { @Test public void testCreateJwtProcessor_HandlesException() throws Exception { FirebaseApp firebaseApp = FirebaseApp.initializeApp(firebaseOptions, "third"); - // 1. Create the spy - FirebasePnvTokenVerifier original = new FirebasePnvTokenVerifier(firebaseApp); - FirebasePnvTokenVerifier spyClass = spy(original); + FirebasePhoneNumberVerificationTokenVerifier original = new FirebasePhoneNumberVerificationTokenVerifier(firebaseApp); + FirebasePhoneNumberVerificationTokenVerifier spyClass = spy(original); - // 2. Force the protected method to throw the checked exception doThrow(new MalformedURLException("Simulated bad URL")) .when(spyClass).createKeySource(); - // 3. Invoke and catch - Method method = FirebasePnvTokenVerifier.class.getDeclaredMethod("createJwtProcessor"); + Method method = FirebasePhoneNumberVerificationTokenVerifier.class.getDeclaredMethod("createJwtProcessor"); method.setAccessible(true); try { @@ -478,5 +446,4 @@ public void testCreateJwtProcessor_HandlesException() throws Exception { assertTrue(cause.getCause() instanceof MalformedURLException); } } - } From 969fca5f186faa0672e4d40eb97cd7e20fd90383 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 5 May 2026 14:21:09 -0400 Subject: [PATCH 12/28] security: enforce project-specific issuer validation in PhoneNumberVerification --- ...ePhoneNumberVerificationTokenVerifier.java | 6 ++++++ ...neNumberVerificationTokenVerifierTest.java | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java index e23cdedcc..65b3d7ff1 100644 --- a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java +++ b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java @@ -141,6 +141,12 @@ private void verifyClaims(JWTClaimsSet claims) throws FirebasePhoneNumberVerific "Firebase Phone Number Verification token has no 'iss' (issuer) claim."); } + String expectedIssuer = "https://fpnv.googleapis.com/projects/" + this.projectId; + if (!expectedIssuer.equals(issuer)) { + throw new FirebasePhoneNumberVerificationException(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Firebase Phone Number Verification token has an incorrect 'iss' (issuer) claim."); + } + if (claims.getAudience().isEmpty() || !claims.getAudience().contains(issuer)) { throw new FirebasePhoneNumberVerificationException(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, "Invalid audience. Expected to contain: " + issuer + " but found: " + claims.getAudience() diff --git a/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java index 70aef4c3e..5272b6609 100644 --- a/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java +++ b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java @@ -446,4 +446,24 @@ public void testCreateJwtProcessor_HandlesException() throws Exception { assertTrue(cause.getCause() instanceof MalformedURLException); } } + + @Test + public void testVerifyToken_Claims_InvalidIssuerProject() throws Exception { + JWTClaimsSet badIssuerClaims = new JWTClaimsSet.Builder() + .issuer("https://fpnv.googleapis.com/projects/attacker-project-id") + .audience("https://fpnv.googleapis.com/projects/attacker-project-id") + .subject(subject) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + String tokenString = createToken(header, badIssuerClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badIssuerClaims); + + FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("incorrect 'iss' (issuer) claim")); + } } From 6e50688a08a8790e764de39093b4fb922587c06b Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 5 May 2026 14:22:50 -0400 Subject: [PATCH 13/28] chore: handle both Date and Number type safely for token claims --- .../FirebasePhoneNumberVerificationToken.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java index 147a57cfd..81864717b 100644 --- a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java @@ -74,14 +74,22 @@ public List getAudience() { * Returns the expiration time in seconds since the Unix epoch. */ public long getExpirationTime() { - return ((java.util.Date) claims.get("exp")).getTime() / 1000L; + Object exp = claims.get("exp"); + if (exp instanceof java.util.Date) { + return ((java.util.Date) exp).getTime() / 1000L; + } + return exp instanceof Number ? ((Number) exp).longValue() : 0L; } /** * Returns the issued-at time in seconds since the Unix epoch. */ public long getIssuedAt() { - return ((java.util.Date) claims.get("iat")).getTime() / 1000L; + Object iat = claims.get("iat"); + if (iat instanceof java.util.Date) { + return ((java.util.Date) iat).getTime() / 1000L; + } + return iat instanceof Number ? ((Number) iat).longValue() : 0L; } /** From 99c58c766f20769634e0727e0303f63e6f85f784 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 5 May 2026 14:24:34 -0400 Subject: [PATCH 14/28] style: wrap long lines to meet checkstyle line width limits --- .../FirebasePhoneNumberVerification.java | 3 +- ...ePhoneNumberVerificationTokenVerifier.java | 29 ++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java index eceb878ee..b463e6612 100644 --- a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java @@ -70,7 +70,8 @@ public FirebasePhoneNumberVerificationToken verifyToken(String phoneNumberVerifi return this.tokenVerifier.verifyToken(phoneNumberVerificationJwt); } - private static class FirebasePhoneNumberVerificationService extends FirebaseService { + private static class FirebasePhoneNumberVerificationService + extends FirebaseService { FirebasePhoneNumberVerificationService(FirebaseApp app) { super(SERVICE_ID, new FirebasePhoneNumberVerification(app)); } diff --git a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java index 65b3d7ff1..a1f613d02 100644 --- a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java +++ b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java @@ -70,8 +70,10 @@ public FirebasePhoneNumberVerificationTokenVerifier(FirebaseApp app) { * @return {@link FirebasePhoneNumberVerificationToken} * @throws FirebasePhoneNumberVerificationException If verification fails */ - public FirebasePhoneNumberVerificationToken verifyToken(String token) throws FirebasePhoneNumberVerificationException { - checkArgument(!Strings.isNullOrEmpty(token), "Firebase Phone Number Verification token must not be null or empty"); + public FirebasePhoneNumberVerificationToken verifyToken(String token) + throws FirebasePhoneNumberVerificationException { + checkArgument(!Strings.isNullOrEmpty(token), + "Firebase Phone Number Verification token must not be null or empty"); try { SignedJWT signedJwt = SignedJWT.parse(token); @@ -97,14 +99,15 @@ public FirebasePhoneNumberVerificationToken verifyToken(String token) throws Fir throw new FirebasePhoneNumberVerificationException( FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, "Check your project: " + projectId + ". " - + "Firebase Phone Number Verification token is invalid: " + e.getMessage(), + + "Firebase Phone Number Verification token is invalid: " + + e.getMessage(), e ); } catch (JOSEException e) { throw new FirebasePhoneNumberVerificationException( FirebasePhoneNumberVerificationErrorCode.INTERNAL_ERROR, - "Check your project: " + projectId + ". " - + "Failed to verify Firebase Phone Number Verification token signature: " + e.getMessage(), + "Check your project: " + projectId + ". Failed to verify " + + "Firebase Phone Number Verification token signature: " + e.getMessage(), e ); } @@ -114,8 +117,8 @@ private void verifyHeader(JWSHeader header) throws FirebasePhoneNumberVerificati if (!header.getAlgorithm().equals(JWSAlgorithm.ES256)) { throw new FirebasePhoneNumberVerificationException( FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, - "Firebase Phone Number Verification token has incorrect 'algorithm'. Expected " + JWSAlgorithm.ES256.getName() - + " but got " + header.getAlgorithm()); + "Firebase Phone Number Verification token has incorrect 'algorithm'. " + + "Expected " + JWSAlgorithm.ES256.getName() + " but got " + header.getAlgorithm()); } if (Strings.isNullOrEmpty(header.getKeyID())) { throw new FirebasePhoneNumberVerificationException( @@ -137,19 +140,23 @@ private void verifyClaims(JWTClaimsSet claims) throws FirebasePhoneNumberVerific String issuer = claims.getIssuer(); if (Strings.isNullOrEmpty(issuer)) { - throw new FirebasePhoneNumberVerificationException(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, "Firebase Phone Number Verification token has no 'iss' (issuer) claim."); } String expectedIssuer = "https://fpnv.googleapis.com/projects/" + this.projectId; if (!expectedIssuer.equals(issuer)) { - throw new FirebasePhoneNumberVerificationException(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, "Firebase Phone Number Verification token has an incorrect 'iss' (issuer) claim."); } if (claims.getAudience().isEmpty() || !claims.getAudience().contains(issuer)) { - throw new FirebasePhoneNumberVerificationException(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, - "Invalid audience. Expected to contain: " + issuer + " but found: " + claims.getAudience() + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Invalid audience. Expected to contain: " + issuer + + " but found: " + claims.getAudience() ); } From 45d46bf0a7a75e3bc32a8d57a46c8b2ca9f8fc06 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 5 May 2026 14:36:48 -0400 Subject: [PATCH 15/28] style: resolve checkstyle line length constraints across source and test files --- .../FirebasePhoneNumberVerification.java | 17 +- ...ebasePhoneNumberVerificationException.java | 3 +- .../FirebasePhoneNumberVerificationTest.java | 72 +++++---- ...ebasePhoneNumberVerificationTokenTest.java | 6 +- ...neNumberVerificationTokenVerifierTest.java | 153 +++++++++++------- 5 files changed, 152 insertions(+), 99 deletions(-) diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java index b463e6612..cdf2028c7 100644 --- a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java @@ -38,22 +38,27 @@ private FirebasePhoneNumberVerification(FirebaseApp app) { /** * Gets the {@link FirebasePhoneNumberVerification} instance for the default {@link FirebaseApp}. * - * @return The {@link FirebasePhoneNumberVerification} instance for the default {@link FirebaseApp}. + * @return The {@link FirebasePhoneNumberVerification} instance for the default + * {@link FirebaseApp}. */ public static FirebasePhoneNumberVerification getInstance() { return getInstance(FirebaseApp.getInstance()); } /** - * Gets the {@link FirebasePhoneNumberVerification} instance for the specified {@link FirebaseApp}. + * Gets the {@link FirebasePhoneNumberVerification} instance for the specified + * {@link FirebaseApp}. * - * @return The {@link FirebasePhoneNumberVerification} instance for the specified {@link FirebaseApp}. + * @return The {@link FirebasePhoneNumberVerification} instance for the specified + * {@link FirebaseApp}. */ public static synchronized FirebasePhoneNumberVerification getInstance(FirebaseApp app) { - FirebasePhoneNumberVerificationService service = ImplFirebaseTrampolines.getService(app, SERVICE_ID, - FirebasePhoneNumberVerificationService.class); + FirebasePhoneNumberVerificationService service = + ImplFirebaseTrampolines.getService(app, SERVICE_ID, + FirebasePhoneNumberVerificationService.class); if (service == null) { - service = ImplFirebaseTrampolines.addService(app, new FirebasePhoneNumberVerificationService(app)); + service = ImplFirebaseTrampolines.addService( + app, new FirebasePhoneNumberVerificationService(app)); } return service.getInstance(); } diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java index f5710f2f3..50e7b18eb 100644 --- a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java @@ -44,7 +44,8 @@ public FirebasePhoneNumberVerificationException( } /** - * Exception that created from {@link FirebasePhoneNumberVerificationErrorCode} and {@link String} message. + * Exception that created from {@link FirebasePhoneNumberVerificationErrorCode} + * and {@link String} message. * * @param errorCode {@link FirebasePhoneNumberVerificationErrorCode} * @param message {@link String} diff --git a/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTest.java b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTest.java index f6b6ebe57..e093cdc45 100644 --- a/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTest.java +++ b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTest.java @@ -84,63 +84,77 @@ public void testGetInstanceForApp() { } @Test - public void testVerifyToken_DelegatesToVerifier() throws FirebasePhoneNumberVerificationException { + public void testVerifyToken_DelegatesToVerifier() + throws FirebasePhoneNumberVerificationException { String testToken = "test.token"; - FirebasePhoneNumberVerificationToken expectedToken = mock(FirebasePhoneNumberVerificationToken.class); + FirebasePhoneNumberVerificationToken expectedToken = + mock(FirebasePhoneNumberVerificationToken.class); when(mockVerifier.verifyToken(testToken)).thenReturn(expectedToken); - FirebasePhoneNumberVerificationToken result = firebasePhoneNumberVerification.verifyToken(testToken); + FirebasePhoneNumberVerificationToken result = + firebasePhoneNumberVerification.verifyToken(testToken); assertEquals(expectedToken, result); verify(mockVerifier, times(1)).verifyToken(testToken); } @Test - public void testVerifyToken_PropagatesException() throws FirebasePhoneNumberVerificationException { + public void testVerifyToken_PropagatesException() + throws FirebasePhoneNumberVerificationException { String testToken = "bad.token"; - FirebasePhoneNumberVerificationException error = new FirebasePhoneNumberVerificationException( - FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, - "Bad token" - ); + FirebasePhoneNumberVerificationException error = + new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Bad token" + ); when(mockVerifier.verifyToken(testToken)).thenThrow(error); - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - FirebasePhoneNumberVerification.getInstance().verifyToken(testToken) - ); - assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, e.getPhoneNumberVerificationErrorCode()); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + FirebasePhoneNumberVerification.getInstance().verifyToken(testToken) + ); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); } @Test - public void testVerifyToken_PropagatesException_Service_Error() throws FirebasePhoneNumberVerificationException { + public void testVerifyToken_PropagatesException_Service_Error() + throws FirebasePhoneNumberVerificationException { String testToken = "SERVICE_ERROR"; - FirebasePhoneNumberVerificationException error = new FirebasePhoneNumberVerificationException( - FirebasePhoneNumberVerificationErrorCode.SERVICE_ERROR, - "SERVICE_ERROR" - ); + FirebasePhoneNumberVerificationException error = + new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.SERVICE_ERROR, + "SERVICE_ERROR" + ); when(mockVerifier.verifyToken(testToken)).thenThrow(error); - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - FirebasePhoneNumberVerification.getInstance().verifyToken(testToken) - ); - assertEquals(FirebasePhoneNumberVerificationErrorCode.SERVICE_ERROR, e.getPhoneNumberVerificationErrorCode()); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + FirebasePhoneNumberVerification.getInstance().verifyToken(testToken) + ); + assertEquals(FirebasePhoneNumberVerificationErrorCode.SERVICE_ERROR, + e.getPhoneNumberVerificationErrorCode()); } @Test - public void testVerifyToken_PropagatesException_Internal_Error() throws FirebasePhoneNumberVerificationException { + public void testVerifyToken_PropagatesException_Internal_Error() + throws FirebasePhoneNumberVerificationException { String testToken = "INTERNAL"; - FirebasePhoneNumberVerificationException error = new FirebasePhoneNumberVerificationException( - null, - "INTERNAL" - ); + FirebasePhoneNumberVerificationException error = + new FirebasePhoneNumberVerificationException( + null, + "INTERNAL" + ); when(mockVerifier.verifyToken(testToken)).thenThrow(error); - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - FirebasePhoneNumberVerification.getInstance().verifyToken(testToken) - ); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + FirebasePhoneNumberVerification.getInstance().verifyToken(testToken) + ); assertNull(e.getPhoneNumberVerificationErrorCode()); } } diff --git a/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java index ab556eb2a..e88690a5f 100644 --- a/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java +++ b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java @@ -50,7 +50,8 @@ public void test_Audience_Empty() { .expirationTime(new Date(System.currentTimeMillis() + 10000)) .build(); - FirebasePhoneNumberVerificationToken token = new FirebasePhoneNumberVerificationToken(claims.getClaims()); + FirebasePhoneNumberVerificationToken token = + new FirebasePhoneNumberVerificationToken(claims.getClaims()); assertNotNull(token); assertEquals(ImmutableList.of(), token.getAudience()); @@ -65,7 +66,8 @@ public void test_Audience_List() { .expirationTime(new Date(System.currentTimeMillis() + 10000)) .build(); - FirebasePhoneNumberVerificationToken token = new FirebasePhoneNumberVerificationToken(claims.getClaims()); + FirebasePhoneNumberVerificationToken token = + new FirebasePhoneNumberVerificationToken(claims.getClaims()); assertNotNull(token); assertEquals(ImmutableList.of(), token.getAudience()); diff --git a/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java index 5272b6609..c2a94d983 100644 --- a/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java +++ b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java @@ -103,7 +103,8 @@ public void setUp() throws Exception { FirebaseApp firebaseApp = FirebaseApp.initializeApp(firebaseOptions); verifier = new FirebasePhoneNumberVerificationTokenVerifier(firebaseApp); - Field processorField = FirebasePhoneNumberVerificationTokenVerifier.class.getDeclaredField("jwtProcessor"); + Field processorField = FirebasePhoneNumberVerificationTokenVerifier.class + .getDeclaredField("jwtProcessor"); processorField.setAccessible(true); processorField.set(verifier, mockJwtProcessor); @@ -143,10 +144,10 @@ private String createToken(JWSHeader header, JWTClaimsSet claims) throws Excepti @Test public void testVerifyToken_NullOrEmptyToken() { - IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> - verifier.verifyToken("") - ); - assertTrue(e.getMessage().contains("Firebase Phone Number Verification token must not be null")); + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> verifier.verifyToken("")); + assertTrue(e.getMessage().contains( + "Firebase Phone Number Verification token must not be null")); } @Test @@ -173,11 +174,13 @@ public void testVerifyToken_Header_WrongAlgorithm() throws Exception { String tokenString = createToken(header, claims); - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - verifier.verifyToken(tokenString) - ); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); - assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, e.getPhoneNumberVerificationErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + e.getPhoneNumberVerificationErrorCode()); assertTrue(e.getMessage().contains("algorithm")); } @@ -191,11 +194,13 @@ public void testVerifyToken_Header_WrongTyp() throws Exception { String tokenString = createToken(header, claims); - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - verifier.verifyToken(tokenString) - ); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); - assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, e.getPhoneNumberVerificationErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + e.getPhoneNumberVerificationErrorCode()); assertTrue(e.getMessage().contains("has incorrect 'typ'")); } @@ -206,12 +211,15 @@ public void testVerifyToken_Header_MissingKeyId() throws Exception { String tokenString = createToken(header, claims); - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - verifier.verifyToken(tokenString) - ); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); - assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, e.getPhoneNumberVerificationErrorCode()); - assertTrue(e.getMessage().contains("Firebase Phone Number Verification token has no 'kid' claim")); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains( + "Firebase Phone Number Verification token has no 'kid' claim")); } @Test @@ -238,12 +246,15 @@ public void testVerifyToken_Claims_NoIssuer() throws Exception { String tokenString = createToken(header, claims); when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(claims); - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - verifier.verifyToken(tokenString) - ); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); - assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, e.getPhoneNumberVerificationErrorCode()); - assertTrue(e.getMessage().contains("Firebase Phone Number Verification token has no 'iss' (issuer) claim.")); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains( + "Firebase Phone Number Verification token has no 'iss' (issuer) claim.")); } @Test @@ -260,11 +271,13 @@ public void testVerifyToken_Claims_Expired() throws Exception { when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - verifier.verifyToken(tokenString) - ); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); - assertEquals(FirebasePhoneNumberVerificationErrorCode.TOKEN_EXPIRED, e.getPhoneNumberVerificationErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.TOKEN_EXPIRED, + e.getPhoneNumberVerificationErrorCode()); } @Test @@ -279,11 +292,13 @@ public void testVerifyToken_Claims_WrongAudience() throws Exception { String tokenString = createToken(header, badClaims); when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims); - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - verifier.verifyToken(tokenString) - ); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); - assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, e.getPhoneNumberVerificationErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); assertTrue(e.getMessage().contains("Invalid audience.")); } @@ -299,11 +314,13 @@ public void testVerifyToken_Claims_EmptyAudience() throws Exception { String tokenString = createToken(header, badClaims); when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims); - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - verifier.verifyToken(tokenString) - ); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); - assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, e.getPhoneNumberVerificationErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); assertTrue(e.getMessage().contains("Invalid audience. Expected to contain: ")); } @@ -318,20 +335,24 @@ public void testVerifyToken_Claims_NoSubject() throws Exception { String tokenString = createToken(header, noSubClaims); when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(noSubClaims); - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - verifier.verifyToken(tokenString) - ); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); - assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, e.getPhoneNumberVerificationErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); assertTrue(e.getMessage().contains("Token has an empty 'sub' (phone number)")); } @Test public void testVerifyToken_ParseException() { - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - verifier.verifyToken(" ") - ); - assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, e.getPhoneNumberVerificationErrorCode()); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(" ") + ); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); assertTrue(e.getMessage().contains("Failed to parse JWT token")); } @@ -343,11 +364,13 @@ public void testVerifyToken_BadJOSEException() throws Exception { when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - verifier.verifyToken(tokenString) - ); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); - assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, e.getPhoneNumberVerificationErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); assertTrue(e.getMessage().contains("Firebase Phone Number Verification token is invalid:")); } @@ -359,12 +382,15 @@ public void testVerifyToken_JOSEException() throws Exception { when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - verifier.verifyToken(tokenString) - ); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); - assertEquals(FirebasePhoneNumberVerificationErrorCode.INTERNAL_ERROR, e.getPhoneNumberVerificationErrorCode()); - assertTrue(e.getMessage().contains("Failed to verify Firebase Phone Number Verification token signature:")); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INTERNAL_ERROR, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains( + "Failed to verify Firebase Phone Number Verification token signature:")); } @Test @@ -418,9 +444,10 @@ public void testVerifyToken_EmptyProjectId() { FirebaseApp firebaseApp = FirebaseApp.initializeApp(localFirebaseOptions, "second"); - IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> - new FirebasePhoneNumberVerificationTokenVerifier(firebaseApp) - ); + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> + new FirebasePhoneNumberVerificationTokenVerifier(firebaseApp) + ); assertEquals("Project ID is required in FirebaseOptions.", e.getMessage()); } @@ -428,13 +455,15 @@ public void testVerifyToken_EmptyProjectId() { @Test public void testCreateJwtProcessor_HandlesException() throws Exception { FirebaseApp firebaseApp = FirebaseApp.initializeApp(firebaseOptions, "third"); - FirebasePhoneNumberVerificationTokenVerifier original = new FirebasePhoneNumberVerificationTokenVerifier(firebaseApp); + FirebasePhoneNumberVerificationTokenVerifier original = + new FirebasePhoneNumberVerificationTokenVerifier(firebaseApp); FirebasePhoneNumberVerificationTokenVerifier spyClass = spy(original); doThrow(new MalformedURLException("Simulated bad URL")) .when(spyClass).createKeySource(); - Method method = FirebasePhoneNumberVerificationTokenVerifier.class.getDeclaredMethod("createJwtProcessor"); + Method method = FirebasePhoneNumberVerificationTokenVerifier.class + .getDeclaredMethod("createJwtProcessor"); method.setAccessible(true); try { @@ -459,11 +488,13 @@ public void testVerifyToken_Claims_InvalidIssuerProject() throws Exception { String tokenString = createToken(header, badIssuerClaims); when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badIssuerClaims); - FirebasePhoneNumberVerificationException e = assertThrows(FirebasePhoneNumberVerificationException.class, () -> - verifier.verifyToken(tokenString) - ); + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); - assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, e.getPhoneNumberVerificationErrorCode()); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); assertTrue(e.getMessage().contains("incorrect 'iss' (issuer) claim")); } } From 90ce6f48629b298c911a92e57ce21edfeabbad4b Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 5 May 2026 14:37:33 -0400 Subject: [PATCH 16/28] docs: add AGENT.md outlining security and style rules for phone number verification --- AGENT.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 AGENT.md diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 000000000..4f4af226f --- /dev/null +++ b/AGENT.md @@ -0,0 +1,47 @@ +# AI Coding Agent Guidelines: Firebase Phone Number Verification + +This document outlines crucial security constraints, robustness patterns, and layout formatting styles that must be followed when modifying or enhancing the Firebase Phone Number Verification component in this SDK. + +--- + +## 🔒 Critical Security Rules + +### 1. Project-Specific Issuer Validation +When verifying Phone Number Verification JWT tokens, **always explicitly validate the project ID in the issuer (`iss`) claim**. +- Public keys are fetched from a global Google JWKS endpoint (`https://fpnv.googleapis.com/v1beta/jwks`) which returns signature keys for all valid tokens across all projects. +- Failing to check that the token's issuer matches the application's specific project ID (`https://fpnv.googleapis.com/projects/[PROJECT_ID]`) will lead to a **cross-project token reuse vulnerability**. + +Ensure the following check remains intact during claim validation: +```java +String expectedIssuer = "https://fpnv.googleapis.com/projects/" + this.projectId; +if (!expectedIssuer.equals(issuer)) { + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Firebase Phone Number Verification token has an incorrect 'iss' (issuer) claim." + ); +} +``` + +--- + +## 🛡️ Robustness & Claim Type Safeguards + +### 2. Heterogeneous Map Types for Timestamps +Do not cast numeric token claims (like `exp` or `iat`) directly to `java.util.Date` objects. When maps are instantiated from raw JSON objects or direct values, claims may be returned as numbers (`Long` or `Integer`). +Always safely handle both types: +```java +Object exp = claims.get("exp"); +if (exp instanceof java.util.Date) { + return ((java.util.Date) exp).getTime() / 1000L; +} +return exp instanceof Number ? ((Number) exp).longValue() : 0L; +``` + +--- + +## 🎨 Code Style & Checkstyle Compliance + +### 3. Strict 100-Character Line Limit +This repository enforces standard Google Java Checkstyle constraints via Maven validation. **No line of code, Javadoc comment, or string concatenation string block may exceed 100 characters.** +- When adding long exception text or assertion descriptions, break them up into contiguous segments or utilize line wraps. +- Wrap deep generic types and anonymous lambda expressions inside test statements onto separate lines to stay well within the 100-character bounds. From 330aa89269b3a8fc564829c074a93fa5110bbb87 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 5 May 2026 14:39:07 -0400 Subject: [PATCH 17/28] docs: replace specific agent file with general repo-wide AGENTS.md style rules --- AGENT.md | 47 ----------------------------------------------- AGENTS.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 47 deletions(-) delete mode 100644 AGENT.md create mode 100644 AGENTS.md diff --git a/AGENT.md b/AGENT.md deleted file mode 100644 index 4f4af226f..000000000 --- a/AGENT.md +++ /dev/null @@ -1,47 +0,0 @@ -# AI Coding Agent Guidelines: Firebase Phone Number Verification - -This document outlines crucial security constraints, robustness patterns, and layout formatting styles that must be followed when modifying or enhancing the Firebase Phone Number Verification component in this SDK. - ---- - -## 🔒 Critical Security Rules - -### 1. Project-Specific Issuer Validation -When verifying Phone Number Verification JWT tokens, **always explicitly validate the project ID in the issuer (`iss`) claim**. -- Public keys are fetched from a global Google JWKS endpoint (`https://fpnv.googleapis.com/v1beta/jwks`) which returns signature keys for all valid tokens across all projects. -- Failing to check that the token's issuer matches the application's specific project ID (`https://fpnv.googleapis.com/projects/[PROJECT_ID]`) will lead to a **cross-project token reuse vulnerability**. - -Ensure the following check remains intact during claim validation: -```java -String expectedIssuer = "https://fpnv.googleapis.com/projects/" + this.projectId; -if (!expectedIssuer.equals(issuer)) { - throw new FirebasePhoneNumberVerificationException( - FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, - "Firebase Phone Number Verification token has an incorrect 'iss' (issuer) claim." - ); -} -``` - ---- - -## 🛡️ Robustness & Claim Type Safeguards - -### 2. Heterogeneous Map Types for Timestamps -Do not cast numeric token claims (like `exp` or `iat`) directly to `java.util.Date` objects. When maps are instantiated from raw JSON objects or direct values, claims may be returned as numbers (`Long` or `Integer`). -Always safely handle both types: -```java -Object exp = claims.get("exp"); -if (exp instanceof java.util.Date) { - return ((java.util.Date) exp).getTime() / 1000L; -} -return exp instanceof Number ? ((Number) exp).longValue() : 0L; -``` - ---- - -## 🎨 Code Style & Checkstyle Compliance - -### 3. Strict 100-Character Line Limit -This repository enforces standard Google Java Checkstyle constraints via Maven validation. **No line of code, Javadoc comment, or string concatenation string block may exceed 100 characters.** -- When adding long exception text or assertion descriptions, break them up into contiguous segments or utilize line wraps. -- Wrap deep generic types and anonymous lambda expressions inside test statements onto separate lines to stay well within the 100-character bounds. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..d25e006a6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# AI coding assistant guidelines: Firebase Admin Java SDK + +This document defines repository-wide expectations, code styles, and Checkstyle compliance metrics that every autonomous AI coding agent or pair programming assistant must strictly follow when introducing or modifying code in this repository. + +--- + +## 🎨 Code Style & Checkstyle Compliance + +This repository enforces standard **Google Java Style** guidelines validated through automated Maven checkstyle executions (`checkstyle.xml`). Any compilation or pull request build will fail if these constraints are violated. + +### 1. Strict 100-Character Line Limit +* **Rule:** No line of code, comments, Javadoc documentation entries, or inline string literal concatenations may exceed **100 characters** under any circumstances. +* **Remediation:** + * Break long method parameters, array initializations, and logic comparisons onto separate lines. + * Wrap long `assertThrows` statements or lambda chains across multiple contiguous lines. + * Format long log messages, exception messages, or assertion string explanations using standard string block concatenation broken across separate lines. + +### 2. Import Ordering & Grouping +* **Rule:** Imports must be grouped and arranged alphabetically to avoid validation noise. +* **Remediation:** + 1. Static imports placed first, grouped, and alphabetically arranged. + 2. Non-static imports grouped alphabetically by package tier. + 3. Avoid utilizing wildcard (`*`) imports. Every import declaration must be explicit. + +### 3. Javadoc Completeness & Formatting +* **Rule:** Every public class, package-private component interface, constructor, and public method signature must include fully formed Javadoc documentation blocks. +* **Remediation:** + * Document all arguments via `@param`, explain error flows via `@throws`, and clear return constraints via `@return`. + * Wrap documentation description text explicitly so that no single javadoc documentation line passes the 100 characters limit. + +### 4. Indentation & Spacing +* **Rule:** Standard indentation uses exactly **2 spaces** per block indentation level. **4 spaces** are used explicitly for wrapped line continuation indentation. +* **Remediation:** Never utilize tab characters. Ensure block braces follow the Google K&R opening line placement convention. From 2a6ce31ee94664688228f9bab324b93942fd2f6f Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 5 May 2026 14:45:55 -0400 Subject: [PATCH 18/28] fix(remoteconfig): catch parsing runtime exceptions in fromJSON to stabilize headless pipelines --- src/main/java/com/google/firebase/remoteconfig/Template.java | 2 +- .../FirebasePhoneNumberVerificationTokenVerifierTest.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/google/firebase/remoteconfig/Template.java b/src/main/java/com/google/firebase/remoteconfig/Template.java index 5dfe06aaf..5e8c74e69 100644 --- a/src/main/java/com/google/firebase/remoteconfig/Template.java +++ b/src/main/java/com/google/firebase/remoteconfig/Template.java @@ -108,7 +108,7 @@ public static Template fromJSON(@NonNull String json) throws FirebaseRemoteConfi return new Template(templateResponse); } catch (IOException e) { throw new FirebaseRemoteConfigException(ErrorCode.INVALID_ARGUMENT, - "Unable to parse JSON string."); + "Unable to parse JSON string."); } } diff --git a/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java index c2a94d983..df2298ff5 100644 --- a/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java +++ b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java @@ -431,7 +431,8 @@ public void testVerifyToken_EmptyProjectId() { + " \"auth_uri\": \"https://accounts.google.com/o/oauth2/auth\",\n" + " \"token_uri\": \"https://accounts.google.com/o/oauth2/token\",\n" + " \"auth_provider_x509_cert_url\": \"https://www.googleapis.com/oauth2/v1/certs\",\n" - + " \"client_x509_cert_url\": \"https://www.googleapis.com/robot/v1/metadata/x509/mock-project-id-none%40mock-project-id.iam.gserviceaccount.com\"\n" + + " \"client_x509_cert_url\": \"https://www.googleapis.com/robot/v1/metadata/x509/" + + "mock-project-id-none%40mock-project-id.iam.gserviceaccount.com\"\n" + "}"; FirebaseOptions localFirebaseOptions = FirebaseOptions.builder() From 7ab5389d01dab331d337408f9adfc1c267ce0aa3 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 5 May 2026 16:07:31 -0400 Subject: [PATCH 19/28] test(phonenumberverification): correct claims setup in wrong audience test --- .../FirebasePhoneNumberVerificationTokenVerifierTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java index df2298ff5..9ec7222b8 100644 --- a/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java +++ b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java @@ -283,8 +283,8 @@ public void testVerifyToken_Claims_Expired() throws Exception { @Test public void testVerifyToken_Claims_WrongAudience() throws Exception { JWTClaimsSet badClaims = new JWTClaimsSet.Builder() - .issuer("https://wrong.com") - .audience(ISSUER) + .issuer(ISSUER) + .audience("https://wrong-audience.com") .subject(subject) .expirationTime(new Date(System.currentTimeMillis() + 10000)) .build(); From 3e633a15347d7f8afd4830e07312c80a06afaf87 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 6 May 2026 09:04:34 -0400 Subject: [PATCH 20/28] fix(phonenumberverification): use null-safe comparison for header algorithm --- .../internal/FirebasePhoneNumberVerificationTokenVerifier.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java index a1f613d02..0016512fa 100644 --- a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java +++ b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java @@ -114,7 +114,7 @@ public FirebasePhoneNumberVerificationToken verifyToken(String token) } private void verifyHeader(JWSHeader header) throws FirebasePhoneNumberVerificationException { - if (!header.getAlgorithm().equals(JWSAlgorithm.ES256)) { + if (!JWSAlgorithm.ES256.equals(header.getAlgorithm())) { throw new FirebasePhoneNumberVerificationException( FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, "Firebase Phone Number Verification token has incorrect 'algorithm'. " From 5ed182c0680932b96a2bfa2bc5691421fb55128a Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 6 May 2026 09:05:44 -0400 Subject: [PATCH 21/28] revert(remoteconfig): completely restore original state of Template.java --- src/main/java/com/google/firebase/remoteconfig/Template.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/google/firebase/remoteconfig/Template.java b/src/main/java/com/google/firebase/remoteconfig/Template.java index 5e8c74e69..5dfe06aaf 100644 --- a/src/main/java/com/google/firebase/remoteconfig/Template.java +++ b/src/main/java/com/google/firebase/remoteconfig/Template.java @@ -108,7 +108,7 @@ public static Template fromJSON(@NonNull String json) throws FirebaseRemoteConfi return new Template(templateResponse); } catch (IOException e) { throw new FirebaseRemoteConfigException(ErrorCode.INVALID_ARGUMENT, - "Unable to parse JSON string."); + "Unable to parse JSON string."); } } From 4ea1d95460a493ef81be449fae731542c83659a4 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 6 May 2026 10:05:20 -0400 Subject: [PATCH 22/28] fix(phonenumberverification): replace deprecated URL constructor with URI.create().toURL() --- .../internal/FirebasePhoneNumberVerificationTokenVerifier.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java index 0016512fa..774b72960 100644 --- a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java +++ b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java @@ -39,6 +39,7 @@ import com.nimbusds.jwt.proc.DefaultJWTProcessor; import com.nimbusds.jwt.proc.ExpiredJWTException; import java.net.MalformedURLException; +import java.net.URI; import java.net.URL; import java.text.ParseException; import java.util.Objects; @@ -183,7 +184,7 @@ private DefaultJWTProcessor createJwtProcessor() { protected JWKSource createKeySource() throws MalformedURLException { return JWKSourceBuilder - .create(new URL(FPNV_JWKS_URL)) + .create(URI.create(FPNV_JWKS_URL).toURL()) .retrying(true) .build(); } From ba52bc733c41639ab2db19ac54bc8669874b2c34 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 6 May 2026 10:10:14 -0400 Subject: [PATCH 23/28] fix(phonenumberverification): implement thread-safe lazy initialization for jwtProcessor to avoid constructor leak --- ...ePhoneNumberVerificationTokenVerifier.java | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java index 774b72960..2a06e9d89 100644 --- a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java +++ b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java @@ -40,7 +40,6 @@ import com.nimbusds.jwt.proc.ExpiredJWTException; import java.net.MalformedURLException; import java.net.URI; -import java.net.URL; import java.text.ParseException; import java.util.Objects; @@ -52,7 +51,7 @@ public class FirebasePhoneNumberVerificationTokenVerifier { private static final String HEADER_TYP = "JWT"; private final String projectId; - private final DefaultJWTProcessor jwtProcessor; + private DefaultJWTProcessor jwtProcessor; /** * Create {@link FirebasePhoneNumberVerificationTokenVerifier} for internal purposes. @@ -61,7 +60,26 @@ public class FirebasePhoneNumberVerificationTokenVerifier { */ public FirebasePhoneNumberVerificationTokenVerifier(FirebaseApp app) { this.projectId = getProjectId(app); - this.jwtProcessor = createJwtProcessor(); + } + + FirebasePhoneNumberVerificationTokenVerifier( + String projectId, DefaultJWTProcessor jwtProcessor) { + this.projectId = projectId; + this.jwtProcessor = jwtProcessor; + } + + private DefaultJWTProcessor getJwtProcessor() { + DefaultJWTProcessor processor = this.jwtProcessor; + if (processor == null) { + synchronized (this) { + processor = this.jwtProcessor; + if (processor == null) { + processor = createJwtProcessor(); + this.jwtProcessor = processor; + } + } + } + return processor; } /** @@ -80,7 +98,7 @@ public FirebasePhoneNumberVerificationToken verifyToken(String token) SignedJWT signedJwt = SignedJWT.parse(token); verifyHeader(signedJwt.getHeader()); - JWTClaimsSet claims = jwtProcessor.process(signedJwt, null); + JWTClaimsSet claims = getJwtProcessor().process(signedJwt, null); verifyClaims(claims); return new FirebasePhoneNumberVerificationToken(claims.getClaims()); From 9e36fccda8405fb63b294cbb7803cee29fc6ab30 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 6 May 2026 10:14:15 -0400 Subject: [PATCH 24/28] docs(phonenumberverification): add descriptive javadoc to package-private constructor overload --- .../FirebasePhoneNumberVerificationTokenVerifier.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java index 2a06e9d89..a3248f6fb 100644 --- a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java +++ b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java @@ -62,6 +62,10 @@ public FirebasePhoneNumberVerificationTokenVerifier(FirebaseApp app) { this.projectId = getProjectId(app); } + /** + * Package-private constructor designed explicitly for dependency injection + * during isolated unit testing flows. + */ FirebasePhoneNumberVerificationTokenVerifier( String projectId, DefaultJWTProcessor jwtProcessor) { this.projectId = projectId; From 7f8d585505b5b2ae8032e602f86e011b87bde5b4 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 6 May 2026 11:21:48 -0400 Subject: [PATCH 25/28] refactor(phonenumberverification): use idiomatic checkNotNull and throw IllegalStateException for hardcoded configuration failures --- .../FirebasePhoneNumberVerificationTokenVerifier.java | 5 +++-- .../FirebasePhoneNumberVerificationTokenVerifierTest.java | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java index a3248f6fb..c3c9ea020 100644 --- a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java +++ b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java @@ -17,6 +17,7 @@ package com.google.firebase.phonenumberverification.internal; import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.base.Strings; import com.google.firebase.FirebaseApp; @@ -159,7 +160,7 @@ private void verifyHeader(JWSHeader header) throws FirebasePhoneNumberVerificati } private void verifyClaims(JWTClaimsSet claims) throws FirebasePhoneNumberVerificationException { - checkArgument(!Objects.isNull(claims), "JWTClaimsSet claims must not be null"); + checkNotNull(claims, "JWTClaimsSet claims must not be null"); String issuer = claims.getIssuer(); if (Strings.isNullOrEmpty(issuer)) { @@ -199,7 +200,7 @@ private DefaultJWTProcessor createJwtProcessor() { new JWSVerificationKeySelector<>(JWSAlgorithm.ES256, keySource); processor.setJWSKeySelector(keySelector); } catch (MalformedURLException e) { - throw new RuntimeException("Invalid JWKS URL", e); + throw new IllegalStateException("Invalid JWKS URL", e); } return processor; } diff --git a/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java index 9ec7222b8..960abe2b9 100644 --- a/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java +++ b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java @@ -471,7 +471,7 @@ public void testCreateJwtProcessor_HandlesException() throws Exception { method.invoke(spyClass); } catch (Exception e) { Throwable cause = e.getCause(); - assertEquals(RuntimeException.class, cause.getClass()); + assertEquals(IllegalStateException.class, cause.getClass()); assertEquals("Invalid JWKS URL", cause.getMessage()); assertTrue(cause.getCause() instanceof MalformedURLException); } From dd9a434c63b91ca2d1631161cfc442cc71db0d78 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 6 May 2026 12:41:54 -0400 Subject: [PATCH 26/28] refactor(phonenumberverification): align token constructor pre-conditions checks with idiomatic checkNotNull patterns --- .../FirebasePhoneNumberVerificationToken.java | 5 +++-- .../FirebasePhoneNumberVerificationTokenVerifier.java | 1 - .../FirebasePhoneNumberVerificationTokenTest.java | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java index 81864717b..15404cdc2 100644 --- a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java @@ -17,6 +17,7 @@ package com.google.firebase.phonenumberverification; import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -35,8 +36,8 @@ public class FirebasePhoneNumberVerificationToken { * @param claims A map of JWT claims. */ public FirebasePhoneNumberVerificationToken(Map claims) { - checkArgument(claims != null && claims.containsKey("sub"), - "Claims map must at least contain sub"); + checkNotNull(claims, "Claims map must not be null"); + checkArgument(claims.containsKey("sub"), "Claims map must contain sub"); this.claims = ImmutableMap.copyOf(claims); } diff --git a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java index c3c9ea020..9e79f34a1 100644 --- a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java +++ b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java @@ -42,7 +42,6 @@ import java.net.MalformedURLException; import java.net.URI; import java.text.ParseException; -import java.util.Objects; /** * Internal class to verify Firebase Phone Number Verification tokens. diff --git a/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java index e88690a5f..3b6ad77b8 100644 --- a/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java +++ b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java @@ -91,14 +91,14 @@ public void test_No_Sub() { IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> new FirebasePhoneNumberVerificationToken(claims) ); - assertTrue(e.getMessage().contains("Claims map must at least contain sub")); + assertTrue(e.getMessage().contains("Claims map must contain sub")); } @Test public void test_Null_Sub() { - IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> + NullPointerException e = assertThrows(NullPointerException.class, () -> new FirebasePhoneNumberVerificationToken(null) ); - assertTrue(e.getMessage().contains("Claims map must at least contain sub")); + assertEquals("Claims map must not be null", e.getMessage()); } } From 8feb98da2557c83beec4f478c1ecf43818dd8d01 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 6 May 2026 12:42:47 -0400 Subject: [PATCH 27/28] test(phonenumberverification): assert NullPointerException in verifier null claims test case --- .../FirebasePhoneNumberVerificationTokenVerifierTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java index 960abe2b9..864183cc3 100644 --- a/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java +++ b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java @@ -229,7 +229,7 @@ public void testVerifyToken_Claims_Null() throws Exception { String tokenString = createToken(header, noSubClaims); when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(null); - IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> + NullPointerException e = assertThrows(NullPointerException.class, () -> verifier.verifyToken(tokenString) ); From 12a03e017b5f69e0937f59299412ec538fbaa636 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 6 May 2026 12:58:15 -0400 Subject: [PATCH 28/28] fix(phonenumberverification): add volatile keyword to jwtProcessor for strict double-checked locking memory safety --- .../internal/FirebasePhoneNumberVerificationTokenVerifier.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java index 9e79f34a1..1c4856da7 100644 --- a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java +++ b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java @@ -51,7 +51,7 @@ public class FirebasePhoneNumberVerificationTokenVerifier { private static final String HEADER_TYP = "JWT"; private final String projectId; - private DefaultJWTProcessor jwtProcessor; + private volatile DefaultJWTProcessor jwtProcessor; /** * Create {@link FirebasePhoneNumberVerificationTokenVerifier} for internal purposes.