Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion etc/firebase-admin.phone-number-verification.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ export function getPhoneNumberVerification(app?: App): PhoneNumberVerification;
// @public
export class PhoneNumberVerification {
get app(): App;
verifyToken(jwt: string): Promise<PhoneNumberVerificationToken>;
verifyToken(jwt: string, options?: PhoneNumberVerificationTokenOptions): Promise<PhoneNumberVerificationToken>;
}

// @public
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
Expand All @@ -46,6 +47,12 @@ export interface PhoneNumberVerificationToken {
nonce: string;
phoneNumber: string;
sub: string;
testNumber: boolean;
}

// @public
export interface PhoneNumberVerificationTokenOptions {
acceptTestTokens?: boolean;
}

```
1 change: 1 addition & 0 deletions src/phone-number-verification/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
3 changes: 2 additions & 1 deletion src/phone-number-verification/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/phone-number-verification/phone-number-verification-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +78 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is a test_number claim then isn't this basically one of the JWT token claims? I don't know that the caveat is helpful in this case

*/
testNumber: boolean;

/**
* Other arbitrary claims included in the token.
*/
[key: string]: any;
}

/**
* Interface representing options for the {@link PhoneNumberVerification.verifyToken} method.
*/
export interface PhoneNumberVerificationTokenOptions {

@lahirumaramba lahirumaramba Nov 6, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we get a TW review on the new docstrings?

super nit: I know the API proposal settled on PhoneNumberVerificationTokenOptions, but we could also consider VerifyPhoneNumberTokenOptions to mirror VerifyAppCheckTokenOptions if we really want to :)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asked @kevinthecheung to do a TW pass on the new TSDoc comments.

/**
* 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.
Comment on lines +95 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* 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.
* Whether to accept test-mode tokens (tokens containing the claim `"test_number": true`). By default, {@link PhoneNumberVerification.verifyToken}
* rejects test-mode tokens.

*
* Enable this option only during development and testing.
*/
acceptTestTokens?: boolean;
}

export {
PhoneNumberVerificationErrorCode,
FirebasePhoneNumberVerificationError,
Expand Down
18 changes: 14 additions & 4 deletions src/phone-number-verification/phone-number-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Comment on lines +64 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @param options - Optional {@link PhoneNumberVerificationTokenOptions} object when verifying a
* Phone Number Verification token.
* @param options - Optional {@link PhoneNumberVerificationTokenOptions} object.

* @returns A promise that resolves with the decoded token.
*/
public verifyToken(jwt: string): Promise<PhoneNumberVerificationToken> {
return this.phoneNumberVerificationVerifier.verifyJWT(jwt);
public verifyToken(
jwt: string,
options?: PhoneNumberVerificationTokenOptions
): Promise<PhoneNumberVerificationToken> {
return this.phoneNumberVerificationVerifier.verifyJWT(jwt, options);
}
}

40 changes: 38 additions & 2 deletions src/phone-number-verification/token-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -81,21 +85,53 @@ export class PhoneNumberTokenVerifier {
// Project ID is validated in the verification call.
}

public async verifyJWT(jwtToken: string): Promise<PhoneNumberVerificationToken> {
public async verifyJWT(
jwtToken: string,
options?: PhoneNumberVerificationTokenOptions
): Promise<PhoneNumberVerificationToken> {
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<string> {
const projectId = await util.findProjectId(this.app);
Expand Down
9 changes: 8 additions & 1 deletion test/unit/phone-number-verification/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});
});
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
Loading
Loading