refactor: native trusted providers callback usage
This commit is contained in:
parent
fe65c54250
commit
55fcf245ff
7 changed files with 120 additions and 212 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, mock, test } from "bun:test";
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
await mock.module("@tanstack/react-router", () => ({
|
await mock.module("@tanstack/react-router", () => ({
|
||||||
|
|
@ -28,10 +28,25 @@ await mock.module("~/client/lib/auth-client", () => ({
|
||||||
|
|
||||||
import { LoginPage } from "../login";
|
import { LoginPage } from "../login";
|
||||||
|
|
||||||
const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const createTestQueryClient = () =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
gcTime: Infinity,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
gcTime: Infinity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
const inviteOnlyMessage =
|
const inviteOnlyMessage =
|
||||||
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
|
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
describe("LoginPage", () => {
|
describe("LoginPage", () => {
|
||||||
test("shows an invite-only message when SSO returns INVITE_REQUIRED code", async () => {
|
test("shows an invite-only message when SSO returns INVITE_REQUIRED code", async () => {
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
|
|
@ -104,7 +119,7 @@ describe("LoginPage", () => {
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// The error message container should be hidden
|
expect(await screen.findByText("Login to your account")).toBeTruthy();
|
||||||
expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
|
expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,16 @@ import { validateSsoCallbackUrls } from "./auth/middlewares/validate-sso-callbac
|
||||||
import { validateSsoProviderId } from "./auth/middlewares/validate-sso-provider-id";
|
import { validateSsoProviderId } from "./auth/middlewares/validate-sso-provider-id";
|
||||||
import { createUserDefaultOrg } from "./auth/helpers/create-default-org";
|
import { createUserDefaultOrg } from "./auth/helpers/create-default-org";
|
||||||
import { isSsoCallbackRequest, requireSsoInvitation } from "./auth/middlewares/require-sso-invitation";
|
import { isSsoCallbackRequest, requireSsoInvitation } from "./auth/middlewares/require-sso-invitation";
|
||||||
import { ssoTrustedProviderLinkingPlugin } from "./auth/plugins/sso-trusted-provider-linking";
|
import { resolveTrustedProvidersForRequest } from "./auth/middlewares/trust-sso-provider-for-linking";
|
||||||
|
|
||||||
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
secret: await cryptoUtils.deriveSecret("better-auth"),
|
secret: await cryptoUtils.deriveSecret("better-auth"),
|
||||||
baseURL: config.baseUrl,
|
baseURL: {
|
||||||
|
allowedHosts: [new URL(config.baseUrl).host, ...config.trustedOrigins.map((origin) => new URL(origin).host)],
|
||||||
|
protocol: "auto",
|
||||||
|
},
|
||||||
trustedOrigins: config.trustedOrigins,
|
trustedOrigins: config.trustedOrigins,
|
||||||
advanced: {
|
advanced: {
|
||||||
cookiePrefix: "zerobyte",
|
cookiePrefix: "zerobyte",
|
||||||
|
|
@ -92,6 +95,7 @@ export const auth = betterAuth({
|
||||||
account: {
|
account: {
|
||||||
accountLinking: {
|
accountLinking: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
trustedProviders: resolveTrustedProvidersForRequest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
|
|
@ -133,7 +137,6 @@ export const auth = betterAuth({
|
||||||
defaultRole: "member",
|
defaultRole: "member",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
ssoTrustedProviderLinkingPlugin(),
|
|
||||||
twoFactor({
|
twoFactor({
|
||||||
backupCodeOptions: {
|
backupCodeOptions: {
|
||||||
storeBackupCodes: "encrypted",
|
storeBackupCodes: "encrypted",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { beforeEach, describe, expect, test } from "bun:test";
|
import { beforeEach, describe, expect, test } from "bun:test";
|
||||||
import type { GenericEndpointContext } from "@better-auth/core";
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "~/server/db/db";
|
import { db } from "~/server/db/db";
|
||||||
import { account, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
import { account, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
||||||
import { isSsoCallbackPath, trustSsoProviderForLinking } from "../trust-sso-provider-for-linking";
|
import { resolveTrustedProvidersForRequest } from "../trust-sso-provider-for-linking";
|
||||||
|
|
||||||
function randomId() {
|
function randomId() {
|
||||||
return Bun.randomUUIDv7();
|
return Bun.randomUUIDv7();
|
||||||
|
|
@ -13,38 +12,8 @@ function randomSlug(prefix: string) {
|
||||||
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockContext(options: {
|
function createRequest(path: string): Request {
|
||||||
path: string;
|
return new Request(`http://test.local${path}`);
|
||||||
method?: string;
|
|
||||||
params?: Record<string, string>;
|
|
||||||
trustedProviders?: string[];
|
|
||||||
enabled?: boolean;
|
|
||||||
}): GenericEndpointContext {
|
|
||||||
const { path, method = "GET", params = {}, trustedProviders = [], enabled = true } = options;
|
|
||||||
|
|
||||||
const accountLinking = {
|
|
||||||
enabled,
|
|
||||||
trustedProviders,
|
|
||||||
};
|
|
||||||
|
|
||||||
const context = {
|
|
||||||
options: {
|
|
||||||
account: {
|
|
||||||
accountLinking,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
path,
|
|
||||||
body: {},
|
|
||||||
query: {},
|
|
||||||
headers: new Headers(),
|
|
||||||
request: new Request(`http://test.local${path}`, { method }),
|
|
||||||
params,
|
|
||||||
method,
|
|
||||||
context: context as GenericEndpointContext["context"],
|
|
||||||
} as GenericEndpointContext;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSsoProviderRecord(
|
async function createSsoProviderRecord(
|
||||||
|
|
@ -86,18 +55,7 @@ async function createSsoProviderRecord(
|
||||||
return { organizationId, userId };
|
return { organizationId, userId };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("isSsoCallbackPath", () => {
|
describe("resolveTrustedProvidersForRequest", () => {
|
||||||
test("detects OIDC callback paths", () => {
|
|
||||||
expect(isSsoCallbackPath("/sso/callback/pocket-id")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ignores non-callback paths", () => {
|
|
||||||
expect(isSsoCallbackPath("/sso/register")).toBe(false);
|
|
||||||
expect(isSsoCallbackPath("/sign-in/email")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("trustSsoProviderForLinking", () => {
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await db.delete(member);
|
await db.delete(member);
|
||||||
await db.delete(account);
|
await db.delete(account);
|
||||||
|
|
@ -106,110 +64,61 @@ describe("trustSsoProviderForLinking", () => {
|
||||||
await db.delete(usersTable);
|
await db.delete(usersTable);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adds callback provider to trusted providers", async () => {
|
test("returns [] when request is missing", async () => {
|
||||||
|
expect(await resolveTrustedProvidersForRequest()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns [] for non-callback paths", async () => {
|
||||||
|
expect(await resolveTrustedProvidersForRequest(createRequest("/sign-in/email"))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns [] for unknown providers", async () => {
|
||||||
|
expect(await resolveTrustedProvidersForRequest(createRequest("/sso/callback/missing-provider"))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns auto-link-enabled providers from the callback provider organization", async () => {
|
||||||
|
const { organizationId, userId } = await createSsoProviderRecord("pocket-id", true);
|
||||||
|
|
||||||
|
await createSsoProviderRecord("acme-saml", true, { organizationId, userId });
|
||||||
|
await createSsoProviderRecord("acme-disabled", false, { organizationId, userId });
|
||||||
|
await createSsoProviderRecord("other-org-provider", true);
|
||||||
|
|
||||||
|
const trustedProviders = await resolveTrustedProvidersForRequest(createRequest("/sso/callback/pocket-id"));
|
||||||
|
|
||||||
|
expect([...trustedProviders].sort()).toEqual(["acme-saml", "pocket-id"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports /sso/saml2/callback/:providerId paths", async () => {
|
||||||
|
await createSsoProviderRecord("saml-provider", true);
|
||||||
|
|
||||||
|
expect(await resolveTrustedProvidersForRequest(createRequest("/sso/saml2/callback/saml-provider"))).toEqual([
|
||||||
|
"saml-provider",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports callback paths nested under /api/auth", async () => {
|
||||||
|
await createSsoProviderRecord("prefixed-provider", true);
|
||||||
|
|
||||||
|
expect(await resolveTrustedProvidersForRequest(createRequest("/api/auth/sso/callback/prefixed-provider"))).toEqual([
|
||||||
|
"prefixed-provider",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports /sso/saml2/sp/acs/:providerId paths", async () => {
|
||||||
|
await createSsoProviderRecord("saml-acs-provider", true);
|
||||||
|
|
||||||
|
expect(await resolveTrustedProvidersForRequest(createRequest("/sso/saml2/sp/acs/saml-acs-provider"))).toEqual([
|
||||||
|
"saml-acs-provider",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes providers from the result when auto-linking is disabled", async () => {
|
||||||
await createSsoProviderRecord("pocket-id", true);
|
await createSsoProviderRecord("pocket-id", true);
|
||||||
|
|
||||||
const ctx = createMockContext({
|
expect(await resolveTrustedProvidersForRequest(createRequest("/sso/callback/pocket-id"))).toEqual(["pocket-id"]);
|
||||||
path: "/sso/callback/pocket-id",
|
|
||||||
params: { providerId: "pocket-id" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await trustSsoProviderForLinking(ctx);
|
|
||||||
|
|
||||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toContain("pocket-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not trust providers with disabled auto-linking", async () => {
|
|
||||||
await createSsoProviderRecord("pocket-id", false);
|
|
||||||
|
|
||||||
const ctx = createMockContext({
|
|
||||||
path: "/sso/callback/pocket-id",
|
|
||||||
params: { providerId: "pocket-id" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await trustSsoProviderForLinking(ctx);
|
|
||||||
|
|
||||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("replaces stale trusted providers with database state", async () => {
|
|
||||||
await createSsoProviderRecord("pocket-id", true);
|
|
||||||
|
|
||||||
const ctx = createMockContext({
|
|
||||||
path: "/sso/callback/pocket-id",
|
|
||||||
params: { providerId: "pocket-id" },
|
|
||||||
trustedProviders: ["stale-provider"],
|
|
||||||
});
|
|
||||||
|
|
||||||
await trustSsoProviderForLinking(ctx);
|
|
||||||
|
|
||||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual(["pocket-id"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not trust unknown providers", async () => {
|
|
||||||
const ctx = createMockContext({
|
|
||||||
path: "/sso/callback/missing-provider",
|
|
||||||
params: { providerId: "missing-provider" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await trustSsoProviderForLinking(ctx);
|
|
||||||
|
|
||||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not duplicate an existing provider", async () => {
|
|
||||||
await createSsoProviderRecord("pocket-id", true);
|
|
||||||
|
|
||||||
const ctx = createMockContext({
|
|
||||||
path: "/sso/callback/pocket-id",
|
|
||||||
params: { providerId: "pocket-id" },
|
|
||||||
trustedProviders: ["pocket-id"],
|
|
||||||
});
|
|
||||||
|
|
||||||
await trustSsoProviderForLinking(ctx);
|
|
||||||
|
|
||||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual(["pocket-id"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removes provider from trusted providers when auto-linking is disabled", async () => {
|
|
||||||
await createSsoProviderRecord("pocket-id", true);
|
|
||||||
|
|
||||||
const ctx = createMockContext({
|
|
||||||
path: "/sso/callback/pocket-id",
|
|
||||||
params: { providerId: "pocket-id" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await trustSsoProviderForLinking(ctx);
|
|
||||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual(["pocket-id"]);
|
|
||||||
|
|
||||||
await db.update(ssoProvider).set({ autoLinkMatchingEmails: false }).where(eq(ssoProvider.providerId, "pocket-id"));
|
await db.update(ssoProvider).set({ autoLinkMatchingEmails: false }).where(eq(ssoProvider.providerId, "pocket-id"));
|
||||||
|
|
||||||
await trustSsoProviderForLinking(ctx);
|
expect(await resolveTrustedProvidersForRequest(createRequest("/sso/callback/pocket-id"))).toEqual([]);
|
||||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does nothing when account linking is disabled", async () => {
|
|
||||||
await createSsoProviderRecord("pocket-id", true);
|
|
||||||
|
|
||||||
const ctx = createMockContext({
|
|
||||||
path: "/sso/callback/pocket-id",
|
|
||||||
params: { providerId: "pocket-id" },
|
|
||||||
enabled: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await trustSsoProviderForLinking(ctx);
|
|
||||||
|
|
||||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does nothing when provider id cannot be extracted", async () => {
|
|
||||||
const ctx = createMockContext({
|
|
||||||
path: "/sso/callback/",
|
|
||||||
params: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
await trustSsoProviderForLinking(ctx);
|
|
||||||
|
|
||||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,14 @@
|
||||||
import type { GenericEndpointContext } from "@better-auth/core";
|
|
||||||
import { db } from "~/server/db/db";
|
import { db } from "~/server/db/db";
|
||||||
import { extractProviderIdFromContext } from "../utils/sso-context";
|
import { extractProviderIdFromUrl } from "../utils/sso-context";
|
||||||
|
|
||||||
export function isSsoCallbackPath(path?: string): boolean {
|
export async function resolveTrustedProvidersForRequest(request?: Request): Promise<string[]> {
|
||||||
if (!path) {
|
if (!request) {
|
||||||
return false;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return path.startsWith("/sso/callback/");
|
const providerId = extractProviderIdFromUrl(request.url);
|
||||||
}
|
|
||||||
|
|
||||||
export async function trustSsoProviderForLinking(ctx: GenericEndpointContext): Promise<void> {
|
|
||||||
const providerId = extractProviderIdFromContext(ctx);
|
|
||||||
|
|
||||||
if (!providerId) {
|
if (!providerId) {
|
||||||
return;
|
return [];
|
||||||
}
|
|
||||||
|
|
||||||
const accountLinking = ctx.context.options.account?.accountLinking;
|
|
||||||
|
|
||||||
if (!accountLinking || accountLinking.enabled === false) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = await db.query.ssoProvider.findFirst({
|
const provider = await db.query.ssoProvider.findFirst({
|
||||||
|
|
@ -28,7 +16,7 @@ export async function trustSsoProviderForLinking(ctx: GenericEndpointContext): P
|
||||||
where: { providerId },
|
where: { providerId },
|
||||||
});
|
});
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
return;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoLinkingProviders = await db.query.ssoProvider.findMany({
|
const autoLinkingProviders = await db.query.ssoProvider.findMany({
|
||||||
|
|
@ -39,5 +27,5 @@ export async function trustSsoProviderForLinking(ctx: GenericEndpointContext): P
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
accountLinking.trustedProviders = autoLinkingProviders.map((entry) => entry.providerId);
|
return autoLinkingProviders.map((entry) => entry.providerId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export const validateSsoCallbackUrls = async (ctx: AuthMiddlewareContext) => {
|
||||||
if (value !== undefined && (typeof value !== "string" || !isValidCallbackPath(value))) {
|
if (value !== undefined && (typeof value !== "string" || !isValidCallbackPath(value))) {
|
||||||
throw new APIError("BAD_REQUEST", {
|
throw new APIError("BAD_REQUEST", {
|
||||||
message: `Invalid ${field}. Only relative paths like /login are allowed.`,
|
message: `Invalid ${field}. Only relative paths like /login are allowed.`,
|
||||||
|
code: `INVALID_${field.toUpperCase()}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
import { beforeEach, describe, expect, test } from "bun:test";
|
import { beforeEach, describe, expect, test } from "bun:test";
|
||||||
import { createApp } from "~/server/app";
|
import { createApp } from "~/server/app";
|
||||||
|
import { config } from "~/server/core/config";
|
||||||
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
||||||
import { db } from "~/server/db/db";
|
import { db } from "~/server/db/db";
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
const ssoSignInUrl = new URL("/api/auth/sign-in/sso", config.baseUrl).toString();
|
||||||
|
|
||||||
|
function postSsoSignIn(body: Record<string, unknown>) {
|
||||||
|
return app.request(ssoSignInUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("auth SSO sign-in security", () => {
|
describe("auth SSO sign-in security", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
@ -16,15 +28,9 @@ describe("auth SSO sign-in security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects malicious callback URL", async () => {
|
test("rejects malicious callback URL", async () => {
|
||||||
const response = await app.request("/api/auth/sign-in/sso", {
|
const response = await postSsoSignIn({
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
providerId: "missing-provider",
|
providerId: "missing-provider",
|
||||||
callbackURL: "https://evil.example",
|
callbackURL: "https://evil.example",
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
|
|
@ -35,16 +41,10 @@ describe("auth SSO sign-in security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects malicious error callback URL", async () => {
|
test("rejects malicious error callback URL", async () => {
|
||||||
const response = await app.request("/api/auth/sign-in/sso", {
|
const response = await postSsoSignIn({
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
providerId: "missing-provider",
|
providerId: "missing-provider",
|
||||||
callbackURL: "/login",
|
callbackURL: "/login",
|
||||||
errorCallbackURL: "https://evil.example",
|
errorCallbackURL: "https://evil.example",
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
|
|
@ -55,16 +55,10 @@ describe("auth SSO sign-in security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects malicious new user callback URL", async () => {
|
test("rejects malicious new user callback URL", async () => {
|
||||||
const response = await app.request("/api/auth/sign-in/sso", {
|
const response = await postSsoSignIn({
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
providerId: "missing-provider",
|
providerId: "missing-provider",
|
||||||
callbackURL: "/login",
|
callbackURL: "/login",
|
||||||
newUserCallbackURL: "https://evil.example",
|
newUserCallbackURL: "https://evil.example",
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
|
|
@ -75,20 +69,14 @@ describe("auth SSO sign-in security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allows relative callback URL to continue normal flow", async () => {
|
test("allows relative callback URL to continue normal flow", async () => {
|
||||||
const response = await app.request("/api/auth/sign-in/sso", {
|
const response = await postSsoSignIn({
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
providerId: "missing-provider",
|
providerId: "missing-provider",
|
||||||
callbackURL: "/login",
|
callbackURL: "/login",
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
|
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
expect(body.code).toBe("NO_PROVIDER_FOUND_FOR_THE_ISSUER");
|
expect(body.message).toBe("No provider found for the issuer");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,19 @@ staticPasswords:
|
||||||
- email: "admin@example.com"
|
- email: "admin@example.com"
|
||||||
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
||||||
username: "admin"
|
username: "admin"
|
||||||
|
userID: "001"
|
||||||
- email: "user@example.com"
|
- email: "user@example.com"
|
||||||
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
||||||
username: "user"
|
username: "user"
|
||||||
|
userID: "002"
|
||||||
- email: "test@example.com"
|
- email: "test@example.com"
|
||||||
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
||||||
username: "test"
|
username: "test"
|
||||||
|
userID: "003"
|
||||||
- email: "test@test.com"
|
- email: "test@test.com"
|
||||||
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
||||||
username: "test-local"
|
username: "test-local"
|
||||||
|
userID: "004"
|
||||||
|
|
||||||
staticClients:
|
staticClients:
|
||||||
- id: zerobyte-test
|
- id: zerobyte-test
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue