diff --git a/CHANGELOG.json b/CHANGELOG.json index 4375e3a7..f04cc0f2 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -8,6 +8,40 @@ "grokify" ], "releases": [ + { + "version": "v0.30.3", + "date": "2026-11-02", + "highlights": [ + { + "description": "SystemAuth's sign-in, sign-out, consent, sign-up and email pages are styled: a centered card in light and dark, with the sign-in options as buttons", + "commit": "7564e10" + } + ], + "added": [ + { + "description": "`/assets/systemauth.css` (`PageStylePath`): the pages' stylesheet, embedded and served from the issuer's origin, linked by every page with its content hash", + "commit": "7564e10" + } + ], + "changed": [ + { + "description": "The sign-in page lists its options in a `nav`, as buttons", + "commit": "7564e10" + } + ], + "tests": [ + { + "description": "The stylesheet is served and cached by version, every page links it, and the no-inline-code check allows only that same-origin link", + "commit": "1185a05" + } + ], + "documentation": [ + { + "description": "The deployment guide's security headers section describes the stylesheet", + "commit": "160ff73" + } + ] + }, { "version": "v0.30.2", "date": "2026-11-02", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7134f51e..94b4bafb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,28 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro ## [Unreleased] +## [v0.30.3] - 2026-11-02 + +### Highlights + +- SystemAuth's sign-in, sign-out, consent, sign-up and email pages are styled: a centered card in light and dark, with the sign-in options as buttons + +### Added + +- `/assets/systemauth.css` (`PageStylePath`): the pages' stylesheet, embedded and served from the issuer's origin, linked by every page with its content hash ([`7564e10`](https://github.com/plexusone/systemforge/commit/7564e10)) + +### Changed + +- The sign-in page lists its options in a `nav`, as buttons ([`7564e10`](https://github.com/plexusone/systemforge/commit/7564e10)) + +### Documentation + +- The deployment guide's security headers section describes the stylesheet ([`160ff73`](https://github.com/plexusone/systemforge/commit/160ff73)) + +### Tests + +- The stylesheet is served and cached by version, every page links it, and the no-inline-code check allows only that same-origin link ([`1185a05`](https://github.com/plexusone/systemforge/commit/1185a05)) + ## [v0.30.2] - 2026-11-02 ### Highlights @@ -1703,7 +1725,8 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro - GitHub Actions lint workflow with golangci-lint ([`e2f98d5`](https://github.com/plexusone/systemforge/commit/e2f98d5)) - Dependabot configuration for Go modules and GitHub Actions ([`e2f98d5`](https://github.com/plexusone/systemforge/commit/e2f98d5)) -[unreleased]: https://github.com/plexusone/systemforge/compare/v0.30.2...HEAD +[unreleased]: https://github.com/plexusone/systemforge/compare/v0.30.3...HEAD +[v0.30.3]: https://github.com/plexusone/systemforge/compare/v0.30.2...v0.30.3 [v0.30.2]: https://github.com/plexusone/systemforge/compare/v0.30.1...v0.30.2 [v0.30.1]: https://github.com/plexusone/systemforge/compare/v0.30.0...v0.30.1 [v0.30.0]: https://github.com/plexusone/systemforge/compare/v0.29.0...v0.30.0 diff --git a/docs/releases/changelog.md b/docs/releases/changelog.md index 7134f51e..94b4bafb 100644 --- a/docs/releases/changelog.md +++ b/docs/releases/changelog.md @@ -9,6 +9,28 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro ## [Unreleased] +## [v0.30.3] - 2026-11-02 + +### Highlights + +- SystemAuth's sign-in, sign-out, consent, sign-up and email pages are styled: a centered card in light and dark, with the sign-in options as buttons + +### Added + +- `/assets/systemauth.css` (`PageStylePath`): the pages' stylesheet, embedded and served from the issuer's origin, linked by every page with its content hash ([`7564e10`](https://github.com/plexusone/systemforge/commit/7564e10)) + +### Changed + +- The sign-in page lists its options in a `nav`, as buttons ([`7564e10`](https://github.com/plexusone/systemforge/commit/7564e10)) + +### Documentation + +- The deployment guide's security headers section describes the stylesheet ([`160ff73`](https://github.com/plexusone/systemforge/commit/160ff73)) + +### Tests + +- The stylesheet is served and cached by version, every page links it, and the no-inline-code check allows only that same-origin link ([`1185a05`](https://github.com/plexusone/systemforge/commit/1185a05)) + ## [v0.30.2] - 2026-11-02 ### Highlights @@ -1703,7 +1725,8 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro - GitHub Actions lint workflow with golangci-lint ([`e2f98d5`](https://github.com/plexusone/systemforge/commit/e2f98d5)) - Dependabot configuration for Go modules and GitHub Actions ([`e2f98d5`](https://github.com/plexusone/systemforge/commit/e2f98d5)) -[unreleased]: https://github.com/plexusone/systemforge/compare/v0.30.2...HEAD +[unreleased]: https://github.com/plexusone/systemforge/compare/v0.30.3...HEAD +[v0.30.3]: https://github.com/plexusone/systemforge/compare/v0.30.2...v0.30.3 [v0.30.2]: https://github.com/plexusone/systemforge/compare/v0.30.1...v0.30.2 [v0.30.1]: https://github.com/plexusone/systemforge/compare/v0.30.0...v0.30.1 [v0.30.0]: https://github.com/plexusone/systemforge/compare/v0.29.0...v0.30.0 diff --git a/docs/releases/index.md b/docs/releases/index.md index b96299fd..7603aceb 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -4,7 +4,11 @@ Release notes, one file per version, newest first. The [changelog](changelog.md) is generated from `CHANGELOG.json` and lists every change with its commit. -The latest release, [v0.30.2](v0.30.2.md), keeps a signed-out browser +The latest release, [v0.30.3](v0.30.3.md), styles SystemAuth's pages +with a same-origin stylesheet, in light and dark. It adds no breaking +change. + +Before it, [v0.30.2](v0.30.2.md) kept a signed-out browser signed out with a single sign-in provider: the sign-in page waits for a click instead of redirecting. Read its upgrade notes. diff --git a/docs/releases/v0.30.3.md b/docs/releases/v0.30.3.md new file mode 100644 index 00000000..d6e62ee6 --- /dev/null +++ b/docs/releases/v0.30.3.md @@ -0,0 +1,26 @@ +# v0.30.3 + +**Release Date:** 2026-11-02 + +SystemAuth's pages are styled: + +- **A plain, modern look.** The sign-in, sign-out, consent, sign-up, + waitlist, age and email pages were unstyled HTML. They now link one + stylesheet: a centered card on a soft background, in light and dark + following the browser, with the sign-in options as full-width buttons, + clear form fields, and notices in color. +- **Nothing loosened.** The stylesheet is embedded in the binary and served + at `/assets/systemauth.css` from the issuer's origin, which the pages' + content security policy (`default-src 'self'`) already allows: still no + script and no inline style. The link carries the stylesheet's content + hash, so browsers cache it until it changes. + +It adds no breaking change. + +## Upgrade notes + +1. **Pass `/assets/` through** any proxy in front of SystemAuth, as it + passes the pages themselves. + +The pages are not themed per application yet. See +[Security headers](../systemauth/deployment.md#security-headers). diff --git a/docs/systemauth/deployment.md b/docs/systemauth/deployment.md index 3e14ce89..43ab8384 100644 --- a/docs/systemauth/deployment.md +++ b/docs/systemauth/deployment.md @@ -508,7 +508,12 @@ pages, carries: | `Referrer-Policy` | `same-origin` | | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains`, when the issuer is https | -The pages run no script and no inline style. Browsers check `form-action` +The pages run no script and no inline style. They link one stylesheet, +`/assets/systemauth.css`, served by SystemAuth from its own origin, which +`default-src 'self'` allows: a plain card in light and dark, with the +sign-in options as buttons. The link carries the stylesheet's content hash, +so a browser caches it until it changes. A proxy in front of SystemAuth must +pass `/assets/` through. Browsers check `form-action` on every redirect that follows a form submission, so a page whose form ends at another origin adds it: the consent page adds the origins of the client's redirect URIs, the logout page the `allowed_redirect_origins`, 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/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, `