You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/server/lib/OIDCConfig.ts

397 lines
16 KiB

/**
* 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
* 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".
* env GRIST_OIDC_SP_PROFILE_NAME_ATTR
* The key of the attribute to use for the user's name.
* If omitted, the name will either be the concatenation of "given_name" + "family_name" or the "name" attribute.
* env GRIST_OIDC_SP_PROFILE_EMAIL_ATTR
* The key of the attribute to use for the user's email. Defaults to "email".
* env GRIST_OIDC_IDP_END_SESSION_ENDPOINT
* If set, overrides the IdP's end_session_endpoint with an alternative URL to redirect user upon logout
* (for an IdP that has a logout endpoint but does not support the OIDC RP-Initiated Logout specification).
* env GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT
* If set to "true", on logout, there won't be any attempt to call the IdP's end_session_endpoint
* (the user will remain logged in in the IdP).
* env GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED
* If set to "true", the user will be allowed to login even if the email is not verified by the IDP.
* Defaults to false.
* env GRIST_OIDC_IDP_ENABLED_PROTECTIONS
* A comma-separated list of protections to enable. Supported values are "PKCE", "STATE", "NONCE".
* It's highly recommended that you enable STATE, and at least either PKCE or NONCE.
* Defaults to "PKCE,STATE".
* env GRIST_OIDC_IDP_ACR_VALUES
* A space-separated list of ACR values to request from the IdP. Optional.
* env GRIST_OIDC_IDP_EXTRA_CLIENT_METADATA
* A JSON object with extra client metadata to pass to openid-client. Optional.
* More info: https://github.com/panva/node-openid-client/tree/main/docs#new-clientmetadata-jwks-options
*
*
* 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, ClientMetadata, generators, Issuer, TokenSet, UserinfoResponse } from 'openid-client';
import { Sessions } from './Sessions';
import log from 'app/server/lib/log';
import { AppSettings, appSettings } from './AppSettings';
import { RequestWithLogin } from './Authorizer';
import { UserProfile } from 'app/common/LoginSessionAPI';
import _ from 'lodash';
import { SessionObj } from './BrowserSession';
import { SendAppPage } from './sendAppPage';
enum ENABLED_PROTECTIONS {
NONCE,
PKCE,
STATE,
}
type EnabledProtectionsString = keyof typeof ENABLED_PROTECTIONS;
const CALLBACK_URL = '/oauth2/callback';
function formatTokenForLogs(token: TokenSet) {
return _.chain(token)
.omitBy(_.isFunction)
.mapValues((value, key) => {
const showValueInClear = ['token_type', 'expires_in', 'expires_at', 'scope'].includes(key);
return showValueInClear ? value : 'REDACTED';
}).value();
}
const DEFAULT_USER_FRIENDLY_MESSAGE =
"Something went wrong while logging, please try again or contact your administrator if the problem persists";
class ErrorWithUserFriendlyMessage extends Error {
constructor(errMessage: string, public readonly userFriendlyMessage: string = DEFAULT_USER_FRIENDLY_MESSAGE) {
super(errMessage);
}
}
export class OIDCConfig {
/**
* Handy alias to create an OIDCConfig instance and initialize it.
*/
public static async build(sendAppPage: SendAppPage): Promise<OIDCConfig> {
const config = new OIDCConfig(sendAppPage);
await config.initOIDC();
return config;
}
protected _client: Client;
private _redirectUrl: string;
private _namePropertyKey?: string;
private _emailPropertyKey: string;
private _endSessionEndpoint: string;
private _skipEndSessionEndpoint: boolean;
private _ignoreEmailVerified: boolean;
private _enabledProtections: EnabledProtectionsString[] = [];
private _acrValues?: string;
protected constructor(
private _sendAppPage: SendAppPage
) {}
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,
});
this._namePropertyKey = section.flag('namePropertyKey').readString({
envVar: 'GRIST_OIDC_SP_PROFILE_NAME_ATTR',
});
this._emailPropertyKey = section.flag('emailPropertyKey').requireString({
envVar: 'GRIST_OIDC_SP_PROFILE_EMAIL_ATTR',
defaultValue: 'email',
});
this._endSessionEndpoint = section.flag('endSessionEndpoint').readString({
envVar: 'GRIST_OIDC_IDP_END_SESSION_ENDPOINT',
defaultValue: '',
})!;
this._skipEndSessionEndpoint = section.flag('skipEndSessionEndpoint').readBool({
envVar: 'GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT',
defaultValue: false,
})!;
this._acrValues = section.flag('acrValues').readString({
envVar: 'GRIST_OIDC_IDP_ACR_VALUES',
})!;
this._ignoreEmailVerified = section.flag('ignoreEmailVerified').readBool({
envVar: 'GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED',
defaultValue: false,
})!;
const extraMetadata: Partial<ClientMetadata> = JSON.parse(section.flag('extraClientMetadata').readString({
envVar: 'GRIST_OIDC_IDP_EXTRA_CLIENT_METADATA',
defaultValue: '{}'
})!);
this._enabledProtections = this._buildEnabledProtections(section);
this._redirectUrl = new URL(CALLBACK_URL, spHost).href;
await this._initClient({ issuerUrl, clientId, clientSecret, extraMetadata });
if (this._client.issuer.metadata.end_session_endpoint === undefined &&
!this._endSessionEndpoint && !this._skipEndSessionEndpoint) {
throw new Error('The Identity provider does not propose end_session_endpoint. ' +
'If that is expected, please set GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT=true ' +
'or provide an alternative logout URL in GRIST_OIDC_IDP_END_SESSION_ENDPOINT');
}
log.info(`OIDCConfig: initialized with issuer ${issuerUrl}`);
}
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> {
const mreq = req as RequestWithLogin;
try {
const params = this._client.callbackParams(req);
const { targetUrl } = mreq.session?.oidc ?? {};
const checks = await this._retrieveChecksFromSession(mreq);
// The callback function will compare the state present in the params and the one we retrieved from the session.
// If they don't match, it will throw an error.
const tokenSet = await this._client.callback(this._redirectUrl, params, checks);
log.debug("Got tokenSet: %o", formatTokenForLogs(tokenSet));
const userInfo = await this._client.userinfo(tokenSet);
log.debug("Got userinfo: %o", userInfo);
if (!this._ignoreEmailVerified && userInfo.email_verified !== true) {
throw new ErrorWithUserFriendlyMessage(
`OIDCConfig: email not verified for ${userInfo.email}`,
"Your email is not verified according to the identity provider, please take the neccessary steps for that " +
"and log in again."
);
}
const profile = this._makeUserProfileFromUserInfo(userInfo);
log.info(`OIDCConfig: got OIDC response for ${profile.email} (${profile.name}) redirecting to ${targetUrl}`);
const scopedSession = sessions.getOrCreateSessionFromRequest(req);
await scopedSession.operateOnScopedSession(req, async (user) => Object.assign(user, {
profile,
}));
mreq.session.oidc = {
idToken: tokenSet.id_token, // keep idToken for logout
state: mreq.session.oidc?.state, // also keep state for logout
};
res.redirect(targetUrl ?? '/');
} catch (err) {
log.error(`OIDC callback failed: ${err.stack}`);
if (Object.prototype.hasOwnProperty.call(err, 'response')) {
log.error(`Response received: ${err.response?.body ?? err.response}`);
}
// Delete the session data even if the login failed.
// This way, we prevent several login attempts.
//
// Also session deletion must be done before sending the response.
delete mreq.session.oidc;
await this._sendAppPage(req, res, {
path: 'error.html',
status: 500,
config: {
errPage: 'signin-failed',
errMessage: err.userFriendlyMessage
},
});
}
}
public async getLoginRedirectUrl(req: express.Request, targetUrl: URL): Promise<string> {
const protections = await this._generateAndStoreConnectionInfo(req, targetUrl.href);
const authUrl = this._client.authorizationUrl({
scope: process.env.GRIST_OIDC_IDP_SCOPES || 'openid email profile',
acr_values: this._acrValues ?? undefined,
...this._forgeProtectionParamsForAuthUrl(protections),
});
return authUrl;
}
public async getLogoutRedirectUrl(req: express.Request, redirectUrl: URL): Promise<string> {
const mreq = req as RequestWithLogin;
// For IdPs that don't have end_session_endpoint, we just redirect to the logout page.
if (this._skipEndSessionEndpoint) {
return redirectUrl.href;
}
// Alternatively, we could use a logout URL specified by configuration.
if (this._endSessionEndpoint) {
return this._endSessionEndpoint;
}
return this._client.endSessionUrl({
post_logout_redirect_uri: redirectUrl.href,
state: mreq.session.oidc?.state,
id_token_hint: mreq.session.oidc?.idToken,
});
}
public supportsProtection(protection: EnabledProtectionsString) {
return this._enabledProtections.includes(protection);
}
protected async _initClient({ issuerUrl, clientId, clientSecret, extraMetadata }:
{ issuerUrl: string, clientId: string, clientSecret: string, extraMetadata: Partial<ClientMetadata> }
): Promise<void> {
const issuer = await Issuer.discover(issuerUrl);
this._client = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [this._redirectUrl],
response_types: ['code'],
...extraMetadata,
});
}
private _forgeProtectionParamsForAuthUrl(protections: { codeVerifier?: string, state?: string, nonce?: string }) {
return _.omitBy({
state: protections.state,
nonce: protections.nonce,
code_challenge: protections.codeVerifier ?
generators.codeChallenge(protections.codeVerifier) :
undefined,
code_challenge_method: protections.codeVerifier ? 'S256' : undefined,
}, _.isUndefined);
}
private async _generateAndStoreConnectionInfo(req: express.Request, targetUrl: string) {
const mreq = req as RequestWithLogin;
if (!mreq.session) { throw new Error('no session available'); }
const oidcInfo: SessionObj['oidc'] = {
targetUrl
};
if (this.supportsProtection('PKCE')) {
oidcInfo.codeVerifier = generators.codeVerifier();
}
if (this.supportsProtection('STATE')) {
oidcInfo.state = generators.state();
}
if (this.supportsProtection('NONCE')) {
oidcInfo.nonce = generators.nonce();
}
mreq.session.oidc = oidcInfo;
return _.pick(oidcInfo, ['codeVerifier', 'state', 'nonce']);
}
private _buildEnabledProtections(section: AppSettings): EnabledProtectionsString[] {
const enabledProtections = section.flag('enabledProtections').readString({
envVar: 'GRIST_OIDC_IDP_ENABLED_PROTECTIONS',
defaultValue: 'PKCE,STATE',
})!.split(',');
if (enabledProtections.length === 1 && enabledProtections[0] === '') {
return [];
}
for (const protection of enabledProtections) {
if (!ENABLED_PROTECTIONS.hasOwnProperty(protection as EnabledProtectionsString)) {
throw new Error(`OIDC: Invalid protection in GRIST_OIDC_IDP_ENABLED_PROTECTIONS: ${protection}`);
}
}
return enabledProtections as EnabledProtectionsString[];
}
private async _retrieveChecksFromSession(mreq: RequestWithLogin):
Promise<{ code_verifier?: string, state?: string, nonce?: string }> {
if (!mreq.session) { throw new Error('no session available'); }
const state = mreq.session.oidc?.state;
if (!state && this.supportsProtection('STATE')) {
throw new Error('Login or logout failed to complete');
}
const codeVerifier = mreq.session.oidc?.codeVerifier;
if (!codeVerifier && this.supportsProtection('PKCE')) { throw new Error('Login is stale'); }
const nonce = mreq.session.oidc?.nonce;
if (!nonce && this.supportsProtection('NONCE')) { throw new Error('Login is stale'); }
return _.omitBy({ code_verifier: codeVerifier, state, nonce }, _.isUndefined);
}
private _makeUserProfileFromUserInfo(userInfo: UserinfoResponse): Partial<UserProfile> {
return {
email: String(userInfo[this._emailPropertyKey]),
name: this._extractName(userInfo)
};
}
private _extractName(userInfo: UserinfoResponse): string | undefined {
if (this._namePropertyKey) {
return (userInfo[this._namePropertyKey] as any)?.toString();
}
const fname = userInfo.given_name ?? '';
const lname = userInfo.family_name ?? '';
return `${fname} ${lname}`.trim() || userInfo.name;
}
}
export async function getOIDCLoginSystem(): Promise<GristLoginSystem | undefined> {
if (!process.env.GRIST_OIDC_IDP_ISSUER) { return undefined; }
return {
async getMiddleware(gristServer: GristServer) {
const config = await OIDCConfig.build(gristServer.sendAppPage.bind(gristServer));
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() { },
};
}