Skip to content

Commit

Permalink
OIDC integration (#2905)
Browse files Browse the repository at this point in the history
* Configured local keycloak with realm provisioning

* Installation and first configuration of pow-assent

* Keycloak provisioned realm confidential

* frontend wip

* oidc callback as conf parameter

* locked users could not login through IDP

* Delete user deletes user identities

* User identity changeset to map oidc standard to user schema

* Add session controller callback test

* Disable and update user forms if OIDC is enabled (#2859)

* Send oicd is enabled value to frontend

* Create users function to check sso is enabled

* Add sso differences in the users views

* Add sso differences in profile views

* Disable password update requested notification

* Correct typo and remove test debug

* Move sso auth logic to auth folder

* Format mix.exs file

* Disable oidc by default in dev

* Disable actions when external idp is enabled (#2863)

* Add external idp guard plug

* Prevent write profile operations when external idp is configured

* Prevent traditional login operation when external idp is configured

* Disable create user endpoint when external idp is configured

* fix controller tests env

* mix credo

* Addressing review feedback

* dialyzer fix

* View field for idp users (#2865)

* Listing and getting users returns also the user identities

* User controller returns idp_user field in response

* profile controller returning idp_user field

* Create user adds empty user_identities

* User identities context assigns global abilities to a oidc user (#2868)

* Single sign on login view (#2866)

* Load oidc url in frontend

* Create SSO login view

* Improve frontend OIDC callback (#2871)

* Send oidc callback url to frontend

* Get oidc callback url in frontend functions

* Refactor and test oidc enrollment saga

* Refactor and test oidc callback component

* Allow only abilities update with OIDC (#2879)

* User update endpoint only updates abilities when oidc enabled

* User update skips password changeset if the user comes from idp

* Allow enable field in users update when oidc is enabled

* mix credo

* Add option to load oidc variables in runtime (#2874)

* Existing user is recovered when login with oidc (#2880)

* Existing user is recovered when login with oidc

* Addressing review feedbacks

* Fix fullname entry in trento console for a new created user by idp provider (#2883)

* fix typo

* Use name to display the full name in trento not only the first name

* fix oidc callback url parameter in oidc runtime (#2906)

* OIDC integration E2E  tests (#2908)

* Add admin user to keycloak trento realm

* Add OIDC integration e2e tests

* Run integration test conditionally

* Add github action to run integration test

---------

Co-authored-by: Carmine Di Monaco <[email protected]>
Co-authored-by: Carmine Di Monaco <[email protected]>
Co-authored-by: Eugen Maksymenko <[email protected]>
  • Loading branch information
4 people authored Aug 20, 2024
1 parent 5e143d4 commit b6be998
Show file tree
Hide file tree
Showing 66 changed files with 2,249 additions and 282 deletions.
103 changes: 103 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ env:
NODE_VERSION: "20"
MANTAINERS: '["cdimonaco", "dottorblaster", "janvhs", "rtorrero", "nelsonkopliku", "arbulu89","jagabomb","emaksy","jamie-suse"]'
RG_TEST_LABEL: regression
INTEGRATION_TEST_LABEL: integration

jobs:
elixir-deps:
Expand Down Expand Up @@ -562,6 +563,108 @@ jobs:
name: regression-${{ matrix.test }}-e2e-screenshots
path: test/e2e/cypress/screenshots/

check-integration-tests-label:
name: Check if the integration test criteria are met, store in the job output
runs-on: ubuntu-22.04
outputs:
run_integration_test: ${{ steps.check.outputs.run_integration_test }}
steps:
- id: check
run: echo "run_integration_test=${{ contains(fromJson(env.MANTAINERS), github.event.sender.login) && contains(github.event.pull_request.labels.*.name, env.INTEGRATION_TEST_LABEL) }}" >> "$GITHUB_OUTPUT"

integration-test-e2e:
name: Integration tests
needs: [check-integration-tests-label, elixir-deps, npm-deps, npm-e2e-deps]
runs-on: ubuntu-22.04
if: needs.check-integration-tests-label.outputs.run_integration_test == 'true'
strategy:
matrix:
include:
- test: oidc
cypress_spec: |
cypress/e2e/oidc_integration.cy.js
config_file_content: |
import Config
config :trento, :oidc, enabled: true
env:
MIX_ENV: dev
CYPRESS_OIDC_INTEGRATION_TESTS: true
env: ${{ matrix.env }}
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup
id: setup-elixir
uses: erlef/setup-beam@v1
with:
version-file: .tool-versions
version-type: strict
env:
ImageOS: ubuntu20
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Retrieve Cached Dependencies
uses: actions/cache@v4
id: mix-cache
with:
path: |
deps
_build/dev
priv/plts
key: ${{ runner.os }}-${{ steps.setup-elixir.outputs.otp-version }}-${{ steps.setup-elixir.outputs.elixir-version }}-${{ hashFiles('mix.lock') }}
- name: Retrieve NPM Cached Dependencies
uses: actions/cache@v4
id: npm-cache
with:
path: |
assets/node_modules
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-${{ hashFiles('assets/package-lock.json') }}
- name: Retrieve E2E NPM Cached Dependencies
uses: actions/cache@v4
id: npm-e2e-cache
with:
path: |
test/e2e/node_modules
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-${{ hashFiles('test/e2e/package-lock.json') }}
- name: "Docker compose dependencies"
uses: isbang/[email protected]
with:
compose-file: "./docker-compose.yaml"
compose-flags: "--profile idp"
down-flags: "--volumes"
- name: Create dev.local.exs file
run: echo "${{ matrix.config_file_content }}" > config/dev.local.exs
- name: Mix setup
run: mix setup
- name: Run trento detached
run: mix phx.server &
- name: Cypress run
uses: cypress-io/github-action@v6
env:
cypress_video: false
cypress_db_host: postgres
cypress_db_port: 5432
with:
working-directory: test/e2e
spec: ${{ matrix.cypress_spec }}
wait-on-timeout: 30
config: baseUrl=http://localhost:4000
- name: Upload cypress test screenshots
uses: actions/upload-artifact@v4
if: failure()
with:
name: integration-${{ matrix.test }}-e2e-screenshots
path: test/e2e/cypress/screenshots/

target-branch-deps:
name: Rebuild target branch dependencies
runs-on: ubuntu-20.04
Expand Down
4 changes: 4 additions & 0 deletions assets/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ module.exports = {
config: {
checksServiceBaseUrl: '',
suseManagerEnabled: true,
adminUsername: 'admin',
oidcEnabled: false,
oidcLoginUrl: 'http://localhost:4000/auth/oidc_callback',
oidcCallbackUrl: '/auth/oidc_callback',
aTestVariable: 123,
},
},
Expand Down
23 changes: 23 additions & 0 deletions assets/js/lib/auth/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getFromConfig } from '@lib/config';

const OIDC_ENABLED = getFromConfig('oidcEnabled') || false;
const OIDC_LOGIN_URL = getFromConfig('oidcLoginUrl') || '';
const OIDC_CALLBACK_URL = getFromConfig('oidcCallbackUrl') || '';

export const isSingleSignOnEnabled = () => OIDC_ENABLED;

export const getSingleSignOnLoginUrl = () => {
if (OIDC_ENABLED) {
return OIDC_LOGIN_URL;
}

return '';
};

export const getSingleSignOnCallbackUrl = () => {
if (OIDC_ENABLED) {
return OIDC_CALLBACK_URL;
}

return '';
};
35 changes: 35 additions & 0 deletions assets/js/lib/auth/config.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { isSingleSignOnEnabled } from './config';

describe('auth config', () => {
beforeEach(() => {
jest.resetModules();
});

it('should check if single sign on is enabled', () => {
expect(isSingleSignOnEnabled()).toBeFalsy();

global.config.oidcEnabled = true;

return import('./config').then((config) => {
expect(config.isSingleSignOnEnabled()).toBeTruthy();
});
});

it('should get OIDC login url if OIDC is enabled', async () => {
global.config.oidcEnabled = true;

return import('./config').then((config) => {
expect(config.getSingleSignOnLoginUrl()).toBe(
'http://localhost:4000/auth/oidc_callback'
);
});
});

it('should get OIDC callback url if OIDC is enabled', async () => {
global.config.oidcEnabled = true;

return import('./config').then((config) => {
expect(config.getSingleSignOnCallbackUrl()).toBe('/auth/oidc_callback');
});
});
});
10 changes: 10 additions & 0 deletions assets/js/lib/auth/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ export const login = (credentials) =>
return response;
});

export const oidcEnrollment = (credentials) =>
authClient
.post('/api/session/oidc_local/callback', credentials)
.then((response) => {
if (response.status !== 200) {
throw Error('unauthorized', { cause: response.status });
}
return response;
});

export const refreshAccessToken = (refreshToken) =>
authClient
.post('/api/session/refresh', { refresh_token: refreshToken })
Expand Down
4 changes: 2 additions & 2 deletions assets/js/lib/model/users.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { isAdmin } from './users';

describe('users', () => {
it('should check if a user is admin', () => {
const admin = adminUser.build();
const admin = adminUser.build({ username: 'admin' });
expect(isAdmin(admin)).toBe(true);

const user = userFactory.build({ id: 2 });
const user = userFactory.build({ username: 'other' });
expect(isAdmin(user)).toBe(false);
});
});
128 changes: 23 additions & 105 deletions assets/js/pages/Login/Login.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import { useDispatch, useSelector } from 'react-redux';
import { toast } from 'react-hot-toast';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { initiateLogin } from '@state/user';
import classNames from 'classnames';
import {
isSingleSignOnEnabled,
getSingleSignOnLoginUrl,
} from '@lib/auth/config';

import Input from '@common/Input';
import LoginForm from './LoginForm';
import LoginSSO from './LoginSSO';

export default function Login() {
const [username, setUsername] = useState('');
Expand Down Expand Up @@ -69,109 +73,23 @@ export default function Login() {

<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<form className="space-y-6" onSubmit={handleLoginSubmit}>
{!totpCodeRequested ? (
<>
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700"
>
Username
</label>
<div className="mt-1">
<Input
id="username"
type="text"
data-testid="login-username"
disabled={authInProgress}
value={username}
onChange={(e) => setUsername(e.target.value)}
name="username"
autoComplete="username"
required
error={isUnauthorized}
className={classNames(
'appearance-none px-3 py-2 text-inherit border-gray-300 shadow-sm focus:ring-jungle-green-500 focus:border-jungle-green-500 sm:text-sm',
{ 'disabled:opacity-50': authInProgress }
)}
/>
</div>
</div>

<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password
</label>
<div className="mt-1">
<Input
id="password"
type="password"
data-testid="login-password"
disabled={authInProgress}
value={password}
onChange={(e) => setPassword(e.target.value)}
name="password"
autoComplete="current-password"
required
error={isUnauthorized}
className={classNames(
'appearance-none px-3 py-2 text-inherit border-gray-300 shadow-sm focus:ring-jungle-green-500 focus:border-jungle-green-500 sm:text-sm',
{ 'disabled:opacity-50': authInProgress }
)}
/>
</div>
</div>
</>
) : (
<div>
<label
htmlFor="otp-code"
className="block text-sm font-medium text-gray-700"
>
TOTP code
</label>
<div className="mt-1">
<Input
id="totp-code"
type="text"
data-testid="login-totp-code"
disabled={authInProgress}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
autoComplete="off"
required
error={isUnauthorized}
className={classNames(
'appearance-none px-3 py-2 text-inherit border-gray-300 shadow-sm focus:ring-jungle-green-500 focus:border-jungle-green-500 sm:text-sm',
{ 'disabled:opacity-50': authInProgress }
)}
/>
</div>
</div>
)}
{authError && authError.code === 401 && (
<p className="text-sm text-center text-red-500">
Invalid credentials
</p>
)}
<div>
<button
type="submit"
disabled={authInProgress}
data-testid="login-submit"
className={classNames(
'w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-jungle-green-500 hover:opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-jungle-green-500',
{ 'disabled:opacity-50': authInProgress }
)}
>
Login
</button>
</div>
</form>
{isSingleSignOnEnabled() ? (
<LoginSSO singleSignOnUrl={getSingleSignOnLoginUrl()} />
) : (
<LoginForm
authError={authError}
authInProgress={authInProgress}
handleLoginSubmit={handleLoginSubmit}
isUnauthorized={isUnauthorized}
password={password}
setPassword={setPassword}
setTotpCode={setTotpCode}
setUsername={setUsername}
totpCodeRequested={totpCodeRequested}
totpCode={totpCode}
username={username}
/>
)}
</div>
</div>
</div>
Expand Down
24 changes: 24 additions & 0 deletions assets/js/pages/Login/Login.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import { Toaster } from 'react-hot-toast';
import { withState, renderWithRouter } from '@lib/test-utils';
import * as authConfig from '@lib/auth/config';
import Login from './Login';

describe('Login component', () => {
Expand Down Expand Up @@ -183,4 +184,27 @@ describe('Login component', () => {
payload: { username: '', password: '', totpCode },
});
});

describe('Single sign on', () => {
it('should display the SSO login button', async () => {
jest.spyOn(authConfig, 'isSingleSignOnEnabled').mockReturnValue(true);
jest
.spyOn(authConfig, 'getSingleSignOnLoginUrl')
.mockReturnValue('http://idp-url');

const [StatefulLogin] = withState(<Login />, {
user: {
loggedIn: false,
authInProgress: false,
},
});

renderWithRouter(StatefulLogin);

const loginButton = screen.getByRole('button', {
name: 'Login with Single Sign-on',
});
expect(loginButton).toBeVisible();
});
});
});
Loading

0 comments on commit b6be998

Please sign in to comment.