From 7564e1013a7d2a5d949f33da8d09be55a5c23b2a Mon Sep 17 00:00:00 2001 From: John Wang Date: Mon, 2 Nov 2026 09:39:03 -0800 Subject: [PATCH 1/4] feat(systemauth): style SystemAuth's pages with a same-origin stylesheet The sign-in, sign-out, consent, sign-up, waitlist, age and email pages were unstyled HTML. They now link /assets/systemauth.css, embedded in the binary and served from the issuer's origin, which the pages' content security policy already allows: a centered card in light and dark, with the sign-in options as full-width buttons in a nav. The link carries the stylesheet's content hash, so it is cached until it changes. Co-Authored-By: Claude Opus 5.5 --- identity/systemauth/assets/page.css | 181 ++++++++++++++++++++++ identity/systemauth/email_change.go | 2 +- identity/systemauth/email_verification.go | 2 +- identity/systemauth/page_style.go | 42 +++++ identity/systemauth/server.go | 3 + identity/systemauth/signup_age.go | 2 +- identity/systemauth/signup_code_page.go | 2 +- identity/systemauth/signup_waitlist.go | 2 +- identity/systemauth/social_consent.go | 2 +- identity/systemauth/social_login.go | 8 +- identity/systemauth/social_logout.go | 2 +- 11 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 identity/systemauth/assets/page.css create mode 100644 identity/systemauth/page_style.go diff --git a/identity/systemauth/assets/page.css b/identity/systemauth/assets/page.css new file mode 100644 index 00000000..605a763f --- /dev/null +++ b/identity/systemauth/assets/page.css @@ -0,0 +1,181 @@ +/* SystemAuth's pages: sign-in, sign-out, consent, sign-up and email. It styles + the pages' plain elements, with no classes, in light and dark. */ +:root { + color-scheme: light dark; + --bg: #f6f7f9; + --card: #ffffff; + --text: #1f2328; + --muted: #59636e; + --border: #d1d9e0; + --accent: #1f2328; + --accent-text: #ffffff; + --accent-hover: #3d444d; + --focus: #0969da; + --alert-bg: #fff1f0; + --alert-text: #a40e26; + --status-bg: #eef6ff; + --status-text: #0b4a8b; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --card: #161b22; + --text: #e6edf3; + --muted: #9198a1; + --border: #30363d; + --accent: #e6edf3; + --accent-text: #0d1117; + --accent-hover: #c9d1d9; + --focus: #4493f8; + --alert-bg: #3c1618; + --alert-text: #ffa198; + --status-bg: #0c2d4f; + --status-text: #a5d6ff; + } +} +*, +*::before, +*::after { + box-sizing: border-box; +} +html { + -webkit-text-size-adjust: 100%; +} +body { + margin: 0; + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px 16px; + background: var(--bg); + color: var(--text); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} +main { + width: 100%; + max-width: 400px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 32px 28px; + box-shadow: 0 1px 3px rgb(0 0 0 / 0.06), 0 8px 24px rgb(0 0 0 / 0.06); +} +main > img { + display: block; + margin: 0 auto 16px; + border-radius: 12px; +} +h1 { + margin: 0 0 8px; + font-size: 22px; + font-weight: 600; + line-height: 1.3; + text-align: center; +} +h1 + p { + margin-top: 0; + color: var(--muted); + text-align: center; +} +p { + margin: 12px 0; +} +a { + color: var(--focus); +} +ul { + margin: 16px 0; + padding-left: 20px; +} +li + li { + margin-top: 4px; +} +nav { + display: grid; + gap: 10px; + margin-top: 24px; +} +nav a, +button { + display: block; + width: 100%; + padding: 10px 16px; + border: 1px solid var(--accent); + border-radius: 8px; + background: var(--accent); + color: var(--accent-text); + font: inherit; + font-weight: 500; + text-align: center; + text-decoration: none; + cursor: pointer; +} +nav a:hover, +button:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} +button + button { + margin-top: 10px; +} +button[value="deny"], +button[value="cancel"] { + background: transparent; + color: var(--text); + border-color: var(--border); +} +button[value="deny"]:hover, +button[value="cancel"]:hover { + background: var(--bg); +} +form { + margin-top: 20px; +} +label { + display: block; + margin: 12px 0 4px; + font-weight: 500; +} +input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]), +select, +textarea { + display: block; + width: 100%; + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card); + color: var(--text); + font: inherit; +} +input[type="checkbox"], +input[type="radio"] { + margin-right: 6px; +} +input + button, +select + button, +textarea + button, +label + button { + margin-top: 20px; +} +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} +[role="alert"], +[role="status"] { + padding: 10px 12px; + border-radius: 8px; +} +[role="alert"] { + background: var(--alert-bg); + color: var(--alert-text); +} +[role="status"] { + background: var(--status-bg); + color: var(--status-text); +} +small, +.muted { + color: var(--muted); +} diff --git a/identity/systemauth/email_change.go b/identity/systemauth/email_change.go index a0f13d74..c78b228e 100644 --- a/identity/systemauth/email_change.go +++ b/identity/systemauth/email_change.go @@ -64,7 +64,7 @@ func emailChangeCSRF(sessionToken string) string { } var accountEmailTemplate = template.Must(template.New("account-email").Parse(` - +` + pageStyleLink + ` {{.Title}}

{{.Title}}

{{if .Message}}

{{.Message}}

{{end}} diff --git a/identity/systemauth/email_verification.go b/identity/systemauth/email_verification.go index cc14c5e4..1a101821 100644 --- a/identity/systemauth/email_verification.go +++ b/identity/systemauth/email_verification.go @@ -171,7 +171,7 @@ func (sl *socialLogin) issueSignInToken(ctx context.Context, cookie, email strin } var verifyEmailTemplate = template.Must(template.New("verify-email").Parse(` - +` + pageStyleLink + ` {{.Title}}

{{.Title}}

{{if .Message}}

{{.Message}}

{{end}} diff --git a/identity/systemauth/page_style.go b/identity/systemauth/page_style.go new file mode 100644 index 00000000..05bd9ec9 --- /dev/null +++ b/identity/systemauth/page_style.go @@ -0,0 +1,42 @@ +package systemauth + +import ( + "crypto/sha256" + _ "embed" + "encoding/hex" + "net/http" +) + +// PageStylePath serves the stylesheet SystemAuth's own pages link: sign-in, +// sign-out, consent, sign-up and the email pages. It is served from the +// issuer's origin, which the pages' content security policy allows. +const PageStylePath = "/assets/systemauth.css" + +//go:embed assets/page.css +var pageStyle []byte + +// pageStyleVersion names this build's stylesheet in its link, so a browser +// fetches it again when it changes and may cache it otherwise. +var pageStyleVersion = func() string { + sum := sha256.Sum256(pageStyle) + return hex.EncodeToString(sum[:6]) +}() + +// pageStyleLink is the every page's head carries, after its +// viewport. +var pageStyleLink = `` + +// handlePageStyle serves GET PageStylePath. +func handlePageStyle(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + // The link names the content, so a versioned request may be cached + // for long; an unversioned one is checked again within the hour. + if r.URL.Query().Get("v") == pageStyleVersion { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "public, max-age=3600") + } + if _, err := w.Write(pageStyle); err != nil { + LoggerFromContext(r.Context()).Debug("writing the page stylesheet", "error", err) + } +} diff --git a/identity/systemauth/server.go b/identity/systemauth/server.go index 06b758d7..09247c0e 100644 --- a/identity/systemauth/server.go +++ b/identity/systemauth/server.go @@ -393,6 +393,9 @@ func (s *Server) registerEndpoints() { s.router.Get(HealthzPath, s.handleHealthz) s.router.Get(ReadyzPath, s.handleReadyz) + // The stylesheet SystemAuth's own pages link. + s.router.Get(PageStylePath, handlePageStyle) + // Register GitHub/Google login routes if s.social != nil { s.social.registerRoutes(s.router) diff --git a/identity/systemauth/signup_age.go b/identity/systemauth/signup_age.go index f882f142..f9d5d7d7 100644 --- a/identity/systemauth/signup_age.go +++ b/identity/systemauth/signup_age.go @@ -211,7 +211,7 @@ func (sl *socialLogin) askAge(w http.ResponseWriter, r *http.Request, provider, } var signupAgeTemplate = template.Must(template.New("signup-age").Parse(` - +` + pageStyleLink + ` {{.Page.Title}}

{{.Page.Title}}

{{if .Refused}}

{{.RefusedText}}

{{else if .Expired}}

This page has expired. Sign in again.

{{else}} diff --git a/identity/systemauth/signup_code_page.go b/identity/systemauth/signup_code_page.go index bdd66f09..86221368 100644 --- a/identity/systemauth/signup_code_page.go +++ b/identity/systemauth/signup_code_page.go @@ -127,7 +127,7 @@ func (sl *socialLogin) keepInviteCode(w http.ResponseWriter, r *http.Request) er } var signupCodeTemplate = template.Must(template.New("signup").Parse(` - +` + pageStyleLink + ` {{.Page.Title}}

{{.Page.Title}}

{{if .Invalid}}

{{.Page.InvalidNotice}}

diff --git a/identity/systemauth/signup_waitlist.go b/identity/systemauth/signup_waitlist.go index 134fc4cf..b11e41eb 100644 --- a/identity/systemauth/signup_waitlist.go +++ b/identity/systemauth/signup_waitlist.go @@ -83,7 +83,7 @@ func WithSignupWaitlist(list SignupWaitlist, page WaitlistPage) Option { } var waitlistTemplate = template.Must(template.New("waitlist").Parse(` - +` + pageStyleLink + ` {{.Page.Title}}

{{.Page.Title}}

{{if .Thanks}}

{{.Page.Thanks}}

{{else}} diff --git a/identity/systemauth/social_consent.go b/identity/systemauth/social_consent.go index d5763698..43edb697 100644 --- a/identity/systemauth/social_consent.go +++ b/identity/systemauth/social_consent.go @@ -31,7 +31,7 @@ var scopeDescriptions = map[string]string{ } var consentTemplate = template.Must(template.New("consent").Parse(` - +` + pageStyleLink + ` Authorize {{.ClientName}}

{{.ClientName}} wants to access your account

    diff --git a/identity/systemauth/social_login.go b/identity/systemauth/social_login.go index 8eff6c52..57f43367 100644 --- a/identity/systemauth/social_login.go +++ b/identity/systemauth/social_login.go @@ -376,12 +376,12 @@ func noStore(w http.ResponseWriter) { } var chooserTemplate = template.Must(template.New("login").Parse(` - +` + pageStyleLink + ` Sign in{{with .ClientName}} to {{.}}{{end}}
    {{if .LogoURL}} -{{end}}

    Sign in

    {{with .ClientName}}

    to continue to {{.}}

    {{end}}
    +{{end}}

    Sign in

    {{with .ClientName}}

    to continue to {{.}}

    {{end}}
`)) // chooserBranding returns the name and logo of the client whose diff --git a/identity/systemauth/social_logout.go b/identity/systemauth/social_logout.go index 24901f41..9276b180 100644 --- a/identity/systemauth/social_logout.go +++ b/identity/systemauth/social_logout.go @@ -14,7 +14,7 @@ import ( const LogoutPath = "/logout" var logoutTemplate = template.Must(template.New("logout").Parse(` - +` + pageStyleLink + ` Sign out

Sign out

From 1185a05179f462f70fddc54b3c0c3dcaa87cabc8 Mon Sep 17 00:00:00 2001 From: John Wang Date: Mon, 2 Nov 2026 09:39:03 -0800 Subject: [PATCH 2/4] test(systemauth): cover the page stylesheet and its link on every page The no-inline-code check allows only the same-origin stylesheet link. Co-Authored-By: Claude Opus 5.5 --- identity/systemauth/page_style_test.go | 56 ++++++++++++++++++++ identity/systemauth/security_headers_test.go | 6 ++- 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 identity/systemauth/page_style_test.go diff --git a/identity/systemauth/page_style_test.go b/identity/systemauth/page_style_test.go new file mode 100644 index 00000000..585d37de --- /dev/null +++ b/identity/systemauth/page_style_test.go @@ -0,0 +1,56 @@ +package systemauth + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPageStyleIsServedAndLinked(t *testing.T) { + f := newFakeGitHub(t) + s := newSocialTestServer(t, f, NewMemoryPrincipalDirectory()) + + w := httptest.NewRecorder() + s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, PageStylePath+"?v="+pageStyleVersion, nil)) + if w.Code != http.StatusOK || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/css") || !strings.Contains(w.Body.String(), "prefers-color-scheme") { + t.Fatalf("stylesheet: %d %q", w.Code, w.Header().Get("Content-Type")) + } + if cc := w.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") { + t.Errorf("a versioned stylesheet is cached as %q", cc) + } + w = httptest.NewRecorder() + s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, PageStylePath, nil)) + if cc := w.Header().Get("Cache-Control"); strings.Contains(cc, "immutable") { + t.Errorf("an unversioned stylesheet is cached as %q", cc) + } + + // The sign-in page links it, from the issuer's own origin, which its + // content security policy allows, and offers each provider in a nav. + w = httptest.NewRecorder() + s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/login", nil)) + body := w.Body.String() + if !strings.Contains(body, ``) { + t.Errorf("the sign-in page does not link the stylesheet: %s", body) + } + if !strings.Contains(body, `