mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
checkProjectAccess skipped the workspace membership lookup whenever access was already granted another way — a PUBLIC project, or an existing project membership — and only forced it for intents other than 'view'. The workspace role does not just gate entry though; it feeds canEdit/isWorkspaceMember. So a workspace ADMIN who is not the project owner lost canEdit the moment a project was switched to public: the Add Version item on video cards, plus canManageTags/canResolveComments/canRequestApproval/canShareVideo on the video page, all disappeared, and the approvals endpoint returned 403. The underlying POST routes use intent 'manage' and would still have accepted the write, so the permission was there — only the UI was gone. Resolve the workspace role for every signed-in non-owner. Owners already pass every check on their own, so theirs is still only loaded when they mutate.
403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
import NextAuth from 'next-auth';
|
|
import Credentials from 'next-auth/providers/credentials';
|
|
import Google from 'next-auth/providers/google';
|
|
import GitHub from 'next-auth/providers/github';
|
|
import { PrismaAdapter } from '@auth/prisma-adapter';
|
|
import bcrypt from 'bcryptjs';
|
|
import { db } from '@/lib/db';
|
|
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
|
|
import { hasBillingAccess } from '@/lib/billing';
|
|
import { isInviteCodeRequired } from '@/lib/feature-flags';
|
|
import { isEmailVerificationEnabled } from '@/lib/email-verification';
|
|
|
|
// Dummy hash for timing-safe comparison when user doesn't exist
|
|
// This prevents user enumeration via timing attacks
|
|
const DUMMY_HASH = '$2a$12$000000000000000000000uGG3k3xK2CVTxXrT7VW2sGd1XrY6Ky';
|
|
|
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
|
// PrismaAdapter handles OAuth account linking and user creation in DB.
|
|
// JWT strategy is still used for sessions (no DB sessions table needed).
|
|
adapter: PrismaAdapter(db),
|
|
providers: [
|
|
Credentials({
|
|
name: 'credentials',
|
|
credentials: {
|
|
email: { label: 'Email', type: 'email' },
|
|
password: { label: 'Password', type: 'password' },
|
|
},
|
|
async authorize(credentials) {
|
|
if (!credentials?.email || !credentials?.password) {
|
|
return null;
|
|
}
|
|
|
|
const email = credentials.email as string;
|
|
const password = credentials.password as string;
|
|
|
|
// Find user by email
|
|
const user = await db.user.findUnique({
|
|
where: { email: email.toLowerCase() },
|
|
});
|
|
|
|
// Always perform bcrypt comparison to prevent timing attacks
|
|
// If user doesn't exist, compare against dummy hash
|
|
const hashToCompare = user?.password || DUMMY_HASH;
|
|
const isValidPassword = await bcrypt.compare(password, hashToCompare);
|
|
|
|
// Only return user if they exist AND password is valid
|
|
if (!user || !user.password || !isValidPassword) {
|
|
return null;
|
|
}
|
|
|
|
// Block sign-in when email verification is required but not yet completed
|
|
if (isEmailVerificationEnabled() && !user.emailVerified) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: user.id,
|
|
name: user.name,
|
|
email: user.email,
|
|
image: user.image,
|
|
};
|
|
},
|
|
}),
|
|
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
|
? [
|
|
Google({
|
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
}),
|
|
]
|
|
: []),
|
|
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
|
|
? [
|
|
{
|
|
...GitHub({
|
|
clientId: process.env.GITHUB_CLIENT_ID,
|
|
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
|
}),
|
|
// GitHub sends iss=https://github.com/login/oauth in callbacks (RFC 9207).
|
|
// Auth.js v5 beta defaults to "https://authjs.dev" for OAuth providers, causing
|
|
// a mismatch. Setting the correct issuer here fixes the CallbackRouteError.
|
|
issuer: 'https://github.com/login/oauth',
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
session: {
|
|
strategy: 'jwt',
|
|
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
},
|
|
pages: {
|
|
signIn: '/login',
|
|
signOut: '/signout',
|
|
},
|
|
callbacks: {
|
|
async signIn({ account, profile }) {
|
|
// Credentials sign-in is handled by the authorize() function above
|
|
if (account?.provider === 'credentials') return true;
|
|
|
|
// Reject OAuth sign-ins where the provider email is not verified.
|
|
// Google always sets email_verified: true. GitHub does not guarantee it.
|
|
if (profile && profile.email_verified === false) {
|
|
return '/login?error=OAuthEmailNotVerified';
|
|
}
|
|
|
|
// OAuth sign-in: allow existing OAuth accounts regardless of invite setting
|
|
if (account?.providerAccountId && account?.provider) {
|
|
const existingAccount = await db.account.findUnique({
|
|
where: {
|
|
provider_providerAccountId: {
|
|
provider: account.provider,
|
|
providerAccountId: account.providerAccountId,
|
|
},
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (existingAccount) return true;
|
|
}
|
|
|
|
// New OAuth user: block when invite-only mode is active
|
|
if (isInviteCodeRequired()) {
|
|
return '/login?error=RegistrationClosed';
|
|
}
|
|
|
|
return true;
|
|
},
|
|
async session({ session, token }) {
|
|
if (token.sub && session.user) {
|
|
session.user.id = token.sub;
|
|
session.user.name = token.name || null;
|
|
session.user.isAdmin = token.isAdmin as boolean;
|
|
}
|
|
return session;
|
|
},
|
|
async jwt({ token, user }) {
|
|
if (user) {
|
|
token.sub = user.id;
|
|
token.name = user.name;
|
|
token.email = user.email; // explicitly ensure email is in the token
|
|
}
|
|
|
|
// Check if user is admin based on emails list on EVERY request to ensure env changes are picked up
|
|
if (token.email) {
|
|
const adminEmails = process.env.ADMIN_EMAILS
|
|
? process.env.ADMIN_EMAILS.split(',').map((e: string) => e.trim().toLowerCase())
|
|
: [];
|
|
token.isAdmin = adminEmails.includes((token.email as string).toLowerCase());
|
|
}
|
|
|
|
return token;
|
|
},
|
|
},
|
|
});
|
|
|
|
type ProjectAccessIntent = 'view' | 'manage' | 'delete';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fast-path: pre-fetch access data alongside any existing DB query so that
|
|
// computeProjectAccess() can resolve the result with zero extra round-trips.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Prisma include fragment to attach to any project fetch. */
|
|
export function projectAccessInclude(userId: string | undefined) {
|
|
return {
|
|
workspace: {
|
|
select: {
|
|
id: true,
|
|
ownerId: true,
|
|
owner: {
|
|
select: {
|
|
subscriptionStatus: true,
|
|
trialEndsAt: true,
|
|
stripeCurrentPeriodEnd: true,
|
|
billingAccessEndedAt: true,
|
|
},
|
|
},
|
|
members: userId
|
|
? {
|
|
where: { userId },
|
|
take: 1,
|
|
orderBy: { createdAt: 'asc' as const },
|
|
select: { role: true },
|
|
}
|
|
: { take: 0, select: { role: true } },
|
|
},
|
|
},
|
|
members: userId
|
|
? {
|
|
where: { userId },
|
|
take: 1,
|
|
orderBy: { createdAt: 'asc' as const },
|
|
select: { role: true },
|
|
}
|
|
: { take: 0, select: { role: true } },
|
|
};
|
|
}
|
|
|
|
type ProjectAccessIncludes = ReturnType<typeof projectAccessInclude>;
|
|
type WorkspaceForAccess = ProjectAccessIncludes['workspace']['select'] extends object
|
|
? {
|
|
id: string;
|
|
ownerId: string;
|
|
owner: Parameters<typeof hasBillingAccess>[0] | null;
|
|
members: Array<{ role: WorkspaceMemberRole }>;
|
|
}
|
|
: never;
|
|
|
|
export type EnrichedProjectForAccess = {
|
|
id: string;
|
|
ownerId: string;
|
|
workspaceId: string;
|
|
visibility: string;
|
|
workspace: WorkspaceForAccess;
|
|
members: Array<{ role: ProjectMemberRole }>;
|
|
};
|
|
|
|
/**
|
|
* Pure access computation — no DB queries.
|
|
* Use after fetching a project with `projectAccessInclude(userId)`.
|
|
*/
|
|
export function computeProjectAccess(
|
|
project: EnrichedProjectForAccess,
|
|
userId: string | undefined
|
|
) {
|
|
const isOwner = userId === project.ownerId;
|
|
const isPublic = project.visibility === 'PUBLIC';
|
|
|
|
const projectMember = project.members[0] ?? null;
|
|
const isProjectMember = !!projectMember;
|
|
const isProjectAdmin = projectMember?.role === ProjectMemberRole.ADMIN;
|
|
|
|
const workspaceOwnerBillingAccess = project.workspace.owner
|
|
? hasBillingAccess(project.workspace.owner)
|
|
: false;
|
|
|
|
let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null;
|
|
if (userId === project.workspace.ownerId) {
|
|
workspaceRole = 'OWNER';
|
|
} else {
|
|
const wsMember = project.workspace.members[0] ?? null;
|
|
if (wsMember) workspaceRole = wsMember.role;
|
|
}
|
|
|
|
const isWorkspaceMember = !!workspaceRole;
|
|
const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER';
|
|
|
|
const hasAccess =
|
|
workspaceOwnerBillingAccess && (isOwner || isProjectMember || isPublic || isWorkspaceMember);
|
|
const canEdit = workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin);
|
|
const canDelete = workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER');
|
|
|
|
return {
|
|
isOwner,
|
|
isProjectMember,
|
|
isProjectAdmin,
|
|
isWorkspaceMember,
|
|
isWorkspaceAdmin,
|
|
hasAccess,
|
|
canEdit,
|
|
canDelete,
|
|
ownerBillingActive: workspaceOwnerBillingAccess,
|
|
};
|
|
}
|
|
|
|
// Helper to check project access including workspace membership
|
|
export async function checkProjectAccess(
|
|
project: { id: string; ownerId: string; workspaceId: string; visibility: string },
|
|
userId: string | undefined,
|
|
options?: { intent?: ProjectAccessIntent }
|
|
) {
|
|
const intent = options?.intent ?? 'view';
|
|
const isOwner = userId === project.ownerId;
|
|
const isPublic = project.visibility === 'PUBLIC';
|
|
|
|
// Get project membership
|
|
const projectMember = userId
|
|
? await db.projectMember.findUnique({
|
|
where: { projectId_userId: { projectId: project.id, userId } },
|
|
})
|
|
: null;
|
|
const isProjectMember = !!projectMember;
|
|
const isProjectAdmin = projectMember?.role === ProjectMemberRole.ADMIN;
|
|
|
|
// The workspace role decides `canEdit`/`isWorkspaceMember`, not just whether the viewer
|
|
// gets in at all, so it has to be resolved for every signed-in non-owner. Skipping it
|
|
// once access was already granted some other way (public project, or an existing project
|
|
// membership) silently downgraded workspace admins to read-only on `intent: 'view'` —
|
|
// the intent pages and GET routes use to decide which actions to render.
|
|
// Owners pass every check on their own; resolve their role only when they mutate.
|
|
const shouldLoadWorkspaceRole = !!userId && (!isOwner || intent !== 'view');
|
|
|
|
// Check workspace membership/role
|
|
let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null;
|
|
let workspaceOwnerBillingAccess = false;
|
|
if (shouldLoadWorkspaceRole && userId) {
|
|
const [wsMember, wsOwner] = await Promise.all([
|
|
db.workspaceMember.findUnique({
|
|
where: { workspaceId_userId: { workspaceId: project.workspaceId, userId } },
|
|
}),
|
|
db.workspace.findUnique({
|
|
where: { id: project.workspaceId },
|
|
select: {
|
|
ownerId: true,
|
|
owner: {
|
|
select: {
|
|
subscriptionStatus: true,
|
|
trialEndsAt: true,
|
|
stripeCurrentPeriodEnd: true,
|
|
billingAccessEndedAt: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
if (wsOwner?.ownerId === userId) {
|
|
workspaceRole = 'OWNER';
|
|
} else if (wsMember) {
|
|
workspaceRole = wsMember.role;
|
|
}
|
|
|
|
if (wsOwner?.owner) {
|
|
workspaceOwnerBillingAccess = hasBillingAccess(wsOwner.owner);
|
|
}
|
|
} else {
|
|
const wsOwner = await db.workspace.findUnique({
|
|
where: { id: project.workspaceId },
|
|
select: {
|
|
owner: {
|
|
select: {
|
|
subscriptionStatus: true,
|
|
trialEndsAt: true,
|
|
stripeCurrentPeriodEnd: true,
|
|
billingAccessEndedAt: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
workspaceOwnerBillingAccess = wsOwner?.owner ? hasBillingAccess(wsOwner.owner) : false;
|
|
}
|
|
const isWorkspaceMember = !!workspaceRole;
|
|
const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER';
|
|
|
|
const hasAccess =
|
|
workspaceOwnerBillingAccess && (isOwner || isProjectMember || isPublic || isWorkspaceMember);
|
|
const canEdit = workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin);
|
|
const canDelete = workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER');
|
|
|
|
return {
|
|
isOwner,
|
|
isProjectMember,
|
|
isProjectAdmin,
|
|
isWorkspaceMember,
|
|
isWorkspaceAdmin,
|
|
hasAccess,
|
|
canEdit,
|
|
canDelete,
|
|
ownerBillingActive: workspaceOwnerBillingAccess,
|
|
};
|
|
}
|
|
|
|
// Helper to check workspace access
|
|
export async function checkWorkspaceAccess(
|
|
workspace: { id: string; ownerId: string },
|
|
userId: string | undefined
|
|
) {
|
|
const isOwner = userId === workspace.ownerId;
|
|
|
|
// Get workspace membership
|
|
const workspaceMember = userId
|
|
? await db.workspaceMember.findUnique({
|
|
where: { workspaceId_userId: { workspaceId: workspace.id, userId } },
|
|
})
|
|
: null;
|
|
const isMember = !!workspaceMember;
|
|
const isAdmin = workspaceMember?.role === WorkspaceMemberRole.ADMIN;
|
|
|
|
const owner = await db.user.findUnique({
|
|
where: { id: workspace.ownerId },
|
|
select: {
|
|
subscriptionStatus: true,
|
|
trialEndsAt: true,
|
|
stripeCurrentPeriodEnd: true,
|
|
billingAccessEndedAt: true,
|
|
},
|
|
});
|
|
const ownerBillingActive = owner ? hasBillingAccess(owner) : false;
|
|
|
|
const hasAccess = ownerBillingActive && (isOwner || isMember);
|
|
const canEdit = ownerBillingActive && (isOwner || isAdmin);
|
|
const canDelete = ownerBillingActive && isOwner;
|
|
|
|
return {
|
|
isOwner,
|
|
isMember,
|
|
isAdmin,
|
|
hasAccess,
|
|
canEdit,
|
|
canDelete,
|
|
ownerBillingActive,
|
|
};
|
|
}
|