refactor(sso): extract sso code into it's own module (#617)
This commit is contained in:
parent
7dc017f4b6
commit
4a601d157d
38 changed files with 1040 additions and 764 deletions
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
57
app/client/modules/sso/components/sso-login-section.tsx
Normal file
57
app/client/modules/sso/components/sso-login-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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")({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
283
app/server/modules/sso/__tests__/sso.integration.test.ts
Normal file
283
app/server/modules/sso/__tests__/sso.integration.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
2
app/server/modules/sso/index.ts
Normal file
2
app/server/modules/sso/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { ssoIntegration } from "./sso.integration";
|
||||
export { ssoController } from "./sso.controller";
|
||||
|
|
@ -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 () => {
|
||||
|
|
@ -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();
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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") {
|
||||
107
app/server/modules/sso/sso.controller.ts
Normal file
107
app/server/modules/sso/sso.controller.ts
Normal 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}`);
|
||||
});
|
||||
117
app/server/modules/sso/sso.dto.ts
Normal file
117
app/server/modules/sso/sso.dto.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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.",
|
||||
107
app/server/modules/sso/sso.integration.ts
Normal file
107
app/server/modules/sso/sso.integration.ts
Normal 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,
|
||||
};
|
||||
136
app/server/modules/sso/sso.service.ts
Normal file
136
app/server/modules/sso/sso.service.ts
Normal 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();
|
||||
Loading…
Reference in a new issue