@optima-chat/agentic-auth
v0.7.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 persists it in sessionStorage before the redirect. 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.
// SDK-initiated flow — CSRF validation is automatic
client.socialLogin('google'); // persists state, redirects
await client.handleCallback(window.location.href); // validates + sets tokens
// Explicit state if you want to carry context through the redirect
client.socialLogin('google', 'return-to=/settings');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 });Follows RFC 6749 §10.12.
