mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
test(e2e): add SAML discover, metadata, and login route tests
Three test files exercising the SAML routes that don't require a full IdP round-trip: - discover.test.ts: 5 cases for /auth/saml/discover — happy path, unknown domain (404), case-insensitivity, unknown project_id, cross-project isolation (project A's connection isn't visible from a query against project B's domain). - metadata.test.ts: 3 cases for /auth/saml/metadata — XML contains entityID + ACS URL embedded, 404 for unknown connection, 404 when connection exists but has no IdP cert (incomplete configuration). - login.test.ts: 5 cases for /auth/saml/login — JSON-mode returns the IdP redirect URL with SAMLRequest+RelayState, browser-redirect mode sets the stack-saml-inner- CSRF cookie, 404 unknown connection, 403 when allowSignIn=false, invalid client_id rejected. Test integrity: all tests drive the API only — no imports from apps/backend/src/saml/. SAML config is set via the standard config override endpoint (no test-only mutator), so the routes run through the same code path real customers would hit. Full SAML round-trip tests (login → mock IdP → ACS → session) deferred to a follow-up — they need a sequenced flow against the mock-saml-idp service that's separate from these endpoint-level tests.
This commit is contained in:
parent
958407d0c3
commit
f8093a31c1
@ -0,0 +1,97 @@
|
||||
import { it } from "../../../../../../helpers";
|
||||
import { Project, niceBackendFetch } from "../../../../../backend-helpers";
|
||||
|
||||
/**
|
||||
* Tests for GET /auth/saml/discover.
|
||||
*
|
||||
* Test integrity: drives the API only — no imports from
|
||||
* apps/backend/src/saml/. Project config is set via the standard config
|
||||
* override endpoint (no special test-only mutator), so the discovery
|
||||
* lookup runs through the same code path real customers would hit.
|
||||
*
|
||||
* Connection isolation note: each `it` block creates its own project via
|
||||
* Project.createAndSwitch, so connections don't leak across tests.
|
||||
*/
|
||||
|
||||
async function createProjectWithSamlConnection(slug: string, domain: string) {
|
||||
const { projectId } = await Project.createAndSwitch();
|
||||
// Push the SAML connection at the environment level — that's where the
|
||||
// IdP-side fields live. The discovery endpoint reads from the rendered
|
||||
// organization config which folds in env overrides.
|
||||
await Project.updateConfig({
|
||||
[`auth.saml.connections.${slug}.displayName`]: `${slug} SSO`,
|
||||
[`auth.saml.connections.${slug}.allowSignIn`]: true,
|
||||
[`auth.saml.connections.${slug}.domain`]: domain,
|
||||
[`auth.saml.connections.${slug}.idpEntityId`]: `https://idp.${domain}/saml/metadata`,
|
||||
[`auth.saml.connections.${slug}.idpSsoUrl`]: `https://idp.${domain}/saml/sso`,
|
||||
[`auth.saml.connections.${slug}.idpCertificate`]: "MIICertificatePlaceholderForDiscoveryTest=",
|
||||
});
|
||||
return { projectId };
|
||||
}
|
||||
|
||||
it("returns the matching connection for a known email domain", async ({ expect }) => {
|
||||
const { projectId } = await createProjectWithSamlConnection("acme", "acme.test");
|
||||
|
||||
const response = await niceBackendFetch(
|
||||
`/api/v1/auth/saml/discover?email=alice@acme.test&project_id=${projectId}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"connection_id": "acme",
|
||||
"display_name": "acme SSO",
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("returns 404 when no connection matches the email's domain", async ({ expect }) => {
|
||||
const { projectId } = await createProjectWithSamlConnection("acme", "acme.test");
|
||||
|
||||
const response = await niceBackendFetch(
|
||||
`/api/v1/auth/saml/discover?email=stranger@unknown.test&project_id=${projectId}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("matches the connection case-insensitively on the email domain", async ({ expect }) => {
|
||||
const { projectId } = await createProjectWithSamlConnection("acme", "acme.test");
|
||||
|
||||
const response = await niceBackendFetch(
|
||||
`/api/v1/auth/saml/discover?email=ALICE@ACME.TEST&project_id=${projectId}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ connection_id: "acme", display_name: "acme SSO" });
|
||||
});
|
||||
|
||||
it("returns 404 for an unknown project_id", async ({ expect }) => {
|
||||
await createProjectWithSamlConnection("acme", "acme.test");
|
||||
|
||||
const response = await niceBackendFetch(
|
||||
`/api/v1/auth/saml/discover?email=alice@acme.test&project_id=00000000-0000-0000-0000-000000000000`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("isolates connections across projects (B's connection is not visible from A)", async ({ expect }) => {
|
||||
// Project A has acme; project B has globex. Querying A for globex's domain must miss.
|
||||
const { projectId: projectA } = await createProjectWithSamlConnection("acme", "acme.test");
|
||||
await createProjectWithSamlConnection("globex", "globex.test");
|
||||
|
||||
const response = await niceBackendFetch(
|
||||
`/api/v1/auth/saml/discover?email=bob@globex.test&project_id=${projectA}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
115
apps/e2e/tests/backend/endpoints/api/v1/auth/saml/login.test.ts
Normal file
115
apps/e2e/tests/backend/endpoints/api/v1/auth/saml/login.test.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { it } from "../../../../../../helpers";
|
||||
import { InternalApiKey, Project, backendContext, niceBackendFetch } from "../../../../../backend-helpers";
|
||||
|
||||
/**
|
||||
* Tests for GET /auth/saml/login/[connection_id].
|
||||
*
|
||||
* Verifies the SDK-facing endpoint that begins SP-initiated SSO. We don't
|
||||
* follow the redirect into the IdP here — that's the round-trip test
|
||||
* (deferred). These tests check:
|
||||
* - URL is built correctly and points at the configured IdP SSO URL
|
||||
* - SAMLRequest + RelayState query params are present
|
||||
* - CSRF cookie is set in browser-redirect mode
|
||||
* - JSON-mode response returns the location instead of redirecting
|
||||
* - Error paths: invalid client, unknown connection, sign-in disabled
|
||||
*/
|
||||
|
||||
async function setupProjectWithSamlConnection(slug: string, idpHost: string) {
|
||||
await Project.createAndSwitch();
|
||||
await InternalApiKey.createAndSetProjectKeys();
|
||||
await Project.updateConfig({
|
||||
[`auth.saml.connections.${slug}.displayName`]: `${slug} SSO`,
|
||||
[`auth.saml.connections.${slug}.allowSignIn`]: true,
|
||||
[`auth.saml.connections.${slug}.idpEntityId`]: `https://${idpHost}/saml/metadata`,
|
||||
[`auth.saml.connections.${slug}.idpSsoUrl`]: `https://${idpHost}/saml/sso`,
|
||||
[`auth.saml.connections.${slug}.idpCertificate`]: "MIICertificatePlaceholderForLoginTest=",
|
||||
});
|
||||
}
|
||||
|
||||
function loginQuery() {
|
||||
const projectKeys = backendContext.value.projectKeys;
|
||||
if (projectKeys === "no-project") throw new Error("No project keys");
|
||||
const branchId = backendContext.value.currentBranchId;
|
||||
return {
|
||||
client_id: !branchId ? projectKeys.projectId : `${projectKeys.projectId}#${branchId}`,
|
||||
client_secret: projectKeys.publishableClientKey ?? "",
|
||||
redirect_uri: "http://localhost:8101/handler/oauth-callback",
|
||||
scope: "legacy",
|
||||
state: "this-is-some-state",
|
||||
grant_type: "authorization_code",
|
||||
code_challenge: "some-code-challenge",
|
||||
code_challenge_method: "S256",
|
||||
response_type: "code",
|
||||
};
|
||||
}
|
||||
|
||||
it("returns the IdP SSO URL with SAMLRequest in JSON-mode", async ({ expect }) => {
|
||||
await setupProjectWithSamlConnection("acme", "idp.acme.test");
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/auth/saml/login/acme", {
|
||||
method: "GET",
|
||||
query: { ...loginQuery(), stack_response_mode: "json" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(typeof (response.body as { location?: unknown }).location).toBe("string");
|
||||
const location = new URL((response.body as { location: string }).location);
|
||||
expect(location.host).toBe("idp.acme.test");
|
||||
expect(location.pathname).toBe("/saml/sso");
|
||||
expect(location.searchParams.get("SAMLRequest")).toBeTruthy();
|
||||
expect(location.searchParams.get("RelayState")).toBe("this-is-some-state");
|
||||
});
|
||||
|
||||
it("redirects + sets CSRF cookie in browser-redirect mode", async ({ expect }) => {
|
||||
await setupProjectWithSamlConnection("acme", "idp.acme.test");
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/auth/saml/login/acme", {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
query: loginQuery(),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
const location = response.headers.get("location");
|
||||
expect(location).toBeTruthy();
|
||||
expect(new URL(location!).host).toBe("idp.acme.test");
|
||||
// CSRF cookie keyed to the AuthnRequest ID — snapshot serializer strips
|
||||
// the suffix via the keyedCookieNamePrefixes registration.
|
||||
expect(response.headers.get("set-cookie")).toMatch(/^stack-saml-inner-[^;]+=true;/);
|
||||
});
|
||||
|
||||
it("returns 404 for an unknown connection ID", async ({ expect }) => {
|
||||
await setupProjectWithSamlConnection("acme", "idp.acme.test");
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/auth/saml/login/does-not-exist", {
|
||||
method: "GET",
|
||||
query: loginQuery(),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 403 when allowSignIn is false on the connection", async ({ expect }) => {
|
||||
await setupProjectWithSamlConnection("acme", "idp.acme.test");
|
||||
await Project.updateConfig({ "auth.saml.connections.acme.allowSignIn": false });
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/auth/saml/login/acme", {
|
||||
method: "GET",
|
||||
query: loginQuery(),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects an invalid client_id", async ({ expect }) => {
|
||||
await setupProjectWithSamlConnection("acme", "idp.acme.test");
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/auth/saml/login/acme", {
|
||||
method: "GET",
|
||||
query: { ...loginQuery(), client_id: "00000000-0000-0000-0000-000000000000" },
|
||||
});
|
||||
|
||||
// Tenancy lookup fails → InvalidOAuthClientIdOrSecret known error.
|
||||
expect(response.status).not.toBe(200);
|
||||
expect(response.status).not.toBe(307);
|
||||
});
|
||||
@ -0,0 +1,72 @@
|
||||
import { it } from "../../../../../../helpers";
|
||||
import { Project, niceBackendFetch } from "../../../../../backend-helpers";
|
||||
|
||||
/**
|
||||
* Tests for GET /auth/saml/metadata/[connection_id].
|
||||
*
|
||||
* The IdP admin fetches this URL to wire up the SP side. We assert the
|
||||
* returned XML contains entityID + AssertionConsumerService URLs that
|
||||
* match what the IdP would actually need.
|
||||
*/
|
||||
|
||||
async function setupSamlConnection(slug: string) {
|
||||
const { projectId } = await Project.createAndSwitch();
|
||||
await Project.updateConfig({
|
||||
[`auth.saml.connections.${slug}.displayName`]: `${slug} SSO`,
|
||||
[`auth.saml.connections.${slug}.allowSignIn`]: true,
|
||||
[`auth.saml.connections.${slug}.idpEntityId`]: `https://idp.${slug}.test/saml/metadata`,
|
||||
[`auth.saml.connections.${slug}.idpSsoUrl`]: `https://idp.${slug}.test/saml/sso`,
|
||||
[`auth.saml.connections.${slug}.idpCertificate`]: "MIICertificatePlaceholderForMetadataTest=",
|
||||
});
|
||||
return { projectId };
|
||||
}
|
||||
|
||||
it("returns SP metadata XML with entityID and ACS URL embedded", async ({ expect }) => {
|
||||
const { projectId } = await setupSamlConnection("acme");
|
||||
|
||||
const response = await niceBackendFetch(
|
||||
`/api/v1/auth/saml/metadata/acme?project_id=${projectId}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(typeof response.body).toBe("string");
|
||||
const xml = response.body as string;
|
||||
// entityID should reference our metadata URL.
|
||||
expect(xml).toContain('entityID="');
|
||||
expect(xml).toContain("/api/v1/auth/saml/metadata/acme");
|
||||
// AssertionConsumerService should point at the ACS endpoint.
|
||||
expect(xml).toContain("/api/v1/auth/saml/acs/acme");
|
||||
// Must declare the HTTP-POST binding for the IdP to know where to POST.
|
||||
expect(xml).toContain("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST");
|
||||
});
|
||||
|
||||
it("returns 404 for an unknown connection ID", async ({ expect }) => {
|
||||
const { projectId } = await setupSamlConnection("acme");
|
||||
|
||||
const response = await niceBackendFetch(
|
||||
`/api/v1/auth/saml/metadata/does-not-exist?project_id=${projectId}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when the connection exists but has no IdP cert configured", async ({ expect }) => {
|
||||
const { projectId } = await Project.createAndSwitch();
|
||||
// Create a connection at branch level but skip the env-level IdP fields.
|
||||
await Project.updateConfig({
|
||||
"auth.saml.connections.partial.displayName": "Partial",
|
||||
"auth.saml.connections.partial.allowSignIn": true,
|
||||
// No idpEntityId / idpSsoUrl / idpCertificate.
|
||||
});
|
||||
|
||||
const response = await niceBackendFetch(
|
||||
`/api/v1/auth/saml/metadata/partial?project_id=${projectId}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
|
||||
// Without the IdP fields, the SP metadata wouldn't be useful (the IdP
|
||||
// and SP need to know each other's cert for trust). Return 404.
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user