Skip to content

[Auth] Update auth token logic to rely on device clock time instead of server time #4210

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

Merged
merged 4 commits into from
Dec 15, 2020
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
5 changes: 5 additions & 0 deletions .changeset/large-books-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@firebase/auth": patch
---

Update auth token logic to rely on device clock time instead of server time. This fixes an issue seen when a device's clock is skewed by a lot: https://github.com/firebase/firebase-js-sdk/issues/3222
8 changes: 7 additions & 1 deletion packages/auth/src/authuser.js
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ fireauth.AuthUser.prototype.initializeProactiveRefreshUtility_ = function() {
function() {
// Get time until expiration minus the refresh offset.
var waitInterval =
self.stsTokenManager_.getExpirationTime() - goog.now() -
self.stsTokenManager_.getExpirationTime() - Date.now() -
fireauth.TokenRefreshTime.OFFSET_DURATION;
// Set to zero if wait interval is negative.
return waitInterval > 0 ? waitInterval : 0;
Expand Down Expand Up @@ -2436,6 +2436,12 @@ fireauth.AuthUser.fromPlainObject = function(user) {
// Refresh token could be expired.
stsTokenManagerResponse[fireauth.RpcHandler.AuthServerField.REFRESH_TOKEN] =
user['stsTokenManager']['refreshToken'] || null;
const expirationTime = user['stsTokenManager']['expirationTime'];
if (expirationTime) {
stsTokenManagerResponse[fireauth.RpcHandler.AuthServerField
.EXPIRES_IN] =
(expirationTime - Date.now()) / 1000;
}
} else {
// Token response is a required field.
return null;
Expand Down
37 changes: 26 additions & 11 deletions packages/auth/src/idtoken.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2017 Google Inc.
* Copyright 2017 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -30,7 +30,7 @@ goog.require('goog.crypt.base64');
* @constructor
*/
fireauth.IdToken = function(tokenString) {
var token = fireauth.IdToken.parseIdTokenClaims(tokenString);
const token = fireauth.IdToken.parseIdTokenClaims(tokenString);
if (!(token && token['sub'] && token['iss'] &&
token['aud'] && token['exp'])) {
throw new Error('Invalid JWT');
Expand All @@ -45,7 +45,7 @@ fireauth.IdToken = function(tokenString) {
this.exp_ = token['exp'];
/** @const @private {string} The local user ID of the token. */
this.localId_ = token['sub'];
var now = goog.now() / 1000;
const now = Date.now() / 1000;
/** @const @private {number} The issue time in seconds of the token. */
this.iat_ = token['iat'] || (now > this.exp_ ? this.exp_ : now);
/** @const @private {?string} The email address of the token. */
Expand Down Expand Up @@ -111,12 +111,24 @@ fireauth.IdToken.prototype.getEmail = function() {
};


/** @return {number} The expire time in seconds. */
/**
* @deprecated Use client side clock to calculate when the token expires.
* @return {number} The expire time in seconds.
*/
fireauth.IdToken.prototype.getExp = function() {
return this.exp_;
};


/**
* @return {number} The difference in seconds between when the token was
* issued and when it expires.
*/
fireauth.IdToken.prototype.getExpiresIn = function() {
return this.exp_ - this.iat_;
};


/** @return {?string} The ID of the identity provider. */
fireauth.IdToken.prototype.getProviderId = function() {
return this.providerId_;
Expand Down Expand Up @@ -165,9 +177,12 @@ fireauth.IdToken.prototype.isVerified = function() {
};


/** @return {boolean} Whether token is expired. */
/**
* @deprecated Use client side clock to calculate when the token expires.
* @return {boolean} Whether token is expired.
*/
fireauth.IdToken.prototype.isExpired = function() {
var now = Math.floor(goog.now() / 1000);
const now = Math.floor(Date.now() / 1000);
// It is expired if token expiration time is less than current time.
return this.getExp() <= now;
};
Expand Down Expand Up @@ -218,18 +233,18 @@ fireauth.IdToken.parseIdTokenClaims = function(tokenString) {
return null;
}
// Token format is <algorithm>.<info>.<sig>
var fields = tokenString.split('.');
const fields = tokenString.split('.');
if (fields.length != 3) {
return null;
}
var jsonInfo = fields[1];
let jsonInfo = fields[1];
// Google base64 library does not handle padding.
var padLen = (4 - jsonInfo.length % 4) % 4;
for (var i = 0; i < padLen; i++) {
const padLen = (4 - jsonInfo.length % 4) % 4;
for (let i = 0; i < padLen; i++) {
jsonInfo += '.';
}
try {
var token = JSON.parse(goog.crypt.base64.decodeString(jsonInfo, true));
const token = JSON.parse(goog.crypt.base64.decodeString(jsonInfo, true));
return /** @type {?Object} */ (token);
} catch (e) {}
return null;
Expand Down
107 changes: 71 additions & 36 deletions packages/auth/src/token.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2017 Google Inc.
* Copyright 2017 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -47,6 +47,8 @@ fireauth.StsTokenManager = function(rpcHandler) {
this.refreshToken_ = null;
/** @private {?fireauth.IdToken} The STS ID token. */
this.accessToken_ = null;
/** @private {number} The expiration time of the token in epoch millis. */
this.expiresAt_ = Date.now();
};


Expand All @@ -73,13 +75,14 @@ fireauth.StsTokenManager.prototype.toPlainObject = function() {
* plain object provided using the RPC handler provided.
*/
fireauth.StsTokenManager.fromPlainObject = function(rpcHandler, obj) {
var stsTokenManager = null;
let stsTokenManager = null;
if (obj && obj['apiKey']) {
// These should be always equals and must be enforced in internal use.
goog.asserts.assert(obj['apiKey'] == rpcHandler.getApiKey());
stsTokenManager = new fireauth.StsTokenManager(rpcHandler);
stsTokenManager.setRefreshToken(obj['refreshToken']);
stsTokenManager.setAccessToken(obj['accessToken']);
stsTokenManager.setExpiresAt(obj['expirationTime']);
}
return stsTokenManager;
};
Expand Down Expand Up @@ -118,6 +121,30 @@ fireauth.StsTokenManager.prototype.setAccessToken = function(accessToken) {
this.accessToken_ = fireauth.IdToken.parse(accessToken || '');
};

/**
* To account for client side clock skew, we try to set the expiration time
* using the local clock by adding the server TTL. If not provided, expiresAt
* will be set from the accessToken by taking the difference between the exp
* and iat fields.
*
* @param {number=} expiresIn The expiration TTL in seconds.
*/
fireauth.StsTokenManager.prototype.setExpiresIn = function(expiresIn) {
expiresIn = typeof expiresIn !== 'undefined' ? expiresIn :
this.accessToken_ ? this.accessToken_.getExpiresIn() :
0;
this.expiresAt_ = Date.now() + expiresIn * 1000;
}

/**
* Allow setting expiresAt directly when we know the time is already in the
* local clock.
*
* @param {number} expiresAt The expiration time in epoch millis.
*/
fireauth.StsTokenManager.prototype.setExpiresAt = function(expiresAt) {
this.expiresAt_ = expiresAt;
}

/**
* @return {?string} The refresh token.
Expand All @@ -131,7 +158,7 @@ fireauth.StsTokenManager.prototype.getRefreshToken = function() {
* @return {number} The STS access token expiration time in milliseconds.
*/
fireauth.StsTokenManager.prototype.getExpirationTime = function() {
return (this.accessToken_ && this.accessToken_.getExp() * 1000) || 0;
return this.expiresAt_;
};


Expand All @@ -148,7 +175,7 @@ fireauth.StsTokenManager.TOKEN_REFRESH_BUFFER = 30 * 1000;
* @private
*/
fireauth.StsTokenManager.prototype.isExpired_ = function() {
return goog.now() >
return Date.now() >
this.getExpirationTime() - fireauth.StsTokenManager.TOKEN_REFRESH_BUFFER;
};

Expand All @@ -161,11 +188,14 @@ fireauth.StsTokenManager.prototype.isExpired_ = function() {
* @return {string} The STS access token.
*/
fireauth.StsTokenManager.prototype.parseServerResponse = function(response) {
var idToken = response[fireauth.RpcHandler.AuthServerField.ID_TOKEN];
var refreshToken =
response[fireauth.RpcHandler.AuthServerField.REFRESH_TOKEN];
const idToken = response[fireauth.RpcHandler.AuthServerField.ID_TOKEN];
this.setAccessToken(idToken);
this.setRefreshToken(refreshToken);
this.setRefreshToken(
response[fireauth.RpcHandler.AuthServerField.REFRESH_TOKEN]);
// Not all IDP server responses come with expiresIn, some like MFA omit it.
const expiresIn = response[fireauth.RpcHandler.AuthServerField.EXPIRES_IN];
this.setExpiresIn(
typeof expiresIn !== 'undefined' ? Number(expiresIn) : undefined);
return idToken;
};

Expand All @@ -175,7 +205,7 @@ fireauth.StsTokenManager.prototype.parseServerResponse = function(response) {
* @return {!Object}
*/
fireauth.StsTokenManager.prototype.toServerResponse = function() {
var stsTokenManagerResponse = {};
const stsTokenManagerResponse = {};
stsTokenManagerResponse[fireauth.RpcHandler.AuthServerField.ID_TOKEN] =
this.accessToken_ && this.accessToken_.toString();
// Refresh token could be expired.
Expand All @@ -192,6 +222,7 @@ fireauth.StsTokenManager.prototype.toServerResponse = function() {
fireauth.StsTokenManager.prototype.copy = function(tokenManagerToCopy) {
this.accessToken_ = tokenManagerToCopy.accessToken_;
this.refreshToken_ = tokenManagerToCopy.refreshToken_;
this.expiresAt_ = tokenManagerToCopy.expiresAt_;
};


Expand All @@ -201,7 +232,7 @@ fireauth.StsTokenManager.prototype.copy = function(tokenManagerToCopy) {
* @private
*/
fireauth.StsTokenManager.prototype.exchangeRefreshToken_ = function() {
var data = {
const data = {
'grant_type': 'refresh_token',
'refresh_token': this.refreshToken_
};
Expand All @@ -216,27 +247,32 @@ fireauth.StsTokenManager.prototype.exchangeRefreshToken_ = function() {
* @private
*/
fireauth.StsTokenManager.prototype.requestToken_ = function(data) {
var self = this;
// Send RPC request to STS token endpoint.
return this.rpcHandler_.requestStsToken(data).then(function(resp) {
var response = /** @type {!fireauth.StsTokenManager.ResponseData} */ (resp);
self.accessToken_ = fireauth.IdToken.parse(
response[fireauth.RpcHandler.StsServerField.ACCESS_TOKEN]);
self.refreshToken_ =
response[fireauth.RpcHandler.StsServerField.REFRESH_TOKEN];
return /** @type {!fireauth.StsTokenManager.Response} */ ({
'accessToken': self.accessToken_.toString(),
'refreshToken': self.refreshToken_
});
}).thenCatch(function(error) {
// Refresh token expired or user deleted. In this case, reset refresh token
// to prevent sending the request again to the STS server unless
// the token is manually updated, perhaps via successful reauthentication.
if (error['code'] == 'auth/user-token-expired') {
self.refreshToken_ = null;
}
throw error;
});
return this.rpcHandler_.requestStsToken(data)
.then((resp) => {
const response =
/** @type {!fireauth.StsTokenManager.ResponseData} */ (resp);
this.accessToken_ = fireauth.IdToken.parse(
response[fireauth.RpcHandler.StsServerField.ACCESS_TOKEN]);
this.refreshToken_ =
response[fireauth.RpcHandler.StsServerField.REFRESH_TOKEN];
this.setExpiresIn(
response[fireauth.RpcHandler.StsServerField.EXPIRES_IN]);
return /** @type {!fireauth.StsTokenManager.Response} */ ({
'accessToken': this.accessToken_.toString(),
'refreshToken': this.refreshToken_
});
})
.thenCatch((error) => {
// Refresh token expired or user deleted. In this case, reset refresh
// token to prevent sending the request again to the STS server unless
// the token is manually updated, perhaps via successful
// reauthentication.
if (error['code'] == 'auth/user-token-expired') {
this.refreshToken_ = null;
}
throw error;
});
};


Expand All @@ -251,12 +287,11 @@ fireauth.StsTokenManager.prototype.isRefreshTokenExpired = function() {
* Otherwise the existing ID token or refresh token is exchanged for a new one.
* If there is no user signed in, returns null.
*
* @param {boolean=} opt_forceRefresh Whether to force refresh token exchange.
* @param {boolean=} forceRefresh Whether to force refresh token exchange.
* @return {!goog.Promise<?fireauth.StsTokenManager.Response>}
*/
fireauth.StsTokenManager.prototype.getToken = function(opt_forceRefresh) {
var self = this;
var forceRefresh = !!opt_forceRefresh;
fireauth.StsTokenManager.prototype.getToken = function(forceRefresh) {
forceRefresh = !!forceRefresh;
// Refresh token is expired.
if (this.isRefreshTokenExpired()) {
return goog.Promise.reject(
Expand All @@ -265,8 +300,8 @@ fireauth.StsTokenManager.prototype.getToken = function(opt_forceRefresh) {
if (!forceRefresh && this.accessToken_ && !this.isExpired_()) {
// Cached STS access token not expired, return it.
return /** @type {!goog.Promise} */ (goog.Promise.resolve({
'accessToken': self.accessToken_.toString(),
'refreshToken': self.refreshToken_
'accessToken': this.accessToken_.toString(),
'refreshToken': this.refreshToken_
}));
} else if (this.refreshToken_) {
// Expired but refresh token available, exchange refresh token for STS
Expand Down
Loading