diff --git a/CHANGELOG.json b/CHANGELOG.json index f04cc0f2..162bbf8e 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -8,6 +8,50 @@ "grokify" ], "releases": [ + { + "version": "v0.30.4", + "date": "2026-11-02", + "highlights": [ + { + "description": "`social_login.signed_out_url` lands every sign-out on the product's own page, such as its marketing site", + "commit": "bba72ce" + }, + { + "description": "SystemAuth's waitlist page links back to `signed_out_url` as \"Back to\" the product's name, so it is no longer a dead end", + "commit": "7552f80" + } + ], + "added": [ + { + "description": "`social_login.signed_out_url`: where every sign-out lands, whatever `return_to` the app sent; it must pass the redirect allowlist", + "commit": "bba72ce" + }, + { + "description": "`social_login.signed_out_name`: the display name of the site at `signed_out_url`, shown on the waitlist page's link back to it; default: the URL's host", + "commit": "7552f80" + } + ], + "tests": [ + { + "description": "A sign-out lands on `signed_out_url`, an open `return_to` is still refused, and an unlisted URL is refused at startup", + "commit": "e0e83eb" + }, + { + "description": "The waitlist page links back to `signed_out_url` by its name before and after joining, names its host by default, and shows no link without one", + "commit": "1257a93" + } + ], + "documentation": [ + { + "description": "The social login and relying-party guides describe `signed_out_url`", + "commit": "1285b92" + }, + { + "description": "The social login and deployment guides describe `signed_out_name` and the waitlist page's link back", + "commit": "d6c6ff5" + } + ] + }, { "version": "v0.30.3", "date": "2026-11-02", diff --git a/CHANGELOG.md b/CHANGELOG.md index 94b4bafb..d43ad431 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.4] - 2026-11-02 + +### Highlights + +- `social_login.signed_out_url` lands every sign-out on the product's own page, such as its marketing site +- SystemAuth's waitlist page links back to `signed_out_url` as "Back to" the product's name, so it is no longer a dead end + +### Added + +- `social_login.signed_out_url`: where every sign-out lands, whatever `return_to` the app sent; it must pass the redirect allowlist ([`bba72ce`](https://github.com/plexusone/systemforge/commit/bba72ce)) +- `social_login.signed_out_name`: the display name of the site at `signed_out_url`, shown on the waitlist page's link back to it; default: the URL's host ([`7552f80`](https://github.com/plexusone/systemforge/commit/7552f80)) + +### Documentation + +- The social login and relying-party guides describe `signed_out_url` ([`1285b92`](https://github.com/plexusone/systemforge/commit/1285b92)) +- The social login and deployment guides describe `signed_out_name` and the waitlist page's link back ([`d6c6ff5`](https://github.com/plexusone/systemforge/commit/d6c6ff5)) + +### Tests + +- A sign-out lands on `signed_out_url`, an open `return_to` is still refused, and an unlisted URL is refused at startup ([`e0e83eb`](https://github.com/plexusone/systemforge/commit/e0e83eb)) +- The waitlist page links back to `signed_out_url` by its name before and after joining, names its host by default, and shows no link without one ([`1257a93`](https://github.com/plexusone/systemforge/commit/1257a93)) + ## [v0.30.3] - 2026-11-02 ### Highlights @@ -1725,7 +1747,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.3...HEAD +[unreleased]: https://github.com/plexusone/systemforge/compare/v0.30.4...HEAD +[v0.30.4]: https://github.com/plexusone/systemforge/compare/v0.30.3...v0.30.4 [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 diff --git a/docs/releases/changelog.md b/docs/releases/changelog.md index 94b4bafb..d43ad431 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.4] - 2026-11-02 + +### Highlights + +- `social_login.signed_out_url` lands every sign-out on the product's own page, such as its marketing site +- SystemAuth's waitlist page links back to `signed_out_url` as "Back to" the product's name, so it is no longer a dead end + +### Added + +- `social_login.signed_out_url`: where every sign-out lands, whatever `return_to` the app sent; it must pass the redirect allowlist ([`bba72ce`](https://github.com/plexusone/systemforge/commit/bba72ce)) +- `social_login.signed_out_name`: the display name of the site at `signed_out_url`, shown on the waitlist page's link back to it; default: the URL's host ([`7552f80`](https://github.com/plexusone/systemforge/commit/7552f80)) + +### Documentation + +- The social login and relying-party guides describe `signed_out_url` ([`1285b92`](https://github.com/plexusone/systemforge/commit/1285b92)) +- The social login and deployment guides describe `signed_out_name` and the waitlist page's link back ([`d6c6ff5`](https://github.com/plexusone/systemforge/commit/d6c6ff5)) + +### Tests + +- A sign-out lands on `signed_out_url`, an open `return_to` is still refused, and an unlisted URL is refused at startup ([`e0e83eb`](https://github.com/plexusone/systemforge/commit/e0e83eb)) +- The waitlist page links back to `signed_out_url` by its name before and after joining, names its host by default, and shows no link without one ([`1257a93`](https://github.com/plexusone/systemforge/commit/1257a93)) + ## [v0.30.3] - 2026-11-02 ### Highlights @@ -1725,7 +1747,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.3...HEAD +[unreleased]: https://github.com/plexusone/systemforge/compare/v0.30.4...HEAD +[v0.30.4]: https://github.com/plexusone/systemforge/compare/v0.30.3...v0.30.4 [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 diff --git a/docs/releases/index.md b/docs/releases/index.md index 7603aceb..9cbeb77d 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -4,7 +4,12 @@ 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.3](v0.30.3.md), styles SystemAuth's pages +The latest release, [v0.30.4](v0.30.4.md), adds +`social_login.signed_out_url`, so every sign-out can land on the product's +marketing site, and links the waitlist page back to it by the product's +name. It adds no breaking change. + +Before it, [v0.30.3](v0.30.3.md) styled SystemAuth's pages with a same-origin stylesheet, in light and dark. It adds no breaking change. diff --git a/docs/releases/v0.30.4.md b/docs/releases/v0.30.4.md new file mode 100644 index 00000000..7f36dc01 --- /dev/null +++ b/docs/releases/v0.30.4.md @@ -0,0 +1,29 @@ +# v0.30.4 + +**Release Date:** 2026-11-02 + +Signing out can land on the product's own site: + +- **`social_login.signed_out_url`.** A product's apps each returned a + signed-out browser to their own origin, which showed the sign-in page + again. When `signed_out_url` is set, every sign-out lands there instead, + such as the product's marketing site, whatever `return_to` the app sent. +- **The waitlist page links back.** It ended in a dead end, before and + after joining. With `signed_out_url` set, it now links back to that page + as "← Back to ", where `social_login.signed_out_name` is the + product's display name, such as `ProductBuildersHQ`, or the URL's host by + default. + +It adds no breaking change: without the settings, sign-out returns where it +did and the waitlist page is unchanged. + +## Upgrade notes + +1. **To land sign-outs on a marketing site,** add its origin to + `social_login.allowed_redirect_origins` and set + `social_login.signed_out_url` to its page. A URL outside the allowed + origins is refused at startup. +2. **To name it on the waitlist page,** set `social_login.signed_out_name`, + such as `ProductBuildersHQ`. Without it, the link shows the URL's host. + +See [Social Login](../systemauth/social-login.md). diff --git a/docs/systemauth/deployment.md b/docs/systemauth/deployment.md index 43ab8384..cedcc816 100644 --- a/docs/systemauth/deployment.md +++ b/docs/systemauth/deployment.md @@ -174,6 +174,8 @@ systemauth invite revoke ada@example.com systemauth waitlist remove ada@example.com ``` + With `social_login.signed_out_url` set, the page links back to it, before + and after joining, as "← Back to" `social_login.signed_out_name`. A host binary sets the page's wording with `Options.WaitlistPage` and reads the list with `Service.SignupWaitlist()`. Behind a proxy, configure [`client_address`](#client-address) so the per-address limit sees real diff --git a/docs/systemauth/relying-party.md b/docs/systemauth/relying-party.md index a0488da4..246469ca 100644 --- a/docs/systemauth/relying-party.md +++ b/docs/systemauth/relying-party.md @@ -168,6 +168,10 @@ The app's origin must be in SystemAuth's `social_login.allowed_redirect_origins` and the app and SystemAuth must share a site (registrable domain), because the login cookie is `SameSite=Lax` and is not sent on a cross-site `POST`. +SystemAuth's `social_login.signed_out_url`, when set, replaces `returnTo` +for every app, such as to send a signed-out browser to the product's +marketing site. + `BFFConfig.SignOutURL` is the sign-out page: the issuer's `/logout` by default, or `-` to leave it out, which keeps the `204`. diff --git a/docs/systemauth/social-login.md b/docs/systemauth/social-login.md index 2d3ab495..cff9737e 100644 --- a/docs/systemauth/social-login.md +++ b/docs/systemauth/social-login.md @@ -141,6 +141,8 @@ social_login: | `github`, `google` | disabled | `client_id` / `client_secret` support `${ENV}` expansion. `redirect_url` defaults to `{issuer}/login/{provider}/callback`; register that URL with the provider. | | `allowed_redirect_origins` | none | Absolute origins a `return_to` may target in addition to the issuer origin. Relative same-origin paths are always allowed. | | `default_redirect` | `/` | Used when no `return_to` is given. Must itself pass the allowlist. | +| `signed_out_url` | none | Where every sign-out lands, whatever `return_to` the app sent, such as the product's marketing site. Must pass the allowlist, so list its origin in `allowed_redirect_origins`. | +| `signed_out_name` | `signed_out_url`'s host | Display name of the site at `signed_out_url`, such as `ProductBuildersHQ`. The waitlist page links back to `signed_out_url` as "← Back to ". Display text only; the link always goes to `signed_out_url`. | | `session_lifetime` | `12h` | Absolute lifetime of the login session. | | `auto_select_single_provider` | `false` | With one provider, skip the sign-in page and go straight to it. Off by default: a browser that has just signed out would otherwise be signed straight back in, since the provider keeps its own session and remembers the person's authorization. | | `skip_consent` | `false` | Deprecated and ignored; logs a warning at startup when set. First-party clients skip consent and other clients always ask (see [Consent](#consent)). | diff --git a/identity/systemauth/config.go b/identity/systemauth/config.go index 01ccc243..e8f83d7a 100644 --- a/identity/systemauth/config.go +++ b/identity/systemauth/config.go @@ -355,6 +355,20 @@ type SocialLoginConfig struct { // given. Default: "/". DefaultRedirect string `json:"default_redirect,omitempty" yaml:"default_redirect,omitempty" jsonschema:"default=/,description=Post-login redirect when none is requested"` + // SignedOutURL, when set, is where a sign-out lands, whatever return_to + // the app sent, such as the product's marketing site. It must pass the + // same allowlist as return_to: an origin in AllowedRedirectOrigins, or a + // path on the issuer. Empty returns to the return_to, or to + // DefaultRedirect. + SignedOutURL string `json:"signed_out_url,omitempty" yaml:"signed_out_url,omitempty" jsonschema:"description=Where a sign-out lands whatever return_to the app sent; it must be an allowed redirect origin"` + + // SignedOutName is the display name of the site at SignedOutURL, such + // as "ProductBuildersHQ", shown in links back to it ("Back to + // ProductBuildersHQ") on pages such as the waitlist. It is display text + // only; the link always goes to SignedOutURL. Default: SignedOutURL's + // host. + SignedOutName string `json:"signed_out_name,omitempty" yaml:"signed_out_name,omitempty" jsonschema:"description=Display name of the site at signed_out_url for links back to it (default: its host)"` + // SessionLifetime is the absolute lifetime of the login session cookie. // Default: 12h. SessionLifetime Duration `json:"session_lifetime,omitempty" yaml:"session_lifetime,omitempty" jsonschema:"default=12h,description=Absolute login session lifetime"` diff --git a/identity/systemauth/hardening_test.go b/identity/systemauth/hardening_test.go index 6e9cab1e..56583598 100644 --- a/identity/systemauth/hardening_test.go +++ b/identity/systemauth/hardening_test.go @@ -306,6 +306,13 @@ func TestDefaultSessionProviderEscapesReturnURL(t *testing.T) { // --- logout and consent (social login) --- func newSocialFlowServer(t *testing.T, party ClientParty) *Server { + t.Helper() + return newSocialFlowServerWith(t, party, &SocialLoginConfig{AllowedRedirectOrigins: []string{"https://app.example.com"}}) +} + +// newSocialFlowServerWith is newSocialFlowServer with its own social login +// settings. +func newSocialFlowServerWith(t *testing.T, party ClientParty, social *SocialLoginConfig) *Server { t.Helper() f := newFakeGitHub(t) s, err := NewEmbedded(Config{ @@ -320,9 +327,7 @@ func newSocialFlowServer(t *testing.T, party ClientParty) *Server { ResponseTypes: []string{"code"}, Scopes: []string{"openid", "profile", "email", "offline_access"}, }}, - SocialLogin: &SocialLoginConfig{ - AllowedRedirectOrigins: []string{"https://app.example.com"}, - }, + SocialLogin: social, }, WithSocialConnector(f.connector())) if err != nil { t.Fatalf("NewEmbedded: %v", err) @@ -598,3 +603,31 @@ func TestUnauthenticatedAuthorizeGoesToLogin(t *testing.T) { t.Errorf("authorize with only a header: %d %q, want a redirect to login", w.Code, w.Header().Get("Location")) } } + +// signed_out_url lands every sign-out on the operator's page, such as the +// marketing site, whatever return_to the app sent; it must be allowed. +func TestLogoutLandsOnSignedOutURL(t *testing.T) { + s := newSocialFlowServerWith(t, PartyFirst, &SocialLoginConfig{ + AllowedRedirectOrigins: []string{"https://app.example.com", "https://www.example.com"}, + SignedOutURL: "https://www.example.com/", + }) + sess := socialSession(t, s) + w := postForm(s, LogoutPath, "https://app.example.com", url.Values{"return_to": {"https://app.example.com/"}}, sess) + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "https://www.example.com/" { + t.Fatalf("logout: %d %q", w.Code, w.Header().Get("Location")) + } + // An open redirect in return_to is still refused. + if w := postForm(s, LogoutPath, "https://app.example.com", url.Values{"return_to": {"https://evil.example.com/"}}, sess); w.Code != http.StatusBadRequest { + t.Errorf("open redirect: %d", w.Code) + } + + // A signed_out_url outside the allowed origins is refused at startup. + f := newFakeGitHub(t) + _, err := NewEmbedded(Config{Issuer: testIssuer, SocialLogin: &SocialLoginConfig{ + AllowedRedirectOrigins: []string{"https://app.example.com"}, + SignedOutURL: "https://elsewhere.example.com/", + }}, WithSocialConnector(f.connector())) + if err == nil || !strings.Contains(err.Error(), "signed_out_url") { + t.Errorf("an unlisted signed_out_url: %v", err) + } +} diff --git a/identity/systemauth/signup_waitlist.go b/identity/systemauth/signup_waitlist.go index b11e41eb..9a090abf 100644 --- a/identity/systemauth/signup_waitlist.go +++ b/identity/systemauth/signup_waitlist.go @@ -96,6 +96,7 @@ var waitlistTemplate = template.Must(template.New("waitlist").Parse(` {{.Page.ConsentText}} {{end}} +{{if .HomeURL}}

← Back to {{.HomeName}}

{{end}} `)) @@ -106,10 +107,15 @@ type waitlistView struct { Thanks bool Error string Email string + // HomeURL and HomeName link back to social_login.signed_out_url, so + // the page is not a dead end; empty without one. + HomeURL string + HomeName string } func (sl *socialLogin) renderWaitlist(w http.ResponseWriter, r *http.Request, status int, v waitlistView) { v.Page, v.Action = sl.waitlistPage, SignupWaitlistPath + v.HomeURL, v.HomeName = sl.redirects.signedOut, sl.redirects.signedOutName w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if err := waitlistTemplate.Execute(w, v); err != nil { diff --git a/identity/systemauth/signup_waitlist_test.go b/identity/systemauth/signup_waitlist_test.go index 40f732b6..f189b971 100644 --- a/identity/systemauth/signup_waitlist_test.go +++ b/identity/systemauth/signup_waitlist_test.go @@ -126,3 +126,56 @@ func TestWaitlistNotServedWithoutOne(t *testing.T) { t.Fatalf("waitlist served without one configured: %d", w.Code) } } + +// newWaitlistServerWith is newWaitlistServer with its own social login +// settings. +func newWaitlistServerWith(t *testing.T, social *SocialLoginConfig) *Server { + t.Helper() + s, err := NewEmbedded(Config{Issuer: testIssuer, SocialLogin: social}, + WithSocialConnector(newFakeGitHub(t).connector()), WithPrincipalDirectory(NewMemoryPrincipalDirectory()), + WithSignupWaitlist(&stubWaitlist{}, WaitlistPage{})) + if err != nil { + t.Fatal(err) + } + return s +} + +// The waitlist page links back to signed_out_url by its display name, on +// the form and after joining, so it is not a dead end. +func TestWaitlistLinksBackToSignedOutURL(t *testing.T) { + s := newWaitlistServerWith(t, &SocialLoginConfig{ + AllowedRedirectOrigins: []string{"https://www.example.com"}, + SignedOutURL: "https://www.example.com/", + SignedOutName: "Example", + }) + link := `← Back to Example` + w := httptest.NewRecorder() + s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, SignupWaitlistPath, nil)) + if !strings.Contains(w.Body.String(), link) { + t.Errorf("form page has no link back: %s", w.Body.String()) + } + w = postForm(s, SignupWaitlistPath, testIssuer, url.Values{"email": {"ada@example.com"}, "consent": {"yes"}}) + if body := w.Body.String(); !strings.Contains(body, DefaultWaitlistPage.Thanks) || !strings.Contains(body, link) { + t.Errorf("thank-you page has no link back: %d %s", w.Code, body) + } +} + +// Without signed_out_name the link names signed_out_url's host, and without +// signed_out_url there is no link. +func TestWaitlistLinkBackDefaults(t *testing.T) { + s := newWaitlistServerWith(t, &SocialLoginConfig{ + AllowedRedirectOrigins: []string{"https://www.example.com"}, + SignedOutURL: "https://www.example.com/", + }) + w := httptest.NewRecorder() + s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, SignupWaitlistPath, nil)) + if !strings.Contains(w.Body.String(), "← Back to www.example.com") { + t.Errorf("default name: %s", w.Body.String()) + } + s = newWaitlistServerWith(t, &SocialLoginConfig{}) + w = httptest.NewRecorder() + s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, SignupWaitlistPath, nil)) + if strings.Contains(w.Body.String(), "Back to") { + t.Errorf("link without signed_out_url: %s", w.Body.String()) + } +} diff --git a/identity/systemauth/social_logout.go b/identity/systemauth/social_logout.go index 9276b180..6e77641b 100644 --- a/identity/systemauth/social_logout.go +++ b/identity/systemauth/social_logout.go @@ -65,6 +65,10 @@ func (sl *socialLogin) handleLogout(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid return_to", http.StatusBadRequest) return } + // The operator's landing for every sign-out, such as the marketing site. + if sl.redirects.signedOut != "" { + target = sl.redirects.signedOut + } if err := sl.endSession(w, r); err != nil { logger.Error("ending login session", "error", err) diff --git a/identity/systemauth/social_redirect.go b/identity/systemauth/social_redirect.go index 98a123f4..de7e80d0 100644 --- a/identity/systemauth/social_redirect.go +++ b/identity/systemauth/social_redirect.go @@ -17,6 +17,10 @@ type redirectPolicy struct { defaultTarget string issuerOrigin string origins map[string]bool + // signedOut, when set, is where every sign-out lands. + signedOut string + // signedOutName is signedOut's display name, for links back to it. + signedOutName string } func newRedirectPolicy(issuer string, cfg *SocialLoginConfig) (*redirectPolicy, error) { @@ -37,6 +41,17 @@ func newRedirectPolicy(issuer string, cfg *SocialLoginConfig) (*redirectPolicy, if _, err := p.validate(p.defaultTarget); err != nil { return nil, fmt.Errorf("default_redirect: %w", err) } + if cfg.SignedOutURL != "" { + v, err := p.validate(cfg.SignedOutURL) + if err != nil { + return nil, fmt.Errorf("signed_out_url: %w", err) + } + p.signedOut = v + p.signedOutName = cfg.SignedOutName + if p.signedOutName == "" { + p.signedOutName = hostOf(v, issuerOrigin) + } + } return p, nil } @@ -135,3 +150,14 @@ func (p *redirectPolicy) fromTrustedOrigin(r *http.Request) bool { origin := requestOrigin(r) return origin != "" && p.origins[origin] } + +// hostOf returns target's host, or the issuer's host for a relative path. +func hostOf(target, issuerOrigin string) string { + if u, err := url.Parse(target); err == nil && u.Host != "" { + return u.Hostname() + } + if u, err := url.Parse(issuerOrigin); err == nil { + return u.Hostname() + } + return target +} diff --git a/mkdocs.yml b/mkdocs.yml index a741277c..4131dcc6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -169,6 +169,7 @@ nav: - Releases: - Overview: releases/index.md - Changelog: releases/changelog.md + - v0.30.4: releases/v0.30.4.md - v0.30.3: releases/v0.30.3.md - v0.30.2: releases/v0.30.2.md - v0.30.1: releases/v0.30.1.md