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 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);
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
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 { 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")({
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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();
|
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,
|
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}`);
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
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 { 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);
|
||||||
|
|
||||||
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 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 () => {
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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 { 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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
@ -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 { 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") {
|
||||||
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([
|
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.",
|
||||||
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