refactor: sso utils (#608)
This commit is contained in:
parent
10303b6596
commit
a24c6d825e
14 changed files with 159 additions and 221 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
24
app/lib/auth-errors.ts
Normal 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.";
|
||||
}
|
||||
}
|
||||
|
|
@ -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>>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue