diff --git a/README.md b/README.md
index 4da13602..c9a5a18c 100644
--- a/README.md
+++ b/README.md
@@ -206,13 +206,14 @@ GRIST_HIDE_UI_ELEMENTS | comma-separated list of parts of the UI to hide. Allowe
GRIST_HOME_INCLUDE_STATIC | if set, home server also serves static resources
GRIST_HOST | hostname to use when listening on a port.
GRIST_ID_PREFIX | for subdomains of form o-*, expect or produce o-${GRIST_ID_PREFIX}*.
+GRIST_IGNORE_SESSION | if set, Grist will not use a session for authentication.
GRIST_INST_DIR | path to Grist instance configuration files, for Grist server.
GRIST_MANAGED_WORKERS | if set, Grist can assume that if a url targeted at a doc worker returns a 404, that worker is gone
GRIST_MAX_UPLOAD_ATTACHMENT_MB | max allowed size for attachments (0 or empty for unlimited).
GRIST_MAX_UPLOAD_IMPORT_MB | max allowed size for imports (except .grist files) (0 or empty for unlimited).
GRIST_ORG_IN_PATH | if true, encode org in path rather than domain
GRIST_PAGE_TITLE_SUFFIX | a string to append to the end of the `
` in HTML documents. Defaults to `" - Grist"`. Set to `_blank` for no suffix at all.
-GRIST_PROXY_AUTH_HEADER | header which will be set by a (reverse) proxy webserver with an authorized users' email. This can be used as an alternative to a SAML service.
+GRIST_PROXY_AUTH_HEADER | header which will be set by a (reverse) proxy webserver with an authorized users' email. This can be used as an alternative to a SAML service. See also GRIST_FORWARD_AUTH_HEADER.
GRIST_ROUTER_URL | optional url for an api that allows servers to be (un)registered with a load balancer
GRIST_SERVE_SAME_ORIGIN | set to "true" to access home server and doc workers on the same protocol-host-port as the top-level page, same as for custom domains (careful, host header should be trustworthy)
GRIST_SESSION_COOKIE | if set, overrides the name of Grist's cookie
@@ -237,6 +238,25 @@ GRIST_SANDBOX | a program or image name to run as the sandbox. See NSandbox.ts f
PYTHON_VERSION | can be 2 or 3. If set, documents without an engine setting are assumed to use the specified version of python. Not all sandboxes support all versions.
PYTHON_VERSION_ON_CREATION | can be 2 or 3. If set, newly created documents have an engine setting set to python2 or python3. Not all sandboxes support all versions.
+Forward authentication variables:
+
+Variable | Purpose
+-------- | -------
+GRIST_FORWARD_AUTH_HEADER | if set, trust the specified header (e.g. "x-forwarded-user") to contain authorized user emails, and enable "forward auth" logins.
+GRIST_FORWARD_AUTH_LOGIN_PATH | if GRIST_FORWARD_AUTH_HEADER is set, Grist will listen at this path for logins. Defaults to `/auth/login`.
+GRIST_FORWARD_AUTH_LOGOUT_PATH | if GRIST_FORWARD_AUTH_HEADER is set, Grist will forward to this path when user logs out.
+
+When using forward authentication, you may wish to also set the following variables:
+
+ * GRIST_FORCE_LOGIN=true to disable anonymous access.
+ * GRIST_IGNORE_SESSION=true to ignore any user identity information in a cookie.
+ Only do this if you use forward authentication on all paths.
+ You may not want to use forward authentication on all paths if it makes
+ signing in required, and you are trying to permit anonymous access.
+
+GRIST_FORWARD_AUTH_HEADER is similar to GRIST_PROXY_AUTH_HEADER, but enables
+a login system (assuming you have some forward authentication set up).
+
Google Drive integrations:
Variable | Purpose
diff --git a/app/common/gutil.ts b/app/common/gutil.ts
index 5ca6af0d..b3c37676 100644
--- a/app/common/gutil.ts
+++ b/app/common/gutil.ts
@@ -150,6 +150,17 @@ export function undef>(...list: T): Undef {
return undefined as any;
}
+/**
+ * Like undef, but each element of list is a method that is only called
+ * if needed, and promises are supported. No fancy type inference though, sorry.
+ */
+export async function firstDefined(...list: Array<() => Promise>): Promise {
+ for(const op of list) {
+ const value = await op();
+ if (value !== undefined) { return value; }
+ }
+ return undefined;
+}
/**
* Parses json and returns the result, or returns defaultVal if parsing fails.
diff --git a/app/server/lib/AppSettings.ts b/app/server/lib/AppSettings.ts
index f4226dd0..aff55934 100644
--- a/app/server/lib/AppSettings.ts
+++ b/app/server/lib/AppSettings.ts
@@ -80,6 +80,17 @@ export class AppSettings {
return result;
}
+ /**
+ * As for read() but type (and store, and report) the result as
+ * a boolean.
+ */
+ public readBool(query: AppSettingQuery): boolean|undefined {
+ this.readString(query);
+ const result = this.getAsBool();
+ this._value = result;
+ return result;
+ }
+
/* set this setting 'manually' */
public set(value: JSONValue): void {
this._value = value;
diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts
index 433260fd..1924a726 100644
--- a/app/server/lib/Authorizer.ts
+++ b/app/server/lib/Authorizer.ts
@@ -98,11 +98,14 @@ export function isSingleUserMode(): boolean {
* Returns a profile if it can be deduced from the request. This requires a
* header to specify the users' email address. The header to set comes from the
* environment variable GRIST_PROXY_AUTH_HEADER, or may be passed in.
+ * A result of null means that the user should be considered known to be anonymous.
+ * A result of undefined means we should go on to consider other authentication
+ * methods (such as cookies).
*/
export function getRequestProfile(req: Request|IncomingMessage,
- header?: string): UserProfile|undefined {
+ header?: string): UserProfile|null|undefined {
header = header || process.env.GRIST_PROXY_AUTH_HEADER;
- let profile: UserProfile|undefined;
+ let profile: UserProfile|null|undefined;
if (header) {
// Careful reading headers. If we have an IncomingMessage, there is no
@@ -118,8 +121,13 @@ export function getRequestProfile(req: Request|IncomingMessage,
};
}
}
+ // If no profile at this point, and header was present,
+ // treat as anonymous user, represented by null value.
+ // Don't go on to look at session.
+ if (!profile && headerContent !== undefined) {
+ profile = null;
+ }
}
-
return profile;
}
@@ -134,6 +142,10 @@ export function getRequestProfile(req: Request|IncomingMessage,
* - req.users: set for org-and-session-based logins, with list of profiles in session
*/
export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPermitStore,
+ options: {
+ skipSession?: boolean,
+ getProfile?(req: Request|IncomingMessage): Promise,
+ },
req: Request, res: Response, next: NextFunction) {
const mreq = req as RequestWithLogin;
let profile: UserProfile|undefined;
@@ -184,125 +196,137 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
return res.status(401).send('Bad request (missing header)');
}
+ // For some configurations, the user profile can be determined from the request.
+ // If this is the case, we won't use session information.
+ let skipSession: boolean = options.skipSession || false;
+ if (!mreq.userId) {
+ let candidate = await options.getProfile?.(mreq);
+ if (candidate === undefined) {
+ candidate = getRequestProfile(mreq);
+ }
+ if (candidate !== undefined) {
+ skipSession = true;
+ }
+ if (candidate) {
+ profile = candidate;
+ const user = await dbManager.getUserByLoginWithRetry(profile.email, {profile});
+ if (user) {
+ mreq.user = user;
+ mreq.users = [profile];
+ mreq.userId = user.id;
+ mreq.userIsAuthorized = true;
+ }
+ }
+ }
+
// A bit of extra info we'll add to the "Auth" log message when this request passes the check
// for custom-host-specific sessionID.
let customHostSession = '';
- // If we haven't selected a user by other means, and have profiles available in the
- // session, then select a user based on those profiles.
- const session = mreq.session;
- if (session && !session.altSessionId) {
- // Create a default alternative session id for use in documents.
- session.altSessionId = makeId();
- forceSessionChange(session);
- }
- mreq.altSessionId = session?.altSessionId;
- if (!mreq.userId && session && session.users && session.users.length > 0 &&
- mreq.org !== undefined) {
-
- // Prevent using custom-domain sessionID to authorize to a different domain, since
- // custom-domain owner could hijack such sessions.
- const allowedOrg = getAllowedOrgForSessionID(mreq.sessionID);
- if (allowedOrg) {
- if (allowHost(req, allowedOrg.host)) {
- customHostSession = ` custom-host-match ${allowedOrg.host}`;
- } else {
- // We need an exception for internal forwarding from home server to doc-workers. These use
- // internal hostnames, so we can't expect a custom domain. These requests do include an
- // Organization header, which we'll use to grant the exception, but security issues remain.
- // TODO Issue 1: an attacker can use a custom-domain request to get an API key, which is an
- // open door to all orgs accessible by this user.
- // TODO Issue 2: Organization header is easy for an attacker (who has stolen a session
- // cookie) to include too; it does nothing to prove that the request is internal.
- const org = req.header('organization');
- if (org && org === allowedOrg.org) {
- customHostSession = ` custom-host-fwd ${org}`;
+ if (!skipSession) {
+ // If we haven't selected a user by other means, and have profiles available in the
+ // session, then select a user based on those profiles.
+ const session = mreq.session;
+ if (session && !session.altSessionId) {
+ // Create a default alternative session id for use in documents.
+ session.altSessionId = makeId();
+ forceSessionChange(session);
+ }
+ mreq.altSessionId = session?.altSessionId;
+ if (!mreq.userId && session && session.users && session.users.length > 0 &&
+ mreq.org !== undefined) {
+
+ // Prevent using custom-domain sessionID to authorize to a different domain, since
+ // custom-domain owner could hijack such sessions.
+ const allowedOrg = getAllowedOrgForSessionID(mreq.sessionID);
+ if (allowedOrg) {
+ if (allowHost(req, allowedOrg.host)) {
+ customHostSession = ` custom-host-match ${allowedOrg.host}`;
} else {
- // Log error and fail.
- log.warn("Auth[%s]: sessionID for host %s org %s; wrong for host %s org %s", mreq.method,
- allowedOrg.host, allowedOrg.org, mreq.get('host'), mreq.org);
- return res.status(403).send('Bad request: invalid session ID');
+ // We need an exception for internal forwarding from home server to doc-workers. These use
+ // internal hostnames, so we can't expect a custom domain. These requests do include an
+ // Organization header, which we'll use to grant the exception, but security issues remain.
+ // TODO Issue 1: an attacker can use a custom-domain request to get an API key, which is an
+ // open door to all orgs accessible by this user.
+ // TODO Issue 2: Organization header is easy for an attacker (who has stolen a session
+ // cookie) to include too; it does nothing to prove that the request is internal.
+ const org = req.header('organization');
+ if (org && org === allowedOrg.org) {
+ customHostSession = ` custom-host-fwd ${org}`;
+ } else {
+ // Log error and fail.
+ log.warn("Auth[%s]: sessionID for host %s org %s; wrong for host %s org %s", mreq.method,
+ allowedOrg.host, allowedOrg.org, mreq.get('host'), mreq.org);
+ return res.status(403).send('Bad request: invalid session ID');
+ }
}
}
- }
- mreq.users = getSessionProfiles(session);
+ mreq.users = getSessionProfiles(session);
- // If we haven't set a maxAge yet, set it now.
- if (session && session.cookie && !session.cookie.maxAge) {
- if (COOKIE_MAX_AGE !== null) {
- session.cookie.maxAge = COOKIE_MAX_AGE;
- forceSessionChange(session);
+ // If we haven't set a maxAge yet, set it now.
+ if (session && session.cookie && !session.cookie.maxAge) {
+ if (COOKIE_MAX_AGE !== null) {
+ session.cookie.maxAge = COOKIE_MAX_AGE;
+ forceSessionChange(session);
+ }
}
- }
- // See if we have a profile linked with the active organization already.
- // TODO: implement userSelector for rest API, to allow "sticky" user selection on pages.
- let sessionUser: SessionUserObj|null = getSessionUser(session, mreq.org, optStringParam(mreq.query.user) || '');
-
- if (!sessionUser) {
- // No profile linked yet, so let's elect one.
- // Choose a profile that is no worse than the others available.
- const option = await dbManager.getBestUserForOrg(mreq.users, mreq.org);
- if (option) {
- // 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);
+ // See if we have a profile linked with the active organization already.
+ // TODO: implement userSelector for rest API, to allow "sticky" user selection on pages.
+ let sessionUser: SessionUserObj|null = getSessionUser(session, mreq.org, optStringParam(mreq.query.user) || '');
+
+ if (!sessionUser) {
+ // No profile linked yet, so let's elect one.
+ // Choose a profile that is no worse than the others available.
+ const option = await dbManager.getBestUserForOrg(mreq.users, mreq.org);
+ if (option) {
+ // 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, {userOptions});
+ mreq.userId = option.id;
+ mreq.userIsAuthorized = true;
+ } else {
+ // No profile has access to this org. We could choose to
+ // link no profile, in which case user will end up
+ // immediately presented with a sign-in page, or choose to
+ // link an arbitrary profile (say, the first one the user
+ // logged in as), in which case user will end up with a
+ // friendlier page explaining the situation and offering to
+ // add an account to resolve it. We go ahead and pick an
+ // arbitrary profile.
+ sessionUser = session.users[0];
+ if (!session.orgToUser) { throw new Error("Session misconfigured"); }
+ // Express-session will save this change.
+ session.orgToUser[mreq.org] = 0;
+ }
+ }
+
+ 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 userOptions: UserOptions = {};
- if (sessionUser?.profile?.loginMethod === 'Email + Password') {
+ 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;
}
- // 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, {userOptions});
- mreq.userId = option.id;
- mreq.userIsAuthorized = true;
- } else {
- // No profile has access to this org. We could choose to
- // link no profile, in which case user will end up
- // immediately presented with a sign-in page, or choose to
- // link an arbitrary profile (say, the first one the user
- // logged in as), in which case user will end up with a
- // friendlier page explaining the situation and offering to
- // add an account to resolve it. We go ahead and pick an
- // arbitrary profile.
- sessionUser = session.users[0];
- if (!session.orgToUser) { throw new Error("Session misconfigured"); }
- // Express-session will save this change.
- session.orgToUser[mreq.org] = 0;
- }
- }
-
- 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 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;
- mreq.userIsAuthorized = true;
- }
- }
- }
-
- if (!mreq.userId) {
- profile = getRequestProfile(mreq);
- if (profile) {
- const user = await dbManager.getUserByLoginWithRetry(profile.email, {profile});
- if(user) {
- mreq.user = user;
- mreq.users = [profile];
- mreq.userId = user.id;
- mreq.userIsAuthorized = true;
+ const user = await dbManager.getUserByLoginWithRetry(profile.email, {profile, userOptions});
+ if (user) {
+ mreq.user = user;
+ mreq.userId = user.id;
+ mreq.userIsAuthorized = true;
+ }
}
}
}
diff --git a/app/server/lib/Comm.ts b/app/server/lib/Comm.ts
index 8dbd16bb..0fc5896f 100644
--- a/app/server/lib/Comm.ts
+++ b/app/server/lib/Comm.ts
@@ -23,13 +23,14 @@ import * as WebSocket from 'ws';
import {CommDocEventType, CommMessage} from 'app/common/CommTypes';
import {parseFirstUrlPart} from 'app/common/gristUrls';
-import {safeJsonParse} from 'app/common/gutil';
+import {firstDefined, safeJsonParse} from 'app/common/gutil';
import {UserProfile} from 'app/common/LoginSessionAPI';
import * as version from 'app/common/version';
import {getRequestProfile} from 'app/server/lib/Authorizer';
import {ScopedSession} from "app/server/lib/BrowserSession";
import {Client, ClientMethod} from "app/server/lib/Client";
import {Hosts, RequestWithOrg} from 'app/server/lib/extractOrg';
+import {GristLoginMiddleware} from 'app/server/lib/GristServer';
import * as log from 'app/server/lib/log';
import {localeFromRequest} from 'app/server/lib/ServerLocale';
import {fromCallback} from 'app/server/lib/serverUtils';
@@ -39,6 +40,7 @@ export interface CommOptions {
sessions: Sessions; // A collection of all sessions for this instance of Grist
settings?: {[key: string]: unknown}; // The config object containing instance settings including features.
hosts?: Hosts; // If set, we use hosts.getOrgInfo(req) to extract an organization from a (possibly versioned) url.
+ loginMiddleware?: GristLoginMiddleware; // If set, use custom getProfile method if available
httpsServer?: https.Server; // An optional HTTPS server to listen on too.
}
@@ -55,18 +57,9 @@ export interface CommOptions {
*/
export class Comm extends EventEmitter {
// Collection of all sessions; maps sessionIds to ScopedSession objects.
- public readonly sessions: Sessions;
+ public readonly sessions: Sessions = this._options.sessions;
private _wss: WebSocket.Server[]|null = null;
- // The config object containing instance settings including features.
- private _settings?: {[key: string]: unknown};
-
- // If set, we use hosts.getOrgInfo(req) to extract an organization from a (possibly versioned) url.
- private _hosts?: Hosts;
-
- // An optional HTTPS server to listen on too.
- private _httpsServer?: https.Server;
-
private _clients = new Map(); // Maps clientIds to Client objects.
private _methods = new Map(); // Maps method names to their implementation.
@@ -77,14 +70,9 @@ export class Comm extends EventEmitter {
// for a valid server.
private _serverVersion: string|null = null;
- constructor(private _server: http.Server, options: CommOptions) {
+ constructor(private _server: http.Server, private _options: CommOptions) {
super();
- this._httpsServer = options.httpsServer;
this._wss = this._startServer();
-
- this.sessions = options.sessions;
- this._settings = options.settings;
- this._hosts = options.hosts;
}
/**
@@ -186,7 +174,11 @@ export class Comm extends EventEmitter {
* Returns a profile based on the request or session.
*/
private async _getSessionProfile(scopedSession: ScopedSession, req: http.IncomingMessage): Promise {
- return getRequestProfile(req) || scopedSession.getSessionProfile();
+ return await firstDefined(
+ async () => this._options.loginMiddleware?.getProfile?.(req),
+ async () => getRequestProfile(req),
+ async () => scopedSession.getSessionProfile(),
+ ) || null;
}
/**
@@ -194,12 +186,12 @@ export class Comm extends EventEmitter {
*/
private async _onWebSocketConnection(websocket: WebSocket, req: http.IncomingMessage) {
log.info("Comm: Got WebSocket connection: %s", req.url);
- if (this._hosts) {
+ if (this._options.hosts) {
// DocWorker ID (/dw/) and version tag (/v/) may be present in this request but are not
// needed. addOrgInfo assumes req.url starts with /o/ if present.
req.url = parseFirstUrlPart('dw', req.url!).path;
req.url = parseFirstUrlPart('v', req.url).path;
- await this._hosts.addOrgInfo(req);
+ await this._options.hosts.addOrgInfo(req);
}
// Parse the cookie in the request to get the sessionId.
@@ -232,7 +224,7 @@ export class Comm extends EventEmitter {
client.sendConnectMessage({
serverVersion: this._serverVersion || version.gitcommit,
- settings: this._settings,
+ settings: this._options.settings,
})
.catch(err => {
log.error(`Comm ${client}: failed to prepare or send clientConnect:`, err);
@@ -241,7 +233,7 @@ export class Comm extends EventEmitter {
private _startServer() {
const servers = [this._server];
- if (this._httpsServer) { servers.push(this._httpsServer); }
+ if (this._options.httpsServer) { servers.push(this._options.httpsServer); }
const wss = [];
for (const server of servers) {
const wssi = new WebSocket.Server({server});
diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts
index 9f80b9e7..f77279e8 100644
--- a/app/server/lib/FlexServer.ts
+++ b/app/server/lib/FlexServer.ts
@@ -178,7 +178,12 @@ export class FlexServer implements GristServer {
this.info.push(['docsRoot', this.docsRoot]);
const homeUrl = process.env.APP_HOME_URL;
- this._defaultBaseDomain = options.baseDomain || (homeUrl && parseSubdomain(new URL(homeUrl).hostname).base);
+ // The "base domain" is only a thing if orgs are encoded as a subdomain.
+ if (process.env.GRIST_ORG_IN_PATH === 'true' || process.env.GRIST_SINGLE_ORG) {
+ this._defaultBaseDomain = options.baseDomain || (homeUrl && new URL(homeUrl).hostname);
+ } else {
+ this._defaultBaseDomain = options.baseDomain || (homeUrl && parseSubdomain(new URL(homeUrl).hostname).base);
+ }
this.info.push(['defaultBaseDomain', this._defaultBaseDomain]);
this._pluginUrl = options.pluginUrl || process.env.APP_UNTRUSTED_URL;
this.info.push(['pluginUrl', this._pluginUrl]);
@@ -488,13 +493,23 @@ export class FlexServer implements GristServer {
// Set up the main express middleware used. For a single user setup, without logins,
// all this middleware is currently a no-op.
public addAccessMiddleware() {
- if (this._check('middleware', 'map', isSingleUserMode() ? null : 'hosts')) { return; }
+ if (this._check('middleware', 'map', 'config', isSingleUserMode() ? null : 'hosts')) { return; }
if (!isSingleUserMode()) {
+ const skipSession = appSettings.section('login').flag('skipSession').readBool({
+ envVar: 'GRIST_IGNORE_SESSION',
+ });
// Middleware to redirect landing pages to preferred host
this._redirectToHostMiddleware = this._hosts.redirectHost;
// Middleware to add the userId to the express request object.
- this._userIdMiddleware = expressWrap(addRequestUser.bind(null, this._dbManager, this._internalPermitStore));
+ this._userIdMiddleware = expressWrap(addRequestUser.bind(
+ null, this._dbManager, this._internalPermitStore,
+ {
+ getProfile: this._loginMiddleware.getProfile?.bind(this._loginMiddleware),
+ // Set this to false to stop Grist using a cookie for authentication purposes.
+ skipSession,
+ }
+ ));
this._trustOriginsMiddleware = expressWrap(trustOriginHandler);
// middleware to authorize doc access to the app. Note that this requires the userId
// to be set on the request by _userIdMiddleware.
@@ -722,8 +737,10 @@ export class FlexServer implements GristServer {
baseDomain: this._defaultBaseDomain,
});
- const forcedLoginMiddleware = process.env.GRIST_FORCE_LOGIN === 'true' ?
- this._redirectToLoginWithoutExceptionsMiddleware : noop;
+ const isForced = appSettings.section('login').flag('forced').readBool({
+ envVar: 'GRIST_FORCE_LOGIN',
+ });
+ const forcedLoginMiddleware = isForced ? this._redirectToLoginWithoutExceptionsMiddleware : noop;
const welcomeNewUser: express.RequestHandler = isSingleUserMode() ?
(req, res, next) => next() :
@@ -836,11 +853,12 @@ export class FlexServer implements GristServer {
}
public addComm() {
- if (this._check('comm', 'start', 'homedb')) { return; }
+ if (this._check('comm', 'start', 'homedb', 'config')) { return; }
this._comm = new Comm(this.server, {
settings: this.settings,
sessions: this._sessions,
hosts: this._hosts,
+ loginMiddleware: this._loginMiddleware,
httpsServer: this.httpsServer,
});
}
diff --git a/app/server/lib/ForwardAuthLogin.ts b/app/server/lib/ForwardAuthLogin.ts
index 68dd3d9f..a61d5ce1 100644
--- a/app/server/lib/ForwardAuthLogin.ts
+++ b/app/server/lib/ForwardAuthLogin.ts
@@ -1,9 +1,12 @@
import { ApiError } from 'app/common/ApiError';
+import { UserProfile } from 'app/common/LoginSessionAPI';
+import { appSettings } from 'app/server/lib/AppSettings';
import { getRequestProfile } from 'app/server/lib/Authorizer';
import { expressWrap } from 'app/server/lib/expressWrap';
import { GristLoginSystem, GristServer, setUserInSession } from 'app/server/lib/GristServer';
import { optStringParam } from 'app/server/lib/requestUtils';
import * as express from 'express';
+import { IncomingMessage } from 'http';
import trimEnd = require('lodash/trimEnd');
import trimStart = require('lodash/trimStart');
@@ -30,13 +33,24 @@ import trimStart = require('lodash/trimStart');
* Redirection logic currently assumes a single-site installation.
*/
export async function getForwardAuthLoginSystem(): Promise {
- const header = process.env.GRIST_FORWARD_AUTH_HEADER;
- const logoutPath = process.env.GRIST_FORWARD_AUTH_LOGOUT_PATH || '';
+ const section = appSettings.section('login').section('system').section('forwardAuth');
+ const header = section.flag('header').readString({
+ envVar: 'GRIST_FORWARD_AUTH_HEADER',
+ });
if (!header) { return; }
+ section.flag('active').set(true);
+ const logoutPath = section.flag('logoutPath').readString({
+ envVar: 'GRIST_FORWARD_AUTH_LOGOUT_PATH'
+ }) || '';
+ const loginPath = section.flag('loginPath').requireString({
+ envVar: 'GRIST_FORWARD_AUTH_LOGIN_PATH',
+ defaultValue: '/auth/login',
+ }) || '';
return {
async getMiddleware(gristServer: GristServer) {
async function getLoginRedirectUrl(req: express.Request, url: URL) {
- const target = new URL(trimEnd(gristServer.getHomeUrl(req), '/') + "/auth/login");
+ const target = new URL(trimEnd(gristServer.getHomeUrl(req), '/') +
+ '/' + trimStart(loginPath, '/'));
// In lieu of sanatizing the next url, we include only the path
// component. This will only work for single-domain installations.
target.searchParams.append('next', url.pathname);
@@ -65,6 +79,9 @@ export async function getForwardAuthLoginSystem(): Promise {
+ return getRequestProfile(req, header);
+ },
};
},
async deleteUser() {
diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts
index 5a6d408f..7e6cb8b3 100644
--- a/app/server/lib/GristServer.ts
+++ b/app/server/lib/GristServer.ts
@@ -15,6 +15,7 @@ import { ISendAppPageOptions } from 'app/server/lib/sendAppPage';
import { fromCallback } from 'app/server/lib/serverUtils';
import { Sessions } from 'app/server/lib/Sessions';
import * as express from 'express';
+import { IncomingMessage } from 'http';
/**
* Basic information about a Grist server. Accessible in many
@@ -59,6 +60,10 @@ export interface GristLoginMiddleware {
getLogoutMiddleware?(): express.RequestHandler[];
// Returns arbitrary string for log.
addEndpoints(app: express.Express): Promise;
+ // Optionally, extract profile from request. Result can be a profile,
+ // or null if anonymous (and other methods of determining profile such
+ // as a cookie should not be used), or undefined to use other methods.
+ getProfile?(req: express.Request|IncomingMessage): Promise;
}
/**
diff --git a/test/nbrowser/testServer.ts b/test/nbrowser/testServer.ts
index 6aac050a..a0ac3bce 100644
--- a/test/nbrowser/testServer.ts
+++ b/test/nbrowser/testServer.ts
@@ -37,6 +37,7 @@ export class TestServerMerged implements IMochaServer {
public removeLogin: HomeUtil["removeLogin"];
private _serverUrl: string;
+ private _proxyUrl: string|null = null;
private _server: ChildProcess;
private _exitPromise: Promise;
private _starts: number = 0;
@@ -86,6 +87,9 @@ export class TestServerMerged implements IMochaServer {
const stubCmd = '_build/stubs/app/server/server';
const isCore = await fse.pathExists(stubCmd + '.js');
const cmd = isCore ? stubCmd : '_build/core/app/server/devServerMain';
+ // If a proxy is set, use a single port - otherwise we'd need a lot of
+ // proxies.
+ const useSinglePort = this._proxyUrl !== null;
// The reason we fork a process rather than start a server within the same process is mainly
// logging. Server code uses a global logger, so it's hard to separate out (especially so if
@@ -106,7 +110,10 @@ export class TestServerMerged implements IMochaServer {
GRIST_SERVE_SAME_ORIGIN: 'true',
APP_UNTRUSTED_URL : "http://localhost:18096",
// Run with HOME_PORT, STATIC_PORT, DOC_PORT, DOC_WORKER_COUNT in the environment to override.
- ...(isCore ? {
+ ...(useSinglePort ? {
+ APP_HOME_URL: this.getHost(),
+ GRIST_SINGLE_PORT: 'true',
+ } : (isCore ? {
HOME_PORT: '8095',
STATIC_PORT: '8095',
DOC_PORT: '8095',
@@ -118,7 +125,7 @@ export class TestServerMerged implements IMochaServer {
DOC_PORT: '8100',
DOC_WORKER_COUNT: '5',
PORT: '0',
- }),
+ })),
// This skips type-checking when running server, but reduces startup time a lot.
TS_NODE_TRANSPILE_ONLY: 'true',
...process.env,
@@ -186,7 +193,7 @@ export class TestServerMerged implements IMochaServer {
public getHost(): string {
if (this.isExternalServer()) { return process.env.HOME_URL!; }
- return this._serverUrl;
+ return this._proxyUrl || this._serverUrl;
}
public getUrl(team: string, relPath: string) {
@@ -200,6 +207,12 @@ export class TestServerMerged implements IMochaServer {
return `${url}${relPath}`;
}
+ // Configure the server to be accessed via a proxy. You'll need to
+ // restart the server after changing this setting.
+ public updateProxy(proxyUrl: string|null) {
+ this._proxyUrl = proxyUrl;
+ }
+
/**
* Returns whether the server is up and responsive.
*/
diff --git a/test/server/lib/Authorizer.ts b/test/server/lib/Authorizer.ts
index 8b90a1bf..41580369 100644
--- a/test/server/lib/Authorizer.ts
+++ b/test/server/lib/Authorizer.ts
@@ -17,6 +17,7 @@ let server: FlexServer;
let dbManager: HomeDBManager;
async function activateServer(home: FlexServer, docManager: DocManager) {
+ await home.loadConfig();
await home.initHomeDBManager();
home.addHosts();
home.addDocWorkerMap();