diff --git a/app/server/lib/BrowserSession.ts b/app/server/lib/BrowserSession.ts index 83de69cd..69b5d97b 100644 --- a/app/server/lib/BrowserSession.ts +++ b/app/server/lib/BrowserSession.ts @@ -68,6 +68,11 @@ export interface SessionObj { // anonymous editing (e.g. to allow the user to edit // something they just added, without allowing the suer // 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. diff --git a/app/server/lib/OIDCConfig.ts b/app/server/lib/OIDCConfig.ts new file mode 100644 index 00000000..d5436169 --- /dev/null +++ b/app/server/lib/OIDCConfig.ts @@ -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:// + * 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 { + 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 { + 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 { + 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 { + 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 { + 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() {}, + }; +} diff --git a/package.json b/package.json index 489865d6..699009de 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "multiparty": "4.2.2", "node-abort-controller": "3.0.1", "node-fetch": "2.6.7", + "openid-client": "^5.6.1", "pg": "8.6.0", "piscina": "3.2.0", "plotly.js-basic-dist": "2.13.2", diff --git a/stubs/app/server/lib/logins.ts b/stubs/app/server/lib/logins.ts index 745ac7ba..ae85ccaf 100644 --- a/stubs/app/server/lib/logins.ts +++ b/stubs/app/server/lib/logins.ts @@ -1,10 +1,12 @@ import { getForwardAuthLoginSystem } from 'app/server/lib/ForwardAuthLogin'; import { GristLoginSystem } from 'app/server/lib/GristServer'; import { getMinimalLoginSystem } from 'app/server/lib/MinimalLogin'; +import { getOIDCLoginSystem } from 'app/server/lib/OIDCConfig'; import { getSamlLoginSystem } from 'app/server/lib/SamlConfig'; export async function getLoginSystem(): Promise { return await getSamlLoginSystem() || + await getOIDCLoginSystem() || await getForwardAuthLoginSystem() || await getMinimalLoginSystem(); } diff --git a/yarn.lock b/yarn.lock index 9349d7f0..29c07168 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4923,6 +4923,11 @@ jest-worker@^27.4.5: merge-stream "^2.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: version "3.1.1" 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" 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: version "1.9.0" 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" 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: version "2.4.1" 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: 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: version "0.8.3" resolved "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz"