diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 48f46c345a..8ca6e549c6 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -104,16 +104,20 @@ export class BaseAuth implements BaseAuthI } /** - * Verifies a JWT auth token. Returns a Promise with the tokens claims. Rejects - * the promise if the token could not be verified. If checkRevoked is set to true, - * verifies if the session corresponding to the ID token was revoked. If the corresponding - * user's session was invalidated, an auth/id-token-revoked error is thrown. If not specified - * the check is not applied. + * Verifies a JWT auth token. Returns a promise with the tokens claims. + * Rejects the promise if the token cannot be verified. + * If `checkRevoked` is set to true, first verifies whether the corresponding + * user is disabled. + * If yes, an auth/user-disabled error is thrown. + * If no, verifies if the session corresponding to the ID token was revoked. + * If the corresponding user's session was invalidated, an + * auth/id-token-revoked error is thrown. + * If not specified the check is not applied. * * @param {string} idToken The JWT to verify. * @param {boolean=} checkRevoked Whether to check if the ID token is revoked. - * @return {Promise} A Promise that will be fulfilled after a successful - * verification. + * @return {Promise} A promise that will be fulfilled after + * a successful verification. */ public verifyIdToken(idToken: string, checkRevoked = false): Promise { const isEmulator = useEmulator(); @@ -121,7 +125,7 @@ export class BaseAuth implements BaseAuthI .then((decodedIdToken: DecodedIdToken) => { // Whether to check if the token was revoked. if (checkRevoked || isEmulator) { - return this.verifyDecodedJWTNotRevoked( + return this.verifyDecodedJWTNotRevokedOrDisabled( decodedIdToken, AuthClientErrorCode.ID_TOKEN_REVOKED); } @@ -506,25 +510,31 @@ export class BaseAuth implements BaseAuthI } /** - * Verifies a Firebase session cookie. Returns a Promise with the tokens claims. Rejects - * the promise if the token could not be verified. If checkRevoked is set to true, - * verifies if the session corresponding to the session cookie was revoked. If the corresponding - * user's session was invalidated, an auth/session-cookie-revoked error is thrown. If not - * specified the check is not performed. + * Verifies a Firebase session cookie. Returns a promise with the tokens claims. + * Rejects the promise if the cookie could not be verified. + * If `checkRevoked` is set to true, first verifies whether the corresponding + * user is disabled: + * If yes, an auth/user-disabled error is thrown. + * If no, verifies if the session corresponding to the session cookie was + * revoked. + * If the corresponding user's session was invalidated, an + * auth/session-cookie-revoked error is thrown. + * If not specified the check is not performed. * * @param {string} sessionCookie The session cookie to verify. - * @param {boolean=} checkRevoked Whether to check if the session cookie is revoked. - * @return {Promise} A Promise that will be fulfilled after a successful - * verification. + * @param {boolean=} checkRevoked Whether to check if the session cookie is + * revoked. + * @return {Promise} A promise that will be fulfilled after + * a successful verification. */ public verifySessionCookie( sessionCookie: string, checkRevoked = false): Promise { const isEmulator = useEmulator(); return this.sessionCookieVerifier.verifyJWT(sessionCookie, isEmulator) .then((decodedIdToken: DecodedIdToken) => { - // Whether to check if the token was revoked. + // Whether to check if the cookie was revoked. if (checkRevoked || isEmulator) { - return this.verifyDecodedJWTNotRevoked( + return this.verifyDecodedJWTNotRevokedOrDisabled( decodedIdToken, AuthClientErrorCode.SESSION_COOKIE_REVOKED); } @@ -723,20 +733,26 @@ export class BaseAuth implements BaseAuthI } /** - * Verifies the decoded Firebase issued JWT is not revoked. Returns a promise that resolves - * with the decoded claims on success. Rejects the promise with revocation error if revoked. + * Verifies the decoded Firebase issued JWT is not revoked or disabled. Returns a promise that + * resolves with the decoded claims on success. Rejects the promise with revocation error if revoked + * or user disabled. * * @param {DecodedIdToken} decodedIdToken The JWT's decoded claims. * @param {ErrorInfo} revocationErrorInfo The revocation error info to throw on revocation * detection. - * @return {Promise} A Promise that will be fulfilled after a successful + * @return {Promise} A promise that will be fulfilled after a successful * verification. */ - private verifyDecodedJWTNotRevoked( + private verifyDecodedJWTNotRevokedOrDisabled( decodedIdToken: DecodedIdToken, revocationErrorInfo: ErrorInfo): Promise { // Get tokens valid after time for the corresponding user. return this.getUser(decodedIdToken.sub) .then((user: UserRecord) => { + if (user.disabled) { + throw new FirebaseAuthError( + AuthClientErrorCode.USER_DISABLED, + 'The user record is disabled.'); + } // If no tokens valid after time available, token is not revoked. if (user.tokensValidAfterTime) { // Get the ID token authentication time and convert to milliseconds UTC. @@ -778,7 +794,7 @@ export class TenantAwareAuth } /** - * Verifies a JWT auth token. Returns a Promise with the tokens claims. Rejects + * Verifies a JWT auth token. Returns a promise with the tokens claims. Rejects * the promise if the token could not be verified. If checkRevoked is set to true, * verifies if the session corresponding to the ID token was revoked. If the corresponding * user's session was invalidated, an auth/id-token-revoked error is thrown. If not specified @@ -786,7 +802,7 @@ export class TenantAwareAuth * * @param {string} idToken The JWT to verify. * @param {boolean=} checkRevoked Whether to check if the ID token is revoked. - * @return {Promise} A Promise that will be fulfilled after a successful + * @return {Promise} A promise that will be fulfilled after a successful * verification. */ public verifyIdToken(idToken: string, checkRevoked = false): Promise { @@ -829,7 +845,7 @@ export class TenantAwareAuth } /** - * Verifies a Firebase session cookie. Returns a Promise with the tokens claims. Rejects + * Verifies a Firebase session cookie. Returns a promise with the tokens claims. Rejects * the promise if the token could not be verified. If checkRevoked is set to true, * verifies if the session corresponding to the session cookie was revoked. If the corresponding * user's session was invalidated, an auth/session-cookie-revoked error is thrown. If not @@ -837,7 +853,7 @@ export class TenantAwareAuth * * @param {string} sessionCookie The session cookie to verify. * @param {boolean=} checkRevoked Whether to check if the session cookie is revoked. - * @return {Promise} A Promise that will be fulfilled after a successful + * @return {Promise} A promise that will be fulfilled after a successful * verification. */ public verifySessionCookie( diff --git a/src/utils/error.ts b/src/utils/error.ts index fcec6d5fd3..de22a4b9ca 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -721,6 +721,10 @@ export class AuthClientErrorCode { code: 'not-found', message: 'The requested resource was not found.', }; + public static USER_DISABLED = { + code: 'user-disabled', + message: 'The user record is disabled.', + } public static USER_NOT_DISABLED = { code: 'user-not-disabled', message: 'The user must be disabled in order to bulk delete it (or you must pass force=true).', diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 55b78fa232..7d275a5f5d 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -760,6 +760,20 @@ describe('admin.auth', () => { safeDelete(userRecord.uid); } }); + + it('A user with user record disabled is unable to sign in', async () => { + const password = 'password'; + const email = 'updatedEmail@example.com'; + return admin.auth().updateUser(updateUser.uid, { disabled : true , password, email }) + .then(() => { + return clientAuth().signInWithEmailAndPassword(email, password); + }) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.have.property('code', 'auth/user-disabled'); + }); + }); }); it('getUser() fails when called with a non-existing UID', () => { @@ -833,53 +847,113 @@ describe('admin.auth', () => { }); }); - it('verifyIdToken() fails when called with an invalid token', () => { - return admin.auth().verifyIdToken('invalid-token') - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); + describe('verifyIdToken()', () => { + const uid = generateRandomString(20).toLowerCase(); + const email = uid + '@example.com'; + const password = 'password'; + const userData = { + uid, + email, + emailVerified: false, + password, + }; + + // Create the test user before running this suite of tests. + before(() => { + return admin.auth().createUser(userData); + }); - if (authEmulatorHost) { - describe('Auth emulator support', () => { - const uid = 'authEmulatorUser'; - before(() => { - return admin.auth().createUser({ - uid, - email: 'lastRefreshTimeUser@example.com', - password: 'p4ssword', + // Sign out after each test. + afterEach(() => { + return clientAuth().signOut(); + }); + + after(() => { + return safeDelete(uid); + }); + + it('verifyIdToken() fails when called with an invalid token', () => { + return admin.auth().verifyIdToken('invalid-token') + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); + + it('verifyIdToken() fails with checkRevoked set to true and corresponding user disabled', async () => { + const { user } = await clientAuth().signInWithEmailAndPassword(email, password); + expect(user).to.exist; + expect(user!.email).to.equal(email); + + const idToken = await user!.getIdToken(); + let decodedIdToken = await admin.auth().verifyIdToken(idToken, true); + expect(decodedIdToken.uid).to.equal(uid); + expect(decodedIdToken.email).to.equal(email); + + const userRecord = await admin.auth().updateUser(uid, { disabled: true }); + expect(userRecord.uid).to.equal(uid); + expect(userRecord.email).to.equal(email); + expect(userRecord.disabled).to.equal(true); + + try { + // If it is in emulator mode, a user-disabled error will be thrown. + decodedIdToken = await admin.auth().verifyIdToken(idToken, false); + expect(decodedIdToken.uid).to.equal(uid); + } catch (error) { + if (authEmulatorHost) { + expect(error).to.have.property('code', 'auth/user-disabled'); + } else { + throw error; + } + } + + try { + await admin.auth().verifyIdToken(idToken, true); + } catch (error) { + expect(error).to.have.property('code', 'auth/user-disabled'); + } + }); + + if (authEmulatorHost) { + describe('Auth emulator support', () => { + const uid = 'authEmulatorUser'; + before(() => { + return admin.auth().createUser({ + uid, + email: 'lastRefreshTimeUser@example.com', + password: 'p4ssword', + }); + }); + after(() => { + return admin.auth().deleteUser(uid); }); - }); - after(() => { - return admin.auth().deleteUser(uid); - }); - it('verifyIdToken() succeeds when called with an unsigned token', () => { - const unsignedToken = mocks.generateIdToken({ - algorithm: 'none', - audience: projectId, - issuer: 'https://securetoken.google.com/' + projectId, - subject: uid, + it('verifyIdToken() succeeds when called with an unsigned token', () => { + const unsignedToken = mocks.generateIdToken({ + algorithm: 'none', + audience: projectId, + issuer: 'https://securetoken.google.com/' + projectId, + subject: uid, + }); + return admin.auth().verifyIdToken(unsignedToken); }); - return admin.auth().verifyIdToken(unsignedToken); - }); - it('verifyIdToken() fails when called with a token with wrong project', () => { - const unsignedToken = mocks.generateIdToken({ algorithm: 'none', audience: 'nosuch' }); - return admin.auth().verifyIdToken(unsignedToken) - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); + it('verifyIdToken() fails when called with a token with wrong project', () => { + const unsignedToken = mocks.generateIdToken({ algorithm: 'none', audience: 'nosuch' }); + return admin.auth().verifyIdToken(unsignedToken) + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); - it('verifyIdToken() fails when called with a token that does not belong to a user', () => { - const unsignedToken = mocks.generateIdToken({ - algorithm: 'none', - audience: projectId, - issuer: 'https://securetoken.google.com/' + projectId, - subject: 'nosuch', + it('verifyIdToken() fails when called with a token that does not belong to a user', () => { + const unsignedToken = mocks.generateIdToken({ + algorithm: 'none', + audience: projectId, + issuer: 'https://securetoken.google.com/' + projectId, + subject: 'nosuch', + }); + return admin.auth().verifyIdToken(unsignedToken) + .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); }); - return admin.auth().verifyIdToken(unsignedToken) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); }); - }); - } + } + }); describe('Link operations', () => { const uid = generateRandomString(20).toLowerCase(); @@ -1982,6 +2056,44 @@ describe('admin.auth', () => { .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); }); }); + + it('fails with checkRevoked set to true and corresponding user disabled', async () => { + const expiresIn = 24 * 60 * 60 * 1000; + const customToken = await admin.auth().createCustomToken(uid, { admin: true, groupId: '1234' }); + const { user } = await clientAuth().signInWithCustomToken(customToken); + expect(user).to.exist; + + const idToken = await user!.getIdToken(); + const decodedIdTokenClaims = await admin.auth().verifyIdToken(idToken); + expect(decodedIdTokenClaims.uid).to.be.equal(uid); + + const sessionCookie = await admin.auth().createSessionCookie(idToken, { expiresIn }); + let decodedIdToken = await admin.auth().verifySessionCookie(sessionCookie, true); + expect(decodedIdToken.uid).to.equal(uid); + + const userRecord = await admin.auth().updateUser(uid, { disabled : true }); + // Ensure disabled field has been updated. + expect(userRecord.uid).to.equal(uid); + expect(userRecord.disabled).to.equal(true); + + try { + // If it is in emulator mode, a user-disabled error will be thrown. + decodedIdToken = await admin.auth().verifySessionCookie(sessionCookie, false); + expect(decodedIdToken.uid).to.equal(uid); + } catch (error) { + if (authEmulatorHost) { + expect(error).to.have.property('code', 'auth/user-disabled'); + } else { + throw error; + } + } + + try { + await admin.auth().verifySessionCookie(sessionCookie, true); + } catch (error) { + expect(error).to.have.property('code', 'auth/user-disabled'); + } + }); }); describe('importUsers()', () => { diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index 71957bc4b4..836a7f29ba 100644 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -482,6 +482,53 @@ AUTH_CONFIGS.forEach((testConfig) => { .should.eventually.be.rejectedWith('Decoding Firebase ID token failed'); }); + it('should be rejected with checkRevoked set to true and corresponding user disabled', () => { + const expectedAccountInfoResponse = getValidGetAccountInfoResponse(tenantId); + expectedAccountInfoResponse.users[0].disabled = true; + const expectedUserRecordDisabled = getValidUserRecord(expectedAccountInfoResponse); + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .resolves(expectedUserRecordDisabled); + expect(expectedUserRecordDisabled.disabled).to.be.equal(true); + stubs.push(getUserStub); + return auth.verifyIdToken(mockIdToken, true) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected error returned. + expect(error).to.have.property('code', 'auth/user-disabled'); + }); + }); + + it('verifyIdToken() should reject user disabled before ID tokens revoked', () => { + const expectedAccountInfoResponse = getValidGetAccountInfoResponse(tenantId); + const expectedAccountInfoResponseUserDisabled = Object.assign({}, expectedAccountInfoResponse); + expectedAccountInfoResponseUserDisabled.users[0].disabled = true; + const expectedUserRecordDisabled = getValidUserRecord(expectedAccountInfoResponseUserDisabled); + const validSince = new Date(expectedUserRecordDisabled.tokensValidAfterTime!); + // Restore verifyIdToken stub. + stub.restore(); + // One second before validSince. + const oneSecBeforeValidSince = new Date(validSince.getTime() - 1000); + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .resolves(getDecodedIdToken(expectedUserRecordDisabled.uid, oneSecBeforeValidSince)); + stubs.push(stub); + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .resolves(expectedUserRecordDisabled); + expect(expectedUserRecordDisabled.disabled).to.be.equal(true); + stubs.push(getUserStub); + return auth.verifyIdToken(mockIdToken, true) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm expected error returned. + expect(error).to.have.property('code', 'auth/user-disabled'); + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(expectedUserRecordDisabled.uid); + }); + }); + it('should work with a non-cert credential when the GOOGLE_CLOUD_PROJECT environment variable is present', () => { process.env.GOOGLE_CLOUD_PROJECT = mocks.projectId; @@ -838,6 +885,53 @@ AUTH_CONFIGS.forEach((testConfig) => { }); }); + it('should be rejected with checkRevoked set to true and corresponding user disabled', () => { + const expectedAccountInfoResponse = getValidGetAccountInfoResponse(tenantId); + expectedAccountInfoResponse.users[0].disabled = true; + const expectedUserRecordDisabled = getValidUserRecord(expectedAccountInfoResponse); + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .resolves(expectedUserRecordDisabled); + expect(expectedUserRecordDisabled.disabled).to.be.equal(true); + stubs.push(getUserStub); + return auth.verifySessionCookie(mockSessionCookie, true) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected error returned. + expect(error).to.have.property('code', 'auth/user-disabled'); + }); + }); + + it('verifySessionCookie() should reject user disabled before ID tokens revoked', () => { + const expectedAccountInfoResponse = getValidGetAccountInfoResponse(tenantId); + const expectedAccountInfoResponseUserDisabled = Object.assign({}, expectedAccountInfoResponse); + expectedAccountInfoResponseUserDisabled.users[0].disabled = true; + const expectedUserRecordDisabled = getValidUserRecord(expectedAccountInfoResponseUserDisabled); + const validSince = new Date(expectedUserRecordDisabled.tokensValidAfterTime!); + // Restore verifySessionCookie stub. + stub.restore(); + // One second before validSince. + const oneSecBeforeValidSince = new Date(validSince.getTime() - 1000); + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .resolves(getDecodedIdToken(expectedUserRecordDisabled.uid, oneSecBeforeValidSince)); + stubs.push(stub); + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .resolves(expectedUserRecordDisabled); + expect(expectedUserRecordDisabled.disabled).to.be.equal(true); + stubs.push(getUserStub); + return auth.verifySessionCookie(mockSessionCookie, true) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(expectedUserRecordDisabled.uid); + // Confirm expected error returned. + expect(error).to.have.property('code', 'auth/user-disabled'); + }); + }); + it('should be fulfilled with checkRevoked set to true when no validSince available', () => { // Simulate no validSince set on the user. const noValidSinceGetAccountInfoResponse = getValidGetAccountInfoResponse(tenantId); @@ -3496,7 +3590,6 @@ AUTH_CONFIGS.forEach((testConfig) => { }); describe('auth emulator support', () => { - let mockAuth = testConfig.init(mocks.app()); const userRecord = getValidUserRecord(getValidGetAccountInfoResponse()); const validSince = new Date(userRecord.tokensValidAfterTime!);