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 token — client.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
// 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.
completeHostedLoginrefuses before any network call (state_mismatch) ifstatedoesn’t match whatbeginHostedLoginreturned 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 wrongcode_verifier) surface as a thrownFleetlessErrorwhose.codeis straight RFC 6749 §5.2 vocabulary (e.g.invalid_grant) — a different vocabulary from this SDK’s usualcode/messagepairs, 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
codebefore checkingerror. Covered above — the single most likely thing to trip up a first implementation, because a worked example shows the happy path first. - Confusing
client_idwithappIdentifier. Two different identifiers, from two different console surfaces, for two different call sites. - Persisting
state/codeVerifierin memory instead ofsessionStorage(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_requesthere.