mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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:
parent
2d1db7a56e
commit
ce66a1908d
@ -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,
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user