feat(auth): add AuthProvider Redux state integration#2
feat(auth): add AuthProvider Redux state integration#2gs-layer wants to merge 1 commit intofeat/auth-providerfrom
Conversation
Integrate AuthProvider contract into Redux store via two event-driven slices (auth/session, auth/permissions), enabling reactive auth state consumption in UI components through useAppSelector and useAuth() hook. - Add auth/session slice (status, session, error, lastSyncAt, capabilities) - Add auth/permissions slice (permissions, loading, error) for independent RBAC - Add AuthSessionMeta type for token-safe Redux state (no sensitive tokens by default) - Add auth effects: subscribe() bridge, sync, login, logout, refresh, permissions fetch - Add auth actions: syncAuth, loginAction, logoutAction, refreshAuth, fetchPermissions - Add useAuth() React hook in @cyberfabric/react with safe degradation - Extend auth plugin config: redux.includeTokens option (default: false) - Add HAI3Actions module augmentation from auth plugin - Add 40 unit tests covering slices, effects, actions, error paths, cleanup - Update AI target docs, package CLAUDE.md files, architecture exploration
Correctness ❌
Test Coverage
|
| Area | Rating |
|---|---|
| Correctness | ❌ reactive auth state handling is incomplete |
| Conformance | ✅ aligns with the intended event-driven architecture |
| Style | ✅ generally clear and consistent |
| Performance | ✅ no meaningful concerns found |
| Tests | |
| Security | ❌ stale RBAC state can survive provider-driven logout/error |
| Reviewer concerns | |
| Risk | 🟡 Medium |
Recommendation
Minimum (blocking) — clear auth/permissions whenever the provider emits a non-authenticated terminal state (unauthenticated, and likely error) through the subscribe bridge, and add tests for that transition.
The problem is that the PR implements two different paths for auth state updates, and only one of them clears permissions.
In the imperative path, permissions are cleared correctly:
-
syncAuth() handles checkAuth().authenticated === false by dispatching clearPermissions() in packages/framework/src/
effects/authEffects.ts:147
But in the reactive path, the provider can push state changes through subscribe(), and that handler only updates the
session slice: -
the provider event is bridged at packages/framework/src/effects/authEffects.ts:118
-
that event is consumed at packages/framework/src/effects/authEffects.ts:130
-
inside that handler, it dispatches only setAuthSessionState(...) and never clearPermissions()
So this sequence can happen:
- User logs in.
- auth/permissions gets populated, for example, with admin roles.
- Later, the auth provider emits a reactive state change like unauthenticated because the token expired, a session was
revoked, or a logout happened elsewhere. - The code updates auth/session.status to unauthenticated, but leaves auth/permissions untouched.
That means Redux can end up in an inconsistent state:
- auth/session.status = 'unauthenticated'
- auth/permissions.permissions = previous user's permissions
Why that matters:
- useAuth() exposes both values independently in packages/react/src/hooks/useAuth.ts:82
- any component that checks permissions directly, or checks them before checking status, can still render privileged UI
based on stale permissions
The fix is straightforward: in the AuthEvents.StateChanged handler, when the pushed state is no longer authenticated, also clear permissions. At minimum, that should happen for unauthenticated; depending on intended semantics, probably also for error.
Minimum (blocking) — fix isFullAuthSession() so it distinguishes full vs redacted cookie/custom sessions correctly, or remove the guard if that distinction cannot be made safely from the projected state.
The second issue is a bad type guard. It tells TypeScript something is a full AuthSession when, at runtime, that is not
necessarily true.
The guard is here: packages/framework/src/authTypes.ts:34
export function isFullAuthSession(
session: AuthSession | AuthSessionMeta,
): session is AuthSession {
return session.kind === 'bearer' ? 'token' in session: true;
}
What it claims:
- bearer session is “full” only if it has a token
- every cookie or custom session is always “full”
That last part is wrong because this PR also introduces redacted session snapshots via toSessionOrMeta() in packages/
framework/src/effects/authEffects.ts:90. When redux.includeTokens is false, it deliberately strips session data down to:
- bearer: { kind, expiresAt }
- cookie: { kind, expiresAt }
- custom: { kind, expiresAt: undefined }
So for cookie/custom sessions, Redux may hold only metadata, not the full provider session.
Example:
- provider returns cookie session { kind: 'cookie', csrfToken: 'abc', expiresAt: 123 }
- toSessionOrMeta(..., false) stores { kind: 'cookie', expiresAt: 123 }
- isFullAuthSession() returns true anyway, because it treats all non-bearer sessions as full
After that, consumer code can do something like:
if (isFullAuthSession(session)) {
useCsrf(session.csrfToken);
}
TypeScript will allow it, but csrfToken is actually missing at runtime.
Why this matters:
- it defeats the purpose of the safe redacted Redux projection
- it creates runtime undefined-field bugs
- it can mislead consumers into assuming sensitive/session-specific fields are present when they were intentionally
removed
The fix is one of these:
- Remove isFullAuthSession() entirely if the projected state cannot safely distinguish full vs redacted cookie/custom
sessions. - Add an explicit discriminator to the stored shape, such as redacted: true/false.
- Make the guard check for required runtime fields per session kind instead of returning true for all non-bearer kinds.
Right now, the guard is unsound because the type narrowing is stronger than the runtime guarantee.
Summary
auth/session,auth/permissions) to the auth plugin, projecting AuthProvider state into the store via event-driven effectsAuthSessionMetatype for token-safe state — no sensitive tokens in Redux by default, configurable viaredux.includeTokensuseAuth()React hook in@cyberfabric/reactwith safe degradation when auth plugin is not registeredKey design decisions
auth/sessionfor session lifecycle,auth/permissionsfor RBAC — independent update cyclesAuthSessionMeta({ kind, expiresAt }) instead of sentinel values — compile-time protection against accidental token leakageuseAuth()for React,app.auth.getSession()for imperative codeHAI3Actionsextended from auth plugin file — auth actions visible in types only when plugin is importeddependencies: ['effects']for guaranteed init orderTest plan
toSessionOrMeta— bearer/cookie/custom × includeTokens true/false (6 tests)npx tsc --noEmit— 0 new errors