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,
) {