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-login and is wired into two relying-party apps in specs/008-connect-clients-hosted-login (the WBSP Website and the AI-Native CRM, both on tenant wbsp). 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)

ModeWho renders the login UIClient points its authorization_endpoint at
Embedded (today)your appn/a — the app calls the auth API directly for login, then /authorize
SSO broker (today)a sibling "broker" appthe broker's handoff endpoint
Hosted Login (target)the platformthe 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_uri match; verify state, 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/aud and 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)

PurposeURL
Issuer (iss/aud base)http://localhost:8000/t/wbsp
Discoveryhttp://localhost:8000/t/wbsp/.well-known/openid-configuration
Authorize (cookie-aware)http://localhost:8000/t/wbsp/oauth2/authorize
Tokenhttp://localhost:8000/t/wbsp/oauth2/token
JWKShttp://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 typeconfidentialconfidential
redirect_urihttp://localhost:3000/api/auth/callbackhttp://localhost:30041/api/auth/callback/hap
post_logout_redirect_urihttp://localhost:3000/http://localhost:30041/login
scopesopenid profile emailopenid profile email offline_access

Enabling it (platform side)

  1. On the tenant, set hosted_login_enabled = true and passwordless_jit_provisioning = true (the latter lets a first-time user be provisioned at the OTP step).
  2. 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=granted on authorize. The Hosted Login surface renders no consent screen, so if the client omits consent the authorize call raises consent_required and the browser is bounced back to the login page — an infinite loop. First-party apps should pass consent=granted (the embedded/headless flow already does).
  • Cookie Secure must match the transport. The hap_session cookie is Secure; browsers (notably Safari) drop a Secure cookie over http://localhost, so the session is never stored and authorize loops. The platform now sets Secure only when the issuer is https — keep this in mind if you front HAP with plain http anywhere.
  • Exact redirect_uri + correct port. Run each app on the port its redirect_uri was 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 the X-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.)