From 191ad700bdeed6097e94bb5dff029b3233f6785d Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Apr 2026 15:37:16 -0700 Subject: [PATCH] feat(backend): add SAML login + ACS routes with OAuth2 integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../auth/saml/acs/[connection_id]/route.tsx | 311 ++++++++++++++++++ .../auth/saml/login/[connection_id]/route.tsx | 179 ++++++++++ apps/backend/src/saml/saml.tsx | 16 + 3 files changed, 506 insertions(+) create mode 100644 apps/backend/src/app/api/latest/auth/saml/acs/[connection_id]/route.tsx create mode 100644 apps/backend/src/app/api/latest/auth/saml/login/[connection_id]/route.tsx diff --git a/apps/backend/src/app/api/latest/auth/saml/acs/[connection_id]/route.tsx b/apps/backend/src/app/api/latest/auth/saml/acs/[connection_id]/route.tsx new file mode 100644 index 000000000..7ee9828b7 --- /dev/null +++ b/apps/backend/src/app/api/latest/auth/saml/acs/[connection_id]/route.tsx @@ -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).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); +} diff --git a/apps/backend/src/app/api/latest/auth/saml/login/[connection_id]/route.tsx b/apps/backend/src/app/api/latest/auth/saml/login/[connection_id]/route.tsx new file mode 100644 index 000000000..832f21896 --- /dev/null +++ b/apps/backend/src/app/api/latest/auth/saml/login/[connection_id]/route.tsx @@ -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, + 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); + }, +}); diff --git a/apps/backend/src/saml/saml.tsx b/apps/backend/src/saml/saml.tsx index 9f04ed498..c7e16235e 100644 --- a/apps/backend/src/saml/saml.tsx +++ b/apps/backend/src/saml/saml.tsx @@ -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