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 { 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, 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]); } }); }); }); });