Discovery — Identifier l'utilisateur
Utiliser le service Discovery pour résoudre l'organisation d'un utilisateur avant l'authentification.
Discovery — Identifier l'utilisateur
Le service Discovery est le gardien de la pré-authentification. Il résout l'organisation d'un utilisateur (ou d'un hostname) sans que votre code n'ait besoin de connaître les identifiants internes de Keycloak — à noter toutefois que
network_realm_id(déprécié) reste exposé dans certaines réponses pour rétro-compatibilité, voir plus bas.
Pourquoi cette étape
Avant toute authentification OAuth2, votre Wakapp doit savoir vers quel realm Keycloak rediriger l'utilisateur. Dans une architecture multi-tenant, chaque Network a son propre realm. Discovery résout cette correspondance de façon sécurisée, côté serveur, sans que votre code n'ait à connaître les identifiants internes de Keycloak.
C'est aussi le seul service public accessible sans token — par définition, car l'utilisateur n'est pas encore authentifié.
Concepts clés
- Subdomain : chaîne courte identifiant un Network ou Customer (
app.test,acme-prod). Peut contenir des points. - WID : identifiant court (6 chars alphanum) utilisé pour les entités (
NET001,ACM001). - Anti-énumération : la latence est normalisée (~80ms) sur
POST /discover/start-loginet sur les routes/internal/*(~50ms). Les lookups directsGET /discover/emailetGET /discover/subdomainn'ont aucun padding de latence — voir la section dédiée plus bas. - Realm : concept Keycloak — 1 Network = 1 realm. Le champ
network_realm_ida été réintroduit dans les réponses de/discover/subdomainet/discover/email(décision produit assumée pour la rétro-compatibilité, VULN-002) ; il est marqué@deprecatedmais bien présent — ce n'est pas un champ masqué.
Les 3 patterns disponibles
Pattern 1 — start-login par hostname (recommandé)
Le flow recommandé pour les Wakapps mono-tenant. Discovery résout automatiquement le client_id Keycloak à partir du hostname.
httpPOST {DISCOVERY_API_URL}/discover/start-login Content-Type: application/json { "host": "app.test", "redirectUri": "https://app.test.wakastart.app/api/auth/callback", "state": "550e8400-e29b-41d4-a716-446655440000", "codeChallenge": "<base64url(SHA256(code_verifier))>", "codeChallengeMethod": "S256" }
json{ "keycloakUrl": "https://auth.example.com/realms/<realm>/protocol/openid-connect/auth?client_id=<resolved>&redirect_uri=...&state=...&code_challenge=...&code_challenge_method=S256" }
Avantages :
- Zéro connaissance du realm ou du
client_idcôté Wakapp. - Discovery résout host → Network → App → client_id automatiquement.
- Élimine le couplage avec la convention de nommage Keycloak (auto-généré du WID, ex:
7OWWAA).
Contraintes : le hostname doit correspondre au subdomain enregistré en base pour le Network cible.
Pattern 2 — start-login par email (Microsoft-style)
L'utilisateur saisit son email. Discovery identifie son organisation et retourne la même keycloakUrl.
httpPOST {DISCOVERY_API_URL}/discover/start-login Content-Type: application/json { "email": "john@acme.com", "redirectUri": "https://app.test.wakastart.app/api/auth/callback", "state": "550e8400-e29b-41d4-a716-446655440000", "codeChallenge": "<base64url(SHA256(code_verifier))>", "codeChallengeMethod": "S256" }
Filtrage de sécurité : Discovery ne retourne les comptes que si l'email est associé à l'App correspondant à l'Origin de la requête (app.url_front). Un utilisateur avec le même email sur une autre app ne remontera pas.
Quand utiliser : si votre Wakapp héberge plusieurs tenants sur le même hostname, ou si vous implémentez une page de login commune type portail.
Ne fournissez jamais
hostsimultanément — la réponse sera400 invalid_request.
Pattern 3 — Lookup direct (informationnel)
Pour afficher des infos avant le login (logo, nom de l'organisation, IDP type) ou après login pour le catalogue d'apps.
httpGET /discover/email?email=john@acme.com
json{ "users": [{ "user_email": "john@acme.com", "customer_wid": "ACM001", "customer_name": "Acme Corp", "customer_subdomain": "acme", "network_wid": "NET001", "network_subdomain": "acme-prod", "partner_wid": "PTR001", "idp_id": "uuid-idp", "idp_type": "OIDC", "keycloak_org_alias": "ACM001", "app_wid": "APP001", "app_name": "Acme Dashboard", "app_client_id": "acme-app", "app_url_front": "https://acme.example.com" }] }
Champs essentiels :
| Champ | Usage |
|---|---|
network_subdomain / customer_subdomain | Identifie le tenant logique |
app_client_id | Keycloak clientId (utile si vous voulez forcer un appId dans start-login) |
keycloak_org_alias | Alias de l'organisation Keycloak (customer.wid), utilisable comme kc_idp_hint |
idp_type | OIDC ou SAML (si IDP fédéré configuré) |
network_realm_idest réintroduit et exposé par/discover/subdomainet/discover/email(VULN-002 : décision produit acceptée pour la rétro-compatibilité). Le champ est marqué@deprecatedmais bien présent dans la réponse — ce n'est pas un champ masqué.
Lookup par subdomain
httpGET /discover/subdomain?subdomain=acme-prod
json{ "subdomain": "acme-prod", "result": { "type": "network", "network_wid": "NET001", "network_subdomain": "acme-prod", "partner_wid": "PTR001", "app_wid": "APP001", "app_client_id": "acme-app", "app_url_front": "https://acme.example.com" } }
Utile pour pré-remplir le contexte (logo, nom) au chargement de la page de login. La réponse est { result: null } si le subdomain est inconnu — même format, latence normalisée.
Lookup d'applications
httpGET /discover/apps/customer?customerId={uuid_or_wid} GET /discover/apps/user?userId={uuid} GET /discover/apps/network?networkId={uuid}
Retourne les applications accessibles pour une entité donnée. À utiliser après login pour construire un catalogue d'apps ou un menu de navigation inter-apps.
Extraction du subdomain
Le subdomain peut être multi-label (contient des points). Convention de nommage :
text{subdomain}.{platform-domain} app.test.wakastart.app └──┬───┘ └──────┬─────┘ subdomain platform domain
Fonction d'extraction recommandée (TypeScript) :
typescriptfunction extractSubdomain(hostname: string): string { if (!hostname) return ""; // Méthode 1 : variable d'env (recommandée) const suffix = process.env.NEXT_PUBLIC_PLATFORM_DOMAIN_SUFFIX; // ex: "wakastart.app" if (suffix && hostname.endsWith(`.${suffix}`)) { return hostname.slice(0, -(suffix.length + 1)); } // Méthode 2 : fallback — strip les 2 derniers labels DNS const labels = hostname.split("."); if (labels.length <= 2) return ""; // localhost ou domaine simple return labels.slice(0, -2).join("."); } // Exemples (suffix = "wakastart.app") : // "app.test.wakastart.app" → "app.test" // "acme.wakastart.app" → "acme" // "localhost" → "" (mode dev local)
Règle de validation DB :
network.subdomainaccepte^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$— alphanum + tirets + points, commence et finit par alphanum.
Subdomain vs email — quand utiliser quoi
| Critère | host (hostname) | email |
|---|---|---|
| Wakapp mono-tenant | Recommandé | Non nécessaire |
| Portail multi-tenant | Non adapté | Recommandé |
| L'utilisateur connaît son org | Recommandé | Optionnel |
| L'utilisateur ne connaît que son email | Non applicable | Recommandé |
| Sécurité (résistance à l'énumération) | Meilleure | Bonne (filtrage Origin) |
Anti-énumération et throttling
[!WARNING] L'anti-énumération n'est pas uniforme sur tout le service.
POST /discover/start-loginet les routes/internal/*normalisent la latence des réponses négatives.GET /discover/emailetGET /discover/subdomainretournent en revanche le payload tenant complet dès qu'il y a un match, sans aucun padding de latence — l'existence d'un compte ou d'un tenant reste observable via le contenu de la réponse sur ces deux routes. État transitoire assumé pendant la migration des auth wakapps ; ne vous appuyez pas sur ces deux endpoints pour un flow qui exige une résistance forte à l'énumération.
| Mesure | Détail |
|---|---|
| Réponse uniforme (forme JSON) | { users: [] } ou { result: null } — la clé est toujours présente, que l'entité existe ou non |
| Latence normalisée | ~80ms sur /discover/start-login, ~50ms sur /internal/*. Absente sur GET /discover/email et GET /discover/subdomain |
| Rate limiting court | 5 requêtes / 60 s par IP |
| Rate limiting moyen | 20 requêtes / 600 s (10 min) par IP |
| Rate limiting long | 100 requêtes / 3600 s (1 h) par IP |
Règle UX impérative : affichez toujours un message générique comme "Vérifiez votre email et réessayez", jamais "Cet email n'existe pas".
Gestion d'erreurs
| Status | Body | Cause | Action |
|---|---|---|---|
400 | invalid_request | email + host fournis simultanément, ou aucun | Vérifier le body |
400 | invalid_request | Body absent ou malformé | Vérifier Content-Type et payload |
404 | not_found | Aucun Network ne correspond | Afficher message générique |
429 | Too Many Requests | Rate limit dépassé | Backoff exponentiel, header Retry-After |
Bonnes pratiques
- Appelez
start-loginune seule fois par tentative de login — ne retentez pas automatiquement sur404. - Extrayez le subdomain depuis
window.location.hostnameavec la fonctionextractSubdomain, ne le hardcodez jamais. - Stockez
client_idetrealm(extraits de lakeycloakUrlretournée) en sessionStorage avant le redirect vers Keycloak. - Ne loggez jamais les adresses email passées à Discovery — elles sont des données personnelles.
- Testez localement avec
subdomain=""et implémentez un champ de saisie manuelle pour le mode dev.
Pièges classiques
- Mauvaise extraction du subdomain : envoyer
host="app"au lieu dehost="app.test"→404 not_found. Voir la fonctionextractSubdomainci-dessus. appIdhardcodé : ne pas envoyerappIdsauf si vous gérez plusieurs apps sur le même hostname. Discovery résout depuis le host automatiquement.email+hostsimultanés :400 invalid_request. Choisir l'un ou l'autre.- Réponse vide traitée comme erreur :
{ users: [] }est une réponse valide signifiant "non trouvé". Ne pas déclencher un crash applicatif.
Aller plus loin
- Authentification — OAuth2 PKCE : utiliser la
keycloakUrlretournée parstart-login - Pièges classiques : diagnostic des erreurs fréquentes de Discovery