Skip to content

Commit 2228a58

Browse files
authored
🔀 Merge pull request #2206 from lissy93/docs/authentication-guides
Clearer authentication docs
2 parents c733e3b + 21fb0e1 commit 2228a58

13 files changed

Lines changed: 561 additions & 871 deletions

File tree

.github/workflows/docker.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ on:
1818
workflow_dispatch:
1919
inputs:
2020
tag:
21-
description: 'Existing git tag to build. Empty = build current ref as :latest.'
21+
description: 'Tag to build (empty = build current ref as :latest. tag must exist in git)'
2222
required: false
2323
default: ''
2424
push:
@@ -139,9 +139,9 @@ jobs:
139139
output: 'trivy-${{ matrix.arch }}.sarif'
140140
timeout: '10m'
141141

142-
# SARIF isn't human-readable, so on a gating failure re-print the CVEs as a table
142+
# If CVEs found, print them in human readable table so i can read and address them
143143
- name: 📋 List blocking CVEs (on scan failure)
144-
if: steps.scan.outcome == 'failure'
144+
if: always() && steps.scan.outcome == 'failure'
145145
uses: aquasecurity/trivy-action@v0.36.0
146146
env:
147147
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2

.github/workflows/tag.yml

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ jobs:
179179
git commit -m "🔖 Bump version to $NEW_VERSION"
180180
git push
181181
echo "bumped=true" >> "$GITHUB_OUTPUT"
182+
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
182183
fi
183184
184185
- name: 🏷️ Create and push tag
@@ -285,9 +286,9 @@ jobs:
285286
`this has now been implemented${byLine} in #${prNumber},`,
286287
`and will be released shortly in ${version} 😇`,
287288
];
288-
if (isOld) parts.push(`\n\nWe're sorry this one took so long 😔`);
289+
if (isOld) parts.push(`\nWe're sorry this one took so long 😔`);
289290
if (isNew) {
290-
parts.push(`\n\nIf you're enjoying Dashy, consider ` +
291+
parts.push(`\nIf you're enjoying Dashy, consider ` +
291292
`[sponsoring us](https://github.com/sponsors/lissy93) on GitHub to help with development 💖`);
292293
}
293294
parts.push(`<!-- ${marker} -->`);
@@ -329,11 +330,13 @@ jobs:
329330
env:
330331
PR_NUMBER: ${{ github.event.pull_request.number || '' }}
331332
PR_TITLE: ${{ github.event.pull_request.title || '' }}
333+
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.actor }}
332334
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
333335
NEEDS_BUMP: ${{ steps.check_pr.outputs.needs_bump }}
334336
NEEDS_TAG: ${{ steps.check_pr.outputs.needs_tag }}
335337
ISSUES: ${{ steps.issues.outputs.numbers }}
336338
BUMPED: ${{ steps.bump.outputs.bumped }}
339+
BUMP_SHA: ${{ steps.bump.outputs.sha }}
337340
TAG_OUTCOME: ${{ steps.tag.outcome }}
338341
TAG_RESULT: ${{ steps.tag.outputs.result }}
339342
TAG_VERSION: ${{ steps.tag.outputs.version }}
@@ -352,11 +355,18 @@ jobs:
352355
if [ "$IS_MANUAL" = "true" ]; then
353356
echo "| Trigger | Manual dispatch |"
354357
elif [ -n "$PR_NUMBER" ]; then
355-
echo "| PR | [#${PR_NUMBER}](${REPO_URL}/pull/${PR_NUMBER}) — ${PR_TITLE} |"
358+
echo "| PR | [#${PR_NUMBER}](${REPO_URL}/pull/${PR_NUMBER}): ${PR_TITLE} |"
359+
fi
360+
if [ -n "$PR_AUTHOR" ]; then
361+
echo "| Author | [@${PR_AUTHOR}](${REPO_URL%/*/*}/${PR_AUTHOR}) |"
362+
fi
363+
364+
if [ "$NEEDS_TAG" = "false" ]; then
365+
echo "| Result | ⏭️ No code changes, nothing to do |"
356366
fi
357367
358368
if [ "$BUMPED" = "true" ]; then
359-
echo "| Version bump | ✅ \`${VERSION}\` |"
369+
echo "| Version bump | ✅ \`${VERSION}\` ([\`${BUMP_SHA:0:7}\`](${REPO_URL}/commit/${BUMP_SHA})) |"
360370
else
361371
echo "| Version bump | ⏭️ Skipped |"
362372
fi

Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ RUN apk upgrade --no-cache \
4545
&& apk add --no-cache tzdata tini iputils-ping \
4646
&& apk add --no-cache --virtual .setcap libcap-setcap \
4747
&& setcap cap_net_raw+ep "$(readlink -f "$(command -v ping)")" \
48-
&& apk del .setcap
48+
&& apk del .setcap \
49+
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
4950

5051
COPY --chown=node:node --from=deps /app/node_modules ./node_modules
5152
COPY --chown=node:node --from=build /app/dist ./dist

docs/authentication.md

Lines changed: 83 additions & 777 deletions
Large diffs are not rendered by default.

docs/authentication/authelia-oidc.md

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Dashy supports using [Authelia](https://www.authelia.com/) as its OIDC provider.
1313
- [Main configuration](#main-configuration)
1414
- [3. Enabling Authelia in Dashy](#3-enabling-authelia-in-dashy)
1515
- [4. Groups and Visibility](#4-groups-and-visibility)
16+
- [5. Silent token renewal (optional)](#5-silent-token-renewal-optional)
1617
- [Troubleshooting](#troubleshooting-common-authelia-issues)
1718
- [How it Works](#how-it-works)
1819
- [Client side](#client-side)
@@ -241,37 +242,6 @@ If Authelia uses a self-signed cert, Dashy's server has to trust it before it ca
241242
Everything should now be fully configured and working 🎉
242243
When you load Dashy, you'll be redirected to Authelia's login page. After signing in, you'll land back on Dashy's homepage with full access, and all of Dashy's client, server and asset endpoints will be locked behind authentication.
243244

244-
### Silent token renewal (optional)
245-
246-
By default, when your token expires Dashy sends you back through Authelia's login to get a new one. Set `enableSilentRenew: true` to have Dashy refresh the session quietly in the background instead, using a refresh token:
247-
248-
```yaml
249-
oidc:
250-
clientId: dashy
251-
endpoint: https://authelia.lvh.me:9091
252-
adminGroup: admins
253-
scope: openid profile email groups
254-
enableSilentRenew: true
255-
```
256-
257-
Dashy adds the `offline_access` scope to its request automatically, but Authelia only issues a refresh token if the client is allowed to. Add `offline_access` to the client's `scopes` and `refresh_token` to its `grant_types`:
258-
259-
```yaml
260-
clients:
261-
- client_id: dashy
262-
scopes:
263-
- openid
264-
- profile
265-
- email
266-
- groups
267-
- offline_access
268-
grant_types:
269-
- authorization_code
270-
- refresh_token
271-
```
272-
273-
It's off by default, and if a refresh ever fails Dashy falls back to the normal sign-in. See [silent token renewal](../authentication.md#silent-token-renewal) for the full notes and caveats.
274-
275245
---
276246

277247
## 4. Groups and Visibility
@@ -304,6 +274,41 @@ sections:
304274
groups: ['interns']
305275
```
306276

277+
---
278+
279+
280+
## 5. Silent token renewal (optional)
281+
282+
By default, when your token expires Dashy sends you back through Authelia's login to get a new one. Set `enableSilentRenew: true` to have Dashy refresh the session quietly in the background instead, using a refresh token:
283+
284+
```yaml
285+
oidc:
286+
clientId: dashy
287+
endpoint: https://authelia.lvh.me:9091
288+
adminGroup: admins
289+
scope: openid profile email groups
290+
enableSilentRenew: true
291+
```
292+
293+
Dashy adds the `offline_access` scope to its request automatically, but Authelia only issues a refresh token if the client is allowed to. Add `offline_access` to the client's `scopes` and `refresh_token` to its `grant_types`:
294+
295+
```yaml
296+
clients:
297+
- client_id: dashy
298+
scopes:
299+
- openid
300+
- profile
301+
- email
302+
- groups
303+
- offline_access
304+
grant_types:
305+
- authorization_code
306+
- refresh_token
307+
```
308+
309+
It's off by default, and if a refresh ever fails Dashy falls back to the normal sign-in. See [silent token renewal](./oidc.md#silent-token-renewal) for the full notes and caveats.
310+
311+
307312
---
308313

309314
## Troubleshooting common Authelia Issues
@@ -395,7 +400,7 @@ Boot starts in [`src/main.js`](https://github.com/lissy93/dashy/blob/4.1.5/src/m
395400
- `loadOidcSettings()` reads `auth.oidc` (or `auth.keycloak`) at boot and returns a normalised `{ issuer, clientId, adminGroup, adminRole }`. For generic OIDC providers the `issuer` is whatever you set as `endpoint` in `conf.yml`, verbatim
396401
- `createOidcMiddleware()` returns a Connect middleware. Permissive on no-token requests so the SPA can bootstrap; otherwise it verifies the Bearer token against the issuer's JWKS using [`jose`](https://github.com/panva/jose). Checks cover signature, issuer (against the canonical value from the discovery doc), audience (must equal `clientId`), and expiry, with a 30-second clock-skew tolerance. Sets `req.auth = { user, isAdmin, claims }` on success, `401` on failure
397402
- `getIssuerContext()` lazily fetches `.well-known/openid-configuration` on first use and wraps `jwks_uri` in `createRemoteJWKSet`, which handles JWKS caching and on-demand key rotation. The result is memoised per-issuer for the life of the process
398-
- `deriveIsAdmin()` checks the token's `groups` claim against `adminGroup`, and the `realm_access.roles` / `resource_access.<clientId>.roles` arrays against `adminRole`. Authelia only emits `groups`, so the group path is what's used in practice
403+
- `deriveIsAdmin()` checks the token's `groups` claim against `adminGroup`, and the top-level `roles` claim against `adminRole` (for Keycloak it also folds in the nested `realm_access.roles` / `resource_access.<clientId>.roles` arrays). Authelia only emits `groups`, so the group path is what's used in practice
399404
- `maybeBootstrapConfig()` is the stripped-response helper. When auth is configured, guest access is off, and an unauthenticated request hits the root `/conf.yml`, it returns a minimal copy with only `appConfig.auth`, `appConfig.enableServiceWorker`, and a `pageInfo.title` of `Login | <your title>`. Sections, items, hostnames and any other secrets never leave the server
400405

401406
[`services/app.js`](https://github.com/lissy93/dashy/blob/4.1.5/services/app.js) wires it all together. The middleware mounts as `protectConfig` in front of every YAML route and config-mutating route. The `/*.yml` handler sets `Cache-Control: private, no-store` and `Vary: Authorization` whenever auth is configured (so intermediate caches can never mix auth states), then calls `maybeBootstrapConfig`; a stripped result is sent as-is, otherwise `res.sendFile` serves the full file. `POST /config-manager/save` is additionally guarded by `requireAdmin`, which returns `401` if `req.auth` is unset and `403` if `req.auth.isAdmin` is false.

docs/authentication/authentik.md

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ Dashy supports using [Authentik](https://goauthentik.io/) as its OIDC provider.
1313
- [Create the application](#create-the-application)
1414
- [Create the admin group](#create-the-admin-group)
1515
- [Create test users](#create-test-users)
16+
- [Restrict who can access Dashy (optional)](#restrict-who-can-access-dashy-optional)
1617
- [3. Enabling Authentik in Dashy](#3-enabling-authentik-in-dashy)
1718
- [4. Groups and Visibility](#4-groups-and-visibility)
19+
- [5. Silent token renewal (optional)](#5-silent-token-renewal-optional)
1820
- [Troubleshooting](#troubleshooting-common-authentik-issues)
1921
- [How it Works](#how-it-works)
2022
- [Client side](#client-side)
@@ -181,6 +183,24 @@ If you want separate accounts beyond `akadmin`:
181183
3. On the new user's page, click **Set password**, set a password, click **Update**
182184
4. Add the user to `dashy-admins` for admin access, or leave them out for a non-admin
183185

186+
### Restrict who can access Dashy (optional)
187+
188+
By default any Authentik user can sign in to Dashy. To limit access to one or more groups, bind a group policy to the `Dashy` application; Authentik then denies sign-in to anyone outside those groups. This is separate from `adminGroup`, which only controls who gets admin rights inside Dashy, not who can access it at all.
189+
190+
1. Go to **Applications > Applications** and open the `Dashy` application
191+
192+
![Open the Dashy application](https://github.com/user-attachments/assets/613fafe7-881f-4664-a903-945854ac65e2)
193+
194+
2. Open the **Policy / Group / User Bindings** tab and click **Bind existing policy**
195+
196+
![Open the bindings tab](https://github.com/user-attachments/assets/10fca15b-e77d-4624-ae03-0ece3910904c)
197+
198+
3. Switch to the **Group** tab, choose the group that should have access, make sure **Enabled** is on, and click **Create**
199+
200+
![Bind a group to the application](https://github.com/user-attachments/assets/ebf680ab-696f-4c08-ae89-d73fe92b398f)
201+
202+
Access is now limited to members of the bound group. Add another binding for each additional group that should be allowed in.
203+
184204
### Summary
185205

186206
Authentik should now be configured, and ready to go!
@@ -219,21 +239,6 @@ If Authentik runs on a different host or behind a reverse proxy, make sure `endp
219239
Everything should now be fully configured and working 🎉
220240
When you load Dashy, you'll be redirected to Authentik's login page. After signing in you will land back on Dashy's homepage with full access, and all of Dashy's client, server and asset endpoints will be locked behind authentication.
221241

222-
### Silent token renewal (optional)
223-
224-
By default, when your token expires Dashy sends you back through Authentik's login to get a new one. Set `enableSilentRenew: true` to have Dashy refresh the session quietly in the background instead, using a refresh token:
225-
226-
```yaml
227-
oidc:
228-
clientId: dashy
229-
endpoint: https://auth.example.com/application/o/dashy/
230-
adminGroup: dashy-admins
231-
scope: openid profile email groups
232-
enableSilentRenew: true
233-
```
234-
235-
Dashy adds the `offline_access` scope to its request automatically. Authentik ships an `offline_access` scope mapping by default, so just make sure it's listed under the provider's **Advanced protocol settings > Selected Scopes**. It's off by default, and if a refresh ever fails Dashy falls back to the normal sign-in. See [silent token renewal](../authentication.md#silent-token-renewal) for the full notes and caveats.
236-
237242
---
238243

239244
## 4. Groups and Visibility
@@ -266,6 +271,22 @@ sections:
266271
groups: ['interns']
267272
```
268273

274+
275+
## 5. Silent token renewal (optional)
276+
277+
By default, when your token expires Dashy sends you back through Authentik's login to get a new one. Set `enableSilentRenew: true` to have Dashy refresh the session quietly in the background instead, using a refresh token:
278+
279+
```yaml
280+
oidc:
281+
clientId: dashy
282+
endpoint: https://auth.example.com/application/o/dashy/
283+
adminGroup: dashy-admins
284+
scope: openid profile email groups
285+
enableSilentRenew: true
286+
```
287+
288+
Dashy adds the `offline_access` scope to its request automatically. Authentik ships an `offline_access` scope mapping by default, so just make sure it's listed under the provider's **Advanced protocol settings > Selected Scopes**. It's off by default, and if a refresh ever fails Dashy falls back to the normal sign-in. See [silent token renewal](./oidc.md#silent-token-renewal) for the full notes and caveats.
289+
269290
---
270291

271292
## Troubleshooting common Authentik Issues
@@ -349,7 +370,7 @@ Boot starts in [`src/main.js`](https://github.com/lissy93/dashy/blob/4.1.5/src/m
349370
- `loadOidcSettings()` reads `auth.oidc` (or `auth.keycloak`) at boot and returns a normalised `{ issuer, clientId, adminGroup, adminRole }`. For generic OIDC providers the `issuer` is whatever you set as `endpoint` in `conf.yml`, verbatim
350371
- `createOidcMiddleware()` returns a Connect middleware. Permissive on no-token requests so the SPA can bootstrap; otherwise it verifies the Bearer token against the issuer's JWKS using [`jose`](https://github.com/panva/jose). Checks cover signature, issuer (against the canonical value from the discovery doc), audience (must equal `clientId`), and expiry, with a 30-second clock-skew tolerance. Sets `req.auth = { user, isAdmin, claims }` on success, `401` on failure
351372
- `getIssuerContext()` lazily fetches `.well-known/openid-configuration` on first use and wraps `jwks_uri` in `createRemoteJWKSet`, which handles JWKS caching and on-demand key rotation. The result is memoised per-issuer for the life of the process
352-
- `deriveIsAdmin()` checks the token's `groups` claim against `adminGroup`, and the `realm_access.roles` / `resource_access.<clientId>.roles` arrays against `adminRole`. Authentik only emits `groups`, so the group path is what's used in practice
373+
- `deriveIsAdmin()` checks the token's `groups` claim against `adminGroup`, and the top-level `roles` claim against `adminRole` (for Keycloak it also folds in the nested `realm_access.roles` / `resource_access.<clientId>.roles` arrays). Authentik only emits `groups`, so the group path is what's used in practice
353374
- `maybeBootstrapConfig()` is the stripped-response helper. When auth is configured, guest access is off, and an unauthenticated request hits the root `/conf.yml`, it returns a minimal copy with only `appConfig.auth`, `appConfig.enableServiceWorker`, and a `pageInfo.title` of `Login | <your title>`. Sections, items, hostnames and any other secrets never leave the server
354375

355376
[`services/app.js`](https://github.com/lissy93/dashy/blob/4.1.5/services/app.js) wires it all together. The middleware mounts as `protectConfig` in front of every YAML route and config-mutating route. The `/*.yml` handler sets `Cache-Control: private, no-store` and `Vary: Authorization` whenever auth is configured (so intermediate caches can never mix auth states), then calls `maybeBootstrapConfig`; a stripped result is sent as-is, otherwise `res.sendFile` serves the full file. `POST /config-manager/save` is additionally guarded by `requireAdmin`, which returns `401` if `req.auth` is unset and `403` if `req.auth.isAdmin` is false.

0 commit comments

Comments
 (0)