Skip to content

Commit 471581e

Browse files
Implements all refresh token revocation APIs. (#149)
* Implements all refresh token revocation APIs. This includes: admin.auth.UserRecord.prototype.tokensValidAfterTime:?string (readonly) admin.auth.Auth.prototype.verifyIdToken(idToken:string, checkRevoked:boolean=}:!Promise<!admin.auth.DecodedIdToken> admin.auth.Auth.prototype.revokeRefreshTokens(uid: string): Promise<void> * Addresses all review comments.
1 parent 7df511c commit 471581e

File tree

11 files changed

+791
-363
lines changed

11 files changed

+791
-363
lines changed

package-lock.json

Lines changed: 216 additions & 345 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,10 @@
4949
"dependencies": {
5050
"@firebase/app": "^0.1.1",
5151
"@firebase/database": "^0.1.3",
52-
"@google-cloud/firestore": "^0.10.1",
52+
"@google-cloud/firestore": "^0.10.0",
5353
"@google-cloud/storage": "^1.2.1",
5454
"@types/google-cloud__storage": "^1.1.1",
55-
"@types/node": "^8.0.32",
55+
"@types/node": "^8.0.53",
5656
"faye-websocket": "0.9.3",
5757
"jsonwebtoken": "7.4.3",
5858
"node-forge": "0.7.1"
@@ -61,7 +61,7 @@
6161
"@types/chai": "^3.4.34",
6262
"@types/chai-as-promised": "0.0.29",
6363
"@types/firebase-token-generator": "^2.0.28",
64-
"@types/lodash": "^4.14.38",
64+
"@types/lodash": "^4.14.85",
6565
"@types/mocha": "^2.2.32",
6666
"@types/nock": "^8.0.33",
6767
"@types/request": "2.0.6",
@@ -72,7 +72,7 @@
7272
"chai-as-promised": "^6.0.0",
7373
"chalk": "^1.1.3",
7474
"del": "^2.2.1",
75-
"firebase": "^3.6.9",
75+
"firebase": "~4.6.2",
7676
"firebase-token-generator": "^2.0.0",
7777
"gulp": "^3.9.1",
7878
"gulp-exit": "0.0.2",
@@ -86,15 +86,15 @@
8686
"merge2": "^1.0.2",
8787
"mocha": "^3.5.0",
8888
"nock": "^8.0.0",
89-
"npm-run-all": "^4.1.1",
90-
"nyc": "^11.1.0",
89+
"npm-run-all": "^4.1.2",
90+
"nyc": "^11.3.0",
9191
"request": "^2.75.0",
9292
"request-promise": "^4.1.1",
9393
"run-sequence": "^1.1.5",
9494
"sinon": "^4.1.3",
9595
"sinon-chai": "^2.8.0",
9696
"ts-node": "^3.3.0",
9797
"tslint": "^3.5.0",
98-
"typescript": "^2.0.3"
98+
"typescript": "^2.6.1"
9999
}
100100
}

src/auth/auth-api-request.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ function validateCreateEditRequest(request: any) {
7878
sanityCheck: true,
7979
phoneNumber: true,
8080
customAttributes: true,
81+
validSince: true,
8182
};
8283
// Remove invalid keys from original request.
8384
for (let key in request) {
@@ -134,6 +135,11 @@ function validateCreateEditRequest(request: any) {
134135
typeof request.disabled !== 'boolean') {
135136
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_DISABLED_FIELD);
136137
}
138+
// validSince should be a number.
139+
if (typeof request.validSince !== 'undefined' &&
140+
!validator.isNumber(request.validSince)) {
141+
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TOKENS_VALID_AFTER_TIME);
142+
}
137143
// disableUser should be a boolean.
138144
if (typeof request.disableUser !== 'undefined' &&
139145
typeof request.disableUser !== 'boolean') {
@@ -264,6 +270,13 @@ export const FIREBASE_AUTH_SIGN_UP_NEW_USER = new ApiSettings('signupNewUser', '
264270
`"customAttributes" cannot be set when creating a new user.`,
265271
);
266272
}
273+
// signupNewUser does not support validSince.
274+
if (typeof request.validSince !== 'undefined') {
275+
throw new FirebaseAuthError(
276+
AuthClientErrorCode.INVALID_ARGUMENT,
277+
`"validSince" cannot be set when creating a new user.`,
278+
);
279+
}
267280
validateCreateEditRequest(request);
268281
})
269282
// Set response validator.
@@ -518,6 +531,32 @@ export class FirebaseAuthRequestHandler {
518531
});
519532
}
520533

534+
/**
535+
* Revokes all refresh tokens for the specified user identified by the uid provided.
536+
* In addition to revoking all refresh tokens for a user, all ID tokens issued
537+
* before revocation will also be revoked on the Auth backend. Any request with an
538+
* ID token generated before revocation will be rejected with a token expired error.
539+
*
540+
* @param {string} uid The user whose tokens are to be revoked.
541+
* @return {Promise<string>} A promise that resolves when the operation completes
542+
* successfully with the user id of the corresponding user.
543+
*/
544+
public revokeRefreshTokens(uid: string): Promise<string> {
545+
// Validate user UID.
546+
if (!validator.isUid(uid)) {
547+
return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_UID));
548+
}
549+
let request: any = {
550+
localId: uid,
551+
// validSince is in UTC seconds.
552+
validSince: Math.ceil(new Date().getTime() / 1000),
553+
};
554+
return this.invokeRequestHandler(FIREBASE_AUTH_SET_ACCOUNT_INFO, request)
555+
.then((response: any) => {
556+
return response.localId as string;
557+
});
558+
}
559+
521560
/**
522561
* Create a new user with the properties supplied.
523562
*

src/auth/auth.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,25 @@ export interface ListUsersResult {
4848
}
4949

5050

51+
/** Inteface representing a decoded ID token. */
52+
export interface DecodedIdToken {
53+
aud: string;
54+
auth_time: number;
55+
exp: number;
56+
firebase: {
57+
identities: {
58+
[key: string]: any;
59+
};
60+
sign_in_provider: string;
61+
[key: string]: any;
62+
};
63+
iat: number;
64+
iss: string;
65+
sub: string;
66+
[key: string]: any;
67+
}
68+
69+
5170
/**
5271
* Auth service bound to the provided app.
5372
*/
@@ -124,20 +143,48 @@ export class Auth implements FirebaseServiceInterface {
124143

125144
/**
126145
* Verifies a JWT auth token. Returns a Promise with the tokens claims. Rejects
127-
* the promise if the token could not be verified.
146+
* the promise if the token could not be verified. If checkRevoked is set to true,
147+
* verifies if the session corresponding to the ID token was revoked. If the corresponding
148+
* user's session was invalidated, an auth/id-token-revoked error is thrown. If not specified
149+
* the check is not applied.
128150
*
129151
* @param {string} idToken The JWT to verify.
130-
* @return {Promise<Object>} A Promise that will be fulfilled after a successful verification.
152+
* @param {boolean=} checkRevoked Whether to check if the ID token is revoked.
153+
* @return {Promise<DecodedIdToken>} A Promise that will be fulfilled after a successful
154+
* verification.
131155
*/
132-
public verifyIdToken(idToken: string): Promise<Object> {
156+
public verifyIdToken(idToken: string, checkRevoked: boolean = false): Promise<Object> {
133157
if (typeof this.tokenGenerator_ === 'undefined') {
134158
throw new FirebaseAuthError(
135159
AuthClientErrorCode.INVALID_CREDENTIAL,
136160
'Must initialize app with a cert credential or set your Firebase project ID as the ' +
137161
'GCLOUD_PROJECT environment variable to call auth().verifyIdToken().',
138162
);
139163
}
140-
return this.tokenGenerator_.verifyIdToken(idToken);
164+
return this.tokenGenerator_.verifyIdToken(idToken)
165+
.then((decodedIdToken: DecodedIdToken) => {
166+
// Whether to check if the token was revoked.
167+
if (!checkRevoked) {
168+
return decodedIdToken;
169+
}
170+
// Get tokens valid after time for the corresponding user.
171+
return this.getUser(decodedIdToken.sub)
172+
.then((user: UserRecord) => {
173+
// If no tokens valid after time available, token is not revoked.
174+
if (user.tokensValidAfterTime) {
175+
// Get the ID token authentication time and convert to milliseconds UTC.
176+
const authTimeUtc = decodedIdToken.auth_time * 1000;
177+
// Get user tokens valid after time in milliseconds UTC.
178+
const validSinceUtc = new Date(user.tokensValidAfterTime).getTime();
179+
// Check if authentication time is older than valid since time.
180+
if (authTimeUtc < validSinceUtc) {
181+
throw new FirebaseAuthError(AuthClientErrorCode.ID_TOKEN_REVOKED);
182+
}
183+
}
184+
// All checks above passed. Return the decoded token.
185+
return decodedIdToken;
186+
});
187+
});
141188
};
142189

143190
/**
@@ -285,4 +332,21 @@ export class Auth implements FirebaseServiceInterface {
285332
// Return nothing on success.
286333
});
287334
};
335+
336+
/**
337+
* Revokes all refresh tokens for the specified user identified by the provided UID.
338+
* In addition to revoking all refresh tokens for a user, all ID tokens issued before
339+
* revocation will also be revoked on the Auth backend. Any request with an ID token
340+
* generated before revocation will be rejected with a token expired error.
341+
*
342+
* @param {string} uid The user whose tokens are to be revoked.
343+
* @return {Promise<void>} A promise that resolves when the operation completes
344+
* successfully.
345+
*/
346+
public revokeRefreshTokens(uid: string): Promise<void> {
347+
return this.authRequestHandler.revokeRefreshTokens(uid)
348+
.then((existingUid) => {
349+
// Return nothing on success.
350+
});
351+
};
288352
};

src/auth/user-record.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export class UserRecord {
148148
public readonly passwordHash?: string;
149149
public readonly passwordSalt?: string;
150150
public readonly customClaims: Object;
151+
public readonly tokensValidAfterTime?: string;
151152

152153
constructor(response: any) {
153154
// The Firebase user id is required.
@@ -180,6 +181,12 @@ export class UserRecord {
180181
// Ignore error.
181182
utils.addReadonlyGetter(this, 'customClaims', undefined);
182183
}
184+
let validAfterTime: string = null;
185+
// Convert validSince first to UTC milliseconds and then to UTC date string.
186+
if (typeof response.validSince !== 'undefined') {
187+
validAfterTime = parseDate(response.validSince * 1000);
188+
}
189+
utils.addReadonlyGetter(this, 'tokensValidAfterTime', validAfterTime);
183190
}
184191

185192
/** @return {Object} The plain object representation of the user record. */
@@ -197,6 +204,7 @@ export class UserRecord {
197204
passwordHash: this.passwordHash,
198205
passwordSalt: this.passwordSalt,
199206
customClaims: deepCopy(this.customClaims),
207+
tokensValidAfterTime: this.tokensValidAfterTime,
200208
};
201209
json.providerData = [];
202210
for (let entry of this.providerData) {

src/index.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ declare namespace admin.auth {
110110
passwordHash?: string;
111111
passwordSalt?: string;
112112
customClaims?: Object;
113+
tokensValidAfterTime?: string;
113114

114115
toJSON(): Object;
115116
}
@@ -162,8 +163,9 @@ declare namespace admin.auth {
162163
getUserByPhoneNumber(phoneNumber: string): Promise<admin.auth.UserRecord>;
163164
listUsers(maxResults?: number, pageToken?: string): Promise<admin.auth.ListUsersResult>;
164165
updateUser(uid: string, properties: admin.auth.UpdateRequest): Promise<admin.auth.UserRecord>;
165-
verifyIdToken(idToken: string): Promise<admin.auth.DecodedIdToken>;
166+
verifyIdToken(idToken: string, checkRevoked?: boolean): Promise<admin.auth.DecodedIdToken>;
166167
setCustomUserClaims(uid: string, customUserClaims: Object): Promise<void>;
168+
revokeRefreshTokens(uid: string): Promise<void>;
167169
}
168170
}
169171

src/utils/error.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,10 @@ export class AuthClientErrorCode {
309309
code: 'reserved-claim',
310310
message: 'The specified developer claim is reserved and cannot be specified.',
311311
};
312+
public static ID_TOKEN_REVOKED = {
313+
code: 'id-token-revoked',
314+
message: 'The Firebase ID token has been revoked.',
315+
};
312316
public static INTERNAL_ERROR = {
313317
code: 'internal-error',
314318
message: 'An internal error has occurred.',
@@ -358,6 +362,10 @@ export class AuthClientErrorCode {
358362
code: 'invalid-uid',
359363
message: 'The uid must be a non-empty string with at most 128 characters.',
360364
};
365+
public static INVALID_TOKENS_VALID_AFTER_TIME = {
366+
code: 'invalid-tokens-valid-after-time',
367+
message: 'The tokensValidAfterTime must be a valid UTC number in seconds.',
368+
};
361369
public static MISSING_UID = {
362370
code: 'missing-uid',
363371
message: 'A uid identifier is required for the current operation.',

test/integration/auth.js

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ function test(utils) {
256256
})
257257
.then(function(user) {
258258
// Get the user's ID token.
259-
return user.getToken();
259+
return user.getIdToken();
260260
})
261261
.then(function(idToken) {
262262
// Verify ID token contents.
@@ -418,7 +418,7 @@ function test(utils) {
418418
return firebase.auth().signInWithCustomToken(customToken);
419419
})
420420
.then(function(user) {
421-
return user.getToken();
421+
return user.getIdToken();
422422
})
423423
.then(function(idToken) {
424424
utils.logSuccess('auth.createCustomToken()');
@@ -449,11 +449,78 @@ function test(utils) {
449449
});
450450
}
451451

452+
function testRefreshTokenRevocation() {
453+
var currentIdToken = null;
454+
var currentUser = null;
455+
// Sign in with an email and password account.
456+
return firebase.auth().signInWithEmailAndPassword(mockUserData.email, mockUserData.password)
457+
.then(function(user) {
458+
currentUser = user;
459+
// Get user's ID token.
460+
return user.getIdToken();
461+
})
462+
.then(function(idToken) {
463+
currentIdToken = idToken;
464+
// Verify that user's ID token while checking for revocation.
465+
return admin.auth().verifyIdToken(currentIdToken, true)
466+
})
467+
.then(function(decodedIdToken) {
468+
// Verification should succeed. Revoke that user's session.
469+
return admin.auth().revokeRefreshTokens(decodedIdToken.sub);
470+
})
471+
.then(function() {
472+
// verifyIdToken without checking revocation should still succeed.
473+
return admin.auth().verifyIdToken(currentIdToken);
474+
})
475+
.then(function() {
476+
// verifyIdToken while checking for revocation should fail.
477+
return admin.auth().verifyIdToken(currentIdToken, true)
478+
.then(function(decodedIdToken) {
479+
throw new Error('verifyIdToken(revoked, true) succeeded');
480+
})
481+
.catch(function(error) {
482+
utils.assert(
483+
error.code === 'auth/id-token-revoked',
484+
'auth().verifyIdToken(revokedIdToken, true)',
485+
'Expected auth/id-token-revoked was not thrown');
486+
});
487+
})
488+
.then(function() {
489+
// Confirm token revoked on client.
490+
return currentUser.reload()
491+
.then(function() {
492+
throw new Error('revokedUser.reload() succeeded');
493+
})
494+
.catch(function(error) {
495+
utils.assert(
496+
error.code === 'auth/user-token-expired',
497+
'auth().revokeRefreshTokens(uid)',
498+
'Expected auth/user-token-expired was not thrown');
499+
});
500+
})
501+
.then(function() {
502+
// New sign-in should succeed.
503+
return firebase.auth().signInWithEmailAndPassword(
504+
mockUserData.email, mockUserData.password);
505+
})
506+
.then(function(user) {
507+
// Get new session's ID token.
508+
return user.getIdToken();
509+
})
510+
.then(function(idToken) {
511+
// ID token for new session should be valid even with revocation check.
512+
return admin.auth().verifyIdToken(idToken, true)
513+
})
514+
.catch(function(error) {
515+
utils.logFailure('auth().revokeRefreshTokens()', error);
516+
});
517+
}
452518

453519
return before()
454520
.then(testCreateUserWithoutUid)
455521
.then(testCreateUserWithUid)
456522
.then(testCreateDuplicateUserWithError)
523+
.then(testRefreshTokenRevocation)
457524
.then(testGetUser)
458525
.then(testGetUserByEmail)
459526
.then(testGetUserByPhoneNumber)

0 commit comments

Comments
 (0)