Skip to content

Commit 9a6c8fc

Browse files
feat(auth): implements auth.setCustomUserClaims and auth.listUsers. (#86)
* feat(auth): implements auth.setCustomUserClaims and auth.listUsers. Adds all relevant unit tests and integration tests. * Applied various fixes based on PR review feedback. * Improves error message for custom claims argument error.
1 parent 3e6e396 commit 9a6c8fc

11 files changed

+1191
-7
lines changed

src/auth/auth-api-request.ts

+148
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ const FIREBASE_AUTH_HEADER = {
4141
const FIREBASE_AUTH_TIMEOUT = 10000;
4242

4343

44+
/** List of reserved claims which cannot be provided when creating a custom token. */
45+
export const RESERVED_CLAIMS = [
46+
'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', 'exp', 'iat',
47+
'iss', 'jti', 'nbf', 'nonce', 'sub', 'firebase',
48+
];
49+
50+
/** Maximum allowed number of characters in the custom claims payload. */
51+
const MAX_CLAIMS_PAYLOAD_SIZE = 1000;
52+
53+
/** Maximum allowed number of users to batch download at one time. */
54+
const MAX_DOWNLOAD_ACCOUNT_PAGE_SIZE = 1000;
55+
56+
4457
/**
4558
* Validates a create/edit request object. All unsupported parameters
4659
* are removed from the original request. If an invalid field is passed
@@ -64,6 +77,7 @@ function validateCreateEditRequest(request: any) {
6477
deleteProvider: true,
6578
sanityCheck: true,
6679
phoneNumber: true,
80+
customAttributes: true,
6781
};
6882
// Remove invalid keys from original request.
6983
for (let key in request) {
@@ -127,9 +141,67 @@ function validateCreateEditRequest(request: any) {
127141
// disabled externally. So the error message should use the client facing name.
128142
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_DISABLED_FIELD);
129143
}
144+
// customAttributes should be stringified JSON with no blacklisted claims.
145+
// The payload should not exceed 1KB.
146+
if (typeof request.customAttributes !== 'undefined') {
147+
let developerClaims;
148+
try {
149+
developerClaims = JSON.parse(request.customAttributes);
150+
} catch (error) {
151+
// JSON parsing error. This should never happen as we stringify the claims internally.
152+
// However, we still need to check since setAccountInfo via edit requests could pass
153+
// this field.
154+
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_CLAIMS, error.message);
155+
}
156+
const invalidClaims = [];
157+
// Check for any invalid claims.
158+
RESERVED_CLAIMS.forEach((blacklistedClaim) => {
159+
if (developerClaims.hasOwnProperty(blacklistedClaim)) {
160+
invalidClaims.push(blacklistedClaim);
161+
}
162+
});
163+
// Throw an error if an invalid claim is detected.
164+
if (invalidClaims.length > 0) {
165+
throw new FirebaseAuthError(
166+
AuthClientErrorCode.FORBIDDEN_CLAIM,
167+
invalidClaims.length > 1 ?
168+
`Developer claims "${invalidClaims.join('", "')}" are reserved and cannot be specified.` :
169+
`Developer claim "${invalidClaims[0]}" is reserved and cannot be specified.`,
170+
);
171+
}
172+
// Check claims payload does not exceed maxmimum size.
173+
if (request.customAttributes.length > MAX_CLAIMS_PAYLOAD_SIZE) {
174+
throw new FirebaseAuthError(
175+
AuthClientErrorCode.CLAIMS_TOO_LARGE,
176+
`Developer claims payload should not exceed ${MAX_CLAIMS_PAYLOAD_SIZE} characters.`,
177+
);
178+
}
179+
}
130180
};
131181

132182

183+
/** Instantiates the downloadAccount endpoint settings. */
184+
export const FIREBASE_AUTH_DOWNLOAD_ACCOUNT = new ApiSettings('downloadAccount', 'POST')
185+
// Set request validator.
186+
.setRequestValidator((request: any) => {
187+
// Validate next page token.
188+
if (typeof request.nextPageToken !== 'undefined' &&
189+
!validator.isNonEmptyString(request.nextPageToken)) {
190+
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PAGE_TOKEN);
191+
}
192+
// Validate max results.
193+
if (!validator.isNumber(request.maxResults) ||
194+
request.maxResults <= 0 ||
195+
request.maxResults > MAX_DOWNLOAD_ACCOUNT_PAGE_SIZE) {
196+
throw new FirebaseAuthError(
197+
AuthClientErrorCode.INVALID_ARGUMENT,
198+
`Required "maxResults" must be a positive non-zero number that does not exceed ` +
199+
`the allowed ${MAX_DOWNLOAD_ACCOUNT_PAGE_SIZE}.`
200+
);
201+
}
202+
});
203+
204+
133205
/** Instantiates the getAccountInfo endpoint settings. */
134206
export const FIREBASE_AUTH_GET_ACCOUNT_INFO = new ApiSettings('getAccountInfo', 'POST')
135207
// Set request validator.
@@ -185,6 +257,13 @@ export const FIREBASE_AUTH_SET_ACCOUNT_INFO = new ApiSettings('setAccountInfo',
185257
export const FIREBASE_AUTH_SIGN_UP_NEW_USER = new ApiSettings('signupNewUser', 'POST')
186258
// Set request validator.
187259
.setRequestValidator((request: any) => {
260+
// signupNewUser does not support customAttributes.
261+
if (typeof request.customAttributes !== 'undefined') {
262+
throw new FirebaseAuthError(
263+
AuthClientErrorCode.INVALID_ARGUMENT,
264+
`"customAttributes" cannot be set when creating a new user.`,
265+
);
266+
}
188267
validateCreateEditRequest(request);
189268
})
190269
// Set response validator.
@@ -275,6 +354,40 @@ export class FirebaseAuthRequestHandler {
275354
return this.invokeRequestHandler(FIREBASE_AUTH_GET_ACCOUNT_INFO, request);
276355
}
277356

357+
/**
358+
* Exports the users (single batch only) with a size of maxResults and starting from
359+
* the offset as specified by pageToken.
360+
*
361+
* @param {number=} maxResults The page size, 1000 if undefined. This is also the maximum
362+
* allowed limit.
363+
* @param {string=} pageToken The next page token. If not specified, returns users starting
364+
* without any offset. Users are returned in the order they were created from oldest to
365+
* newest, relative to the page token offset.
366+
* @return {Promise<Object>} A promise that resolves with the current batch of downloaded
367+
* users and the next page token if available. For the last page, an empty list of users
368+
* and no page token are returned.
369+
*/
370+
public downloadAccount(
371+
maxResults: number = MAX_DOWNLOAD_ACCOUNT_PAGE_SIZE,
372+
pageToken?: string): Promise<{users: Object[], nextPageToken?: string}> {
373+
// Construct request.
374+
const request = {
375+
maxResults,
376+
nextPageToken: pageToken,
377+
};
378+
// Remove next page token if not provided.
379+
if (typeof request.nextPageToken === 'undefined') {
380+
delete request.nextPageToken;
381+
}
382+
return this.invokeRequestHandler(FIREBASE_AUTH_DOWNLOAD_ACCOUNT, request)
383+
.then((response: any) => {
384+
// No more users available.
385+
if (!response.users) {
386+
response.users = [];
387+
}
388+
return response as {users: Object[], nextPageToken?: string};
389+
});
390+
}
278391

279392
/**
280393
* Deletes an account identified by a uid.
@@ -293,6 +406,41 @@ export class FirebaseAuthRequestHandler {
293406
return this.invokeRequestHandler(FIREBASE_AUTH_DELETE_ACCOUNT, request);
294407
}
295408

409+
/**
410+
* Sets additional developer claims on an existing user identified by provided UID.
411+
*
412+
* @param {string} uid The user to edit.
413+
* @param {Object} customUserClaims The developer claims to set.
414+
* @return {Promise<string>} A promise that resolves when the operation completes
415+
* with the user id that was edited.
416+
*/
417+
public setCustomUserClaims(uid: string, customUserClaims: Object): Promise<string> {
418+
// Validate user UID.
419+
if (!validator.isUid(uid)) {
420+
return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_UID));
421+
} else if (!validator.isObject(customUserClaims)) {
422+
return Promise.reject(
423+
new FirebaseAuthError(
424+
AuthClientErrorCode.INVALID_ARGUMENT,
425+
'CustomUserClaims argument must be an object or null.',
426+
),
427+
);
428+
}
429+
// Delete operation. Replace null with an empty object.
430+
if (customUserClaims === null) {
431+
customUserClaims = {};
432+
}
433+
// Construct custom user attribute editting request.
434+
let request: any = {
435+
localId: uid,
436+
customAttributes: JSON.stringify(customUserClaims),
437+
};
438+
return this.invokeRequestHandler(FIREBASE_AUTH_SET_ACCOUNT_INFO, request)
439+
.then((response: any) => {
440+
return response.localId as string;
441+
});
442+
}
443+
296444
/**
297445
* Edits an existing user.
298446
*

src/auth/auth.ts

+56
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export class AuthInternals implements FirebaseServiceInternalsInterface {
4141
}
4242

4343

44+
/** Response object for a listUsers operation. */
45+
export interface ListUsersResult {
46+
users: UserRecord[];
47+
pageToken?: string;
48+
}
49+
50+
4451
/**
4552
* Auth service bound to the provided app.
4653
*/
@@ -178,6 +185,40 @@ class Auth implements FirebaseServiceInterface {
178185
});
179186
};
180187

188+
/**
189+
* Exports a batch of user accounts. Batch size is determined by the maxResults argument.
190+
* Starting point of the batch is determined by the pageToken argument.
191+
*
192+
* @param {number=} maxResults The page size, 1000 if undefined. This is also the maximum
193+
* allowed limit.
194+
* @param {string=} pageToken The next page token. If not specified, returns users starting
195+
* without any offset.
196+
* @return {Promise<{users: UserRecord[], pageToken?: string}>} A promise that resolves with
197+
* the current batch of downloaded users and the next page token. For the last page, an
198+
* empty list of users and no page token are returned.
199+
*/
200+
public listUsers(maxResults?: number, pageToken?: string): Promise<ListUsersResult> {
201+
return this.authRequestHandler.downloadAccount(maxResults, pageToken)
202+
.then((response: any) => {
203+
// List of users to return.
204+
const users: UserRecord[] = [];
205+
// Convert each user response to a UserRecord.
206+
response.users.forEach((userResponse) => {
207+
users.push(new UserRecord(userResponse));
208+
});
209+
// Return list of user records and the next page token if available.
210+
let result = {
211+
users,
212+
pageToken: response.nextPageToken,
213+
};
214+
// Delete result.pageToken if undefined.
215+
if (typeof result.pageToken === 'undefined') {
216+
delete result.pageToken;
217+
}
218+
return result;
219+
});
220+
};
221+
181222
/**
182223
* Creates a new user with the properties provided.
183224
*
@@ -229,6 +270,21 @@ class Auth implements FirebaseServiceInterface {
229270
return this.getUser(existingUid);
230271
});
231272
};
273+
274+
/**
275+
* Sets additional developer claims on an existing user identified by the provided UID.
276+
*
277+
* @param {string} uid The user to edit.
278+
* @param {Object} customUserClaims The developer claims to set.
279+
* @return {Promise<void>} A promise that resolves when the operation completes
280+
* successfully.
281+
*/
282+
public setCustomUserClaims(uid: string, customUserClaims: Object): Promise<void> {
283+
return this.authRequestHandler.setCustomUserClaims(uid, customUserClaims)
284+
.then((existingUid) => {
285+
// Return nothing on success.
286+
});
287+
};
232288
};
233289

234290

src/auth/user-record.ts

+16
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17+
import {deepCopy} from '../utils/deep-copy';
1718
import * as utils from '../utils';
1819
import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error';
1920

@@ -144,6 +145,9 @@ export class UserRecord {
144145
public readonly disabled: boolean;
145146
public readonly metadata: UserMetadata;
146147
public readonly providerData: UserInfo[];
148+
public readonly passwordHash?: string;
149+
public readonly passwordSalt?: string;
150+
public readonly customClaims: Object;
147151

148152
constructor(response: any) {
149153
// The Firebase user id is required.
@@ -167,6 +171,15 @@ export class UserRecord {
167171
providerData.push(new UserInfo(entry));
168172
}
169173
utils.addReadonlyGetter(this, 'providerData', providerData);
174+
utils.addReadonlyGetter(this, 'passwordHash', response.passwordHash);
175+
utils.addReadonlyGetter(this, 'passwordSalt', response.salt);
176+
try {
177+
utils.addReadonlyGetter(
178+
this, 'customClaims', JSON.parse(response.customAttributes));
179+
} catch (e) {
180+
// Ignore error.
181+
utils.addReadonlyGetter(this, 'customClaims', undefined);
182+
}
170183
}
171184

172185
/** @return {Object} The plain object representation of the user record. */
@@ -181,6 +194,9 @@ export class UserRecord {
181194
disabled: this.disabled,
182195
// Convert metadata to json.
183196
metadata: this.metadata.toJSON(),
197+
passwordHash: this.passwordHash,
198+
passwordSalt: this.passwordSalt,
199+
customClaims: deepCopy(this.customClaims),
184200
};
185201
json.providerData = [];
186202
for (let entry of this.providerData) {

src/index.d.ts

+10
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ declare namespace admin.auth {
9999
disabled: boolean;
100100
metadata: admin.auth.UserMetadata;
101101
providerData: admin.auth.UserInfo[];
102+
passwordHash?: string;
103+
passwordSalt?: string;
104+
customClaims?: Object;
102105

103106
toJSON(): Object;
104107
}
@@ -135,6 +138,11 @@ declare namespace admin.auth {
135138
[key: string]: any;
136139
}
137140

141+
interface ListUsersResult {
142+
users: admin.auth.UserRecord[];
143+
pageToken?: string;
144+
}
145+
138146
interface Auth {
139147
app: admin.app.App;
140148

@@ -144,8 +152,10 @@ declare namespace admin.auth {
144152
getUser(uid: string): Promise<admin.auth.UserRecord>;
145153
getUserByEmail(email: string): Promise<admin.auth.UserRecord>;
146154
getUserByPhoneNumber(phoneNumber: string): Promise<admin.auth.UserRecord>;
155+
listUsers(maxResults?: number, pageToken?: string): Promise<admin.auth.ListUsersResult>;
147156
updateUser(uid: string, properties: admin.auth.UpdateRequest): Promise<admin.auth.UserRecord>;
148157
verifyIdToken(idToken: string): Promise<admin.auth.DecodedIdToken>;
158+
setCustomUserClaims(uid: string, customUserClaims: Object): Promise<void>;
149159
}
150160
}
151161

0 commit comments

Comments
 (0)