Skip to content

Commit 0af1c9f

Browse files
Implements phone auth support for Admin node.js SDK. (#58)
* Implements phone auth support for Admin node.js SDK. Adds phoneNumber to UserRecord and UserInfo. Implements new API for looking up users by phone number: getUserByPhoneNumber Adds the ability to pass the phoneNumber property when creating a new user, or updating an existing one via createUser and updateUser. This includes the ability to delete the phone number for a specified user. Defines the new related auth errors: auth/invalid-phone-number and auth/phone-number-already-exists. * Adds integration tests for phone Auth APIs. In addition, adds some basic checks on the expected email/phone number on update and create operations.
1 parent ab70ce1 commit 0af1c9f

File tree

11 files changed

+619
-11
lines changed

11 files changed

+619
-11
lines changed

src/auth/auth-api-request.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ function validateCreateEditRequest(request: any) {
6060
disabled: true,
6161
disableUser: true,
6262
deleteAttribute: true,
63+
deleteProvider: true,
6364
sanityCheck: true,
65+
phoneNumber: true,
6466
};
6567
// Remove invalid keys from original request.
6668
for (let key in request) {
@@ -83,6 +85,11 @@ function validateCreateEditRequest(request: any) {
8385
if (typeof request.email !== 'undefined' && !validator.isEmail(request.email)) {
8486
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL);
8587
}
88+
// phoneNumber should be a string and a valid phone number.
89+
if (typeof request.phoneNumber !== 'undefined' &&
90+
!validator.isPhoneNumber(request.phoneNumber)) {
91+
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER);
92+
}
8693
// password should be a string and a minimum of 6 chars.
8794
if (typeof request.password !== 'undefined' &&
8895
!validator.isPassword(request.password)) {
@@ -126,7 +133,7 @@ function validateCreateEditRequest(request: any) {
126133
export const FIREBASE_AUTH_GET_ACCOUNT_INFO = new ApiSettings('getAccountInfo', 'POST')
127134
// Set request validator.
128135
.setRequestValidator((request: any) => {
129-
if (!request.localId && !request.email) {
136+
if (!request.localId && !request.email && !request.phoneNumber) {
130137
throw new FirebaseAuthError(
131138
AuthClientErrorCode.INTERNAL_ERROR,
132139
'INTERNAL ASSERT FAILED: Server request is missing user identifier');
@@ -217,7 +224,7 @@ export class FirebaseAuthRequestHandler {
217224
}
218225

219226
/**
220-
* Looks a user by uid.
227+
* Looks up a user by uid.
221228
*
222229
* @param {string} uid The uid of the user to lookup.
223230
* @return {Promise<Object>} A promise that resolves with the user information.
@@ -234,7 +241,7 @@ export class FirebaseAuthRequestHandler {
234241
}
235242

236243
/**
237-
* Looks a user by email.
244+
* Looks up a user by email.
238245
*
239246
* @param {string} email The email of the user to lookup.
240247
* @return {Promise<Object>} A promise that resolves with the user information.
@@ -250,6 +257,24 @@ export class FirebaseAuthRequestHandler {
250257
return this.invokeRequestHandler(FIREBASE_AUTH_GET_ACCOUNT_INFO, request);
251258
}
252259

260+
/**
261+
* Looks up a user by phone number.
262+
*
263+
* @param {string} phoneNumber The phone number of the user to lookup.
264+
* @return {Promise<Object>} A promise that resolves with the user information.
265+
*/
266+
public getAccountInfoByPhoneNumber(phoneNumber: string): Promise<Object> {
267+
if (!validator.isPhoneNumber(phoneNumber)) {
268+
return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER));
269+
}
270+
271+
const request = {
272+
phoneNumber: [phoneNumber],
273+
};
274+
return this.invokeRequestHandler(FIREBASE_AUTH_GET_ACCOUNT_INFO, request);
275+
}
276+
277+
253278
/**
254279
* Deletes an account identified by a uid.
255280
*
@@ -314,6 +339,20 @@ export class FirebaseAuthRequestHandler {
314339
if (request.deleteAttribute.length === 0) {
315340
delete request.deleteAttribute;
316341
}
342+
343+
// For deleting phoneNumber, this value must be passed as null.
344+
// It will be removed from the backend request and an additional parameter
345+
// deleteProvider: ['phone'] with an array of providerIds (phone in this case),
346+
// will be passed.
347+
// Currently this applies to phone provider only.
348+
if (request.phoneNumber === null) {
349+
request.deleteProvider = ['phone'];
350+
delete request.phoneNumber;
351+
} else {
352+
// Doesn't apply to other providers in admin SDK.
353+
delete request.deleteProvider;
354+
}
355+
317356
// Rewrite photoURL to photoUrl.
318357
if (typeof request.photoURL !== 'undefined') {
319358
request.photoUrl = request.photoURL;

src/auth/auth.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,21 @@ class Auth implements FirebaseServiceInterface {
163163
});
164164
};
165165

166+
/**
167+
* Looks up the user identified by the provided phone number and returns a promise that is
168+
* fulfilled with a user record for the given user if that user is found.
169+
*
170+
* @param {string} phoneNumber The phone number of the user to look up.
171+
* @return {Promise<UserRecord>} A promise that resolves with the corresponding user record.
172+
*/
173+
public getUserByPhoneNumber(phoneNumber: string): Promise<UserRecord> {
174+
return this.authRequestHandler.getAccountInfoByPhoneNumber(phoneNumber)
175+
.then((response: any) => {
176+
// Returns the user record populated with server response.
177+
return new UserRecord(response.users[0]);
178+
});
179+
};
180+
166181
/**
167182
* Creates a new user with the properties provided.
168183
*

src/auth/user-record.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export class UserInfo {
8080
public readonly email: string;
8181
public readonly photoURL: string;
8282
public readonly providerId: string;
83+
public readonly phoneNumber: string;
8384

8485
constructor(response: any) {
8586
// Provider user id and provider id are required.
@@ -94,6 +95,7 @@ export class UserInfo {
9495
utils.addReadonlyGetter(this, 'email', response.email);
9596
utils.addReadonlyGetter(this, 'photoURL', response.photoUrl);
9697
utils.addReadonlyGetter(this, 'providerId', response.providerId);
98+
utils.addReadonlyGetter(this, 'phoneNumber', response.phoneNumber);
9799
}
98100

99101
/** @return {Object} The plain object representation of the current provider data. */
@@ -104,6 +106,7 @@ export class UserInfo {
104106
email: this.email,
105107
photoURL: this.photoURL,
106108
providerId: this.providerId,
109+
phoneNumber: this.phoneNumber,
107110
};
108111
}
109112
}
@@ -122,6 +125,7 @@ export class UserRecord {
122125
public readonly emailVerified: boolean;
123126
public readonly displayName: string;
124127
public readonly photoURL: string;
128+
public readonly phoneNumber: string;
125129
public readonly disabled: boolean;
126130
public readonly metadata: UserMetadata;
127131
public readonly providerData: UserInfo[];
@@ -139,6 +143,7 @@ export class UserRecord {
139143
utils.addReadonlyGetter(this, 'emailVerified', !!response.emailVerified);
140144
utils.addReadonlyGetter(this, 'displayName', response.displayName);
141145
utils.addReadonlyGetter(this, 'photoURL', response.photoUrl);
146+
utils.addReadonlyGetter(this, 'phoneNumber', response.phoneNumber);
142147
// If disabled is not provided, the account is enabled by default.
143148
utils.addReadonlyGetter(this, 'disabled', response.disabled || false);
144149
utils.addReadonlyGetter(this, 'metadata', new UserMetadata(response));
@@ -157,6 +162,7 @@ export class UserRecord {
157162
emailVerified: this.emailVerified,
158163
displayName: this.displayName,
159164
photoURL: this.photoURL,
165+
phoneNumber: this.phoneNumber,
160166
disabled: this.disabled,
161167
// Convert metadata to json.
162168
metadata: this.metadata.toJSON(),

src/index.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ declare namespace admin.auth {
8080
uid: string;
8181
displayName: string;
8282
email: string;
83+
phoneNumber: string;
8384
photoURL: string;
8485
providerId: string;
8586

@@ -91,6 +92,7 @@ declare namespace admin.auth {
9192
email: string;
9293
emailVerified: boolean;
9394
displayName: string;
95+
phoneNumber: string;
9496
photoURL: string;
9597
disabled: boolean;
9698
metadata: admin.auth.UserMetadata;
@@ -125,6 +127,7 @@ declare namespace admin.auth {
125127
deleteUser(uid: string): Promise<void>;
126128
getUser(uid: string): Promise<admin.auth.UserRecord>;
127129
getUserByEmail(email: string): Promise<admin.auth.UserRecord>;
130+
getUserByPhoneNumber(phoneNumber: string): Promise<admin.auth.UserRecord>;
128131
updateUser(uid: string, properties: Object): Promise<admin.auth.UserRecord>;
129132
verifyIdToken(idToken: string): Promise<admin.auth.DecodedIdToken>;
130133
}

src/utils/error.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,11 @@ export class AuthClientErrorCode {
235235
code: 'invalid-password',
236236
message: 'The password must be a string with at least 6 characters.',
237237
};
238+
public static INVALID_PHONE_NUMBER = {
239+
code: 'invalid-phone-number',
240+
message: 'The phone number must be a non-empty E.164 standard compliant identifier ' +
241+
'string.',
242+
};
238243
public static INVALID_PHOTO_URL = {
239244
code: 'invalid-photo-url',
240245
message: 'The photoURL field must be a valid URL.',
@@ -253,6 +258,10 @@ export class AuthClientErrorCode {
253258
'Enable it in the Firebase console, under the sign-in method tab of the ' +
254259
'Auth section.',
255260
};
261+
public static PHONE_NUMBER_ALREADY_EXISTS = {
262+
code: 'phone-number-already-exists',
263+
message: 'The user with the provided phone number already exists.',
264+
};
256265
public static PROJECT_NOT_FOUND = {
257266
code: 'project-not-found',
258267
message: 'No Firebase project was found for the provided credential.',
@@ -382,6 +391,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = {
382391
EMAIL_EXISTS: 'EMAIL_ALREADY_EXISTS',
383392
// Invalid email provided.
384393
INVALID_EMAIL: 'INVALID_EMAIL',
394+
// Invalid phone number.
395+
INVALID_PHONE_NUMBER: 'INVALID_PHONE_NUMBER',
385396
// Invalid service account.
386397
INVALID_SERVICE_ACCOUNT: 'INVALID_SERVICE_ACCOUNT',
387398
// No localId provided (deleteAccount missing localId).
@@ -390,6 +401,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = {
390401
MISSING_USER_ACCOUNT: 'MISSING_UID',
391402
// Password auth disabled in console.
392403
OPERATION_NOT_ALLOWED: 'OPERATION_NOT_ALLOWED',
404+
// Phone number already exists.
405+
PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_ALREADY_EXISTS',
393406
// Project not found.
394407
PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND',
395408
// User on which action is to be performed is not found.

src/utils/validator.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,27 @@ export function isEmail(email: any): boolean {
131131
}
132132

133133

134+
/**
135+
* Validates that a string is a valid phone number.
136+
*
137+
* @param {any} phoneNumber The string to validate.
138+
* @return {boolean} Whether the string is a valid phone number or not.
139+
*/
140+
export function isPhoneNumber(phoneNumber: any): boolean {
141+
if (typeof phoneNumber !== 'string') {
142+
return false;
143+
}
144+
// Phone number validation is very lax here. Backend will enforce E.164
145+
// spec compliance and will normalize accordingly.
146+
// The phone number string must be non-empty and starts with a plus sign.
147+
let re1 = /^\+/;
148+
// The phone number string must contain at least one alphanumeric character.
149+
let re2 = /[\da-zA-Z]+/;
150+
return re1.test(phoneNumber) && re2.test(phoneNumber);
151+
}
152+
153+
154+
134155
/**
135156
* Validates that a string is a valid web URL.
136157
*

0 commit comments

Comments
 (0)