feat(dashboard): brand the token-proxy login page from the console config (#4255)

## What this PR does

Part of #4217. Dashboard branding (title, logo) is only reachable
through the authenticated dashboard/kube-api path, so the pre-auth
surfaces render unbranded. This PR fixes the first of them: the
token-proxy login page — the first screen an unauthenticated user sees
on a non-OIDC cluster — hardcoded `<title>Login</title>` and had no
branding hookup at all.

The branding config lives in the `cozy-dashboard-console-config`
ConfigMap, which the SPA reads only through the authenticated kube-api.
This mounts that same ConfigMap read-only into the token-proxy container
and renders its `titleText`, `logoText` and `logoSvg` on the login page,
falling back to the historical unbranded page when the file is absent or
unreadable so a cluster without branding is unaffected. The SVG is
encoded into an `<img>` data URI rather than inlined, which keeps any
embedded script inert. The OIDC branch is untouched: its login is served
by Keycloak, already branded through the realm login theme.

The unauthorized/access-denied screens rendered by the SPA itself (for a
logged-in user without kube-api RBAC) are handled in a separate PR.

Verified with `go test`, `go vet`, `gofmt`, and `helm unittest`
(branding mount asserted present in the non-OIDC branch and absent in
the OIDC branch).

### Downstream repositories

The diff touches only `packages/system/dashboard` (the in-repo
token-proxy image and its chart). No app is added, renamed or removed,
no `values.schema.json` / enum / default changes, no CRD or namespace
changes, and the `config.json` branding keys are unchanged. Nothing in
the trigger map applies.

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
feat(dashboard): the token-proxy login page now renders the deployment's branding (title and logo from `branding.titleText` / `branding.logoText` / `branding.logoSvg`) instead of the hardcoded default, so unauthenticated users on non-OIDC clusters see a branded sign-in screen.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added deployment branding to the login page, including a custom title,
logo, and heading.
* Branding is automatically loaded from the dashboard configuration when
available.
* Login pages continue to display a default unbranded experience when
branding configuration is unavailable or invalid.

* **Tests**
  * Added coverage for branded and unbranded login-page rendering.
* Added validation for branding volume mounts across authentication
modes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
scooby87 authored and GitHub committed 2026-09-22 22:07:32 +03:00
commit 95b8549238
4 files changed
+278 -13

No files matched your search

@@ -10,6 +10,7 @@ import (
"flag"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"net/http/httputil"
@@ -37,6 +38,7 @@ var (
jwksURL string
saTokenPath string
saCACertPath string
brandingConfigPath string
)
func init() {
@@ -51,6 +53,12 @@ func init() {
flag.StringVar(&jwksURL, "jwks-url", "https://kubernetes.default.svc/openid/v1/jwks", "JWKS URL for token verification")
flag.StringVar(&saTokenPath, "sa-token-path", "/var/run/secrets/kubernetes.io/serviceaccount/token", "Path to service account token")
flag.StringVar(&saCACertPath, "sa-ca-cert-path", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "Path to service account CA certificate")
// The dashboard branding lives in the cozy-dashboard-console-config
// ConfigMap, which the SPA reaches only through the authenticated kube-api.
// Mounting the same config.json here gives the pre-auth login page a source
// for the deployment's branding; an absent or unreadable file falls back to
// the unbranded page, so a cluster without branding renders as before.
flag.StringVar(&brandingConfigPath, "branding-config-path", "/etc/cozystack/branding/config.json", "Path to the dashboard branding config.json mounted from the console ConfigMap")
}
// newJWKCache builds the JWK cache used to verify Kubernetes-issued JWTs.
@@ -144,7 +152,7 @@ var loginTmpl = template.Must(template.New("login").Parse(`
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login</title>
<title>{{.Title}}</title>
<style>
body {
margin: 0;
@@ -163,6 +171,16 @@ var loginTmpl = template.Must(template.New("login").Parse(`
width: 400px;
text-align: center;
}
.logo {
max-width: 220px;
max-height: 72px;
margin-bottom: 1rem;
}
.brand {
margin: 0 0 1rem;
font-size: 1.3rem;
color: #333;
}
h2 {
margin-bottom: 1rem;
color: #333;
@@ -203,6 +221,8 @@ var loginTmpl = template.Must(template.New("login").Parse(`
</head>
<body>
<div class="card">
{{if .LogoSrc}}<img class="logo" src="{{.LogoSrc}}" alt="{{.Title}}" />{{end}}
{{if .Heading}}<h1 class="brand">{{.Heading}}</h1>{{end}}
<h2>Kubernetes API Token</h2>
{{if .Err}}<p class="error">{{.Err}}</p>{{end}}
<form method="POST" action="{{.Action}}">
@@ -213,6 +233,75 @@ var loginTmpl = template.Must(template.New("login").Parse(`
</body>
</html>`))
// brandingConfig mirrors the subset of the dashboard console config.json that
// the login page can render. Its JSON tags match the keys the SPA reads from
// the cozy-dashboard-console-config ConfigMap, so operators set branding once.
type brandingConfig struct {
TitleText string `json:"titleText"`
LogoText string `json:"logoText"`
LogoSvg string `json:"logoSvg"`
}
// loadBranding reads the mounted branding config.json. Any failure yields the
// zero value, which newLoginData turns into the historical unbranded page
// rather than a hard error. It is called per request so a ConfigMap edit is
// picked up without restarting the pod — the gatekeeper Deployment carries no
// checksum/config annotation, unlike the console.
func loadBranding(path string) brandingConfig {
var b brandingConfig
if path == "" {
return b
}
data, err := os.ReadFile(path)
if err != nil {
// A missing file is the normal "no branding configured" case; anything
// else (a broken mount, permissions) is worth a line so it is visible.
if !errors.Is(err, fs.ErrNotExist) {
log.Printf("branding: cannot read %s: %v", path, err)
}
return b
}
if err := json.Unmarshal(data, &b); err != nil {
log.Printf("branding: ignoring invalid %s: %v", path, err)
}
return b
}
// loginData is the template model for loginTmpl.
type loginData struct {
Action string
Err string
Title string
Heading string
// LogoSrc is an <img> data: URI. logoSvg is already base64-encoded in the
// console config.json — the SPA's Logo.tsx drops it straight after the same
// prefix — so it is only prefixed here, never re-encoded. It is typed
// template.URL because html/template rewrites a data: URI in a src
// attribute to #ZgotmplZ otherwise; serving the SVG through an <img> keeps
// any embedded script inert rather than inlining it into the page.
LogoSrc template.URL
}
// logoDataURIPrefix is the only scheme LogoSrc ever carries; because LogoSrc is
// template.URL (URL filtering off), the single construction site below must be
// the only thing that ever assigns it.
const logoDataURIPrefix = "data:image/svg+xml;base64,"
func newLoginData(action, errMsg, brandingPath string) loginData {
d := loginData{Action: action, Err: errMsg, Title: "Login"}
b := loadBranding(brandingPath)
if b.TitleText != "" {
d.Title = b.TitleText
}
if b.LogoText != "" {
d.Heading = b.LogoText
}
if b.LogoSvg != "" {
d.LogoSrc = template.URL(logoDataURIPrefix + b.LogoSvg)
}
return d
}
/* ----------------------------- JWK cache -------------------------------- */
var (
@@ -385,17 +474,11 @@ func main() {
http.HandleFunc(signIn, func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
_ = loginTmpl.Execute(w, struct {
Action string
Err string
}{Action: signIn})
_ = loginTmpl.Execute(w, newLoginData(signIn, "", brandingConfigPath))
case http.MethodPost:
token := strings.TrimSpace(r.FormValue("token"))
if token == "" {
_ = loginTmpl.Execute(w, struct {
Action string
Err string
}{Action: signIn, Err: "Token required"})
_ = loginTmpl.Execute(w, newLoginData(signIn, "Token required", brandingConfigPath))
return
}
@@ -407,10 +490,7 @@ func main() {
if errors.Is(err, errJWKSNotReady) {
msg = "Service is starting, please retry in a moment"
}
_ = loginTmpl.Execute(w, struct {
Action string
Err string
}{Action: signIn, Err: msg})
_ = loginTmpl.Execute(w, newLoginData(signIn, msg, brandingConfigPath))
return
}
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
@@ -8,8 +9,11 @@ import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"html/template"
"math/big"
"net/http"
"net/http/httptest"
@@ -405,3 +409,134 @@ func TestHealthzReady_ReportsCacheState(t *testing.T) {
// the entire fix turns on this option, so a future upstream rename
// surfaces as a build error here instead of a silent semantic change.
var _ jwk.RegisterOption = jwk.WithWaitReady(false)
// writeBranding writes a branding config.json to a temp dir, mirroring how the
// cozy-dashboard-console-config ConfigMap is projected into the container.
func writeBranding(t *testing.T, json string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(p, []byte(json), 0o600); err != nil {
t.Fatalf("write branding: %v", err)
}
return p
}
// TestNewLoginData_DefaultsWithoutBranding pins the fallback: with no branding
// file the login page keeps its historical unbranded shape (title "Login", no
// logo, no brand heading). A missing file must never break sign-in.
func TestNewLoginData_DefaultsWithoutBranding(t *testing.T) {
d := newLoginData("/oauth2/sign_in", "", filepath.Join(t.TempDir(), "absent.json"))
if d.Title != "Login" {
t.Errorf("Title = %q, want Login", d.Title)
}
if d.Heading != "" {
t.Errorf("Heading = %q, want empty", d.Heading)
}
if d.LogoSrc != "" {
t.Errorf("LogoSrc = %q, want empty", d.LogoSrc)
}
}
// TestNewLoginData_AppliesBranding proves the branding keys from the console
// ConfigMap reach the login model: titleText -> <title>, logoText -> heading,
// logoSvg -> an <img> data: URI.
//
// logoSvg in config.json is already base64 (the SPA's Logo.tsx consumes it
// that way), so the value must be prefixed verbatim, never re-encoded. The
// decode-back assertion is the contract: a second encoding would make the
// payload decode to the base64 text instead of the original SVG, i.e. a broken
// image on every cluster that configured a logo.
func TestNewLoginData_AppliesBranding(t *testing.T) {
svg := "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>"
svgB64 := base64.StdEncoding.EncodeToString([]byte(svg))
p := writeBranding(t, `{"titleText":"Acme Cloud","logoText":"Acme","logoSvg":`+quoteJSON(svgB64)+`}`)
d := newLoginData("/oauth2/sign_in", "", p)
if d.Title != "Acme Cloud" {
t.Errorf("Title = %q, want Acme Cloud", d.Title)
}
if d.Heading != "Acme" {
t.Errorf("Heading = %q, want Acme", d.Heading)
}
want := template.URL("data:image/svg+xml;base64," + svgB64)
if d.LogoSrc != want {
t.Errorf("LogoSrc = %q, want %q (logoSvg must not be re-encoded)", d.LogoSrc, want)
}
// The data: prefix is mandatory because LogoSrc is template.URL: nothing
// without it may reach the field.
const prefix = "data:image/svg+xml;base64,"
if !strings.HasPrefix(string(d.LogoSrc), prefix) {
t.Fatalf("LogoSrc %q lacks the fixed %q prefix", d.LogoSrc, prefix)
}
decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(string(d.LogoSrc), prefix))
if err != nil {
t.Fatalf("payload is not valid base64: %v", err)
}
if string(decoded) != svg {
t.Errorf("payload decodes to %q, want the original SVG %q (double-encoded)", decoded, svg)
}
}
// TestNewLoginData_MalformedBrandingFallsBack proves a corrupt config.json
// degrades to the unbranded page instead of erroring the handler.
func TestNewLoginData_MalformedBrandingFallsBack(t *testing.T) {
p := writeBranding(t, `{not json`)
d := newLoginData("/oauth2/sign_in", "", p)
if d.Title != "Login" || d.Heading != "" || d.LogoSrc != "" {
t.Errorf("malformed branding leaked into %+v", d)
}
}
// TestLoginTemplate_RendersBranding executes the real template and asserts the
// branded title, heading and logo data URI survive rendering — in particular
// that html/template does not rewrite the data: URI to #ZgotmplZ, which would
// silently blank the logo.
func TestLoginTemplate_RendersBranding(t *testing.T) {
svg := "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>"
svgB64 := base64.StdEncoding.EncodeToString([]byte(svg))
p := writeBranding(t, `{"titleText":"Acme Cloud","logoText":"Acme","logoSvg":`+quoteJSON(svgB64)+`}`)
var buf bytes.Buffer
if err := loginTmpl.Execute(&buf, newLoginData("/oauth2/sign_in", "", p)); err != nil {
t.Fatalf("execute: %v", err)
}
out := buf.String()
if strings.Contains(out, "#ZgotmplZ") {
t.Fatal("logo data URI was sanitized to #ZgotmplZ")
}
// The SVG mime "+" renders as the HTML entity &#43; inside the attribute,
// which the browser decodes back to "+"; assert on the surviving prefix and
// the operator's base64 payload verbatim (re-encoding it would change it).
for _, want := range []string{
"<title>Acme Cloud</title>",
`class="brand">Acme</h1>`,
`src="data:image/svg`,
"base64," + svgB64,
"Kubernetes API Token",
} {
if !strings.Contains(out, want) {
t.Errorf("rendered page missing %q", want)
}
}
}
// TestLoginTemplate_RendersUnbranded pins the default page: title "Login", no
// logo <img>, no brand heading.
func TestLoginTemplate_RendersUnbranded(t *testing.T) {
var buf bytes.Buffer
if err := loginTmpl.Execute(&buf, newLoginData("/oauth2/sign_in", "", "")); err != nil {
t.Fatalf("execute: %v", err)
}
out := buf.String()
if !strings.Contains(out, "<title>Login</title>") {
t.Error("default page lost its <title>Login</title>")
}
if strings.Contains(out, "class=\"logo\"") || strings.Contains(out, "class=\"brand\"") {
t.Error("unbranded page rendered a logo or brand heading")
}
}
// quoteJSON returns s as a JSON string literal so an SVG with quotes embeds
// cleanly into the test fixtures above.
func quoteJSON(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
@@ -180,6 +180,12 @@ spec:
timeoutSeconds: 2
successThreshold: 1
failureThreshold: 3
# The branding config.json the SPA reads via the kube-api is mounted
# here so the pre-auth login page can render the same title and logo.
volumeMounts:
- name: branding
mountPath: /etc/cozystack/branding
readOnly: true
{{- end }}
ports:
- name: proxy
@@ -206,3 +212,12 @@ spec:
type: RuntimeDefault
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
{{- if ne $oidcEnabled "true" }}
volumes:
# optional: the login page degrades to its unbranded default if the
# ConfigMap is missing, so a start-up dependency is not worth failing on.
- name: branding
configMap:
name: cozy-dashboard-console-config
optional: true
{{- end }}
@@ -50,6 +50,41 @@ tests:
- equal:
path: spec.template.spec.containers[0].ports[0].containerPort
value: 8000
# The pre-auth login page reads its branding from the same
# cozy-dashboard-console-config ConfigMap the SPA consumes, mounted
# read-only. The volume is optional so a cluster without branding still
# starts and renders the historical unbranded page.
- equal:
path: spec.template.spec.containers[0].volumeMounts[0].name
value: branding
- equal:
path: spec.template.spec.containers[0].volumeMounts[0].mountPath
value: /etc/cozystack/branding
- equal:
path: spec.template.spec.containers[0].volumeMounts[0].readOnly
value: true
- contains:
path: spec.template.spec.volumes
content:
name: branding
configMap:
name: cozy-dashboard-console-config
optional: true
# The OIDC branch runs oauth2-proxy, whose login is served by Keycloak (already
# branded via the realm theme), so the branding ConfigMap must not be mounted
# there — pin its absence so a refactor that hoists the volume out of the
# non-OIDC guard is a test failure.
- it: oidc-enabled=true does not mount the branding config
set:
_cluster:
root-host: example.org
oidc-enabled: "true"
asserts:
- notExists:
path: spec.template.spec.volumes
- notExists:
path: spec.template.spec.containers[0].volumeMounts
# authentication.oidc.enabled defaults to false while keycloakInternalUrl now
# defaults to the in-cluster Service, so THIS pair — OIDC off, internal URL