(core) revive saml support and test against Auth0

Summary:
SAML support had broken due to SameSite changes in browsers. This
makes it work again, and tests it against Auth0 (now owned by Okta).

Logging in and out works.  The logged out state is confusing, and may
not be complete.  The "Add Account" menu item doesn't work.
But with this, an important part of self-hosting becomes easier.

SAML support works also in grist-core, for site pages, but there
is a glitch on document pages that I'll look into separately.

Test Plan: tested manually

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2976
pull/15/head
Paul Fitzpatrick 3 years ago
parent 800731e771
commit 54beaede84

@ -2,7 +2,7 @@ import {MapWithTTL} from 'app/common/AsyncCreate';
import * as version from 'app/common/version';
import {DocStatus, DocWorkerInfo, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
import * as log from 'app/server/lib/log';
import {checkPermitKey, formatPermitKey, Permit} from 'app/server/lib/Permit';
import {checkPermitKey, formatPermitKey, IPermitStore, Permit} from 'app/server/lib/Permit';
import {promisifyAll} from 'bluebird';
import mapValues = require('lodash/mapValues');
import {createClient, Multi, RedisClient} from 'redis';
@ -27,8 +27,8 @@ const PERMIT_TTL_MSEC = 1 * 60 * 1000; // 1 minute
class DummyDocWorkerMap implements IDocWorkerMap {
private _worker?: DocWorkerInfo;
private _available: boolean = false;
private _permits = new MapWithTTL<string, string>(PERMIT_TTL_MSEC);
private _elections = new MapWithTTL<string, string>(1); // default ttl never used
private _permitStores = new Map<string, IPermitStore>();
public async getDocWorker(docId: string) {
if (!this._worker) { throw new Error('no workers'); }
@ -70,23 +70,38 @@ class DummyDocWorkerMap implements IDocWorkerMap {
return [];
}
public async setPermit(permit: Permit): Promise<string> {
const key = formatPermitKey(uuidv4());
this._permits.set(key, JSON.stringify(permit));
return key;
}
public async getPermit(key: string): Promise<Permit> {
const result = this._permits.get(key);
return result ? JSON.parse(result) : null;
}
public async removePermit(key: string): Promise<void> {
this._permits.delete(key);
public getPermitStore(prefix: string, defaultTtlMs?: number): IPermitStore {
let store = this._permitStores.get(prefix);
if (store) { return store; }
const _permits = new MapWithTTL<string, string>(defaultTtlMs || PERMIT_TTL_MSEC);
store = {
async setPermit(permit: Permit, ttlMs?: number): Promise<string> {
const key = formatPermitKey(uuidv4(), prefix);
if (ttlMs) {
_permits.setWithCustomTTL(key, JSON.stringify(permit), ttlMs);
} else {
_permits.set(key, JSON.stringify(permit));
}
return key;
},
async getPermit(key: string): Promise<Permit> {
const result = _permits.get(key);
return result ? JSON.parse(result) : null;
},
async removePermit(key: string): Promise<void> {
_permits.delete(key);
},
async close(): Promise<void> {
_permits.clear();
}
};
this._permitStores.set(prefix, store);
return store;
}
public async close(): Promise<void> {
this._permits.clear();
await Promise.all([...this._permitStores.values()].map(store => store.close()));
this._permitStores.clear();
this._elections.clear();
}
@ -436,24 +451,30 @@ export class DocWorkerMap implements IDocWorkerMap {
return checksum === 'null' ? null : checksum;
}
public async setPermit(permit: Permit): Promise<string> {
const key = formatPermitKey(uuidv4());
const duration = (this._options && this._options.permitMsec) || PERMIT_TTL_MSEC;
// seems like only integer seconds are supported?
await this._client.setexAsync(key, Math.ceil(duration / 1000.0),
JSON.stringify(permit));
return key;
}
public async getPermit(key: string): Promise<Permit|null> {
if (!checkPermitKey(key)) { throw new Error('permit could not be read'); }
const result = await this._client.getAsync(key);
return result && JSON.parse(result);
}
public async removePermit(key: string): Promise<void> {
if (!checkPermitKey(key)) { throw new Error('permit could not be read'); }
await this._client.delAsync(key);
public getPermitStore(prefix: string, defaultTtlMs?: number): IPermitStore {
const permitMsec = defaultTtlMs || (this._options && this._options.permitMsec) || PERMIT_TTL_MSEC;
const client = this._client;
return {
async setPermit(permit: Permit, ttlMs?: number): Promise<string> {
const key = formatPermitKey(uuidv4(), prefix);
// seems like only integer seconds are supported?
const duration = ttlMs || permitMsec;
await client.setexAsync(key, Math.ceil(duration / 1000.0), JSON.stringify(permit));
return key;
},
async getPermit(key: string): Promise<Permit|null> {
if (!checkPermitKey(key, prefix)) { throw new Error('permit could not be read'); }
const result = await client.getAsync(key);
return result && JSON.parse(result);
},
async removePermit(key: string): Promise<void> {
if (!checkPermitKey(key, prefix)) { throw new Error('permit could not be read'); }
await client.delAsync(key);
},
async close() {
// nothing to do
}
};
}
public async close(): Promise<void> {

@ -266,8 +266,8 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
* Returns a handler that redirects the user to a login or signup page.
*/
export function redirectToLoginUnconditionally(
getLoginRedirectUrl: (redirectUrl: URL) => Promise<string>,
getSignUpRedirectUrl: (redirectUrl: URL) => Promise<string>
getLoginRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>,
getSignUpRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>
) {
return async (req: Request, resp: Response, next: NextFunction) => {
const mreq = req as RequestWithLogin;
@ -281,9 +281,9 @@ export function redirectToLoginUnconditionally(
log.debug(`Authorizer: redirecting to ${signUp ? 'sign up' : 'log in'}`);
const redirectUrl = new URL(req.protocol + '://' + req.get('host') + req.originalUrl);
if (signUp) {
return resp.redirect(await getSignUpRedirectUrl(redirectUrl));
return resp.redirect(await getSignUpRedirectUrl(req, redirectUrl));
} else {
return resp.redirect(await getLoginRedirectUrl(redirectUrl));
return resp.redirect(await getLoginRedirectUrl(req, redirectUrl));
}
};
}
@ -296,8 +296,8 @@ export function redirectToLoginUnconditionally(
*/
export function redirectToLogin(
allowExceptions: boolean,
getLoginRedirectUrl: (redirectUrl: URL) => Promise<string>,
getSignUpRedirectUrl: (redirectUrl: URL) => Promise<string>,
getLoginRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>,
getSignUpRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>,
dbManager: HomeDBManager
): RequestHandler {
const redirectUnconditionally = redirectToLoginUnconditionally(getLoginRedirectUrl,

@ -22,6 +22,10 @@ export interface SessionUserObj {
// [UNUSED] 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

@ -5,7 +5,7 @@
import { IChecksumStore } from 'app/server/lib/IChecksumStore';
import { IElectionStore } from 'app/server/lib/IElectionStore';
import { IPermitStore } from 'app/server/lib/Permit';
import { IPermitStores } from 'app/server/lib/Permit';
export interface DocWorkerInfo {
id: string;
@ -36,7 +36,7 @@ export interface DocStatus {
/**
* Assignment of documents to workers, and other storage related to distributed work.
*/
export interface IDocWorkerMap extends IPermitStore, IElectionStore, IChecksumStore {
export interface IDocWorkerMap extends IPermitStores, IElectionStore, IChecksumStore {
// Looks up which DocWorker is responsible for this docId.
getDocWorker(docId: string): Promise<DocStatus|null>;

@ -21,7 +21,6 @@ import {attachAppEndpoint} from 'app/server/lib/AppEndpoint';
import {addRequestUser, getUser, getUserId, isSingleUserMode,
redirectToLoginUnconditionally} from 'app/server/lib/Authorizer';
import {redirectToLogin, RequestWithLogin} from 'app/server/lib/Authorizer';
import {SessionUserObj} from 'app/server/lib/BrowserSession';
import * as Comm from 'app/server/lib/Comm';
import {create} from 'app/server/lib/create';
import {addDocApiRoutes} from 'app/server/lib/DocApi';
@ -97,7 +96,6 @@ export class FlexServer implements GristServer {
public host: string;
public tag: string;
public info = new Array<[string, any]>();
public sessions: Sessions;
public dbManager: HomeDBManager;
public notifier: INotifier;
public usage: Usage;
@ -117,9 +115,12 @@ export class FlexServer implements GristServer {
private _docWorker: DocWorker;
private _hosts: Hosts;
private _pluginManager: PluginManager;
private _sessions: Sessions;
private _sessionStore: SessionStore;
private _storageManager: IDocStorageManager;
private _docWorkerMap: IDocWorkerMap;
private _internalPermitStore: IPermitStore; // store for permits that stay within our servers
private _externalPermitStore: IPermitStore; // store for permits that pass through outside servers
private _disabled: boolean = false;
private _disableS3: boolean = false;
private _healthy: boolean = true; // becomes false if a serious error has occurred and
@ -140,9 +141,9 @@ export class FlexServer implements GristServer {
private _redirectToLoginUnconditionally: express.RequestHandler | null;
private _redirectToOrgMiddleware: express.RequestHandler;
private _redirectToHostMiddleware: express.RequestHandler;
private _getLoginRedirectUrl: (target: URL) => Promise<string>;
private _getSignUpRedirectUrl: (target: URL) => Promise<string>;
private _getLogoutRedirectUrl: (nextUrl: URL, userSession: SessionUserObj) => Promise<string>;
private _getLoginRedirectUrl: (req: express.Request, target: URL) => Promise<string>;
private _getSignUpRedirectUrl: (req: express.Request, target: URL) => Promise<string>;
private _getLogoutRedirectUrl: (req: express.Request, nextUrl: URL) => Promise<string>;
private _sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>;
constructor(public port: number, public name: string = 'flexServer',
@ -234,8 +235,18 @@ export class FlexServer implements GristServer {
}
public getPermitStore(): IPermitStore {
if (!this._docWorkerMap) { throw new Error('no permit store available'); }
return this._docWorkerMap;
if (!this._internalPermitStore) { throw new Error('no permit store available'); }
return this._internalPermitStore;
}
public getExternalPermitStore(): IPermitStore {
if (!this._externalPermitStore) { throw new Error('no permit store available'); }
return this._externalPermitStore;
}
public getSessions(): Sessions {
if (!this._sessions) { throw new Error('no sessions available'); }
return this._sessions;
}
public addLogging() {
@ -424,6 +435,8 @@ export class FlexServer implements GristServer {
public addDocWorkerMap() {
if (this._check('map')) { return; }
this._docWorkerMap = getDocWorkerMap();
this._internalPermitStore = this._docWorkerMap.getPermitStore('internal');
this._externalPermitStore = this._docWorkerMap.getPermitStore('external');
}
// Set up the main express middleware used. For a single user setup, without logins,
@ -438,7 +451,7 @@ export class FlexServer implements GristServer {
// If GRIST_DEFAULT_EMAIL is set, login as that user when no other credentials
// presented.
const fallbackEmail = process.env.GRIST_DEFAULT_EMAIL || null;
this._userIdMiddleware = expressWrap(addRequestUser.bind(null, this.dbManager, this._docWorkerMap,
this._userIdMiddleware = expressWrap(addRequestUser.bind(null, this.dbManager, this._internalPermitStore,
fallbackEmail));
this._trustOriginsMiddleware = expressWrap(trustOriginHandler);
// middleware to authorize doc access to the app. Note that this requires the userId
@ -579,7 +592,7 @@ export class FlexServer implements GristServer {
res.status(200).send(`Grist ${this.name} is alive and is interested in you.`);
});
this.sessions = sessions;
this._sessions = sessions;
this._sessionStore = sessionStore;
}
@ -734,7 +747,7 @@ export class FlexServer implements GristServer {
// TODO: We could include a third mock provider of login/logout URLs for better tests. Or we
// could create a mock SAML identity provider for testing this using the SAML flow.
this._loginMiddleware = await getLoginMiddleware();
this._loginMiddleware = await getLoginMiddleware(this);
this._getLoginRedirectUrl = tbind(this._loginMiddleware.getLoginRedirectUrl, this._loginMiddleware);
this._getSignUpRedirectUrl = tbind(this._loginMiddleware.getSignUpRedirectUrl, this._loginMiddleware);
this._getLogoutRedirectUrl = tbind(this._loginMiddleware.getLogoutRedirectUrl, this._loginMiddleware);
@ -744,7 +757,7 @@ export class FlexServer implements GristServer {
if (this._check('comm', 'start')) { return; }
this.comm = new Comm(this.server, {
settings: this.settings,
sessions: this.sessions,
sessions: this._sessions,
hosts: this._hosts,
httpsServer: this.httpsServer,
});
@ -792,7 +805,7 @@ export class FlexServer implements GristServer {
signUp = (mreq.session.users === undefined);
}
const getRedirectUrl = signUp ? this._getSignUpRedirectUrl : this._getLoginRedirectUrl;
resp.redirect(await getRedirectUrl(new URL(next)));
resp.redirect(await getRedirectUrl(req, new URL(next)));
}
this.app.get('/login', expressWrap(redirectToLoginOrSignup.bind(this, false)));
@ -811,7 +824,7 @@ export class FlexServer implements GristServer {
this.app.get('/test/login', expressWrap(async (req, res) => {
log.warn("Serving unauthenticated /test/login endpoint, made available because GRIST_TEST_LOGIN is set.");
const scopedSession = this.sessions.getOrCreateSessionFromRequest(req);
const scopedSession = this._sessions.getOrCreateSessionFromRequest(req);
const profile: UserProfile = {
email: optStringParam(req.query.email) || 'chimpy@getgrist.com',
name: optStringParam(req.query.name) || 'Chimpy McBanana',
@ -831,12 +844,11 @@ export class FlexServer implements GristServer {
}
this.app.get('/logout', expressWrap(async (req, resp) => {
const scopedSession = this.sessions.getOrCreateSessionFromRequest(req);
const userSession = await scopedSession.getScopedSession();
const scopedSession = this._sessions.getOrCreateSessionFromRequest(req);
// If 'next' param is missing, redirect to "/" on our requested hostname.
const next = optStringParam(req.query.next) || (req.protocol + '://' + req.get('host') + '/');
const redirectUrl = await this._getLogoutRedirectUrl(new URL(next), userSession);
const redirectUrl = await this._getLogoutRedirectUrl(req, new URL(next));
// Clear session so that user needs to log in again at the next request.
// SAML logout in theory uses userSession, so clear it AFTER we compute the URL.
@ -857,7 +869,7 @@ export class FlexServer implements GristServer {
this.app.get('/verified', expressWrap((req, resp) =>
this._sendAppPage(req, resp, {path: 'error.html', status: 200, config: {errPage: 'verified'}})));
const comment = this._loginMiddleware.addEndpoints(this.app, this.comm, this.sessions, this._hosts);
const comment = this._loginMiddleware.addEndpoints(this.app, this.comm, this._sessions, this._hosts);
this.info.push(['loginMiddlewareComment', comment]);
}
@ -1057,7 +1069,7 @@ export class FlexServer implements GristServer {
throw new Error(`Can't resolve ${urlId}: ${docAuth.error}`);
}
permitKey = await this._docWorkerMap.setPermit({docId});
permitKey = await this._internalPermitStore.setPermit({docId});
const res = await fetch(await this.getHomeUrlByDocId(docId, `/api/docs/${docId}/tables/Responses/data`), {
method: 'POST',
headers: {'Permit': permitKey, 'Content-Type': 'application/json'},
@ -1071,7 +1083,7 @@ export class FlexServer implements GristServer {
log.rawWarn(`Failed to record new user info: ${e.message}`, {newUserQuestions: body});
} finally {
if (permitKey) {
await this._docWorkerMap.removePermit(permitKey);
await this._internalPermitStore.removePermit(permitKey);
}
}
@ -1212,7 +1224,7 @@ export class FlexServer implements GristServer {
public async addHousekeeper() {
if (this._check('housekeeper', 'start', 'homedb', 'map', 'json', 'api-mw')) { return; }
const store = this._docWorkerMap;
this.housekeeper = new Housekeeper(this.dbManager, this, store, store);
this.housekeeper = new Housekeeper(this.dbManager, this, this._internalPermitStore, store);
this.housekeeper.addEndpoints(this.app);
await this.housekeeper.start();
}

@ -2,7 +2,6 @@ import { GristLoadConfig } from 'app/common/gristUrls';
import { Document } from 'app/gen-server/entity/Document';
import { Organization } from 'app/gen-server/entity/Organization';
import { Workspace } from 'app/gen-server/entity/Workspace';
import { SessionUserObj } from 'app/server/lib/BrowserSession';
import * as Comm from 'app/server/lib/Comm';
import { Hosts } from 'app/server/lib/extractOrg';
import { ICreate } from 'app/server/lib/ICreate';
@ -24,12 +23,14 @@ export interface GristServer {
getResourceUrl(resource: Organization|Workspace|Document): Promise<string>;
getGristConfig(): GristLoadConfig;
getPermitStore(): IPermitStore;
getExternalPermitStore(): IPermitStore;
getSessions(): Sessions;
}
export interface GristLoginMiddleware {
getLoginRedirectUrl(target: URL): Promise<string>;
getSignUpRedirectUrl(target: URL): Promise<string>;
getLogoutRedirectUrl(nextUrl: URL, userSession: SessionUserObj): Promise<string>;
getLoginRedirectUrl(req: express.Request, target: URL): Promise<string>;
getSignUpRedirectUrl(req: express.Request, target: URL): Promise<string>;
getLogoutRedirectUrl(req: express.Request, nextUrl: URL): Promise<string>;
// Returns arbitrary string for log.
addEndpoints(app: express.Express, comm: Comm, sessions: Sessions, hosts: Hosts): string;

@ -25,10 +25,13 @@
* - Optionally, remove the permit with removePermit().
*/
export interface Permit {
docId?: string;
workspaceId?: number;
org?: string|number;
docId?: string; // A particular document.
workspaceId?: number; // A particular workspace.
org?: string|number; // A particular org.
otherDocId?: string; // For operations involving two documents.
sessionId?: string; // A particular session.
url?: string; // A particular url.
action?: string; // A string denoting what kind of action the permit applies to.
}
/* A store of permits */
@ -36,7 +39,7 @@ export interface IPermitStore {
// Store a permit, and return the key it is stored in.
// Permits are transient, and will expire.
setPermit(permit: Permit): Promise<string>;
setPermit(permit: Permit, ttlMs?: number): Promise<string>;
// Get any permit associated with the given key, or null if none.
getPermit(permitKey: string): Promise<Permit|null>;
@ -48,12 +51,16 @@ export interface IPermitStore {
close(): Promise<void>;
}
export interface IPermitStores {
getPermitStore(prefix: string, defaultTtlMs?: number): IPermitStore;
}
// Create a well formatted permit key from a seed string.
export function formatPermitKey(seed: string) {
return `permit-${seed}`;
export function formatPermitKey(seed: string, prefix: string) {
return `permit-${prefix}-${seed}`;
}
// Check that permit key is well formatted.
export function checkPermitKey(key: string): boolean {
return key.startsWith('permit-');
export function checkPermitKey(key: string, prefix: string): boolean {
return key.startsWith(`permit-${prefix}-`);
}

@ -0,0 +1,262 @@
/**
* Configuration for SAML, useful for enterprise single-sign-on logins.
* A good informative overview of SAML is at https://www.okta.com/integrate/documentation/saml/
* Note:
* SP is "Service Provider", in our case, the Grist application.
* IdP is the "Identity Provider", somewhere users log into, e.g. Okta or Google Apps.
*
* We expect IdP to provide us with name_id, a unique identifier for the user.
* We also use optional attributes for the user's name, for which we accept any of:
* FirstName
* LastName
* http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname
* http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname
*
* Note that the code is based on the example at https://github.com/Clever/saml2
*
* Expected environment variables:
* env GRIST_SAML_SP_HOST=https://grist.tigerglobal.com
* Host at which our /saml/assert endpoint will live; identifies our application.
* env GRIST_SAML_SP_KEY
* Path to file with our private key, PEM format.
* env GRIST_SAML_SP_CERT
* Path to file with our public key, PEM format.
* env GRIST_SAML_IDP_LOGIN
* Login url to redirect user to for log-in.
* env GRIST_SAML_IDP_LOGOUT
* Logout URL to redirect user to for log-out.
* env GRIST_SAML_IDP_SKIP_SLO
* If set and non-empty, don't attempt "Single Logout" flow (which I haven't gotten to
* work), but simply redirect to GRIST_SAML_IDP_LOGOUT after clearing session.
* env GRIST_SAML_IDP_CERTS
* Comma-separated list of paths for certificates from identity provider, PEM format.
* env GRIST_SAML_IDP_UNENCRYPTED
* If set and non-empty, allow unencrypted assertions, relying on https for privacy.
*
* This version of SamlConfig has been tested with Auth0 SAML IdP following the instructions
* at:
* https://auth0.com/docs/protocols/saml-protocol/configure-auth0-as-saml-identity-provider
* When running on localhost and http, the settings tested were with:
* - GRIST_SAML_IDP_SKIP_SLO not set
* - GRIST_SAML_SP_HOST=http://localhost:8080 or 8484
* - GRIST_SAML_IDP_UNENCRYPTED=1
* - GRIST_SAML_IDP_LOGIN=https://...auth0.com/samlp/xxxx
* - GRIST_SAML_IDP_LOGOUT=https://...auth0.com/samlp/xxxx # these are same for Auth0
* - GRIST_SAML_IDP_CERTS=.../auth0.pem # downloaded per Auth0 instructions
* - GRIST_SAML_SP_KEY=.../saml.pem # created
* - GRIST_SAML_SP_CERT=.../saml.crt # created
*
* Created and used the key/cert pair following instructions here:
* https://auth0.com/docs/protocols/saml-protocol/saml-sso-integrations/sign-and-encrypt-saml-requests#use-custom-certificate-to-sign-requests
* https://auth0.com/docs/protocols/saml-protocol/saml-sso-integrations/sign-and-encrypt-saml-requests#auth0-as-the-saml-identity-provider
*
*/
import * as bodyParser from 'body-parser';
import * as express from 'express';
import * as fse from 'fs-extra';
import * as saml2 from 'saml2-js';
import * as Comm from 'app/server/lib/Comm';
import {expressWrap} from 'app/server/lib/expressWrap';
import {Hosts} from 'app/server/lib/extractOrg';
import {GristLoginMiddleware, GristServer} from 'app/server/lib/GristServer';
import * as log from 'app/server/lib/log';
import {Permit} from 'app/server/lib/Permit';
import {fromCallback} from 'app/server/lib/serverUtils';
import {Sessions} from 'app/server/lib/Sessions';
export class SamlConfig {
private _serviceProvider: saml2.ServiceProvider;
private _identityProvider: saml2.IdentityProvider;
public constructor(private _gristServer: GristServer) {}
// Read SAML certificate files and initialize the SAML state.
public async initSaml(): Promise<void> {
if (!process.env.GRIST_SAML_SP_HOST) { throw new Error("initSaml requires GRIST_SAML_SP_HOST to be set"); }
if (!process.env.GRIST_SAML_SP_KEY) { throw new Error("initSaml requires GRIST_SAML_SP_KEY to be set"); }
if (!process.env.GRIST_SAML_SP_CERT) { throw new Error("initSaml requires GRIST_SAML_SP_CERT to be set"); }
if (!process.env.GRIST_SAML_IDP_LOGIN) { throw new Error("initSaml requires GRIST_SAML_IDP_LOGIN to be set"); }
if (!process.env.GRIST_SAML_IDP_LOGOUT) { throw new Error("initSaml requires GRIST_SAML_IDP_LOGOUT to be set"); }
if (!process.env.GRIST_SAML_IDP_CERTS) { throw new Error("initSaml requires GRIST_SAML_IDP_CERTS to be set"); }
const spHost: string = process.env.GRIST_SAML_SP_HOST;
const spOptions: saml2.ServiceProviderOptions = {
entity_id: `${spHost}/saml/metadata.xml`,
private_key: await fse.readFile(process.env.GRIST_SAML_SP_KEY, {encoding: 'utf8'}),
certificate: await fse.readFile(process.env.GRIST_SAML_SP_CERT, {encoding: 'utf8'}),
assert_endpoint: `${spHost}/saml/assert`,
notbefore_skew: 5, // allow 5 seconds of time skew
sign_get_request: true // Auth0 requires this. If it is a problem for others, could make optional.
};
this._serviceProvider = new saml2.ServiceProvider(spOptions);
const idpCerts = process.env.GRIST_SAML_IDP_CERTS.split(",");
const idpOptions: saml2.IdentityProviderOptions = {
sso_login_url: process.env.GRIST_SAML_IDP_LOGIN,
sso_logout_url: process.env.GRIST_SAML_IDP_LOGOUT,
certificates: await Promise.all(idpCerts.map((p) => fse.readFile(p, {encoding: 'utf8'}))),
// Encrypted assertions are recommended, but not necessary when over https.
allow_unencrypted_assertion: Boolean(process.env.GRIST_SAML_IDP_UNENCRYPTED),
};
this._identityProvider = new saml2.IdentityProvider(idpOptions);
log.info(`SamlConfig set with host ${spHost}, IdP ${process.env.GRIST_SAML_IDP_LOGIN}`);
}
// Return a login URL to which to redirect the user to log in. Once logged in, the user will be
// redirected to redirectUrl
public async getLoginRedirectUrl(req: express.Request, redirectUrl: URL): Promise<string> {
const sp = this._serviceProvider;
const idp = this._identityProvider;
const { permit: relay_state, samlNameId } = await this._prepareAppState(req, redirectUrl, {
action: 'login',
waitMinutes: 20,
});
const force_authn = samlNameId === undefined; // If logged out locally, ignore any
// log in state retained by IdP.
return fromCallback((cb) => sp.create_login_request_url(idp, {relay_state, force_authn}, cb));
}
// Returns the URL to log the user out of SAML IdentityProvider.
public async getLogoutRedirectUrl(req: express.Request, redirectUrl: URL): Promise<string> {
if (process.env.GRIST_SAML_IDP_SKIP_SLO) {
// TODO: This does NOT eventually take us to redirectUrl.
return process.env.GRIST_SAML_IDP_LOGOUT!;
}
const sp = this._serviceProvider;
const idp = this._identityProvider;
// 2020: Not sure what I am doing wrong here, but all my attempt to use "Single Logout" fail with
// a "400 Bad Request" error message from Okta.
// 2021: This doesn't fail with Auth0 (now owned by Okta), but also doesn't seem to do anything.
const { permit: relay_state, samlNameId, samlSessionIndex } = await this._prepareAppState(req, redirectUrl, {
action: 'logout',
waitMinutes: 1
});
const options: saml2.CreateLogoutRequestUrlOptions = {
name_id: samlNameId,
session_index: samlSessionIndex,
relay_state,
};
return fromCallback<string>((cb) => sp.create_logout_request_url(idp, options, cb));
}
// Adds several /saml/* endpoints to the given express app, to support SAML logins.
public addSamlEndpoints(app: express.Express, sessions: Sessions): void {
const sp = this._serviceProvider;
const idp = this._identityProvider;
// A purely informational endpoint, which simply dumps the SAML metadata.
app.get("/saml/metadata.xml", (req, res) => {
res.type('application/xml');
res.send(sp.create_metadata());
});
// Starting point for login. It redirects to the IdP, and then to /saml/assert.
app.get("/saml/login", expressWrap(async (req, res, next) => {
res.redirect(await this.getLoginRedirectUrl(req, new URL(req.protocol + "://" + req.get('host'))));
}));
// Assert endpoint for when the login completes as POST.
app.post("/saml/assert", bodyParser.urlencoded({extended: true}), expressWrap(async (req, res, next) => {
const relayState: string = req.body.RelayState;
if (!relayState) { throw new Error('Login or logout failed to complete'); }
const permitStore = this._gristServer.getExternalPermitStore();
const state = await permitStore.getPermit(relayState);
if (!state) { throw new Error('Login or logout is stale'); }
await permitStore.removePermit(relayState);
const redirectUrl = state.url!;
const samlResponse: any = await fromCallback((cb) => sp.post_assert(idp, {request_body: req.body}, cb));
if (state.action === 'login') {
const samlUser = samlResponse.user;
if (!samlUser || !samlUser.name_id) {
log.warn(`SamlConfig: bad SAML reponse: ${JSON.stringify(samlUser)}`);
throw new Error("Invalid user info in SAML response");
}
// An example IdP response is at https://github.com/Clever/saml2#assert_response. Saml2-js
// maps some standard attributes as user.given_name, user.surname, which we use if
// available. Otherwise we use user.attributes which has the form {Name: [Value]}.
const fname = samlUser.given_name || samlUser.attributes.FirstName || '';
const lname = samlUser.surname || samlUser.attributes.LastName || '';
const email = samlUser.email || samlUser.nameId;
const profile = {
email,
name: `${fname} ${lname}`.trim(),
};
const samlSessionIndex = samlUser.session_index;
const samlNameId = samlUser.name_id;
log.info(`SamlConfig: got SAML response for ${profile.email} (${profile.name}) redirecting to ${redirectUrl}`);
const scopedSession = sessions.getOrCreateSessionFromRequest(req, state.sessionId);
await scopedSession.operateOnScopedSession(async (user) => Object.assign(user, {
profile,
samlSessionIndex,
samlNameId,
}));
}
res.redirect(redirectUrl);
}));
}
/**
*
* Login and logout involves redirecting to a SAML IdP, which will then POST some information
* back to Grist. The POST won't have Grist's cookie, because of relatively new SameSite
* behavior. Grist's cookie is SameSite=Lax, which withholds cookies from POSTs initiated
* on a different site. That's a good setting in general, but for this case we need
* to link what the identity provider sends us with the session. We place some state
* in the permit store temporarily and pass the permit key through the request chain
* so it is available when needed.
*
*/
private async _prepareAppState(req: express.Request, redirectUrl: URL, options: {
action: 'login' | 'logout', // We'll need to remember whether we are logging in or out.
waitMinutes: number // State may need to linger quite some time for login,
// less so for logout.
}) {
const permitStore = this._gristServer.getExternalPermitStore();
const sessionId = this._gristServer.getSessions().getSessionIdFromRequest(req);
if (!sessionId) { throw new Error('no session available'); }
const state: Permit = {
url: redirectUrl.href,
sessionId,
action: options.action,
};
const scopedSession = this._gristServer.getSessions().getOrCreateSessionFromRequest(req);
const userSession = await scopedSession.getScopedSession();
const samlNameId = userSession.samlNameId;
const samlSessionIndex = userSession.samlSessionIndex;
const permit = await permitStore.setPermit(state, options.waitMinutes * 60 * 1000);
return { permit, samlNameId, samlSessionIndex };
}
}
/**
* Return SAML middleware if environment looks configured for it, else return undefined.
*/
export async function getSamlLoginMiddleware(gristServer: GristServer): Promise<GristLoginMiddleware|undefined> {
if (!process.env.GRIST_SAML_SP_HOST) {
return undefined;
}
const samlConfig = new SamlConfig(gristServer);
await samlConfig.initSaml();
return {
getLoginRedirectUrl: samlConfig.getLoginRedirectUrl.bind(samlConfig),
// For saml, always use regular login page, users are enrolled externally.
// TODO: is there a better link to give here?
getSignUpRedirectUrl: samlConfig.getLoginRedirectUrl.bind(samlConfig),
getLogoutRedirectUrl: samlConfig.getLogoutRedirectUrl.bind(samlConfig),
addEndpoints(app: express.Express, comm: Comm, sessions: Sessions, hosts: Hosts) {
samlConfig.addSamlEndpoints(app, sessions);
return 'saml';
}
};
}

@ -30,11 +30,11 @@ export class Sessions {
}
/**
* Get the session id and organization from the request, and return the
* identified session.
* Get the session id and organization from the request (or just pass it in if known), and
* return the identified session.
*/
public getOrCreateSessionFromRequest(req: Request): ScopedSession {
const sid = this.getSessionIdFromRequest(req);
public getOrCreateSessionFromRequest(req: Request, sessionId?: string): ScopedSession {
const sid = sessionId || this.getSessionIdFromRequest(req);
const org = (req as any).org;
if (!sid) { throw new Error("session not found"); }
return this.getOrCreateSession(sid, org, ''); // TODO: allow for tying to a preferred user.

@ -44,6 +44,7 @@
"@types/pidusage": "2.0.1",
"@types/plotly.js": "1.44.15",
"@types/redlock": "3.0.2",
"@types/saml2-js": "2.0.1",
"@types/selenium-webdriver": "4.0.0",
"@types/sqlite3": "3.1.6",
"@types/tmp": "0.0.33",
@ -113,6 +114,7 @@
"randomcolor": "0.5.3",
"redis": "2.8.0",
"redlock": "3.1.2",
"saml2-js": "2.0.3",
"short-uuid": "3.1.1",
"tmp": "0.0.33",
"ts-interface-checker": "0.1.6",

@ -1,12 +1,13 @@
import {GristLoginMiddleware} from 'app/server/lib/GristServer';
import {GristLoginMiddleware, GristServer} from 'app/server/lib/GristServer';
import {getSamlLoginMiddleware} from 'app/server/lib/SamlConfig';
export async function getLoginMiddleware(): Promise<GristLoginMiddleware> {
export async function getLoginMiddleware(gristServer: GristServer): Promise<GristLoginMiddleware> {
const saml = await getSamlLoginMiddleware(gristServer);
if (saml) { return saml; }
return {
async getLoginRedirectUrl(target: URL) { throw new Error('logins not implemented'); },
async getLogoutRedirectUrl(target: URL) { throw new Error('logins not implemented'); },
async getSignUpRedirectUrl(target: URL) { throw new Error('logins not implemented'); },
addEndpoints(...args: any[]) {
return "no-logins";
}
async getLoginRedirectUrl() { throw new Error('logins not implemented'); },
async getLogoutRedirectUrl() { throw new Error('logins not implemented'); },
async getSignUpRedirectUrl() { throw new Error('logins not implemented'); },
addEndpoints() { return "no-logins"; }
};
}

@ -41,8 +41,10 @@ export async function main() {
console.log('For full logs, re-run with DEBUG=1');
}
// There's no login system released yet, so set a default email address.
setDefaultEnv('GRIST_DEFAULT_EMAIL', 'you@example.com');
// If SAML is not configured, there's no login system, so force a default email address.
if (!process.env.GRIST_SAML_SP_HOST) {
setDefaultEnv('GRIST_DEFAULT_EMAIL', 'you@example.com');
}
// Set directory for uploaded documents.
setDefaultEnv('GRIST_DATA_DIR', 'docs');
await fse.mkdirp(process.env.GRIST_DATA_DIR!);

@ -319,6 +319,11 @@
"@types/bluebird" "*"
"@types/events" "*"
"@types/saml2-js@2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/saml2-js/-/saml2-js-2.0.1.tgz#d4dba6467f29093639edf81645576b753d81f9c6"
integrity sha512-Gr7528CgFBXqAoMlxdGjj8s8ihgAIXXvVjoUthjZGHJBz2TDyXMObmDALWnFeudcGilxqha/WpFdWJ+FI6RlWA==
"@types/selenium-webdriver@4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-4.0.0.tgz#21aa550f08d2cca49f8537f6697288a0d4300f28"
@ -878,7 +883,7 @@ async-mutex@0.2.4:
dependencies:
tslib "^2.0.0"
async@^2.5.0:
async@^2.1.5, async@^2.5.0:
version "2.6.3"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
@ -2040,7 +2045,7 @@ dayjs@^1.8.34:
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.6.tgz#288b2aa82f2d8418a6c9d4df5898c0737ad02a63"
integrity sha512-AztC/IOW4L1Q41A86phW5Thhcrco3xuAA+YX/BLpLWWjRcTj5TOt/QImBLmCKlrF7u7k47arTnOyL6GnbG8Hvw==
debug@2.6.9, debug@^2.1.3, debug@^2.2.0, debug@^2.3.3, debug@^2.6.9:
debug@2.6.9, debug@^2.1.3, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
@ -2324,6 +2329,11 @@ ee-first@1.1.1:
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
ejs@^2.5.6:
version "2.7.4"
resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba"
integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==
electron-download@^4.1.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/electron-download/-/electron-download-4.1.1.tgz#02e69556705cc456e520f9e035556ed5a015ebe8"
@ -4229,6 +4239,11 @@ locate-path@^3.0.0:
p-locate "^3.0.0"
path-exists "^3.0.0"
lodash-node@~2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/lodash-node/-/lodash-node-2.4.1.tgz#ea82f7b100c733d1a42af76801e506105e2a80ec"
integrity sha1-6oL3sQDHM9GkKvdoAeUGEF4qgOw=
lodash.defaults@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
@ -4882,6 +4897,11 @@ node-forge@^0.10.0:
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3"
integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==
node-forge@^0.7.0:
version "0.7.6"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac"
integrity sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw==
node-libs-browser@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425"
@ -6045,6 +6065,20 @@ safe-regex@^1.1.0:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
saml2-js@2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/saml2-js/-/saml2-js-2.0.3.tgz#c7d935408d32b2f4b987defc43987d7b838d41bd"
integrity sha1-x9k1QI0ysvS5h978Q5h9e4ONQb0=
dependencies:
async "^2.5.0"
debug "^2.6.0"
underscore "^1.8.0"
xml-crypto "^0.10.0"
xml-encryption "^0.11.0"
xml2js "^0.4.0"
xmlbuilder "~2.2.0"
xmldom "^0.1.0"
sax@>=0.6.0, sax@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
@ -7089,6 +7123,11 @@ underscore@>=1.8.3:
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.1.tgz#7bb8cc9b3d397e201cf8553336d262544ead829e"
integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==
underscore@^1.8.0:
version "1.13.1"
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.1.tgz#0c1c6bd2df54b6b69f2314066d65b6cde6fcf9d1"
integrity sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==
union-value@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
@ -7505,7 +7544,26 @@ xdg-basedir@^4.0.0:
resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"
integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==
xml2js@^0.4.17:
xml-crypto@^0.10.0:
version "0.10.1"
resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-0.10.1.tgz#f832f74ccf56f24afcae1163a1fcab44d96774a8"
integrity sha1-+DL3TM9W8kr8rhFjofyrRNlndKg=
dependencies:
xmldom "=0.1.19"
xpath.js ">=0.0.3"
xml-encryption@^0.11.0:
version "0.11.2"
resolved "https://registry.yarnpkg.com/xml-encryption/-/xml-encryption-0.11.2.tgz#c217f5509547e34b500b829f2c0bca85cca73a21"
integrity sha512-jVvES7i5ovdO7N+NjgncA326xYKjhqeAnnvIgRnY7ROLCfFqEDLwP0Sxp/30SHG0AXQV1048T5yinOFyvwGFzg==
dependencies:
async "^2.1.5"
ejs "^2.5.6"
node-forge "^0.7.0"
xmldom "~0.1.15"
xpath "0.0.27"
xml2js@^0.4.0, xml2js@^0.4.17:
version "0.4.23"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==
@ -7518,11 +7576,38 @@ xmlbuilder@~11.0.0:
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
xmlbuilder@~2.2.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-2.2.1.tgz#9326430f130d87435d4c4086643aa2926e105a32"
integrity sha1-kyZDDxMNh0NdTECGZDqikm4QWjI=
dependencies:
lodash-node "~2.4.1"
xmlchars@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
xmldom@=0.1.19:
version "0.1.19"
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.19.tgz#631fc07776efd84118bf25171b37ed4d075a0abc"
integrity sha1-Yx/Ad3bv2EEYvyUXGzftTQdaCrw=
xmldom@^0.1.0, xmldom@~0.1.15:
version "0.1.31"
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
xpath.js@>=0.0.3:
version "1.1.0"
resolved "https://registry.yarnpkg.com/xpath.js/-/xpath.js-1.1.0.tgz#3816a44ed4bb352091083d002a383dd5104a5ff1"
integrity sha512-jg+qkfS4K8E7965sqaUl8mRngXiKb3WZGfONgE18pr03FUQiuSV6G+Ej4tS55B+rIQSFEIw3phdVAQ4pPqNWfQ==
xpath@0.0.27:
version "0.0.27"
resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.27.tgz#dd3421fbdcc5646ac32c48531b4d7e9d0c2cfa92"
integrity sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ==
xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"

Loading…
Cancel
Save