mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
268 lines
13 KiB
TypeScript
268 lines
13 KiB
TypeScript
/**
|
|
* 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://<your-domain>
|
|
* 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 {expressWrap} from 'app/server/lib/expressWrap';
|
|
import {GristLoginSystem, 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 response: ${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, {sessionId: state.sessionId});
|
|
await scopedSession.operateOnScopedSession(req, 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 login system if environment looks configured for it, else return undefined.
|
|
*/
|
|
export async function getSamlLoginSystem(): Promise<GristLoginSystem|undefined> {
|
|
if (!process.env.GRIST_SAML_SP_HOST) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
async getMiddleware(gristServer: GristServer) {
|
|
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),
|
|
async addEndpoints(app: express.Express) {
|
|
samlConfig.addSamlEndpoints(app, gristServer.getSessions());
|
|
return 'saml';
|
|
},
|
|
};
|
|
},
|
|
deleteUser() {
|
|
throw new Error('users cannot be deleted with SAML yet');
|
|
},
|
|
};
|
|
}
|