refactor(sso): extract sso code into it's own module (#617)

This commit is contained in:
Nico 2026-03-04 18:47:24 +01:00 committed by Nicolas Meienberger
parent 2f253d4999
commit 0c20473e30
38 changed files with 1040 additions and 764 deletions

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"editor.defaultFormatter": "oxc.oxc-vscode"
}

View file

@ -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<LoginErrorCode>(LOGIN_ERROR_CODES);

View file

@ -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<LoginFormValues>({
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 (
<AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app">
@ -265,26 +239,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
</form>
</Form>
{ssoProviders.providers.length > 0 && (
<div className="pt-4 border-t border-border/60 space-y-3">
<p className="text-sm font-medium">Alternative Sign-in</p>
<div className="flex flex-col gap-2">
{ssoProviders.providers.map((provider) => (
<Button
key={provider.providerId}
type="button"
variant="outline"
className="w-full"
loading={ssoLoginMutation.isPending}
disabled={ssoLoginMutation.isPending}
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
>
Log in with {provider.providerId}
</Button>
))}
</div>
</div>
)}
<SsoLoginSection />
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
</AuthLayout>

View file

@ -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";

View file

@ -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 (
<div className="pt-4 border-t border-border/60 space-y-3">
<p className="text-sm font-medium">Alternative Sign-in</p>
<div className="flex flex-col gap-2">
{ssoProviders.providers.map((provider) => (
<Button
key={provider.providerId}
type="button"
variant="outline"
className="w-full"
loading={ssoLoginMutation.isPending}
disabled={ssoLoginMutation.isPending}
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
>
Log in with {provider.providerId}
</Button>
))}
</div>
</div>
);
}

View file

@ -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")({

View file

@ -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)

View file

@ -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<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
@ -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",

View file

@ -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<string, string> = {}): 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");

View file

@ -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<ReturnType<typeof authService.getSsoProviderById>>,
) {
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 });

View file

@ -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<string, unknown> = {},
query: Record<string, unknown> = {},
): 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");
});
});

View file

@ -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<string[]> {
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);
}

View file

@ -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();
});
});

View file

@ -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<GetStatusDto>({ hasUsers });
})
.get("/sso-providers", getPublicSsoProvidersDto, async (c) => {
const providers = await authService.getPublicSsoProviders();
return c.json<PublicSsoProvidersDto>(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<SsoSettingsDto>({ 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<SsoSettingsDto>({
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}`);
});

View file

@ -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",
},
},
});

View file

@ -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
*/

View file

@ -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");
});
});

View file

@ -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);

View file

@ -0,0 +1,2 @@
export { ssoIntegration } from "./sso.integration";
export { ssoController } from "./sso.controller";

View file

@ -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<string, string> = {}): 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 () => {

View file

@ -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();

View file

@ -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<string, unknown> = {},
query: Record<string, unknown> = {},
): 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");
});
});

View file

@ -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<string, unknown> = {}): 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();
});
});

View file

@ -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 });

View file

@ -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<string[]> {
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);
}

View file

@ -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") {

View file

@ -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<PublicSsoProvidersDto>(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<SsoSettingsDto>({ 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<SsoSettingsDto>({
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}`);
});

View file

@ -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",
},
},
});

View file

@ -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.",

View file

@ -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,
};

View file

@ -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();