@optima-chat/agentic-auth
v0.9.0
Published
Optional auth module for Optima chat - OAuth, token management, authenticated HTTP client
Readme
@optima-chat/agentic-auth
Optional auth module for Optima chat — OAuth flow helpers, JWT token management, and an authenticated fetch wrapper.
Install
pnpm add @optima-chat/agentic-authAlso available via npm install @optima-chat/agentic-auth or yarn add @optima-chat/agentic-auth.
Quickstart
import {
TokenManager,
OAuthClient,
createOAuth2RefreshFn,
} from '@optima-chat/agentic-auth';
const tokenManager = new TokenManager({
refreshTokens: createOAuth2RefreshFn({
tokenUrl: 'https://auth.example.com/api/v1/oauth/token',
clientId: 'my-app',
}),
});
const client = new OAuthClient({
tokenManager,
authBaseUrl: 'https://auth.example.com',
clientId: 'my-app',
redirectUri: 'https://app.example.com/callback',
});
await client.sendEmailCode('[email protected]');
await client.verifyEmailCode('[email protected]', '123456');Multi-tab safety
TokenManager (in its default localStorage mode) is safe to run in several
tabs — or several instances — that share the same storage keys. Storage is the
shared source of truth:
- Re-read before refresh. A refresh re-reads storage first. If another tab already installed a fresh access token, it is adopted without a network call (memory follows storage; nothing is written back). Otherwise the refresh uses the refresh token on disk when there is one (never a stale in-memory copy when a newer one has been persisted) — the stale replay that makes rotation-aware auth servers revoke the whole token family.
- One refresh at a time, across tabs. The refresh runs under an exclusive
Web Lock (
optima-auth:refresh:<refreshKey>); concurrent tabs make a single request and the rest adopt its result. If the lock cannot be obtained within 30s (ornavigator.locksis unavailable), the refresh proceeds without it. - Live sync. Another tab's rotation reaches this instance through the
storageevent: memory is updated and subscribers are notified with the new token (so a WebSocket session can be handedupdate_token). - Logout elsewhere. When another tab removes the access token, this
instance clears its memory and calls
onTokensCleared— route the user to your login screen there. A refresh that was in flight at that moment does not write its result back (it rejects viaonRefreshFailed).
const tokenManager = new TokenManager({
refreshTokens,
onTokensCleared: () => {
// logged out in another tab
window.location.href = '/login';
},
});
// Only needed if you construct TokenManagers repeatedly (tests, HMR):
tokenManager.dispose(); // removes the `storage` listenerSubscriber contract: a subscribe() listener must not force a refresh
(getAccessToken(true)) in response to a notification — the token it was just
handed is the fresh one, and a forced mint per notification would rotate on
every tab in turn. Read it plainly (getAccessToken()).
storage: 'memory' keeps tokens private to the instance; none of the above
applies.
Per-endpoint proxying
Each entry in authEndpoints can be either:
- A string path, which is prepended to
authBaseUrl. Use this for endpoints hosted by the auth service directly. { path, raw: true }(orraw(path)), which is used as-is —authBaseUrlis skipped. Use this when the consumer app hosts a same-origin proxy route (e.g. a Next.js/api/auth/*route adding rate-limiting / i18n error wrapping) or the endpoint lives on a different host.
import { OAuthClient, raw } from '@optima-chat/agentic-auth';
const client = new OAuthClient({
tokenManager,
authBaseUrl: 'https://auth.example.com',
clientId: 'my-app',
redirectUri: 'https://app.example.com/callback',
authEndpoints: {
sendEmailCode: raw('/api/auth/email/send-code'),
verifyEmailCode: raw('/api/auth/email/verify'),
// socialAuthorize, revoke default to /api/v1/... on authBaseUrl
},
});Passing a fully qualified URL (https://...) as a bare string now throws — use raw() for that case.
Social login CSRF protection (strict by default)
socialLogin() always sends a state parameter and a PKCE code_challenge (S256, RFC 7636), and persists the state and the matching code_verifier in sessionStorage before the redirect (best-effort: if sessionStorage is unavailable the redirect still happens, and the callback is rejected later for lack of a persisted state / verifier). handleCallback() reads and clears that state (single-use), rejecting any callback whose state doesn't match. If you don't supply an explicit state, a cryptographically-random one is generated.
socialLogin() is async: it returns a Promise and reports every failure as a rejection, so await it (or attach .catch). It needs crypto.subtle, i.e. a secure context (https, or localhost / 127.0.0.1); anywhere else it rejects without redirecting.
// SDK-initiated flow — CSRF validation is automatic
await client.socialLogin('google'); // persists state + verifier, redirects
await client.handleCallback(window.location.href); // validates + sets tokens
// Explicit state — it must be an unpredictable random value
await client.socialLogin('google', crypto.randomUUID());state must be an unpredictable random value: it is what ties the callback to the login this tab started. Keep application context such as a return-to path somewhere else (for example your own sessionStorage entry), not in state.
Strict default: if no state was persisted (e.g. an attacker crafted a callback URL for a victim who never clicked social login), handleCallback throws. This is the point of CSRF protection — without it, the attacker can force login as an attacker-controlled identity.
// OAuth flow initiated OUTSIDE this SDK and CSRF enforced elsewhere
// (e.g. server-assigned state via HTTP-only cookie). Opt-out explicitly:
await client.handleCallback(url, { requireState: false });Callback shapes: tokens or a one-time code
The auth service answers the redirect in one of two shapes. It decides which one per client; handleCallback() accepts both.
- Tokens —
?access_token=...&refresh_token=...&state=.... The state is validated and the tokens are stored, as before. - A one-time code —
?code=...&state=.... The code is exchanged for tokens together with the PKCE verifier. The auth service's token endpoint cannot be called from the browser cross-origin, so the exchange goes through a backend route of your own, configured asauthEndpoints.socialExchange(there is no default):
import { OAuthClient, raw } from '@optima-chat/agentic-auth';
const client = new OAuthClient({
// ...
authEndpoints: {
// POST, JSON { code, code_verifier, client_id, redirect_uri } → snake_case tokens
socialExchange: raw('/api/auth/social/exchange'),
},
});
// Let handleCallback do everything (exchange, then store the tokens) …
await client.handleCallback(window.location.href);
// … or exchange yourself and decide when to store the tokens:
const { accessToken, refreshToken } = await client.exchangeSocialCode({ code, state });exchangeSocialCode() requires the callback state to equal the persisted one (requireState: false has no effect on code callbacks), removes the persisted state and verifier as soon as it is called (single-use, also when it rejects), sends one request (no retry, 15 s timeout covering the response body) and returns the tokens without storing them. Errors from fetch itself (network failure, or the timeout's AbortError while waiting for the response headers) are passed through unchanged; a timeout while the response body is still being read surfaces as exchangeSocialCode failed: <status> or exchangeSocialCode: invalid response instead. The errors it raises itself never contain the code, the verifier, the state, a token or response text — an upstream failure is reported with the HTTP status and, at most, a well-formed upstream error code.
Follows RFC 6749 §10.12 and RFC 7636.
