Florent 2 weeks ago committed by GitHub
commit 8dce97c4ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -21,6 +21,7 @@ export function createErrPage(appModel: AppModel) {
errPage === 'not-found' ? createNotFoundPage(appModel, errMessage) :
errPage === 'access-denied' ? createForbiddenPage(appModel, errMessage) :
errPage === 'account-deleted' ? createAccountDeletedPage(appModel) :
errPage === 'signin-failed' ? createSigninFailedPage(appModel, errMessage) :
createOtherErrorPage(appModel, errMessage);
}
@ -98,6 +99,19 @@ export function createNotFoundPage(appModel: AppModel, message?: string) {
]);
}
export function createSigninFailedPage(appModel: AppModel, message?: string) {
document.title = t("Signin failed{{suffix}}", {suffix: getPageTitleSuffix(getGristConfig())});
return pagePanelsError(appModel, t("Signin failed{{suffix}}", {suffix: ''}), [
cssErrorText(message ??
t("Failed to login.{{separator}}Please try again or contact the support.", {
separator: dom('br')
})),
cssButtonWrap(bigPrimaryButtonLink(t("Login again"), testId('error-primary-btn'),
urlState().setLinkUrl({login: 'login', params: {}}))),
cssButtonWrap(bigBasicButtonLink(t("Contact support"), {href: commonUrls.contactSupport})),
]);
}
/**
* Creates a generic error page with the given message.
*/

@ -74,6 +74,8 @@ export interface SessionObj {
codeVerifier?: string;
state?: string;
targetUrl?: string;
nonce?: string;
idToken?: string;
}
}

@ -1958,7 +1958,9 @@ export class FlexServer implements GristServer {
}
public resolveLoginSystem() {
return process.env.GRIST_TEST_LOGIN ? getTestLoginSystem() : (this._getLoginSystem?.() || getLoginSystem());
return isAffirmative(process.env.GRIST_TEST_LOGIN) ?
getTestLoginSystem() :
(this._getLoginSystem?.() || getLoginSystem());
}
public addUpdatesCheck() {
@ -2489,7 +2491,7 @@ function configServer<T extends https.Server|http.Server>(server: T): T {
// Returns true if environment is configured to allow unauthenticated test logins.
function allowTestLogin() {
return Boolean(process.env.GRIST_TEST_LOGIN);
return isAffirmative(process.env.GRIST_TEST_LOGIN);
}
// Check OPTIONS requests for allowed origins, and return heads to allow the browser to proceed

@ -35,6 +35,16 @@
* 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:
@ -52,26 +62,67 @@
import * as express from 'express';
import { GristLoginSystem, GristServer } from './GristServer';
import { Client, generators, Issuer, UserinfoResponse } from 'openid-client';
import { Client, ClientMetadata, generators, Issuer, TokenSet, UserinfoResponse } from 'openid-client';
import { Sessions } from './Sessions';
import log from 'app/server/lib/log';
import { appSettings } from './AppSettings';
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 {
private _client: Client;
/**
* 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;
public constructor() {
}
protected constructor(
private _sendAppPage: SendAppPage
) {}
public async initOIDC(): Promise<void> {
const section = appSettings.section('login').section('system').section('oidc');
@ -108,21 +159,26 @@ export class OIDCConfig {
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 issuer = await Issuer.discover(issuerUrl);
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;
this._client = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [ this._redirectUrl ],
response_types: [ 'code' ],
});
await this._initClient({ issuerUrl, clientId, clientSecret, extraMetadata });
if (this._client.issuer.metadata.end_session_endpoint === undefined &&
!this._endSessionEndpoint && !this._skipEndSessionEndpoint) {
!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');
@ -138,25 +194,23 @@ export class OIDCConfig {
const mreq = req as RequestWithLogin;
try {
const params = this._client.callbackParams(req);
const { state, targetUrl } = mreq.session?.oidc ?? {};
if (!state) {
throw new Error('Login or logout failed to complete');
}
const codeVerifier = await this._retrieveCodeVerifierFromSession(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,
{ state, code_verifier: codeVerifier }
);
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 Error(`OIDCConfig: email not verified for ${userInfo.email}`);
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);
@ -167,33 +221,45 @@ export class OIDCConfig {
profile,
}));
delete mreq.session.oidc;
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;
res.status(500).send(`OIDC callback failed.`);
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 { codeVerifier, state } = await this._generateAndStoreConnectionInfo(req, targetUrl.href);
const codeChallenge = generators.codeChallenge(codeVerifier);
const protections = await this._generateAndStoreConnectionInfo(req, targetUrl.href);
const authUrl = this._client.authorizationUrl({
scope: process.env.GRIST_OIDC_IDP_SCOPES || 'openid email profile',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
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;
@ -203,42 +269,105 @@ export class OIDCConfig {
return this._endSessionEndpoint;
}
return this._client.endSessionUrl({
post_logout_redirect_uri: redirectUrl.href
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 codeVerifier = generators.codeVerifier();
const state = generators.state();
mreq.session.oidc = {
codeVerifier,
state,
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 { codeVerifier, state };
return _.pick(oidcInfo, ['codeVerifier', 'state', 'nonce']);
}
private async _retrieveCodeVerifierFromSession(req: express.Request) {
const mreq = req as RequestWithLogin;
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) { throw new Error('Login is stale'); }
return 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 ]),
email: String(userInfo[this._emailPropertyKey]),
name: this._extractName(userInfo)
};
}
private _extractName(userInfo: UserinfoResponse): string|undefined {
private _extractName(userInfo: UserinfoResponse): string | undefined {
if (this._namePropertyKey) {
return (userInfo[ this._namePropertyKey ] as any)?.toString();
return (userInfo[this._namePropertyKey] as any)?.toString();
}
const fname = userInfo.given_name ?? '';
const lname = userInfo.family_name ?? '';
@ -247,12 +376,11 @@ export class OIDCConfig {
}
}
export async function getOIDCLoginSystem(): Promise<GristLoginSystem|undefined> {
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();
const config = await OIDCConfig.build(gristServer.sendAppPage.bind(gristServer));
return {
getLoginRedirectUrl: config.getLoginRedirectUrl.bind(config),
getSignUpRedirectUrl: config.getLoginRedirectUrl.bind(config),
@ -263,6 +391,6 @@ export async function getOIDCLoginSystem(): Promise<GristLoginSystem|undefined>
},
};
},
async deleteUser() {},
async deleteUser() { },
};
}

@ -118,15 +118,15 @@ export function makeMessagePage(staticDir: string) {
};
}
export type SendAppPage = (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>;
/**
* Send a simple template page, read from file at pagePath (relative to static/), with certain
* placeholders replaced.
*/
export function makeSendAppPage(opts: {
server: GristServer, staticDir: string, tag: string, testLogin?: boolean,
baseDomain?: string
}) {
const {server, staticDir, tag, testLogin} = opts;
export function makeSendAppPage({ server, staticDir, tag, testLogin, baseDomain }: {
server: GristServer, staticDir: string, tag: string, testLogin?: boolean, baseDomain?: string
}): SendAppPage {
// If env var GRIST_INCLUDE_CUSTOM_SCRIPT_URL is set, load it in a <script> tag on all app pages.
const customScriptUrl = process.env.GRIST_INCLUDE_CUSTOM_SCRIPT_URL;
@ -137,7 +137,7 @@ export function makeSendAppPage(opts: {
const config = makeGristConfig({
homeUrl: !isSingleUserMode() ? server.getHomeUrl(req) : null,
extra: options.config,
baseDomain: opts.baseDomain,
baseDomain: baseDomain,
req,
server,
});

@ -381,7 +381,7 @@ export class HomeUtil {
/**
* Waits for browser to navigate to a Grist login page.
*/
public async checkGristLoginPage(waitMs: number = 2000) {
public async checkGristLoginPage(waitMs: number = 2000) {
await this.driver.wait(this.isOnGristLoginPage.bind(this), waitMs);
}

@ -152,7 +152,10 @@ export class TestServerMerged extends EventEmitter implements IMochaServer {
delete env.DOC_WORKER_COUNT;
}
this._server = spawn('node', [cmd], {
env,
env: {
...env,
...(process.env.SERVER_NODE_OPTIONS ? {NODE_OPTIONS: process.env.SERVER_NODE_OPTIONS} : {})
},
stdio: quiet ? 'ignore' : ['inherit', serverLog, serverLog],
});
this._exitPromise = exitPromise(this._server);

@ -0,0 +1,733 @@
import {EnvironmentSnapshot} from "../testUtils";
import {OIDCConfig} from "app/server/lib/OIDCConfig";
import {SessionObj} from "app/server/lib/BrowserSession";
import {Sessions} from "app/server/lib/Sessions";
import log from "app/server/lib/log";
import {assert} from "chai";
import Sinon from "sinon";
import {Client, generators} from "openid-client";
import express from "express";
import _ from "lodash";
import {RequestWithLogin} from "app/server/lib/Authorizer";
import { SendAppPage } from "app/server/lib/sendAppPage";
const NOOPED_SEND_APP_PAGE: SendAppPage = () => Promise.resolve();
class OIDCConfigStubbed extends OIDCConfig {
public static async buildWithStub(client: Client = new ClientStub().asClient()) {
return this.build(NOOPED_SEND_APP_PAGE, client);
}
public static async build(sendAppPage: SendAppPage, clientStub?: Client): Promise<OIDCConfigStubbed> {
const result = new OIDCConfigStubbed(sendAppPage);
if (clientStub) {
result._initClient = Sinon.spy(() => {
result._client = clientStub!;
});
}
await result.initOIDC();
return result;
}
public _initClient: Sinon.SinonSpy;
}
class ClientStub {
public static FAKE_REDIRECT_URL = 'FAKE_REDIRECT_URL';
public authorizationUrl = Sinon.stub().returns(ClientStub.FAKE_REDIRECT_URL);
public callbackParams = Sinon.stub().returns(undefined);
public callback = Sinon.stub().returns(undefined);
public userinfo = Sinon.stub().returns(undefined);
public endSessionUrl = Sinon.stub().returns(undefined);
public issuer: {
metadata: {
end_session_endpoint: string | undefined;
}
} = {
metadata: {
end_session_endpoint: 'http://localhost:8484/logout',
}
};
public asClient() {
return this as unknown as Client;
}
public getAuthorizationUrlStub() {
return this.authorizationUrl;
}
}
describe('OIDCConfig', () => {
let oldEnv: EnvironmentSnapshot;
let sandbox: Sinon.SinonSandbox;
let logInfoStub: Sinon.SinonStub;
let logErrorStub: Sinon.SinonStub;
let logDebugStub: Sinon.SinonStub;
before(() => {
oldEnv = new EnvironmentSnapshot();
});
beforeEach(() => {
sandbox = Sinon.createSandbox();
logInfoStub = sandbox.stub(log, 'info');
logErrorStub = sandbox.stub(log, 'error');
logDebugStub = sandbox.stub(log, 'debug');
});
afterEach(() => {
oldEnv.restore();
sandbox.restore();
});
function setEnvVars() {
// Prevent any environment variable from leaking into the test:
for (const envVar in process.env) {
if (envVar.startsWith('GRIST_OIDC_')) {
delete process.env[envVar];
}
}
process.env.GRIST_OIDC_SP_HOST = 'http://localhost:8484';
process.env.GRIST_OIDC_IDP_CLIENT_ID = 'client id';
process.env.GRIST_OIDC_IDP_CLIENT_SECRET = 'secret';
process.env.GRIST_OIDC_IDP_ISSUER = 'http://localhost:8000';
}
describe('build', () => {
it('should reject when required env variables are not passed', async () => {
for (const envVar of [
'GRIST_OIDC_SP_HOST',
'GRIST_OIDC_IDP_ISSUER',
'GRIST_OIDC_IDP_CLIENT_ID',
'GRIST_OIDC_IDP_CLIENT_SECRET',
]) {
setEnvVars();
delete process.env[envVar];
const promise = OIDCConfig.build(NOOPED_SEND_APP_PAGE);
await assert.isRejected(promise, `missing environment variable: ${envVar}`);
}
});
it('should reject when the client initialization fails', async () => {
setEnvVars();
sandbox.stub(OIDCConfigStubbed.prototype, '_initClient').rejects(new Error('client init failed'));
const promise = OIDCConfigStubbed.build(NOOPED_SEND_APP_PAGE);
await assert.isRejected(promise, 'client init failed');
});
it('should create a client with passed information', async () => {
setEnvVars();
const config = await OIDCConfigStubbed.buildWithStub();
assert.isTrue(config._initClient.calledOnce);
assert.deepEqual(config._initClient.firstCall.args, [{
clientId: process.env.GRIST_OIDC_IDP_CLIENT_ID,
clientSecret: process.env.GRIST_OIDC_IDP_CLIENT_SECRET,
issuerUrl: process.env.GRIST_OIDC_IDP_ISSUER,
extraMetadata: {},
}]);
assert.isTrue(logInfoStub.calledOnce);
assert.deepEqual(
logInfoStub.firstCall.args,
[`OIDCConfig: initialized with issuer ${process.env.GRIST_OIDC_IDP_ISSUER}`]
);
});
it('should create a client with passed information with extra configuration', async () => {
setEnvVars();
const extraMetadata = {
userinfo_signed_response_alg: 'RS256',
};
process.env.GRIST_OIDC_IDP_EXTRA_CLIENT_METADATA = JSON.stringify(extraMetadata);
const config = await OIDCConfigStubbed.buildWithStub();
assert.isTrue(config._initClient.calledOnce);
assert.deepEqual(config._initClient.firstCall.args, [{
clientId: process.env.GRIST_OIDC_IDP_CLIENT_ID,
clientSecret: process.env.GRIST_OIDC_IDP_CLIENT_SECRET,
issuerUrl: process.env.GRIST_OIDC_IDP_ISSUER,
extraMetadata,
}]);
});
describe('End Session Endpoint', () => {
[
{
itMsg: 'should fulfill when the end_session_endpoint is not known ' +
'and GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT=true',
end_session_endpoint: undefined,
env: {
GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT: 'true'
}
},
{
itMsg: 'should fulfill when the end_session_endpoint is not known ' +
'and GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT=true',
end_session_endpoint: undefined,
env: {
GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT: 'true'
}
},
{
itMsg: 'should fulfill when the end_session_endpoint is provided with GRIST_OIDC_IDP_END_SESSION_ENDPOINT',
end_session_endpoint: undefined,
env: {
GRIST_OIDC_IDP_END_SESSION_ENDPOINT: 'http://localhost:8484/logout'
}
},
{
itMsg: 'should fulfill when the end_session_endpoint is provided with the issuer',
end_session_endpoint: 'http://localhost:8484/logout',
},
{
itMsg: 'should reject when the end_session_endpoint is not known',
errorMsg: /If that is expected, please set GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT/,
end_session_endpoint: undefined,
}
].forEach((ctx) => {
it(ctx.itMsg, async () => {
setEnvVars();
Object.assign(process.env, ctx.env);
const client = new ClientStub();
client.issuer.metadata.end_session_endpoint = ctx.end_session_endpoint;
const promise = OIDCConfigStubbed.buildWithStub(client.asClient());
if (ctx.errorMsg) {
await assert.isRejected(promise, ctx.errorMsg);
assert.isFalse(logInfoStub.calledOnce);
} else {
await assert.isFulfilled(promise);
assert.isTrue(logInfoStub.calledOnce);
}
});
});
});
});
describe('GRIST_OIDC_IDP_ENABLED_PROTECTIONS', () => {
it('should throw when GRIST_OIDC_IDP_ENABLED_PROTECTIONS contains unsupported values', async () => {
setEnvVars();
process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = 'STATE,NONCE,PKCE,invalid';
const promise = OIDCConfig.build(NOOPED_SEND_APP_PAGE);
await assert.isRejected(promise, 'OIDC: Invalid protection in GRIST_OIDC_IDP_ENABLED_PROTECTIONS: invalid');
});
it('should successfully change the supported protections', async function () {
setEnvVars();
process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = 'NONCE';
const config = await OIDCConfigStubbed.buildWithStub();
assert.isTrue(config.supportsProtection("NONCE"));
assert.isFalse(config.supportsProtection("PKCE"));
assert.isFalse(config.supportsProtection("STATE"));
});
it('should successfully accept an empty string', async function () {
setEnvVars();
process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = '';
const config = await OIDCConfigStubbed.buildWithStub();
assert.isFalse(config.supportsProtection("NONCE"));
assert.isFalse(config.supportsProtection("PKCE"));
assert.isFalse(config.supportsProtection("STATE"));
});
it('if omitted, should defaults to "STATE,PKCE"', async function () {
setEnvVars();
const config = await OIDCConfigStubbed.buildWithStub();
assert.isFalse(config.supportsProtection("NONCE"));
assert.isTrue(config.supportsProtection("PKCE"));
assert.isTrue(config.supportsProtection("STATE"));
});
});
describe('getLoginRedirectUrl', () => {
const FAKE_NONCE = 'fake-nonce';
const FAKE_STATE = 'fake-state';
const FAKE_CODE_VERIFIER = 'fake-code-verifier';
const FAKE_CODE_CHALLENGE = 'fake-code-challenge';
const TARGET_URL = 'http://localhost:8484/';
beforeEach(() => {
sandbox.stub(generators, 'nonce').returns(FAKE_NONCE);
sandbox.stub(generators, 'state').returns(FAKE_STATE);
sandbox.stub(generators, 'codeVerifier').returns(FAKE_CODE_VERIFIER);
sandbox.stub(generators, 'codeChallenge').returns(FAKE_CODE_CHALLENGE);
});
[
{
itMsg: 'should forge the url with default values',
expectedCalledWith: [{
scope: 'openid email profile',
acr_values: undefined,
code_challenge: FAKE_CODE_CHALLENGE,
code_challenge_method: 'S256',
state: FAKE_STATE,
}],
expectedSession: {
oidc: {
codeVerifier: FAKE_CODE_VERIFIER,
state: FAKE_STATE,
targetUrl: TARGET_URL,
}
}
},
{
itMsg: 'should forge the URL with passed GRIST_OIDC_IDP_SCOPES',
env: {
GRIST_OIDC_IDP_SCOPES: 'my scopes',
},
expectedCalledWith: [{
scope: 'my scopes',
acr_values: undefined,
code_challenge: FAKE_CODE_CHALLENGE,
code_challenge_method: 'S256',
state: FAKE_STATE,
}],
expectedSession: {
oidc: {
codeVerifier: FAKE_CODE_VERIFIER,
state: FAKE_STATE,
targetUrl: TARGET_URL,
}
}
},
{
itMsg: 'should pass the nonce when GRIST_OIDC_IDP_ENABLED_PROTECTIONS includes NONCE',
env: {
GRIST_OIDC_IDP_ENABLED_PROTECTIONS: 'STATE,NONCE,PKCE',
},
expectedCalledWith: [{
scope: 'openid email profile',
acr_values: undefined,
code_challenge: FAKE_CODE_CHALLENGE,
code_challenge_method: 'S256',
state: FAKE_STATE,
nonce: FAKE_NONCE,
}],
expectedSession: {
oidc: {
codeVerifier: FAKE_CODE_VERIFIER,
nonce: FAKE_NONCE,
state: FAKE_STATE,
targetUrl: TARGET_URL,
}
}
},
{
itMsg: 'should not pass the code_challenge when PKCE is omitted in GRIST_OIDC_IDP_ENABLED_PROTECTIONS',
env: {
GRIST_OIDC_IDP_ENABLED_PROTECTIONS: 'STATE,NONCE',
},
expectedCalledWith: [{
scope: 'openid email profile',
acr_values: undefined,
state: FAKE_STATE,
nonce: FAKE_NONCE,
}],
expectedSession: {
oidc: {
nonce: FAKE_NONCE,
state: FAKE_STATE,
targetUrl: TARGET_URL,
}
}
},
].forEach(ctx => {
it(ctx.itMsg, async () => {
setEnvVars();
Object.assign(process.env, ctx.env);
const clientStub = new ClientStub();
const config = await OIDCConfigStubbed.buildWithStub(clientStub.asClient());
const session = {};
const req = {
session
} as unknown as express.Request;
const url = await config.getLoginRedirectUrl(req, new URL(TARGET_URL));
assert.equal(url, ClientStub.FAKE_REDIRECT_URL);
assert.isTrue(clientStub.authorizationUrl.calledOnce);
assert.deepEqual(clientStub.authorizationUrl.firstCall.args, ctx.expectedCalledWith);
assert.deepEqual(session, ctx.expectedSession);
});
});
});
describe('handleCallback', () => {
const FAKE_STATE = 'fake-state';
const FAKE_NONCE = 'fake-nonce';
const FAKE_CODE_VERIFIER = 'fake-code-verifier';
const FAKE_USER_INFO = {
email: 'fake-email',
name: 'fake-name',
email_verified: true,
};
const DEFAULT_SESSION = {
oidc: {
codeVerifier: FAKE_CODE_VERIFIER,
state: FAKE_STATE
}
} as SessionObj;
const DEFAULT_EXPECTED_CALLBACK_CHECKS = {
state: FAKE_STATE,
code_verifier: FAKE_CODE_VERIFIER
};
let fakeRes: {
status: Sinon.SinonStub;
send: Sinon.SinonStub;
redirect: Sinon.SinonStub;
};
let fakeSessions: {
getOrCreateSessionFromRequest: Sinon.SinonStub
};
let fakeScopedSession: {
operateOnScopedSession: Sinon.SinonStub
};
beforeEach(() => {
fakeRes = {
redirect: Sinon.stub(),
status: Sinon.stub().returnsThis(),
send: Sinon.stub().returnsThis(),
};
fakeScopedSession = {
operateOnScopedSession: Sinon.stub().resolves(),
};
fakeSessions = {
getOrCreateSessionFromRequest: Sinon.stub().returns(fakeScopedSession),
};
});
function checkUserProfile(expectedUserProfile: object) {
return function ({user}: {user: any}) {
assert.deepEqual(user.profile, expectedUserProfile,
`user profile should have been populated with ${JSON.stringify(expectedUserProfile)}`);
};
}
function checkRedirect(expectedRedirection: string) {
return function ({fakeRes}: {fakeRes: any}) {
assert.deepEqual(fakeRes.redirect.firstCall.args, [expectedRedirection],
`should have redirected to ${expectedRedirection}`);
};
}
[
{
itMsg: 'should resolve when the state and the code challenge are found in the session',
session: DEFAULT_SESSION,
},
{
itMsg: 'should reject when the state is not found in the session',
session: {},
expectedErrorMsg: /Login or logout failed to complete/,
},
{
itMsg: 'should resolve when the state is missing and its check has been disabled',
session: DEFAULT_SESSION,
env: {
GRIST_OIDC_IDP_ENABLED_PROTECTIONS: '',
},
},
{
itMsg: 'should reject when the codeVerifier is missing from the session',
session: {
oidc: {
state: FAKE_STATE
}
},
expectedErrorMsg: /Login is stale/,
},
{
itMsg: 'should resolve when the codeVerifier is missing and its check has been disabled',
session: {
oidc: {
state: FAKE_STATE,
nonce: FAKE_NONCE
}
},
env: {
GRIST_OIDC_IDP_ENABLED_PROTECTIONS: 'STATE,NONCE',
},
expectedCbChecks: {
state: FAKE_STATE,
nonce: FAKE_NONCE
},
},
{
itMsg: 'should reject when nonce is missing from the session despite its check being enabled',
session: DEFAULT_SESSION,
env: {
GRIST_OIDC_IDP_ENABLED_PROTECTIONS: 'STATE,NONCE,PKCE',
},
expectedErrorMsg: /Login is stale/,
}, {
itMsg: 'should resolve when nonce is present in the session and its check is enabled',
session: {
oidc: {
state: FAKE_STATE,
nonce: FAKE_NONCE,
},
},
env: {
GRIST_OIDC_IDP_ENABLED_PROTECTIONS: 'STATE,NONCE',
},
expectedCbChecks: {
state: FAKE_STATE,
nonce: FAKE_NONCE,
},
},
{
itMsg: 'should reject when the userinfo mail is not verified',
session: DEFAULT_SESSION,
userInfo: {
...FAKE_USER_INFO,
email_verified: false,
},
expectedErrorMsg: /email not verified for/,
extraChecks: function ({ sendAppPageStub }: { sendAppPageStub: Sinon.SinonStub }) {
assert.match(sendAppPageStub.firstCall.args[2].config.errMessage, /Your email is not verified/);
}
},
{
itMsg: 'should resolve when the userinfo mail is not verified but its check disabled',
session: DEFAULT_SESSION,
userInfo: {
...FAKE_USER_INFO,
email_verified: false,
},
env: {
GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED: 'true',
}
},
{
itMsg: 'should resolve when the userinfo mail is not verified but its check disabled',
session: DEFAULT_SESSION,
userInfo: {
...FAKE_USER_INFO,
email_verified: false,
},
env: {
GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED: 'true',
},
},
{
itMsg: 'should fill user profile with email and name',
session: DEFAULT_SESSION,
userInfo: FAKE_USER_INFO,
extraChecks: checkUserProfile({
email: FAKE_USER_INFO.email,
name: FAKE_USER_INFO.name,
})
},
{
itMsg: 'should fill user profile with name constructed using ' +
'given_name and family_name when GRIST_OIDC_SP_PROFILE_NAME_ATTR is not set',
session: DEFAULT_SESSION,
userInfo: {
...FAKE_USER_INFO,
given_name: 'given_name',
family_name: 'family_name',
},
extrachecks: checkUserProfile({
email: 'fake-email',
name: 'given_name family_name',
})
},
{
itMsg: 'should fill user profile with email and name when ' +
'GRIST_OIDC_SP_PROFILE_NAME_ATTR and GRIST_OIDC_SP_PROFILE_EMAIL_ATTR are set',
session: DEFAULT_SESSION,
userInfo: {
...FAKE_USER_INFO,
fooMail: 'fake-email2',
fooName: 'fake-name2',
},
env: {
GRIST_OIDC_SP_PROFILE_NAME_ATTR: 'fooName',
GRIST_OIDC_SP_PROFILE_EMAIL_ATTR: 'fooMail',
},
extraChecks: checkUserProfile({
email: 'fake-email2',
name: 'fake-name2',
}),
},
{
itMsg: 'should redirect by default to the root page',
session: DEFAULT_SESSION,
extraChecks: checkRedirect('/'),
},
{
itMsg: 'should redirect to the targetUrl when it is present in the session',
session: {
oidc: {
...DEFAULT_SESSION.oidc,
targetUrl: 'http://localhost:8484/some/path'
}
},
extraChecks: checkRedirect('http://localhost:8484/some/path'),
},
{
itMsg: "should redact confidential information in the tokenSet in the logs",
session: DEFAULT_SESSION,
tokenSet: {
id_token: 'fake-id-token',
access_token: 'fake-access',
whatever: 'fake-whatever',
token_type: 'fake-token-type',
expires_at: 1234567890,
expires_in: 987654321,
scope: 'fake-scope',
},
extraChecks: function () {
assert.isTrue(logDebugStub.called);
assert.deepEqual(logDebugStub.firstCall.args, [
'Got tokenSet: %o', {
id_token: 'REDACTED',
access_token: 'REDACTED',
whatever: 'REDACTED',
token_type: this.tokenSet.token_type,
expires_at: this.tokenSet.expires_at,
expires_in: this.tokenSet.expires_in,
scope: this.tokenSet.scope,
}
]);
}
},
].forEach(ctx => {
it(ctx.itMsg, async () => {
setEnvVars();
Object.assign(process.env, ctx.env);
const clientStub = new ClientStub();
const sendAppPageStub = Sinon.stub().resolves();
const fakeParams = {
state: FAKE_STATE,
};
const config = await OIDCConfigStubbed.build(sendAppPageStub as SendAppPage, clientStub.asClient());
const session = _.clone(ctx.session); // session is modified, so clone it
const req = {
session,
query: {
state: FAKE_STATE,
codeVerifier: FAKE_CODE_VERIFIER,
}
} as unknown as express.Request;
clientStub.callbackParams.returns(fakeParams);
const tokenSet = { id_token: 'id_token', ...ctx.tokenSet };
clientStub.callback.resolves(tokenSet);
clientStub.userinfo.returns(_.clone(ctx.userInfo ?? FAKE_USER_INFO));
const user: { profile?: object } = {};
fakeScopedSession.operateOnScopedSession.yields(user);
await config.handleCallback(
fakeSessions as unknown as Sessions,
req,
fakeRes as unknown as express.Response
);
if (ctx.expectedErrorMsg) {
assert.isTrue(logErrorStub.calledOnce);
assert.match(logErrorStub.firstCall.args[0], ctx.expectedErrorMsg);
assert.isTrue(sendAppPageStub.calledOnceWith(req, fakeRes));
assert.include(sendAppPageStub.firstCall.args[2], {
path: 'error.html',
status: 500,
});
} else {
assert.isFalse(logErrorStub.called, 'no error should be logged. Got: ' + logErrorStub.firstCall?.args[0]);
assert.isTrue(fakeRes.redirect.calledOnce, 'should redirect');
assert.isTrue(clientStub.callback.calledOnce);
assert.deepEqual(clientStub.callback.firstCall.args, [
'http://localhost:8484/oauth2/callback',
fakeParams,
ctx.expectedCbChecks ?? DEFAULT_EXPECTED_CALLBACK_CHECKS
]);
assert.deepEqual(session, {
oidc: {
state: FAKE_STATE,
idToken: tokenSet.id_token,
}
}, 'oidc info should only keep state and id_token in the session and for the logout');
}
ctx.extraChecks?.({ fakeRes, user, sendAppPageStub });
});
});
it('should log err.response when userinfo fails to parse response body', async () => {
// See https://github.com/panva/node-openid-client/blob/47a549cb4e36ffe2ebfe2dc9d6b69a02643cc0a9/lib/client.js#L1293
setEnvVars();
const clientStub = new ClientStub();
const sendAppPageStub = Sinon.stub().resolves();
const config = await OIDCConfigStubbed.build(sendAppPageStub, clientStub.asClient());
const req = {
session: DEFAULT_SESSION,
query: {
state: FAKE_STATE,
codeVerifier: FAKE_CODE_VERIFIER,
}
} as unknown as express.Request;
clientStub.callbackParams.returns({state: FAKE_STATE});
const err: Error & {response?: {body: string }} = new Error('userinfo failed');
err.response = { body: 'response here' };
clientStub.userinfo.rejects(err);
await config.handleCallback(
fakeSessions as unknown as Sessions,
req,
fakeRes as unknown as express.Response
);
assert.isTrue(logErrorStub.calledTwice);
assert.include(logErrorStub.firstCall.args[0], err.message);
assert.include(logErrorStub.secondCall.args[0], err.response.body);
assert.isTrue(sendAppPageStub.calledOnce, "An error should have been sent");
});
});
describe('getLogoutRedirectUrl', () => {
const REDIRECT_URL = new URL('http://localhost:8484/docs/signed-out');
const URL_RETURNED_BY_CLIENT = 'http://localhost:8484/logout_url_from_issuer';
const ENV_VALUE_GRIST_OIDC_IDP_END_SESSION_ENDPOINT = 'http://localhost:8484/logout';
const FAKE_SESSION = {
oidc: {
idToken: 'id_token',
state: 'state',
}
} as SessionObj;
[
{
itMsg: 'should skip the end session endpoint when GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT=true',
env: {
GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT: 'true',
},
expectedUrl: REDIRECT_URL.href,
}, {
itMsg: 'should use the GRIST_OIDC_IDP_END_SESSION_ENDPOINT when it is set',
env: {
GRIST_OIDC_IDP_END_SESSION_ENDPOINT: ENV_VALUE_GRIST_OIDC_IDP_END_SESSION_ENDPOINT
},
expectedUrl: ENV_VALUE_GRIST_OIDC_IDP_END_SESSION_ENDPOINT
}, {
itMsg: 'should call the end session endpoint with the expected parameters',
expectedUrl: URL_RETURNED_BY_CLIENT,
expectedLogoutParams: {
post_logout_redirect_uri: REDIRECT_URL.href,
id_token_hint: FAKE_SESSION.oidc!.idToken,
state: FAKE_SESSION.oidc!.state,
}
}
].forEach(ctx => {
it(ctx.itMsg, async () => {
setEnvVars();
Object.assign(process.env, ctx.env);
const clientStub = new ClientStub();
clientStub.endSessionUrl.returns(URL_RETURNED_BY_CLIENT);
const config = await OIDCConfigStubbed.buildWithStub(clientStub.asClient());
const req = {
session: FAKE_SESSION
} as unknown as RequestWithLogin;
const url = await config.getLogoutRedirectUrl(req, REDIRECT_URL);
assert.equal(url, ctx.expectedUrl);
if (ctx.expectedLogoutParams) {
assert.isTrue(clientStub.endSessionUrl.calledOnce);
assert.deepEqual(clientStub.endSessionUrl.firstCall.args, [ctx.expectedLogoutParams]);
}
});
});
});
});
Loading…
Cancel
Save