fflorent 3 months ago
parent 6c95af32b8
commit 75b06d622e

@ -35,6 +35,11 @@
* 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".
* Defaults to "PKCE,STATE".
* env GRIST_OIDC_IDP_ACR_VALUES
* A space-separated list of ACR values to request from the IdP. Optional.
*
* This version of OIDCConfig has been tested with Keycloak OIDC IdP following the instructions
* at:
@ -88,6 +93,7 @@ export class OIDCConfig {
private _skipEndSessionEndpoint: boolean;
private _ignoreEmailVerified: boolean;
private _enabledProtections: EnabledProtectionsString[] = [];
private _acrValues?: string;
protected constructor() {
}
@ -127,6 +133,10 @@ 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,
@ -196,14 +206,12 @@ export class OIDCConfig {
}
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;
}
@ -238,6 +246,17 @@ export class OIDCConfig {
});
}
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'); }

@ -2,10 +2,13 @@ import {OIDCConfig} from "app/server/lib/OIDCConfig";
import {assert} from "chai";
import {EnvironmentSnapshot} from "../testUtils";
import Sinon from "sinon";
import {Client} from "openid-client";
import {Client, generators} from "openid-client";
import express from "express";
import log from "app/server/lib/log";
import _ from "lodash";
class OIDCConfigStubbed extends OIDCConfig {
public static async build(clientStub?: Client): Promise<OIDCConfig> {
public static async build(clientStub?: Client): Promise<OIDCConfigStubbed> {
const result = new OIDCConfigStubbed();
if (clientStub) {
result._initClient = Sinon.spy(() => {
@ -20,6 +23,8 @@ class OIDCConfigStubbed extends OIDCConfig {
}
class ClientStub {
public static FAKE_REDIRECT_URL = 'FAKE_REDIRECT_URL';
public authorizationUrl = Sinon.stub().returns(ClientStub.FAKE_REDIRECT_URL);
public issuer: {
metadata: {
end_session_endpoint: string | undefined;
@ -29,11 +34,18 @@ class ClientStub {
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;
before(() => {
oldEnv = new EnvironmentSnapshot();
@ -41,6 +53,7 @@ describe('OIDCConfig', () => {
beforeEach(() => {
sandbox = Sinon.createSandbox();
logInfoStub = sandbox.stub(log, 'info');
});
afterEach(() => {
@ -53,7 +66,6 @@ describe('OIDCConfig', () => {
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';
process.env.GRIST_OIDC_IDP_SCOPES = 'openid email profile';
process.env.GRIST_OIDC_SP_PROFILE_NAME_ATTR = ''; // use the default behavior
process.env.GRIST_OIDC_SP_PROFILE_EMAIL_ATTR = ''; // use the default behavior
}
@ -80,6 +92,23 @@ describe('OIDCConfig', () => {
await assert.isRejected(promise, 'client init failed');
});
it('should create a client with passed information', async () => {
setEnvVars();
const client = new ClientStub();
const config = await OIDCConfigStubbed.build(client.asClient());
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,
}]);
assert.isTrue(logInfoStub.calledOnce);
assert.deepEqual(
logInfoStub.firstCall.args,
[`OIDCConfig: initialized with issuer ${process.env.GRIST_OIDC_IDP_ISSUER}`]
);
});
describe('End Session Endpoint', () => {
[
{
@ -120,11 +149,13 @@ describe('OIDCConfig', () => {
Object.assign(process.env, ctx.env);
const client = new ClientStub();
client.issuer.metadata.end_session_endpoint = ctx.end_session_endpoint;
const promise = OIDCConfigStubbed.build(client as Client);
const promise = OIDCConfigStubbed.build(client.asClient());
if (ctx.errorMsg) {
await assert.isRejected(promise, ctx.errorMsg);
assert.isFalse(logInfoStub.calledOnce);
} else {
await assert.isFulfilled(promise);
assert.isTrue(logInfoStub.calledOnce);
}
});
});
@ -142,7 +173,7 @@ describe('OIDCConfig', () => {
it('should successfully change the supported protections', async function () {
setEnvVars();
process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = 'NONCE';
const config = await OIDCConfigStubbed.build(new ClientStub() as Client);
const config = await OIDCConfigStubbed.build((new ClientStub()).asClient());
assert.isTrue(config.supportsProtection("NONCE"));
assert.isFalse(config.supportsProtection("PKCE"));
assert.isFalse(config.supportsProtection("STATE"));
@ -150,10 +181,163 @@ describe('OIDCConfig', () => {
it('if omitted, should defaults to "STATE,PKCE"', async function () {
setEnvVars();
const config = await OIDCConfigStubbed.build(new ClientStub() as Client);
const config = await OIDCConfigStubbed.build((new ClientStub()).asClient());
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.build(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_CODE_VERIFIER = 'fake-code-verifier';
// const FAKE_CODE_CHALLENGE = 'fake-code-challenge';
// const FAKE_NONCE = 'fake-nonce';
// const FAKE_CODE = 'fake-code';
// let fakeRes: express.Response;
// beforeEach(() => {
// fakeRes = {
// redirect: sandbox.stub(),
// status: sandbox.stub().returnsThis(),
// send: sandbox.stub().returnsThis(),
// } as unknown as express.Response;
// });
// [
// {
// itMsg: 'should reject when the state is not found in the session',
// session: {},
// expectedErrorMsg: /Login or logout failed to complete/,
// }
// ].forEach(ctx => {
// it(ctx.itMsg, async () => {
// setEnvVars();
// const clientStub = new ClientStub();
// const config = await OIDCConfigStubbed.build(clientStub.asClient());
// const req = {
// session: ctx.session,
// query: {
// state: FAKE_STATE,
// code: FAKE_CODE,
// }
// } as unknown as express.Request;
// const promise = config.handleCallback(req, fakeRes);
// await assert.isRejected(promise, ctx.expectedErrorMsg);
// });
// });
// });
});

Loading…
Cancel
Save