Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .changeset/hungry-donkeys-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@tailor-platform/app-shell": patch
---

Fix sessions hanging permanently after the server rejects the grant, by raising `@tailor-platform/auth-public-client` to `^0.6.0`.

When a refresh token expired or was revoked, the auth client kept `isAuthenticated` true and reattached the dead token to every request, so apps sat in a permanent `{"errors":[{"message":"unauthorized","type":"Gateway"}]}` loop that only a manual IndexedDB clear recovered from. Any token-endpoint rejection now ends the session (`use_dpop_nonce` excepted), emitting `logout` and `auth_state_changed`. `AuthProvider` responds as it already does for a signed-out user: `guardComponent` renders, and with `autoLogin` the app redirects to sign-in. A timeout, a network failure, and a plain 5xx still leave the session intact. Note that the boundary is the response shape rather than the underlying cause: any 4xx carrying an `error` code ends the session, so a token endpoint that reports overload as `400 {"error":"server_error"}` will sign users out.

One behaviour change worth checking even though this is a patch: `fetch` and `getAuthHeaders` no longer throw `Error("No valid access token")` when a refresh is rejected — they throw the underlying error. If your app detects dead sessions by matching that message, it has stopped detecting them; listen for `logout` / `auth_state_changed` instead, which fire on exactly that condition. Grepping the installed package for the string will not tell you whether you are affected, because the throw still exists for the genuinely-no-token case — check your own error handling.
39 changes: 34 additions & 5 deletions docs/concepts/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ const authClient = createAuthClient({

### Using `authClient.fetch` with a GraphQL Client

Pass `authClient.fetch` directly to your GraphQL client (e.g., urql). It transparently handles DPoP proof generation and token refresh on every request:
Pass `authClient.fetch` directly to your GraphQL client (e.g., urql). It transparently handles DPoP proof generation and token refresh on every request. If the server rejects the grant outright, it ends the session rather than replaying a dead token — see [Session expiry](#session-expiry):

```tsx
import { createAuthClient, AuthProvider } from "@tailor-platform/app-shell";
Expand Down Expand Up @@ -204,10 +204,10 @@ function ChatScreen() {

### `EnhancedAuthClient` Methods

| Method / Property | Type | Description |
| ----------------- | -------------- | ------------------------------------------------------------------------- |
| `getAppUri()` | `() => string` | Returns the `appUri` used to create this client |
| `fetch` | `typeof fetch` | Authenticated fetch with built-in DPoP proof generation and token refresh |
| Method / Property | Type | Description |
| ----------------- | -------------- | ---------------------------------------------------------------------------------------------------------------- |
| `getAppUri()` | `() => string` | Returns the `appUri` used to create this client |
| `fetch` | `typeof fetch` | Authenticated fetch with built-in DPoP proof generation, token refresh, and session teardown on a rejected grant |

## `AuthProvider` Props

Expand All @@ -224,6 +224,35 @@ The authentication provider works seamlessly with AppShell's data layer, automat
- OAuth2 token management
- Authenticated fetch with DPoP proof generation
- Session persistence and token refresh
- Session teardown when the server rejects the grant
- Automatic redirects for protected routes (via `autoLogin`)

OAuth callback parameters (`code`, `state`) are automatically cleaned from the URL after a successful login — no dedicated callback page is needed.

## Session expiry

Access tokens are refreshed transparently, so a session normally outlives individual token lifetimes without the app doing anything.

When a refresh cannot succeed — the refresh token has expired, been revoked, or is otherwise rejected by the token endpoint — the auth client ends the session instead of retrying indefinitely. It clears stored tokens, sets `isAuthenticated` to `false`, and emits `logout` followed by `auth_state_changed`.

What the user sees follows from the props you already pass:

- With `autoLogin`, `AuthProvider` redirects to sign-in.
- With `guardComponent`, the guard renders in place of your app.
- With neither, `useAuth().isAuthenticated` flips to `false` and your own UI decides.

Not every failed refresh ends the session. A request timeout, a network failure, and a plain 5xx from the token endpoint all leave it intact, so a dropped connection does not sign users out.

The line is drawn on the shape of the response, not on how transient the underlying cause is. Any rejection the token endpoint returns as a 4xx carrying an `error` code ends the session — the sole exception is `use_dpop_nonce`. A server that reports overload as `400 {"error":"server_error"}` or `400 {"error":"temporarily_unavailable"}` will therefore sign users out, as will a 5xx that carries a `WWW-Authenticate` header. If you operate the token endpoint, prefer a bare 5xx for conditions you want treated as retryable.

To react to teardown yourself — clearing app-level caches, for instance — subscribe to the client's events:

```tsx
useEffect(() => {
return authClient.addEventListener((event) => {
if (event.type === "logout") {
clearAppCaches();
}
});
}, [authClient]);
```
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"@internationalized/date": "3.12.2",
"@standard-schema/spec": "^1.1.0",
"@tailor-platform/app-shell-vite-plugin": "workspace:*",
"@tailor-platform/auth-public-client": "^0.5.1",
"@tailor-platform/auth-public-client": "^0.6.0",
"change-case": "^5.4.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
64 changes: 64 additions & 0 deletions packages/core/src/contexts/auth-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,70 @@ describe("AuthProvider", () => {
});
});

it("should drive auto-login from auth_state_changed only, not from logout", async () => {
// auth-public-client 0.6.0 tears a server-rejected session down itself,
// emitting `logout` immediately followed by `auth_state_changed`. Only the
// latter may drive auto-login: if `logout` drove it too, the pair would
// race two redirects. Asserting `logout` alone is inert is what makes this
// test discriminating — asserting only the final count would still pass
// with the event filter removed, because loginInFlightRef would absorb
// the duplicate.
let authEventListener: ((event: { type: string; data?: unknown }) => void) | undefined;

const mockAddEventListener = vi.fn(
(listener: (event: { type: string; data?: unknown }) => void) => {
authEventListener = listener;
return () => {};
},
);

let currentState = {
isAuthenticated: true,
error: null as string | null,
isReady: true,
};

const mockLogin = vi.fn().mockResolvedValue(undefined);
const mockClient = createMockAuthClient(undefined, {
login: mockLogin,
addEventListener: mockAddEventListener,
getState: vi.fn(() => currentState),
});

render(
<AuthProvider client={mockClient} autoLogin={true}>
<div>Content</div>
</AuthProvider>,
);

await waitFor(() => {
expect(mockLogin).not.toHaveBeenCalled();
});

// logoutImpl resets to ready + unauthenticated before it emits anything,
// so the state is already login-eligible when `logout` arrives.
currentState = {
isAuthenticated: false,
error: null,
isReady: true,
};

await act(async () => {
authEventListener?.({ type: "logout", data: currentState });
});

// `logout` must not be what triggers the redirect.
expect(mockLogin).not.toHaveBeenCalled();

await act(async () => {
authEventListener?.({ type: "auth_state_changed", data: currentState });
});

await waitFor(() => {
expect(mockLogin).toHaveBeenCalledTimes(1);
});
});

it("should not login when autoLogin is false", async () => {
const state = {
isAuthenticated: false,
Expand Down
16 changes: 8 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading