Nœud d'authentification personnalisé
Les nœuds natifs de TOSIAM couvrent la plupart des besoins, mais pas tout. Ce tutoriel construit un graphe à deux écrans avec une navigation qu’aucune combinaison de nœuds standards ne permet : un écran mot de passe avec deux vrais boutons distincts Submit / Back, où Back revient à l’écran précédent avec le nom d’utilisateur déjà saisi. Cela nécessite d’écrire deux nœuds personnalisés (extension par SPI, voir Graphes d’authentification) et deux rendus front-end personnalisés — c’est ce second point, la notion de stage, qui fait toute la différence.
Ce qui est — et n’est pas — possible avec un formulaire auto-généré seul
UsernameCollectorNodene pré-remplit jamais son champ : soit il redemande un champ vide, soit il saute complètement l’écran si le shared state contient déjà une valeur.PageNoderegroupe plusieurs nœuds sur un seul écran, mais c’est unSingleOutcomeNode: une seule sortie possible, quel que soit le nombre d’enfants — impossible de dispatcher un clic « Back » vers un nœud différent d’un clic « Submit ».- Le formulaire auto-généré (voir Callbacks et génération de formulaire) ne rend jamais deux boutons de soumission distincts : au mieux, un
ChoiceCallbackde deux options se rend en boutons radio + un unique bouton générique.
Pour deux vrais boutons, chacun avec sa propre action, il faut sortir du rendu générique et écrire un rendu personnalisé pour ce stage — c’est-à-dire pour ce type de nœud précisément, comme le sont déjà les nœuds UsernameCollectorNode/DataStore1/ConsentNode en interne.
Vue d’ensemble du mécanisme
Chaque callback envoyé au client est neutre (voir Callbacks et génération de formulaire), mais chaque réponse porte aussi un champ stage, qui est le nom du nœud courant. Le client tosiam-authentication-ui choisit son rendu par une fabrique indexée sur ce nom :
StageRendererFactory.getRenderer(stage) // cherche un renderer enregistré pour stage.stage
→ trouvé → rendu personnalisé (boutons dédiés, logique JS spécifique...)
→ sinon → DefaultStageRenderer (formulaire générique, un seul bouton)
registerNodeRenderer(type, RendererClass, callbackCount) enregistre un tel rendu. C’est exactement ce que fait déjà le framework pour ses propres nœuds (registerNodeRenderer('PasswordCollectorNode', PasswordCollectorRenderer, 1), etc.) — rien n’empêche d’enregistrer un rendu pour votre type de nœud.
Le module auth d’un projet généré est un clone complet et éditable de tosiam-authentication-ui (voir Générer un projet avec Maven), pas une dépendance boîte noire : on peut y ajouter directement de nouveaux fichiers de rendu.
Écran 2 — le nœud Java
Contrairement au tutoriel précédent (qui utilisait un ChoiceCallback), on utilise ici un HiddenValueCallback : un champ invisible que le rendu personnalisé positionnera différemment selon le bouton cliqué.
package org.tst.tosiam.example.graph.nodes;
import com.sun.identity.authentication.callbacks.HiddenValueCallback;
import org.tst.tosiam.auth.graph.GraphContext;
import org.tst.tosiam.auth.graph.GraphNode;
import org.tst.tosiam.auth.graph.NodeResult;
import org.tst.tosiam.auth.graph.meta.NodeMetadata;
import org.tst.tosiam.auth.graph.meta.OutcomeProvider;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.PasswordCallback;
import java.util.Arrays;
import java.util.List;
import static com.sun.identity.authentication.util.ISAuthConstants.SHARED_STATE_PASSWORD;
import static org.tst.tosiam.auth.graph.GraphContext.HIDDEN_STATE_HIDDEN;
@NodeMetadata(
type = "PasswordWithBackNode",
outcomeProvider = PasswordWithBackNode.Outcomes.class
)
public final class PasswordWithBackNode implements GraphNode {
private static final String BACK_VALUE = "back";
@Override
public List<Callback> getInitialCallbacks() {
return buildCallbacks();
}
@Override
public NodeResult process(GraphContext context) {
String hidden = context.submittedCallbacks().getStringOrNull(HIDDEN_STATE_HIDDEN);
context.submittedCallbacks().consume(HIDDEN_STATE_HIDDEN);
if (BACK_VALUE.equals(hidden)) {
return NodeResult.goTo("back");
}
char[] secret = context.submittedCallbacks().getSecretOrNull(SHARED_STATE_PASSWORD);
if (secret == null || secret.length == 0) {
return NodeResult.requestInput(buildCallbacks());
}
context.submittedCallbacks().consume(SHARED_STATE_PASSWORD);
context.sharedState().put(GraphContext.TRANSIENT_PASSWORD, secret.clone());
Arrays.fill(secret, '\0');
return NodeResult.goTo("submit");
}
private List<Callback> buildCallbacks() {
return List.of(
new PasswordCallback("Password", false),
new HiddenValueCallback("hidden", "")
);
}
public static final class Outcomes implements OutcomeProvider {
@Override
public List<String> getOutcomes() {
return List.of("submit", "back");
}
}
}
Le choix « Back » est vérifié avant le mot de passe : revenir en arrière sans avoir rempli le mot de passe n’exige rien de plus. HiddenValueCallback n’a pas le piège de NameCallback — getValue()/setValue() correspondent bien à ce qui transite en JSON.
Écran 1 — le nœud Java (identique au tutoriel précédent)
package org.tst.tosiam.example.graph.nodes;
import org.tst.tosiam.auth.graph.GraphContext;
import org.tst.tosiam.auth.graph.NodeResult;
import org.tst.tosiam.auth.graph.meta.NodeMetadata;
import org.tst.tosiam.auth.graph.meta.SingleOutcomeNode;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.NameCallback;
import java.util.List;
import static com.sun.identity.authentication.util.ISAuthConstants.SHARED_STATE_USERNAME;
@NodeMetadata(
type = "PrefillableUsernameNode",
outcomeProvider = SingleOutcomeNode.SingleOutcomeProvider.class,
providesIdentity = true
)
public final class PrefillableUsernameNode extends SingleOutcomeNode {
@Override
public List<Callback> getInitialCallbacks() {
return List.of(new NameCallback("Username"));
}
@Override
public NodeResult process(GraphContext context) {
String submitted = context.submittedCallbacks().getStringOrNull(SHARED_STATE_USERNAME);
if (submitted != null && !submitted.isBlank()) {
context.submittedCallbacks().consume(SHARED_STATE_USERNAME);
context.putShared(SHARED_STATE_USERNAME, submitted);
return goToNext();
}
// NameCallback(prompt, defaultName) est un piège : defaultName n'alimente que
// getDefaultName(), jamais getName() — que la couche REST sérialise dans
// input[0].value. Pré-remplir ce que le client affiche réellement exige setName()
// sur le callback à un seul argument.
NameCallback callback = new NameCallback("Username");
String previous = (String) context.getShared(SHARED_STATE_USERNAME);
if (previous != null && !previous.isBlank()) {
callback.setName(previous);
}
return NodeResult.requestInput(List.of(callback));
}
}
Enregistrer les nœuds (Guice + SPI)
package org.tst.tosiam.example.graph.config;
import com.google.inject.AbstractModule;
import com.google.inject.TypeLiteral;
import com.google.inject.multibindings.Multibinder;
import org.forgerock.guice.core.GuiceModule;
import org.tst.tosiam.auth.graph.GraphNode;
import org.tst.tosiam.example.graph.nodes.PasswordWithBackNode;
import org.tst.tosiam.example.graph.nodes.PrefillableUsernameNode;
@GuiceModule
public class ExampleGraphNodesModule extends AbstractModule {
@Override
protected void configure() {
Multibinder<Class<? extends GraphNode>> binder =
Multibinder.newSetBinder(binder(), new TypeLiteral<Class<? extends GraphNode>>() {
});
binder.addBinding().toInstance(PrefillableUsernameNode.class);
binder.addBinding().toInstance(PasswordWithBackNode.class);
}
}
custom-java/src/main/resources/META-INF/services/com.google.inject.AbstractModule
org.tst.tosiam.example.graph.config.ExampleGraphNodesModule
Placez ces trois classes dans custom-java/src/main/java/org/tst/tosiam/example/graph/{nodes,config}/.
Le rendu de l’écran 2 — deux vrais boutons
C’est la vraie nouveauté par rapport au tutoriel précédent. Le pattern exact — boutons type="button" (pas type="submit") qui déclenchent la soumission via un SubmitEvent synthétique — est déjà utilisé en interne par ConsentRenderer pour un cas similaire (Grant/Deny) ; on le reprend tel quel :
// auth/src/app/pages/login/renderers/password-with-back-renderer.ts
import { addHeader, createFormElement } from './renderer-utils';
import { AbstractStageRenderer } from './abstract-stage-renderer';
export class PasswordWithBackRenderer extends AbstractStageRenderer {
override getHtml(): string {
const passwordField = createFormElement(this._stage, 0);
this._formElements = [passwordField];
return `
${addHeader(this._stage)}
<div class="card-container">
<form action="#" method="post" autocomplete="off">
<div data-callback-wrapper="0">${passwordField.getHtml()}</div>
<input type="hidden" name="callback_1" id="callback_1" value="" />
<div class="wizard-action-group">
<button type="button" class="submit-button" data-hidden-value="back">Back</button>
<button type="button" class="submit-button" data-hidden-value="">Submit</button>
</div>
</form>
</div>
`;
}
override afterRender(root: Element): void {
super.afterRender(root);
const hiddenInput = root.querySelector<HTMLInputElement>('#callback_1');
const form = root.querySelector('form');
if (!hiddenInput || !form) return;
for (const btn of root.querySelectorAll<HTMLButtonElement>('[data-hidden-value]')) {
btn.addEventListener('click', () => {
hiddenInput.value = btn.dataset.hiddenValue ?? '';
form.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true }));
});
}
}
}
required. Deux boutons natifs type="submit" déclencheraient tous deux la validation native du navigateur, bloquant le clic sur Back tant que le mot de passe n’est pas rempli. En utilisant type="button" et en déclenchant nous-mêmes un SubmitEvent via dispatchEvent(), on contourne entièrement la validation native — exactement ce que fait déjà ConsentRenderer en interne. Le nœud Java lit le champ caché avant le mot de passe, donc « Back » fonctionne même mot de passe vide.Le champ mot de passe (createFormElement(this._stage, 0)) est produit par la même fabrique que le formulaire auto-généré — on n’écrit à la main que ce qui diffère (le champ caché et les deux boutons).
Le rendu de l’écran 1 — juste un libellé différent
// auth/src/app/pages/login/renderers/prefillable-username-renderer.ts
import { addHeader, buildFormCallbacks } from './renderer-utils';
import { AbstractStageRenderer } from './abstract-stage-renderer';
export class PrefillableUsernameRenderer extends AbstractStageRenderer {
override getHtml(): string {
const { html: formCallbacksHtml, formElements } = buildFormCallbacks(this._stage);
this._formElements = formElements;
return `
${addHeader(this._stage)}
<div class="card-container">
<form action="#" method="post" autocomplete="off">
${formCallbacksHtml}
<button type="submit" class="submit-button">Next</button>
</form>
</div>
`;
}
}
Ici, tout le formulaire reste auto-généré (buildFormCallbacks) ; seul le bouton change.
Enregistrer les rendus
Ajoutez les deux imports et les deux registerNodeRenderer(...) dans le fichier existant auth/src/app/pages/login/renderers/register.ts :
import { PasswordWithBackRenderer } from './password-with-back-renderer';
import { PrefillableUsernameRenderer } from './prefillable-username-renderer';
// ...
function registerRenderers(): void {
// ... les enregistrements existants ...
registerNodeRenderer('PrefillableUsernameNode', PrefillableUsernameRenderer, 1);
registerNodeRenderer('PasswordWithBackNode', PasswordWithBackRenderer, 2);
}
Buildez :
mvn org.tst.tosiam:tosiam-project-generator-maven-plugin:3.36.0:up
tosiam-quickstart/bin/tosiam.sh réextrait tout tosiam.war par-dessus tosiam-quickstart/apache-tomcat-11.0.14/webapps/tosiam/ à chaque up/restart, y compris auth/ — une mesure du script pour forcer Tomcat à toujours déployer web.xml/les JSP (dont isAlive.jsp), sans laquelle le démarrage reste bloqué. Effet de bord : le auth/main.js fraîchement construit par Maven est écrasé par la version figée à la création du QuickStart, après le build et avant le démarrage de Tomcat — vos rendus personnalisés semblent alors ignorés sans aucune erreur.
Contournement : après up/restart, reconstruisez le module auth seul en pointant sa sortie directement sur le webapp déployé, sans redémarrer Tomcat (il sert les fichiers statiques directement depuis le disque) :
cd auth
TOSIAM_AUTHENTIFICATION="$(pwd)/../tosiam-quickstart/apache-tomcat-11.0.14/webapps/tosiam/auth" \
npx webpack --config webpack.dev.config.js
Rafraîchissez simplement le navigateur ensuite.
Le graphe
{
"startNodeId": "username",
"steps": {
"username": { "type": "PrefillableUsernameNode", "config": {}, "outcomes": { "outcome": "password" } },
"password": {
"type": "PasswordWithBackNode",
"config": {},
"outcomes": { "submit": "check", "back": "username" }
},
"check": {
"type": "DataStoreNode",
"config": { "authLevel": 0 },
"outcomes": { "true": "success", "false": "failure" }
},
"success": { "type": "SuccessNode", "config": {}, "outcomes": {} },
"failure": { "type": "FailureNode", "config": {}, "outcomes": {} }
}
}
Créez-le et exposez-le exactement comme dans Callbacks et génération de formulaire :
mvn org.tst.tosiam:tosiam-project-generator-maven-plugin:3.36.0:ssoadm \
-Dssoadm.args="create-auth-graph --realm / --name wizard --datafile $(pwd)/graphe-wizard.json"
mvn org.tst.tosiam:tosiam-project-generator-maven-plugin:3.36.0:ssoadm \
-Dssoadm.args="create-auth-instance --realm / --name wizard --authtype AuthGraph"
mvn org.tst.tosiam:tosiam-project-generator-maven-plugin:3.36.0:ssoadm \
-Dssoadm.args="update-auth-instance --realm / --name wizard --attributevalues tosiam-auth-graph-id=wizard"
mvn org.tst.tosiam:tosiam-project-generator-maven-plugin:3.36.0:ssoadm \
-Dssoadm.args="create-auth-cfg --realm / --name wizard"
mvn org.tst.tosiam:tosiam-project-generator-maven-plugin:3.36.0:ssoadm \
-Dssoadm.args="add-auth-cfg-entr --realm / --name wizard --modulename wizard --criteria REQUISITE"
Résultat
Ouvrez http://localhost:8080/tosiam/auth/#login?service=wizard et saisissez demo — le bouton porte désormais le libellé Next :

L’écran suivant affiche deux vrais boutons, Back et Submit — plus aucune trace de bouton radio ou de libellé générique :

Cliquez sur Back : retour à l’écran précédent, demo déjà rempli — exactement la valeur écrite en shared state par PrefillableUsernameNode lors du premier passage, relue et injectée via setName() :

Cliquez de nouveau sur Next, saisissez changeit, puis Submit : DataStoreNode authentifie normalement.