-
-
Notifications
You must be signed in to change notification settings - Fork 348
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
- Loading branch information
Showing
5 changed files
with
220 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() {}, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<GristLoginSystem> { | ||
return await getSamlLoginSystem() || | ||
await getOIDCLoginSystem() || | ||
await getForwardAuthLoginSystem() || | ||
await getMinimalLoginSystem(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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== | ||
|
||
[email protected]: | ||
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" | ||
|