fix(saml): enforce trusted provider check (#6551)

This commit is contained in:
Paola Estefanía de Campos
2025-12-05 20:35:27 -03:00
committed by GitHub
parent 2495956502
commit 69db13bcce
2 changed files with 194 additions and 13 deletions

View File

@@ -1722,6 +1722,35 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
});
if (existingUser) {
const account = await ctx.context.adapter.findOne<Account>({
model: "account",
where: [
{ field: "userId", value: existingUser.id },
{ field: "providerId", value: provider.providerId },
{ field: "accountId", value: userInfo.id },
],
});
if (!account) {
const isTrustedProvider =
ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
provider.providerId,
) ||
("domainVerified" in provider &&
provider.domainVerified &&
validateEmailDomain(userInfo.email, provider.domain));
if (!isTrustedProvider) {
const redirectUrl =
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
throw ctx.redirect(`${redirectUrl}?error=account_not_linked`);
}
await ctx.context.internalAdapter.createAccount({
userId: existingUser.id,
providerId: provider.providerId,
accountId: userInfo.id,
accessToken: "",
refreshToken: "",
});
}
user = existingUser;
} else {
// if implicit sign up is disabled, we should not create a new user nor a new account.
@@ -1737,19 +1766,6 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
name: userInfo.name,
emailVerified: userInfo.emailVerified,
});
}
// Create or update account link
const account = await ctx.context.adapter.findOne<Account>({
model: "account",
where: [
{ field: "userId", value: user.id },
{ field: "providerId", value: provider.providerId },
{ field: "accountId", value: userInfo.id },
],
});
if (!account) {
await ctx.context.internalAdapter.createAccount({
userId: user.id,
providerId: provider.providerId,

View File

@@ -1182,6 +1182,171 @@ describe("SAML SSO", async () => {
},
});
});
it("should deny account linking when provider is not trusted and domain is not verified", async () => {
const {
auth: authUntrusted,
signInWithTestUser,
client,
} = await getTestInstance({
account: {
accountLinking: {
enabled: true,
trustedProviders: [],
},
},
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
await authUntrusted.api.registerSSOProvider({
body: {
providerId: "untrusted-saml-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
callbackUrl: "http://localhost:3000/dashboard",
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const ctx = await authUntrusted.$context;
await ctx.adapter.create({
model: "user",
data: {
id: "existing-user-id",
email: "test@email.com",
name: "Existing User",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
},
});
let samlResponse: any;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
const response = await authUntrusted.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/callback/untrusted-saml-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
RelayState: "http://localhost:3000/dashboard",
}),
},
),
);
expect(response.status).toBe(302);
const redirectLocation = response.headers.get("location") || "";
expect(redirectLocation).toContain("error=account_not_linked");
});
it("should allow account linking when provider is in trustedProviders", async () => {
const { auth: authWithTrusted, signInWithTestUser } = await getTestInstance(
{
account: {
accountLinking: {
enabled: true,
trustedProviders: ["trusted-saml-provider"],
},
},
plugins: [sso()],
},
);
const { headers } = await signInWithTestUser();
await authWithTrusted.api.registerSSOProvider({
body: {
providerId: "trusted-saml-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
callbackUrl: "http://localhost:3000/dashboard",
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const ctx = await authWithTrusted.$context;
await ctx.adapter.create({
model: "user",
data: {
id: "existing-user-id-2",
email: "test@email.com",
name: "Existing User",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
},
});
let samlResponse: any;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
const response = await authWithTrusted.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/callback/trusted-saml-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
RelayState: "http://localhost:3000/dashboard",
}),
},
),
);
expect(response.status).toBe(302);
const redirectLocation = response.headers.get("location") || "";
expect(redirectLocation).not.toContain("error");
expect(redirectLocation).toContain("dashboard");
});
});
describe("SAML SSO with custom fields", () => {