diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..6386db09 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.defaultFormatter": "oxc.oxc-vscode" +} diff --git a/app/client/lib/auth-errors.ts b/app/client/lib/sso-errors.ts similarity index 62% rename from app/client/lib/auth-errors.ts rename to app/client/lib/sso-errors.ts index 1b7de35a..a87b867d 100644 --- a/app/client/lib/auth-errors.ts +++ b/app/client/lib/sso-errors.ts @@ -1,7 +1,7 @@ -import { LOGIN_ERROR_CODES, type LoginErrorCode } from "~/lib/auth-errors"; +import { LOGIN_ERROR_CODES, type LoginErrorCode } from "~/lib/sso-errors"; -export type { LoginErrorCode } from "~/lib/auth-errors"; -export { getLoginErrorDescription } from "~/lib/auth-errors"; +export type { LoginErrorCode } from "~/lib/sso-errors"; +export { getLoginErrorDescription } from "~/lib/sso-errors"; const VALID_ERROR_CODES = new Set(LOGIN_ERROR_CODES); diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index e6198a95..83317925 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -10,13 +10,12 @@ import { Input } from "~/client/components/ui/input"; import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp"; import { Label } from "~/client/components/ui/label"; import { authClient } from "~/client/lib/auth-client"; -import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/auth-errors"; +import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors"; import { ResetPasswordDialog } from "../components/reset-password-dialog"; import { useNavigate } from "@tanstack/react-router"; import { normalizeUsername } from "~/lib/username"; -import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; -import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { cn } from "~/client/lib/utils"; +import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section"; const loginSchema = type({ username: "2<=string<=50", @@ -40,10 +39,6 @@ export function LoginPage({ error }: LoginPageProps = {}) { const errorCode = decodeLoginError(error); const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null; - const { data: ssoProviders } = useSuspenseQuery({ - ...getPublicSsoProvidersOptions(), - }); - const form = useForm({ resolver: arktypeResolver(loginSchema), defaultValues: { @@ -129,27 +124,6 @@ export function LoginPage({ error }: LoginPageProps = {}) { form.reset(); }; - const ssoLoginMutation = useMutation({ - mutationFn: async (providerId: string) => { - const callbackPath = "/login"; - const { data, error } = await authClient.signIn.sso({ - providerId: providerId, - callbackURL: callbackPath, - errorCallbackURL: "/api/v1/auth/login-error", - }); - if (error) throw error; - - return data; - }, - onSuccess: (data) => { - window.location.href = data.url; - }, - onError: (error) => { - console.error(error); - toast.error("SSO Login failed", { description: error.message }); - }, - }); - if (requires2FA) { return ( @@ -265,26 +239,7 @@ export function LoginPage({ error }: LoginPageProps = {}) { - {ssoProviders.providers.length > 0 && ( -
-

Alternative Sign-in

-
- {ssoProviders.providers.map((provider) => ( - - ))} -
-
- )} +
diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index d3621073..5916d7ab 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -22,7 +22,7 @@ import { authClient } from "~/client/lib/auth-client"; import { type AppContext } from "~/context"; import { TwoFactorSection } from "../components/two-factor-section"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { SsoSettingsSection } from "../components/sso/sso-settings-section"; +import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section"; import { OrgMembersSection } from "../components/org-members-section"; import { useOrganizationContext } from "~/client/hooks/use-org-context"; diff --git a/app/client/modules/sso/components/sso-login-section.tsx b/app/client/modules/sso/components/sso-login-section.tsx new file mode 100644 index 00000000..b7629000 --- /dev/null +++ b/app/client/modules/sso/components/sso-login-section.tsx @@ -0,0 +1,57 @@ +import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Button } from "~/client/components/ui/button"; +import { authClient } from "~/client/lib/auth-client"; +import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen"; + +export function SsoLoginSection() { + const { data: ssoProviders } = useSuspenseQuery({ + ...getPublicSsoProvidersOptions(), + }); + + const ssoLoginMutation = useMutation({ + mutationFn: async (providerId: string) => { + const callbackPath = "/login"; + const { data, error } = await authClient.signIn.sso({ + providerId: providerId, + callbackURL: callbackPath, + errorCallbackURL: "/api/v1/auth/login-error", + }); + if (error) throw error; + + return data; + }, + onSuccess: (data) => { + window.location.href = data.url; + }, + onError: (error) => { + console.error(error); + toast.error("SSO Login failed", { description: error.message }); + }, + }); + + if (ssoProviders.providers.length === 0) { + return null; + } + + return ( +
+

Alternative Sign-in

+
+ {ssoProviders.providers.map((provider) => ( + + ))} +
+
+ ); +} diff --git a/app/client/modules/settings/components/sso/sso-settings-section.tsx b/app/client/modules/sso/components/sso-settings-section.tsx similarity index 100% rename from app/client/modules/settings/components/sso/sso-settings-section.tsx rename to app/client/modules/sso/components/sso-settings-section.tsx diff --git a/app/client/modules/settings/routes/create-sso-provider.tsx b/app/client/modules/sso/routes/create-sso-provider.tsx similarity index 100% rename from app/client/modules/settings/routes/create-sso-provider.tsx rename to app/client/modules/sso/routes/create-sso-provider.tsx diff --git a/app/lib/auth-errors.ts b/app/lib/sso-errors.ts similarity index 100% rename from app/lib/auth-errors.ts rename to app/lib/sso-errors.ts diff --git a/app/routes/(dashboard)/settings/sso/new.tsx b/app/routes/(dashboard)/settings/sso/new.tsx index 69bbca75..99578093 100644 --- a/app/routes/(dashboard)/settings/sso/new.tsx +++ b/app/routes/(dashboard)/settings/sso/new.tsx @@ -1,5 +1,5 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import { CreateSsoProviderPage } from "~/client/modules/settings/routes/create-sso-provider"; +import { CreateSsoProviderPage } from "~/client/modules/sso/routes/create-sso-provider"; import { getOrganizationContext } from "~/server/lib/functions/organization-context"; export const Route = createFileRoute("/(dashboard)/settings/sso/new")({ diff --git a/app/server/app.ts b/app/server/app.ts index 1e241678..56527d87 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -7,6 +7,7 @@ import { secureHeaders } from "hono/secure-headers"; import { rateLimiter } from "hono-rate-limiter"; import { openAPIRouteHandler } from "hono-openapi"; import { authController } from "./modules/auth/auth.controller"; +import { ssoController } from "./modules/sso/sso.controller"; import { requireAuth } from "./modules/auth/auth.middleware"; import { repositoriesController } from "./modules/repositories/repositories.controller"; import { systemController } from "./modules/system/system.controller"; @@ -74,6 +75,7 @@ export const createApp = () => { app .get("/api/healthcheck", (c) => c.json({ status: "ok" })) .route("/api/v1/auth", authController) + .route("/api/v1/auth", ssoController) .route("/api/v1/volumes", volumeController) .route("/api/v1/repositories", repositoriesController) .route("/api/v1/backups", backupScheduleController) diff --git a/app/server/lib/auth.ts b/app/server/lib/auth.ts index 93ee7bf5..a19959dd 100644 --- a/app/server/lib/auth.ts +++ b/app/server/lib/auth.ts @@ -4,12 +4,10 @@ import { type BetterAuthOptions, type MiddlewareContext, type MiddlewareOptions, - type User, } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { admin, twoFactor, username, organization, testUtils } from "better-auth/plugins"; import { createAuthMiddleware } from "better-auth/api"; -import { sso } from "@better-auth/sso"; import { config } from "../core/config"; import { db } from "../db/db"; import { cryptoUtils } from "../utils/crypto"; @@ -19,13 +17,9 @@ import { tanstackStartCookies } from "better-auth/tanstack-start"; import { isValidUsername, normalizeUsername } from "~/lib/username"; import { ensureOnlyOneUser } from "./auth/middlewares/only-one-user"; import { convertLegacyUserOnFirstLogin } from "./auth/middlewares/convert-legacy-user"; -import { validateSsoCallbackUrls } from "./auth/middlewares/validate-sso-callback-urls"; -import { validateSsoProviderId } from "./auth/middlewares/validate-sso-provider-id"; -import { createUserDefaultOrg } from "./auth/helpers/create-default-org"; -import { requireSsoInvitation } from "./auth/middlewares/require-sso-invitation"; -import { resolveTrustedProvidersForRequest } from "./auth/middlewares/trust-sso-provider-for-linking"; +import { ensureDefaultOrg } from "./auth/helpers/create-default-org"; import { buildAllowedHosts } from "./auth/base-url"; -import { isSsoCallbackRequest } from "./auth/utils/sso-context"; +import { ssoIntegration } from "../modules/sso/sso.integration"; export type AuthMiddlewareContext = MiddlewareContext>; @@ -52,8 +46,10 @@ export const auth = betterAuth({ }, hooks: { before: createAuthMiddleware(async (ctx) => { - await validateSsoProviderId(ctx); - await validateSsoCallbackUrls(ctx); + for (const mw of ssoIntegration.beforeMiddlewares) { + await mw(ctx); + } + await ensureOnlyOneUser(ctx); await convertLegacyUserOnFirstLogin(ctx); }), @@ -70,9 +66,8 @@ export const auth = betterAuth({ }, create: { before: async (user, ctx) => { - if (isSsoCallbackRequest(ctx)) { - await requireSsoInvitation(user.email, ctx); - user.hasDownloadedResticPassword = true; + if (ssoIntegration.isSsoCallback(ctx)) { + await ssoIntegration.onUserCreate(user, ctx); } const anyUser = await db.query.usersTable.findFirst(); @@ -93,7 +88,9 @@ export const auth = betterAuth({ session: { create: { before: async (session, ctx) => { - const membership = await createUserDefaultOrg(session.userId, ctx); + const membership = + (await ssoIntegration.resolveOrgMembership(session.userId, ctx)) ?? + (await ensureDefaultOrg(session.userId)); return { data: { ...session, activeOrganizationId: membership.organizationId } }; }, }, @@ -105,7 +102,7 @@ export const auth = betterAuth({ account: { accountLinking: { enabled: true, - trustedProviders: resolveTrustedProvidersForRequest, + trustedProviders: ssoIntegration.resolveTrustedProviders, }, }, user: { @@ -136,17 +133,7 @@ export const auth = betterAuth({ organization({ allowUserToCreateOrganization: false, }), - sso({ - trustEmailVerified: false, - providersLimit: async (user: User) => { - const isOrgAdmin = await authService.isOrgAdminAnywhere(user.id); - return isOrgAdmin ? 10 : 0; - }, - organizationProvisioning: { - disabled: false, - defaultRole: "member", - }, - }), + ssoIntegration.plugin, twoFactor({ backupCodeOptions: { storeBackupCodes: "encrypted", diff --git a/app/server/lib/auth/helpers/__tests__/create-default-org.test.ts b/app/server/lib/auth/helpers/__tests__/create-default-org.test.ts index 74d7fa0a..fb93cd97 100644 --- a/app/server/lib/auth/helpers/__tests__/create-default-org.test.ts +++ b/app/server/lib/auth/helpers/__tests__/create-default-org.test.ts @@ -1,26 +1,8 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import type { GenericEndpointContext } from "better-auth"; import { eq } from "drizzle-orm"; import { db } from "~/server/db/db"; -import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; -import { createUserDefaultOrg } from "../create-default-org"; - -function createMockContext(path: string, params: Record = {}): GenericEndpointContext { - return { - path, - body: {}, - query: {}, - headers: new Headers(), - request: new Request(`http://localhost:3000${path}`), - params, - method: "POST", - context: {} as GenericEndpointContext["context"], - } as GenericEndpointContext; -} - -function createMockSsoCallbackContext(providerId: string): GenericEndpointContext { - return createMockContext(`/sso/callback/${providerId}`, { providerId }); -} +import { account, invitation, member, organization, usersTable } from "~/server/db/schema"; +import { ensureDefaultOrg } from "../create-default-org"; function randomId() { return Bun.randomUUIDv7(); @@ -41,123 +23,15 @@ async function createUser(email: string, username: string) { return userId; } -describe("createUserDefaultOrg", () => { +describe("ensureDefaultOrg", () => { beforeEach(async () => { await db.delete(member); await db.delete(account); await db.delete(invitation); - await db.delete(ssoProvider); await db.delete(organization); await db.delete(usersTable); }); - test("creates invited membership from SSO callback request context", async () => { - const invitedUserId = await createUser("invited@example.com", randomSlug("invited")); - const inviterId = await createUser("inviter@example.com", randomSlug("inviter")); - const organizationId = randomId(); - - await db.insert(organization).values({ - id: organizationId, - name: "Acme", - slug: randomSlug("acme"), - createdAt: new Date(), - }); - - await db.insert(ssoProvider).values({ - id: randomId(), - providerId: "oidc-acme", - organizationId, - userId: inviterId, - issuer: "https://issuer.example.com", - domain: "example.com", - }); - - await db.insert(invitation).values({ - id: randomId(), - organizationId, - email: "invited@example.com", - role: "member", - status: "pending", - expiresAt: new Date(Date.now() + 60 * 60 * 1000), - createdAt: new Date(), - inviterId, - }); - - const ctx = createMockSsoCallbackContext("oidc-acme"); - const membership = await createUserDefaultOrg(invitedUserId, ctx); - - expect(membership.organizationId).toBe(organizationId); - expect(membership.role).toBe("member"); - - const updatedInvitations = await db.select().from(invitation).where(eq(invitation.organizationId, organizationId)); - const updatedInvitation = updatedInvitations.find((i) => i.email === "invited@example.com"); - expect(updatedInvitation?.status).toBe("accepted"); - }); - - test("blocks SSO callback users without pending invitations", async () => { - const userId = await createUser("new-user@example.com", randomSlug("new-user")); - const inviterId = await createUser("inviter@example.com", randomSlug("inviter")); - const organizationId = randomId(); - - await db.insert(organization).values({ - id: organizationId, - name: "Acme", - slug: randomSlug("acme"), - createdAt: new Date(), - }); - - await db.insert(ssoProvider).values({ - id: randomId(), - providerId: "oidc-acme", - organizationId, - userId: inviterId, - issuer: "https://issuer.example.com", - domain: "example.com", - }); - - const ctx = createMockSsoCallbackContext("oidc-acme"); - expect(createUserDefaultOrg(userId, ctx)).rejects.toThrow("invite-only"); - }); - - test("blocks existing users with a personal org from SSO orgs they were not invited to", async () => { - const userId = await createUser("alice@example.com", randomSlug("alice")); - const inviterId = await createUser("inviter@example.com", randomSlug("inviter")); - - const personalOrgId = randomId(); - await db.insert(organization).values({ - id: personalOrgId, - name: "Alice's Workspace", - slug: randomSlug("alice"), - createdAt: new Date(), - }); - await db.insert(member).values({ - id: randomId(), - userId, - organizationId: personalOrgId, - role: "owner", - createdAt: new Date(), - }); - - const ssoOrgId = randomId(); - await db.insert(organization).values({ - id: ssoOrgId, - name: "Acme Corp", - slug: randomSlug("acme"), - createdAt: new Date(), - }); - await db.insert(ssoProvider).values({ - id: randomId(), - providerId: "oidc-acme", - organizationId: ssoOrgId, - userId: inviterId, - issuer: "https://issuer.example.com", - domain: "example.com", - }); - - const ctx = createMockSsoCallbackContext("oidc-acme"); - await expect(createUserDefaultOrg(userId, ctx)).rejects.toThrow("invite-only"); - }); - test("returns existing membership without creating another workspace", async () => { const userId = await createUser("existing-member@example.com", randomSlug("existing-member")); const organizationId = randomId(); @@ -177,7 +51,7 @@ describe("createUserDefaultOrg", () => { createdAt: new Date(), }); - const membership = await createUserDefaultOrg(userId, null); + const membership = await ensureDefaultOrg(userId); expect(membership.organizationId).toBe(organizationId); expect(membership.role).toBe("owner"); @@ -189,10 +63,10 @@ describe("createUserDefaultOrg", () => { expect(organizations.length).toBe(1); }); - test("creates personal workspace for non-SSO flows", async () => { + test("creates personal workspace for new users", async () => { const userId = await createUser("local-user@example.com", randomSlug("local-user")); - const membership = await createUserDefaultOrg(userId, null); + const membership = await ensureDefaultOrg(userId); expect(membership.role).toBe("owner"); expect(membership.organization.name).toContain("Workspace"); diff --git a/app/server/lib/auth/helpers/create-default-org.ts b/app/server/lib/auth/helpers/create-default-org.ts index 317253f4..ea2349fd 100644 --- a/app/server/lib/auth/helpers/create-default-org.ts +++ b/app/server/lib/auth/helpers/create-default-org.ts @@ -1,13 +1,8 @@ -import { eq } from "drizzle-orm"; import { UnauthorizedError } from "http-errors-enhanced"; -import type { GenericEndpointContext } from "better-auth"; import { db } from "~/server/db/db"; -import { invitation, member, organization, type User } from "~/server/db/schema"; +import { member, organization, type User } from "~/server/db/schema"; import { cryptoUtils } from "~/server/utils/crypto"; -import { APIError } from "better-auth"; -import { extractProviderIdFromContext, normalizeEmail } from "../utils/sso-context"; import { logger } from "~/server/utils/logger"; -import { authService } from "~/server/modules/auth/auth.service"; export async function findMembershipWithOrganization(userId: string, organizationId?: string) { const membership = await db.query.member.findFirst({ @@ -58,51 +53,6 @@ export async function buildDefaultOrganizationData( }; } -async function tryCreateInvitedMembership( - userId: string, - email: string, - ssoProviderRecord: Awaited>, -) { - if (!ssoProviderRecord) { - return null; - } - - logger.debug("Checking for pending invitations for user", { userId, providerId: ssoProviderRecord.providerId }); - - const pendingInvitation = await authService.getPendingInvitation(ssoProviderRecord.organizationId, email); - - if (!pendingInvitation) { - logger.debug("No pending invitation found for user"); - throw new APIError("FORBIDDEN", { message: "SSO sign-in is invite-only for this organization" }); - } - - await db.transaction(async (tx) => { - tx.insert(member) - .values({ - id: Bun.randomUUIDv7(), - userId, - role: pendingInvitation.role as "member", - organizationId: pendingInvitation.organizationId, - createdAt: new Date(), - }) - .run(); - - tx.update(invitation).set({ status: "accepted" }).where(eq(invitation.id, pendingInvitation.id)).run(); - }); - - const invitedMembership = await findMembershipWithOrganization(userId, pendingInvitation.organizationId); - logger.debug("Created organization membership from invitation", { - userId, - organizationId: pendingInvitation.organizationId, - }); - - if (!invitedMembership) { - throw new Error("Failed to create invited organization membership"); - } - - return invitedMembership; -} - async function createDefaultOrganizationMembership(user: User) { logger.debug("Creating default organization for user", { userId: user.id }); const organizationData = await buildDefaultOrganizationData(user); @@ -122,32 +72,12 @@ async function createDefaultOrganizationMembership(user: User) { }); } -export async function createUserDefaultOrg(userId: string, ctx: GenericEndpointContext | null) { +export async function ensureDefaultOrg(userId: string) { const user = await db.query.usersTable.findFirst({ where: { id: userId } }); if (!user) { throw new UnauthorizedError("User not found"); } - const providerId = extractProviderIdFromContext(ctx); - if (providerId) { - const ssoProviderRecord = await authService.getSsoProviderById(providerId); - - if (ssoProviderRecord) { - // If the user is already a member of this SSO org (accepted a past invitation), let them through - const existingSsoMembership = await findMembershipWithOrganization(user.id, ssoProviderRecord.organizationId); - if (existingSsoMembership) { - return existingSsoMembership; - } - - // Not yet a member of this SSO org, a valid pending invitation is required. - const invitedMembership = await tryCreateInvitedMembership(userId, normalizeEmail(user.email), ssoProviderRecord); - if (invitedMembership) { - return invitedMembership; - } - } - } - - // Non-SSO path: check for any existing membership before creating a personal org. const existingMembership = await findMembershipWithOrganization(user.id); if (existingMembership) { logger.debug("User already has an organization membership, skipping default org creation", { userId }); diff --git a/app/server/lib/auth/middlewares/__tests__/validate-sso-callback-urls.test.ts b/app/server/lib/auth/middlewares/__tests__/validate-sso-callback-urls.test.ts deleted file mode 100644 index f6f30a3d..00000000 --- a/app/server/lib/auth/middlewares/__tests__/validate-sso-callback-urls.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { AuthMiddlewareContext } from "~/server/lib/auth"; -import { validateSsoCallbackUrls } from "../validate-sso-callback-urls"; - -function createContext( - path: string, - body: Record = {}, - query: Record = {}, -): AuthMiddlewareContext { - return { - path, - body, - query, - headers: new Headers(), - request: new Request(`http://localhost:3000${path}`), - params: {}, - method: "POST", - context: {} as AuthMiddlewareContext["context"], - } as AuthMiddlewareContext; -} - -describe("validateSsoCallbackUrls", () => { - test("accepts relative paths for every callback field", async () => { - const ctx = createContext("/sign-in/sso", { - callbackURL: "/login", - errorCallbackURL: "/login/error", - newUserCallbackURL: "/download-recovery-key", - }); - - expect(validateSsoCallbackUrls(ctx)).resolves.toBeUndefined(); - }); - - test("rejects https://evil.example", async () => { - const ctx = createContext("/sign-in/sso", { callbackURL: "https://evil.example" }); - - expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL"); - }); - - test("rejects //evil.example", async () => { - const ctx = createContext("/sign-in/sso", { callbackURL: "//evil.example" }); - - expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL"); - }); - - test("rejects /sso/callback/foo", async () => { - const ctx = createContext("/sign-in/sso", { callbackURL: "/sso/callback/foo" }); - - expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL"); - }); - - test("rejects /sso/saml2/foo", async () => { - const ctx = createContext("/sign-in/sso", { callbackURL: "/sso/saml2/foo" }); - - expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL"); - }); - - test("rejects malicious query callback fields", async () => { - const ctx = createContext("/sign-in/sso", {}, { errorCallbackURL: "https://evil.example" }); - - expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("errorCallbackURL"); - }); - - test("rejects malicious newUserCallbackURL", async () => { - const ctx = createContext("/sign-in/sso", { newUserCallbackURL: "https://evil.example" }); - - expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("newUserCallbackURL"); - }); - - test("skips validation outside SSO sign-in endpoint", async () => { - const ctx = createContext("/sign-in/email", { callbackURL: "https://evil.example" }); - - expect(validateSsoCallbackUrls(ctx)).resolves.toBeUndefined(); - }); - - test("rejects URL-encoded external URL (%2F%2Fevil.example)", async () => { - const ctx = createContext("/sign-in/sso", { callbackURL: "%2F%2Fevil.example" }); - - expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL"); - }); - - test("rejects URL-encoded reserved path (%2Fsso%2Fcallback%2Ffoo)", async () => { - const ctx = createContext("/sign-in/sso", { callbackURL: "%2Fsso%2Fcallback%2Ffoo" }); - - expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL"); - }); - - test("rejects invalid URL encoding (malformed)", async () => { - const ctx = createContext("/sign-in/sso", { callbackURL: "%ZZ" }); - - expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL"); - }); -}); diff --git a/app/server/lib/auth/middlewares/trust-sso-provider-for-linking.ts b/app/server/lib/auth/middlewares/trust-sso-provider-for-linking.ts deleted file mode 100644 index b334cfc3..00000000 --- a/app/server/lib/auth/middlewares/trust-sso-provider-for-linking.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { authService } from "~/server/modules/auth/auth.service"; -import { extractProviderIdFromUrl } from "../utils/sso-context"; - -export async function resolveTrustedProvidersForRequest(request?: Request): Promise { - if (!request) { - return []; - } - - const providerId = extractProviderIdFromUrl(request.url); - if (!providerId) { - return []; - } - - const provider = await authService.getSsoProviderById(providerId); - if (!provider) { - return []; - } - - return authService.getAutoLinkingSsoProviderIds(provider.organizationId); -} diff --git a/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts b/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts index 8882536b..c41a2486 100644 --- a/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts +++ b/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts @@ -120,4 +120,42 @@ describe("authService.cleanupUserOrganizations", () => { expect(deletedMembership).toBeUndefined(); }); + + test("sets active organization to null when user has no other memberships", async () => { + const deletedOnlyUserId = await createUser(`${randomSlug("deleted")}@example.com`); + + const deletedWorkspaceId = await createOrganization("Deleted Workspace"); + + await createMembership({ + userId: deletedOnlyUserId, + organizationId: deletedWorkspaceId, + role: "owner", + }); + + const userSessionId = await createSession({ + userId: deletedOnlyUserId, + activeOrganizationId: deletedWorkspaceId, + }); + + await authService.cleanupUserOrganizations(deletedOnlyUserId); + + const deletedWorkspace = await db.query.organization.findFirst({ + where: { id: deletedWorkspaceId }, + columns: { id: true }, + }); + const updatedSession = await db.query.sessionsTable.findFirst({ + where: { id: userSessionId }, + columns: { activeOrganizationId: true }, + }); + + expect(deletedWorkspace).toBeUndefined(); + expect(updatedSession?.activeOrganizationId).toBeNull(); + + const membership = await db.query.member.findFirst({ + where: { userId: deletedOnlyUserId }, + columns: { id: true }, + }); + + expect(membership).toBeUndefined(); + }); }); diff --git a/app/server/modules/auth/auth.controller.ts b/app/server/modules/auth/auth.controller.ts index 2d5dbef6..b32d583f 100644 --- a/app/server/modules/auth/auth.controller.ts +++ b/app/server/modules/auth/auth.controller.ts @@ -5,16 +5,8 @@ import { getStatusDto, getUserDeletionImpactDto, type UserDeletionImpactDto, - getPublicSsoProvidersDto, - type PublicSsoProvidersDto, - getSsoSettingsDto, - type SsoSettingsDto, getAdminUsersDto, type AdminUsersDto, - deleteSsoProviderDto, - deleteSsoInvitationDto, - updateSsoProviderAutoLinkingBody, - updateSsoProviderAutoLinkingDto, deleteUserAccountDto, getOrgMembersDto, type OrgMembersDto, @@ -25,97 +17,12 @@ import { import { authService } from "./auth.service"; import { requireAdmin, requireAuth, requireOrgAdmin } from "./auth.middleware"; import { auth } from "~/server/lib/auth"; -import { mapAuthErrorToCode } from "./auth.errors"; -import { config } from "~/server/core/config"; export const authController = new Hono() .get("/status", getStatusDto, async (c) => { const hasUsers = await authService.hasUsers(); return c.json({ hasUsers }); }) - .get("/sso-providers", getPublicSsoProvidersDto, async (c) => { - const providers = await authService.getPublicSsoProviders(); - return c.json(providers); - }) - .get("/sso-settings", requireAuth, requireOrgAdmin, getSsoSettingsDto, async (c) => { - const headers = c.req.raw.headers; - const activeOrganizationId = c.get("organizationId"); - - if (!activeOrganizationId) { - return c.json({ providers: [], invitations: [] }); - } - - const [providersData, invitationsData, autoLinkingSettings] = await Promise.all([ - auth.api.listSSOProviders({ headers, query: { organizationId: activeOrganizationId } }), - auth.api.listInvitations({ headers, query: { organizationId: activeOrganizationId } }), - authService.getSsoProviderAutoLinkingSettings(activeOrganizationId), - ]); - - return c.json({ - providers: providersData.providers - .map((provider) => ({ - providerId: provider.providerId, - type: provider.type, - issuer: provider.issuer, - domain: provider.domain, - autoLinkMatchingEmails: autoLinkingSettings[provider.providerId] ?? false, - organizationId: provider.organizationId, - })) - .filter((p) => p.organizationId === activeOrganizationId), - invitations: invitationsData.map((invitation) => ({ - id: invitation.id, - email: invitation.email, - role: invitation.role, - status: invitation.status, - expiresAt: invitation.expiresAt.toISOString(), - })), - }); - }) - .delete("/sso-providers/:providerId", requireAuth, requireOrgAdmin, deleteSsoProviderDto, async (c) => { - const providerId = c.req.param("providerId"); - const organizationId = c.get("organizationId"); - - const deleted = await authService.deleteSsoProvider(providerId, organizationId); - - if (!deleted) { - return c.json({ message: "Provider not found" }, 404); - } - - return c.json({ success: true }); - }) - .patch( - "/sso-providers/:providerId/auto-linking", - requireAuth, - requireOrgAdmin, - updateSsoProviderAutoLinkingDto, - validator("json", updateSsoProviderAutoLinkingBody), - async (c) => { - const providerId = c.req.param("providerId"); - const organizationId = c.get("organizationId"); - const { enabled } = c.req.valid("json"); - - const updated = await authService.updateSsoProviderAutoLinking(providerId, organizationId, enabled); - - if (!updated) { - return c.json({ message: "Provider not found" }, 404); - } - - return c.json({ success: true }); - }, - ) - .delete("/sso-invitations/:invitationId", requireAuth, requireOrgAdmin, deleteSsoInvitationDto, async (c) => { - const invitationId = c.req.param("invitationId"); - const organizationId = c.get("organizationId"); - - const invitation = await authService.getSsoInvitationById(invitationId); - if (!invitation || invitation.organizationId !== organizationId) { - return c.json({ message: "Invitation not found" }, 404); - } - - await authService.deleteSsoInvitation(invitationId); - - return c.json({ success: true }); - }) .get("/admin-users", requireAuth, requireAdmin, getAdminUsersDto, async (c) => { const headers = c.req.raw.headers; @@ -205,9 +112,4 @@ export const authController = new Hono() } return c.json({ success: true }); - }) - .get("/login-error", async (c) => { - const error = c.req.query("error"); - const errorCode = error ? mapAuthErrorToCode(error) : "SSO_LOGIN_FAILED"; - return c.redirect(`${config.baseUrl}/login?error=${errorCode}`); }); diff --git a/app/server/modules/auth/auth.dto.ts b/app/server/modules/auth/auth.dto.ts index 421b939e..03859b56 100644 --- a/app/server/modules/auth/auth.dto.ts +++ b/app/server/modules/auth/auth.dto.ts @@ -5,69 +5,6 @@ const statusResponseSchema = type({ hasUsers: "boolean", }); -export const publicSsoProvidersDto = type({ - providers: type({ - providerId: "string", - organizationSlug: "string", - }) - .onUndeclaredKey("delete") - .array(), -}); - -export type PublicSsoProvidersDto = typeof publicSsoProvidersDto.infer; - -export const getPublicSsoProvidersDto = describeRoute({ - description: "Get public SSO providers for the instance", - operationId: "getPublicSsoProviders", - tags: ["Auth"], - responses: { - 200: { - description: "List of public SSO providers", - content: { - "application/json": { - schema: resolver(publicSsoProvidersDto), - }, - }, - }, - }, -}); - -export const ssoSettingsResponse = type({ - providers: type({ - providerId: "string", - type: "string", - issuer: "string", - domain: "string", - autoLinkMatchingEmails: "boolean", - organizationId: "string | null", - }).array(), - invitations: type({ - id: "string", - email: "string", - role: "string", - status: "string", - expiresAt: "string", - }).array(), -}); - -export type SsoSettingsDto = typeof ssoSettingsResponse.infer; - -export const getSsoSettingsDto = describeRoute({ - description: "Get SSO providers and invitations for the active organization", - operationId: "getSsoSettings", - tags: ["Auth"], - responses: { - 200: { - description: "SSO settings for the active organization", - content: { - "application/json": { - schema: resolver(ssoSettingsResponse), - }, - }, - }, - }, -}); - export const adminUsersResponse = type({ users: type({ id: "string", @@ -149,37 +86,6 @@ export const getUserDeletionImpactDto = describeRoute({ }, }); -export const deleteSsoProviderDto = describeRoute({ - description: "Delete an SSO provider", - operationId: "deleteSsoProvider", - tags: ["Auth"], - responses: { - 200: { - description: "SSO provider deleted successfully", - }, - 404: { - description: "Provider not found", - }, - 403: { - description: "Forbidden", - }, - }, -}); - -export const deleteSsoInvitationDto = describeRoute({ - description: "Delete an SSO invitation", - operationId: "deleteSsoInvitation", - tags: ["Auth"], - responses: { - 200: { - description: "SSO invitation deleted successfully", - }, - 403: { - description: "Forbidden", - }, - }, -}); - export const deleteUserAccountDto = describeRoute({ description: "Delete an account linked to a user", operationId: "deleteUserAccount", @@ -265,24 +171,3 @@ export const removeOrgMemberDto = describeRoute({ }, }, }); - -export const updateSsoProviderAutoLinkingBody = type({ - enabled: "boolean", -}); - -export const updateSsoProviderAutoLinkingDto = describeRoute({ - description: "Update whether SSO sign-in can auto-link existing accounts by email", - operationId: "updateSsoProviderAutoLinking", - tags: ["Auth"], - responses: { - 200: { - description: "SSO provider auto-linking setting updated successfully", - }, - 403: { - description: "Forbidden", - }, - 404: { - description: "Provider not found", - }, - }, -}); diff --git a/app/server/modules/auth/auth.service.ts b/app/server/modules/auth/auth.service.ts index 8c3582df..b350ea02 100644 --- a/app/server/modules/auth/auth.service.ts +++ b/app/server/modules/auth/auth.service.ts @@ -7,14 +7,10 @@ import { volumesTable, repositoriesTable, backupSchedulesTable, - ssoProvider, account, - invitation, } from "../../db/schema"; import { eq, ne, and, count, inArray } from "drizzle-orm"; import type { UserDeletionImpactDto } from "./auth.dto"; -import { isReservedSsoProviderId } from "~/server/lib/auth/utils/sso-provider-id"; -import { normalizeEmail } from "~/server/lib/auth/utils/sso-context"; export class AuthService { /** @@ -25,21 +21,6 @@ export class AuthService { return !!user; } - /** - * Get public SSO providers for the instance - */ - async getPublicSsoProviders() { - const providers = await db - .select({ - providerId: ssoProvider.providerId, - organizationSlug: organization.slug, - }) - .from(ssoProvider) - .innerJoin(organization, eq(ssoProvider.organizationId, organization.id)); - - return { providers }; - } - /** * Get the impact of deleting a user */ @@ -138,124 +119,14 @@ export class AuthService { } await tx.delete(organization).where(inArray(organization.id, orgIds)); + + await tx + .update(sessionsTable) + .set({ activeOrganizationId: null }) + .where(inArray(sessionsTable.activeOrganizationId, orgIds)); }); } - /** - * Get an SSO provider by provider id - */ - async getSsoProviderById(providerId: string) { - return db.query.ssoProvider.findFirst({ - where: { providerId }, - columns: { id: true, providerId: true, organizationId: true }, - }); - } - - /** - * Get an active pending invitation for organization/email - */ - async getPendingInvitation(organizationId: string, email: string) { - return db.query.invitation.findFirst({ - where: { - AND: [ - { organizationId }, - { status: "pending" }, - { expiresAt: { gt: new Date() } }, - { email: normalizeEmail(email) }, - ], - }, - columns: { id: true, email: true, role: true, organizationId: true }, - }); - } - - /** - * Get trusted provider ids for organization auto-linking - */ - async getAutoLinkingSsoProviderIds(organizationId: string) { - const providers = await db.query.ssoProvider.findMany({ - columns: { providerId: true }, - where: { organizationId, autoLinkMatchingEmails: true }, - }); - - return providers.map((provider) => provider.providerId); - } - - /** - * Delete an SSO provider and its associated accounts - */ - async deleteSsoProvider(providerId: string, organizationId: string) { - return db.transaction(async (tx) => { - const provider = await tx.query.ssoProvider.findFirst({ - where: { AND: [{ providerId }, { organizationId }] }, - columns: { id: true, providerId: true }, - }); - - if (!provider) { - return false; - } - - if (isReservedSsoProviderId(provider.providerId)) { - await tx.delete(ssoProvider).where(eq(ssoProvider.id, provider.id)); - return true; - } - - await tx.delete(account).where(eq(account.providerId, provider.providerId)); - await tx.delete(ssoProvider).where(eq(ssoProvider.id, provider.id)); - - return true; - }); - } - - /** - * Get per-provider auto-linking setting for an organization - */ - async getSsoProviderAutoLinkingSettings(organizationId: string) { - const providers = await db.query.ssoProvider.findMany({ - columns: { providerId: true, autoLinkMatchingEmails: true }, - where: { organizationId }, - }); - - return Object.fromEntries(providers.map((provider) => [provider.providerId, provider.autoLinkMatchingEmails])); - } - - /** - * Update per-provider auto-linking setting - */ - async updateSsoProviderAutoLinking(providerId: string, organizationId: string, enabled: boolean) { - const existingProvider = await db.query.ssoProvider.findFirst({ - where: { AND: [{ providerId }, { organizationId }] }, - columns: { id: true }, - }); - - if (!existingProvider) { - return false; - } - - await db - .update(ssoProvider) - .set({ autoLinkMatchingEmails: enabled }) - .where(and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, organizationId))); - - return true; - } - - /** - * Get an SSO invitation by ID - */ - async getSsoInvitationById(invitationId: string) { - return db.query.invitation.findFirst({ - where: { id: invitationId }, - columns: { id: true, organizationId: true }, - }); - } - - /** - * Delete an invitation - */ - async deleteSsoInvitation(invitationId: string) { - await db.delete(invitation).where(eq(invitation.id, invitationId)); - } - /** * Check if a user is a member of an organization */ diff --git a/app/server/modules/sso/__tests__/sso.integration.test.ts b/app/server/modules/sso/__tests__/sso.integration.test.ts new file mode 100644 index 00000000..188075a3 --- /dev/null +++ b/app/server/modules/sso/__tests__/sso.integration.test.ts @@ -0,0 +1,283 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import type { GenericEndpointContext } from "better-auth"; +import { eq } from "drizzle-orm"; +import { db } from "~/server/db/db"; +import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; +import { ssoIntegration } from "../sso.integration"; + +function createMockSsoCallbackContext(providerId: string): GenericEndpointContext { + return { + path: `/sso/callback/${providerId}`, + body: {}, + query: {}, + headers: new Headers(), + request: new Request(`http://localhost:3000/sso/callback/${providerId}`), + params: { providerId }, + method: "POST", + context: {} as GenericEndpointContext["context"], + } as unknown as GenericEndpointContext; +} + +function randomId() { + return Bun.randomUUIDv7(); +} + +function randomSlug(prefix: string) { + return `${prefix}-${Math.random().toString(36).slice(2, 8)}`; +} + +async function createUser(email: string, username: string) { + const userId = randomId(); + await db.insert(usersTable).values({ + id: userId, + email, + name: username, + username, + }); + return userId; +} + +describe("ssoIntegration.resolveOrgMembership", () => { + beforeEach(async () => { + await db.delete(member); + await db.delete(account); + await db.delete(invitation); + await db.delete(ssoProvider); + await db.delete(organization); + await db.delete(usersTable); + }); + + test("creates invited membership from SSO callback request context", async () => { + const invitedUserId = await createUser("invited@example.com", randomSlug("invited")); + const inviterId = await createUser("inviter@example.com", randomSlug("inviter")); + const organizationId = randomId(); + + await db.insert(organization).values({ + id: organizationId, + name: "Acme", + slug: randomSlug("acme"), + createdAt: new Date(), + }); + + await db.insert(ssoProvider).values({ + id: randomId(), + providerId: "oidc-acme", + organizationId, + userId: inviterId, + issuer: "https://issuer.example.com", + domain: "example.com", + }); + + await db.insert(invitation).values({ + id: randomId(), + organizationId, + email: "invited@example.com", + role: "member", + status: "pending", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + createdAt: new Date(), + inviterId, + }); + + const ctx = createMockSsoCallbackContext("oidc-acme"); + const membership = await ssoIntegration.resolveOrgMembership(invitedUserId, ctx); + + expect(membership).not.toBeNull(); + expect(membership?.organizationId).toBe(organizationId); + expect(membership?.role).toBe("member"); + + const updatedInvitations = await db.select().from(invitation).where(eq(invitation.organizationId, organizationId)); + const updatedInvitation = updatedInvitations.find((i) => i.email === "invited@example.com"); + expect(updatedInvitation?.status).toBe("accepted"); + }); + + test("blocks SSO callback users without pending invitations", async () => { + const userId = await createUser("new-user@example.com", randomSlug("new-user")); + const inviterId = await createUser("inviter@example.com", randomSlug("inviter")); + const organizationId = randomId(); + + await db.insert(organization).values({ + id: organizationId, + name: "Acme", + slug: randomSlug("acme"), + createdAt: new Date(), + }); + + await db.insert(ssoProvider).values({ + id: randomId(), + providerId: "oidc-acme", + organizationId, + userId: inviterId, + issuer: "https://issuer.example.com", + domain: "example.com", + }); + + const ctx = createMockSsoCallbackContext("oidc-acme"); + await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only"); + }); + + test("blocks existing users with a personal org from SSO orgs they were not invited to", async () => { + const userId = await createUser("alice@example.com", randomSlug("alice")); + const inviterId = await createUser("inviter@example.com", randomSlug("inviter")); + + const personalOrgId = randomId(); + await db.insert(organization).values({ + id: personalOrgId, + name: "Alice's Workspace", + slug: randomSlug("alice"), + createdAt: new Date(), + }); + await db.insert(member).values({ + id: randomId(), + userId, + organizationId: personalOrgId, + role: "owner", + createdAt: new Date(), + }); + + const ssoOrgId = randomId(); + await db.insert(organization).values({ + id: ssoOrgId, + name: "Acme Corp", + slug: randomSlug("acme"), + createdAt: new Date(), + }); + await db.insert(ssoProvider).values({ + id: randomId(), + providerId: "oidc-acme", + organizationId: ssoOrgId, + userId: inviterId, + issuer: "https://issuer.example.com", + domain: "example.com", + }); + + const ctx = createMockSsoCallbackContext("oidc-acme"); + await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only"); + }); + + test("returns null when context is not an SSO callback", async () => { + const userId = await createUser("local-user@example.com", randomSlug("local-user")); + + const result = await ssoIntegration.resolveOrgMembership(userId, null); + expect(result).toBeNull(); + }); + + test("blocks user whose invitation has expired", async () => { + const userId = await createUser("expired@example.com", randomSlug("expired")); + const inviterId = await createUser("inviter@example.com", randomSlug("inviter")); + const organizationId = randomId(); + + await db.insert(organization).values({ + id: organizationId, + name: "Acme", + slug: randomSlug("acme"), + createdAt: new Date(), + }); + + await db.insert(ssoProvider).values({ + id: randomId(), + providerId: "oidc-acme", + organizationId, + userId: inviterId, + issuer: "https://issuer.example.com", + domain: "example.com", + }); + + await db.insert(invitation).values({ + id: randomId(), + organizationId, + email: "expired@example.com", + role: "member", + status: "pending", + expiresAt: new Date(Date.now() - 60 * 60 * 1000), // expired 1 hour ago + createdAt: new Date(), + inviterId, + }); + + const ctx = createMockSsoCallbackContext("oidc-acme"); + await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only"); + }); + + test("blocks user whose invitation was already accepted", async () => { + const userId = await createUser("returning@example.com", randomSlug("returning")); + const inviterId = await createUser("inviter@example.com", randomSlug("inviter")); + const organizationId = randomId(); + + await db.insert(organization).values({ + id: organizationId, + name: "Acme", + slug: randomSlug("acme"), + createdAt: new Date(), + }); + + await db.insert(ssoProvider).values({ + id: randomId(), + providerId: "oidc-acme", + organizationId, + userId: inviterId, + issuer: "https://issuer.example.com", + domain: "example.com", + }); + + // Invitation was already consumed (user was removed from org, invitation remains accepted) + await db.insert(invitation).values({ + id: randomId(), + organizationId, + email: "returning@example.com", + role: "member", + status: "accepted", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + createdAt: new Date(), + inviterId, + }); + + const ctx = createMockSsoCallbackContext("oidc-acme"); + await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only"); + }); + + test("does not grant access to org B when invitation belongs to a different org A", async () => { + const userId = await createUser("alice@example.com", randomSlug("alice")); + const inviterId = await createUser("inviter@example.com", randomSlug("inviter")); + + const orgAId = randomId(); + await db.insert(organization).values({ + id: orgAId, + name: "Org A", + slug: randomSlug("org-a"), + createdAt: new Date(), + }); + + const orgBId = randomId(); + await db.insert(organization).values({ + id: orgBId, + name: "Org B", + slug: randomSlug("org-b"), + createdAt: new Date(), + }); + + // SSO provider belongs to org B + await db.insert(ssoProvider).values({ + id: randomId(), + providerId: "oidc-org-b", + organizationId: orgBId, + userId: inviterId, + issuer: "https://issuer.example.com", + domain: "example.com", + }); + + // User has a valid pending invitation, but only for org A — not org B + await db.insert(invitation).values({ + id: randomId(), + organizationId: orgAId, + email: "alice@example.com", + role: "member", + status: "pending", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + createdAt: new Date(), + inviterId, + }); + + const ctx = createMockSsoCallbackContext("oidc-org-b"); + await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only"); + }); +}); diff --git a/app/server/modules/auth/__tests__/auth.sso-security.test.ts b/app/server/modules/sso/__tests__/sso.security.test.ts similarity index 100% rename from app/server/modules/auth/__tests__/auth.sso-security.test.ts rename to app/server/modules/sso/__tests__/sso.security.test.ts diff --git a/app/server/modules/auth/__tests__/auth.service.sso-provider.test.ts b/app/server/modules/sso/__tests__/sso.service.test.ts similarity index 93% rename from app/server/modules/auth/__tests__/auth.service.sso-provider.test.ts rename to app/server/modules/sso/__tests__/sso.service.test.ts index cb7f6c8b..7c9eef54 100644 --- a/app/server/modules/auth/__tests__/auth.service.sso-provider.test.ts +++ b/app/server/modules/sso/__tests__/sso.service.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { db } from "~/server/db/db"; import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; -import { authService } from "../auth.service"; +import { ssoService } from "../sso.service"; function randomId() { return Bun.randomUUIDv7(); @@ -37,7 +37,7 @@ async function createOrganization(name: string) { return id; } -describe("authService.deleteSsoProvider", () => { +describe("ssoService.deleteSsoProvider", () => { beforeEach(async () => { await db.delete(member); await db.delete(account); @@ -71,7 +71,7 @@ describe("authService.deleteSsoProvider", () => { userId: accountUser, }); - const deleted = await authService.deleteSsoProvider(providerId, orgA); + const deleted = await ssoService.deleteSsoProvider(providerId, orgA); expect(deleted).toBe(false); @@ -120,7 +120,7 @@ describe("authService.deleteSsoProvider", () => { }, ]); - const deleted = await authService.deleteSsoProvider(providerId, org); + const deleted = await ssoService.deleteSsoProvider(providerId, org); expect(deleted).toBe(true); @@ -158,7 +158,7 @@ describe("authService.deleteSsoProvider", () => { userId: credentialUser, }); - const deleted = await authService.deleteSsoProvider("credential", org); + const deleted = await ssoService.deleteSsoProvider("credential", org); expect(deleted).toBe(true); diff --git a/app/server/modules/sso/index.ts b/app/server/modules/sso/index.ts new file mode 100644 index 00000000..efb2f8a8 --- /dev/null +++ b/app/server/modules/sso/index.ts @@ -0,0 +1,2 @@ +export { ssoIntegration } from "./sso.integration"; +export { ssoController } from "./sso.controller"; diff --git a/app/server/lib/auth/middlewares/__tests__/require-sso-invitation.test.ts b/app/server/modules/sso/middlewares/__tests__/require-invitation.test.ts similarity index 94% rename from app/server/lib/auth/middlewares/__tests__/require-sso-invitation.test.ts rename to app/server/modules/sso/middlewares/__tests__/require-invitation.test.ts index e6af370a..d2c563a6 100644 --- a/app/server/lib/auth/middlewares/__tests__/require-sso-invitation.test.ts +++ b/app/server/modules/sso/middlewares/__tests__/require-invitation.test.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, test } from "bun:test"; import type { GenericEndpointContext } from "better-auth"; import { db } from "~/server/db/db"; import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; -import { isSsoCallbackRequest } from "../../utils/sso-context"; -import { requireSsoInvitation } from "../require-sso-invitation"; +import { isSsoCallbackRequest } from "~/server/modules/sso/utils/sso-context"; +import { requireSsoInvitation } from "../require-invitation"; function createMockContext(path: string, params: Record = {}): GenericEndpointContext { return { @@ -73,7 +73,7 @@ describe("requireSsoInvitation", () => { test("throws when context is null", async () => { await createSsoProvider("oidc-acme"); - expect(requireSsoInvitation("user@example.com", null)).rejects.toThrow("Missing SSO context"); + await expect(requireSsoInvitation("user@example.com", null)).rejects.toThrow("Missing SSO context"); }); test("throws when request is not an SSO callback", async () => { diff --git a/app/server/lib/auth/middlewares/__tests__/trust-sso-provider-for-linking.test.ts b/app/server/modules/sso/middlewares/__tests__/trust-provider-for-linking.test.ts similarity index 97% rename from app/server/lib/auth/middlewares/__tests__/trust-sso-provider-for-linking.test.ts rename to app/server/modules/sso/middlewares/__tests__/trust-provider-for-linking.test.ts index 925ba218..7d124e64 100644 --- a/app/server/lib/auth/middlewares/__tests__/trust-sso-provider-for-linking.test.ts +++ b/app/server/modules/sso/middlewares/__tests__/trust-provider-for-linking.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { eq } from "drizzle-orm"; import { db } from "~/server/db/db"; import { account, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; -import { resolveTrustedProvidersForRequest } from "../trust-sso-provider-for-linking"; +import { resolveTrustedProvidersForRequest } from "../trust-provider-for-linking"; function randomId() { return Bun.randomUUIDv7(); diff --git a/app/server/modules/sso/middlewares/__tests__/validate-callback-urls.test.ts b/app/server/modules/sso/middlewares/__tests__/validate-callback-urls.test.ts new file mode 100644 index 00000000..15b1dba4 --- /dev/null +++ b/app/server/modules/sso/middlewares/__tests__/validate-callback-urls.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import type { AuthMiddlewareContext } from "~/server/lib/auth"; +import { validateSsoCallbackUrls } from "../validate-callback-urls"; + +function createContext( + path: string, + body: Record = {}, + query: Record = {}, +): AuthMiddlewareContext { + return { + path, + body, + query, + headers: new Headers(), + request: new Request(`http://localhost:3000${path}`), + params: {}, + method: "POST", + context: {} as AuthMiddlewareContext["context"], + } as AuthMiddlewareContext; +} + +describe("validateSsoCallbackUrls", () => { + test("accepts relative paths for every callback field", async () => { + const ctx = createContext("/sign-in/sso", { + callbackURL: "/login", + errorCallbackURL: "/login/error", + newUserCallbackURL: "/download-recovery-key", + }); + + await expect(validateSsoCallbackUrls(ctx)).resolves.toBeUndefined(); + }); + + test("rejects https://evil.example", async () => { + const ctx = createContext("/sign-in/sso", { + callbackURL: "https://evil.example", + }); + + await expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL"); + }); + + test("rejects //evil.example", async () => { + const ctx = createContext("/sign-in/sso", { + callbackURL: "//evil.example", + }); + + await expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL"); + }); + + test("rejects /sso/callback/foo", async () => { + const ctx = createContext("/sign-in/sso", { + callbackURL: "/sso/callback/foo", + }); + + await expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL"); + }); + + test("rejects /sso/saml2/foo", async () => { + const ctx = createContext("/sign-in/sso", { + callbackURL: "/sso/saml2/foo", + }); + + await expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL"); + }); + + test("rejects malicious query callback fields", async () => { + const ctx = createContext("/sign-in/sso", {}, { errorCallbackURL: "https://evil.example" }); + + await expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("errorCallbackURL"); + }); + + test("rejects malicious newUserCallbackURL", async () => { + const ctx = createContext("/sign-in/sso", { + newUserCallbackURL: "https://evil.example", + }); + + await expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("newUserCallbackURL"); + }); + + test("skips validation outside SSO sign-in endpoint", async () => { + const ctx = createContext("/sign-in/email", { + callbackURL: "https://evil.example", + }); + + await expect(validateSsoCallbackUrls(ctx)).resolves.toBeUndefined(); + }); + + test("rejects URL-encoded external URL (%2F%2Fevil.example)", async () => { + const ctx = createContext("/sign-in/sso", { + callbackURL: "%2F%2Fevil.example", + }); + + await expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL"); + }); + + test("rejects URL-encoded reserved path (%2Fsso%2Fcallback%2Ffoo)", async () => { + const ctx = createContext("/sign-in/sso", { + callbackURL: "%2Fsso%2Fcallback%2Ffoo", + }); + + await expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL"); + }); + + test("rejects invalid URL encoding (malformed)", async () => { + const ctx = createContext("/sign-in/sso", { callbackURL: "%ZZ" }); + + await expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL"); + }); +}); diff --git a/app/server/lib/auth/middlewares/__tests__/validate-sso-provider-id.test.ts b/app/server/modules/sso/middlewares/__tests__/validate-provider-id.test.ts similarity index 64% rename from app/server/lib/auth/middlewares/__tests__/validate-sso-provider-id.test.ts rename to app/server/modules/sso/middlewares/__tests__/validate-provider-id.test.ts index da5bbc4d..2ecd55b1 100644 --- a/app/server/lib/auth/middlewares/__tests__/validate-sso-provider-id.test.ts +++ b/app/server/modules/sso/middlewares/__tests__/validate-provider-id.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { AuthMiddlewareContext } from "~/server/lib/auth"; -import { validateSsoProviderId } from "../validate-sso-provider-id"; +import { validateSsoProviderId } from "../validate-provider-id"; function createContext(path: string, body: Record = {}): AuthMiddlewareContext { return { @@ -19,24 +19,28 @@ describe("validateSsoProviderId", () => { test("allows non-reserved provider id", async () => { const ctx = createContext("/sso/register", { providerId: "acme-oidc" }); - expect(validateSsoProviderId(ctx)).resolves.toBeUndefined(); + await expect(validateSsoProviderId(ctx)).resolves.toBeUndefined(); }); test("rejects reserved credential provider id", async () => { - const ctx = createContext("/sso/register", { providerId: "credential" }); + const ctx = createContext("/sso/register", { + providerId: "credential", + }); - expect(validateSsoProviderId(ctx)).rejects.toThrow("reserved"); + await expect(validateSsoProviderId(ctx)).rejects.toThrow("reserved"); }); test("rejects reserved credentials provider id case-insensitively", async () => { - const ctx = createContext("/sso/register", { providerId: " Credential " }); + const ctx = createContext("/sso/register", { + providerId: " Credential ", + }); - expect(validateSsoProviderId(ctx)).rejects.toThrow("reserved"); + await expect(validateSsoProviderId(ctx)).rejects.toThrow("reserved"); }); test("skips validation outside register endpoint", async () => { const ctx = createContext("/sign-in/sso", { providerId: "credential" }); - expect(validateSsoProviderId(ctx)).resolves.toBeUndefined(); + await expect(validateSsoProviderId(ctx)).resolves.toBeUndefined(); }); }); diff --git a/app/server/lib/auth/middlewares/require-sso-invitation.ts b/app/server/modules/sso/middlewares/require-invitation.ts similarity index 75% rename from app/server/lib/auth/middlewares/require-sso-invitation.ts rename to app/server/modules/sso/middlewares/require-invitation.ts index f3db2198..44c02c17 100644 --- a/app/server/lib/auth/middlewares/require-sso-invitation.ts +++ b/app/server/modules/sso/middlewares/require-invitation.ts @@ -1,8 +1,8 @@ import { APIError } from "better-auth/api"; import type { GenericEndpointContext } from "better-auth"; import { logger } from "~/server/utils/logger"; -import { extractProviderIdFromContext } from "../utils/sso-context"; -import { authService } from "~/server/modules/auth/auth.service"; +import { extractProviderIdFromContext } from "~/server/modules/sso/utils/sso-context"; +import { ssoService } from "~/server/modules/sso/sso.service"; export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpointContext | null) => { if (!ctx) { @@ -14,14 +14,14 @@ export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpoi throw new APIError("BAD_REQUEST", { message: "Missing providerId in context" }); } - const provider = await authService.getSsoProviderById(providerId); + const provider = await ssoService.getSsoProviderById(providerId); if (!provider) { throw new APIError("NOT_FOUND", { message: "SSO provider not found" }); } logger.debug("Checking for pending invitations", { organizationId: provider.organizationId }); - const pendingInvitation = await authService.getPendingInvitation(provider.organizationId, userEmail); + const pendingInvitation = await ssoService.getPendingInvitation(provider.organizationId, userEmail); logger.debug("Pending invitation result", { found: !!pendingInvitation, invitationId: pendingInvitation?.id }); diff --git a/app/server/modules/sso/middlewares/trust-provider-for-linking.ts b/app/server/modules/sso/middlewares/trust-provider-for-linking.ts new file mode 100644 index 00000000..5bb00b4a --- /dev/null +++ b/app/server/modules/sso/middlewares/trust-provider-for-linking.ts @@ -0,0 +1,20 @@ +import { ssoService } from "~/server/modules/sso/sso.service"; +import { extractProviderIdFromUrl } from "~/server/modules/sso/utils/sso-context"; + +export async function resolveTrustedProvidersForRequest(request?: Request): Promise { + if (!request) { + return []; + } + + const providerId = extractProviderIdFromUrl(request.url); + if (!providerId) { + return []; + } + + const provider = await ssoService.getSsoProviderById(providerId); + if (!provider) { + return []; + } + + return ssoService.getAutoLinkingSsoProviderIds(provider.organizationId); +} diff --git a/app/server/lib/auth/middlewares/validate-sso-callback-urls.ts b/app/server/modules/sso/middlewares/validate-callback-urls.ts similarity index 100% rename from app/server/lib/auth/middlewares/validate-sso-callback-urls.ts rename to app/server/modules/sso/middlewares/validate-callback-urls.ts diff --git a/app/server/lib/auth/middlewares/validate-sso-provider-id.ts b/app/server/modules/sso/middlewares/validate-provider-id.ts similarity index 87% rename from app/server/lib/auth/middlewares/validate-sso-provider-id.ts rename to app/server/modules/sso/middlewares/validate-provider-id.ts index 903a972c..c7a27f5c 100644 --- a/app/server/lib/auth/middlewares/validate-sso-provider-id.ts +++ b/app/server/modules/sso/middlewares/validate-provider-id.ts @@ -1,6 +1,6 @@ import { APIError } from "better-auth/api"; import type { AuthMiddlewareContext } from "~/server/lib/auth"; -import { isReservedSsoProviderId } from "../utils/sso-provider-id"; +import { isReservedSsoProviderId } from "~/server/modules/sso/utils/sso-provider-id"; export const validateSsoProviderId = async (ctx: AuthMiddlewareContext) => { if (ctx.path !== "/sso/register") { diff --git a/app/server/modules/sso/sso.controller.ts b/app/server/modules/sso/sso.controller.ts new file mode 100644 index 00000000..a6569efe --- /dev/null +++ b/app/server/modules/sso/sso.controller.ts @@ -0,0 +1,107 @@ +import { Hono } from "hono"; +import { validator } from "hono-openapi"; +import { + type PublicSsoProvidersDto, + type SsoSettingsDto, + deleteSsoInvitationDto, + deleteSsoProviderDto, + getPublicSsoProvidersDto, + getSsoSettingsDto, + updateSsoProviderAutoLinkingBody, + updateSsoProviderAutoLinkingDto, +} from "./sso.dto"; +import { ssoService } from "./sso.service"; +import { requireAuth, requireOrgAdmin } from "../auth/auth.middleware"; +import { auth } from "~/server/lib/auth"; +import { mapAuthErrorToCode } from "./sso.errors"; +import { config } from "~/server/core/config"; + +export const ssoController = new Hono() + .get("/sso-providers", getPublicSsoProvidersDto, async (c) => { + const providers = await ssoService.getPublicSsoProviders(); + return c.json(providers); + }) + .get("/sso-settings", requireAuth, requireOrgAdmin, getSsoSettingsDto, async (c) => { + const headers = c.req.raw.headers; + const activeOrganizationId = c.get("organizationId"); + + if (!activeOrganizationId) { + return c.json({ providers: [], invitations: [] }); + } + + const [providersData, invitationsData, autoLinkingSettings] = await Promise.all([ + auth.api.listSSOProviders({ headers, query: { organizationId: activeOrganizationId } }), + auth.api.listInvitations({ headers, query: { organizationId: activeOrganizationId } }), + ssoService.getSsoProviderAutoLinkingSettings(activeOrganizationId), + ]); + + return c.json({ + providers: providersData.providers + .map((provider) => ({ + providerId: provider.providerId, + type: provider.type, + issuer: provider.issuer, + domain: provider.domain, + autoLinkMatchingEmails: autoLinkingSettings[provider.providerId] ?? false, + organizationId: provider.organizationId, + })) + .filter((p) => p.organizationId === activeOrganizationId), + invitations: invitationsData.map((invitation) => ({ + id: invitation.id, + email: invitation.email, + role: invitation.role, + status: invitation.status, + expiresAt: invitation.expiresAt.toISOString(), + })), + }); + }) + .delete("/sso-providers/:providerId", requireAuth, requireOrgAdmin, deleteSsoProviderDto, async (c) => { + const providerId = c.req.param("providerId"); + const organizationId = c.get("organizationId"); + + const deleted = await ssoService.deleteSsoProvider(providerId, organizationId); + + if (!deleted) { + return c.json({ message: "Provider not found" }, 404); + } + + return c.json({ success: true }); + }) + .patch( + "/sso-providers/:providerId/auto-linking", + requireAuth, + requireOrgAdmin, + updateSsoProviderAutoLinkingDto, + validator("json", updateSsoProviderAutoLinkingBody), + async (c) => { + const providerId = c.req.param("providerId"); + const organizationId = c.get("organizationId"); + const { enabled } = c.req.valid("json"); + + const updated = await ssoService.updateSsoProviderAutoLinking(providerId, organizationId, enabled); + + if (!updated) { + return c.json({ message: "Provider not found" }, 404); + } + + return c.json({ success: true }); + }, + ) + .delete("/sso-invitations/:invitationId", requireAuth, requireOrgAdmin, deleteSsoInvitationDto, async (c) => { + const invitationId = c.req.param("invitationId"); + const organizationId = c.get("organizationId"); + + const invitation = await ssoService.getSsoInvitationById(invitationId); + if (!invitation || invitation.organizationId !== organizationId) { + return c.json({ message: "Invitation not found" }, 404); + } + + await ssoService.deleteSsoInvitation(invitationId); + + return c.json({ success: true }); + }) + .get("/login-error", async (c) => { + const error = c.req.query("error"); + const errorCode = error ? mapAuthErrorToCode(error) : "SSO_LOGIN_FAILED"; + return c.redirect(`${config.baseUrl}/login?error=${errorCode}`); + }); diff --git a/app/server/modules/sso/sso.dto.ts b/app/server/modules/sso/sso.dto.ts new file mode 100644 index 00000000..11da94a0 --- /dev/null +++ b/app/server/modules/sso/sso.dto.ts @@ -0,0 +1,117 @@ +import { type } from "arktype"; +import { describeRoute, resolver } from "hono-openapi"; + +export const publicSsoProvidersDto = type({ + providers: type({ + providerId: "string", + organizationSlug: "string", + }) + .onUndeclaredKey("delete") + .array(), +}); + +export type PublicSsoProvidersDto = typeof publicSsoProvidersDto.infer; + +export const getPublicSsoProvidersDto = describeRoute({ + description: "Get public SSO providers for the instance", + operationId: "getPublicSsoProviders", + tags: ["Auth"], + responses: { + 200: { + description: "List of public SSO providers", + content: { + "application/json": { + schema: resolver(publicSsoProvidersDto), + }, + }, + }, + }, +}); + +export const ssoSettingsResponse = type({ + providers: type({ + providerId: "string", + type: "string", + issuer: "string", + domain: "string", + autoLinkMatchingEmails: "boolean", + organizationId: "string | null", + }).array(), + invitations: type({ + id: "string", + email: "string", + role: "string", + status: "string", + expiresAt: "string", + }).array(), +}); + +export type SsoSettingsDto = typeof ssoSettingsResponse.infer; + +export const getSsoSettingsDto = describeRoute({ + description: "Get SSO providers and invitations for the active organization", + operationId: "getSsoSettings", + tags: ["Auth"], + responses: { + 200: { + description: "SSO settings for the active organization", + content: { + "application/json": { + schema: resolver(ssoSettingsResponse), + }, + }, + }, + }, +}); + +export const deleteSsoProviderDto = describeRoute({ + description: "Delete an SSO provider", + operationId: "deleteSsoProvider", + tags: ["Auth"], + responses: { + 200: { + description: "SSO provider deleted successfully", + }, + 404: { + description: "Provider not found", + }, + 403: { + description: "Forbidden", + }, + }, +}); + +export const deleteSsoInvitationDto = describeRoute({ + description: "Delete an SSO invitation", + operationId: "deleteSsoInvitation", + tags: ["Auth"], + responses: { + 200: { + description: "SSO invitation deleted successfully", + }, + 403: { + description: "Forbidden", + }, + }, +}); + +export const updateSsoProviderAutoLinkingBody = type({ + enabled: "boolean", +}); + +export const updateSsoProviderAutoLinkingDto = describeRoute({ + description: "Update whether SSO sign-in can auto-link existing accounts by email", + operationId: "updateSsoProviderAutoLinking", + tags: ["Auth"], + responses: { + 200: { + description: "SSO provider auto-linking setting updated successfully", + }, + 403: { + description: "Forbidden", + }, + 404: { + description: "Provider not found", + }, + }, +}); diff --git a/app/server/modules/auth/auth.errors.ts b/app/server/modules/sso/sso.errors.ts similarity index 92% rename from app/server/modules/auth/auth.errors.ts rename to app/server/modules/sso/sso.errors.ts index ea4831e9..42434449 100644 --- a/app/server/modules/auth/auth.errors.ts +++ b/app/server/modules/sso/sso.errors.ts @@ -1,4 +1,4 @@ -import type { LoginErrorCode } from "~/lib/auth-errors"; +import type { LoginErrorCode } from "~/lib/sso-errors"; const INVITE_REQUIRED_ERRORS = new Set([ "Access denied. You must be invited to this organization before you can sign in with SSO.", diff --git a/app/server/modules/sso/sso.integration.ts b/app/server/modules/sso/sso.integration.ts new file mode 100644 index 00000000..26940090 --- /dev/null +++ b/app/server/modules/sso/sso.integration.ts @@ -0,0 +1,107 @@ +import { sso } from "@better-auth/sso"; +import { eq } from "drizzle-orm"; +import { APIError } from "better-auth"; +import type { GenericEndpointContext, User } from "better-auth"; +import { db } from "~/server/db/db"; +import { invitation, member } from "~/server/db/schema"; +import { authService } from "../auth/auth.service"; +import { ssoService } from "./sso.service"; +import { validateSsoProviderId } from "./middlewares/validate-provider-id"; +import { validateSsoCallbackUrls } from "./middlewares/validate-callback-urls"; +import { requireSsoInvitation } from "./middlewares/require-invitation"; +import { resolveTrustedProvidersForRequest } from "./middlewares/trust-provider-for-linking"; +import { isSsoCallbackRequest, extractProviderIdFromContext, normalizeEmail } from "./utils/sso-context"; +import { findMembershipWithOrganization } from "~/server/lib/auth/helpers/create-default-org"; +import { logger } from "~/server/utils/logger"; + +async function resolveOrgMembership(userId: string, ctx: GenericEndpointContext | null) { + const user = await db.query.usersTable.findFirst({ where: { id: userId } }); + if (!user) { + return null; + } + + const providerId = extractProviderIdFromContext(ctx); + if (!providerId) { + return null; + } + + const ssoProviderRecord = await ssoService.getSsoProviderById(providerId); + if (!ssoProviderRecord) { + return null; + } + + const existingSsoMembership = await findMembershipWithOrganization(user.id, ssoProviderRecord.organizationId); + if (existingSsoMembership) { + return existingSsoMembership; + } + + logger.debug("Checking for pending invitations for user", { userId, providerId: ssoProviderRecord.providerId }); + + const pendingInvitation = await ssoService.getPendingInvitation( + ssoProviderRecord.organizationId, + normalizeEmail(user.email), + ); + + if (!pendingInvitation) { + logger.debug("No pending invitation found for user"); + throw new APIError("FORBIDDEN", { message: "SSO sign-in is invite-only for this organization" }); + } + + await db.transaction(async (tx) => { + tx.insert(member) + .values({ + id: Bun.randomUUIDv7(), + userId, + role: pendingInvitation.role as "member", + organizationId: pendingInvitation.organizationId, + createdAt: new Date(), + }) + .run(); + + tx.update(invitation).set({ status: "accepted" }).where(eq(invitation.id, pendingInvitation.id)).run(); + }); + + const invitedMembership = await findMembershipWithOrganization(userId, pendingInvitation.organizationId); + logger.debug("Created organization membership from invitation", { + userId, + organizationId: pendingInvitation.organizationId, + }); + + if (!invitedMembership) { + throw new Error("Failed to create invited organization membership"); + } + + return invitedMembership; +} + +async function onUserCreate( + user: User & { hasDownloadedResticPassword?: boolean }, + ctx: GenericEndpointContext | null, +) { + await requireSsoInvitation(user.email, ctx); + user.hasDownloadedResticPassword = true; +} + +export const ssoIntegration = { + plugin: sso({ + trustEmailVerified: false, + providersLimit: async (user: User) => { + const isOrgAdmin = await authService.isOrgAdminAnywhere(user.id); + return isOrgAdmin ? 10 : 0; + }, + organizationProvisioning: { + disabled: false, + defaultRole: "member", + }, + }), + + beforeMiddlewares: [validateSsoProviderId, validateSsoCallbackUrls] as const, + + isSsoCallback: isSsoCallbackRequest, + + onUserCreate, + + resolveOrgMembership, + + resolveTrustedProviders: resolveTrustedProvidersForRequest, +}; diff --git a/app/server/modules/sso/sso.service.ts b/app/server/modules/sso/sso.service.ts new file mode 100644 index 00000000..d2359bc6 --- /dev/null +++ b/app/server/modules/sso/sso.service.ts @@ -0,0 +1,136 @@ +import { db } from "~/server/db/db"; +import { ssoProvider, account, invitation, organization } from "~/server/db/schema"; +import { eq, and } from "drizzle-orm"; +import { isReservedSsoProviderId } from "./utils/sso-provider-id"; +import { normalizeEmail } from "./utils/sso-context"; + +export class SsoService { + /** + * Get public SSO providers for the instance + */ + async getPublicSsoProviders() { + const providers = await db + .select({ + providerId: ssoProvider.providerId, + organizationSlug: organization.slug, + }) + .from(ssoProvider) + .innerJoin(organization, eq(ssoProvider.organizationId, organization.id)); + + return { providers }; + } + + /** + * Get an SSO provider by provider id + */ + async getSsoProviderById(providerId: string) { + return db.query.ssoProvider.findFirst({ + where: { providerId }, + columns: { id: true, providerId: true, organizationId: true }, + }); + } + + /** + * Get an active pending invitation for organization/email + */ + async getPendingInvitation(organizationId: string, email: string) { + return db.query.invitation.findFirst({ + where: { + AND: [ + { organizationId }, + { status: "pending" }, + { expiresAt: { gt: new Date() } }, + { email: normalizeEmail(email) }, + ], + }, + columns: { + id: true, + email: true, + role: true, + organizationId: true, + }, + }); + } + + /** + * Get trusted provider ids for organization auto-linking + */ + async getAutoLinkingSsoProviderIds(organizationId: string) { + const providers = await db.query.ssoProvider.findMany({ + columns: { providerId: true }, + where: { organizationId, autoLinkMatchingEmails: true }, + }); + + return providers.map((provider) => provider.providerId); + } + + /** + * Delete an SSO provider and its associated accounts + */ + async deleteSsoProvider(providerId: string, organizationId: string) { + return db.transaction(async (tx) => { + const provider = await tx.query.ssoProvider.findFirst({ + where: { AND: [{ providerId }, { organizationId }] }, + columns: { id: true, providerId: true }, + }); + + if (!provider) { + return false; + } + + if (isReservedSsoProviderId(provider.providerId)) { + await tx.delete(ssoProvider).where(eq(ssoProvider.id, provider.id)); + return true; + } + + await tx.delete(account).where(eq(account.providerId, provider.providerId)); + await tx.delete(ssoProvider).where(eq(ssoProvider.id, provider.id)); + + return true; + }); + } + + /** + * Get per-provider auto-linking setting for an organization + */ + async getSsoProviderAutoLinkingSettings(organizationId: string) { + const providers = await db.query.ssoProvider.findMany({ + columns: { providerId: true, autoLinkMatchingEmails: true }, + where: { organizationId }, + }); + + return Object.fromEntries(providers.map((provider) => [provider.providerId, provider.autoLinkMatchingEmails])); + } + + /** + * Update per-provider auto-linking setting + */ + async updateSsoProviderAutoLinking(providerId: string, organizationId: string, enabled: boolean) { + const result = await db + .update(ssoProvider) + .set({ autoLinkMatchingEmails: enabled }) + .where(and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, organizationId))) + .returning(); + + return result.length > 0; + } + + /** + * Get an SSO invitation by ID + */ + async getSsoInvitationById(invitationId: string) { + return db.query.invitation.findFirst({ + where: { id: invitationId }, + columns: { id: true, organizationId: true }, + }); + } + + /** + * Delete an invitation + */ + async deleteSsoInvitation(invitationId: string) { + await db.delete(invitation).where(eq(invitation.id, invitationId)); + } +} + +export const ssoService = new SsoService(); diff --git a/app/server/lib/auth/utils/sso-context.ts b/app/server/modules/sso/utils/sso-context.ts similarity index 100% rename from app/server/lib/auth/utils/sso-context.ts rename to app/server/modules/sso/utils/sso-context.ts diff --git a/app/server/lib/auth/utils/sso-provider-id.ts b/app/server/modules/sso/utils/sso-provider-id.ts similarity index 100% rename from app/server/lib/auth/utils/sso-provider-id.ts rename to app/server/modules/sso/utils/sso-provider-id.ts