Skip to content

Commit

Permalink
Let people log into the server with their github accounts.
Browse files Browse the repository at this point in the history
  • Loading branch information
jyasskin committed Nov 15, 2024
1 parent 9e98514 commit e974f46
Show file tree
Hide file tree
Showing 16 changed files with 289 additions and 33 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@
"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",
"@github/relative-time-element": "^4.4.3",
"@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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions prisma/migrations/20241115014420_login/migration.sql
Original file line number Diff line number Diff line change
@@ -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
);
29 changes: 29 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
48 changes: 45 additions & 3 deletions src/layouts/Layout.astro
Original file line number Diff line number Diff line change
@@ -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;
---

<!doctype html>
Expand All @@ -17,6 +23,42 @@ const { title } = Astro.props;
<title>{title}</title>
</head>
<body>
<nav id="topnav">
<a href="/">Home</a>
<a href="/metrics">Metrics</a>
<span id="login"
>{
user ? (
<>
<a href={`https://github.com/${user.username}`}>
<b>@{user.username}</b>
</a>
(
<a
href={buildUrl(new URL("/api/logout", Astro.url), {
next: Astro.request.url,
})}
>
Logout
</a>
)
</>
) : CLIENT_ID && app ? (
<a
href={
app.oauth.getWebFlowAuthorizationUrl({
redirectUrl: new URL("/api/github/oauth/callback", Astro.url)
.href,
state: Astro.request.url,
}).url
}
>
Login
</a>
) : null
}</span
>
</nav>
<slot />
</body>
</html>
Expand All @@ -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;
}
</style>
30 changes: 16 additions & 14 deletions src/lib/github/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
110 changes: 110 additions & 0 deletions src/lib/login.ts
Original file line number Diff line number Diff line change
@@ -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<GitHubAppUserAuthenticationWithExpiration>,
) {
const sessionId = (await randomBytes(128 / 8)).toString("base64url");
const sessionExpires = new Date();
sessionExpires.setDate(sessionExpires.getDate() + 31);
const update: Omit<Prisma.GithubUserCreateInput, "id"> = {
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<User | null> {
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<void> {
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,
},
});
}
9 changes: 9 additions & 0 deletions src/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,12 @@ export function setMapDefault<K, V>(map: Map<K, V>, key: K, deflt: V): V {
}
return val;
}

export function buildUrl(
base: string | URL,
params: Record<string, string>,
): URL {
const result = new URL(base);
result.search = new URLSearchParams(params).toString();
return result;
}
21 changes: 21 additions & 0 deletions src/pages/api/github/oauth/callback.ts
Original file line number Diff line number Diff line change
@@ -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);
};
8 changes: 8 additions & 0 deletions src/pages/api/logout.ts
Original file line number Diff line number Diff line change
@@ -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);
};
6 changes: 4 additions & 2 deletions src/pages/attendance.astro
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
---
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);
const percentFormatter = new Intl.NumberFormat(undefined, { style: "percent" });
---

<Layout title="Attendance">
<nav><a href="/">Home</a></nav>
<Layout title="Attendance" {user}>
<main>
<h1>Rough meeting attendance by year</h1>
<p>
Expand Down
8 changes: 4 additions & 4 deletions src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } },
Expand All @@ -33,10 +36,7 @@ for (const review of openReviews) {
}
---

<Layout title="Open design reviews">
<nav>
<a href="/metrics">Metrics</a>
</nav>
<Layout title="Open design reviews" {user}>
<main>
<h2>Open reviews</h2>
{
Expand Down
Loading

0 comments on commit e974f46

Please sign in to comment.