WakaStart
Getting started

Discovery — Identifying the user

Using the Discovery service to resolve a user's organization before authentication.

Version v1.07 min read

Discovery — Identifying the user

The Discovery service is the gatekeeper of pre-authentication. It resolves a user's (or a hostname's) organization without your code ever needing to know Keycloak's internal identifiers — note however that network_realm_id (deprecated) is still exposed in some responses for backward compatibility, see below.

Why this step

Before any OAuth2 authentication, your Wakapp needs to know which Keycloak realm to redirect the user to. In a multi-tenant architecture, each Network has its own realm. Discovery resolves this mapping securely, server-side, without your code ever needing to know Keycloak's internal identifiers.

It is also the only public service reachable without a token — by definition, since the user is not yet authenticated.

Key concepts

  • Subdomain: short string identifying a Network or Customer (app.test, acme-prod). May contain dots.
  • WID: short identifier (6 alphanumeric chars) used for entities (NET001, ACM001).
  • Anti-enumeration: latency is normalized (~80ms) on POST /discover/start-login and on the /internal/* routes (~50ms). The direct lookups GET /discover/email and GET /discover/subdomain have no latency padding — see the dedicated section below.
  • Realm: a Keycloak concept — 1 Network = 1 realm. The network_realm_id field has been reintroduced in the responses of /discover/subdomain and /discover/email (accepted product decision for backward compatibility, VULN-002); it is marked @deprecated but still present — it is not a hidden field.

The 3 available patterns

The recommended flow for single-tenant Wakapps. Discovery automatically resolves the Keycloak client_id from the hostname.

http
POST {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" }

Advantages:

  • Zero knowledge of the realm or client_id on the Wakapp side.
  • Discovery resolves host → Network → App → client_id automatically.
  • Removes coupling with the Keycloak naming convention (auto-generated from the WID, e.g. 7OWWAA).

Constraints: the hostname must match the subdomain registered in the database for the target Network.


Pattern 2 — start-login by email (Microsoft-style)

The user enters their email. Discovery identifies their organization and returns the same keycloakUrl.

http
POST {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" }

Security filtering: Discovery only returns accounts if the email is associated with the App matching the request's Origin (app.url_front). A user with the same email on another app will not show up.

When to use it: if your Wakapp hosts multiple tenants on the same hostname, or if you are implementing a portal-style common login page.

Never supply email AND host at the same time — the response will be 400 invalid_request.


Pattern 3 — Direct lookup (informational)

To display information before login (logo, organization name, IDP type) or after login for the app catalog.

http
GET /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" }] }

Essential fields:

FieldUsage
network_subdomain / customer_subdomainIdentifies the logical tenant
app_client_idKeycloak clientId (useful if you want to force an appId in start-login)
keycloak_org_aliasKeycloak organization alias (customer.wid), usable as kc_idp_hint
idp_typeOIDC or SAML (if a federated IDP is configured)

network_realm_id is reintroduced and exposed by /discover/subdomain and /discover/email (VULN-002: accepted product decision for backward compatibility). The field is marked @deprecated but is still present in the response — it is not a hidden field.


Lookup by subdomain

http
GET /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" } }

Useful for pre-filling the context (logo, name) when the login page loads. The response is { result: null } if the subdomain is unknown — same format, normalized latency.


Application lookup

http
GET /discover/apps/customer?customerId={uuid_or_wid} GET /discover/apps/user?userId={uuid} GET /discover/apps/network?networkId={uuid}

Returns the applications accessible for a given entity. To be used after login to build an app catalog or a cross-app navigation menu.


Extracting the subdomain

The subdomain can be multi-label (contains dots). Naming convention:

text
{subdomain}.{platform-domain} app.test.wakastart.app └──┬───┘ └──────┬─────┘ subdomain platform domain

Recommended extraction function (TypeScript):

typescript
function 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)

Database validation rule: network.subdomain accepts ^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$ — alphanumeric + hyphens + dots, must start and end with an alphanumeric character.


Subdomain vs. email — when to use which

Criterionhost (hostname)email
Single-tenant WakappRecommendedNot necessary
Multi-tenant portalNot suitableRecommended
The user knows their orgRecommendedOptional
The user only knows their emailNot applicableRecommended
Security (resistance to enumeration)BetterGood (Origin filtering)

Anti-enumeration and throttling

[!WARNING] Anti-enumeration is not uniform across the entire service. POST /discover/start-login and the /internal/* routes normalize the latency of negative responses. GET /discover/email and GET /discover/subdomain, on the other hand, return the full tenant payload as soon as there is a match, with no latency padding whatsoever — the existence of an account or tenant remains observable through the response content on these two routes. This is a transitional state accepted during the wakapp auth migration; do not rely on these two endpoints for a flow that requires strong resistance to enumeration.

MeasureDetail
Uniform response (JSON shape){ users: [] } or { result: null } — the key is always present, whether the entity exists or not
Normalized latency~80ms on /discover/start-login, ~50ms on /internal/*. Absent on GET /discover/email and GET /discover/subdomain
Short rate limit5 requests / 60s per IP
Medium rate limit20 requests / 600s (10 min) per IP
Long rate limit100 requests / 3600s (1 h) per IP

Mandatory UX rule: always display a generic message such as "Check your email and try again", never "This email does not exist".


Error handling

StatusBodyCauseAction
400invalid_requestemail + host provided simultaneously, or neitherCheck the body
400invalid_requestMissing or malformed bodyCheck Content-Type and payload
404not_foundNo matching NetworkDisplay a generic message
429Too Many RequestsRate limit exceededExponential backoff, Retry-After header

Best practices

  • Call start-login only once per login attempt — do not automatically retry on 404.
  • Extract the subdomain from window.location.hostname using the extractSubdomain function, never hardcode it.
  • Store client_id and realm (extracted from the returned keycloakUrl) in sessionStorage before the redirect to Keycloak.
  • Never log the email addresses passed to Discovery — they are personal data.
  • Test locally with subdomain="" and implement a manual entry field for dev mode.

Common pitfalls

  • Incorrect subdomain extraction: sending host="app" instead of host="app.test" → 404 not_found. See the extractSubdomain function above.
  • Hardcoded appId: do not send appId unless you are managing several apps on the same hostname. Discovery resolves it from the host automatically.
  • email + host together: 400 invalid_request. Choose one or the other.
  • Empty response treated as an error: { users: [] } is a valid response meaning "not found". Do not trigger an application crash.

Learn more