mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
feat(backend): add SAML login + ACS routes with OAuth2 integration
Two routes that complete the SAML SP-initiated round trip: - GET /api/v1/auth/saml/login/[connection_id] Receives the same Stack Auth OAuth client params as /auth/oauth/authorize (client_id, redirect_uri, scope, state, etc.), builds an AuthnRequest, persists the OAuth context + AuthnRequest ID in SamlOuterInfo, sets a CSRF cookie keyed to the request ID, and redirects to the IdP. Honors stack_response_mode=json so the SDK can intercept programmatically. V1 scope: SP-initiated only, no signed AuthnRequests, no link/upgrade flow. - POST /api/v1/auth/saml/acs/[connection_id] Receives the IdP's POST. Parses InResponseTo from the response WITHOUT verifying the signature, looks up SamlOuterInfo to recover tenancy/connection (this is necessary because the connection ID alone doesn't index a tenancy in the JSON-config storage model). Validates CSRF cookie, then runs node-saml's full validatePostResponseAsync (signature + audience + clock skew + InResponseTo). Defense-in-depth re-checks InResponseTo and cross-connection mismatch (the latter handles 'assertion sent to the wrong ACS endpoint' forgery, e2e test #10). On success, runs find-existing / link / create via the saml-account.tsx helpers, then hands off to oauthServer.authorize so Stack Auth issues a customer-facing OAuth code (mirrors the oauth/callback pattern). Deletes SamlOuterInfo at the end for replay protection. Adds extractInResponseTo helper to saml/saml.tsx for the pre-validation parse described above. Routes typecheck and lint clean. Runtime untested — needs the e2e test matrix (task #15) to exercise the round-trip end-to-end against the mock IdP.
This commit is contained in:
parent
189a543a31
commit
191ad700bd
@ -0,0 +1,311 @@
|
||||
/**
|
||||
* SAML Assertion Consumer Service. Mirrors /auth/oauth/callback/[provider_id]:
|
||||
* receives the IdP's POST, verifies the assertion, runs the SAML user-linking
|
||||
* flow, then hands off to oauthServer.authorize so Stack Auth issues a
|
||||
* customer-facing OAuth code (Stack Auth itself acts as an OAuth2 provider
|
||||
* to the customer's SDK — see comment at top of oauth/callback/[provider_id]
|
||||
* for context).
|
||||
*
|
||||
* Replay protection: the matching SamlOuterInfo row is consumed
|
||||
* (deleted) at the end of a successful flow, and the route looks up by
|
||||
* InResponseTo before calling node-saml's full validation. node-saml then
|
||||
* also enforces signature, audience, NotBefore/NotOnOrAfter, and
|
||||
* InResponseTo equality.
|
||||
*/
|
||||
import { usersCrudHandlers } from "@/app/api/latest/users/crud";
|
||||
import { getBestEffortEndUserRequestContext } from "@/lib/end-users";
|
||||
import { reconstructTurnstileAssessment, buildSignUpRuleOptions } from "@/lib/sign-up-context";
|
||||
import { checkApiKeySet, throwCheckApiKeySetError } from "@/lib/internal-api-keys";
|
||||
import { isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls";
|
||||
import { createSamlUserAndAccount, findExistingSamlAccount, handleSamlEmailMergeStrategy, linkSamlAccountToUser } from "@/lib/saml-account";
|
||||
import { Tenancy, getTenancy } from "@/lib/tenancies";
|
||||
import { oauthServer } from "@/oauth";
|
||||
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { buildSamlClient, extractInResponseTo, parseAndVerifyAssertion, SamlConnectionConfig } from "@/saml/saml";
|
||||
import { InvalidClientError, InvalidScopeError, Request as OAuthRequest, Response as OAuthResponse } from "@node-oauth/oauth2-server";
|
||||
import { KnownError, KnownErrors } from "@stackframe/stack-shared";
|
||||
import { yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { oauthResponseToSmartResponse } from "../../../oauth/oauth-helpers";
|
||||
|
||||
type SamlOuterInfoPayload = {
|
||||
tenancyId: string,
|
||||
samlConnectionId: string,
|
||||
publishableClientKey: string,
|
||||
redirectUri: string,
|
||||
state: string,
|
||||
scope: string,
|
||||
grantType: string,
|
||||
codeChallenge: string,
|
||||
codeChallengeMethod: string,
|
||||
responseType: string,
|
||||
errorRedirectUrl?: string,
|
||||
afterCallbackRedirectUrl?: string,
|
||||
responseMode: "json" | "redirect",
|
||||
};
|
||||
|
||||
const redirectOrThrowError = (error: KnownError, tenancy: Tenancy, options: {
|
||||
callbackRedirectUrl?: string,
|
||||
errorRedirectUrl?: string,
|
||||
}) => {
|
||||
const target =
|
||||
options.callbackRedirectUrl && (validateRedirectUrl(options.callbackRedirectUrl, tenancy) || isAcceptedNativeAppUrl(options.callbackRedirectUrl))
|
||||
? options.callbackRedirectUrl
|
||||
: options.errorRedirectUrl && (validateRedirectUrl(options.errorRedirectUrl, tenancy) || isAcceptedNativeAppUrl(options.errorRedirectUrl))
|
||||
? options.errorRedirectUrl
|
||||
: null;
|
||||
if (!target) throw error;
|
||||
|
||||
const url = new URL(target);
|
||||
url.searchParams.set("error", "server_error");
|
||||
url.searchParams.set("error_description", error.message);
|
||||
url.searchParams.set("errorCode", error.errorCode);
|
||||
url.searchParams.set("message", error.message);
|
||||
url.searchParams.set("details", error.details ? JSON.stringify(error.details) : JSON.stringify({}));
|
||||
redirect(url.toString());
|
||||
};
|
||||
|
||||
const shouldRedirectKnownError = (error: KnownError) => (
|
||||
KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse.isInstance(error)
|
||||
|| KnownErrors.SignUpNotEnabled.isInstance(error)
|
||||
|| KnownErrors.SignUpRejected.isInstance(error)
|
||||
);
|
||||
|
||||
export const POST = createSmartRouteHandler({
|
||||
metadata: { hidden: true },
|
||||
request: yupObject({
|
||||
params: yupObject({
|
||||
connection_id: yupString().defined(),
|
||||
}).defined(),
|
||||
body: yupMixed().defined(),
|
||||
}),
|
||||
response: yupObject({
|
||||
statusCode: yupNumber().oneOf([303, 307]).defined(),
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: yupMixed().defined(),
|
||||
headers: yupMixed().defined(),
|
||||
}),
|
||||
async handler({ params, body }) {
|
||||
const samlResponseB64 = (body as Record<string, unknown>).SAMLResponse as string | undefined;
|
||||
if (!samlResponseB64) {
|
||||
throw new StatusError(StatusError.BadRequest, "Missing SAMLResponse in form body");
|
||||
}
|
||||
|
||||
const inResponseTo = extractInResponseTo(samlResponseB64);
|
||||
if (!inResponseTo) {
|
||||
throw new StatusError(StatusError.BadRequest, "SAMLResponse has no InResponseTo (IdP-initiated SSO is not supported in V1)");
|
||||
}
|
||||
|
||||
const outerInfoDB = await globalPrismaClient.samlOuterInfo.findUnique({ where: { id: inResponseTo } });
|
||||
if (!outerInfoDB) {
|
||||
throw new StatusError(StatusError.BadRequest, "Unknown InResponseTo — SAMLResponse does not match any pending AuthnRequest. Please try signing in again.");
|
||||
}
|
||||
|
||||
const outerInfo = outerInfoDB.info as unknown as SamlOuterInfoPayload;
|
||||
|
||||
if (outerInfo.samlConnectionId !== params.connection_id) {
|
||||
// Cross-connection forgery — assertion was sent to the wrong ACS endpoint.
|
||||
throw new StatusError(StatusError.BadRequest, "SAML connection mismatch (assertion sent to wrong ACS endpoint)");
|
||||
}
|
||||
|
||||
if (outerInfoDB.expiresAt < new Date()) {
|
||||
throw new KnownErrors.OuterOAuthTimeout();
|
||||
}
|
||||
|
||||
if (outerInfo.responseMode !== "json") {
|
||||
const cookieInfo = (await cookies()).get("stack-saml-inner-" + inResponseTo);
|
||||
(await cookies()).delete("stack-saml-inner-" + inResponseTo);
|
||||
if (cookieInfo?.value !== "true") {
|
||||
throw new StatusError(StatusError.BadRequest, "Inner SAML cookie not found. Likely the page was refreshed mid-flow. Please try signing in again.");
|
||||
}
|
||||
}
|
||||
|
||||
const tenancy = await getTenancy(outerInfo.tenancyId);
|
||||
if (!tenancy) {
|
||||
throw new StackAssertionError("Tenancy from SamlOuterInfo not found", { tenancyId: outerInfo.tenancyId });
|
||||
}
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
|
||||
const connectionRaw = tenancy.config.auth.saml.connections[params.connection_id];
|
||||
if (!connectionRaw.idpEntityId || !connectionRaw.idpSsoUrl || !connectionRaw.idpCertificate) {
|
||||
throw new StatusError(StatusError.NotFound, `SAML connection ${params.connection_id} is not configured`);
|
||||
}
|
||||
const connection: SamlConnectionConfig = {
|
||||
id: params.connection_id,
|
||||
displayName: connectionRaw.displayName,
|
||||
idpEntityId: connectionRaw.idpEntityId,
|
||||
idpSsoUrl: connectionRaw.idpSsoUrl,
|
||||
idpCertificate: connectionRaw.idpCertificate,
|
||||
domain: connectionRaw.domain,
|
||||
attributeMapping: connectionRaw.attributeMapping,
|
||||
};
|
||||
|
||||
const keyCheck = await checkApiKeySet(tenancy.project.id, { publishableClientKey: outerInfo.publishableClientKey });
|
||||
if (keyCheck.status === "error") {
|
||||
throwCheckApiKeySetError(keyCheck.error, tenancy.project.id, new KnownErrors.InvalidPublishableClientKey(tenancy.project.id));
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = new URL(outerInfo.redirectUri).origin;
|
||||
const client = buildSamlClient(connection, baseUrl);
|
||||
const assertion = await parseAndVerifyAssertion(client, connection, samlResponseB64, undefined);
|
||||
|
||||
// Defense-in-depth: node-saml's validation already checks InResponseTo,
|
||||
// but we re-check here against what we stored.
|
||||
if (assertion.inResponseTo !== inResponseTo) {
|
||||
throw new StatusError(StatusError.BadRequest, "Assertion InResponseTo does not match the AuthnRequest ID we stored");
|
||||
}
|
||||
if (!assertion.nameId) {
|
||||
throw new StatusError(StatusError.BadRequest, "Assertion has no NameID");
|
||||
}
|
||||
if (!assertion.email) {
|
||||
throw new StatusError(StatusError.BadRequest, "Assertion has no email attribute or email-format NameID");
|
||||
}
|
||||
|
||||
// Reconstruct the OAuth context originally passed to /auth/saml/login —
|
||||
// oauthServer.authorize needs all of it to issue the customer-facing code.
|
||||
const oauthRequest = new OAuthRequest({
|
||||
headers: {},
|
||||
body: {},
|
||||
method: "GET",
|
||||
query: {
|
||||
client_id: `${tenancy.project.id}#${tenancy.branchId}`,
|
||||
client_secret: outerInfo.publishableClientKey,
|
||||
redirect_uri: outerInfo.redirectUri,
|
||||
state: outerInfo.state,
|
||||
scope: outerInfo.scope,
|
||||
grant_type: outerInfo.grantType,
|
||||
code_challenge: outerInfo.codeChallenge,
|
||||
code_challenge_method: outerInfo.codeChallengeMethod,
|
||||
response_type: outerInfo.responseType,
|
||||
},
|
||||
});
|
||||
|
||||
const oauthResponse = new OAuthResponse();
|
||||
try {
|
||||
await oauthServer.authorize(
|
||||
oauthRequest,
|
||||
oauthResponse,
|
||||
{
|
||||
authenticateHandler: {
|
||||
handle: async () => {
|
||||
try {
|
||||
const existing = await findExistingSamlAccount(
|
||||
prisma,
|
||||
outerInfo.tenancyId,
|
||||
params.connection_id,
|
||||
assertion.nameId,
|
||||
);
|
||||
if (existing) {
|
||||
return {
|
||||
id: existing.projectUserId ?? throwAssertion("SAML account exists but has no associated user"),
|
||||
newUser: false,
|
||||
afterCallbackRedirectUrl: outerInfo.afterCallbackRedirectUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// No existing SAML account → try to merge with an existing
|
||||
// user by email, otherwise create a new user.
|
||||
const { linkedUserId, primaryEmailAuthEnabled } = await handleSamlEmailMergeStrategy(
|
||||
prisma,
|
||||
tenancy,
|
||||
{ email: assertion.email!, emailVerified: true },
|
||||
);
|
||||
|
||||
if (linkedUserId) {
|
||||
await linkSamlAccountToUser(prisma, {
|
||||
tenancyId: outerInfo.tenancyId,
|
||||
samlConnectionId: params.connection_id,
|
||||
nameId: assertion.nameId,
|
||||
nameIdFormat: assertion.nameIdFormat,
|
||||
email: assertion.email,
|
||||
projectUserId: linkedUserId,
|
||||
});
|
||||
return {
|
||||
id: linkedUserId,
|
||||
newUser: false,
|
||||
afterCallbackRedirectUrl: outerInfo.afterCallbackRedirectUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const requestContext = await getBestEffortEndUserRequestContext();
|
||||
const { projectUserId: newUserId } = await createSamlUserAndAccount(
|
||||
prisma,
|
||||
tenancy,
|
||||
{
|
||||
samlConnectionId: params.connection_id,
|
||||
nameId: assertion.nameId,
|
||||
nameIdFormat: assertion.nameIdFormat,
|
||||
email: assertion.email,
|
||||
emailVerified: true, // SAML assertions are signed by the IdP — treat email as verified
|
||||
primaryEmailAuthEnabled,
|
||||
currentUser: null,
|
||||
displayName: assertion.displayName,
|
||||
profileImageUrl: null,
|
||||
signUpRuleOptions: buildSignUpRuleOptions({
|
||||
authMethod: "oauth", // closest existing tag; future: add 'saml'
|
||||
oauthProvider: `saml:${params.connection_id}`,
|
||||
requestContext,
|
||||
turnstileAssessment: reconstructTurnstileAssessment("invalid", undefined),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
id: newUserId,
|
||||
newUser: true,
|
||||
afterCallbackRedirectUrl: outerInfo.afterCallbackRedirectUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
if (KnownError.isKnownError(error) && shouldRedirectKnownError(error)) {
|
||||
redirectOrThrowError(error, tenancy, {
|
||||
callbackRedirectUrl: outerInfo.redirectUri,
|
||||
errorRedirectUrl: outerInfo.errorRedirectUrl,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidClientError) {
|
||||
if (error.message.includes("redirect_uri") || error.message.includes("redirectUri")) {
|
||||
throw new KnownErrors.RedirectUrlNotWhitelisted();
|
||||
}
|
||||
} else if (error instanceof InvalidScopeError) {
|
||||
captureError("saml-acs-invalid-scope", new StackAssertionError(deindent`
|
||||
Client requested an invalid scope during SAML ACS.
|
||||
Scopes requested: ${oauthRequest.query?.scope}
|
||||
`, { outerInfo, cause: error, scopes: oauthRequest.query?.scope }));
|
||||
throw new StatusError(StatusError.BadRequest, "Invalid scope requested");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Replay protection — consume the OuterInfo row so the same assertion
|
||||
// (or a re-issued one from the same AuthnRequest) cannot be replayed.
|
||||
// The next look-up by InResponseTo will 400.
|
||||
await globalPrismaClient.samlOuterInfo.delete({ where: { id: inResponseTo } });
|
||||
|
||||
return oauthResponseToSmartResponse(oauthResponse);
|
||||
} catch (error) {
|
||||
if (KnownError.isKnownError(error)) {
|
||||
redirectOrThrowError(error, tenancy, {
|
||||
callbackRedirectUrl: outerInfo.redirectUri,
|
||||
errorRedirectUrl: outerInfo.errorRedirectUrl,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function throwAssertion(msg: string): never {
|
||||
throw new StackAssertionError(msg);
|
||||
}
|
||||
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* SAML SP-initiated login. Mirrors /auth/oauth/authorize/[provider_id]:
|
||||
* receives the same OAuth client params (so Stack Auth itself can later
|
||||
* issue an OAuth code via oauthServer.authorize), stashes them in
|
||||
* SamlOuterInfo keyed by AuthnRequest ID, and redirects to the IdP.
|
||||
*
|
||||
* V1 scope: SP-initiated only, no signed AuthnRequests, no link/upgrade
|
||||
* flow (just plain sign-in). Turnstile is also skipped here — SAML
|
||||
* sign-in originates from a corporate IdP, not a public form.
|
||||
*/
|
||||
import { checkApiKeySet, throwCheckApiKeySetError } from "@/lib/internal-api-keys";
|
||||
import { isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls";
|
||||
import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
|
||||
import { getProjectBranchFromClientId } from "@/oauth";
|
||||
import { globalPrismaClient } from "@/prisma-client";
|
||||
import type { SmartResponse } from "@/route-handlers/smart-response";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { buildAuthnRequestUrl, buildSamlClient, SamlConnectionConfig } from "@/saml/saml";
|
||||
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
|
||||
import { urlSchema, yupArray, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Schema } from "yup";
|
||||
|
||||
const SAML_OUTER_TTL_MINUTES = 10;
|
||||
|
||||
// Stored in SamlOuterInfo.info — narrower than OAuth's outer info because
|
||||
// SAML doesn't have PKCE / response_type / scope handling at this layer.
|
||||
type SamlOuterInfoPayload = {
|
||||
tenancyId: string,
|
||||
samlConnectionId: string,
|
||||
publishableClientKey: string,
|
||||
redirectUri: string,
|
||||
state: string,
|
||||
scope: string,
|
||||
grantType: string,
|
||||
codeChallenge: string,
|
||||
codeChallengeMethod: string,
|
||||
responseType: string,
|
||||
errorRedirectUrl?: string,
|
||||
afterCallbackRedirectUrl?: string,
|
||||
responseMode: "json" | "redirect",
|
||||
};
|
||||
|
||||
export const GET = createSmartRouteHandler({
|
||||
metadata: {
|
||||
summary: "SAML SP-initiated login",
|
||||
description: "Build a SAML AuthnRequest, persist outer state, and redirect the browser to the IdP.",
|
||||
tags: ["Saml"],
|
||||
},
|
||||
request: yupObject({
|
||||
params: yupObject({
|
||||
connection_id: yupString().defined(),
|
||||
}).defined(),
|
||||
query: yupObject({
|
||||
// Stack Auth OAuth client params — same as /auth/oauth/authorize.
|
||||
client_id: yupString().defined(),
|
||||
client_secret: yupString().defined(),
|
||||
redirect_uri: urlSchema.defined(),
|
||||
scope: yupString().defined(),
|
||||
state: yupString().defined(),
|
||||
grant_type: yupString().oneOf(["authorization_code"]).defined(),
|
||||
code_challenge: yupString().defined(),
|
||||
code_challenge_method: yupString().defined(),
|
||||
response_type: yupString().defined(),
|
||||
// Optional after-callback redirect (where to send the user post-sign-in).
|
||||
after_callback_redirect_url: urlSchema.optional(),
|
||||
error_redirect_uri: urlSchema.optional(),
|
||||
// SDK uses stack_response_mode=json so it can intercept before navigating.
|
||||
stack_response_mode: yupString().oneOf(["json", "redirect"]).default("redirect"),
|
||||
}).noUnknown(false).defined(),
|
||||
}),
|
||||
response: yupUnion(
|
||||
yupObject({
|
||||
statusCode: yupNumber().oneOf([200]).defined(),
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: yupObject({
|
||||
location: yupString().defined(),
|
||||
}).defined(),
|
||||
}).defined(),
|
||||
yupObject({
|
||||
statusCode: yupNumber().oneOf([307]).defined(),
|
||||
headers: yupObject({
|
||||
location: yupArray(yupString().defined()).defined(),
|
||||
}).defined(),
|
||||
bodyType: yupString().oneOf(["text"]).defined(),
|
||||
body: yupString().defined(),
|
||||
}).defined(),
|
||||
) as unknown as Schema<SmartResponse>,
|
||||
async handler({ params, query }) {
|
||||
const tenancy = await getSoleTenancyFromProjectBranch(...getProjectBranchFromClientId(query.client_id), true);
|
||||
if (!tenancy) {
|
||||
throw new KnownErrors.InvalidOAuthClientIdOrSecret(query.client_id);
|
||||
}
|
||||
|
||||
const keyCheck = await checkApiKeySet(tenancy.project.id, { publishableClientKey: query.client_secret });
|
||||
if (keyCheck.status === "error") {
|
||||
throwCheckApiKeySetError(keyCheck.error, tenancy.project.id, new KnownErrors.InvalidPublishableClientKey(tenancy.project.id));
|
||||
}
|
||||
|
||||
const connectionRaw = tenancy.config.auth.saml.connections[params.connection_id];
|
||||
if (!connectionRaw.idpEntityId || !connectionRaw.idpSsoUrl || !connectionRaw.idpCertificate) {
|
||||
throw new StatusError(StatusError.NotFound, `SAML connection ${params.connection_id} is not configured`);
|
||||
}
|
||||
if (connectionRaw.allowSignIn === false) {
|
||||
throw new StatusError(StatusError.Forbidden, `SAML connection ${params.connection_id} has sign-in disabled`);
|
||||
}
|
||||
|
||||
if (
|
||||
query.after_callback_redirect_url
|
||||
&& !validateRedirectUrl(query.after_callback_redirect_url, tenancy)
|
||||
&& !isAcceptedNativeAppUrl(query.after_callback_redirect_url)
|
||||
) {
|
||||
throw new KnownErrors.RedirectUrlNotWhitelisted();
|
||||
}
|
||||
|
||||
const connection: SamlConnectionConfig = {
|
||||
id: params.connection_id,
|
||||
displayName: connectionRaw.displayName,
|
||||
idpEntityId: connectionRaw.idpEntityId,
|
||||
idpSsoUrl: connectionRaw.idpSsoUrl,
|
||||
idpCertificate: connectionRaw.idpCertificate,
|
||||
domain: connectionRaw.domain,
|
||||
attributeMapping: connectionRaw.attributeMapping,
|
||||
};
|
||||
|
||||
const baseUrl = new URL(query.redirect_uri).origin;
|
||||
const client = buildSamlClient(connection, baseUrl);
|
||||
const { url: samlUrl, requestId } = await buildAuthnRequestUrl(client, query.state);
|
||||
|
||||
const payload: SamlOuterInfoPayload = {
|
||||
tenancyId: tenancy.id,
|
||||
samlConnectionId: params.connection_id,
|
||||
publishableClientKey: query.client_secret,
|
||||
redirectUri: query.redirect_uri.split("#")[0],
|
||||
state: query.state,
|
||||
scope: query.scope,
|
||||
grantType: query.grant_type,
|
||||
codeChallenge: query.code_challenge,
|
||||
codeChallengeMethod: query.code_challenge_method,
|
||||
responseType: query.response_type,
|
||||
errorRedirectUrl: query.error_redirect_uri,
|
||||
afterCallbackRedirectUrl: query.after_callback_redirect_url,
|
||||
responseMode: query.stack_response_mode,
|
||||
};
|
||||
|
||||
await globalPrismaClient.samlOuterInfo.create({
|
||||
data: {
|
||||
id: requestId,
|
||||
info: payload as unknown as object,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * SAML_OUTER_TTL_MINUTES),
|
||||
},
|
||||
});
|
||||
|
||||
if (query.stack_response_mode === "json") {
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "json",
|
||||
body: { location: samlUrl },
|
||||
};
|
||||
}
|
||||
|
||||
// Browser-redirect mode: set a CSRF cookie keyed to the AuthnRequest ID.
|
||||
// The ACS route checks this cookie before honoring the assertion.
|
||||
(await cookies()).set(
|
||||
"stack-saml-inner-" + requestId,
|
||||
"true",
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: getNodeEnvironment() !== "development",
|
||||
maxAge: 60 * SAML_OUTER_TTL_MINUTES,
|
||||
},
|
||||
);
|
||||
|
||||
redirect(samlUrl);
|
||||
},
|
||||
});
|
||||
@ -70,6 +70,22 @@ export function buildSamlClient(connection: SamlConnectionConfig, baseUrl: strin
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the InResponseTo attribute from a SAMLResponse without verifying
|
||||
* the signature. Used by the ACS handler to look up the matching
|
||||
* SamlOuterInfo (and thus recover the tenancy) BEFORE calling node-saml's
|
||||
* full validation.
|
||||
*
|
||||
* Returns null if the attribute isn't present (which would be the case for
|
||||
* IdP-initiated SSO — out of scope for V1, so the caller treats null as
|
||||
* an error).
|
||||
*/
|
||||
export function extractInResponseTo(samlResponseB64: string): string | null {
|
||||
const xml = Buffer.from(samlResponseB64, "base64").toString("utf-8");
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml");
|
||||
return doc.documentElement.getAttribute("InResponseTo");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the redirect URL the browser should follow to begin SAML SSO. Returns
|
||||
* both the URL and the AuthnRequest ID — the ID is stored in SamlOuterInfo
|
||||
|
||||
Loading…
Reference in New Issue
Block a user