Skip to content

Commit

Permalink
🔀 Merge pull request #1573 from twsouthwick/oidc
Browse files Browse the repository at this point in the history
Enable public application OIDC client support
Fixes #823
  • Loading branch information
Lissy93 committed May 13, 2024
2 parents 5a88bea + b9902e3 commit 4b919f8
Show file tree
Hide file tree
Showing 9 changed files with 199 additions and 3 deletions.
41 changes: 41 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,47 @@ Your app is now secured :) When you load Dashy, it will redirect to your Keycloa

From within the Keycloak console, you can then configure things like time-outs, password policies, etc. You can also backup your full Keycloak config, and it is recommended to do this, along with your Dashy config. You can spin up both Dashy and Keycloak simultaneously and restore both applications configs using a `docker-compose.yml` file, and this is recommended.

## OIDC

Dashy also supports using a general [OIDC compatible](https://openid.net/connect/) authentication server. In order to use it, the authentication section needs to be configured:

```yaml
appConfig:
auth:
enableOidc: true
oidc:
clientId: [registered client id]
endpoint: [OIDC endpoint]
```

Because Dashy is a SPA, a [public client](https://datatracker.ietf.org/doc/html/rfc6749#section-2.1) registration with PKCE is needed.

An example for Authelia is shared below, but other OIDC systems can be used:

```yaml
identity_providers:
oidc:
clients:
- client_id: dashy
client_name: dashy
public: true
authorization_policy: 'one_factor'
require_pkce: true
pkce_challenge_method: 'S256'
redirect_uris:
- https://dashy.local # should point to your dashy endpoint
grant_types:
- authorization_code
scopes:
- 'openid'
- 'profile'
- 'roles'
- 'email'
- 'groups'
```

Groups and roles will be populated and available for controlling display similar to [Keycloak](#Keycloak) abvoe.

---

## Alternative Authentication Methods
Expand Down
11 changes: 11 additions & 0 deletions docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ The following file provides a reference of all supported configuration options.
**`keycloak`** | `object` | _Optional_ | Config options to point Dashy to your Keycloak server. Requires `enableKeycloak: true`. See [`auth.keycloak`](#appconfigauthkeycloak-optional) for more info
**`enableHeaderAuth`** | `boolean` | _Optional_ | If set to `true`, then authentication using HeaderAuth will be enabled. Note that you need to have your web server/reverse proxy running, and have also configured `auth.headerAuth`. Defaults to `false`
**`headerAuth`** | `object` | _Optional_ | Config options to point Dashy to your headers for authentication. Requires `enableHeaderAuth: true`. See [`auth.headerAuth`](#appconfigauthheaderauth-optional) for more info
**`enableOidc`** | `boolean` | _Optional_ | If set to `true`, then authentication using OIDC will be enabled. Note that you need to have a configured OIDC server and configure it with `auth.oidc`. Defaults to `false`
**`oidc`** | `object` | _Optional_ | Config options to point Dash to your OIDC configuration. Request `enableOidc: true`. See [`auth.oidc`](#appconfigauthoidc-optional) for more info
**`enableGuestAccess`** | `boolean` | _Optional_ | When set to `true`, an unauthenticated user will be able to access the dashboard, with read-only access, without having to login. Requires `auth.users` to be configured. Defaults to `false`.

For more info, see the **[Authentication Docs](/docs/authentication.md)**
Expand Down Expand Up @@ -194,6 +196,15 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)**

**[⬆️ Back to Top](#configuring)**

## `appConfig.auth.oidc` _(optional)_

**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`clientId`** | `string` | Required | The client id registered in the OIDC server
**`endpoint`** | `string` | Required | The URL of the OIDC server that should be used.

**[⬆️ Back to Top](#configuring)**

## `appConfig.webSearch` _(optional)_

**Field** | **Type** | **Required**| **Description**
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dashy",
"version": "3.0.1",
"version": "3.1.0",
"license": "MIT",
"main": "server",
"author": "Alicia Sykes <alicia@omg.lol> (https://aliciasykes.com)",
Expand Down Expand Up @@ -30,6 +30,7 @@
"frappe-charts": "^1.6.2",
"js-yaml": "^4.1.0",
"keycloak-js": "^20.0.3",
"oidc-client-ts": "^3.0.1",
"register-service-worker": "^1.7.2",
"remedial": "^1.0.8",
"rss-parser": "3.13.0",
Expand Down
15 changes: 15 additions & 0 deletions src/components/Settings/AuthButtons.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@
v-tooltip="tooltip($t('settings.sign-out-tooltip'))"
class="layout-icon" tabindex="-2"
/>
<!-- If user logged in via oidc, show oidc logout button -->
<IconLogout
v-if="userType == userStateEnum.oidcEnabled"
@click="oidcLogout()"
v-tooltip="tooltip($t('settings.sign-out-tooltip'))"
class="layout-icon" tabindex="-2"
/>
</div>
</div>
</template>
Expand All @@ -32,6 +39,7 @@
import router from '@/router';
import { logout as registerLogout } from '@/utils/Auth';
import { getKeycloakAuth } from '@/utils/KeycloakAuth';
import { getOidcAuth } from '@/utils/OidcAuth';
import { localStorageKeys, userStateEnum } from '@/utils/defaults';
import IconLogout from '@/assets/interface-icons/user-logout.svg';
Expand All @@ -56,6 +64,13 @@ export default {
router.push({ path: '/login' });
}, 500);
},
oidcLogout() {
const oidc = getOidcAuth();
this.$toasted.show(this.$t('login.logout-message'));
setTimeout(() => {
oidc.logout();
}, 500);
},
keycloakLogout() {
const keycloak = getKeycloakAuth();
this.$toasted.show(this.$t('login.logout-message'));
Expand Down
9 changes: 8 additions & 1 deletion src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import clickOutside from '@/directives/ClickOutside'; // Directive for closing p
import { toastedOptions, tooltipOptions, language as defaultLanguage } from '@/utils/defaults';
import { initKeycloakAuth, isKeycloakEnabled } from '@/utils/KeycloakAuth';
import { initHeaderAuth, isHeaderAuthEnabled } from '@/utils/HeaderAuth';
import { initOidcAuth, isOidcEnabled } from '@/utils/OidcAuth';
import Keys from '@/utils/StoreMutations';
import ErrorHandler from '@/utils/ErrorHandler';

Expand Down Expand Up @@ -62,7 +63,13 @@ const mount = () => new Vue({
}).$mount('#app');

store.dispatch(Keys.INITIALIZE_CONFIG).then(() => {
if (isKeycloakEnabled()) { // If Keycloak is enabled, initialize auth
if (isOidcEnabled()) {
initOidcAuth()
.then(() => mount())
.catch((e) => {
ErrorHandler('Failed to authenticate with OIDC', e);
});
} else if (isKeycloakEnabled()) { // If Keycloak is enabled, initialize auth
initKeycloakAuth()
.then(() => mount())
.catch((e) => {
Expand Down
5 changes: 4 additions & 1 deletion src/utils/Auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ConfigAccumulator from '@/utils/ConfigAccumalator';
import ErrorHandler from '@/utils/ErrorHandler';
import { cookieKeys, localStorageKeys, userStateEnum } from '@/utils/defaults';
import { isKeycloakEnabled } from '@/utils/KeycloakAuth';
import { isOidcEnabled } from '@/utils/OidcAuth';

/* Uses config accumulator to get and return app config */
const getAppConfig = () => {
Expand Down Expand Up @@ -96,7 +97,7 @@ export const isAuthEnabled = () => {
/* Returns true if guest access is enabled */
export const isGuestAccessEnabled = () => {
const appConfig = getAppConfig();
if (appConfig.auth && typeof appConfig.auth === 'object' && !isKeycloakEnabled()) {
if (appConfig.auth && typeof appConfig.auth === 'object' && !isKeycloakEnabled() && !isOidcEnabled()) {
return appConfig.auth.enableGuestAccess || false;
}
return false;
Expand Down Expand Up @@ -229,8 +230,10 @@ export const getUserState = () => {
loggedIn,
guestAccess,
keycloakEnabled,
oidcEnabled,
} = userStateEnum; // Numeric enum options
if (isKeycloakEnabled()) return keycloakEnabled; // Keycloak auth configured
if (isOidcEnabled()) return oidcEnabled;
if (!isAuthEnabled()) return notConfigured; // No auth enabled
if (isLoggedIn()) return loggedIn; // User is logged in
if (isGuestAccessEnabled()) return guestAccess; // Guest is viewing
Expand Down
27 changes: 27 additions & 0 deletions src/utils/ConfigSchema.json
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,33 @@
]
}
},
"enableOidc": {
"title": "Enable OIDC?",
"type": "boolean",
"default": false,
"description": "If set to true, enable OIDC. See appConfig.auth.oidc"
},
"oidc": {
"type": "object",
"description": "Configuration for OIDC",
"additionalProperties": false,
"required": [
"clientId",
"endpoint"
],
"properties": {
"endpoint": {
"title": "OIDC Endpoint",
"type": "string",
"description": "Endpoint of OIDC provider"
},
"clientId": {
"title": "OIDC Client Id",
"type": "string",
"description": "ClientId from OIDC provider"
}
}
},
"enableHeaderAuth": {
"title": "Enable HeaderAuth?",
"type": "boolean",
Expand Down
90 changes: 90 additions & 0 deletions src/utils/OidcAuth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
import ConfigAccumulator from '@/utils/ConfigAccumalator';
import { localStorageKeys } from '@/utils/defaults';
import ErrorHandler from '@/utils/ErrorHandler';
import { statusMsg, statusErrorMsg } from '@/utils/CoolConsole';

const getAppConfig = () => {
const Accumulator = new ConfigAccumulator();
const config = Accumulator.config();
return config.appConfig || {};
};

class OidcAuth {
constructor() {
const { auth } = getAppConfig();
const { clientId, endpoint } = auth.oidc;
const settings = {
userStore: new WebStorageStateStore({ store: window.localStorage }),
authority: endpoint,
client_id: clientId,
redirect_uri: `${window.location.origin}`,
response_type: 'code',
scope: 'openid profile email roles groups',
response_mode: 'query',
filterProtocolClaims: true,
};

this.userManager = new UserManager(settings);
}

async login() {
const url = new URL(window.location.href);
const code = url.searchParams.get('code');

if (code) {
await this.userManager.signinCallback(window.location.href);
window.location.href = '/';
return;
}

const user = await this.userManager.getUser();

if (user === null) {
await this.userManager.signinRedirect();
} else {
const { roles, groups } = user.profile;
const info = {
groups,
roles,
};

statusMsg(`user: ${user.profile.preferred_username}`, JSON.stringify(info));

localStorage.setItem(localStorageKeys.KEYCLOAK_INFO, JSON.stringify(info));
localStorage.setItem(localStorageKeys.USERNAME, user.profile.preferred_username);
}
}

async logout() {
localStorage.removeItem(localStorageKeys.USERNAME);
localStorage.removeItem(localStorageKeys.KEYCLOAK_INFO);

try {
await this.userManager.signoutRedirect();
} catch (reason) {
statusErrorMsg('logout', 'could not log out. Redirecting to OIDC instead', reason);
window.location.href = this.userManager.settings.authority;
}
}
}

export const isOidcEnabled = () => {
const { auth } = getAppConfig();
if (!auth) return false;
return auth.enableOidc || false;
};

let oidc;

export const initOidcAuth = () => {
oidc = new OidcAuth();
return oidc.login();
};

export const getOidcAuth = () => {
if (!oidc) {
ErrorHandler("OIDC not initialized, can't get instance of class");
}
return oidc;
};
1 change: 1 addition & 0 deletions src/utils/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ module.exports = {
guestAccess: 2,
notLoggedIn: 3,
keycloakEnabled: 4,
oidcEnabled: 5,
},
/* Progressive Web App settings, used by Vue Config */
pwa: {
Expand Down

0 comments on commit 4b919f8

Please sign in to comment.