AuthServer
Headless Auth How-To
Step-by-step implementation guide for custom authorize pages.
Step-by-step companion to Headless Auth.
You build:
BuildUiUrl(...) — where your custom page livesfetch calls to SqlOS headless APIsOnHeadlessSignupAsync for extra signup fieldsSqlOS still does:
/sqlos/auth/authorize, /sqlos/auth/tokenstateNo special React SDK required.
End-to-end:
/sqlos/auth/headless/requests/{id}.Model:
Set BuildUiUrl. Example API: examples/SqlOS.Example.Api/Program.cs.
options.AuthServer.UseHeadlessAuthPage(headless =>
{
headless.BuildUiUrl = ctx =>
{
var query = new Dictionary<string, string?>
{
["request"] = ctx.RequestId,
["view"] = ctx.View,
["error"] = ctx.Error,
["email"] = ctx.Email,
["pendingToken"] = ctx.PendingToken,
["displayName"] = ctx.DisplayName,
};
return QueryHelpers.AddQueryString(
$"{headlessFrontendUrl.TrimEnd('/')}/auth/authorize",
query);
};
});Flow:
GET /sqlos/auth/authorize.EasyCOI uses the same pattern with extra query context in its app startup. EasyCOI mounts SqlOS at /oauth, not the default /sqlos/auth:
options.AuthServer.UseHeadlessAuthPage(headless =>
{
headless.BuildUiUrl = ctx =>
{
var language = uiContext?["lng"]?.GetValue<string?>()?.Trim() ?? "en";
var template = uiContext?["template"]?.GetValue<string?>();
var shellUrl = $"{firstPartyWebOrigin.TrimEnd('/')}/{language}/authorize";
var query = new Dictionary<string, string?>
{
["view"] = ctx.View,
["request"] = ctx.RequestId,
["error"] = ctx.Error,
["pendingToken"] = ctx.PendingToken,
["email"] = ctx.Email,
["displayName"] = ctx.DisplayName,
["template"] = string.IsNullOrWhiteSpace(template) ? null : template
};
return QueryHelpers.AddQueryString(shellUrl, query!);
};
});The only difference is that EasyCOI threads language and template state through ui_context.
Your custom page still starts a real PKCE authorization request.
In the Example app, the starter lives in examples/SqlOS.Example.Web/components/sqlos-headless-auth-panel.tsx:
const verifier = createOpaqueToken(48);
const state = createOpaqueToken(24);
const challenge = await createCodeChallenge(verifier);
persistSqlOSAuthFlow(flowView, state, verifier, "/app");
const url = new URL(`${getExampleAuthServerUrl()}/authorize`);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", getExampleClientId());
url.searchParams.set("redirect_uri", getExampleRedirectUri());
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
if (flowView === "signup") {
url.searchParams.set("view", "signup");
}
window.location.replace(url.toString());EasyCOI does the same thing in its app code. That app uses /oauth as its SqlOS base path:
const url = new URL(`${apiUrl}/oauth/authorize`);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", SQL_OS_CLIENT_ID);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("view", view === "signup" ? "signup" : "login");
url.searchParams.set(
"ui_context",
JSON.stringify({
lng,
template: template || undefined,
}),
);
window.location.replace(url.toString());This is important: the page is custom, but the request is still normal OAuth.
After SqlOS redirects back to your custom page with ?request=req_..., your page loads the request state.
EasyCOI does that in its app code too. Again, EasyCOI uses /oauth instead of the default /sqlos/auth:
const requestUrl = new URL(`${apiUrl}/oauth/headless/requests/${requestId}`);
requestUrl.searchParams.set("view", requestedView);
if (queryError) {
requestUrl.searchParams.set("error", queryError);
}
const response = await fetch(requestUrl.toString(), {
credentials: "include",
cache: "no-store",
});
const payload = (await response.json()) as HeadlessModel;
setModel(payload);That request model gives your page everything it needs to render the next step:
viewThis is the main UI integration. Your page does not implement auth rules. It just posts user actions.
EasyCOI posts email identify the same way:
await postAction("/oauth/headless/identify", {
requestId: model.requestId,
email,
});SqlOS then decides:
await postAction("/oauth/headless/password/login", {
requestId: model.requestId,
email,
password,
});If the user or selected organization requires MFA, this call can return view: "mfa" or view: "mfa-enroll" instead of a final redirect.
Render this state when model.view === "mfa".
await postAction("/oauth/headless/mfa/verify", {
requestId: model.requestId,
mfaToken: model.mfaToken,
code,
});Use one input for both authenticator codes and recovery codes. SqlOS validates the code against the user's available factors and returns the final redirect on success.
Render this state when model.view === "mfa-enroll".
const enrollment = model.totpEnrollment;
// Show enrollment.qrCodeDataUrl as an image.
// Show enrollment.secret as manual setup fallback.
await postAction("/oauth/headless/mfa/totp/enroll/verify", {
requestId: model.requestId,
mfaToken: model.mfaToken,
enrollmentToken: enrollment.enrollmentToken,
code,
});SqlOS starts forced enrollment before issuing the authorization code. Your UI only renders the QR code, manual setup value, and verification input.
Use Email OTP when model.settings.enabledCredentialTypes includes email_otp. Keep home realm discovery in the loop so SSO-required domains still redirect to SSO.
await postAction("/oauth/headless/email-otp/start", {
requestId: model.requestId,
email,
invitationToken,
});
await postAction("/oauth/headless/email-otp/verify", {
requestId: model.requestId,
challengeToken: model.challengeToken,
code,
invitationToken,
});If the email belongs to a configured SSO organization, SqlOS returns a redirect before creating an OTP challenge.
In the Example app, signup sends custom fields in examples/SqlOS.Example.Web/components/sqlos-headless-auth-panel.tsx:
const result = await headlessSignup(
requestId,
buildDisplayName(firstName, lastName, email),
email,
password,
organizationName,
{
referralSource,
firstName,
lastName,
},
);EasyCOI does the same pattern in its app code:
await postAction("/oauth/headless/signup", {
requestId: model.requestId,
displayName: `${firstName} ${lastName}`.trim(),
email,
password,
organizationName: companyName,
customFields: {
firstName,
lastName,
companyName,
language: lng,
template: template || undefined,
},
});For passwordless new-user signup, start and verify the signup challenge:
await postAction("/oauth/headless/signup/email-otp/start", {
requestId: model.requestId,
displayName,
email,
organizationName,
customFields,
});
await postAction("/oauth/headless/signup/email-otp/verify", {
requestId: model.requestId,
signupToken: model.signupToken,
challengeToken: model.challengeToken,
code,
});signupToken and challengeToken are short-lived flow state. Preserve them only until the user submits the code.
Invitation acceptance is a separate lifecycle. Do not send a new signup OTP after the invited user clicks a valid invite link.
await postAction("/oauth/headless/invitations/signup", {
requestId: model.requestId,
displayName,
email: model.invitation.email,
customFields,
invitationToken,
});That call creates the passwordless user, marks the invited email verified, accepts the organization invite, runs OnHeadlessSignupAsync, and returns the final redirect. This is the path for new invited users when Email OTP is enabled. If HRD resolves the invited email to SSO, SqlOS returns the SSO redirect instead.
await postAction("/oauth/headless/provider/start", {
requestId: model.requestId,
connectionId,
email,
});await postAction("/oauth/headless/organization/select", {
pendingToken: model.pendingToken,
organizationId,
});If your app handles invite links directly, resolve the token before starting OAuth:
const resolved = await postAction("/oauth/headless/invitations/resolve", {
invitationToken,
});Then start /oauth/authorize with the same invitationToken. SqlOS binds it to the auth request so later actions can accept the invitation safely.
This is the part that makes headless mode more than “hosted auth with different HTML.”
In the Example app, OnHeadlessSignupAsync(...) validates and persists referralSource into app-owned data in examples/SqlOS.Example.Api/Program.cs:
headless.OnHeadlessSignupAsync = async (ctx, cancellationToken) =>
{
var referralSource = ctx.CustomFields?["referralSource"]?.GetValue<string>()?.Trim();
if (string.IsNullOrWhiteSpace(referralSource))
{
throw new SqlOSHeadlessValidationException(
"Tell us how you heard about SqlOS.",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["referralSource"] = "Select a referral source to complete signup."
});
}
var profile = await dbContext.ExampleUserProfiles
.FirstOrDefaultAsync(x => x.SqlOSUserId == ctx.User.Id, cancellationToken);
// create/update app-owned profile row here
};EasyCOI delegates the same idea to an app service in its backend:
headless.OnHeadlessSignupAsync = async (ctx, cancellationToken) =>
{
var provisioner = ctx.HttpContext.RequestServices
.GetRequiredService<IEasyCoiHeadlessSignupProvisioningService>();
await provisioner.HandleAsync(ctx, cancellationToken);
};That is the clean pattern:
This is the part you do not write.
When your page posts an action, SqlOS returns one of two things:
In the Example app, the UI reacts like this in examples/SqlOS.Example.Web/components/sqlos-headless-auth-panel.tsx:
if (result.type === "redirect" && result.redirectUrl) {
const url = new URL(result.redirectUrl);
const code = url.searchParams.get("code");
if (code) {
// exchange code, create session, enter app
}
window.location.href = result.redirectUrl;
return;
}
if (result.viewModel) {
setViewModel(result.viewModel);
}That is the whole contract:
If you are building a consumer app, this is the minimum lifecycle to implement:
UseHeadlessAuthPage(...)BuildUiUrl(...)/sqlos/auth/authorize request from that pageGET /sqlos/auth/headless/requests/{requestId}OnHeadlessSignupAsync(...) for app-specific fieldsThat is all you need.
You do not need to write:
That is the entire point of headless mode.
If you want a starting point:
examples/SqlOS.Example.Api/Program.csexamples/SqlOS.Example.Web/components/sqlos-headless-auth-panel.tsxIf you want the product framing, go back to Headless Auth and Headless Auth Server Mode: Keep OAuth, Own the UI.