AuthServer
Headless Auth
Use SqlOS as the authorization server while your app owns the login UI.
Headless = SqlOS runs OAuth (/sqlos/auth/authorize, /sqlos/auth/token, PKCE, sessions, OIDC, SAML, codes). Your app draws the HTML.
Not sure? Start hosted. You can switch later.
builder.AddSqlOS<AppDbContext>(options =>
{
options.AuthServer.UseHeadlessAuthPage(headless =>
{
headless.BuildUiUrl = ctx =>
QueryHelpers.AddQueryString(
"https://app.example.com/authorize",
new Dictionary<string, string?>
{
["request"] = ctx.RequestId,
["view"] = ctx.View,
["email"] = ctx.Email,
["pendingToken"] = ctx.PendingToken,
["ui_context"] = ctx.UiContext?.ToJsonString()
});
});
options.AuthServer.SeedAuthPage(page =>
{
page.PageTitle = "Sign in";
});
});
var app = builder.Build();
app.MapSqlOS();The browser hits /sqlos/auth/authorize on SqlOS first. SqlOS then redirects to the URL from BuildUiUrl. Your app shows the login UI. Preserve ui_context values on the page model when present, but do not invent separate auth lifecycles for invitations or CLI device flow; SqlOS binds those to the normal request id.
SqlOS uses one rule:
BuildUiUrl exists: headless.BuildUiUrl does not exist: hosted.There is no second seed or dashboard switch to keep in sync.
More context: Hosted vs Headless.
Your UI calls these SqlOS endpoints:
| Endpoint | Purpose |
|---|---|
GET /sqlos/auth/headless/requests/{requestId} | Get the current authorization request state |
POST /sqlos/auth/headless/identify | Submit email for home realm discovery |
POST /sqlos/auth/headless/password/login | Submit email + password |
POST /sqlos/auth/headless/email-otp/start | Send an existing-user email code |
POST /sqlos/auth/headless/email-otp/verify | Verify an existing-user email code |
POST /sqlos/auth/headless/signup | Create account with optional custom fields |
POST /sqlos/auth/headless/signup/email-otp/start | Send a new-user signup code |
POST /sqlos/auth/headless/signup/email-otp/verify | Verify signup code and complete authorization |
POST /sqlos/auth/headless/invitations/resolve | Resolve an invitation token before starting OAuth |
POST /sqlos/auth/headless/invitations/signup | Create an invited passwordless account and accept the invite |
POST /sqlos/auth/headless/device/resolve | Resolve a CLI device user code |
POST /sqlos/auth/headless/device/approve | Approve a CLI device request after sign-in |
POST /sqlos/auth/headless/device/deny | Deny a CLI device request |
POST /sqlos/auth/headless/organization/select | Select org for multi-org users |
POST /sqlos/auth/headless/mfa/verify | Verify a TOTP or recovery-code challenge |
POST /sqlos/auth/headless/mfa/totp/enroll/start | Start forced TOTP enrollment for an MFA challenge |
POST /sqlos/auth/headless/mfa/totp/enroll/verify | Verify forced TOTP enrollment and continue authorization |
POST /sqlos/auth/headless/provider/start | Start OIDC or SSO flow |
When a CLI sends the user to /sqlos/auth/device?user_code=..., SqlOS resolves the device request and creates a normal headless authorization request bound to that device authorization. Your app receives the same request id shape used by login, signup, OTP, SSO, and organization selection.
The lowest-friction pattern is:
/headless/requests/{requestId} and render your normal auth UI. The view may be device, login, email-otp, password, organization, or device-approve.device-approve, show the CLI client/resource/scope from deviceAuthorization./headless/device/approve or /headless/device/deny, passing the requestId. You may pass userCode instead for manual-entry UIs, but request-bound approval is preferred.SqlOS releases tokens to the polling CLI only after approval. See CLI OAuth.
Pass extra signup data in customFields. Validate in OnHeadlessSignupAsync:
options.AuthServer.UseHeadlessAuthPage(headless =>
{
headless.BuildUiUrl = ctx =>
$"https://app.example.com/authorize?request={ctx.RequestId}&view={ctx.View}";
headless.OnHeadlessSignupAsync = async (ctx, ct) =>
{
var referral = ctx.CustomFields["referralSource"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(referral))
{
throw new SqlOSHeadlessValidationException(
"Tell us how you heard about the product.",
new Dictionary<string, string>
{
["referralSource"] = "Required."
});
}
// Persist to your own table
await SaveProfileAsync(ctx.User.Id, referral);
};
});The example repo has a full headless UI. Main files:
app/auth/authorize/page.tsx → Custom authorize route
components/sqlos-headless-auth-panel.tsx → Login/signup/org-selection UI
lib/sqlos-headless.ts → Typed client for headless endpointsTypical flow in your custom authorize page:
// 1. Read the authorization request
const request = await getHeadlessRequest(requestId);
// 2. Show login or signup based on request.view
if (request.view === "signup") {
// Show signup form
}
// 3. User submits email
const identified = await headlessIdentify(requestId, email);
// SqlOS returns a view, redirect, or next credential step.
// 4. User submits password or starts/verifies email OTP
const result = await headlessPasswordLogin(requestId, email, password);
// 5. If multi-org, select organization
if (result.requiresOrganizationSelection) {
await headlessSelectOrganization(requestId, orgId);
}
// 6. If policy requires MFA, render request.view === "mfa" or "mfa-enroll"
// 7. SqlOS issues the auth code and redirects back to the OAuth clientHeadless MFA uses the same result contract as every other step.
When viewModel.view is mfa, render an input for a 6-digit authenticator code or a recovery code. Post it to /headless/mfa/verify with requestId, mfaToken, and code.
When viewModel.view is mfa-enroll, render authenticator setup. The view model includes totpEnrollment with qrCodeDataUrl, secret, provisioningUri, enrollmentToken, and expiresAt. Post the verification code to /headless/mfa/totp/enroll/verify.
If your headless app does not render these views, required-MFA users will authenticate their primary credential and then stop at an unhandled state.
Headless invitation links should use the invitation endpoints instead of pretending the invite is a normal signup.
// Resolve token shown in the emailed link.
const invite = await headlessResolveInvitation(invitationToken);
// Start OAuth and include invitationToken in the authorize request.
// SqlOS binds the invitation to the saved auth request.
// Existing user: continue through identify, SSO, password, or email OTP.
// New invited user in passwordless mode:
await headlessSignupWithInvitation(
requestId,
displayName,
invite.email,
customFields,
invitationToken,
);Calling /signup/email-otp/start for a new invited user is the wrong shape: the invite link has already proven mailbox possession. Use /invitations/signup so SqlOS creates the verified user, accepts the invitation, and issues the redirect in one transaction. If HRD requires SSO for the invited email domain, SqlOS returns the SSO redirect instead of creating a local account.
OAuth on the wire is the same. MCP and other clients still use:
/sqlos/auth/.well-known/oauth-authorization-server/sqlos/auth/authorize/sqlos/auth/token/sqlos/auth/.well-known/jwks.jsonOnly the browser UI during login changes.