FleetlessFleetlessdocs
Recipes/Hosted Login

Recipe: the hosted login page — end-user auth without your app touching a password

This recipe builds an end-user sign-in that your app never sees a password for: your app redirects to a Fleetless-served login page, the user authenticates there, and your app exchanges the code it gets back for the same session client.auth.login() would have produced.

What this is, and what it deliberately is not

There are two ways an end user reaches a Fleetless token, and this page is one of them — the other is client.auth.login(), where your app collects the email and password itself. There is no self-registration: a user exists because an org admin invited or assigned them, or because their group’s IdP provisioned them. Use the hosted page when you want your app to never see a password at all: redirect the end user to a Fleetless-served page, they authenticate there (directly, or through their group’s IdP), and your app gets back the identical token shape login() would have produced. Both paths converge on the same Fleetless tokenclient.auth.me() answers identically either way.

This is also MCP’s front door — the central MCP server’s tools reach Fleetless through the same login page, IdP federation included. Nothing about using it from an ordinary app depends on that; it’s the same page either way.

Authorization Code + PKCE, mandatory, S256 only — this is OAuth 2.1, not the older implicit or password grants, and plain PKCE is not offered (a challenge equal to its verifier defends against nothing).

Prerequisite: register an OAuth client for your app

App Settings → OAuth clients, in the console. Give it a name and your app’s redirect URI(s) — the exact URI your app’s callback page lives at, matched by exact string comparison server-side, never a prefix. This returns a client_id your app needs for every call below.

client_id is not your appIdentifier. They are deliberately different identifiers — the app identifier is what client.auth.login() uses; the OAuth client_id is what this flow uses. Don’t reach for the one you already have out of habit.

Use the console panel — this recipe does, and it is the path a developer should actually take. POST /api/apps/:id/oauth-clients still exists underneath it if you’re scripting setup, but there is no reason to hand-craft it once a UI exists for the same thing.

This client_id is not a secret — the console shows it plainly with a copy button, not behind a reveal-once flow like a server key. Wire it into your app’s config however you’d wire in any non-secret identifier.

The flow

ts
// 1. Send the browser there. beginHostedLogin makes no network call — it
//    just builds the URL and generates the values PKCE and CSRF protection
//    need.
const request = await client.auth.beginHostedLogin({
  clientId: 'the-client-id-from-app-settings',
  redirectUri: 'https://your-app.example.com/callback',
})

// Persist these before navigating away — a full page redirect drops
// everything in memory. sessionStorage survives the round trip and clears
// itself when the tab closes.
sessionStorage.setItem('fleetless_state', request.state)
sessionStorage.setItem('fleetless_code_verifier', request.codeVerifier)
window.location.href = request.url

// 2. On your redirectUri page, once the hosted flow has finished:
const params = new URLSearchParams(window.location.search)

// CHECK FOR AN ERROR FIRST — see "the callback can carry an error, not just
// a code" below. Skipping this is the first thing anyone new to this recipe
// gets wrong.
if (params.has('error')) {
  // params.get('error') — RFC 6749 §5.2 vocabulary: 'access_denied',
  // 'invalid_request', 'invalid_target', ...
  // params.get('error_description') — human-readable, safe to show
  // params.get('fleetless_code') — present only for the five Fleetless-specific
  //   outcomes below; absent for a plain protocol-shaped failure
  handleAuthError(params.get('error')!, params.get('error_description'), params.get('fleetless_code'))
} else {
  await client.auth.completeHostedLogin({
    code: params.get('code')!,
    state: params.get('state')!,
    expectedState: sessionStorage.getItem('fleetless_state')!,
    codeVerifier: sessionStorage.getItem('fleetless_code_verifier')!,
    clientId: 'the-client-id-from-app-settings',
    redirectUri: 'https://your-app.example.com/callback',
  })
  // From here on client.auth.me() / logout() / silent refresh behave
  // exactly as they do after login() — completeHostedLogin stores the
  // identical session shape, through the same tokenStore.
}
sessionStorage.removeItem('fleetless_state')
sessionStorage.removeItem('fleetless_code_verifier')

The callback can carry an error, not just a code

/oauth/idp-callback and /oauth/authorize answer a failure by redirecting to your own redirectUri with ?error=...&error_description=... (RFC 6749 §5.2 shape) rather than a code — this is the standard’s own error channel, and your callback page is the only place it can land. Reading params.get('code')! unconditionally, the way a happy-path example tends to, throws on the assertion instead of showing the user why they aren’t signed in. Check error first, always.

Five outcomes carry a fleetless_code alongside the standard error, and each has a distinct, actionable meaning your app can switch on rather than just displaying the generic description:

fleetless_code What it means What your app can tell the user
dynamic_registration_disabled Not reachable from this flow — DCR-only.
client_limit_reached Not reachable from this flow — DCR-only.
identity_conflict The asserted email already belongs to an account this login is not allowed to link to. “Sign in the way you originally signed up” (a password, if you have one).
identity_not_provisioned The identity is known, but nothing grants it a route into this app — federation is a login mechanism, never a signup path. “Ask an app owner to invite you,” or contact your developer.
idp_unavailable Your IdP is unreachable, or its discovery/JWKS/token response failed verification. “Try again shortly” — this is the developer’s IdP, not Fleetless.

A fleetless_code-less error (e.g. invalid_request, access_denied for “the user declined consent”) is a protocol-shaped outcome with no Fleetless-specific remedy beyond error_description.

  • completeHostedLogin refuses before any network call (state_mismatch) if state doesn’t match what beginHostedLogin returned for this attempt — it will not complete a login your app did not itself start.
  • A successful exchange’s own errors (an expired or already-used code, a wrong code_verifier) surface as a thrown FleetlessError whose .code is straight RFC 6749 §5.2 vocabulary (e.g. invalid_grant) — a different vocabulary from this SDK’s usual code/message pairs, because the exchange happens against /oauth/token, a standards-compliant endpoint any OAuth client can drive, not an SDK-specific API.

Optional: federating to your own IdP

Federation is configured per group, not per app. In the console: Settings → Groups, pick the group, then OIDC provider — issuer URL, client id/secret, scopes, and claim mapping (which claim is the stable subject, which is the email). Every app linked to that group federates together, because the identity being federated belongs to the group’s users rather than to any one app.

Joining a federated login to an account that already exists needs both conditions: the IdP asserts email_verified and the org setting (Settings → Federation) is on. Off until somebody turns it on — either condition alone is account takeover.

Nothing in your app’s code changes whether or not federation is configured — beginHostedLogin/completeHostedLogin are identical either way. What changes is where the browser lands: when the app’s group carries a provider, /oauth/authorize redirects straight to that IdP, and the Fleetless password form is never shown for that app. With no provider on the group, every login into the app uses the email/password form on the hosted page.

Optional: branding the login page

App Settings → Branding, in the console — primary colour, a logo (PNG/JPEG data URI, 256 KiB cap; SVG is refused, since this page holds a password field and an SVG is a script host), footer text. Injected server-side into the page on first byte — there is no flash of unbranded content, and no separate unauthenticated endpoint your app or anyone else can probe to enumerate which apps exist or how they’re branded. An unconfigured app renders neutral Fleetless branding, not a broken or empty page.

Common mistakes, named up front

  • Reading code before checking error. Covered above — the single most likely thing to trip up a first implementation, because a worked example shows the happy path first.
  • Confusing client_id with appIdentifier. Two different identifiers, from two different console surfaces, for two different call sites.
  • Persisting state/codeVerifier in memory instead of sessionStorage (or an equivalent that survives a full navigation). A full-page redirect to the hosted login page and back drops anything held only in a JS variable; this SDK deliberately hands both values back to you rather than holding them itself, because it cannot assume your app’s storage strategy.
  • Redirect URI mismatches. The server matches by exact string comparison against what you registered in App Settings → OAuth clients — never a prefix, never a wildcard. A trailing-slash difference between what you registered and what your app actually redirects to is a real, common cause of invalid_request here.