refactor: user existing check
This commit is contained in:
parent
a30af6026d
commit
efc112820d
4 changed files with 73 additions and 129 deletions
|
|
@ -19,6 +19,7 @@ import { convertLegacyUserOnFirstLogin } from "./auth/middlewares/convert-legacy
|
||||||
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 { isSsoCallbackRequest, requireSsoInvitation } from "./auth/middlewares/require-sso-invitation";
|
||||||
import { ssoTrustedProviderLinkingPlugin } from "./auth/plugins/sso-trusted-provider-linking";
|
import { ssoTrustedProviderLinkingPlugin } from "./auth/plugins/sso-trusted-provider-linking";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
||||||
|
|
||||||
|
|
@ -52,6 +53,7 @@ export const auth = betterAuth({
|
||||||
create: {
|
create: {
|
||||||
before: async (user, ctx) => {
|
before: async (user, ctx) => {
|
||||||
if (isSsoCallbackRequest(ctx)) {
|
if (isSsoCallbackRequest(ctx)) {
|
||||||
|
logger.debug("SSO callback detected, checking for pending invitations for user", user.email);
|
||||||
await requireSsoInvitation(user.email, ctx);
|
await requireSsoInvitation(user.email, ctx);
|
||||||
user.hasDownloadedResticPassword = true;
|
user.hasDownloadedResticPassword = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
import { and, eq, gt, inArray } 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/core";
|
import type { GenericEndpointContext } from "@better-auth/core";
|
||||||
import { db } from "~/server/db/db";
|
import { db } from "~/server/db/db";
|
||||||
import { invitation, member, organization, ssoProvider } 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";
|
||||||
|
|
||||||
async function findMembershipWithOrganization(userId: string, organizationId?: string) {
|
async function findMembershipWithOrganization(userId: string, organizationId?: string) {
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
return db.query.member.findFirst({
|
return db.query.member.findFirst({
|
||||||
where: {
|
where: { AND: [{ userId }, { organizationId }] },
|
||||||
AND: [{ userId }, { organizationId }],
|
|
||||||
},
|
|
||||||
with: { organization: true },
|
with: { organization: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -23,95 +22,52 @@ async function findMembershipWithOrganization(userId: string, organizationId?: s
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findUserById(userId: string) {
|
|
||||||
return db.query.usersTable.findFirst({
|
|
||||||
where: { id: userId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOrgSlug(email: string) {
|
function buildOrgSlug(email: string) {
|
||||||
const [emailPrefix] = email.split("@");
|
const [emailPrefix] = email.split("@");
|
||||||
return `${emailPrefix}-${Math.random().toString(36).slice(-4)}`;
|
return `${emailPrefix}-${Math.random().toString(36).slice(-4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getProviderOrganizationIdsFromContext(ctx: GenericEndpointContext | null) {
|
async function getProviderFromContext(ctx: GenericEndpointContext | null) {
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
return [];
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerId = extractProviderIdFromContext(ctx);
|
const providerId = extractProviderIdFromContext(ctx);
|
||||||
if (!providerId) {
|
if (!providerId) {
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = await db
|
|
||||||
.select({ organizationId: ssoProvider.organizationId })
|
|
||||||
.from(ssoProvider)
|
|
||||||
.where(eq(ssoProvider.providerId, providerId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return provider.map((providerRow) => providerRow.organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserRecord = NonNullable<Awaited<ReturnType<typeof findUserById>>>;
|
|
||||||
|
|
||||||
async function tryCreateInvitedMembership(userId: string, email: string, ctx: GenericEndpointContext | null) {
|
|
||||||
let providerOrganizationIds = await getProviderOrganizationIdsFromContext(ctx);
|
|
||||||
|
|
||||||
if (providerOrganizationIds.length === 0) {
|
|
||||||
const linkedAccounts = await db.query.account.findMany({
|
|
||||||
where: { userId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const linkedSsoProviderIds = linkedAccounts
|
|
||||||
.map((linkedAccount) => linkedAccount.providerId)
|
|
||||||
.filter((providerId) => providerId !== "credential");
|
|
||||||
|
|
||||||
if (linkedSsoProviderIds.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const linkedSsoProviders = await db
|
|
||||||
.select({ organizationId: ssoProvider.organizationId })
|
|
||||||
.from(ssoProvider)
|
|
||||||
.where(inArray(ssoProvider.providerId, linkedSsoProviderIds));
|
|
||||||
|
|
||||||
if (linkedSsoProviders.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
providerOrganizationIds = [...new Set(linkedSsoProviders.map((provider) => provider.organizationId))];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (providerOrganizationIds.length === 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const provider = await db.query.ssoProvider.findFirst({ where: { providerId } });
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryCreateInvitedMembership(userId: string, email: string, ctx: GenericEndpointContext | null) {
|
||||||
|
logger.debug("Checking for pending invitations for user", userId, email);
|
||||||
|
|
||||||
|
const ssoProvider = await getProviderFromContext(ctx);
|
||||||
|
if (!ssoProvider) {
|
||||||
|
logger.debug("No SSO provider found in context, skipping invitation check");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
logger.debug("SSO provider found in context, checking for linked accounts", ssoProvider.providerId);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
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"),
|
|
||||||
inArray(invitation.organizationId, providerOrganizationIds),
|
|
||||||
gt(invitation.expiresAt, now),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const matchingInvitation = pendingInvitations.find(
|
const pendingInvitation = await db.query.invitation.findFirst({
|
||||||
(invitationCandidate) => normalizeEmail(invitationCandidate.email) === email,
|
where: {
|
||||||
);
|
AND: [
|
||||||
|
{ status: "pending" },
|
||||||
|
{ organizationId: ssoProvider.organizationId },
|
||||||
|
{ expiresAt: { gt: now } },
|
||||||
|
{ email: normalizeEmail(email) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
columns: { id: true, email: true, role: true, organizationId: true },
|
||||||
|
});
|
||||||
|
|
||||||
if (!matchingInvitation) {
|
if (!pendingInvitation) {
|
||||||
throw new APIError("FORBIDDEN", {
|
logger.debug("No pending invitation found for user", { email });
|
||||||
message: "SSO sign-in is invite-only for this organization",
|
throw new APIError("FORBIDDEN", { message: "SSO sign-in is invite-only for this organization" });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db.transaction((tx) => {
|
db.transaction((tx) => {
|
||||||
|
|
@ -119,16 +75,20 @@ async function tryCreateInvitedMembership(userId: string, email: string, ctx: Ge
|
||||||
.values({
|
.values({
|
||||||
id: Bun.randomUUIDv7(),
|
id: Bun.randomUUIDv7(),
|
||||||
userId,
|
userId,
|
||||||
role: matchingInvitation.role as "member",
|
role: pendingInvitation.role as "member",
|
||||||
organizationId: matchingInvitation.organizationId,
|
organizationId: pendingInvitation.organizationId,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
tx.update(invitation).set({ status: "accepted" }).where(eq(invitation.id, matchingInvitation.id)).run();
|
tx.update(invitation).set({ status: "accepted" }).where(eq(invitation.id, pendingInvitation.id)).run();
|
||||||
});
|
});
|
||||||
|
|
||||||
const invitedMembership = await findMembershipWithOrganization(userId, matchingInvitation.organizationId);
|
const invitedMembership = await findMembershipWithOrganization(userId, pendingInvitation.organizationId);
|
||||||
|
logger.debug("Created organization membership from invitation", {
|
||||||
|
userId,
|
||||||
|
organizationId: pendingInvitation.organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
if (!invitedMembership) {
|
if (!invitedMembership) {
|
||||||
throw new Error("Failed to create invited organization membership");
|
throw new Error("Failed to create invited organization membership");
|
||||||
|
|
@ -137,7 +97,8 @@ async function tryCreateInvitedMembership(userId: string, email: string, ctx: Ge
|
||||||
return invitedMembership;
|
return invitedMembership;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createDefaultOrganizationMembership(user: UserRecord) {
|
async function createDefaultOrganizationMembership(user: User) {
|
||||||
|
logger.debug("Creating default organization for user", { userId: user.id });
|
||||||
const resticPassword = cryptoUtils.generateResticPassword();
|
const resticPassword = cryptoUtils.generateResticPassword();
|
||||||
const metadata = { resticPassword: await cryptoUtils.sealSecret(resticPassword) };
|
const metadata = { resticPassword: await cryptoUtils.sealSecret(resticPassword) };
|
||||||
|
|
||||||
|
|
@ -168,16 +129,17 @@ async function createDefaultOrganizationMembership(user: UserRecord) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUserDefaultOrg(userId: string, ctx: GenericEndpointContext | null) {
|
export async function createUserDefaultOrg(userId: string, ctx: GenericEndpointContext | null) {
|
||||||
const existingMembership = await findMembershipWithOrganization(userId);
|
const user = await db.query.usersTable.findFirst({ where: { id: userId } });
|
||||||
if (existingMembership) {
|
|
||||||
return existingMembership;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await findUserById(userId);
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedError("User not found");
|
throw new UnauthorizedError("User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingMembership = await findMembershipWithOrganization(user.id);
|
||||||
|
if (existingMembership) {
|
||||||
|
logger.debug("User already has an organization membership, skipping default org creation", { userId });
|
||||||
|
return existingMembership;
|
||||||
|
}
|
||||||
|
|
||||||
const invitedMembership = await tryCreateInvitedMembership(userId, normalizeEmail(user.email), ctx);
|
const invitedMembership = await tryCreateInvitedMembership(userId, normalizeEmail(user.email), ctx);
|
||||||
if (invitedMembership) {
|
if (invitedMembership) {
|
||||||
return invitedMembership;
|
return invitedMembership;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { and, eq, gt } from "drizzle-orm";
|
|
||||||
import { APIError } from "better-auth/api";
|
import { APIError } from "better-auth/api";
|
||||||
import type { GenericEndpointContext } from "@better-auth/core";
|
import type { GenericEndpointContext } from "@better-auth/core";
|
||||||
import { db } from "~/server/db/db";
|
import { db } from "~/server/db/db";
|
||||||
import { invitation, ssoProvider } from "~/server/db/schema";
|
|
||||||
import { logger } from "~/server/utils/logger";
|
import { logger } from "~/server/utils/logger";
|
||||||
import { extractProviderIdFromContext, normalizeEmail } from "../utils/sso-context";
|
import { extractProviderIdFromContext, normalizeEmail } from "../utils/sso-context";
|
||||||
|
|
||||||
|
|
@ -16,53 +14,35 @@ export function isSsoCallbackRequest(ctx: GenericEndpointContext | null) {
|
||||||
|
|
||||||
export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpointContext | null) => {
|
export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpointContext | null) => {
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new APIError("BAD_REQUEST", {
|
throw new APIError("BAD_REQUEST", { message: "Missing SSO context" });
|
||||||
message: "Missing SSO context",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerId = extractProviderIdFromContext(ctx);
|
const providerId = extractProviderIdFromContext(ctx);
|
||||||
if (!providerId) {
|
if (!providerId) {
|
||||||
throw new APIError("BAD_REQUEST", {
|
throw new APIError("BAD_REQUEST", { message: "Missing providerId in context" });
|
||||||
message: "Missing providerId in context",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = await db
|
const provider = await db.query.ssoProvider.findFirst({ where: { providerId } });
|
||||||
.select({ organizationId: ssoProvider.organizationId })
|
if (!provider) {
|
||||||
.from(ssoProvider)
|
throw new APIError("NOT_FOUND", { message: "SSO provider not found" });
|
||||||
.where(eq(ssoProvider.providerId, providerId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (provider.length === 0) {
|
|
||||||
throw new APIError("NOT_FOUND", {
|
|
||||||
message: "SSO provider not found",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedEmail = normalizeEmail(userEmail);
|
const normalizedEmail = normalizeEmail(userEmail);
|
||||||
const now = new Date();
|
logger.debug("Checking for pending invitations", normalizedEmail, provider.organizationId);
|
||||||
|
|
||||||
logger.debug(
|
const pendingInvitation = await db.query.invitation.findFirst({
|
||||||
"Checking for pending invitations for email %s in organization %s",
|
where: {
|
||||||
normalizedEmail,
|
AND: [
|
||||||
provider[0].organizationId,
|
{ organizationId: provider.organizationId },
|
||||||
);
|
{ status: "pending" },
|
||||||
|
{ expiresAt: { gt: new Date() } },
|
||||||
|
{ email: normalizedEmail },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
columns: { id: true, email: true },
|
||||||
|
});
|
||||||
|
|
||||||
const pendingInvitations = await db
|
logger.debug("Pending invitation result", { pendingInvitation });
|
||||||
.select({ id: invitation.id, email: invitation.email })
|
|
||||||
.from(invitation)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(invitation.organizationId, provider[0].organizationId),
|
|
||||||
eq(invitation.status, "pending"),
|
|
||||||
gt(invitation.expiresAt, now),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const pendingInvitation = pendingInvitations.find(
|
|
||||||
(invitationCandidate) => normalizeEmail(invitationCandidate.email) === normalizedEmail,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!pendingInvitation) {
|
if (!pendingInvitation) {
|
||||||
throw new APIError("FORBIDDEN", {
|
throw new APIError("FORBIDDEN", {
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ export async function trustSsoProviderForLinking(ctx: GenericEndpointContext): P
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = await db.query.ssoProvider.findFirst({ where: { providerId } });
|
const provider = await db.query.ssoProvider.findFirst({ where: { providerId, autoLinkMatchingEmails: true } });
|
||||||
if (!provider || !provider.autoLinkMatchingEmails) {
|
if (!provider) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue