mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
parent
2be130032e
commit
a4998b4b21
@ -68,6 +68,11 @@ export interface SessionObj {
|
|||||||
// anonymous editing (e.g. to allow the user to edit
|
// anonymous editing (e.g. to allow the user to edit
|
||||||
// something they just added, without allowing the suer
|
// something they just added, without allowing the suer
|
||||||
// to edit other people's contributions).
|
// to edit other people's contributions).
|
||||||
|
|
||||||
|
oidc?: {
|
||||||
|
// codeVerifier is used during OIDC authentication, to protect against attacks like CSRF.
|
||||||
|
codeVerifier?: string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make an artificial change to a session to encourage express-session to set a cookie.
|
// Make an artificial change to a session to encourage express-session to set a cookie.
|
||||||
|
187
app/server/lib/OIDCConfig.ts
Normal file
187
app/server/lib/OIDCConfig.ts
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Configuration for OpenID Connect (OIDC), useful for enterprise single-sign-on logins.
|
||||||
|
* A good informative overview of OIDC is at https://developer.okta.com/blog/2019/10/21/illustrated-guide-to-oauth-and-oidc
|
||||||
|
* 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 also use optional attributes for the user's name, for which we accept any of:
|
||||||
|
* given_name
|
||||||
|
* family_name
|
||||||
|
*
|
||||||
|
* Expected environment variables:
|
||||||
|
* env GRIST_OIDC_SP_HOST=https://<your-domain>
|
||||||
|
* Host at which our /oauth2 endpoint will live. Optional, defaults to `APP_HOME_URL`.
|
||||||
|
* env GRIST_OIDC_IDP_ISSUER
|
||||||
|
* The issuer URL for the IdP, passed to node-openid-client, see: https://github.com/panva/node-openid-client/blob/a84d022f195f82ca1c97f8f6b2567ebcef8738c3/docs/README.md#issuerdiscoverissuer.
|
||||||
|
* This variable turns on the OIDC login system.
|
||||||
|
* env GRIST_OIDC_IDP_CLIENT_ID
|
||||||
|
* The client ID for the application, as registered with the IdP.
|
||||||
|
* env GRIST_OIDC_IDP_CLIENT_SECRET
|
||||||
|
* The client secret for the application, as registered with the IdP.
|
||||||
|
* env GRIST_OIDC_IDP_SCOPES
|
||||||
|
* The scopes to request from the IdP, as a space-separated list. Defaults to "openid email profile".
|
||||||
|
*
|
||||||
|
* This version of OIDCConfig has been tested with Keycloak OIDC IdP following the instructions
|
||||||
|
* at:
|
||||||
|
* https://www.keycloak.org/getting-started/getting-started-docker
|
||||||
|
*
|
||||||
|
* /!\ CAUTION: For production, be sure to use https for all URLs. /!\
|
||||||
|
*
|
||||||
|
* For development of this module on localhost, these settings should work:
|
||||||
|
* - GRIST_OIDC_SP_HOST=http://localhost:8484 (or whatever port you use for Grist)
|
||||||
|
* - GRIST_OIDC_IDP_ISSUER=http://localhost:8080/realms/myrealm (replace 8080 by the port you use for keycloak)
|
||||||
|
* - GRIST_OIDC_IDP_CLIENT_ID=my_grist_instance
|
||||||
|
* - GRIST_OIDC_IDP_CLIENT_SECRET=YOUR_SECRET (as set in keycloak)
|
||||||
|
* - GRIST_OIDC_IDP_SCOPES="openid email profile"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as express from 'express';
|
||||||
|
import { GristLoginSystem, GristServer } from './GristServer';
|
||||||
|
import { Client, generators, Issuer, UserinfoResponse } from 'openid-client';
|
||||||
|
import { Sessions } from './Sessions';
|
||||||
|
import log from 'app/server/lib/log';
|
||||||
|
import { appSettings } from './AppSettings';
|
||||||
|
import { RequestWithLogin } from './Authorizer';
|
||||||
|
|
||||||
|
const CALLBACK_URL = '/oauth2/callback';
|
||||||
|
|
||||||
|
export class OIDCConfig {
|
||||||
|
private _client: Client;
|
||||||
|
private _redirectUrl: string;
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public async initOIDC(): Promise<void> {
|
||||||
|
const section = appSettings.section('login').section('system').section('oidc');
|
||||||
|
const spHost = section.flag('spHost').requireString({
|
||||||
|
envVar: 'GRIST_OIDC_SP_HOST',
|
||||||
|
defaultValue: process.env.APP_HOME_URL,
|
||||||
|
});
|
||||||
|
const issuerUrl = section.flag('issuer').requireString({
|
||||||
|
envVar: 'GRIST_OIDC_IDP_ISSUER',
|
||||||
|
});
|
||||||
|
const clientId = section.flag('clientId').requireString({
|
||||||
|
envVar: 'GRIST_OIDC_IDP_CLIENT_ID',
|
||||||
|
});
|
||||||
|
const clientSecret = section.flag('clientSecret').requireString({
|
||||||
|
envVar: 'GRIST_OIDC_IDP_CLIENT_SECRET',
|
||||||
|
censor: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issuer = await Issuer.discover(issuerUrl);
|
||||||
|
this._redirectUrl = new URL(CALLBACK_URL, spHost).href;
|
||||||
|
this._client = new issuer.Client({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uris: [ this._redirectUrl ],
|
||||||
|
response_types: [ 'code' ],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public addEndpoints(app: express.Application, sessions: Sessions): void {
|
||||||
|
app.get(CALLBACK_URL, this.handleCallback.bind(this, sessions));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handleCallback(sessions: Sessions, req: express.Request, res: express.Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const params = this._client.callbackParams(req);
|
||||||
|
const { state } = params;
|
||||||
|
if (!state) {
|
||||||
|
throw new Error('Login or logout failed to complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeVerifier = await this._retrieveCodeVerifierFromSession(req);
|
||||||
|
|
||||||
|
const tokenSet = await this._client.callback(
|
||||||
|
this._redirectUrl,
|
||||||
|
params,
|
||||||
|
{ state, code_verifier: codeVerifier }
|
||||||
|
);
|
||||||
|
|
||||||
|
const userInfo = await this._client.userinfo(tokenSet);
|
||||||
|
const profile = this._makeUserProfileFromUserInfo(userInfo);
|
||||||
|
|
||||||
|
const scopedSession = sessions.getOrCreateSessionFromRequest(req);
|
||||||
|
await scopedSession.operateOnScopedSession(req, async (user) => Object.assign(user, {
|
||||||
|
profile,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.redirect('/');
|
||||||
|
} catch (err) {
|
||||||
|
log.error(`OIDC callback failed: ${err.message}`);
|
||||||
|
res.status(500).send(`OIDC callback failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLoginRedirectUrl(req: express.Request): Promise<string> {
|
||||||
|
const codeVerifier = await this._generateAndStoreCodeVerifier(req);
|
||||||
|
const codeChallenge = generators.codeChallenge(codeVerifier);
|
||||||
|
const state = generators.state();
|
||||||
|
|
||||||
|
const authUrl = this._client.authorizationUrl({
|
||||||
|
scope: process.env.GRIST_OIDC_IDP_SCOPES || 'openid email profile',
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
return authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLogoutRedirectUrl(req: express.Request, redirectUrl: URL): Promise<string> {
|
||||||
|
return this._client.endSessionUrl({
|
||||||
|
post_logout_redirect_uri: redirectUrl.href
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _generateAndStoreCodeVerifier(req: express.Request) {
|
||||||
|
const mreq = req as RequestWithLogin;
|
||||||
|
if (!mreq.session) { throw new Error('no session available'); }
|
||||||
|
const codeVerifier = generators.codeVerifier();
|
||||||
|
mreq.session.oidc = {
|
||||||
|
codeVerifier,
|
||||||
|
};
|
||||||
|
|
||||||
|
return codeVerifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _retrieveCodeVerifierFromSession(req: express.Request) {
|
||||||
|
const mreq = req as RequestWithLogin;
|
||||||
|
if (!mreq.session) { throw new Error('no session available'); }
|
||||||
|
const codeVerifier = mreq.session.oidc?.codeVerifier;
|
||||||
|
if (!codeVerifier) { throw new Error('Login is stale'); }
|
||||||
|
delete mreq.session.oidc?.codeVerifier;
|
||||||
|
return codeVerifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _makeUserProfileFromUserInfo(userInfo: UserinfoResponse) {
|
||||||
|
const email = userInfo.email;
|
||||||
|
const fname = userInfo.given_name ?? '';
|
||||||
|
const lname = userInfo.family_name ?? '';
|
||||||
|
return {
|
||||||
|
email,
|
||||||
|
name: `${fname} ${lname}`.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOIDCLoginSystem(): Promise<GristLoginSystem|undefined> {
|
||||||
|
if (!process.env.GRIST_OIDC_IDP_ISSUER) { return undefined; }
|
||||||
|
return {
|
||||||
|
async getMiddleware(gristServer: GristServer) {
|
||||||
|
const config = new OIDCConfig();
|
||||||
|
await config.initOIDC();
|
||||||
|
return {
|
||||||
|
getLoginRedirectUrl: config.getLoginRedirectUrl.bind(config),
|
||||||
|
getSignUpRedirectUrl: config.getLoginRedirectUrl.bind(config),
|
||||||
|
getLogoutRedirectUrl: config.getLogoutRedirectUrl.bind(config),
|
||||||
|
async addEndpoints(app: express.Express) {
|
||||||
|
config.addEndpoints(app, gristServer.getSessions());
|
||||||
|
return 'oidc';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async deleteUser() {},
|
||||||
|
};
|
||||||
|
}
|
@ -168,6 +168,7 @@
|
|||||||
"multiparty": "4.2.2",
|
"multiparty": "4.2.2",
|
||||||
"node-abort-controller": "3.0.1",
|
"node-abort-controller": "3.0.1",
|
||||||
"node-fetch": "2.6.7",
|
"node-fetch": "2.6.7",
|
||||||
|
"openid-client": "^5.6.1",
|
||||||
"pg": "8.6.0",
|
"pg": "8.6.0",
|
||||||
"piscina": "3.2.0",
|
"piscina": "3.2.0",
|
||||||
"plotly.js-basic-dist": "2.13.2",
|
"plotly.js-basic-dist": "2.13.2",
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { getForwardAuthLoginSystem } from 'app/server/lib/ForwardAuthLogin';
|
import { getForwardAuthLoginSystem } from 'app/server/lib/ForwardAuthLogin';
|
||||||
import { GristLoginSystem } from 'app/server/lib/GristServer';
|
import { GristLoginSystem } from 'app/server/lib/GristServer';
|
||||||
import { getMinimalLoginSystem } from 'app/server/lib/MinimalLogin';
|
import { getMinimalLoginSystem } from 'app/server/lib/MinimalLogin';
|
||||||
|
import { getOIDCLoginSystem } from 'app/server/lib/OIDCConfig';
|
||||||
import { getSamlLoginSystem } from 'app/server/lib/SamlConfig';
|
import { getSamlLoginSystem } from 'app/server/lib/SamlConfig';
|
||||||
|
|
||||||
export async function getLoginSystem(): Promise<GristLoginSystem> {
|
export async function getLoginSystem(): Promise<GristLoginSystem> {
|
||||||
return await getSamlLoginSystem() ||
|
return await getSamlLoginSystem() ||
|
||||||
|
await getOIDCLoginSystem() ||
|
||||||
await getForwardAuthLoginSystem() ||
|
await getForwardAuthLoginSystem() ||
|
||||||
await getMinimalLoginSystem();
|
await getMinimalLoginSystem();
|
||||||
}
|
}
|
||||||
|
25
yarn.lock
25
yarn.lock
@ -4923,6 +4923,11 @@ jest-worker@^27.4.5:
|
|||||||
merge-stream "^2.0.0"
|
merge-stream "^2.0.0"
|
||||||
supports-color "^8.0.0"
|
supports-color "^8.0.0"
|
||||||
|
|
||||||
|
jose@^4.15.1:
|
||||||
|
version "4.15.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.4.tgz#02a9a763803e3872cf55f29ecef0dfdcc218cc03"
|
||||||
|
integrity sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==
|
||||||
|
|
||||||
joycon@^3.0.1:
|
joycon@^3.0.1:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz"
|
resolved "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz"
|
||||||
@ -6067,6 +6072,11 @@ object-assign@^4.0.1, object-assign@^4.1.1:
|
|||||||
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
|
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
|
||||||
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
|
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
|
||||||
|
|
||||||
|
object-hash@^2.2.0:
|
||||||
|
version "2.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5"
|
||||||
|
integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==
|
||||||
|
|
||||||
object-inspect@^1.9.0:
|
object-inspect@^1.9.0:
|
||||||
version "1.9.0"
|
version "1.9.0"
|
||||||
resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz"
|
resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz"
|
||||||
@ -6087,6 +6097,11 @@ object.assign@^4.0.4:
|
|||||||
has-symbols "^1.0.3"
|
has-symbols "^1.0.3"
|
||||||
object-keys "^1.1.1"
|
object-keys "^1.1.1"
|
||||||
|
|
||||||
|
oidc-token-hash@^5.0.3:
|
||||||
|
version "5.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz#9a229f0a1ce9d4fc89bcaee5478c97a889e7b7b6"
|
||||||
|
integrity sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==
|
||||||
|
|
||||||
on-finished@2.4.1:
|
on-finished@2.4.1:
|
||||||
version "2.4.1"
|
version "2.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
|
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
|
||||||
@ -6113,6 +6128,16 @@ once@^1.3.0, once@^1.3.1, once@^1.3.2, once@^1.4.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
wrappy "1"
|
wrappy "1"
|
||||||
|
|
||||||
|
openid-client@^5.6.1:
|
||||||
|
version "5.6.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.6.1.tgz#8f7526a50c290a5e28a7fe21b3ece3107511bc73"
|
||||||
|
integrity sha512-PtrWsY+dXg6y8mtMPyL/namZSYVz8pjXz3yJiBNZsEdCnu9miHLB4ELVC85WvneMKo2Rg62Ay7NkuCpM0bgiLQ==
|
||||||
|
dependencies:
|
||||||
|
jose "^4.15.1"
|
||||||
|
lru-cache "^6.0.0"
|
||||||
|
object-hash "^2.2.0"
|
||||||
|
oidc-token-hash "^5.0.3"
|
||||||
|
|
||||||
optionator@^0.8.1:
|
optionator@^0.8.1:
|
||||||
version "0.8.3"
|
version "0.8.3"
|
||||||
resolved "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz"
|
resolved "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz"
|
||||||
|
Loading…
Reference in New Issue
Block a user