feat: slack auth

This commit is contained in:
2025-01-15 21:29:17 +01:00
parent 8a61ae6179
commit 5a50ed08c0
12 changed files with 864 additions and 1260 deletions

View File

@@ -1,2 +0,0 @@
# from the dev postgres containeer
DATABASE_URL=postgresql://postgres:dfsjhkdswkjntelsmldbfvsgknl5t@localhost:5555/postgres

2
.gitignore vendored
View File

@@ -35,3 +35,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
certificates

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "docker compose --file dev/docker-compose.yml up -d && next dev",
"dev": "docker compose --file dev/docker-compose.yml up -d && next dev --turbo --experimental-https",
"setup": "docker compose --file dev/docker-compose.yml up -d && prisma migrate deploy",
"build": "prisma generate && next build",
"start": "next start",
@@ -21,6 +21,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-slot": "^1.1.1",
"arctic": "^3.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"lucia": "^3.2.2",

View File

@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `hashed_password` on the `User` table. All the data in the column will be lost.
- Added the required column `slack_id` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "hashed_password",
ADD COLUMN "slack_id" TEXT NOT NULL;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `pfpUrl` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "pfpUrl" TEXT NOT NULL;

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -15,8 +15,9 @@ datasource db {
model User {
id String @id @default(cuid())
slack_id String
pfpUrl String
username String @unique
hashed_password String
sessions Session[]
}

View File

@@ -0,0 +1,118 @@
import { slack, lucia } from '@/lib/auth';
import { cookies as nextCookies } from 'next/headers';
import { decodeIdToken, OAuth2RequestError } from 'arctic';
import { generateIdFromEntropySize } from 'lucia';
import prisma from '@/lib/db';
export async function GET(request: Request): Promise<Response> {
const cookies = await nextCookies();
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const storedState = cookies.get("slack_oauth_state")?.value ?? null;
if (!code || !state || !storedState || state !== storedState) {
return new Response(null, {
status: 400
});
}
try {
const tokens = await slack.validateAuthorizationCode(code);
const accessToken = tokens.accessToken()
const slackUserResponse = await fetch('https://slack.com/api/openid.connect.userInfo', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
const slackUser: SlackUserInfo = await slackUserResponse.json();
const existingUser = await prisma.user.findFirst({
where: {
slack_id: slackUser.sub,
},
});
if (existingUser) {
const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return new Response(null, {
status: 302,
headers: {
Location: '/',
},
});
}
const userId = generateIdFromEntropySize(10);
await prisma.user.create({
data: {
id: userId,
slack_id: slackUser.sub,
username: slackUser.name,
pfpUrl: slackUser.picture,
},
});
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return new Response(null, {
status: 302,
headers: {
Location: '/',
},
});
} catch (e) {
console.error(e);
// the specific error message depends on the provider
if (e instanceof OAuth2RequestError) {
// invalid code
return new Response(null, {
status: 400,
});
}
return new Response(null, {
status: 500,
});
}
}
interface SlackUserInfo {
// OpenID Connect standard fields
ok: boolean;
sub: string;
email: string;
email_verified: boolean;
date_email_verified: number;
name: string;
picture: string;
given_name: string;
family_name: string;
locale: string;
// Slack-specific fields
['https://slack.com/user_id']: string;
['https://slack.com/team_id']: string;
['https://slack.com/team_name']: string;
['https://slack.com/team_domain']: string;
// User image URLs
['https://slack.com/user_image_24']: string;
['https://slack.com/user_image_32']: string;
['https://slack.com/user_image_48']: string;
['https://slack.com/user_image_72']: string;
['https://slack.com/user_image_192']: string;
['https://slack.com/user_image_512']: string;
// Team image URLs
['https://slack.com/team_image_34']?: string;
['https://slack.com/team_image_44']?: string;
['https://slack.com/team_image_68']?: string;
['https://slack.com/team_image_88']?: string;
['https://slack.com/team_image_102']?: string;
['https://slack.com/team_image_132']?: string;
['https://slack.com/team_image_230']?: string;
['https://slack.com/team_image_default']?: boolean;
}

View File

@@ -0,0 +1,18 @@
import { generateState } from "arctic";
import { slack } from "@/lib/auth";
import { cookies } from "next/headers";
export async function GET(): Promise<Response> {
const state = generateState();
const url = slack.createAuthorizationURL(state, ['openid', 'profile']);
(await cookies()).set("slack_oauth_state", state, {
path: "/",
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 60 * 10,
sameSite: "lax"
});
return Response.redirect(url);
}

View File

@@ -1,11 +1,6 @@
'use client';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
/**
* v0 by Vercel.
* @see https://v0.dev/t/igzEEdGqAvH
* Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app
*/
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -21,6 +16,7 @@ import { useSession } from '@/lib/providers/SessionProvider';
import Link from 'next/link';
import MobileNavbarLinks from '../MobileNavbarLinks/MobileNavbarLinks';
import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher';
import { Slack } from 'lucide-react';
export const links = [
{ href: '/', name: 'Home' },
@@ -60,7 +56,7 @@ export default function Navbar() {
<DropdownMenuTrigger asChild className="cursor-pointer">
<Avatar>
{/* TODO: Implement avatar system */}
{/*<AvatarImage src={"https://srizan.dev/pfp.webp"} alt="@srizan" />*/}
<AvatarImage src={user.pfpUrl} alt={`@${user.username}`} />
<AvatarFallback>{user.username}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
@@ -81,8 +77,8 @@ export default function Navbar() {
</DropdownMenu>
</>
) : (
<Link href="/auth/login">
<Button variant="outline">Sign in</Button>
<Link href="/auth/slack">
<Button variant="outline" className='gap-2'><Slack className='w-4 h-4' />Sign in</Button>
</Link>
)}
</nav>

View File

@@ -3,8 +3,10 @@ import { Lucia, Session, User } from 'lucia';
import prisma from '../db';
import { cache } from 'react';
import { cookies } from 'next/headers';
import { Slack } from 'arctic';
const adapter = new PrismaAdapter(prisma.session, prisma.user);
export const slack = new Slack(process.env.SLACK_ID!, process.env.SLACK_SECRET!, process.env.SLACK_REDIRECT_URI!);
export const lucia = new Lucia(adapter, {
sessionCookie: {
@@ -18,7 +20,9 @@ export const lucia = new Lucia(adapter, {
},
getUserAttributes: (attributes) => {
return {
slack_id: attributes.slack_id,
username: attributes.username,
pfpUrl: attributes.pfpUrl,
};
},
});
@@ -58,5 +62,7 @@ declare module 'lucia' {
}
interface DatabaseUserAttributes {
slack_id: string;
username: string;
pfpUrl: string;
}

1938
yarn.lock

File diff suppressed because it is too large Load Diff