diff --git a/pom.xml b/pom.xml index 7a56a39c6..c258760e4 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/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..db16f547d --- /dev/null +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java @@ -0,0 +1,60 @@ +/* + * 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 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. + * + * @param errorCode {@link FirebasePnvErrorCode} + * @param message {@link String} + */ + public FirebasePnvException( + FirebasePnvErrorCode errorCode, + String message + ) { + this(errorCode, message, null); + } + + 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 new file mode 100644 index 000000000..e54b1eb6d --- /dev/null +++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java @@ -0,0 +1,90 @@ +/* + * 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.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 FirebasePnvToken { + private final Map claims; + + public FirebasePnvToken(Map claims) { + 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() { + 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 ImmutableList.of(); + } + + /** + * Returns the expiration time in seconds since the Unix epoch. + */ + public long getExpirationTime() { + return ((java.util.Date) claims.get("exp")).getTime(); + } + + /** + * Returns the issued-at time in seconds since the Unix epoch. + */ + public long getIssuedAt() { + return ((java.util.Date) claims.get("iat")).getTime(); + } + + /** + * Returns the entire map of claims. + */ + 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 new file mode 100644 index 000000000..a3cf78e2e --- /dev/null +++ b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java @@ -0,0 +1,197 @@ +/* + * 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(), + e + ); + } catch (ExpiredJWTException e) { + 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 + ); + } + } + + 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, + "FPNV token has no 'iss' (issuer) claim."); + } + + // 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)." + ); + } + } + + 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..f7f519827 --- /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.assertNotNull; + +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..6be4ceea3 --- /dev/null +++ b/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java @@ -0,0 +1,116 @@ +/* + * 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.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; +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 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 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() throws FirebasePnvException { + 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() throws FirebasePnvException { + 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..bab502dca --- /dev/null +++ b/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java @@ -0,0 +1,246 @@ +/* + * 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.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; +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 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 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 = "mock-project-id"; + 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 { + 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"); + + // Mock processor returning the expired claims + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); + + 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)")); + } +}