mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
764 lines
26 KiB
TypeScript
764 lines
26 KiB
TypeScript
|
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, errors as OIDCError} from "openid-client";
|
||
|
import express from "express";
|
||
|
import _ from "lodash";
|
||
|
import {RequestWithLogin} from "app/server/lib/Authorizer";
|
||
|
import { SendAppPageFunction } from "app/server/lib/sendAppPage";
|
||
|
|
||
|
const NOOPED_SEND_APP_PAGE: SendAppPageFunction = () => 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: SendAppPageFunction, 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({});
|
||
|
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 logWarnStub: 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');
|
||
|
logWarnStub = sandbox.stub(log, 'warn');
|
||
|
});
|
||
|
|
||
|
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', () => {
|
||
|
function isInitializedLogCalled() {
|
||
|
return logInfoStub.calledWithExactly(`OIDCConfig: initialized with issuer ${process.env.GRIST_OIDC_IDP_ISSUER}`);
|
||
|
}
|
||
|
|
||
|
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(isInitializedLogCalled());
|
||
|
});
|
||
|
|
||
|
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 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(isInitializedLogCalled());
|
||
|
} else {
|
||
|
await assert.isFulfilled(promise);
|
||
|
assert.isTrue(isInitializedLogCalled());
|
||
|
}
|
||
|
});
|
||
|
});
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('GRIST_OIDC_IDP_ENABLED_PROTECTIONS', () => {
|
||
|
async function checkRejection(promise: Promise<OIDCConfig>, actualValue: string) {
|
||
|
return assert.isRejected(
|
||
|
promise,
|
||
|
`OIDC: Invalid protection in GRIST_OIDC_IDP_ENABLED_PROTECTIONS: "${actualValue}". ` +
|
||
|
'Expected at least one of these values: "STATE,NONCE,PKCE"');
|
||
|
}
|
||
|
it('should reject 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 checkRejection(promise, '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 reject when set to an empty string', async function () {
|
||
|
setEnvVars();
|
||
|
process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = '';
|
||
|
const promise = OIDCConfigStubbed.buildWithStub();
|
||
|
await checkRejection(promise, '');
|
||
|
});
|
||
|
|
||
|
it('should accept to be set to "UNPROTECTED"', async function () {
|
||
|
setEnvVars();
|
||
|
process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = 'UNPROTECTED';
|
||
|
const config = await OIDCConfigStubbed.buildWithStub();
|
||
|
assert.isFalse(config.supportsProtection("NONCE"));
|
||
|
assert.isFalse(config.supportsProtection("PKCE"));
|
||
|
assert.isFalse(config.supportsProtection("STATE"));
|
||
|
assert.equal(logWarnStub.callCount, 1, 'a warning should be raised');
|
||
|
assert.match(logWarnStub.firstCall.args[0], /with no protection/);
|
||
|
});
|
||
|
|
||
|
it('should reject when set to "UNPROTECTED,PKCE"', async function () {
|
||
|
setEnvVars();
|
||
|
process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = 'UNPROTECTED,PKCE';
|
||
|
const promise = OIDCConfigStubbed.buildWithStub();
|
||
|
await checkRejection(promise, 'UNPROTECTED');
|
||
|
});
|
||
|
|
||
|
it('if omitted, should default 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: {
|
||
|
code_verifier: 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: {
|
||
|
code_verifier: 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: {
|
||
|
code_verifier: 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: {
|
||
|
code_verifier: 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 reject when no OIDC information is present in the session',
|
||
|
session: {},
|
||
|
expectedErrorMsg: /Missing OIDC information/
|
||
|
},
|
||
|
{
|
||
|
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: {
|
||
|
oidc: {}
|
||
|
},
|
||
|
expectedErrorMsg: /Login or logout failed to complete/,
|
||
|
},
|
||
|
{
|
||
|
itMsg: 'should resolve when the state is missing and its check has been disabled (UNPROTECTED)',
|
||
|
session: DEFAULT_SESSION,
|
||
|
env: {
|
||
|
GRIST_OIDC_IDP_ENABLED_PROTECTIONS: 'UNPROTECTED',
|
||
|
},
|
||
|
expectedCbChecks: {},
|
||
|
},
|
||
|
{
|
||
|
itMsg: 'should reject when the code_verifier is missing from the session',
|
||
|
session: {
|
||
|
oidc: {
|
||
|
state: FAKE_STATE,
|
||
|
GRIST_OIDC_IDP_ENABLED_PROTECTIONS: 'STATE,PKCE'
|
||
|
}
|
||
|
},
|
||
|
expectedErrorMsg: /Login is stale/,
|
||
|
},
|
||
|
{
|
||
|
itMsg: 'should resolve when the code_verifier 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,
|
||
|
code_verifier: undefined,
|
||
|
},
|
||
|
},
|
||
|
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.equal(sendAppPageStub.firstCall.args[2].config.errMessage, 'oidc.emailNotVerifiedError');
|
||
|
}
|
||
|
},
|
||
|
{
|
||
|
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 SendAppPageFunction, clientStub.asClient());
|
||
|
const session = _.clone(ctx.session); // session is modified, so clone it
|
||
|
const req = {
|
||
|
session,
|
||
|
t: (key: string) => key
|
||
|
} 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: {
|
||
|
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,
|
||
|
} as unknown as express.Request;
|
||
|
clientStub.callbackParams.returns({state: FAKE_STATE});
|
||
|
const errorResponse = {
|
||
|
body: { property: 'response here' },
|
||
|
statusCode: 400,
|
||
|
statusMessage: 'statusMessage'
|
||
|
} as unknown as any;
|
||
|
|
||
|
const err = new OIDCError.OPError({error: 'userinfo failed'}, errorResponse);
|
||
|
clientStub.userinfo.rejects(err);
|
||
|
|
||
|
await config.handleCallback(
|
||
|
fakeSessions as unknown as Sessions,
|
||
|
req,
|
||
|
fakeRes as unknown as express.Response
|
||
|
);
|
||
|
|
||
|
assert.equal(logErrorStub.callCount, 2, 'logErrorStub show be called twice');
|
||
|
assert.include(logErrorStub.firstCall.args[0], err.message);
|
||
|
assert.include(logErrorStub.secondCall.args[0], 'Response received');
|
||
|
assert.deepEqual(logErrorStub.secondCall.args[1], errorResponse);
|
||
|
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',
|
||
|
}
|
||
|
} 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,
|
||
|
}
|
||
|
}, {
|
||
|
itMsg: 'should call the end session endpoint with no idToken if session is missing',
|
||
|
expectedUrl: URL_RETURNED_BY_CLIENT,
|
||
|
expectedLogoutParams: {
|
||
|
post_logout_redirect_uri: REDIRECT_URL.href,
|
||
|
id_token_hint: undefined,
|
||
|
},
|
||
|
session: null
|
||
|
}
|
||
|
].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: 'session' in ctx ? ctx.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]);
|
||
|
}
|
||
|
});
|
||
|
});
|
||
|
});
|
||
|
});
|