Description
Headless authentication provider for the HAI3 framework — a pluggable auth() plugin that wires any AuthProvider implementation into the existing @hai3/api REST transport layer, automatically managing credentials, token refresh, and session lifecycle without coupling to any specific auth backend or UI framework.
Problem Statement
HAI3 applications currently have no standardized way to handle authentication. Each project implements its own ad-hoc solution for:
- Attaching credentials (Bearer tokens, cookies) to API requests
- Handling 401 responses and token refresh
- Managing session lifecycle (login, logout, refresh, destroy)
- Authorization checks (canAccess, permissions)
This leads to duplicated boilerplate, inconsistent error handling, and tight coupling between auth logic and UI components. There is no framework-level contract that separates session acquisition (how you log in) from session transport (how credentials reach the server).
Proposed Solution
A three-layer architecture:
1. @hai3/auth (new L1 SDK package) — headless contract, types only, zero dependencies:
AuthProvider interface with 3 required methods (getSession, checkAuth, logout) and 8 optional (login, refresh, handleCallback, getIdentity, getPermissions, canAccess, subscribe, destroy)
AuthSession discriminated by kind: bearer, cookie, custom — supporting different auth mechanisms through a single contract
AuthContext carries AbortSignal into every provider call for cancellation support
2. auth() framework plugin (in @hai3/framework) — transport binding:
- Registers
AuthRestPlugin as a global REST plugin via apiRegistry.plugins.add()
- Bearer: attaches
Authorization: Bearer <token> header on every request
- Cookie-session: sets
withCredentials: true + optional CSRF header for relative URLs and allowlisted origins
- 401 refresh+retry: on first 401, calls
provider.refresh(), deduplicates concurrent refresh calls into a single in-flight promise, retries original request with new credentials
- Custom transport: pass a
transport option to override the default binding entirely
- Exposes
app.auth runtime surface (getSession, login, logout, etc.) via a new generic provides.app + HAI3AppRuntimeExtensions mechanism
3. REST protocol enhancements (in @hai3/api):
RestRequestOptions (params, signal, withCredentials) with backward-compatible overloads
AbortSignal propagation through plugin context and axios; axios.isCancel bypasses onError chain
- Retry preserves plugin-modified request context correctly
Use Case / Example
- Bearer token (JWT/OAuth): SPA logs in via password or OAuth redirect, stores access+refresh tokens in memory,
auth() plugin attaches Bearer header and auto-refreshes on 401
- Cookie-session (SSR/CSRF): Server sets HttpOnly cookie, plugin enables
withCredentials and attaches CSRF token header for mutating requests
- Custom SSO: Enterprise SSO provider implements
AuthProvider.login() with SAML/OIDC redirect flow, handleCallback() processes the redirect response — no framework changes needed
- Authorization guards:
app.auth.canAccess({ action: 'edit', resource: 'invoice', record }) returns 'allow' or 'deny' — UI components use this for conditional rendering
- Preset integration:
createHAI3App({ auth: { provider } }) — single line to wire auth into the full preset
Affected Package(s)
New package:
Alternative Solutions
react-admin AuthProvider: considered as reference. Useful lifecycle methods, but tightly coupled to react-admin router semantics (redirectTo in every return value, useCheckAuth hook). HAI3 contract is router-agnostic — AuthTransition returns plain URL, no router semantics leak into the contract.
Transport-level only (no framework plugin): attaching tokens directly in service constructors. Rejected because it duplicates logic across services, doesn't handle 401 refresh globally, and forces each service to know about auth.
Middleware/interceptor approach: global axios interceptor. Rejected because it bypasses the HAI3 plugin architecture, doesn't support per-protocol plugin ordering, and makes testing harder.
Additional Context
HAI3AppRuntimeExtensions is a generic mechanism: any plugin can now expose typed APIs on app via provides.app + module augmentation — not auth-specific
- All provider methods accept optional
AuthContext with AbortSignal for request cancellation
- Concurrent 401 refresh is deduplicated: multiple parallel requests hitting 401 share a single in-flight
refresh() promise
provider.onTransportError() is called on every transport error (informational callback for logging/telemetry)
- Cookie-session
withCredentials is set per-request (not constructor-time), supporting mixed origin scenarios
- Test coverage: 27 auth plugin tests + 30 REST plugin integration tests + existing suite (123 total, all green)
Priority
Description
Headless authentication provider for the HAI3 framework — a pluggable
auth()plugin that wires anyAuthProviderimplementation into the existing@hai3/apiREST transport layer, automatically managing credentials, token refresh, and session lifecycle without coupling to any specific auth backend or UI framework.Problem Statement
HAI3 applications currently have no standardized way to handle authentication. Each project implements its own ad-hoc solution for:
This leads to duplicated boilerplate, inconsistent error handling, and tight coupling between auth logic and UI components. There is no framework-level contract that separates session acquisition (how you log in) from session transport (how credentials reach the server).
Proposed Solution
A three-layer architecture:
1.
@hai3/auth(new L1 SDK package) — headless contract, types only, zero dependencies:AuthProviderinterface with 3 required methods (getSession,checkAuth,logout) and 8 optional (login,refresh,handleCallback,getIdentity,getPermissions,canAccess,subscribe,destroy)AuthSessiondiscriminated bykind: bearer, cookie, custom — supporting different auth mechanisms through a single contractAuthContextcarriesAbortSignalinto every provider call for cancellation support2.
auth()framework plugin (in@hai3/framework) — transport binding:AuthRestPluginas a global REST plugin viaapiRegistry.plugins.add()Authorization: Bearer <token>header on every requestwithCredentials: true+ optional CSRF header for relative URLs and allowlisted originsprovider.refresh(), deduplicates concurrent refresh calls into a single in-flight promise, retries original request with new credentialstransportoption to override the default binding entirelyapp.authruntime surface (getSession, login, logout, etc.) via a new genericprovides.app+HAI3AppRuntimeExtensionsmechanism3. REST protocol enhancements (in
@hai3/api):RestRequestOptions(params, signal, withCredentials) with backward-compatible overloadsAbortSignalpropagation through plugin context and axios;axios.isCancelbypassesonErrorchainUse Case / Example
auth()plugin attaches Bearer header and auto-refreshes on 401withCredentialsand attaches CSRF token header for mutating requestsAuthProvider.login()with SAML/OIDC redirect flow,handleCallback()processes the redirect response — no framework changes neededapp.auth.canAccess({ action: 'edit', resource: 'invoice', record })returns'allow'or'deny'— UI components use this for conditional renderingcreateHAI3App({ auth: { provider } })— single line to wire auth into the full presetAffected Package(s)
New package:
Alternative Solutions
react-admin AuthProvider: considered as reference. Useful lifecycle methods, but tightly coupled to react-admin router semantics (
redirectToin every return value,useCheckAuthhook). HAI3 contract is router-agnostic —AuthTransitionreturns plain URL, no router semantics leak into the contract.Transport-level only (no framework plugin): attaching tokens directly in service constructors. Rejected because it duplicates logic across services, doesn't handle 401 refresh globally, and forces each service to know about auth.
Middleware/interceptor approach: global axios interceptor. Rejected because it bypasses the HAI3 plugin architecture, doesn't support per-protocol plugin ordering, and makes testing harder.
Additional Context
HAI3AppRuntimeExtensionsis a generic mechanism: any plugin can now expose typed APIs onappviaprovides.app+ module augmentation — not auth-specificAuthContextwithAbortSignalfor request cancellationrefresh()promiseprovider.onTransportError()is called on every transport error (informational callback for logging/telemetry)withCredentialsis set per-request (not constructor-time), supporting mixed origin scenariosPriority