Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions CHANGELOG.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 24 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion docs/releases/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/releases/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
29 changes: 29 additions & 0 deletions docs/releases/v0.30.4.md
Original file line number Diff line number Diff line change
@@ -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 <name>", 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).
2 changes: 2 additions & 0 deletions docs/systemauth/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/systemauth/relying-party.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 2 additions & 0 deletions docs/systemauth/social-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>". 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)). |
Expand Down
14 changes: 14 additions & 0 deletions identity/systemauth/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
39 changes: 36 additions & 3 deletions identity/systemauth/hardening_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}
6 changes: 6 additions & 0 deletions identity/systemauth/signup_waitlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ var waitlistTemplate = template.Must(template.New("waitlist").Parse(`<!doctype h
<label><input name="consent" type="checkbox" value="yes" required> {{.Page.ConsentText}}</label>
<button type="submit">Notify me</button>
</form>{{end}}
{{if .HomeURL}}<p><a href="{{.HomeURL}}">&larr; Back to {{.HomeName}}</a></p>{{end}}
</main></body></html>
`))

Expand All @@ -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 {
Expand Down
53 changes: 53 additions & 0 deletions identity/systemauth/signup_waitlist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := `<a href="https://www.example.com/">&larr; Back to Example</a>`
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(), "&larr; Back to www.example.com</a>") {
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())
}
}
4 changes: 4 additions & 0 deletions identity/systemauth/social_logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading