@levante-framework/firekit
Version:
A library to facilitate Firebase authentication and Firestore interaction for LEVANTE apps
1,016 lines • 52.7 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.RoarFirekit = void 0;
/* eslint-disable @typescript-eslint/no-non-null-assertion */
const get_1 = __importDefault(require("lodash/get"));
const isEmpty_1 = __importDefault(require("lodash/isEmpty"));
const auth_1 = require("firebase/auth");
const firestore_1 = require("firebase/firestore");
const functions_1 = require("firebase/functions");
const util_1 = require("./firestore/util");
const appkit_1 = require("./firestore/app/appkit");
const task_1 = require("./firestore/app/task");
var AuthProviderType;
(function (AuthProviderType) {
AuthProviderType["GOOGLE"] = "google";
AuthProviderType["EMAIL"] = "email";
AuthProviderType["USERNAME"] = "username";
AuthProviderType["PASSWORD"] = "password";
})(AuthProviderType || (AuthProviderType = {}));
class RoarFirekit {
/**
* Create a RoarFirekit. This expects an object with keys `roarConfig`,
* where `roarConfig` is a [[RoarConfig]] object.
* @param {{roarConfig: RoarConfig }=} destructuredParam
* roarConfig: The ROAR firebase config object
*/
constructor({ roarConfig, verboseLogging = false, authPersistence = util_1.AuthPersistence.session, markRawConfig = {}, listenerUpdateCallback, emulatorConfig, }) {
this.roarConfig = roarConfig;
this.emulatorConfig = emulatorConfig;
this._verboseLogging = verboseLogging;
this._authPersistence = authPersistence;
this._markRawConfig = markRawConfig;
this._initialized = false;
this._idTokens = {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
this.listenerUpdateCallback = listenerUpdateCallback ?? (() => { });
}
_getProviderIds() {
return {
...auth_1.ProviderId,
ROAR_ADMIN_PROJECT: `oidc.${this.roarConfig.admin.projectId}`,
};
}
_scrubAuthProperties() {
this.userData = undefined;
this.roarAppUserInfo = undefined;
this._adminOrgs = undefined;
this._superAdmin = undefined;
this.currentAssignments = undefined;
this.oAuthAccessToken = undefined;
this._adminClaimsListener = undefined;
this._adminTokenListener = undefined;
this._idTokens = {};
}
async init() {
this.admin = await (0, util_1.initializeFirebaseProject)(this.roarConfig.admin, 'admin', this.emulatorConfig, this._authPersistence, this._markRawConfig);
this._initialized = true;
(0, auth_1.onAuthStateChanged)(this.admin.auth, async (user) => {
this.verboseLog('onAuthStateChanged triggered for admin auth');
if (this.admin) {
if (user) {
this.verboseLog('admin firebase instance and user are defined');
this.admin.user = user;
this._adminClaimsListener = this._listenToClaims(this.admin);
this.verboseLog('adminClaimsListener instance set up using listenToClaims');
this._adminTokenListener = this._listenToTokenChange(this.admin, 'admin');
this.verboseLog('adminTokenListener instance set up using listenToClaims');
this.verboseLog('[admin] Attempting to fire user.getIdToken(), existing token is', this._idTokens.admin);
user.getIdToken().then((idToken) => {
this.verboseLog('in .then() for user.getIdToken() with new token', idToken);
this._idTokens.admin = idToken;
this.verboseLog(`Updated internal admin token to ${idToken}`);
});
this._roarUid = await this.getRoarUid();
}
else {
this.verboseLog('User for admin is undefined.');
this.admin.user = undefined;
this._roarUid = undefined;
}
}
this.verboseLog('[admin] Call this.listenerUpdateCallback()');
this.listenerUpdateCallback();
});
return this;
}
verboseLog(...logStatement) {
if (this._verboseLogging) {
console.log('[RoarFirekit] ', ...logStatement);
}
else
return;
}
get initialized() {
return this._initialized;
}
/**
* Verifies if the RoarFirekit instance has been initialized.
*
* This method checks if the RoarFirekit instance has been initialized by checking the `_initialized` property.
* If the instance has not been initialized, it throws an error with a descriptive message.
*
* @throws {Error} - If the RoarFirekit instance has not been initialized.
*
*/
_verifyInit() {
if (!this._initialized) {
throw new Error('RoarFirekit has not been initialized. Use the `init` method.');
}
}
// +--------------------------------+
// ----------| Begin Authentication Methods |----------
// +--------------------------------+
/**
* Verifies if the user is authenticated in the application.
*
* This method checks if the user is authenticated in both the admin and assessment Firebase projects.
* If the user is authenticated in both projects, the method returns without throwing an error.
* If the user is not authenticated in either project, the method throws an error with the message 'User is not authenticated.'
*
* @throws {Error} - Throws an error if the user is not authenticated.
*/
_verifyAuthentication() {
this._verifyInit();
if (this.admin.user === undefined) {
throw new Error('User is not authenticated.');
}
return true;
}
_verifyAdmin() {
if (!this.superAdmin && !this._admin) {
throw new Error('User is not an administrator.');
}
}
/**
* Listens for changes in the user's custom claims and updates the internal state accordingly.
*
* This method sets up a snapshot listener on the user's custom claims document in the admin Firebase project.
* When the listener detects changes in the claims, it updates the internal state of the `RoarAuth` instance.
* It also refreshes the user's ID token if the claims have been updated.
*
* @param {FirebaseFirestore.Firestore} firekit.db - The Firestore database instance for the admin Firebase project.
* @param {FirebaseAuth.User} firekit.user - The user object for the admin Firebase project.
* @returns {FirebaseFirestore.Unsubscribe} - The unsubscribe function to stop listening for changes in the user's custom claims.
* @throws {FirebaseError} - If there is an error setting up the snapshot listener.
*/
_listenToClaims(firekit) {
this.verboseLog('entry point to listenToClaims');
this._verifyInit();
if (firekit.user) {
this.verboseLog('firekit.user is defined');
let unsubscribe;
this.verboseLog('About to try setting up the claims listener');
try {
this.verboseLog('Beginning onSnapshot definition');
unsubscribe = (0, firestore_1.onSnapshot)((0, firestore_1.doc)(firekit.db, 'userClaims', firekit.user.uid), async (doc) => {
this.verboseLog('In onSnapshot call for listenToClaims');
const data = doc.data();
this._adminOrgs = data?.claims?.adminOrgs;
this._superAdmin = data?.claims?.super_admin;
if (this.roarConfig.admin.projectId.includes('levante')) {
this._admin = data?.claims?.admin || false;
}
this.verboseLog('about to check for existance of data.lastUpdated');
if (data?.lastUpdated) {
this.verboseLog('lastUpdate exists.');
const lastUpdated = new Date(data.lastUpdated);
this.verboseLog('Checking for firekit.claimsLastUpdated existance or outdated (< lastUpdated from retrieved data)');
if (!firekit.claimsLastUpdated || lastUpdated > firekit.claimsLastUpdated) {
this.verboseLog("Firekit's last updated either does not exist or is outdated. Await getIdToken and update firekit's claimsLastUpdated field.");
// Update the user's ID token and refresh claimsLastUpdated.
await (0, auth_1.getIdToken)(firekit.user, true);
firekit.claimsLastUpdated = lastUpdated;
}
}
this.verboseLog('Call listenerUpdateCallback from listenToClaims');
this.listenerUpdateCallback();
}, (error) => {
throw error;
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
catch (error) {
this.verboseLog('Attempt to set up claims listener failed. Error is', error);
if (error.code !== 'permission-denied') {
throw error;
}
}
return unsubscribe;
}
}
/**
* Forces a refresh of the ID token for the admin Firebase user.
*
* This method retrieves the ID token for the admin Firebase user
* and refreshes it. It ensures that the token is up-to-date and valid.
*
* @returns {Promise<void>} - A promise that resolves when the ID tokens are refreshed successfully.
* @throws {FirebaseError} - If an error occurs while refreshing the ID tokens.
*/
async forceIdTokenRefresh() {
this.verboseLog('Entry point for forceIdTokenRefresh');
this._verifyAuthentication();
await (0, auth_1.getIdToken)(this.admin.user, true);
}
/**
* Listens for changes in the ID token of the specified Firebase project and updates the corresponding token.
*
* This method sets up a listener to track changes in the ID token of the specified Firebase project (admin).
* When the ID token changes, it retrieves the new ID token and updates the corresponding token in the `_idTokens` object.
* It also calls the `listenerUpdateCallback` function to notify any listeners of the token update.
*
* @param {FirebaseProject} firekit - The Firebase project to listen for token changes.
* @param {'admin'} _type - The type of Firebase project ('admin').
* @returns {firebase.Unsubscribe} - A function to unsubscribe from the listener.
* @private
*/
_listenToTokenChange(firekit, _type) {
this.verboseLog('Entry point for listenToTokenChange, called with', _type);
this._verifyInit();
this.verboseLog('Checking for existance of tokenListener with type', _type);
if ((!this._adminTokenListener && _type === 'admin')) {
this.verboseLog('Token listener does not exist, create now.');
return (0, auth_1.onIdTokenChanged)(firekit.auth, async (user) => {
this.verboseLog('onIdTokenChanged body');
if (user) {
this.verboseLog('user exists, await user.getIdTokenResult(false)');
const idTokenResult = await user.getIdTokenResult(false);
this.verboseLog('Returned with token', idTokenResult);
if (_type === 'admin') {
this.verboseLog('Type is admin, set idTokenRecieved flag');
this._idTokenReceived = true;
}
this.verboseLog(`Setting idTokens.${_type} to token`, idTokenResult.token);
this._idTokens[_type] = idTokenResult.token;
}
this.verboseLog('Calling listenerUpdateCallback from listenToTokenChange', _type);
this.listenerUpdateCallback();
});
}
return this._adminTokenListener;
}
/**
* Sets the UID custom claims for the admin and assessment UIDs in the Firebase projects.
*
* This method is responsible for associating the admin and assessment UIDs in the Firebase projects.
* It calls the setUidClaims cloud function in the admin Firebase project.
* If the cloud function execution is successful, it refreshes the ID tokens for both projects.
*
* @returns {Promise<any>} - A promise that resolves with the result of the setUidClaims cloud function execution.
* @param {object} input - An object containing the required parameters
* @param {string} input.identityProviderId - The identity provider ID for the user (optional).
* @param {AuthProviderType} input.identityProviderType - The type of the identity provider (optional).
* @throws {Error} - If the setUidClaims cloud function execution fails, an Error is thrown.
*/
async _setUidCustomClaims({ identityProviderId = undefined, identityProviderType = undefined, } = {}) {
this.verboseLog('Entry point to setUidCustomClaims');
this._verifyAuthentication();
this.verboseLog('Calling cloud function for setUidClaims');
const setUidClaims = (0, functions_1.httpsCallable)(this.admin.functions, 'setUidClaims');
const result = await setUidClaims({
identityProviderId,
identityProviderType,
});
this.verboseLog('setUidClaims returned with result', result);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((0, get_1.default)(result.data, 'status') !== 'ok') {
this.verboseLog('Error in calling setUidClaims cloud function', result.data);
throw new Error('Failed to set UIDs in the admin and assessment Firebase projects.');
}
await this.forceIdTokenRefresh();
this.verboseLog('Returning result from setUidCustomClaims', result);
return result;
}
/**
* Checks if the given email address is available for a new user registration.
*
* This method verifies if the given email address is not already associated with
* a user in the admin Firebase project. It returns a promise that resolves with
* a boolean value indicating whether the email address is available or not.
*
* @param {string} email - The email address to check.
* @returns {Promise<boolean>} - A promise that resolves with a boolean value indicating whether the email address is available or not.
* @throws {FirebaseError} - If an error occurs while checking the email availability.
*/
async isEmailAvailable(email) {
this._verifyInit();
const signInMethods = await (0, auth_1.fetchSignInMethodsForEmail)(this.admin.auth, email);
return signInMethods.length === 0;
}
/**
* Fetches the list of providers associated with the given user's email address.
*
* This method retrieves the list of providers associated with the given user's email address
* from the admin Firebase project. The list of providers includes the authentication methods
* that the user has used to sign in with their email address.
*
* @param {string} email - The email address of the user.
* @returns {Promise<string[]>} - A promise that resolves with an array of provider IDs.
* @throws {FirebaseError} - If an error occurs while fetching the email authentication methods.
*/
async fetchEmailAuthMethods(email) {
this._verifyInit();
return (0, auth_1.fetchSignInMethodsForEmail)(this.admin.auth, email).then((signInMethods) => {
const providerMap = {
[auth_1.EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD]: 'password',
[auth_1.EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD]: 'link',
'google.com': 'google',
};
return signInMethods
.filter((method) => method in providerMap)
.map(method => providerMap[method]);
});
}
/**
* Registers a new user with the provided email and password.
*
* This method creates a new user in both the admin and assessment Firebase projects.
* It first creates the user in the admin project and then in the assessment project.
* After successful user creation, it sets the UID custom claims by calling the `_setUidCustomClaims` method.
*
* @param {object} params - The parameters for registering a new user.
* @param {string} params.email - The email address of the new user.
* @param {string} params.password - The password of the new user.
* @returns {Promise<void>} - A promise that resolves when the user registration is complete.
* @throws {AuthError} - If the user registration fails, the promise will be rejected with an AuthError.
*/
async registerWithEmailAndPassword({ email, password }) {
this._verifyInit();
return (0, auth_1.createUserWithEmailAndPassword)(this.admin.auth, email, password)
.then(() => {
this._identityProviderType = AuthProviderType.EMAIL;
return this._setUidCustomClaims();
})
.catch((error) => {
console.log('Error creating user', error);
console.log(error.code);
console.log(error.message);
throw error;
});
}
/**
* Initiates a login process using an email and password.
*
* This method signs in the user with the provided email and password in both the admin and assessment Firebase projects.
* It first signs in the user in the admin project and then in the assessment project. After successful sign-in, it sets
* the UID custom claims by calling the `_setUidCustomClaims` method.
*
* @param {object} params - The parameters for initiating the login process.
* @param {string} params.email - The email address of the user.
* @param {string} params.password - The password of the user.
* @returns {Promise<void>} - A promise that resolves when the login process is complete.
* @throws {AuthError} - If the login process fails, the promise will be rejected with an AuthError.
*/
async logInWithEmailAndPassword({ email, password }) {
this._verifyInit();
return (0, auth_1.signInWithEmailAndPassword)(this.admin.auth, email, password)
.then(async (adminUserCredential) => {
this._identityProviderType = AuthProviderType.EMAIL;
})
.then(() => {
return this._setUidCustomClaims();
})
.catch((error) => {
console.error('Error signing in', error);
throw error;
});
}
/**
* Link the current user with email and password credentials.
*
* This method creates a credential using the provided email and password, and then links the user's account with the current user in both the admin and app Firebase projects.
*
* @param {string} email - The email of the user to link.
* @param {string} password - The password of the user to link.
*
* @returns {Promise<void>} - A promise that resolves when the user is successfully linked with the specified authentication provider.
*/
async linkEmailPasswordWithAuthProvider(email, password) {
this._verifyAuthentication();
const emailCredential = auth_1.EmailAuthProvider.credential(email, password);
return (0, auth_1.linkWithCredential)(this.admin.auth.currentUser, emailCredential)
.catch((error) => {
console.error('Error linking email and password', error);
throw error;
});
}
/**
* Initiates the login process with an email link.
*
* This method sends a sign-in link to the specified email address. The user
* can click on the link to sign in to their account. The sign-in process is
* handled in a separate browser window or tab.
*
* @param {object} params - The parameters for initiating the login process.
* @param {string} params.email - The email address to send the sign-in link to.
* @param {string} params.redirectUrl - The URL to redirect the user to after they click on the sign-in link.
* @returns {Promise<void>} - A promise that resolves when the sign-in link is sent successfully.
*/
async initiateLoginWithEmailLink({ email, redirectUrl }) {
this._verifyInit();
const actionCodeSettings = {
url: redirectUrl,
handleCodeInApp: true,
};
try {
await (0, auth_1.sendSignInLinkToEmail)(this.admin.auth, email, actionCodeSettings);
}
catch (error) {
console.error('Error sending sign in link:', error);
throw error;
}
}
/**
* Check if the given email link is a sign-in with email link.
*
* This method checks if the given email link is a valid sign-in with email link
* for the admin Firebase project. It returns a promise that resolves with a boolean
* value indicating whether the email link is valid or not.
*
* @param {string} emailLink - The email link to check.
* @returns {Promise<boolean>} - A promise that resolves with a boolean value indicating whether the email link is valid or not.
*/
async isSignInWithEmailLink(emailLink) {
this._verifyInit();
return (0, auth_1.isSignInWithEmailLink)(this.admin.auth, emailLink);
}
async signInWithEmailLink({ email, emailLink }) {
this._verifyInit();
return (0, auth_1.signInWithEmailLink)(this.admin.auth, email, emailLink)
.then(async (userCredential) => {
this._identityProviderType = AuthProviderType.EMAIL;
const roarProviderIds = this._getProviderIds();
const roarAdminProvider = new auth_1.OAuthProvider(roarProviderIds.ROAR_ADMIN_PROJECT);
const roarAdminIdToken = await (0, auth_1.getIdToken)(userCredential.user);
const roarAdminCredential = roarAdminProvider.credential({
idToken: roarAdminIdToken,
});
return roarAdminCredential;
})
.then((credential) => {
if (credential) {
return this._setUidCustomClaims();
}
});
}
/**
* Handle the sign-in process in a popup window.
*
* This method handles the sign-in process in a popup window from from an
* external identity provider. It retrieves the user's credentials from the
* popup result and authenticates the user to the admin Firebase project
* using these credentials.
*
* The identity provider token is generally mean to be one-time use only.
* Because of this, the external identity provider's credential cannot be
* reused in the assessment project. To authenticate into the assessment
* project, we ask the admin Firebase project itself to mint a new credential
* for the assessment project. Thus, the external identity providers are used
* only in the admin Firebase project. And the admin Firebase project acts as
* an "external" identity provider for the assessment project.
*
* Therefore, the workflow for this method is as follows:
* 1. Authenticate into the external provider using a popup window.
* 2. Retrieve the external identity provider's credential from the popup result.
* 3. Authenticate into the admin Firebase project with this credential.
* 4. Generate a new "external" credential from the admin Firebase project.
* 5. Authenticate into the assessment Firebase project with the admin project's "external" credential.
* 6. Set UID custom claims by calling setUidCustomClaims().
*
* @param {AuthProviderType} provider - The authentication provider to use. It can be one of the following:
* - AuthProviderType.GOOGLE
*
* @returns {Promise<UserCredential | null>} - A promise that resolves with the user's credential or null.
*/
async signInWithPopup(provider) {
this._verifyInit();
const allowedProviders = [AuthProviderType.GOOGLE];
let authProvider;
if (provider === AuthProviderType.GOOGLE) {
authProvider = new auth_1.GoogleAuthProvider();
}
else {
throw new Error(`provider must be one of ${allowedProviders.join(', ')}. Received ${provider} instead.`);
}
const allowedErrors = ['auth/cancelled-popup-request', 'auth/popup-closed-by-user'];
const swallowAllowedErrors = (error) => {
if (!allowedErrors.includes(error.code)) {
throw error;
}
};
let oAuthAccessToken;
return (0, auth_1.signInWithPopup)(this.admin.auth, authProvider)
.then(async (adminUserCredential) => {
this._identityProviderType = provider;
if (provider === AuthProviderType.GOOGLE) {
const credential = auth_1.GoogleAuthProvider.credentialFromResult(adminUserCredential);
// This gives you a Google Access Token. You can use it to access Google APIs.
// TODO: Find a way to put this in the onAuthStateChanged handler
oAuthAccessToken = credential?.accessToken;
return credential;
}
})
.catch(swallowAllowedErrors)
.then((credential) => {
if (credential) {
const claimsParams = {
identityProviderId: this._identityProviderId,
identityProviderType: this._identityProviderType,
};
return this._setUidCustomClaims(claimsParams);
}
});
}
/**
* Link the current user with the specified authentication provider using a popup window.
*
* This method opens a popup window to allow the user to sign in with the specified authentication provider.
* It then links the user's account with the current user in both the admin and app Firebase projects.
*
* @param {AuthProviderType} provider - The authentication provider to link with. It can be one of the following:
* - AuthProviderType.GOOGLE
*
* @returns {Promise<void>} - A promise that resolves when the user is successfully linked with the specified authentication provider.
*
* @throws {Error} - If the specified provider is not one of the allowed providers, an error is thrown.
*/
async linkAuthProviderWithPopup(provider) {
this._verifyAuthentication();
const allowedProviders = [AuthProviderType.GOOGLE];
let authProvider;
if (provider === AuthProviderType.GOOGLE) {
authProvider = new auth_1.GoogleAuthProvider();
}
else {
throw new Error(`provider must be one of ${allowedProviders.join(', ')}. Received ${provider} instead.`);
}
const allowedErrors = ['auth/cancelled-popup-request', 'auth/popup-closed-by-user'];
const swallowAllowedErrors = (error) => {
if (!allowedErrors.includes(error.code)) {
throw error;
}
};
let oAuthAccessToken;
return (0, auth_1.linkWithPopup)(this.admin.auth.currentUser, authProvider)
.then(async (adminUserCredential) => {
this._identityProviderType = provider;
if (provider === AuthProviderType.GOOGLE) {
const credential = auth_1.GoogleAuthProvider.credentialFromResult(adminUserCredential);
// This gives you a Google Access Token. You can use it to access Google APIs.
// TODO: Find a way to put this in the onAuthStateChanged handler
oAuthAccessToken = credential?.accessToken;
return credential;
}
})
.catch(swallowAllowedErrors)
.then((credential) => {
if (credential) {
const claimsParams = {
identityProviderId: this._identityProviderId,
identityProviderType: this._identityProviderType,
};
return this._setUidCustomClaims(claimsParams);
}
});
}
/**
* Initiates a redirect sign-in flow with the specified authentication provider.
*
* This method triggers a redirect to the authentication provider's sign-in page.
* After the user successfully signs in, they will be redirected back to the application.
*
* If the linkToAuthenticatedUser parameter is set to true, an existing user
* must already be authenticated and the user's account will be linked with
* the new provider.
*
* @param {AuthProviderType} provider - The authentication provider to initiate the sign-in flow with.
* It can be one of the following: AuthProviderType.GOOGLE.
* @param {boolean} linkToAuthenticatedUser - Whether to link an authenticated user's account with the new provider.
*
* @returns {Promise<void>} - A promise that resolves when the redirect sign-in flow is initiated.
* @throws {Error} - If the specified provider is not one of the allowed providers, an error is thrown.
*/
async initiateRedirect(provider, linkToAuthenticatedUser = false) {
this.verboseLog('Entry point for initiateRedirect');
this._verifyInit();
if (linkToAuthenticatedUser) {
this._verifyAuthentication();
}
const allowedProviders = [AuthProviderType.GOOGLE];
let authProvider;
this.verboseLog('Attempting sign in with AuthProvider', provider);
if (provider === AuthProviderType.GOOGLE) {
authProvider = new auth_1.GoogleAuthProvider();
this.verboseLog('Google AuthProvider object:', authProvider);
}
else {
throw new Error(`provider must be one of ${allowedProviders.join(', ')}. Received ${provider} instead.`);
}
this.verboseLog('Calling signInWithRedirect from initiateRedirect with provider', authProvider);
if (linkToAuthenticatedUser) {
return (0, auth_1.linkWithRedirect)(this.admin.auth.currentUser, authProvider);
}
return (0, auth_1.signInWithRedirect)(this.admin.auth, authProvider);
}
/**
* Handle the sign-in process from a redirect result.
*
* This method handles the sign-in process after a user has been redirected
* from an external identity provider. It retrieves the user's credentials
* from the redirect result and authenticates the user to the admin Firebase
* project using the credentials.
*
* The identity provider token is generally mean to be one-time use only.
* Because of this, the external identity provider's credential cannot be
* reused in the assessment project. To authenticate into the assessment
* project, we ask the admin Firebase project itself to mint a new credential
* for the assessment project. Thus, the external identity providers are used
* only in the admin Firebase project. And the admin Firebase project acts as
* an "external" identity provider for the assessment project.
*
* Therefore, the workflow for this method is as follows:
* 1. Get the redirect result from the admin Firebase project.
* 2. Retrieve the external identity provider's credential from the redirect result.
* 3. Authenticate into the admin Firebase project with this credential.
* 4. Generate a new "external" credential from the admin Firebase project.
* 5. Authenticate into the assessment Firebase project with the admin project's "external" credential.
* 6. Set UID custom claims by calling setUidCustomClaims().
*
* @param {() => void} enableCookiesCallback - A callback function to be invoked when the enable cookies error occurs.
* @returns {Promise<{ status: 'ok' } | null>} - A promise that resolves with an object containing the status 'ok' if the sign-in is successful,
* or resolves with null if the sign-in is not successful.
*/
async signInFromRedirectResult(enableCookiesCallback) {
this._verifyInit();
this.verboseLog('Entry point for signInFromRedirectResult');
const catchEnableCookiesError = (error) => {
this.verboseLog('Catching error, checking if it is the enableCookies error');
if (error.code == 'auth/web-storage-unsupported') {
this.verboseLog('Error was known enableCookies error, invoking enableCookiesCallback()');
enableCookiesCallback();
}
else {
this.verboseLog('It was not the known enableCookies error', error);
throw error;
}
};
let oAuthAccessToken;
let authProvider;
this.verboseLog('calling getRedirect result from signInFromRedirect');
return (0, auth_1.getRedirectResult)(this.admin.auth)
.then(async (adminUserCredential) => {
this.verboseLog('Then block for getRedirectResult');
if (adminUserCredential !== null) {
this.verboseLog('adminUserCredential is not null');
const providerId = adminUserCredential.providerId;
const roarProviderIds = this._getProviderIds();
this.verboseLog('providerId is', providerId);
this.verboseLog('roarProviderIds are', roarProviderIds);
if (providerId === roarProviderIds.GOOGLE) {
this.verboseLog('ProviderId is google, calling credentialFromResult with ', adminUserCredential);
const credential = auth_1.GoogleAuthProvider.credentialFromResult(adminUserCredential);
// This gives you a Google Access Token. You can use it to access Google APIs.
// TODO: Find a way to put this in the onAuthStateChanged handler
authProvider = AuthProviderType.GOOGLE;
this._identityProviderType = authProvider;
oAuthAccessToken = credential?.accessToken;
this.verboseLog('oAuthAccessToken = ', oAuthAccessToken);
this.verboseLog('returning credential from first .then() ->', credential);
return credential;
}
}
return null;
})
.catch(catchEnableCookiesError)
.then((credential) => {
this.verboseLog('Attempting to set uid custom claims using credential', credential);
if (credential) {
this.verboseLog('Calling setUidCustomClaims with creds', credential);
const claimsParams = {
identityProviderId: this._identityProviderId,
identityProviderType: this._identityProviderType,
};
return this._setUidCustomClaims(claimsParams);
}
return null;
});
}
/**
* Unlinks the specified authentication provider from the current user.
*
* This method only unlinks the specified provider from the user in the admin Firebase project.
* The roarProciderIds.ROAR_ADMIN_PROJECT provider is maintained in the assessment Firebase project.
*
* @param {AuthProviderType} provider - The authentication provider to unlink.
* It can be one of the following: AuthProviderType.GOOGLE
* @returns {Promise<void>} - A promise that resolves when the provider is unlinked.
* @throws {Error} - If the provided provider is not one of the allowed providers.
*/
async unlinkAuthProvider(provider) {
this._verifyAuthentication();
const allowedProviders = [AuthProviderType.GOOGLE];
const roarProviderIds = this._getProviderIds();
let providerId;
if (provider === AuthProviderType.GOOGLE) {
providerId = roarProviderIds.GOOGLE;
}
else {
throw new Error(`provider must be one of ${allowedProviders.join(', ')}. Received ${provider} instead.`);
}
return (0, auth_1.unlink)(this.admin.auth.currentUser, providerId);
}
/**
* Sign out the current user from both the assessment (aka app) Firebase project and the admin Firebase project.
*
* This method clears the authentication properties and signs out the user from both the app (aka assessment) and admin Firebase projects.
*
* @returns {Promise<void>} - A promise that resolves when the user is successfully signed out.
*/
async signOut() {
this._verifyAuthentication();
if (this._adminClaimsListener)
this._adminClaimsListener();
if (this._adminTokenListener)
this._adminTokenListener();
this._scrubAuthProperties();
await (0, auth_1.signOut)(this.admin.auth);
}
// +--------------------------------+
// ----------| End Authentication Methods |----------
// +--------------------------------+
// +--------------------------------+
// ----------| Begin Methods to Read User and |----------
// ----------| Assignment/Administration Data |----------
// +--------------------------------+
get superAdmin() {
return this._superAdmin;
}
get idTokenReceived() {
return this._idTokenReceived;
}
get idTokens() {
return this._idTokens;
}
get restConfig() {
return {
admin: {
headers: { Authorization: `Bearer ${this._idTokens.admin}` }
},
};
}
get adminOrgs() {
return this._adminOrgs;
}
get dbRefs() {
if (this.admin?.user) {
return {
admin: {
user: (0, firestore_1.doc)(this.admin.db, 'users', this.roarUid),
assignments: (0, firestore_1.collection)(this.admin.db, 'users', this.roarUid, 'assignments'),
runs: (0, firestore_1.collection)(this.admin.db, 'users', this.roarUid, 'runs'),
tasks: (0, firestore_1.collection)(this.admin.db, 'tasks'),
},
};
}
else {
return undefined;
}
}
// Not used, but could be used for task dictionary query in dashboard
async getTasksDictionary() {
this._verifyAuthentication();
const taskDocs = await (0, firestore_1.getDocs)(this.dbRefs.admin.tasks);
// Create a map with document IDs as keys and document data as values
const taskMap = taskDocs.docs.reduce((acc, doc) => {
acc[doc.id] = doc.data();
return acc;
}, {});
return taskMap;
}
async getAdministrations({ testData = false, restrictToOpenAdministrations = false, }) {
this._verifyAuthentication();
const getAdministrationCallable = (0, functions_1.httpsCallable)(this.admin.functions, 'getAdministrations');
const response = (await getAdministrationCallable({
testData,
restrictToOpenAdministrations,
}));
if ((0, get_1.default)(response.data, 'status') !== 'ok') {
throw new Error('Failed to retrieve administration IDs.');
}
return response.data.data ?? [];
}
async getLegalDoc(docName) {
const docRef = (0, firestore_1.doc)(this.admin.db, 'legal', docName);
const docSnap = await (0, firestore_1.getDoc)(docRef);
if (docSnap.exists()) {
const data = docSnap.data();
const gitHubUrl = `https://raw.githubusercontent.com/${(0, get_1.default)(data, 'gitHubOrg')}/${(0, get_1.default)(data, 'gitHubRepository')}/${(0, get_1.default)(data, 'currentCommit')}/${(0, get_1.default)(data, 'fileName')}`;
try {
const response = await fetch(gitHubUrl);
const legalText = await response.text();
return {
text: legalText,
version: (0, get_1.default)(data, 'currentCommit'),
};
}
catch (e) {
throw new Error('Error retrieving consent document from GitHub.');
}
}
else {
return null;
}
}
async updateConsentStatus(docName, consentVersion, params = {}) {
console.log(`Updating consent status for ${this.dbRefs.admin.user.path}.`);
if (!(0, isEmpty_1.default)(params) && (0, get_1.default)(params, 'dateSigned')) {
return await (0, firestore_1.updateDoc)(this.dbRefs.admin.user, {
[`legal.${docName}.${consentVersion}`]: (0, firestore_1.arrayUnion)(params),
});
}
else {
return await (0, firestore_1.updateDoc)(this.dbRefs.admin.user, {
[`legal.${docName}.${consentVersion}`]: (0, firestore_1.arrayUnion)({ dateSigned: new Date() }),
});
}
}
get roarUid() {
return this._roarUid;
}
async getRoarUid() {
const userClaimsRef = (0, firestore_1.doc)(this.admin.db, 'userClaims', this.admin.user.uid);
const userClaims = await (0, firestore_1.getDoc)(userClaimsRef).then((doc) => {
if (!doc.exists()) {
throw new Error('User claims document does not exist.');
}
return doc.data();
});
let _roarUid;
if (!(0, isEmpty_1.default)(userClaims) && userClaims.claims.roarUid) {
_roarUid = userClaims.claims.roarUid;
}
else {
_roarUid = this.admin?.user?.uid;
}
this._roarUid = _roarUid;
return _roarUid;
}
async startAssessment(administrationId, taskId, taskVersion, targetUid) {
this._verifyAuthentication();
const uid = targetUid ?? this.roarUid ?? (await this.getRoarUid());
if (!uid) {
throw new Error('Could not determine user ID');
}
try {
const startTaskCloudFunction = (0, functions_1.httpsCallable)(this.admin.functions, 'startTask');
const result = await startTaskCloudFunction({ administrationId, taskId, targetUid: uid });
if (this.roarAppUserInfo === undefined) {
this.roarAppUserInfo = {
db: this.admin.db,
roarUid: uid,
assessmentUid: this.admin.user.uid,
assessmentPid: result.data.assessmentPid,
userType: result.data.userData.userType,
};
}
const taskInfo = {
db: this.admin.db,
taskId,
// This is fine being hardcoded to undefined since this field does not exist on the assignment document which is where we get the task info from.
// When this is defined, it actually breaks starting the task (permissions error). Can probably be removed.
taskName: undefined,
taskVersion,
variantName: result.data.taskInfo.variantName,
variantParams: result.data.taskInfo.variantParams,
variantId: result.data.taskInfo.variantId,
};
const app = new appkit_1.RoarAppkit({
firebaseProject: this.admin,
userInfo: this.roarAppUserInfo,
assigningOrgs: result.data.assigningOrgs,
readOrgs: result.data.readOrgs,
assignmentId: administrationId,
taskInfo,
});
return app;
}
catch (error) {
console.error('Error starting task: ', error);
throw error;
}
}
async completeAssessment(administrationId, taskId, targetUid) {
this._verifyAuthentication();
const cloudCompleteTask = (0, functions_1.httpsCallable)(this.admin.functions, 'completeTask');
const userId = targetUid ?? this.roarUid ?? (await this.getRoarUid());
if (!userId) {
throw new Error('Could not determine user ID');
}
const result = await cloudCompleteTask({ administrationId, taskId, userId });
return result;
}
// These are all methods that will be important for admins, but not necessary for students
/**
* Create or update an administration
*
* @param input input object
* @param input.name The administration name
* @param input.assessments The list of assessments for this administration
* @param input.dateOpen The start date for this administration
* @param input.dateClose The end date for this administration
* @param input.sequential Whether or not the assessments in this
* administration must be taken sequentially
* @param input.orgs The orgs assigned to this administration
* @param input.tags Metadata tags for this administration
* @param input.administrationId Optional ID of an existing administration. If
* provided, this method will update an
* existing administration.
*/
async upsertAdministration({ name, publicName, assessments, dateOpen, dateClose, sequential = true, orgs = (0, util_1.emptyOrgList)(), tags = [], administrationId, isTestData = false, legal, }) {
this._verifyAuthentication();
this._verifyAdmin();
if ([name, dateOpen, dateClose, assessments].some((param) => param === undefined || param === null)) {
throw new Error('The parameters name, dateOpen, dateClose, and assessments are required');
}
if (dateClose < dateOpen) {
throw new Error(`The end date cannot be before the start date: ${dateClose.toISOString()} < ${dateOpen.toISOString()}`);
}
// Call the Cloud Function
const upsertAdministrationFunction = (0, functions_1.httpsCallable)(this.admin.functions, 'upsertAdministration');
try {
// Pass all arguments directly to the cloud function
const result = await upsertAdministrationFunction({
name,
publicName,
assessments,
dateOpen: dateOpen.toISOString(),
dateClose: dateClose.toISOString(),
sequential,
orgs,
tags,
administrationId,
isTestData,
legal,
});
// You might want to log or use the result if the cloud function returns data
this.verboseLog('upsertAdministration cloud function called successfully:', result);
// Assuming the cloud function returns the administration ID or similar relevant data
return result.data;
}
catch (error) {
console.error('Error calling upsertAdministration cloud function', error);
// Re-throw the error or handle it as appropriate for the application
throw error;
}
}
/**
* Delete an administration
*
* @param administrationId The administration ID to delete
*/
async deleteAdministration(administrationId) {
this._verifyAuthentication();
if (!this._superAdmin) {
throw new Error('You must be a super admin to delete an administration.');
}
const cloudDeleteAdministration = (0, functions_1.httpsCallable)(this.admin.functions, 'deleteAdministration');
const result = await cloudDeleteAdministration({ administrationId });
return result;
}
/**
* Send a password reset email to the specified user's email address.
*
* This will reset the password in the admin Firebase project. The assessment
* Firebase project remains unchanged because we use the admin project's
* credentials to authenticate into the assessment project.
*
* @param {string} email - The email address of the user to send the password reset email to.
* @returns A promise that resolves when the password reset email is sent.
*/
async sendPasswordResetEmail(email) {
return (0, auth_1.sendPasswordResetEmail)(this.admin.auth, email).then(() => {
this.verboseLog('Password reset email sent to', email);
});
}
async createAdministrator(email, name, targetOrgs, targetAdminOrgs, isTestData = false) {
this._verifyAuthentication();
this._verifyAdmin();
const cloudCreateAdministrator = (0, functions_1.httpsCallable)(this.admin.functions, 'createAdministratorAccount');
const adminResponse = await cloudCreateAdministrator({
email,
name,
orgs: targetOrgs,
adminOrgs: targetAdminOrgs,
isTestData,
});
if ((0, get_1.default)(adminResponse.data, 'status') !== 'ok') {
throw new Error('Failed to create administrator user account.');
}
}
/**
* Upserts an organization in the database.
*
* @param orgData The organization data to upsert.
* @returns The upserted organization id.
*/
async upsertOrg(orgData) {
this._verifyAuthentication();
this._verifyAdmin();
const cloudUpsertOrg = (0, functions_1.httpsCallable)(this.admin.functions, 'upsertOrg');
return await cloudUpsertOrg({ orgData });
}
async registerTaskVariant({ taskId, taskName, taskDescription