From 6c013c49a52a5238e1e822968347e6c23d876fc3 Mon Sep 17 00:00:00 2001 From: DJCrossman Date: Fri, 21 Aug 2026 11:05:58 -0600 Subject: [PATCH] feat: add Slack OAuth admin authentication Adds next-auth v5 (pinned beta) with the Slack OIDC provider. Only administrators of the HackRegina Slack workspace can sign in: the signIn callback verifies the workspace team id and checks is_admin/is_owner via users.info (requires the bot token to have users:read). Sessions are stateless JWTs (12h), so no database is needed. - proxy.ts protects /admin/* and /api/admin/*: pages redirect to /admin/login, APIs get 401 JSON - requireAdmin()/requireAdminOrRedirect() in lib/adminAuth.ts give admin routes and layouts a second, defense-in-depth check - /admin/login renders a Sign in with Slack button and the AccessDenied message for non-admins - /admin is a placeholder shell replaced by the events list in a follow-up PR - Slack requires HTTPS redirect URLs, so local dev uses bun run dev:https Co-Authored-By: Claude Fable 5 --- .env.local.example | 12 ++++++ app/admin/(protected)/layout.tsx | 42 +++++++++++++++++++ app/admin/(protected)/page.tsx | 14 +++++++ app/admin/login/page.tsx | 63 +++++++++++++++++++++++++++++ app/api/auth/[...nextauth]/route.ts | 3 ++ auth.ts | 43 ++++++++++++++++++++ bun.lock | 15 +++++++ lib/adminAuth.ts | 15 +++++++ lib/route.ts | 5 +++ lib/slackAdmin.ts | 23 +++++++++++ package.json | 2 + proxy.ts | 24 +++++++++++ types/next-auth.d.ts | 17 ++++++++ 13 files changed, 278 insertions(+) create mode 100644 app/admin/(protected)/layout.tsx create mode 100644 app/admin/(protected)/page.tsx create mode 100644 app/admin/login/page.tsx create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 auth.ts create mode 100644 lib/adminAuth.ts create mode 100644 lib/slackAdmin.ts create mode 100644 proxy.ts create mode 100644 types/next-auth.d.ts diff --git a/.env.local.example b/.env.local.example index bac5aef..8af19b6 100644 --- a/.env.local.example +++ b/.env.local.example @@ -3,8 +3,20 @@ NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_token_here # Slack API (for community members) SLACK_TOKEN=your_slack_user_token_here +# Bot token also needs the `users:read` scope for the admin dashboard's admin check SLACK_BOT_TOKEN=your_slack_bot_token_here +# Admin dashboard auth (Slack OIDC via next-auth) +# Generate AUTH_SECRET with: openssl rand -base64 32 +AUTH_SECRET=your_auth_secret_here +# Client ID/Secret from the Slack app's Basic Information page +AUTH_SLACK_ID=your_slack_client_id_here +AUTH_SLACK_SECRET=your_slack_client_secret_here +# Slack workspace ID (T...) — only admins of this workspace may sign in +SLACK_TEAM_ID=your_slack_team_id_here +# Local HTTPS dev only (bun run dev:https); unset in production +# AUTH_URL=https://localhost:3000 + # Eventbrite API (for events) EVENTBRITE_TOKEN=your_eventbrite_token_here EVENTBRITE_WEBHOOK_SECRET=your_eventbrite_webhook_secret_here diff --git a/app/admin/(protected)/layout.tsx b/app/admin/(protected)/layout.tsx new file mode 100644 index 0000000..2f5a8d3 --- /dev/null +++ b/app/admin/(protected)/layout.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from 'next'; +import Image from 'next/image'; +import type { ReactNode } from 'react'; +import { signOut } from '@/auth'; +import { Button } from '@/components/ui/button'; +import { requireAdminOrRedirect } from '@/lib/adminAuth'; +import { routes } from '@/lib/route'; + +export const metadata: Metadata = { + robots: { index: false, follow: false }, +}; + +export default async function AdminLayout({ children }: { children: ReactNode }) { + const session = await requireAdminOrRedirect(); + const { name, image } = session.user; + + const signOutAction = async () => { + 'use server'; + await signOut({ redirectTo: routes.admin.login() }); + }; + + return ( +
+
+
+

Admin

+

HackRegina event management

+
+
+ {image && } + {name} +
+ +
+
+
+ {children} +
+ ); +} diff --git a/app/admin/(protected)/page.tsx b/app/admin/(protected)/page.tsx new file mode 100644 index 0000000..fcba953 --- /dev/null +++ b/app/admin/(protected)/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next'; +import { requireAdminOrRedirect } from '@/lib/adminAuth'; + +export const metadata: Metadata = { title: 'Admin - HackRegina' }; + +export default async function AdminPage() { + const session = await requireAdminOrRedirect(); + return ( +

+ Signed in as {session.user.name ?? 'unknown user'}. Event management is coming in the next + update. +

+ ); +} diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 0000000..bb7e6be --- /dev/null +++ b/app/admin/login/page.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from 'next'; +import { redirect } from 'next/navigation'; +import { auth, signIn } from '@/auth'; +import { Slack } from '@/components/icons/BrandIcons'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { routes } from '@/lib/route'; + +export const metadata: Metadata = { + title: 'Admin Sign In - HackRegina', + robots: { index: false, follow: false }, +}; + +const ERROR_MESSAGES: Record = { + AccessDenied: 'You must be an administrator of the HackRegina Slack workspace to sign in.', +}; + +interface AdminLoginPageProps { + searchParams: Promise<{ error?: string; callbackUrl?: string }>; +} + +export default async function AdminLoginPage({ searchParams }: AdminLoginPageProps) { + const { error, callbackUrl } = await searchParams; + const session = await auth(); + if (session?.user?.isAdmin) redirect(routes.admin.dashboard()); + + const redirectTo = + callbackUrl?.startsWith('/') && !callbackUrl.startsWith('//') + ? callbackUrl + : routes.admin.dashboard(); + + const signInWithSlack = async () => { + 'use server'; + await signIn('slack', { redirectTo }); + }; + + return ( +
+ + + Admin Dashboard + + Sign in with Slack to manage HackRegina events. Access is limited to workspace + administrators. + + + + {error && ( +

+ {ERROR_MESSAGES[error] ?? 'Sign-in failed. Please try again.'} +

+ )} +
+ +
+
+
+
+ ); +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..0a98352 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/auth'; + +export const { GET, POST } = handlers; diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..74fdcde --- /dev/null +++ b/auth.ts @@ -0,0 +1,43 @@ +import NextAuth from 'next-auth'; +import Slack from 'next-auth/providers/slack'; +import { isWorkspaceAdmin } from '@/lib/slackAdmin'; + +const teamId = process.env.SLACK_TEAM_ID; + +// Slack's OIDC id_token carries team/user ids as namespaced claims, not top-level fields. +const TEAM_CLAIM = 'https://slack.com/team_id'; +const USER_CLAIM = 'https://slack.com/user_id'; + +export const { handlers, auth, signIn, signOut } = NextAuth({ + providers: [Slack], + session: { strategy: 'jwt', maxAge: 60 * 60 * 12 }, + trustHost: true, + pages: { signIn: '/admin/login', error: '/admin/login' }, + callbacks: { + async signIn({ profile }) { + const slackTeamId = profile?.[TEAM_CLAIM]; + const slackUserId = profile?.[USER_CLAIM] ?? profile?.sub; + if (!teamId || slackTeamId !== teamId) return false; + if (typeof slackUserId !== 'string' || !slackUserId) return false; + return isWorkspaceAdmin(slackUserId); + }, + async jwt({ token, account, profile }) { + if (account && profile) { + const slackUserId = profile[USER_CLAIM] ?? profile.sub; + if (typeof slackUserId === 'string' && slackUserId) { + token.slackUserId = slackUserId; + token.isAdmin = + !!teamId && profile[TEAM_CLAIM] === teamId && (await isWorkspaceAdmin(slackUserId)); + } else { + token.isAdmin = false; + } + } + return token; + }, + async session({ session, token }) { + session.user.isAdmin = !!token.isAdmin; + session.user.slackUserId = typeof token.slackUserId === 'string' ? token.slackUserId : ''; + return session; + }, + }, +}); diff --git a/bun.lock b/bun.lock index fdb2771..6447407 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "luxon": "^3.7.2", "mapbox-gl": "^3.25.0", "next": "^16.2.9", + "next-auth": "5.0.0-beta.32", "react": "^19.2.7", "react-dom": "^19.2.7", "react-map-gl": "^8.1.1", @@ -41,6 +42,8 @@ "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@auth/core": ["@auth/core@0.41.3", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7 || ^8.0.5" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw=="], + "@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="], @@ -155,6 +158,8 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="], + "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="], @@ -387,6 +392,8 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="], + "json-stringify-pretty-compact": ["json-stringify-pretty-compact@3.0.0", "", {}, ""], "kdbush": ["kdbush@4.0.2", "", {}, ""], @@ -441,8 +448,12 @@ "next": ["next@16.2.9", "", { "dependencies": { "@next/env": "16.2.9", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.9", "@next/swc-darwin-x64": "16.2.9", "@next/swc-linux-arm64-gnu": "16.2.9", "@next/swc-linux-arm64-musl": "16.2.9", "@next/swc-linux-x64-gnu": "16.2.9", "@next/swc-linux-x64-musl": "16.2.9", "@next/swc-win32-arm64-msvc": "16.2.9", "@next/swc-win32-x64-msvc": "16.2.9", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww=="], + "next-auth": ["next-auth@5.0.0-beta.32", "", { "dependencies": { "@auth/core": "0.41.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7 || ^8.0.5", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q=="], + "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], + "oauth4webapi": ["oauth4webapi@3.8.7", "", {}, "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw=="], + "p-finally": ["p-finally@1.0.0", "", {}, ""], "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, ""], @@ -461,6 +472,10 @@ "potpack": ["potpack@2.0.0", "", {}, ""], + "preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="], + + "preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="], + "protocol-buffers-schema": ["protocol-buffers-schema@3.6.0", "", {}, ""], "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], diff --git a/lib/adminAuth.ts b/lib/adminAuth.ts new file mode 100644 index 0000000..52724c8 --- /dev/null +++ b/lib/adminAuth.ts @@ -0,0 +1,15 @@ +import type { Session } from 'next-auth'; +import { redirect } from 'next/navigation'; +import { auth } from '@/auth'; +import { routes } from '@/lib/route'; + +export const requireAdmin = async (): Promise => { + const session = await auth(); + return session?.user?.isAdmin ? session : null; +}; + +export const requireAdminOrRedirect = async (): Promise => { + const session = await requireAdmin(); + if (!session) redirect(routes.admin.login()); + return session; +}; diff --git a/lib/route.ts b/lib/route.ts index f17ce02..83940a7 100644 --- a/lib/route.ts +++ b/lib/route.ts @@ -1,6 +1,11 @@ export const routes = { home: () => '/', events: () => '/events', + admin: { + dashboard: () => '/admin', + event: (id: string) => `/admin/events/${id}`, + login: () => '/admin/login', + }, techmap: { list: () => '/techmap', technologies: () => '/techmap?view=technologies', diff --git a/lib/slackAdmin.ts b/lib/slackAdmin.ts new file mode 100644 index 0000000..227260d --- /dev/null +++ b/lib/slackAdmin.ts @@ -0,0 +1,23 @@ +import { WebClient } from '@slack/web-api'; + +const token = process.env.SLACK_BOT_TOKEN; + +/** + * Checks whether a Slack user is an admin, owner, or primary owner of the + * workspace. Requires the bot token to have the `users:read` scope. Fails + * closed: a missing token or any API error results in `false`. + */ +export const isWorkspaceAdmin = async (slackUserId: string): Promise => { + if (!token) { + console.error('SLACK_BOT_TOKEN is not set; denying admin access'); + return false; + } + try { + const web = new WebClient(token); + const { user } = await web.users.info({ user: slackUserId }); + return !!(user?.is_admin || user?.is_owner || user?.is_primary_owner); + } catch (error) { + console.error('Slack users.info failed during admin check', error); + return false; + } +}; diff --git a/package.json b/package.json index 74330da..69c0561 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "dev:https": "next dev --experimental-https", "build": "next build", "start": "next start", "lint": "biome lint .", @@ -37,6 +38,7 @@ "luxon": "^3.7.2", "mapbox-gl": "^3.25.0", "next": "^16.2.9", + "next-auth": "5.0.0-beta.32", "react": "^19.2.7", "react-dom": "^19.2.7", "react-map-gl": "^8.1.1", diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..3785e86 --- /dev/null +++ b/proxy.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { routes } from '@/lib/route'; + +export default auth((req) => { + const { nextUrl } = req; + const isAdmin = !!req.auth?.user?.isAdmin; + + if (nextUrl.pathname === routes.admin.login()) { + if (isAdmin) return NextResponse.redirect(new URL(routes.admin.dashboard(), nextUrl)); + return NextResponse.next(); + } + if (isAdmin) return NextResponse.next(); + if (nextUrl.pathname.startsWith('/api/admin')) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const login = new URL(routes.admin.login(), nextUrl); + login.searchParams.set('callbackUrl', nextUrl.pathname); + return NextResponse.redirect(login); +}); + +export const config = { + matcher: ['/admin/:path*', '/api/admin/:path*'], +}; diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts new file mode 100644 index 0000000..89a98a1 --- /dev/null +++ b/types/next-auth.d.ts @@ -0,0 +1,17 @@ +import type { DefaultSession } from 'next-auth'; + +declare module 'next-auth' { + interface Session { + user: { + isAdmin: boolean; + slackUserId: string; + } & DefaultSession['user']; + } +} + +declare module 'next-auth/jwt' { + interface JWT { + isAdmin?: boolean; + slackUserId?: string; + } +}