diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index 3723abd051..012d889321 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -516,6 +516,7 @@ export interface UpdateProjectConfigRequest { // @public export interface UpdateRequest { + customUserClaims?: object | null; disabled?: boolean; displayName?: string | null; email?: string; diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 9fd535777c..ef141c9d96 100644 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -1349,7 +1349,7 @@ export abstract class AbstractAuthRequestHandler { if (customUserClaims === null) { customUserClaims = {}; } - // Construct custom user attribute editting request. + // Construct custom user attribute editing request. const request: any = { localId: uid, customAttributes: JSON.stringify(customUserClaims), @@ -1405,6 +1405,14 @@ export abstract class AbstractAuthRequestHandler { 'providersToUnlink of properties argument must be an array of strings.'); } }); + } else if ((typeof properties.customUserClaims !== 'undefined') + && !validator.isObject(properties.customUserClaims)) { + return Promise.reject( + new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'customUserClaims of properties argument must be an object, null, or undefined.', + ), + ); } // Build the setAccountInfo request. @@ -1470,6 +1478,16 @@ export abstract class AbstractAuthRequestHandler { request.disableUser = request.disabled; delete request.disabled; } + // Rewrite customClaims to customAttributes + if (typeof request.customUserClaims !== 'undefined') { + if (request.customUserClaims === null) { + // Delete operation. Replace null with an empty object. + request.customAttributes = JSON.stringify({}); + } else { + request.customAttributes = JSON.stringify(request.customUserClaims); + } + delete request.customUserClaims; + } // Construct mfa related user data. if (validator.isNonNullObject(request.multiFactor)) { if (request.multiFactor.enrolledFactors === null) { diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 28ee595c46..1ce8b3a453 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -124,10 +124,9 @@ export interface MultiFactorUpdateSettings { } /** - * Interface representing the properties to update on the provided user. + * Interface representing the base properties for `CreateRequest` and `UpdateRequest`. */ -export interface UpdateRequest { - +export interface BaseCreateUpdateUserRequest { /** * Whether or not the user is disabled: `true` for disabled; * `false` for enabled. @@ -163,6 +162,12 @@ export interface UpdateRequest { * The user's photo URL. */ photoURL?: string | null; +} + +/** + * Interface representing the properties to update on the provided user. + */ +export interface UpdateRequest extends BaseCreateUpdateUserRequest { /** * The user's updated multi-factor related properties. @@ -188,6 +193,13 @@ export interface UpdateRequest { * Unlinks this user from the specified providers. */ providersToUnlink?: string[]; + + /** + * If provided, sets additional developer claims on the user's token, overwriting + * any existing claims. Providing `null` will clear any existing custom claims. + * If not provided or `undefined`, then existing claims will remain unchanged. + */ + customUserClaims?: object | null; } /** @@ -231,7 +243,7 @@ export interface UserProvider { * Interface representing the properties to set on a new user record to be * created. */ -export interface CreateRequest extends UpdateRequest { +export interface CreateRequest extends BaseCreateUpdateUserRequest { /** * The user's `uid`. diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 7b113b3156..4922928c85 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -800,6 +800,56 @@ describe('admin.auth', () => { }); }); + it('sets claims that are accessible via user\'s ID token', () => { + // Set custom claims on the user. + return getAuth().updateUser(updateUser.uid, { customUserClaims: customClaims }) + .then((userRecord) => { + // Confirm custom claims set on the UserRecord. + expect(userRecord.customClaims).to.deep.equal(customClaims); + expect(userRecord.email).to.exist; + return clientAuth().signInWithEmailAndPassword( + userRecord.email!, mockUserData.password); + }) + .then(({ user }) => { + // Get the user's ID token. + expect(user).to.exist; + return user!.getIdToken(); + }) + .then((idToken) => { + // Verify ID token contents. + return getAuth().verifyIdToken(idToken); + }) + .then((decodedIdToken: { [key: string]: any }) => { + // Confirm expected claims set on the user's ID token. + for (const key in customClaims) { + if (Object.prototype.hasOwnProperty.call(customClaims, key)) { + expect(decodedIdToken[key]).to.equal(customClaims[key]); + } + } + // Test clearing of custom claims. + return getAuth().updateUser(newUserUid, { customUserClaims: null }); + }) + .then((userRecord) => { + // Custom claims should be cleared. + expect(userRecord.customClaims).to.deep.equal({}); + // Force token refresh. All claims should be cleared. + expect(clientAuth().currentUser).to.exist; + return clientAuth().currentUser!.getIdToken(true); + }) + .then((idToken) => { + // Verify ID token contents. + return getAuth().verifyIdToken(idToken); + }) + .then((decodedIdToken: { [key: string]: any }) => { + // Confirm all custom claims are cleared. + for (const key in customClaims) { + if (Object.prototype.hasOwnProperty.call(customClaims, key)) { + expect(decodedIdToken[key]).to.be.undefined; + } + } + }); + }); + it('creates, updates, and removes second factors', function () { if (authEmulatorHost) { return this.skip(); // Not yet supported in Auth Emulator. diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index 574962df53..27bab859ba 100644 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -2044,6 +2044,10 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { photoURL: 'http://localhost/1234/photo.png', password: 'password', phoneNumber: '+11234567890', + customUserClaims: { + admin: true, + groupId: '123', + }, multiFactor: { enrolledFactors: [ { @@ -2076,6 +2080,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { photoUrl: 'http://localhost/1234/photo.png', password: 'password', phoneNumber: '+11234567890', + customAttributes: JSON.stringify({ admin: true, groupId: '123' }), mfa: { enrollments: [ { @@ -2106,6 +2111,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { disableUser: false, password: 'password', phoneNumber: '+11234567890', + customAttributes: JSON.stringify({ admin: true, groupId: '123' }), deleteAttribute: ['DISPLAY_NAME', 'PHOTO_URL'], }; // Valid request to delete phoneNumber. @@ -2120,8 +2126,38 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { disableUser: false, photoUrl: 'http://localhost/1234/photo.png', password: 'password', + customAttributes: JSON.stringify({ admin: true, groupId: '123' }), deleteProvider: ['phone'], }; + // Valid request to delete custom claims. + const validDeleteCustomClaimsData = deepCopy(validData); + validDeleteCustomClaimsData.customUserClaims = null; + delete validDeleteCustomClaimsData.multiFactor; + const expectedValidDeleteCustomClaimsData = { + localId: uid, + displayName: 'John Doe', + email: 'user@example.com', + emailVerified: true, + disableUser: false, + photoUrl: 'http://localhost/1234/photo.png', + password: 'password', + phoneNumber: '+11234567890', + customAttributes: JSON.stringify({}), + }; + // Valid request to leave custom claims unchanged. + const validUnchangedCustomClaimsData = deepCopy(validData); + delete validUnchangedCustomClaimsData.customUserClaims; + delete validUnchangedCustomClaimsData.multiFactor; + const expectedValidUnchangedCustomClaimsData = { + localId: uid, + displayName: 'John Doe', + email: 'user@example.com', + emailVerified: true, + disableUser: false, + photoUrl: 'http://localhost/1234/photo.png', + password: 'password', + phoneNumber: '+11234567890', + }; // Valid request to delete all second factors. const expectedValidDeleteMfaData = { localId: uid, @@ -2222,6 +2258,50 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { callParams(path, method, expectedValidDeletePhoneNumberData)); }); }); + + it('should be fulfilled given null custom claims', () => { + // Successful result server response. + const expectedResult = utils.responseFrom({ + kind: 'identitytoolkit#SetAccountInfoResponse', + localId: uid, + }); + + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + // Send update request to delete custom claims. + return requestHandler.updateExistingAccount(uid, validDeleteCustomClaimsData) + .then((returnedUid: string) => { + // uid should be returned. + expect(returnedUid).to.be.equal(uid); + // Confirm expected rpc request parameters sent. In this case, customAttributes added. + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, expectedValidDeleteCustomClaimsData)); + }); + }); + + it('should be fulfilled given undefined custom claims', () => { + // Successful result server response. + const expectedResult = utils.responseFrom({ + kind: 'identitytoolkit#SetAccountInfoResponse', + localId: uid, + }); + + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + // Send update request to update account excluding custom claims. + return requestHandler.updateExistingAccount(uid, validUnchangedCustomClaimsData) + .then((returnedUid: string) => { + // uid should be returned. + expect(returnedUid).to.be.equal(uid); + // Confirm expected rpc request parameters sent. In this case, customAttributes removed. + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, expectedValidUnchangedCustomClaimsData)); + }); + }); it('should be fulfilled given null enrolled factors', () => { // Successful result server response. diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index 4924cb5b12..0f0657b09c 100644 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -1845,6 +1845,10 @@ AUTH_CONFIGS.forEach((testConfig) => { emailVerified: expectedUserRecord.emailVerified, password: 'password', phoneNumber: expectedUserRecord.phoneNumber, + customUserClaims: { + admin: true, + groupId: '123', + }, providerToLink: { providerId: 'google.com', uid: 'google_uid', @@ -1855,10 +1859,12 @@ AUTH_CONFIGS.forEach((testConfig) => { beforeEach(() => { sinon.spy(validator, 'isUid'); sinon.spy(validator, 'isNonNullObject'); + sinon.spy(validator, 'isObject'); }); afterEach(() => { (validator.isUid as any).restore(); (validator.isNonNullObject as any).restore(); + (validator.isObject as any).restore(); _.forEach(stubs, (stub) => stub.restore()); stubs = []; }); @@ -1978,6 +1984,18 @@ AUTH_CONFIGS.forEach((testConfig) => { }); }); + it('should be rejected given invalid custom user claims', () => { + return auth.updateUser(uid, { customUserClaims: 'invalid' as any }) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + expect(validator.isObject).to.have.been.calledOnce.and.calledWith('invalid'); + }); + }); + + describe('non-federated providers', () => { let invokeRequestHandlerStub: sinon.SinonStub; let getAccountInfoByUidStub: sinon.SinonStub;