refactor: sso utils (#608)

This commit is contained in:
Nico 2026-03-03 21:28:39 +01:00 committed by Nicolas Meienberger
parent 10303b6596
commit a24c6d825e
14 changed files with 159 additions and 221 deletions

View file

@ -1,17 +1,9 @@
export type LoginErrorCode =
| "ACCOUNT_LINK_REQUIRED"
| "EMAIL_NOT_VERIFIED"
| "INVITE_REQUIRED"
| "BANNED_USER"
| "SSO_LOGIN_FAILED";
import { LOGIN_ERROR_CODES, type LoginErrorCode } from "~/lib/auth-errors";
const VALID_ERROR_CODES: Set<LoginErrorCode> = new Set([
"ACCOUNT_LINK_REQUIRED",
"EMAIL_NOT_VERIFIED",
"INVITE_REQUIRED",
"BANNED_USER",
"SSO_LOGIN_FAILED",
]);
export type { LoginErrorCode } from "~/lib/auth-errors";
export { getLoginErrorDescription } from "~/lib/auth-errors";
const VALID_ERROR_CODES = new Set<LoginErrorCode>(LOGIN_ERROR_CODES);
export function decodeLoginError(error?: string): LoginErrorCode | null {
if (!error) {
@ -26,20 +18,3 @@ export function decodeLoginError(error?: string): LoginErrorCode | null {
return null;
}
export function getLoginErrorDescription(errorCode: LoginErrorCode | null): string | null {
switch (errorCode) {
case "ACCOUNT_LINK_REQUIRED":
return "Your account exists but is not linked to this SSO provider. Sign in with username/password first, then enable auto linking in your provider settings or contact your administrator.";
case "EMAIL_NOT_VERIFIED":
return "Your identity provider did not mark your email as verified.";
case "INVITE_REQUIRED":
return "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
case "BANNED_USER":
return "You have been banned from this application. Please contact support if you believe this is an error.";
case "SSO_LOGIN_FAILED":
return "SSO authentication failed. Please try again.";
default:
return null;
}
}

View file

@ -38,7 +38,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
const [isVerifying2FA, setIsVerifying2FA] = useState(false);
const [trustDevice, setTrustDevice] = useState(false);
const errorCode = decodeLoginError(error);
const errorDescription = getLoginErrorDescription(errorCode);
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
const { data: ssoProviders } = useSuspenseQuery({
...getPublicSsoProvidersOptions(),

24
app/lib/auth-errors.ts Normal file
View file

@ -0,0 +1,24 @@
export const LOGIN_ERROR_CODES = [
"ACCOUNT_LINK_REQUIRED",
"EMAIL_NOT_VERIFIED",
"INVITE_REQUIRED",
"BANNED_USER",
"SSO_LOGIN_FAILED",
] as const;
export type LoginErrorCode = (typeof LOGIN_ERROR_CODES)[number];
export function getLoginErrorDescription(errorCode: LoginErrorCode): string {
switch (errorCode) {
case "ACCOUNT_LINK_REQUIRED":
return "Your account exists but is not linked to this SSO provider. Sign in with username/password first, then enable auto linking in your provider settings or contact your administrator.";
case "EMAIL_NOT_VERIFIED":
return "Your identity provider did not mark your email as verified.";
case "INVITE_REQUIRED":
return "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
case "BANNED_USER":
return "You have been banned from this application. Please contact support if you believe this is an error.";
case "SSO_LOGIN_FAILED":
return "SSO authentication failed. Please try again.";
}
}

View file

@ -22,9 +22,10 @@ import { convertLegacyUserOnFirstLogin } from "./auth/middlewares/convert-legacy
import { validateSsoCallbackUrls } from "./auth/middlewares/validate-sso-callback-urls";
import { validateSsoProviderId } from "./auth/middlewares/validate-sso-provider-id";
import { createUserDefaultOrg } from "./auth/helpers/create-default-org";
import { isSsoCallbackRequest, requireSsoInvitation } from "./auth/middlewares/require-sso-invitation";
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 { isSsoCallbackRequest } from "./auth/utils/sso-context";
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;

View file

@ -1,41 +1,26 @@
import { and, eq, gt } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { UnauthorizedError } from "http-errors-enhanced";
import type { GenericEndpointContext } from "better-auth";
import { db } from "~/server/db/db";
import { invitation, member, organization, ssoProvider, usersTable, type User } from "~/server/db/schema";
import { invitation, member, organization, type User } from "~/server/db/schema";
import { cryptoUtils } from "~/server/utils/crypto";
import { APIError } from "better-auth";
import { extractProviderIdFromContext, normalizeEmail } from "../utils/sso-context";
import { logger } from "~/server/utils/logger";
import { authService } from "~/server/modules/auth/auth.service";
export async function findMembershipWithOrganization(userId: string, organizationId?: string) {
const memberships = await db
.select()
.from(member)
.where(
organizationId
? and(eq(member.userId, userId), eq(member.organizationId, organizationId))
: eq(member.userId, userId),
)
.limit(1);
const membership = await db.query.member.findFirst({
where: organizationId ? { AND: [{ userId }, { organizationId }] } : { userId },
with: {
organization: true,
},
});
const membership = memberships[0];
if (!membership) {
return null;
}
const orgs = await db.select().from(organization).where(eq(organization.id, membership.organizationId)).limit(1);
const org = orgs[0];
if (!org) {
return null;
}
return { ...membership, organization: org };
return membership ?? null;
}
function buildOrgSlug(email: string) {
export function buildOrgSlug(email: string) {
const [emailPrefix] = email.split("@");
const sanitized = emailPrefix
.toLowerCase()
@ -46,39 +31,45 @@ function buildOrgSlug(email: string) {
return `${safePrefix}-${Math.random().toString(36).slice(-4)}`;
}
async function tryCreateInvitedMembership(userId: string, email: string, ctx: GenericEndpointContext | null) {
logger.debug("Checking for pending invitations for user", userId);
export type DefaultOrganizationData = {
id: string;
name: string;
slug: string;
createdAt: Date;
metadata: {
resticPassword: string;
};
};
const providerId = extractProviderIdFromContext(ctx);
const ssoProviders = await db.select().from(ssoProvider).where(eq(ssoProvider.providerId, providerId)).limit(1);
const ssoProviderRecord = ssoProviders[0];
export async function buildDefaultOrganizationData(
user: Pick<User, "name" | "email">,
organizationId = Bun.randomUUIDv7(),
): Promise<DefaultOrganizationData> {
const resticPassword = cryptoUtils.generateResticPassword();
return {
id: organizationId,
name: `${user.name}'s Workspace`,
slug: buildOrgSlug(user.email),
createdAt: new Date(),
metadata: {
resticPassword: await cryptoUtils.sealSecret(resticPassword),
},
};
}
async function tryCreateInvitedMembership(
userId: string,
email: string,
ssoProviderRecord: Awaited<ReturnType<typeof authService.getSsoProviderById>>,
) {
if (!ssoProviderRecord) {
logger.debug("No SSO provider found in context, skipping invitation check");
return null;
}
logger.debug("SSO provider found in context, checking for linked accounts", ssoProviderRecord.providerId);
const now = new Date();
logger.debug("Checking for pending invitations for user", { userId, providerId: ssoProviderRecord.providerId });
const pendingInvitations = await db
.select({
id: invitation.id,
email: invitation.email,
role: invitation.role,
organizationId: invitation.organizationId,
})
.from(invitation)
.where(
and(
eq(invitation.status, "pending"),
eq(invitation.organizationId, ssoProviderRecord.organizationId),
gt(invitation.expiresAt, now),
eq(invitation.email, normalizeEmail(email)),
),
)
.limit(1);
const pendingInvitation = pendingInvitations[0];
const pendingInvitation = await authService.getPendingInvitation(ssoProviderRecord.organizationId, email);
if (!pendingInvitation) {
logger.debug("No pending invitation found for user");
@ -114,29 +105,17 @@ async function tryCreateInvitedMembership(userId: string, email: string, ctx: Ge
async function createDefaultOrganizationMembership(user: User) {
logger.debug("Creating default organization for user", { userId: user.id });
const resticPassword = cryptoUtils.generateResticPassword();
const metadata = { resticPassword: await cryptoUtils.sealSecret(resticPassword) };
const organizationData = await buildDefaultOrganizationData(user);
await db.transaction(async (tx) => {
const orgId = Bun.randomUUIDv7();
const slug = buildOrgSlug(user.email);
tx.insert(organization)
.values({
name: `${user.name}'s Workspace`,
slug,
id: orgId,
createdAt: new Date(),
metadata,
})
.run();
tx.insert(organization).values(organizationData).run();
tx.insert(member)
.values({
id: Bun.randomUUIDv7(),
userId: user.id,
role: "owner",
organizationId: orgId,
organizationId: organizationData.id,
createdAt: new Date(),
})
.run();
@ -144,19 +123,14 @@ async function createDefaultOrganizationMembership(user: User) {
}
export async function createUserDefaultOrg(userId: string, ctx: GenericEndpointContext | null) {
const users = await db.select().from(usersTable).where(eq(usersTable.id, userId)).limit(1);
const user = users[0];
const user = await db.query.usersTable.findFirst({ where: { id: userId } });
if (!user) {
throw new UnauthorizedError("User not found");
}
const providerId = extractProviderIdFromContext(ctx);
if (providerId) {
const [ssoProviderRecord] = await db
.select()
.from(ssoProvider)
.where(eq(ssoProvider.providerId, providerId))
.limit(1);
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
@ -166,7 +140,7 @@ export async function createUserDefaultOrg(userId: string, ctx: GenericEndpointC
}
// Not yet a member of this SSO org, a valid pending invitation is required.
const invitedMembership = await tryCreateInvitedMembership(userId, normalizeEmail(user.email), ctx);
const invitedMembership = await tryCreateInvitedMembership(userId, normalizeEmail(user.email), ssoProviderRecord);
if (invitedMembership) {
return invitedMembership;
}

View file

@ -2,7 +2,8 @@ import { beforeEach, describe, expect, test } from "bun:test";
import type { GenericEndpointContext } from "better-auth";
import { db } from "~/server/db/db";
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
import { isSsoCallbackRequest, requireSsoInvitation } from "../require-sso-invitation";
import { isSsoCallbackRequest } from "../../utils/sso-context";
import { requireSsoInvitation } from "../require-sso-invitation";
function createMockContext(path: string, params: Record<string, string> = {}): GenericEndpointContext {
return {

View file

@ -4,8 +4,8 @@ import { db } from "~/server/db/db";
import { account, usersTable, member, organization } from "~/server/db/schema";
import type { AuthMiddlewareContext } from "~/server/lib/auth";
import { UnauthorizedError } from "http-errors-enhanced";
import { cryptoUtils } from "~/server/utils/crypto";
import { normalizeUsername } from "~/lib/username";
import { buildDefaultOrganizationData, type DefaultOrganizationData } from "../helpers/create-default-org";
export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext) => {
const { path, body } = ctx;
@ -40,27 +40,10 @@ export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext)
const passwordHash = await hashPassword(body.password);
let newOrganizationData: {
id: string;
name: string;
slug: string;
createdAt: Date;
metadata: {
resticPassword: string;
};
} | null = null;
let newOrganizationData: DefaultOrganizationData | null = null;
if (!oldMembership?.organization) {
const resticPassword = cryptoUtils.generateResticPassword();
newOrganizationData = {
id: Bun.randomUUIDv7(),
name: `${legacyUser.name}'s Workspace`,
slug: legacyUser.email.split("@")[0] + "-" + Math.random().toString(36).slice(-4),
createdAt: new Date(),
metadata: {
resticPassword: await cryptoUtils.sealSecret(resticPassword),
},
};
newOrganizationData = await buildDefaultOrganizationData(legacyUser);
}
db.transaction((tx) => {

View file

@ -1,16 +1,8 @@
import { APIError } from "better-auth/api";
import type { GenericEndpointContext } from "better-auth";
import { db } from "~/server/db/db";
import { logger } from "~/server/utils/logger";
import { extractProviderIdFromContext, extractProviderIdFromUrl, normalizeEmail } from "../utils/sso-context";
export function isSsoCallbackRequest(ctx: GenericEndpointContext | null) {
if (!ctx?.request?.url) {
return false;
}
return extractProviderIdFromUrl(ctx.request.url) !== null;
}
import { extractProviderIdFromContext } from "../utils/sso-context";
import { authService } from "~/server/modules/auth/auth.service";
export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpointContext | null) => {
if (!ctx) {
@ -22,25 +14,14 @@ export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpoi
throw new APIError("BAD_REQUEST", { message: "Missing providerId in context" });
}
const provider = await db.query.ssoProvider.findFirst({ where: { providerId } });
const provider = await authService.getSsoProviderById(providerId);
if (!provider) {
throw new APIError("NOT_FOUND", { message: "SSO provider not found" });
}
const normalizedEmail = normalizeEmail(userEmail);
logger.debug("Checking for pending invitations", { organizationId: provider.organizationId });
const pendingInvitation = await db.query.invitation.findFirst({
where: {
AND: [
{ organizationId: provider.organizationId },
{ status: "pending" },
{ expiresAt: { gt: new Date() } },
{ email: normalizedEmail },
],
},
columns: { id: true },
});
const pendingInvitation = await authService.getPendingInvitation(provider.organizationId, userEmail);
logger.debug("Pending invitation result", { found: !!pendingInvitation, invitationId: pendingInvitation?.id });

View file

@ -1,4 +1,4 @@
import { db } from "~/server/db/db";
import { authService } from "~/server/modules/auth/auth.service";
import { extractProviderIdFromUrl } from "../utils/sso-context";
export async function resolveTrustedProvidersForRequest(request?: Request): Promise<string[]> {
@ -11,21 +11,10 @@ export async function resolveTrustedProvidersForRequest(request?: Request): Prom
return [];
}
const provider = await db.query.ssoProvider.findFirst({
columns: { organizationId: true },
where: { providerId },
});
const provider = await authService.getSsoProviderById(providerId);
if (!provider) {
return [];
}
const autoLinkingProviders = await db.query.ssoProvider.findMany({
columns: { providerId: true },
where: {
organizationId: provider.organizationId,
autoLinkMatchingEmails: true,
},
});
return autoLinkingProviders.map((entry) => entry.providerId);
return authService.getAutoLinkingSsoProviderIds(provider.organizationId);
}

View file

@ -1,35 +1,41 @@
import type { GenericEndpointContext } from "better-auth";
const SSO_CALLBACK_PATH_SEGMENTS = ["/sso/callback/", "/sso/saml2/callback/", "/sso/saml2/sp/acs/"] as const;
const SSO_CALLBACK_PATH_PATTERN = /\/sso\/(?:callback|saml2\/callback|saml2\/sp\/acs)\/([^/]+)$/;
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
export function isSsoCallbackPath(pathname: string): boolean {
return SSO_CALLBACK_PATH_SEGMENTS.some((segment) => pathname.includes(segment));
}
export function extractProviderIdFromPathname(pathname: string): string | null {
if (!isSsoCallbackPath(pathname)) {
return null;
}
const match = pathname.match(SSO_CALLBACK_PATH_PATTERN);
function extractProviderIdFromPath(path: string): string | null {
const match = path.match(SSO_CALLBACK_PATH_PATTERN);
return match?.[1] ?? null;
}
export function extractProviderIdFromUrl(url: string): string | null {
try {
const pathname = new URL(url, "http://localhost").pathname;
return extractProviderIdFromPathname(pathname);
return extractProviderIdFromPath(pathname);
} catch {
return null;
}
}
export function isSsoCallbackPath(path?: string | null): boolean {
if (!path) {
return false;
}
return extractProviderIdFromPath(path) !== null;
}
export function isSsoCallbackRequest(ctx?: GenericEndpointContext | null): boolean {
if (!ctx?.request?.url) {
return false;
}
return extractProviderIdFromUrl(ctx.request.url) !== null;
}
export function extractProviderIdFromContext(ctx?: GenericEndpointContext | null) {
if (!ctx) {
return null;

View file

@ -1,9 +1,4 @@
export type LoginErrorCode =
| "ACCOUNT_LINK_REQUIRED"
| "EMAIL_NOT_VERIFIED"
| "INVITE_REQUIRED"
| "BANNED_USER"
| "SSO_LOGIN_FAILED";
import type { LoginErrorCode } from "~/lib/auth-errors";
const INVITE_REQUIRED_ERRORS = new Set([
"Access denied. You must be invited to this organization before you can sign in with SSO.",
@ -38,18 +33,3 @@ export function mapAuthErrorToCode(error: string): LoginErrorCode {
return "SSO_LOGIN_FAILED";
}
export function getLoginErrorDescription(errorCode: LoginErrorCode): string {
switch (errorCode) {
case "ACCOUNT_LINK_REQUIRED":
return "Your account exists but is not linked to this SSO provider. Sign in with username/password first, then enable auto linking in your provider settings or contact your administrator.";
case "EMAIL_NOT_VERIFIED":
return "Your identity provider did not mark your email as verified.";
case "INVITE_REQUIRED":
return "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
case "BANNED_USER":
return "You have been banned from this application. Please contact support if you believe this is an error.";
case "SSO_LOGIN_FAILED":
return "SSO authentication failed. Please try again.";
}
}

View file

@ -13,6 +13,7 @@ declare module "hono" {
role?: string | null | undefined;
};
organizationId: string;
membership: { role: string };
}
}
@ -44,6 +45,7 @@ export const requireAuth = createMiddleware(async (c, next) => {
c.set("user", user);
c.set("organizationId", activeOrganizationId);
c.set("membership", membership);
await withContext({ organizationId: activeOrganizationId, userId: user.id }, async () => {
await next();
@ -55,16 +57,9 @@ export const requireAuth = createMiddleware(async (c, next) => {
* Verifies the user has the required role in the current organization
*/
export const requireOrgAdmin = createMiddleware(async (c, next) => {
const user = c.get("user");
const organizationId = c.get("organizationId");
const { role } = c.get("membership");
const membership = await db.query.member.findFirst({
where: {
AND: [{ userId: user.id }, { organizationId: organizationId }],
},
});
if (!membership || (membership.role !== "owner" && membership.role !== "admin")) {
if (role !== "owner" && role !== "admin") {
return c.json({ message: "Forbidden" }, 403);
}

View file

@ -13,6 +13,7 @@ import {
import { eq, ne, and, count, inArray } from "drizzle-orm";
import type { UserDeletionImpactDto } from "./auth.dto";
import { isReservedSsoProviderId } from "~/server/lib/auth/utils/sso-provider-id";
import { normalizeEmail } from "~/server/lib/auth/utils/sso-context";
export class AuthService {
/**
@ -105,6 +106,45 @@ export class AuthService {
}
}
/**
* 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
*/

View file

@ -15,25 +15,14 @@ import {
type DevPanelDto,
} from "./system.dto";
import { systemService } from "./system.service";
import { requireAuth, requireOrgAdmin } from "../auth/auth.middleware";
import { requireAdmin, requireAuth, requireOrgAdmin } from "../auth/auth.middleware";
import { db } from "../../db/db";
import { usersTable } from "../../db/schema";
import { eq } from "drizzle-orm";
import { verifyUserPassword } from "../auth/helpers";
import { cryptoUtils } from "../../utils/crypto";
import { createMiddleware } from "hono/factory";
import { getOrganizationId } from "~/server/core/request-context";
const requireGlobalAdmin = createMiddleware(async (c, next) => {
const user = c.get("user");
if (!user || user.role !== "admin") {
return c.json({ message: "Forbidden" }, 403);
}
await next();
});
export const systemController = new Hono()
.use(requireAuth)
.get("/info", systemInfoDto, async (c) => {
@ -53,7 +42,7 @@ export const systemController = new Hono()
})
.put(
"/registration-status",
requireGlobalAdmin,
requireAdmin,
setRegistrationStatusDto,
validator("json", registrationStatusBody),
async (c) => {