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
70 changes: 70 additions & 0 deletions CHANGELOG.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,76 @@
"grokify"
],
"releases": [
{
"version": "v0.32.0",
"date": "2026-11-05",
"highlights": [
{
"description": "A manager replaces a managed principal's login tokens in one call: every live token is revoked and one issued, atomically, so a lost card is retired and reprinted in one step",
"commit": "289b658"
},
{
"description": "The sign-in methods page shows the account's linked methods, offers only the others, and links back to the application through a validated `return_to`",
"commit": "19553ad"
}
],
"added": [
{
"description": "`managedprincipal.Service.ReplaceLoginTokens` revokes every live login token and issues one in one transaction; a failure while issuing revokes nothing",
"commit": "289b658"
},
{
"description": "`POST /{principalID}/login-tokens/replace` answers 201 with the new token and the number revoked; `mpclient.Client.ReplaceLoginTokens` calls it",
"commit": "289b658"
},
{
"description": "Audit event `managed_principal.login_tokens_replaced`, with the number of tokens revoked",
"commit": "289b658"
},
{
"description": "The sign-in methods page shows the account's email and its linked providers, with a Link button only for the others",
"commit": "19553ad"
},
{
"description": "`/account/sign-in-methods?return_to=...` shows a back link to an allowed `return_to`, kept through signing in, linking and the callback and validated again at each step",
"commit": "19553ad"
},
{
"description": "Optional `LinkedProviderLister` directory interface, implemented by the Ent and memory directories",
"commit": "19553ad"
}
],
"security": [
{
"description": "Issuing and replacing a login token check the issuer's authority under the managed principal's row lock, in the transaction that issues, so a manager removed at the same moment can no longer be issued a token",
"commit": "0d8d277"
}
],
"tests": [
{
"description": "Replacing login tokens: every live token revoked and one issued, a failed issue keeps the old token, refusals revoke nothing, single-use and expiry honored, and the route and client",
"commit": "f263e91"
},
{
"description": "Sign-in methods page: linked providers listed and not offered, valid and invalid `return_to`, and `return_to` kept through the link and the signed-out redirect",
"commit": "53b650d"
},
{
"description": "A manager removed between the client check and the transaction is refused for issuing and replacing; two concurrent replaces on PostgreSQL leave one live token; a failed replace had revoked the old token before rolling back",
"commit": "8630cb2"
}
],
"documentation": [
{
"description": "Replacing a card in the managed principals guide and the architecture's route list",
"commit": "6a73560"
},
{
"description": "Linked methods and `return_to` on the sign-in methods page in the social login guide",
"commit": "c6be3bb"
}
]
},
{
"version": "v0.31.0",
"date": "2026-11-04",
Expand Down
34 changes: 33 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,37 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro

## [Unreleased]

## [v0.32.0] - 2026-11-05

### Highlights

- A manager replaces a managed principal's login tokens in one call: every live token is revoked and one issued, atomically, so a lost card is retired and reprinted in one step
- The sign-in methods page shows the account's linked methods, offers only the others, and links back to the application through a validated `return_to`

### Security

- Issuing and replacing a login token check the issuer's authority under the managed principal's row lock, in the transaction that issues, so a manager removed at the same moment can no longer be issued a token ([`0d8d277`](https://github.com/plexusone/systemforge/commit/0d8d277))

### Added

- `managedprincipal.Service.ReplaceLoginTokens` revokes every live login token and issues one in one transaction; a failure while issuing revokes nothing ([`289b658`](https://github.com/plexusone/systemforge/commit/289b658))
- `POST /{principalID}/login-tokens/replace` answers 201 with the new token and the number revoked; `mpclient.Client.ReplaceLoginTokens` calls it ([`289b658`](https://github.com/plexusone/systemforge/commit/289b658))
- Audit event `managed_principal.login_tokens_replaced`, with the number of tokens revoked ([`289b658`](https://github.com/plexusone/systemforge/commit/289b658))
- The sign-in methods page shows the account's email and its linked providers, with a Link button only for the others ([`19553ad`](https://github.com/plexusone/systemforge/commit/19553ad))
- `/account/sign-in-methods?return_to=...` shows a back link to an allowed `return_to`, kept through signing in, linking and the callback and validated again at each step ([`19553ad`](https://github.com/plexusone/systemforge/commit/19553ad))
- Optional `LinkedProviderLister` directory interface, implemented by the Ent and memory directories ([`19553ad`](https://github.com/plexusone/systemforge/commit/19553ad))

### Documentation

- Replacing a card in the managed principals guide and the architecture's route list ([`6a73560`](https://github.com/plexusone/systemforge/commit/6a73560))
- Linked methods and `return_to` on the sign-in methods page in the social login guide ([`c6be3bb`](https://github.com/plexusone/systemforge/commit/c6be3bb))

### Tests

- Replacing login tokens: every live token revoked and one issued, a failed issue keeps the old token, refusals revoke nothing, single-use and expiry honored, and the route and client ([`f263e91`](https://github.com/plexusone/systemforge/commit/f263e91))
- Sign-in methods page: linked providers listed and not offered, valid and invalid `return_to`, and `return_to` kept through the link and the signed-out redirect ([`53b650d`](https://github.com/plexusone/systemforge/commit/53b650d))
- A manager removed between the client check and the transaction is refused for issuing and replacing; two concurrent replaces on PostgreSQL leave one live token; a failed replace had revoked the old token before rolling back ([`8630cb2`](https://github.com/plexusone/systemforge/commit/8630cb2))

## [v0.31.0] - 2026-11-04

### Highlights
Expand Down Expand Up @@ -1824,7 +1855,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.31.0...HEAD
[unreleased]: https://github.com/plexusone/systemforge/compare/v0.32.0...HEAD
[v0.32.0]: https://github.com/plexusone/systemforge/compare/v0.31.0...v0.32.0
[v0.31.0]: https://github.com/plexusone/systemforge/compare/v0.30.5...v0.31.0
[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
Expand Down
7 changes: 5 additions & 2 deletions docs/design/architecture/managed-principals.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,10 +258,12 @@ ClientID, CreatedAt, ExpiresAt, LastUsedAt, ClientIP}`:
| `principal_token` | `sf_principal_tokens`, one entry per family | Family marked revoked |

`ListLoginTokens` returns live login tokens without their values;
`RevokeLoginToken` revokes one; `RevokeSession(kind, id)` ends one session;
`RevokeLoginToken` revokes one; `ReplaceLoginTokens` revokes every live
login token and issues one in one transaction, so a failure leaves the old
tokens working; `RevokeSession(kind, id)` ends one session;
`RevokeAll` revokes every login token and ends every session in one
transaction. Each emits an audit event (`login_token_revoked`,
`session_revoked`, `all_revoked`). `managedprincipal.NewHandler(svc)`
`login_tokens_replaced`, `session_revoked`, `all_revoked`). `managedprincipal.NewHandler(svc)`
exposes these and the managing operations as a chi router to mount behind
`requestprincipal.Middleware`, which identifies the calling principal. The
caller is always the actor; every route runs the service operation and so
Expand All @@ -283,6 +285,7 @@ POST /manager-invites/decline DeclineManagerInvite (JSON: to
DELETE /manager-invites/{inviteID} RevokeManagerInvite
GET /{principalID}/login-tokens
POST /{principalID}/login-tokens IssueLoginToken (JSON: client_id, name?, single_use?, expires_at?)
POST /{principalID}/login-tokens/replace ReplaceLoginTokens (same JSON); 201 with the token and `revoked`
DELETE /{principalID}/login-tokens/{credentialID}
GET /{principalID}/sessions
DELETE /{principalID}/sessions/{kind}/{sessionID}
Expand Down
25 changes: 25 additions & 0 deletions docs/identity/managed-principals.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,9 +334,33 @@ principal is signed in:
| Method | Covers |
|--------|--------|
| `ListLoginTokens`, `RevokeLoginToken` | Live login tokens, never their values |
| `ReplaceLoginTokens` | Revokes every live login token and issues one, in one transaction |
| `ListSessions`, `RevokeSession` | Browser login sessions, OAuth token families, principal token families |
| `RevokeAll` | All of the above, in one transaction |

### Replacing a Card

A printed card is a reusable token, and tokens are stored only as hashes, so
a lost or shared card cannot be printed again from what is stored.
`ReplaceLoginTokens` takes the same input as `IssueLoginToken`, revokes every
live login token and issues one new token, in one transaction:

```go
out, err := svc.ReplaceLoginTokens(ctx, managedprincipal.IssueLoginTokenInput{
ManagedID: learnerID,
ClientID: "learning-app",
IssuedBy: parentID,
Name: "Robin's card",
})
// out.Token.PlainToken is the new card; out.Revoked counts the old ones.
```

If issuing fails, nothing is revoked, so the old card keeps working rather
than none. Sessions already signed in with an old token keep running;
`RevokeAll` ends them too. It is audited once, as `login_tokens_replaced`,
with the number revoked. `mpclient.Client.ReplaceLoginTokens` calls it over
HTTP.

Revoking a token family revokes all its tokens, so a refresh token cannot
renew it. `managedprincipal.NewHandler` serves these as JSON for the
manager identified by `requestprincipal`:
Expand All @@ -349,6 +373,7 @@ router.With(requestprincipal.Middleware(opts, authenticators...)).
```text
GET /managed-principals/{id}/login-tokens
POST /managed-principals/{id}/login-tokens
POST /managed-principals/{id}/login-tokens/replace
DELETE /managed-principals/{id}/login-tokens/{credentialID}
GET /managed-principals/{id}/sessions
DELETE /managed-principals/{id}/sessions/{kind}/{sessionID}
Expand Down
34 changes: 33 additions & 1 deletion docs/releases/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,37 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro

## [Unreleased]

## [v0.32.0] - 2026-11-05

### Highlights

- A manager replaces a managed principal's login tokens in one call: every live token is revoked and one issued, atomically, so a lost card is retired and reprinted in one step
- The sign-in methods page shows the account's linked methods, offers only the others, and links back to the application through a validated `return_to`

### Security

- Issuing and replacing a login token check the issuer's authority under the managed principal's row lock, in the transaction that issues, so a manager removed at the same moment can no longer be issued a token ([`0d8d277`](https://github.com/plexusone/systemforge/commit/0d8d277))

### Added

- `managedprincipal.Service.ReplaceLoginTokens` revokes every live login token and issues one in one transaction; a failure while issuing revokes nothing ([`289b658`](https://github.com/plexusone/systemforge/commit/289b658))
- `POST /{principalID}/login-tokens/replace` answers 201 with the new token and the number revoked; `mpclient.Client.ReplaceLoginTokens` calls it ([`289b658`](https://github.com/plexusone/systemforge/commit/289b658))
- Audit event `managed_principal.login_tokens_replaced`, with the number of tokens revoked ([`289b658`](https://github.com/plexusone/systemforge/commit/289b658))
- The sign-in methods page shows the account's email and its linked providers, with a Link button only for the others ([`19553ad`](https://github.com/plexusone/systemforge/commit/19553ad))
- `/account/sign-in-methods?return_to=...` shows a back link to an allowed `return_to`, kept through signing in, linking and the callback and validated again at each step ([`19553ad`](https://github.com/plexusone/systemforge/commit/19553ad))
- Optional `LinkedProviderLister` directory interface, implemented by the Ent and memory directories ([`19553ad`](https://github.com/plexusone/systemforge/commit/19553ad))

### Documentation

- Replacing a card in the managed principals guide and the architecture's route list ([`6a73560`](https://github.com/plexusone/systemforge/commit/6a73560))
- Linked methods and `return_to` on the sign-in methods page in the social login guide ([`c6be3bb`](https://github.com/plexusone/systemforge/commit/c6be3bb))

### Tests

- Replacing login tokens: every live token revoked and one issued, a failed issue keeps the old token, refusals revoke nothing, single-use and expiry honored, and the route and client ([`f263e91`](https://github.com/plexusone/systemforge/commit/f263e91))
- Sign-in methods page: linked providers listed and not offered, valid and invalid `return_to`, and `return_to` kept through the link and the signed-out redirect ([`53b650d`](https://github.com/plexusone/systemforge/commit/53b650d))
- A manager removed between the client check and the transaction is refused for issuing and replacing; two concurrent replaces on PostgreSQL leave one live token; a failed replace had revoked the old token before rolling back ([`8630cb2`](https://github.com/plexusone/systemforge/commit/8630cb2))

## [v0.31.0] - 2026-11-04

### Highlights
Expand Down Expand Up @@ -1824,7 +1855,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.31.0...HEAD
[unreleased]: https://github.com/plexusone/systemforge/compare/v0.32.0...HEAD
[v0.32.0]: https://github.com/plexusone/systemforge/compare/v0.31.0...v0.32.0
[v0.31.0]: https://github.com/plexusone/systemforge/compare/v0.30.5...v0.31.0
[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
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.31.0](v0.31.0.md), is a security release: only
The latest release, [v0.32.0](v0.32.0.md), lets a manager replace a
managed principal's login tokens in one call, so a lost printed card is
retired and reprinted in one step. Its sign-in methods page shows what is
linked and links back to the application. It adds no breaking change.

Before it, [v0.31.0](v0.31.0.md) was a security release: only
owners manage admins, return addresses with dot segments are refused, and
logo, website and avatar URLs must be https when written; URLs already stored
are not changed. It is breaking; read its upgrade notes.
Expand Down
60 changes: 60 additions & 0 deletions docs/releases/v0.32.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# v0.32.0

**Release Date:** 2026-11-05

A manager can now replace a managed principal's printed card in one step,
and the sign-in methods page becomes a page an application can send people
to from its own settings.

- **Replace login tokens.** `managedprincipal.Service.ReplaceLoginTokens`
revokes every live login token of a managed principal and issues one new
token, in one transaction. It takes the same input as `IssueLoginToken`
(name, single use, expiry) and is authorized the same way, by
`manage_credentials`. If issuing fails, nothing is revoked, so a failure
leaves the old card working rather than none. Two replaces at once on
PostgreSQL leave one token. Sessions already signed in with an old token
keep running; `RevokeAll` ends them too.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
- **Over HTTP.** `managedprincipal.NewHandler` serves it as
`POST /{principalID}/login-tokens/replace`, with the issue body. It answers
201 with the new token and `revoked`, the number of tokens it revoked.
`mpclient.Client.ReplaceLoginTokens` calls it.
- **Audited once.** A replace emits `managed_principal.login_tokens_replaced`
with the new token's identifiers and the number revoked.
- **Checked under the lock.** Issuing and replacing now check the issuer's
authority under the managed principal's row lock, in the transaction that
issues, as removing a manager does. A manager removed at the same moment
can no longer be issued a token, which `IssueLoginToken` previously
allowed.

Tokens are stored only as hashes, so a lost or shared card cannot be printed
again from what is stored; replacing it is how a card is reprinted.

## Sign-in methods

- **What is linked.** `/account/sign-in-methods` shows the account's email
and the providers already linked to it, and offers a **Link** button only
for the others. Directories list linked providers through the optional
`LinkedProviderLister` interface, which the Ent and memory directories
implement; with another directory the page offers every provider, as
before.
- **Back to the application.** An application links to the page with
`return_to`, such as
`https://auth.example.com/account/sign-in-methods?return_to=https://app.example.com/settings`.
It passes the same allowlist as sign-in's `return_to` (a path on the
issuer, or an origin in `allowed_redirect_origins`) and shows a
**Back to** link, named by `signed_out_name` or else its host. It is kept
through signing in first, the link button, the provider and the return to
the page, and validated again at each step. An invalid `return_to` is
ignored.

The release adds no breaking change.

## Upgrading

Nothing is required. Applications that print login cards can offer "Replace
card" with the new call instead of revoking each token and issuing a new
one. Applications can link their account settings to
`/account/sign-in-methods?return_to=<their settings page>`.

See [Replacing a Card](../identity/managed-principals.md#replacing-a-card) and
[Linking sign-in methods](../systemauth/social-login.md#linking-sign-in-methods).
2 changes: 1 addition & 1 deletion docs/specs/initiatives/INIT-SYSTEMFORGE-007/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,5 @@ implementing an item carry `Refs: RMI-SYSTEMFORGE-<NNN>`.

**Theme:** A lost card is retired and replaced in one step

- [ ] `RMI-SYSTEMFORGE-222` Replace a managed principal's login tokens in one call: revoke every live token and issue one, atomically, in the service, the handler and `mpclient`
- [x] `RMI-SYSTEMFORGE-222` Replace a managed principal's login tokens in one call: revoke every live token and issue one, atomically, in the service, the handler and `mpclient`
- Depends on: `RMI-SYSTEMFORGE-108`
Loading
Loading