From fde6c8142d0afb2b19a798ba0e49eedee0267021 Mon Sep 17 00:00:00 2001 From: Florent Date: Thu, 8 Aug 2024 21:35:37 +0200 Subject: [PATCH] Support nonce and acr with OIDC + other improvements and tests (#883) * Introduces new configuration variables for OIDC: - GRIST_OIDC_IDP_ENABLED_PROTECTIONS - GRIST_OIDC_IDP_ACR_VALUES - GRIST_OIDC_IDP_EXTRA_CLIENT_METADATA * Implements all supported protections in oidc/Protections.ts * Includes a better error page for failed OIDC logins * Includes some other improvements, e.g. to logging, to OIDC * Adds a large unit test for OIDCConfig * Adds support for SERVER_NODE_OPTIONS for running tests * Adds to documentation/develop.md info about GREP_TESTS, VERBOSE, and SERVER_NODE_OPTIONS. --- app/client/ui/errorPages.ts | 23 +- app/common/StringUnion.ts | 10 +- app/server/lib/BrowserSession.ts | 21 +- app/server/lib/FlexServer.ts | 12 +- app/server/lib/OIDCConfig.ts | 251 +++++++--- app/server/lib/oidc/Protections.ts | 121 +++++ app/server/lib/sendAppPage.ts | 13 +- documentation/develop.md | 7 + static/locales/en.server.json | 3 + test/nbrowser/homeUtil.ts | 2 +- test/nbrowser/testServer.ts | 5 +- test/server/lib/OIDCConfig.ts | 763 +++++++++++++++++++++++++++++ 12 files changed, 1148 insertions(+), 83 deletions(-) create mode 100644 app/server/lib/oidc/Protections.ts create mode 100644 test/server/lib/OIDCConfig.ts diff --git a/app/client/ui/errorPages.ts b/app/client/ui/errorPages.ts index b21ac106..26264bc1 100644 --- a/app/client/ui/errorPages.ts +++ b/app/client/ui/errorPages.ts @@ -15,12 +15,19 @@ const testId = makeTestId('test-'); const t = makeT('errorPages'); +function signInAgainButton() { + return cssButtonWrap(bigPrimaryButtonLink( + t("Sign in again"), {href: getLoginUrl()}, testId('error-signin') + )); +} + export function createErrPage(appModel: AppModel) { const {errMessage, errPage} = getGristConfig(); return errPage === 'signed-out' ? createSignedOutPage(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); } @@ -61,9 +68,7 @@ export function createSignedOutPage(appModel: AppModel) { return pagePanelsError(appModel, t("Signed out{{suffix}}", {suffix: ''}), [ cssErrorText(t("You are now signed out.")), - cssButtonWrap(bigPrimaryButtonLink( - t("Sign in again"), {href: getLoginUrl()}, testId('error-signin') - )) + signInAgainButton(), ]); } @@ -98,6 +103,18 @@ export function createNotFoundPage(appModel: AppModel, message?: string) { ]); } +export function createSigninFailedPage(appModel: AppModel, message?: string) { + document.title = t("Sign-in failed{{suffix}}", {suffix: getPageTitleSuffix(getGristConfig())}); + return pagePanelsError(appModel, t("Sign-in failed{{suffix}}", {suffix: ''}), [ + cssErrorText(message ?? + t("Failed to log in.{{separator}}Please try again or contact support.", { + separator: dom('br') + })), + signInAgainButton(), + cssButtonWrap(bigBasicButtonLink(t("Contact support"), {href: commonUrls.contactSupport})), + ]); +} + /** * Creates a generic error page with the given message. */ diff --git a/app/common/StringUnion.ts b/app/common/StringUnion.ts index c61eecd9..d14b27d4 100644 --- a/app/common/StringUnion.ts +++ b/app/common/StringUnion.ts @@ -1,3 +1,9 @@ +export class StringUnionError extends TypeError { + constructor(errMessage: string, public readonly actual: string, public readonly values: string[]) { + super(errMessage); + } +} + /** * TypeScript will infer a string union type from the literal values passed to * this function. Without `extends string`, it would instead generalize them @@ -28,7 +34,7 @@ export const StringUnion = (...values: UnionType[]) => if (!guard(value)) { const actual = JSON.stringify(value); const expected = values.map(s => JSON.stringify(s)).join(' | '); - throw new TypeError(`Value '${actual}' is not assignable to type '${expected}'.`); + throw new StringUnionError(`Value '${actual}' is not assignable to type '${expected}'.`, actual, values); } return value; }; @@ -44,6 +50,6 @@ export const StringUnion = (...values: UnionType[]) => return value != null && guard(value) ? value : undefined; }; - const unionNamespace = {guard, check, parse, values, checkAll}; + const unionNamespace = { guard, check, parse, values, checkAll }; return Object.freeze(unionNamespace as typeof unionNamespace & {type: UnionType}); }; diff --git a/app/server/lib/BrowserSession.ts b/app/server/lib/BrowserSession.ts index 07136e3e..a54aecac 100644 --- a/app/server/lib/BrowserSession.ts +++ b/app/server/lib/BrowserSession.ts @@ -69,12 +69,21 @@ export interface SessionObj { // something they just added, without allowing the suer // to edit other people's contributions). - oidc?: { - // codeVerifier is used during OIDC authentication, to protect against attacks like CSRF. - codeVerifier?: string; - state?: string; - targetUrl?: string; - } + oidc?: SessionOIDCInfo; +} + +export interface SessionOIDCInfo { + // more details on protections are available here: https://danielfett.de/2020/05/16/pkce-vs-nonce-equivalent-or-not/#special-case-error-responses + // code_verifier is used during OIDC authentication for PKCE protection, to protect against attacks like CSRF. + // PKCE + state are currently the best combination to protect against CSRF and code injection attacks. + code_verifier?: string; + // much like code_verifier, for OIDC providers that do not support PKCE. + nonce?: string; + // state is used to protect against Error Responses spoofs. + state?: string; + targetUrl?: string; + // Stores user claims signed by the issuer, store it to allow loging out. + idToken?: string; } // Make an artificial change to a session to encourage express-session to set a cookie. diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 240d8195..37070d98 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -1036,7 +1036,7 @@ export class FlexServer implements GristServer { server: this, staticDir: getAppPathTo(this.appRoot, 'static'), tag: this.tag, - testLogin: allowTestLogin(), + testLogin: isTestLoginAllowed(), baseDomain: this._defaultBaseDomain, }); @@ -1207,7 +1207,7 @@ export class FlexServer implements GristServer { }))); this.app.get('/signin', ...signinMiddleware, expressWrap(this._redirectToLoginOrSignup.bind(this, {}))); - if (allowTestLogin()) { + if (isTestLoginAllowed()) { // This is an endpoint for the dev environment that lets you log in as anyone. // For a standard dev environment, it will be accessible at localhost:8080/test/login // and localhost:8080/o//test/login. Only available when GRIST_TEST_LOGIN is set. @@ -1989,7 +1989,9 @@ export class FlexServer implements GristServer { } public resolveLoginSystem() { - return process.env.GRIST_TEST_LOGIN ? getTestLoginSystem() : (this._getLoginSystem?.() || getLoginSystem()); + return isTestLoginAllowed() ? + getTestLoginSystem() : + (this._getLoginSystem?.() || getLoginSystem()); } public addUpdatesCheck() { @@ -2519,8 +2521,8 @@ function configServer(server: T): T { } // Returns true if environment is configured to allow unauthenticated test logins. -function allowTestLogin() { - return Boolean(process.env.GRIST_TEST_LOGIN); +function isTestLoginAllowed() { + return isAffirmative(process.env.GRIST_TEST_LOGIN); } // Check OPTIONS requests for allowed origins, and return heads to allow the browser to proceed diff --git a/app/server/lib/OIDCConfig.ts b/app/server/lib/OIDCConfig.ts index 86f78bce..8a353545 100644 --- a/app/server/lib/OIDCConfig.ts +++ b/app/server/lib/OIDCConfig.ts @@ -35,6 +35,19 @@ * 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" + * (or you may set it to "UNPROTECTED" alone, to disable any protections if you *really* know what you do!). + * Defaults to "PKCE,STATE", which is the recommended settings. + * It's highly recommended that you enable STATE, and at least one of PKCE or NONCE, + * depending on what your OIDC provider requires/supports. + * 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. + * Be aware that setting this object may override any other values passed to the openid client. + * 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 +65,61 @@ import * as express from 'express'; import { GristLoginSystem, GristServer } from './GristServer'; -import { Client, generators, Issuer, UserinfoResponse } from 'openid-client'; +import { + Client, ClientMetadata, Issuer, errors as OIDCError, 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 { SendAppPageFunction } from 'app/server/lib/sendAppPage'; +import { StringUnionError } from 'app/common/StringUnion'; +import { EnabledProtection, EnabledProtectionString, ProtectionsManager } from './oidc/Protections'; +import { SessionObj } from './BrowserSession'; const CALLBACK_URL = '/oauth2/callback'; +function formatTokenForLogs(token: TokenSet) { + const showValueInClear = ['token_type', 'expires_in', 'expires_at', 'scope']; + const result: Record = {}; + for (const [key, value] of Object.entries(token)) { + if (typeof value !== 'function') { + result[key] = showValueInClear.includes(key) ? value : 'REDACTED'; + } + } + return result; +} + +class ErrorWithUserFriendlyMessage extends Error { + constructor(errMessage: string, public readonly userFriendlyMessage: string) { + super(errMessage); + } +} + export class OIDCConfig { - private _client: Client; + /** + * Handy alias to create an OIDCConfig instance and initialize it. + */ + public static async build(sendAppPage: SendAppPageFunction): Promise { + 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 _protectionManager: ProtectionsManager; + private _acrValues?: string; - public constructor() { - } + protected constructor( + private _sendAppPage: SendAppPageFunction + ) {} public async initOIDC(): Promise { const section = appSettings.section('login').section('system').section('oidc'); @@ -108,21 +156,27 @@ 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 = JSON.parse(section.flag('extraClientMetadata').readString({ + envVar: 'GRIST_OIDC_IDP_EXTRA_CLIENT_METADATA', + }) || '{}'); + + const enabledProtections = this._buildEnabledProtections(section); + this._protectionManager = new ProtectionsManager(enabledProtections); + 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'); @@ -135,28 +189,37 @@ export class OIDCConfig { } public async handleCallback(sessions: Sessions, req: express.Request, res: express.Response): Promise { - const mreq = req as RequestWithLogin; + let mreq; + try { + mreq = this._getRequestWithSession(req); + } catch(err) { + log.warn("OIDCConfig callback:", err.message); + return this._sendErrorPage(req, res); + } + try { const params = this._client.callbackParams(req); - const { state, targetUrl } = mreq.session?.oidc ?? {}; - if (!state) { - throw new Error('Login or logout failed to complete'); + if (!mreq.session.oidc) { + throw new Error('Missing OIDC information associated to this session'); } - const codeVerifier = await this._retrieveCodeVerifierFromSession(req); + const { targetUrl } = mreq.session.oidc; + + const checks = this._protectionManager.getCallbackChecks(mreq.session.oidc); - // 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 } - ); + // The callback function will compare the protections present in the params and the ones we retrieved + // from the session. If they don't match, it will throw an error. + const tokenSet = await this._client.callback(this._redirectUrl, params, checks); + log.debug("Got tokenSet: %o", formatTokenForLogs(tokenSet)); const userInfo = await this._client.userinfo(tokenSet); + log.debug("Got userinfo: %o", userInfo); if (!this._ignoreEmailVerified && userInfo.email_verified !== true) { - throw new Error(`OIDCConfig: email not verified for ${userInfo.email}`); + throw new ErrorWithUserFriendlyMessage( + `OIDCConfig: email not verified for ${userInfo.email}`, + req.t("oidc.emailNotVerifiedError") + ); } const profile = this._makeUserProfileFromUserInfo(userInfo); @@ -167,33 +230,47 @@ export class OIDCConfig { profile, })); - delete mreq.session.oidc; + // We clear the previous session info, like the states, nonce or the code verifier, which + // now that we are authenticated. + // We store the idToken for later, especially for the logout + mreq.session.oidc = { + idToken: tokenSet.id_token, + }; res.redirect(targetUrl ?? '/'); } catch (err) { log.error(`OIDC callback failed: ${err.stack}`); - // Delete the session data even if the login failed. + const maybeResponse = this._maybeExtractDetailsFromError(err); + if (maybeResponse) { + log.error('Response received: %o', maybeResponse); + } + + // Delete entirely the session data when 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._sendErrorPage(req, res, err.userFriendlyMessage); } } public async getLoginRedirectUrl(req: express.Request, targetUrl: URL): Promise { - const { codeVerifier, state } = await this._generateAndStoreConnectionInfo(req, targetUrl.href); - const codeChallenge = generators.codeChallenge(codeVerifier); + const mreq = this._getRequestWithSession(req); + + mreq.session.oidc = { + targetUrl: targetUrl.href, + ...this._protectionManager.generateSessionInfo() + }; - const authUrl = this._client.authorizationUrl({ + return 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, + ...this._protectionManager.forgeAuthUrlParams(mreq.session.oidc), }); - return authUrl; } public async getLogoutRedirectUrl(req: express.Request, redirectUrl: URL): Promise { + const session: SessionObj|undefined = (req as RequestWithLogin).session; // For IdPs that don't have end_session_endpoint, we just redirect to the logout page. if (this._skipEndSessionEndpoint) { return redirectUrl.href; @@ -203,56 +280,112 @@ export class OIDCConfig { return this._endSessionEndpoint; } return this._client.endSessionUrl({ - post_logout_redirect_uri: redirectUrl.href + post_logout_redirect_uri: redirectUrl.href, + id_token_hint: session?.oidc?.idToken, }); } - 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, - targetUrl - }; + public supportsProtection(protection: EnabledProtectionString) { + return this._protectionManager.supportsProtection(protection); + } - return { codeVerifier, state }; + protected async _initClient({ issuerUrl, clientId, clientSecret, extraMetadata }: + { issuerUrl: string, clientId: string, clientSecret: string, extraMetadata: Partial } + ): Promise { + 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 async _retrieveCodeVerifierFromSession(req: express.Request) { + private _sendErrorPage(req: express.Request, res: express.Response, userFriendlyMessage?: string) { + return this._sendAppPage(req, res, { + path: 'error.html', + status: 500, + config: { + errPage: 'signin-failed', + errMessage: userFriendlyMessage + }, + }); + } + + private _getRequestWithSession(req: express.Request) { const mreq = req as RequestWithLogin; if (!mreq.session) { throw new Error('no session available'); } - const codeVerifier = mreq.session.oidc?.codeVerifier; - if (!codeVerifier) { throw new Error('Login is stale'); } - return codeVerifier; + + return mreq; + } + + private _buildEnabledProtections(section: AppSettings): Set { + const enabledProtections = section.flag('enabledProtections').readString({ + envVar: 'GRIST_OIDC_IDP_ENABLED_PROTECTIONS', + defaultValue: 'PKCE,STATE', + })!.split(','); + if (enabledProtections.length === 1 && enabledProtections[0] === 'UNPROTECTED') { + log.warn("You chose to enable OIDC connection with no protection, you are exposed to vulnerabilities." + + " Please never do that in production."); + return new Set(); + } + try { + return new Set(EnabledProtection.checkAll(enabledProtections)); + } catch (e) { + if (e instanceof StringUnionError) { + throw new TypeError(`OIDC: Invalid protection in GRIST_OIDC_IDP_ENABLED_PROTECTIONS: ${e.actual}.`+ + ` Expected at least one of these values: "${e.values.join(",")}"` + ); + } + throw e; + } } private _makeUserProfileFromUserInfo(userInfo: UserinfoResponse): Partial { 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 ?? ''; return `${fname} ${lname}`.trim() || userInfo.name; } + + /** + * Returns some response details from either OIDCClient's RPError or OPError, + * which are handy for error logging. + */ + private _maybeExtractDetailsFromError(error: Error) { + if (error instanceof OIDCError.OPError || error instanceof OIDCError.RPError) { + const { response } = error; + if (response) { + // Ensure that we don't log a buffer (which might be noisy), at least for now, unless we're sure that + // would be relevant. + const isBodyPureObject = response.body && Object.getPrototypeOf(response.body) === Object.prototype; + return { + body: isBodyPureObject ? response.body : undefined, + statusCode: response.statusCode, + statusMessage: response.statusMessage, + }; + } + } + return null; + } } -export async function getOIDCLoginSystem(): Promise { +export async function getOIDCLoginSystem(): Promise { 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 +396,6 @@ export async function getOIDCLoginSystem(): Promise }, }; }, - async deleteUser() {}, + async deleteUser() { }, }; } diff --git a/app/server/lib/oidc/Protections.ts b/app/server/lib/oidc/Protections.ts new file mode 100644 index 00000000..42070f8d --- /dev/null +++ b/app/server/lib/oidc/Protections.ts @@ -0,0 +1,121 @@ +import { StringUnion } from 'app/common/StringUnion'; +import { SessionOIDCInfo } from 'app/server/lib/BrowserSession'; +import { AuthorizationParameters, generators, OpenIDCallbackChecks } from 'openid-client'; + +export const EnabledProtection = StringUnion( + "STATE", + "NONCE", + "PKCE", +); +export type EnabledProtectionString = typeof EnabledProtection.type; + +interface Protection { + generateSessionInfo(): SessionOIDCInfo; + forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters; + getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks; +} + +function checkIsSet(value: string|undefined, message: string): string { + if (!value) { throw new Error(message); } + return value; +} + +class PKCEProtection implements Protection { + public generateSessionInfo(): SessionOIDCInfo { + return { + code_verifier: generators.codeVerifier() + }; + } + public forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters { + return { + code_challenge: generators.codeChallenge(checkIsSet(sessionInfo.code_verifier, "Login is stale")), + code_challenge_method: 'S256' + }; + } + public getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks { + return { + code_verifier: checkIsSet(sessionInfo.code_verifier, "Login is stale") + }; + } +} + +class NonceProtection implements Protection { + public generateSessionInfo(): SessionOIDCInfo { + return { + nonce: generators.nonce() + }; + } + public forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters { + return { + nonce: sessionInfo.nonce + }; + } + public getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks { + return { + nonce: checkIsSet(sessionInfo.nonce, "Login is stale") + }; + } +} + +class StateProtection implements Protection { + public generateSessionInfo(): SessionOIDCInfo { + return { + state: generators.state() + }; + } + public forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters { + return { + state: sessionInfo.state + }; + } + public getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks { + return { + state: checkIsSet(sessionInfo.state, "Login or logout failed to complete") + }; + } +} + +export class ProtectionsManager implements Protection { + private _protections: Protection[] = []; + + constructor(private _enabledProtections: Set) { + if (this._enabledProtections.has('STATE')) { + this._protections.push(new StateProtection()); + } + if (this._enabledProtections.has('NONCE')) { + this._protections.push(new NonceProtection()); + } + if (this._enabledProtections.has('PKCE')) { + this._protections.push(new PKCEProtection()); + } + } + + public generateSessionInfo(): SessionOIDCInfo { + const sessionInfo: SessionOIDCInfo = {}; + for (const protection of this._protections) { + Object.assign(sessionInfo, protection.generateSessionInfo()); + } + return sessionInfo; + } + + public forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters { + const authParams: AuthorizationParameters = {}; + for (const protection of this._protections) { + Object.assign(authParams, protection.forgeAuthUrlParams(sessionInfo)); + } + return authParams; + } + + public getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks { + const checks: OpenIDCallbackChecks = {}; + for (const protection of this._protections) { + Object.assign(checks, protection.getCallbackChecks(sessionInfo)); + } + return checks; + } + + public supportsProtection(protection: EnabledProtectionString) { + return this._enabledProtections.has(protection); + } +} + diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index 460137fa..653028a7 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -121,15 +121,16 @@ export function makeMessagePage(staticDir: string) { }; } +export type SendAppPageFunction = + (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise; + /** * 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 +}): SendAppPageFunction { // If env var GRIST_INCLUDE_CUSTOM_SCRIPT_URL is set, load it in a