From b2a05bef9f5e4220b88ee4d03aa07f968132c55c Mon Sep 17 00:00:00 2001 From: Micah Baker Date: Mon, 26 Oct 2026 20:34:19 +0000 Subject: [PATCH 1/2] feat(pnv): reject test tokens by default unless acceptTestTokens is enabled --- ...ase-admin.phone-number-verification.api.md | 9 ++++- src/phone-number-verification/index.ts | 1 + .../phone-number-verification-api.ts | 30 ++++++++++++++++ .../phone-number-verification.ts | 34 ++++++++++++++++--- .../token-verifier.ts | 19 +++++++++-- .../phone-number-verification.spec.ts | 19 ++++++++++- .../token-verifier.spec.ts | 33 ++++++++++++++++-- 7 files changed, 135 insertions(+), 10 deletions(-) diff --git a/etc/firebase-admin.phone-number-verification.api.md b/etc/firebase-admin.phone-number-verification.api.md index 256778f3fb..b4103519ad 100644 --- a/etc/firebase-admin.phone-number-verification.api.md +++ b/etc/firebase-admin.phone-number-verification.api.md @@ -22,7 +22,7 @@ export function getPhoneNumberVerification(app?: App): PhoneNumberVerification; // @public export class PhoneNumberVerification { get app(): App; - verifyToken(jwt: string): Promise; + verifyToken(jwt: string, options?: VerifyPhoneNumberTokenOptions): Promise; } // @public @@ -46,6 +46,13 @@ export interface PhoneNumberVerificationToken { nonce: string; phoneNumber: string; sub: string; + test_number?: boolean; + testNumber?: boolean; +} + +// @public +export interface VerifyPhoneNumberTokenOptions { + acceptTestTokens?: boolean; } ``` diff --git a/src/phone-number-verification/index.ts b/src/phone-number-verification/index.ts index f27db5700c..29212a81e2 100644 --- a/src/phone-number-verification/index.ts +++ b/src/phone-number-verification/index.ts @@ -31,6 +31,7 @@ export { export { PhoneNumberVerificationToken, + VerifyPhoneNumberTokenOptions, } from './phone-number-verification-api' /** diff --git a/src/phone-number-verification/phone-number-verification-api.ts b/src/phone-number-verification/phone-number-verification-api.ts index a3527d78d6..9edf3650c7 100644 --- a/src/phone-number-verification/phone-number-verification-api.ts +++ b/src/phone-number-verification/phone-number-verification-api.ts @@ -73,12 +73,42 @@ export interface PhoneNumberVerificationToken { */ phoneNumber: string; + /** + * Indicates whether the token was issued while the app was in test mode. + * Present and set to `true` for test-mode tokens. + * As a convenience, a boolean value is also copied over to the + * {@link PhoneNumberVerificationToken.testNumber | testNumber} property. + */ + test_number?: boolean; + + /** + * Indicates whether the token was issued while the app was in test mode. + * This value is not actually one of the JWT token claims. It is added as a + * convenience, and is set to `true` when the {@link PhoneNumberVerificationToken.test_number} + * claim is `true`, or `false` otherwise. + */ + testNumber?: boolean; + /** * Other arbitrary claims included in the token. */ [key: string]: any; } +/** + * Interface representing options for the {@link PhoneNumberVerification.verifyToken} method. + */ +export interface VerifyPhoneNumberTokenOptions { + /** + * To accept test-mode tokens (tokens containing the claim `"test_number": true`), + * set this option to `true`. By default, {@link PhoneNumberVerification.verifyToken} + * rejects test-mode tokens. + * + * Enable this option only during development and testing. + */ + acceptTestTokens?: boolean; +} + export { PhoneNumberVerificationErrorCode, FirebasePhoneNumberVerificationError, diff --git a/src/phone-number-verification/phone-number-verification.ts b/src/phone-number-verification/phone-number-verification.ts index 06e1818a68..837aa0fa7f 100644 --- a/src/phone-number-verification/phone-number-verification.ts +++ b/src/phone-number-verification/phone-number-verification.ts @@ -16,7 +16,13 @@ */ import { App } from '../app'; -import { PhoneNumberVerificationToken } from './phone-number-verification-api'; +import * as validator from '../utils/validator'; +import { + FirebasePhoneNumberVerificationError, + PhoneNumberVerificationToken, + VerifyPhoneNumberTokenOptions, +} from './phone-number-verification-api'; +import { FPNV_ERROR_CODE_MAPPING } from './error'; import { PhoneNumberTokenVerifier } from './token-verifier'; import { JWKS_URL, FPNV_TOKEN_INFO } from './phone-number-verification-api-client-internal'; @@ -53,13 +59,33 @@ export class PhoneNumberVerification { } /** - * Verifies a Firebase Phone Number Verification token. + * Verifies a Firebase Phone Number Verification token. By default, tokens issued in + * test mode (containing `"test_number": true`) are rejected unless + * `options.acceptTestTokens` is set to `true`. * * @param jwt - A string containing the Firebase Phone Number Verification JWT. + * @param options - Optional {@link VerifyPhoneNumberTokenOptions} object when verifying a + * Phone Number Verification token. * @returns A promise that resolves with the decoded token. */ - public verifyToken(jwt: string): Promise { - return this.phoneNumberVerificationVerifier.verifyJWT(jwt); + public verifyToken( + jwt: string, + options?: VerifyPhoneNumberTokenOptions + ): Promise { + this.validateVerifyPhoneNumberTokenOptions(options); + return this.phoneNumberVerificationVerifier.verifyJWT(jwt, options); + } + + private validateVerifyPhoneNumberTokenOptions(options?: VerifyPhoneNumberTokenOptions): void { + if (typeof options === 'undefined') { + return; + } + if (!validator.isNonNullObject(options)) { + throw new FirebasePhoneNumberVerificationError({ + code: FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + message: 'VerifyPhoneNumberTokenOptions must be a non-null object.', + }); + } } } diff --git a/src/phone-number-verification/token-verifier.ts b/src/phone-number-verification/token-verifier.ts index 3eda268599..ab39dbc78a 100644 --- a/src/phone-number-verification/token-verifier.ts +++ b/src/phone-number-verification/token-verifier.ts @@ -16,7 +16,11 @@ */ import { App } from '../app'; -import { FirebasePhoneNumberVerificationError, PhoneNumberVerificationToken } from './phone-number-verification-api'; +import { + FirebasePhoneNumberVerificationError, + PhoneNumberVerificationToken, + VerifyPhoneNumberTokenOptions, +} from './phone-number-verification-api'; import * as util from '../utils/index'; import * as validator from '../utils/validator'; import { @@ -81,7 +85,10 @@ export class PhoneNumberTokenVerifier { // Project ID is validated in the verification call. } - public async verifyJWT(jwtToken: string): Promise { + public async verifyJWT( + jwtToken: string, + options?: VerifyPhoneNumberTokenOptions + ): Promise { if (!validator.isString(jwtToken)) { throw new FirebasePhoneNumberVerificationError({ code: FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN, @@ -92,7 +99,15 @@ export class PhoneNumberTokenVerifier { const projectId = await this.ensureProjectId(); const decoded = await this.decodeAndVerify(jwtToken, projectId); const decodedIdToken = decoded.payload as PhoneNumberVerificationToken; + if (decodedIdToken.test_number === true && !options?.acceptTestTokens) { + throw new FirebasePhoneNumberVerificationError({ + code: FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN, + message: `${this.tokenInfo.jwtName} is a test token. To accept test tokens during ` + + `development, pass { acceptTestTokens: true } to ${this.tokenInfo.verifyApiName}.`, + }); + } decodedIdToken.phoneNumber = decodedIdToken.sub; + decodedIdToken.testNumber = Boolean(decodedIdToken.test_number); return decodedIdToken; } diff --git a/test/unit/phone-number-verification/phone-number-verification.spec.ts b/test/unit/phone-number-verification/phone-number-verification.spec.ts index e21ac19627..0cc05ab6f8 100644 --- a/test/unit/phone-number-verification/phone-number-verification.spec.ts +++ b/test/unit/phone-number-verification/phone-number-verification.spec.ts @@ -74,7 +74,24 @@ describe('PhoneNumberVerification Service', () => { await phoneNumberVerificationService.verifyToken(mockTokenString); expect(verifyJwtStub.calledOnce).to.be.true; - expect(verifyJwtStub.calledWith(mockTokenString)).to.be.true; + expect(verifyJwtStub.calledWith(mockTokenString, undefined)).to.be.true; + }); + + it('should pass VerifyPhoneNumberTokenOptions to the internal verifier', async () => { + verifyJwtStub.resolves(mockDecodedToken); + + await phoneNumberVerificationService.verifyToken(mockTokenString, { acceptTestTokens: true }); + + expect(verifyJwtStub.calledOnce).to.be.true; + expect(verifyJwtStub.calledWith(mockTokenString, { acceptTestTokens: true })).to.be.true; + }); + + it('should throw given invalid VerifyPhoneNumberTokenOptions', () => { + [null, 100, -100, 'abc', [], true].forEach((invalidOptions) => { + expect(() => { + return phoneNumberVerificationService.verifyToken(mockTokenString, invalidOptions as any); + }).to.throw('VerifyPhoneNumberTokenOptions must be a non-null object.'); + }); }); it('should return the decoded token object on success', async () => { diff --git a/test/unit/phone-number-verification/token-verifier.spec.ts b/test/unit/phone-number-verification/token-verifier.spec.ts index 9fe68fe42b..d587407c45 100644 --- a/test/unit/phone-number-verification/token-verifier.spec.ts +++ b/test/unit/phone-number-verification/token-verifier.spec.ts @@ -306,7 +306,7 @@ describe('PhoneNumberTokenVerifier', () => { }); describe('Success', () => { - it('should return the token with phoneNumber property appended', async () => { + it('should return the token with phoneNumber and testNumber properties appended', async () => { decodeJwtStub.resolves({ header: VALID_HEADER, payload: VALID_PAYLOAD }); signatureVerifierStub.verify.resolves(); @@ -316,9 +316,38 @@ describe('PhoneNumberTokenVerifier', () => { expect(result.sub).to.equal(VALID_PAYLOAD.sub); expect(result.aud).to.deep.equal(VALID_PAYLOAD.aud); - // Check the property + // Check the properties expect(result).to.have.property('phoneNumber'); expect(result.phoneNumber).to.equal(VALID_PAYLOAD.sub); + expect(result.testNumber).to.be.false; + }); + }); + + describe('Test Token Handling', () => { + beforeEach(() => { + decodeJwtStub.resolves({ + header: VALID_HEADER, + payload: { ...VALID_PAYLOAD, test_number: true }, + }); + signatureVerifierStub.verify.resolves(); + }); + + it('should reject a test token by default with INVALID_TOKEN', async () => { + await expect(verifier.verifyJWT('test-token')) + .to.be.rejectedWith(/is a test token/) + .and.eventually.have.property('code', `${MOCK_FPNV_PREFIX}/${FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN}`); + + await expect(verifier.verifyJWT('test-token', { acceptTestTokens: false })) + .to.be.rejectedWith(/is a test token/) + .and.eventually.have.property('code', `${MOCK_FPNV_PREFIX}/${FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN}`); + }); + + it('should accept a test token when acceptTestTokens is true and populate testNumber', async () => { + const result = await verifier.verifyJWT('test-token', { acceptTestTokens: true }); + + expect(result.test_number).to.be.true; + expect(result.testNumber).to.be.true; + expect(result.phoneNumber).to.equal(VALID_PAYLOAD.sub); }); }); From 5243af30ad01d421201619be7b46afe650d7dc51 Mon Sep 17 00:00:00 2001 From: Micah Baker Date: Mon, 26 Oct 2026 20:46:54 +0000 Subject: [PATCH 2/2] fix(pnv): validate boolean acceptTestTokens option and use strict boolean check --- .../phone-number-verification.ts | 6 ++++++ src/phone-number-verification/token-verifier.ts | 2 +- .../phone-number-verification.spec.ts | 11 +++++++++++ .../phone-number-verification/token-verifier.spec.ts | 4 ++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/phone-number-verification/phone-number-verification.ts b/src/phone-number-verification/phone-number-verification.ts index 837aa0fa7f..310999c495 100644 --- a/src/phone-number-verification/phone-number-verification.ts +++ b/src/phone-number-verification/phone-number-verification.ts @@ -86,6 +86,12 @@ export class PhoneNumberVerification { message: 'VerifyPhoneNumberTokenOptions must be a non-null object.', }); } + if (typeof options.acceptTestTokens !== 'undefined' && !validator.isBoolean(options.acceptTestTokens)) { + throw new FirebasePhoneNumberVerificationError({ + code: FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + message: 'VerifyPhoneNumberTokenOptions.acceptTestTokens must be a boolean.', + }); + } } } diff --git a/src/phone-number-verification/token-verifier.ts b/src/phone-number-verification/token-verifier.ts index ab39dbc78a..3e79f47225 100644 --- a/src/phone-number-verification/token-verifier.ts +++ b/src/phone-number-verification/token-verifier.ts @@ -99,7 +99,7 @@ export class PhoneNumberTokenVerifier { const projectId = await this.ensureProjectId(); const decoded = await this.decodeAndVerify(jwtToken, projectId); const decodedIdToken = decoded.payload as PhoneNumberVerificationToken; - if (decodedIdToken.test_number === true && !options?.acceptTestTokens) { + if (decodedIdToken.test_number === true && options?.acceptTestTokens !== true) { throw new FirebasePhoneNumberVerificationError({ code: FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN, message: `${this.tokenInfo.jwtName} is a test token. To accept test tokens during ` + diff --git a/test/unit/phone-number-verification/phone-number-verification.spec.ts b/test/unit/phone-number-verification/phone-number-verification.spec.ts index 0cc05ab6f8..522f583454 100644 --- a/test/unit/phone-number-verification/phone-number-verification.spec.ts +++ b/test/unit/phone-number-verification/phone-number-verification.spec.ts @@ -94,6 +94,17 @@ describe('PhoneNumberVerification Service', () => { }); }); + it('should throw given non-boolean acceptTestTokens option', () => { + ['false', 'true', 0, 1, null, {}, []].forEach((invalidAcceptTestTokens) => { + expect(() => { + return phoneNumberVerificationService.verifyToken( + mockTokenString, + { acceptTestTokens: invalidAcceptTestTokens as any } + ); + }).to.throw('VerifyPhoneNumberTokenOptions.acceptTestTokens must be a boolean.'); + }); + }); + it('should return the decoded token object on success', async () => { verifyJwtStub.resolves(mockDecodedToken); diff --git a/test/unit/phone-number-verification/token-verifier.spec.ts b/test/unit/phone-number-verification/token-verifier.spec.ts index d587407c45..2620e65f16 100644 --- a/test/unit/phone-number-verification/token-verifier.spec.ts +++ b/test/unit/phone-number-verification/token-verifier.spec.ts @@ -340,6 +340,10 @@ describe('PhoneNumberTokenVerifier', () => { await expect(verifier.verifyJWT('test-token', { acceptTestTokens: false })) .to.be.rejectedWith(/is a test token/) .and.eventually.have.property('code', `${MOCK_FPNV_PREFIX}/${FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN}`); + + await expect(verifier.verifyJWT('test-token', { acceptTestTokens: 'false' as any })) + .to.be.rejectedWith(/is a test token/) + .and.eventually.have.property('code', `${MOCK_FPNV_PREFIX}/${FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN}`); }); it('should accept a test token when acceptTestTokens is true and populate testNumber', async () => {