feat: migrate to idv

This commit is contained in:
2025-11-24 22:41:03 +01:00
parent 6e8539a8d1
commit 3611e23869
9 changed files with 72 additions and 72 deletions

View File

@@ -4,7 +4,7 @@ import { redirect, RedirectType } from 'next/navigation';
export default async function Layout({ children }: { children: React.ReactNode }) {
const { user } = await validateRequest();
if (!user) {
return redirect('/auth/slack');
return redirect('/auth/hackclub');
}
if (!user.hasOnboarded) {
return redirect(`/onboarding`, RedirectType.push);

View File

@@ -13,7 +13,7 @@ export default async function ChannelSettingsPage({
const { user } = await validateRequest();
if (!user) {
redirect('/auth/slack');
redirect('/auth/hackclub');
}
const channel = await prisma.channel.findUnique({

View File

@@ -1,6 +1,6 @@
import { slack, lucia } from '@hctv/auth';
import { hackClub, lucia, HCID_TOKEN_URL, HCID_USER_INFO_URL } from '@hctv/auth';
import { cookies as nextCookies } from 'next/headers';
import { decodeIdToken, OAuth2RequestError } from 'arctic';
import { OAuth2RequestError } from 'arctic';
import { generateIdFromEntropySize } from 'lucia';
import { prisma } from '@hctv/db';
import { getRedisConnection } from '@hctv/db';
@@ -10,7 +10,7 @@ export async function GET(request: Request): Promise<Response> {
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;
const storedState = cookies.get("hackclub_oauth_state")?.value ?? null;
if (!code || !state || !storedState || state !== storedState) {
console.log('invalid state stuff');
return new Response(null, {
@@ -19,22 +19,33 @@ export async function GET(request: Request): Promise<Response> {
}
try {
const tokens = await slack.validateAuthorizationCode(code);
const accessToken = tokens.accessToken()
const slackUserResponse = await fetch('https://slack.com/api/openid.connect.userInfo', {
const tokens = await hackClub.validateAuthorizationCode(HCID_TOKEN_URL, code, null);
const accessToken = tokens.accessToken();
const userResponse = await fetch(HCID_USER_INFO_URL, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
const slackUser: SlackUserInfo = await slackUserResponse.json();
const userResult: HackClubUserResponse = await userResponse.json();
const identity = userResult.identity;
const slackId = identity.slack_id || identity.id;
const existingUser = await prisma.user.findFirst({
where: {
slack_id: slackUser.sub,
slack_id: slackId,
},
});
if (existingUser) {
// Update email if it's missing or changed
if (existingUser.email !== identity.primary_email) {
await prisma.user.update({
where: { id: existingUser.id },
data: { email: identity.primary_email },
});
}
const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
await getRedisConnection().set(`sessions:${session.id}`, '');
@@ -52,8 +63,9 @@ export async function GET(request: Request): Promise<Response> {
await prisma.user.create({
data: {
id: userId,
slack_id: slackUser.sub,
pfpUrl: `https://cachet.dunkirk.sh/users/${slackUser.sub}/r`,
slack_id: slackId,
email: identity.primary_email,
pfpUrl: identity.slack_id ? `https://cachet.dunkirk.sh/users/${identity.slack_id}/r` : 'https://github.com/hackclub.png',
hasOnboarded: false,
},
});
@@ -83,40 +95,15 @@ export async function GET(request: Request): Promise<Response> {
}
}
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;
interface HackClubIdentity {
id: string;
slack_id?: string;
first_name: string;
last_name: string;
primary_email: string;
}
interface HackClubUserResponse {
identity: HackClubIdentity;
}

View File

@@ -1,12 +1,12 @@
import { generateState } from "arctic";
import { slack } from '@hctv/auth';
import { hackClub, HCID_AUTH_URL } from '@hctv/auth';
import { cookies } from "next/headers";
export async function GET(): Promise<Response> {
const state = generateState();
const url = slack.createAuthorizationURL(state, ['openid', 'profile']);
const url = hackClub.createAuthorizationURL(HCID_AUTH_URL, state, ['slack_id', 'verification_status', 'email']);
(await cookies()).set("slack_oauth_state", state, {
(await cookies()).set("hackclub_oauth_state", state, {
path: "/",
secure: process.env.NODE_ENV === "production",
httpOnly: true,

View File

@@ -97,7 +97,7 @@ export default function Navbar(props: Props) {
</DropdownMenuContent>
</DropdownMenu>
) : (
<Link href="/auth/slack">
<Link href="/auth/hackclub">
<Button variant="outline" size="sm" className="gap-1 md:gap-2 text-xs md:text-sm">
<Slack className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Sign in</span>

View File

@@ -1,10 +1,18 @@
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
import { Lucia } from 'lucia';
import { prisma } from '@hctv/db';
import { Slack } from 'arctic';
import { OAuth2Client } 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 hackClub = new OAuth2Client(
process.env.HCID_CLIENT!,
process.env.HCID_SECRET!,
process.env.HCID_REDIRECT_URI!
);
export const HCID_AUTH_URL = "https://identity.hackclub.com/oauth/authorize";
export const HCID_TOKEN_URL = "https://identity.hackclub.com/oauth/token";
export const HCID_USER_INFO_URL = "https://identity.hackclub.com/api/v1/me";
export const lucia = new Lucia(adapter, {
sessionCookie: {
@@ -19,6 +27,7 @@ export const lucia = new Lucia(adapter, {
getUserAttributes: (attributes) => {
return {
slack_id: attributes.slack_id,
email: attributes.email,
pfpUrl: attributes.pfpUrl,
hasOnboarded: attributes.hasOnboarded,
personalChannelId: attributes.personalChannelId,
@@ -35,6 +44,7 @@ declare module 'lucia' {
interface DatabaseUserAttributes {
slack_id: string;
email: string | null;
pfpUrl: string;
hasOnboarded: boolean;
personalChannelId: string | null;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "email" TEXT;

View File

@@ -19,6 +19,7 @@ datasource db {
model User {
id String @id @default(cuid())
slack_id String
email String?
pfpUrl String
hasOnboarded Boolean @default(false)

View File

@@ -6,12 +6,12 @@ use std::io::Write;
use std::env;
#[derive(Debug, Deserialize)]
struct SlackEmojiResponse {
emoji: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[allow(dead_code)]
error: Option<String>,
struct SlackEmojiItem {
name: String,
#[serde(rename = "imageUrl")]
image_url: String,
}
#[derive(Debug, Deserialize)]
struct DefaultEmojiResponse {
emoji: HashMap<String, String>,
@@ -35,41 +35,41 @@ async fn main() {
let mut slack_emojis = slack_request()
.await
.expect("Failed to fetch slack_emojis from Slack API");
println!("{:?} slack_emojis fetched", slack_emojis.emoji.len());
println!("{:?} slack_emojis fetched", slack_emojis.len());
if args.len() > 1 && args[1] == "default" {
let default_emojis = default_request()
.await
.expect("Failed to fetch default_emojis from GitHub");
println!("{:?} default_emojis fetched", default_emojis.emoji.len());
slack_emojis.emoji.extend(default_emojis.emoji);
slack_emojis.extend(default_emojis.emoji);
}
let mut file = File::create("emojis.json").expect("failed to create file for some reason");
let json_data =
serde_json::to_string(&slack_emojis.emoji).expect("failed to serialize emojis wtf");
serde_json::to_string(&slack_emojis).expect("failed to serialize emojis wtf");
file
.write_all(json_data.as_bytes())
.expect("failed to write emojis to file");
println!("saved :yay:");
}
async fn slack_request() -> Result<SlackEmojiResponse, Box<dyn std::error::Error>> {
async fn slack_request() -> Result<HashMap<String, String>, Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let res = client
.get("https://slack.com/api/emoji.list")
.header(
"Authorization",
format!(
"Bearer {}",
std::env::var("SLACK_TOKEN").expect("SLACK_TOKEN not set")
),
)
.get("https://cachet.dunkirk.sh/emojis")
.send()
.await;
match res {
Ok(response) => Ok(response.json().await?),
Ok(response) => {
let items: Vec<SlackEmojiItem> = response.json().await?;
let map: HashMap<String, String> = items
.into_iter()
.map(|item| (item.name, item.image_url))
.collect();
Ok(map)
}
Err(err) => {
eprintln!("Error: {:?}", err);
Err(Box::new(err))