diff --git a/package.json b/package.json index 0a9d22e..9a4b6c2 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "preview": "astro preview", "astro": "astro", "codegen": "graphql-codegen --config gql-codegen.config.ts && prisma generate", - "test": "astro check && eslint . && tsc && vitest" + "test": "astro check && eslint . && tsc && DATABASE_URL=file:./test.db prisma db push && DATABASE_URL=file:./test.db vitest" }, "dependencies": { "@astrojs/node": "^8.3.4", @@ -17,6 +17,7 @@ "@graphql-typed-document-node/core": "^3.2.0", "@js-temporal/polyfill": "^0.4.4", "@observablehq/plot": "^0.6.16", + "@octokit/auth-oauth-app": "^8.1.1", "@octokit/webhooks": "^13.3.0", "@prisma/client": "^5.22.0", "astro": "^4.16.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66d6497..8e84aed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@observablehq/plot': specifier: ^0.6.16 version: 0.6.16 + '@octokit/auth-oauth-app': + specifier: ^8.1.1 + version: 8.1.1 '@octokit/webhooks': specifier: ^13.3.0 version: 13.3.0 diff --git a/prisma/migrations/20241115014420_login/migration.sql b/prisma/migrations/20241115014420_login/migration.sql new file mode 100644 index 0000000..e96fc2e --- /dev/null +++ b/prisma/migrations/20241115014420_login/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "GithubUser" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT NOT NULL, + "accessToken" TEXT, + "refreshToken" TEXT, + "accessTokenExpires" DATETIME, + "refreshTokenExpires" DATETIME +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "expires" DATETIME NOT NULL, + CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "GithubUser" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 40f5237..2eb1079 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -116,3 +116,32 @@ model ProposedComment { /// The text of the proposed comment. markdown String } + +/// Logins only support Github oauth login, since this bot is solely for mediating access to Github +/// data. +model GithubUser { + /// The GraphQL node ID of this user. + id String @id + /// Github login name, for display in the UI. + username String + + // Tokens used for acting as this user. E.g. posting comments and updating labels. + accessToken String? + refreshToken String? + /// When the accessToken needs to be refreshed. + accessTokenExpires DateTime? + /// When the user will need to log in again from scratch. + refreshTokenExpires DateTime? + + sessions Session[] +} + +/// One user can have multiple active sessions in different browsers. +model Session { + /// Cryptographically random identifier for storage in a cookie. + id String @id + userId String + user GithubUser @relation(fields: [userId], references: [id], onDelete: Cascade) + + expires DateTime +} diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 50ef698..e5599b8 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -1,9 +1,15 @@ --- +import { CLIENT_ID } from "astro:env/server"; +import { app } from "../lib/github/auth"; +import type { User } from "../lib/login"; +import { buildUrl } from "../lib/util"; + interface Props { title: string; + user: User | null; } -const { title } = Astro.props; +const { title, user } = Astro.props; --- @@ -17,6 +23,42 @@ const { title } = Astro.props; {title} + @@ -27,9 +69,9 @@ const { title } = Astro.props; } :first-child { - margin-block-start: 0; + margin-block-start: 0; } :last-child { - margin-block-end: 0; + margin-block-end: 0; } diff --git a/src/lib/github/auth.ts b/src/lib/github/auth.ts index 7ce4b51..46fe424 100644 --- a/src/lib/github/auth.ts +++ b/src/lib/github/auth.ts @@ -9,20 +9,22 @@ import { } from "astro:env/server"; import { App, Octokit } from "octokit"; -let app: App | undefined = undefined; -if (APP_ID && CLIENT_ID && CLIENT_SECRET && PRIVATE_KEY) { - app = new App({ - appId: APP_ID, - privateKey: PRIVATE_KEY, - oauth: { - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }, - webhooks: { - secret: WEBHOOK_SECRET, - }, - }); -} +export const app: App | undefined = (function () { + if (APP_ID && CLIENT_ID && CLIENT_SECRET && PRIVATE_KEY) { + return new App({ + appId: APP_ID, + privateKey: PRIVATE_KEY, + oauth: { + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + }, + webhooks: { + secret: WEBHOOK_SECRET, + }, + }); + } + return undefined; +})(); export const webhooks: Webhooks = app?.webhooks ?? new Webhooks({ secret: WEBHOOK_SECRET }); diff --git a/src/lib/login.ts b/src/lib/login.ts new file mode 100644 index 0000000..ab5b0cf --- /dev/null +++ b/src/lib/login.ts @@ -0,0 +1,110 @@ +import type { + GitHubAppUserAuthentication, + GitHubAppUserAuthenticationWithExpiration, +} from "@octokit/auth-oauth-app"; +import type { Prisma } from "@prisma/client"; +import type { AstroCookies } from "astro"; +import crypto from "node:crypto"; +import util from "node:util"; +import { app } from "./github/auth"; +import { prisma } from "./prisma"; + +// Can't use __Host- because of https://crbug.com/40196122. +const LOGIN_COOKIE_NAME = "Session"; + +const randomBytes = util.promisify(crypto.randomBytes); + +export type User = { + /** The active user's GraphQL node ID. */ + githubId: string; + username: string; +}; + +type GithubRestApiUser = { + login: string; + /** GraphQL ID */ + node_id: string; +}; + +export async function finishLogin( + cookies: AstroCookies, + { login, node_id }: GithubRestApiUser, + { + token, + refreshToken, + expiresAt, + refreshTokenExpiresAt, + }: GitHubAppUserAuthentication & + Partial, +) { + const sessionId = (await randomBytes(128 / 8)).toString("base64url"); + const sessionExpires = new Date(); + sessionExpires.setDate(sessionExpires.getDate() + 31); + const update: Omit = { + username: login, + accessToken: token, + accessTokenExpires: expiresAt, + refreshToken, + refreshTokenExpires: refreshTokenExpiresAt, + sessions: { create: { id: sessionId, expires: sessionExpires } }, + }; + await prisma.githubUser.upsert({ + where: { id: node_id }, + create: { + id: node_id, + ...update, + }, + update, + }); + cookies.set(LOGIN_COOKIE_NAME, sessionId, { + httpOnly: true, + secure: true, + path: "/", + expires: sessionExpires, + }); +} + +export async function getLogin(cookies: AstroCookies): Promise { + const sessionId = cookies.get(LOGIN_COOKIE_NAME)?.value; + if (!sessionId) return null; + const session = await prisma.session.findUnique({ + where: { id: sessionId }, + include: { user: true }, + }); + if (!session) return null; + const now = new Date(); + if (session.expires < now) { + void prisma.session + .deleteMany({ + where: { expires: { lte: now } }, + }) + .catch((e: unknown) => { + console.error(e instanceof Error ? e.stack : e); + }); + cookies.delete(LOGIN_COOKIE_NAME, { path: "/" }); + return null; + } + return { githubId: session.user.id, username: session.user.username }; +} + +export async function logout(cookies: AstroCookies): Promise { + const sessionId = cookies.get(LOGIN_COOKIE_NAME)?.value; + if (!sessionId) return; + const { user } = await prisma.session.delete({ + where: { id: sessionId }, + include: { user: true }, + }); + cookies.delete(LOGIN_COOKIE_NAME); + if (user.accessToken) { + await app?.oauth.deleteToken({ token: user.accessToken }); + } + await prisma.githubUser.update({ + where: { id: user.id }, + data: { + accessToken: null, + accessTokenExpires: null, + refreshToken: null, + refreshTokenExpires: null, + }, + }); +} diff --git a/src/lib/util.ts b/src/lib/util.ts index 2ce73d3..b0aeb2f 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -31,3 +31,12 @@ export function setMapDefault(map: Map, key: K, deflt: V): V { } return val; } + +export function buildUrl( + base: string | URL, + params: Record, +): URL { + const result = new URL(base); + result.search = new URLSearchParams(params).toString(); + return result; +} diff --git a/src/pages/api/github/oauth/callback.ts b/src/pages/api/github/oauth/callback.ts new file mode 100644 index 0000000..e1d5bcb --- /dev/null +++ b/src/pages/api/github/oauth/callback.ts @@ -0,0 +1,21 @@ +// At this path for compatibility with the octokit middleware. + +import type { APIRoute } from "astro"; +import { app } from "../../../../lib/github/auth"; +import { finishLogin } from "../../../../lib/login"; + +export const GET: APIRoute = async ({ url, cookies, redirect }) => { + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state") ?? "/"; + const next = URL.canParse(state, url.href) ? state : "/"; + if (app && code) { + const { authentication } = await app.oauth.createToken({ code }); + const { + data: { user }, + } = await app.oauth.checkToken({ token: authentication.token }); + if (user) { + await finishLogin(cookies, user, authentication); + } + } + return redirect(next, 303); +}; diff --git a/src/pages/api/logout.ts b/src/pages/api/logout.ts new file mode 100644 index 0000000..54f143c --- /dev/null +++ b/src/pages/api/logout.ts @@ -0,0 +1,8 @@ +import type { APIRoute } from "astro"; +import { logout } from "../../lib/login"; + +export const GET: APIRoute = async ({ url, cookies, redirect }) => { + await logout(cookies); + const next = url.searchParams.get("next") ?? "/"; + return redirect(next, 303); +}; diff --git a/src/pages/attendance.astro b/src/pages/attendance.astro index cc3c2ba..b0a9e99 100644 --- a/src/pages/attendance.astro +++ b/src/pages/attendance.astro @@ -1,8 +1,11 @@ --- import Layout from "../layouts/Layout.astro"; import { summarizeAttendance } from "../lib/attendance"; +import { getLogin } from "../lib/login"; import { tagMembers } from "../lib/tag-members"; +const user = await getLogin(Astro.cookies); + const attendance = await summarizeAttendance(); const years = Array.from(attendance.byTerm.keys()).sort((a, b) => b - a); @@ -10,8 +13,7 @@ const years = Array.from(attendance.byTerm.keys()).sort((a, b) => b - a); const percentFormatter = new Intl.NumberFormat(undefined, { style: "percent" }); --- - - +

Rough meeting attendance by year

diff --git a/src/pages/index.astro b/src/pages/index.astro index c6abb31..5515e70 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -4,9 +4,12 @@ import type { DesignReview } from "@prisma/client"; import ReviewList from "../components/ReviewList.astro"; import Layout from "../layouts/Layout.astro"; import { closestMonday, toPlainDateUTC } from "../lib/date"; +import { getLogin } from "../lib/login"; import { prisma } from "../lib/prisma"; import { setMapDefault } from "../lib/util"; +const user = await getLogin(Astro.cookies); + const openReviews = await prisma.designReview.findMany({ where: { closed: null }, include: { milestone: { select: { dueOn: true } } }, @@ -33,10 +36,7 @@ for (const review of openReviews) { } --- - -

+

Open reviews

{ diff --git a/src/pages/metrics.astro b/src/pages/metrics.astro index 55874ac..8f91556 100644 --- a/src/pages/metrics.astro +++ b/src/pages/metrics.astro @@ -1,7 +1,10 @@ --- import Layout from "../layouts/Layout.astro"; +import { getLogin } from "../lib/login"; import { prisma } from "../lib/prisma"; +const user = await getLogin(Astro.cookies); + const openCloseHistory = ( await prisma.designReview.findMany({ select: { created: true, closed: true }, @@ -12,7 +15,7 @@ const openCloseHistory = ( })); --- - +
diff --git a/src/pages/reparse.astro b/src/pages/reparse.astro index d3f62dc..53d4884 100644 --- a/src/pages/reparse.astro +++ b/src/pages/reparse.astro @@ -1,9 +1,13 @@ --- import Layout from "../layouts/Layout.astro"; import { reparseMinutes } from "../lib/github/update"; +import { getLogin } from "../lib/login"; + +const user = await getLogin(Astro.cookies); const count = await reparseMinutes(); --- - -

{count} minutes files re-parsed. Go back home.

+ + +

{count} minutes files re-parsed. Go back home.

diff --git a/src/pages/review/[number].astro b/src/pages/review/[number].astro index 04fbba7..679d53e 100644 --- a/src/pages/review/[number].astro +++ b/src/pages/review/[number].astro @@ -4,10 +4,13 @@ import Markdown from "../../components/Markdown.astro"; import RelativeTime from "../../components/RelativeTime.astro"; import ReviewList from "../../components/ReviewList.astro"; import Layout from "../../layouts/Layout.astro"; +import { getLogin } from "../../lib/login"; import { prisma } from "../../lib/prisma"; const { number } = Astro.params; +const user = await getLogin(Astro.cookies); + const parsedNumber = parseInt(number!); if (isNaN(parsedNumber)) { return new Response(undefined, { status: 404 }); @@ -37,10 +40,7 @@ const sameWeek = await prisma.designReview.findMany({ }); --- - - +