Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Backport 1.x] Split up a value into multiple cookie payloads #1702

Closed
Closed
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
390fd60
[Backport 1.3] Switch to new tenant after loading a copied long URL (…
DarshitChanpura Jul 19, 2023
cac8e3f
[Backport 1.3] Add the tenant into the short URL once the short URL i…
DarshitChanpura Jul 20, 2023
f44b2b9
Increment version to 1.3.12.0 (#1536)
opensearch-trigger-bot[bot] Aug 3, 2023
c3edcf4
Add release notes for 1.3.12.0 (#1543) (#1544)
opensearch-trigger-bot[bot] Aug 5, 2023
87dd14e
Fix a bad import path (#1498) (#1547)
opensearch-trigger-bot[bot] Aug 7, 2023
fba07b8
Increment version to 1.3.13.0 (#1556)
opensearch-trigger-bot[bot] Sep 12, 2023
01931ad
Backport cookie compression
jochen-kressin Oct 25, 2023
21e7a42
The cookie splitter should be able to use cookie values that have bee…
jochen-kressin Sep 11, 2023
1077d7d
[1.3] Increment version to 1.3.14.0 (#1659)
cwperks Nov 28, 2023
e2b1424
Check out latest OSD and re-run yarn install to address CVEs (#1669)
derek-ho Nov 28, 2023
f861f46
Bump debug and browserify-sign dependencies (#1674)
derek-ho Nov 29, 2023
f6f7a89
adding config as per 1x branch and craig p instructions
leanneeliatra Dec 6, 2023
9c3a8f6
Merge branch '1.x' into backport-cookie-splitter
DarshitChanpura Dec 7, 2023
307c7a0
[1.3] Add 1.3.14 release notes (#1690)
derek-ho Dec 8, 2023
9e629db
Stabilize SAML integ test on 1.3
cwperks Dec 11, 2023
09fac24
Merge branch 'stabilize-1.3' into backport-cookie-splitter
cwperks Dec 11, 2023
a31755d
WIP on init
cwperks Dec 18, 2023
763831b
Run yarn lint
cwperks Dec 18, 2023
efdfc18
Merge branch '1.3' into backport-cookie-splitter
cwperks Dec 18, 2023
b0fd19c
Checkout 1.3.14 tag
cwperks Dec 18, 2023
4e67bfc
Fix auth_handler_factory.test.ts
cwperks Dec 18, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/install-dashboards/action.yml
Original file line number Diff line number Diff line change
@@ -48,7 +48,7 @@ runs:
- id: branch-switch-if-possible
continue-on-error: true # Defaults onto main if the branch switch doesn't work
if: ${{ steps.osd-version.outputs.osd-version }}
run: git checkout ${{ steps.osd-version.outputs.osd-version }} || git checkout ${{ steps.osd-version.outputs.osd-x-version }}x
run: git checkout 1.3.14 || git checkout ${{ steps.osd-version.outputs.osd-version }} || git checkout ${{ steps.osd-version.outputs.osd-x-version }}x
working-directory: ./OpenSearch-Dashboards
shell: bash

2 changes: 2 additions & 0 deletions common/index.ts
Original file line number Diff line number Diff line change
@@ -37,6 +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',
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## 2023-12-08 Version 1.3.14.0

Compatible with OpenSearch-Dashboards 1.3.14

### Maintenance

* Update `yarn.lock` file ([#1669](https://github.com/opensearch-project/security-dashboards-plugin/pull/1669))
* Bump `debug` to `4.3.4` and `browserify-sign` to `4.2.2` to address CVEs ([#1674](https://github.com/opensearch-project/security-dashboards-plugin/pull/1674))
40 changes: 24 additions & 16 deletions server/auth/auth_handler_factory.test.ts
Original file line number Diff line number Diff line change
@@ -30,30 +30,35 @@ jest.mock('./types', () => {
return {
authHandler: () => {},
type: 'basicauth',
init: () => {},
};
}),
JwtAuthentication: jest.fn().mockImplementation(() => {
return {
authHandler: () => {},
type: 'jwt',
init: () => {},
};
}),
OpenIdAuthentication: jest.fn().mockImplementation(() => {
return {
authHandler: () => {},
type: 'openid',
init: () => {},
};
}),
ProxyAuthentication: jest.fn().mockImplementation(() => {
return {
authHandler: () => {},
type: 'proxy',
init: () => {},
};
}),
SamlAuthentication: jest.fn().mockImplementation(() => {
return {
authHandler: () => {},
type: 'saml',
init: () => {},
};
}),
};
@@ -69,8 +74,8 @@ describe('test authentication factory', () => {

beforeEach(() => {});

test('get basic auth', () => {
const auth = getAuthenticationHandler(
test('get basic auth', async () => {
const auth = await getAuthenticationHandler(
'basicauth',
router,
config,
@@ -82,8 +87,8 @@ describe('test authentication factory', () => {
expect(auth.type).toEqual('basicauth');
});

test('get basic auth with empty auth type', () => {
const auth = getAuthenticationHandler(
test('get basic auth with empty auth type', async () => {
const auth = await getAuthenticationHandler(
'',
router,
config,
@@ -95,8 +100,8 @@ describe('test authentication factory', () => {
expect(auth.type).toEqual('basicauth');
});

test('get jwt auth', () => {
const auth = getAuthenticationHandler(
test('get jwt auth', async () => {
const auth = await getAuthenticationHandler(
'jwt',
router,
config,
@@ -108,8 +113,8 @@ describe('test authentication factory', () => {
expect(auth.type).toEqual('jwt');
});

test('get openid auth', () => {
const auth = getAuthenticationHandler(
test('get openid auth', async () => {
const auth = await getAuthenticationHandler(
'openid',
router,
config,
@@ -121,8 +126,8 @@ describe('test authentication factory', () => {
expect(auth.type).toEqual('openid');
});

test('get proxy auth', () => {
const auth = getAuthenticationHandler(
test('get proxy auth', async () => {
const auth = await getAuthenticationHandler(
'proxy',
router,
config,
@@ -134,8 +139,8 @@ describe('test authentication factory', () => {
expect(auth.type).toEqual('proxy');
});

test('get saml auth', () => {
const auth = getAuthenticationHandler(
test('get saml auth', async () => {
const auth = await getAuthenticationHandler(
'saml',
router,
config,
@@ -147,9 +152,9 @@ describe('test authentication factory', () => {
expect(auth.type).toEqual('saml');
});

test('throws error for invalid auth type', () => {
expect(() => {
getAuthenticationHandler(
test('throws error for invalid auth type', async () => {
try {
await getAuthenticationHandler(
'invalid',
router,
config,
@@ -158,6 +163,9 @@ describe('test authentication factory', () => {
sessionStorageFactory,
logger
);
}).toThrow('Unsupported authentication type: invalid');
} catch (e) {
const targetError = 'Error: Unsupported authentication type: invalid';
expect(e.toString()).toEqual(targetError);
}
});
});
14 changes: 8 additions & 6 deletions server/auth/auth_handler_factory.ts
Original file line number Diff line number Diff line change
@@ -32,27 +32,29 @@ import { SecuritySessionCookie } from '../session/security_cookie';
import { IAuthenticationType, IAuthHandlerConstructor } from './types/authentication_type';
import { SecurityPluginConfigType } from '..';

function createAuthentication(
async function createAuthentication(
ctor: IAuthHandlerConstructor,
config: SecurityPluginConfigType,
sessionStorageFactory: SessionStorageFactory<SecuritySessionCookie>,
router: IRouter,
esClient: ILegacyClusterClient,
coreSetup: CoreSetup,
logger: Logger
): IAuthenticationType {
return new ctor(config, sessionStorageFactory, router, esClient, coreSetup, logger);
): Promise<IAuthenticationType> {
const authHandler = new ctor(config, sessionStorageFactory, router, esClient, coreSetup, logger);
await authHandler.init();
return authHandler;
}

export function getAuthenticationHandler(
export async function getAuthenticationHandler(
authType: string,
router: IRouter,
config: SecurityPluginConfigType,
core: CoreSetup,
esClient: ILegacyClusterClient,
securitySessionStorageFactory: SessionStorageFactory<SecuritySessionCookie>,
logger: Logger
): IAuthenticationType {
): Promise<IAuthenticationType> {
let authHandlerType: IAuthHandlerConstructor;
switch (authType) {
case '':
@@ -74,7 +76,7 @@ export function getAuthenticationHandler(
default:
throw new Error(`Unsupported authentication type: ${authType}`);
}
const auth: IAuthenticationType = createAuthentication(
const auth: IAuthenticationType = await createAuthentication(
authHandlerType,
config,
securitySessionStorageFactory,
19 changes: 15 additions & 4 deletions server/auth/types/authentication_type.ts
Original file line number Diff line number Diff line change
@@ -39,6 +39,7 @@ import { UnauthenticatedError } from '../../errors';
export interface IAuthenticationType {
type: string;
authHandler: AuthenticationHandler;
init: () => Promise<void>;
}

export type IAuthHandlerConstructor = new (
@@ -118,7 +119,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 +141,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 +237,21 @@ export abstract class AuthenticationType implements IAuthenticationType {
request: OpenSearchDashboardsRequest,
authInfo: any
): SecuritySessionCookie;
protected abstract async isValidCookie(cookie: SecuritySessionCookie): Promise<boolean>;

public abstract isValidCookie(
cookie: SecuritySessionCookie,
request: OpenSearchDashboardsRequest
): Promise<boolean>;

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<void>;
}
4 changes: 1 addition & 3 deletions server/auth/types/basic/basic_auth.ts
Original file line number Diff line number Diff line change
@@ -44,11 +44,9 @@ export class BasicAuthentication extends AuthenticationType {
logger: Logger
) {
super(config, sessionStorageFactory, router, esClient, coreSetup, logger);

this.init();
}

private async init() {
public async init() {
const routes = new BasicAuthRoutes(
this.router,
this.config,
4 changes: 1 addition & 3 deletions server/auth/types/jwt/jwt_auth.ts
Original file line number Diff line number Diff line change
@@ -45,11 +45,9 @@ export class JwtAuthentication extends AuthenticationType {
) {
super(config, sessionStorageFactory, router, esClient, coreSetup, logger);
this.authHeaderName = this.config.jwt?.header.toLowerCase() || 'authorization';

this.init();
}

private async init() {
public async init() {
const routes = new JwtAuthRoutes(this.router, this.sessionStorageFactory);
routes.setupRoutes();
}
120 changes: 120 additions & 0 deletions server/auth/types/openid/openid_auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<SecuritySessionCookie>;
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);
});
});
99 changes: 91 additions & 8 deletions server/auth/types/openid/openid_auth.ts
Original file line number Diff line number Diff line change
@@ -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;
@@ -80,11 +86,9 @@ export class OpenIdAuthentication extends AuthenticationType {
scope = `openid ${scope}`;
}
this.openIdAuthConfig.scope = scope;

this.init();
}

private async init() {
public async init() {
try {
const response = await this.wreckClient.get(this.openIdConnectUrl);
const payload = JSON.parse(response.payload as string);
@@ -93,6 +97,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 +141,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,27 +181,37 @@ 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,
};
}

// TODO: Add token expiration check here
async isValidCookie(cookie: SecuritySessionCookie): Promise<boolean> {
async isValidCookie(
cookie: SecuritySessionCookie,
request: OpenSearchDashboardsRequest
): Promise<boolean> {
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 +234,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 +280,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;
44 changes: 41 additions & 3 deletions server/auth/types/openid/routes.ts
Original file line number Diff line number Diff line change
@@ -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 <auth_token>"
const token = cookie?.credentials.authHeaderValue.split(' ')[1]; // get auth token
// tokenFromExtraStorage is the bearer header, e.g. "Bearer <auth_token>"
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,
45 changes: 38 additions & 7 deletions server/auth/types/saml/routes.ts
Original file line number Diff line number Diff line change
@@ -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 =
118 changes: 118 additions & 0 deletions server/auth/types/saml/saml_auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<SecuritySessionCookie>;
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);
});
});
98 changes: 91 additions & 7 deletions server/auth/types/saml/saml_auth.ts
Original file line number Diff line number Diff line change
@@ -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,7 +55,6 @@ export class SamlAuthentication extends AuthenticationType {
logger: Logger
) {
super(config, sessionStorageFactory, router, esClient, coreSetup, logger);
this.setupRoutes();
}

private generateNextUrl(request: OpenSearchDashboardsRequest): string {
@@ -67,7 +73,10 @@ export class SamlAuthentication extends AuthenticationType {
});
};

private setupRoutes(): void {
public async init() {
console.log('SAML Auth init');
this.createExtraStorage();

const samlAuthRoutes = new SamlAuthRoutes(
this.router,
this.config,
@@ -78,6 +87,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,23 +127,36 @@ 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,
};
}

// Can be improved to check if the token is expiring.
async isValidCookie(cookie: SecuritySessionCookie): Promise<boolean> {
async isValidCookie(
cookie: SecuritySessionCookie,
request: OpenSearchDashboardsRequest
): Promise<boolean> {
return (
cookie.authType === this.type &&
cookie.username &&
cookie.expiryTime &&
cookie.credentials?.authHeaderValue
(cookie.credentials?.authHeaderValue || this.getExtraAuthStorageValue(request, cookie))
);
}

@@ -119,9 +172,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;
}
}
14 changes: 14 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion server/plugin.ts
Original file line number Diff line number Diff line change
@@ -108,8 +108,9 @@ export class SecurityPlugin implements Plugin<SecurityPluginSetup, SecurityPlugi
};
});

console.log('getAuthenticationHandler');
// setup auth
const auth: IAuthenticationType = getAuthenticationHandler(
const auth: IAuthenticationType = await getAuthenticationHandler(
config.auth.type,
router,
config,
239 changes: 239 additions & 0 deletions server/session/cookie_splitter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
/*
* 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 { merge } from 'lodash';
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<HapiRequest['cookieAuth']> & {
h: Partial<HapiResponseObject>;
};

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');
});

test('should check for cookie values updated in the same request', async () => {
const cookiePrefix = 'testcookie';
const additionalCookies = 5;

const mockRequest = httpServerMock.createRawRequest();

const extendedMockRequest = merge(mockRequest, {
_states: {
[cookiePrefix + '1']: {
name: cookiePrefix + '1',
value: 'abc',
},
[cookiePrefix + '2']: {
name: cookiePrefix + '2',
value: 'def',
},
[cookiePrefix + '3']: {
name: cookiePrefix + '3',
value: 'ghi',
},
},
}) as HapiRequest;

const osRequest = OpenSearchDashboardsRequest.from(extendedMockRequest);
const unsplitValue = unsplitCookiesIntoValue(osRequest, cookiePrefix, additionalCookies);

expect(unsplitValue).toEqual('abcdefghi');
});

test('should not mix cookie values updated in the same request with previous cookie values', async () => {
const cookiePrefix = 'testcookie';
const additionalCookies = 5;

const mockRequest = httpServerMock.createRawRequest({
state: {
[cookiePrefix + '1']: 'abc',
[cookiePrefix + '2']: 'def',
[cookiePrefix + '3']: 'ghi',
},
});

const extendedMockRequest = merge(mockRequest, {
_states: {
[cookiePrefix + '1']: {
name: cookiePrefix + '1',
value: 'jkl',
},
[cookiePrefix + '2']: {
name: cookiePrefix + '2',
value: 'mno',
},
[cookiePrefix + '3']: {
name: cookiePrefix + '3',
value: 'pqr',
},
},
}) as HapiRequest;

const osRequest = OpenSearchDashboardsRequest.from(extendedMockRequest);
const unsplitValue = unsplitCookiesIntoValue(osRequest, cookiePrefix, additionalCookies);

expect(unsplitValue).toEqual('jklmnopqr');
});
});
181 changes: 181 additions & 0 deletions server/session/cookie_splitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/*
* 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 };

interface HapiStates {
[cookieName: string]: {
name: string;
value: string;
};
}

export type HapiRequestWithStates = HapiRequest & { _states: HapiStates };

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: HapiRequestWithStates = ensureRawRequest(request) as HapiRequestWithStates;
let fullCookieValue = '';

// We don't want to mix and match between _states and .state.
// If we find the first additional cookie in _states, we
// use _states for all subsequent additional cookies
const requestHasNewerCookieState = rawRequest._states && rawRequest._states[cookiePrefix + 1];

for (let i = 1; i <= additionalCookies; i++) {
const cookieName = cookiePrefix + i;
if (
requestHasNewerCookieState &&
rawRequest._states[cookieName] &&
rawRequest._states[cookieName].value
) {
fullCookieValue = fullCookieValue + rawRequest._states[cookieName].value;
} else if (!requestHasNewerCookieState && 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);
}
}
}
28 changes: 28 additions & 0 deletions server/utils/compression.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
28 changes: 28 additions & 0 deletions server/utils/compression.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 6 additions & 8 deletions test/jest_integration/saml_auth.test.ts
Original file line number Diff line number Diff line change
@@ -33,7 +33,6 @@ describe('start OpenSearch Dashboards server', () => {
// XPath Constants
const userIconBtnXPath = '//button[@id="user-icon-btn"]';
const signInBtnXPath = '//*[@id="btn-sign-in"]';
const skipWelcomeBtnXPath = '//button[@data-test-subj="skipWelcomeScreen"]';
const tenantNameLabelXPath = '//*[@id="tenantName"]';
const pageTitleXPath = '//*[@id="osdOverviewPageHeader__title"]';
// Browser Settings
@@ -46,6 +45,7 @@ describe('start OpenSearch Dashboards server', () => {
plugins: {
scanDirs: [resolve(__dirname, '../..')],
},
home: { disableWelcomeScreen: true },
server: {
host: 'localhost',
port: 5601,
@@ -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,13 +259,13 @@ 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();
});

it('Login to Dashboard with Hash', async () => {
const urlWithHash = `http://localhost:5601/app/dashboards#/view/7adfa750-4c81-11e8-b3d7-01146121b73d?_g=(filters:!(),refreshInterval:(pause:!f,value:900000),time:(from:now-24h,to:now))&_a=(description:'Analyze%20mock%20flight%20data%20for%20OpenSearch-Air,%20Logstash%20Airways,%20OpenSearch%20Dashboards%20Airlines%20and%20BeatsWest',filters:!(),fullScreenMode:!f,options:(hidePanelTitles:!f,useMargins:!t),query:(language:kuery,query:''),timeRestore:!t,title:'%5BFlights%5D%20Global%20Flight%20Dashboard',viewMode:view)`;
const urlWithHash = `http://localhost:5601/app/security-dashboards-plugin#/getstarted`;
const driver = getDriver(browser, options).build();
await driver.manage().deleteAllCookies();
await driver.get(urlWithHash);
@@ -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();
});
@@ -316,9 +316,7 @@ describe('start OpenSearch Dashboards server', () => {

await driver.findElement(By.xpath(signInBtnXPath)).click();

await driver.wait(until.elementsLocated(By.xpath(skipWelcomeBtnXPath)), 10000);

await driver.findElement(By.xpath(skipWelcomeBtnXPath)).click();
await driver.wait(until.elementsLocated(By.xpath(userIconBtnXPath)), 10000);

await driver.findElement(By.xpath(userIconBtnXPath)).click();