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
13 changes: 13 additions & 0 deletions decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,3 +315,16 @@ Architectural and design decisions for the oidc-js project. Each entry captures
**Decision**: Option 3. `trackTraffic(page)` now returns `{ requests(), navigations(), sequence() }`. The sequence log records fetch requests as `GET /path` or `POST /path` and navigations as `NAV /path`, in the order Playwright observes them. All three are asserted in tests that check traffic.

**Rationale**: The combined sequence is the source of truth for protocol ordering. The separate `requests()` and `navigations()` remain as convenience filters for tests that only need to check "which fetches happened" without reasoning about navigation interleaving. The assumed race condition from decision 023 turned out to be incorrect. Playwright's `page.on("request")` fires deterministically for both fetch and document requests, and the combined log is stable across runs. Named constants (`GET_WELLKNOWN`, `POST_TOKEN`, `GET_USERINFO`, `NAV_AUTHORIZE`, `NAV_LOGOUT`) make the sequence assertions read like a protocol spec.

### 027 - Proactive token refresh via setInterval polling (2026-05-08)

**Context**: Token expiration was only detected reactively — by `RequireAuth` at render time or by HTTP interceptors catching 401s. This meant consumers using plain `fetch` had to manually check for 401 on every request, and `RequireAuth` briefly unmounted children when it detected expiration (losing form state).

**Alternatives considered**:
1. Single `setTimeout` scheduled for `expiresAt - buffer` — simple but unreliable (browsers throttle background tabs, laptops sleep, timer fires late or not at all)
2. `setInterval` polling with a short interval (e.g. 10s) that checks `Date.now() >= expiresAt - expiryBuffer` each tick
3. Service Worker or `BroadcastChannel` based approach — complex, not universally supported

**Decision**: Option 2. A `setInterval` in `OidcClient` polls every `autoRefreshInterval` seconds (default 10) and triggers `refresh()` when `isExpiredAt(expiresAt, expiryBuffer)` returns true. Enabled by default (`autoRefresh: true`). On refresh failure, the interval stops (no endpoint hammering); a successful manual refresh or new login restarts it.

**Rationale**: This is the same approach `oidc-client-ts` uses (their `Timer` class polls every 5s). A short interval is drift-proof (always compares real time), sleep-proof (first tick after wake catches expiration), and cheap (one number comparison per tick). The implementation lives in `OidcClient`, so all framework adapters benefit via the existing subscribe/notify mechanism. The `expiryBuffer` config (already existed) controls how early the refresh fires — the token is still valid during the refresh request, so `RequireAuth` never sees an expired token and children stay mounted.
1 change: 0 additions & 1 deletion docs-web/src/content/docs/angular/provide-auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ bootstrapApplication(AppComponent, {
| Option | Type | Default | Description |
|---|---|---|---|
| `config` | `OidcConfig` | **required** | OIDC configuration (issuer, clientId, redirectUri, etc.) |
| `fetchProfile` | `boolean` | `true` | Whether to fetch the UserInfo endpoint after login |
| `onLogin` | `(returnTo: string) => void` | - | Called after successful login with the URL to restore |
| `onError` | `(error: Error) => void` | - | Called when an error occurs during initialization |

Expand Down
41 changes: 39 additions & 2 deletions docs-web/src/content/docs/guides/token-refresh.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,42 @@ sequenceDiagram
OidcClient->>App: Render protected content
```

## Automatic refresh with RequireAuth
## Proactive refresh

`RequireAuth` handles refresh automatically. When a user navigates to a protected route with an expired token:
By default, `OidcClient` proactively refreshes the access token *before* it expires using a short `setInterval` poll. Every tick compares the current time against `expiresAt - expiryBuffer` and triggers a refresh when the token is about to expire. This means the token is always fresh — no 401s, no interceptors needed, no fallback flash.

This is enabled by default. You can configure the behavior with two options:

```ts
const config = {
issuer: "https://auth.example.com",
clientId: "my-app",
redirectUri: "http://localhost:5173/callback",
expiryBuffer: 60, // refresh 60s before expiry (default: 30)
autoRefresh: true, // enabled by default
autoRefreshInterval: 10, // polling interval in seconds (default: 10)
};
```

The polling interval is lightweight — one number comparison per tick, no re-renders or network calls until a refresh is actually needed. The approach is drift-proof and sleep-proof: after a laptop wakes from sleep, the very first tick catches the expiration.

<Aside type="tip">
If the refresh request fails (e.g. network error), the polling stops to avoid hammering the token endpoint. The auth state stays authenticated with the current token. A subsequent successful manual refresh (or a new login) restarts the polling automatically.
</Aside>

### Disabling proactive refresh

Set `autoRefresh: false` to rely on reactive mechanisms instead (interceptors, `RequireAuth` re-mount):

```tsx
<AuthProvider config={{ ...config, autoRefresh: false }}>
<App />
</AuthProvider>
```

## Reactive refresh with RequireAuth

`RequireAuth` handles refresh reactively. When a user navigates to a protected route with an expired token:

1. Checks `tokens.expiresAt` using `isExpiredAt()` from core (with an optional buffer)
2. If expired, calls `actions.refresh()`
Expand All @@ -48,6 +81,10 @@ You can set an `expiryBuffer` (in seconds) on the provider config to refresh the
</AuthProvider>
```

<Aside type="note">
With proactive refresh enabled (the default), `RequireAuth` rarely needs to trigger a refresh because the token is typically renewed before it expires. It serves as a safety net for edge cases like initial page load with a stale token.
</Aside>

## Manual refresh

You can also trigger a refresh manually:
Expand Down
2 changes: 1 addition & 1 deletion docs-web/src/content/docs/guides/user-profile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ user?.profile?.preferred_username;
By default, `AuthProvider` fetches the user profile after login. You can disable this:

```tsx
<AuthProvider config={config} fetchProfile={false}>
<AuthProvider config={{ ...config, fetchProfile: false }}>
<App />
</AuthProvider>
```
Expand Down
1 change: 0 additions & 1 deletion docs-web/src/content/docs/lit/auth-controller.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ customElements.define("my-app", MyApp);
| Option | Type | Default | Description |
|---|---|---|---|
| `config` | `OidcConfig` | **required** | OIDC configuration (issuer, clientId, redirectUri, etc.) |
| `fetchProfile` | `boolean` | `true` | Whether to fetch the UserInfo endpoint after login |
| `onLogin` | `(returnTo: string) => void` | - | Called after successful login with the URL to restore |
| `onError` | `(error: Error) => void` | - | Called when an error occurs during initialization |

Expand Down
1 change: 0 additions & 1 deletion docs-web/src/content/docs/preact/auth-provider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ function App() {
| Prop | Type | Default | Description |
|---|---|---|---|
| `config` | `OidcConfig` | **required** | OIDC configuration (issuer, clientId, redirectUri, etc.) |
| `fetchProfile` | `boolean` | `true` | Whether to fetch the UserInfo endpoint after login |
| `onLogin` | `(returnTo: string) => void` | - | Called after successful login with the URL to restore |
| `onError` | `(error: Error) => void` | - | Called when an error occurs during initialization |
| `children` | `ComponentChildren` | **required** | Child components |
Expand Down
1 change: 0 additions & 1 deletion docs-web/src/content/docs/react/auth-provider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ function App() {
| Prop | Type | Default | Description |
|---|---|---|---|
| `config` | `OidcConfig` | **required** | OIDC configuration (issuer, clientId, redirectUri, etc.) |
| `fetchProfile` | `boolean` | `true` | Whether to fetch the UserInfo endpoint after login |
| `onLogin` | `(returnTo: string) => void` | — | Called after successful login with the URL to restore |
| `onError` | `(error: Error) => void` | — | Called when an error occurs during initialization |
| `children` | `ReactNode` | **required** | Child components |
Expand Down
2 changes: 1 addition & 1 deletion docs-web/src/content/docs/react/use-auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ interface AuthUser {
}
```

`null` when not authenticated. After login, `claims` is always populated from the ID token. `profile` is populated from the UserInfo endpoint if `fetchProfile` is `true` (default).
`null` when not authenticated. After login, `claims` is always populated from the ID token. `profile` is populated from the UserInfo endpoint if `config.fetchProfile` is `true` (default).

### tokens

Expand Down
1 change: 0 additions & 1 deletion docs-web/src/content/docs/solid/auth-provider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ function App() {
| Prop | Type | Default | Description |
|---|---|---|---|
| `config` | `OidcConfig` | **required** | OIDC configuration (issuer, clientId, redirectUri, etc.) |
| `fetchProfile` | `boolean` | `true` | Whether to fetch the UserInfo endpoint after login |
| `onLogin` | `(returnTo: string) => void` | - | Called after successful login with the URL to restore |
| `onError` | `(error: Error) => void` | - | Called when an error occurs during initialization |
| `children` | `JSX.Element` | **required** | Child components |
Expand Down
1 change: 0 additions & 1 deletion docs-web/src/content/docs/svelte/auth-provider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ npm install oidc-js-svelte
| Prop | Type | Default | Description |
|---|---|---|---|
| `config` | `OidcConfig` | **required** | OIDC configuration (issuer, clientId, redirectUri, etc.) |
| `fetchProfile` | `boolean` | `true` | Whether to fetch the UserInfo endpoint after login |
| `onLogin` | `(returnTo: string) => void` | - | Called after successful login with the URL to restore |
| `onError` | `(error: Error) => void` | - | Called when an error occurs during initialization |
| `children` | `Snippet` | **required** | Child content to render |
Expand Down
1 change: 0 additions & 1 deletion docs-web/src/content/docs/vue/plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ app.mount("#app");
| Option | Type | Default | Description |
|---|---|---|---|
| `config` | `OidcConfig` | **required** | OIDC configuration (issuer, clientId, redirectUri, etc.) |
| `fetchProfile` | `boolean` | `true` | Whether to fetch the UserInfo endpoint after login |
| `onLogin` | `(returnTo: string) => void` | - | Called after successful login with the URL to restore |
| `onError` | `(error: Error) => void` | - | Called when an error occurs during initialization |

Expand Down
2 changes: 1 addition & 1 deletion packages/angular/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "oidc-js-angular",
"version": "1.1.0",
"version": "1.1.2",
"description": "Simple OIDC authentication for Angular. Signals, DI, and route guards with zero dependencies.",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
5 changes: 1 addition & 4 deletions packages/angular/src/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,7 @@ export class AuthService {
this.router = inject(Router);
const destroyRef = inject(DestroyRef);

this.client = new OidcClient({
...this.options.config,
fetchProfile: this.options.fetchProfile,
});
this.client = new OidcClient(this.options.config);

const unsub = this.client.subscribe((state: AuthState) => {
this._user.set(state.user);
Expand Down
1 change: 1 addition & 0 deletions packages/angular/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export type {
LoginOptions,
} from "./types.js";

export type { OidcClientConfig } from "oidc-js";
export type { OidcConfig, OidcUser, TokenSet } from "oidc-js-core";
3 changes: 1 addition & 2 deletions packages/angular/src/tests/auth.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ const CONFIG = {
};

const mockOptions: Record<string, unknown> = {
config: CONFIG,
fetchProfile: true,
config: { ...CONFIG, fetchProfile: true },
};

vi.mock("@angular/core", () => {
Expand Down
8 changes: 3 additions & 5 deletions packages/angular/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { OidcConfig } from "oidc-js-core";

export type { IdTokenClaims, AuthUser, AuthTokens, LoginOptions } from "oidc-js";

import type { OidcClientConfig } from "oidc-js";

/**
* Configuration options for {@link provideAuth}.
*
Expand All @@ -10,9 +10,7 @@ export type { IdTokenClaims, AuthUser, AuthTokens, LoginOptions } from "oidc-js"
*/
export interface AuthProviderOptions {
/** Core OIDC configuration (issuer, clientId, redirectUri, scopes, etc.). */
config: OidcConfig;
/** Whether to fetch the userinfo profile after token exchange. Defaults to `true`. */
fetchProfile?: boolean;
config: OidcClientConfig;
/**
* Called after a successful login callback with the `returnTo` path.
* If not provided, the adapter uses Angular's `Router.navigateByUrl` to navigate.
Expand Down
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "oidc-js",
"version": "1.1.0",
"version": "1.1.2",
"description": "Simple OIDC authentication for JavaScript. Drop-in client with login, logout, token refresh, and zero dependencies.",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
31 changes: 31 additions & 0 deletions packages/client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
parseUserinfoResponse,
buildLogoutUrl,
decodeJwtPayload,
isExpiredAt,
DEFAULT_EXPIRY_BUFFER,
type OidcDiscovery,
type OidcUser,
Expand Down Expand Up @@ -65,6 +66,7 @@ export class OidcClient {
private subscribers = new Set<Subscriber>();
private abortController: AbortController | null = null;
private refreshPromise: Promise<AuthTokens> | null = null;
private autoRefreshTimer: ReturnType<typeof setInterval> | null = null;

private _state: AuthState = {
user: null,
Expand Down Expand Up @@ -178,6 +180,8 @@ export class OidcClient {
isLoading: false,
});

this.startAutoRefresh();

return { returnTo };
}

Expand Down Expand Up @@ -228,6 +232,7 @@ export class OidcClient {
* with the current ID token hint and `postLogoutRedirectUri` from config.
*/
logout(): void {
this.stopAutoRefresh();
const idToken = this._state.tokens.id;

this.setState({
Expand Down Expand Up @@ -305,6 +310,8 @@ export class OidcClient {
error: null,
});

this.startAutoRefresh();

return newTokens;
}

Expand All @@ -331,10 +338,34 @@ export class OidcClient {
* Tears down the client by aborting any in-flight requests and removing all subscribers.
*/
destroy(): void {
this.stopAutoRefresh();
this.abortController?.abort();
this.subscribers.clear();
}

private startAutoRefresh(): void {
if (this.config.autoRefresh === false) return;
if (this.autoRefreshTimer) return;

const intervalSeconds = this.config.autoRefreshInterval ?? 10;
const buffer = this.config.expiryBuffer ?? DEFAULT_EXPIRY_BUFFER;

this.autoRefreshTimer = setInterval(() => {
const { expiresAt } = this._state.tokens;
if (!this._state.isAuthenticated || !this._state.tokens.refresh) return;
if (!isExpiredAt(expiresAt, buffer)) return;

this.refresh().catch(() => { this.stopAutoRefresh(); });
}, intervalSeconds * 1000);
}

private stopAutoRefresh(): void {
if (this.autoRefreshTimer) {
clearInterval(this.autoRefreshTimer);
this.autoRefreshTimer = null;
}
}

/**
* Internal helper that calls the userinfo endpoint and parses the response.
*
Expand Down
Loading
Loading