diff --git a/.github/workflows/cypress-test-oidc-e2e.yml b/.github/workflows/cypress-test-oidc-e2e.yml
index c673018b7..6ef90f4fd 100644
--- a/.github/workflows/cypress-test-oidc-e2e.yml
+++ b/.github/workflows/cypress-test-oidc-e2e.yml
@@ -48,7 +48,6 @@ jobs:
echo "Unpacking Keycloak"
tar -xzf keycloak-${{ env.KEYCLOAK_VERSION }}.tar.gz
cd keycloak-${{ env.KEYCLOAK_VERSION }}/bin
- chmod +x ./kc.sh
echo "Generating checksum for the downloaded kc.sh script..."
DOWNLOADED_CHECKSUM=$(sha256sum kc.sh | awk '{print $1}')
echo "Downloaded kc.sh checksum: $DOWNLOADED_CHECKSUM"
diff --git a/common/index.ts b/common/index.ts
index c688731d6..9b038a581 100644
--- a/common/index.ts
+++ b/common/index.ts
@@ -30,9 +30,10 @@ export const CUSTOM_ERROR_PAGE_URI = '/app/' + APP_ID_CUSTOMERROR;
export const API_AUTH_LOGIN = '/auth/login';
export const API_AUTH_LOGOUT = '/auth/logout';
export const OPENID_AUTH_LOGIN = '/auth/openid/login';
+export const OPENID_AUTH_LOGIN_WITH_FRAGMENT = '/auth/openid/captureUrlFragment';
export const SAML_AUTH_LOGIN = '/auth/saml/login';
-export const ANONYMOUS_AUTH_LOGIN = '/auth/anonymous';
export const SAML_AUTH_LOGIN_WITH_FRAGMENT = '/auth/saml/captureUrlFragment';
+export const ANONYMOUS_AUTH_LOGIN = '/auth/anonymous';
export const OPENID_AUTH_LOGOUT = '/auth/openid/logout';
export const SAML_AUTH_LOGOUT = '/auth/saml/logout';
diff --git a/public/apps/login/login-page.tsx b/public/apps/login/login-page.tsx
index 22421e3a7..70d894781 100644
--- a/public/apps/login/login-page.tsx
+++ b/public/apps/login/login-page.tsx
@@ -32,7 +32,7 @@ import { validateCurrentPassword } from '../../utils/login-utils';
import {
ANONYMOUS_AUTH_LOGIN,
AuthType,
- OPENID_AUTH_LOGIN,
+ OPENID_AUTH_LOGIN_WITH_FRAGMENT,
SAML_AUTH_LOGIN_WITH_FRAGMENT,
} from '../../../common';
@@ -228,7 +228,9 @@ export function LoginPage(props: LoginPageDeps) {
}
case AuthType.OPEN_ID: {
const oidcConfig = props.config.ui[AuthType.OPEN_ID].login;
- formBodyOp.push(renderLoginButton(AuthType.OPEN_ID, OPENID_AUTH_LOGIN, oidcConfig));
+ const nextUrl = extractNextUrlFromWindowLocation();
+ const oidcAuthLoginUrl = OPENID_AUTH_LOGIN_WITH_FRAGMENT + nextUrl;
+ formBodyOp.push(renderLoginButton(AuthType.OPEN_ID, oidcAuthLoginUrl, oidcConfig));
break;
}
case AuthType.SAML: {
diff --git a/public/apps/login/test/__snapshots__/login-page.test.tsx.snap b/public/apps/login/test/__snapshots__/login-page.test.tsx.snap
index c2dac62c1..b8a1e1182 100644
--- a/public/apps/login/test/__snapshots__/login-page.test.tsx.snap
+++ b/public/apps/login/test/__snapshots__/login-page.test.tsx.snap
@@ -121,7 +121,7 @@ exports[`Login page renders renders with config value for multiauth 1`] = `
aria-label="openid_login_button"
className="test-btn-style"
data-test-subj="submit"
- href="/app/opensearch-dashboards/auth/openid/login"
+ href="/app/opensearch-dashboards/auth/openid/captureUrlFragment?nextUrl=%2F"
iconType="http://localhost:5601/images/test.png"
size="s"
type="prime"
diff --git a/server/auth/types/openid/openid_auth.ts b/server/auth/types/openid/openid_auth.ts
index 747cb84eb..4d59ea632 100644
--- a/server/auth/types/openid/openid_auth.ts
+++ b/server/auth/types/openid/openid_auth.ts
@@ -25,13 +25,17 @@ import {
LifecycleResponseFactory,
AuthToolkit,
IOpenSearchDashboardsResponse,
+ AuthResult,
} from 'opensearch-dashboards/server';
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 {
+ SecuritySessionCookie,
+ clearOldVersionCookieValue,
+} from '../../../session/security_cookie';
import { OpenIdAuthRoutes } from './routes';
import { AuthenticationType } from '../authentication_type';
import { callTokenEndpoint } from './helper';
@@ -124,6 +128,22 @@ export class OpenIdAuthentication extends AuthenticationType {
}
}
+ private generateNextUrl(request: OpenSearchDashboardsRequest): string {
+ const path =
+ this.coreSetup.http.basePath.serverBasePath +
+ (request.url.pathname || '/app/opensearch-dashboards');
+ return escape(path);
+ }
+
+ private redirectOIDCCapture = (request: OpenSearchDashboardsRequest, toolkit: AuthToolkit) => {
+ const nextUrl = this.generateNextUrl(request);
+ const clearOldVersionCookie = clearOldVersionCookieValue(this.config);
+ return toolkit.redirected({
+ location: `${this.coreSetup.http.basePath.serverBasePath}/auth/openid/captureUrlFragment?nextUrl=${nextUrl}`,
+ 'set-cookie': clearOldVersionCookie,
+ });
+ };
+
private createWreckClient(): typeof wreck {
if (this.config.openid?.root_ca) {
this.wreckHttpsOption.ca = [fs.readFileSync(this.config.openid.root_ca)];
@@ -297,18 +317,9 @@ export class OpenIdAuthentication extends AuthenticationType {
request: OpenSearchDashboardsRequest,
response: LifecycleResponseFactory,
toolkit: AuthToolkit
- ): IOpenSearchDashboardsResponse {
+ ): IOpenSearchDashboardsResponse | AuthResult {
if (this.isPageRequest(request)) {
- // nextUrl is a key value pair
- const nextUrl = composeNextUrlQueryParam(
- request,
- this.coreSetup.http.basePath.serverBasePath
- );
- return response.redirected({
- headers: {
- location: `${this.coreSetup.http.basePath.serverBasePath}${OPENID_AUTH_LOGIN}?${nextUrl}`,
- },
- });
+ return this.redirectOIDCCapture(request, toolkit);
} else {
return response.unauthorized();
}
diff --git a/server/auth/types/openid/routes.ts b/server/auth/types/openid/routes.ts
index 46c0d6c88..a9b84e75c 100644
--- a/server/auth/types/openid/routes.ts
+++ b/server/auth/types/openid/routes.ts
@@ -100,6 +100,7 @@ export class OpenIdAuthRoutes {
validate: validateNextUrl,
})
),
+ redirectHash: schema.maybe(schema.boolean()),
state: schema.maybe(schema.string()),
refresh: schema.maybe(schema.string()),
},
@@ -135,6 +136,7 @@ export class OpenIdAuthRoutes {
oidc: {
state: nonce,
nextUrl: getNextUrl(this.config, this.core, request),
+ redirectHash: request.query.redirectHash === 'true',
},
authType: AuthType.OPEN_ID,
};
@@ -164,6 +166,7 @@ export class OpenIdAuthRoutes {
const nextUrl: string = cookie.oidc.nextUrl;
const clientId = this.config.openid?.client_id;
const clientSecret = this.config.openid?.client_secret;
+ const redirectHash: boolean = cookie.oidc?.redirectHash || false;
const query: any = {
grant_type: AUTH_GRANT_TYPE,
code: request.query.code,
@@ -211,11 +214,21 @@ export class OpenIdAuthRoutes {
);
this.sessionStorageFactory.asScoped(request).set(sessionStorage);
- return response.redirected({
- headers: {
- location: nextUrl,
- },
- });
+ if (redirectHash) {
+ return response.redirected({
+ headers: {
+ location: `${
+ this.core.http.basePath.serverBasePath
+ }/auth/openid/redirectUrlFragment?nextUrl=${escape(nextUrl)}`,
+ },
+ });
+ } else {
+ return response.redirected({
+ headers: {
+ location: nextUrl,
+ },
+ });
+ }
} catch (error: any) {
context.security_plugin.logger.error(`OpenId authentication failed: ${error}`);
if (error.toString().toLowerCase().includes('authentication exception')) {
@@ -271,5 +284,116 @@ export class OpenIdAuthRoutes {
});
}
);
+
+ // captureUrlFragment is the first route that will be invoked in the SP initiated login.
+ // This route will execute the captureUrlFragment.js script.
+ this.core.http.resources.register(
+ {
+ path: '/auth/openid/captureUrlFragment',
+ validate: {
+ query: schema.object({
+ nextUrl: schema.maybe(
+ schema.string({
+ validate: validateNextUrl,
+ })
+ ),
+ }),
+ },
+ options: {
+ authRequired: false,
+ },
+ },
+ async (context, request, response) => {
+ this.sessionStorageFactory.asScoped(request).clear();
+ const serverBasePath = this.core.http.basePath.serverBasePath;
+ return response.renderHtml({
+ body: `
+
+
OSD OIDC Capture
+
+
+ `,
+ });
+ }
+ );
+
+ // This script will store the URL Hash in browser's local storage.
+ this.core.http.resources.register(
+ {
+ path: '/auth/openid/captureUrlFragment.js',
+ validate: false,
+ options: {
+ authRequired: false,
+ },
+ },
+ async (context, request, response) => {
+ this.sessionStorageFactory.asScoped(request).clear();
+ return response.renderJs({
+ body: `let oidcHash=window.location.hash.toString();
+ let redirectHash = false;
+ if (oidcHash !== "") {
+ window.localStorage.removeItem('oidcHash');
+ window.localStorage.setItem('oidcHash', oidcHash);
+ redirectHash = true;
+ }
+ let params = new URLSearchParams(window.location.search);
+ let nextUrl = params.get("nextUrl");
+ finalUrl = "login?nextUrl=" + encodeURIComponent(nextUrl);
+ finalUrl += "&redirectHash=" + encodeURIComponent(redirectHash);
+ window.location.replace(finalUrl);
+ `,
+ });
+ }
+ );
+
+ // Once the User is authenticated the browser will be redirected to '/auth/openid/redirectUrlFragment'
+ // route, which will execute the redirectUrlFragment.js.
+ this.core.http.resources.register(
+ {
+ path: '/auth/openid/redirectUrlFragment',
+ validate: {
+ query: schema.object({
+ nextUrl: schema.any(),
+ }),
+ },
+ options: {
+ authRequired: true,
+ },
+ },
+ async (context, request, response) => {
+ const serverBasePath = this.core.http.basePath.serverBasePath;
+ return response.renderHtml({
+ body: `
+
+ OSD OpenID Success
+
+
+ `,
+ });
+ }
+ );
+
+ // This script will pop the Hash from local storage if it exists.
+ // And forward the browser to the next url.
+ this.core.http.resources.register(
+ {
+ path: '/auth/openid/redirectUrlFragment.js',
+ validate: false,
+ options: {
+ authRequired: true,
+ },
+ },
+ async (context, request, response) => {
+ return response.renderJs({
+ body: `let oidcHash=window.localStorage.getItem('oidcHash');
+ window.localStorage.removeItem('oidcHash');
+ let params = new URLSearchParams(window.location.search);
+ let nextUrl = params.get("nextUrl");
+ finalUrl = nextUrl + oidcHash;
+ window.location.replace(finalUrl);
+ `,
+ });
+ }
+ );
}
}
diff --git a/server/session/security_cookie.ts b/server/session/security_cookie.ts
index 50b880d9b..c0365e7e1 100644
--- a/server/session/security_cookie.ts
+++ b/server/session/security_cookie.ts
@@ -30,7 +30,11 @@ export interface SecuritySessionCookie {
tenant?: any;
// for oidc auth workflow
- oidc?: any;
+ oidc?: {
+ state?: string;
+ nextUrl?: string;
+ redirectHash?: boolean;
+ };
// for Saml auth workflow
saml?: {
diff --git a/test/cypress/e2e/oidc/oidc_auth_test.spec.js b/test/cypress/e2e/oidc/oidc_auth_test.spec.js
index b4c5c80d2..08a7e8ae1 100644
--- a/test/cypress/e2e/oidc/oidc_auth_test.spec.js
+++ b/test/cypress/e2e/oidc/oidc_auth_test.spec.js
@@ -18,22 +18,22 @@
* SPDX-License-Identifier: Apache-2.0
*/
-const login = 'admin';
-const password = 'admin';
-
describe('Log in via OIDC', () => {
afterEach(() => {
- cy.origin('http://localhost:5601', () => {
- cy.clearCookies();
- cy.clearLocalStorage();
- });
+ cy.clearCookies();
+ cy.clearLocalStorage();
});
const kcLogin = () => {
- cy.get('#kc-page-title').should('be.visible');
- cy.get('input[id=username]').should('be.visible').type(login);
- cy.get('input[id=password]').should('be.visible').type(password);
- cy.get('#kc-login').click();
+ cy.origin('http://127.0.0.1:8080', () => {
+ const login = 'admin';
+ const password = 'admin';
+
+ cy.get('#kc-page-title').should('be.visible');
+ cy.get('input[id=username]').should('be.visible').type(login);
+ cy.get('input[id=password]').should('be.visible').type(password);
+ cy.get('#kc-login').click();
+ });
};
it('Login to app/opensearch_dashboards_overview#/ when OIDC is enabled', () => {
@@ -43,14 +43,12 @@ describe('Log in via OIDC', () => {
kcLogin();
- cy.origin('http://localhost:5601', () => {
- localStorage.setItem('opendistro::security::tenant::saved', '""');
- localStorage.setItem('home:newThemeModal:show', 'false');
+ localStorage.setItem('opendistro::security::tenant::saved', '""');
+ localStorage.setItem('home:newThemeModal:show', 'false');
- cy.get('#osdOverviewPageHeader__title').should('be.visible');
+ cy.get('#osdOverviewPageHeader__title').should('be.visible');
- cy.getCookie('security_authentication').should('exist');
- });
+ cy.getCookie('security_authentication').should('exist');
});
it('Login to app/dev_tools#/console when OIDC is enabled', () => {
@@ -60,33 +58,37 @@ describe('Log in via OIDC', () => {
kcLogin();
- cy.origin('http://localhost:5601', () => {
- localStorage.setItem('opendistro::security::tenant::saved', '""');
- localStorage.setItem('home:newThemeModal:show', 'false');
+ localStorage.setItem('opendistro::security::tenant::saved', '""');
+ localStorage.setItem('home:newThemeModal:show', 'false');
- cy.visit('http://localhost:5601/app/dev_tools#/console');
+ cy.visit('http://localhost:5601/app/dev_tools#/console');
- cy.get('a').contains('Dev Tools').should('be.visible');
+ cy.get('a').contains('Dev Tools').should('be.visible');
- cy.getCookie('security_authentication').should('exist');
- });
+ cy.getCookie('security_authentication').should('exist');
});
it('Login to Dashboard with Hash', () => {
- cy.visit(
- `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`;
+
+ cy.visit(urlWithHash, {
+ failOnStatusCode: false,
+ });
kcLogin();
+ cy.getCookie('security_authentication').should('exist');
+ cy.getCookie('security_authentication_oidc1').should('exist');
- cy.origin('http://localhost:5601', () => {
- localStorage.setItem('opendistro::security::tenant::saved', '""');
- localStorage.setItem('home:newThemeModal:show', 'false');
+ cy.url().then((url) => {
+ cy.visit(url, {
+ failOnStatusCode: false,
+ });
+ });
- cy.get('.euiHeader.euiHeader--default.euiHeader--fixed.primaryHeader').should('be.visible');
+ localStorage.setItem('opendistro::security::tenant::saved', '""');
+ localStorage.setItem('home:newThemeModal:show', 'false');
- cy.getCookie('security_authentication').should('exist');
- });
+ cy.get('h1.euiTitle--large').contains('Get started');
});
it('Tenancy persisted after logout in OIDC', () => {
@@ -96,30 +98,32 @@ describe('Log in via OIDC', () => {
kcLogin();
- cy.origin('http://localhost:5601', () => {
- localStorage.setItem('home:newThemeModal:show', 'false');
+ cy.url().then((url) => {
+ cy.visit(url, {
+ failOnStatusCode: false,
+ });
+ });
- cy.get('#private').should('be.enabled');
- cy.get('#private').click({ force: true });
+ localStorage.setItem('home:newThemeModal:show', 'false');
- cy.get('button[data-test-subj="confirm"]').click();
+ cy.get('#private').should('be.enabled');
+ cy.get('#private').click({ force: true });
- cy.get('#osdOverviewPageHeader__title').should('be.visible');
+ cy.get('button[data-test-subj="confirm"]').click();
- cy.get('button[id="user-icon-btn"]').click();
+ cy.get('#osdOverviewPageHeader__title').should('be.visible');
- cy.get('button[data-test-subj^="log-out-"]').click();
- });
+ cy.get('button[id="user-icon-btn"]').click();
+
+ cy.get('button[data-test-subj^="log-out-"]').click();
kcLogin();
- cy.origin('http://localhost:5601', () => {
- cy.get('#user-icon-btn').should('be.visible');
- cy.get('#user-icon-btn').click();
+ cy.get('#user-icon-btn').should('be.visible');
+ cy.get('#user-icon-btn').click();
- cy.get('#osdOverviewPageHeader__title').should('be.visible');
+ cy.get('#osdOverviewPageHeader__title').should('be.visible');
- cy.get('#tenantName').should('have.text', 'Private');
- });
+ cy.get('#tenantName').should('have.text', 'Private');
});
});