Nœud d'authentification personnalisé
Sur cette page
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.AbstractModuleorg.tst.tosiam.example.graph.config.ExampleGraphNodesModulePlacez 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 }));
});
}
}
}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.37.0:upLe 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.37.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.37.0:ssoadm \
-Dssoadm.args="create-auth-instance --realm / --name wizard --authtype AuthGraph"
mvn org.tst.tosiam:tosiam-project-generator-maven-plugin:3.37.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.37.0:ssoadm \
-Dssoadm.args="create-auth-cfg --realm / --name wizard"
mvn org.tst.tosiam:tosiam-project-generator-maven-plugin:3.37.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.
Mis à jour le