voluptasmollitia
Version:
Monorepo for the Firebase JavaScript SDK
326 lines (297 loc) • 9.81 kB
text/typescript
/**
* @license
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getToken as getReCAPTCHAToken } from './recaptcha';
import { FirebaseApp } from '@firebase/app-types';
import {
AppCheckTokenResult,
AppCheckTokenListener
} from '@firebase/app-check-interop-types';
import {
AppCheckTokenInternal,
getDebugState,
getState,
setState
} from './state';
import { TOKEN_REFRESH_TIME } from './constants';
import { Refresher } from './proactive-refresh';
import { ensureActivated } from './util';
import {
exchangeToken,
getExchangeDebugTokenRequest,
getExchangeRecaptchaTokenRequest
} from './client';
import { writeTokenToStorage, readTokenFromStorage } from './storage';
import { getDebugToken, isDebugMode } from './debug';
import { base64, issuedAtTime } from '@firebase/util';
import { ERROR_FACTORY, AppCheckError } from './errors';
import { logger } from './logger';
import { Provider } from '@firebase/component';
// Initial hardcoded value agreed upon across platforms for initial launch.
// Format left open for possible dynamic error values and other fields in the future.
export const defaultTokenErrorData = { error: 'UNKNOWN_ERROR' };
/**
* Stringify and base64 encode token error data.
*
* @param tokenError Error data, currently hardcoded.
*/
export function formatDummyToken(
tokenErrorData: Record<string, string>
): string {
return base64.encodeString(
JSON.stringify(tokenErrorData),
/* webSafe= */ false
);
}
/**
* This function will always resolve.
* The result will contain an error field if there is any error.
* In case there is an error, the token field in the result will be populated with a dummy value
*/
export async function getToken(
app: FirebaseApp,
platformLoggerProvider: Provider<'platform-logger'>,
forceRefresh = false
): Promise<AppCheckTokenResult> {
ensureActivated(app);
/**
* DEBUG MODE
* return the debug token directly
*/
if (isDebugMode()) {
const tokenFromDebugExchange: AppCheckTokenInternal = await exchangeToken(
getExchangeDebugTokenRequest(app, await getDebugToken()),
platformLoggerProvider
);
return { token: tokenFromDebugExchange.token };
}
const state = getState(app);
let token: AppCheckTokenInternal | undefined = state.token;
let error: Error | undefined = undefined;
/**
* try to load token from indexedDB if it's the first time this function is called
*/
if (!token) {
// readTokenFromStorage() always resolves. In case of an error, it resolves with `undefined`.
const cachedToken = await readTokenFromStorage(app);
if (cachedToken && isValid(cachedToken)) {
token = cachedToken;
setState(app, { ...state, token });
// notify all listeners with the cached token
notifyTokenListeners(app, { token: token.token });
}
}
// return the cached token if it's valid
if (!forceRefresh && token && isValid(token)) {
return {
token: token.token
};
}
/**
* request a new token
*/
try {
if (state.customProvider) {
const customToken = await state.customProvider.getToken();
// Try to extract IAT from custom token, in case this token is not
// being newly issued. JWT timestamps are in seconds since epoch.
const issuedAtTimeSeconds = issuedAtTime(customToken.token);
// Very basic validation, use current timestamp as IAT if JWT
// has no `iat` field or value is out of bounds.
const issuedAtTimeMillis =
issuedAtTimeSeconds !== null &&
issuedAtTimeSeconds < Date.now() &&
issuedAtTimeSeconds > 0
? issuedAtTimeSeconds * 1000
: Date.now();
token = { ...customToken, issuedAtTimeMillis };
} else {
const attestedClaimsToken = await getReCAPTCHAToken(app).catch(_e => {
// reCaptcha.execute() throws null which is not very descriptive.
throw ERROR_FACTORY.create(AppCheckError.RECAPTCHA_ERROR);
});
token = await exchangeToken(
getExchangeRecaptchaTokenRequest(app, attestedClaimsToken),
platformLoggerProvider
);
}
} catch (e) {
// `getToken()` should never throw, but logging error text to console will aid debugging.
logger.error(e);
error = e;
}
let interopTokenResult: AppCheckTokenResult | undefined;
if (!token) {
// if token is undefined, there must be an error.
// we return a dummy token along with the error
interopTokenResult = makeDummyTokenResult(error!);
} else {
interopTokenResult = {
token: token.token
};
// write the new token to the memory state as well ashe persistent storage.
// Only do it if we got a valid new token
setState(app, { ...state, token });
await writeTokenToStorage(app, token);
}
notifyTokenListeners(app, interopTokenResult);
return interopTokenResult;
}
export function addTokenListener(
app: FirebaseApp,
platformLoggerProvider: Provider<'platform-logger'>,
listener: AppCheckTokenListener
): void {
const state = getState(app);
const newState = {
...state,
tokenListeners: [...state.tokenListeners, listener]
};
/**
* DEBUG MODE
*
* invoke the listener once with the debug token.
*/
if (isDebugMode()) {
const debugState = getDebugState();
if (debugState.enabled && debugState.token) {
debugState.token.promise
.then(token => listener({ token }))
.catch(() => {
/* we don't care about exceptions thrown in listeners */
});
}
} else {
/**
* PROD MODE
*
* invoke the listener with the valid token, then start the token refresher
*/
if (!newState.tokenRefresher) {
const tokenRefresher = createTokenRefresher(app, platformLoggerProvider);
newState.tokenRefresher = tokenRefresher;
}
// Create the refresher but don't start it if `isTokenAutoRefreshEnabled`
// is not true.
if (
!newState.tokenRefresher.isRunning() &&
state.isTokenAutoRefreshEnabled === true
) {
newState.tokenRefresher.start();
}
// invoke the listener async immediately if there is a valid token
if (state.token && isValid(state.token)) {
const validToken = state.token;
Promise.resolve()
.then(() => listener({ token: validToken.token }))
.catch(() => {
/* we don't care about exceptions thrown in listeners */
});
}
}
setState(app, newState);
}
export function removeTokenListener(
app: FirebaseApp,
listener: AppCheckTokenListener
): void {
const state = getState(app);
const newListeners = state.tokenListeners.filter(l => l !== listener);
if (
newListeners.length === 0 &&
state.tokenRefresher &&
state.tokenRefresher.isRunning()
) {
state.tokenRefresher.stop();
}
setState(app, {
...state,
tokenListeners: newListeners
});
}
function createTokenRefresher(
app: FirebaseApp,
platformLoggerProvider: Provider<'platform-logger'>
): Refresher {
return new Refresher(
// Keep in mind when this fails for any reason other than the ones
// for which we should retry, it will effectively stop the proactive refresh.
async () => {
const state = getState(app);
// If there is no token, we will try to load it from storage and use it
// If there is a token, we force refresh it because we know it's going to expire soon
let result;
if (!state.token) {
result = await getToken(app, platformLoggerProvider);
} else {
result = await getToken(app, platformLoggerProvider, true);
}
// getToken() always resolves. In case the result has an error field defined, it means the operation failed, and we should retry.
if (result.error) {
throw result.error;
}
},
() => {
// TODO: when should we retry?
return true;
},
() => {
const state = getState(app);
if (state.token) {
// issuedAtTime + (50% * total TTL) + 5 minutes
let nextRefreshTimeMillis =
state.token.issuedAtTimeMillis +
(state.token.expireTimeMillis - state.token.issuedAtTimeMillis) *
0.5 +
5 * 60 * 1000;
// Do not allow refresh time to be past (expireTime - 5 minutes)
const latestAllowableRefresh =
state.token.expireTimeMillis - 5 * 60 * 1000;
nextRefreshTimeMillis = Math.min(
nextRefreshTimeMillis,
latestAllowableRefresh
);
return Math.max(0, nextRefreshTimeMillis - Date.now());
} else {
return 0;
}
},
TOKEN_REFRESH_TIME.RETRIAL_MIN_WAIT,
TOKEN_REFRESH_TIME.RETRIAL_MAX_WAIT
);
}
function notifyTokenListeners(
app: FirebaseApp,
token: AppCheckTokenResult
): void {
const listeners = getState(app).tokenListeners;
for (const listener of listeners) {
try {
listener(token);
} catch (e) {
// If any handler fails, ignore and run next handler.
}
}
}
function isValid(token: AppCheckTokenInternal): boolean {
return token.expireTimeMillis - Date.now() > 0;
}
function makeDummyTokenResult(error: Error): AppCheckTokenResult {
return {
token: formatDummyToken(defaultTokenErrorData),
error
};
}