diff --git a/entrypoints.json b/entrypoints.json index caa92a7604..96c3f52e73 100644 --- a/entrypoints.json +++ b/entrypoints.json @@ -16,6 +16,10 @@ "typings": "./lib/auth/index.d.ts", "dist": "./lib/auth/index.js" }, + "firebase-admin/fpnv": { + "typings": "./lib/fpnv/index.d.ts", + "dist": "./lib/fpnv/index.js" + }, "firebase-admin/database": { "typings": "./lib/database/index.d.ts", "dist": "./lib/database/index.js" diff --git a/etc/firebase-admin.fpnv.api.md b/etc/firebase-admin.fpnv.api.md new file mode 100644 index 0000000000..ef7cf2df2f --- /dev/null +++ b/etc/firebase-admin.fpnv.api.md @@ -0,0 +1,32 @@ +## API Report File for "firebase-admin.fpnv" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Agent } from 'http'; + +// @public +export class Fpnv { + // Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts + get app(): App; + verifyToken(fpnvJwt: string): Promise; +} + +// @public +export interface FpnvToken { + [key: string]: any; + aud: string[]; + exp: number; + getPhoneNumber(): string; + iat: number; + iss: string; + jti: string; + nonce: string; + sub: string; +} + +// @public +export function getFirebasePnv(app?: App): Fpnv; + +``` diff --git a/package.json b/package.json index a17a1e291f..7a33f881be 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,9 @@ "auth": [ "lib/auth" ], + "fpnv": [ + "lib/fpnv" + ], "eventarc": [ "lib/eventarc" ], @@ -134,6 +137,11 @@ "require": "./lib/auth/index.js", "import": "./lib/esm/auth/index.js" }, + "./fpnv": { + "types": "./lib/fpnv/index.d.ts", + "require": "./lib/fpnv/index.js", + "import": "./lib/esm/fpnv/index.js" + }, "./database": { "types": "./lib/database/index.d.ts", "require": "./lib/database/index.js", diff --git a/src/fpnv/fpnv-api-client-internal.ts b/src/fpnv/fpnv-api-client-internal.ts new file mode 100644 index 0000000000..e4a74596c0 --- /dev/null +++ b/src/fpnv/fpnv-api-client-internal.ts @@ -0,0 +1,71 @@ +/*! + * @license + * Copyright 2025 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. + */ + +import { PrefixedFirebaseError } from '../utils/error'; + +export interface FirebasePhoneNumberTokenInfo { + /** Documentation URL. */ + url: string; + /** verify API name. */ + verifyApiName: string; + /** The JWT full name. */ + jwtName: string; + /** The JWT short name. */ + shortName: string; + /** The JWT typ (Type) */ + typ: string; +} + +export const JWKS_URL = 'https://fpnv.googleapis.com/v1beta/jwks'; + +export const PN_TOKEN_INFO: FirebasePhoneNumberTokenInfo = { + url: 'https://firebase.google.com/docs/phone-number-verification', + verifyApiName: 'verifyToken()', + jwtName: 'Firebase Phone Verification token', + shortName: 'FPNV token', + typ: 'JWT', +}; + +export const FPNV_ERROR_CODE_MAPPING = { + INVALID_ARGUMENT: 'invalid-argument', + INVALID_TOKEN: 'invalid-token', + EXPIRED_TOKEN: 'expired-token', +} satisfies Record; + +export type FpnvErrorCode = + | 'invalid-argument' + | 'invalid-token' + | 'expired-token' + +/** + * Firebase Phone Number Verification error code structure. This extends `PrefixedFirebaseError`. + * + * @param code - The error code. + * @param message - The error message. + * @constructor + */ +export class FirebaseFpnvError extends PrefixedFirebaseError { + constructor(code: FpnvErrorCode, message: string) { + super('fpnv', code, message); + + /* tslint:disable:max-line-length */ + // Set the prototype explicitly. See the following link for more details: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + /* tslint:enable:max-line-length */ + (this as any).__proto__ = FirebaseFpnvError.prototype; + } +} diff --git a/src/fpnv/fpnv-api.ts b/src/fpnv/fpnv-api.ts new file mode 100644 index 0000000000..abc33d8cc6 --- /dev/null +++ b/src/fpnv/fpnv-api.ts @@ -0,0 +1,81 @@ +/*! + * @license + * Copyright 2025 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. + */ + + +/** + * Interface representing a Fpnv token. + */ +export interface FpnvToken { + /** + * The issuer identifier for the issuer of the response. + * This value is a URL with the format + * `https://fpnv.googleapis.com/projects/`, where `` is the + * same project number specified in the {@link FpnvToken.aud} property. + */ + iss: string; + + /** + * The audience for which this token is intended. + * This value is a string array, one of which is a URL with the format + * `https://fpnv.googleapis.com/projects/`, where `` is the + * project number of your Firebase project. + */ + aud: string[]; + + /** + * The Fpnv token's expiration time, in seconds since the Unix epoch. That is, the + * time at which this Fpnv token expires and should no longer be considered valid. + */ + exp: number; + + /** + * The Fpnv token's issued-at time, in seconds since the Unix epoch. That is, the + * time at which this Fpnv token was issued and should start to be considered + * valid. + */ + iat: number; + + /** + * The phone number of User. + */ + sub: string; + + /** + * A case-sensitive string that uniquely identifies a specific JWT instance + */ + jti: string; + + /** + * A unique, single-use "number used once" value. + */ + nonce: string; + + /** + * The corresponding user's phone number. + * This value is not actually one of the JWT token claims. It is added as a + * convenience, and is set as the value of the {@link FpnvToken.sub} property. + */ + getPhoneNumber(): string; + + /** + * Other arbitrary claims included in the token. + */ + [key: string]: any; +} + +export { FpnvErrorCode, FirebaseFpnvError } from './fpnv-api-client-internal'; + diff --git a/src/fpnv/fpnv.ts b/src/fpnv/fpnv.ts new file mode 100644 index 0000000000..cc3cd13a3e --- /dev/null +++ b/src/fpnv/fpnv.ts @@ -0,0 +1,64 @@ +/*! + * @license + * Copyright 2025 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. + */ + +import { App } from '../app'; +import { FpnvToken } from './fpnv-api'; +import { FirebasePhoneNumberTokenVerifier } from './token-verifier'; +import { JWKS_URL, PN_TOKEN_INFO } from './fpnv-api-client-internal'; + +/** + * Fpnv service bound to the provided app. + */ +export class Fpnv { + private readonly appInternal: App; + private readonly fpnvVerifier: FirebasePhoneNumberTokenVerifier; + + /** + * @param app - The app for this `Fpnv` service. + * @constructor + * @internal + */ + constructor(app: App) { + + this.appInternal = app; + this.fpnvVerifier = new FirebasePhoneNumberTokenVerifier( + JWKS_URL, + 'https://fpnv.googleapis.com/projects/', + PN_TOKEN_INFO, + app + ); + } + + /** + * Returns the app associated with this `Fpnv` instance. + * + * @returns The app associated with this `Fpnv` instance. + */ + get app(): App { + return this.appInternal; + } + + /** + * Verifies a Firebase Phone Number Verification token (FPNV JWT). + * + * @param fpnvJwt - The FPNV JWT string to verify. + * @returns A promise that resolves with the decoded token. + */ + public verifyToken(fpnvJwt: string): Promise { + return this.fpnvVerifier.verifyJWT(fpnvJwt); + } +} diff --git a/src/fpnv/index.ts b/src/fpnv/index.ts new file mode 100644 index 0000000000..90d96bff92 --- /dev/null +++ b/src/fpnv/index.ts @@ -0,0 +1,64 @@ +/** + * Firebase Phone Number Verification. + * + * @packageDocumentation + */ + +/*! + * @license + * Copyright 2025 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. + */ + +import { App, getApp } from '../app'; +import { FirebaseApp } from '../app/firebase-app'; +import { Fpnv } from './fpnv'; + +export { + Fpnv +} from './fpnv'; + +export { + FpnvToken, +} from './fpnv-api' + +/** + * Gets the {@link Fpnv} service for the default app or a + * given app. + * + * `getFirebasePnv()` can be called with no arguments to access the default app's + * {@link Fpnv} service or as `getFirebasePnv(app)` to access the + * {@link Fpnv} service associated with a specific app. + * + * @example + * ```javascript + * // Get the Fpnv service for the default app + * const defaultFpnv = getFirebasePnv(); + * ``` + * + * @example + * ```javascript + * // Get the Fpnv service for a given app + * const otherFpnv = getFirebasePnv(otherApp); + * ``` + * + */ +export function getFirebasePnv(app?: App): Fpnv { + if (typeof app === 'undefined') { + app = getApp(); + } + + const firebaseApp: FirebaseApp = app as FirebaseApp; + return firebaseApp.getOrInitService('fpnv', (app) => new Fpnv(app)); +} diff --git a/src/fpnv/token-verifier.ts b/src/fpnv/token-verifier.ts new file mode 100644 index 0000000000..39265b1c84 --- /dev/null +++ b/src/fpnv/token-verifier.ts @@ -0,0 +1,203 @@ +/*! + * @license + * Copyright 2025 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. + */ + +import { App } from '../app'; +import { FirebaseFpnvError, FpnvToken } from './fpnv-api'; +import * as util from '../utils/index'; +import * as validator from '../utils/validator'; +import { + DecodedToken, decodeJwt, JwtError, JwtErrorCode, + PublicKeySignatureVerifier, ALGORITHM_ES256, SignatureVerifier, +} from '../utils/jwt'; +import { FirebasePhoneNumberTokenInfo, FPNV_ERROR_CODE_MAPPING } from './fpnv-api-client-internal'; + +export class FirebasePhoneNumberTokenVerifier { + private readonly shortNameArticle: string; + private readonly signatureVerifier: SignatureVerifier; + + constructor( + jwksUrl: string, + private issuer: string, + private tokenInfo: FirebasePhoneNumberTokenInfo, + private readonly app: App + ) { + + if (!validator.isURL(jwksUrl)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'The provided public client certificate URL is an invalid URL.', + ); + } else if (!validator.isURL(issuer)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'The provided JWT issuer is an invalid URL.', + ); + } else if (!validator.isNonNullObject(tokenInfo)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'The provided JWT information is not an object or null.', + ); + } else if (!validator.isURL(tokenInfo.url)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'The provided JWT verification documentation URL is invalid.', + ); + } else if (!validator.isNonEmptyString(tokenInfo.verifyApiName)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'The JWT verify API name must be a non-empty string.', + ); + } else if (!validator.isNonEmptyString(tokenInfo.jwtName)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'The JWT public full name must be a non-empty string.', + ); + } else if (!validator.isNonEmptyString(tokenInfo.shortName)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'The JWT public short name must be a non-empty string.', + ); + } + this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; + + this.signatureVerifier = PublicKeySignatureVerifier.withJwksUrl(jwksUrl, app.options.httpAgent); + + // For backward compatibility, the project ID is validated in the verification call. + } + + public async verifyJWT(jwtToken: string): Promise { + if (!validator.isString(jwtToken)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN, + `First argument to ${this.tokenInfo.verifyApiName} must be a string.`, + ); + } + + await this.ensureProjectId(); + const decoded = await this.decodeAndVerify(jwtToken); + const decodedIdToken = decoded.payload as FpnvToken; + decodedIdToken.getPhoneNumber = () => decodedIdToken.sub; + return decodedIdToken; + } + + private async ensureProjectId(): Promise { + const projectId = await util.findProjectId(this.app); + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'Must initialize app with a cert credential or set your Firebase project ID as the ' + + `GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`); + } + return projectId; + } + + private async decodeAndVerify( + token: string, + ): Promise { + const decodedToken = await this.safeDecode(token); + this.verifyContent(decodedToken); + await this.verifySignature(token); + return decodedToken; + } + + private async safeDecode(jwtToken: string): Promise { + try { + return await decodeJwt(jwtToken); + } catch (err) { + if (err.code === JwtErrorCode.INVALID_ARGUMENT) { + const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + const errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed ` + + `the entire string JWT which represents ${this.shortNameArticle} ` + + `${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage; + throw new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + errorMessage); + } + throw new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, err.message); + } + } + + private verifyContent( + fullDecodedToken: DecodedToken, + ): void { + const header = fullDecodedToken && fullDecodedToken.header; + const payload = fullDecodedToken && fullDecodedToken.payload; + + const projectIdMatchMessage = ` Make sure the ${this.tokenInfo.shortName} comes from the same ` + + 'Firebase project as the service account used to authenticate this SDK.'; + const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + + let errorMessage: string | undefined; + + // JWT Header + if (typeof header.kid === 'undefined') { + errorMessage = `${this.tokenInfo.jwtName} has no "kid" claim.`; + errorMessage += verifyJwtTokenDocsMessage; + } else if (header.alg !== ALGORITHM_ES256) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected ` + + `"${ALGORITHM_ES256}" but got "${header.alg}". ${verifyJwtTokenDocsMessage}`; + } else if (header.typ !== this.tokenInfo.typ) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect typ. Expected "${this.tokenInfo.typ}" but got ` + + `"${header.typ}". ${verifyJwtTokenDocsMessage}`; + } + // FPNV Token + else if (!validator.isNonEmptyString(payload.iss)) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect "iss" (issuer) claim. Expected ` + + `an issuer starting with "${this.issuer}" but got "${payload.iss}".` + + ` ${projectIdMatchMessage} ${verifyJwtTokenDocsMessage}`; + } else if (!validator.isNonEmptyArray(payload.aud) || !payload.aud.includes(payload.iss)) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected ` + + `"${payload.iss}" to be one of "${payload.aud}". ${projectIdMatchMessage} ${verifyJwtTokenDocsMessage}`; + } else if (typeof payload.sub !== 'string') { + errorMessage = `${this.tokenInfo.jwtName} has no "sub" (subject) claim. ${verifyJwtTokenDocsMessage}`; + } else if (payload.sub === '') { + errorMessage = `${this.tokenInfo.jwtName} has an empty "sub" (subject) claim. ${verifyJwtTokenDocsMessage}`; + } + + if (errorMessage) { + throw new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); + } + } + + private async verifySignature(jwtToken: string): Promise { + try { + return await this.signatureVerifier.verify(jwtToken); + } catch (error) { + throw this.mapJwtErrorToAuthError(error); + } + } + + private mapJwtErrorToAuthError(error: JwtError): Error { + const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + if (error.code === JwtErrorCode.TOKEN_EXPIRED) { + const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` + + ` from your client app and try again. ${verifyJwtTokenDocsMessage}`; + return new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.EXPIRED_TOKEN, errorMessage); + } else if (error.code === JwtErrorCode.INVALID_SIGNATURE) { + const errorMessage = `${this.tokenInfo.jwtName} has invalid signature. ${verifyJwtTokenDocsMessage}`; + return new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); + } else if (error.code === JwtErrorCode.NO_MATCHING_KID) { + const errorMessage = `${this.tokenInfo.jwtName} has "kid" claim which does not ` + + `correspond to a known public key. Most likely the ${this.tokenInfo.shortName} ` + + 'is expired, so get a fresh token from your client app and try again.'; + return new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); + } + return new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, error.message); + } +} diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index 9a66494482..41433c4f06 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -21,6 +21,7 @@ import { HttpClient, HttpRequestConfig, RequestResponseError } from '../utils/ap import { Agent } from 'http'; export const ALGORITHM_RS256: jwt.Algorithm = 'RS256' as const; +export const ALGORITHM_ES256: jwt.Algorithm = 'ES256' as const; // `jsonwebtoken` converts errors from the `getKey` callback to its own `JsonWebTokenError` type // and prefixes the error message with the following. Use the prefix to identify errors thrown @@ -201,7 +202,8 @@ export class PublicKeySignatureVerifier implements SignatureVerifier { 'The provided token must be a string.')); } - return verifyJwtSignature(token, getKeyCallback(this.keyFetcher), { algorithms: [ALGORITHM_RS256] }) + return verifyJwtSignature(token, getKeyCallback(this.keyFetcher), + { algorithms: [ALGORITHM_RS256, ALGORITHM_ES256] }) .catch((error: JwtError) => { if (error.code === JwtErrorCode.NO_KID_IN_HEADER) { // No kid in JWT header. Try with all the public keys. diff --git a/test/unit/fpnv/fpnv-api-client-internal.spec.ts b/test/unit/fpnv/fpnv-api-client-internal.spec.ts new file mode 100644 index 0000000000..db639cb47d --- /dev/null +++ b/test/unit/fpnv/fpnv-api-client-internal.spec.ts @@ -0,0 +1,85 @@ +/*! + * @license + * Copyright 2025 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. + */ + +'use strict'; + +import { expect } from 'chai'; +import { + FirebaseFpnvError, + JWKS_URL, + PN_TOKEN_INFO, + FPNV_ERROR_CODE_MAPPING +} from '../../../src/fpnv/fpnv-api-client-internal'; +import { PrefixedFirebaseError, FirebaseError } from '../../../src/utils/error'; + +const FPNV_PREFIX = 'fpnv'; + +describe('FPNV Constants and Error Class', () => { + + describe('Constants Integrity', () => { + it('should have the correct CLIENT_CERT_URL', () => { + expect(JWKS_URL).to.equal('https://fpnv.googleapis.com/v1beta/jwks'); + }); + + it('should have the correct structure and values for PN_TOKEN_INFO', () => { + expect(PN_TOKEN_INFO).to.be.an('object'); + expect(PN_TOKEN_INFO).to.have.all.keys('url', 'verifyApiName', 'jwtName', 'shortName', 'typ'); + expect(PN_TOKEN_INFO.shortName).to.equal('FPNV token'); + expect(PN_TOKEN_INFO.typ).to.equal('JWT'); + }); + + it('should have the correct structure and values for FPNV_ERROR_CODE_MAPPING', () => { + expect(FPNV_ERROR_CODE_MAPPING).to.be.an('object'); + expect(FPNV_ERROR_CODE_MAPPING).to.deep.equal({ + INVALID_ARGUMENT: 'invalid-argument', + INVALID_TOKEN: 'invalid-token', + EXPIRED_TOKEN: 'expired-token', + }); + }); + }); + + describe('FirebaseFpnvError', () => { + const testCode = FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN; + const testMessage = 'The provided token is malformed or invalid.'; + + it('should correctly extend PrefixedFirebaseError', () => { + const error = new FirebaseFpnvError(testCode, testMessage); + + expect(error).to.be.an.instanceOf(FirebaseFpnvError); + expect(error).to.be.an.instanceOf(PrefixedFirebaseError); + expect(error).to.be.an.instanceOf(FirebaseError); + expect(error).to.be.an.instanceOf(Error); + }); + + + it('should have the correct error properties on the instance', () => { + const error = new FirebaseFpnvError(testCode, testMessage); + + expect(error.code).to.equal(`${FPNV_PREFIX}/${testCode}`); + expect(error.message).to.equal(testMessage); + }); + + it('should handle all defined error codes', () => { + const codes = Object.values(FPNV_ERROR_CODE_MAPPING); + + codes.forEach(code => { + const error = new FirebaseFpnvError(code, `Test message for ${code}`); + expect(error.code).to.equal(`${FPNV_PREFIX}/${code}`); + }); + }); + }); +}); diff --git a/test/unit/fpnv/fpnv-api.spec.ts b/test/unit/fpnv/fpnv-api.spec.ts new file mode 100644 index 0000000000..0779eba069 --- /dev/null +++ b/test/unit/fpnv/fpnv-api.spec.ts @@ -0,0 +1,101 @@ +/*! + * @license + * Copyright 2025 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. + */ + +'use strict'; + +import { expect } from 'chai'; +import { FpnvToken } from '../../../src/fpnv/fpnv-api'; + +describe('FpnvToken Interface Compliance', () => { + + // A helper to create a valid Mock implementation of the FpnvToken interface. + // In a real scenario, this object would be returned by the SDK's verify method. + const createMockToken = (overrides: Partial = {}): FpnvToken => { + const defaultToken: FpnvToken = { + iss: 'https://fpnv.googleapis.com/projects/1234567890', + aud: ['1234567890', 'my-project-id'], + exp: Math.floor(Date.now() / 1000) + 3600, // Expires in 1 hour + iat: Math.floor(Date.now() / 1000), // Issued now + sub: '+15555550100', // Phone number + jti: 'unique-token-id-123', + nonce: 'random-nonce-string', + + // Implementation of the convenience method per JSDoc + getPhoneNumber() { + return this.sub; + }, + + // Arbitrary claims + custom_claim: true + }; + + return { ...defaultToken, ...overrides }; + }; + + describe('Structure and Claims', () => { + it('should have a valid issuer (iss) URL format', () => { + const token = createMockToken({ iss: 'https://fpnv.googleapis.com/1234567890' }); + expect(token.iss).to.match(/^https:\/\/fpnv\.googleapis\.com\/\d+$/); + }); + + it('should have an audience (aud) containing project number and project ID', () => { + const projectNumber = '1234567890'; + const projectId = 'my-project-id'; + const token = createMockToken({ aud: [projectNumber, projectId] }); + + expect(token.aud).to.be.an('array').that.has.lengthOf(2); + expect(token.aud).to.include(projectNumber); + expect(token.aud).to.include(projectId); + }); + + it('should have an expiration time (exp) after the issued-at time (iat)', () => { + const token = createMockToken(); + expect(token.exp).to.be.greaterThan(token.iat); + }); + }); + + describe('getPhoneNumber()', () => { + it('should be a function', () => { + const token = createMockToken(); + expect(token.getPhoneNumber).to.be.a('function'); + }); + + it('should return the value of the "sub" property', () => { + const phoneNumber = '+15550009999'; + const token = createMockToken({ sub: phoneNumber }); + + const result = token.getPhoneNumber(); + + expect(result).to.equal(phoneNumber); + expect(result).to.equal(token.sub); + }); + + it('should handle cases where sub is empty string (if valid in your context)', () => { + const token = createMockToken({ sub: '' }); + expect(token.getPhoneNumber()).to.equal(''); + }); + }); + + describe('Arbitrary Claims', () => { + it('should allow accessing custom claims via index signature', () => { + const token = createMockToken({ isAdmin: true, tier: 'gold' }); + + expect(token['isAdmin']).to.be.true; + expect(token['tier']).to.equal('gold'); + }); + }); +}); diff --git a/test/unit/fpnv/fpnv.spec.ts b/test/unit/fpnv/fpnv.spec.ts new file mode 100644 index 0000000000..e986f5075e --- /dev/null +++ b/test/unit/fpnv/fpnv.spec.ts @@ -0,0 +1,103 @@ +/*! + * @license + * Copyright 2025 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. + */ + +'use strict'; + +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { App } from '../../../src/app/index'; +import * as mocks from '../../resources/mocks'; +import { Fpnv } from '../../../src/fpnv/fpnv'; +import { FirebasePhoneNumberTokenVerifier } from '../../../src/fpnv/token-verifier'; +import { FpnvToken } from '../../../src/fpnv/fpnv-api'; + +describe('Fpnv Service', () => { + let fpnvService: Fpnv; + let mockApp: App; + let verifyJwtStub: sinon.SinonStub; + + beforeEach(() => { + mockApp = mocks.app(); + verifyJwtStub = sinon.stub(FirebasePhoneNumberTokenVerifier.prototype, 'verifyJWT'); + + fpnvService = new Fpnv(mockApp); + }); + + afterEach(() => { + + sinon.restore(); + }); + + describe('Constructor', () => { + it('should be an instance of Fpnv', () => { + expect(fpnvService).to.be.instanceOf(Fpnv); + }); + }); + + describe('get app()', () => { + it('should return the app instance provided in the constructor', () => { + expect(fpnvService.app).to.equal(mockApp); + }); + }); + + describe('verifyToken()', () => { + const mockTokenString = 'eyJh...mock.jwt...token'; + const mockDecodedToken: FpnvToken = { + iss: 'https://fpnv.googleapis.com/projects/1234567890', + aud: ['1234567890', 'my-project-id'], + exp: Math.floor(Date.now() / 1000) + 3600, // Expires in 1 hour + iat: Math.floor(Date.now() / 1000), // Issued now + sub: '+15555550100', // Phone number + jti: 'unique-token-id-123', + nonce: 'random-nonce-string', + getPhoneNumber() { + return this.sub; + }, + }; + + it('should call the internal verifier with the provided JWT string', async () => { + verifyJwtStub.resolves(mockDecodedToken); + + await fpnvService.verifyToken(mockTokenString); + + expect(verifyJwtStub.calledOnce).to.be.true; + expect(verifyJwtStub.calledWith(mockTokenString)).to.be.true; + }); + + it('should return the decoded token object on success', async () => { + verifyJwtStub.resolves(mockDecodedToken); + + const result = await fpnvService.verifyToken(mockTokenString); + + expect(result).to.equal(mockDecodedToken); + }); + + it('should bubble up errors if the verifier fails', async () => { + const mockError = new Error('Token expired'); + verifyJwtStub.rejects(mockError); + + try { + await fpnvService.verifyToken(mockTokenString); + // If we reach here, the test failed + expect.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.equal(mockError); + } + }); + }); +}); diff --git a/test/unit/fpnv/index.spec.ts b/test/unit/fpnv/index.spec.ts new file mode 100644 index 0000000000..484ed82b84 --- /dev/null +++ b/test/unit/fpnv/index.spec.ts @@ -0,0 +1,68 @@ +/*! + * @license + * Copyright 2025 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. + */ + +'use strict'; + +import * as chai from 'chai'; +import * as sinonChai from 'sinon-chai'; +import * as chaiAsPromised from 'chai-as-promised'; + +import * as mocks from '../../resources/mocks'; +import { App } from '../../../src/app/index'; +import { getFirebasePnv, Fpnv } from '../../../src/fpnv/index'; + +chai.should(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +describe('Fpnv', () => { + let mockApp: App; + let mockCredentialApp: App; + + beforeEach(() => { + mockApp = mocks.app(); + mockCredentialApp = mocks.mockCredentialApp(); + }); + + describe('getFirebasePnv()', () => { + it('should throw when default app is not available', () => { + expect(() => { + return getFirebasePnv(); + }).to.throw('The default Firebase app does not exist.'); + }); + + it('should not throw given a valid app', () => { + expect(() => { + return getFirebasePnv(mockApp); + }).not.to.throw(); + }); + + it('should return the same instance for app instance', () => { + const fpnvFirst: Fpnv = getFirebasePnv(mockApp); + const fpnvSecond: Fpnv = getFirebasePnv(mockApp); + expect(fpnvFirst).to.equal(fpnvSecond); + }); + + it('should not return the same instance when different app are provided', () => { + const fpnvFirst: Fpnv = getFirebasePnv(mockApp); + const fpnvSecond: Fpnv = getFirebasePnv(mockCredentialApp); + expect(fpnvFirst).to.not.equal(fpnvSecond); + }); + }); +}); diff --git a/test/unit/fpnv/token-verifier.spec.ts b/test/unit/fpnv/token-verifier.spec.ts new file mode 100644 index 0000000000..4786164a1b --- /dev/null +++ b/test/unit/fpnv/token-verifier.spec.ts @@ -0,0 +1,255 @@ +/*! + * @license + * Copyright 2025 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. + */ + +'use strict'; + +import * as chai from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; + +import { App } from '../../../src/app/index'; +import * as jwt from '../../../src/utils/jwt'; +import * as util from '../../../src/utils/index'; +import { FirebasePhoneNumberTokenVerifier } from '../../../src/fpnv/token-verifier'; +import { FirebasePhoneNumberTokenInfo, FPNV_ERROR_CODE_MAPPING } from '../../../src/fpnv/fpnv-api-client-internal'; +import * as mocks from '../../resources/mocks'; + +chai.use(chaiAsPromised); +const expect = chai.expect; + +describe('FirebasePhoneNumberTokenVerifier', () => { + let verifier: FirebasePhoneNumberTokenVerifier; + let mockApp: App; + + let findProjectIdStub: sinon.SinonStub; + let decodeJwtStub: sinon.SinonStub; + let signatureVerifierStub: { verify: sinon.SinonStub }; + let withJwksUrlStub: sinon.SinonStub; + + const MOCK_CERT_URL = 'https://fpnv.googleapis.com/v1-beta/jwks'; + const MOCK_ISSUER = 'https://fpnv.googleapis.com/projects/'; + const MOCK_PROJECT_NUMBER = '123456789012'; + const MOCK_PROJECT_ID = 'fpnv-team-test'; + const MOCK_FPNV_PREFIX = 'fpnv'; + + + const MOCK_TOKEN_INFO: FirebasePhoneNumberTokenInfo = { + url: 'https://firebase.google.com/docs/phone-number-verification', + verifyApiName: 'verifyToken()', + jwtName: 'Firebase Phone Verification token', + shortName: 'FPNV token', + typ: 'JWT', + }; + + const VALID_HEADER = { + kid: 'mock-key-id', + alg: 'ES256', + typ: 'JWT', // Matches MOCK_TOKEN_INFO.typ + }; + + const VALID_PAYLOAD = { + iss: MOCK_ISSUER + MOCK_PROJECT_NUMBER, + aud: [MOCK_ISSUER + MOCK_PROJECT_NUMBER, MOCK_ISSUER + MOCK_PROJECT_ID], + sub: '+15555550100', + exp: Math.floor(Date.now() / 1000) + 3600, // Expires in 1 hour + iat: Math.floor(Date.now() / 1000), // Issued now + }; + + beforeEach(() => { + mockApp = mocks.app(); + findProjectIdStub = sinon.stub(util, 'findProjectId').resolves(MOCK_PROJECT_ID); + decodeJwtStub = sinon.stub(jwt, 'decodeJwt'); + signatureVerifierStub = { verify: sinon.stub().resolves() }; + withJwksUrlStub = sinon.stub(jwt.PublicKeySignatureVerifier, 'withJwksUrl') + .returns(signatureVerifierStub as any); + }); + + afterEach(() => { + sinon.restore(); + }); + + /** + * Helper to instantiate the verifier with default valid args + */ + function createVerifier(overrides: Partial = {}): FirebasePhoneNumberTokenVerifier { + return new FirebasePhoneNumberTokenVerifier( + overrides.clientCertUrl || MOCK_CERT_URL, + overrides.issuer || MOCK_ISSUER, + overrides.tokenInfo || MOCK_TOKEN_INFO, + mockApp + ); + } + + describe('Constructor', () => { + it('should instantiate successfully with valid arguments', () => { + const v = createVerifier(); + expect(v).to.be.instanceOf(FirebasePhoneNumberTokenVerifier); + expect(withJwksUrlStub.calledOnce).to.be.true; + }); + + it('should throw if clientCertUrl is invalid', () => { + expect(() => createVerifier({ clientCertUrl: 'not-a-url' })) + .to.throw('invalid URL'); + }); + + it('should throw if issuer is invalid', () => { + expect(() => createVerifier({ issuer: 'not-a-url' })) + .to.throw('invalid URL'); + }); + + it('should throw if tokenInfo is missing required fields', () => { + const invalidInfo = { ...MOCK_TOKEN_INFO, verifyApiName: '' }; + expect(() => createVerifier({ tokenInfo: invalidInfo })) + .to.throw('verify API name must be a non-empty string'); + }); + }); + + describe('verifyJWT()', () => { + beforeEach(() => { + verifier = createVerifier(); + }); + + it('should throw if jwtToken is not a string', async () => { + await expect(verifier.verifyJWT(123 as any)) + .to.be.rejectedWith('First argument to verifyToken() must be a string.'); + }); + + it('should throw if project ID cannot be determined', async () => { + findProjectIdStub.resolves(null); + await expect(verifier.verifyJWT('token')) + .to.be.rejectedWith('Must initialize app with a cert credential or set your' + + ' Firebase project ID as the GOOGLE_CLOUD_PROJECT environment variable to call verifyToken().'); + }); + + describe('Token Decoding', () => { + it('should throw if decodeJwt fails with invalid argument', async () => { + const err = new Error('Invalid token'); + (err as any).code = jwt.JwtErrorCode.INVALID_ARGUMENT; + decodeJwtStub.rejects(err); + + await expect(verifier.verifyJWT('bad-token')) + .to.be.rejectedWith(/Decoding Firebase Phone Verification token failed/); + }); + + it('should rethrow unknown errors from decodeJwt', async () => { + decodeJwtStub.rejects(new Error('Unknown error')); + await expect(verifier.verifyJWT('token')) + .to.be.rejectedWith('Unknown error'); + }); + }); + + describe('Content Verification', () => { + // Helper to setup a successful decode + const setupDecode = (headerOverrides = {}, payloadOverrides = {}): void => { + decodeJwtStub.resolves({ + header: { ...VALID_HEADER, ...headerOverrides }, + payload: { ...VALID_PAYLOAD, ...payloadOverrides }, + }); + }; + + it('should throw if "kid" is missing', async () => { + setupDecode({ kid: undefined }); + await expect(verifier.verifyJWT('token')).to.be.rejectedWith('has no "kid" claim'); + }); + + it('should throw if algorithm is not ES256', async () => { + setupDecode({ alg: 'RS256' }); + await expect(verifier.verifyJWT('token')).to.be.rejectedWith('incorrect algorithm'); + }); + + it('should throw if "typ" is incorrect', async () => { + setupDecode({ typ: 'WRONG' }); + await expect(verifier.verifyJWT('token')).to.be.rejectedWith('incorrect typ'); + }); + + it('should throw if "aud" does not contain issuer+projectId', async () => { + setupDecode({}, { aud: ['wrong-audience'] }); + await expect(verifier.verifyJWT('token')).to.be.rejectedWith('incorrect "aud"'); + }); + + it('should throw if "sub" is missing', async () => { + setupDecode({}, { sub: undefined }); + await expect(verifier.verifyJWT('token')).to.be.rejectedWith('no "sub"'); + }); + + it('should throw if "sub" is empty', async () => { + setupDecode({}, { sub: '' }); + await expect(verifier.verifyJWT('token')).to.be.rejectedWith('empty "sub"'); + }); + }); + + describe('Signature Verification', () => { + beforeEach(() => { + // Assume decoding passes for these tests + decodeJwtStub.resolves({ header: VALID_HEADER, payload: VALID_PAYLOAD }); + }); + + it('should call signatureVerifier.verify with the token', async () => { + const token = 'valid.jwt.string'; + await verifier.verifyJWT(token); + expect(signatureVerifierStub.verify.calledWith(token)).to.be.true; + }); + + it('should throw EXPIRED_TOKEN if signature verifier throws TOKEN_EXPIRED', async () => { + const error = new Error('Expired'); + (error as any).code = jwt.JwtErrorCode.TOKEN_EXPIRED; + signatureVerifierStub.verify.rejects(error); + + await expect(verifier.verifyJWT('token')) + .to.be.rejectedWith(/has expired/) + .and.eventually.have.property('code', `${MOCK_FPNV_PREFIX}/${FPNV_ERROR_CODE_MAPPING.EXPIRED_TOKEN}`); + }); + + it('should throw INVALID_ARGUMENT if signature verifier throws INVALID_SIGNATURE', async () => { + const error = new Error('Bad Sig'); + (error as any).code = jwt.JwtErrorCode.INVALID_SIGNATURE; + signatureVerifierStub.verify.rejects(error); + + await expect(verifier.verifyJWT('token')) + .to.be.rejectedWith(/invalid signature/) + .and.eventually.have.property('code',`${MOCK_FPNV_PREFIX}/${FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT}`); + }); + + it('should throw INVALID_ARGUMENT if signature verifier throws NO_MATCHING_KID', async () => { + const error = new Error('No Key'); + (error as any).code = jwt.JwtErrorCode.NO_MATCHING_KID; + signatureVerifierStub.verify.rejects(error); + + await expect(verifier.verifyJWT('token')) + .to.be.rejectedWith(/does not correspond to a known public key/) + .and.eventually.have.property('code',`${MOCK_FPNV_PREFIX}/${FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT}`); + }); + }); + + describe('Success', () => { + it('should return the token with getPhoneNumber() method appended', async () => { + decodeJwtStub.resolves({ header: VALID_HEADER, payload: VALID_PAYLOAD }); + signatureVerifierStub.verify.resolves(); + + const result = await verifier.verifyJWT('valid-token'); + + // Check data integrity + expect(result.sub).to.equal(VALID_PAYLOAD.sub); + expect(result.aud).to.deep.equal(VALID_PAYLOAD.aud); + + // Check the dynamic method addition + expect(result).to.have.property('getPhoneNumber'); + expect(result.getPhoneNumber()).to.equal(VALID_PAYLOAD.sub); + }); + }); + }); +}); diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index 39ae51fd9f..826336b8bd 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -123,3 +123,10 @@ import './data-connect/index.spec'; import './data-connect/data-connect-api-client-internal.spec'; import './data-connect/data-connect.spec'; import './data-connect/validate-admin-args.spec'; + +// Fpnv +import './fpnv/index.spec'; +import './fpnv/fpnv-api-client-internal.spec'; +import './fpnv/fpnv-api.spec'; +import './fpnv/fpnv.spec'; +import './fpnv/token-verifier.spec'