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