feature(auth): replace clerk.com with custom auth (#103)
* feature(auth): replace clerk.com with custom auth * minor fixes * remove notification preferences * decrease live events interval fix(api): cookies.. # Conflicts: # .gitignore # apps/api/src/index.ts # apps/dashboard/src/app/providers.tsx # packages/trpc/src/trpc.ts
This commit is contained in:
committed by
Carl-Gerhard Lindesvärd
parent
f28802b1c2
commit
d31d9924a5
@@ -11,3 +11,15 @@ export const TRPCNotFoundError = (message: string) =>
|
||||
code: 'NOT_FOUND',
|
||||
message,
|
||||
});
|
||||
|
||||
export const TRPCInternalServerError = (message: string) =>
|
||||
new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message,
|
||||
});
|
||||
|
||||
export const TRPCBadRequestError = (message: string) =>
|
||||
new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { authRouter } from './routers/auth';
|
||||
import { chartRouter } from './routers/chart';
|
||||
import { clientRouter } from './routers/client';
|
||||
import { dashboardRouter } from './routers/dashboard';
|
||||
@@ -36,6 +37,7 @@ export const appRouter = createTRPCRouter({
|
||||
ticket: ticketRouter,
|
||||
notification: notificationRouter,
|
||||
integration: integrationRouter,
|
||||
auth: authRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
279
packages/trpc/src/routers/auth.ts
Normal file
279
packages/trpc/src/routers/auth.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
Arctic,
|
||||
createSession,
|
||||
deleteSessionTokenCookie,
|
||||
generateSessionToken,
|
||||
github,
|
||||
google,
|
||||
hashPassword,
|
||||
invalidateSession,
|
||||
setSessionTokenCookie,
|
||||
verifyPasswordHash,
|
||||
} from '@openpanel/auth';
|
||||
import { generateSecureId } from '@openpanel/common/server/id';
|
||||
import { connectUserToOrganization, db, getUserAccount } from '@openpanel/db';
|
||||
import { sendEmail } from '@openpanel/email';
|
||||
import {
|
||||
zRequestResetPassword,
|
||||
zResetPassword,
|
||||
zSignInEmail,
|
||||
zSignUpEmail,
|
||||
} from '@openpanel/validation';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { z } from 'zod';
|
||||
import { TRPCAccessError, TRPCNotFoundError } from '../errors';
|
||||
import {
|
||||
createTRPCRouter,
|
||||
publicProcedure,
|
||||
rateLimitMiddleware,
|
||||
} from '../trpc';
|
||||
|
||||
const zProvider = z.enum(['email', 'google', 'github']);
|
||||
|
||||
export const authRouter = createTRPCRouter({
|
||||
signOut: publicProcedure.mutation(async ({ ctx }) => {
|
||||
deleteSessionTokenCookie(ctx.setCookie);
|
||||
if (ctx.session?.session?.id) {
|
||||
await invalidateSession(ctx.session.session.id);
|
||||
}
|
||||
}),
|
||||
signInOAuth: publicProcedure
|
||||
.input(z.object({ provider: zProvider, inviteId: z.string().nullish() }))
|
||||
.mutation(({ input, ctx }) => {
|
||||
const { provider } = input;
|
||||
|
||||
if (provider === 'github') {
|
||||
const state = Arctic.generateState();
|
||||
const url = github.createAuthorizationURL(state, [
|
||||
'user:email',
|
||||
'user:read',
|
||||
]);
|
||||
|
||||
// if we have an inviteId we want to add it to the redirect url
|
||||
// so we have this information in the callback url later
|
||||
if (input.inviteId) {
|
||||
const redirectUri = url.searchParams.get('redirect_uri');
|
||||
if (redirectUri) {
|
||||
const redirectUrl = new URL(redirectUri);
|
||||
redirectUrl.searchParams.set('inviteId', input.inviteId);
|
||||
url.searchParams.set('redirect_uri', redirectUrl.toString());
|
||||
}
|
||||
}
|
||||
|
||||
ctx.setCookie('github_oauth_state', state, {
|
||||
maxAge: 60 * 10,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'github',
|
||||
url: url.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
const state = Arctic.generateState();
|
||||
const codeVerifier = Arctic.generateCodeVerifier();
|
||||
const url = google.createAuthorizationURL(state, codeVerifier, [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
]);
|
||||
|
||||
ctx.setCookie('google_oauth_state', state, {
|
||||
maxAge: 60 * 10,
|
||||
});
|
||||
ctx.setCookie('google_code_verifier', codeVerifier, {
|
||||
maxAge: 60 * 10,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'google',
|
||||
url: url.toString(),
|
||||
};
|
||||
}),
|
||||
signUpEmail: publicProcedure
|
||||
.input(zSignUpEmail)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const provider = 'email';
|
||||
const user = await getUserAccount({
|
||||
email: input.email,
|
||||
provider,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
throw TRPCNotFoundError('User already exists');
|
||||
}
|
||||
|
||||
const createdUser = await db.user.create({
|
||||
data: {
|
||||
id: generateSecureId('user'),
|
||||
email: input.email,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
accounts: {
|
||||
create: {
|
||||
provider,
|
||||
password: await hashPassword(input.password),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (input.inviteId) {
|
||||
await connectUserToOrganization({
|
||||
user: createdUser,
|
||||
inviteId: input.inviteId,
|
||||
});
|
||||
}
|
||||
|
||||
const token = generateSessionToken();
|
||||
const session = await createSession(token, createdUser.id);
|
||||
|
||||
setSessionTokenCookie(ctx.setCookie, token, session.expiresAt);
|
||||
return session;
|
||||
}),
|
||||
signInEmail: publicProcedure
|
||||
.use(
|
||||
rateLimitMiddleware({
|
||||
max: 3,
|
||||
windowMs: 30_000,
|
||||
}),
|
||||
)
|
||||
.input(zSignInEmail)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const provider = 'email';
|
||||
|
||||
const user = await getUserAccount({
|
||||
email: input.email,
|
||||
provider,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw TRPCNotFoundError('User does not exists');
|
||||
}
|
||||
|
||||
if (provider === 'email') {
|
||||
// if the password starts with $argon2 we use the new password hashing
|
||||
// otherwise its legacy from Clerk which uses bcrypt
|
||||
// TODO: Remove this after 2025-06-01 (half year from now)
|
||||
if (user.account.password?.startsWith('$argon2')) {
|
||||
const validPassword = await verifyPasswordHash(
|
||||
user.account.password ?? '',
|
||||
input.password,
|
||||
);
|
||||
|
||||
if (!validPassword) {
|
||||
throw TRPCAccessError('Incorrect email or password');
|
||||
}
|
||||
} else {
|
||||
const validPassword = await bcrypt.compare(
|
||||
input.password,
|
||||
user.account.password ?? '',
|
||||
);
|
||||
|
||||
if (!validPassword) {
|
||||
throw TRPCAccessError('Incorrect email or password');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const token = generateSessionToken();
|
||||
const session = await createSession(token, user.id);
|
||||
|
||||
setSessionTokenCookie(ctx.setCookie, token, session.expiresAt);
|
||||
return {
|
||||
type: 'email',
|
||||
};
|
||||
}),
|
||||
|
||||
resetPassword: publicProcedure
|
||||
.input(zResetPassword)
|
||||
.use(
|
||||
rateLimitMiddleware({
|
||||
max: 3,
|
||||
windowMs: 60_000,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { token, password } = input;
|
||||
|
||||
const resetPassword = await db.resetPassword.findUnique({
|
||||
where: {
|
||||
id: token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!resetPassword) {
|
||||
throw TRPCNotFoundError('Reset password not found');
|
||||
}
|
||||
|
||||
if (resetPassword.expiresAt < new Date()) {
|
||||
throw TRPCNotFoundError('Reset password expired');
|
||||
}
|
||||
|
||||
await db.account.update({
|
||||
where: { id: resetPassword.accountId },
|
||||
data: {
|
||||
password: await hashPassword(password),
|
||||
},
|
||||
});
|
||||
|
||||
await db.resetPassword.delete({
|
||||
where: { id: token },
|
||||
});
|
||||
|
||||
return true;
|
||||
}),
|
||||
|
||||
requestResetPassword: publicProcedure
|
||||
.use(
|
||||
rateLimitMiddleware({
|
||||
max: 3,
|
||||
windowMs: 60_000,
|
||||
}),
|
||||
)
|
||||
.input(zRequestResetPassword)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const user = await getUserAccount({
|
||||
email: input.email,
|
||||
provider: 'email',
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!user.account.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await db.resetPassword.deleteMany({
|
||||
where: {
|
||||
accountId: user.account.id,
|
||||
},
|
||||
});
|
||||
|
||||
const token = generateSecureId('pw');
|
||||
// expires in 10 minutes
|
||||
const expiresAt = new Date(Date.now() + 1000 * 60 * 10);
|
||||
|
||||
await db.resetPassword.create({
|
||||
data: {
|
||||
id: token,
|
||||
expiresAt,
|
||||
accountId: user.account.id,
|
||||
},
|
||||
});
|
||||
|
||||
await sendEmail('reset-password', {
|
||||
to: input.email,
|
||||
data: {
|
||||
url: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/reset-password?token=${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}),
|
||||
session: publicProcedure.query(async ({ ctx }) => {
|
||||
return ctx.session;
|
||||
}),
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { clerkClient } from '@clerk/fastify';
|
||||
import { pathOr } from 'ramda';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { db } from '@openpanel/db';
|
||||
import { connectUserToOrganization, db } from '@openpanel/db';
|
||||
import { zInviteUser } from '@openpanel/validation';
|
||||
|
||||
import { generateSecureId } from '@openpanel/common/server/id';
|
||||
import { sendEmail } from '@openpanel/email';
|
||||
import { addDays } from 'date-fns';
|
||||
import { getOrganizationAccess } from '../access';
|
||||
import { TRPCAccessError } from '../errors';
|
||||
import { TRPCAccessError, TRPCBadRequestError } from '../errors';
|
||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||
|
||||
export const organizationRouter = createTRPCRouter({
|
||||
@@ -59,68 +60,101 @@ export const organizationRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
let invitationId: string | undefined;
|
||||
const alreadyMember = await db.member.findFirst({
|
||||
where: {
|
||||
userId: userExists?.id,
|
||||
organizationId: input.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userExists) {
|
||||
const ticket = await clerkClient.invitations.createInvitation({
|
||||
emailAddress: email,
|
||||
notify: true,
|
||||
});
|
||||
invitationId = ticket.id;
|
||||
if (alreadyMember && userExists) {
|
||||
throw TRPCBadRequestError(
|
||||
'User is already a member of the organization',
|
||||
);
|
||||
}
|
||||
|
||||
return db.member.create({
|
||||
const alreadyInvited = await db.invite.findFirst({
|
||||
where: {
|
||||
email,
|
||||
organizationId: input.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (alreadyInvited) {
|
||||
throw TRPCBadRequestError(
|
||||
'User is already invited to the organization',
|
||||
);
|
||||
}
|
||||
|
||||
const invite = await db.invite.create({
|
||||
data: {
|
||||
id: generateSecureId('invite'),
|
||||
email,
|
||||
organizationId: input.organizationId,
|
||||
role: input.role,
|
||||
invitedById: ctx.session.userId,
|
||||
meta: {
|
||||
access: input.access,
|
||||
invitationId,
|
||||
createdById: ctx.session.userId,
|
||||
projectAccess: input.access || [],
|
||||
expiresAt: addDays(new Date(), 3),
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (userExists) {
|
||||
const member = await connectUserToOrganization({
|
||||
user: userExists,
|
||||
inviteId: invite.id,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'is_member',
|
||||
member,
|
||||
};
|
||||
}
|
||||
|
||||
await sendEmail('invite', {
|
||||
to: email,
|
||||
data: {
|
||||
url: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/onboarding?inviteId=${invite.id}`,
|
||||
organizationName: invite.organization.name,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'is_invited',
|
||||
invite,
|
||||
};
|
||||
}),
|
||||
revokeInvite: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
memberId: z.string(),
|
||||
inviteId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const member = await db.member.findUniqueOrThrow({
|
||||
const invite = await db.invite.findUniqueOrThrow({
|
||||
where: {
|
||||
id: input.memberId,
|
||||
id: input.inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
const access = await getOrganizationAccess({
|
||||
userId: ctx.session.userId,
|
||||
organizationId: member.organizationId,
|
||||
organizationId: invite.organizationId,
|
||||
});
|
||||
|
||||
if (access?.role !== 'org:admin') {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
|
||||
const invitationId = pathOr<string | undefined>(
|
||||
undefined,
|
||||
['meta', 'invitationId'],
|
||||
member,
|
||||
);
|
||||
|
||||
if (invitationId) {
|
||||
await clerkClient.invitations
|
||||
.revokeInvitation(invitationId)
|
||||
.catch(() => {
|
||||
// Ignore errors, this will throw if the invitation is already accepted
|
||||
});
|
||||
}
|
||||
|
||||
return db.member.delete({
|
||||
return db.invite.delete({
|
||||
where: {
|
||||
id: input.memberId,
|
||||
id: input.inviteId,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { clerkClient } from '@clerk/fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { db } from '@openpanel/db';
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
update: protectedProcedure
|
||||
@@ -14,23 +13,15 @@ export const userRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const [updatedUser] = await Promise.all([
|
||||
db.user.update({
|
||||
where: {
|
||||
id: ctx.session.userId,
|
||||
},
|
||||
data: {
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
},
|
||||
}),
|
||||
clerkClient.users.updateUser(ctx.session.userId, {
|
||||
return db.user.update({
|
||||
where: {
|
||||
id: ctx.session.userId,
|
||||
},
|
||||
data: {
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
}),
|
||||
]);
|
||||
|
||||
return updatedUser;
|
||||
},
|
||||
});
|
||||
}),
|
||||
debugPostCookie: protectedProcedure
|
||||
.input(
|
||||
|
||||
@@ -1,24 +1,54 @@
|
||||
import { getAuth } from '@clerk/fastify';
|
||||
import { TRPCError, initTRPC } from '@trpc/server';
|
||||
import type { CreateFastifyContextOptions } from '@trpc/server/adapters/fastify';
|
||||
import { has } from 'ramda';
|
||||
import superjson from 'superjson';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { COOKIE_OPTIONS, validateSessionToken } from '@openpanel/auth';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import type { ISetCookie } from '@openpanel/validation';
|
||||
import {
|
||||
createTrpcRedisLimiter,
|
||||
defaultFingerPrint,
|
||||
} from '@trpc-limiter/redis';
|
||||
import { getOrganizationAccessCached, getProjectAccessCached } from './access';
|
||||
import { TRPCAccessError } from './errors';
|
||||
|
||||
export function createContext({ req, res }: CreateFastifyContextOptions) {
|
||||
export const rateLimitMiddleware = ({
|
||||
max,
|
||||
windowMs,
|
||||
}: {
|
||||
max: number;
|
||||
windowMs: number;
|
||||
}) =>
|
||||
createTrpcRedisLimiter<typeof t>({
|
||||
fingerprint: (ctx) => defaultFingerPrint(ctx.req),
|
||||
message: (hitInfo) =>
|
||||
`Too many requests, please try again later. ${hitInfo}`,
|
||||
max,
|
||||
windowMs,
|
||||
redisClient: getRedisCache(),
|
||||
});
|
||||
|
||||
export async function createContext({ req, res }: CreateFastifyContextOptions) {
|
||||
const setCookie: ISetCookie = (key, value, options) => {
|
||||
// @ts-ignore
|
||||
res.setCookie(key, value, {
|
||||
maxAge: options.maxAge,
|
||||
...COOKIE_OPTIONS,
|
||||
});
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const session = await validateSessionToken(req.cookies?.session);
|
||||
|
||||
return {
|
||||
req,
|
||||
res,
|
||||
session: getAuth(req),
|
||||
session,
|
||||
// we do not get types for `setCookie` from fastify
|
||||
// so define it here and be safe in routers
|
||||
setCookie: (key: string, value: string, options: any) => {
|
||||
// @ts-ignore
|
||||
res.setCookie(key, value, options);
|
||||
},
|
||||
setCookie,
|
||||
};
|
||||
}
|
||||
export type Context = Awaited<ReturnType<typeof createContext>>;
|
||||
|
||||
Reference in New Issue
Block a user