feat(sdk): add signInWithSaml, signInWithSso, getSamlConnectionForEmail

Three methods on StackClientApp that mirror signInWithOAuth:

- signInWithSaml({ connectionId, returnTo }) — explicit connection
  selection. Calls /auth/saml/login/[connectionId] in stack_response_mode
  =json so the SDK can intercept the redirect URL.

- signInWithSso({ email, returnTo }) — email-domain discovery via
  /auth/saml/discover, then redirects through the matched connection.
  Throws when no connection matches so callers can fall back to other
  sign-in methods.

- getSamlConnectionForEmail(email) — pure lookup with no redirect, so
  the customer's UI can render branding ("Sign in with Acme SSO")
  before the user clicks.

Backed by getSamlUrl + authorizeSaml + discoverSamlConnection on
StackClientInterface (mirrors getOAuthUrl + authorizeOAuth pattern,
without provider_scope or bot challenge — SAML originates from a
corporate IdP, not a public form).

Generated via pnpm -w run generate-sdks; propagates from
packages/template into packages/js, packages/react, packages/stack.
This commit is contained in:
Bilal Godil 2026-04-29 15:42:42 -07:00
parent 2d1db7a56e
commit ce66a1908d
2 changed files with 163 additions and 0 deletions

View File

@ -1422,6 +1422,112 @@ export class StackClientInterface {
return Result.ok(location);
}
/**
* Build the URL the SDK redirects to for SAML SSO. Mirrors getOAuthUrl
* but without provider_scope / bot challenge SAML sign-in is initiated
* from a corporate IdP, not a public form.
*/
async getSamlUrl(
options: {
connectionId: string,
redirectUrl: string,
errorRedirectUrl: string,
afterCallbackRedirectUrl?: string,
codeChallenge: string,
state: string,
}
): Promise<string> {
const updatedRedirectUrl = new URL(options.redirectUrl);
for (const key of ["code", "state"]) {
if (updatedRedirectUrl.searchParams.has(key)) {
updatedRedirectUrl.searchParams.delete(key);
}
}
if ("projectOwnerSession" in this.options) {
throw new Error("Admin session token is currently not supported for SAML");
}
const clientSecret = this.options.publishableClientKey ?? publishableClientKeyNotNecessarySentinel;
const url = new URL(this.getBestApiUrl() + "/auth/saml/login/" + encodeURIComponent(options.connectionId));
url.searchParams.set("client_id", this.projectId);
url.searchParams.set("client_secret", clientSecret);
url.searchParams.set("redirect_uri", updatedRedirectUrl.toString());
url.searchParams.set("scope", "legacy");
url.searchParams.set("state", options.state);
url.searchParams.set("grant_type", "authorization_code");
url.searchParams.set("code_challenge", options.codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("response_type", "code");
url.searchParams.set("error_redirect_uri", options.errorRedirectUrl);
if (options.afterCallbackRedirectUrl) {
url.searchParams.set("after_callback_redirect_url", options.afterCallbackRedirectUrl);
}
return url.toString();
}
async authorizeSaml(options: {
connectionId: string,
redirectUrl: string,
errorRedirectUrl: string,
afterCallbackRedirectUrl?: string,
codeChallenge: string,
state: string,
}): Promise<string> {
if (typeof window === "undefined") {
throw new StackAssertionError("authorizeSaml can currently only be called in a browser environment");
}
await this.options.prepareRequest?.();
const url = new URL(await this.getSamlUrl(options));
url.searchParams.set("stack_response_mode", "json");
const rawRes = await fetch(url, { method: "GET" });
const processedResponse = await this._processResponse(rawRes);
if (processedResponse.status === "error") {
throw processedResponse.error;
}
if (processedResponse.data.status !== 200) {
throw new StackAssertionError(`SAML authorize returned an unexpected status: ${processedResponse.data.status}`);
}
const body = await processedResponse.data.json();
if (body == null || typeof body !== "object" || Array.isArray(body)) {
throw new StackAssertionError("SAML authorize response body must be an object", { body });
}
const location = body.location;
if (typeof location !== "string") {
throw new StackAssertionError("SAML authorize response is missing a redirect location", { body });
}
return location;
}
/**
* Looks up the SAML connection matching an email's domain. Returns null
* when no connection matches, so callers can fall back to other sign-in
* methods.
*/
async discoverSamlConnection(email: string): Promise<{ connectionId: string, displayName: string } | null> {
const url = new URL(this.getBestApiUrl() + "/auth/saml/discover");
url.searchParams.set("email", email);
url.searchParams.set("project_id", this.projectId);
const rawRes = await fetch(url, { method: "GET" });
if (rawRes.status === 404) {
return null;
}
const processedResponse = await this._processResponse(rawRes);
if (processedResponse.status === "error") {
throw processedResponse.error;
}
if (processedResponse.data.status !== 200) {
throw new StackAssertionError(`SAML discover returned an unexpected status: ${processedResponse.data.status}`);
}
const body = await processedResponse.data.json();
if (body == null || typeof body !== "object" || Array.isArray(body)) {
throw new StackAssertionError("SAML discover response body must be an object", { body });
}
return {
connectionId: String(body.connection_id),
displayName: String(body.display_name),
};
}
async callOAuthCallback(options: {
oauthParams: URLSearchParams,
redirectUri: string,

View File

@ -2854,6 +2854,63 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
await neverResolve();
}
/**
* Sign in via a configured SAML connection (SP-initiated flow).
* Mirrors signInWithOAuth redirects to /auth/saml/login/[connectionId],
* which round-trips through the customer's IdP and back via ACS.
*/
async signInWithSaml(options: {
connectionId: string,
returnTo?: string,
}) {
if (typeof window === "undefined") {
throw new Error("signInWithSaml can currently only be called in a browser environment");
}
this._ensurePersistentTokenStore();
const currentUrl = new URL(window.location.href);
const afterCallbackRedirectUrl = options.returnTo != null
? constructRedirectUrl(options.returnTo, "returnTo")
: (currentUrl.searchParams.has("after_auth_return_to") ? currentUrl.toString() : undefined);
const { codeChallenge, state } = await saveVerifierAndState();
const location = await this._interface.authorizeSaml({
connectionId: options.connectionId,
redirectUrl: constructRedirectUrl(this.urls.oauthCallback, "redirectUrl"),
errorRedirectUrl: constructRedirectUrl(this.urls.error, "errorRedirectUrl"),
afterCallbackRedirectUrl,
codeChallenge,
state,
});
await this._redirectTo({ url: location });
await neverResolve();
}
/**
* Sign in via SSO discovered by email domain. Looks up the SAML
* connection whose `domain` matches the email, then redirects through
* SAML. Throws if no matching connection callers can catch and fall
* back to other sign-in methods.
*/
async signInWithSso(options: {
email: string,
returnTo?: string,
}) {
const connection = await this._interface.discoverSamlConnection(options.email);
if (!connection) {
throw new Error(`No SSO connection configured for email domain "${options.email.split("@").pop()}"`);
}
return await this.signInWithSaml({ connectionId: connection.connectionId, returnTo: options.returnTo });
}
/**
* Look up the SAML connection matching an email's domain without
* initiating sign-in. The customer's UI can use this to render
* branding (e.g. "Sign in with Acme SSO") before the user clicks.
*/
async getSamlConnectionForEmail(email: string): Promise<{ connectionId: string, displayName: string } | null> {
return await this._interface.discoverSamlConnection(email);
}
/**
* Handles MFA verification by redirecting to the OTP page
*/