diff --git a/etc/firebase-admin.phone-number-verification.api.md b/etc/firebase-admin.phone-number-verification.api.md index 256778f3fb..2db533d83b 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?: PhoneNumberVerificationTokenOptions): Promise; } // @public @@ -30,6 +30,7 @@ export const PhoneNumberVerificationErrorCode: { readonly INVALID_ARGUMENT: "invalid-argument"; readonly INVALID_TOKEN: "invalid-token"; readonly EXPIRED_TOKEN: "expired-token"; + readonly TEST_TOKEN_REJECTED: "test-token-rejected"; }; // @public @@ -46,6 +47,12 @@ export interface PhoneNumberVerificationToken { nonce: string; phoneNumber: string; sub: string; + testNumber: boolean; +} + +// @public +export interface PhoneNumberVerificationTokenOptions { + acceptTestTokens?: boolean; } ``` diff --git a/src/phone-number-verification/error.ts b/src/phone-number-verification/error.ts index 168c7bd055..95962c1898 100644 --- a/src/phone-number-verification/error.ts +++ b/src/phone-number-verification/error.ts @@ -23,6 +23,7 @@ export const PhoneNumberVerificationErrorCode = { INVALID_ARGUMENT: 'invalid-argument', INVALID_TOKEN: 'invalid-token', EXPIRED_TOKEN: 'expired-token', + TEST_TOKEN_REJECTED: 'test-token-rejected', } as const; /** diff --git a/src/phone-number-verification/index.ts b/src/phone-number-verification/index.ts index f27db5700c..963021b44f 100644 --- a/src/phone-number-verification/index.ts +++ b/src/phone-number-verification/index.ts @@ -31,7 +31,8 @@ export { export { PhoneNumberVerificationToken, -} from './phone-number-verification-api' + PhoneNumberVerificationTokenOptions, +} from './phone-number-verification-api'; /** * Gets the {@link PhoneNumberVerification} service for the default app or a diff --git a/src/phone-number-verification/phone-number-verification-api.ts b/src/phone-number-verification/phone-number-verification-api.ts index a3527d78d6..cb18284cd9 100644 --- a/src/phone-number-verification/phone-number-verification-api.ts +++ b/src/phone-number-verification/phone-number-verification-api.ts @@ -73,12 +73,34 @@ export interface PhoneNumberVerificationToken { */ phoneNumber: string; + /** + * 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 `"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 PhoneNumberVerificationTokenOptions { + /** + * 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..893c4d63eb 100644 --- a/src/phone-number-verification/phone-number-verification.ts +++ b/src/phone-number-verification/phone-number-verification.ts @@ -16,7 +16,10 @@ */ import { App } from '../app'; -import { PhoneNumberVerificationToken } from './phone-number-verification-api'; +import { + PhoneNumberVerificationToken, + PhoneNumberVerificationTokenOptions, +} from './phone-number-verification-api'; import { PhoneNumberTokenVerifier } from './token-verifier'; import { JWKS_URL, FPNV_TOKEN_INFO } from './phone-number-verification-api-client-internal'; @@ -53,13 +56,20 @@ 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 PhoneNumberVerificationTokenOptions} 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?: PhoneNumberVerificationTokenOptions + ): Promise { + return this.phoneNumberVerificationVerifier.verifyJWT(jwt, options); } } diff --git a/src/phone-number-verification/token-verifier.ts b/src/phone-number-verification/token-verifier.ts index 3eda268599..771f1330e4 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, + PhoneNumberVerificationTokenOptions, +} from './phone-number-verification-api'; import * as util from '../utils/index'; import * as validator from '../utils/validator'; import { @@ -81,21 +85,53 @@ export class PhoneNumberTokenVerifier { // Project ID is validated in the verification call. } - public async verifyJWT(jwtToken: string): Promise { + public async verifyJWT( + jwtToken: string, + options?: PhoneNumberVerificationTokenOptions + ): Promise { if (!validator.isString(jwtToken)) { throw new FirebasePhoneNumberVerificationError({ code: FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN, message: `First argument to ${this.tokenInfo.verifyApiName} must be a string.`, }); } + this.validatePhoneNumberVerificationTokenOptions(options); 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 !== true) { + throw new FirebasePhoneNumberVerificationError({ + code: FPNV_ERROR_CODE_MAPPING.TEST_TOKEN_REJECTED, + 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 = decodedIdToken.test_number === true; return decodedIdToken; } + private validatePhoneNumberVerificationTokenOptions( + options?: PhoneNumberVerificationTokenOptions + ): void { + if (typeof options === 'undefined') { + return; + } + if (!validator.isNonNullObject(options)) { + throw new FirebasePhoneNumberVerificationError({ + code: FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + message: 'PhoneNumberVerificationTokenOptions 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: 'PhoneNumberVerificationTokenOptions.acceptTestTokens must be a boolean.', + }); + } + } + private async ensureProjectId(): Promise { const projectId = await util.findProjectId(this.app); diff --git a/test/unit/phone-number-verification/index.spec.ts b/test/unit/phone-number-verification/index.spec.ts index 2911810987..9ed6d87d0d 100644 --- a/test/unit/phone-number-verification/index.spec.ts +++ b/test/unit/phone-number-verification/index.spec.ts @@ -34,9 +34,16 @@ chai.use(chaiAsPromised); const expect = chai.expect; describe('Module export', () => { - it('should export PhoneNumberVerification class', () => { + it('should export PhoneNumberVerification class and error types', () => { expect(pnvIndex.PhoneNumberVerification).to.exist; expect(pnvIndex.getPhoneNumberVerification).to.be.a('function'); + expect(pnvIndex.FirebasePhoneNumberVerificationError).to.be.a('function'); + expect(pnvIndex.PhoneNumberVerificationErrorCode).to.deep.equal({ + INVALID_ARGUMENT: 'invalid-argument', + INVALID_TOKEN: 'invalid-token', + EXPIRED_TOKEN: 'expired-token', + TEST_TOKEN_REJECTED: 'test-token-rejected', + }); }); }); diff --git a/test/unit/phone-number-verification/phone-number-verification-api-client-internal.spec.ts b/test/unit/phone-number-verification/phone-number-verification-api-client-internal.spec.ts index dd9b88f8c0..2413c9ac12 100644 --- a/test/unit/phone-number-verification/phone-number-verification-api-client-internal.spec.ts +++ b/test/unit/phone-number-verification/phone-number-verification-api-client-internal.spec.ts @@ -50,6 +50,7 @@ describe('FPNV Constants and Error Class', () => { INVALID_ARGUMENT: 'invalid-argument', INVALID_TOKEN: 'invalid-token', EXPIRED_TOKEN: 'expired-token', + TEST_TOKEN_REJECTED: 'test-token-rejected', }); }); }); @@ -74,6 +75,18 @@ describe('FPNV Constants and Error Class', () => { expect(error.message).to.equal(testMessage); }); + it('should match error codes using hasCode()', () => { + const error = new FirebasePhoneNumberVerificationError({ + code: FPNV_ERROR_CODE_MAPPING.TEST_TOKEN_REJECTED, + message: 'Test token rejected.', + }); + + expect(error.code).to.equal(`${FPNV_PREFIX}/${FPNV_ERROR_CODE_MAPPING.TEST_TOKEN_REJECTED}`); + expect(error.hasCode(FPNV_ERROR_CODE_MAPPING.TEST_TOKEN_REJECTED)).to.be.true; + expect(error.hasCode(`${FPNV_PREFIX}/${FPNV_ERROR_CODE_MAPPING.TEST_TOKEN_REJECTED}`)).to.be.true; + expect(error.hasCode(FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN)).to.be.false; + }); + it('should handle all defined error codes', () => { const codes = Object.values(FPNV_ERROR_CODE_MAPPING); 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..33336f503b 100644 --- a/test/unit/phone-number-verification/phone-number-verification.spec.ts +++ b/test/unit/phone-number-verification/phone-number-verification.spec.ts @@ -17,14 +17,22 @@ 'use strict'; -import { expect } from 'chai'; +import * as chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; import * as sinon from 'sinon'; import { App } from '../../../src/app/index'; import * as mocks from '../../resources/mocks'; import { PhoneNumberVerification } from '../../../src/phone-number-verification/phone-number-verification'; import { PhoneNumberTokenVerifier } from '../../../src/phone-number-verification/token-verifier'; -import { PhoneNumberVerificationToken } from '../../../src/phone-number-verification/phone-number-verification-api'; +import { + FirebasePhoneNumberVerificationError, + PhoneNumberVerificationToken, + PhoneNumberVerificationTokenOptions, +} from '../../../src/phone-number-verification/phone-number-verification-api'; + +chai.use(chaiAsPromised); +const expect = chai.expect; describe('PhoneNumberVerification Service', () => { let phoneNumberVerificationService: PhoneNumberVerification; @@ -66,6 +74,7 @@ describe('PhoneNumberVerification Service', () => { jti: 'unique-token-id-123', nonce: 'random-nonce-string', phoneNumber: '+15555550100', + testNumber: false, }; it('should call the internal verifier with the provided JWT string', async () => { @@ -74,7 +83,60 @@ 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 PhoneNumberVerificationTokenOptions to the internal verifier', async () => { + verifyJwtStub.resolves(mockDecodedToken); + const options: PhoneNumberVerificationTokenOptions = { acceptTestTokens: true }; + + await phoneNumberVerificationService.verifyToken(mockTokenString, options); + + expect(verifyJwtStub.calledOnce).to.be.true; + expect(verifyJwtStub.calledWith(mockTokenString, options)).to.be.true; + }); + + it('should reject given invalid PhoneNumberVerificationTokenOptions', async () => { + verifyJwtStub.restore(); + for (const invalidOptions of [null, 100, -100, 'abc', [], true]) { + await expect( + phoneNumberVerificationService.verifyToken(mockTokenString, invalidOptions as any) + ) + .to.be.rejectedWith( + FirebasePhoneNumberVerificationError, + 'PhoneNumberVerificationTokenOptions must be a non-null object.' + ) + .and.eventually.have.property('code', 'phone-number-verification/invalid-argument'); + } + }); + + it('should reject given non-boolean acceptTestTokens option', async () => { + verifyJwtStub.restore(); + for (const invalidAcceptTestTokens of ['false', 'true', 0, 1, null, {}, []]) { + await expect( + phoneNumberVerificationService.verifyToken( + mockTokenString, + { acceptTestTokens: invalidAcceptTestTokens as any } + ) + ) + .to.be.rejectedWith( + FirebasePhoneNumberVerificationError, + 'PhoneNumberVerificationTokenOptions.acceptTestTokens must be a boolean.' + ) + .and.eventually.have.property('code', 'phone-number-verification/invalid-argument'); + } + }); + + it('should validate jwt before options and reject on the promise path', async () => { + verifyJwtStub.restore(); + await expect( + phoneNumberVerificationService.verifyToken(123 as any, null as any) + ) + .to.be.rejectedWith( + FirebasePhoneNumberVerificationError, + 'First argument to verifyToken() must be a string.' + ) + .and.eventually.have.property('code', 'phone-number-verification/invalid-token'); }); 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..101be0ab29 100644 --- a/test/unit/phone-number-verification/token-verifier.spec.ts +++ b/test/unit/phone-number-verification/token-verifier.spec.ts @@ -28,7 +28,11 @@ import { PhoneNumberTokenVerifier } from '../../../src/phone-number-verification import { FirebasePhoneNumberTokenInfo, } from '../../../src/phone-number-verification/phone-number-verification-api-client-internal'; -import { FPNV_ERROR_CODE_MAPPING } from '../../../src/phone-number-verification/error'; +import { + FirebasePhoneNumberVerificationError, + FPNV_ERROR_CODE_MAPPING, + PhoneNumberVerificationErrorCode, +} from '../../../src/phone-number-verification/error'; import * as mocks from '../../resources/mocks'; chai.use(chaiAsPromised); @@ -160,6 +164,44 @@ describe('PhoneNumberTokenVerifier', () => { .to.be.rejectedWith('First argument to verifyToken() must be a string.'); }); + it('should validate jwtToken before options', async () => { + await expect(verifier.verifyJWT(123 as any, null as any)) + .to.be.rejectedWith( + FirebasePhoneNumberVerificationError, + 'First argument to verifyToken() must be a string.' + ) + .and.eventually.have.property( + 'code', + `${MOCK_FPNV_PREFIX}/${FPNV_ERROR_CODE_MAPPING.INVALID_TOKEN}` + ); + }); + + it('should reject with INVALID_ARGUMENT given invalid PhoneNumberVerificationTokenOptions', async () => { + const expectedCode = `${MOCK_FPNV_PREFIX}/${FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT}`; + for (const invalidOptions of [null, 100, -100, 'abc', [], true]) { + await expect(verifier.verifyJWT('token', invalidOptions as any)) + .to.be.rejectedWith( + FirebasePhoneNumberVerificationError, + 'PhoneNumberVerificationTokenOptions must be a non-null object.' + ) + .and.eventually.have.property('code', expectedCode); + } + }); + + it('should reject with INVALID_ARGUMENT given non-boolean acceptTestTokens option', async () => { + const expectedCode = `${MOCK_FPNV_PREFIX}/${FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT}`; + for (const invalidAcceptTestTokens of ['false', 'true', 0, 1, null, {}, []]) { + await expect( + verifier.verifyJWT('token', { acceptTestTokens: invalidAcceptTestTokens as any }) + ) + .to.be.rejectedWith( + FirebasePhoneNumberVerificationError, + 'PhoneNumberVerificationTokenOptions.acceptTestTokens must be a boolean.' + ) + .and.eventually.have.property('code', expectedCode); + } + }); + it('should throw if project ID cannot be determined', async () => { findProjectIdStub.resolves(null); await expect(verifier.verifyJWT('token')) @@ -306,7 +348,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 +358,62 @@ 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; + }); + + it('should set testNumber to false when test_number claim is false or non-boolean', async () => { + decodeJwtStub.resolves({ + header: VALID_HEADER, + payload: { ...VALID_PAYLOAD, test_number: 'false' }, + }); + signatureVerifierStub.verify.resolves(); + + const result = await verifier.verifyJWT('valid-token'); + + 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 TEST_TOKEN_REJECTED', async () => { + const expectedCode = `${MOCK_FPNV_PREFIX}/${FPNV_ERROR_CODE_MAPPING.TEST_TOKEN_REJECTED}`; + const defaultRejectedError = await expect(verifier.verifyJWT('test-token')) + .to.be.rejectedWith(FirebasePhoneNumberVerificationError, /is a test token/); + expect(defaultRejectedError.code).to.equal(expectedCode); + expect(defaultRejectedError.hasCode(PhoneNumberVerificationErrorCode.TEST_TOKEN_REJECTED)).to.be.true; + + await expect(verifier.verifyJWT('test-token', { acceptTestTokens: false })) + .to.be.rejectedWith(FirebasePhoneNumberVerificationError, /is a test token/) + .and.eventually.have.property('code', expectedCode); + + await expect(verifier.verifyJWT('test-token', { acceptTestTokens: 'false' as any })) + .to.be.rejectedWith( + FirebasePhoneNumberVerificationError, + 'PhoneNumberVerificationTokenOptions.acceptTestTokens must be a boolean.' + ) + .and.eventually.have.property( + 'code', + `${MOCK_FPNV_PREFIX}/${FPNV_ERROR_CODE_MAPPING.INVALID_ARGUMENT}` + ); + }); + + 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); }); });