@edwinlovett/ignite-auth
v1.4.0
Published
Consumer SDK for the Ignite Toolbox unified worker — identity + brand context. Core is framework-agnostic; React hooks are an opt-in /react subpath.
Readme
@edwinlovett/ignite-auth
Consumer SDK for the Ignite Toolbox unified worker — identity + brand context.
The core client is framework-agnostic. React hooks ship from a separate
@edwinlovett/ignite-auth/react subpath so non-React consumers don't pay the cost
of pulling React into their bundle.
Renamed from
@lovett/[email protected]. Hook names are preserved 1:1 — the workspace migration is import-path-only. The only behavioural changes are the defaultgatewayorigin (https://api.ignitetoolbelt.com) and the cookie name (ignite_session/ignite_refresh). See ADR-055 §D4.
Install
npm install @edwinlovett/ignite-authQuick start (vanilla)
import { createAuthClient } from "@edwinlovett/ignite-auth";
const auth = createAuthClient({
// Optional — defaults to https://api.ignitetoolbelt.com.
// Override during the C7 cutover window if you're pointing at a
// preview deployment.
gateway: "https://api.ignitetoolbelt.com",
});
const session = await auth.getSession();
if (!session) {
auth.redirectToLogin({ redirect: window.location.href });
} else {
console.log("signed in as", session.user.email);
}
// Subscribe to changes (token refresh, sign-out, etc.)
const unsubscribe = auth.subscribe((s) => console.log("session →", s));
// Force refresh on demand
await auth.refresh();
// Sign out
await auth.signOut();React
import { AuthProvider, useSession, useUser } from "@edwinlovett/ignite-auth/react";
import { createAuthClient } from "@edwinlovett/ignite-auth";
const auth = createAuthClient({ gateway: "https://api.ignitetoolbelt.com" });
export function App() {
return (
<AuthProvider client={auth}>
<Inner />
</AuthProvider>
);
}
function Inner() {
const { user, loading, signOut } = useSession();
if (loading) return null;
if (!user) return <a href="/login">Sign in</a>;
return (
<div>
Hello, {user.email}{" "}
<button onClick={() => signOut()}>Sign out</button>
</div>
);
}AuthProvider puts the client on context. useSession exposes
user, expiresAt, loading, signOut. useUser is shorthand for
useSession().user.
Brand-aware hooks (ADR-041)
import {
useActiveBrand,
useAvailableBrands,
useAvailableGroups,
useTaxonomies,
} from "@edwinlovett/ignite-auth/react";
function BrandPicker({ slug }: { slug?: string }) {
const brand = useActiveBrand(slug); // current brand (URL-driven)
const brands = useAvailableBrands(); // every brand the user can reach
const groups = useAvailableGroups(); // picker grouping metadata
const taxonomies = useTaxonomies(); // tenant-scoped tag dictionary
// ...
}The deprecated useActiveClient alias (ADR-041 D14) is also exported
and emits a dev-only console warning — migrate to useActiveBrand at
your leisure.
API
createAuthClient(opts) → AuthClient
| Option | Type | Default | |
|---|---|---|---|
| gateway | string | https://api.ignitetoolbelt.com | base origin of the unified worker; trailing slash stripped |
| fetch | typeof fetch | global fetch | inject for tests/instrumentation |
| refreshLeadSeconds | number | 60 | refresh this many seconds before token expiry |
AuthClient
getSession() → Promise<Session | null>— first call hits the gateway; subsequent calls return cached valuepeekSession() → Session | null— synchronous, may be null pre-loadisAuthenticated() → booleansubscribe(listener) → unsubscriberedirectToLogin({ redirect? })— full-page redirect to/loginrefresh() → Promise<Session | null>signOut() → Promise<void>— calls/auth/logout; the worker clears theignite_session/ignite_refreshcookiesgetAvailableBrands() → AvailableBrand[]getAvailableGroups() → JwtBrandGroup[]getActiveBrand() → AvailableBrand | null— landing preferencefindBrandBySlug(slug) → AvailableBrand | nullswitchBrand(brandId) → Promise<Session | null>grantSelfBrandAccess(brandId, role?) → Promise<Session | null>listTaxonomies() → Promise<TaxonomyWithValues[]>— memoised per session
How it works
The SDK assumes a session cookie on the parent domain (e.g.
.ignitetoolbelt.com). It calls GET /auth/session to read the current
session, attempts POST /auth/refresh once on a 401, and schedules a
proactive refresh ~60s before expiry. There's no localStorage —
HttpOnly cookies (ignite_session / ignite_refresh) are the source
of truth; the SDK only mirrors them in memory.
Cross-origin requirements
For SSO across sibling subdomains the unified worker is configured with:
COOKIE_DOMAIN=.ignitetoolbelt.com(or your equivalent)ALLOWED_ORIGINSincluding each consumer's exact origin
The SDK sends credentials: 'include' on every gateway call.
Ecosystem apps: SSO + offline verification (ADR-134)
Two extra subpaths, for apps that are not the Ignite workspace. They're
independent: /sso is dependency-free and isomorphic (~1 KB); /verify is
server-only and the sole importer of jose. Import only what you need.
The login page (/react) — don't build your own
<IgniteLoginPage /> is the canonical Ignite sign-in screen. Render it and you get the
SSO flow, the error semantics, and the layout — consistent with every other Ignite app.
import { IgniteLoginPage } from '@edwinlovett/ignite-auth/react'
<IgniteLoginPage productName="Meta Ads Audit" redirect={location.origin + '/dashboard'} />No CSS import, no design-system dependency. Theme it by mapping your tokens onto its CSS custom properties:
<IgniteLoginPage style={{ '--ia-accent': '#2563eb', '--ia-radius': '10px' } as CSSProperties} />--ia-accent, --ia-accent-fg, --ia-fg, --ia-muted, --ia-border, --ia-card,
--ia-page, --ia-radius, --ia-danger. Plus className (root) and per-part classes
(.ia-card, .ia-sso, .ia-input, .ia-submit). Dark mode follows
prefers-color-scheme unless you override.
The email field is hidden by default — the passwordless path currently admits nobody
(the corporate domains are SSO-only). emailFallback re-enables it when the client tier
lands.
@edwinlovett/ignite-auth/sso — start the Microsoft sign-in flow
SSO is a full-page navigation (the start route 302s to Microsoft), never a fetch. Build the URL here so your app can't drift from the worker's contract.
import { ssoStartUrl } from '@edwinlovett/ignite-auth/sso'
window.location.assign(
ssoStartUrl({ redirect: window.location.origin + '/dashboard' }),
)redirect must be an absolute URL on a host the worker allows
(ALLOWED_REDIRECT_HOSTS) — pass window.location.origin + path, not a bare
path. Pass ext: true from a Chrome-extension popup to land on a
"signed in, close this window" page instead of redirecting.
React apps with no design system can use the batteries-included button:
import { SignInWithMicrosoft } from '@edwinlovett/ignite-auth/react'
<SignInWithMicrosoft /> // returns to the current page
<SignInWithMicrosoft redirect="https://app.ignitetoolbelt.com/home" />Signing out (/sso) — clearing your own cookie is NOT enough
The most common ecosystem-app bug: you clear your session cookie, but the shared
ignite_session survives on .ignitetoolbelt.com, your next request verifies it, and the
user is instantly signed back in. Sign-out becomes a no-op that looks like it worked.
You also can't fix it by expiring ignite_session yourself — that drops the cookie from the
browser but does not revoke the session in D1 (the refresh token stays live). Only the
gateway can revoke.
import { logoutUrl, logout } from '@edwinlovett/ignite-auth/sso'
// Link / redirect (server-rendered friendly, no CORS needed):
<a href={logoutUrl({ redirect: 'https://myapp.ignitetoolbelt.com/' })}>Sign out</a>
// Or a credentialed POST, to stay on the page (SPA):
await logout()Both revoke the session server-side and clear ignite_session + ignite_refresh. Afterwards,
drop your own app cookie/state too.
Microsoft session: federated defaults to false — the user leaves Ignite but stays
signed into their work Microsoft account (so "Sign in with Microsoft" re-auths with no
prompt). Pass logoutUrl({ federated: true }) to also end the Entra session — for shared
machines, or an explicit "sign out everywhere".
@edwinlovett/ignite-auth/verify — trust the cookie, server-side
Any app on *.ignitetoolbelt.com automatically receives the ignite_session
cookie (it's scoped to the registrable domain). This verifies it is real —
signature, algorithm, issuer, audience, expiry — against the gateway's public
JWKS. No secret, no per-request call to us.
import { createSessionVerifier } from '@edwinlovett/ignite-auth/verify'
const auth = createSessionVerifier() // defaults to the prod gateway's JWKS
// Cloudflare Workers / Hono
const session = await auth.verifyRequest(request) // or c.req.raw
// Express / Vercel
const session = await auth.verifyRequest(req)
// → { userId, tenantId, orgId, role, sessionId, lastActiveBrandId, expiresAt }verify/verifyRequest throw SessionVerificationError (with a .reason)
so a forgotten try/catch fails closed. Use tryVerify/tryVerifyRequest for
a null-on-failure variant.
alg is pinned to EdDSA, so alg: none and HS256-with-public-key confusion
are rejected outright. The JWKS is fetched once and cached (and refetched on an
unknown kid, so key rotation just works).
Two limits — read these
- Revocation is invisible to an offline verifier. The worker checks D1
revoked_aton every call and revokes instantly; this cannot. It will accept a revoked-but-unexpired token for up to the 15-minute access TTL. That short TTL is the bound. For sensitive or destructive operations, also make an online check (GET /auth/session). - This is authentication, not authorization. A valid token proves the bearer is a signed-in Ignite user. It does not prove they may use your app — a shared SSO cookie serves every app on the domain by design. Gate your app separately.
Not a security control: publishing this package privately would gate who can download the code, never who can authenticate. The SSO endpoints are public browser redirects by necessity, and a browser SDK cannot hold a secret. The real controls are the ones above: signature,
alg,iss,aud,exp.
License
UNLICENSED — distribution + use by Ignite Toolbox consumers only.
