Authentication
Three ways to sign someone in, and they share almost everything. Pick the wrong one and you change a single function, not the app.
how credentials become a token <- only this differs
|
@fluixi/session token, user, status, refresh, roles, 401 guard
|
@fluixi/auth/client useUser, protectedRoute, Can
|
your UI
@fluixi/session owns the session state machine. @fluixi/auth/client owns the guards
and the reads. Neither knows how you authenticated, which is why the layer above stays
the same whichever path you take.
Which one is yours
The deciding question is who holds the user records.
| You have | Use | Page |
|---|---|---|
| Your own login page and your own database | @fluixi/session |
Sessions |
| A provider's login page: Keycloak, Auth0, Okta, Curity | @fluixi/oauth2 |
OAuth2 and OIDC |
| An app that is the identity system | better-auth with @fluixi/auth |
Better Auth |
What each one costs
@fluixi/session. You write the password check, the user table, the login routes and
rate limiting on them. In exchange you keep your schema and take no dependency beyond the
framework. @fluixi/session/server gives you the session cookie so you are not writing
crypto.
@fluixi/oauth2. No user table and no password handling, because the provider does
both. Works with any OAuth2 or OIDC provider, and in browser mode needs no server at all,
so a static single page deployment is enough. The tradeoff is that the login page is not
yours.
better-auth with @fluixi/auth. Account linking, two-factor, organisations and
email/password alongside SSO, all provided. It needs a database and a server, because it
creates its own user records and its own sessions even when login is delegated to a
provider.
A note on that last one, since it is easy to misread: Better Auth can delegate login to
Keycloak through its generic OAuth plugin, but the session it issues is still its own,
stored in its own database. If you want the provider to be the only identity system,
@fluixi/oauth2 is the smaller answer.
What stays the same
Whichever you picked, the interface layer is identical:
import { createAuthHooks } from '@fluixi/auth/client';
import { useNavigate } from '@fluixi/start/router';
export const { protectedRoute, Can, useUser } = createAuthHooks({
session: auth,
navigate: { handler: useNavigate },
loginPath: '/login',
});
// a whole page, behind a role
export default protectedRoute(AdminPage, { role: 'admin' });
// one control, behind a permission
<Can perform={(p) => p.invoices.refund} fallback={null}>
<button onClick={refund}>Refund</button>
</Can>
// who is signed in
const user = useUser();
<span>{() => user()?.email ?? 'Signed out'}</span>
createAuthHooks accepts any object with a user() accessor, so all three paths feed it.
@fluixi/oauth2 goes further and returns the guards from the service itself, so an app
using it declares authentication in one file.
Protect, protectedRoute and Can
Three guards that look similar and are not.
Can |
Protect |
protectedRoute |
|
|---|---|---|---|
| Asks | what may you do | who are you | who are you |
| Scope | one control | a section of a page | the whole page |
| When denied | renders fallback |
renders fallback |
navigates away |
The consequence worth knowing: protectedRoute means the component never runs, so a page
that fetches on mount does not fetch for someone who should not see it. Protect leaves
the rest of the page intact and swaps one region.
Everything the hooks return
| Hook | What it gives |
|---|---|
useUser() |
the signed-in user, or null, as a signal |
useSession() |
the session record beside the user: when it expires, and what else the backend sent |
useAuth() |
user, session, isAuthenticated, roles, can, signIn, signOut in one object |
useSignIn() |
starts a sign-in; credentials for Better Auth, whatever the session's login takes otherwise |
useSignOut() |
ends the session |
usePermissions() |
the permission set for the current roles |
useCan() |
can(query): may the current user do this. false when no roles are configured, so a missing config hides a control rather than showing it |
refresh() |
re-reads the session, for a change made outside this tab |
store |
the underlying store, for a test that seeds a user |
usePermissions and useCan decide what to render and nothing else. A client can claim any
permission, so the server decides what is allowed on every request.
const can = useCan();
<button disabled={() => !can((p) => p.users.delete)}>Delete</button>
On the server
A server function or an API route asks the request, not the hooks:
import { requireUser, requireRole, requirePermission, optionalUser } from '@fluixi/auth/server';
export async function deleteUser(id: string) {
"use server";
const admin = await requireRole('admin');
// ...
}
| Helper | Returns | Refuses with |
|---|---|---|
optionalUser() |
the user, or null |
never |
requireUser({ redirectTo? }) |
the user | 401, redirectTo defaulting to /login |
requireRole(role, { redirectTo? }) |
the user | 401 when signed out, 403 when the role differs |
requirePermission(check, { redirectTo? }) |
nothing | 401 when signed out, 403 when check (a boolean or a function) is false |
They throw an AuthError carrying the status, and a server function or API route answers it
with that status and { error, redirectTo } as the body, not a 500.
They read the user from request locals, so something has to put it there first. With Better
Auth that is authMiddleware; with OAuth2 in server mode it is sessionLocals, which
authServerModule mounts. In OAuth2's browser mode the server has no session to read: verify
the access token instead, with Token verification.
The boundary that is not optional
Everything above decides what to render. None of it decides what is allowed.
A client can claim any role, because a client is code on someone else's machine. Read
can(...) as "should I show this button", never as "may this person do this". The server
re-checks on every request, and that check is what actually protects anything. See
Token verification for the other side.
Next: Sessions.