Sign-in methods
+Link another sign-in to this account, so either one signs you in to the same account and its work.
+{{with .Linked}}{{.}} is linked to your account. You can now sign in with it.
+{{end}}{{with .Problem}}{{.}}
+{{end}}diff --git a/CHANGELOG.json b/CHANGELOG.json index 162bbf8e..68e4d40e 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -8,6 +8,44 @@ "grokify" ], "releases": [ + { + "version": "v0.30.5", + "date": "2026-11-02", + "highlights": [ + { + "description": "A signed-in person can link another provider's sign-in to their account at `/account/sign-in-methods`, so either reaches the same account", + "commit": "18333eb" + } + ], + "added": [ + { + "description": "`/account/sign-in-methods` and `POST /account/sign-in-methods/{provider}`: link a provider's sign-in to the signed-in account (`SignInMethodsPath`)", + "commit": "18333eb" + }, + { + "description": "The security event `systemauth.sign_in_method.linked`, for a completed link and a link the callback refuses, as OCSF User Management, Update", + "commit": "18333eb" + } + ], + "security": [ + { + "description": "Linking needs a session for the account that started it and an allowed origin, never moves an identity from another account, never creates an account and never changes its email", + "commit": "18333eb" + } + ], + "tests": [ + { + "description": "Linking, relinking, signing in with a linked identity, and refusals for another account's identity, a missing session and a cross-origin start", + "commit": "1dc9898" + } + ], + "documentation": [ + { + "description": "The social login guide describes linking sign-in methods; the OCSF guide lists the new event", + "commit": "2159827" + } + ] + }, { "version": "v0.30.4", "date": "2026-11-02", diff --git a/CHANGELOG.md b/CHANGELOG.md index d43ad431..fc5909cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,29 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro ## [Unreleased] +## [v0.30.5] - 2026-11-02 + +### Highlights + +- A signed-in person can link another provider's sign-in to their account at `/account/sign-in-methods`, so either reaches the same account + +### Security + +- Linking needs a session for the account that started it and an allowed origin, never moves an identity from another account, never creates an account and never changes its email ([`18333eb`](https://github.com/plexusone/systemforge/commit/18333eb)) + +### Added + +- `/account/sign-in-methods` and `POST /account/sign-in-methods/{provider}`: link a provider's sign-in to the signed-in account (`SignInMethodsPath`) ([`18333eb`](https://github.com/plexusone/systemforge/commit/18333eb)) +- The security event `systemauth.sign_in_method.linked`, for a completed link and a link the callback refuses, as OCSF User Management, Update ([`18333eb`](https://github.com/plexusone/systemforge/commit/18333eb)) + +### Documentation + +- The social login guide describes linking sign-in methods; the OCSF guide lists the new event ([`2159827`](https://github.com/plexusone/systemforge/commit/2159827)) + +### Tests + +- Linking, relinking, signing in with a linked identity, and refusals for another account's identity, a missing session and a cross-origin start ([`1dc9898`](https://github.com/plexusone/systemforge/commit/1dc9898)) + ## [v0.30.4] - 2026-11-02 ### Highlights @@ -1747,7 +1770,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.4...HEAD +[unreleased]: https://github.com/plexusone/systemforge/compare/v0.30.5...HEAD +[v0.30.5]: https://github.com/plexusone/systemforge/compare/v0.30.4...v0.30.5 [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 diff --git a/contract/audit/ocsfaudit/details.go b/contract/audit/ocsfaudit/details.go index 4a74ac92..6eaed4c0 100644 --- a/contract/audit/ocsfaudit/details.go +++ b/contract/audit/ocsfaudit/details.go @@ -42,6 +42,7 @@ var allowed = map[string]map[string]kind{ "systemauth.token_family.revoked": {"reason": kindCode, "subject": kindUUID, "client_id": kindCode}, "systemauth.email_verification.sent": {"purpose": kindCode, "sent": kindBool}, "systemauth.email.verified": {"purpose": kindCode, "method": kindCode}, + "systemauth.sign_in_method.linked": {"method": kindCode, "reason": kindCode}, "systemauth.signing_key.loaded": {"key_id": kindCode, "verification_key_ids": kindCodeList, "ephemeral": kindBool}, "systemauth.production.waiver": {"check": kindCode, "setting": kindCode}, diff --git a/contract/audit/ocsfaudit/doc.go b/contract/audit/ocsfaudit/doc.go index bda60987..1b27824b 100644 --- a/contract/audit/ocsfaudit/doc.go +++ b/contract/audit/ocsfaudit/doc.go @@ -51,6 +51,7 @@ // systemauth.email_change.requested, User Management (3007) Update Informational // systemauth.email_change.cancelled, // systemauth.email.changed +// systemauth.sign_in_method.linked User Management (3007) Update Informational // systemauth.signing_key.loaded Application Lifecycle (6002) Start Informational // systemauth.production.waiver Application Lifecycle (6002) Start Medium // organization.membership Group Management (3006) Add User, Remove User, Update Informational; Medium for ownership_transferred diff --git a/contract/audit/ocsfaudit/mapping.go b/contract/audit/ocsfaudit/mapping.go index c5117e52..fe3008a6 100644 --- a/contract/audit/ocsfaudit/mapping.go +++ b/contract/audit/ocsfaudit/mapping.go @@ -94,6 +94,8 @@ func (m *mapper) toOCSF(ev *audit.Event) any { case "systemauth.email_verification.sent", "systemauth.email.verified", "systemauth.email_change.requested", "systemauth.email_change.cancelled", "systemauth.email.changed": return c.email() + case "systemauth.sign_in_method.linked": + return c.userManagement(events.UserManagementActivityIDUpdate, c.subject()) case "systemauth.signing_key.loaded", "systemauth.production.waiver": return c.startup() case "organization.membership": diff --git a/contract/audit/ocsfaudit/mapping_test.go b/contract/audit/ocsfaudit/mapping_test.go index a9ca8cb7..46e7b52d 100644 --- a/contract/audit/ocsfaudit/mapping_test.go +++ b/contract/audit/ocsfaudit/mapping_test.go @@ -96,6 +96,8 @@ func mappingCases() []mappingCase { {"email change requested", evt("systemauth.email_change.requested", "request_email_change", ok, nil), userMg, events.UserManagementActivityIDUpdate, info}, {"email change cancelled", evt("systemauth.email_change.cancelled", "cancel_email_change", ok, nil), userMg, events.UserManagementActivityIDUpdate, info}, {"email changed", evt("systemauth.email.changed", "change_email", ok, map[string]any{"old_email": leakEmail, "new_email": leakEmail}), userMg, events.UserManagementActivityIDUpdate, info}, + {"sign-in method linked", evt("systemauth.sign_in_method.linked", "link_sign_in_method", ok, map[string]any{"method": "google", "email": leakEmail}), userMg, events.UserManagementActivityIDUpdate, info}, + {"sign-in method link refused", evt("systemauth.sign_in_method.linked", "link_sign_in_method", fail, map[string]any{"method": "github", "reason": "other_account"}), userMg, events.UserManagementActivityIDUpdate, low}, {"signing key loaded", withResource(withActor(evt("systemauth.signing_key.loaded", "load", ok, map[string]any{"key_id": "kid-2026", "verification_key_ids": []string{"kid-2025", "kid-2026"}, "ephemeral": false}), service), audit.Resource{Type: "signing_key", Identifier: "kid-2026"}), appLC, events.ApplicationLifecycleActivityIDStart, info}, {"production waiver", withResource(withActor(evt("systemauth.production.waiver", "waive", ok, map[string]any{"check": "dpop.single_instance", "setting": "dpop.single_instance", "risk": leakRisk}), service), audit.Resource{Type: "production_check", Identifier: "dpop.single_instance"}), appLC, events.ApplicationLifecycleActivityIDStart, medium}, {"membership added", withResource(evt("organization.membership", "added", ok, map[string]any{"organization_id": orgID.String(), "principal_id": resourceID.String(), "role": "member"}), audit.Resource{Type: "membership", ID: resourceID}), groupM, events.GroupManagementActivityIDAddUser, info}, diff --git a/docs/releases/changelog.md b/docs/releases/changelog.md index d43ad431..fc5909cd 100644 --- a/docs/releases/changelog.md +++ b/docs/releases/changelog.md @@ -9,6 +9,29 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro ## [Unreleased] +## [v0.30.5] - 2026-11-02 + +### Highlights + +- A signed-in person can link another provider's sign-in to their account at `/account/sign-in-methods`, so either reaches the same account + +### Security + +- Linking needs a session for the account that started it and an allowed origin, never moves an identity from another account, never creates an account and never changes its email ([`18333eb`](https://github.com/plexusone/systemforge/commit/18333eb)) + +### Added + +- `/account/sign-in-methods` and `POST /account/sign-in-methods/{provider}`: link a provider's sign-in to the signed-in account (`SignInMethodsPath`) ([`18333eb`](https://github.com/plexusone/systemforge/commit/18333eb)) +- The security event `systemauth.sign_in_method.linked`, for a completed link and a link the callback refuses, as OCSF User Management, Update ([`18333eb`](https://github.com/plexusone/systemforge/commit/18333eb)) + +### Documentation + +- The social login guide describes linking sign-in methods; the OCSF guide lists the new event ([`2159827`](https://github.com/plexusone/systemforge/commit/2159827)) + +### Tests + +- Linking, relinking, signing in with a linked identity, and refusals for another account's identity, a missing session and a cross-origin start ([`1dc9898`](https://github.com/plexusone/systemforge/commit/1dc9898)) + ## [v0.30.4] - 2026-11-02 ### Highlights @@ -1747,7 +1770,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.4...HEAD +[unreleased]: https://github.com/plexusone/systemforge/compare/v0.30.5...HEAD +[v0.30.5]: https://github.com/plexusone/systemforge/compare/v0.30.4...v0.30.5 [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 diff --git a/docs/releases/index.md b/docs/releases/index.md index 9cbeb77d..9e179276 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -4,7 +4,10 @@ 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.4](v0.30.4.md), adds +The latest release, [v0.30.5](v0.30.5.md), lets a signed-in person link +another provider's sign-in to their account. It adds no breaking change. + +Before it, [v0.30.4](v0.30.4.md) added `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. diff --git a/docs/releases/v0.30.5.md b/docs/releases/v0.30.5.md new file mode 100644 index 00000000..9a891bfe --- /dev/null +++ b/docs/releases/v0.30.5.md @@ -0,0 +1,24 @@ +# v0.30.5 + +**Release Date:** 2026-11-02 + +One account, several sign-ins: + +- **Linking sign-in methods.** A person who signs in with GitHub under one + email would get a second account by signing in with Google under another, + since a sign-in joins an existing account only by an email its provider is + authoritative for. Signed in, they can now open `/account/sign-in-methods` + on the auth host and link the other provider first. Either sign-in then + reaches the same account and its work. A sign-in that already has an + account of its own is not linked: merging accounts, and removing a sign-in + method, are not available yet. +- **Safe by default.** Linking needs a session for the account that started + it, is origin-checked like sign-out, never moves an identity from another + account, never creates an account and never changes the account's email. + A completed link, and a link the callback refuses, is the security event + `systemauth.sign_in_method.linked`, mapped to OCSF User Management. + +It adds no breaking change and no setting: the page is served wherever +social login is. + +See [Linking sign-in methods](../systemauth/social-login.md#linking-sign-in-methods). diff --git a/docs/security/ocsf-events.md b/docs/security/ocsf-events.md index a0e87105..90435ea0 100644 --- a/docs/security/ocsf-events.md +++ b/docs/security/ocsf-events.md @@ -100,6 +100,7 @@ selected by it as well as by OCSF class. | `systemauth.token_family.revoked` | Authentication (3002) | Logoff, protocol OAuth 2.0 | Informational | | `systemauth.email_verification.sent`, `systemauth.email.verified` | Authentication (3002) for purpose `verify_email`; User Management (3007) for `email_change` | Preauth; Update | Informational | | `systemauth.email_change.requested`, `.cancelled`, `systemauth.email.changed` | User Management (3007) | Update | Informational | +| `systemauth.sign_in_method.linked` | User Management (3007) | Update | Informational; Low when refused | | `systemauth.signing_key.loaded` | Application Lifecycle (6002) | Start | Informational | | `systemauth.production.waiver` | Application Lifecycle (6002) | Start | Medium | | `organization.membership` | Group Management (3006) | Add User (`added`), Remove User (`removed`), Update (`updated`, `ownership_transferred`) | Informational; Medium for `ownership_transferred` | diff --git a/docs/systemauth/social-login.md b/docs/systemauth/social-login.md index cff9737e..d19d489d 100644 --- a/docs/systemauth/social-login.md +++ b/docs/systemauth/social-login.md @@ -18,6 +18,7 @@ endpoints: |-------|---------| | `GET /login?return_to=...` | The sign-in page: a link per provider, waiting for the person to choose, even with one provider. With `auto_select_single_provider`, redirects straight to the only provider. | | `GET /login/{provider}?return_to=...` | Starts the upstream authorization-code flow (`github` or `google`). | +| `GET /account/sign-in-methods` | A signed-in person's page to link another provider's sign-in to their account (see [Linking sign-in methods](#linking-sign-in-methods)). | | `GET /login/{provider}/callback` | Upstream callback. Resolves the principal, sets the `__Host-sf_login` session, and redirects to `return_to`. | | `GET /logout?return_to=...` | Renders a sign-out confirmation form (never signs out by itself). | | `POST /logout` | Deletes the login session, clears the cookie, revokes all of the principal's access and refresh tokens, and redirects (`303`) to the validated `return_to` (default `default_redirect`). | @@ -192,6 +193,32 @@ email already belongs to an account, the sign-in is refused as a conflict (`ErrEmailConflict`), and the person signs in the way they signed in before. GitHub sign-in uses the account's primary email only. +### Linking sign-in methods + +A signed-in person can link another provider's sign-in to their account at +`/account/sign-in-methods`, so either one signs them in to the same account, +whatever email each provider reports. The page lists a **Link** button per +configured provider. The button's form posts to +`/account/sign-in-methods/{provider}`, which is origin-checked like sign-out +and needs a login session. It sends the browser through the provider's +sign-in. The callback then attaches that provider identity to the account: + +- The browser must still be signed in as the account that started the link. +- An identity already on another account is never moved, and the page says + so. Removing a sign-in method from an account, or merging two accounts, is + not available yet: link a provider before its first sign-in makes an + account of its own. +- An identity already on this account is a success that changes nothing. +- No account is created, no sign-up gate or age question applies, and the + account's own email is unchanged. + +A completed link, and a link the callback refuses (the identity is on +another account, or the browser is no longer signed in as the account that +started it), is the security event `systemauth.sign_in_method.linked`. A +start refused before the provider, such as a cross-origin request, is not; +nor is a denial at the provider, which is a refused sign-in +(`systemauth.sign_in`). + ## Security properties - `state` is stored server-side (single use, 10 minute TTL) **and** bound to the diff --git a/identity/systemauth/social_link.go b/identity/systemauth/social_link.go new file mode 100644 index 00000000..a1992b8d --- /dev/null +++ b/identity/systemauth/social_link.go @@ -0,0 +1,218 @@ +package systemauth + +import ( + "context" + "errors" + "html/template" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "golang.org/x/oauth2" + + "github.com/plexusone/systemforge/contract/audit" + "github.com/plexusone/systemforge/identity/oauthclient" +) + +const ( + // SignInMethodsPath shows a signed-in person their account's sign-in + // methods and lets them link another provider's sign-in to the same + // account, so either signs them in. + SignInMethodsPath = "/account/sign-in-methods" + // linkStartPattern starts linking a provider: a POST from the page. + linkStartPattern = SignInMethodsPath + "/{provider}" + + // EventSignInMethodLinked records linking a provider's sign-in to an + // account, or a refusal to (Details: method, and reason on failure). + EventSignInMethodLinked = "systemauth.sign_in_method.linked" + + // linkNoncePrefix marks a login state that links to the account named + // after it rather than signing in. + linkNoncePrefix = "link:" +) + +var signInMethodsTemplate = template.Must(template.New("sign-in-methods").Parse(` +
` + pageStyleLink + ` +Link another sign-in to this account, so either one signs you in to the same account and its work.
+{{with .Linked}}{{.}} is linked to your account. You can now sign in with it.
+{{end}}{{with .Problem}}{{.}}
+{{end}}