Hosted Login Guidelines
How to use Hosted Login: the optional mode in which the auth platform (HAP) hosts the login experience at the auth domain, so your web app ships no login UI of its own — no sign-in, sign-up, OTP, or passkey screens. Your app redirects the browser to HAP, the user signs in on a HAP-hosted page, and the browser comes back authenticated.
Status: available now. Hosted Login shipped in
specs/007-hosted-loginand is wired into two relying-party apps inspecs/008-connect-clients-hosted-login(the WBSP Website and the AI-Native CRM, both on tenantwbsp). Embedded login (your app renders the UI) and the SSO broker remain available and unaffected. The client code is the same across modes — adopting one is a config change, not a rewrite (see "Three modes, one client contract" below). A concrete, end-to-end worked example is at the end of this guide.
The three login modes (and why your client doesn't care which)
| Mode | Who renders the login UI | Client points its authorization_endpoint at |
|---|---|---|
| Embedded (today) | your app | n/a — the app calls the auth API directly for login, then /authorize |
| SSO broker (today) | a sibling "broker" app | the broker's handoff endpoint |
| Hosted Login (target) | the platform | the tenant's hosted authorize endpoint |
Hosted Login and the SSO broker present the identical client-facing contract — standard OpenID
Connect Authorization-Code + PKCE. The only difference is which URL the client treats as its
authorization endpoint. So a standard OIDC client switches between them by configuration, not code
(ideally by reading the authorization endpoint from discovery). Embedded differs only in that the app
also drives the login API itself; the /authorize → /token half is the same.
Recommendation: build your client as a plain, standards-compliant OIDC client (use an off-the-shelf library). Then embedded/broker/hosted are deployment choices, not code branches. See the Client Cookbook for ready-to-adapt code.
How a client uses Hosted Login
It is ordinary OIDC Authorization-Code + PKCE; only the source of the login page is HAP.
1. (server) Make a PKCE pair (verifier + S256 challenge) and a random `state`; keep them in a
pending-login server-side session. KEEP THE VERIFIER SERVER-SIDE.
2. Redirect the browser to the tenant's authorization endpoint (from discovery):
{HAP_BASE}/t/{tenant}/oauth2/authorize
?response_type=code&client_id=<your client_id>
&redirect_uri=<your callback, exact match>
&scope=openid%20profile%20email
&code_challenge=<challenge>&code_challenge_method=S256
&state=<state>&nonce=<nonce>
3. The platform shows the HOSTED login page, authenticates the user (any method the tenant enables),
then redirects the browser back to:
<your callback>?code=<CODE>&state=<state>
4. (callback, server) Verify `state`, then exchange the code at {HAP_BASE}/t/{tenant}/oauth2/token
with code_verifier + client_id + client_secret.
5. Verify the id_token against {HAP_BASE}/t/{tenant}/oauth2/jwks (iss = the public issuer,
aud = your client_id, nonce). Key your user on `sub`; mint your own session.That's it — no login screens in your app.
Cross-app SSO comes for free
After a hosted login, the platform holds a browser session at the auth domain. A second app in the
same tenant that requests prompt=none gets a code without the user re-authenticating —
seamless SSO. With no session, prompt=none redirects back with error=login_required; show your
"sign in" entry point (which is just a redirect to the authorize endpoint without prompt=none).
Discovery: make the mode a config value
Read the per-tenant discovery document and use the endpoints it gives you, rather than hard-coding:
GET {HAP_BASE}/t/{tenant}/.well-known/openid-configuration
→ { "issuer", "authorization_endpoint", "token_endpoint", "userinfo_endpoint",
"jwks_uri", "end_session_endpoint", ... }To switch a client between Hosted Login and the SSO broker, change only the configured
authorization_endpoint (hosted endpoint vs broker handoff URL). Everything else — client_id,
callback, token exchange, token verification — is unchanged.
Rules & expectations
- Opt-in: Hosted Login is enabled per tenant; embedded clients are unaffected and remain the default. Both can coexist in one tenant on the same identities.
- Same tenant for SSO: silent cross-app SSO only spans applications registered in the same tenant.
- Standards: OIDC Core authorization-code + PKCE; exact
redirect_urimatch; verifystate,iss,aud,nonce,exp. Use a maintained OIDC library rather than rolling your own. client_secret(confidential web apps): inject from your platform secret store; never commit.- Key on
sub(stable identity id), not email. - Lifecycle: a suspended/deleted/revoked identity stops yielding silent sign-ins; re-validate via refresh (refresh failure ⇒ sign out) and/or subscribe to identity-lifecycle webhooks.
- In-cluster: if your app runs in the same cluster as HAP, use split-horizon — back-channel
calls (token, JWKS, discovery) over the in-cluster Service, but
iss/audand the browser redirect use the public issuer/URL (see API Reference §1). - Branding: the hosted pages can carry your tenant's name/logo so the experience still feels like your product.
Worked example: two apps on tenant wbsp (feature 008)
A concrete, end-to-end setup connecting two relying parties to Hosted Login on one tenant, so a single sign-in serves both (cross-app SSO). This is the reference local-dev configuration.
Endpoints (tenant wbsp, local base http://localhost:8000)
| Purpose | URL |
|---|---|
Issuer (iss/aud base) | http://localhost:8000/t/wbsp |
| Discovery | http://localhost:8000/t/wbsp/.well-known/openid-configuration |
| Authorize (cookie-aware) | http://localhost:8000/t/wbsp/oauth2/authorize |
| Token | http://localhost:8000/t/wbsp/oauth2/token |
| JWKS | http://localhost:8000/t/wbsp/oauth2/jwks |
| End-session (logout) | http://localhost:8000/t/wbsp/hosted/logout |
The client redirects the browser to authorize; with no hap_session cookie it renders the
Hosted Login OTP page, then bounces back and issues the code to your redirect_uri.
Per-app configuration
WBSP Website (:3000) | AI-Native CRM (:30041) | |
|---|---|---|
| client type | confidential | confidential |
| redirect_uri | http://localhost:3000/api/auth/callback | http://localhost:30041/api/auth/callback/hap |
| post_logout_redirect_uri | http://localhost:3000/ | http://localhost:30041/login |
| scopes | openid profile email | openid profile email offline_access |
Enabling it (platform side)
- On the tenant, set
hosted_login_enabled = trueandpasswordless_jit_provisioning = true(the latter lets a first-time user be provisioned at the OTP step). - Register each app with its exact
redirect_uris+post_logout_redirect_uris. For local dev there is a one-shot seed:target/scripts/seed_hosted_login.py.
Three gotchas worth knowing
- Send
consent=grantedon authorize. The Hosted Login surface renders no consent screen, so if the client omits consent the authorize call raisesconsent_requiredand the browser is bounced back to the login page — an infinite loop. First-party apps should passconsent=granted(the embedded/headless flow already does). - Cookie
Securemust match the transport. Thehap_sessioncookie isSecure; browsers (notably Safari) drop aSecurecookie overhttp://localhost, so the session is never stored and authorize loops. The platform now setsSecureonly when the issuer ishttps— keep this in mind if you front HAP with plain http anywhere. - Exact
redirect_uri+ correct port. Run each app on the port itsredirect_uriwas registered with (the CRM on:30041, not:3000).
Single logout (FR-015)
Sign-out should clear the local app session and redirect the browser to the end-session
endpoint (/t/wbsp/hosted/logout?client_id=…&post_logout_redirect_uri=…) to revoke the shared
hap_session. Otherwise the next sign-in silently re-authenticates from the surviving platform
session. The post_logout_redirect_uri must be registered on the app.
Revocation (US4)
When an identity is suspended/deleted at the platform, propagate it two ways:
- Pull (backstop): re-validate periodically with the refresh token — a refresh failure
(
invalid_grant, "identity unavailable") means suspended/deleted/revoked → sign the user out. (The CRM does this hourly via Auth.js.) - Push (prompt): subscribe to identity-lifecycle webhooks
(
identity.suspended/deleted/reinstated/email_changed), verify theX-HAP-Signature: t=<unix>,v1=<hmac-sha256(secret,"<t>.<body>")>header, and update your local user mirror so the existing session is cut off on the next request. (The website does this.)