Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements all refresh token revocation APIs. #149

Merged
merged 3 commits into from
Jan 4, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
561 changes: 216 additions & 345 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@
"dependencies": {
"@firebase/app": "^0.1.1",
"@firebase/database": "^0.1.3",
"@google-cloud/firestore": "^0.10.1",
"@google-cloud/firestore": "^0.10.0",
"@google-cloud/storage": "^1.2.1",
"@types/google-cloud__storage": "^1.1.1",
"@types/node": "^8.0.32",
"@types/node": "^8.0.53",
"faye-websocket": "0.9.3",
"jsonwebtoken": "7.4.3",
"node-forge": "0.7.1"
Expand All @@ -61,7 +61,7 @@
"@types/chai": "^3.4.34",
"@types/chai-as-promised": "0.0.29",
"@types/firebase-token-generator": "^2.0.28",
"@types/lodash": "^4.14.38",
"@types/lodash": "^4.14.85",
"@types/mocha": "^2.2.32",
"@types/nock": "^8.0.33",
"@types/request": "2.0.6",
Expand All @@ -72,7 +72,7 @@
"chai-as-promised": "^6.0.0",
"chalk": "^1.1.3",
"del": "^2.2.1",
"firebase": "^3.6.9",
"firebase": "~4.6.2",
"firebase-token-generator": "^2.0.0",
"gulp": "^3.9.1",
"gulp-exit": "0.0.2",
Expand All @@ -86,15 +86,15 @@
"merge2": "^1.0.2",
"mocha": "^3.5.0",
"nock": "^8.0.0",
"npm-run-all": "^4.1.1",
"nyc": "^11.1.0",
"npm-run-all": "^4.1.2",
"nyc": "^11.3.0",
"request": "^2.75.0",
"request-promise": "^4.1.1",
"run-sequence": "^1.1.5",
"sinon": "^4.1.3",
"sinon-chai": "^2.8.0",
"ts-node": "^3.3.0",
"tslint": "^3.5.0",
"typescript": "^2.0.3"
"typescript": "^2.6.1"
}
}
39 changes: 39 additions & 0 deletions src/auth/auth-api-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ function validateCreateEditRequest(request: any) {
sanityCheck: true,
phoneNumber: true,
customAttributes: true,
validSince: true,
};
// Remove invalid keys from original request.
for (let key in request) {
Expand Down Expand Up @@ -134,6 +135,11 @@ function validateCreateEditRequest(request: any) {
typeof request.disabled !== 'boolean') {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_DISABLED_FIELD);
}
// validSince should be a number.
if (typeof request.validSince !== 'undefined' &&
!validator.isNumber(request.validSince)) {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TOKENS_VALID_AFTER_TIME);
}
// disableUser should be a boolean.
if (typeof request.disableUser !== 'undefined' &&
typeof request.disableUser !== 'boolean') {
Expand Down Expand Up @@ -264,6 +270,13 @@ export const FIREBASE_AUTH_SIGN_UP_NEW_USER = new ApiSettings('signupNewUser', '
`"customAttributes" cannot be set when creating a new user.`,
);
}
// signupNewUser does not support validSince.
if (typeof request.validSince !== 'undefined') {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
`"validSince" cannot be set when creating a new user.`,
);
}
validateCreateEditRequest(request);
})
// Set response validator.
Expand Down Expand Up @@ -518,6 +531,32 @@ export class FirebaseAuthRequestHandler {
});
}

/**
* Revokes all refresh tokens for the specified user identified by the uid provided.
* In addition to revoking all refresh tokens for a user, all ID tokens issued
* before revocation will also be revoked on the Auth backend. Any request with an
* ID token generated before revocation will be rejected with a token expired error.
*
* @param {string} uid The user whose tokens are to be revoked.
* @return {Promise<string>} A promise that resolves when the operation completes
* successfully with the user id of the corresponding user.
*/
public revokeRefreshTokens(uid: string): Promise<string> {
// Validate user UID.
if (!validator.isUid(uid)) {
return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_UID));
}
let request: any = {
localId: uid,
// validSince is in UTC seconds.
validSince: Math.ceil(new Date().getTime() / 1000),
};
return this.invokeRequestHandler(FIREBASE_AUTH_SET_ACCOUNT_INFO, request)
.then((response: any) => {
return response.localId as string;
});
}

/**
* Create a new user with the properties supplied.
*
Expand Down
72 changes: 68 additions & 4 deletions src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,25 @@ export interface ListUsersResult {
}


/** Inteface representing a decoded ID token. */
export interface DecodedIdToken {
aud: string;
auth_time: number;
exp: number;
firebase: {
identities: {
[key: string]: any;
};
sign_in_provider: string;
[key: string]: any;
};
iat: number;
iss: string;
sub: string;
[key: string]: any;
}


/**
* Auth service bound to the provided app.
*/
Expand Down Expand Up @@ -124,20 +143,48 @@ export class Auth implements FirebaseServiceInterface {

/**
* Verifies a JWT auth token. Returns a Promise with the tokens claims. Rejects
* the promise if the token could not be verified.
* 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.
* @return {Promise<Object>} A Promise that will be fulfilled after a successful verification.
* @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.
*/
public verifyIdToken(idToken: string): Promise<Object> {
public verifyIdToken(idToken: string, checkRevoked: boolean = false): Promise<Object> {
if (typeof this.tokenGenerator_ === 'undefined') {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CREDENTIAL,
'Must initialize app with a cert credential or set your Firebase project ID as the ' +
'GCLOUD_PROJECT environment variable to call auth().verifyIdToken().',
);
}
return this.tokenGenerator_.verifyIdToken(idToken);
return this.tokenGenerator_.verifyIdToken(idToken)
.then((decodedIdToken: DecodedIdToken) => {
// Whether to check if the token was revoked.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do a check for !checkRevoked at the top, and remove the large if block:

if (!checkRevoked) {
  return decodedIdToken;
}
return this.getUser(decodedIdToken.sub)
  ...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

if (!checkRevoked) {
return decodedIdToken;
}
// Get tokens valid after time for the corresponding user.
return this.getUser(decodedIdToken.sub)
.then((user: UserRecord) => {
// 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.
const authTimeUtc = decodedIdToken.auth_time * 1000;
// Get user tokens valid after time in milliseconds UTC.
const validSinceUtc = new Date(user.tokensValidAfterTime).getTime();
// Check if authentication time is older than valid since time.
if (authTimeUtc < validSinceUtc) {
throw new FirebaseAuthError(AuthClientErrorCode.ID_TOKEN_REVOKED);
}
}
// All checks above passed. Return the decoded token.
return decodedIdToken;
});
});
};

/**
Expand Down Expand Up @@ -285,4 +332,21 @@ export class Auth implements FirebaseServiceInterface {
// Return nothing on success.
});
};

/**
* Revokes all refresh tokens for the specified user identified by the provided UID.
* In addition to revoking all refresh tokens for a user, all ID tokens issued before
* revocation will also be revoked on the Auth backend. Any request with an ID token
* generated before revocation will be rejected with a token expired error.
*
* @param {string} uid The user whose tokens are to be revoked.
* @return {Promise<void>} A promise that resolves when the operation completes
* successfully.
*/
public revokeRefreshTokens(uid: string): Promise<void> {
return this.authRequestHandler.revokeRefreshTokens(uid)
.then((existingUid) => {
// Return nothing on success.
});
};
};
8 changes: 8 additions & 0 deletions src/auth/user-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export class UserRecord {
public readonly passwordHash?: string;
public readonly passwordSalt?: string;
public readonly customClaims: Object;
public readonly tokensValidAfterTime?: string;

constructor(response: any) {
// The Firebase user id is required.
Expand Down Expand Up @@ -180,6 +181,12 @@ export class UserRecord {
// Ignore error.
utils.addReadonlyGetter(this, 'customClaims', undefined);
}
let validAfterTime: string = null;
// Convert validSince first to UTC milliseconds and then to UTC date string.
if (typeof response.validSince !== 'undefined') {
validAfterTime = parseDate(response.validSince * 1000);
}
utils.addReadonlyGetter(this, 'tokensValidAfterTime', validAfterTime);
}

/** @return {Object} The plain object representation of the user record. */
Expand All @@ -197,6 +204,7 @@ export class UserRecord {
passwordHash: this.passwordHash,
passwordSalt: this.passwordSalt,
customClaims: deepCopy(this.customClaims),
tokensValidAfterTime: this.tokensValidAfterTime,
};
json.providerData = [];
for (let entry of this.providerData) {
Expand Down
4 changes: 3 additions & 1 deletion src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ declare namespace admin.auth {
passwordHash?: string;
passwordSalt?: string;
customClaims?: Object;
tokensValidAfterTime?: string;

toJSON(): Object;
}
Expand Down Expand Up @@ -162,8 +163,9 @@ declare namespace admin.auth {
getUserByPhoneNumber(phoneNumber: string): Promise<admin.auth.UserRecord>;
listUsers(maxResults?: number, pageToken?: string): Promise<admin.auth.ListUsersResult>;
updateUser(uid: string, properties: admin.auth.UpdateRequest): Promise<admin.auth.UserRecord>;
verifyIdToken(idToken: string): Promise<admin.auth.DecodedIdToken>;
verifyIdToken(idToken: string, checkRevoked?: boolean): Promise<admin.auth.DecodedIdToken>;
setCustomUserClaims(uid: string, customUserClaims: Object): Promise<void>;
revokeRefreshTokens(uid: string): Promise<void>;
}
}

Expand Down
8 changes: 8 additions & 0 deletions src/utils/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,10 @@ export class AuthClientErrorCode {
code: 'reserved-claim',
message: 'The specified developer claim is reserved and cannot be specified.',
};
public static ID_TOKEN_REVOKED = {
code: 'id-token-revoked',
message: 'The Firebase ID token has been revoked.',
};
public static INTERNAL_ERROR = {
code: 'internal-error',
message: 'An internal error has occurred.',
Expand Down Expand Up @@ -358,6 +362,10 @@ export class AuthClientErrorCode {
code: 'invalid-uid',
message: 'The uid must be a non-empty string with at most 128 characters.',
};
public static INVALID_TOKENS_VALID_AFTER_TIME = {
code: 'invalid-tokens-valid-after-time',
message: 'The tokensValidAfterTime must be a valid UTC number in seconds.',
};
public static MISSING_UID = {
code: 'missing-uid',
message: 'A uid identifier is required for the current operation.',
Expand Down
71 changes: 69 additions & 2 deletions test/integration/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ function test(utils) {
})
.then(function(user) {
// Get the user's ID token.
return user.getToken();
return user.getIdToken();
})
.then(function(idToken) {
// Verify ID token contents.
Expand Down Expand Up @@ -418,7 +418,7 @@ function test(utils) {
return firebase.auth().signInWithCustomToken(customToken);
})
.then(function(user) {
return user.getToken();
return user.getIdToken();
})
.then(function(idToken) {
utils.logSuccess('auth.createCustomToken()');
Expand Down Expand Up @@ -449,11 +449,78 @@ function test(utils) {
});
}

function testRefreshTokenRevocation() {
var currentIdToken = null;
var currentUser = null;
// Sign in with an email and password account.
return firebase.auth().signInWithEmailAndPassword(mockUserData.email, mockUserData.password)
.then(function(user) {
currentUser = user;
// Get user's ID token.
return user.getIdToken();
})
.then(function(idToken) {
currentIdToken = idToken;
// Verify that user's ID token while checking for revocation.
return admin.auth().verifyIdToken(currentIdToken, true)
})
.then(function(decodedIdToken) {
// Verification should succeed. Revoke that user's session.
return admin.auth().revokeRefreshTokens(decodedIdToken.sub);
})
.then(function() {
// verifyIdToken without checking revocation should still succeed.
return admin.auth().verifyIdToken(currentIdToken);
})
.then(function() {
// verifyIdToken while checking for revocation should fail.
return admin.auth().verifyIdToken(currentIdToken, true)
.then(function(decodedIdToken) {
throw new Error('verifyIdToken(revoked, true) succeeded');
})
.catch(function(error) {
utils.assert(
error.code === 'auth/id-token-revoked',
'auth().verifyIdToken(revokedIdToken, true)',
'Expected auth/id-token-revoked was not thrown');
});
})
.then(function() {
// Confirm token revoked on client.
return currentUser.reload()
.then(function() {
throw new Error('revokedUser.reload() succeeded');
})
.catch(function(error) {
utils.assert(
error.code === 'auth/user-token-expired',
'auth().revokeRefreshTokens(uid)',
'Expected auth/user-token-expired was not thrown');
});
})
.then(function() {
// New sign-in should succeed.
return firebase.auth().signInWithEmailAndPassword(
mockUserData.email, mockUserData.password);
})
.then(function(user) {
// Get new session's ID token.
return user.getIdToken();
})
.then(function(idToken) {
// ID token for new session should be valid even with revocation check.
return admin.auth().verifyIdToken(idToken, true)
})
.catch(function(error) {
utils.logFailure('auth().revokeRefreshTokens()', error);
});
}

return before()
.then(testCreateUserWithoutUid)
.then(testCreateUserWithUid)
.then(testCreateDuplicateUserWithError)
.then(testRefreshTokenRevocation)
.then(testGetUser)
.then(testGetUserByEmail)
.then(testGetUserByPhoneNumber)
Expand Down
Loading