@aiquants/auth-react-router
v0.5.0
Published
React Router v7 auth adapter for @aiquants/auth-core: Google OAuth strategy factory (remix-auth), cookie session storage with DI test backdoor, per-request loader guard with token refresh + photo cache, auth route handlers, and a <GoogleForm> login button
Readme
@aiquants/auth-react-router
React Router v7 auth adapter for @aiquants/auth-core: Google OAuth (remix-auth v4 + a vendored GoogleStrategy over remix-auth-oauth2) authenticator factory, cookie session storage, per-request loader guard with token refresh + photo cache, auth route handlers, and a <GoogleForm> login button. All app couplings (env, backend HTTP, DB photo lookup, warmup, test backdoor) are DI ports. The Google strategy (formerly @coji/remix-auth-google, 68 lines) is vendored in src/server/google-strategy-impl.ts, so the package has no @coji/* dependency.
Install
pnpm add @aiquants/auth-core @aiquants/auth-react-routerPeers: react, react-router@^7, remix-auth@^4, remix-auth-oauth2@^3, react-icons.
Wiring (single composition point — app/services/auth/config.server.ts)
import { emailDomainAllowlist, parseAllowlistCsv } from "@aiquants/auth-core"
import { createAuthServer } from "@aiquants/auth-react-router/server"
export const authServer = createAuthServer({
google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, redirectURI: env.GOOGLE_OAUTH_REDIRECT_URL },
session: { secrets: [env.REMIX_SESSION_SECRET] },
isEmailAllowed: emailDomainAllowlist(parseAllowlistCsv(env.ALLOWED_EMAILS), parseAllowlistCsv(env.ALLOWED_DOMAINS)),
isUserActive: async (profile) => isAccountEnabled(profile), // optional: deactivation revokes live sessions
backendAuth: { verify: verifyBackend, signup: (p, t) => signupBackend(p, t) }, // FastAPI verify/signup
getUserPhoto: async (openid) => (await getOpenids({ openid }))[0]?.picture ?? "", // your identity store
warmup: async () => { await Promise.all([ensurePrimaryConnection(), ensureSecondaryConnection()]) },
mockUser: mockUserPort, // test backdoor (E2E) — omit in production-only apps
})
export const { authenticator, sessionStorage, getSession, getSessionUser, saveSession, requireUser, requireAdminUser, commitSession, destroySession, authenticate, refreshedAccessToken, authenticateInLoader, loginLoader, loginAction, logoutLoader, logout, logoutAndRedirect, googleCallbackLoader } = authServerClient:
import { GoogleForm } from "@aiquants/auth-react-router/client"
<GoogleForm /> // <Form method="POST"> + _action="Sign In with Google"User administration surface (/admin)
A splat-mounted admin UI for users, groups, group members, and the sign-in allowlist — the identity-side counterpart to @aiquants/authz-react-router.
import { createUserAdminApp, jaUserAdminLabels } from "@aiquants/auth-react-router/admin"
export const userAdminApp = createUserAdminApp({
store: myUserAdminStore, // you implement UserAdminStore against your DB
labels: jaUserAdminLabels, // optional; package default is English
guards: { // REQUIRED — see below
requireAccess: async (request, { action }) => {
await requireUser(request)
await requirePermission(request, { resourceKey: "user_admin", action })
},
},
})import { AuthUserAdminAppView } from "@aiquants/auth-react-router/admin"
export const loader = userAdminApp.loader
export const action = userAdminApp.action
export default () => <AuthUserAdminAppView />guardsis mandatory — this surface lists every user and mutates group membership, so an unguarded mount is an account-enumeration and privilege-escalation path. Allow by returning, deny by throwing (Response/redirect/Error); the return value isvoidby contract, so a predicate-style guard that returnsfalsecannot silently fail open.- Per-operation authorization: the guard receives the CRUD verb each intent actually performs (
read/create/update/delete), so a principal holding onlyupdatecannot delete. Membership add/remove map tocreate/deletebecause they add and remove rows. - Unknown intents reach neither the guard nor the store (the intent table is a
Map, so prototype keys such asconstructordo not resolve). - Store errors (e.g. "cannot remove the last administrator") are returned as
{ ok: false, error }and rendered by the views; guard rejections propagate untouched. - Labels default to English (
defaultUserAdminLabels); injectjaUserAdminLabelsor a partial override viaresolveUserAdminLabels.
Styling: this package has no hand-written component CSS (the GoogleForm classes are plain Tailwind utilities), so it ships no components-only artifact — only a standalone build. Two consumption modes, never mixed:
Tailwind v4 host — add
@source "../node_modules/@aiquants/auth-react-router/src/**/*.{ts,tsx}";(monorepo:../../../../packages/auth-react-router/src/**/*.{ts,tsx}) so theGoogleFormclasses are generated in the host's own canonical build;srcships in the published package.Non-Tailwind host — import the single self-contained standalone build (
pnpm run build:css→dist/styles/auth-react-router.standalone.css):@import "@aiquants/auth-react-router/styles/auth-react-router.standalone.css";
Never mix the two: importing the standalone utility CSS next to a host Tailwind build duplicates same-named utilities, and base/variant cascade winners flip versus the single-build canonical order.
Behavior Contract
- Session cookie byte compatibility:
__session(httpOnly/lax/30d), key"user", value ={ id, displayName, name, emails, accessToken, refreshToken?, expirationDateMs?, provider, role? }— neverphoto/_json/photos. Changing this invalidates all live 30-day sessions. - Verify order: Google profile check →
backendAuth.verify→ allowlist →backendAuth.signup(every login). Failures redirect to<loginPath>?error=<encoded error message>(messages overridable viamessages). - Token refresh: expiry check → Google
oauth2/v4/tokenrotate → session update;invalid_grantdestroys the session and redirects to login withSet-Cookie. - Loader guard:
authenticateInLoader(request, { failureRedirect })returns{ user?, cookie? };failureRedirect: null= no-redirect mode. Photo lookups are cached with in-flight dedup per factory instance.
DI Ports Absorbing Per-App Differences
| Port | App A | App B |
| --- | --- | --- |
| google.scopes | default | + directory.readonly etc. |
| mockUser | mock_user_id cookie parse + name map | fixed id |
| warmup | 2 pools | 1 pool |
| backendAuth | primary-api | secondary-api |
| isUserActive | DB lookup + allowlist re-check | omitted (always active) |
isUserActive is evaluated at login and on every session resolution, so disabling an account revokes already-issued session cookies. Return false only for a known-disabled account — returning false for an unknown identity would block first-time sign-up.
⚠️
isEmailAllowedmay return aPromise(to consult a database as well as env vars). Callers mustawaitit: aPromiseis always truthy, so a missingawaitmakes the allowlist fail open, and the type checker cannot catch it.
Intentional Changes vs Original (§8, approved)
- No import-time env reads/throws — all validation happens in
createAuthServer. - Allowlist matching is trim+lowercase (unified with the Python side via
@aiquants/auth-core). - Dead exports (
refreshBackend,authenticateBackend) are not ported — the backend port only needsverify/signup.
MIT
