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
34 changes: 34 additions & 0 deletions CHANGELOG.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,40 @@
"commitConvention": "conventional",
"maintainers": ["grokify", "johncwang@gmail.com"],
"releases": [
{
"version": "v0.5.1",
"date": "2026-11-02",
"highlights": [
{
"description": "`@plexusone/auth` 0.2.2: signing out also ends the SystemAuth login session, so the next sign-in asks who the person is instead of resuming it",
"commit": "c9661d5"
}
],
"security": [
{
"description": "`signOut` and `logout` follow the BFF's SystemAuth sign-out with a form POST, so a person who signs out, for example on a shared computer, is no longer signed straight back in by the still-open login session",
"commit": "c9661d5"
}
],
"added": [
{
"description": "`endSession` on `SessionProvider` and `AuthProvider`, and the exported `readSignOut`, `endLoginSession` and `SystemAuthSignOut`",
"commit": "c9661d5"
}
],
"tests": [
{
"description": "Cover the sign-out target, the form post, the session client's JSON sign-out and the provider leaving for SystemAuth",
"commit": "4290823"
}
],
"documentation": [
{
"description": "The auth package's guide describes signing out of SystemAuth too, and its requirements",
"commit": "0415449"
}
]
},
{
"version": "v0.5.0",
"date": "2026-10-26",
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.5.1] - 2026-11-02

### Highlights

- `@plexusone/auth` 0.2.2: signing out also ends the SystemAuth login session, so the next sign-in asks who the person is instead of resuming it

### Security

- `signOut` and `logout` follow the BFF's SystemAuth sign-out with a form POST, so a person who signs out, for example on a shared computer, is no longer signed straight back in by the still-open login session ([`c9661d5`](https://github.com/plexusone/systemforge-web/commit/c9661d5))

### Added

- `endSession` on `SessionProvider` and `AuthProvider`, and the exported `readSignOut`, `endLoginSession` and `SystemAuthSignOut` ([`c9661d5`](https://github.com/plexusone/systemforge-web/commit/c9661d5))

### Documentation

- The auth package's guide describes signing out of SystemAuth too, and its requirements ([`0415449`](https://github.com/plexusone/systemforge-web/commit/0415449))

### Tests

- Cover the sign-out target, the form post, the session client's JSON sign-out and the provider leaving for SystemAuth ([`4290823`](https://github.com/plexusone/systemforge-web/commit/4290823))

## [v0.5.0] - 2026-10-26

### Highlights
Expand Down Expand Up @@ -255,7 +277,8 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro

- Shared pnpm workflows from grokify/.github ([`bb07c1d`](https://github.com/plexusone/systemforge-web/commit/bb07c1d))

[unreleased]: https://github.com/plexusone/systemforge-web/compare/v0.5.0...HEAD
[unreleased]: https://github.com/plexusone/systemforge-web/compare/v0.5.1...HEAD
[v0.5.1]: https://github.com/plexusone/systemforge-web/compare/v0.5.0...v0.5.1
[v0.5.0]: https://github.com/plexusone/systemforge-web/compare/v0.4.0...v0.5.0
[v0.4.0]: https://github.com/plexusone/systemforge-web/compare/v0.3.1...v0.4.0
[v0.3.1]: https://github.com/plexusone/systemforge-web/compare/v0.3.0...v0.3.1
Expand Down
25 changes: 24 additions & 1 deletion docs/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.5.1] - 2026-11-02

### Highlights

- `@plexusone/auth` 0.2.2: signing out also ends the SystemAuth login session, so the next sign-in asks who the person is instead of resuming it

### Security

- `signOut` and `logout` follow the BFF's SystemAuth sign-out with a form POST, so a person who signs out, for example on a shared computer, is no longer signed straight back in by the still-open login session ([`c9661d5`](https://github.com/plexusone/systemforge-web/commit/c9661d5))

### Added

- `endSession` on `SessionProvider` and `AuthProvider`, and the exported `readSignOut`, `endLoginSession` and `SystemAuthSignOut` ([`c9661d5`](https://github.com/plexusone/systemforge-web/commit/c9661d5))

### Documentation

- The auth package's guide describes signing out of SystemAuth too, and its requirements ([`0415449`](https://github.com/plexusone/systemforge-web/commit/0415449))

### Tests

- Cover the sign-out target, the form post, the session client's JSON sign-out and the provider leaving for SystemAuth ([`4290823`](https://github.com/plexusone/systemforge-web/commit/4290823))

## [v0.5.0] - 2026-10-26

### Highlights
Expand Down Expand Up @@ -255,7 +277,8 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro

- Shared pnpm workflows from grokify/.github ([`bb07c1d`](https://github.com/plexusone/systemforge-web/commit/bb07c1d))

[unreleased]: https://github.com/plexusone/systemforge-web/compare/v0.5.0...HEAD
[unreleased]: https://github.com/plexusone/systemforge-web/compare/v0.5.1...HEAD
[v0.5.1]: https://github.com/plexusone/systemforge-web/compare/v0.5.0...v0.5.1
[v0.5.0]: https://github.com/plexusone/systemforge-web/compare/v0.4.0...v0.5.0
[v0.4.0]: https://github.com/plexusone/systemforge-web/compare/v0.3.1...v0.4.0
[v0.3.1]: https://github.com/plexusone/systemforge-web/compare/v0.3.0...v0.3.1
Expand Down
27 changes: 22 additions & 5 deletions docs/packages/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,15 +139,32 @@ The session is re-read when the window regains focus and every five
minutes (`refreshInterval`, `refreshOnFocus`). `onUnauthorized` runs when a
signed-in session is found signed out, not after a deliberate sign-out.

### Signing out of SystemAuth too

Signing out ends the app's session at the BFF, then SystemAuth's login
session, so the next sign-in asks who the person is instead of resuming
it. `signOut` (and `logout`) asks the BFF for JSON; a BFF on SystemForge
v0.30.1 or later answers with SystemAuth's sign-out page, and the provider
submits a form there with `return_to`. SystemAuth ends its login session,
revokes the person's tokens, ends their sessions in the other apps, and
returns the browser signed out. An older BFF answers `204`, and the page
stays as before.

`endSession` on `SessionProvider` or `AuthProvider` replaces that step;
`null` leaves it out. SystemAuth must list the app's origin in
`social_login.allowed_redirect_origins`, and the app and SystemAuth must
share a site, such as `app.example.com` and `auth.example.com`.

## API Reference

### AuthProvider Props

| Prop | Type | Description |
| ------------- | ------------------------ | ---------------------------------------------- |
| `bffBaseUrl` | `string` | Base URL for BFF auth endpoints |
| `loginPath` | `string` | Path to redirect for login (default: `/login`) |
| `onAuthError` | `(error: Error) => void` | Error callback |
| Prop | Type | Description |
| ------------- | ---------------------------- | ---------------------------------------------------------------------------------------- |
| `bffBaseUrl` | `string` | Base URL for BFF auth endpoints |
| `loginPath` | `string` | Path to redirect for login (default: `/login`) |
| `onAuthError` | `(error: Error) => void` | Error callback |
| `endSession` | `((target) => void) \| null` | Ends SystemAuth's login session after logout (default: a form POST to its sign-out page) |

### useAuth Return Value

Expand Down
37 changes: 37 additions & 0 deletions docs/releases/v0.5.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# v0.5.1 Release Notes

**Release Date:** 2026-11-02

A security fix in `@plexusone/auth` 0.2.2. The other packages are unchanged.

## Highlights

- **Signing out ends the SystemAuth login session too.** Signing out ended
the app's session at the BFF but left SystemAuth's login session, so the
next sign-in resumed it without asking, and a person who signed out, for
example on a shared computer, was signed straight back in. `signOut` and
`logout` now ask the BFF for JSON; when it names SystemAuth's sign-out
page, the provider submits a form there, and SystemAuth ends its login
session, revokes the person's tokens, ends their sessions in the other
apps and returns the browser signed out. See
[Signing out of SystemAuth too](../packages/auth.md#signing-out-of-systemauth-too).

## Requirements

- systemforge v0.30.1 or later names SystemAuth's sign-out page. An older
BFF answers `204`, and sign-out stays as before.
- SystemAuth lists the app's origin in `social_login.allowed_redirect_origins`.
- The app and SystemAuth share a site, such as `app.example.com` and
`auth.example.com`: SystemAuth's login cookie is `SameSite=Lax`, so a
browser does not send it on a cross-site `POST`.

## Installation

```bash
pnpm add @plexusone/auth@^0.2.2
```

## Upgrading

Nothing to change. `endSession` on `SessionProvider` or `AuthProvider`
replaces the form post, or `null` leaves it out.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ nav:
- Tasks: specs/initiatives/invention/productgraph/TASKS.md
- Releases:
- Changelog: changelog.md
- v0.5.1: releases/v0.5.1.md
- v0.5.0: releases/v0.5.0.md
- v0.4.0: releases/v0.4.0.md
- v0.3.1: releases/v0.3.1.md
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@plexusone/auth",
"version": "0.2.1",
"version": "0.2.2",
"description": "Authentication primitives for SystemForge applications",
"homepage": "https://plexusone.github.io/systemforge-web/packages/auth/",
"main": "./dist/index.js",
Expand Down
10 changes: 8 additions & 2 deletions packages/auth/src/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { endLoginSession } from './signout';
import {
createContext,
useContext,
Expand Down Expand Up @@ -57,6 +58,7 @@ export function AuthProvider({
onSessionExpired,
onAuthError,
checkInterval = 60000,
endSession = endLoginSession,
}: AuthProviderProps) {
// loginUrl and defaultRedirect are stored in config for other components
void _loginUrl;
Expand Down Expand Up @@ -181,17 +183,21 @@ export function AuthProvider({
const logout = useCallback(async () => {
setIsLoading(true);
try {
await client.logout();
const next = await client.logout();
setUser(null);
setError(null);
if (next && endSession) {
// SystemAuth ends its login session too, then returns here signed out.
endSession(next);
}
} catch (err) {
// Even if logout fails on server, clear local state
setUser(null);
handleError(err as AuthError);
} finally {
setIsLoading(false);
}
}, [client, handleError]);
}, [client, handleError, endSession]);

/**
* Refresh user data
Expand Down
49 changes: 49 additions & 0 deletions packages/auth/src/__tests__/signout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { endLoginSession, readSignOut } from '../signout';

const target = { url: 'https://auth.example.com/logout', returnTo: 'https://app.example.com/' };

describe('readSignOut', () => {
it('reads the sign-out target', () => {
expect(readSignOut({ signOut: target })).toEqual(target);
});

it('ignores a body that is not one', () => {
for (const body of [
undefined,
null,
'x',
{},
{ signOut: null },
{ signOut: { url: target.url } },
]) {
expect(readSignOut(body)).toBeUndefined();
}
});

it('refuses a URL that is not http or https', () => {
expect(
readSignOut({ signOut: { url: 'javascript:alert(1)', returnTo: target.returnTo } })
).toBeUndefined();
expect(readSignOut({ signOut: { url: target.url, returnTo: '/relative' } })).toBeUndefined();
});
});

describe('endLoginSession', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});

it('posts a form with return_to to the sign-out page', () => {
const submit = vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {});
endLoginSession(target);
const form = document.querySelector('form');
expect(form?.method).toBe('post');
expect(form?.action).toBe(target.url);
const field = form?.querySelector<HTMLInputElement>('input[name="return_to"]');
expect(field?.type).toBe('hidden');
expect(field?.value).toBe(target.returnTo);
expect(submit).toHaveBeenCalledOnce();
});
});
7 changes: 5 additions & 2 deletions packages/auth/src/bff-client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readSignOut, type SystemAuthSignOut } from './signout';
import type {
User,
SessionStatus,
Expand Down Expand Up @@ -88,10 +89,12 @@ export class BFFClient {
/**
* Logout current session
*/
async logout(): Promise<void> {
await this.request<void>('/bff/auth/logout', {
async logout(): Promise<SystemAuthSignOut | undefined> {
const body = await this.request<unknown>('/bff/auth/logout', {
method: 'POST',
headers: { Accept: 'application/json' },
});
return readSignOut(body);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export * from './session';

// BFF client (for advanced use cases)
export { BFFClient, defaultBFFClient } from './bff-client';
export { endLoginSession, readSignOut } from './signout';
export type { SystemAuthSignOut } from './signout';

// Types
export type {
Expand Down
17 changes: 15 additions & 2 deletions packages/auth/src/session/SessionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useState,
type ReactNode,
} from 'react';
import { endLoginSession, type SystemAuthSignOut } from '../signout';
import { SessionClient, type SignInOptions } from './client';
import type { Session } from './types';

Expand Down Expand Up @@ -42,6 +43,12 @@ export interface SessionProviderProps {
onUnauthorized?: () => void;
/** Navigation for sign-in, for tests. Default `window.location.assign`. */
navigate?: (url: string) => void;
/**
* Ends SystemAuth's login session after the app's sign-out, when the BFF
* names its sign-out page. Default: a form POST there, which navigates
* away; `null` stays on the page, signed out of the app only.
*/
endSession?: ((target: SystemAuthSignOut) => void) | null;
}

const SessionContext = createContext<SessionContextValue | null>(null);
Expand All @@ -59,6 +66,7 @@ export function SessionProvider({
refreshOnFocus = true,
onUnauthorized,
navigate,
endSession = endLoginSession,
}: SessionProviderProps) {
const bff = useMemo(() => client ?? new SessionClient(), [client]);
const [status, setStatus] = useState<SessionStatusKind>('loading');
Expand Down Expand Up @@ -111,11 +119,16 @@ export function SessionProvider({
);

const signOut = useCallback(async () => {
await bff.signOut();
const next = await bff.signOut();
// Signing out on purpose is not an unauthorized surprise.
wasAuthenticated.current = false;
if (next && endSession) {
// SystemAuth ends its login session too, then returns here signed out.
endSession(next);
return;
}
await refresh();
}, [bff, refresh]);
}, [bff, refresh, endSession]);

const value = useMemo<SessionContextValue>(
() => ({ status, session, error, refresh, signIn, signOut }),
Expand Down
Loading
Loading