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:
Carl-Gerhard Lindesvärd
2024-12-18 21:30:39 +01:00
committed by Carl-Gerhard Lindesvärd
parent f28802b1c2
commit d31d9924a5
151 changed files with 18484 additions and 12853 deletions

View File

@@ -1,4 +1,3 @@
import { validateClerkJwt } from '@/utils/auth';
import type { FastifyReply, FastifyRequest } from 'fastify';
import superjson from 'superjson';
import type * as WebSocket from 'ws';
@@ -124,18 +123,24 @@ export async function wsProjectEvents(
}>,
) {
const { params, query } = req;
const { token } = query;
const type = query.type || 'saved';
const subscribeToEvent = `event:${type}`;
if (!['saved', 'received'].includes(type)) {
connection.socket.send('Invalid type');
connection.socket.close();
return;
}
const subscribeToEvent = `event:${type}`;
const decoded = validateClerkJwt(token);
const userId = decoded?.sub;
const userId = req.session?.userId;
if (!userId) {
connection.socket.send('No active session');
connection.socket.close();
return;
}
const access = await getProjectAccess({
userId: userId!,
userId,
projectId: params.projectId,
});
@@ -185,18 +190,18 @@ export async function wsProjectNotifications(
}>,
) {
const { params, query } = req;
const userId = req.session?.userId;
if (!query.token) {
connection.socket.send('No token provided');
if (!userId) {
connection.socket.send('No active session');
connection.socket.close();
return;
}
const subscribeToEvent = 'notification';
const decoded = validateClerkJwt(query.token);
const userId = decoded?.sub;
const access = await getProjectAccess({
userId: userId!,
userId,
projectId: params.projectId,
});

View File

@@ -0,0 +1,359 @@
import {
Arctic,
type OAuth2Tokens,
createSession,
generateSessionToken,
github,
google,
setSessionTokenCookie,
} from '@openpanel/auth';
import { type User, connectUserToOrganization, db } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
async function getGithubEmail(githubAccessToken: string) {
const emailListRequest = new Request('https://api.github.com/user/emails');
emailListRequest.headers.set('Authorization', `Bearer ${githubAccessToken}`);
const emailListResponse = await fetch(emailListRequest);
const emailListResult: unknown = await emailListResponse.json();
if (!Array.isArray(emailListResult) || emailListResult.length < 1) {
return null;
}
let email: string | null = null;
for (const emailRecord of emailListResult) {
const emailParser = z.object({
primary: z.boolean(),
verified: z.boolean(),
email: z.string(),
});
const emailResult = emailParser.safeParse(emailRecord);
if (!emailResult.success) {
continue;
}
if (emailResult.data.primary && emailResult.data.verified) {
email = emailResult.data.email;
}
}
return email;
}
export async function githubCallback(
req: FastifyRequest<{
Querystring: {
code: string;
state: string;
};
}>,
reply: FastifyReply,
) {
const schema = z.object({
code: z.string(),
state: z.string(),
inviteId: z.string().nullish(),
});
const query = schema.safeParse(req.query);
if (!query.success) {
return reply.status(400).send(query.error.message);
}
const { code, state, inviteId } = query.data;
const storedState = req.cookies.github_oauth_state ?? null;
if (code === null || state === null || storedState === null) {
return new Response('Please restart the process.', {
status: 400,
});
}
if (state !== storedState) {
return new Response('Please restart the process.', {
status: 400,
});
}
let tokens: OAuth2Tokens;
try {
tokens = await github.validateAuthorizationCode(code);
} catch {
// Invalid code or client credentials
return new Response('Please restart the process.', {
status: 400,
});
}
const githubAccessToken = tokens.accessToken();
const userRequest = new Request('https://api.github.com/user');
userRequest.headers.set('Authorization', `Bearer ${githubAccessToken}`);
const userResponse = await fetch(userRequest);
const userSchema = z.object({
id: z.number(),
login: z.string(),
name: z.string(),
});
const userJson = await userResponse.json();
const userResult = userSchema.safeParse(userJson);
if (!userResult.success) {
return reply.status(400).send(userResult.error.message);
}
const githubUserId = userResult.data.id;
const email = await getGithubEmail(githubAccessToken);
const existingUser = await db.account.findFirst({
where: {
OR: [
{
provider: 'github',
providerId: String(githubUserId),
},
{
provider: 'oauth',
user: {
email: email ?? '',
},
},
],
},
});
if (existingUser !== null) {
const sessionToken = generateSessionToken();
const session = await createSession(sessionToken, existingUser.userId);
if (existingUser.provider === 'oauth') {
await db.account.update({
where: {
id: existingUser.id,
},
data: {
provider: 'github',
providerId: String(githubUserId),
},
});
} else if (existingUser.provider !== 'github') {
await db.account.create({
data: {
provider: 'github',
providerId: String(githubUserId),
user: {
connect: {
id: existingUser.userId,
},
},
},
});
}
setSessionTokenCookie(
(...args) => reply.setCookie(...args),
sessionToken,
session.expiresAt,
);
return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
}
if (email === null) {
return reply.status(400).send('Please verify your GitHub email address.');
}
// (githubUserId, email, username);
const user = await await db.user.create({
data: {
email,
firstName: userResult.data.name,
accounts: {
create: {
provider: 'github',
providerId: String(githubUserId),
},
},
},
});
if (inviteId) {
try {
await connectUserToOrganization({ user, inviteId });
} catch (error) {
req.log.error(
error instanceof Error
? error.message
: 'Unknown error connecting user to projects',
{
inviteId,
email: user.email,
error,
},
);
}
}
const sessionToken = generateSessionToken();
const session = await createSession(sessionToken, user.id);
setSessionTokenCookie(
(...args) => reply.setCookie(...args),
sessionToken,
session.expiresAt,
);
return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
}
export async function googleCallback(
req: FastifyRequest<{
Querystring: {
code: string;
state: string;
};
}>,
reply: FastifyReply,
) {
const schema = z.object({
code: z.string(),
state: z.string(),
inviteId: z.string().nullish(),
});
const query = schema.safeParse(req.query);
if (!query.success) {
return reply.status(400).send(query.error.message);
}
const { code, state, inviteId } = query.data;
const storedState = req.cookies.google_oauth_state ?? null;
const codeVerifier = req.cookies.google_code_verifier ?? null;
if (
code === null ||
state === null ||
storedState === null ||
codeVerifier === null
) {
return reply.status(400).send('Please restart the process.');
}
if (state !== storedState) {
return reply.status(400).send('Please restart the process.');
}
let tokens: OAuth2Tokens;
try {
tokens = await google.validateAuthorizationCode(code, codeVerifier);
} catch {
return reply.status(400).send('Please restart the process.');
}
const claims = Arctic.decodeIdToken(tokens.idToken());
const claimsParser = z.object({
sub: z.string(),
given_name: z.string(),
family_name: z.string(),
picture: z.string(),
email: z.string(),
});
const claimsResult = claimsParser.safeParse(claims);
if (!claimsResult.success) {
return reply.status(400).send(claimsResult.error.message);
}
const { sub: googleId, given_name, family_name, email } = claimsResult.data;
const existingAccount = await db.account.findFirst({
where: {
OR: [
{
provider: 'google',
providerId: googleId,
},
{
provider: 'oauth',
user: {
email,
},
},
],
},
});
if (existingAccount !== null) {
const sessionToken = generateSessionToken();
const session = await createSession(sessionToken, existingAccount.userId);
if (existingAccount.provider === 'oauth') {
await db.account.update({
where: {
id: existingAccount.id,
},
data: {
provider: 'google',
providerId: googleId,
},
});
} else if (existingAccount.provider !== 'google') {
await db.account.create({
data: {
provider: 'google',
providerId: googleId,
user: {
connect: {
id: existingAccount.userId,
},
},
},
});
}
setSessionTokenCookie(
(...args) => reply.setCookie(...args),
sessionToken,
session.expiresAt,
);
return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
}
const user = await db.user.upsert({
where: {
email,
},
update: {
firstName: given_name,
lastName: family_name,
},
create: {
email,
firstName: given_name,
lastName: family_name,
accounts: {
create: {
provider: 'google',
providerId: googleId,
},
},
},
});
if (inviteId) {
try {
await connectUserToOrganization({ user, inviteId });
} catch (error) {
req.log.error(
error instanceof Error
? error.message
: 'Unknown error connecting user to projects',
{
inviteId,
email: user.email,
error,
},
);
}
}
const sessionToken = generateSessionToken();
const session = await createSession(sessionToken, user.id);
setSessionTokenCookie(
(...args) => reply.setCookie(...args),
sessionToken,
session.expiresAt,
);
return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
}

View File

@@ -1,164 +1,14 @@
import fs from 'node:fs';
import path from 'node:path';
import type { WebhookEvent } from '@clerk/fastify';
import { AccessLevel, db } from '@openpanel/db';
import { db } from '@openpanel/db';
import {
sendSlackNotification,
slackInstaller,
} from '@openpanel/integrations/src/slack';
import { getRedisPub } from '@openpanel/redis';
import { zSlackAuthResponse } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { pathOr } from 'ramda';
import { Webhook } from 'svix';
import { z } from 'zod';
if (!process.env.CLERK_SIGNING_SECRET) {
throw new Error('CLERK_SIGNING_SECRET is required');
}
const wh = new Webhook(process.env.CLERK_SIGNING_SECRET);
function verify(body: any, headers: FastifyRequest['headers']) {
try {
const svix_id = headers['svix-id'] as string;
const svix_timestamp = headers['svix-timestamp'] as string;
const svix_signature = headers['svix-signature'] as string;
wh.verify(JSON.stringify(body), {
'svix-id': svix_id,
'svix-timestamp': svix_timestamp,
'svix-signature': svix_signature,
});
return true;
} catch (error) {
return false;
}
}
export async function clerkWebhook(
request: FastifyRequest<{
Body: WebhookEvent;
}>,
reply: FastifyReply,
) {
const payload = request.body;
const verified = verify(payload, request.headers);
if (!verified) {
return reply.send({ message: 'Invalid signature' });
}
if (payload.type === 'user.created') {
const email = payload.data.email_addresses[0]?.email_address;
const emails = payload.data.email_addresses.map((e) => e.email_address);
if (!email) {
return Response.json(
{ message: 'No email address found' },
{ status: 400 },
);
}
const user = await db.user.create({
data: {
id: payload.data.id,
email,
firstName: payload.data.first_name,
lastName: payload.data.last_name,
},
});
const memberships = await db.member.findMany({
where: {
email: {
in: emails,
},
userId: null,
},
});
for (const membership of memberships) {
const access = pathOr<string[]>([], ['meta', 'access'], membership);
await db.$transaction([
// Update the member to link it to the user
// This will remove the item from invitations
db.member.update({
where: {
id: membership.id,
},
data: {
userId: user.id,
},
}),
db.projectAccess.createMany({
data: access
.filter((a) => typeof a === 'string')
.map((projectId) => ({
organizationId: membership.organizationId,
projectId: projectId,
userId: user.id,
level: AccessLevel.read,
})),
}),
]);
}
}
if (payload.type === 'organizationMembership.created') {
const access = payload.data.public_metadata.access;
if (Array.isArray(access)) {
await db.projectAccess.createMany({
data: access
.filter((a): a is string => typeof a === 'string')
.map((projectId) => ({
organizationId: payload.data.organization.slug,
projectId: projectId,
userId: payload.data.public_user_data.user_id,
level: AccessLevel.read,
})),
});
}
}
if (payload.type === 'user.deleted') {
await db.$transaction([
db.user.update({
where: {
id: payload.data.id,
},
data: {
deletedAt: new Date(),
firstName: null,
lastName: null,
},
}),
db.projectAccess.deleteMany({
where: {
userId: payload.data.id,
},
}),
db.member.deleteMany({
where: {
userId: payload.data.id,
},
}),
]);
}
if (payload.type === 'organizationMembership.deleted') {
await db.projectAccess.deleteMany({
where: {
organizationId: payload.data.organization.slug,
userId: payload.data.public_user_data.user_id,
},
});
}
reply.send({ success: true });
}
const paramsSchema = z.object({
code: z.string(),
state: z.string(),
@@ -172,7 +22,7 @@ const metadataSchema = z.object({
export async function slackWebhook(
request: FastifyRequest<{
Querystring: WebhookEvent;
Querystring: unknown;
}>,
reply: FastifyReply,
) {

View File

@@ -1,5 +1,4 @@
import zlib from 'node:zlib';
import { clerkPlugin } from '@clerk/fastify';
import compress from '@fastify/compress';
import cookie from '@fastify/cookie';
import cors, { type FastifyCorsOptions } from '@fastify/cors';
@@ -8,14 +7,18 @@ import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
import Fastify from 'fastify';
import metricsPlugin from 'fastify-metrics';
import { path, pick } from 'ramda';
import { generateId } from '@openpanel/common';
import type { IServiceClient, IServiceClientWithProject } from '@openpanel/db';
import type { IServiceClientWithProject } from '@openpanel/db';
import { getRedisPub } from '@openpanel/redis';
import type { AppRouter } from '@openpanel/trpc';
import { appRouter, createContext } from '@openpanel/trpc';
import {
EMPTY_SESSION,
type SessionValidationResult,
validateSessionToken,
} from '@openpanel/auth';
import sourceMapSupport from 'source-map-support';
import {
healthcheck,
@@ -30,6 +33,7 @@ import exportRouter from './routes/export.router';
import importRouter from './routes/import.router';
import liveRouter from './routes/live.router';
import miscRouter from './routes/misc.router';
import oauthRouter from './routes/oauth-callback.router';
import profileRouter from './routes/profile.router';
import trackRouter from './routes/track.router';
import webhookRouter from './routes/webhook.router';
@@ -42,6 +46,7 @@ declare module 'fastify' {
client: IServiceClientWithProject | null;
clientIp?: string;
timestamp?: number;
session: SessionValidationResult;
}
}
@@ -61,6 +66,31 @@ const startServer = async () => {
: generateId(),
});
fastify.register(cors, () => {
return (
req: FastifyRequest,
callback: (error: Error | null, options: FastifyCorsOptions) => void,
) => {
// TODO: set prefix on dashboard routes
const corsPaths = ['/trpc', '/live', '/webhook', '/oauth', '/misc'];
const isPrivatePath = corsPaths.some((path) =>
req.url.startsWith(path),
);
if (isPrivatePath) {
return callback(null, {
origin: process.env.NEXT_PUBLIC_DASHBOARD_URL,
credentials: true,
});
}
return callback(null, {
origin: '*',
});
};
});
fastify.addHook('preHandler', ipHook);
fastify.addHook('preHandler', timestampHook);
fastify.addHook('onRequest', requestIdHook);
@@ -105,40 +135,34 @@ const startServer = async () => {
},
);
fastify.register(cors, () => {
return (
req: FastifyRequest,
callback: (error: Error | null, options: FastifyCorsOptions) => void,
) => {
// TODO: set prefix on dashboard routes
const corsPaths = ['/trpc', '/live', '/webhook', '/oauth', '/misc'];
const isPrivatePath = corsPaths.some((path) =>
req.url.startsWith(path),
);
if (isPrivatePath) {
return callback(null, {
origin: process.env.NEXT_PUBLIC_DASHBOARD_URL,
credentials: true,
});
}
return callback(null, {
origin: '*',
});
};
});
// Dashboard API
fastify.register((instance, opts, done) => {
fastify.register(cookie, {
secret: 'random', // for cookies signature
instance.register(cookie, {
secret: process.env.COOKIE_SECRET ?? '',
hook: 'onRequest',
parseOptions: {},
});
instance.register(clerkPlugin, {
publishableKey: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
secretKey: process.env.CLERK_SECRET_KEY,
instance.addHook('onRequest', (req, reply, done) => {
if (req.cookies?.session) {
validateSessionToken(req.cookies.session)
.then((session) => {
if (session.session) {
req.session = session;
}
})
.catch(() => {
req.session = EMPTY_SESSION;
})
.finally(() => {
done();
});
} else {
req.session = EMPTY_SESSION;
done();
}
});
instance.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {
@@ -155,22 +179,27 @@ const startServer = async () => {
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
});
instance.register(liveRouter, { prefix: '/live' });
instance.register(webhookRouter, { prefix: '/webhook' });
instance.register(oauthRouter, { prefix: '/oauth' });
instance.register(miscRouter, { prefix: '/misc' });
done();
});
fastify.register(metricsPlugin, { endpoint: '/metrics' });
fastify.register(eventRouter, { prefix: '/event' });
fastify.register(profileRouter, { prefix: '/profile' });
fastify.register(miscRouter, { prefix: '/misc' });
fastify.register(exportRouter, { prefix: '/export' });
fastify.register(webhookRouter, { prefix: '/webhook' });
fastify.register(importRouter, { prefix: '/import' });
fastify.register(trackRouter, { prefix: '/track' });
fastify.get('/', (_request, reply) =>
reply.send({ name: 'openpanel sdk api' }),
);
fastify.get('/healthcheck', healthcheck);
fastify.get('/healthcheck/queue', healthcheckQueue);
// Public API
fastify.register((instance, opts, done) => {
instance.register(metricsPlugin, { endpoint: '/metrics' });
instance.register(eventRouter, { prefix: '/event' });
instance.register(profileRouter, { prefix: '/profile' });
instance.register(exportRouter, { prefix: '/export' });
instance.register(importRouter, { prefix: '/import' });
instance.register(trackRouter, { prefix: '/track' });
instance.get('/healthcheck', healthcheck);
instance.get('/healthcheck/queue', healthcheckQueue);
instance.get('/', (_request, reply) =>
reply.send({ name: 'openpanel sdk api' }),
);
done();
});
fastify.setErrorHandler((error, request, reply) => {
if (error.statusCode === 429) {

View File

@@ -0,0 +1,18 @@
import * as controller from '@/controllers/oauth-callback.controller';
import type { FastifyPluginCallback } from 'fastify';
const router: FastifyPluginCallback = (fastify, opts, done) => {
fastify.route({
method: 'GET',
url: '/github/callback',
handler: controller.githubCallback,
});
fastify.route({
method: 'GET',
url: '/google/callback',
handler: controller.googleCallback,
});
done();
};
export default router;

View File

@@ -2,11 +2,6 @@ import * as controller from '@/controllers/webhook.controller';
import type { FastifyPluginCallback } from 'fastify';
const webhookRouter: FastifyPluginCallback = (fastify, opts, done) => {
fastify.route({
method: 'POST',
url: '/clerk',
handler: controller.clerkWebhook,
});
fastify.route({
method: 'GET',
url: '/slack',

View File

@@ -1,13 +1,8 @@
import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
import jwt from 'jsonwebtoken';
import { verifyPassword } from '@openpanel/common/server';
import type {
Client,
IServiceClient,
IServiceClientWithProject,
} from '@openpanel/db';
import { ClientType, db, getClientByIdCached } from '@openpanel/db';
import type { IServiceClientWithProject } from '@openpanel/db';
import { ClientType, getClientByIdCached } from '@openpanel/db';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type {
IProjectFilterIp,
@@ -187,23 +182,3 @@ export async function validateImportRequest(
return client;
}
export function validateClerkJwt(token?: string) {
if (!token) {
return null;
}
try {
const decoded = jwt.verify(
token,
process.env.CLERK_PUBLIC_PEM_KEY!.replace(/\\n/g, '\n'),
);
if (typeof decoded === 'object') {
return decoded;
}
} catch (e) {
//
}
return null;
}