diff --git a/entrypoints.json b/entrypoints.json index caa92a7604..96c3f52e73 100644 --- a/entrypoints.json +++ b/entrypoints.json @@ -16,6 +16,10 @@ "typings": "./lib/auth/index.d.ts", "dist": "./lib/auth/index.js" }, + "firebase-admin/fpnv": { + "typings": "./lib/fpnv/index.d.ts", + "dist": "./lib/fpnv/index.js" + }, "firebase-admin/database": { "typings": "./lib/database/index.d.ts", "dist": "./lib/database/index.js" diff --git a/etc/firebase-admin.fpnv.api.md b/etc/firebase-admin.fpnv.api.md new file mode 100644 index 0000000000..8a390a79fb --- /dev/null +++ b/etc/firebase-admin.fpnv.api.md @@ -0,0 +1,37 @@ +## API Report File for "firebase-admin.fpnv" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Agent } from 'http'; + +// @public +export class Fpnv { + // Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts + get app(): App; + // 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; +} + +// @public +export interface FpnvToken { + [key: string]: any; + aud: string[]; + exp: number; + getPhoneNumber(): string; + iat: number; + iss: string; + jti: string; + nonce: string; + sub: string; +} + +// @public +export function getFirebasePnv(app?: App): Fpnv; + +``` diff --git a/package.json b/package.json index a17a1e291f..7a33f881be 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,9 @@ "auth": [ "lib/auth" ], + "fpnv": [ + "lib/fpnv" + ], "eventarc": [ "lib/eventarc" ], @@ -134,6 +137,11 @@ "require": "./lib/auth/index.js", "import": "./lib/esm/auth/index.js" }, + "./fpnv": { + "types": "./lib/fpnv/index.d.ts", + "require": "./lib/fpnv/index.js", + "import": "./lib/esm/fpnv/index.js" + }, "./database": { "types": "./lib/database/index.d.ts", "require": "./lib/database/index.js", diff --git a/src/fpnv/fpnv-api-client-internal.ts b/src/fpnv/fpnv-api-client-internal.ts new file mode 100644 index 0000000000..22a3a85ff3 --- /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 FirebaseFpnvError extends PrefixedFirebaseError { + constructor(code: FpnvErrorCode, message: string) { + super('fpnv', code, message); + + /* tslint:disable:max-line-length */ + // Set the prototype explicitly. See the following link for more details: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + /* tslint:enable:max-line-length */ + (this as any).__proto__ = FirebaseFpnvError.prototype; + } +} \ No newline at end of file diff --git a/src/fpnv/fpnv-api.ts b/src/fpnv/fpnv-api.ts new file mode 100644 index 0000000000..1c298302a6 --- /dev/null +++ b/src/fpnv/fpnv-api.ts @@ -0,0 +1,80 @@ +/*! + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +/** + * Interface representing a Fpnv token. + */ +export interface FpnvToken { + /** + * The issuer identifier for the issuer of the response. + * This value is a URL with the format + * `https://firebaseappcheck.googleapis.com/`, where `` is the + * same project number specified in the {@link FpnvToken.aud} property. + */ + iss: string; + + /** + * The audience for which this token is intended. + * This value is a 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; + + /** + * 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 FpnvToken.sub} property. + */ + getPhoneNumber(): string; + + /** + * Other arbitrary claims included in the token. + */ + [key: string]: any; +} + +export { FpnvErrorCode, FirebaseFpnvError } from './fpnv-api-client-internal'; + diff --git a/src/fpnv/fpnv.ts b/src/fpnv/fpnv.ts new file mode 100644 index 0000000000..5d72b480f5 --- /dev/null +++ b/src/fpnv/fpnv.ts @@ -0,0 +1,58 @@ +/*! + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { App } from '../app'; +import { FpnvToken } from './fpnv-api'; +import { FirebasePhoneNumberTokenVerifier } from './token-verifier'; +import { CLIENT_CERT_URL, PN_TOKEN_INFO } from './fpnv-api-client-internal'; + +/** + * Fpnv service bound to the provided 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.appInternal = app; + this.fpnvVerifier = new FirebasePhoneNumberTokenVerifier( + CLIENT_CERT_URL, + 'https://fpnv.googleapis.com/projects/', + PN_TOKEN_INFO, + app + ); + } + + /** + * Returns the app associated with this `Fpnv` instance. + * + * @returns The app associated with this `Fpnv` instance. + */ + get app(): App { + return this.appInternal; + } + + public async verifyToken(fpnvJwt: string): Promise { + return await this.fpnvVerifier.verifyJWT(fpnvJwt); + } +} diff --git a/src/fpnv/index.ts b/src/fpnv/index.ts new file mode 100644 index 0000000000..90d96bff92 --- /dev/null +++ b/src/fpnv/index.ts @@ -0,0 +1,64 @@ +/** + * Firebase Phone Number Verification. + * + * @packageDocumentation + */ + +/*! + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { App, getApp } from '../app'; +import { FirebaseApp } from '../app/firebase-app'; +import { Fpnv } from './fpnv'; + +export { + Fpnv +} from './fpnv'; + +export { + FpnvToken, +} from './fpnv-api' + +/** + * Gets the {@link Fpnv} service for the default app or a + * given app. + * + * `getFirebasePnv()` can be called with no arguments to access the default app's + * {@link Fpnv} service or as `getFirebasePnv(app)` to access the + * {@link Fpnv} service associated with a specific app. + * + * @example + * ```javascript + * // Get the Fpnv service for the default app + * const defaultFpnv = getFirebasePnv(); + * ``` + * + * @example + * ```javascript + * // Get the Fpnv service for a given app + * const otherFpnv = getFirebasePnv(otherApp); + * ``` + * + */ +export function getFirebasePnv(app?: App): Fpnv { + if (typeof app === 'undefined') { + app = getApp(); + } + + const firebaseApp: FirebaseApp = app as FirebaseApp; + return firebaseApp.getOrInitService('fpnv', (app) => new Fpnv(app)); +} diff --git a/src/fpnv/token-verifier.ts b/src/fpnv/token-verifier.ts new file mode 100644 index 0000000000..3debad2595 --- /dev/null +++ b/src/fpnv/token-verifier.ts @@ -0,0 +1,205 @@ +/*! + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { App } from '../app'; +import { FirebaseFpnvError, FpnvToken } from './fpnv-api'; +import * as util from '../utils/index'; +import * as validator from '../utils/validator'; +import { + DecodedToken, decodeJwt, JwtError, JwtErrorCode, + PublicKeySignatureVerifier, ALGORITHM_ES256, SignatureVerifier, +} from '../utils/jwt'; +import { FirebasePhoneNumberTokenInfo, FPNV_ERROR_CODE_MAPPING } from './fpnv-api-client-internal'; + +export class FirebasePhoneNumberTokenVerifier { + + private readonly shortNameArticle: string; + private readonly signatureVerifier: SignatureVerifier; + + constructor( + clientCertUrl: string, + private issuer: string, + private tokenInfo: FirebasePhoneNumberTokenInfo, + private readonly app: App + ) { + + if (!validator.isURL(clientCertUrl)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'The provided public client certificate URL is an invalid URL.', + ); + } else if (!validator.isURL(issuer)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'The provided JWT issuer is an invalid URL.', + ); + } else if (!validator.isNonNullObject(tokenInfo)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'The provided JWT information is not an object or null.', + ); + } else if (!validator.isURL(tokenInfo.url)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'The provided JWT verification documentation URL is invalid.', + ); + } else if (!validator.isNonEmptyString(tokenInfo.verifyApiName)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'The JWT verify API name must be a non-empty string.', + ); + } else if (!validator.isNonEmptyString(tokenInfo.jwtName)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'The JWT public full name must be a non-empty string.', + ); + } else if (!validator.isNonEmptyString(tokenInfo.shortName)) { + throw new FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'The JWT public short name must be a non-empty string.', + ); + } + this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; + + this.signatureVerifier = + PublicKeySignatureVerifier.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 FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN, + `First argument to ${this.tokenInfo.verifyApiName} must be a 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 FirebaseFpnvError( + FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'Must initialize app with a cert credential or set your Firebase project ID as the ' + + `GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`); + } + return projectId; + } + + private async decodeAndVerify( + token: string, + 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 FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + errorMessage); + } + throw new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, err.message); + } + } + + private verifyContent( + fullDecodedToken: DecodedToken, + projectId: string | null, + ): void { + 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} ` + + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + + let errorMessage: string | undefined; + + // JWT Header + if (typeof header.kid === 'undefined') { + errorMessage = `${this.tokenInfo.jwtName} has no "kid" claim.`; + errorMessage += verifyJwtTokenDocsMessage; + } else if (header.alg !== ALGORITHM_ES256) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected ` + + `"${ALGORITHM_ES256}" but got "${header.alg}". ${verifyJwtTokenDocsMessage}`; + } else if (header.typ !== this.tokenInfo.typ) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect typ. Expected "${this.tokenInfo.typ}" but got ` + + `"${header.typ}". ${verifyJwtTokenDocsMessage}`; + } + // FPNV Token + else if (!validator.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}`; + } else if (payload.sub === '') { + errorMessage = `${this.tokenInfo.jwtName} has an empty "sub" (subject) claim. ${verifyJwtTokenDocsMessage}`; + } + + if (errorMessage) { + throw new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); + } + } + + private async verifySignature(jwtToken: string): Promise { + try { + return await this.signatureVerifier.verify(jwtToken); + } catch (error) { + throw this.mapJwtErrorToAuthError(error); + } + } + + private mapJwtErrorToAuthError(error: JwtError): Error { + const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + if (error.code === JwtErrorCode.TOKEN_EXPIRED) { + const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` + + ` from your client app and try again. ${verifyJwtTokenDocsMessage}`; + return new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.EXPIRED_TOKEN, errorMessage); + } else if (error.code === JwtErrorCode.INVALID_SIGNATURE) { + const errorMessage = `${this.tokenInfo.jwtName} has invalid signature. ${verifyJwtTokenDocsMessage}`; + return new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); + } else if (error.code === JwtErrorCode.NO_MATCHING_KID) { + const errorMessage = `${this.tokenInfo.jwtName} has "kid" claim which does not ` + + `correspond to a known public key. Most likely the ${this.tokenInfo.shortName} ` + + 'is expired, so get a fresh token from your client app and try again.'; + return new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, errorMessage); + } + return new FirebaseFpnvError(FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, error.message); + } + +} diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index 9a66494482..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