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 = import { LOGIN_ERROR_CODES, type LoginErrorCode } from "~/lib/auth-errors";
| "ACCOUNT_LINK_REQUIRED"
| "EMAIL_NOT_VERIFIED"
| "INVITE_REQUIRED"
| "BANNED_USER"
| "SSO_LOGIN_FAILED";
const VALID_ERROR_CODES: Set<LoginErrorCode> = new Set([ export type { LoginErrorCode } from "~/lib/auth-errors";
"ACCOUNT_LINK_REQUIRED", export { getLoginErrorDescription } from "~/lib/auth-errors";
"EMAIL_NOT_VERIFIED",
"INVITE_REQUIRED", const VALID_ERROR_CODES = new Set<LoginErrorCode>(LOGIN_ERROR_CODES);
"BANNED_USER",
"SSO_LOGIN_FAILED",
]);
export function decodeLoginError(error?: string): LoginErrorCode | null { export function decodeLoginError(error?: string): LoginErrorCode | null {
if (!error) { if (!error) {
@ -26,20 +18,3 @@ export function decodeLoginError(error?: string): LoginErrorCode | null {
return 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 [isVerifying2FA, setIsVerifying2FA] = useState(false);
const [trustDevice, setTrustDevice] = useState(false); const [trustDevice, setTrustDevice] = useState(false);
const errorCode = decodeLoginError(error); const errorCode = decodeLoginError(error);
const errorDescription = getLoginErrorDescription(errorCode); const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
const { data: ssoProviders } = useSuspenseQuery({ const { data: ssoProviders } = useSuspenseQuery({
...getPublicSsoProvidersOptions(), ...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 { validateSsoCallbackUrls } from "./auth/middlewares/validate-sso-callback-urls";
import { validateSsoProviderId } from "./auth/middlewares/validate-sso-provider-id"; import { validateSsoProviderId } from "./auth/middlewares/validate-sso-provider-id";
import { createUserDefaultOrg } from "./auth/helpers/create-default-org"; 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 { 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";
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>; 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 { UnauthorizedError } from "http-errors-enhanced";
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 { 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 { cryptoUtils } from "~/server/utils/crypto";
import { APIError } from "better-auth"; import { APIError } from "better-auth";
import { extractProviderIdFromContext, normalizeEmail } from "../utils/sso-context"; 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 memberships = await db const membership = await db.query.member.findFirst({
.select() where: organizationId ? { AND: [{ userId }, { organizationId }] } : { userId },
.from(member) with: {
.where( organization: true,
organizationId },
? and(eq(member.userId, userId), eq(member.organizationId, organizationId)) });
: eq(member.userId, userId),
)
.limit(1);
const membership = memberships[0]; return membership ?? null;
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 };
} }
function buildOrgSlug(email: string) { export function buildOrgSlug(email: string) {
const [emailPrefix] = email.split("@"); const [emailPrefix] = email.split("@");
const sanitized = emailPrefix const sanitized = emailPrefix
.toLowerCase() .toLowerCase()
@ -46,39 +31,45 @@ function buildOrgSlug(email: string) {
return `${safePrefix}-${Math.random().toString(36).slice(-4)}`; return `${safePrefix}-${Math.random().toString(36).slice(-4)}`;
} }
async function tryCreateInvitedMembership(userId: string, email: string, ctx: GenericEndpointContext | null) { export type DefaultOrganizationData = {
logger.debug("Checking for pending invitations for user", userId); id: string;
name: string;
slug: string;
createdAt: Date;
metadata: {
resticPassword: string;
};
};
const providerId = extractProviderIdFromContext(ctx); export async function buildDefaultOrganizationData(
const ssoProviders = await db.select().from(ssoProvider).where(eq(ssoProvider.providerId, providerId)).limit(1); user: Pick<User, "name" | "email">,
const ssoProviderRecord = ssoProviders[0]; 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) { if (!ssoProviderRecord) {
logger.debug("No SSO provider found in context, skipping invitation check");
return null; 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 const pendingInvitation = await authService.getPendingInvitation(ssoProviderRecord.organizationId, email);
.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];
if (!pendingInvitation) { if (!pendingInvitation) {
logger.debug("No pending invitation found for user"); 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) { 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 resticPassword = cryptoUtils.generateResticPassword(); const organizationData = await buildDefaultOrganizationData(user);
const metadata = { resticPassword: await cryptoUtils.sealSecret(resticPassword) };
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const orgId = Bun.randomUUIDv7(); tx.insert(organization).values(organizationData).run();
const slug = buildOrgSlug(user.email);
tx.insert(organization)
.values({
name: `${user.name}'s Workspace`,
slug,
id: orgId,
createdAt: new Date(),
metadata,
})
.run();
tx.insert(member) tx.insert(member)
.values({ .values({
id: Bun.randomUUIDv7(), id: Bun.randomUUIDv7(),
userId: user.id, userId: user.id,
role: "owner", role: "owner",
organizationId: orgId, organizationId: organizationData.id,
createdAt: new Date(), createdAt: new Date(),
}) })
.run(); .run();
@ -144,19 +123,14 @@ async function createDefaultOrganizationMembership(user: User) {
} }
export async function createUserDefaultOrg(userId: string, ctx: GenericEndpointContext | null) { 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 = await db.query.usersTable.findFirst({ where: { id: userId } });
const user = users[0];
if (!user) { if (!user) {
throw new UnauthorizedError("User not found"); throw new UnauthorizedError("User not found");
} }
const providerId = extractProviderIdFromContext(ctx); const providerId = extractProviderIdFromContext(ctx);
if (providerId) { if (providerId) {
const [ssoProviderRecord] = await db const ssoProviderRecord = await authService.getSsoProviderById(providerId);
.select()
.from(ssoProvider)
.where(eq(ssoProvider.providerId, providerId))
.limit(1);
if (ssoProviderRecord) { if (ssoProviderRecord) {
// If the user is already a member of this SSO org (accepted a past invitation), let them through // 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. // 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) { if (invitedMembership) {
return invitedMembership; return invitedMembership;
} }

View file

@ -2,7 +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, 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 { function createMockContext(path: string, params: Record<string, string> = {}): GenericEndpointContext {
return { return {

View file

@ -4,8 +4,8 @@ import { db } from "~/server/db/db";
import { account, usersTable, member, organization } from "~/server/db/schema"; import { account, usersTable, member, organization } from "~/server/db/schema";
import type { AuthMiddlewareContext } from "~/server/lib/auth"; import type { AuthMiddlewareContext } from "~/server/lib/auth";
import { UnauthorizedError } from "http-errors-enhanced"; import { UnauthorizedError } from "http-errors-enhanced";
import { cryptoUtils } from "~/server/utils/crypto";
import { normalizeUsername } from "~/lib/username"; import { normalizeUsername } from "~/lib/username";
import { buildDefaultOrganizationData, type DefaultOrganizationData } from "../helpers/create-default-org";
export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext) => { export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext) => {
const { path, body } = ctx; const { path, body } = ctx;
@ -40,27 +40,10 @@ export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext)
const passwordHash = await hashPassword(body.password); const passwordHash = await hashPassword(body.password);
let newOrganizationData: { let newOrganizationData: DefaultOrganizationData | null = null;
id: string;
name: string;
slug: string;
createdAt: Date;
metadata: {
resticPassword: string;
};
} | null = null;
if (!oldMembership?.organization) { if (!oldMembership?.organization) {
const resticPassword = cryptoUtils.generateResticPassword(); newOrganizationData = await buildDefaultOrganizationData(legacyUser);
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),
},
};
} }
db.transaction((tx) => { db.transaction((tx) => {

View file

@ -1,16 +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 { db } from "~/server/db/db";
import { logger } from "~/server/utils/logger"; import { logger } from "~/server/utils/logger";
import { extractProviderIdFromContext, extractProviderIdFromUrl, normalizeEmail } from "../utils/sso-context"; import { extractProviderIdFromContext } from "../utils/sso-context";
import { authService } from "~/server/modules/auth/auth.service";
export function isSsoCallbackRequest(ctx: GenericEndpointContext | null) {
if (!ctx?.request?.url) {
return false;
}
return extractProviderIdFromUrl(ctx.request.url) !== null;
}
export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpointContext | null) => { export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpointContext | null) => {
if (!ctx) { if (!ctx) {
@ -22,25 +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 db.query.ssoProvider.findFirst({ where: { providerId } }); const provider = await authService.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" });
} }
const normalizedEmail = normalizeEmail(userEmail);
logger.debug("Checking for pending invitations", { organizationId: provider.organizationId }); logger.debug("Checking for pending invitations", { organizationId: provider.organizationId });
const pendingInvitation = await db.query.invitation.findFirst({ const pendingInvitation = await authService.getPendingInvitation(provider.organizationId, userEmail);
where: {
AND: [
{ organizationId: provider.organizationId },
{ status: "pending" },
{ expiresAt: { gt: new Date() } },
{ email: normalizedEmail },
],
},
columns: { id: true },
});
logger.debug("Pending invitation result", { found: !!pendingInvitation, invitationId: pendingInvitation?.id }); 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"; import { extractProviderIdFromUrl } from "../utils/sso-context";
export async function resolveTrustedProvidersForRequest(request?: Request): Promise<string[]> { export async function resolveTrustedProvidersForRequest(request?: Request): Promise<string[]> {
@ -11,21 +11,10 @@ export async function resolveTrustedProvidersForRequest(request?: Request): Prom
return []; return [];
} }
const provider = await db.query.ssoProvider.findFirst({ const provider = await authService.getSsoProviderById(providerId);
columns: { organizationId: true },
where: { providerId },
});
if (!provider) { if (!provider) {
return []; return [];
} }
const autoLinkingProviders = await db.query.ssoProvider.findMany({ return authService.getAutoLinkingSsoProviderIds(provider.organizationId);
columns: { providerId: true },
where: {
organizationId: provider.organizationId,
autoLinkMatchingEmails: true,
},
});
return autoLinkingProviders.map((entry) => entry.providerId);
} }

View file

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

View file

@ -1,9 +1,4 @@
export type LoginErrorCode = import type { LoginErrorCode } from "~/lib/auth-errors";
| "ACCOUNT_LINK_REQUIRED"
| "EMAIL_NOT_VERIFIED"
| "INVITE_REQUIRED"
| "BANNED_USER"
| "SSO_LOGIN_FAILED";
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.",
@ -38,18 +33,3 @@ export function mapAuthErrorToCode(error: string): LoginErrorCode {
return "SSO_LOGIN_FAILED"; 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; role?: string | null | undefined;
}; };
organizationId: string; organizationId: string;
membership: { role: string };
} }
} }
@ -44,6 +45,7 @@ export const requireAuth = createMiddleware(async (c, next) => {
c.set("user", user); c.set("user", user);
c.set("organizationId", activeOrganizationId); c.set("organizationId", activeOrganizationId);
c.set("membership", membership);
await withContext({ organizationId: activeOrganizationId, userId: user.id }, async () => { await withContext({ organizationId: activeOrganizationId, userId: user.id }, async () => {
await next(); await next();
@ -55,16 +57,9 @@ export const requireAuth = createMiddleware(async (c, next) => {
* Verifies the user has the required role in the current organization * Verifies the user has the required role in the current organization
*/ */
export const requireOrgAdmin = createMiddleware(async (c, next) => { export const requireOrgAdmin = createMiddleware(async (c, next) => {
const user = c.get("user"); const { role } = c.get("membership");
const organizationId = c.get("organizationId");
const membership = await db.query.member.findFirst({ if (role !== "owner" && role !== "admin") {
where: {
AND: [{ userId: user.id }, { organizationId: organizationId }],
},
});
if (!membership || (membership.role !== "owner" && membership.role !== "admin")) {
return c.json({ message: "Forbidden" }, 403); return c.json({ message: "Forbidden" }, 403);
} }

View file

@ -13,6 +13,7 @@ import {
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 { isReservedSsoProviderId } from "~/server/lib/auth/utils/sso-provider-id";
import { normalizeEmail } from "~/server/lib/auth/utils/sso-context";
export class AuthService { 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 * Delete an SSO provider and its associated accounts
*/ */

View file

@ -15,25 +15,14 @@ import {
type DevPanelDto, type DevPanelDto,
} from "./system.dto"; } from "./system.dto";
import { systemService } from "./system.service"; 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 { db } from "../../db/db";
import { usersTable } from "../../db/schema"; import { usersTable } from "../../db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { verifyUserPassword } from "../auth/helpers"; import { verifyUserPassword } from "../auth/helpers";
import { cryptoUtils } from "../../utils/crypto"; import { cryptoUtils } from "../../utils/crypto";
import { createMiddleware } from "hono/factory";
import { getOrganizationId } from "~/server/core/request-context"; 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() export const systemController = new Hono()
.use(requireAuth) .use(requireAuth)
.get("/info", systemInfoDto, async (c) => { .get("/info", systemInfoDto, async (c) => {
@ -53,7 +42,7 @@ export const systemController = new Hono()
}) })
.put( .put(
"/registration-status", "/registration-status",
requireGlobalAdmin, requireAdmin,
setRegistrationStatusDto, setRegistrationStatusDto,
validator("json", registrationStatusBody), validator("json", registrationStatusBody),
async (c) => { async (c) => {