import {normalizeEmail} from 'app/common/emails'; import {UserProfile} from 'app/common/LoginSessionAPI'; import {SessionStore} from 'app/server/lib/gristSessions'; import log from 'app/server/lib/log'; import {fromCallback} from 'app/server/lib/serverUtils'; import {Request} from 'express'; // Part of a session related to a single user. export interface SessionUserObj { // a grist-internal identify for the user, if known. userId?: number; // The user profile object. profile?: UserProfile; /** * Unix time in seconds of the last successful login. Includes security * verification prompts, such as those for configuring MFA preferences. */ lastLoginTimestamp?: number; /** * The authentication provider. (Typically the JWT "iss".) */ authProvider?: string; /** * Identifier for the user from the authentication provider. (Typically * the JWT "sub".) */ authSubject?: string; // [UNUSED] Login ID token used to access AWS services. idToken?: string; // Login access token used to access other AWS services. accessToken?: string; // Login refresh token used to retrieve new ID and access tokens. refreshToken?: string; // State for SAML-mediated logins. samlNameId?: string; samlSessionIndex?: string; } // Session state maintained for a particular browser. It is identified by a cookie. There may be // several browser windows/tabs that share this cookie and this state. export interface SessionObj { // Session cookie. // This is marked optional to reflect the reality of pre-existing code. cookie?: any; // A list of users we have logged in as. // This is optional since the session may already exist. users?: SessionUserObj[]; // map from org to an index into users[] // This is optional since the session may already exist. orgToUser?: {[org: string]: number}; // This gets set to encourage express-session to set a cookie. Was a boolean in the past. alive?: number; altSessionId?: string; // An ID unique to the session, but which isn't related // to the session id used to lookup the cookie. This ID // is suitable for embedding in documents that allows // anonymous editing (e.g. to allow the user to edit // something they just added, without allowing the suer // to edit other people's contributions). oidc?: { // codeVerifier is used during OIDC authentication, to protect against attacks like CSRF. codeVerifier?: string; } } // Make an artificial change to a session to encourage express-session to set a cookie. export function forceSessionChange(session: SessionObj) { session.alive = Number(session.alive || 0) + 1; } // We expose a sign-in status in a cookie accessible to all subdomains, to assist in auto-signin. // The values are: // - "S": the user is signed in once; in this case an automatic signin can be unambiguous and seamless. // - "M": the user is signed in multiple times. // - "": the user is not signed in. export type SignInStatus = 'S'|'M'|''; export function getSignInStatus(sessionObj: SessionObj|null): SignInStatus { const length = sessionObj?.users?.length; return !length ? "" : (length === 1 ? 'S' : 'M'); } /** * Extract the available user profiles from the session. * */ export function getSessionProfiles(session: SessionObj): UserProfile[] { if (!session.users) { return []; } return session.users.filter(user => user && user.profile).map(user => user.profile!); } /** * * Gets user profile from the session for a given org, returning null if no profile is * found specific to that org. * */ export function getSessionUser(session: SessionObj, org: string, userSelector: string): SessionUserObj|null { if (!session.users) { return null; } if (!session.users.length) { return null; } if (userSelector) { for (const user of session.users) { if (user.profile?.email.toLowerCase() === userSelector.toLowerCase()) { return user; } } } if (session.orgToUser && session.orgToUser[org] !== undefined && session.users.length > session.orgToUser[org]) { return session.users[session.orgToUser[org]] || null; } return null; } /** * * Record which user to use by default for a given org in future. * This method mutates the session object passed to it. It does not save it, * that is up to the caller. * */ export function linkOrgWithEmail(session: SessionObj, email: string, org: string): SessionUserObj { if (!session.users || !session.orgToUser) { throw new Error("Session not set up"); } email = normalizeEmail(email); for (let i = 0; i < session.users.length; i++) { const iUser = session.users[i]; if (iUser && iUser.profile && normalizeEmail(iUser.profile.email) === email) { session.orgToUser[org] = i; return iUser; } } throw new Error("Failed to link org with email"); } /** * * This is a view of the session object, for a single organization (the "scope"). * * Local caching is disabled in an environment where there is a home server (or we are * the home server). In hosted Grist, per-instance caching would be a problem. * * We retain local caching for situations with a single server - especially electron. * */ export class ScopedSession { private _sessionCache?: SessionObj; private _live: boolean; // if set, never cache session in memory. private _altSessionId?: string; /** * Create an interface to the session identified by _sessionId, in the store identified * by _sessionStore, for the organization identified by _scope. */ constructor(private _sessionId: string, private _sessionStore: SessionStore, private _org: string, private _userSelector: string) { // Assume we need to skip cache in a hosted environment. GRIST_HOST is always set there. // TODO: find a cleaner way to configure this flag. this._live = Boolean(process.env.GRIST_HOST || process.env.GRIST_HOSTED); } /** * Get the user entry from the current session. * @param prev: if supplied, this session object is used rather than querying the session again. * @return the user entry */ public async getScopedSession(prev?: SessionObj): Promise { const session = prev || await this._getSession(); return getSessionUser(session, this._org, this._userSelector) || {}; } // Retrieves the user profile from the session. public async getSessionProfile(prev?: SessionObj): Promise { return (await this.getScopedSession(prev)).profile || null; } // Updates a user profile. The session may have multiple profiles associated with different // email addresses. This will update the one with a matching email address, or add a new one. // This is mainly used to know which emails are logged in in this session; fields like name and // picture URL come from the database instead. public async updateUserProfile(req: Request, profile: UserProfile|null): Promise { profile ? await this.updateUser(req, {profile}) : await this.clearScopedSession(req); } /** * Updates the properties of the current session user. * * @param {Partial} newProps New property values to set. */ public async updateUser(req: Request, newProps: Partial): Promise { await this.operateOnScopedSession(req, async user => ({...user, ...newProps})); } /** * * This performs an operation on the session object, limited to a single user entry. The state of that * user entry before and after the operation are returned. LoginSession relies heavily on this method, * to determine whether the change made by an operation merits certain follow-up work. * * @param op: Operation to perform. Given a single user entry, and should return a single user entry. * It is fine to modify the supplied user entry in place. * * @return a pair [prev, current] with the state of the single user entry before and after the operation. * */ public async operateOnScopedSession(req: Request, op: (user: SessionUserObj) => Promise): Promise<[SessionUserObj, SessionUserObj]> { const session = await this._getSession(); const user = await this.getScopedSession(session); const oldUser = JSON.parse(JSON.stringify(user)); // Old version to compare against. const newUser = await op(JSON.parse(JSON.stringify(user))); // Modify a scratch version. if (Object.keys(newUser).length === 0) { await this.clearScopedSession(req, session); } else { await this._updateScopedSession(req, newUser, session); } return [oldUser, newUser]; } /** * This clears the current user entry from the session. * @param prev: if supplied, this session object is used rather than querying the session again. */ public async clearScopedSession(req: Request, prev?: SessionObj): Promise { const session = prev || await this._getSession(); this._clearUser(session); await this._setSession(req, session); } public getAltSessionId(): string | undefined { return this._altSessionId; } /** * Read the state of the session. */ private async _getSession(): Promise { if (this._sessionCache) { return this._sessionCache; } const session = ((await this._sessionStore.getAsync(this._sessionId)) || {}) as SessionObj; if (!this._live) { this._sessionCache = session; } this._altSessionId = session.altSessionId; return session; } /** * Set the session to the supplied object. */ private async _setSession(req: Request, session: SessionObj): Promise { try { await this._sessionStore.setAsync(this._sessionId, session); if (!this._live) { this._sessionCache = session; } const reqSession = (req as any).session; if (reqSession?.reload) { await fromCallback(cb => reqSession.reload(cb)); } } catch (e) { // (I've copied this from old code, not sure if continuing after a session save error is // something existing code depends on?) // Report and keep going. This ensures that the session matches what's in the sessionStore. log.error(`ScopedSession[${this._sessionId}]: Error updating sessionStore: ${e}`); } } /** * Update the session with the supplied user entry, replacing anything for that user already there. * @param user: user entry to insert in session * @param prev: if supplied, this session object is used rather than querying the session again. * */ private async _updateScopedSession(req: Request, user: SessionUserObj, prev?: SessionObj): Promise { const profile = user.profile; if (!profile) { throw new Error("No profile available"); } // We used to also check profile.email_verified, but we no longer create UserProfile objects // unless the email is verified, so this check is no longer needed. if (!profile.email) { throw new Error("Profile has no email address"); } const session = prev || await this._getSession(); if (!session.users) { session.users = []; } if (!session.orgToUser) { session.orgToUser = {}; } let index = session.users.findIndex(u => { return Boolean(u.profile && normalizeEmail(u.profile.email) === normalizeEmail(profile.email)); }); if (index < 0) { index = session.users.length; } session.orgToUser[this._org] = index; session.users[index] = user; await this._setSession(req, session); } /** * This clears all user logins (not just the current login). * In future, we may want to be able to log in and out selectively, slack style, * but right now it seems confusing. */ private _clearUser(session: SessionObj): void { session.users = []; session.orgToUser = {}; } }