From 335c1da086551acc3dda6fe522c35d52260a7393 Mon Sep 17 00:00:00 2001 From: Jochen Kressin Date: Wed, 25 Oct 2023 11:38:28 +0200 Subject: [PATCH] Backport cookie compression Signed-off-by: Jochen Kressin --- common/index.ts | 3 +- server/auth/types/authentication_type.ts | 18 +- server/auth/types/openid/openid_auth.test.ts | 120 +++++++++++++ server/auth/types/openid/openid_auth.ts | 95 +++++++++- server/auth/types/openid/routes.ts | 44 ++++- server/auth/types/saml/routes.ts | 45 ++++- server/auth/types/saml/saml_auth.test.ts | 118 +++++++++++++ server/auth/types/saml/saml_auth.ts | 93 +++++++++- server/index.ts | 14 ++ server/session/cookie_splitter.test.ts | 174 +++++++++++++++++++ server/session/cookie_splitter.ts | 161 +++++++++++++++++ server/utils/compression.test.ts | 28 +++ server/utils/compression.ts | 28 +++ test/jest_integration/saml_auth.test.ts | 6 +- 14 files changed, 919 insertions(+), 28 deletions(-) create mode 100644 server/auth/types/openid/openid_auth.test.ts create mode 100644 server/auth/types/saml/saml_auth.test.ts create mode 100644 server/session/cookie_splitter.test.ts create mode 100644 server/session/cookie_splitter.ts create mode 100644 server/utils/compression.test.ts create mode 100644 server/utils/compression.ts diff --git a/common/index.ts b/common/index.ts index 4fd01c8d7..fa60375c8 100644 --- a/common/index.ts +++ b/common/index.ts @@ -37,7 +37,8 @@ export const DEFAULT_TENANT = 'default'; export const GLOBAL_TENANT_RENDERING_TEXT = 'Global'; export const PRIVATE_TENANT_RENDERING_TEXT = 'Private'; export const globalTenantName = 'global_tenant'; - +export const MAX_LENGTH_OF_COOKIE_BYTES = 4000; +export const ESTIMATED_IRON_COOKIE_OVERHEAD = 1.5; export enum AuthType { BASIC = 'basicauth', OPEN_ID = 'openid', diff --git a/server/auth/types/authentication_type.ts b/server/auth/types/authentication_type.ts index 53df57309..ca4ae5bdb 100644 --- a/server/auth/types/authentication_type.ts +++ b/server/auth/types/authentication_type.ts @@ -118,7 +118,7 @@ export abstract class AuthenticationType implements IAuthenticationType { cookie = undefined; } - if (!cookie || !(await this.isValidCookie(cookie))) { + if (!cookie || !(await this.isValidCookie(cookie, request))) { // clear cookie this.sessionStorageFactory.asScoped(request).clear(); @@ -140,7 +140,7 @@ export abstract class AuthenticationType implements IAuthenticationType { } // cookie is valid // build auth header - const authHeadersFromCookie = this.buildAuthHeaderFromCookie(cookie!); + const authHeadersFromCookie = this.buildAuthHeaderFromCookie(cookie!, request); Object.assign(authHeaders, authHeadersFromCookie); const additonalAuthHeader = this.getAdditionalAuthHeader(request); Object.assign(authHeaders, additonalAuthHeader); @@ -236,11 +236,21 @@ export abstract class AuthenticationType implements IAuthenticationType { request: OpenSearchDashboardsRequest, authInfo: any ): SecuritySessionCookie; - protected abstract async isValidCookie(cookie: SecuritySessionCookie): Promise; + + public abstract isValidCookie( + cookie: SecuritySessionCookie, + request: OpenSearchDashboardsRequest + ): Promise; + protected abstract handleUnauthedRequest( request: OpenSearchDashboardsRequest, response: LifecycleResponseFactory, toolkit: AuthToolkit ): IOpenSearchDashboardsResponse | AuthResult; - protected abstract buildAuthHeaderFromCookie(cookie: SecuritySessionCookie): any; + + public abstract buildAuthHeaderFromCookie( + cookie: SecuritySessionCookie, + request: OpenSearchDashboardsRequest + ): any; + public abstract init(): Promise; } diff --git a/server/auth/types/openid/openid_auth.test.ts b/server/auth/types/openid/openid_auth.test.ts new file mode 100644 index 000000000..f998d844f --- /dev/null +++ b/server/auth/types/openid/openid_auth.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { httpServerMock } from '../../../../../../src/core/server/http/http_server.mocks'; + +import { OpenSearchDashboardsRequest } from '../../../../../../src/core/server/http/router'; + +import { OpenIdAuthentication } from './openid_auth'; +import { SecurityPluginConfigType } from '../../../index'; +import { SecuritySessionCookie } from '../../../session/security_cookie'; +import { deflateValue } from '../../../utils/compression'; +import { + IRouter, + CoreSetup, + ILegacyClusterClient, + Logger, + SessionStorageFactory, +} from '../../../../../../src/core/server'; + +describe('test OpenId authHeaderValue', () => { + let router: IRouter; + let core: CoreSetup; + let esClient: ILegacyClusterClient; + let sessionStorageFactory: SessionStorageFactory; + let logger: Logger; + + // Consistent with auth_handler_factory.test.ts + beforeEach(() => {}); + + const config = ({ + openid: { + header: 'authorization', + scope: [], + extra_storage: { + cookie_prefix: 'testcookie', + additional_cookies: 5, + }, + }, + } as unknown) as SecurityPluginConfigType; + + test('make sure that cookies with authHeaderValue are still valid', async () => { + const openIdAuthentication = new OpenIdAuthentication( + config, + sessionStorageFactory, + router, + esClient, + core, + logger + ); + + const mockRequest = httpServerMock.createRawRequest(); + const osRequest = OpenSearchDashboardsRequest.from(mockRequest); + + const cookie: SecuritySessionCookie = { + credentials: { + authHeaderValue: 'Bearer eyToken', + }, + }; + + const expectedHeaders = { + authorization: 'Bearer eyToken', + }; + + const headers = openIdAuthentication.buildAuthHeaderFromCookie(cookie, osRequest); + + expect(headers).toEqual(expectedHeaders); + }); + + test('get authHeaderValue from split cookies', async () => { + const openIdAuthentication = new OpenIdAuthentication( + config, + sessionStorageFactory, + router, + esClient, + core, + logger + ); + + const testString = 'Bearer eyCombinedToken'; + const testStringBuffer: Buffer = deflateValue(testString); + const cookieValue = testStringBuffer.toString('base64'); + const cookiePrefix = config.openid!.extra_storage.cookie_prefix; + const splitValueAt = Math.ceil( + cookieValue.length / config.openid!.extra_storage.additional_cookies + ); + const mockRequest = httpServerMock.createRawRequest({ + state: { + [cookiePrefix + '1']: cookieValue.substring(0, splitValueAt), + [cookiePrefix + '2']: cookieValue.substring(splitValueAt), + }, + }); + const osRequest = OpenSearchDashboardsRequest.from(mockRequest); + + const cookie: SecuritySessionCookie = { + credentials: { + authHeaderValueExtra: true, + }, + }; + + const expectedHeaders = { + authorization: testString, + }; + + const headers = openIdAuthentication.buildAuthHeaderFromCookie(cookie, osRequest); + + expect(headers).toEqual(expectedHeaders); + }); +}); diff --git a/server/auth/types/openid/openid_auth.ts b/server/auth/types/openid/openid_auth.ts index b43d0bb0d..d8d61c2c3 100644 --- a/server/auth/types/openid/openid_auth.ts +++ b/server/auth/types/openid/openid_auth.ts @@ -29,12 +29,18 @@ import { import HTTP from 'http'; import HTTPS from 'https'; import { PeerCertificate } from 'tls'; +import { Server, ServerStateCookieOptions } from '@hapi/hapi'; import { SecurityPluginConfigType } from '../../..'; import { SecuritySessionCookie } from '../../../session/security_cookie'; import { OpenIdAuthRoutes } from './routes'; import { AuthenticationType } from '../authentication_type'; import { callTokenEndpoint } from './helper'; import { composeNextUrlQeuryParam } from '../../../utils/next_url'; +import { + ExtraAuthStorageOptions, + getExtraAuthStorageValue, + setExtraAuthStorage, +} from '../../../session/cookie_splitter'; export interface OpenIdAuthConfig { authorizationEndpoint?: string; @@ -93,6 +99,8 @@ export class OpenIdAuthentication extends AuthenticationType { this.openIdAuthConfig.tokenEndpoint = payload.token_endpoint; this.openIdAuthConfig.endSessionEndpoint = payload.end_session_endpoint || undefined; + this.createExtraStorage(); + const routes = new OpenIdAuthRoutes( this.router, this.config, @@ -135,6 +143,37 @@ export class OpenIdAuthentication extends AuthenticationType { } } + createExtraStorage() { + // @ts-ignore + const hapiServer: Server = this.sessionStorageFactory.asScoped({}).server; + + const extraCookiePrefix = this.config.openid!.extra_storage.cookie_prefix; + const extraCookieSettings: ServerStateCookieOptions = { + isSecure: this.config.cookie.secure, + isSameSite: this.config.cookie.isSameSite, + password: this.config.cookie.password, + domain: this.config.cookie.domain, + path: this.coreSetup.http.basePath.serverBasePath || '/', + clearInvalid: false, + isHttpOnly: true, + ignoreErrors: true, + encoding: 'iron', // Same as hapi auth cookie + }; + + for (let i = 1; i <= this.config.openid!.extra_storage.additional_cookies; i++) { + hapiServer.states.add(extraCookiePrefix + i, extraCookieSettings); + } + } + + private getExtraAuthStorageOptions(): ExtraAuthStorageOptions { + // If we're here, we will always have the openid configuration + return { + cookiePrefix: this.config.openid!.extra_storage.cookie_prefix, + additionalCookies: this.config.openid!.extra_storage.additional_cookies, + logger: this.logger, + }; + } + requestIncludesAuthInfo(request: OpenSearchDashboardsRequest): boolean { return request.headers.authorization ? true : false; } @@ -144,10 +183,16 @@ export class OpenIdAuthentication extends AuthenticationType { } getCookie(request: OpenSearchDashboardsRequest, authInfo: any): SecuritySessionCookie { + setExtraAuthStorage( + request, + request.headers.authorization as string, + this.getExtraAuthStorageOptions() + ); + return { username: authInfo.user_name, credentials: { - authHeaderValue: request.headers.authorization, + authHeaderValueExtra: true, }, authType: this.type, expiryTime: Date.now() + this.config.session.ttl, @@ -155,16 +200,20 @@ export class OpenIdAuthentication extends AuthenticationType { } // TODO: Add token expiration check here - async isValidCookie(cookie: SecuritySessionCookie): Promise { + async isValidCookie( + cookie: SecuritySessionCookie, + request: OpenSearchDashboardsRequest + ): Promise { if ( cookie.authType !== this.type || !cookie.username || !cookie.expiryTime || - !cookie.credentials?.authHeaderValue || + (!cookie.credentials?.authHeaderValue && !this.getExtraAuthStorageValue(request, cookie)) || !cookie.credentials?.expires_at ) { return false; } + if (cookie.credentials?.expires_at > Date.now()) { return true; } @@ -187,10 +236,17 @@ export class OpenIdAuthentication extends AuthenticationType { // if no id_token from refresh token call, maybe the Idp doesn't allow refresh id_token if (refreshTokenResponse.idToken) { cookie.credentials = { - authHeaderValue: `Bearer ${refreshTokenResponse.idToken}`, + authHeaderValueExtra: true, refresh_token: refreshTokenResponse.refreshToken, expires_at: Date.now() + refreshTokenResponse.expiresIn! * 1000, // expiresIn is in second }; + + setExtraAuthStorage( + request, + `Bearer ${refreshTokenResponse.idToken}`, + this.getExtraAuthStorageOptions() + ); + return true; } else { return false; @@ -226,8 +282,37 @@ export class OpenIdAuthentication extends AuthenticationType { } } - buildAuthHeaderFromCookie(cookie: SecuritySessionCookie): any { + getExtraAuthStorageValue(request: OpenSearchDashboardsRequest, cookie: SecuritySessionCookie) { + let extraValue = ''; + if (!cookie.credentials?.authHeaderValueExtra) { + return extraValue; + } + + try { + extraValue = getExtraAuthStorageValue(request, this.getExtraAuthStorageOptions()); + } catch (error) { + this.logger.info(error); + } + + return extraValue; + } + + buildAuthHeaderFromCookie( + cookie: SecuritySessionCookie, + request: OpenSearchDashboardsRequest + ): any { const header: any = {}; + if (cookie.credentials.authHeaderValueExtra) { + try { + const extraAuthStorageValue = this.getExtraAuthStorageValue(request, cookie); + header.authorization = extraAuthStorageValue; + return header; + } catch (error) { + this.logger.error(error); + // TODO Re-throw? + // throw error; + } + } const authHeaderValue = cookie.credentials?.authHeaderValue; if (authHeaderValue) { header.authorization = authHeaderValue; diff --git a/server/auth/types/openid/routes.ts b/server/auth/types/openid/routes.ts index 078f52e6d..47339d3ff 100644 --- a/server/auth/types/openid/routes.ts +++ b/server/auth/types/openid/routes.ts @@ -22,6 +22,7 @@ import { CoreSetup, OpenSearchDashboardsResponseFactory, OpenSearchDashboardsRequest, + Logger, } from '../../../../../../src/core/server'; import { SecuritySessionCookie } from '../../../session/security_cookie'; import { SecurityPluginConfigType } from '../../..'; @@ -30,6 +31,13 @@ import { SecurityClient } from '../../../backend/opensearch_security_client'; import { getBaseRedirectUrl, callTokenEndpoint, composeLogoutUrl } from './helper'; import { validateNextUrl } from '../../../utils/next_url'; +import { + clearSplitCookies, + ExtraAuthStorageOptions, + getExtraAuthStorageValue, + setExtraAuthStorage, +} from '../../../session/cookie_splitter'; + export class OpenIdAuthRoutes { private static readonly NONCE_LENGTH: number = 22; @@ -55,6 +63,15 @@ export class OpenIdAuthRoutes { }); } + private getExtraAuthStorageOptions(logger?: Logger): ExtraAuthStorageOptions { + // If we're here, we will always have the openid configuration + return { + cookiePrefix: this.config.openid!.extra_storage.cookie_prefix, + additionalCookies: this.config.openid!.extra_storage.additional_cookies, + logger, + }; + } + public setupRoutes() { this.router.get( { @@ -155,7 +172,7 @@ export class OpenIdAuthRoutes { const sessionStorage: SecuritySessionCookie = { username: user.username, credentials: { - authHeaderValue: `Bearer ${tokenResponse.idToken}`, + authHeaderValueExtra: true, expires_at: Date.now() + tokenResponse.expiresIn! * 1000, // expiresIn is in second }, authType: 'openid', @@ -166,6 +183,13 @@ export class OpenIdAuthRoutes { refresh_token: tokenResponse.refreshToken, }); } + + setExtraAuthStorage( + request, + `Bearer ${tokenResponse.idToken}`, + this.getExtraAuthStorageOptions(context.security_plugin.logger) + ); + this.sessionStorageFactory.asScoped(request).set(sessionStorage); return response.redirected({ headers: { @@ -187,10 +211,24 @@ export class OpenIdAuthRoutes { }, async (context, request, response) => { const cookie = await this.sessionStorageFactory.asScoped(request).get(); + let tokenFromExtraStorage = ''; + + const extraAuthStorageOptions: ExtraAuthStorageOptions = this.getExtraAuthStorageOptions( + context.security_plugin.logger + ); + + if (cookie?.credentials?.authHeaderValueExtra) { + tokenFromExtraStorage = getExtraAuthStorageValue(request, extraAuthStorageOptions); + } + + clearSplitCookies(request, extraAuthStorageOptions); this.sessionStorageFactory.asScoped(request).clear(); - // authHeaderValue is the bearer header, e.g. "Bearer " - const token = cookie?.credentials.authHeaderValue.split(' ')[1]; // get auth token + // tokenFromExtraStorage is the bearer header, e.g. "Bearer " + const token = tokenFromExtraStorage.length + ? tokenFromExtraStorage.split(' ')[1] + : cookie?.credentials.authHeaderValue.split(' ')[1]; // get auth token + const logoutQueryParams = { post_logout_redirect_uri: getBaseRedirectUrl(this.config, this.core), id_token_hint: token, diff --git a/server/auth/types/saml/routes.ts b/server/auth/types/saml/routes.ts index 79454272c..0333f9bda 100644 --- a/server/auth/types/saml/routes.ts +++ b/server/auth/types/saml/routes.ts @@ -14,17 +14,19 @@ */ import { schema } from '@osd/config-schema'; -import { - IRouter, - SessionStorageFactory, - OpenSearchDashboardsRequest, -} from '../../../../../../src/core/server'; +import { IRouter, SessionStorageFactory, Logger } from '../../../../../../src/core/server'; import { SecuritySessionCookie } from '../../../session/security_cookie'; import { SecurityPluginConfigType } from '../../..'; import { SecurityClient } from '../../../backend/opensearch_security_client'; import { CoreSetup } from '../../../../../../src/core/server'; import { validateNextUrl } from '../../../utils/next_url'; +import { + clearSplitCookies, + ExtraAuthStorageOptions, + setExtraAuthStorage, +} from '../../../session/cookie_splitter'; + export class SamlAuthRoutes { constructor( private readonly router: IRouter, @@ -35,6 +37,15 @@ export class SamlAuthRoutes { private readonly coreSetup: CoreSetup ) {} + private getExtraAuthStorageOptions(logger?: Logger): ExtraAuthStorageOptions { + // If we're here, we will always have the openid configuration + return { + cookiePrefix: this.config.saml.extra_storage.cookie_prefix, + additionalCookies: this.config.saml.extra_storage.additional_cookies, + logger, + }; + } + public setupRoutes() { this.router.get( { @@ -138,15 +149,24 @@ export class SamlAuthRoutes { if (tokenPayload.exp) { expiryTime = parseInt(tokenPayload.exp, 10) * 1000; } + const cookie: SecuritySessionCookie = { username: user.username, credentials: { - authHeaderValue: credentials.authorization, + authHeaderValueExtra: true, }, authType: 'saml', // TODO: create constant expiryTime, }; + + setExtraAuthStorage( + request, + credentials.authorization, + this.getExtraAuthStorageOptions(context.security_plugin.logger) + ); + this.sessionStorageFactory.asScoped(request).set(cookie); + if (redirectHash) { return response.redirected({ headers: { @@ -209,11 +229,18 @@ export class SamlAuthRoutes { const cookie: SecuritySessionCookie = { username: user.username, credentials: { - authHeaderValue: credentials.authorization, + authHeaderValueExtra: true, }, authType: 'saml', // TODO: create constant expiryTime, }; + + setExtraAuthStorage( + request, + credentials.authorization, + this.getExtraAuthStorageOptions(context.security_plugin.logger) + ); + this.sessionStorageFactory.asScoped(request).set(cookie); return response.redirected({ headers: { @@ -350,6 +377,10 @@ export class SamlAuthRoutes { async (context, request, response) => { try { const authInfo = await this.securityClient.authinfo(request); + await clearSplitCookies( + request, + this.getExtraAuthStorageOptions(context.security_plugin.logger) + ); this.sessionStorageFactory.asScoped(request).clear(); // TODO: need a default logout page const redirectUrl = diff --git a/server/auth/types/saml/saml_auth.test.ts b/server/auth/types/saml/saml_auth.test.ts new file mode 100644 index 000000000..355d8b28c --- /dev/null +++ b/server/auth/types/saml/saml_auth.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { httpServerMock } from '../../../../../../src/core/server/http/http_server.mocks'; + +import { OpenSearchDashboardsRequest } from '../../../../../../src/core/server/http/router'; + +import { SecurityPluginConfigType } from '../../../index'; +import { SecuritySessionCookie } from '../../../session/security_cookie'; +import { deflateValue } from '../../../utils/compression'; +import { + IRouter, + CoreSetup, + ILegacyClusterClient, + Logger, + SessionStorageFactory, +} from '../../../../../../src/core/server'; +import { SamlAuthentication } from './saml_auth'; + +describe('test SAML authHeaderValue', () => { + let router: IRouter; + let core: CoreSetup; + let esClient: ILegacyClusterClient; + let sessionStorageFactory: SessionStorageFactory; + let logger: Logger; + + // Consistent with auth_handler_factory.test.ts + beforeEach(() => {}); + + const config = ({ + saml: { + extra_storage: { + cookie_prefix: 'testcookie', + additional_cookies: 5, + }, + }, + } as unknown) as SecurityPluginConfigType; + + test('make sure that cookies with authHeaderValue are still valid', async () => { + const samlAuthentication = new SamlAuthentication( + config, + sessionStorageFactory, + router, + esClient, + core, + logger + ); + + const mockRequest = httpServerMock.createRawRequest(); + const osRequest = OpenSearchDashboardsRequest.from(mockRequest); + + const cookie: SecuritySessionCookie = { + credentials: { + authHeaderValue: 'Bearer eyToken', + }, + }; + + const expectedHeaders = { + authorization: 'Bearer eyToken', + }; + + const headers = samlAuthentication.buildAuthHeaderFromCookie(cookie, osRequest); + + expect(headers).toEqual(expectedHeaders); + }); + + test('get authHeaderValue from split cookies', async () => { + const samlAuthentication = new SamlAuthentication( + config, + sessionStorageFactory, + router, + esClient, + core, + logger + ); + + const testString = 'Bearer eyCombinedToken'; + const testStringBuffer: Buffer = deflateValue(testString); + const cookieValue = testStringBuffer.toString('base64'); + const cookiePrefix = config.saml.extra_storage.cookie_prefix; + const splitValueAt = Math.ceil( + cookieValue.length / config.saml.extra_storage.additional_cookies + ); + const mockRequest = httpServerMock.createRawRequest({ + state: { + [cookiePrefix + '1']: cookieValue.substring(0, splitValueAt), + [cookiePrefix + '2']: cookieValue.substring(splitValueAt), + }, + }); + const osRequest = OpenSearchDashboardsRequest.from(mockRequest); + + const cookie: SecuritySessionCookie = { + credentials: { + authHeaderValueExtra: true, + }, + }; + + const expectedHeaders = { + authorization: testString, + }; + + const headers = samlAuthentication.buildAuthHeaderFromCookie(cookie, osRequest); + + expect(headers).toEqual(expectedHeaders); + }); +}); diff --git a/server/auth/types/saml/saml_auth.ts b/server/auth/types/saml/saml_auth.ts index 6b4ec03e5..a6a50f013 100644 --- a/server/auth/types/saml/saml_auth.ts +++ b/server/auth/types/saml/saml_auth.ts @@ -15,6 +15,7 @@ import { escape } from 'querystring'; import { CoreSetup } from 'opensearch-dashboards/server'; +import { Server, ServerStateCookieOptions } from '@hapi/hapi'; import { SecurityPluginConfigType } from '../../..'; import { SessionStorageFactory, @@ -34,6 +35,12 @@ import { import { SamlAuthRoutes } from './routes'; import { AuthenticationType } from '../authentication_type'; +import { + setExtraAuthStorage, + getExtraAuthStorageValue, + ExtraAuthStorageOptions, +} from '../../../session/cookie_splitter'; + export class SamlAuthentication extends AuthenticationType { public static readonly AUTH_HEADER_NAME = 'authorization'; @@ -48,6 +55,7 @@ export class SamlAuthentication extends AuthenticationType { logger: Logger ) { super(config, sessionStorageFactory, router, esClient, coreSetup, logger); + this.createExtraStorage(); this.setupRoutes(); } @@ -78,6 +86,37 @@ export class SamlAuthentication extends AuthenticationType { samlAuthRoutes.setupRoutes(); } + createExtraStorage() { + // @ts-ignore + const hapiServer: Server = this.sessionStorageFactory.asScoped({}).server; + + const extraCookiePrefix = this.config.saml.extra_storage.cookie_prefix; + const extraCookieSettings: ServerStateCookieOptions = { + isSecure: this.config.cookie.secure, + isSameSite: this.config.cookie.isSameSite, + password: this.config.cookie.password, + domain: this.config.cookie.domain, + path: this.coreSetup.http.basePath.serverBasePath || '/', + clearInvalid: false, + isHttpOnly: true, + ignoreErrors: true, + encoding: 'iron', // Same as hapi auth cookie + }; + + for (let i = 1; i <= this.config.saml.extra_storage.additional_cookies; i++) { + hapiServer.states.add(extraCookiePrefix + i, extraCookieSettings); + } + } + + private getExtraAuthStorageOptions(logger?: Logger): ExtraAuthStorageOptions { + // If we're here, we will always have the openid configuration + return { + cookiePrefix: this.config.saml.extra_storage.cookie_prefix, + additionalCookies: this.config.saml.extra_storage.additional_cookies, + logger, + }; + } + requestIncludesAuthInfo(request: OpenSearchDashboardsRequest): boolean { return request.headers[SamlAuthentication.AUTH_HEADER_NAME] ? true : false; } @@ -87,10 +126,20 @@ export class SamlAuthentication extends AuthenticationType { } getCookie(request: OpenSearchDashboardsRequest, authInfo: any): SecuritySessionCookie { + const authorizationHeaderValue: string = request.headers[ + SamlAuthentication.AUTH_HEADER_NAME + ] as string; + + setExtraAuthStorage( + request, + authorizationHeaderValue, + this.getExtraAuthStorageOptions(this.logger) + ); + return { username: authInfo.user_name, credentials: { - authHeaderValue: request.headers[SamlAuthentication.AUTH_HEADER_NAME], + authHeaderValueExtra: true, }, authType: this.type, expiryTime: Date.now() + this.config.session.ttl, @@ -98,12 +147,15 @@ export class SamlAuthentication extends AuthenticationType { } // Can be improved to check if the token is expiring. - async isValidCookie(cookie: SecuritySessionCookie): Promise { + async isValidCookie( + cookie: SecuritySessionCookie, + request: OpenSearchDashboardsRequest + ): Promise { return ( cookie.authType === this.type && cookie.username && cookie.expiryTime && - cookie.credentials?.authHeaderValue + (cookie.credentials?.authHeaderValue || this.getExtraAuthStorageValue(request, cookie)) ); } @@ -119,9 +171,40 @@ export class SamlAuthentication extends AuthenticationType { } } - buildAuthHeaderFromCookie(cookie: SecuritySessionCookie): any { + getExtraAuthStorageValue(request: OpenSearchDashboardsRequest, cookie: SecuritySessionCookie) { + let extraValue = ''; + if (!cookie.credentials?.authHeaderValueExtra) { + return extraValue; + } + + try { + extraValue = getExtraAuthStorageValue(request, this.getExtraAuthStorageOptions(this.logger)); + } catch (error) { + this.logger.info(error); + } + + return extraValue; + } + + buildAuthHeaderFromCookie( + cookie: SecuritySessionCookie, + request: OpenSearchDashboardsRequest + ): any { const headers: any = {}; - headers[SamlAuthentication.AUTH_HEADER_NAME] = cookie.credentials?.authHeaderValue; + + if (cookie.credentials?.authHeaderValueExtra) { + try { + const extraAuthStorageValue = this.getExtraAuthStorageValue(request, cookie); + headers[SamlAuthentication.AUTH_HEADER_NAME] = extraAuthStorageValue; + } catch (error) { + this.logger.error(error); + // @todo Re-throw? + // throw error; + } + } else { + headers[SamlAuthentication.AUTH_HEADER_NAME] = cookie.credentials?.authHeaderValue; + } + return headers; } } diff --git a/server/index.ts b/server/index.ts index a25bca467..f4d9dfeeb 100644 --- a/server/index.ts +++ b/server/index.ts @@ -135,8 +135,22 @@ export const configSchema = schema.object({ root_ca: schema.string({ defaultValue: '' }), verify_hostnames: schema.boolean({ defaultValue: true }), refresh_tokens: schema.boolean({ defaultValue: true }), + extra_storage: schema.object({ + cookie_prefix: schema.string({ + defaultValue: 'security_authentication_oidc', + minLength: 2, + }), + additional_cookies: schema.number({ min: 1, defaultValue: 5 }), + }), }) ), + saml: schema.object({ + extra_storage: schema.object({ + cookie_prefix: schema.string({ defaultValue: 'security_authentication_saml', minLength: 2 }), + additional_cookies: schema.number({ min: 0, defaultValue: 3 }), + }), + }), + proxycache: schema.maybe( schema.object({ // when auth.type is proxycache, user_header, roles_header and proxy_header_ip are required diff --git a/server/session/cookie_splitter.test.ts b/server/session/cookie_splitter.test.ts new file mode 100644 index 000000000..833987427 --- /dev/null +++ b/server/session/cookie_splitter.test.ts @@ -0,0 +1,174 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +import { Request as HapiRequest, ResponseObject as HapiResponseObject } from '@hapi/hapi'; +import { httpServerMock } from '../../../../src/core/server/http/http_server.mocks'; +import { + clearSplitCookies, + getExtraAuthStorageValue, + setExtraAuthStorage, + splitValueIntoCookies, + unsplitCookiesIntoValue, +} from './cookie_splitter'; +import { OpenSearchDashboardsRequest } from '../../../../src/core/server/http/router'; +import { deflateValue } from '../utils/compression'; + +type CookieAuthWithResponseObject = Partial & { + h: Partial; +}; + +describe('Test extra auth storage', () => { + test('the cookie value is split up into multiple cookies', async () => { + const cookiePrefix = 'testcookie'; + const additionalCookies = 2; + + const mockRequest = httpServerMock.createRawRequest(); + (mockRequest.cookieAuth as CookieAuthWithResponseObject) = { + h: { + state: jest.fn(), + unstate: jest.fn(), + }, + }; + + const osRequest = OpenSearchDashboardsRequest.from(mockRequest); + + setExtraAuthStorage(osRequest, 'THIS IS MY VALUE', { + cookiePrefix, + additionalCookies, + }); + + const cookieAuth = mockRequest.cookieAuth as CookieAuthWithResponseObject; + expect(cookieAuth.h.state).toHaveBeenCalledTimes(1); + expect(cookieAuth.h.state).toHaveBeenCalledWith(cookiePrefix + '1', expect.anything()); + }); + + test('cookies are stitched together and inflated', async () => { + const cookiePrefix = 'testcookie'; + const additionalCookies = 2; + + const testString = 'abcdefghi'; + const testStringBuffer: Buffer = deflateValue(testString); + const cookieValue = testStringBuffer.toString('base64'); + + const splitValueAt = Math.ceil(cookieValue.length / additionalCookies); + const mockRequest = httpServerMock.createRawRequest({ + state: { + [cookiePrefix + '1']: cookieValue.substring(0, splitValueAt), + [cookiePrefix + '2']: cookieValue.substring(splitValueAt), + }, + }); + + (mockRequest.cookieAuth as CookieAuthWithResponseObject) = { + h: { + state: jest.fn(), + unstate: jest.fn(), + }, + }; + + const osRequest = OpenSearchDashboardsRequest.from(mockRequest); + + const extraStorageValue = getExtraAuthStorageValue(osRequest, { + cookiePrefix, + additionalCookies, + }); + + expect(extraStorageValue).toEqual(testString); + }); + + /** + * Should calculate the number of cookies correctly. + * Any cookies required should be unstated + */ + test('number of cookies used is correctly calculated', async () => { + const cookiePrefix = 'testcookie'; + const additionalCookies = 5; + + // 4000 bytes would require two cookies + const cookieValue = 'a'.repeat(4000); + + const mockRequest = httpServerMock.createRawRequest({ + state: { + [cookiePrefix + '1']: 'should be overridden', + [cookiePrefix + '2']: 'should be overridden', + [cookiePrefix + '3']: 'should be unstated', + [cookiePrefix + '4']: 'should be unstated', + [cookiePrefix + '5']: 'should be unstated', + }, + }); + + (mockRequest.cookieAuth as CookieAuthWithResponseObject) = { + h: { + state: jest.fn(), + unstate: jest.fn(), + }, + }; + + const osRequest = OpenSearchDashboardsRequest.from(mockRequest); + + splitValueIntoCookies(osRequest, cookiePrefix, cookieValue, additionalCookies); + + const cookieAuth = mockRequest.cookieAuth as CookieAuthWithResponseObject; + expect(cookieAuth.h.state).toHaveBeenCalledTimes(2); + expect(cookieAuth.h.unstate).toHaveBeenCalledTimes(3); + }); + + test('clear all cookies', async () => { + const cookiePrefix = 'testcookie'; + const additionalCookies = 5; + + const mockRequest = httpServerMock.createRawRequest({ + state: { + [cookiePrefix + '1']: 'should be unstated', + [cookiePrefix + '2']: 'should be unstated', + [cookiePrefix + '3']: 'should be unstated', + }, + }); + + (mockRequest.cookieAuth as CookieAuthWithResponseObject) = { + h: { + state: jest.fn(), + unstate: jest.fn(), + }, + }; + + const osRequest = OpenSearchDashboardsRequest.from(mockRequest); + + clearSplitCookies(osRequest, { + cookiePrefix, + additionalCookies, + }); + + const cookieAuth = mockRequest.cookieAuth as CookieAuthWithResponseObject; + // Only 3 out of 5 cookies set in the request + expect(cookieAuth.h.unstate).toHaveBeenCalledTimes(3); + }); + + test('should unsplit cookies', async () => { + const cookiePrefix = 'testcookie'; + const additionalCookies = 5; + + const mockRequest = httpServerMock.createRawRequest({ + state: { + [cookiePrefix + '1']: 'abc', + [cookiePrefix + '2']: 'def', + [cookiePrefix + '3']: 'ghi', + }, + }); + + const osRequest = OpenSearchDashboardsRequest.from(mockRequest); + const unsplitValue = unsplitCookiesIntoValue(osRequest, cookiePrefix, additionalCookies); + + expect(unsplitValue).toEqual('abcdefghi'); + }); +}); diff --git a/server/session/cookie_splitter.ts b/server/session/cookie_splitter.ts new file mode 100644 index 000000000..c6b563902 --- /dev/null +++ b/server/session/cookie_splitter.ts @@ -0,0 +1,161 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +import { Request as HapiRequest, ResponseObject as HapiResponseObject } from '@hapi/hapi'; +import { Logger } from '@osd/logging'; +import { + ensureRawRequest, + OpenSearchDashboardsRequest, +} from '../../../../src/core/server/http/router'; +import { deflateValue, inflateValue } from '../utils/compression'; +import { ESTIMATED_IRON_COOKIE_OVERHEAD, MAX_LENGTH_OF_COOKIE_BYTES } from '../../common'; + +export interface ExtraAuthStorageOptions { + cookiePrefix: string; + additionalCookies: number; + logger?: Logger; +} + +type CookieAuthWithResponseObject = HapiRequest['cookieAuth'] & { h: HapiResponseObject }; + +export function getExtraAuthStorageValue( + request: OpenSearchDashboardsRequest, + options: ExtraAuthStorageOptions +): string { + let compressedContent = ''; + let content = ''; + + if (options.additionalCookies > 0) { + compressedContent = unsplitCookiesIntoValue( + request, + options.cookiePrefix, + options.additionalCookies + ); + } + + try { + content = inflateValue(Buffer.from(compressedContent, 'base64')).toString(); + } catch (error) { + throw error; + } + + return content; +} + +/** + * Compress and split up the given value into multiple cookies + * @param request + * @param cookie + * @param options + */ +export function setExtraAuthStorage( + request: OpenSearchDashboardsRequest, + content: string, + options: ExtraAuthStorageOptions +): void { + const compressedAuthorizationHeaderValue: Buffer = deflateValue(content); + const compressedContent = compressedAuthorizationHeaderValue.toString('base64'); + + splitValueIntoCookies( + request, + options.cookiePrefix, + compressedContent, + options.additionalCookies, + options.logger + ); +} + +export function splitValueIntoCookies( + request: OpenSearchDashboardsRequest, // @todo Should be OpenSearchDashboardsRequest, I believe? + cookiePrefix: string, + value: string, + additionalCookies: number, + logger?: Logger +): void { + /** + * Assume that Iron adds around 50%. + * Remember that an empty cookie is around 30 bytes + */ + + const maxLengthPerCookie = Math.floor( + MAX_LENGTH_OF_COOKIE_BYTES / ESTIMATED_IRON_COOKIE_OVERHEAD + ); + const cookiesNeeded = value.length / maxLengthPerCookie; // Assume 1 bit per character since this value is encoded + // If the amount of additional cookies aren't enough for our logic, we try to write the value anyway + // TODO We could also consider throwing an error, since a failed cookie leads to weird redirects. + // But throwing would probably also lead to a weird redirect, since we'd get the token from the IdP again and again + let splitValueAt = maxLengthPerCookie; + if (cookiesNeeded > additionalCookies) { + splitValueAt = Math.ceil(value.length / additionalCookies); + if (logger) { + logger.warn( + 'The payload may be too large for the cookies. To be safe, we would need ' + + Math.ceil(cookiesNeeded) + + ' cookies in total, but we only have ' + + additionalCookies + + '. This can be changed with opensearch_security.openid.extra_storage.additional_cookies.' + ); + } + } + + const rawRequest: HapiRequest = ensureRawRequest(request); + + const values: string[] = []; + + for (let i = 1; i <= additionalCookies; i++) { + values.push(value.substring((i - 1) * splitValueAt, i * splitValueAt)); + } + + values.forEach(async (cookieSplitValue: string, index: number) => { + const cookieName: string = cookiePrefix + (index + 1); + + if (cookieSplitValue === '') { + // Make sure we clean up cookies that are not needed for the given value + (rawRequest.cookieAuth as CookieAuthWithResponseObject).h.unstate(cookieName); + } else { + (rawRequest.cookieAuth as CookieAuthWithResponseObject).h.state(cookieName, cookieSplitValue); + } + }); +} + +export function unsplitCookiesIntoValue( + request: OpenSearchDashboardsRequest, + cookiePrefix: string, + additionalCookies: number +): string { + const rawRequest: HapiRequest = ensureRawRequest(request); + let fullCookieValue = ''; + + for (let i = 1; i <= additionalCookies; i++) { + const cookieName = cookiePrefix + i; + if (rawRequest.state[cookieName]) { + fullCookieValue = fullCookieValue + rawRequest.state[cookieName]; + } + } + + return fullCookieValue; +} + +export function clearSplitCookies( + request: OpenSearchDashboardsRequest, + options: ExtraAuthStorageOptions +): void { + const rawRequest: HapiRequest = ensureRawRequest(request); + for (let i = 1; i <= options.additionalCookies; i++) { + const cookieName = options.cookiePrefix + i; + if (rawRequest.state[cookieName]) { + (rawRequest.cookieAuth as CookieAuthWithResponseObject).h.unstate(cookieName); + } + } +} diff --git a/server/utils/compression.test.ts b/server/utils/compression.test.ts new file mode 100644 index 000000000..63b807c51 --- /dev/null +++ b/server/utils/compression.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +import { deflateValue, inflateValue } from './compression'; + +describe('test compression', () => { + test('get original value from deflated value', () => { + const originalValue = 'This is the original value'; + const deflatedValue: Buffer = deflateValue(originalValue); + const inflatedValue: Buffer = inflateValue(deflatedValue); + + // Make sure deflateValue actually does something + expect(deflatedValue).not.toEqual(originalValue); + + expect(inflatedValue.toString()).toEqual(originalValue); + }); +}); diff --git a/server/utils/compression.ts b/server/utils/compression.ts new file mode 100644 index 000000000..8104efdc6 --- /dev/null +++ b/server/utils/compression.ts @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import zlib, { ZlibOptions } from 'zlib'; + +export function deflateValue(value: string, options: ZlibOptions = {}): Buffer { + const compressedBuffer: Buffer = zlib.deflateSync(value, options); + + return compressedBuffer; +} + +export function inflateValue(value: Buffer, options: ZlibOptions = {}): Buffer { + const uncompressedBuffer: Buffer = zlib.inflateSync(value, options); + + return uncompressedBuffer; +} diff --git a/test/jest_integration/saml_auth.test.ts b/test/jest_integration/saml_auth.test.ts index 53d2a951e..9a491828a 100644 --- a/test/jest_integration/saml_auth.test.ts +++ b/test/jest_integration/saml_auth.test.ts @@ -243,7 +243,7 @@ describe('start OpenSearch Dashboards server', () => { await driver.wait(until.elementsLocated(By.xpath(pageTitleXPath)), 10000); const cookie = await driver.manage().getCookies(); - expect(cookie.length).toEqual(2); + expect(cookie.length).toEqual(3); await driver.manage().deleteAllCookies(); await driver.quit(); }); @@ -259,7 +259,7 @@ describe('start OpenSearch Dashboards server', () => { ); const cookie = await driver.manage().getCookies(); - expect(cookie.length).toEqual(2); + expect(cookie.length).toEqual(3); await driver.manage().deleteAllCookies(); await driver.quit(); }); @@ -278,7 +278,7 @@ describe('start OpenSearch Dashboards server', () => { const windowHash = await driver.getCurrentUrl(); expect(windowHash).toEqual(urlWithHash); const cookie = await driver.manage().getCookies(); - expect(cookie.length).toEqual(2); + expect(cookie.length).toEqual(3); await driver.manage().deleteAllCookies(); await driver.quit(); });