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 GitHub
parent 7dc017f4b6
commit 4a601d157d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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 type { LoginErrorCode } from "~/lib/sso-errors";
export { getLoginErrorDescription } from "~/lib/auth-errors"; export { getLoginErrorDescription } from "~/lib/sso-errors";
const VALID_ERROR_CODES = new Set<LoginErrorCode>(LOGIN_ERROR_CODES); 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 { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
import { Label } from "~/client/components/ui/label"; import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client"; 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 { ResetPasswordDialog } from "../components/reset-password-dialog";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username"; 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 { cn } from "~/client/lib/utils";
import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section";
const loginSchema = type({ const loginSchema = type({
username: "2<=string<=50", username: "2<=string<=50",
@ -40,10 +39,6 @@ export function LoginPage({ error }: LoginPageProps = {}) {
const errorCode = decodeLoginError(error); const errorCode = decodeLoginError(error);
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null; const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
const { data: ssoProviders } = useSuspenseQuery({
...getPublicSsoProvidersOptions(),
});
const form = useForm<LoginFormValues>({ const form = useForm<LoginFormValues>({
resolver: arktypeResolver(loginSchema), resolver: arktypeResolver(loginSchema),
defaultValues: { defaultValues: {
@ -129,27 +124,6 @@ export function LoginPage({ error }: LoginPageProps = {}) {
form.reset(); 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) { if (requires2FA) {
return ( return (
<AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app"> <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>
</Form> </Form>
{ssoProviders.providers.length > 0 && ( <SsoLoginSection />
<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>
)}
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} /> <ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
</AuthLayout> </AuthLayout>

View file

@ -22,7 +22,7 @@ import { authClient } from "~/client/lib/auth-client";
import { type AppContext } from "~/context"; import { type AppContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section"; import { TwoFactorSection } from "../components/two-factor-section";
import { useNavigate, useSearch } from "@tanstack/react-router"; 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 { OrgMembersSection } from "../components/org-members-section";
import { useOrganizationContext } from "~/client/hooks/use-org-context"; 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 { 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"; import { getOrganizationContext } from "~/server/lib/functions/organization-context";
export const Route = createFileRoute("/(dashboard)/settings/sso/new")({ 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 { rateLimiter } from "hono-rate-limiter";
import { openAPIRouteHandler } from "hono-openapi"; import { openAPIRouteHandler } from "hono-openapi";
import { authController } from "./modules/auth/auth.controller"; import { authController } from "./modules/auth/auth.controller";
import { ssoController } from "./modules/sso/sso.controller";
import { requireAuth } from "./modules/auth/auth.middleware"; import { requireAuth } from "./modules/auth/auth.middleware";
import { repositoriesController } from "./modules/repositories/repositories.controller"; import { repositoriesController } from "./modules/repositories/repositories.controller";
import { systemController } from "./modules/system/system.controller"; import { systemController } from "./modules/system/system.controller";
@ -74,6 +75,7 @@ export const createApp = () => {
app app
.get("/api/healthcheck", (c) => c.json({ status: "ok" })) .get("/api/healthcheck", (c) => c.json({ status: "ok" }))
.route("/api/v1/auth", authController) .route("/api/v1/auth", authController)
.route("/api/v1/auth", ssoController)
.route("/api/v1/volumes", volumeController) .route("/api/v1/volumes", volumeController)
.route("/api/v1/repositories", repositoriesController) .route("/api/v1/repositories", repositoriesController)
.route("/api/v1/backups", backupScheduleController) .route("/api/v1/backups", backupScheduleController)

View file

@ -4,12 +4,10 @@ import {
type BetterAuthOptions, type BetterAuthOptions,
type MiddlewareContext, type MiddlewareContext,
type MiddlewareOptions, type MiddlewareOptions,
type User,
} from "better-auth"; } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, twoFactor, username, organization, testUtils } from "better-auth/plugins"; import { admin, twoFactor, username, organization, testUtils } from "better-auth/plugins";
import { createAuthMiddleware } from "better-auth/api"; import { createAuthMiddleware } from "better-auth/api";
import { sso } from "@better-auth/sso";
import { config } from "../core/config"; import { config } from "../core/config";
import { db } from "../db/db"; import { db } from "../db/db";
import { cryptoUtils } from "../utils/crypto"; import { cryptoUtils } from "../utils/crypto";
@ -19,13 +17,9 @@ import { tanstackStartCookies } from "better-auth/tanstack-start";
import { isValidUsername, normalizeUsername } from "~/lib/username"; import { isValidUsername, normalizeUsername } from "~/lib/username";
import { ensureOnlyOneUser } from "./auth/middlewares/only-one-user"; import { ensureOnlyOneUser } from "./auth/middlewares/only-one-user";
import { convertLegacyUserOnFirstLogin } from "./auth/middlewares/convert-legacy-user"; import { convertLegacyUserOnFirstLogin } from "./auth/middlewares/convert-legacy-user";
import { validateSsoCallbackUrls } from "./auth/middlewares/validate-sso-callback-urls"; import { ensureDefaultOrg } from "./auth/helpers/create-default-org";
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 { buildAllowedHosts } from "./auth/base-url"; 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>>; export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
@ -52,8 +46,10 @@ export const auth = betterAuth({
}, },
hooks: { hooks: {
before: createAuthMiddleware(async (ctx) => { before: createAuthMiddleware(async (ctx) => {
await validateSsoProviderId(ctx); for (const mw of ssoIntegration.beforeMiddlewares) {
await validateSsoCallbackUrls(ctx); await mw(ctx);
}
await ensureOnlyOneUser(ctx); await ensureOnlyOneUser(ctx);
await convertLegacyUserOnFirstLogin(ctx); await convertLegacyUserOnFirstLogin(ctx);
}), }),
@ -70,9 +66,8 @@ export const auth = betterAuth({
}, },
create: { create: {
before: async (user, ctx) => { before: async (user, ctx) => {
if (isSsoCallbackRequest(ctx)) { if (ssoIntegration.isSsoCallback(ctx)) {
await requireSsoInvitation(user.email, ctx); await ssoIntegration.onUserCreate(user, ctx);
user.hasDownloadedResticPassword = true;
} }
const anyUser = await db.query.usersTable.findFirst(); const anyUser = await db.query.usersTable.findFirst();
@ -93,7 +88,9 @@ export const auth = betterAuth({
session: { session: {
create: { create: {
before: async (session, ctx) => { 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 } }; return { data: { ...session, activeOrganizationId: membership.organizationId } };
}, },
}, },
@ -105,7 +102,7 @@ export const auth = betterAuth({
account: { account: {
accountLinking: { accountLinking: {
enabled: true, enabled: true,
trustedProviders: resolveTrustedProvidersForRequest, trustedProviders: ssoIntegration.resolveTrustedProviders,
}, },
}, },
user: { user: {
@ -136,17 +133,7 @@ export const auth = betterAuth({
organization({ organization({
allowUserToCreateOrganization: false, allowUserToCreateOrganization: false,
}), }),
sso({ ssoIntegration.plugin,
trustEmailVerified: false,
providersLimit: async (user: User) => {
const isOrgAdmin = await authService.isOrgAdminAnywhere(user.id);
return isOrgAdmin ? 10 : 0;
},
organizationProvisioning: {
disabled: false,
defaultRole: "member",
},
}),
twoFactor({ twoFactor({
backupCodeOptions: { backupCodeOptions: {
storeBackupCodes: "encrypted", storeBackupCodes: "encrypted",

View file

@ -1,26 +1,8 @@
import { beforeEach, describe, expect, test } from "bun:test"; import { beforeEach, describe, expect, test } from "bun:test";
import type { GenericEndpointContext } from "better-auth";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; import { account, invitation, member, organization, usersTable } from "~/server/db/schema";
import { createUserDefaultOrg } from "../create-default-org"; import { ensureDefaultOrg } 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 });
}
function randomId() { function randomId() {
return Bun.randomUUIDv7(); return Bun.randomUUIDv7();
@ -41,123 +23,15 @@ async function createUser(email: string, username: string) {
return userId; return userId;
} }
describe("createUserDefaultOrg", () => { describe("ensureDefaultOrg", () => {
beforeEach(async () => { beforeEach(async () => {
await db.delete(member); await db.delete(member);
await db.delete(account); await db.delete(account);
await db.delete(invitation); await db.delete(invitation);
await db.delete(ssoProvider);
await db.delete(organization); await db.delete(organization);
await db.delete(usersTable); 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 () => { test("returns existing membership without creating another workspace", async () => {
const userId = await createUser("existing-member@example.com", randomSlug("existing-member")); const userId = await createUser("existing-member@example.com", randomSlug("existing-member"));
const organizationId = randomId(); const organizationId = randomId();
@ -177,7 +51,7 @@ describe("createUserDefaultOrg", () => {
createdAt: new Date(), createdAt: new Date(),
}); });
const membership = await createUserDefaultOrg(userId, null); const membership = await ensureDefaultOrg(userId);
expect(membership.organizationId).toBe(organizationId); expect(membership.organizationId).toBe(organizationId);
expect(membership.role).toBe("owner"); expect(membership.role).toBe("owner");
@ -189,10 +63,10 @@ describe("createUserDefaultOrg", () => {
expect(organizations.length).toBe(1); 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 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.role).toBe("owner");
expect(membership.organization.name).toContain("Workspace"); expect(membership.organization.name).toContain("Workspace");

View file

@ -1,13 +1,8 @@
import { eq } from "drizzle-orm";
import { UnauthorizedError } from "http-errors-enhanced"; import { UnauthorizedError } from "http-errors-enhanced";
import type { GenericEndpointContext } from "better-auth";
import { db } from "~/server/db/db"; 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 { cryptoUtils } from "~/server/utils/crypto";
import { APIError } from "better-auth";
import { extractProviderIdFromContext, normalizeEmail } from "../utils/sso-context";
import { logger } from "~/server/utils/logger"; import { logger } from "~/server/utils/logger";
import { authService } from "~/server/modules/auth/auth.service";
export async function findMembershipWithOrganization(userId: string, organizationId?: string) { export async function findMembershipWithOrganization(userId: string, organizationId?: string) {
const membership = await db.query.member.findFirst({ 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) { async function createDefaultOrganizationMembership(user: User) {
logger.debug("Creating default organization for user", { userId: user.id }); logger.debug("Creating default organization for user", { userId: user.id });
const organizationData = await buildDefaultOrganizationData(user); 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 } }); const user = await db.query.usersTable.findFirst({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError("User not found"); 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); const existingMembership = await findMembershipWithOrganization(user.id);
if (existingMembership) { if (existingMembership) {
logger.debug("User already has an organization membership, skipping default org creation", { userId }); 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(); 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, getStatusDto,
getUserDeletionImpactDto, getUserDeletionImpactDto,
type UserDeletionImpactDto, type UserDeletionImpactDto,
getPublicSsoProvidersDto,
type PublicSsoProvidersDto,
getSsoSettingsDto,
type SsoSettingsDto,
getAdminUsersDto, getAdminUsersDto,
type AdminUsersDto, type AdminUsersDto,
deleteSsoProviderDto,
deleteSsoInvitationDto,
updateSsoProviderAutoLinkingBody,
updateSsoProviderAutoLinkingDto,
deleteUserAccountDto, deleteUserAccountDto,
getOrgMembersDto, getOrgMembersDto,
type OrgMembersDto, type OrgMembersDto,
@ -25,97 +17,12 @@ import {
import { authService } from "./auth.service"; import { authService } from "./auth.service";
import { requireAdmin, requireAuth, requireOrgAdmin } from "./auth.middleware"; import { requireAdmin, requireAuth, requireOrgAdmin } from "./auth.middleware";
import { auth } from "~/server/lib/auth"; import { auth } from "~/server/lib/auth";
import { mapAuthErrorToCode } from "./auth.errors";
import { config } from "~/server/core/config";
export const authController = new Hono() export const authController = new Hono()
.get("/status", getStatusDto, async (c) => { .get("/status", getStatusDto, async (c) => {
const hasUsers = await authService.hasUsers(); const hasUsers = await authService.hasUsers();
return c.json<GetStatusDto>({ 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) => { .get("/admin-users", requireAuth, requireAdmin, getAdminUsersDto, async (c) => {
const headers = c.req.raw.headers; const headers = c.req.raw.headers;
@ -205,9 +112,4 @@ export const authController = new Hono()
} }
return c.json({ success: true }); 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", 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({ export const adminUsersResponse = type({
users: type({ users: type({
id: "string", 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({ export const deleteUserAccountDto = describeRoute({
description: "Delete an account linked to a user", description: "Delete an account linked to a user",
operationId: "deleteUserAccount", 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, volumesTable,
repositoriesTable, repositoriesTable,
backupSchedulesTable, backupSchedulesTable,
ssoProvider,
account, account,
invitation,
} from "../../db/schema"; } from "../../db/schema";
import { eq, ne, and, count, inArray } from "drizzle-orm"; import { eq, ne, and, count, inArray } from "drizzle-orm";
import type { UserDeletionImpactDto } from "./auth.dto"; 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 { export class AuthService {
/** /**
@ -25,21 +21,6 @@ export class AuthService {
return !!user; 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 * 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.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 * 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 { beforeEach, describe, expect, test } from "bun:test";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
import { authService } from "../auth.service"; import { ssoService } from "../sso.service";
function randomId() { function randomId() {
return Bun.randomUUIDv7(); return Bun.randomUUIDv7();
@ -37,7 +37,7 @@ async function createOrganization(name: string) {
return id; return id;
} }
describe("authService.deleteSsoProvider", () => { describe("ssoService.deleteSsoProvider", () => {
beforeEach(async () => { beforeEach(async () => {
await db.delete(member); await db.delete(member);
await db.delete(account); await db.delete(account);
@ -71,7 +71,7 @@ describe("authService.deleteSsoProvider", () => {
userId: accountUser, userId: accountUser,
}); });
const deleted = await authService.deleteSsoProvider(providerId, orgA); const deleted = await ssoService.deleteSsoProvider(providerId, orgA);
expect(deleted).toBe(false); 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); expect(deleted).toBe(true);
@ -158,7 +158,7 @@ describe("authService.deleteSsoProvider", () => {
userId: credentialUser, userId: credentialUser,
}); });
const deleted = await authService.deleteSsoProvider("credential", org); const deleted = await ssoService.deleteSsoProvider("credential", org);
expect(deleted).toBe(true); 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 type { GenericEndpointContext } from "better-auth";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
import { isSsoCallbackRequest } from "../../utils/sso-context"; import { isSsoCallbackRequest } from "~/server/modules/sso/utils/sso-context";
import { requireSsoInvitation } from "../require-sso-invitation"; import { requireSsoInvitation } from "../require-invitation";
function createMockContext(path: string, params: Record<string, string> = {}): GenericEndpointContext { function createMockContext(path: string, params: Record<string, string> = {}): GenericEndpointContext {
return { return {
@ -73,7 +73,7 @@ describe("requireSsoInvitation", () => {
test("throws when context is null", async () => { test("throws when context is null", async () => {
await createSsoProvider("oidc-acme"); 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 () => { 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 { eq } from "drizzle-orm";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { account, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; import { account, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
import { resolveTrustedProvidersForRequest } from "../trust-sso-provider-for-linking"; import { resolveTrustedProvidersForRequest } from "../trust-provider-for-linking";
function randomId() { function randomId() {
return Bun.randomUUIDv7(); 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 { describe, expect, test } from "bun:test";
import type { AuthMiddlewareContext } from "~/server/lib/auth"; 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 { function createContext(path: string, body: Record<string, unknown> = {}): AuthMiddlewareContext {
return { return {
@ -19,24 +19,28 @@ describe("validateSsoProviderId", () => {
test("allows non-reserved provider id", async () => { test("allows non-reserved provider id", async () => {
const ctx = createContext("/sso/register", { providerId: "acme-oidc" }); 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 () => { 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 () => { 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 () => { test("skips validation outside register endpoint", async () => {
const ctx = createContext("/sign-in/sso", { providerId: "credential" }); 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 { APIError } from "better-auth/api";
import type { GenericEndpointContext } from "better-auth"; import type { GenericEndpointContext } from "better-auth";
import { logger } from "~/server/utils/logger"; import { logger } from "~/server/utils/logger";
import { extractProviderIdFromContext } from "../utils/sso-context"; import { extractProviderIdFromContext } from "~/server/modules/sso/utils/sso-context";
import { authService } from "~/server/modules/auth/auth.service"; import { ssoService } from "~/server/modules/sso/sso.service";
export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpointContext | null) => { export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpointContext | null) => {
if (!ctx) { if (!ctx) {
@ -14,14 +14,14 @@ export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpoi
throw new APIError("BAD_REQUEST", { message: "Missing providerId in context" }); throw new APIError("BAD_REQUEST", { message: "Missing providerId in context" });
} }
const provider = await authService.getSsoProviderById(providerId); const provider = await ssoService.getSsoProviderById(providerId);
if (!provider) { if (!provider) {
throw new APIError("NOT_FOUND", { message: "SSO provider not found" }); throw new APIError("NOT_FOUND", { message: "SSO provider not found" });
} }
logger.debug("Checking for pending invitations", { organizationId: provider.organizationId }); 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 }); 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 { APIError } from "better-auth/api";
import type { AuthMiddlewareContext } from "~/server/lib/auth"; 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) => { export const validateSsoProviderId = async (ctx: AuthMiddlewareContext) => {
if (ctx.path !== "/sso/register") { 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([ const INVITE_REQUIRED_ERRORS = new Set([
"Access denied. You must be invited to this organization before you can sign in with SSO.", "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();