@sqlos/headless
Public TypeScript API for the SqlOS headless AuthPage state machine. Completes at an authorization code; it is not an OAuth client.
Install at the same version as the SqlOS NuGet package:
npm install @sqlos/headless@7.1.0In this repository, examples use "@sqlos/headless": "file:../../packages/headless" so local and CI installs never need npmjs.com. Build the package first (./scripts/setup-js-examples.sh).
createHeadlessFlow holds the current view model and opaque tokens (requestId, challenge, pending, MFA, consent, enrollment). Actions take user input only. Completion is status === "redirect" with either:
authorization — { code, redirectUri, state, codeVerifier } when the redirect includes a code. redirectUri is the exact registered value, including any registered query string, so it can be posted to /token as-is.redirectUrl without authorization — an external IdP URL; follow it, do not treat it as a codeThe package never calls /sqlos/auth/token (any such URL throws HeadlessTokenEndpointError), never stores refresh tokens, and never writes to localStorage.
HeadlessViewModel.view is typed as HeadlessView (the same union as the server contract). Dead branches such as "identify" fail at compile time — the initial email screen is "login".
status | Meaning |
|---|---|
idle | No request loaded |
loading | An action is in flight |
view | Render viewModel.view |
redirect | Leave via redirectUrl |
error | Read error and fieldErrors |
Server and validation failures resolve. Actions return the new HeadlessFlowStatus and update error / fieldErrors. You do not need try/catch for the normal path.
Programmer mistakes reject with a HeadlessProgrammerError subclass and also set status === "error" so the UI shows something:
| Error | Cause |
|---|---|
HeadlessFlowBusyError | A second action was called while one was in flight. Disable inputs while status === "loading". |
HeadlessFlowNotLoadedError | An action ran before resume / start, or the current view lacks the token it needs (for example mfa.verify outside the mfa view). |
HeadlessApiPathMismatchError | The server's headlessApiBasePath differs from the configured one. |
HeadlessTokenEndpointError | A request targeted /token. |
resume and start are the exception to the busy rule: an identical call while the same load is in flight returns the in-flight promise. React StrictMode's double-invoked effect therefore needs no guard.
Your OIDC library starts /authorize. SqlOS redirects to your page with ?request=. Resume, then render a loop over view:
import { createHeadlessFlow } from "@sqlos/headless";
import { useHeadlessAuth } from "@sqlos/headless/react";
const flow = createHeadlessFlow({
issuer: "https://id.example.com/sqlos/auth",
clientId: "acme-app",
redirectUri: "https://app.example.com/api/auth/callback/sqlos",
credentials: "include",
});
await flow.resume(window.location);
// Render switch (flow.viewModel.view) → collect input → one action
await flow.identify({ email });
await flow.password.login({ password });
if (flow.status === "redirect" && flow.redirectUrl) {
window.location.assign(flow.redirectUrl);
}React snapshots (same shape from @sqlos/headless/react-native):
const { flow, status, view, viewModel, error, fieldErrors, redirectUrl } =
useHeadlessAuth({
issuer,
clientId,
redirectUri,
credentials: "include",
});The hook keeps one flow per authorization request. It rebuilds the flow only when issuer, clientId, redirectUri, headlessApiBasePath, or credentials change; fetch and generatePkce may be inline functions.
credentials: "include" is required in the browser so the issuer session cookie is sent. Omit it on native. Always leave with window.location.assign(redirectUrl) — do not hand-parse the URL.
await flow.start({
scope: "openid profile email offline_access",
view: "login",
});PKCE is generated (or accepted) as start input because SqlOS requires it. The built-in generator produces a 43-character base64url verifier (the RFC 7636 minimum SqlOS enforces at /token) and an S256 challenge with Web Crypto. Where Web Crypto subtle is unavailable (React Native / Expo), inject the primitives instead of forking the generator:
import * as Crypto from "expo-crypto";
import { createPkceGenerator } from "@sqlos/headless";
const generatePkce = createPkceGenerator({
randomBytes: (size) => Crypto.getRandomBytesAsync(size),
sha256: async (data) =>
new Uint8Array(await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, data)),
});
const flow = createHeadlessFlow({ issuer, clientId, redirectUri, generatePkce });After flow.authorization, the host OIDC library exchanges the code.
import { useHeadlessAuth } from "@sqlos/headless/react-native";HEADLESS_VIEWS is the complete list a view model can carry. The contract check in CI fails when the server adds or removes one.
| View | Meaning | Typical next action |
|---|---|---|
login | Collect the email (home realm discovery) | identify |
password | Password for a known email | password.login, password.forgot |
signup | Password signup form | signup |
forgot-password | Ask for the account email | password.forgot |
forgot-password-sent | Reset email sent; read flow.passwordReset for maskedEmail, expiresAt, nextAllowedSendAt | — |
password-reset | Choose a new password from an emailed token | password.reset |
email-otp | Send a one-time code to the email | emailOtp.start |
email-otp-verify | Enter the emailed code | emailOtp.verify |
email-otp-signup-verify | Verify the code for an OTP signup | emailOtp.signupVerify |
magic-link | Send a sign-in link | magicLink.start |
magic-link-sent | Link sent; wait for the click | magicLink.complete (from the link) |
phone-otp | Send an SMS code | phoneOtp.start |
phone-otp-verify | Enter the SMS code | phoneOtp.verify |
phone-otp-signup | Phone signup form | phoneOtp.signupStart |
phone-otp-signup-verify | Verify the SMS code for a phone signup | phoneOtp.signupVerify |
invite | Invitation resolved; show organization and email | invitation.signup or identify |
invite-login | Invited email already has an account | password.login / OTP |
invite-email-otp-verify | Verify the code for an invitation signup | emailOtp.signupVerify |
invite-accepted | Invitation bound to the signed-in account | — |
device | Device-flow user code entry | device.resolve |
device-approve | Confirm the device request | device.approve, device.deny |
device-approved | Device approved; the device polls /token | — |
device-denied | Device request denied | — |
mfa | Second-factor challenge | mfa.verify |
mfa-enroll | Authenticator enrollment required | mfa.totp.enrollStart, mfa.totp.enrollVerify |
organization | Choose an organization | organization.select |
consent | Third-party client consent | consent.approve, consent.deny |
logged-out | Session ended; nothing more to do on this request | — |
Render a fallback branch for views your UI does not draw (the in-repo examples send those users to hosted AuthPage). A typed switch over HeadlessView with a never check turns a new server view into a compile error.
| Call | Headless route |
|---|---|
resume(location) | GET /requests/{requestId} |
start(input) | POST /start |
identify({ email }) | POST /identify |
password.login({ password }) | POST /password/login |
password.forgot({ email }) | POST /password/forgot |
password.reset({ token, newPassword }) | POST /password/reset |
emailOtp.start / verify | /email-otp/* |
emailOtp.signupStart / signupVerify | /signup/email-otp/* |
magicLink.start / complete | /magic-link/* |
phoneOtp.* | /phone-otp/* and /signup/phone-otp/* |
signup({ displayName, password, ... }) | POST /signup |
organization.select | POST /organization/select |
mfa.verify / mfa.totp.enrollStart / enrollVerify | /mfa/* |
consent.approve / deny | /consent/* |
invitation.resolve / signup | /invitations/* |
device.resolve / approve / deny | /device/* |
provider.start | POST /provider/start |
submit(path, body?) | Any headless route |
submit is the escape hatch for a server step the SDK does not name yet. It posts through the same status and token bookkeeping as the typed actions, fills in requestId from the loaded request, and applies the response whether it is an action result (type: "view" | "redirect"), a bare view model, or a 204:
await flow.submit("/passkey/start", { credentialId });
// flow.viewModel now reflects the server's next step, and its tokens feed
// the typed actions that follow.Every typed action posts exactly the fields of the server request record for its route (HEADLESS_REQUEST_FIELDS); the package's test suite and the contract check in CI enforce that against the .NET source.
viewModel.settings lists enabledCredentialTypes, but a type is usable only when its runtime is also configured. credentialEnabled applies the same rule hosted AuthPage uses, so a custom UI does not copy it:
import { credentialEnabled } from "@sqlos/headless";
const showPassword = credentialEnabled(viewModel?.settings, "password");
const showEmailCode = credentialEnabled(viewModel?.settings, "email_otp");
// also "magic_link" and "phone_otp" — see HEADLESS_CREDENTIAL_TYPESIf the host moved HeadlessApiBasePath, set headlessApiBasePath to that exact path. A mismatch with the returned view model fails closed.
Dashboard and admin APIs are not applicable. This is a client SDK over the public headless HTTP contract, not an operator-managed setting.
Publishing: NPM publishing. Guide: Build your own login UI. Wire protocol: Headless Auth.