(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
This commit is contained in:
Paul Fitzpatrick 2021-08-16 11:11:17 -04:00
parent 800731e771
commit 54beaede84
13 changed files with 488 additions and 91 deletions

View File

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

View File

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

View File

@ -22,6 +22,10 @@ export interface SessionUserObj {
// [UNUSED] Login refresh token used to retrieve new ID and access tokens. // [UNUSED] Login refresh token used to retrieve new ID and access tokens.
refreshToken?: string; 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 // Session state maintained for a particular browser. It is identified by a cookie. There may be

View File

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

View File

@ -21,7 +21,6 @@ import {attachAppEndpoint} from 'app/server/lib/AppEndpoint';
import {addRequestUser, getUser, getUserId, isSingleUserMode, import {addRequestUser, getUser, getUserId, isSingleUserMode,
redirectToLoginUnconditionally} from 'app/server/lib/Authorizer'; redirectToLoginUnconditionally} from 'app/server/lib/Authorizer';
import {redirectToLogin, RequestWithLogin} 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 * as Comm from 'app/server/lib/Comm';
import {create} from 'app/server/lib/create'; import {create} from 'app/server/lib/create';
import {addDocApiRoutes} from 'app/server/lib/DocApi'; import {addDocApiRoutes} from 'app/server/lib/DocApi';
@ -97,7 +96,6 @@ export class FlexServer implements GristServer {
public host: string; public host: string;
public tag: string; public tag: string;
public info = new Array<[string, any]>(); public info = new Array<[string, any]>();
public sessions: Sessions;
public dbManager: HomeDBManager; public dbManager: HomeDBManager;
public notifier: INotifier; public notifier: INotifier;
public usage: Usage; public usage: Usage;
@ -117,9 +115,12 @@ export class FlexServer implements GristServer {
private _docWorker: DocWorker; private _docWorker: DocWorker;
private _hosts: Hosts; private _hosts: Hosts;
private _pluginManager: PluginManager; private _pluginManager: PluginManager;
private _sessions: Sessions;
private _sessionStore: SessionStore; private _sessionStore: SessionStore;
private _storageManager: IDocStorageManager; private _storageManager: IDocStorageManager;
private _docWorkerMap: IDocWorkerMap; 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 _disabled: boolean = false;
private _disableS3: boolean = false; private _disableS3: boolean = false;
private _healthy: boolean = true; // becomes false if a serious error has occurred and 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 _redirectToLoginUnconditionally: express.RequestHandler | null;
private _redirectToOrgMiddleware: express.RequestHandler; private _redirectToOrgMiddleware: express.RequestHandler;
private _redirectToHostMiddleware: express.RequestHandler; private _redirectToHostMiddleware: express.RequestHandler;
private _getLoginRedirectUrl: (target: URL) => Promise<string>; private _getLoginRedirectUrl: (req: express.Request, target: URL) => Promise<string>;
private _getSignUpRedirectUrl: (target: URL) => Promise<string>; private _getSignUpRedirectUrl: (req: express.Request, target: URL) => Promise<string>;
private _getLogoutRedirectUrl: (nextUrl: URL, userSession: SessionUserObj) => Promise<string>; private _getLogoutRedirectUrl: (req: express.Request, nextUrl: URL) => Promise<string>;
private _sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>; private _sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>;
constructor(public port: number, public name: string = 'flexServer', constructor(public port: number, public name: string = 'flexServer',
@ -234,8 +235,18 @@ export class FlexServer implements GristServer {
} }
public getPermitStore(): IPermitStore { public getPermitStore(): IPermitStore {
if (!this._docWorkerMap) { throw new Error('no permit store available'); } if (!this._internalPermitStore) { throw new Error('no permit store available'); }
return this._docWorkerMap; 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() { public addLogging() {
@ -424,6 +435,8 @@ export class FlexServer implements GristServer {
public addDocWorkerMap() { public addDocWorkerMap() {
if (this._check('map')) { return; } if (this._check('map')) { return; }
this._docWorkerMap = getDocWorkerMap(); 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, // 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 // If GRIST_DEFAULT_EMAIL is set, login as that user when no other credentials
// presented. // presented.
const fallbackEmail = process.env.GRIST_DEFAULT_EMAIL || null; 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)); fallbackEmail));
this._trustOriginsMiddleware = expressWrap(trustOriginHandler); this._trustOriginsMiddleware = expressWrap(trustOriginHandler);
// middleware to authorize doc access to the app. Note that this requires the userId // 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.`); res.status(200).send(`Grist ${this.name} is alive and is interested in you.`);
}); });
this.sessions = sessions; this._sessions = sessions;
this._sessionStore = sessionStore; 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 // 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. // 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._getLoginRedirectUrl = tbind(this._loginMiddleware.getLoginRedirectUrl, this._loginMiddleware);
this._getSignUpRedirectUrl = tbind(this._loginMiddleware.getSignUpRedirectUrl, this._loginMiddleware); this._getSignUpRedirectUrl = tbind(this._loginMiddleware.getSignUpRedirectUrl, this._loginMiddleware);
this._getLogoutRedirectUrl = tbind(this._loginMiddleware.getLogoutRedirectUrl, 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; } if (this._check('comm', 'start')) { return; }
this.comm = new Comm(this.server, { this.comm = new Comm(this.server, {
settings: this.settings, settings: this.settings,
sessions: this.sessions, sessions: this._sessions,
hosts: this._hosts, hosts: this._hosts,
httpsServer: this.httpsServer, httpsServer: this.httpsServer,
}); });
@ -792,7 +805,7 @@ export class FlexServer implements GristServer {
signUp = (mreq.session.users === undefined); signUp = (mreq.session.users === undefined);
} }
const getRedirectUrl = signUp ? this._getSignUpRedirectUrl : this._getLoginRedirectUrl; 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))); 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) => { this.app.get('/test/login', expressWrap(async (req, res) => {
log.warn("Serving unauthenticated /test/login endpoint, made available because GRIST_TEST_LOGIN is set."); 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 = { const profile: UserProfile = {
email: optStringParam(req.query.email) || 'chimpy@getgrist.com', email: optStringParam(req.query.email) || 'chimpy@getgrist.com',
name: optStringParam(req.query.name) || 'Chimpy McBanana', name: optStringParam(req.query.name) || 'Chimpy McBanana',
@ -831,12 +844,11 @@ export class FlexServer implements GristServer {
} }
this.app.get('/logout', expressWrap(async (req, resp) => { this.app.get('/logout', expressWrap(async (req, resp) => {
const scopedSession = this.sessions.getOrCreateSessionFromRequest(req); const scopedSession = this._sessions.getOrCreateSessionFromRequest(req);
const userSession = await scopedSession.getScopedSession();
// If 'next' param is missing, redirect to "/" on our requested hostname. // If 'next' param is missing, redirect to "/" on our requested hostname.
const next = optStringParam(req.query.next) || (req.protocol + '://' + req.get('host') + '/'); 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. // 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. // 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.app.get('/verified', expressWrap((req, resp) =>
this._sendAppPage(req, resp, {path: 'error.html', status: 200, config: {errPage: 'verified'}}))); 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]); this.info.push(['loginMiddlewareComment', comment]);
} }
@ -1057,7 +1069,7 @@ export class FlexServer implements GristServer {
throw new Error(`Can't resolve ${urlId}: ${docAuth.error}`); 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`), { const res = await fetch(await this.getHomeUrlByDocId(docId, `/api/docs/${docId}/tables/Responses/data`), {
method: 'POST', method: 'POST',
headers: {'Permit': permitKey, 'Content-Type': 'application/json'}, 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}); log.rawWarn(`Failed to record new user info: ${e.message}`, {newUserQuestions: body});
} finally { } finally {
if (permitKey) { if (permitKey) {
await this._docWorkerMap.removePermit(permitKey); await this._internalPermitStore.removePermit(permitKey);
} }
} }
@ -1212,7 +1224,7 @@ export class FlexServer implements GristServer {
public async addHousekeeper() { public async addHousekeeper() {
if (this._check('housekeeper', 'start', 'homedb', 'map', 'json', 'api-mw')) { return; } if (this._check('housekeeper', 'start', 'homedb', 'map', 'json', 'api-mw')) { return; }
const store = this._docWorkerMap; 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); this.housekeeper.addEndpoints(this.app);
await this.housekeeper.start(); await this.housekeeper.start();
} }

View File

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

View File

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

View File

@ -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';
}
};
}

View File

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

View File

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

View File

@ -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 { return {
async getLoginRedirectUrl(target: URL) { throw new Error('logins not implemented'); }, async getLoginRedirectUrl() { throw new Error('logins not implemented'); },
async getLogoutRedirectUrl(target: URL) { throw new Error('logins not implemented'); }, async getLogoutRedirectUrl() { throw new Error('logins not implemented'); },
async getSignUpRedirectUrl(target: URL) { throw new Error('logins not implemented'); }, async getSignUpRedirectUrl() { throw new Error('logins not implemented'); },
addEndpoints(...args: any[]) { addEndpoints() { return "no-logins"; }
return "no-logins";
}
}; };
} }

View File

@ -41,8 +41,10 @@ export async function main() {
console.log('For full logs, re-run with DEBUG=1'); console.log('For full logs, re-run with DEBUG=1');
} }
// There's no login system released yet, so set a default email address. // 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'); setDefaultEnv('GRIST_DEFAULT_EMAIL', 'you@example.com');
}
// Set directory for uploaded documents. // Set directory for uploaded documents.
setDefaultEnv('GRIST_DATA_DIR', 'docs'); setDefaultEnv('GRIST_DATA_DIR', 'docs');
await fse.mkdirp(process.env.GRIST_DATA_DIR!); await fse.mkdirp(process.env.GRIST_DATA_DIR!);

View File

@ -319,6 +319,11 @@
"@types/bluebird" "*" "@types/bluebird" "*"
"@types/events" "*" "@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": "@types/selenium-webdriver@4.0.0":
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-4.0.0.tgz#21aa550f08d2cca49f8537f6697288a0d4300f28" 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: dependencies:
tslib "^2.0.0" tslib "^2.0.0"
async@^2.5.0: async@^2.1.5, async@^2.5.0:
version "2.6.3" version "2.6.3"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg== 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" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.6.tgz#288b2aa82f2d8418a6c9d4df5898c0737ad02a63"
integrity sha512-AztC/IOW4L1Q41A86phW5Thhcrco3xuAA+YX/BLpLWWjRcTj5TOt/QImBLmCKlrF7u7k47arTnOyL6GnbG8Hvw== 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" version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== 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" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= 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: electron-download@^4.1.0:
version "4.1.1" version "4.1.1"
resolved "https://registry.yarnpkg.com/electron-download/-/electron-download-4.1.1.tgz#02e69556705cc456e520f9e035556ed5a015ebe8" 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" p-locate "^3.0.0"
path-exists "^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: lodash.defaults@^4.2.0:
version "4.2.0" version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" 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" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3"
integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA== 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: node-libs-browser@^2.2.1:
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425" 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" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== 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: sax@>=0.6.0, sax@^1.2.4:
version "1.2.4" version "1.2.4"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" 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" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.1.tgz#7bb8cc9b3d397e201cf8553336d262544ead829e"
integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw== 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: union-value@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" 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" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"
integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== 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" version "0.4.23"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== 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" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== 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: xmlchars@^2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== 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: xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.1:
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"