OAuth2 et OIDC
@fluixi/oauth2 s'adresse à une application qui délègue l'identité à un fournisseur :
Keycloak, Auth0, Okta, Curity, Entra, ou votre propre serveur d'autorisation. Vous ne
construisez pas la page de connexion et vous ne détenez aucun compte utilisateur.
import { createAuthService } from '@fluixi/oauth2';
import { useNavigate } from '@fluixi/start/router';
export const auth = createAuthService<User>({
config: {
tokens: 'browser',
issuer: 'https://localhost:8443/realms/app',
clientId: 'admin-spa',
redirectUri: `${location.origin}/callback`,
},
navigate: { handler: useNavigate },
loginPath: '/login',
});
Une seule URL d'émetteur constitue toute la configuration. La discovery lit les points
d'entrée d'autorisation, de token, de userinfo et de fin de session depuis
/.well-known/openid-configuration.
Les deux moitiés
AuthClient est le protocole : démarrer une connexion, transformer un callback en tokens,
rafraîchir, terminer la session. Il ne détient aucun état applicatif.
AuthService est l'application : qui est connecté, si la session tient, quand rafraîchir,
quoi afficher. Il est bâti sur @fluixi/session, donc tout ce que dit
cette page s'applique ici aussi.
Le service dépend d'un client et jamais duquel, et c'est ce qui permet au même code applicatif de tourner que les tokens vivent dans le navigateur ou sur un serveur.
Configuration par l'environnement
Quel fournisseur, quel émetteur, quel client et où vivent les tokens sont des faits de
déploiement : leur place est dans l'environnement. authFromEnv les lit et renvoie un preset
pour le fournisseur, plus une configuration client prête à étaler :
// src/config/env.ts
import { publicEnv } from '@fluixi/start/env';
import { authFromEnv } from '@fluixi/oauth2/env';
import { rolesFrom } from '@fluixi/oauth2/roles';
export const auth = authFromEnv(publicEnv(), {
clientPrefix: '',
roles: rolesFrom.keycloakRealm,
});
Il renvoie provider, providerName, clientId, tokens, redirectUri, scopes et
client. Changer de fournisseur devient une modification de .env, pas du code.
| Variable | Signification |
|---|---|
AUTH_PROVIDER |
oidc (par défaut), keycloak, auth0, okta, google, dex, oauth2 |
AUTH_ISSUER |
l'émetteur, pour oidc et oauth2 |
AUTH_CLIENT_ID |
ce client |
AUTH_TOKENS |
browser (par défaut) ou server |
AUTH_REDIRECT_URI |
où le fournisseur renvoie le navigateur |
AUTH_SCOPES |
séparés par des espaces ou des virgules, remplacent ceux du preset |
AUTH_AUDIENCE |
l'identifiant d'une API. Auth0 en a besoin pour émettre un access token JWT |
AUTH_DPOP |
true ou false, prend le pas sur le preset |
AUTH_DOMAIN |
le tenant, pour auth0 et okta |
AUTH_BASE_URL |
pour keycloak et dex |
AUTH_REALM |
pour keycloak |
AUTH_AUTH_SERVER |
pour okta : default sauf indication contraire, org pour le serveur de l'org |
AUTH_ROLES_CLAIM |
le claim à espace de noms qu'ajoute une action Auth0 |
Ces noms portent clientPrefix, VITE_ par défaut. Avec publicEnv(), passez
clientPrefix: '' : l'application déclare dans env.public les variables que le navigateur
peut voir, les noms n'ont donc pas besoin d'un préfixe pour le dire (voir
Variables d'environnement). Un secret client n'en fait jamais partie ;
le serveur le lit avec serverEnv.
prefix: 'GOOGLE' lit GOOGLE_* avant AUTH_*, pour qu'une application puisse porter
plusieurs fournisseurs. provider, tokens et roles dans les options l'emportent sur ce que
dit l'environnement. Une variable manquante lève une erreur qui la nomme, et une valeur de
TOKENS autre que browser ou server lève une erreur qui nomme les deux.
Presets des fournisseurs
Un preset contient ce dont chaque fournisseur s'est révélé avoir besoin lors d'un test réel :
comment il écrit son émetteur, où il range les rôles, ce qu'un vérificateur doit contrôler.
authFromEnv en choisit un d'après AUTH_PROVIDER ; presets de @fluixi/oauth2/presets
s'appelle aussi directement.
| Preset | AUTH_PROVIDER |
Demande |
|---|---|---|
| Tout fournisseur OIDC | oidc |
_ISSUER |
| OAuth2 simple | oauth2 |
_ISSUER, plus endpoints dans le code |
| Keycloak | keycloak |
_BASE_URL, _REALM |
| Auth0 | auth0 |
_DOMAIN; _AUDIENCE et _ROLES_CLAIM facultatifs |
| Okta | okta |
_DOMAIN; _AUTH_SERVER facultatif |
google |
rien ; un domaine hébergé facultatif | |
| Dex | dex |
_BASE_URL |
Chaque preset consigne aussi les contraintes qu'un essai réel a coûté à découvrir. Elles sont citées telles que le paquet les porte :
OAuth2 simple
- Publishes no discovery document, so every endpoint is configured by hand.
Keycloak
- Puts no
audon an access token until a dedicated audience mapper is configured. Configure one and switch the verifier toaudience: clientId. - Revokes the whole session when an authorization code is replayed, so a replay test has to run last.
Auth0
- Issues an opaque access token unless
audiencenames an API, so token verification needs it. - A refresh token needs three things together:
offline_accessin the scopes, an audience whose API has Allow Offline Access on, and Refresh Token Rotation enabled on the application. Any one missing and the scope is ignored silently. - Has no roles claim of its own. An action must add a namespaced one, and Auth0 drops a custom claim that is not a URI.
- The issuer carries a trailing slash. A verifier pinned without it rejects every token.
Okta
- Two authorization servers: /oauth2/
for an application, the bare domain for the management API. They are not interchangeable. - Turns DPoP on by default for a new application, and an integrator org will not let you untick it.
- Binds the refresh token to the DPoP key as well as the access token, so a refresh must be proved with the key that obtained it.
- A public SPA cannot introspect at all. An API Services client with
private_key_jwthas to ask instead. - Client credentials against the org server must use
private_key_jwt, whatever the application is set to.
- Refuses a public client: the token endpoint reports
client_secret is missingbefore checking PKCE, so browser mode cannot work. - Publishes its issuer both with and without a scheme, so a verifier needs both spellings.
- Issues a refresh token only on the first consent unless
prompt=consentandaccess_type=offlineare sent.
Dex
- Sends no CORS headers on discovery or the token endpoint, so browser mode cannot work. Naming the endpoints explicitly only moves the failure to the token request.
- Has no admin interface. Users are static entries in its config file, and a password is a bcrypt hash.
- Publishes no revocation endpoint.
Le branchement
Trois fichiers : le service, la configuration d'application qui l'enregistre, et la route de callback.
// src/services/auth.ts
import { createAuthService, authModule } from '@fluixi/oauth2';
import { auth as authEnv } from '../config/env.js';
interface User {
sub: string;
email?: string;
role?: string;
}
export const auth = createAuthService<User>({
config: {
...authEnv.client,
tokens: 'browser',
redirectUri: `${location.origin}/callback`,
postLogoutRedirectUri: `${location.origin}/login`,
},
});
export const AuthModule = authModule(auth);
export const { protectedRoute, Protect, Can, useUser } = auth;
tokens est redonné après l'étalement pour que TypeScript sache de quelle moitié de la
configuration il s'agit.
// src/app.config.ts
import { defineApp } from '@fluixi/start/app';
import { AuthModule } from './services/auth.js';
export default defineApp({ imports: [AuthModule] });
@fluixi/start enregistre src/app.config.ts avant que l'une ou l'autre entrée ne tourne :
ni entry-client ni entry-server ne mentionne l'authentification. Voir
Modules d'application.
// src/routes/callback.tsx
import { OAuthCallback } from '@fluixi/oauth2/callback';
import { auth } from '../config/env.js';
export default OAuthCallback({
enabled: () => auth.tokens === 'browser',
pending: () => <p>Signing you in</p>,
failed: (reason) => (
<>
<p>{reason}</p>
<a href="/login">Try again</a>
</>
),
});
OAuthCallback est toute la route de redirect URI. Il échange le code, vérifie le state et
envoie le visiteur vers la destination que signIn({ returnTo }) a notée avant la redirection.
Il obtient le service par AuthServiceToken, que authModule a enregistré ; passez service
pour se passer de l'injection.
Donnez à failed un chemin vers la connexion : un visiteur bloqué sur cette page n'a rien
d'autre à faire. enabled renvoie false quand l'application tourne en tokens: 'server', où le
fournisseur redirige vers le /auth/callback du serveur et où il n'y a rien à échanger ici.
Il repart avec location.replace par défaut, puisque la page n'existe que pour une transition.
Passez onSignedIn(destination) pour rester dans le routeur client. useOAuthCallback est le
hook en dessous, avec stage, error, destination et params en signaux, pour une page qui
veut montrer les étapes.
La page de connexion et les gardes :
// src/routes/login.tsx
import { auth } from '../services/auth.js';
export default function Login() {
return <button onClick={() => auth.signIn({ returnTo: '/dashboard' })}>Sign in</button>;
}
export default protectedRoute(Dashboard);
Où vivent les tokens
tokens: 'browser'
Un client public avec PKCE, qui détient ses propres tokens. Aucun serveur requis, donc un déploiement statique en page unique suffit.
Le token d'accès reste en mémoire seule ; le token de rafraîchissement va dans
sessionStorage par défaut, ou dans localStorage avec storage: 'local' si une session
doit survivre à la fermeture d'un onglet. Dans les deux cas il est atteignable par
n'importe quel script de la page. C'est le prix de ne pas faire tourner de serveur, et
c'est la raison d'être de l'autre mode.
Il n'y a pas de secret client, car ce qui est livré à un navigateur n'est pas un secret. PKCE est ce qui le remplace, et il n'est pas optionnel ici.
tokens: 'server'
Les tokens restent sur votre serveur et le navigateur reçoit un cookie httpOnly qu'il ne peut pas lire : un script sur la page n'a donc rien à voler. La moitié serveur est un module :
// src/services/auth.server.ts
import { authServerModule } from '@fluixi/oauth2/server';
import { publicEnv, serverEnv } from '@fluixi/start/env';
import { auth } from '../config/env.js';
export function authServer() {
const appUrl = publicEnv().APP_URL ?? 'http://localhost:3000';
return authServerModule({
...auth.client,
clientSecret: serverEnv('OIDC_CLIENT_SECRET', { required: true }),
cookieSecret: serverEnv('SESSION_SECRET', { required: true }),
redirectUri: `${appUrl}/auth/callback`,
defaultReturnTo: '/dashboard',
secureCookie: appUrl.startsWith('https://'),
});
}
// src/app.config.server.ts
import { defineApp } from '@fluixi/start/app';
import { auth } from './config/env.js';
import { authServer } from './services/auth.server.js';
export default defineApp({
imports: auth.tokens === 'server' ? [authServer()] : [],
});
Seul le build serveur importe src/app.config.server.ts : le secret client et la clé du cookie
n'atteignent jamais le bundle navigateur. authServer est une fonction parce que serverEnv
avec required lève une erreur quand une variable manque, et en mode navigateur elles
manquent.
authServerModule monte oauthRoutes puis sessionLocals. Les routes vivent sous
basePath, /auth par défaut : /login, /callback, /refresh, /me, /revoke et
/logout. Déclarez <appUrl>/auth/callback comme redirect URI chez le fournisseur. Le cookie
est chiffré avec une clé dérivée de cookieSecret, qui doit faire au moins 32 octets.
C'est aussi le mode d'un fournisseur qui refuse les clients publics : Google répond à une
requête de token sans secret par client_secret is missing.
sessionLocals place l'utilisateur connecté dans les locals de la requête, là où les
hooks d'authentification regardent pendant un rendu serveur : useUser() répond donc
pendant le SSR au lieu de se remplir après l'hydratation.
Il réutilise une réponse userinfo pendant userCacheMs (30 secondes par défaut) au lieu
d'interroger le fournisseur à chaque rendu. 0 interroge à chaque fois. La contrepartie
est la fraîcheur : une session terminée chez le fournisseur continue de s'afficher comme
connectée jusqu'à l'expiration de l'entrée.
Il n'existe pas d'équivalent de sessionLocals en mode navigateur, et ce n'est pas un
oubli. Dans ce mode le token ne quitte jamais le navigateur et n'est pas envoyé sur une
requête de document : le serveur n'a donc aucun identifiant à résoudre. Une application
construite ainsi rend le squelette déconnecté et le remplit côté client.
DPoP
Un bearer token fonctionne pour quiconque en détient une copie. DPoP (RFC 9449) lie le token à
une clé que détient le client : le fournisseur inscrit l'empreinte de la clé dans le token sous
cnf.jkt, et chaque requête porte une preuve fraîche signée avec cette clé. Un token copié ne
sert à rien sans la clé.
Activez-le avec dpop: true, ou AUTH_DPOP=true via authFromEnv. Les preuves sont signées
avec dpopAlgorithm, ES256 sauf indication contraire, et demandent le paquet jose, une
dépendance pair facultative. Certains fournisseurs ne laissent pas le choix : Okta active DPoP
pour toute nouvelle application, et une org d'intégrateur ne permet pas de le désactiver.
Un fournisseur qui veut un nonce choisi par le serveur refuse la requête avec use_dpop_nonce
et un en-tête DPoP-Nonce. Le client refait la requête une fois avec ce nonce dans la preuve,
dans les deux modes.
Dans le navigateur
import { createAuthService } from '@fluixi/oauth2';
export const auth = createAuthService({
config: {
tokens: 'browser',
issuer: 'https://example.okta.com/oauth2/default',
clientId: 'spa',
redirectUri: `${location.origin}/callback`,
dpop: true,
},
});
La clé est créée par client, non extractible, et gardée dans IndexedDB. IndexedDB stocke l'objet clé lui-même, si bien que la moitié privée n'est jamais des octets qu'un script pourrait lire ou envoyer ailleurs. Elle persiste d'un rechargement à l'autre exprès : un refresh token est lié à la clé qui l'a obtenu, et un rafraîchissement prouvé avec une autre clé est refusé.
Sur le serveur
Mettez dpop: true dans la configuration de authServerModule. Chaque session reçoit sa propre
clé à l'échange du code, gardée en JWK dans le cookie de session chiffré. Les rafraîchissements
sont signés avec cette même clé, et les appels userinfo derrière /auth/me et sessionLocals
utilisent le schéma DPoP avec une preuve portant le hash du token (ath).
Côté API
La liaison ne protège rien si l'API ne vérifie pas la preuve. createTokenVerifier({ dpop: true })
le fait ; voir Vérification des tokens.
Se déconnecter, et ce que cela laisse derrière
auth.logout() efface la session ici et envoie la personne vers l'end session endpoint du
fournisseur, donc le cookie du fournisseur part aussi et la prochaine connexion est une
invite plutôt qu'une redirection silencieuse.
Il révoque aussi le refresh token d'abord, via l'endpoint de révocation du fournisseur (RFC 7009). Cela compte plus qu'il n'y paraît : oublier un refresh token localement laisse toute copie de celui-ci fonctionner pendant toute sa durée de vie, alors que ce navigateur croit s'être déconnecté. Un access token expire seul en quelques minutes ; un refresh token non.
Pour retirer un token sans terminer la session, appelez-le directement :
await auth.client.revoke(); // le refresh token
await auth.client.revoke(token, 'access_token'); // un token précis
Un fournisseur qui ne publie pas d'endpoint de révocation ne peut pas faire cela, et l'appel se résout sans rien avoir fait plutôt que de faire échouer une déconnexion pour autant.
D'où vient l'utilisateur
Par défaut, du endpoint userinfo OIDC du fournisseur. La plupart des applications veulent le leur, car le fournisseur sait qui est la personne mais pas qu'elle est agent de support ici.
createAuthService<User>({
config: {
tokens: 'browser',
issuer,
clientId,
redirectUri,
userInfo: {
url: 'https://api.example.com/me',
map: (raw) => ({ id: raw.data.uid, email: raw.data.email, role: raw.data.role }),
headers: { 'x-tenant': 'acme' },
credentials: 'include', // pour une API qui répond depuis un cookie
},
},
});
map reçoit le corps analysé et le token d'accès, et peut être asynchrone. Utilisez-le
seul, sans url, pour remodeler les claims du fournisseur. Le token Bearer est
toujours envoyé à un endpoint personnalisé : c'est votre API, mais elle reste
authentifiée.
Pour ce que cela n'atteint pas, remplacez fetchUser entièrement :
createAuthService<User>({ config, fetchUser: async (accessToken) => { /* ... */ } });
Les fournisseurs qui ne sont pas de l'OIDC standard
Pas de document de discovery. Donnez les endpoints directement :
config: {
tokens: 'browser',
clientId,
redirectUri,
endpoints: {
authorization: 'https://example.com/oauth/authorize',
token: 'https://example.com/oauth/token',
userinfo: 'https://example.com/api/user',
},
}
Aucun endpoint userinfo, comme chez les fournisseurs OAuth2 simples tels que
GitHub. Renseignez userInfo.url ou fetchUser.
Identifiants dans un en-tête. Certains token endpoints n'acceptent que HTTP Basic :
config: { /* ... */ tokenAuthMethod: 'basic' }
Paramètres propres au fournisseur, comme l'audience d'Auth0 :
config: { /* ... */ extraParams: { audience: 'https://api.example.com' } }
Plusieurs fournisseurs sur une page. Chaque client isole ses valeurs stockées par
clientId, donc deux connexions en cours ne s'écrasent pas. Renseignez storageKey quand
deux clients partagent un fournisseur.
Les rôles venant du fournisseur
createAuthService<User, Permissions>({
config,
access: {
getRole: (user) => user?.role ?? user?.groups?.[0] ?? null,
permissions: ROLE_PERMISSIONS,
},
});
L'endroit où se trouvent les rôles dépend du fournisseur. Keycloak place les realm roles
dans realm_access.roles sur l'access token, pas dans userinfo : une application qui les
veut configure donc un mapper ou les lit côté serveur. Voir
Vérification des tokens.
Injection de dépendances
Le code applicatif importe en général le service, ce qui est plus simple et marche hors d'un
composant. Les tokens servent au code qui ne peut pas importer votre application :
OAuthCallback injecte AuthServiceToken quand on ne lui donne pas de service, et un test
peut fournir un double.
authModule(auth) dans src/app.config.ts enregistre AuthServiceToken et AuthClientToken.
Côté serveur la même instance sert toutes les requêtes. C'est sans risque parce qu'elle ne
garde rien sur le visiteur : l'utilisateur connecté vient des locals de la requête, que
sessionLocals remplit à chaque requête. Voir Modules d'application et
Injection de dépendances.
Ce que le paquet vérifie, et ce qu'il ne vérifie pas
Vérifié. Le state d'un callback doit correspondre à celui que ce client a envoyé,
c'est le contrôle CSRF. Le vérificateur PKCE est à usage unique, donc un callback rejoué
échoue. Un sub de userinfo qui ne correspond pas au token d'identité est rejeté, comme
OIDC l'exige. Un rafraîchissement refusé avec invalid_grant efface le refresh
token enregistré, puisque la RFC 6749 le dit terminé ; tout autre échec (une preuve fausse,
une demande de nonce, une connexion coupée) concerne la tentative et le conserve.
Un returnTo arrive par la query string : il est donc contrôlé par l'attaquant. Tout ce qui
n'est pas un chemin de cette origine est refusé, sauf si son origine figure dans
allowedReturnOrigins. Le mode serveur le vérifie au début du flux, et de nouveau au moment de
rediriger.
Non vérifié, délibérément. Le client ne vérifie pas les signatures des tokens. Ils arrivent par un appel TLS direct que ce client fait au endpoint de token, en échangeant un code obtenu avec un vérificateur PKCE que lui seul détient, et OIDC autorise un client en flux de code à s'appuyer là-dessus. L'identité vient d'un aller-retour userinfo plutôt que du décodage d'un token, ce qui est de toute façon la source la plus sûre.
La vérification de signature appartient à l'API qui reçoit le token. C'est Vérification des tokens, et elle n'est pas optionnelle.
Les pièces de plus bas niveau
Le service est construit à partir de celles-ci, exportées pour ce qu'il ne couvre pas.
| Export | Depuis | Ce que c'est |
|---|---|---|
createBrowserClient |
@fluixi/oauth2 |
le client de protocole de tokens: 'browser' : PKCE, stockage, rafraîchissement, DPoP |
createServerClient |
@fluixi/oauth2 |
la moitié navigateur de tokens: 'server' ; appelle /auth/* et ne voit jamais de token |
discover, clearDiscoveryCache |
@fluixi/oauth2 |
lit et met en cache /.well-known/openid-configuration |
createPkcePair, createVerifier, createChallenge, createStateValue |
@fluixi/oauth2 |
PKCE (S256) et valeurs de state |
safeReturnTo |
@fluixi/oauth2 |
le contrôle appliqué à une destination après connexion |
expiryFromJwt |
@fluixi/oauth2 |
lit exp dans un JWT sans le vérifier |
createDpopKey, proofFor, thumbprintOf, boundKeyOf |
@fluixi/oauth2 |
clés et preuves DPoP, empreintes RFC 7638, le cnf.jkt d'un token |
presets |
@fluixi/oauth2/presets |
les presets de fournisseurs ci-dessus |
rolesFrom |
@fluixi/oauth2/roles |
où chaque fournisseur range les rôles ; sans risque dans un bundle navigateur |
Suite : Better Auth.