mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-30 08:37:31 +09:00
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:
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 + 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
|
||||
|
||||
Reference in new issue
Block a user