diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 00cde1e8..055de845 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -135,6 +135,8 @@ export interface Document extends DocumentProperties { export interface UserOptions { // Whether signing in with Google is allowed. Defaults to true if unset. allowGoogleLogin?: boolean; + // The "sub" (subject) from the JWT issued by the password-based authentication provider. + authSubject?: string; // Whether user is a consultant. Consultant users can be added to sites // without being counted for billing. Defaults to false if unset. isConsultant?: boolean; diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 871efbda..0b6f0277 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -203,6 +203,12 @@ export interface DocAuthResult { cachedDoc?: Document; // For cases where stale info is ok. } +interface GetUserOptions { + manager?: EntityManager; + profile?: UserProfile; + userOptions?: UserOptions; +} + // Represent a DocAuthKey as a string. The format is ": ". // flushSingleDocAuthCache() depends on this format. function stringifyDocAuthKey(key: DocAuthKey): string { @@ -402,7 +408,7 @@ export class HomeDBManager extends EventEmitter { public async testClearUserPrefs(emails: string[]) { return await this._connection.transaction(async manager => { for (const email of emails) { - const user = await this.getUserByLogin(email, undefined, manager); + const user = await this.getUserByLogin(email, {manager}); if (user) { await manager.delete(Pref, {userId: user.id}); } @@ -493,16 +499,16 @@ export class HomeDBManager extends EventEmitter { // for an email key conflict failure. This is in case our transaction conflicts with a peer // doing the same thing. This is quite likely if the first page visited by a previously // unseen user fires off multiple api calls. - public async getUserByLoginWithRetry(email: string, profile: UserProfile): Promise { + public async getUserByLoginWithRetry(email: string, options: GetUserOptions = {}): Promise { try { - return await this.getUserByLogin(email, profile); + return await this.getUserByLogin(email, options); } catch (e) { if (e.name === 'QueryFailedError' && e.detail && e.detail.match(/Key \(email\)=[^ ]+ already exists/)) { // This is a postgres-specific error message. This problem cannot arise in sqlite, // because we have to serialize sqlite transactions in any case to get around a typeorm // limitation. - return await this.getUserByLogin(email, profile); + return await this.getUserByLogin(email, options); } throw e; } @@ -513,15 +519,12 @@ export class HomeDBManager extends EventEmitter { * Fetches a user record based on an email address. If a user record already * exists linked to the email address supplied, that is the record returned. * Otherwise a fresh record is created, linked to the supplied email address. - * The name supplied is used to create this fresh record - otherwise it is - * ignored. + * The supplied `options` are used when creating a fresh record, or updating + * unset/outdated fields of an existing record. * */ - public async getUserByLogin( - email: string, - profile?: UserProfile, - transaction?: EntityManager - ): Promise { + public async getUserByLogin(email: string, options: GetUserOptions = {}): Promise { + const {manager: transaction, profile, userOptions} = options; const normalizedEmail = normalizeEmail(email); const userByLogin = await this._runInTransaction(transaction, async manager => { let needUpdate = false; @@ -580,6 +583,11 @@ export class HomeDBManager extends EventEmitter { login.displayEmail = email; needUpdate = true; } + if (!user.options?.authSubject && userOptions?.authSubject) { + // Link subject from password-based authentication provider if not previously linked. + user.options = {...(user.options ?? {}), authSubject: userOptions.authSubject}; + needUpdate = true; + } if (needUpdate) { login.user = user; await manager.save([user, login]); @@ -2879,7 +2887,7 @@ export class HomeDBManager extends EventEmitter { // get or create user - with retry, since there'll be a race to create the // user if a bunch of servers start simultaneously and the user doesn't exist // yet. - const user = await this.getUserByLoginWithRetry(profile.email, profile); + const user = await this.getUserByLoginWithRetry(profile.email, {profile}); if (user) { id = this._specialUserIds[profile.email] = user.id; } } if (!id) { throw new Error(`Could not find or create user ${profile.email}`); } @@ -2979,7 +2987,7 @@ export class HomeDBManager extends EventEmitter { const emailMap = delta.users; const emails = Object.keys(emailMap); const emailUsers = await Promise.all( - emails.map(async email => await this.getUserByLogin(email, undefined, transaction)) + emails.map(async email => await this.getUserByLogin(email, {manager: transaction})) ); emails.forEach((email, i) => { const userIdAffected = emailUsers[i]!.id; diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts index a1030139..9ddcd7af 100644 --- a/app/server/lib/Authorizer.ts +++ b/app/server/lib/Authorizer.ts @@ -3,6 +3,7 @@ import {OpenDocMode} from 'app/common/DocListAPI'; import {ErrorWithCode} from 'app/common/ErrorWithCode'; import {FullUser, UserProfile} from 'app/common/LoginSessionAPI'; import {canEdit, canView, getWeakestRole, Role} from 'app/common/roles'; +import {UserOptions} from 'app/common/UserAPI'; import {Document} from 'app/gen-server/entity/Document'; import {User} from 'app/gen-server/entity/User'; import {DocAuthKey, DocAuthResult, HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; @@ -243,8 +244,14 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer // Modify request session object to link the current org with our choice of // profile. Express-session will save this change. sessionUser = linkOrgWithEmail(session, option.email, mreq.org); + const userOptions: UserOptions = {}; + if (sessionUser?.profile?.loginMethod === 'Email + Password') { + // Link the session authSubject, if present, to the user. This has no effect + // if the user already has an authSubject set in the db. + userOptions.authSubject = sessionUser.authSubject; + } // In this special case of initially linking a profile, we need to look up the user's info. - mreq.user = await dbManager.getUserByLogin(option.email); + mreq.user = await dbManager.getUserByLogin(option.email, {userOptions}); mreq.userId = option.id; mreq.userIsAuthorized = true; } else { @@ -263,12 +270,18 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer } } - profile = sessionUser && sessionUser.profile || undefined; + profile = sessionUser?.profile ?? undefined; // If we haven't computed a userId yet, check for one using an email address in the profile. // A user record will be created automatically for emails we've never seen before. if (profile && !mreq.userId) { - const user = await dbManager.getUserByLoginWithRetry(profile.email, profile); + const userOptions: UserOptions = {}; + if (profile?.loginMethod === 'Email + Password') { + // Link the session authSubject, if present, to the user. This has no effect + // if the user already has an authSubject set in the db. + userOptions.authSubject = sessionUser.authSubject; + } + const user = await dbManager.getUserByLoginWithRetry(profile.email, {profile, userOptions}); if (user) { mreq.user = user; mreq.userId = user.id; @@ -280,7 +293,7 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer if (!mreq.userId) { profile = getRequestProfile(mreq); if (profile) { - const user = await dbManager.getUserByLoginWithRetry(profile.email, profile); + const user = await dbManager.getUserByLoginWithRetry(profile.email, {profile}); if(user) { mreq.user = user; mreq.users = [profile]; @@ -290,7 +303,6 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer } } - // If no userId has been found yet, fall back on anonymous. if (!mreq.userId) { const anon = dbManager.getAnonymousUser(); diff --git a/app/server/lib/BrowserSession.ts b/app/server/lib/BrowserSession.ts index b21b1013..81df20ce 100644 --- a/app/server/lib/BrowserSession.ts +++ b/app/server/lib/BrowserSession.ts @@ -19,9 +19,17 @@ export interface SessionUserObj { */ lastLoginTimestamp?: number; - // [UNUSED] Authentication provider string indicating the login method used. + /** + * 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; diff --git a/app/server/lib/MinimalLogin.ts b/app/server/lib/MinimalLogin.ts index e792dd81..71f82433 100644 --- a/app/server/lib/MinimalLogin.ts +++ b/app/server/lib/MinimalLogin.ts @@ -24,7 +24,7 @@ export async function getMinimalLoginSystem(): Promise { // If working without a login system, make sure default user exists. const dbManager = gristServer.getHomeDBManager(); const profile = getDefaultProfile(); - const user = await dbManager.getUserByLoginWithRetry(profile.email, profile); + const user = await dbManager.getUserByLoginWithRetry(profile.email, {profile}); if (user) { // No need to survey this user! user.isFirstTimeUser = false; diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts index 718e12c7..45bf6eea 100644 --- a/app/server/lib/requestUtils.ts +++ b/app/server/lib/requestUtils.ts @@ -21,6 +21,7 @@ export const TEST_HTTPS_OFFSET = process.env.GRIST_TEST_HTTPS_OFFSET ? const INTERNAL_FIELDS = new Set([ 'apiKey', 'billingAccountId', 'firstLoginAt', 'filteredOut', 'ownerId', 'gracePeriodStart', 'stripeCustomerId', 'stripeSubscriptionId', 'stripePlanId', 'stripeProductId', 'userId', 'isFirstTimeUser', 'allowGoogleLogin', + 'authSubject', ]); /** diff --git a/stubs/app/server/server.ts b/stubs/app/server/server.ts index 98e95ea4..5ea64259 100644 --- a/stubs/app/server/server.ts +++ b/stubs/app/server/server.ts @@ -78,10 +78,8 @@ export async function main() { if (!email) { throw new Error('need GRIST_DEFAULT_EMAIL to create site'); } - const user = await db.getUserByLogin(email, { - email, - name: email, - }); + const profile = {email, name: email}; + const user = await db.getUserByLogin(email, {profile}); if (!user) { // This should not happen. throw new Error('failed to create GRIST_DEFAULT_EMAIL user'); diff --git a/test/gen-server/testUtils.ts b/test/gen-server/testUtils.ts index 81dc18a1..827a0c19 100644 --- a/test/gen-server/testUtils.ts +++ b/test/gen-server/testUtils.ts @@ -32,7 +32,7 @@ export function configForUser(username: string): AxiosRequestConfig { export async function createUser(dbManager: HomeDBManager, name: string): Promise { const username = name.toLowerCase(); const email = `${username}@getgrist.com`; - const user = await dbManager.getUserByLogin(email, {email, name}); + const user = await dbManager.getUserByLogin(email, {profile: {email, name}}); if (!user) { throw new Error('failed to create user'); } user.apiKey = `api_key_for_${username}`; await user.save(); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index bef64ae9..fc8617ce 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1760,10 +1760,8 @@ export async function addSupportUserIfPossible() { if (!server.isExternalServer() && process.env.TEST_SUPPORT_API_KEY) { // Make sure we have a test support user. const dbManager = await server.getDatabase(); - const user = await dbManager.getUserByLoginWithRetry('support@getgrist.com', { - email: 'support@getgrist.com', - name: 'Support', - }); + const profile = {email: 'support@getgrist.com', name: 'Support'}; + const user = await dbManager.getUserByLoginWithRetry('support@getgrist.com', {profile}); if (!user) { throw new Error('Failed to create test support user'); }