diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 4ec1733220..ca2f305545 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -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:
@@ -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/cancel-workflow-action@0.12.1
+ 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/compose-action@v2.0.1
+ 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
diff --git a/assets/jest.config.js b/assets/jest.config.js
index 0442bfd103..9786828bef 100644
--- a/assets/jest.config.js
+++ b/assets/jest.config.js
@@ -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,
},
},
diff --git a/assets/js/lib/auth/config.js b/assets/js/lib/auth/config.js
new file mode 100644
index 0000000000..e1430616c2
--- /dev/null
+++ b/assets/js/lib/auth/config.js
@@ -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 '';
+};
diff --git a/assets/js/lib/auth/config.test.js b/assets/js/lib/auth/config.test.js
new file mode 100644
index 0000000000..e71ec53c12
--- /dev/null
+++ b/assets/js/lib/auth/config.test.js
@@ -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');
+ });
+ });
+});
diff --git a/assets/js/lib/auth/index.js b/assets/js/lib/auth/index.js
index 5fdd57b236..549df21672 100644
--- a/assets/js/lib/auth/index.js
+++ b/assets/js/lib/auth/index.js
@@ -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 })
diff --git a/assets/js/lib/model/users.test.js b/assets/js/lib/model/users.test.js
index 5c530e42cf..6fd5328dc5 100644
--- a/assets/js/lib/model/users.test.js
+++ b/assets/js/lib/model/users.test.js
@@ -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);
});
});
diff --git a/assets/js/pages/Login/Login.jsx b/assets/js/pages/Login/Login.jsx
index 7704129328..ebcacf0b90 100644
--- a/assets/js/pages/Login/Login.jsx
+++ b/assets/js/pages/Login/Login.jsx
@@ -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('');
@@ -69,109 +73,23 @@ export default function Login() {
-
+ {isSingleSignOnEnabled() ? (
+
+ ) : (
+
+ )}
diff --git a/assets/js/pages/Login/Login.test.jsx b/assets/js/pages/Login/Login.test.jsx
index 9415e11eaf..e542ee2836 100644
--- a/assets/js/pages/Login/Login.test.jsx
+++ b/assets/js/pages/Login/Login.test.jsx
@@ -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', () => {
@@ -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(, {
+ user: {
+ loggedIn: false,
+ authInProgress: false,
+ },
+ });
+
+ renderWithRouter(StatefulLogin);
+
+ const loginButton = screen.getByRole('button', {
+ name: 'Login with Single Sign-on',
+ });
+ expect(loginButton).toBeVisible();
+ });
+ });
});
diff --git a/assets/js/pages/Login/LoginForm.jsx b/assets/js/pages/Login/LoginForm.jsx
new file mode 100644
index 0000000000..46c1488669
--- /dev/null
+++ b/assets/js/pages/Login/LoginForm.jsx
@@ -0,0 +1,123 @@
+import React from 'react';
+
+import classNames from 'classnames';
+
+import Input from '@common/Input';
+
+export default function LoginForm({
+ authError,
+ authInProgress,
+ handleLoginSubmit,
+ isUnauthorized,
+ password,
+ setPassword,
+ setTotpCode,
+ setUsername,
+ totpCodeRequested,
+ totpCode,
+ username,
+}) {
+ return (
+
+ );
+}
diff --git a/assets/js/pages/Login/LoginSSO.jsx b/assets/js/pages/Login/LoginSSO.jsx
new file mode 100644
index 0000000000..e8676f0dda
--- /dev/null
+++ b/assets/js/pages/Login/LoginSSO.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+
+import Button from '@common/Button';
+
+export default function LoginSSO({ singleSignOnUrl, error }) {
+ return (
+ <>
+ {error && (
+
+ An error occurred while trying to Login. Please retry login again.
+ Should the error persist, contact the administrator.
+
+ )}
+
+ >
+ );
+}
diff --git a/assets/js/pages/OidcCallback/OidcCallback.jsx b/assets/js/pages/OidcCallback/OidcCallback.jsx
new file mode 100644
index 0000000000..bc467137e7
--- /dev/null
+++ b/assets/js/pages/OidcCallback/OidcCallback.jsx
@@ -0,0 +1,71 @@
+import React, { useEffect, useState } from 'react';
+import TrentoLogo from '@static/trento-dark.svg';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { useDispatch, useSelector } from 'react-redux';
+import { performOidcEnrollment } from '@state/user';
+import { getSingleSignOnLoginUrl } from '@lib/auth/config';
+import LoginSSO from '@pages/Login/LoginSSO';
+import { getUserProfile } from '@state/selectors/user';
+
+function OidCallback() {
+ const { search } = useLocation();
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+ const [error, setError] = useState(null);
+ const { authError, loggedIn } = useSelector(getUserProfile);
+
+ useEffect(() => {
+ const params = new URLSearchParams(search);
+ const code = params.get('code');
+ const state = params.get('state');
+
+ setError(!code || !state);
+
+ dispatch(performOidcEnrollment({ state, code }));
+ }, [search]);
+
+ useEffect(() => {
+ if (loggedIn) {
+ navigate('/');
+ }
+ }, [loggedIn]);
+
+ if (authError || error) {
+ return (
+
+
+
+
+ Login Failed
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Loading...
+
+
+
+ );
+}
+
+export default OidCallback;
diff --git a/assets/js/pages/OidcCallback/OidcCallback.test.jsx b/assets/js/pages/OidcCallback/OidcCallback.test.jsx
new file mode 100644
index 0000000000..754dc85a90
--- /dev/null
+++ b/assets/js/pages/OidcCallback/OidcCallback.test.jsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import 'intersection-observer';
+import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
+import { withState, renderWithRouterMatch } from '@lib/test-utils';
+import OidcCallback from './OidcCallback';
+
+describe('OidcCallback component', () => {
+ beforeEach(() => {
+ jest.resetModules();
+ });
+
+ it('should display loading state when authentication is happening', async () => {
+ const [StatefulOidCallback, store] = withState(, {
+ user: {},
+ });
+
+ renderWithRouterMatch(StatefulOidCallback, {
+ path: 'auth/oidc_callback',
+ route: `/auth/oidc_callback?code=code&state=state`,
+ });
+
+ expect(screen.getByText('Loading...')).toBeVisible();
+
+ expect(store.getActions()).toContainEqual({
+ type: 'PERFORM_OIDC_ENROLLMENT',
+ payload: { code: 'code', state: 'state' },
+ });
+ });
+
+ it('should display an error message if some search param is missing', async () => {
+ const user = userEvent.setup();
+
+ const [StatefulOidCallback] = withState(, {
+ user: {},
+ });
+
+ renderWithRouterMatch(StatefulOidCallback, {
+ path: 'auth/oidc_callback',
+ route: `/auth/oidc_callback?code=code`,
+ });
+
+ expect(screen.getByText('Login Failed')).toBeVisible();
+
+ expect(
+ screen.getByText('An error occurred while trying to Login', {
+ exact: false,
+ })
+ ).toBeVisible();
+
+ const loginButton = screen.getByRole('button', {
+ name: 'Login with Single Sign-on',
+ });
+ user.click(loginButton);
+
+ expect(window.location.pathname).toBe('/auth/oidc_callback');
+ });
+
+ it('should display an error message if authentication fails', async () => {
+ const [StatefulOidCallback] = withState(, {
+ user: {
+ authError: true,
+ },
+ });
+
+ renderWithRouterMatch(StatefulOidCallback, {
+ path: 'auth/oidc_callback',
+ route: `/auth/oidc_callback?code=code&state=state`,
+ });
+
+ expect(screen.getByText('Login Failed')).toBeVisible();
+
+ expect(
+ screen.getByText('An error occurred while trying to Login', {
+ exact: false,
+ })
+ ).toBeVisible();
+ });
+
+ it('should navigate to home after user is logged in', () => {
+ const [StatefulOidCallback] = withState(, {
+ user: {
+ loggedIn: true,
+ },
+ });
+
+ renderWithRouterMatch(StatefulOidCallback, {
+ path: '',
+ route: '/',
+ });
+
+ expect(window.location.pathname).toBe('/');
+ });
+});
diff --git a/assets/js/pages/OidcCallback/index.js b/assets/js/pages/OidcCallback/index.js
new file mode 100644
index 0000000000..f6d3545420
--- /dev/null
+++ b/assets/js/pages/OidcCallback/index.js
@@ -0,0 +1,3 @@
+import OidCallback from './OidcCallback';
+
+export default OidCallback;
diff --git a/assets/js/pages/Profile/ProfileForm.jsx b/assets/js/pages/Profile/ProfileForm.jsx
index 0459f16787..de91eadc25 100644
--- a/assets/js/pages/Profile/ProfileForm.jsx
+++ b/assets/js/pages/Profile/ProfileForm.jsx
@@ -25,6 +25,7 @@ function ProfileForm({
disableForm,
passwordModalOpen = false,
totpBoxOpen = false,
+ singleSignOnEnabled = false,
toggleTotpBox = noop,
togglePasswordModal = noop,
onSave = noop,
@@ -94,6 +95,7 @@ function ProfileForm({
setFullName(value);
setFullNameError(null);
}}
+ disabled={singleSignOnEnabled}
/>
{fullNameErrorState && errorMessage(fullNameErrorState)}
@@ -108,6 +110,7 @@ function ProfileForm({
setEmailAddress(value);
setEmailAddressError(null);
}}
+ disabled={singleSignOnEnabled}
/>
{emailAddressErrorState && errorMessage(emailAddressErrorState)}
@@ -115,54 +118,58 @@ function ProfileForm({
-
-
-
-
-
-
-
-
-
- {totpBoxOpen && (
-
toggleTotpBox(false)}
- aria-hidden="true"
+ {!singleSignOnEnabled && (
+ <>
+
+
+
+ Change Password
+
+
- {totpBoxOpen && (
+
-
+
+
+ {totpBoxOpen && (
+ toggleTotpBox(false)}
+ aria-hidden="true"
+ >
+ Cancel
+
+ )}
+
+
+ {totpBoxOpen && (
+
+
+
+ )}
- )}
-
+ >
+ )}
@@ -174,15 +181,17 @@ function ProfileForm({
/>
-
-
-
+ {!singleSignOnEnabled && (
+
+
+
+ )}
(
@@ -129,3 +133,10 @@ export const WithErrors = {
],
},
};
+
+export const SingleSignOnEnabled = {
+ args: {
+ ...Default.args,
+ singleSignOnEnabled: true,
+ },
+};
diff --git a/assets/js/pages/Profile/ProfileForm.test.jsx b/assets/js/pages/Profile/ProfileForm.test.jsx
index 73b71d4fc5..23b088dcd8 100644
--- a/assets/js/pages/Profile/ProfileForm.test.jsx
+++ b/assets/js/pages/Profile/ProfileForm.test.jsx
@@ -305,4 +305,59 @@ describe('ProfileForm', () => {
);
expect(screen.getByText('Error validating totp code')).toBeVisible();
});
+
+ describe('Single sign on', () => {
+ it('should disable fullname, email and username fields', () => {
+ const { username, fullname, email, abilities } = profileFactory.build();
+
+ render(
+
+ );
+
+ expect(screen.getByLabelText('fullname')).toBeDisabled();
+ expect(screen.getByLabelText('email')).toBeDisabled();
+ expect(screen.getByLabelText('username')).toBeDisabled();
+ expect(screen.getByLabelText('permissions')).toBeDisabled();
+ });
+
+ it('should remove password and totp fields', () => {
+ const { username, fullname, email, abilities } = profileFactory.build();
+
+ render(
+
+ );
+
+ expect(screen.queryByText('Password')).not.toBeInTheDocument();
+ expect(screen.queryByText('Authenticator App')).not.toBeInTheDocument();
+ expect(screen.getByText('Permissions')).toBeVisible();
+ });
+
+ it('should remove save button', () => {
+ const { username, fullname, email, abilities } = profileFactory.build();
+
+ render(
+
+ );
+
+ expect(screen.queryByText('Save')).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/assets/js/pages/Profile/ProfilePage.jsx b/assets/js/pages/Profile/ProfilePage.jsx
index 945e07d29d..3373b457e4 100644
--- a/assets/js/pages/Profile/ProfilePage.jsx
+++ b/assets/js/pages/Profile/ProfilePage.jsx
@@ -3,6 +3,7 @@ import { toast } from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import PageHeader from '@common/PageHeader';
import { isAdmin } from '@lib/model/users';
+import { isSingleSignOnEnabled } from '@lib/auth/config';
import ProfileForm from '@pages/Profile/ProfileForm';
import {
getUserProfile,
@@ -162,6 +163,7 @@ function ProfilePage() {
toggleTotpBox={setTotpBoxOpen}
loading={loading || saving}
disableForm={isDefaultAdmin}
+ singleSignOnEnabled={isSingleSignOnEnabled()}
onSave={updateProfile}
onEnableTotp={totpInitiateEnrolling}
onVerifyTotp={verifyTotpEnrollment}
diff --git a/assets/js/pages/Users/CreateUserPage.jsx b/assets/js/pages/Users/CreateUserPage.jsx
index af38ac1884..06aad73533 100644
--- a/assets/js/pages/Users/CreateUserPage.jsx
+++ b/assets/js/pages/Users/CreateUserPage.jsx
@@ -4,7 +4,9 @@ import { toast } from 'react-hot-toast';
import BackButton from '@common/BackButton';
import PageHeader from '@common/PageHeader';
+import NotFound from '@pages/NotFound';
+import { isSingleSignOnEnabled } from '@lib/auth/config';
import { listAbilities } from '@lib/api/abilities';
import { createUser } from '@lib/api/users';
@@ -55,6 +57,10 @@ function CreateUserPage() {
navigate('/users');
};
+ if (isSingleSignOnEnabled()) {
+ return ;
+ }
+
useEffect(() => {
fetchAbilities(setAbilities);
}, []);
diff --git a/assets/js/pages/Users/CreateUserPage.test.jsx b/assets/js/pages/Users/CreateUserPage.test.jsx
index 3ac8ef373e..1dc2c5c2c3 100644
--- a/assets/js/pages/Users/CreateUserPage.test.jsx
+++ b/assets/js/pages/Users/CreateUserPage.test.jsx
@@ -13,7 +13,7 @@ import { faker } from '@faker-js/faker';
import * as router from 'react-router';
import { networkClient } from '@lib/network';
-
+import * as authConfig from '@lib/auth/config';
import { abilityFactory, userFactory } from '@lib/test-utils/factories/users';
import CreateUserPage from './CreateUserPage';
@@ -144,4 +144,16 @@ describe('CreateUserPage', () => {
await user.click(screen.getByRole('button', { name: 'Create' }));
expect(toast.error).toHaveBeenCalledWith(toastMessage);
});
+
+ describe('Single sign on', () => {
+ it('should redirect to not found page', async () => {
+ jest.spyOn(authConfig, 'isSingleSignOnEnabled').mockReturnValue(true);
+
+ render();
+
+ expect(
+ screen.getByText('the page is in another castle', { exact: false })
+ ).toBeVisible();
+ });
+ });
});
diff --git a/assets/js/pages/Users/EditUserPage.jsx b/assets/js/pages/Users/EditUserPage.jsx
index 503665b7ed..f5bb732213 100644
--- a/assets/js/pages/Users/EditUserPage.jsx
+++ b/assets/js/pages/Users/EditUserPage.jsx
@@ -7,6 +7,8 @@ import Banner from '@common/Banners/Banner';
import PageHeader from '@common/PageHeader';
import { isAdmin } from '@lib/model/users';
+import { isSingleSignOnEnabled } from '@lib/auth/config';
+
import { editUser, getUser } from '@lib/api/users';
import { fetchAbilities } from './CreateUserPage';
@@ -123,6 +125,7 @@ function EditUserPage() {
onSave={onEditUser}
onCancel={onCancel}
editing
+ singleSignOnEnabled={isSingleSignOnEnabled()}
/>
);
diff --git a/assets/js/pages/Users/UserForm.jsx b/assets/js/pages/Users/UserForm.jsx
index 4d34cd96d5..50355a9e8a 100644
--- a/assets/js/pages/Users/UserForm.jsx
+++ b/assets/js/pages/Users/UserForm.jsx
@@ -38,6 +38,7 @@ function UserForm({
saveEnabled = true,
saveText = 'Create',
editing = false,
+ singleSignOnEnabled = false,
onSave = noop,
onCancel = noop,
}) {
@@ -95,23 +96,32 @@ function UserForm({
return error;
};
+ const buildUserPayload = () => ({
+ fullname: fullNameState,
+ email: emailAddressState,
+ enabled: statusState === USER_ENABLED,
+ ...(!editing && { username: usernameState }),
+ ...(passwordState && { password: passwordState }),
+ ...(confirmPasswordState && {
+ password_confirmation: confirmPasswordState,
+ }),
+ abilities: abilities.filter(({ id }) => selectedAbilities.includes(id)),
+ ...(totpEnabledAt && !totpState && { totp_disabled: true }),
+ });
+
+ const buildSSOUserPayload = () => ({
+ enabled: statusState === USER_ENABLED,
+ abilities: abilities.filter(({ id }) => selectedAbilities.includes(id)),
+ });
+
const onSaveClicked = () => {
if (validateRequired()) {
return;
}
- const user = {
- fullname: fullNameState,
- email: emailAddressState,
- enabled: statusState === USER_ENABLED,
- ...(!editing && { username: usernameState }),
- ...(passwordState && { password: passwordState }),
- ...(confirmPasswordState && {
- password_confirmation: confirmPasswordState,
- }),
- abilities: abilities.filter(({ id }) => selectedAbilities.includes(id)),
- ...(totpEnabledAt && !totpState && { totp_disabled: true }),
- };
+ const user = singleSignOnEnabled
+ ? buildSSOUserPayload()
+ : buildUserPayload();
onSave(user);
};
@@ -139,6 +149,7 @@ function UserForm({
setFullName(value);
setFullNameError(null);
}}
+ disabled={singleSignOnEnabled}
/>
{fullNameErrorState && errorMessage(fullNameErrorState)}
@@ -155,6 +166,7 @@ function UserForm({
setEmailAddress(value);
setEmailAddressError(null);
}}
+ disabled={singleSignOnEnabled}
/>
{emailAddressErrorState && errorMessage(emailAddressErrorState)}
@@ -171,56 +183,64 @@ function UserForm({
setUsername(value);
setUsernameError(null);
}}
- disabled={editing}
+ disabled={editing || singleSignOnEnabled}
/>
{usernameErrorState && errorMessage(usernameErrorState)}
-
-
-
{
- setPassword(value);
- setPasswordError(null);
- }}
- />
- {passwordErrorState && errorMessage(passwordErrorState)}
-
-
-
-
{
- setConfirmPassword(value);
- setConfirmPasswordError(null);
- }}
- />
- {confirmPasswordErrorState &&
- errorMessage(confirmPasswordErrorState)}
-
-
-
-
+ {!singleSignOnEnabled && (
+ <>
+
+
+
{
+ setPassword(value);
+ setPasswordError(null);
+ }}
+ />
+ {passwordErrorState && errorMessage(passwordErrorState)}
+
+
+
+
{
+ setConfirmPassword(value);
+ setConfirmPasswordError(null);
+ }}
+ />
+ {confirmPasswordErrorState &&
+ errorMessage(confirmPasswordErrorState)}
+
+
+
+
+ >
+ )}
- {editing && (
+ {editing && !singleSignOnEnabled && (
<>
- >
- )}
- {editing && (
- <>
{format(parseISO(createdAt), 'PPpp')}
diff --git a/assets/js/pages/Users/UserForm.stories.jsx b/assets/js/pages/Users/UserForm.stories.jsx
index 817b9fb425..61236f8107 100644
--- a/assets/js/pages/Users/UserForm.stories.jsx
+++ b/assets/js/pages/Users/UserForm.stories.jsx
@@ -117,6 +117,10 @@ export default {
errors: {
description: 'OpenAPI errors coming from backend validation',
},
+ singleSignOnEnabled: {
+ description: 'Single sign on login is enabled',
+ control: { type: 'boolean' },
+ },
onSave: {
action: 'Save user',
description: 'Save user action',
@@ -205,3 +209,10 @@ export const WithErrors = {
],
},
};
+
+export const SingleSignOnEnabled = {
+ args: {
+ ...Editing.args,
+ singleSignOnEnabled: true,
+ },
+};
diff --git a/assets/js/pages/Users/UserForm.test.jsx b/assets/js/pages/Users/UserForm.test.jsx
index 1e35e1e999..abdca6a9e2 100644
--- a/assets/js/pages/Users/UserForm.test.jsx
+++ b/assets/js/pages/Users/UserForm.test.jsx
@@ -360,4 +360,72 @@ describe('UserForm', () => {
abilities: abilities.slice(0, 2),
});
});
+
+ describe('Single sign on', () => {
+ it('should disable fullname, email and username fields', () => {
+ render();
+
+ expect(screen.getByLabelText('fullname')).toBeDisabled();
+ expect(screen.getByLabelText('email')).toBeDisabled();
+ expect(screen.getByLabelText('username')).toBeDisabled();
+ });
+
+ it('should remove password, totp and timestamp fields', () => {
+ render();
+
+ expect(screen.queryByText('Created')).not.toBeInTheDocument();
+ expect(screen.queryByText('Updated')).not.toBeInTheDocument();
+ expect(screen.queryByText('TOTP')).not.toBeInTheDocument();
+ expect(screen.getByText('Permissions')).toBeVisible();
+ expect(screen.getByText('Enabled')).toBeVisible();
+ });
+
+ it('should save permissions and status', async () => {
+ const user = userEvent.setup();
+
+ const { fullname, email, username } = userFactory.build();
+
+ const abilities = abilityFactory.buildList(3);
+ const userAbilities = [abilities[0]];
+
+ const mockOnSave = jest.fn();
+
+ render(
+
+ );
+
+ expect(
+ screen.getByText(
+ `${userAbilities[0].name}:${userAbilities[0].resource}`
+ )
+ ).toBeVisible();
+
+ await user.click(screen.getByLabelText('permissions'));
+
+ abilities.forEach(({ name, resource }) =>
+ expect(screen.getByText(`${name}:${resource}`)).toBeVisible()
+ );
+
+ await user.click(
+ screen.getByText(`${abilities[1].name}:${abilities[1].resource}`)
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(mockOnSave).toHaveBeenNthCalledWith(1, {
+ enabled: true,
+ abilities: abilities.slice(0, 2),
+ });
+ });
+ });
});
diff --git a/assets/js/pages/Users/Users.jsx b/assets/js/pages/Users/Users.jsx
index 56e5d1db26..ead04dbf0c 100644
--- a/assets/js/pages/Users/Users.jsx
+++ b/assets/js/pages/Users/Users.jsx
@@ -19,6 +19,7 @@ function Users({
navigate = noop,
users = defaultUsers,
loading = false,
+ singleSignOnEnabled = false,
}) {
const [modalOpen, setModalOpen] = useState(false);
const [user, setUser] = useState(null);
@@ -92,18 +93,20 @@ function Users({
-
-
-
+ {!singleSignOnEnabled && (
+
+
+
+
-
+ )}
{
await userEvent.click(cancelButton);
expect(modalTitel).not.toBeInTheDocument();
});
+
+ describe('Single sign on', () => {
+ it('should disable user creation', () => {
+ renderWithRouter();
+ expect(screen.queryByText('Create User')).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/assets/js/pages/Users/UsersPage.jsx b/assets/js/pages/Users/UsersPage.jsx
index 1e8e17d253..9207e77756 100644
--- a/assets/js/pages/Users/UsersPage.jsx
+++ b/assets/js/pages/Users/UsersPage.jsx
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
-import { listUsers, deleteUser } from '@lib/api/users';
-
import { toast } from 'react-hot-toast';
+import { listUsers, deleteUser } from '@lib/api/users';
+import { isSingleSignOnEnabled } from '@lib/auth/config';
+
import Users from './Users';
const SUCCESS_DELETE_MESSAGE = 'User deleted successfully';
@@ -62,6 +63,7 @@ function UsersPage() {
navigate={navigate}
users={users}
loading={loading}
+ singleSignOnEnabled={isSingleSignOnEnabled()}
/>
);
}
diff --git a/assets/js/state/sagas/user.js b/assets/js/state/sagas/user.js
index 53cd1340ae..0044834854 100644
--- a/assets/js/state/sagas/user.js
+++ b/assets/js/state/sagas/user.js
@@ -8,18 +8,21 @@ import {
USER_LOCKED,
USER_UPDATED,
PERFORM_LOGIN,
+ PERFORM_OIDC_ENROLLMENT,
USER_PASSWORD_CHANGE_REQUESTED_NOTIFICATION_ID,
} from '@state/user';
import { customNotify } from '@state/notifications';
import { getUserProfile } from '@state/selectors/user';
import {
login,
+ oidcEnrollment,
profile,
storeAccessToken,
storeRefreshToken,
clearCredentialsFromStore,
} from '@lib/auth';
import { networkClient } from '@lib/network';
+import { isSingleSignOnEnabled } from '@lib/auth/config';
export function* performLogin({ payload: { username, password, totpCode } }) {
yield put(setAuthInProgress());
@@ -61,6 +64,40 @@ export function* performLogin({ payload: { username, password, totpCode } }) {
}
}
+export function* performOIDCEnrollment({ payload: { code, state } }) {
+ yield put(setAuthInProgress());
+ try {
+ const {
+ data: { access_token: accessToken, refresh_token: refreshToken },
+ } = yield call(oidcEnrollment, { code, session_state: state });
+ yield call(storeAccessToken, accessToken);
+ yield call(storeRefreshToken, refreshToken);
+
+ const {
+ id,
+ username: profileUsername,
+ email,
+ fullname,
+ abilities,
+ } = yield call(profile, networkClient);
+ yield put(
+ setUser({
+ username: profileUsername,
+ id,
+ email,
+ fullname,
+ abilities,
+ })
+ );
+ yield put(setUserAsLogged());
+ } catch (error) {
+ yield put(
+ setAuthError({ message: error.message, code: error.response?.status })
+ );
+ yield call(clearCredentialsFromStore);
+ }
+}
+
export function* clearUserAndLogout() {
yield call(clearCredentialsFromStore);
window.location.href = '/session/new';
@@ -71,6 +108,10 @@ export function* userUpdated() {
}
export function* checkUserPasswordChangeRequested() {
+ if (isSingleSignOnEnabled()) {
+ return;
+ }
+
const { password_change_requested } = yield select(getUserProfile);
if (!password_change_requested) {
@@ -92,4 +133,5 @@ export function* watchUserActions() {
yield takeEvery(USER_LOCKED, clearUserAndLogout);
yield takeEvery(USER_UPDATED, userUpdated);
yield takeEvery(PERFORM_LOGIN, performLogin);
+ yield takeEvery(PERFORM_OIDC_ENROLLMENT, performOIDCEnrollment);
}
diff --git a/assets/js/state/sagas/user.test.js b/assets/js/state/sagas/user.test.js
index dafef3ec12..6afd806112 100644
--- a/assets/js/state/sagas/user.test.js
+++ b/assets/js/state/sagas/user.test.js
@@ -16,15 +16,25 @@ import {
import { customNotify } from '@state/notifications';
import { networkClient } from '@lib/network';
import { profileFactory } from '@lib/test-utils/factories/users';
+import * as authConfig from '@lib/auth/config';
import {
performLogin,
clearUserAndLogout,
checkUserPasswordChangeRequested,
+ performOIDCEnrollment,
} from './user';
const axiosMock = new MockAdapter(authClient);
const networkClientAxiosMock = new MockAdapter(networkClient);
+const credentialResponse = {
+ access_token:
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0cmVudG8tcHJvamVjdCIsImV4cCI6MTY3MTY0MTE5NiwiaWF0IjoxNjcxNjQwNTk2LCJpc3MiOiJodHRwczovL2dpdGh1Yi5jb20vdHJlbnRvLXByb2plY3Qvd2ViIiwianRpIjoiMnNwZG9ndmxtOTJmdG1kdm1nMDAwbmExIiwibmJmIjoxNjcxNjQwNTk2LCJzdWIiOjEsInR5cCI6IkJlYXJlciJ9.ZuHORuLkK9e15NGGMRRpxFOUR1BO1_BLuT9EeOJfuLM',
+ expires_in: 600,
+ refresh_token:
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0cmVudG8tcHJvamVjdCIsImV4cCI6MTY3MTY0MDY1NiwiaWF0IjoxNjcxNjQwNTk2LCJpc3MiOiJodHRwczovL2dpdGh1Yi5jb20vdHJlbnRvLXByb2plY3Qvd2ViIiwianRpIjoiMnNwZG9ndmxtZWhmbG1kdm1nMDAwbmMxIiwibmJmIjoxNjcxNjQwNTk2LCJzdWIiOjEsInR5cCI6IlJlZnJlc2gifQ.AW6-iV1XHWdzQKBVadhf7o7gUdidYg6mEyyuDke_zlA',
+};
+
describe('user actions saga', () => {
beforeEach(() => {
axiosMock.reset();
@@ -84,14 +94,6 @@ describe('user login saga', () => {
});
it('should set the username in the store and set the user as logged when login is successful, persisting the information in the local storage', async () => {
- const credentialResponse = {
- access_token:
- 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0cmVudG8tcHJvamVjdCIsImV4cCI6MTY3MTY0MTE5NiwiaWF0IjoxNjcxNjQwNTk2LCJpc3MiOiJodHRwczovL2dpdGh1Yi5jb20vdHJlbnRvLXByb2plY3Qvd2ViIiwianRpIjoiMnNwZG9ndmxtOTJmdG1kdm1nMDAwbmExIiwibmJmIjoxNjcxNjQwNTk2LCJzdWIiOjEsInR5cCI6IkJlYXJlciJ9.ZuHORuLkK9e15NGGMRRpxFOUR1BO1_BLuT9EeOJfuLM',
- expires_in: 600,
- refresh_token:
- 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0cmVudG8tcHJvamVjdCIsImV4cCI6MTY3MTY0MDY1NiwiaWF0IjoxNjcxNjQwNTk2LCJpc3MiOiJodHRwczovL2dpdGh1Yi5jb20vdHJlbnRvLXByb2plY3Qvd2ViIiwianRpIjoiMnNwZG9ndmxtZWhmbG1kdm1nMDAwbmMxIiwibmJmIjoxNjcxNjQwNTk2LCJzdWIiOjEsInR5cCI6IlJlZnJlc2gifQ.AW6-iV1XHWdzQKBVadhf7o7gUdidYg6mEyyuDke_zlA',
- };
-
const {
email,
username,
@@ -168,7 +170,7 @@ describe('user login saga', () => {
expect(dispatched).toEqual([expectedAction]);
});
- it('should not dispatch notification is password change is not requested', async () => {
+ it('should not dispatch notification if password change is not requested', async () => {
const dispatched = await recordSaga(
checkUserPasswordChangeRequested,
{},
@@ -181,4 +183,104 @@ describe('user login saga', () => {
expect(dispatched).toEqual([]);
});
+
+ describe('Single sign on', () => {
+ it('should not dispatch notification if single sign on is enabled', async () => {
+ jest.spyOn(authConfig, 'isSingleSignOnEnabled').mockReturnValue(true);
+
+ const dispatched = await recordSaga(
+ checkUserPasswordChangeRequested,
+ {},
+ {
+ user: {
+ password_change_requested: true,
+ },
+ }
+ );
+
+ expect(dispatched).toEqual([]);
+ });
+
+ it('should permorm OIDC enrollment', async () => {
+ const { email, username, id, fullname, abilities } =
+ profileFactory.build();
+
+ axiosMock
+ .onPost('/api/session/oidc_local/callback', {
+ code: 'code',
+ session_state: 'state',
+ })
+ .reply(200, credentialResponse);
+
+ networkClientAxiosMock.onGet('/api/v1/profile').reply(200, {
+ username,
+ id,
+ email,
+ fullname,
+ abilities,
+ });
+
+ const dispatched = await recordSaga(
+ performOIDCEnrollment,
+ {
+ payload: {
+ code: 'code',
+ state: 'state',
+ },
+ },
+ {
+ user: {
+ password_change_requested: true,
+ },
+ }
+ );
+
+ expect(dispatched).toContainEqual(setAuthInProgress());
+ expect(dispatched).toContainEqual(
+ setUser({
+ username,
+ id,
+ email,
+ fullname,
+ abilities,
+ })
+ );
+ expect(dispatched).toContainEqual(setUserAsLogged());
+
+ expect(getAccessTokenFromStore()).toEqual(
+ credentialResponse.access_token
+ );
+ expect(getRefreshTokenFromStore()).toEqual(
+ credentialResponse.refresh_token
+ );
+ });
+
+ it('should set the error when the OIDC enrollment fails', async () => {
+ axiosMock
+ .onPost('/api/session/oidc_local/callback', {
+ code: 'bad',
+ session_state: 'bad',
+ })
+ .reply(401, {
+ error: 'unauthorized',
+ });
+
+ const dispatched = await recordSaga(performOIDCEnrollment, {
+ payload: {
+ code: 'bad',
+ state: 'bad',
+ },
+ });
+
+ expect(dispatched).toContainEqual(setAuthInProgress());
+ expect(dispatched).toContainEqual(
+ setAuthError({
+ message: 'Request failed with status code 401',
+ code: 401,
+ })
+ );
+ expect(getAccessTokenFromStore()).toEqual(null);
+ expect(getRefreshTokenFromStore()).toEqual(null);
+ });
+ });
});
diff --git a/assets/js/state/user.js b/assets/js/state/user.js
index 54ea50b4fa..1d66207b02 100644
--- a/assets/js/state/user.js
+++ b/assets/js/state/user.js
@@ -63,6 +63,7 @@ export const PERFORM_LOGIN = 'PERFORM_LOGIN';
export const USER_UPDATED = 'USER_UPDATED';
export const USER_LOCKED = 'USER_LOCKED';
export const USER_DELETED = 'USER_DELETED';
+export const PERFORM_OIDC_ENROLLMENT = 'PERFORM_OIDC_ENROLLMENT';
export const SET_USER_AS_LOGGED = 'user/setUserAsLogged';
@@ -75,6 +76,14 @@ export const initiateLogin = createAction(
payload: { username, password, totpCode },
})
);
+
+export const performOidcEnrollment = createAction(
+ PERFORM_OIDC_ENROLLMENT,
+ ({ state, code }) => ({
+ payload: { state, code },
+ })
+);
+
export const userUpdated = createAction(USER_UPDATED);
export const userLocked = createAction(USER_LOCKED);
export const userDeleted = createAction(USER_DELETED);
diff --git a/assets/js/trento.jsx b/assets/js/trento.jsx
index 1fe27895d7..bf003cd30d 100644
--- a/assets/js/trento.jsx
+++ b/assets/js/trento.jsx
@@ -41,8 +41,13 @@ import SomethingWentWrong from '@pages/SomethingWentWrong';
import UsersPage, { CreateUserPage, EditUserPage } from '@pages/Users';
import ProfilePage from '@pages/Profile';
import ActivityLogPage from '@pages/ActivityLogPage';
+import OidCallback from '@pages/OidcCallback';
import { profile } from '@lib/auth';
+import {
+ isSingleSignOnEnabled,
+ getSingleSignOnCallbackUrl,
+} from '@lib/auth/config';
import { networkClient } from '@lib/network';
import { TARGET_CLUSTER, TARGET_HOST } from '@lib/model';
@@ -53,6 +58,12 @@ const createRouter = ({ getUser }) =>
createRoutesFromElements(
} ErrorBoundary={SomethingWentWrong}>
} />
+ {isSingleSignOnEnabled() && (
+ }
+ />
+ )}
}
diff --git a/config/config.exs b/config/config.exs
index 13725b5b74..2c99223b3c 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -84,6 +84,10 @@ config :trento, :pow,
extensions: [PowPersistentSession],
controller_callbacks: Pow.Extension.Phoenix.ControllerCallbacks
+config :trento, :pow_assent, user_identities_context: Trento.UserIdentities
+
+config :trento, :oidc, enabled: false
+
# Agent heartbeat interval. Adding one extra second to the agent 5s interval to avoid glitches
config :trento, Trento.Heartbeats, interval: :timer.seconds(6)
diff --git a/config/dev.exs b/config/dev.exs
index 58f4ff5afb..0eed8a49a3 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -135,6 +135,22 @@ config :unplug, :init_mode, :runtime
config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache
+config :trento, :oidc,
+ enabled: false,
+ callback_url: "http://localhost:4000/auth/oidc_callback"
+
+config :trento, :pow_assent,
+ providers: [
+ oidc_local: [
+ client_id: "trento-web",
+ client_secret: "ihfasdEaB5M5r44i4AbNulmLWjgejluX",
+ strategy: Assent.Strategy.OIDC,
+ base_url: "http://localhost:8081/realms/trento",
+ # The default oidc ones, replicated just for the sake of docs
+ authorization_params: [scope: "openid profile"]
+ ]
+ ]
+
# Override with local dev.local.exs file
if File.exists?("#{__DIR__}/dev.local.exs") do
import_config "dev.local.exs"
diff --git a/config/runtime.exs b/config/runtime.exs
index 7a683cd082..3a3605fe7a 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -174,4 +174,31 @@ if config_env() in [:prod, :demo] do
iv_length: 12
}
]
+
+ enable_oidc = System.get_env("ENABLE_OIDC", "false") == "true"
+
+ config :trento, :oidc,
+ enabled: enable_oidc,
+ callback_url:
+ System.get_env(
+ "OIDC_CALLBACK_URL",
+ "https://#{System.get_env("TRENTO_WEB_ORIGIN")}/auth/oidc_callback"
+ )
+
+ if enable_oidc do
+ config :trento, :pow_assent,
+ providers: [
+ oidc_local: [
+ client_id:
+ System.get_env("OIDC_CLIENT_ID") ||
+ raise("environment variable OIDC_CLIENT_ID is missing"),
+ client_secret:
+ System.get_env("OIDC_CLIENT_SECRET") ||
+ raise("environment variable OIDC_CLIENT_SECRET is missing"),
+ base_url:
+ System.get_env("OIDC_BASE_URL") ||
+ raise("environment variable OIDC_BASE_URL is missing")
+ ]
+ ]
+ end
end
diff --git a/container_fixtures/keycloak/init_keycloak_db.sh b/container_fixtures/keycloak/init_keycloak_db.sh
new file mode 100755
index 0000000000..7edeaa182e
--- /dev/null
+++ b/container_fixtures/keycloak/init_keycloak_db.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+set -e
+
+psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
+ CREATE USER keycloak WITH PASSWORD 'password';
+ CREATE DATABASE keycloak;
+ GRANT ALL PRIVILEGES ON DATABASE keycloak TO keycloak;
+ \c keycloak
+ GRANT ALL ON SCHEMA public TO keycloak;
+ \q
+EOSQL
diff --git a/container_fixtures/keycloak/realm.json b/container_fixtures/keycloak/realm.json
new file mode 100644
index 0000000000..aa82407aa0
--- /dev/null
+++ b/container_fixtures/keycloak/realm.json
@@ -0,0 +1,63 @@
+{
+ "id": "trento",
+ "realm": "trento",
+ "sslRequired": "none",
+ "enabled": true,
+ "eventsEnabled": true,
+ "eventsExpiration": 900,
+ "adminEventsEnabled": true,
+ "adminEventsDetailsEnabled": true,
+ "attributes": {
+ "adminEventsExpiration": "900"
+ },
+ "clients": [
+ {
+ "id": "trento-web",
+ "clientId": "trento-web",
+ "name": "trento-web",
+ "enabled": true,
+ "publicClient": false,
+ "secret": "ihfasdEaB5M5r44i4AbNulmLWjgejluX",
+ "clientAuthenticatorType": "client-secret",
+ "rootUrl": "http://localhost:4000",
+ "adminUrl": "http://localhost:4000",
+ "baseUrl": "http://localhost:4000",
+ "redirectUris": ["http://localhost:4000/auth/oidc_callback"],
+ "webOrigins": ["http://localhost:4000"]
+ }
+ ],
+ "users": [
+ {
+ "id": "trento-admin",
+ "email": "trentoadmin@trento.suse.com",
+ "username": "admin",
+ "firstName": "Trento admin user",
+ "lastName": "Superadmin",
+ "enabled": true,
+ "emailVerified": true,
+ "credentials": [
+ {
+ "temporary": false,
+ "type": "admin",
+ "value": "admin"
+ }
+ ]
+ },
+ {
+ "id": "trento-idp-user",
+ "email": "trentoidp@trento.suse.com",
+ "username": "trentoidp",
+ "firstName": "Trento IDP user",
+ "lastName": "Of Monk",
+ "enabled": true,
+ "emailVerified": true,
+ "credentials": [
+ {
+ "temporary": false,
+ "type": "password",
+ "value": "password"
+ }
+ ]
+ }
+ ]
+}
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 4b2d6664b0..8130ea01c9 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -3,6 +3,23 @@ volumes:
pg_data:
prometheus_data:
services:
+ keycloak:
+ profiles: [idp]
+ image: quay.io/keycloak/keycloak:25.0.2
+ depends_on: [postgres]
+ command: ["start-dev", "--import-realm"]
+ environment:
+ KC_DB: postgres
+ KC_DB_USERNAME: keycloak
+ KC_DB_PASSWORD: password
+ KC_DB_URL: "jdbc:postgresql://postgres:5432/keycloak"
+ KC_REALM_NAME: trento
+ KEYCLOAK_ADMIN: keycloak
+ KEYCLOAK_ADMIN_PASSWORD: admin
+ ports:
+ - 8081:8080
+ volumes:
+ - ./container_fixtures/keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro
node_exporter:
image: prom/node-exporter:v1.7.0
volumes:
@@ -10,10 +27,10 @@ services:
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- - '--path.procfs=/host/proc'
- - '--path.rootfs=/rootfs'
- - '--path.sysfs=/host/sys'
- - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
+ - "--path.procfs=/host/proc"
+ - "--path.rootfs=/rootfs"
+ - "--path.sysfs=/host/sys"
+ - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"
prometheus:
image: prom/prometheus:v2.48.1
user: "0:0"
@@ -23,14 +40,14 @@ services:
- prometheus_data:/prometheus
entrypoint: "/container_init/prometheus_entrypoint.sh"
command:
- - '--config.file=/etc/prometheus/prometheus.yml'
- - '--storage.tsdb.path=/prometheus'
- - '--storage.tsdb.retention.time=5y'
- - '--storage.tsdb.retention.size=1GB'
- - '--web.console.libraries=/etc/prometheus/console_libraries'
- - '--web.console.templates=/etc/prometheus/consoles'
- - '--web.enable-lifecycle'
- - '--web.enable-admin-api'
+ - "--config.file=/etc/prometheus/prometheus.yml"
+ - "--storage.tsdb.path=/prometheus"
+ - "--storage.tsdb.retention.time=5y"
+ - "--storage.tsdb.retention.size=1GB"
+ - "--web.console.libraries=/etc/prometheus/console_libraries"
+ - "--web.console.templates=/etc/prometheus/consoles"
+ - "--web.enable-lifecycle"
+ - "--web.enable-admin-api"
ports:
- 9090:9090
postgres:
@@ -43,6 +60,7 @@ services:
- 5433:5432
volumes:
- pg_data:/var/lib/postgresql/data
+ - ./container_fixtures/keycloak/init_keycloak_db.sh:/docker-entrypoint-initdb.d/init_keycloak_db.sh
rabbitmq:
image: rabbitmq:3.12.6-management-alpine
ports:
diff --git a/lib/trento/user_identities.ex b/lib/trento/user_identities.ex
new file mode 100644
index 0000000000..9b0e037f21
--- /dev/null
+++ b/lib/trento/user_identities.ex
@@ -0,0 +1,102 @@
+defmodule Trento.UserIdentities do
+ @moduledoc """
+ The UserIdentities context, serves as custom context for PowAssent
+ """
+ require Logger
+
+ use PowAssent.Ecto.UserIdentities.Context,
+ repo: Trento.Repo,
+ user: Trento.Users.User
+
+ import Ecto.Query, warn: false
+
+ alias Trento.Abilities.UsersAbilities
+ alias Trento.Repo
+ alias Trento.Users
+ alias Trento.Users.User
+
+ @impl true
+ @doc """
+ redefining the PowAssent create user method, this is called when the user login through idp and a user identity
+ does not exists on our database.
+
+ If a user with the same username exists on our database, the user will be recovered and associated with the idp identity,
+ otherwise the user will be created.
+ """
+ def create_user(user_identity_params, %{"username" => username} = user_params, user_id_params) do
+ existing_user = Users.get_by(username: username)
+ maybe_create_user(existing_user, user_identity_params, user_params, user_id_params)
+ end
+
+ @impl true
+ @doc """
+ redefining the PowAssent upsert method, if a IDP user is associated with a locked user,
+ this is called when the user login with IDP and exist in our database with or without a user identity
+ """
+ def upsert(%User{locked_at: locked_at} = user, _)
+ when not is_nil(locked_at),
+ do: {:error, {:user_not_allowed, user}}
+
+ def upsert(user, user_identity_params) do
+ pow_assent_upsert(maybe_assign_global_abilities(user), user_identity_params)
+ end
+
+ defp maybe_create_user(nil, user_identity_params, user_params, user_id_params) do
+ case pow_assent_create_user(user_identity_params, user_params, user_id_params) do
+ {:ok, %User{} = user} ->
+ {:ok, maybe_assign_global_abilities(user)}
+
+ error ->
+ error
+ end
+ end
+
+ defp maybe_create_user(user, _, _, _) do
+ {:ok, maybe_assign_global_abilities(user)}
+ end
+
+ defp maybe_assign_global_abilities(user) do
+ if admin_user?(user) do
+ {:ok, user} = assign_global_abilities(user)
+
+ user
+ else
+ user
+ end
+ end
+
+ # assign_global_abilities assigns the global ability to the admin user retrieved from oidc
+ # we don't use the Users context directly because it's forbidden to update an admin user.
+ # The only exception is in this particular flow, because it's strictly needed
+ defp assign_global_abilities(%User{} = user) do
+ result =
+ Ecto.Multi.new()
+ |> Ecto.Multi.put(:user, user)
+ |> Ecto.Multi.delete_all(
+ :delete_abilities,
+ fn %{user: %User{id: user_id}} ->
+ from(u in UsersAbilities, where: u.user_id == ^user_id)
+ end
+ )
+ |> Ecto.Multi.insert(:add_global_ability, fn %{user: %User{id: user_id}} ->
+ UsersAbilities.changeset(%UsersAbilities{}, %{user_id: user_id, ability_id: 1})
+ end)
+ |> Repo.transaction()
+
+ case result do
+ {:ok, %{user: %{id: user_id}}} ->
+ # reload the current user with full assoc
+ Users.get_user(user_id)
+
+ {:error, _, changeset_error, _} ->
+ Logger.error(
+ "could not assign the global abilities to the oidc admin user #{inspect(changeset_error)}"
+ )
+
+ {:error, :assign_global_abilities}
+ end
+ end
+
+ defp admin_user?(%User{username: username}),
+ do: username == Application.fetch_env!(:trento, :admin_user)
+end
diff --git a/lib/trento/user_identities/user_identity.ex b/lib/trento/user_identities/user_identity.ex
new file mode 100644
index 0000000000..4cf74299c1
--- /dev/null
+++ b/lib/trento/user_identities/user_identity.ex
@@ -0,0 +1,11 @@
+defmodule Trento.UserIdentities.UserIdentity do
+ @moduledoc false
+ use Ecto.Schema
+ use PowAssent.Ecto.UserIdentities.Schema, user: Trento.Users.User
+
+ schema "user_identities" do
+ pow_assent_user_identity_fields()
+
+ timestamps()
+ end
+end
diff --git a/lib/trento/users.ex b/lib/trento/users.ex
index e62c3b46e9..bb12353c4b 100644
--- a/lib/trento/users.ex
+++ b/lib/trento/users.ex
@@ -11,6 +11,8 @@ defmodule Trento.Users do
alias Trento.Repo
alias Trento.Abilities.UsersAbilities
+ alias Trento.UserIdentities.UserIdentity
+
alias Trento.Users.User
@impl true
@@ -31,6 +33,7 @@ defmodule Trento.Users do
User
|> where([u], is_nil(u.deleted_at))
|> preload(:abilities)
+ |> preload(:user_identities)
|> Repo.all()
end
@@ -38,6 +41,7 @@ defmodule Trento.Users do
case User
|> where([u], is_nil(u.deleted_at) and u.id == ^id)
|> preload(:abilities)
+ |> preload(:user_identities)
|> Repo.one() do
nil -> {:error, :not_found}
user -> {:ok, user}
@@ -52,7 +56,7 @@ defmodule Trento.Users do
result =
Ecto.Multi.new()
- |> Ecto.Multi.insert(:user, User.changeset(%User{}, updated_attrs))
+ |> Ecto.Multi.insert(:user, User.changeset(%User{user_identities: []}, updated_attrs))
|> insert_abilities_multi(abilities)
|> Repo.transaction()
@@ -71,7 +75,7 @@ defmodule Trento.Users do
|> maybe_set_locked_at()
|> maybe_set_password_change_requested_at(false)
- %User{abilities: []}
+ %User{abilities: [], user_identities: []}
|> User.changeset(updated_attrs)
|> Repo.insert()
end
@@ -123,16 +127,6 @@ defmodule Trento.Users do
def maybe_disable_totp(attrs), do: attrs
- def delete_user(%User{abilities: [], username: username} = user) do
- if username == admin_username() do
- {:error, :forbidden}
- else
- user
- |> User.delete_changeset(%{deleted_at: DateTime.utc_now()})
- |> Repo.update()
- end
- end
-
def delete_user(%User{username: username} = user) do
if username == admin_username() do
{:error, :forbidden}
@@ -144,6 +138,7 @@ defmodule Trento.Users do
User.delete_changeset(user, %{deleted_at: DateTime.utc_now()})
)
|> delete_abilities_multi()
+ |> delete_user_identities_multi()
|> Repo.transaction()
case result do
@@ -266,6 +261,16 @@ defmodule Trento.Users do
)
end
+ defp delete_user_identities_multi(multi) do
+ Ecto.Multi.delete_all(
+ multi,
+ :delete_user_identities,
+ fn %{user: %User{id: user_id}} ->
+ from(u in UserIdentity, where: u.user_id == ^user_id)
+ end
+ )
+ end
+
defp do_update(%User{username: username} = user, %{abilities: abilities} = attrs) do
if username == admin_username() do
{:error, :forbidden}
diff --git a/lib/trento/users/user.ex b/lib/trento/users/user.ex
index 3da1a85224..7469ae5089 100644
--- a/lib/trento/users/user.ex
+++ b/lib/trento/users/user.ex
@@ -13,6 +13,8 @@ defmodule Trento.Users.User do
alias EctoCommons.EmailValidator
+ use PowAssent.Ecto.Schema
+
alias Trento.Abilities.{
Ability,
UsersAbilities
@@ -52,11 +54,22 @@ defmodule Trento.Users.User do
|> cast(attrs, [:locked_at, :password_change_requested_at])
end
+ def user_identity_changeset(user_or_changeset, user_identity, attrs, user_id_attrs) do
+ username = Map.get(attrs, "username")
+
+ user_or_changeset
+ |> cast(attrs, [:username, :email])
+ |> put_change(
+ :fullname,
+ Map.get(attrs, "name", "Trento IDP User #{username}")
+ )
+ |> pow_assent_user_identity_changeset(user_identity, attrs, user_id_attrs)
+ end
+
def update_changeset(user, attrs) do
user
- |> pow_password_changeset(attrs)
+ |> maybe_apply_password_changesets(attrs)
|> pow_extension_changeset(attrs)
- |> validate_password()
|> custom_fields_changeset(attrs)
|> cast(attrs, [:locked_at, :lock_version, :password_change_requested_at, :totp_enabled_at])
|> validate_inclusion(:totp_enabled_at, [nil])
@@ -88,6 +101,16 @@ defmodule Trento.Users.User do
|> put_change(:email, "#{email}__#{deleted_at}")
end
+ # When the user has user identities associated, means that the user comes from an external IDP
+ # the password is not set in the user schema, so it should be skipped in updates.
+ defp maybe_apply_password_changesets(%{user_identities: []} = user, attrs) do
+ user
+ |> pow_password_changeset(attrs)
+ |> validate_password()
+ end
+
+ defp maybe_apply_password_changesets(user, _), do: user
+
defp validate_current_password(changeset, %{password: _password} = attrs),
do: pow_current_password_changeset(changeset, attrs)
diff --git a/lib/trento_web/controllers/page_controller.ex b/lib/trento_web/controllers/page_controller.ex
index 1033b07457..2a3ad41425 100644
--- a/lib/trento_web/controllers/page_controller.ex
+++ b/lib/trento_web/controllers/page_controller.ex
@@ -7,13 +7,31 @@ defmodule TrentoWeb.PageController do
deregistration_debounce = Application.fetch_env!(:trento, :deregistration_debounce)
suse_manager_enabled = Application.fetch_env!(:trento, :suse_manager_enabled)
admin_username = Application.fetch_env!(:trento, :admin_user)
+ oidc_enabled = Application.fetch_env!(:trento, :oidc)[:enabled]
+
+ %URI{path: oidc_callback_url} =
+ URI.parse(Application.fetch_env!(:trento, :oidc)[:callback_url])
render(conn, "index.html",
check_service_base_url: check_service_base_url,
charts_enabled: charts_enabled,
deregistration_debounce: deregistration_debounce,
suse_manager_enabled: suse_manager_enabled,
- admin_username: admin_username
+ admin_username: admin_username,
+ oidc_enabled: oidc_enabled,
+ oidc_login_url: oidc_login_url(conn, oidc_enabled),
+ oidc_callback_url: oidc_callback_url
)
end
+
+ defp oidc_login_url(conn, true) do
+ oidc_callback = Application.fetch_env!(:trento, :oidc)[:callback_url]
+
+ {:ok, url, _} =
+ PowAssent.Plug.authorize_url(conn, "oidc_local", oidc_callback)
+
+ url
+ end
+
+ defp oidc_login_url(_, _), do: ""
end
diff --git a/lib/trento_web/controllers/session_controller.ex b/lib/trento_web/controllers/session_controller.ex
index d394e704bd..775247ada8 100644
--- a/lib/trento_web/controllers/session_controller.ex
+++ b/lib/trento_web/controllers/session_controller.ex
@@ -1,9 +1,10 @@
defmodule TrentoWeb.SessionController do
+ alias Plug.Conn
+ alias PowAssent.Plug, as: PowAssentPlug
alias Trento.Repo
alias Trento.Users
alias Trento.Users.User
alias TrentoWeb.OpenApi.V1.Schema
-
alias TrentoWeb.Plugs.AppJWTAuthPlug
use TrentoWeb, :controller
@@ -11,6 +12,8 @@ defmodule TrentoWeb.SessionController do
action_fallback TrentoWeb.FallbackController
+ plug TrentoWeb.Plugs.ExternalIdpGuardPlug when action in [:create]
+
require Logger
operation :create,
@@ -174,6 +177,85 @@ defmodule TrentoWeb.SessionController do
end
end
+ operation :callback,
+ summary: "Platform external IDP callback",
+ tags: ["Platform"],
+ description: "Authenticate against an external authentication provider",
+ security: [],
+ request_body:
+ {"User IDP credentials", "application/json",
+ %OpenApiSpex.Schema{
+ title: "UserIDPEnrollmentCredentials",
+ type: :object,
+ example: %{
+ code: "kyLCJ1c2VyX2lkIjoxfQ.frHteBttgtW8706m7nqYC6ruYt",
+ session_sate: "frHteBttgtW8706m7nqYC6ruYt"
+ },
+ properties: %{
+ code: %OpenApiSpex.Schema{
+ type: :string
+ },
+ session_state: %OpenApiSpex.Schema{
+ type: :string
+ }
+ },
+ required: [:code, :session_state]
+ }},
+ parameters: [
+ provider: [
+ in: :path,
+ schema: %OpenApiSpex.Schema{type: :string},
+ required: true
+ ]
+ ],
+ responses: [
+ unauthorized: Schema.Unauthorized.response(),
+ ok:
+ {"User IDP credentials", "application/json",
+ %OpenApiSpex.Schema{
+ title: "UserIDPCredentials",
+ type: :object,
+ example: %{
+ access_token:
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0cmVudG8tcHJvamVjdCIsImV4cCI6MTY3MTU1NjY5MiwiaWF0IjoxNjcxNTQ5NDkyLCJpc3MiOiJodHRwczovL2dpdGh1Yi5jb20vdHJlbnRvLXByb2plY3Qvd2ViIiwianRpIjoiMnNwOGlxMmkxNnRlbHNycWE4MDAwMWM4IiwibmJmIjoxNjcxNTQ5NDkyLCJ1c2VyX2lkIjoxfQ.frHteBttgtW8706m7nqYC6ruYtTrbVcCEO_UgIkHn6A",
+ refresh_token:
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0cmVudG8tcHJvamVjdCIsImV4cCI6MTY3MTU1NjY5MiwiaWF0IjoxNjcxNTQ5NDkyLCJpc3MiOiJodHRwczovL2dpdGh1Yi5jb20vdHJlbnRvLXByb2plY3Qvd2ViIiwianRpIjoiMnNwOGlxMmkxNnRlbHNycWE4MDAwMWM4IiwibmJmIjoxNjcxNTQ5NDkyLCJ1c2VyX2lkIjoxfQ.frHteBttgtW8706m7nqYC6ruYtTrbVcCEO_UgIkHn6A"
+ },
+ properties: %{
+ access_token: %OpenApiSpex.Schema{
+ type: :string
+ },
+ refresh_token: %OpenApiSpex.Schema{
+ type: :string
+ }
+ }
+ }}
+ ]
+
+ def callback(%{body_params: body_params} = conn, %{"provider" => provider}) do
+ params = Map.drop(body_params, ["session_params"])
+ session_params = Map.get(body_params, "session_params")
+
+ conn
+ |> Conn.put_private(:pow_assent_session_params, session_params)
+ |> PowAssentPlug.callback_upsert(provider, params, idp_redirect_uri())
+ |> case do
+ {:ok, conn} ->
+ render(conn, "logged.json",
+ token: conn.private[:api_access_token],
+ expiration: conn.private[:access_token_expiration],
+ refresh_token: conn.private[:api_refresh_token]
+ )
+
+ {:error, %{private: %{pow_assent_callback_error: {:user_not_allowed, _}}}} ->
+ {:error, :invalid_credentials}
+
+ error ->
+ Logger.error("error during oidc callback execution: #{inspect(error)}")
+ error
+ end
+ end
+
defp authenticate_trento_user(conn, credentials) do
with {:ok, %{assigns: %{current_user: logged_user}} = conn} <-
Pow.Plug.authenticate_user(conn, credentials),
@@ -188,4 +270,6 @@ defmodule TrentoWeb.SessionController do
do: Users.validate_totp(user, totp_code)
defp maybe_validate_totp(_, _), do: {:error, :totp_code_missing}
+
+ defp idp_redirect_uri, do: Application.fetch_env!(:trento, :oidc)[:callback_url]
end
diff --git a/lib/trento_web/controllers/v1/profile_controller.ex b/lib/trento_web/controllers/v1/profile_controller.ex
index 2fc63ff9e4..e586286569 100644
--- a/lib/trento_web/controllers/v1/profile_controller.ex
+++ b/lib/trento_web/controllers/v1/profile_controller.ex
@@ -6,7 +6,11 @@ defmodule TrentoWeb.V1.ProfileController do
alias Trento.Users.User
alias TrentoWeb.OpenApi.V1.Schema
+ plug TrentoWeb.Plugs.ExternalIdpGuardPlug
+ when action in [:update, :reset_totp, :get_totp_enrollment_data, :confirm_totp_enrollment]
+
plug OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true
+
plug TrentoWeb.Plugs.LoadUserPlug
action_fallback TrentoWeb.FallbackController
diff --git a/lib/trento_web/controllers/v1/users_controller.ex b/lib/trento_web/controllers/v1/users_controller.ex
index b60b1bec04..f3837d5a7e 100644
--- a/lib/trento_web/controllers/v1/users_controller.ex
+++ b/lib/trento_web/controllers/v1/users_controller.ex
@@ -14,6 +14,8 @@ defmodule TrentoWeb.V1.UsersController do
UserUpdateRequest
}
+ plug TrentoWeb.Plugs.ExternalIdpGuardPlug when action in [:create]
+
plug TrentoWeb.Plugs.LoadUserPlug
plug Bodyguard.Plug.Authorize,
@@ -128,6 +130,7 @@ defmodule TrentoWeb.V1.UsersController do
def update(%{body_params: body_params} = conn, %{id: id}) do
with {:ok, user} <- Users.get_user(id),
{:ok, lock_version} <- user_version_from_if_match_header(conn),
+ body_params <- clean_params_for_oidc_integration(body_params, oidc_enabled?()),
update_params <- Map.put(body_params, :lock_version, lock_version),
{:ok, %User{} = user} <- Users.update_user(user, update_params),
:ok <- broadcast_update_or_locked_user(user),
@@ -177,4 +180,10 @@ defmodule TrentoWeb.V1.UsersController do
defp attach_user_version_as_etag_header(conn, %User{lock_version: lock_version}) do
put_resp_header(conn, "etag", Integer.to_string(lock_version))
end
+
+ # when oidc is enabled, we only allow abilities as parameter
+ defp clean_params_for_oidc_integration(attrs, true), do: Map.take(attrs, [:abilities, :enabled])
+ defp clean_params_for_oidc_integration(attrs, _), do: attrs
+
+ defp oidc_enabled?, do: Application.fetch_env!(:trento, :oidc)[:enabled]
end
diff --git a/lib/trento_web/openapi/v1/schema/user.ex b/lib/trento_web/openapi/v1/schema/user.ex
index a74f20e148..7ad50b76c2 100644
--- a/lib/trento_web/openapi/v1/schema/user.ex
+++ b/lib/trento_web/openapi/v1/schema/user.ex
@@ -93,6 +93,11 @@ defmodule TrentoWeb.OpenApi.V1.Schema.User do
nullable: false,
format: :email
},
+ idp_user: %Schema{
+ type: :boolean,
+ description: "User coming from an external IDP",
+ nullable: false
+ },
abilities: AbilityCollection,
password_change_requested: %Schema{
type: :boolean,
@@ -270,6 +275,11 @@ defmodule TrentoWeb.OpenApi.V1.Schema.User do
description: "User enabled in the system",
nullable: false
},
+ idp_user: %Schema{
+ type: :boolean,
+ description: "User coming from an external IDP",
+ nullable: false
+ },
abilities: AbilityCollection,
password_change_requested_at: %OpenApiSpex.Schema{
type: :string,
diff --git a/lib/trento_web/plugs/external_idp_guard_plug.ex b/lib/trento_web/plugs/external_idp_guard_plug.ex
new file mode 100644
index 0000000000..7a68e593e1
--- /dev/null
+++ b/lib/trento_web/plugs/external_idp_guard_plug.ex
@@ -0,0 +1,35 @@
+defmodule TrentoWeb.Plugs.ExternalIdpGuardPlug do
+ @moduledoc """
+ This plug acts as a guard for certain actions/endpoint to disable them when an external idp integration is enabled
+ """
+ @behaviour Plug
+
+ alias TrentoWeb.ErrorView
+
+ import Plug.Conn
+
+ @impl true
+ def init(opts) do
+ Keyword.put(opts, :external_idp_enabled, oidc_enabled?())
+ end
+
+ @impl true
+ @spec call(Plug.Conn.t(), keyword()) :: Plug.Conn.t()
+ def call(conn, external_idp_enabled: true) do
+ conn
+ |> put_resp_content_type("application/json")
+ |> resp(
+ 501,
+ Jason.encode!(
+ ErrorView.render("501.json", %{
+ reason: "Endpoint disabled due an external IDP is enabled"
+ })
+ )
+ )
+ |> halt()
+ end
+
+ def call(conn, _), do: conn
+
+ defp oidc_enabled?, do: Application.fetch_env!(:trento, :oidc)[:enabled]
+end
diff --git a/lib/trento_web/router.ex b/lib/trento_web/router.ex
index b84381f0b0..bebb0bcf97 100644
--- a/lib/trento_web/router.ex
+++ b/lib/trento_web/router.ex
@@ -1,6 +1,7 @@
defmodule TrentoWeb.Router do
use TrentoWeb, :router
use Pow.Phoenix.Router
+ use PowAssent.Phoenix.Router
# From newest to oldest
@available_api_versions ["v2", "v1"]
@@ -65,6 +66,7 @@ defmodule TrentoWeb.Router do
post "/session", SessionController, :create, as: :login
post "/session/refresh", SessionController, :refresh, as: :refresh
+ post "/session/:provider/callback", SessionController, :callback
end
scope "/feature-flags" do
diff --git a/lib/trento_web/templates/page/index.html.heex b/lib/trento_web/templates/page/index.html.heex
index 691fa4bae1..5b60a50f06 100644
--- a/lib/trento_web/templates/page/index.html.heex
+++ b/lib/trento_web/templates/page/index.html.heex
@@ -6,7 +6,10 @@
deregistrationDebounce: <%= @deregistration_debounce %>,
chartsEnabled: <%= @charts_enabled %>,
suseManagerEnabled: <%= @suse_manager_enabled %>,
- adminUsername: '<%= @admin_username %>'
+ adminUsername: '<%= @admin_username %>',
+ oidcEnabled: <%= @oidc_enabled %>,
+ oidcLoginUrl: '<%= raw @oidc_login_url %>',
+ oidcCallbackUrl: '<%= raw @oidc_callback_url %>',
};
diff --git a/lib/trento_web/views/v1/profile_view.ex b/lib/trento_web/views/v1/profile_view.ex
index 8911b36c81..31c8701687 100644
--- a/lib/trento_web/views/v1/profile_view.ex
+++ b/lib/trento_web/views/v1/profile_view.ex
@@ -12,6 +12,7 @@ defmodule TrentoWeb.V1.ProfileView do
abilities: abilities,
password_change_requested_at: password_change_requested_at,
totp_enabled_at: totp_enabled_at,
+ user_identities: user_identities,
inserted_at: created_at,
updated_at: updated_at
}
@@ -25,6 +26,7 @@ defmodule TrentoWeb.V1.ProfileView do
password_change_requested: password_change_requested_at != nil,
totp_enabled: totp_enabled_at != nil,
created_at: created_at,
+ idp_user: length(user_identities) > 0,
updated_at: updated_at
}
end
diff --git a/lib/trento_web/views/v1/users_view.ex b/lib/trento_web/views/v1/users_view.ex
index 42311784f1..e3e89edf43 100644
--- a/lib/trento_web/views/v1/users_view.ex
+++ b/lib/trento_web/views/v1/users_view.ex
@@ -20,6 +20,7 @@ defmodule TrentoWeb.V1.UsersView do
abilities: abilities,
locked_at: locked_at,
password_change_requested_at: password_change_requested_at,
+ user_identities: user_identities,
totp_enabled_at: totp_enabled_at,
inserted_at: created_at,
updated_at: updated_at
@@ -32,6 +33,7 @@ defmodule TrentoWeb.V1.UsersView do
email: email,
abilities: render_many(abilities, AbilityView, "ability.json", as: :ability),
enabled: locked_at == nil,
+ idp_user: length(user_identities) > 0,
password_change_requested_at: password_change_requested_at,
totp_enabled_at: totp_enabled_at,
created_at: created_at,
diff --git a/mix.exs b/mix.exs
index 38de82c847..b98370d973 100644
--- a/mix.exs
+++ b/mix.exs
@@ -117,7 +117,8 @@ defmodule Trento.MixProject do
{:bodyguard, "~> 2.4"},
{:nimble_totp, "~> 1.0"},
{:phoenix_view, "~> 2.0"},
- {:phoenix_html_helpers, "~> 1.0"}
+ {:phoenix_html_helpers, "~> 1.0"},
+ {:pow_assent, "~> 0.4.18"}
]
end
diff --git a/mix.lock b/mix.lock
index 2e819aacc6..1f0e03ab96 100644
--- a/mix.lock
+++ b/mix.lock
@@ -2,6 +2,7 @@
"amqp": {:hex, :amqp, "3.3.0", "056d9f4bac96c3ab5a904b321e70e78b91ba594766a1fc2f32afd9c016d9f43b", [:mix], [{:amqp_client, "~> 3.9", [hex: :amqp_client, repo: "hexpm", optional: false]}], "hexpm", "8d3ae139d2646c630d674a1b8d68c7f85134f9e8b2a1c3dd5621616994b10a8b"},
"amqp_client": {:hex, :amqp_client, "3.12.14", "2b677bc3f2e2234ba7517042b25d72071a79735042e91f9116bd3c176854b622", [:make, :rebar3], [{:credentials_obfuscation, "3.4.0", [hex: :credentials_obfuscation, repo: "hexpm", optional: false]}, {:rabbit_common, "3.12.14", [hex: :rabbit_common, repo: "hexpm", optional: false]}], "hexpm", "5f70b6c3b1a739790080da4fddc94a867e99f033c4b1edc20d6ff8b8fb4bd160"},
"argon2_elixir": {:hex, :argon2_elixir, "4.0.0", "7f6cd2e4a93a37f61d58a367d82f830ad9527082ff3c820b8197a8a736648941", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f9da27cf060c9ea61b1bd47837a28d7e48a8f6fa13a745e252556c14f9132c7f"},
+ "assent": {:hex, :assent, "0.2.10", "27e544c3428996c8ad744d473b3ceae86e4eb7db6bc7432676420e67e9148dd7", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "8483bf9621e994795a70a4ad8fda725abfb6a9675d63a9bfd4217c76d4a2d82a"},
"backoff": {:hex, :backoff, "1.1.6", "83b72ed2108ba1ee8f7d1c22e0b4a00cfe3593a67dbc792799e8cce9f42f796b", [:rebar3], [], "hexpm", "cf0cfff8995fb20562f822e5cc47d8ccf664c5ecdc26a684cbe85c225f9d7c39"},
"bodyguard": {:hex, :bodyguard, "2.4.3", "5faec1c7a346b3a6bac0b63aa3b2ae05b8dab6a5e4f8384e21577710498f8b56", [:mix], [{:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "5bb6bcc04871e18d97da5822a4d5d25ec38158447cb767b2eea3e2eb99cdc351"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
@@ -86,6 +87,7 @@
"polymorphic_embed": {:hex, :polymorphic_embed, "4.1.1", "1a93d0c9184eb5a50c58578d0dfe4b05045d9a86bbcc7cd142114f3c4393c439", [:mix], [{:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.19", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "3f3d2354d2a86a180cf0415273bb21ac134615c005d1b2537e7c6708eee21fcc"},
"postgrex": {:hex, :postgrex, "0.18.0", "f34664101eaca11ff24481ed4c378492fed2ff416cd9b06c399e90f321867d7e", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a042989ba1bc1cca7383ebb9e461398e3f89f868c92ce6671feb7ef132a252d1"},
"pow": {:hex, :pow, "1.0.38", "21ad368917e8823ba3cd7f91f765ecc28ac172aac1351a933ef213462162ae1a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0 and < 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 2.0.0 and < 5.0.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.5.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "6d7418a740cb15838d77bdbad3eaae34690eda6b48d671ca4ed90dd048974461"},
+ "pow_assent": {:hex, :pow_assent, "0.4.18", "8f3d333c99bc1333a31e6939c0784c966cc262145f523fa31a86fd2d10cf8ff2", [:mix], [{:assent, "~> 0.2.8", [hex: :assent, repo: "hexpm", optional: false]}, {:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0 and < 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 2.0.0 and <= 5.0.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.18.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.5.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:pow, "~> 1.0.29", [hex: :pow, repo: "hexpm", optional: false]}], "hexpm", "3214be0680e4f9870ddf183019ce86d24ce0a676799f435b234fe6300287054a"},
"proper_case": {:hex, :proper_case, "1.3.1", "5f51cabd2d422a45f374c6061b7379191d585b5154456b371432d0fa7cb1ffda", [:mix], [], "hexpm", "6cc715550cc1895e61608060bbe67aef0d7c9cf55d7ddb013c6d7073036811dd"},
"protobuf": {:hex, :protobuf, "0.12.0", "58c0dfea5f929b96b5aa54ec02b7130688f09d2de5ddc521d696eec2a015b223", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "75fa6cbf262062073dd51be44dd0ab940500e18386a6c4e87d5819a58964dc45"},
"quantum": {:hex, :quantum, "3.5.3", "ee38838a07761663468145f489ad93e16a79440bebd7c0f90dc1ec9850776d99", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "500fd3fa77dcd723ed9f766d4a175b684919ff7b6b8cfd9d7d0564d58eba8734"},
diff --git a/priv/repo/migrations/20240730085711_create_user_identities.exs b/priv/repo/migrations/20240730085711_create_user_identities.exs
new file mode 100644
index 0000000000..09ab84f055
--- /dev/null
+++ b/priv/repo/migrations/20240730085711_create_user_identities.exs
@@ -0,0 +1,15 @@
+defmodule Trento.Repo.Migrations.CreateUserIdentities do
+ use Ecto.Migration
+
+ def change do
+ create table(:user_identities) do
+ add :provider, :string, null: false
+ add :uid, :string, null: false
+ add :user_id, references("users", on_delete: :nothing)
+
+ timestamps()
+ end
+
+ create unique_index(:user_identities, [:uid, :provider])
+ end
+end
diff --git a/test/e2e/cypress.config.js b/test/e2e/cypress.config.js
index 3dbe54e901..6ca0a4ce4a 100644
--- a/test/e2e/cypress.config.js
+++ b/test/e2e/cypress.config.js
@@ -5,16 +5,17 @@ module.exports = defineConfig({
viewportHeight: 768,
defaultCommandTimeout: 10000,
env: {
- web_api_host: '127.0.0.1',
+ web_api_host: 'localhost',
web_api_port: 4000,
heartbeat_interval: 5000,
- db_host: '127.0.0.1',
+ db_host: 'localhost',
db_port: 5432,
project_root: '../..',
photofinish_binary: 'photofinish',
login_user: 'admin',
login_password: 'adminpassword',
destination_environment: 'dev',
+ oidc_url: 'http://localhost:8081',
},
e2e: {
// We've imported your old cypress plugins here.
@@ -23,6 +24,6 @@ module.exports = defineConfig({
return require('./cypress/plugins/index.js')(on, config);
},
testIsolation: false,
- baseUrl: 'http://127.0.0.1:4000',
+ baseUrl: 'http://localhost:4000',
},
});
diff --git a/test/e2e/cypress/e2e/oidc_integration.cy.js b/test/e2e/cypress/e2e/oidc_integration.cy.js
new file mode 100644
index 0000000000..5db530292a
--- /dev/null
+++ b/test/e2e/cypress/e2e/oidc_integration.cy.js
@@ -0,0 +1,108 @@
+import { adminUser, plainUser } from '../fixtures/oidc-integration/users';
+
+const loginWithOIDC = (username, password) => {
+ const args = [username, password];
+ cy.session(args, () => {
+ cy.visit('/');
+ cy.get('button').contains('Login with Single Sign-on').click();
+ cy.origin(Cypress.env('oidc_url'), { args }, ([username, password]) => {
+ cy.get('[id="username"]').type(username);
+ cy.get('[id="password"]').type(password);
+ cy.get('input').contains('Sign In').click();
+ });
+
+ cy.url().should('contain', '/auth/oidc_callback');
+ cy.get('h2').contains('Loading...');
+ cy.get('h1').contains('At a glance');
+ });
+};
+
+describe('OIDC integration', () => {
+ if (!Cypress.env('OIDC_INTEGRATION_TESTS')) {
+ return;
+ }
+
+ before(() => {
+ cy.clearAllLocalStorage();
+ cy.clearAllCookies();
+ });
+
+ it('should display Single Sign-on login page', () => {
+ cy.visit('/');
+ cy.get('h2').contains('Login to Trento');
+ cy.get('button').contains('Login with Single Sign-on');
+ });
+
+ it('should redirect to external IDP login page when login button is clicked', () => {
+ cy.get('button').contains('Login with Single Sign-on').click();
+ cy.origin(Cypress.env('oidc_url'), () => {
+ cy.url().should('contain', '/realms/trento');
+ });
+ });
+
+ it('should login properly once authentication is completed', () => {
+ loginWithOIDC(plainUser.username, plainUser.password);
+ cy.get('span').contains(plainUser.username);
+ });
+
+ describe('Plain user', () => {
+ beforeEach(() => {
+ loginWithOIDC(plainUser.username, plainUser.password);
+ });
+
+ it('should have a read only profile view and empty list of permissions', () => {
+ cy.visit('/profile');
+ cy.get('input').eq(0).should('have.value', plainUser.fullname);
+ cy.get('input').eq(1).should('have.value', plainUser.email);
+ cy.get('input').eq(2).should('have.value', plainUser.username);
+ });
+
+ it('should be able to logout and login without a new authentication request', () => {
+ cy.get('span').contains(plainUser.username).click();
+ cy.get('button').contains('Sign out').click();
+ cy.get('button').contains('Login with Single Sign-on').click();
+ cy.get('h2').contains('Loading...');
+ cy.get('h1').contains('At a glance');
+ });
+ });
+
+ describe('Admin user', () => {
+ beforeEach(() => {
+ loginWithOIDC(adminUser.username, adminUser.password);
+ });
+
+ it('should have access to Users view', () => {
+ cy.visit('/users');
+ cy.url().should('include', '/users');
+ cy.get('a').contains(plainUser.username);
+ cy.get('a').contains(adminUser.username);
+ });
+
+ it('should not have user creation button', () => {
+ cy.get('button').contains('Create User').should('not.exist');
+ });
+
+ it('should have the ability to update user permissions and status', () => {
+ cy.get('a').contains(plainUser.username).click();
+ cy.get('div').contains('Default').click({ force: true });
+ cy.get('div').contains('all:users').click();
+ cy.get('div').contains('Enabled').click();
+ cy.get('div').contains('Disabled').click();
+ cy.get('button').contains('Save').click();
+
+ cy.get('a').contains(plainUser.username).click();
+ cy.get('div').contains('all:users').parent().find('svg').click();
+ cy.get('div').contains('Disabled').click();
+ cy.get('div').contains('Enabled').click();
+ cy.get('button').contains('Save').click();
+ });
+
+ it('should have a read only profile view and all:all permissions', () => {
+ cy.visit('/profile');
+ cy.get('input').eq(0).should('have.value', adminUser.fullname);
+ cy.get('input').eq(1).should('have.value', adminUser.email);
+ cy.get('input').eq(2).should('have.value', adminUser.username);
+ cy.get('div').contains(adminUser.permissions);
+ });
+ });
+});
diff --git a/test/e2e/cypress/fixtures/oidc-integration/users.js b/test/e2e/cypress/fixtures/oidc-integration/users.js
new file mode 100644
index 0000000000..cab3524209
--- /dev/null
+++ b/test/e2e/cypress/fixtures/oidc-integration/users.js
@@ -0,0 +1,14 @@
+export const plainUser = {
+ username: 'trentoidp',
+ password: 'password',
+ fullname: 'Trento IDP user Of Monk',
+ email: 'trentoidp@trento.suse.com',
+};
+
+export const adminUser = {
+ username: 'admin',
+ password: 'admin',
+ fullname: 'Trento Admin',
+ email: 'admin@trento.suse.com',
+ permissions: 'all:all',
+};
diff --git a/test/e2e/cypress/support/e2e.js b/test/e2e/cypress/support/e2e.js
index ff55e8ddd3..86fb901a27 100644
--- a/test/e2e/cypress/support/e2e.js
+++ b/test/e2e/cypress/support/e2e.js
@@ -24,5 +24,7 @@ import './commands';
before(() => {
Cypress.session.clearAllSavedSessions();
- cy.login();
+ if (!Cypress.env('OIDC_INTEGRATION_TESTS')) {
+ cy.login();
+ }
});
diff --git a/test/support/factory.ex b/test/support/factory.ex
index ff58128d2f..652809a3b4 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -136,6 +136,7 @@ defmodule Trento.Factory do
}
alias Trento.SoftwareUpdates.Discovery.DiscoveryResult
+ alias Trento.UserIdentities.UserIdentity
alias Trento.Users.User
use ExMachina.Ecto, repo: Trento.Repo
@@ -1077,4 +1078,12 @@ defmodule Trento.Factory do
ability_id: 1
}
end
+
+ def user_identity_factory do
+ %UserIdentity{
+ user_id: 1,
+ uid: Faker.UUID.v4(),
+ provider: Faker.Pokemon.name()
+ }
+ end
end
diff --git a/test/trento/user_identities_test.exs b/test/trento/user_identities_test.exs
new file mode 100644
index 0000000000..cc5a6633d1
--- /dev/null
+++ b/test/trento/user_identities_test.exs
@@ -0,0 +1,68 @@
+defmodule Trento.UserIdentitiesTest do
+ use Trento.DataCase
+
+ alias Trento.UserIdentities
+ alias Trento.Users
+ alias Trento.Users.User
+
+ import Trento.Factory
+
+ describe "create_user/3" do
+ test "should create the user and assign the global abilities when the user is the default admin" do
+ %{username: username} = user = build(:user)
+ Application.put_env(:trento, :admin_user, username)
+
+ user_id_params = nil
+
+ user_identity_params = %{
+ "provider" => "test_provider",
+ "token" => %{"access_token" => "access_token"},
+ "uid" => Faker.UUID.v4(),
+ "userinfo" => %{
+ "email" => user.email,
+ "sid" => nil,
+ "sub" => user.username,
+ "username" => user.username
+ }
+ }
+
+ user_params = %{
+ "email" => user.email,
+ "sid" => nil,
+ "username" => user.username
+ }
+
+ assert {:ok, %User{id: user_id}} =
+ UserIdentities.create_user(user_identity_params, user_params, user_id_params)
+
+ {:ok, %{abilities: abilities}} = Users.get_user(user_id)
+ assert [%{id: 1}] = abilities
+
+ Application.put_env(:trento, :admin_user, "admin")
+ end
+ end
+
+ describe "upsert/2" do
+ test "should assign the global abilities to a user when the user is the default admin" do
+ %{id: user_id, username: username} = user = insert(:user)
+ Application.put_env(:trento, :admin_user, username)
+
+ {:ok, _} =
+ UserIdentities.upsert(user, %{"uid" => user_id, "provider" => "test_provider"})
+
+ {:ok, %{abilities: abilities}} = Users.get_user(user_id)
+ assert [%{id: 1}] = abilities
+ Application.put_env(:trento, :admin_user, "admin")
+ end
+
+ test "should not assign the global abilities to a user when the user is not the default admin" do
+ %{id: user_id} = user = insert(:user)
+
+ {:ok, _} =
+ UserIdentities.upsert(user, %{"uid" => user_id, "provider" => "test_provider"})
+
+ {:ok, %{abilities: abilities}} = Users.get_user(user_id)
+ assert [] = abilities
+ end
+ end
+end
diff --git a/test/trento/users_test.exs b/test/trento/users_test.exs
index b0becc882e..7f1f69ab78 100644
--- a/test/trento/users_test.exs
+++ b/test/trento/users_test.exs
@@ -1,4 +1,5 @@
defmodule Trento.UsersTest do
+ alias Trento.UserIdentities.UserIdentity
use Trento.DataCase
alias Trento.Abilities.{
@@ -100,10 +101,19 @@ defmodule Trento.UsersTest do
%{id: user_id} = insert(:user)
%{id: ability_id} = insert(:ability)
insert(:users_abilities, user_id: user_id, ability_id: ability_id)
+ %{id: identity_id} = insert(:user_identity, user_id: user_id)
insert(:user, deleted_at: DateTime.utc_now())
users = Users.list_users()
- assert [%User{id: ^user_id, abilities: [%{id: ^ability_id}]}] = users
+
+ assert [
+ %User{
+ id: ^user_id,
+ user_identities: [%{id: ^identity_id}],
+ abilities: [%{id: ^ability_id}]
+ }
+ ] = users
+
assert length(users) == 1
end
@@ -127,6 +137,20 @@ defmodule Trento.UsersTest do
assert {:ok, %User{id: ^user_id, abilities: [%{id: ^ability_id}]}} = Users.get_user(user_id)
end
+ test "get_user return a user with the user identities" do
+ %{id: user_id} = insert(:user)
+ %{id: ability_id} = insert(:ability)
+ insert(:users_abilities, user_id: user_id, ability_id: ability_id)
+ %{id: identity_id} = insert(:user_identity, user_id: user_id)
+
+ assert {:ok,
+ %User{
+ id: ^user_id,
+ user_identities: [%{id: ^identity_id}],
+ abilities: [%{id: ^ability_id}]
+ }} = Users.get_user(user_id)
+ end
+
test "create_user with valid data creates a user" do
assert {:ok, %User{} = user} =
Users.create_user(%{
@@ -413,6 +437,34 @@ defmodule Trento.UsersTest do
assert user.totp_enabled_at == nil
end
+ test "update_user/2 does not care for password validation when a user has user_identities associated" do
+ user_attrs = %{
+ "email" => Faker.Internet.email(),
+ "sub" => Faker.Internet.user_name(),
+ "username" => Faker.Internet.user_name()
+ }
+
+ user_identity_params = %{
+ "provider" => "test_provider",
+ "token" => %{"access_token" => "access_token"},
+ "uid" => Faker.UUID.v4(),
+ "userinfo" => %{
+ "email" => user_attrs["email"],
+ "sid" => nil,
+ "sub" => user_attrs["username"],
+ "username" => user_attrs["username"]
+ }
+ }
+
+ # we create the user with the user identity changeset
+ {:ok, %User{} = user} =
+ Trento.Repo.insert(
+ User.user_identity_changeset(%User{}, user_identity_params, user_attrs, %{})
+ )
+
+ assert {:ok, %User{}} = Users.update_user(user, %{})
+ end
+
test "delete_user/2 does not delete user with id 1" do
assert {:error, :forbidden} = Users.delete_user(%User{username: admin_username()})
end
@@ -448,6 +500,19 @@ defmodule Trento.UsersTest do
assert [] == Trento.Repo.all(from u in UsersAbilities, where: u.user_id == ^user_id)
end
+ test "delete_user/1 deletes user identities" do
+ %{id: user_id} = user = insert(:user)
+ insert(:user_identity, user_id: user_id)
+
+ assert {:ok, %User{}} = Users.delete_user(user)
+
+ %User{deleted_at: deleted_at} =
+ Trento.Repo.get_by!(User, id: user_id)
+
+ refute deleted_at == nil
+ assert [] == Trento.Repo.all(from u in UserIdentity, where: u.user_id == ^user_id)
+ end
+
test "reset_totp/1 reset user topt values" do
user =
insert(:user, %{
diff --git a/test/trento_web/controllers/session_controller_test.exs b/test/trento_web/controllers/session_controller_test.exs
index c2de380016..b83b0bc08e 100644
--- a/test/trento_web/controllers/session_controller_test.exs
+++ b/test/trento_web/controllers/session_controller_test.exs
@@ -5,10 +5,12 @@ defmodule TrentoWeb.SessionControllerTest do
import Mox
import OpenApiSpex.TestAssertions
+ import Trento.Factory
alias TrentoWeb.Auth.RefreshToken
alias TrentoWeb.OpenApi.V1.ApiSpec
+ alias Trento.Users
alias Trento.Users.User
setup [:set_mox_from_context, :verify_on_exit!]
@@ -388,5 +390,251 @@ defmodule TrentoWeb.SessionControllerTest do
|> json_response(200)
|> assert_schema("Credentials", api_spec)
end
+
+ test "should return 501 if external IDP integration is enabled", %{conn: conn} do
+ Application.put_env(:trento, :oidc, enabled: true)
+
+ conn =
+ post(conn, "/api/session", %{
+ "username" => "trento_user",
+ "password" => "testpassword"
+ })
+
+ json_response(conn, 501)
+
+ Application.put_env(:trento, :oidc, enabled: false)
+ end
+ end
+
+ describe "callback endpoint" do
+ defmodule TestProvider do
+ @moduledoc false
+ @behaviour Assent.Strategy
+
+ @impl true
+ def authorize_url(config) do
+ case config[:error] do
+ nil ->
+ {:ok, %{url: "https://provider.example.com/oauth/authorize", session_params: %{a: 1}}}
+
+ error ->
+ {:error, error}
+ end
+ end
+
+ @impl true
+ def callback(config, _params) do
+ user = Keyword.get(config, :test_user)
+
+ {:ok,
+ %{
+ uid: user.username,
+ user: %{
+ "sub" => user.username,
+ "sid" => user.id,
+ "email" => user.email,
+ "username" => user.username
+ },
+ token: %{"access_token" => "access_token"}
+ }}
+ end
+ end
+
+ setup %{conn: conn} = context do
+ conn =
+ conn
+ |> Plug.Conn.put_private(:plug_session, %{})
+ |> Plug.Conn.put_private(:plug_session_fetch, :done)
+ |> Pow.Plug.put_config(otp_app: :trento)
+
+ Map.put(context, :conn, conn)
+ end
+
+ test "should return the credentials when the oidc callback flow is completed without errors and the user does not exist on trento",
+ %{conn: conn, api_spec: api_spec} do
+ user = build(:user)
+
+ expect(
+ Joken.CurrentTime.Mock,
+ :current_time,
+ 6,
+ fn ->
+ 1_671_715_992
+ end
+ )
+
+ Application.put_env(:trento, :pow_assent,
+ user_identities_context: Trento.UserIdentities,
+ providers: [
+ test_provider: [strategy: TestProvider, test_user: user]
+ ]
+ )
+
+ valid_params = %{"code" => "valid", "session_params" => %{"a" => 1}}
+
+ conn = post(conn, ~p"/api/session/test_provider/callback?#{valid_params}")
+
+ conn
+ |> json_response(200)
+ |> assert_schema("Credentials", api_spec)
+ end
+
+ test "should return the credentials when the oidc callback flow is completed without errors and the user exists on trento but without an associated user identity",
+ %{conn: conn, api_spec: api_spec} do
+ user = insert(:user)
+
+ expect(
+ Joken.CurrentTime.Mock,
+ :current_time,
+ 6,
+ fn ->
+ 1_671_715_992
+ end
+ )
+
+ Application.put_env(:trento, :pow_assent,
+ user_identities_context: Trento.UserIdentities,
+ providers: [
+ test_provider: [strategy: TestProvider, test_user: user]
+ ]
+ )
+
+ valid_params = %{"code" => "valid", "session_params" => %{"a" => 1}}
+
+ conn = post(conn, ~p"/api/session/test_provider/callback?#{valid_params}")
+
+ conn
+ |> json_response(200)
+ |> assert_schema("Credentials", api_spec)
+ end
+
+ test "should return the credentials when the oidc callback flow is completed without errors and the user does exist on trento",
+ %{conn: conn, api_spec: api_spec} do
+ user = insert(:user)
+
+ expect(
+ Joken.CurrentTime.Mock,
+ :current_time,
+ 6,
+ fn ->
+ 1_671_715_992
+ end
+ )
+
+ Application.put_env(:trento, :pow_assent,
+ user_identities_context: Trento.UserIdentities,
+ providers: [
+ test_provider: [strategy: TestProvider, test_user: user]
+ ]
+ )
+
+ valid_params = %{"code" => "valid", "session_params" => %{"a" => 1}}
+
+ conn =
+ conn
+ |> Pow.Plug.assign_current_user(user, Pow.Plug.fetch_config(conn))
+ |> post(~p"/api/session/test_provider/callback?#{valid_params}")
+
+ conn
+ |> json_response(200)
+ |> assert_schema("Credentials", api_spec)
+ end
+
+ test "should return the credentials when the oidc callback flow is completed without errors and assign the global abilities when the oidc user is the default admin and does not exists on trento",
+ %{conn: conn, api_spec: api_spec} do
+ %{username: username} = user = build(:user)
+ Application.put_env(:trento, :admin_user, username)
+
+ expect(
+ Joken.CurrentTime.Mock,
+ :current_time,
+ 6,
+ fn ->
+ 1_671_715_992
+ end
+ )
+
+ Application.put_env(:trento, :pow_assent,
+ user_identities_context: Trento.UserIdentities,
+ providers: [
+ test_provider: [strategy: TestProvider, test_user: user]
+ ]
+ )
+
+ valid_params = %{"code" => "valid", "session_params" => %{"a" => 1}}
+
+ conn = post(conn, ~p"/api/session/test_provider/callback?#{valid_params}")
+
+ conn
+ |> json_response(200)
+ |> assert_schema("Credentials", api_spec)
+
+ %User{id: user_id} = Users.get_by(username: username)
+ {:ok, %User{abilities: abilities}} = Users.get_user(user_id)
+ assert [%{id: 1}] = abilities
+
+ Application.put_env(:trento, :admin_user, "admin")
+ end
+
+ test "should return the credentials when the oidc callback flow is completed without errors and assign the global abilities when the oidc user is the default admin and already exists on trento",
+ %{conn: conn, api_spec: api_spec} do
+ %{username: username, id: user_id} = user = insert(:user)
+ Application.put_env(:trento, :admin_user, username)
+
+ expect(
+ Joken.CurrentTime.Mock,
+ :current_time,
+ 6,
+ fn ->
+ 1_671_715_992
+ end
+ )
+
+ Application.put_env(:trento, :pow_assent,
+ user_identities_context: Trento.UserIdentities,
+ providers: [
+ test_provider: [strategy: TestProvider, test_user: user]
+ ]
+ )
+
+ valid_params = %{"code" => "valid", "session_params" => %{"a" => 1}}
+
+ conn =
+ conn
+ |> Pow.Plug.assign_current_user(user, Pow.Plug.fetch_config(conn))
+ |> post(~p"/api/session/test_provider/callback?#{valid_params}")
+
+ conn
+ |> json_response(200)
+ |> assert_schema("Credentials", api_spec)
+
+ {:ok, %User{abilities: abilities}} = Users.get_user(user_id)
+ assert [%{id: 1}] = abilities
+
+ Application.put_env(:trento, :admin_user, "admin")
+ end
+
+ test "should return unauthorized when the oidc callback flow is completed without errors and the user is locked on trento",
+ %{conn: conn, api_spec: api_spec} do
+ user = insert(:user, locked_at: DateTime.utc_now())
+
+ Application.put_env(:trento, :pow_assent,
+ user_identities_context: Trento.UserIdentities,
+ providers: [
+ test_provider: [strategy: TestProvider, test_user: user]
+ ]
+ )
+
+ valid_params = %{"code" => "valid", "session_params" => %{"a" => 1}}
+
+ conn =
+ conn
+ |> Pow.Plug.assign_current_user(user, Pow.Plug.fetch_config(conn))
+ |> post(~p"/api/session/test_provider/callback?#{valid_params}")
+
+ conn
+ |> json_response(401)
+ |> assert_schema("Unauthorized", api_spec)
+ end
end
end
diff --git a/test/trento_web/controllers/v1/profile_controller_test.exs b/test/trento_web/controllers/v1/profile_controller_test.exs
index 9affcefe4a..451604147f 100644
--- a/test/trento_web/controllers/v1/profile_controller_test.exs
+++ b/test/trento_web/controllers/v1/profile_controller_test.exs
@@ -25,6 +25,28 @@ defmodule TrentoWeb.V1.ProfileControllerTest do
conn: put_req_header(conn, "accept", "application/json"), api_spec: api_spec, user: user}
end
+ test "should disable write profile action when external IDP integration is enabled", %{
+ conn: conn
+ } do
+ Application.put_env(:trento, :oidc, enabled: true)
+
+ conn = put_req_header(conn, "content-type", "application/json")
+
+ Enum.each(
+ [
+ patch(conn, "/api/v1/profile", %{}),
+ get(conn, "/api/v1/profile/totp_enrollment"),
+ post(conn, "/api/v1/profile/totp_enrollment", %{}),
+ delete(conn, "/api/v1/profile/totp_enrollment")
+ ],
+ fn conn ->
+ json_response(conn, 501)
+ end
+ )
+
+ Application.put_env(:trento, :oidc, enabled: false)
+ end
+
test "should show the current user profile", %{
user: %{id: user_id},
conn: conn,
diff --git a/test/trento_web/controllers/v1/users_controller_test.exs b/test/trento_web/controllers/v1/users_controller_test.exs
index 99ac87e2ad..4e5bae3a51 100644
--- a/test/trento_web/controllers/v1/users_controller_test.exs
+++ b/test/trento_web/controllers/v1/users_controller_test.exs
@@ -13,6 +13,17 @@ defmodule TrentoWeb.V1.UsersControllerTest do
setup :setup_user
describe "forbidden response" do
+ test "should return not implemented on create endpoint when external idp integration is enabled",
+ %{conn: conn} do
+ Application.put_env(:trento, :oidc, enabled: true)
+
+ res = post(conn, "/api/v1/users", %{})
+
+ json_response(res, 501)
+
+ Application.put_env(:trento, :oidc, enabled: false)
+ end
+
test "should return forbidden on any controller action if the user does not have the right permission",
%{conn: conn, api_spec: api_spec} do
%{id: user_id} = insert(:user)
@@ -328,6 +339,73 @@ defmodule TrentoWeb.V1.UsersControllerTest do
|> assert_schema("UserItem", api_spec)
end
+ test "should only update abilities and enabled when oidc is enabled", %{
+ conn: conn,
+ api_spec: api_spec
+ } do
+ Application.put_env(:trento, :oidc, enabled: true)
+
+ %{id: id, name: name, resource: resource, label: label} = insert(:ability)
+ %{id: user_id, lock_version: lock_version} = insert(:user, locked_at: DateTime.utc_now())
+
+ valid_params = %{
+ fullname: Faker.Person.name(),
+ email: Faker.Internet.email(),
+ enabled: true,
+ password: "testpassword89",
+ password_confirmation: "testpassword89",
+ abilities: [%{id: id, name: name, resource: resource, label: label}]
+ }
+
+ %{abilities: abilities} =
+ resp =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> put_req_header("if-match", "#{lock_version}")
+ |> patch("/api/v1/users/#{user_id}", valid_params)
+ |> json_response(:ok)
+ |> assert_schema("UserItem", api_spec)
+
+ refute resp.fullname == valid_params.fullname
+ refute resp.email == valid_params.email
+ assert resp.enabled
+ assert resp.password_change_requested_at == nil
+ assert abilities == [%{id: id, name: name, resource: resource, label: label}]
+
+ Application.put_env(:trento, :oidc, enabled: false)
+ end
+
+ test "should not perform an update when oidc is enabled and abilities or enabled are not passed",
+ %{
+ conn: conn,
+ api_spec: api_spec
+ } do
+ Application.put_env(:trento, :oidc, enabled: true)
+
+ %{id: user_id, lock_version: lock_version} = insert(:user)
+
+ valid_params = %{
+ fullname: Faker.Person.name(),
+ email: Faker.Internet.email(),
+ password: "testpassword89",
+ password_confirmation: "testpassword89"
+ }
+
+ resp =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> put_req_header("if-match", "#{lock_version}")
+ |> patch("/api/v1/users/#{user_id}", valid_params)
+ |> json_response(:ok)
+ |> assert_schema("UserItem", api_spec)
+
+ refute resp.fullname == valid_params.fullname
+ refute resp.email == valid_params.email
+ assert resp.password_change_requested_at == nil
+
+ Application.put_env(:trento, :oidc, enabled: false)
+ end
+
test "should disable the TOTP feature for a user", %{conn: conn, api_spec: api_spec} do
%{id: user_id, lock_version: lock_version} =
insert(:user, totp_enabled_at: DateTime.utc_now())
diff --git a/test/trento_web/plugs/external_idp_guard_plug_test.exs b/test/trento_web/plugs/external_idp_guard_plug_test.exs
new file mode 100644
index 0000000000..cb1db3f34c
--- /dev/null
+++ b/test/trento_web/plugs/external_idp_guard_plug_test.exs
@@ -0,0 +1,29 @@
+defmodule TrentoWeb.Plugs.ExternalIdpGuardPlugTest do
+ @moduledoc false
+
+ use TrentoWeb.ConnCase, async: true
+ use Plug.Test
+
+ alias TrentoWeb.Plugs.ExternalIdpGuardPlug
+
+ describe "call/2" do
+ test "should return 501 when external idp integration is enabled", %{conn: conn} do
+ Application.put_env(:trento, :oidc, enabled: true)
+
+ opts = ExternalIdpGuardPlug.init([])
+ res = ExternalIdpGuardPlug.call(conn, opts)
+
+ assert res.status == 501
+ assert res.halted
+
+ Application.put_env(:trento, :oidc, enabled: false)
+ end
+
+ test "should not halt connection when external idp integration is disabled", %{conn: conn} do
+ opts = ExternalIdpGuardPlug.init([])
+ res = ExternalIdpGuardPlug.call(conn, opts)
+
+ refute res.halted
+ end
+ end
+end
diff --git a/test/trento_web/views/v1/users_view_test.exs b/test/trento_web/views/v1/users_view_test.exs
new file mode 100644
index 0000000000..6eee544dd5
--- /dev/null
+++ b/test/trento_web/views/v1/users_view_test.exs
@@ -0,0 +1,47 @@
+defmodule TrentoWeb.V1.UsersViewtest do
+ use TrentoWeb.ConnCase, async: true
+
+ import Phoenix.View
+ import Trento.Factory
+
+ alias TrentoWeb.V1.UsersView
+
+ describe "renders user.json" do
+ test "should correctly render a user when the user has user identities" do
+ identities = build_list(1, :user_identity)
+ abilities = build_list(1, :ability)
+
+ %{
+ email: email,
+ fullname: fullname,
+ id: id
+ } = user = build(:user, user_identities: identities, abilities: abilities)
+
+ assert %{
+ email: ^email,
+ enabled: true,
+ fullname: ^fullname,
+ id: ^id,
+ idp_user: true
+ } = render(UsersView, "user.json", user: user)
+ end
+
+ test "should correctly render a user when the user has no user identities" do
+ abilities = build_list(1, :ability)
+
+ %{
+ email: email,
+ fullname: fullname,
+ id: id
+ } = user = build(:user, abilities: abilities, user_identities: [])
+
+ assert %{
+ email: ^email,
+ enabled: true,
+ fullname: ^fullname,
+ id: ^id,
+ idp_user: false
+ } = render(UsersView, "user.json", user: user)
+ end
+ end
+end