Merge pull request #1 from chutch1122/FEAT/open-id-connect-authentication

Add Cypress tests and documentation
This commit is contained in:
Samuel Oechsler 2024-12-11 20:27:27 +01:00 committed by GitHub
commit 2cae60d5e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 408 additions and 11 deletions

View File

@ -102,7 +102,7 @@ module.exports = {
.first()
.then((user) => {
if (!user) {
throw new error.AuthError('No relevant user found');
throw new error.AuthError(`A user with the email ${data.identity} does not exist. Please contact your administrator.`);
}
// Create a moment of the expiry expression

View File

@ -146,4 +146,43 @@ Immediately after logging in with this default user you will be asked to modify
INITIAL_ADMIN_PASSWORD: mypassword1
```
## OpenID Connect - Single Sign-On (SSO)
Nginx Proxy Manager supports single sign-on (SSO) with OpenID Connect. This feature allows you to use an external OpenID Connect provider log in.
::: warning
Please note, that this feature requires a user to have an existing account to have been created via the "Users" page in the admin interface.
:::
### Provider Configuration
However, before you configure this feature, you need to have an OpenID Connect provider.
If you don't have one, you can use Authentik, which is an open-source OpenID Connect provider. Auth0 is another popular OpenID Connect provider that offers a free tier.
Each provider is a little different, so you will need to refer to the provider's documentation to get the necessary information to configure a new application.
You will need the `Client ID`, `Client Secret`, and `Issuer URL` from the provider. When you create the application in the provider, you will also need to include the `Redirect URL` in the list of allowed redirect URLs for the application.
Nginx Proxy Manager uses the `/api/oidc/callback` endpoint for the redirect URL.
The scopes requested by Nginx Proxy Manager are `openid`, `email`, and `profile` - make sure your auth provider supports these scopes.
We have confirmed that the following providers work with Nginx Proxy Manager. If you have success with another provider, make a pull request to add it to the list!
- Authentik
- Authelia
- Auth0
### Nginx Proxy Manager Configuration
To enable SSO, log into the management interface as an Administrator and navigate to the "Settings" page.
The setting to configure OpenID Connect is named "OpenID Connect Configuration".
Click the 3 dots on the far right side of the table and then click "Edit".
In the modal that appears, you will see a form with the following fields:
| Field | Description | Example Value | Notes |
|---------------|-----------------------------------------------------------|---------------------------------------------|---------------------------------------------------------------------|
| Name | The name of the OpenID Connect provider | Authentik | This will be shown on the login page (eg: "Sign in with Authentik") |
| Client ID | The client ID provided by the OpenID Connect provider | `xyz...456` | |
| Client Secret | The client secret provided by the OpenID Connect provider | `abc...123` |
| Issuer URL | The issuer URL provided by the OpenID Connect provider | `https://authentik.example.com` | This is the URL that the provider uses to identify itself |
| Redirect URL | The redirect URL to use for the OpenID Connect provider | `https://npm.example.com/api/oidc/callback` | |
After filling in the fields, click "Save" to save the settings. You can now use the "Sign in with Authentik" button on the login page to sign in with your OpenID Connect provider.

View File

@ -1,5 +1,5 @@
<div class="page-header">
<h1 class="page-title"><%- i18n('dashboard', 'title', {name: getUserName()}) %></h1>
<h1 class="page-title" data-cy="page-title"><%- i18n('dashboard', 'title', {name: getUserName()}) %></h1>
</div>
<% if (columns) { %>

View File

@ -296,7 +296,7 @@
"oidc-config-description": "Sign in to Nginx Proxy Manager with an external Identity Provider",
"oidc-not-configured": "Not configured",
"oidc-config-hint-1": "Provide configuration for an IdP that supports Open ID Connect Discovery.",
"oidc-config-hint-2": "The 'RedirectURL' must be set to '[base URL]/api/oidc/callback', the IdP must send the 'email' claim and a user with matching email address must exist in Nginx Proxy Manager."
"oidc-config-hint-2": "The 'Redirect URL' must be set to '[base URL]/api/oidc/callback', the IdP must send the 'email' claim and a user with matching email address must exist in Nginx Proxy Manager."
}
}
}
}

View File

@ -17,19 +17,19 @@
<div class="card-title"><%- i18n('login', 'title') %></div>
<div class="form-group">
<label class="form-label"><%- i18n('str', 'email-address') %></label>
<input name="identity" type="email" class="form-control" placeholder="<%- i18n('str', 'email-address') %>" required autofocus>
<input name="identity" type="email" class="form-control" placeholder="<%- i18n('str', 'email-address') %>" data-cy="identity" required autofocus>
</div>
<div class="form-group">
<label class="form-label"><%- i18n('str', 'password') %></label>
<input name="secret" type="password" class="form-control" placeholder="<%- i18n('str', 'password') %>" required>
<div class="invalid-feedback secret-error"></div>
<input name="secret" type="password" class="form-control" placeholder="<%- i18n('str', 'password') %>" data-cy="password" required>
<div class="invalid-feedback secret-error" data-cy="password-error"></div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-teal btn-block"><%- i18n('str', 'sign-in') %></button>
<button type="submit" class="btn btn-teal btn-block" data-cy="sign-in"><%- i18n('str', 'sign-in') %></button>
</div>
<div class="form-footer login-oidc">
<div class="separator"><slot>OR</slot></div>
<button type="button" id="login-oidc" class="btn btn-teal btn-block">
<button type="button" id="login-oidc" class="btn btn-teal btn-block" data-cy="oidc-login">
<%- i18n('str', 'sign-in-with') %> <span class="oidc-provider"></span>
</button>
<div class="invalid-feedback oidc-error"></div>

View File

@ -19,6 +19,51 @@ describe('Settings endpoints', () => {
});
});
it('Get oidc-config setting', function() {
cy.task('backendApiGet', {
token: token,
path: '/api/settings/oidc-config',
}).then((data) => {
cy.validateSwaggerSchema('get', 200, '/settings/{settingID}', data);
expect(data).to.have.property('id');
expect(data.id).to.be.equal('oidc-config');
});
});
it('OIDC settings can be updated', function() {
cy.task('backendApiPut', {
token: token,
path: '/api/settings/oidc-config',
data: {
meta: {
name: 'Some OIDC Provider',
clientID: 'clientID',
clientSecret: 'clientSecret',
issuerURL: 'https://oidc.example.com',
redirectURL: 'https://redirect.example.com/api/oidc/callback',
enabled: true,
}
},
}).then((data) => {
cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
expect(data).to.have.property('id');
expect(data.id).to.be.equal('oidc-config');
expect(data).to.have.property('meta');
expect(data.meta).to.have.property('name');
expect(data.meta.name).to.be.equal('Some OIDC Provider');
expect(data.meta).to.have.property('clientID');
expect(data.meta.clientID).to.be.equal('clientID');
expect(data.meta).to.have.property('clientSecret');
expect(data.meta.clientSecret).to.be.equal('clientSecret');
expect(data.meta).to.have.property('issuerURL');
expect(data.meta.issuerURL).to.be.equal('https://oidc.example.com');
expect(data.meta).to.have.property('redirectURL');
expect(data.meta.redirectURL).to.be.equal('https://redirect.example.com/api/oidc/callback');
expect(data.meta).to.have.property('enabled');
expect(data.meta.enabled).to.be.true;
});
});
it('Get default-site setting', function() {
cy.task('backendApiGet', {
token: token,

View File

@ -0,0 +1,185 @@
/// <reference types="cypress" />
import {TEST_USER_EMAIL, TEST_USER_NICKNAME, TEST_USER_PASSWORD} from "../../support/constants";
describe('Login', () => {
beforeEach(() => {
// Clear all cookies and local storage so we start fresh
cy.clearCookies();
cy.clearLocalStorage();
});
describe('when OIDC is not enabled', () => {
beforeEach(() => {
cy.configureOidc(false);
cy.visit('/');
})
it('should show the login form', () => {
cy.get('input[data-cy="identity"]').should('exist');
cy.get('input[data-cy="password"]').should('exist');
cy.get('button[data-cy="sign-in"]').should('exist');
});
it('should NOT show the button to sign in with an identity provider', () => {
cy.get('button[data-cy="oidc-login"]').should('not.exist');
});
describe('logging in with a username and password', () => {
// These tests are duplicated below. The difference is that OIDC is disabled here.
beforeEach(() => {
// Delete and recreate the test user
cy.deleteTestUser();
cy.createTestUser();
});
it('should log the user in when the credentials are correct', () => {
// Fill in the form with the test user's email and the correct password
cy.get('input[data-cy="identity"]').type(TEST_USER_EMAIL);
cy.get('input[data-cy="password"]').type(TEST_USER_PASSWORD);
// Intercept the POST request to /api/tokens, so we can wait for it to complete before proceeding
cy.intercept('POST', '/api/tokens').as('login');
// Click the sign-in button
cy.get('button[data-cy="sign-in"]').click();
cy.wait('@login');
// Expect a 200 from the backend
cy.get('@login').its('response.statusCode').should('eq', 200);
// Expect the user to be redirected to the dashboard with a welcome message
cy.get('h1[data-cy="page-title"]').should('contain.text', `Hi ${TEST_USER_NICKNAME}`);
});
it('should show an error message if the password is incorrect', () => {
// Fill in the form with the test user's email and an incorrect password
cy.get('input[data-cy="identity"]').type(TEST_USER_EMAIL);
cy.get('input[data-cy="password"]').type(`${TEST_USER_PASSWORD}_obviously_not_correct`);
// Intercept the POST request to /api/tokens, so we can wait for it to complete before checking the error message
cy.intercept('POST', '/api/tokens').as('login');
// Click the sign-in button
cy.get('button[data-cy="sign-in"]').click();
cy.wait('@login');
// Expect a 401 from the backend
cy.get('@login').its('response.statusCode').should('eq', 401);
// Expect an error message on the UI
cy.get('div[data-cy="password-error"]').should('contain.text', 'Invalid password');
});
it('should show an error message if the email is incorrect', () => {
// Fill in the form with the test user's email and an incorrect password
cy.get('input[data-cy="identity"]').type(`definitely_not_${TEST_USER_EMAIL}`);
cy.get('input[data-cy="password"]').type(TEST_USER_PASSWORD);
// Intercept the POST request to /api/tokens, so we can wait for it to complete before checking the error message
cy.intercept('POST', '/api/tokens').as('login');
// Click the sign-in button
cy.get('button[data-cy="sign-in"]').click();
cy.wait('@login');
// Expect a 401 from the backend
cy.get('@login').its('response.statusCode').should('eq', 401);
// Expect an error message on the UI
cy.get('div[data-cy="password-error"]').should('contain.text', 'No relevant user found');
});
});
});
describe('when OIDC is enabled', () => {
beforeEach(() => {
cy.configureOidc(true);
cy.visit('/');
});
it('should show the login form', () => {
cy.get('input[data-cy="identity"]').should('exist');
cy.get('input[data-cy="password"]').should('exist');
cy.get('button[data-cy="sign-in"]').should('exist');
});
it('should show the button to sign in with the configured identity provider', () => {
cy.get('button[data-cy="oidc-login"]').should('exist');
cy.get('button[data-cy="oidc-login"]').should('contain.text', 'Sign in with ACME OIDC Provider');
});
describe('logging in with a username and password', () => {
// These tests are the same as the ones above, but we need to repeat them here because the OIDC configuration
beforeEach(() => {
// Delete and recreate the test user
cy.deleteTestUser();
cy.createTestUser();
});
it('should log the user in when the credentials are correct', () => {
// Fill in the form with the test user's email and the correct password
cy.get('input[data-cy="identity"]').type(TEST_USER_EMAIL);
cy.get('input[data-cy="password"]').type(TEST_USER_PASSWORD);
// Intercept the POST request to /api/tokens, so we can wait for it to complete before proceeding
cy.intercept('POST', '/api/tokens').as('login');
// Click the sign-in button
cy.get('button[data-cy="sign-in"]').click();
cy.wait('@login');
// Expect a 200 from the backend
cy.get('@login').its('response.statusCode').should('eq', 200);
// Expect the user to be redirected to the dashboard with a welcome message
cy.get('h1[data-cy="page-title"]').should('contain.text', `Hi ${TEST_USER_NICKNAME}`);
});
it('should show an error message if the password is incorrect', () => {
// Fill in the form with the test user's email and an incorrect password
cy.get('input[data-cy="identity"]').type(TEST_USER_EMAIL);
cy.get('input[data-cy="password"]').type(`${TEST_USER_PASSWORD}_obviously_not_correct`);
// Intercept the POST request to /api/tokens, so we can wait for it to complete before checking the error message
cy.intercept('POST', '/api/tokens').as('login');
// Click the sign-in button
cy.get('button[data-cy="sign-in"]').click();
cy.wait('@login');
// Expect a 401 from the backend
cy.get('@login').its('response.statusCode').should('eq', 401);
// Expect an error message on the UI
cy.get('div[data-cy="password-error"]').should('contain.text', 'Invalid password');
});
it('should show an error message if the email is incorrect', () => {
// Fill in the form with the test user's email and an incorrect password
cy.get('input[data-cy="identity"]').type(`definitely_not_${TEST_USER_EMAIL}`);
cy.get('input[data-cy="password"]').type(TEST_USER_PASSWORD);
// Intercept the POST request to /api/tokens, so we can wait for it to complete before checking the error message
cy.intercept('POST', '/api/tokens').as('login');
// Click the sign-in button
cy.get('button[data-cy="sign-in"]').click();
cy.wait('@login');
// Expect a 401 from the backend
cy.get('@login').its('response.statusCode').should('eq', 401);
// Expect an error message on the UI
cy.get('div[data-cy="password-error"]').should('contain.text', 'No relevant user found');
});
});
describe('logging in with OIDC', () => {
beforeEach(() => {
// Delete and recreate the test user
cy.deleteTestUser();
cy.createTestUser();
});
// TODO: Create a dummy OIDC provider that we can use for testing so we can test this fully.
});
});
});

View File

@ -10,6 +10,13 @@
//
import 'cypress-wait-until';
import {
DEFAULT_ADMIN_EMAIL,
DEFAULT_ADMIN_PASSWORD,
TEST_USER_EMAIL,
TEST_USER_NAME,
TEST_USER_NICKNAME, TEST_USER_PASSWORD
} from "./constants";
Cypress.Commands.add('randomString', (length) => {
var result = '';
@ -40,13 +47,118 @@ Cypress.Commands.add('validateSwaggerSchema', (method, code, path, data) => {
}).should('equal', null);
});
/**
* Configure OIDC settings in the backend, so we can test scenarios around OIDC being enabled or disabled.
*/
Cypress.Commands.add('configureOidc', (enabled) => {
cy.getToken().then((token) => {
if (enabled) {
cy.task('backendApiPut', {
token: token,
path: '/api/settings/oidc-config',
data: {
meta: {
name: 'ACME OIDC Provider',
clientID: 'clientID',
clientSecret: 'clientSecret',
// TODO: Create dummy OIDC provider for testing
issuerURL: 'https://oidc.example.com',
redirectURL: 'https://redirect.example.com/api/oidc/callback',
enabled: true,
}
},
})
} else {
cy.task('backendApiPut', {
token: token,
path: '/api/settings/oidc-config',
data: {
meta: {
name: '',
clientID: '',
clientSecret: '',
issuerURL: '',
redirectURL: '',
enabled: false,
}
},
})
}
});
});
/**
* Create a new user in the backend for testing purposes.
*
* The created user will have a name, nickname, email, and password as defined in the constants file (TEST_USER_*).
*
* @param {boolean} withPassword Whether to create the user with a password or not (default: true)
*/
Cypress.Commands.add('createTestUser', (withPassword) => {
if (withPassword === undefined) {
withPassword = true;
}
cy.getToken().then((token) => {
cy.task('backendApiPost', {
token: token,
path: '/api/users',
data: {
name: TEST_USER_NAME,
nickname: TEST_USER_NICKNAME,
email: TEST_USER_EMAIL,
roles: ['admin'],
is_disabled: false,
auth: withPassword ? {
type: 'password',
secret: TEST_USER_PASSWORD
} : {}
}
})
});
});
/**
* Delete the test user from the backend.
* The test user is identified by the email address defined in the constants file (TEST_USER_EMAIL).
*
* This command will only attempt to delete the test user if it exists.
*/
Cypress.Commands.add('deleteTestUser', () => {
cy.getToken().then((token) => {
cy.task('backendApiGet', {
token: token,
path: '/api/users',
}).then((data) => {
// Find the test user
const testUser = data.find(user => user.email === TEST_USER_EMAIL);
// If the test user doesn't exist, we don't need to delete it
if (!testUser) {
return;
}
// Delete the test user
cy.task('backendApiDelete', {
token: token,
path: `/api/users/${testUser.id}`,
});
});
});
});
/**
* Get a new token from the backend.
* The token will be created using the default admin email and password defined in the constants file (DEFAULT_ADMIN_*).
*/
Cypress.Commands.add('getToken', () => {
// login with existing user
cy.task('backendApiPost', {
path: '/api/tokens',
data: {
identity: 'admin@example.com',
secret: 'changeme'
identity: DEFAULT_ADMIN_EMAIL,
secret: DEFAULT_ADMIN_PASSWORD
}
}).then(res => {
cy.wrap(res.token);

View File

@ -0,0 +1,16 @@
// Description: Constants used in the tests.
/**
* The default admin user is used to get tokens from the backend API to make requests.
* It is also used to create the test user.
*/
export const DEFAULT_ADMIN_EMAIL = Cypress.env('DEFAULT_ADMIN_EMAIL') || 'admin@example.com';
export const DEFAULT_ADMIN_PASSWORD = Cypress.env('DEFAULT_ADMIN_PASSWORD') || 'changeme';
/**
* The test user is created and deleted by the tests using `cy.createTestUser()` and `cy.deleteTestUser()`.
*/
export const TEST_USER_NAME = 'Robert Ross';
export const TEST_USER_NICKNAME = 'Bob';
export const TEST_USER_EMAIL = 'bob@ross.com';
export const TEST_USER_PASSWORD = 'changeme';