Skip to content

Commit

Permalink
fix: Throw error on user disabled and check revoked set true (#1401)
Browse files Browse the repository at this point in the history
* fix: Throw error on user disabled and check revoked set true

* resolve 2 calls on getUser(), improve tests and lints

* remove currentUser.reload

* add return

* Tweak tests

* small fix

* Use async and await instead of chain in integration test and change CI dependency

* retry

* Add special case of authEmulator

* fix emulator on issue

* remove firebase-tool version changes

* useMockIdToken for unit test

* fix typos
  • Loading branch information
xil222 committed Aug 16, 2021
1 parent e22f0ef commit 9f7529f
Show file tree
Hide file tree
Showing 4 changed files with 291 additions and 66 deletions.
68 changes: 42 additions & 26 deletions src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,24 +104,28 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> 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<DecodedIdToken>} A Promise that will be fulfilled after a successful
* verification.
* @return {Promise<DecodedIdToken>} A promise that will be fulfilled after
* a successful verification.
*/
public verifyIdToken(idToken: string, checkRevoked = false): Promise<DecodedIdToken> {
const isEmulator = useEmulator();
return this.idTokenVerifier.verifyJWT(idToken, isEmulator)
.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);
}
Expand Down Expand Up @@ -506,25 +510,31 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> 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<DecodedIdToken>} A Promise that will be fulfilled after a successful
* verification.
* @param {boolean=} checkRevoked Whether to check if the session cookie is
* revoked.
* @return {Promise<DecodedIdToken>} A promise that will be fulfilled after
* a successful verification.
*/
public verifySessionCookie(
sessionCookie: string, checkRevoked = false): Promise<DecodedIdToken> {
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);
}
Expand Down Expand Up @@ -723,20 +733,26 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> 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<DecodedIdToken>} A Promise that will be fulfilled after a successful
* @return {Promise<DecodedIdToken>} A promise that will be fulfilled after a successful
* verification.
*/
private verifyDecodedJWTNotRevoked(
private verifyDecodedJWTNotRevokedOrDisabled(
decodedIdToken: DecodedIdToken, revocationErrorInfo: ErrorInfo): Promise<DecodedIdToken> {
// 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.
Expand Down Expand Up @@ -778,15 +794,15 @@ 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
* 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<DecodedIdToken>} A Promise that will be fulfilled after a successful
* @return {Promise<DecodedIdToken>} A promise that will be fulfilled after a successful
* verification.
*/
public verifyIdToken(idToken: string, checkRevoked = false): Promise<DecodedIdToken> {
Expand Down Expand Up @@ -829,15 +845,15 @@ 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
* 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<DecodedIdToken>} A Promise that will be fulfilled after a successful
* @return {Promise<DecodedIdToken>} A promise that will be fulfilled after a successful
* verification.
*/
public verifySessionCookie(
Expand Down
4 changes: 4 additions & 0 deletions src/utils/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).',
Expand Down
190 changes: 151 additions & 39 deletions test/integration/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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()', () => {
Expand Down

0 comments on commit 9f7529f

Please sign in to comment.