@aiquants/auth-react-router
v0.18.0
Published
React Router v7 / v8 auth adapter for @aiquants/auth-core: Google OAuth strategy factory (remix-auth), cookie session storage with DI test backdoor, a loader guard that authenticates at most once per request (token refresh + photo cache), auth route handl
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.
User admin shell: the self-permission badge
The admin shell (createUserAdminApp) renders a "your permissions" badge in its header. Two optional ports feed it,
and both are fail-closed by design:
createUserAdminApp({
store, guards,
// resolves to a map of { canRead, canWrite, canDelete } per resource key.
// Any authz view carrying those three booleans satisfies it structurally — no import from an authz package.
getMyPermissions: (request) => myAuthz.getMyPermissions(request, { resourceKeys: ["authz_admin"] }),
// display names for those keys; unlisted keys render as the raw key
resourceLabels: { authz_admin: "ユーザー管理 UI" },
})- Query the resource your guards actually check. Asking about a different key makes the badge disagree with the guard — the user is admitted but told they have no permission.
- If the port is absent, rejects, or returns no entries, the badge renders an explicit "unknown" state
(
data-testid="user-admin-permission-unavailable") and the failure is logged. It never renders a permissive value: a badge that claims "editable" when the answer is unknown is worse than no badge at all.
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
// mounted as the splat route "/users/*"
export default () => <AuthUserAdminAppView basePath="/users" />basePathis required (AuthUserAdminAppView/UserAdminShell): the absolute path the console is mounted at, without a trailing slash ("/users"for a/users/*route). The five tabs and the per-row "members" links of the groups tab are built from it. There is no default: the host decides where the console lives, and a built-in default would keep drawing links to that default after the mount moves — the routes still resolve and the build still passes, only the links point at a place that is not served.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.
Localization (labels.locale vs labels.formatLocale)
Every string this package draws lives in UserAdminLabels, and a host picks the language by injecting a whole catalog
(defaultUserAdminLabels for English, the package default, or jaUserAdminLabels) or a partial override. Two keys
carry language, and they are different axes:
| Key | Type | defaultUserAdminLabels | jaUserAdminLabels | Drives |
| --- | --- | --- | --- | --- |
| locale | UserAdminLocale ("en" | "ja") | "en" | "ja" | UI chrome language of both upstream-group pickers (SelectBox locale) |
| formatLocale | BCP 47 string | "en-US" | "ja-JP" | Intl.DateTimeFormat (with timeZone) and the user-list Intl.Collator |
localeis forwarded to both@aiquants/select-boxpickers (the create-group dialog and the directory link form). The picker strings this package owns (directoryView.pickerNoOptions/pickerClear/pickerToggle/pickerSelected) are passed as the picker'slabels, the placeholder is the per-instanceplaceholderprop, and every other key comes from the select-box catalog oflocale(the values below are those ofdefaultUserAdminLabels/jaUserAdminLabelsand of select-box 0.11.0):| select-box key | Source |
"en"|"ja"| | --- | --- | --- | --- | |noOptions|directoryView.pickerNoOptions| No matching group | 一致するグループがありません | |clear|directoryView.pickerClear| Clear the selection | 選択を解除 | |toggle|directoryView.pickerToggle| Show the group list | グループ一覧を開く | |selection|directoryView.pickerSelected({value}= the picked option's label) | Selected: {value} | 選択中: {value} | |status| select-box catalog (live-region announcement; see below) | 2 options / 3 options, 2 available | 候補 2 件 / 候補 3 件・選べるもの 2 件 | |placeholder|placeholderprop:groupsView.pickUpstreamPlaceholder(dialog) /directoryView.pickUpstreamPlaceholder(link form) | Search by name or address... | 名前かアドレスで検索... | |scrollUp| select-box catalog | Scroll up | 上へスクロール | |scrollDown| select-box catalog | Scroll down | 下へスクロール | |scrollLeft| select-box catalog | Scroll left | 左へスクロール | |scrollRight| select-box catalog | Scroll right | 右へスクロール | |scrollToTop| select-box catalog | Top | 先頭へ | |scrollToBottom| select-box catalog | Bottom | 末尾へ | |noItems| select-box catalog | No items | 項目がありません | |searching| select-box catalog (message of the open list while nothing matches yet and more rows may still come) | Searching... | 検索中... | |guess| select-box catalog (accessible description of a suggested row, one the picker lists as a guess rather than a match) | suggestion | 推測 | |removeTag| select-box catalog (multi-select chips; both pickers are single-select) | Remove {label} | 「{label}」を削除 | |resizeHandle| select-box catalog (tooltip of the option list's resize handle) | Resize handle | サイズ変更ハンドル |To change a catalog-sourced string, there is no per-key override here: pick the
localewhose catalog you want.The live-region announcement (
status) is not overridden, so select-box owns its whole policy in one place: silent while the popup is closed, while rows may still come and while the query is being typed;pickerNoOptions(the resolvednoOptions) when no row is listed; otherwise the listed count alone when every row is selectable, or the count followed by how many are selectable when some are not (already-linked upstream groups are listed but disabled): the available rows for a blank query ("3 options, 2 available" / 「候補 3 件・選べるもの 2 件」), the available matches for a typed one ("3 options, 2 available matches" / 「候補 3 件・選べる一致 2 件」). The examples in the table are the blank-query phrases.resolveUserAdminLabels(run once whencreateUserAdminAppis built) validateslocale: an omitted value keeps the English default"en";"en"/"ja"pass; anything else ("ja-JP","EN","",null, ...) throwsRangeErrornaminglabels.formatLocale, so a mistake stops the app at startup instead of crashing the picker on first render. There is no language negotiation — mapnavigator.languageor a user setting to"en"/"ja"yourself.formatLocaleis not validated: an unreadable tag degrades (dates fall back to the raw ISO string, sorting to the runtime collator) instead of throwing, because formatting must never take the screen down.
Migrating from 0.15.x (breaking changes in 0.16.0)
basePathis now required onAuthUserAdminAppView/UserAdminShell(see above). Pass the absolute mount path, without a trailing slash:<AuthUserAdminAppView basePath="/users" />. Omitting it fails type-checking (TS2741); an untyped caller would draw links such asundefined/groups.labels.localechanged meaning: the oldlabels.locale(a BCP 47 tag for dates and sorting) is nowlabels.formatLocale— no alias. The newlabels.locale(typeUserAdminLocale="en"|"ja") is the UI language of the pickers, validated at startup.- Hosts that inject
jaUserAdminLabelswhole, or spread it ({ ...jaUserAdminLabels, heading: "" }), need no change: it carrieslocale: "ja"andformatLocale: "ja-JP". - A host that overrode
locale: "ja-JP"must writeformatLocale: "ja-JP", and addlocale: "ja"if it relies on Japanese picker chrome (without it the pickers' catalog-sourced strings, such as the scroll-arrow names, are English). - A host that builds a full
UserAdminLabelsliteral must add both keys.
- Hosts that inject
@aiquants/select-boxtextsis gone: the pickers take select-box 0.10.0locale/labels(SelectBoxLabelOverrides) instead oftexts/SelectBoxTexts. This is internal to the views — hosts that only renderAuthUserAdminAppViewdo nothing — but a host must install@aiquants/select-box>= 0.10.0 and@aiquants/virtualscroll>= 3.7.0 (the new peer floors).
Upstream group catalog (optional port)
The groups and directory tabs let the operator pick an upstream group instead of spelling its address. Wire the optional
directoryCatalog port to enable it; omit it and both surfaces fall back to typing the address by hand.
createUserAdminApp({
store: myUserAdminStore,
guards: { ... },
// `observedAt` is required. It is `null` only when the upstream has NEVER been observed —
// which is not the same as "observed, and the answer was empty".
directoryCatalog: {
listGroups: async (request) => {
const { groups, observedAt } = await myCatalogReader.read(request)
return { groups, observedAt }
},
},
})The list is usually a copy written by a separate process that holds the upstream credential, not a live read.
That is why the port must report when the upstream was observed: a list with no timestamp reads as "the directory right
now", and the operator picks a group that no longer exists. observedAt: null is rendered as unavailable /
neverObserved rather than as an empty list, so a deployment whose synchronization has never run is never mistaken for a
domain with no groups.
- The catalog is a convenience read, never a gate. A failure is reported to the view as
{ kind: "unavailable", reason }— the tab still opens, and manual entry stays available. It is fetched only for the two segments that render a picker, and only for the intents that create a link. reasonis a classification, not the upstream's text (denied/rateLimited/upstreamError/neverObserved/unknown; the first three are derived from a numericstatuswhen the thrown value carries one,neverObservedfrom anullobservation time). The original error goes toconsole.erroronly: an upstream message can carry the service-account address, the impersonated subject, internal URLs, or a credential fragment, and holding read access to this page is not a reason to be shown any of them.- The same rule governs store errors. An exception's own text reaches the operator only when the store declares it
operator-facing — by throwing
UserAdminOperatorError, or by settingoperatorFacing: trueon the error (for hosts that cannot extend this package's class). Everything else is reported as a generic sentence and logged: a driver exception carries the schema, table, constraint and the offending values. - The provider is taken from
getDirectoryStatus(), never from the form, and the link intents are refused outright when the directory is not enabled. The absence of a control in the UI is not a constraint. observedAtandisStaleare decided on the server. Judging staleness in the browser lets a client whose clock runs behind suppress the warning, and desynchronizes server and client rendering.- A host that overrides
labelswholesale must supplygroupsView.catalogFailures(five keys) alongside the rest;resolveUserAdminLabelsfills anything omitted from a partial override. - Listing the catalog requires the same verbs as creating a link (
update+create+delete), notread. It enumerates another organization's department structure and headcounts; a principal who cannot link has no use for it. A denial is turned into "no picker", never into a failed page. - The action re-checks what the picker offered. A key the catalog does not list is refused server-side (case and
surrounding whitespace are treated as equal), because the picker's
disabledrows are a UI promise, not a constraint. When no catalog is wired — or the upstream is unreachable — the check is skipped rather than turned into a refusal: refusing there would make linking impossible in exactly the deployments that must type the address by hand. - An upstream group already linked to another local group is refused, so its membership cannot be projected onto two groups (double-counted members, both sets of roles granted).
createLinkedGroupcreates the group and links it in one intent, and therefore requires the union of the verbs both halves need (create+update+delete). Splitting it in two would leave a state where only one half succeeded.
Sync requests: queued on link, followed through on screen
The console never reaches the upstream itself. "Sync now" — and, since 0.15.0, every link creation — queues a request
for the synchronization job (store.requestDirectorySync(groupId, actor)), and the job writes the result back to the
database a few seconds later.
- Both link intents return
{ ok: true, syncRequest }with"queued","already-pending", or"not-queued". The last one means the link succeeded but the request could not be queued; it is reported as such (and logged) rather than thrown, because throwing would report a failure for a link that now exists. The request is scoped to the group, not the tenant, so linking one group does not re-sweep every link. - While
directoryStatus.pendingRequestCount > 0, the shell re-validates the route data everyintervalMsand stops aftermaxMsif the count never changes (a deployment with no job consuming the queue must not be polled forever from every open tab). Configure it with the optionalpendingSyncPollingprop ofAuthUserAdminAppView/UserAdminShell; the default isDEFAULT_PENDING_SYNC_POLLING(3 s, up to 5 min). Nothing here adds an upstream call: the re-validation reads the rows the job wrote.
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.
The admin surface renders @aiquants/select-box, whose own stylesheet is not part of either artifact above. Import it in
the host, or the group picker's dropdown renders unstyled:
@import "@aiquants/select-box/styles/select-box.css";That is the components-only build. Follow select-box's own README ("CSS Setup") for the rest of your build: a Tailwind v4
host imports it (and the @aiquants/virtualscroll peer build) in layer(components) and points @source at
node_modules/@aiquants/select-box/dist (the published package ships no src); a host without Tailwind imports
@aiquants/select-box/styles/select-box.standalone.css instead.
The option rows of both pickers are drawn by this package: each row shows the group name and address, a note (the local
group already linked to it, which makes the row unpickable, or else its member count when known), and the part of the
label the typed query matched. The match paint is
the picker's own (RenderOptionContext.highlight: the query compiled once under the picker's normalization and bound to
the row's match category), so a row the picker lists as a guess is never painted as an exact match; the runs carry
data-aqar-match="exact" or "fuzzy" and are paint-only (background and text colour, no bold), so a label keeps its
width while you type.
@aiquants/fuzzy-search is a transitive peer (the picker's search index, reached only through select-box) and is
declared here as well, so a host that installs this package is told what the picker needs rather than discovering it
as a runtime resolution failure. @aiquants/virtualscroll is both: select-box renders its option list with it, and
this package imports it directly — src/admin/labels.ts resolves the UI language with
resolveVirtualScrollLocale, so dist/admin.mjs carries its specifier. The pickers use the locale / labels props
of @aiquants/select-box and its 0.11 render and typing contract (RenderOptionContext.highlight, and value /
onChange typed by the option type), plus the locale resolution of @aiquants/virtualscroll 3.7.0, so select-box 0.11.0
and virtualscroll 3.7.0 are the peer floors (the published manifest carries ^0.11.0, which a 0.10 install does not
satisfy).
All three are required peers, not optional. src/admin/views.tsx imports @aiquants/select-box and
src/admin/labels.ts imports @aiquants/virtualscroll at the top level, and ./admin re-exports both modules, so the
specifiers survive into dist/admin.mjs: a host without them does not get a degraded
picker, it gets ERR_MODULE_NOT_FOUND for the whole admin surface — including the groups and allowlist tabs, which have
nothing to do with the picker. Marking them optional would suppress the install warning that is the only thing standing
between a consumer and that failure. Note also that @aiquants/select-box pins React 19, which is stricter than this
package's own react: ">=18"; a host that mounts /admin must satisfy the stricter one.
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? }withuserrequired whenfailureRedirectis a string (any authentication failure throws a redirect), and{ user?, cookie? }when it isnull(no-redirect mode). Passing""throws aTypeErrorbefore any work — it is a mistake, not a request for the default. - One authentication per request: the guard performs its work at most once per
Requestinstance and shares a mode-neutral result, so a root loader (null) and a leaf loader ("/auth/login") in the same React Router data pass share a single pass — one warmup, one token refresh, oneSet-Cookie. The failure policy is applied per caller on top of the shared result, so it must never be part of the cache key. - Infrastructure failures are never laundered into logouts: anything that is not an authentication failure (an unexpected error inside the auth step, a
getUserPhotoport outage, asaveSessionfailure) propagates unchanged in both modes. The session is not destroyed and no redirect is produced. - 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 | 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
