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. 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 + 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..cdf2028c7 --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java @@ -0,0 +1,84 @@ +/* + * 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/phonenumberverification/FirebasePhoneNumberVerificationErrorCode.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCode.java new file mode 100644 index 000000000..056126c15 --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCode.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.phonenumberverification; + +/** + * Error codes that are used in {@link FirebasePhoneNumberVerification}. + */ +public enum FirebasePhoneNumberVerificationErrorCode { + INVALID_ARGUMENT, + INVALID_TOKEN, + TOKEN_EXPIRED, + INTERNAL_ERROR, + SERVICE_ERROR, +} diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java new file mode 100644 index 000000000..50e7b18eb --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java @@ -0,0 +1,81 @@ +/* + * 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.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 FirebasePhoneNumberVerificationException extends FirebaseException { + private final FirebasePhoneNumberVerificationErrorCode errorCode; + + /** + * Exception that created from {@link FirebasePhoneNumberVerificationErrorCode}, + * {@link String} message and {@link Throwable} cause. + * + * @param errorCode {@link FirebasePhoneNumberVerificationErrorCode} + * @param message {@link String} + * @param cause {@link Throwable} + */ + public FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode errorCode, + String message, + Throwable cause + ) { + super(mapToFirebaseError(errorCode), message, cause); + this.errorCode = errorCode; + } + + /** + * Exception that created from {@link FirebasePhoneNumberVerificationErrorCode} + * and {@link String} message. + * + * @param errorCode {@link FirebasePhoneNumberVerificationErrorCode} + * @param message {@link String} + */ + public FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode errorCode, + String message + ) { + this(errorCode, message, null); + } + + public FirebasePhoneNumberVerificationErrorCode getPhoneNumberVerificationErrorCode() { + return errorCode; + } + + private static ErrorCode mapToFirebaseError(FirebasePhoneNumberVerificationErrorCode 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; + } + } +} diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java new file mode 100644 index 000000000..15404cdc2 --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java @@ -0,0 +1,102 @@ +/* + * 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 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; +import java.util.List; +import java.util.Map; + +/** + * Represents a verified Firebase Phone Number Verification token. + */ +public class FirebasePhoneNumberVerificationToken { + private final Map claims; + + /** + * Create an instance of {@link FirebasePhoneNumberVerificationToken} from a map of JWT claims. + * + * @param claims A map of JWT claims. + */ + public FirebasePhoneNumberVerificationToken(Map claims) { + checkNotNull(claims, "Claims map must not be null"); + checkArgument(claims.containsKey("sub"), "Claims map must 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() { + Object audience = claims.get("aud"); + if (audience instanceof String) { + return ImmutableList.of((String) audience); + } else if (audience instanceof List) { + @SuppressWarnings("unchecked") + List audienceList = (List) audience; + return ImmutableList.copyOf(audienceList); + } + return ImmutableList.of(); + } + + /** + * Returns the expiration time in seconds since the Unix epoch. + */ + public long getExpirationTime() { + 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() { + 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; + } + + /** + * Returns the entire map of claims. + */ + public Map getClaims() { + return 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 new file mode 100644 index 000000000..1c4856da7 --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java @@ -0,0 +1,221 @@ +/* + * 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.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; +import com.google.firebase.ImplFirebaseTrampolines; +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; +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.URI; +import java.text.ParseException; + +/** + * Internal class to verify Firebase Phone Number Verification tokens. + */ +public class FirebasePhoneNumberVerificationTokenVerifier { + 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 volatile DefaultJWTProcessor jwtProcessor; + + /** + * Create {@link FirebasePhoneNumberVerificationTokenVerifier} for internal purposes. + * + * @param app The {@link FirebaseApp} to get a project ID from. + */ + 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; + 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; + } + + /** + * Main method that performs token verification steps. + * + * @param token String input data + * @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"); + + try { + SignedJWT signedJwt = SignedJWT.parse(token); + verifyHeader(signedJwt.getHeader()); + + JWTClaimsSet claims = getJwtProcessor().process(signedJwt, null); + verifyClaims(claims); + + return new FirebasePhoneNumberVerificationToken(claims.getClaims()); + } catch (ParseException e) { + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Failed to parse JWT token: " + e.getMessage(), + e + ); + } catch (ExpiredJWTException e) { + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.TOKEN_EXPIRED, + "Firebase Phone Number Verification token has expired.", + e + ); + } catch (BadJOSEException e) { + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Check your project: " + projectId + ". " + + "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(), + e + ); + } + } + + private void verifyHeader(JWSHeader header) throws FirebasePhoneNumberVerificationException { + if (!JWSAlgorithm.ES256.equals(header.getAlgorithm())) { + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + "Firebase Phone Number Verification token has incorrect 'algorithm'. " + + "Expected " + JWSAlgorithm.ES256.getName() + " but got " + header.getAlgorithm()); + } + if (Strings.isNullOrEmpty(header.getKeyID())) { + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + "Firebase Phone Number Verification token has no 'kid' claim." + ); + } + if (!JOSEObjectType.JWT.equals(header.getType())) { + 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 FirebasePhoneNumberVerificationException { + checkNotNull(claims, "JWTClaimsSet claims must not be null"); + String issuer = claims.getIssuer(); + + if (Strings.isNullOrEmpty(issuer)) { + 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, + "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() + ); + } + + if (Strings.isNullOrEmpty(claims.getSubject())) { + throw new FirebasePhoneNumberVerificationException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Token has an empty 'sub' (phone number)." + ); + } + } + + private DefaultJWTProcessor createJwtProcessor() { + DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); + try { + JWKSource keySource = createKeySource(); + JWSKeySelector keySelector = + new JWSVerificationKeySelector<>(JWSAlgorithm.ES256, keySource); + processor.setJWSKeySelector(keySelector); + } catch (MalformedURLException e) { + throw new IllegalStateException("Invalid JWKS URL", e); + } + return processor; + } + + protected JWKSource createKeySource() throws MalformedURLException { + return JWKSourceBuilder + .create(URI.create(FPNV_JWKS_URL).toURL()) + .retrying(true) + .build(); + } + + 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/phonenumberverification/FirebasePhoneNumberVerificationErrorCodeTest.java b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCodeTest.java new file mode 100644 index 000000000..c9f02ac23 --- /dev/null +++ b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCodeTest.java @@ -0,0 +1,32 @@ +/* + * 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.assertNotNull; + +import org.junit.Test; + +public class FirebasePhoneNumberVerificationErrorCodeTest { + @Test + public void testEnum() { + 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/phonenumberverification/FirebasePhoneNumberVerificationTest.java b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTest.java new file mode 100644 index 000000000..e093cdc45 --- /dev/null +++ b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTest.java @@ -0,0 +1,160 @@ +/* + * 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; +import static org.junit.Assert.assertNull; +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; +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; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class FirebasePhoneNumberVerificationTest { + private static final FirebaseOptions firebaseOptions = FirebaseOptions.builder() + .setCredentials(TestUtils.getCertCredential(ServiceAccount.OWNER.asStream())) + .build(); + + @Mock + private FirebasePhoneNumberVerificationTokenVerifier mockVerifier; + + private FirebasePhoneNumberVerification firebasePhoneNumberVerification; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + + FirebaseApp.initializeApp(firebaseOptions); + firebasePhoneNumberVerification = FirebasePhoneNumberVerification.getInstance(); + + Field verifierField = FirebasePhoneNumberVerification.class.getDeclaredField("tokenVerifier"); + verifierField.setAccessible(true); + verifierField.set(firebasePhoneNumberVerification, mockVerifier); + } + + @After + public void tearDown() { + FirebaseProcessEnvironment.clearCache(); + TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); + } + + @Test + public void testGetInstance() { + FirebasePhoneNumberVerification instance = FirebasePhoneNumberVerification.getInstance(); + assertNotNull(instance); + assertSame(instance, FirebasePhoneNumberVerification.getInstance()); + } + + @Test + public void testGetInstanceForApp() { + FirebaseApp app = FirebaseApp.initializeApp(firebaseOptions, "testGetInstanceForApp"); + FirebasePhoneNumberVerification instance = FirebasePhoneNumberVerification.getInstance(app); + assertNotNull(instance); + assertSame(instance, FirebasePhoneNumberVerification.getInstance(app)); + } + + @Test + public void testVerifyToken_DelegatesToVerifier() + throws FirebasePhoneNumberVerificationException { + String testToken = "test.token"; + FirebasePhoneNumberVerificationToken expectedToken = + mock(FirebasePhoneNumberVerificationToken.class); + + when(mockVerifier.verifyToken(testToken)).thenReturn(expectedToken); + + FirebasePhoneNumberVerificationToken result = + firebasePhoneNumberVerification.verifyToken(testToken); + + assertEquals(expectedToken, result); + verify(mockVerifier, times(1)).verifyToken(testToken); + } + + @Test + public void testVerifyToken_PropagatesException() + throws FirebasePhoneNumberVerificationException { + String testToken = "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()); + } + + @Test + public void testVerifyToken_PropagatesException_Service_Error() + throws FirebasePhoneNumberVerificationException { + String testToken = "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()); + } + + @Test + public void testVerifyToken_PropagatesException_Internal_Error() + throws FirebasePhoneNumberVerificationException { + String testToken = "INTERNAL"; + FirebasePhoneNumberVerificationException error = + new FirebasePhoneNumberVerificationException( + null, + "INTERNAL" + ); + + when(mockVerifier.verifyToken(testToken)).thenThrow(error); + + 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 new file mode 100644 index 000000000..3b6ad77b8 --- /dev/null +++ b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java @@ -0,0 +1,104 @@ +/* + * 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; +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 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"; + + @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(); + + FirebasePhoneNumberVerificationToken token = + new FirebasePhoneNumberVerificationToken(claims.getClaims()); + + assertNotNull(token); + assertEquals(ImmutableList.of(), token.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(); + + FirebasePhoneNumberVerificationToken token = + new FirebasePhoneNumberVerificationToken(claims.getClaims()); + + assertNotNull(token); + assertEquals(ImmutableList.of(), token.getAudience()); + } + + @Test + public void test_Audience_String() { + Map claims = new HashMap<>(); + claims.put("sub", subject); + claims.put("aud", ISSUER); + + FirebasePhoneNumberVerificationToken token = new FirebasePhoneNumberVerificationToken(claims); + + assertNotNull(token); + assertEquals(ImmutableList.of(ISSUER), token.getAudience()); + } + + @Test + public void test_No_Sub() { + Map claims = new HashMap<>(); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> + new FirebasePhoneNumberVerificationToken(claims) + ); + assertTrue(e.getMessage().contains("Claims map must contain sub")); + } + + @Test + public void test_Null_Sub() { + NullPointerException e = assertThrows(NullPointerException.class, () -> + new FirebasePhoneNumberVerificationToken(null) + ); + assertEquals("Claims map must not be null", e.getMessage()); + } +} diff --git a/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java new file mode 100644 index 000000000..864183cc3 --- /dev/null +++ b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java @@ -0,0 +1,501 @@ +/* + * 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.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.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; +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.BadJOSEException; +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.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; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class FirebasePhoneNumberVerificationTokenVerifierTest { + 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())) + .build(); + 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 FirebasePhoneNumberVerificationTokenVerifier verifier; + private KeyPair rsaKeyPair; + 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 { + MockitoAnnotations.openMocks(this); + + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048); + rsaKeyPair = gen.generateKeyPair(); + + ecKey = new ECKeyGenerator(Curve.P_256).keyID("ec-key-id").generate(); + + FirebaseApp firebaseApp = FirebaseApp.initializeApp(firebaseOptions); + verifier = new FirebasePhoneNumberVerificationTokenVerifier(firebaseApp); + + Field processorField = FirebasePhoneNumberVerificationTokenVerifier.class + .getDeclaredField("jwtProcessor"); + processorField.setAccessible(true); + processorField.set(verifier, mockJwtProcessor); + + header = new JWSHeader.Builder(JWSAlgorithm.ES256) + .keyID(ecKey.getKeyID()) + .type(JOSEObjectType.JWT) + .build(); + + claims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(Arrays.asList(AUD)) + .subject(subject) + .issueTime(issueTime) + .expirationTime(expirationTime) + .build(); + } + + @After + public void tearDown() { + FirebaseProcessEnvironment.clearCache(); + TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); + } + + private String createToken(JWSHeader header, JWTClaimsSet claims) throws Exception { + SignedJWT jwt = new SignedJWT(header, claims); + + if (JWSAlgorithm.RS256.equals(header.getAlgorithm())) { + jwt.sign(new RSASSASigner(rsaKeyPair.getPrivate())); + } else if (JWSAlgorithm.HS256.equals(header.getAlgorithm())) { + jwt.sign(new MACSigner("12345678901234567890123456789012")); + } else if (JWSAlgorithm.ES256.equals(header.getAlgorithm())) { + jwt.sign(new ECDSASigner(ecKey.toECPrivateKey())); + } + + return jwt.serialize(); + } + + @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")); + } + + @Test + public void testVerifyToken_Success() throws Exception { + String tokenString = createToken(header, claims); + + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(claims); + + FirebasePhoneNumberVerificationToken result = verifier.verifyToken(tokenString); + + assertNotNull(result); + 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 + public void testVerifyToken_Header_WrongAlgorithm() throws Exception { + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.HS256).build(); + JWTClaimsSet claims = new JWTClaimsSet.Builder().build(); + + String tokenString = createToken(header, claims); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + 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) + .keyID(ecKey.getKeyID()) + .type(JOSEObjectType.JOSE) + .build(); + JWTClaimsSet claims = new JWTClaimsSet.Builder().build(); + + String tokenString = createToken(header, claims); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("has incorrect 'typ'")); + } + + @Test + public void testVerifyToken_Header_MissingKeyId() throws Exception { + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256).build(); + JWTClaimsSet claims = new JWTClaimsSet.Builder().build(); + + String tokenString = createToken(header, claims); + + 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")); + } + + @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); + + NullPointerException e = assertThrows(NullPointerException.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); + + 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.")); + } + + @Test + public void testVerifyToken_Claims_Expired() throws Exception { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(ISSUER) + .subject("+1555") + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + String tokenString = createToken(header, claims); + ExpiredJWTException error = new ExpiredJWTException("Bad token"); + + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.TOKEN_EXPIRED, + e.getPhoneNumberVerificationErrorCode()); + } + + @Test + public void testVerifyToken_Claims_WrongAudience() throws Exception { + JWTClaimsSet badClaims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience("https://wrong-audience.com") + .subject(subject) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + String tokenString = createToken(header, badClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); + 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); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("Invalid audience. Expected to contain: ")); + } + + @Test + public void testVerifyToken_Claims_NoSubject() throws Exception { + JWTClaimsSet noSubClaims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(ISSUER) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + String tokenString = createToken(header, noSubClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(noSubClaims); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + 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()); + 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); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("Firebase Phone Number Verification token is invalid:")); + } + + @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); + + 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:")); + } + + @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(); + + FirebaseApp firebaseApp = FirebaseApp.initializeApp(localFirebaseOptions, "second"); + + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> + new FirebasePhoneNumberVerificationTokenVerifier(firebaseApp) + ); + + assertEquals("Project ID is required in FirebaseOptions.", e.getMessage()); + } + + @Test + public void testCreateJwtProcessor_HandlesException() throws Exception { + FirebaseApp firebaseApp = FirebaseApp.initializeApp(firebaseOptions, "third"); + 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.setAccessible(true); + + try { + method.invoke(spyClass); + } catch (Exception e) { + Throwable cause = e.getCause(); + assertEquals(IllegalStateException.class, cause.getClass()); + assertEquals("Invalid JWKS URL", cause.getMessage()); + 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")); + } +}