From f6e525a5e85036e2582d95700eef3a949d053bac Mon Sep 17 00:00:00 2001 From: tada5hi Date: Wed, 19 Aug 2026 10:16:59 +0200 Subject: [PATCH 1/3] feat(authup): track authup 1.0.0-beta.62 Bumps appVersion from 1.0.0-beta.58 and picks up the operator-visible changes of the beta.59 to beta.62 line. ACCOUNT_CONSOLE_ENABLED (beta.62) becomes server.features.accountConsole, defaulting to true like authup itself. It serves the /account self-service surface (profile, password, authenticators, sessions, applications) off the IdP origin, so NOTES now prints its URL alongside the OIDC discovery one. TRUSTED_ORIGINS gained a boot-time rejection of "**" in a host (beta.59). Every trusted origin becomes an /** redirect pattern on the built-in system clients, and a "**" there matches the rest of the value outright, so one typo turns that allowlist into allow-any-origin. The chart asserts it AFTER tpl rendering, in authup.server.trustedOrigins, because a template-valued origin and the auto-appended admin console origin only materialize there. A single "*" stays allowed: authup supports it as a host wildcard. The per-realm `web` client was removed in the same release, so the trustedOrigins doc no longer names it. TRUSTED_ORIGINS now feeds the redirect allowlists of the per-realm system clients, and "admin-console" and "account-console" are reserved names that authup takes over in every realm. NOTES warns about that whenever provisioning is enabled, since a provisioning file declaring either name loses its attributes silently. AUTH_CONSOLE_PATH / ACCOUNT_CONSOLE_PATH stay deliberately out of the value surface. They substitute a whole console package, which owns the prompt ladder, PKCE and redirect gating, so they are a server.config plus extraVolumes escape hatch and the doc comment points at server.theme for the branding case they are usually reached for by mistake. --- .agents/references/authup.md | 24 +++++++++++++++++++++--- .agents/testing.md | 4 ++++ charts/authup/Chart.yaml | 10 ++++++---- charts/authup/README.md | 12 +++++++----- charts/authup/README.md.gotmpl | 5 +++-- charts/authup/templates/NOTES.txt | 10 ++++++++++ charts/authup/templates/_server-env.tpl | 3 ++- charts/authup/templates/_urls.tpl | 20 +++++++++++++++++++- charts/authup/values.schema.json | 14 +++++++++++--- charts/authup/values.yaml | 16 +++++++++++++--- 10 files changed, 96 insertions(+), 22 deletions(-) diff --git a/.agents/references/authup.md b/.agents/references/authup.md index f9d9708..6e9639d 100644 --- a/.agents/references/authup.md +++ b/.agents/references/authup.md @@ -3,7 +3,7 @@ Repo: https://github.com/authup/authup (local checkout commonly at `/opt/projects/authup/authup`). The chart encodes facts about the app; verify against these sources when authup releases change behavior. Pinned against the -v1.0.0-beta.58 line (chart `appVersion`). +v1.0.0-beta.62 line (chart `appVersion`). ## Image / entrypoint contract @@ -14,6 +14,7 @@ v1.0.0-beta.58 line (chart `appVersion`). | Image runs as root; writable paths `/usr/src/app/writable` + npm cache | `Dockerfile` (`WRITABLE_DIRECTORY_PATH`, no `USER`) | emptyDir mounts + `npm_config_cache=/tmp/.npm-cache`; root securityContext default | | `latest`/``/`beta`/`next` tags | `.github/workflows/release.yml`, `docker-nightly.yml` | `image.tag` defaults to `Chart.AppVersion` | | `authup` CLI supervisor NOT routable through the entrypoint | `entrypoint.sh` case statement | chart never offers a combined pod | +| An unknown service arg EXITS 1 since beta.59 (it used to exit 0 and start nothing); `client/web` was renamed `client/admin-console` with no alias | `entrypoint.sh` `*)` branch | chart already passes `client/admin-console` | ## server-core env surface @@ -29,9 +30,13 @@ Docs mirror: `docs/src/guide/deployment/configuration-server-core*.md`. | `SMTP` (URL form; per-field SMTP is config-file-only) | `secret-smtp.yaml` | | `PUBLIC_URL`, `TRUSTED_ORIGINS`, `TRUST_PROXY` (app default trusts every hop; chart pins "1") | `_urls.tpl` + `authup.server.configEnv` | | `REGISTRATION_ENABLED`, `PASSWORD_RECOVERY_ENABLED`, `EMAIL_VERIFICATION_ENABLED`, `MFA_ENABLED`, `MFA_REQUIRED` (strict booleans: unparsable value crashes boot) | `server.features.*` / `server.mfa.*`, always quoted | +| `ACCOUNT_CONSOLE_ENABLED` (beta.62, default true): serves the `/account` self-service SPA off the IdP origin | `server.features.accountConsole` | +| `THEME_DIRECTORY_PATH` / `THEME_FRAGMENTS_ENABLED` (beta.59, EXPERIMENTAL): operator theme for the two served consoles; manifest at `/theme.json`, HTTP mount root is `/assets` only | `server.theme.*` (the chart composes theme.json) | +| `AUTH_CONSOLE_PATH` / `ACCOUNT_CONSOLE_PATH`: substitute a whole console package, boot-asserted `CONTRACT_VERSION` | deliberately NOT first-class; `server.config` + `extraVolumes` escape hatch | | `USER_ADMIN_PASSWORD(_RESET)`, `CLIENT_SYSTEM_ENABLED/SECRET(_RESET)` | `auth.*` values + chart-managed secret | | `SECRETS_ENCRYPTION_KEY` (base64 32 bytes; write-once, removal with wrapped rows fails loud) | `auth.secretsEncryptionKey(+Enabled)`, never generated, never optional | | Cross-field boot validations (throttle needs event log, mfaRequired needs mfaEnabled, KEK length) | mirrored as render-time guards in `templates/validations.yaml` | +| `TRUSTED_ORIGINS` rejects `**` in a host at boot since beta.59 (`config/origins.ts`, `patternHasGlobstarInAuthority`); a single `*` is a supported host wildcard | `authup.assertTrustedOrigin` in `_urls.tpl`, asserted after tpl rendering | Config file: `authup.server.core.conf` in the process cwd (`app/modules/config/read/fs.ts`; env always wins) -> `server.configuration` / @@ -47,8 +52,10 @@ and dead): `apps/client-admin-console/nuxt.config.ts`, `NUXT_PUBLIC_API_URL` (browser-reachable server URL), `NUXT_PUBLIC_PUBLIC_URL`, `NUXT_API_URL` (SSR-side override), `NUXT_PUBLIC_COOKIE_DOMAIN` (deliberately never set by the chart: sharing a cookie domain with the server origin is -unsupported per `.agents/architecture.md` in the monorepo). Chart counterpart: -`_admin-console-env.tpl`. +unsupported per `.agents/architecture.md` in the monorepo), +`NUXT_PUBLIC_CLIENT_ID` (beta.59+, defaults to the per-realm built-in +`admin-console` client; fork-only override, reachable via +`adminConsole.config`). Chart counterpart: `_admin-console-env.tpl`. ## Operational contract @@ -65,3 +72,14 @@ unsupported per `.agents/architecture.md` in the monorepo). Chart counterpart: ServiceMonitor targets the Service; ingress warning in values/NOTES. - In-process cron sweepers (oauth2-cleaner, event-cleaner) are idempotent deletes; no leader election needed. +- Reserved client names: `admin-console` and `account-console` are provisioned + as built-in system clients in EVERY realm and take over a pre-existing client + of that name (beta.59). The shared per-realm `web` client was removed in the + same release; `TRUSTED_ORIGINS` now feeds the system clients' redirect + allowlists. NOTES warns against declaring either name in + `server.provisioning`. +- beta.60 ships a heavy migration (140 indexes, MySQL `varchar(36)` -> + `varchar(255)` table rewrites, three dropped tables) and beta.62 adds a + unique constraint on `auth_identity_provider_accounts` that ABORTS the boot + on pre-existing duplicates. Both are arguments for + `server.migration.enabled` on an upgrade, not just for multi-replica. diff --git a/.agents/testing.md b/.agents/testing.md index cbe742b..b770e6d 100644 --- a/.agents/testing.md +++ b/.agents/testing.md @@ -46,8 +46,12 @@ helm template t charts/authup --set server.publicUrl=auth.example.com # s helm template t charts/authup --set postgresql.enabled=false --set externalDatabase.host=db # extdb w/o password helm template t charts/authup --set server.ingress.enabled=true # ingress w/o hostname helm template t charts/authup --set server.config.PUBLIC_URL=http://x # first-class collision +helm template t charts/authup --set 'server.trustedOrigins[0]=https://**.x' # globstar host ``` +A single `*` host wildcard (`https://*.example.com`) must still RENDER: authup +supports it, only `**` is the allow-any-origin trap. + The generated `values.schema.json` must keep catching typos (`--set server.replicaCountt=3` fails) while free-form maps stay open (`--set server.config.X=y`, `--set server.resources.limits.cpu=1` succeed). diff --git a/charts/authup/Chart.yaml b/charts/authup/Chart.yaml index bcc5504..b27cea6 100644 --- a/charts/authup/Chart.yaml +++ b/charts/authup/Chart.yaml @@ -5,7 +5,7 @@ description: Authup is an authentication & authorization system. This chart with optional built-in PostgreSQL, MySQL and Valkey instances. type: application version: 0.2.0 -appVersion: "1.0.0-beta.58" +appVersion: "1.0.0-beta.62" kubeVersion: ">=1.25.0-0" home: https://authup.org icon: https://raw.githubusercontent.com/authup/helm/master/assets/icon.svg @@ -31,7 +31,9 @@ annotations: - name: Source url: https://github.com/authup/helm artifacthub.io/changes: | - - kind: fixed - description: Chart license corrected to Apache-2.0 + - kind: changed + description: appVersion tracks authup 1.0.0-beta.62 - kind: added - description: Chart icon + description: server.features.accountConsole toggles the /account self-service console + - kind: added + description: Trusted origins carrying "**" now fail the render, as authup fails the boot diff --git a/charts/authup/README.md b/charts/authup/README.md index 0280f29..f1c304a 100644 --- a/charts/authup/README.md +++ b/charts/authup/README.md @@ -7,13 +7,14 @@ # authup ![Version](https://img.shields.io/badge/Version-0.2.0?style=flat-square&color=informational) -![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.0.0-beta.58](https://img.shields.io/badge/AppVersion-1.0.0--beta.58-informational?style=flat-square) +![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.0.0-beta.62](https://img.shields.io/badge/AppVersion-1.0.0--beta.62-informational?style=flat-square) Authup is an authentication & authorization system. This chart deploys the server-core IdP/API service and the client-admin-console admin UI, with optional built-in PostgreSQL, MySQL and Valkey instances. It deploys: - **server-core** — the Authup IdP/API service: the OAuth2/OIDC protocol - surface plus the server-rendered auth pages (login, consent, registration, - password recovery). This is the identity origin. + surface, the server-rendered auth pages (login, consent, registration, + password recovery) and the `/account` self-service console + (`server.features.accountConsole`). This is the identity origin. - **client-admin-console** — the Nuxt-based admin UI, an ordinary OAuth2 relying party (optional; disable with `adminConsole.enabled=false` for a headless IdP). - optionally, single-instance **PostgreSQL**, **MySQL** or **Valkey** built-in @@ -297,7 +298,7 @@ Kubernetes: `>=1.25.0-0` | server.autoscaling.hpa.targetCPU | int | `75` | Target CPU utilization percentage | | server.autoscaling.hpa.targetMemory | string | `""` | Target memory utilization percentage | | server.command | list | `[]` | Override the container command | -| server.config | object | `{}` | Extra environment variables rendered literally into the env ConfigMap (map of NAME: value) for options without first-class values | +| server.config | object | `{}` | Extra environment variables rendered literally into the env ConfigMap (map of NAME: value) for options without first-class values, e.g. AUTH_CONSOLE_PATH / ACCOUNT_CONSOLE_PATH, which replace a served console with your own build (pair them with extraVolumes; the substituted package owns the login flow, so use server.theme for branding instead) | | server.configuration | string | `""` | Content of an authup.server.core.conf mounted into the working directory for file-only options (middleware objects, per-field SMTP, CORS allowlist). Environment variables always win over file values. | | server.containerSecurityContext | object | `{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"enabled":true,"readOnlyRootFilesystem":false,"runAsNonRoot":false,"runAsUser":0,"seccompProfile":{"type":"RuntimeDefault"}}` | Container security context. The upstream image runs as root and needs a writable npm cache; the chart mounts emptyDirs at /usr/src/app/writable and /tmp to keep readOnlyRootFilesystem viable. | | server.customLivenessProbe | object | `{}` | Custom liveness probe | @@ -311,6 +312,7 @@ Kubernetes: `>=1.25.0-0` | server.extraEnvVarsSecret | string | `""` | Extra Secret with environment variables (tpl-rendered name) | | server.extraVolumeMounts | list | `[]` | Extra volume mounts (tpl-rendered) | | server.extraVolumes | list | `[]` | Extra volumes (tpl-rendered) | +| server.features.accountConsole | bool | `true` | Serve the account self-service console at /account (profile, password, authenticators, sessions, applications). ACCOUNT_CONSOLE_ENABLED; disable it when you run your own portal | | server.features.emailVerification | bool | `false` | Enable email verification (EMAIL_VERIFICATION_ENABLED; requires SMTP) | | server.features.passwordRecovery | bool | `false` | Enable password recovery (PASSWORD_RECOVERY_ENABLED; requires SMTP) | | server.features.registration | bool | `false` | Enable self-service user registration (REGISTRATION_ENABLED) | @@ -414,7 +416,7 @@ Kubernetes: `>=1.25.0-0` | server.tolerations | list | `[]` | Tolerations | | server.topologySpreadConstraints | list | `[]` | Topology spread constraints (a missing labelSelector is filled with the pod's selector labels) | | server.trustProxy | string | `"1"` | TRUST_PROXY setting. The chart defaults to one trusted hop (the ingress), not authup's spoofable trust-everything default | -| server.trustedOrigins | list | `[]` | Additional trusted first-party app origins (TRUSTED_ORIGINS). Each listed origin can obtain full-permission tokens via the per-realm web client. List or comma-separated string; tpl-rendered. | +| server.trustedOrigins | list | `[]` | Additional trusted first-party app origins (TRUSTED_ORIGINS). Each entry is added to the redirect allowlist of the per-realm built-in system clients (admin-console, account-console), so any listed origin can complete a login and obtain a full-permission token. A host may carry a single "*" (https://*.example.com); "**" in a host is rejected by authup at boot. List or comma-separated string; tpl-rendered. | | server.trustedOriginsAppendAdminConsole | bool | `true` | Automatically append the client-admin-console UI origin to TRUSTED_ORIGINS (removes the most common dead-login misconfiguration) | | server.updateStrategy | object | `{"type":"RollingUpdate"}` | Deployment update strategy | | serviceAccount.annotations | object | `{}` | ServiceAccount annotations (tpl-rendered) | diff --git a/charts/authup/README.md.gotmpl b/charts/authup/README.md.gotmpl index fdc2027..27589f4 100644 --- a/charts/authup/README.md.gotmpl +++ b/charts/authup/README.md.gotmpl @@ -25,8 +25,9 @@ {{ template "chart.description" . }} It deploys: - **server-core** — the Authup IdP/API service: the OAuth2/OIDC protocol - surface plus the server-rendered auth pages (login, consent, registration, - password recovery). This is the identity origin. + surface, the server-rendered auth pages (login, consent, registration, + password recovery) and the `/account` self-service console + (`server.features.accountConsole`). This is the identity origin. - **client-admin-console** — the Nuxt-based admin UI, an ordinary OAuth2 relying party (optional; disable with `adminConsole.enabled=false` for a headless IdP). - optionally, single-instance **PostgreSQL**, **MySQL** or **Valkey** built-in diff --git a/charts/authup/templates/NOTES.txt b/charts/authup/templates/NOTES.txt index 6e4a8e3..b6c6d42 100644 --- a/charts/authup/templates/NOTES.txt +++ b/charts/authup/templates/NOTES.txt @@ -9,6 +9,9 @@ server-core (IdP / API): {{- if $serverUrl }} Public URL / OIDC issuer: {{ $serverUrl }} OIDC discovery: {{ $serverUrl }}/.well-known/openid-configuration +{{- if .Values.server.features.accountConsole }} + Account self-service: {{ $serverUrl }}/account +{{- end }} {{- else }} In-cluster URL: http://{{ include "authup.server.fullname" . }}.{{ include "authup.namespace" . }}.svc:{{ .Values.server.service.ports.http }} (port-forward: kubectl -n {{ include "authup.namespace" . }} port-forward svc/{{ include "authup.server.fullname" . }} 3001:{{ .Values.server.service.ports.http }}) @@ -81,6 +84,13 @@ schema migrations are serialized in a pre-upgrade Job instead of racing at pod boot. {{- end }} +{{- if .Values.server.provisioning.enabled }} + +NOTE: "admin-console" and "account-console" are reserved client names: authup +provisions them as built-in system clients in every realm and overwrites a +client of either name. Do not declare them in server.provisioning. +{{- end }} + {{- if or .Values.postgresql.enabled .Values.mysql.enabled }} The built-in {{ ternary "PostgreSQL" "MySQL" .Values.postgresql.enabled }} instance is a single-node convenience for dev and diff --git a/charts/authup/templates/_server-env.tpl b/charts/authup/templates/_server-env.tpl index d5e71db..847f11d 100644 --- a/charts/authup/templates/_server-env.tpl +++ b/charts/authup/templates/_server-env.tpl @@ -26,6 +26,7 @@ TRUST_PROXY: {{ .Values.server.trustProxy | toString | quote }} REGISTRATION_ENABLED: {{ .Values.server.features.registration | toString | quote }} PASSWORD_RECOVERY_ENABLED: {{ .Values.server.features.passwordRecovery | toString | quote }} EMAIL_VERIFICATION_ENABLED: {{ .Values.server.features.emailVerification | toString | quote }} +ACCOUNT_CONSOLE_ENABLED: {{ .Values.server.features.accountConsole | toString | quote }} MFA_ENABLED: {{ .Values.server.mfa.enabled | toString | quote }} MFA_REQUIRED: {{ .Values.server.mfa.required | toString | quote }} {{- if .Values.auth.adminPasswordReset }} @@ -37,7 +38,7 @@ CLIENT_SYSTEM_ENABLED: "true" CLIENT_SYSTEM_SECRET_RESET: "true" {{- end }} {{- end }} -{{- $reserved := list "DB_TYPE" "DB_HOST" "DB_PORT" "DB_USERNAME" "DB_DATABASE" "DB_PASSWORD" "PUBLIC_URL" "TRUSTED_ORIGINS" "TRUST_PROXY" "REGISTRATION_ENABLED" "PASSWORD_RECOVERY_ENABLED" "EMAIL_VERIFICATION_ENABLED" "MFA_ENABLED" "MFA_REQUIRED" "THEME_DIRECTORY_PATH" "THEME_FRAGMENTS_ENABLED" "USER_ADMIN_PASSWORD" "USER_ADMIN_PASSWORD_RESET" "CLIENT_SYSTEM_ENABLED" "CLIENT_SYSTEM_SECRET" "CLIENT_SYSTEM_SECRET_RESET" "REDIS" "SMTP" "SECRETS_ENCRYPTION_KEY" }} +{{- $reserved := list "DB_TYPE" "DB_HOST" "DB_PORT" "DB_USERNAME" "DB_DATABASE" "DB_PASSWORD" "PUBLIC_URL" "TRUSTED_ORIGINS" "TRUST_PROXY" "REGISTRATION_ENABLED" "PASSWORD_RECOVERY_ENABLED" "EMAIL_VERIFICATION_ENABLED" "ACCOUNT_CONSOLE_ENABLED" "MFA_ENABLED" "MFA_REQUIRED" "THEME_DIRECTORY_PATH" "THEME_FRAGMENTS_ENABLED" "USER_ADMIN_PASSWORD" "USER_ADMIN_PASSWORD_RESET" "CLIENT_SYSTEM_ENABLED" "CLIENT_SYSTEM_SECRET" "CLIENT_SYSTEM_SECRET_RESET" "REDIS" "SMTP" "SECRETS_ENCRYPTION_KEY" }} {{- range $key, $value := .Values.server.config }} {{- if has $key $reserved }} {{- fail (printf "authup: server.config.%s collides with a first-class chart value — set it through the dedicated value instead." $key) }} diff --git a/charts/authup/templates/_urls.tpl b/charts/authup/templates/_urls.tpl index 633ad92..15f0c8e 100644 --- a/charts/authup/templates/_urls.tpl +++ b/charts/authup/templates/_urls.tpl @@ -89,5 +89,23 @@ disabled or already covered by the server public URL's origin. {{- $origins = append $origins $uiOrigin -}} {{- end -}} {{- end -}} -{{- $origins | uniq | join "," -}} +{{- $origins = $origins | uniq -}} +{{- range $origins -}} +{{- include "authup.assertTrustedOrigin" . -}} +{{- end -}} +{{- $origins | join "," -}} +{{- end -}} + +{{/* +Post-render origin assertion. A trusted origin becomes an `/**` +redirect pattern on every realm's built-in system clients, and `**` in the +host matches the rest of the value outright, so one typo turns that +allowlist into allow-any-origin. authup rejects it at boot (beta.59+); +asserting here turns a crash-looping IdP into a failed render. A single `*` +is a supported host wildcard and stays allowed. +*/}} +{{- define "authup.assertTrustedOrigin" -}} +{{- if contains "**" . -}} +{{- fail (printf "authup: a trusted origin must not use \"**\" in the host, it would match every origin. Use a single \"*\" for a host wildcard (got %q)." .) -}} +{{- end -}} {{- end -}} diff --git a/charts/authup/values.schema.json b/charts/authup/values.schema.json index 2143ba7..eea1c60 100644 --- a/charts/authup/values.schema.json +++ b/charts/authup/values.schema.json @@ -2200,7 +2200,7 @@ }, "config": { "additionalProperties": true, - "description": "Extra environment variables rendered literally into the env ConfigMap\n(map of NAME: value) for options without first-class values", + "description": "Extra environment variables rendered literally into the env ConfigMap\n(map of NAME: value) for options without first-class values, e.g.\nAUTH_CONSOLE_PATH / ACCOUNT_CONSOLE_PATH, which replace a served console\nwith your own build (pair them with extraVolumes; the substituted package\nowns the login flow, so use server.theme for branding instead)", "required": [], "title": "config" }, @@ -2380,6 +2380,13 @@ "features": { "additionalProperties": false, "properties": { + "accountConsole": { + "default": true, + "description": "Serve the account self-service console at \u003cpublicUrl\u003e/account\n(profile, password, authenticators, sessions, applications).\nACCOUNT_CONSOLE_ENABLED; disable it when you run your own portal", + "required": [], + "title": "accountConsole", + "type": "boolean" + }, "emailVerification": { "default": false, "description": "Enable email verification (EMAIL_VERIFICATION_ENABLED; requires SMTP)", @@ -2405,7 +2412,8 @@ "required": [ "registration", "passwordRecovery", - "emailVerification" + "emailVerification", + "accountConsole" ], "title": "features", "type": "object" @@ -3381,7 +3389,7 @@ "type": "string" }, "trustedOrigins": { - "description": "Additional trusted first-party app origins (TRUSTED_ORIGINS). Each listed\norigin can obtain full-permission tokens via the per-realm web client.\nList or comma-separated string; tpl-rendered.", + "description": "Additional trusted first-party app origins (TRUSTED_ORIGINS). Each entry\nis added to the redirect allowlist of the per-realm built-in system clients\n(admin-console, account-console), so any listed origin can complete a login\nand obtain a full-permission token. A host may carry a single \"*\"\n(https://*.example.com); \"**\" in a host is rejected by authup at boot.\nList or comma-separated string; tpl-rendered.", "items": { "required": [] }, diff --git a/charts/authup/values.yaml b/charts/authup/values.yaml index 5917134..40a042b 100644 --- a/charts/authup/values.yaml +++ b/charts/authup/values.yaml @@ -402,8 +402,11 @@ server: # "" = derived from server.ingress when enabled. Changing it later breaks # enrolled WebAuthn credentials and the OIDC issuer. publicUrl: "" - # -- Additional trusted first-party app origins (TRUSTED_ORIGINS). Each listed - # origin can obtain full-permission tokens via the per-realm web client. + # -- Additional trusted first-party app origins (TRUSTED_ORIGINS). Each entry + # is added to the redirect allowlist of the per-realm built-in system clients + # (admin-console, account-console), so any listed origin can complete a login + # and obtain a full-permission token. A host may carry a single "*" + # (https://*.example.com); "**" in a host is rejected by authup at boot. # List or comma-separated string; tpl-rendered. trustedOrigins: [] # -- Automatically append the client-admin-console UI origin to TRUSTED_ORIGINS (removes the @@ -419,6 +422,10 @@ server: passwordRecovery: false # -- Enable email verification (EMAIL_VERIFICATION_ENABLED; requires SMTP) emailVerification: false + # -- Serve the account self-service console at /account + # (profile, password, authenticators, sessions, applications). + # ACCOUNT_CONSOLE_ENABLED; disable it when you run your own portal + accountConsole: true mfa: # -- Enable multi-factor authentication (MFA_ENABLED) enabled: false @@ -428,7 +435,10 @@ server: # additionalProperties: true # @schema # -- Extra environment variables rendered literally into the env ConfigMap - # (map of NAME: value) for options without first-class values + # (map of NAME: value) for options without first-class values, e.g. + # AUTH_CONSOLE_PATH / ACCOUNT_CONSOLE_PATH, which replace a served console + # with your own build (pair them with extraVolumes; the substituted package + # owns the login flow, so use server.theme for branding instead) config: {} # -- Content of an authup.server.core.conf mounted into the working directory # for file-only options (middleware objects, per-field SMTP, CORS allowlist). From d133ae7e6e5c9c20cbd8e87a9143463609c4d3eb Mon Sep 17 00:00:00 2001 From: tada5hi Date: Wed, 19 Aug 2026 10:17:43 +0200 Subject: [PATCH 2/3] feat(authup): compose theme.json from structured manifest values The chart could already mount a theme directory, but only as raw file blobs: an operator wrote theme.json as a JSON document inside a YAML string, with no schema behind it and no check that the assets it named existed. server.theme now takes the manifest as values (title, favicon, logo, logoDark, stylesheet, tokens, tokensDark) and composes theme.json itself. Only keys that were actually set are emitted, because authup fails the boot on an unknown manifest key and reads an empty string as a real, broken asset reference. This mirrors an upstream schema, which invariant 14 otherwise forbids. It earns the exception on three counts, recorded in .agents/architecture.md: the manifest is a fixed eight-key document rather than a growing config surface, authup fails the BOOT on a bad key or token so a typo has no cheaper detector, and the thing being replaced is an unschema'd blob. A hand-written theme.json in `files` still works and is mutually exclusive with the manifest. The validation is the point of the feature. Theming fails silently by nature: a missing asset 404s and the page renders exactly like an un-themed one. So the chart now rejects at render time what authup rejects at boot or answers with a 404: an asset outside assets/ (the only directory served over HTTP), an asset no file provides, a non-image logo or a non-css stylesheet, a token name that is not a lowercase custom property, an oversized token value, and each character authup forbids in one. ConfigMap keys and the volume's items projection now derive from one helper, so a key cannot be stored but never mounted. Manifest values cannot be combined with existingConfigMap: that ConfigMap is mounted whole, so the composed theme.json would never reach the pod. The consequence is that a binary logo still needs a hand-written manifest inside that ConfigMap, which the failure message says outright. ci/theme-values.yaml now exercises both carriers in one install. --- .agents/architecture.md | 9 ++ .agents/testing.md | 5 + charts/authup/Chart.yaml | 2 + charts/authup/README.md | 60 +++++++- charts/authup/README.md.gotmpl | 47 +++++++ charts/authup/ci/theme-values.yaml | 31 ++--- charts/authup/templates/_server-env.tpl | 131 +++++++++++++++++- .../templates/server/configmap-theme.yaml | 6 +- .../authup/templates/server/deployment.yaml | 2 +- charts/authup/values.schema.json | 58 +++++++- charts/authup/values.yaml | 36 ++++- 11 files changed, 355 insertions(+), 32 deletions(-) diff --git a/.agents/architecture.md b/.agents/architecture.md index be8b291..8104d98 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -71,6 +71,15 @@ editing templates or values. `server.configuration` file mount). Never mirror authup's config schema in templates (Authelia's 714-line configMap treadmill is the cautionary tale). `server.config` keys colliding with first-class env names fail the render. + The ONE mirrored schema is the theme manifest (`server.theme.title` / + `logo` / `tokens` / ... compose `theme.json`), and it earns the exception + on three counts: the file is a fixed 8-key document rather than a growing + config surface, authup fails the BOOT on an unknown key or a malformed + token so a typo has no cheaper detector, and the alternative is a JSON + blob inside a YAML string with no schema at all. It stays worth it only + while the manifest stays small: `files` remains the escape hatch, and a + hand-written `theme.json` there is still supported (the two are mutually + exclusive by validation). 15. **URL derivation is the chart's core UX.** `PUBLIC_URL`, `NUXT_PUBLIC_API_URL`, `NUXT_PUBLIC_PUBLIC_URL` derive from the two ingress blocks; the UI origin is auto-appended to `TRUSTED_ORIGINS` diff --git a/.agents/testing.md b/.agents/testing.md index b770e6d..8283390 100644 --- a/.agents/testing.md +++ b/.agents/testing.md @@ -46,6 +46,11 @@ helm template t charts/authup --set server.publicUrl=auth.example.com # s helm template t charts/authup --set postgresql.enabled=false --set externalDatabase.host=db # extdb w/o password helm template t charts/authup --set server.ingress.enabled=true # ingress w/o hostname helm template t charts/authup --set server.config.PUBLIC_URL=http://x # first-class collision +helm template t charts/authup --set server.theme.enabled=true # theme with no carrier +helm template t charts/authup --set server.theme.enabled=true --set server.theme.title=X --set server.theme.existingConfigMap=cm # manifest + existing CM +helm template t charts/authup --set server.theme.enabled=true --set server.theme.logo=logo.svg # asset outside assets/ +helm template t charts/authup --set server.theme.enabled=true --set server.theme.logo=assets/logo.svg # asset missing from files +helm template t charts/authup --set server.theme.enabled=true --set 'server.theme.tokens.--authup-bg=url(x)' # token value authup rejects helm template t charts/authup --set 'server.trustedOrigins[0]=https://**.x' # globstar host ``` diff --git a/charts/authup/Chart.yaml b/charts/authup/Chart.yaml index b27cea6..3800f0c 100644 --- a/charts/authup/Chart.yaml +++ b/charts/authup/Chart.yaml @@ -33,6 +33,8 @@ annotations: artifacthub.io/changes: | - kind: changed description: appVersion tracks authup 1.0.0-beta.62 + - kind: added + description: server.theme manifest values compose theme.json (title, logo, tokens) - kind: added description: server.features.accountConsole toggles the /account self-service console - kind: added diff --git a/charts/authup/README.md b/charts/authup/README.md index f1c304a..83fd55e 100644 --- a/charts/authup/README.md +++ b/charts/authup/README.md @@ -89,6 +89,53 @@ Notable operational facts (enforced or warned about by the chart): `server.configuration` file. See the [Authup configuration reference](https://authup.org). +## Theming the served consoles + +Both consoles server-core serves (the auth pages and `/account`) are rebranded +from a directory the chart mounts read-only. Set the manifest as values and the +chart composes `theme.json` for you; `files` carries the assets it references: + +```yaml +server: + theme: + enabled: true + title: Sign in to ACME + logo: assets/logo.svg + stylesheet: assets/theme.css + tokens: + # the accent the whole primary palette is mixed from + --authup-periwinkle: "#c0392b" + --authup-surface-card: "#ffffff" + tokensDark: + --authup-surface-card: "#201e1d" + files: + assets/logo.svg: | + + assets/theme.css: | + .a-auth-shell-card { border: 1px solid var(--authup-surface-border); } +``` + +A colour in `tokens` wins in dark mode too, so surface colours belong in both +maps. The chart rejects at render time what Authup rejects at boot or answers +with a 404: an asset outside `assets/`, an asset no file provides, a token name +that is not a lowercase custom property, and a token value carrying `url(` or +`;`. Only `assets/` is served over HTTP, so `theme.json` is unreachable by +construction. + +`existingConfigMap` replaces the whole mechanism when you need binary assets +(`binaryData`); it is mounted whole, so it must carry `theme.json` itself and +cannot be combined with the manifest values. `fragmentsEnabled` splices +`fragments/head.html` into the console `` verbatim: raw operator markup +on the origin that holds your users' session cookies, hence opt-in. + +> The theme directory is as sensitive as the config file: CSS there can restyle +> or cover the OAuth2 consent buttons. Never source it from somewhere a tenant +> or a lower-privileged CI job can write. + +Theming is experimental upstream: the directory layout and the `theme*` options +may change in an Authup minor release. See the +[Authup theming guide](https://authup.org/guide/deployment/theming.html). + ## GitOps / ArgoCD The generate-once-keep-forever behavior of empty passwords relies on helm's @@ -408,11 +455,18 @@ Kubernetes: `>=1.25.0-0` | server.startupProbe.successThreshold | int | `1` | | | server.startupProbe.timeoutSeconds | int | `5` | | | server.terminationGracePeriodSeconds | int | `30` | Pod termination grace period (server-core tears down within ~10s after signal) | -| server.theme.enabled | bool | `false` | Mount an operator theme for the served consoles (the auth console and the account console). Requires an authup image that supports THEME_DIRECTORY_PATH; older images ignore it | -| server.theme.existingConfigMap | string | `""` | Existing ConfigMap holding the theme (tpl-rendered name). Use for binary assets, which cannot be expressed in files | +| server.theme.enabled | bool | `false` | Mount an operator theme for the served consoles (the auth console and the account console). Requires an authup image that supports THEME_DIRECTORY_PATH; older images ignore it. Experimental upstream: the directory layout and the theme* options may change in a minor release | +| server.theme.existingConfigMap | string | `""` | Existing ConfigMap holding the theme (tpl-rendered name). Use for binary assets, which cannot be expressed in files. Mounted whole, so it must carry theme.json itself and excludes the manifest values above | | server.theme.existingConfigMapItems | list | `[]` | Key -> path projection for existingConfigMap, so its keys can land in subdirectories (e.g. [{key: theme-css, path: assets/theme.css}]). Empty mounts every key flat at the theme root | -| server.theme.files | object | `{}` | Map of path -> file content, relative to the theme root (tpl-rendered). Keys may carry a "/" ("assets/theme.css") and are projected into subdirectories. Only assets/ is served over HTTP. Text only — use existingConfigMap with binaryData for images | +| server.theme.favicon | string | `""` | Favicon path, relative to the theme root and under assets/ (e.g. assets/favicon.svg). Must be a key of files | +| server.theme.files | object | `{}` | Map of path -> file content, relative to the theme root (tpl-rendered). Keys may carry a "/" ("assets/theme.css") and are projected into subdirectories. Only assets/ is served over HTTP. Text only — use existingConfigMap with binaryData for images. Set theme.json here only when writing the manifest by hand instead of using the values above | | server.theme.fragmentsEnabled | bool | `false` | Read fragments/head.html and splice it into the console . Raw, unsanitized markup on the identity provider origin, so it is opt-in | +| server.theme.logo | string | `""` | Logo replacing the built-in mark on both consoles, under assets/. Painted into the existing mark's box, so it needs no sizing | +| server.theme.logoDark | string | `""` | Dark-mode logo variant, under assets/. Without it dark mode reuses logo, which disappears when the mark is drawn dark-on-light | +| server.theme.stylesheet | string | `""` | Stylesheet path, under assets/ and ending in .css. Linked last, so it beats the token block; it is unlayered, so set dark colors explicitly | +| server.theme.title | string | `""` | Document title of both served consoles ("" = authup's own) | +| server.theme.tokens | object | `{}` | authup-periwinkle alone recolors buttons, focus rings and links. A color set here also wins in dark mode: put surface colors in both tokens and tokensDark | +| server.theme.tokensDark | object | `{}` | CSS custom properties applied in dark mode only (tpl-rendered) | | server.tolerations | list | `[]` | Tolerations | | server.topologySpreadConstraints | list | `[]` | Topology spread constraints (a missing labelSelector is filled with the pod's selector labels) | | server.trustProxy | string | `"1"` | TRUST_PROXY setting. The chart defaults to one trusted hop (the ingress), not authup's spoofable trust-everything default | diff --git a/charts/authup/README.md.gotmpl b/charts/authup/README.md.gotmpl index 27589f4..12931ad 100644 --- a/charts/authup/README.md.gotmpl +++ b/charts/authup/README.md.gotmpl @@ -102,6 +102,53 @@ Notable operational facts (enforced or warned about by the chart): `server.configuration` file. See the [Authup configuration reference](https://authup.org). +## Theming the served consoles + +Both consoles server-core serves (the auth pages and `/account`) are rebranded +from a directory the chart mounts read-only. Set the manifest as values and the +chart composes `theme.json` for you; `files` carries the assets it references: + +```yaml +server: + theme: + enabled: true + title: Sign in to ACME + logo: assets/logo.svg + stylesheet: assets/theme.css + tokens: + # the accent the whole primary palette is mixed from + --authup-periwinkle: "#c0392b" + --authup-surface-card: "#ffffff" + tokensDark: + --authup-surface-card: "#201e1d" + files: + assets/logo.svg: | + + assets/theme.css: | + .a-auth-shell-card { border: 1px solid var(--authup-surface-border); } +``` + +A colour in `tokens` wins in dark mode too, so surface colours belong in both +maps. The chart rejects at render time what Authup rejects at boot or answers +with a 404: an asset outside `assets/`, an asset no file provides, a token name +that is not a lowercase custom property, and a token value carrying `url(` or +`;`. Only `assets/` is served over HTTP, so `theme.json` is unreachable by +construction. + +`existingConfigMap` replaces the whole mechanism when you need binary assets +(`binaryData`); it is mounted whole, so it must carry `theme.json` itself and +cannot be combined with the manifest values. `fragmentsEnabled` splices +`fragments/head.html` into the console `` verbatim: raw operator markup +on the origin that holds your users' session cookies, hence opt-in. + +> The theme directory is as sensitive as the config file: CSS there can restyle +> or cover the OAuth2 consent buttons. Never source it from somewhere a tenant +> or a lower-privileged CI job can write. + +Theming is experimental upstream: the directory layout and the `theme*` options +may change in an Authup minor release. See the +[Authup theming guide](https://authup.org/guide/deployment/theming.html). + ## GitOps / ArgoCD The generate-once-keep-forever behavior of empty passwords relies on helm's diff --git a/charts/authup/ci/theme-values.yaml b/charts/authup/ci/theme-values.yaml index e44d962..2a84084 100644 --- a/charts/authup/ci/theme-values.yaml +++ b/charts/authup/ci/theme-values.yaml @@ -1,24 +1,23 @@ -# Operator theme mounted from an inline files map, covering the path-keyed -# ConfigMap projection (a "/" key must land in a subdirectory) and the env -# wiring. THEME_DIRECTORY_PATH is ignored by an authup image that predates -# console theming, so the rollout assertion holds either way. +# Operator theme covering both carriers in one install: the structured +# manifest values (the chart composes theme.json) and the inline files map +# for the assets they reference, which exercises the path-keyed ConfigMap +# projection (a "/" key must land in a subdirectory) and the env wiring. +# THEME_DIRECTORY_PATH is ignored by an authup image that predates console +# theming, so the rollout assertion holds either way. server: theme: enabled: true fragmentsEnabled: true + title: "Sign in to ACME" + logo: assets/logo.svg + stylesheet: assets/theme.css + tokens: + --authup-periwinkle: "#c0392b" + tokensDark: + --authup-auth-accent: "#e06c5a" files: - theme.json: | - { - "version": 1, - "title": "Sign in to ACME", - "stylesheet": "assets/theme.css", - "tokens": { - "--authup-periwinkle": "#c0392b" - }, - "tokensDark": { - "--authup-auth-accent": "#e06c5a" - } - } + assets/logo.svg: | + assets/theme.css: | .a-auth-shell-card { border-radius: 2px; } fragments/head.html: | diff --git a/charts/authup/templates/_server-env.tpl b/charts/authup/templates/_server-env.tpl index 847f11d..1ecafdb 100644 --- a/charts/authup/templates/_server-env.tpl +++ b/charts/authup/templates/_server-env.tpl @@ -187,7 +187,7 @@ THEME_FRAGMENTS_ENABLED: {{ .Values.server.theme.fragmentsEnabled | toString | q {{- end }} {{- else }} items: - {{- range $path, $content := .Values.server.theme.files }} + {{- range $path := splitList "\n" (include "authup.server.themePaths" $) }} - key: {{ include "authup.server.themeConfigMapKey" $path }} path: {{ $path }} {{- end }} @@ -224,24 +224,145 @@ operator. True when a theme should be mounted at all. */}} {{- define "authup.server.themeMounted" -}} -{{- if and .Values.server.theme.enabled (or .Values.server.theme.files .Values.server.theme.existingConfigMap) -}} +{{- if and .Values.server.theme.enabled (or .Values.server.theme.files .Values.server.theme.existingConfigMap (include "authup.server.themeManifestConfigured" .)) -}} true {{- end -}} {{- end -}} +{{/* +True when any structured manifest value is set, i.e. when the chart owns +theme.json instead of the operator writing it into `files` by hand. +*/}} +{{- define "authup.server.themeManifestConfigured" -}} +{{- $t := .Values.server.theme -}} +{{- if or $t.title $t.favicon $t.logo $t.logoDark $t.stylesheet $t.tokens $t.tokensDark -}} +true +{{- end -}} +{{- end -}} + +{{/* +theme.json composed from the structured manifest values. Only keys the +operator actually set are emitted: authup fails the boot on an UNKNOWN +manifest key, and would also read an empty string as a real (broken) asset +reference, so a "" must never reach the file. + +`version` is authup's manifest version, not the chart's: it is how a future +per-realm layout change becomes detectable instead of silent. +*/}} +{{- define "authup.server.themeManifest" -}} +{{- $ctx := . -}} +{{- $t := .Values.server.theme -}} +{{- $manifest := dict "version" 1 -}} +{{- range $key := list "title" "favicon" "logo" "logoDark" "stylesheet" -}} +{{- $value := get $t $key -}} +{{- if $value -}} +{{- $_ := set $manifest $key (include "authup.tplvalues.render" (dict "value" $value "context" $ctx)) -}} +{{- end -}} +{{- end -}} +{{- range $key := list "tokens" "tokensDark" -}} +{{- $source := get $t $key -}} +{{- if $source -}} +{{- $rendered := dict -}} +{{- range $name, $value := $source -}} +{{- $_ := set $rendered $name (include "authup.tplvalues.render" (dict "value" ($value | toString) "context" $ctx)) -}} +{{- end -}} +{{- $_ := set $manifest $key $rendered -}} +{{- end -}} +{{- end -}} +{{- toPrettyJson $manifest -}} +{{- end -}} + +{{/* +Every path the chart-managed theme ConfigMap carries, newline separated. +Single source for the ConfigMap keys AND the volume's items projection, so +the two cannot diverge into a key that is stored but never mounted. +*/}} +{{- define "authup.server.themePaths" -}} +{{- $paths := list -}} +{{- if include "authup.server.themeManifestConfigured" . -}} +{{- $paths = append $paths "theme.json" -}} +{{- end -}} +{{- range $path, $content := .Values.server.theme.files -}} +{{- $paths = append $paths $path -}} +{{- end -}} +{{- $paths | join "\n" -}} +{{- end -}} + {{/* Render-time validation. The chart fails loud rather than shipping a silently-inert theme: the dominant failure mode of theming is a page that looks exactly like an un-themed page. */}} {{- define "authup.server.validateTheme" -}} +{{- $theme := .Values.server.theme }} +{{- $manifest := include "authup.server.themeManifestConfigured" . }} {{- if .Values.server.theme.enabled }} -{{- if not (or .Values.server.theme.files .Values.server.theme.existingConfigMap) }} -{{- fail "authup: server.theme.enabled requires server.theme.files or server.theme.existingConfigMap — an empty theme directory would render an un-themed page with no error." }} +{{- if not (or $theme.files $theme.existingConfigMap $manifest) }} +{{- fail "authup: server.theme.enabled requires server.theme.files, server.theme.existingConfigMap or the structured manifest values (title / logo / tokens / ...). An empty theme directory would render an un-themed page with no error." }} {{- end }} -{{- if and .Values.server.theme.files .Values.server.theme.existingConfigMap }} +{{- if and $theme.files $theme.existingConfigMap }} {{- fail "authup: set either server.theme.files or server.theme.existingConfigMap, not both — the existing ConfigMap would win and the inline files would be silently ignored." }} {{- end }} +{{- if and $manifest $theme.existingConfigMap }} +{{- fail "authup: the structured server.theme manifest values cannot be combined with server.theme.existingConfigMap: the ConfigMap is mounted whole, so the chart-composed theme.json would never reach the pod. Put theme.json into that ConfigMap instead." }} +{{- end }} +{{- if and $manifest (hasKey $theme.files "theme.json") }} +{{- fail "authup: server.theme.files has a \"theme.json\" key while the structured manifest values are also set. The chart composes theme.json from those values, so one of the two would be silently dropped. Use one or the other." }} +{{- end }} +{{- /* An asset reference the theme does not carry is the exact failure the + chart exists to catch: authup answers 404 and the page renders + un-themed, which looks identical to theming being off. Only assets/ + is served over HTTP (the theme root is deliberately unreachable, so + theme.json cannot be fetched), hence the prefix requirement. */}} +{{- $imageExtensions := list ".svg" ".png" ".jpg" ".jpeg" ".gif" ".webp" ".avif" ".ico" }} +{{- range $key := list "favicon" "logo" "logoDark" }} +{{- $value := get $theme $key }} +{{- if $value }} +{{- if not (hasPrefix "assets/" $value) }} +{{- fail (printf "authup: server.theme.%s must reference a file under \"assets/\" (got %q). assets/ is the only directory authup serves over HTTP." $key $value) }} +{{- end }} +{{- $matched := false }} +{{- range $extension := $imageExtensions }} +{{- if hasSuffix $extension (lower $value) }}{{- $matched = true }}{{- end }} +{{- end }} +{{- if not $matched }} +{{- fail (printf "authup: server.theme.%s must be an image (%s), got %q. authup returns 404 for any other type." $key (join ", " $imageExtensions) $value) }} +{{- end }} +{{- if not (hasKey ($theme.files | default dict) $value) }} +{{- fail (printf "authup: server.theme.%s references %q, which is not a key of server.theme.files. The asset would 404 and the console would render un-themed." $key $value) }} +{{- end }} +{{- end }} +{{- end }} +{{- if $theme.stylesheet }} +{{- if not (hasPrefix "assets/" $theme.stylesheet) }} +{{- fail (printf "authup: server.theme.stylesheet must reference a file under \"assets/\" (got %q). assets/ is the only directory authup serves over HTTP." $theme.stylesheet) }} +{{- end }} +{{- if not (hasSuffix ".css" (lower $theme.stylesheet)) }} +{{- fail (printf "authup: server.theme.stylesheet must be a .css file (got %q)." $theme.stylesheet) }} +{{- end }} +{{- if not (hasKey ($theme.files | default dict) $theme.stylesheet) }} +{{- fail (printf "authup: server.theme.stylesheet references %q, which is not a key of server.theme.files. The stylesheet would 404 and the console would render un-themed." $theme.stylesheet) }} +{{- end }} +{{- end }} +{{- /* Mirrors authup's own manifest validation. It fails the BOOT on a bad + token, so catching it at render time turns a crash-looping IdP into a + failed `helm upgrade`. */}} +{{- range $key := list "tokens" "tokensDark" }} +{{- range $name, $value := (get $theme $key) }} +{{- if not (regexMatch "^--[a-z0-9-]+$" $name) }} +{{- fail (printf "authup: server.theme.%s key %q must be a lowercase CSS custom property (--foo-bar)." $key $name) }} +{{- end }} +{{- $rendered := $value | toString }} +{{- if gt (len $rendered) 256 }} +{{- fail (printf "authup: server.theme.%s.%s exceeds the 256 character limit authup enforces on a token value." $key $name) }} +{{- end }} +{{- range $forbidden := list "}" "<" ">" ";" "@" "\\" "/*" "url(" "expression(" }} +{{- if contains $forbidden $rendered }} +{{- fail (printf "authup: server.theme.%s.%s contains %q, which authup rejects in a token value. Use server.theme.stylesheet for anything needing url() or multiple declarations." $key $name $forbidden) }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} {{- range $path, $content := .Values.server.theme.files }} {{- if hasPrefix "/" $path }} {{- fail (printf "authup: server.theme.files key %q must be relative to the theme root." $path) }} diff --git a/charts/authup/templates/server/configmap-theme.yaml b/charts/authup/templates/server/configmap-theme.yaml index 59d7169..5d5e79c 100644 --- a/charts/authup/templates/server/configmap-theme.yaml +++ b/charts/authup/templates/server/configmap-theme.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.server.enabled .Values.server.theme.enabled .Values.server.theme.files (not .Values.server.theme.existingConfigMap) }} +{{- if and .Values.server.enabled .Values.server.theme.enabled (not .Values.server.theme.existingConfigMap) (or .Values.server.theme.files (include "authup.server.themeManifestConfigured" .)) }} apiVersion: v1 kind: ConfigMap metadata: @@ -11,6 +11,10 @@ data: subdirectories. Keys are flattened here and projected back to their real path by the volume's items list, so the operator writes "assets/theme.css" and never sees the encoding. */}} + {{- if include "authup.server.themeManifestConfigured" . }} + theme.json: |- + {{- include "authup.server.themeManifest" . | nindent 4 }} + {{- end }} {{- range $path, $content := .Values.server.theme.files }} {{ include "authup.server.themeConfigMapKey" $path }}: |- {{- include "authup.tplvalues.render" (dict "value" $content "context" $) | nindent 4 }} diff --git a/charts/authup/templates/server/deployment.yaml b/charts/authup/templates/server/deployment.yaml index 684b648..ee53043 100644 --- a/charts/authup/templates/server/deployment.yaml +++ b/charts/authup/templates/server/deployment.yaml @@ -44,7 +44,7 @@ spec: {{- if and .Values.server.provisioning.enabled .Values.server.provisioning.files }} checksum/provisioning: {{ include (print $.Template.BasePath "/server/configmap-provisioning.yaml") . | sha256sum }} {{- end }} - {{- if and .Values.server.theme.enabled .Values.server.theme.files }} + {{- if and .Values.server.theme.enabled (or .Values.server.theme.files (include "authup.server.themeManifestConfigured" .)) }} checksum/theme: {{ include (print $.Template.BasePath "/server/configmap-theme.yaml") . | sha256sum }} {{- end }} {{- if .Values.server.configuration }} diff --git a/charts/authup/values.schema.json b/charts/authup/values.schema.json index eea1c60..a6c28cc 100644 --- a/charts/authup/values.schema.json +++ b/charts/authup/values.schema.json @@ -3319,14 +3319,14 @@ "properties": { "enabled": { "default": false, - "description": "Mount an operator theme for the served consoles (the auth console and\nthe account console). Requires an authup image that supports\nTHEME_DIRECTORY_PATH; older images ignore it", + "description": "Mount an operator theme for the served consoles (the auth console and\nthe account console). Requires an authup image that supports\nTHEME_DIRECTORY_PATH; older images ignore it. Experimental upstream: the\ndirectory layout and the theme* options may change in a minor release", "required": [], "title": "enabled", "type": "boolean" }, "existingConfigMap": { "default": "", - "description": "Existing ConfigMap holding the theme (tpl-rendered name). Use for\nbinary assets, which cannot be expressed in files", + "description": "Existing ConfigMap holding the theme (tpl-rendered name). Use for\nbinary assets, which cannot be expressed in files. Mounted whole, so it\nmust carry theme.json itself and excludes the manifest values above", "required": [], "title": "existingConfigMap", "type": "string" @@ -3340,9 +3340,16 @@ "title": "existingConfigMapItems", "type": "array" }, + "favicon": { + "default": "", + "description": "Favicon path, relative to the theme root and under assets/\n(e.g. assets/favicon.svg). Must be a key of files", + "required": [], + "title": "favicon", + "type": "string" + }, "files": { "additionalProperties": true, - "description": "Map of path -\u003e file content, relative to the theme root (tpl-rendered).\nKeys may carry a \"/\" (\"assets/theme.css\") and are projected into\nsubdirectories. Only assets/ is served over HTTP. Text only — use\nexistingConfigMap with binaryData for images", + "description": "Map of path -\u003e file content, relative to the theme root (tpl-rendered).\nKeys may carry a \"/\" (\"assets/theme.css\") and are projected into\nsubdirectories. Only assets/ is served over HTTP. Text only — use\nexistingConfigMap with binaryData for images. Set theme.json here only\nwhen writing the manifest by hand instead of using the values above", "required": [], "title": "files" }, @@ -3352,10 +3359,55 @@ "required": [], "title": "fragmentsEnabled", "type": "boolean" + }, + "logo": { + "default": "", + "description": "Logo replacing the built-in mark on both consoles, under assets/.\nPainted into the existing mark's box, so it needs no sizing", + "required": [], + "title": "logo", + "type": "string" + }, + "logoDark": { + "default": "", + "description": "Dark-mode logo variant, under assets/. Without it dark mode reuses\nlogo, which disappears when the mark is drawn dark-on-light", + "required": [], + "title": "logoDark", + "type": "string" + }, + "stylesheet": { + "default": "", + "description": "Stylesheet path, under assets/ and ending in .css. Linked last, so it\nbeats the token block; it is unlayered, so set dark colors explicitly", + "required": [], + "title": "stylesheet", + "type": "string" + }, + "title": { + "default": "", + "description": "Document title of both served consoles (\"\" = authup's own)", + "required": [], + "title": "title", + "type": "string" + }, + "tokens": { + "additionalProperties": true, + "description": "CSS custom properties applied in both color modes (tpl-rendered).\nauthup-periwinkle alone recolors buttons, focus rings and links.\nA color set here also wins in dark mode: put surface colors in both\ntokens and tokensDark", + "required": [], + "title": "tokens" + }, + "tokensDark": { + "additionalProperties": true, + "description": "CSS custom properties applied in dark mode only (tpl-rendered)", + "required": [], + "title": "tokensDark" } }, "required": [ "enabled", + "title", + "favicon", + "logo", + "logoDark", + "stylesheet", "existingConfigMap", "existingConfigMapItems", "fragmentsEnabled" diff --git a/charts/authup/values.yaml b/charts/authup/values.yaml index 40a042b..677b5e4 100644 --- a/charts/authup/values.yaml +++ b/charts/authup/values.yaml @@ -464,18 +464,48 @@ server: theme: # -- Mount an operator theme for the served consoles (the auth console and # the account console). Requires an authup image that supports - # THEME_DIRECTORY_PATH; older images ignore it + # THEME_DIRECTORY_PATH; older images ignore it. Experimental upstream: the + # directory layout and the theme* options may change in a minor release enabled: false + # -- Document title of both served consoles ("" = authup's own) + title: "" + # -- Favicon path, relative to the theme root and under assets/ + # (e.g. assets/favicon.svg). Must be a key of files + favicon: "" + # -- Logo replacing the built-in mark on both consoles, under assets/. + # Painted into the existing mark's box, so it needs no sizing + logo: "" + # -- Dark-mode logo variant, under assets/. Without it dark mode reuses + # logo, which disappears when the mark is drawn dark-on-light + logoDark: "" + # -- Stylesheet path, under assets/ and ending in .css. Linked last, so it + # beats the token block; it is unlayered, so set dark colors explicitly + stylesheet: "" + # @schema + # additionalProperties: true + # @schema + # -- CSS custom properties applied in both color modes (tpl-rendered). + # --authup-periwinkle alone recolors buttons, focus rings and links. + # A color set here also wins in dark mode: put surface colors in both + # tokens and tokensDark + tokens: {} + # @schema + # additionalProperties: true + # @schema + # -- CSS custom properties applied in dark mode only (tpl-rendered) + tokensDark: {} # @schema # additionalProperties: true # @schema # -- Map of path -> file content, relative to the theme root (tpl-rendered). # Keys may carry a "/" ("assets/theme.css") and are projected into # subdirectories. Only assets/ is served over HTTP. Text only — use - # existingConfigMap with binaryData for images + # existingConfigMap with binaryData for images. Set theme.json here only + # when writing the manifest by hand instead of using the values above files: {} # -- Existing ConfigMap holding the theme (tpl-rendered name). Use for - # binary assets, which cannot be expressed in files + # binary assets, which cannot be expressed in files. Mounted whole, so it + # must carry theme.json itself and excludes the manifest values above existingConfigMap: "" # -- Key -> path projection for existingConfigMap, so its keys can land in # subdirectories (e.g. [{key: theme-css, path: assets/theme.css}]). From 27a3121617aa5eb42516bbcf8665fa52ddaa65ae Mon Sep 17 00:00:00 2001 From: tada5hi Date: Wed, 19 Aug 2026 10:27:01 +0200 Subject: [PATCH 3/3] fix(authup): assert theme manifest values after tpl rendering themeManifest tpl-renders every manifest value, but validateTheme checked the raw one. Invariant 7 exists for exactly this: a template-valued input only materializes after rendering, so that is where it has to be asserted. The visible effect was the opposite of a bypass. Every Helm expression ends in "}}", and "}" is on the list of characters authup rejects in a token value, so a templated token was always refused, for the wrong reason. A templated asset path was refused too, since the raw string does not start with "assets/". Both made tpl rendering unreachable and contradicted invariant 16, which is what lets an umbrella chart inject a brand colour or a logo path. Rendering first restores that and closes the case the raw check only ever blocked by accident: a token whose template RESULT carries url() or exceeds 256 characters is now rejected on its merits rather than on its delimiter. ci/theme-values.yaml templates the title so the kind install covers the assert-after-render path end to end. --- .agents/testing.md | 6 ++++++ charts/authup/ci/theme-values.yaml | 5 ++++- charts/authup/templates/_server-env.tpl | 25 ++++++++++++++++--------- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/.agents/testing.md b/.agents/testing.md index 8283390..5533e4d 100644 --- a/.agents/testing.md +++ b/.agents/testing.md @@ -57,6 +57,12 @@ helm template t charts/authup --set 'server.trustedOrigins[0]=https://**.x' # g A single `*` host wildcard (`https://*.example.com`) must still RENDER: authup supports it, only `**` is the allow-any-origin trap. +The `server.theme` manifest guards assert the value AFTER `tpl` rendering, so +both directions need a case: a templated token or asset path must RENDER, and +one whose rendered result is illegal must FAIL. Validating the raw value gets +this backwards in a way that looks correct (every `{{ ... }}` contains `}`, so +the forbidden-character check rejects it for the wrong reason). + The generated `values.schema.json` must keep catching typos (`--set server.replicaCountt=3` fails) while free-form maps stay open (`--set server.config.X=y`, `--set server.resources.limits.cpu=1` succeed). diff --git a/charts/authup/ci/theme-values.yaml b/charts/authup/ci/theme-values.yaml index 2a84084..e28fb13 100644 --- a/charts/authup/ci/theme-values.yaml +++ b/charts/authup/ci/theme-values.yaml @@ -8,7 +8,10 @@ server: theme: enabled: true fragmentsEnabled: true - title: "Sign in to ACME" + # tpl-rendered, so an umbrella chart can inject it (invariant 16). The + # manifest guards assert the RENDERED value, so this also covers the + # assert-after-render path. + title: "Sign in to {{ .Release.Name }}" logo: assets/logo.svg stylesheet: assets/theme.css tokens: diff --git a/charts/authup/templates/_server-env.tpl b/charts/authup/templates/_server-env.tpl index 1ecafdb..38ce47c 100644 --- a/charts/authup/templates/_server-env.tpl +++ b/charts/authup/templates/_server-env.tpl @@ -294,6 +294,7 @@ silently-inert theme: the dominant failure mode of theming is a page that looks exactly like an un-themed page. */}} {{- define "authup.server.validateTheme" -}} +{{- $ctx := . }} {{- $theme := .Values.server.theme }} {{- $manifest := include "authup.server.themeManifestConfigured" . }} {{- if .Values.server.theme.enabled }} @@ -316,8 +317,13 @@ looks exactly like an un-themed page. theme.json cannot be fetched), hence the prefix requirement. */}} {{- $imageExtensions := list ".svg" ".png" ".jpg" ".jpeg" ".gif" ".webp" ".avif" ".ico" }} {{- range $key := list "favicon" "logo" "logoDark" }} -{{- $value := get $theme $key }} -{{- if $value }} +{{- $raw := get $theme $key }} +{{- if $raw }} +{{- /* Assert the RENDERED value: themeManifest tpl-renders it, so a + template-valued path only materializes here (invariant 7). Validating + the raw string would reject every umbrella-injected expression, since + none of them start with "assets/". */}} +{{- $value := include "authup.tplvalues.render" (dict "value" $raw "context" $ctx) }} {{- if not (hasPrefix "assets/" $value) }} {{- fail (printf "authup: server.theme.%s must reference a file under \"assets/\" (got %q). assets/ is the only directory authup serves over HTTP." $key $value) }} {{- end }} @@ -334,14 +340,15 @@ looks exactly like an un-themed page. {{- end }} {{- end }} {{- if $theme.stylesheet }} -{{- if not (hasPrefix "assets/" $theme.stylesheet) }} -{{- fail (printf "authup: server.theme.stylesheet must reference a file under \"assets/\" (got %q). assets/ is the only directory authup serves over HTTP." $theme.stylesheet) }} +{{- $stylesheet := include "authup.tplvalues.render" (dict "value" $theme.stylesheet "context" $ctx) }} +{{- if not (hasPrefix "assets/" $stylesheet) }} +{{- fail (printf "authup: server.theme.stylesheet must reference a file under \"assets/\" (got %q). assets/ is the only directory authup serves over HTTP." $stylesheet) }} {{- end }} -{{- if not (hasSuffix ".css" (lower $theme.stylesheet)) }} -{{- fail (printf "authup: server.theme.stylesheet must be a .css file (got %q)." $theme.stylesheet) }} +{{- if not (hasSuffix ".css" (lower $stylesheet)) }} +{{- fail (printf "authup: server.theme.stylesheet must be a .css file (got %q)." $stylesheet) }} {{- end }} -{{- if not (hasKey ($theme.files | default dict) $theme.stylesheet) }} -{{- fail (printf "authup: server.theme.stylesheet references %q, which is not a key of server.theme.files. The stylesheet would 404 and the console would render un-themed." $theme.stylesheet) }} +{{- if not (hasKey ($theme.files | default dict) $stylesheet) }} +{{- fail (printf "authup: server.theme.stylesheet references %q, which is not a key of server.theme.files. The stylesheet would 404 and the console would render un-themed." $stylesheet) }} {{- end }} {{- end }} {{- /* Mirrors authup's own manifest validation. It fails the BOOT on a bad @@ -352,7 +359,7 @@ looks exactly like an un-themed page. {{- if not (regexMatch "^--[a-z0-9-]+$" $name) }} {{- fail (printf "authup: server.theme.%s key %q must be a lowercase CSS custom property (--foo-bar)." $key $name) }} {{- end }} -{{- $rendered := $value | toString }} +{{- $rendered := include "authup.tplvalues.render" (dict "value" ($value | toString) "context" $ctx) }} {{- if gt (len $rendered) 256 }} {{- fail (printf "authup: server.theme.%s.%s exceeds the 256 character limit authup enforces on a token value." $key $name) }} {{- end }}