mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2025-05-03 20:42:28 +00:00
Merge pull request #1 from chutch1122/FEAT/open-id-connect-authentication
Add Cypress tests and documentation
This commit is contained in:
commit
2cae60d5e4
@ -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
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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) { %>
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
185
test/cypress/e2e/ui/Login.cy.js
Normal file
185
test/cypress/e2e/ui/Login.cy.js
Normal 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.
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
|
16
test/cypress/support/constants.js
Normal file
16
test/cypress/support/constants.js
Normal 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';
|
Loading…
x
Reference in New Issue
Block a user