From f603e659bfc0a77d988d7fd332e11ae3d72b2ab6 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Tue, 9 Dec 2025 06:38:06 +0100 Subject: [PATCH 01/18] feat: add basic fpnv --- entrypoints.json | 4 + package.json | 8 ++ src/firebase-namespace-api.ts | 3 + src/fpnv/base-fpnv.ts | 23 ++++ src/fpnv/fpnv-namespace.ts | 39 ++++++ src/fpnv/fpnv.ts | 41 ++++++ src/fpnv/index.ts | 74 ++++++++++ src/fpnv/token-verifier.ts | 246 ++++++++++++++++++++++++++++++++++ src/utils/error.ts | 32 +++++ src/utils/jwt.ts | 1 + 10 files changed, 471 insertions(+) create mode 100644 src/fpnv/base-fpnv.ts create mode 100644 src/fpnv/fpnv-namespace.ts create mode 100644 src/fpnv/fpnv.ts create mode 100644 src/fpnv/index.ts create mode 100644 src/fpnv/token-verifier.ts 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/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/firebase-namespace-api.ts b/src/firebase-namespace-api.ts index 3808876910..e024fa7b75 100644 --- a/src/firebase-namespace-api.ts +++ b/src/firebase-namespace-api.ts @@ -16,6 +16,7 @@ import { appCheck } from './app-check/app-check-namespace'; import { auth } from './auth/auth-namespace'; +import { fpnv } from './fpnv/fpnv-namespace'; import { database } from './database/database-namespace'; import { firestore } from './firestore/firestore-namespace'; import { instanceId } from './instance-id/instance-id-namespace'; @@ -43,6 +44,7 @@ export namespace app { export interface App extends AppCore { appCheck(): appCheck.AppCheck; auth(): auth.Auth; + fpnv(): fpnv.Fpnv; database(url?: string): database.Database; firestore(): firestore.Firestore; installations(): installations.Installations; @@ -81,6 +83,7 @@ export namespace app { export * from './credential/index'; export { appCheck } from './app-check/app-check-namespace'; export { auth } from './auth/auth-namespace'; +export { fpnv } from './fpnv/fpnv-namespace'; export { database } from './database/database-namespace'; export { firestore } from './firestore/firestore-namespace'; export { instanceId } from './instance-id/instance-id-namespace'; diff --git a/src/fpnv/base-fpnv.ts b/src/fpnv/base-fpnv.ts new file mode 100644 index 0000000000..ca4f013b6c --- /dev/null +++ b/src/fpnv/base-fpnv.ts @@ -0,0 +1,23 @@ +import { App } from '../app'; + +import { + FirebasePhoneNumberTokenVerifier, + FpnvToken, + createFPNTVerifier, +} from './token-verifier'; + + +export abstract class BaseFpnv { + protected readonly fPNTVerifier: FirebasePhoneNumberTokenVerifier; + + protected constructor( + app: App, + ) { + this.fPNTVerifier = createFPNTVerifier(app); + } + + + public async verifyToken(idToken: string): Promise { + return await this.fPNTVerifier.verifyJWT(idToken); + } +} diff --git a/src/fpnv/fpnv-namespace.ts b/src/fpnv/fpnv-namespace.ts new file mode 100644 index 0000000000..2c8be84101 --- /dev/null +++ b/src/fpnv/fpnv-namespace.ts @@ -0,0 +1,39 @@ +/*! + * Copyright 2021 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 all public types with aliases, and re-export from the auth namespace. + +import { Fpnv as TFpnv } from './fpnv'; + +import { + BaseFpnv as TBaseFpnv, +} from './base-fpnv'; + +import { + FpnvToken as TFpnvToken, +} from './token-verifier'; + + +export declare function fpnv(app?: App): fpnv.Fpnv; + +/* eslint-disable @typescript-eslint/no-namespace */ +export namespace fpnv { + export type BaseFpnv = TBaseFpnv; + export type Fpnv = TFpnv; + export type FpnvToken = TFpnvToken; +} diff --git a/src/fpnv/fpnv.ts b/src/fpnv/fpnv.ts new file mode 100644 index 0000000000..c0c8c87f60 --- /dev/null +++ b/src/fpnv/fpnv.ts @@ -0,0 +1,41 @@ +/*! + * @license + * Copyright 2017 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 { BaseFpnv } from './base-fpnv'; + +/** + * Fpnv service bound to the provided app. + */ +export class Fpnv extends BaseFpnv { + private readonly app_: App; + + constructor(app: App) { + super(app); + + this.app_ = app; + } + + /** + * Returns the app associated with this Fpnv instance. + * + * @returns The app associated with this Fpnv instance. + */ + get app(): App { + return this.app_; + } +} diff --git a/src/fpnv/index.ts b/src/fpnv/index.ts new file mode 100644 index 0000000000..8de3872b40 --- /dev/null +++ b/src/fpnv/index.ts @@ -0,0 +1,74 @@ +/*! + * Copyright 2020 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. + */ + +/** + * Firebase Phone Number Verification. + * + * @packageDocumentation + */ + +import { App, getApp } from '../app'; +import { FirebaseApp } from '../app/firebase-app'; +import { Fpnv } from './fpnv'; + +/** + * 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)); +} + +export { + Fpnv, +} from './fpnv'; + +export { + BaseFpnv, +} from './base-fpnv'; + + +export { + FpnvToken, +} from './token-verifier'; + + +export { + FirebasePnvError, + FpnvErrorCode, +} from '../utils/error'; diff --git a/src/fpnv/token-verifier.ts b/src/fpnv/token-verifier.ts new file mode 100644 index 0000000000..cc3cf27a4e --- /dev/null +++ b/src/fpnv/token-verifier.ts @@ -0,0 +1,246 @@ +import { App } from '../app'; +import { FpnvErrorCode, ErrorInfo, FirebasePnvError } from '../utils/error'; +import * as util from '../utils/index'; +import * as validator from '../utils/validator'; +import { + DecodedToken, decodeJwt, JwtError, JwtErrorCode, + PublicKeySignatureVerifier, ALGORITHM_ES256, SignatureVerifier, +} from '../utils/jwt'; + +export interface FpnvToken { + aud: string; + auth_time: number; + exp: number; + iat: number; + iss: string; + sub: string; + + getPhoneNumber(): string; + + /** + * Other arbitrary claims included in the ID token. + */ + [key: string]: any; +} + +const CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'; + +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', + expiredErrorCode: FpnvErrorCode.COMMON_ISSUE, +}; + +export interface FirebasePhoneNumberTokenInfo { + /** Documentation URL. */ + url: string; + /** verify API name. */ + verifyApiName: string; + /** The JWT full name. */ + jwtName: string; + /** The JWT short name. */ + shortName: string; + /** JWT Expiration error code. */ + expiredErrorCode: ErrorInfo; + /** The JWT typ" (Type) */ + typ: string; +} + +export class FirebasePhoneNumberTokenVerifier { + + private readonly shortNameArticle: string; + private readonly signatureVerifier: SignatureVerifier; + + constructor( + clientCertUrl: string, + private issuer: string, + private tokenInfo: FirebasePhoneNumberTokenInfo, + private readonly app: App + ) { + + if (!validator.isURL(clientCertUrl)) { + throw new FirebasePnvError( + FpnvErrorCode.COMMON_ISSUE, + 'The provided public client certificate URL is an invalid URL.', + ); + } else if (!validator.isURL(issuer)) { + throw new FirebasePnvError( + FpnvErrorCode.COMMON_ISSUE, + 'The provided JWT issuer is an invalid URL.', + ); + } else if (!validator.isNonNullObject(tokenInfo)) { + throw new FirebasePnvError( + FpnvErrorCode.COMMON_ISSUE, + 'The provided JWT information is not an object or null.', + ); + } else if (!validator.isURL(tokenInfo.url)) { + throw new FirebasePnvError( + FpnvErrorCode.COMMON_ISSUE, + 'The provided JWT verification documentation URL is invalid.', + ); + } else if (!validator.isNonEmptyString(tokenInfo.verifyApiName)) { + throw new FirebasePnvError( + FpnvErrorCode.COMMON_ISSUE, + 'The JWT verify API name must be a non-empty string.', + ); + } else if (!validator.isNonEmptyString(tokenInfo.jwtName)) { + throw new FirebasePnvError( + FpnvErrorCode.COMMON_ISSUE, + 'The JWT public full name must be a non-empty string.', + ); + } else if (!validator.isNonEmptyString(tokenInfo.shortName)) { + throw new FirebasePnvError( + FpnvErrorCode.COMMON_ISSUE, + 'The JWT public short name must be a non-empty string.', + ); + } else if (!validator.isNonNullObject(tokenInfo.expiredErrorCode) || !('code' in tokenInfo.expiredErrorCode)) { + throw new FirebasePnvError( + FpnvErrorCode.COMMON_ISSUE, + 'The JWT expiration error code must be a non-null ErrorInfo object.', + ); + } + this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; + + this.signatureVerifier = + PublicKeySignatureVerifier.withCertificateUrl(clientCertUrl, 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 FirebasePnvError( + FpnvErrorCode.COMMON_ISSUE, + `First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.`, + ); + } + + const projectId = await this.ensureProjectId(); + const decoded = await this.decodeAndVerify(jwtToken, projectId); + 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 FirebasePnvError( + FpnvErrorCode.COMMON_ISSUE, + '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, + projectId: string, + ): Promise { + const decodedToken = await this.safeDecode(token); + this.verifyContent(decodedToken, projectId); + 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 FirebasePnvError(FpnvErrorCode.COMMON_ISSUE, + errorMessage); + } + throw new FirebasePnvError(FpnvErrorCode.COMMON_ISSUE, err.message); + } + } + + + private verifyContent( + fullDecodedToken: DecodedToken, + projectId: string | null, + ): 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 (!((payload.aud as string[]).some(item => item === this.issuer + projectId))) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` + + this.issuer + projectId + '" 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 FirebasePnvError(FpnvErrorCode.COMMON_ISSUE, 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 (auth/${this.tokenInfo.expiredErrorCode.code}).` + + verifyJwtTokenDocsMessage; + return new FirebasePnvError(this.tokenInfo.expiredErrorCode, errorMessage); + } else if (error.code === JwtErrorCode.INVALID_SIGNATURE) { + const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; + return new FirebasePnvError(FpnvErrorCode.COMMON_ISSUE, 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 FirebasePnvError(FpnvErrorCode.COMMON_ISSUE, errorMessage); + } + return new FirebasePnvError(FpnvErrorCode.COMMON_ISSUE, error.message); + } + +} + +export function createFPNTVerifier(app: App): FirebasePhoneNumberTokenVerifier { + return new FirebasePhoneNumberTokenVerifier( + CLIENT_CERT_URL, + 'https://fpnv.googleapis.com/projects/', + PN_TOKEN_INFO, + app + ); +} diff --git a/src/utils/error.ts b/src/utils/error.ts index db013b8967..1887b6c799 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -1145,3 +1145,35 @@ const TOPIC_MGT_SERVER_TO_CLIENT_CODE: ServerToClientCode = { INTERNAL: 'INTERNAL_ERROR', UNKNOWN: 'UNKNOWN_ERROR', }; + +/** + * Firebase Phone Number Verification error code structure. This extends PrefixedFirebaseError. + */ +export class FirebasePnvError extends PrefixedFirebaseError { + /** + * @param info - The error code info. + * @param message - The error message. This will override the default message if provided. + * @constructor + * @internal + */ + constructor(info: ErrorInfo, message?: string) { + // Override default message if custom message provided. + super('fpnv', info.code, message || info.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__ = FirebasePnvError.prototype; + } +} + +export class FpnvErrorCode { + static INVALID_TOKEN = 'invalid_token'; + static EXPIRED_TOKEN = 'expired_token'; + static COMMON_ISSUE = { + code: "code", + message: "message" + }; + // TODO: need to update codes properly +} \ No newline at end of file diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index 9a66494482..f3e4ba0744 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 From 23868e8f6e3a13f88f193a58b22a7cc09aa9c9b9 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Wed, 10 Dec 2025 15:26:37 +0100 Subject: [PATCH 02/18] chore: resolve comments --- src/firebase-namespace-api.ts | 3 - src/fpnv/base-fpnv.ts | 23 ------ src/fpnv/{fpnv-namespace.ts => fpnv-api.ts} | 42 +++++------ src/fpnv/fpnv.ts | 26 +++++-- src/fpnv/index.ts | 22 +----- src/fpnv/token-verifier.ts | 81 +++++++++++---------- src/utils/error.ts | 22 ++++-- 7 files changed, 99 insertions(+), 120 deletions(-) delete mode 100644 src/fpnv/base-fpnv.ts rename src/fpnv/{fpnv-namespace.ts => fpnv-api.ts} (51%) diff --git a/src/firebase-namespace-api.ts b/src/firebase-namespace-api.ts index e024fa7b75..3808876910 100644 --- a/src/firebase-namespace-api.ts +++ b/src/firebase-namespace-api.ts @@ -16,7 +16,6 @@ import { appCheck } from './app-check/app-check-namespace'; import { auth } from './auth/auth-namespace'; -import { fpnv } from './fpnv/fpnv-namespace'; import { database } from './database/database-namespace'; import { firestore } from './firestore/firestore-namespace'; import { instanceId } from './instance-id/instance-id-namespace'; @@ -44,7 +43,6 @@ export namespace app { export interface App extends AppCore { appCheck(): appCheck.AppCheck; auth(): auth.Auth; - fpnv(): fpnv.Fpnv; database(url?: string): database.Database; firestore(): firestore.Firestore; installations(): installations.Installations; @@ -83,7 +81,6 @@ export namespace app { export * from './credential/index'; export { appCheck } from './app-check/app-check-namespace'; export { auth } from './auth/auth-namespace'; -export { fpnv } from './fpnv/fpnv-namespace'; export { database } from './database/database-namespace'; export { firestore } from './firestore/firestore-namespace'; export { instanceId } from './instance-id/instance-id-namespace'; diff --git a/src/fpnv/base-fpnv.ts b/src/fpnv/base-fpnv.ts deleted file mode 100644 index ca4f013b6c..0000000000 --- a/src/fpnv/base-fpnv.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { App } from '../app'; - -import { - FirebasePhoneNumberTokenVerifier, - FpnvToken, - createFPNTVerifier, -} from './token-verifier'; - - -export abstract class BaseFpnv { - protected readonly fPNTVerifier: FirebasePhoneNumberTokenVerifier; - - protected constructor( - app: App, - ) { - this.fPNTVerifier = createFPNTVerifier(app); - } - - - public async verifyToken(idToken: string): Promise { - return await this.fPNTVerifier.verifyJWT(idToken); - } -} diff --git a/src/fpnv/fpnv-namespace.ts b/src/fpnv/fpnv-api.ts similarity index 51% rename from src/fpnv/fpnv-namespace.ts rename to src/fpnv/fpnv-api.ts index 2c8be84101..ca3e46d22d 100644 --- a/src/fpnv/fpnv-namespace.ts +++ b/src/fpnv/fpnv-api.ts @@ -1,5 +1,6 @@ /*! - * Copyright 2021 Google LLC + * @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. @@ -14,26 +15,25 @@ * limitations under the License. */ -import { App } from '../app'; - -// Import all public types with aliases, and re-export from the auth namespace. - -import { Fpnv as TFpnv } from './fpnv'; - -import { - BaseFpnv as TBaseFpnv, -} from './base-fpnv'; - -import { - FpnvToken as TFpnvToken, -} from './token-verifier'; +/** + * Interface representing a Fpnv token. + */ +export interface FpnvToken { + aud: string; + auth_time: number; + exp: number; + iat: number; + iss: string; + sub: string; + + getPhoneNumber(): string; + + /** + * Other arbitrary claims included in the ID token. + */ + [key: string]: any; +} -export declare function fpnv(app?: App): fpnv.Fpnv; +export {FpnvErrorCode, FirebasePnvError, ErrorInfo} from '../utils/error'; -/* eslint-disable @typescript-eslint/no-namespace */ -export namespace fpnv { - export type BaseFpnv = TBaseFpnv; - export type Fpnv = TFpnv; - export type FpnvToken = TFpnvToken; -} diff --git a/src/fpnv/fpnv.ts b/src/fpnv/fpnv.ts index c0c8c87f60..dc64a4cd6e 100644 --- a/src/fpnv/fpnv.ts +++ b/src/fpnv/fpnv.ts @@ -1,6 +1,6 @@ /*! * @license - * Copyright 2017 Google LLC + * 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. @@ -16,26 +16,36 @@ */ import { App } from '../app'; -import { BaseFpnv } from './base-fpnv'; +import { FpnvToken } from './fpnv-api'; +import { + FirebasePhoneNumberTokenVerifier, + createFPNTVerifier, +} from './token-verifier'; /** * Fpnv service bound to the provided app. */ -export class Fpnv extends BaseFpnv { +export class Fpnv { private readonly app_: App; + protected readonly fpnvVerifier: FirebasePhoneNumberTokenVerifier; + constructor(app: App) { - super(app); this.app_ = app; + this.fpnvVerifier = createFPNTVerifier(app); } /** - * Returns the app associated with this Fpnv instance. - * - * @returns The app associated with this Fpnv instance. - */ + * Returns the app associated with this Auth instance. + * + * @returns The app associated with this Auth instance. + */ get app(): App { return this.app_; } + + public async verifyToken(idToken: string): Promise { + return await this.fpnvVerifier.verifyJWT(idToken); + } } diff --git a/src/fpnv/index.ts b/src/fpnv/index.ts index 8de3872b40..a295136b6b 100644 --- a/src/fpnv/index.ts +++ b/src/fpnv/index.ts @@ -1,5 +1,6 @@ /*! - * Copyright 2020 Google LLC + * @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. @@ -53,22 +54,3 @@ export function getFirebasePnv(app?: App): Fpnv { const firebaseApp: FirebaseApp = app as FirebaseApp; return firebaseApp.getOrInitService('fpnv', (app) => new Fpnv(app)); } - -export { - Fpnv, -} from './fpnv'; - -export { - BaseFpnv, -} from './base-fpnv'; - - -export { - FpnvToken, -} from './token-verifier'; - - -export { - FirebasePnvError, - FpnvErrorCode, -} from '../utils/error'; diff --git a/src/fpnv/token-verifier.ts b/src/fpnv/token-verifier.ts index cc3cf27a4e..ae3c1bf357 100644 --- a/src/fpnv/token-verifier.ts +++ b/src/fpnv/token-verifier.ts @@ -1,5 +1,23 @@ +/*! + * @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 { FpnvErrorCode, ErrorInfo, FirebasePnvError } from '../utils/error'; +import { FpnvErrorCode, FirebasePnvError, ErrorInfo } from '../utils/error'; +import {FirebasePhoneNumberTokenInfo, FpnvToken} from './fpnv-api'; import * as util from '../utils/index'; import * as validator from '../utils/validator'; import { @@ -7,34 +25,19 @@ import { PublicKeySignatureVerifier, ALGORITHM_ES256, SignatureVerifier, } from '../utils/jwt'; -export interface FpnvToken { - aud: string; - auth_time: number; - exp: number; - iat: number; - iss: string; - sub: string; - - getPhoneNumber(): string; - - /** - * Other arbitrary claims included in the ID token. - */ - [key: string]: any; -} - const CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'; -export const PN_TOKEN_INFO: FirebasePhoneNumberTokenInfo = { + +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', - expiredErrorCode: FpnvErrorCode.COMMON_ISSUE, + expiredErrorCode: FpnvErrorCode.EXPIRED_TOKEN, }; -export interface FirebasePhoneNumberTokenInfo { +interface FirebasePhoneNumberTokenInfo { /** Documentation URL. */ url: string; /** verify API name. */ @@ -63,42 +66,42 @@ export class FirebasePhoneNumberTokenVerifier { if (!validator.isURL(clientCertUrl)) { throw new FirebasePnvError( - FpnvErrorCode.COMMON_ISSUE, + FpnvErrorCode.INVALID_ARGUMENT, 'The provided public client certificate URL is an invalid URL.', ); } else if (!validator.isURL(issuer)) { throw new FirebasePnvError( - FpnvErrorCode.COMMON_ISSUE, + FpnvErrorCode.INVALID_ARGUMENT, 'The provided JWT issuer is an invalid URL.', ); } else if (!validator.isNonNullObject(tokenInfo)) { throw new FirebasePnvError( - FpnvErrorCode.COMMON_ISSUE, + FpnvErrorCode.INVALID_ARGUMENT, 'The provided JWT information is not an object or null.', ); } else if (!validator.isURL(tokenInfo.url)) { throw new FirebasePnvError( - FpnvErrorCode.COMMON_ISSUE, + FpnvErrorCode.INVALID_ARGUMENT, 'The provided JWT verification documentation URL is invalid.', ); } else if (!validator.isNonEmptyString(tokenInfo.verifyApiName)) { throw new FirebasePnvError( - FpnvErrorCode.COMMON_ISSUE, + FpnvErrorCode.INVALID_ARGUMENT, 'The JWT verify API name must be a non-empty string.', ); } else if (!validator.isNonEmptyString(tokenInfo.jwtName)) { throw new FirebasePnvError( - FpnvErrorCode.COMMON_ISSUE, + FpnvErrorCode.INVALID_ARGUMENT, 'The JWT public full name must be a non-empty string.', ); } else if (!validator.isNonEmptyString(tokenInfo.shortName)) { throw new FirebasePnvError( - FpnvErrorCode.COMMON_ISSUE, + FpnvErrorCode.INVALID_ARGUMENT, 'The JWT public short name must be a non-empty string.', ); } else if (!validator.isNonNullObject(tokenInfo.expiredErrorCode) || !('code' in tokenInfo.expiredErrorCode)) { throw new FirebasePnvError( - FpnvErrorCode.COMMON_ISSUE, + FpnvErrorCode.INVALID_ARGUMENT, 'The JWT expiration error code must be a non-null ErrorInfo object.', ); } @@ -113,7 +116,7 @@ export class FirebasePhoneNumberTokenVerifier { public async verifyJWT(jwtToken: string): Promise { if (!validator.isString(jwtToken)) { throw new FirebasePnvError( - FpnvErrorCode.COMMON_ISSUE, + FpnvErrorCode.PROJECT_NOT_FOUND, `First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.`, ); } @@ -129,7 +132,7 @@ export class FirebasePhoneNumberTokenVerifier { const projectId = await util.findProjectId(this.app); if (!validator.isNonEmptyString(projectId)) { throw new FirebasePnvError( - FpnvErrorCode.COMMON_ISSUE, + FpnvErrorCode.PROJECT_NOT_FOUND, '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}.`); } @@ -156,10 +159,10 @@ export class FirebasePhoneNumberTokenVerifier { 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 FirebasePnvError(FpnvErrorCode.COMMON_ISSUE, + throw new FirebasePnvError(FpnvErrorCode.INVALID_ARGUMENT, errorMessage); } - throw new FirebasePnvError(FpnvErrorCode.COMMON_ISSUE, err.message); + throw new FirebasePnvError(FpnvErrorCode.INVALID_ARGUMENT, err.message); } } @@ -183,14 +186,14 @@ export class FirebasePhoneNumberTokenVerifier { 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; + 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 (!((payload.aud as string[]).some(item => item === this.issuer + projectId))) { + else if (!((Array.isArray(payload.aud) ? payload.aud : []).some(item => item === this.issuer + projectId))) { errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` + this.issuer + projectId + '" to be one of "' + payload.aud + '".' + projectIdMatchMessage + verifyJwtTokenDocsMessage; @@ -202,7 +205,7 @@ export class FirebasePhoneNumberTokenVerifier { } if (errorMessage) { - throw new FirebasePnvError(FpnvErrorCode.COMMON_ISSUE, errorMessage); + throw new FirebasePnvError(FpnvErrorCode.INVALID_ARGUMENT, errorMessage); } } @@ -224,14 +227,14 @@ export class FirebasePhoneNumberTokenVerifier { return new FirebasePnvError(this.tokenInfo.expiredErrorCode, errorMessage); } else if (error.code === JwtErrorCode.INVALID_SIGNATURE) { const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; - return new FirebasePnvError(FpnvErrorCode.COMMON_ISSUE, errorMessage); + return new FirebasePnvError(FpnvErrorCode.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 FirebasePnvError(FpnvErrorCode.COMMON_ISSUE, errorMessage); + return new FirebasePnvError(FpnvErrorCode.INVALID_ARGUMENT, errorMessage); } - return new FirebasePnvError(FpnvErrorCode.COMMON_ISSUE, error.message); + return new FirebasePnvError(FpnvErrorCode.INVALID_ARGUMENT, error.message); } } diff --git a/src/utils/error.ts b/src/utils/error.ts index 1887b6c799..7a25d9b7f8 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -1169,11 +1169,21 @@ export class FirebasePnvError extends PrefixedFirebaseError { } export class FpnvErrorCode { - static INVALID_TOKEN = 'invalid_token'; - static EXPIRED_TOKEN = 'expired_token'; - static COMMON_ISSUE = { - code: "code", - message: "message" + static readonly INVALID_ARGUMENT: ErrorInfo = { + code: 'invalid-argument', + message: 'Invalid argument provided to the FPNV method.', + }; + static readonly INVALID_TOKEN: ErrorInfo = { + code: 'invalid-token', + message: 'The provided phone number verification token is invalid.', }; - // TODO: need to update codes properly + static readonly EXPIRED_TOKEN: ErrorInfo = { + code: 'expired-token', + message: 'The provided phone number verification token has expired.', + }; + static readonly PROJECT_NOT_FOUND: ErrorInfo = { + code: 'project-not-found', + message: 'No Firebase project was found for the provided credential.', + }; + } \ No newline at end of file From 7f71eedc76d88442405ea4dbcb9e10f4aca5ad7b Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Wed, 10 Dec 2025 15:32:32 +0100 Subject: [PATCH 03/18] fix: remove unused interface --- src/fpnv/token-verifier.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fpnv/token-verifier.ts b/src/fpnv/token-verifier.ts index ae3c1bf357..fbe7ca7171 100644 --- a/src/fpnv/token-verifier.ts +++ b/src/fpnv/token-verifier.ts @@ -17,7 +17,7 @@ import { App } from '../app'; import { FpnvErrorCode, FirebasePnvError, ErrorInfo } from '../utils/error'; -import {FirebasePhoneNumberTokenInfo, FpnvToken} from './fpnv-api'; +import { FpnvToken} from './fpnv-api'; import * as util from '../utils/index'; import * as validator from '../utils/validator'; import { From 8413368bba47be484592f5ecc3a90ab0b946f55a Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Wed, 10 Dec 2025 16:28:30 +0100 Subject: [PATCH 04/18] chore: update export --- src/fpnv/index.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/fpnv/index.ts b/src/fpnv/index.ts index a295136b6b..96682f1543 100644 --- a/src/fpnv/index.ts +++ b/src/fpnv/index.ts @@ -1,3 +1,9 @@ +/** + * Firebase Phone Number Verification. + * + * @packageDocumentation + */ + /*! * @license * Copyright 2025 Google LLC @@ -15,16 +21,21 @@ * limitations under the License. */ -/** - * Firebase Phone Number Verification. - * - * @packageDocumentation - */ - import { App, getApp } from '../app'; import { FirebaseApp } from '../app/firebase-app'; import { Fpnv } from './fpnv'; +export { + Fpnv +} from './fpnv'; + +export { + FpnvToken, + FirebasePnvError, + FpnvErrorCode, + ErrorInfo +} from './fpnv-api' + /** * Gets the {@link Fpnv} service for the default app or a * given app. From e90c2bd13f25cb36284b97c78fc4213a7e365ed9 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Wed, 10 Dec 2025 16:30:22 +0100 Subject: [PATCH 05/18] chore: linting --- src/fpnv/fpnv-api.ts | 2 +- src/fpnv/fpnv.ts | 2 +- src/fpnv/token-verifier.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fpnv/fpnv-api.ts b/src/fpnv/fpnv-api.ts index ca3e46d22d..df15661fd0 100644 --- a/src/fpnv/fpnv-api.ts +++ b/src/fpnv/fpnv-api.ts @@ -35,5 +35,5 @@ export interface FpnvToken { [key: string]: any; } -export {FpnvErrorCode, FirebasePnvError, ErrorInfo} from '../utils/error'; +export { FpnvErrorCode, FirebasePnvError, ErrorInfo } from '../utils/error'; diff --git a/src/fpnv/fpnv.ts b/src/fpnv/fpnv.ts index dc64a4cd6e..9596418f25 100644 --- a/src/fpnv/fpnv.ts +++ b/src/fpnv/fpnv.ts @@ -28,7 +28,7 @@ import { export class Fpnv { private readonly app_: App; - protected readonly fpnvVerifier: FirebasePhoneNumberTokenVerifier; + protected readonly fpnvVerifier: FirebasePhoneNumberTokenVerifier; constructor(app: App) { diff --git a/src/fpnv/token-verifier.ts b/src/fpnv/token-verifier.ts index fbe7ca7171..f56cee69e1 100644 --- a/src/fpnv/token-verifier.ts +++ b/src/fpnv/token-verifier.ts @@ -17,7 +17,7 @@ import { App } from '../app'; import { FpnvErrorCode, FirebasePnvError, ErrorInfo } from '../utils/error'; -import { FpnvToken} from './fpnv-api'; +import { FpnvToken } from './fpnv-api'; import * as util from '../utils/index'; import * as validator from '../utils/validator'; import { From 2a759df08eff51f313f161869f0feafb0ab5d1e7 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Wed, 10 Dec 2025 16:33:54 +0100 Subject: [PATCH 06/18] chore: update export --- src/fpnv/fpnv-api.ts | 2 +- src/fpnv/index.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/fpnv/fpnv-api.ts b/src/fpnv/fpnv-api.ts index df15661fd0..0cea0e98ac 100644 --- a/src/fpnv/fpnv-api.ts +++ b/src/fpnv/fpnv-api.ts @@ -35,5 +35,5 @@ export interface FpnvToken { [key: string]: any; } -export { FpnvErrorCode, FirebasePnvError, ErrorInfo } from '../utils/error'; +export { FpnvErrorCode, FirebasePnvError } from '../utils/error'; diff --git a/src/fpnv/index.ts b/src/fpnv/index.ts index 96682f1543..1d296412c9 100644 --- a/src/fpnv/index.ts +++ b/src/fpnv/index.ts @@ -33,7 +33,6 @@ export { FpnvToken, FirebasePnvError, FpnvErrorCode, - ErrorInfo } from './fpnv-api' /** From fabcf80af71651861b4a79d2a5ec1995c26557af Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Wed, 10 Dec 2025 16:35:01 +0100 Subject: [PATCH 07/18] feat: add apidocs --- etc/firebase-admin.fpnv.api.md | 64 ++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 etc/firebase-admin.fpnv.api.md diff --git a/etc/firebase-admin.fpnv.api.md b/etc/firebase-admin.fpnv.api.md new file mode 100644 index 0000000000..2b32fa3d91 --- /dev/null +++ b/etc/firebase-admin.fpnv.api.md @@ -0,0 +1,64 @@ +## 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'; + +// Warning: (ae-forgotten-export) The symbol "PrefixedFirebaseError" needs to be exported by the entry point index.d.ts +// +// @public +export class FirebasePnvError extends PrefixedFirebaseError { +} + +// @public +export class Fpnv { + // Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts + constructor(app: App); + get app(): App; + // Warning: (ae-forgotten-export) The symbol "FirebasePhoneNumberTokenVerifier" needs to be exported by the entry point index.d.ts + // + // (undocumented) + protected readonly fpnvVerifier: FirebasePhoneNumberTokenVerifier; + // (undocumented) + verifyToken(idToken: string): Promise; +} + +// @public (undocumented) +export class FpnvErrorCode { + // (undocumented) + static readonly EXPIRED_TOKEN: ErrorInfo; + // Warning: (ae-forgotten-export) The symbol "ErrorInfo" needs to be exported by the entry point index.d.ts + // + // (undocumented) + static readonly INVALID_ARGUMENT: ErrorInfo; + // (undocumented) + static readonly INVALID_TOKEN: ErrorInfo; + // (undocumented) + static readonly PROJECT_NOT_FOUND: ErrorInfo; +} + +// @public +export interface FpnvToken { + [key: string]: any; + // (undocumented) + aud: string; + // (undocumented) + auth_time: number; + // (undocumented) + exp: number; + // (undocumented) + getPhoneNumber(): string; + // (undocumented) + iat: number; + // (undocumented) + iss: string; + // (undocumented) + sub: string; +} + +// @public +export function getFirebasePnv(app?: App): Fpnv; + +``` From 38e5a17f6cd25bc2252f14959bf244f65ae9d7e0 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Thu, 11 Dec 2025 14:12:32 +0100 Subject: [PATCH 08/18] chore: resolve commnets --- src/fpnv/fpnv-api-client-internal.ts | 71 +++++++++++++++++++ src/fpnv/fpnv-api.ts | 51 ++++++++++++-- src/fpnv/fpnv.ts | 35 ++++++---- src/fpnv/token-verifier.ts | 100 ++++++++------------------- src/utils/error.ts | 42 ----------- 5 files changed, 166 insertions(+), 133 deletions(-) create mode 100644 src/fpnv/fpnv-api-client-internal.ts diff --git a/src/fpnv/fpnv-api-client-internal.ts b/src/fpnv/fpnv-api-client-internal.ts new file mode 100644 index 0000000000..82fceffb24 --- /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 CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'; + +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 FirebasePnvError 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__ = FirebasePnvError.prototype; + } +} \ No newline at end of file diff --git a/src/fpnv/fpnv-api.ts b/src/fpnv/fpnv-api.ts index 0cea0e98ac..4528ca2d79 100644 --- a/src/fpnv/fpnv-api.ts +++ b/src/fpnv/fpnv-api.ts @@ -20,20 +20,61 @@ * Interface representing a Fpnv token. */ export interface FpnvToken { - aud: string; - auth_time: number; + /** + * The issuer identifier for the issuer of the response. + * This value is a URL with the format + * `https://firebaseappcheck.googleapis.com/`, where `` is the + * same project number specified in the {@link aud} property. + */ + iss: string; + + /** + * The audience for which this token is intended. + * This value is a JSON array of two strings, the first is the project number of your + * Firebase project, and the second is the project ID of the same 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; - iss: string; + + /** + * The phone number of User. + */ sub: string; + /** + * Unique ID. + */ + jti: string; + + /** + * Unique ID. + */ + 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 sub} property. + */ getPhoneNumber(): string; /** - * Other arbitrary claims included in the ID token. + * Other arbitrary claims included in the token. */ [key: string]: any; } -export { FpnvErrorCode, FirebasePnvError } from '../utils/error'; +export { FpnvErrorCode, FirebasePnvError } from './fpnv-api-client-internal'; diff --git a/src/fpnv/fpnv.ts b/src/fpnv/fpnv.ts index 9596418f25..5d72b480f5 100644 --- a/src/fpnv/fpnv.ts +++ b/src/fpnv/fpnv.ts @@ -17,35 +17,42 @@ import { App } from '../app'; import { FpnvToken } from './fpnv-api'; -import { - FirebasePhoneNumberTokenVerifier, - createFPNTVerifier, -} from './token-verifier'; +import { FirebasePhoneNumberTokenVerifier } from './token-verifier'; +import { CLIENT_CERT_URL, PN_TOKEN_INFO } from './fpnv-api-client-internal'; /** * Fpnv service bound to the provided app. */ -export class Fpnv { - private readonly app_: App; - +export class Fpnv { + private readonly appInternal: App; protected readonly fpnvVerifier: FirebasePhoneNumberTokenVerifier; + /** + * @param app - The app for this `Fpnv` service. + * @constructor + * @internal + */ constructor(app: App) { - this.app_ = app; - this.fpnvVerifier = createFPNTVerifier(app); + this.appInternal = app; + this.fpnvVerifier = new FirebasePhoneNumberTokenVerifier( + CLIENT_CERT_URL, + 'https://fpnv.googleapis.com/projects/', + PN_TOKEN_INFO, + app + ); } /** - * Returns the app associated with this Auth instance. + * Returns the app associated with this `Fpnv` instance. * - * @returns The app associated with this Auth instance. + * @returns The app associated with this `Fpnv` instance. */ get app(): App { - return this.app_; + return this.appInternal; } - public async verifyToken(idToken: string): Promise { - return await this.fpnvVerifier.verifyJWT(idToken); + public async verifyToken(fpnvJwt: string): Promise { + return await this.fpnvVerifier.verifyJWT(fpnvJwt); } } diff --git a/src/fpnv/token-verifier.ts b/src/fpnv/token-verifier.ts index f56cee69e1..75c84ac935 100644 --- a/src/fpnv/token-verifier.ts +++ b/src/fpnv/token-verifier.ts @@ -16,41 +16,14 @@ */ import { App } from '../app'; -import { FpnvErrorCode, FirebasePnvError, ErrorInfo } from '../utils/error'; -import { FpnvToken } from './fpnv-api'; +import { FirebasePnvError, 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'; - -const CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'; - - -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', - expiredErrorCode: FpnvErrorCode.EXPIRED_TOKEN, -}; - -interface FirebasePhoneNumberTokenInfo { - /** Documentation URL. */ - url: string; - /** verify API name. */ - verifyApiName: string; - /** The JWT full name. */ - jwtName: string; - /** The JWT short name. */ - shortName: string; - /** JWT Expiration error code. */ - expiredErrorCode: ErrorInfo; - /** The JWT typ" (Type) */ - typ: string; -} +import { FirebasePhoneNumberTokenInfo, FPNV_ERROR_CODE_MAPPING } from './fpnv-api-client-internal'; export class FirebasePhoneNumberTokenVerifier { @@ -66,44 +39,39 @@ export class FirebasePhoneNumberTokenVerifier { if (!validator.isURL(clientCertUrl)) { throw new FirebasePnvError( - FpnvErrorCode.INVALID_ARGUMENT, + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, 'The provided public client certificate URL is an invalid URL.', ); } else if (!validator.isURL(issuer)) { throw new FirebasePnvError( - FpnvErrorCode.INVALID_ARGUMENT, + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, 'The provided JWT issuer is an invalid URL.', ); } else if (!validator.isNonNullObject(tokenInfo)) { throw new FirebasePnvError( - FpnvErrorCode.INVALID_ARGUMENT, + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, 'The provided JWT information is not an object or null.', ); } else if (!validator.isURL(tokenInfo.url)) { throw new FirebasePnvError( - FpnvErrorCode.INVALID_ARGUMENT, + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, 'The provided JWT verification documentation URL is invalid.', ); } else if (!validator.isNonEmptyString(tokenInfo.verifyApiName)) { throw new FirebasePnvError( - FpnvErrorCode.INVALID_ARGUMENT, + 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 FirebasePnvError( - FpnvErrorCode.INVALID_ARGUMENT, + 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 FirebasePnvError( - FpnvErrorCode.INVALID_ARGUMENT, + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, 'The JWT public short name must be a non-empty string.', ); - } else if (!validator.isNonNullObject(tokenInfo.expiredErrorCode) || !('code' in tokenInfo.expiredErrorCode)) { - throw new FirebasePnvError( - FpnvErrorCode.INVALID_ARGUMENT, - 'The JWT expiration error code must be a non-null ErrorInfo object.', - ); } this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; @@ -116,8 +84,8 @@ export class FirebasePhoneNumberTokenVerifier { public async verifyJWT(jwtToken: string): Promise { if (!validator.isString(jwtToken)) { throw new FirebasePnvError( - FpnvErrorCode.PROJECT_NOT_FOUND, - `First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.`, + FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN, + `First argument to ${this.tokenInfo.verifyApiName} must be a string.`, ); } @@ -132,7 +100,7 @@ export class FirebasePhoneNumberTokenVerifier { const projectId = await util.findProjectId(this.app); if (!validator.isNonEmptyString(projectId)) { throw new FirebasePnvError( - FpnvErrorCode.PROJECT_NOT_FOUND, + 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}.`); } @@ -159,14 +127,13 @@ export class FirebasePhoneNumberTokenVerifier { 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 FirebasePnvError(FpnvErrorCode.INVALID_ARGUMENT, + throw new FirebasePnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); } - throw new FirebasePnvError(FpnvErrorCode.INVALID_ARGUMENT, err.message); + throw new FirebasePnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, err.message); } } - private verifyContent( fullDecodedToken: DecodedToken, projectId: string | null, @@ -174,6 +141,7 @@ export class FirebasePhoneNumberTokenVerifier { const header = fullDecodedToken && fullDecodedToken.header; const payload = fullDecodedToken && fullDecodedToken.payload; + const scopedProjectId = `${this.issuer}${projectId}`; 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} ` + @@ -190,22 +158,20 @@ export class FirebasePhoneNumberTokenVerifier { `"${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; + `"${header.typ}". ${verifyJwtTokenDocsMessage}`; } // FPNV Token - else if (!((Array.isArray(payload.aud) ? payload.aud : []).some(item => item === this.issuer + projectId))) { - errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` + - this.issuer + projectId + '" to be one of "' + payload.aud + '".' + projectIdMatchMessage + - verifyJwtTokenDocsMessage; + else if (!validator.isNonEmptyArray(payload.aud) || !payload.aud.includes(scopedProjectId)) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected ` + + `"${scopedProjectId}" to be one of "${payload.aud}". ${projectIdMatchMessage} ${verifyJwtTokenDocsMessage}`; } else if (typeof payload.sub !== 'string') { - errorMessage = `${this.tokenInfo.jwtName} has no "sub" (subject) claim.` + verifyJwtTokenDocsMessage; + 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; + errorMessage = `${this.tokenInfo.jwtName} has an empty "sub" (subject) claim. ${verifyJwtTokenDocsMessage}`; } if (errorMessage) { - throw new FirebasePnvError(FpnvErrorCode.INVALID_ARGUMENT, errorMessage); + throw new FirebasePnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); } } @@ -222,28 +188,18 @@ export class FirebasePhoneNumberTokenVerifier { `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 (auth/${this.tokenInfo.expiredErrorCode.code}).` + - verifyJwtTokenDocsMessage; - return new FirebasePnvError(this.tokenInfo.expiredErrorCode, errorMessage); + ` from your client app and try again. ${verifyJwtTokenDocsMessage}`; + return new FirebasePnvError(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 FirebasePnvError(FpnvErrorCode.INVALID_ARGUMENT, errorMessage); + const errorMessage = `${this.tokenInfo.jwtName} has invalid signature. ${verifyJwtTokenDocsMessage}`; + return new FirebasePnvError(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 FirebasePnvError(FpnvErrorCode.INVALID_ARGUMENT, errorMessage); + return new FirebasePnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); } - return new FirebasePnvError(FpnvErrorCode.INVALID_ARGUMENT, error.message); + return new FirebasePnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, error.message); } } - -export function createFPNTVerifier(app: App): FirebasePhoneNumberTokenVerifier { - return new FirebasePhoneNumberTokenVerifier( - CLIENT_CERT_URL, - 'https://fpnv.googleapis.com/projects/', - PN_TOKEN_INFO, - app - ); -} diff --git a/src/utils/error.ts b/src/utils/error.ts index 7a25d9b7f8..db013b8967 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -1145,45 +1145,3 @@ const TOPIC_MGT_SERVER_TO_CLIENT_CODE: ServerToClientCode = { INTERNAL: 'INTERNAL_ERROR', UNKNOWN: 'UNKNOWN_ERROR', }; - -/** - * Firebase Phone Number Verification error code structure. This extends PrefixedFirebaseError. - */ -export class FirebasePnvError extends PrefixedFirebaseError { - /** - * @param info - The error code info. - * @param message - The error message. This will override the default message if provided. - * @constructor - * @internal - */ - constructor(info: ErrorInfo, message?: string) { - // Override default message if custom message provided. - super('fpnv', info.code, message || info.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__ = FirebasePnvError.prototype; - } -} - -export class FpnvErrorCode { - static readonly INVALID_ARGUMENT: ErrorInfo = { - code: 'invalid-argument', - message: 'Invalid argument provided to the FPNV method.', - }; - static readonly INVALID_TOKEN: ErrorInfo = { - code: 'invalid-token', - message: 'The provided phone number verification token is invalid.', - }; - static readonly EXPIRED_TOKEN: ErrorInfo = { - code: 'expired-token', - message: 'The provided phone number verification token has expired.', - }; - static readonly PROJECT_NOT_FOUND: ErrorInfo = { - code: 'project-not-found', - message: 'No Firebase project was found for the provided credential.', - }; - -} \ No newline at end of file From 71f48cbafb8ec95d236e1fafb7b5fb1f53b58fc3 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Thu, 11 Dec 2025 14:36:37 +0100 Subject: [PATCH 09/18] chore: linting --- etc/firebase-admin.fpnv.api.md | 35 ++++------------------------ src/fpnv/fpnv-api-client-internal.ts | 4 ++-- src/fpnv/fpnv-api.ts | 6 ++--- src/fpnv/index.ts | 2 -- src/fpnv/token-verifier.ts | 34 +++++++++++++-------------- 5 files changed, 26 insertions(+), 55 deletions(-) diff --git a/etc/firebase-admin.fpnv.api.md b/etc/firebase-admin.fpnv.api.md index 2b32fa3d91..8a390a79fb 100644 --- a/etc/firebase-admin.fpnv.api.md +++ b/etc/firebase-admin.fpnv.api.md @@ -6,55 +6,28 @@ import { Agent } from 'http'; -// Warning: (ae-forgotten-export) The symbol "PrefixedFirebaseError" needs to be exported by the entry point index.d.ts -// -// @public -export class FirebasePnvError extends PrefixedFirebaseError { -} - // @public export class Fpnv { // Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts - constructor(app: App); get app(): App; // Warning: (ae-forgotten-export) The symbol "FirebasePhoneNumberTokenVerifier" needs to be exported by the entry point index.d.ts // // (undocumented) protected readonly fpnvVerifier: FirebasePhoneNumberTokenVerifier; // (undocumented) - verifyToken(idToken: string): Promise; -} - -// @public (undocumented) -export class FpnvErrorCode { - // (undocumented) - static readonly EXPIRED_TOKEN: ErrorInfo; - // Warning: (ae-forgotten-export) The symbol "ErrorInfo" needs to be exported by the entry point index.d.ts - // - // (undocumented) - static readonly INVALID_ARGUMENT: ErrorInfo; - // (undocumented) - static readonly INVALID_TOKEN: ErrorInfo; - // (undocumented) - static readonly PROJECT_NOT_FOUND: ErrorInfo; + verifyToken(fpnvJwt: string): Promise; } // @public export interface FpnvToken { [key: string]: any; - // (undocumented) - aud: string; - // (undocumented) - auth_time: number; - // (undocumented) + aud: string[]; exp: number; - // (undocumented) getPhoneNumber(): string; - // (undocumented) iat: number; - // (undocumented) iss: string; - // (undocumented) + jti: string; + nonce: string; sub: string; } diff --git a/src/fpnv/fpnv-api-client-internal.ts b/src/fpnv/fpnv-api-client-internal.ts index 82fceffb24..22a3a85ff3 100644 --- a/src/fpnv/fpnv-api-client-internal.ts +++ b/src/fpnv/fpnv-api-client-internal.ts @@ -58,7 +58,7 @@ export type FpnvErrorCode = * @param message - The error message. * @constructor */ -export class FirebasePnvError extends PrefixedFirebaseError { +export class FirebaseFpnvError extends PrefixedFirebaseError { constructor(code: FpnvErrorCode, message: string) { super('fpnv', code, message); @@ -66,6 +66,6 @@ export class FirebasePnvError extends PrefixedFirebaseError { // 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__ = FirebasePnvError.prototype; + (this as any).__proto__ = FirebaseFpnvError.prototype; } } \ No newline at end of file diff --git a/src/fpnv/fpnv-api.ts b/src/fpnv/fpnv-api.ts index 4528ca2d79..1c298302a6 100644 --- a/src/fpnv/fpnv-api.ts +++ b/src/fpnv/fpnv-api.ts @@ -24,7 +24,7 @@ export interface FpnvToken { * The issuer identifier for the issuer of the response. * This value is a URL with the format * `https://firebaseappcheck.googleapis.com/`, where `` is the - * same project number specified in the {@link aud} property. + * same project number specified in the {@link FpnvToken.aud} property. */ iss: string; @@ -66,7 +66,7 @@ export interface FpnvToken { /** * 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 sub} property. + * convenience, and is set as the value of the {@link FpnvToken.sub} property. */ getPhoneNumber(): string; @@ -76,5 +76,5 @@ export interface FpnvToken { [key: string]: any; } -export { FpnvErrorCode, FirebasePnvError } from './fpnv-api-client-internal'; +export { FpnvErrorCode, FirebaseFpnvError } from './fpnv-api-client-internal'; diff --git a/src/fpnv/index.ts b/src/fpnv/index.ts index 1d296412c9..90d96bff92 100644 --- a/src/fpnv/index.ts +++ b/src/fpnv/index.ts @@ -31,8 +31,6 @@ export { export { FpnvToken, - FirebasePnvError, - FpnvErrorCode, } from './fpnv-api' /** diff --git a/src/fpnv/token-verifier.ts b/src/fpnv/token-verifier.ts index 75c84ac935..3debad2595 100644 --- a/src/fpnv/token-verifier.ts +++ b/src/fpnv/token-verifier.ts @@ -16,7 +16,7 @@ */ import { App } from '../app'; -import { FirebasePnvError, FpnvToken } from './fpnv-api'; +import { FirebaseFpnvError, FpnvToken } from './fpnv-api'; import * as util from '../utils/index'; import * as validator from '../utils/validator'; import { @@ -38,37 +38,37 @@ export class FirebasePhoneNumberTokenVerifier { ) { if (!validator.isURL(clientCertUrl)) { - throw new FirebasePnvError( + 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 FirebasePnvError( + throw new FirebaseFpnvError( FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, 'The provided JWT issuer is an invalid URL.', ); } else if (!validator.isNonNullObject(tokenInfo)) { - throw new FirebasePnvError( + 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 FirebasePnvError( + 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 FirebasePnvError( + 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 FirebasePnvError( + 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 FirebasePnvError( + throw new FirebaseFpnvError( FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, 'The JWT public short name must be a non-empty string.', ); @@ -83,7 +83,7 @@ export class FirebasePhoneNumberTokenVerifier { public async verifyJWT(jwtToken: string): Promise { if (!validator.isString(jwtToken)) { - throw new FirebasePnvError( + throw new FirebaseFpnvError( FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN, `First argument to ${this.tokenInfo.verifyApiName} must be a string.`, ); @@ -99,7 +99,7 @@ export class FirebasePhoneNumberTokenVerifier { private async ensureProjectId(): Promise { const projectId = await util.findProjectId(this.app); if (!validator.isNonEmptyString(projectId)) { - throw new FirebasePnvError( + 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}.`); @@ -127,10 +127,10 @@ export class FirebasePhoneNumberTokenVerifier { 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 FirebasePnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + throw new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); } - throw new FirebasePnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, err.message); + throw new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, err.message); } } @@ -171,7 +171,7 @@ export class FirebasePhoneNumberTokenVerifier { } if (errorMessage) { - throw new FirebasePnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); + throw new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); } } @@ -189,17 +189,17 @@ export class FirebasePhoneNumberTokenVerifier { 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 FirebasePnvError(FPNV_ERROR_CODE_MAPPING.EXPIRED_TOKEN, errorMessage); + 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 FirebasePnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); + 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 FirebasePnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); + return new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); } - return new FirebasePnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, error.message); + return new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, error.message); } } From 1c81224fb268e675d8806d0381b830b1c7d32252 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Mon, 15 Dec 2025 15:01:45 +0100 Subject: [PATCH 10/18] chore: add unit test --- test/unit/fpnv/index.spec.ts | 68 ++++++++++++++++++++++++++++++++++++ test/unit/index.spec.ts | 4 +++ 2 files changed, 72 insertions(+) create mode 100644 test/unit/fpnv/index.spec.ts diff --git a/test/unit/fpnv/index.spec.ts b/test/unit/fpnv/index.spec.ts new file mode 100644 index 0000000000..760618beda --- /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 configs are provided', () => { + const fpnvFirst: Fpnv = getFirebasePnv(mockApp); + const fpnvSecond: Fpnv = getFirebasePnv(mockCredentialApp); + expect(fpnvFirst).to.not.equal(fpnvSecond); + }); + }); +}); diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index 39ae51fd9f..d8fb652775 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -123,3 +123,7 @@ 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'; From 7ac9f0c18294a9ecbb0689d602f4dd9baf31dbc4 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Mon, 15 Dec 2025 15:09:44 +0100 Subject: [PATCH 11/18] chore: change test description --- test/unit/fpnv/index.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/fpnv/index.spec.ts b/test/unit/fpnv/index.spec.ts index 760618beda..484ed82b84 100644 --- a/test/unit/fpnv/index.spec.ts +++ b/test/unit/fpnv/index.spec.ts @@ -59,7 +59,7 @@ describe('Fpnv', () => { expect(fpnvFirst).to.equal(fpnvSecond); }); - it('should not return the same instance when different configs are provided', () => { + 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); From fda717eee84c2e3d07eef9ac01eafa6d81d6d3ea Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Mon, 15 Dec 2025 15:40:48 +0100 Subject: [PATCH 12/18] chore: remove extra line --- test/unit/index.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index d8fb652775..87d1f69df9 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -124,6 +124,5 @@ 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'; From 73da5d2d10f87a392aa12a574cc8328d2fd896b1 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Mon, 15 Dec 2025 16:29:26 +0100 Subject: [PATCH 13/18] chore: add unit test --- .../fpnv/fpnv-api-client-internal.spec.ts | 85 +++++++++++++++++++ test/unit/index.spec.ts | 1 + 2 files changed, 86 insertions(+) create mode 100644 test/unit/fpnv/fpnv-api-client-internal.spec.ts 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..0df18f6956 --- /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, + CLIENT_CERT_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(CLIENT_CERT_URL).to.equal('https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'); + }); + + 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/index.spec.ts b/test/unit/index.spec.ts index 87d1f69df9..5e1102b830 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -126,3 +126,4 @@ import './data-connect/validate-admin-args.spec'; // Fpnv import './fpnv/index.spec'; +import './fpnv/fpnv-api-client-internal.spec'; From b8c0f084ef32d53288480c6bc07b1fc3e351d127 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Mon, 15 Dec 2025 17:19:20 +0100 Subject: [PATCH 14/18] chore: add unit test --- src/fpnv/fpnv-api.ts | 2 +- test/unit/fpnv/fpnv-api.spec.ts | 82 +++++++++++++++++++++++++++++++++ test/unit/index.spec.ts | 1 + 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 test/unit/fpnv/fpnv-api.spec.ts diff --git a/src/fpnv/fpnv-api.ts b/src/fpnv/fpnv-api.ts index 1c298302a6..4178afe60c 100644 --- a/src/fpnv/fpnv-api.ts +++ b/src/fpnv/fpnv-api.ts @@ -23,7 +23,7 @@ export interface FpnvToken { /** * The issuer identifier for the issuer of the response. * This value is a URL with the format - * `https://firebaseappcheck.googleapis.com/`, where `` is the + * `https://fpnv.googleapis.com/projects/`, where `` is the * same project number specified in the {@link FpnvToken.aud} property. */ iss: string; diff --git a/test/unit/fpnv/fpnv-api.spec.ts b/test/unit/fpnv/fpnv-api.spec.ts new file mode 100644 index 0000000000..38f05f53ed --- /dev/null +++ b/test/unit/fpnv/fpnv-api.spec.ts @@ -0,0 +1,82 @@ +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/index.spec.ts b/test/unit/index.spec.ts index 5e1102b830..eb6659ed82 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -127,3 +127,4 @@ import './data-connect/validate-admin-args.spec'; // Fpnv import './fpnv/index.spec'; import './fpnv/fpnv-api-client-internal.spec'; +import './fpnv/fpnv-api.spec'; From 26bb576d1f159e95b3eae24d64eef15e905d6090 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Tue, 16 Dec 2025 15:48:29 +0100 Subject: [PATCH 15/18] chore: fix minor comments --- src/fpnv/fpnv-api-client-internal.ts | 4 ++-- src/fpnv/fpnv-api.ts | 4 ++-- src/fpnv/fpnv.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/fpnv/fpnv-api-client-internal.ts b/src/fpnv/fpnv-api-client-internal.ts index 22a3a85ff3..ba739fed0c 100644 --- a/src/fpnv/fpnv-api-client-internal.ts +++ b/src/fpnv/fpnv-api-client-internal.ts @@ -26,7 +26,7 @@ export interface FirebasePhoneNumberTokenInfo { jwtName: string; /** The JWT short name. */ shortName: string; - /** The JWT typ" (Type) */ + /** The JWT typ (Type) */ typ: string; } @@ -68,4 +68,4 @@ export class FirebaseFpnvError extends PrefixedFirebaseError { /* tslint:enable:max-line-length */ (this as any).__proto__ = FirebaseFpnvError.prototype; } -} \ No newline at end of file +} diff --git a/src/fpnv/fpnv-api.ts b/src/fpnv/fpnv-api.ts index 4178afe60c..e64a35a5fa 100644 --- a/src/fpnv/fpnv-api.ts +++ b/src/fpnv/fpnv-api.ts @@ -54,12 +54,12 @@ export interface FpnvToken { sub: string; /** - * Unique ID. + * A case-sensitive string that uniquely identifies a specific JWT instance */ jti: string; /** - * Unique ID. + * A unique, single-use "number used once" value. */ nonce: string; diff --git a/src/fpnv/fpnv.ts b/src/fpnv/fpnv.ts index 5d72b480f5..258bc42223 100644 --- a/src/fpnv/fpnv.ts +++ b/src/fpnv/fpnv.ts @@ -25,7 +25,7 @@ import { CLIENT_CERT_URL, PN_TOKEN_INFO } from './fpnv-api-client-internal'; */ export class Fpnv { private readonly appInternal: App; - protected readonly fpnvVerifier: FirebasePhoneNumberTokenVerifier; + private readonly fpnvVerifier: FirebasePhoneNumberTokenVerifier; /** * @param app - The app for this `Fpnv` service. @@ -52,7 +52,7 @@ export class Fpnv { return this.appInternal; } - public async verifyToken(fpnvJwt: string): Promise { - return await this.fpnvVerifier.verifyJWT(fpnvJwt); + public verifyToken(fpnvJwt: string): Promise { + return this.fpnvVerifier.verifyJWT(fpnvJwt); } } From 467924aba790d362ecaa87b6fead00077fbe0a97 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Tue, 16 Dec 2025 16:09:12 +0100 Subject: [PATCH 16/18] chore: add unit test --- test/unit/fpnv/fpnv.spec.ts | 101 ++++++++++++++++++++++++++++++++++++ test/unit/index.spec.ts | 1 + 2 files changed, 102 insertions(+) create mode 100644 test/unit/fpnv/fpnv.spec.ts diff --git a/test/unit/fpnv/fpnv.spec.ts b/test/unit/fpnv/fpnv.spec.ts new file mode 100644 index 0000000000..0c974b9b10 --- /dev/null +++ b/test/unit/fpnv/fpnv.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. + */ + +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/index.spec.ts b/test/unit/index.spec.ts index eb6659ed82..7f9c472580 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -128,3 +128,4 @@ import './data-connect/validate-admin-args.spec'; import './fpnv/index.spec'; import './fpnv/fpnv-api-client-internal.spec'; import './fpnv/fpnv-api.spec'; +import './fpnv/fpnv.spec'; From 166ac1f6bbcb8aaa26e0b67b9ebf5c30f1541c2c Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Tue, 16 Dec 2025 17:02:09 +0100 Subject: [PATCH 17/18] chore: add unot test --- test/unit/fpnv/fpnv-api.spec.ts | 19 ++ test/unit/fpnv/fpnv.spec.ts | 2 + test/unit/fpnv/token-verifier.spec.ts | 254 ++++++++++++++++++++++++++ test/unit/index.spec.ts | 1 + 4 files changed, 276 insertions(+) create mode 100644 test/unit/fpnv/token-verifier.spec.ts diff --git a/test/unit/fpnv/fpnv-api.spec.ts b/test/unit/fpnv/fpnv-api.spec.ts index 38f05f53ed..0779eba069 100644 --- a/test/unit/fpnv/fpnv-api.spec.ts +++ b/test/unit/fpnv/fpnv-api.spec.ts @@ -1,3 +1,22 @@ +/*! + * @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'; diff --git a/test/unit/fpnv/fpnv.spec.ts b/test/unit/fpnv/fpnv.spec.ts index 0c974b9b10..e986f5075e 100644 --- a/test/unit/fpnv/fpnv.spec.ts +++ b/test/unit/fpnv/fpnv.spec.ts @@ -15,6 +15,8 @@ * limitations under the License. */ +'use strict'; + import { expect } from 'chai'; import * as sinon from 'sinon'; diff --git a/test/unit/fpnv/token-verifier.spec.ts b/test/unit/fpnv/token-verifier.spec.ts new file mode 100644 index 0000000000..e087466fe3 --- /dev/null +++ b/test/unit/fpnv/token-verifier.spec.ts @@ -0,0 +1,254 @@ +/*! + * @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 withCertificateUrlStub: sinon.SinonStub; + + const MOCK_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/secure-token@system.gserviceaccount.com'; + 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 = { + 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() }; + withCertificateUrlStub = sinon.stub(jwt.PublicKeySignatureVerifier, 'withCertificateUrl') + .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(withCertificateUrlStub.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 7f9c472580..826336b8bd 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -129,3 +129,4 @@ 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' From 80dcb8ea6636f53344ad898abdccafb67aa91c00 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Tue, 16 Dec 2025 17:04:35 +0100 Subject: [PATCH 18/18] chore: update api.md --- etc/firebase-admin.fpnv.api.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/etc/firebase-admin.fpnv.api.md b/etc/firebase-admin.fpnv.api.md index 8a390a79fb..13a81115a9 100644 --- a/etc/firebase-admin.fpnv.api.md +++ b/etc/firebase-admin.fpnv.api.md @@ -10,10 +10,6 @@ import { Agent } from 'http'; export class Fpnv { // Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts get app(): App; - // Warning: (ae-forgotten-export) The symbol "FirebasePhoneNumberTokenVerifier" needs to be exported by the entry point index.d.ts - // - // (undocumented) - protected readonly fpnvVerifier: FirebasePhoneNumberTokenVerifier; // (undocumented) verifyToken(fpnvJwt: string): Promise; }