From c5cdbbb57e55ec860fe0277ddf5668fd3726ed87 Mon Sep 17 00:00:00 2001 From: Jochen Kressin <126353411+jochen-kressin@users.noreply.github.com> Date: Thu, 13 Apr 2023 15:11:34 +0200 Subject: [PATCH] Split up a value into multiple cookie payloads (#1352) * PoC for splitting up a value into multiple cookie payloads Signed-off-by: Jochen Kressin * Cookie splitting for OpenId and SAML Signed-off-by: Jochen Kressin * Changes after review comments Signed-off-by: Jochen Kressin * WIP: First unit tests Signed-off-by: Jochen Kressin * More unit tests Signed-off-by: Jochen Kressin * Fix for multi auth, request argument was missing Signed-off-by: Jochen Kressin * Fixed linting errors Signed-off-by: Jochen Kressin * Added one additional cookie for the SAML integration tests Signed-off-by: Jochen Kressin --------- Signed-off-by: Jochen Kressin Co-authored-by: Stephen Crawford <65832608+scrawfor99@users.noreply.github.com> --- common/index.ts | 2 + server/auth/types/authentication_type.ts | 14 +- server/auth/types/multiple/multi_auth.ts | 14 +- server/auth/types/openid/openid_auth.test.ts | 120 +++++++++++++ server/auth/types/openid/openid_auth.ts | 96 +++++++++- server/auth/types/openid/routes.ts | 43 ++++- server/auth/types/saml/routes.ts | 45 ++++- server/auth/types/saml/saml_auth.test.ts | 118 +++++++++++++ server/auth/types/saml/saml_auth.ts | 94 +++++++++- 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 +- 15 files changed, 927 insertions(+), 30 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 d2c1120a81..430b74c0bd 100644 --- a/common/index.ts +++ b/common/index.ts @@ -50,6 +50,8 @@ export const PRIVATE_TENANT_RENDERING_TEXT = 'Private'; export const globalTenantName = 'global_tenant'; export const MAX_INTEGER = 2147483647; +export const MAX_LENGTH_OF_COOKIE_BYTES = 4000; +export const ESTIMATED_IRON_COOKIE_OVERHEAD = 1.5; export enum AuthType { BASIC = 'basicauth', diff --git a/server/auth/types/authentication_type.ts b/server/auth/types/authentication_type.ts index c26db20db4..46066cf229 100755 --- a/server/auth/types/authentication_type.ts +++ b/server/auth/types/authentication_type.ts @@ -138,7 +138,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(); @@ -160,7 +160,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 = await this.getAdditionalAuthHeader(request); Object.assign(authHeaders, additonalAuthHeader); @@ -269,12 +269,18 @@ export abstract class AuthenticationType implements IAuthenticationType { request: OpenSearchDashboardsRequest, authInfo: any ): SecuritySessionCookie; - public abstract isValidCookie(cookie: SecuritySessionCookie): Promise; + public abstract isValidCookie( + cookie: SecuritySessionCookie, + request: OpenSearchDashboardsRequest + ): Promise; protected abstract handleUnauthedRequest( request: OpenSearchDashboardsRequest, response: LifecycleResponseFactory, toolkit: AuthToolkit ): IOpenSearchDashboardsResponse | AuthResult; - public abstract buildAuthHeaderFromCookie(cookie: SecuritySessionCookie): any; + public abstract buildAuthHeaderFromCookie( + cookie: SecuritySessionCookie, + request: OpenSearchDashboardsRequest + ): any; public abstract init(): Promise; } diff --git a/server/auth/types/multiple/multi_auth.ts b/server/auth/types/multiple/multi_auth.ts index 26a3439ef0..8763eaa4f5 100644 --- a/server/auth/types/multiple/multi_auth.ts +++ b/server/auth/types/multiple/multi_auth.ts @@ -130,10 +130,13 @@ export class MultipleAuthentication extends AuthenticationType { return {}; } - async isValidCookie(cookie: SecuritySessionCookie): Promise { + async isValidCookie( + cookie: SecuritySessionCookie, + request: OpenSearchDashboardsRequest + ): Promise { const reqAuthType = cookie?.authType?.toLowerCase(); if (reqAuthType && this.authHandlers.has(reqAuthType)) { - return this.authHandlers.get(reqAuthType)!.isValidCookie(cookie); + return this.authHandlers.get(reqAuthType)!.isValidCookie(cookie, request); } else { return false; } @@ -168,11 +171,14 @@ export class MultipleAuthentication extends AuthenticationType { } } - buildAuthHeaderFromCookie(cookie: SecuritySessionCookie): any { + buildAuthHeaderFromCookie( + cookie: SecuritySessionCookie, + request: OpenSearchDashboardsRequest + ): any { const reqAuthType = cookie?.authType?.toLowerCase(); if (reqAuthType && this.authHandlers.has(reqAuthType)) { - return this.authHandlers.get(reqAuthType)!.buildAuthHeaderFromCookie(cookie); + return this.authHandlers.get(reqAuthType)!.buildAuthHeaderFromCookie(cookie, request); } else { return {}; } 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 0000000000..f998d844f3 --- /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 7f5d498df2..accabb7c13 100644 --- a/server/auth/types/openid/openid_auth.ts +++ b/server/auth/types/openid/openid_auth.ts @@ -29,6 +29,7 @@ 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'; @@ -37,6 +38,11 @@ import { callTokenEndpoint } from './helper'; import { composeNextUrlQueryParam } from '../../../utils/next_url'; import { getExpirationDate } from './helper'; import { AuthType, OPENID_AUTH_LOGIN } from '../../../../common'; +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, @@ -102,6 +110,7 @@ export class OpenIdAuthentication extends AuthenticationType { this.coreSetup, this.wreckClient ); + routes.setupRoutes(); } catch (error: any) { this.logger.error(error); // TODO: log more info @@ -135,6 +144,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 +184,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 +201,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 +237,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: getExpirationDate(refreshTokenResponse), // expiresIn is in second }; + + setExtraAuthStorage( + request, + `Bearer ${refreshTokenResponse.idToken}`, + this.getExtraAuthStorageOptions() + ); + return true; } else { return false; @@ -226,8 +283,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 0274749519..b598dd1a8c 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 '../../..'; @@ -44,6 +45,13 @@ import { LOGIN_PAGE_URI, } from '../../../../common'; +import { + clearSplitCookies, + ExtraAuthStorageOptions, + getExtraAuthStorageValue, + setExtraAuthStorage, +} from '../../../session/cookie_splitter'; + export class OpenIdAuthRoutes { private static readonly NONCE_LENGTH: number = 22; @@ -69,6 +77,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( { @@ -173,7 +190,7 @@ export class OpenIdAuthRoutes { const sessionStorage: SecuritySessionCookie = { username: user.username, credentials: { - authHeaderValue: `Bearer ${tokenResponse.idToken}`, + authHeaderValueExtra: true, expires_at: getExpirationDate(tokenResponse), }, authType: AuthType.OPEN_ID, @@ -184,6 +201,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: { @@ -208,15 +232,30 @@ 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 + const token = tokenFromExtraStorage.length + ? tokenFromExtraStorage.split(' ')[1] + : cookie?.credentials.authHeaderValue.split(' ')[1]; // get auth token const nextUrl = getBaseRedirectUrl(this.config, this.core, request); + const logoutQueryParams = { post_logout_redirect_uri: `${nextUrl}`, id_token_hint: token, }; + const endSessionUrl = composeLogoutUrl( this.config.openid?.logout_url, this.openIdAuthConfig.endSessionEndpoint, diff --git a/server/auth/types/saml/routes.ts b/server/auth/types/saml/routes.ts index 2279766125..87605d65eb 100644 --- a/server/auth/types/saml/routes.ts +++ b/server/auth/types/saml/routes.ts @@ -14,11 +14,7 @@ */ 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'; @@ -26,6 +22,12 @@ import { CoreSetup } from '../../../../../../src/core/server'; import { validateNextUrl } from '../../../utils/next_url'; import { AuthType, SAML_AUTH_LOGIN, SAML_AUTH_LOGOUT } from '../../../../common'; +import { + clearSplitCookies, + ExtraAuthStorageOptions, + setExtraAuthStorage, +} from '../../../session/cookie_splitter'; + export class SamlAuthRoutes { constructor( private readonly router: IRouter, @@ -36,6 +38,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( { @@ -141,15 +152,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: 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: { @@ -212,11 +232,18 @@ export class SamlAuthRoutes { const cookie: SecuritySessionCookie = { username: user.username, credentials: { - authHeaderValue: credentials.authorization, + authHeaderValueExtra: true, }, authType: 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: { @@ -352,6 +379,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 0000000000..355d8b28cd --- /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 4f6e8b4f9c..5c5c3e4263 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, @@ -35,6 +36,12 @@ import { SamlAuthRoutes } from './routes'; import { AuthenticationType } from '../authentication_type'; import { AuthType } from '../../../../common'; +import { + setExtraAuthStorage, + getExtraAuthStorageValue, + ExtraAuthStorageOptions, +} from '../../../session/cookie_splitter'; + export class SamlAuthentication extends AuthenticationType { public static readonly AUTH_HEADER_NAME = 'authorization'; @@ -69,6 +76,8 @@ export class SamlAuthentication extends AuthenticationType { }; public async init() { + this.createExtraStorage(); + const samlAuthRoutes = new SamlAuthRoutes( this.router, this.config, @@ -79,6 +88,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; } @@ -88,10 +128,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: AuthType.SAML, expiryTime: Date.now() + this.config.session.ttl, @@ -99,12 +149,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 === AuthType.SAML && cookie.username && cookie.expiryTime && - cookie.credentials?.authHeaderValue + (cookie.credentials?.authHeaderValue || this.getExtraAuthStorageValue(request, cookie)) ); } @@ -120,9 +173,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 c6cab52b6a..b4384315ab 100644 --- a/server/index.ts +++ b/server/index.ts @@ -180,8 +180,22 @@ export const configSchema = schema.object({ verify_hostnames: schema.boolean({ defaultValue: true }), refresh_tokens: schema.boolean({ defaultValue: true }), trust_dynamic_headers: schema.boolean({ defaultValue: false }), + 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 0000000000..8339874272 --- /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 0000000000..c6b563902d --- /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 0000000000..63b807c514 --- /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 0000000000..c052f0be59 --- /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 'node: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 f12143fbe3..86d2e34971 100644 --- a/test/jest_integration/saml_auth.test.ts +++ b/test/jest_integration/saml_auth.test.ts @@ -244,7 +244,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(); }); @@ -260,7 +260,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(); }); @@ -279,7 +279,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(); });