diff --git a/README.md b/README.md
index 77b80d3..80cf539 100644
--- a/README.md
+++ b/README.md
@@ -42,11 +42,23 @@ All commands are run from the root of the project, from a terminal:
The app is a mix of **prerendered** content pages (about, how it works, editorial stance, imprint — one static file per locale, generated under `src/pages/[...locale]/`) and **server-rendered** pages that depend on the device API or on query parameters (home, browse, device detail, surveys, 404, `robots.txt`). It reads these env vars:
-| Variable | Required | Default | Description |
-| :------------------ | :------- | :---------------------- | :-------------------------------------------------------------------------------------------------------------------------- |
-| `API_AUTHORITY` | No | `http://localhost:3000` | Base URL of the device API. The app fetches `/api/unstable/derived/devices` from here, server-side. |
-| `CSR_API_AUTHORITY` | No | `http://localhost:3000` | Base URL of the device API for client-side fetches (search box, filters). Served to the browser by `/config.js` at runtime. |
-| `NOINDEX` | No | `true` | Adds `noindex` for preview deploys. Set to `false` for a real production deploy. |
+| Variable | Required | Default | Description |
+| :--------------------------- | :------- | :---------------------- | :-------------------------------------------------------------------------------------------------------------------------- |
+| `API_AUTHORITY` | No | `http://localhost:3000` | Base URL of the device API. The app fetches `/api/unstable/derived/devices` from here, server-side. |
+| `CSR_API_AUTHORITY` | No | `http://localhost:3000` | Base URL of the device API for client-side fetches (search box, filters). Served to the browser by `/config.js` at runtime. |
+| `NOINDEX` | No | `true` | Adds `noindex` for preview deploys. Set to `false` for a real production deploy. |
+| `GITHUB_OAUTH_CLIENT_ID` | No | _(unset)_ | Client identifier of the GitHub OAuth app behind `/vendor/github/authorize`. While unset, the routes answer `503`. |
+| `GITHUB_OAUTH_CLIENT_SECRET` | No | _(unset)_ | Client secret for the token exchange. Never leaves the server. |
+
+### GitHub sign-in
+
+`/vendor/github/authorize` and `/vendor/github/callback` (register as OAuth callback url) enable token exchange, other routes in `/vendor/github` are required because they act on http-only cookies.
+
+| Cookie | Lifetime | HTTP only | Holds | Path |
+| :--------------- | :------------------------------------ | :-------- | :---------------------------- | :--------------- |
+| `github-user` | 400 days (browser max) | no | the account name, for display | / |
+| `github-token` | the token's `expires_in` | no | access token and scope | / |
+| `github-refresh` | whatever github reports or six months | yes | the refresh token | `/vendor/github` |
Create a `.env` for local runs (auto-loaded by Astro and `netlify dev`):
diff --git a/astro.config.mjs b/astro.config.mjs
index 98652ec..2dd7e8c 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -80,6 +80,18 @@ export default defineConfig({
optional: true,
default: true,
}),
+ GITHUB_OAUTH_CLIENT_ID: envField.string({
+ context: "server",
+ access: "secret",
+ optional: true,
+ default: "",
+ }),
+ GITHUB_OAUTH_CLIENT_SECRET: envField.string({
+ context: "server",
+ access: "secret",
+ optional: true,
+ default: "",
+ }),
},
},
adapter,
diff --git a/messages/en.json b/messages/en.json
index 22990a7..ed2a8fa 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -13,6 +13,21 @@
"nav_language_label": "Language",
"nav_language_translate": "Translate",
"nav_language_translate_hint": "Help us support more languages",
+ "account_sign_in": "Sign In",
+ "account_sign_in_required": "An account is currently required to contribute to the Device Database",
+ "account_sign_in_consent": "By proceeding, you agree to our {#termsLink}terms and conditions{/termsLink}. For more information about how we use your data, visit our {#privacyLink}privacy policy{/privacyLink}.",
+ "account_sign_in_continue_with": "Continue With",
+ "account_menu_label": "Account of {login}",
+ "account_title": "Account",
+ "account_signed_out": "You are not signed in.",
+ "account_sign_out": "Sign Out",
+ "account_sign_out_hint": "Signs you out of your current browser session.",
+ "account_danger_zone": "Danger Zone",
+ "account_disconnect": "Disconnect GitHub",
+ "account_disconnect_hint": "Revokes this app's access to your GitHub account across all browsers.",
+ "account_disconnect_hint_contributions": "Your past contributions will remain. You can reconnect at any time.",
+ "account_disconnect_confirm": "Disconnect GitHub? This revokes this app's access to your GitHub account across all browsers. Your past contributions will remain, and you can reconnect at any time.",
+ "account_action_failed": "That didn't work. Please try again in a moment.",
"search_placeholder": "Search",
"search_devices_label": "Search devices",
"search_clear": "Clear",
diff --git a/src/assets/github-mark.svg b/src/assets/github-mark.svg
new file mode 100755
index 0000000..4f46bf3
--- /dev/null
+++ b/src/assets/github-mark.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/AccountMenu.astro b/src/components/AccountMenu.astro
new file mode 100644
index 0000000..d11c752
--- /dev/null
+++ b/src/components/AccountMenu.astro
@@ -0,0 +1,93 @@
+---
+import GitHubMark from "../assets/github-mark.svg";
+
+import Message from "./Message.astro";
+
+import { GITHUB_AUTHORIZE_PATH } from "../io/vendor/github/routes";
+import { m } from "../paraglide/messages.js";
+import { localizeHref } from "../paraglide/runtime.js";
+import { generic, render } from "../utilities/presentation";
+import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset";
+
+// TODO: point to /terms and /privacy once the legal copy is written
+const termsLink = { href: localizeHref("/") };
+const privacyLink = { href: localizeHref("/") };
+
+const signInHref = `${GITHUB_AUTHORIZE_PATH}?${new URLSearchParams({
+ "return-to": Astro.url.pathname + Astro.url.search,
+})}`;
+---
+
+
+
+
+ {m.account_sign_in()}
+
+
+
{m.account_sign_in_required()}
+
+
+
+ {(children: unknown[]) => {children} }
+
+
+ {(children: unknown[]) => {children} }
+
+
+
+
{m.account_sign_in_continue_with()}
+
+
+ GitHub
+
+
+
+
+
+
+
+
diff --git a/src/components/Nav.astro b/src/components/Nav.astro
index 3a86e18..c6d6551 100644
--- a/src/components/Nav.astro
+++ b/src/components/Nav.astro
@@ -1,4 +1,5 @@
---
+import AccountMenu from "./AccountMenu.astro";
import LanguagePicker from "./LanguagePicker.astro";
import SearchBox from "./SearchBox.astro";
@@ -73,6 +74,7 @@ const showSearch = page !== "home";
}
diff --git a/src/io/oauth.ts b/src/io/oauth.ts
new file mode 100644
index 0000000..f8c1d47
--- /dev/null
+++ b/src/io/oauth.ts
@@ -0,0 +1,185 @@
+import { z } from "astro/zod";
+import type { AstroCookieSetOptions } from "astro";
+
+// provider-agnostic half of the oauth 2.0 authorization code flow
+
+export class OAuthError extends Error {
+ constructor(
+ public code: string,
+ public description?: string | undefined,
+ ) {
+ super(
+ typeof description !== "undefined" ? `${code}: ${description}` : code,
+ );
+ Object.setPrototypeOf(this, OAuthError.prototype);
+ }
+}
+
+/** the pending authorization: single-use csrf state / the page to return to */
+export const OAuthState = z.object({
+ state: z.string().min(1),
+ returnTo: z.string().startsWith("/"),
+});
+export type OAuthState = z.infer;
+
+const hasControlCharacter = (value: string): boolean =>
+ [...value].some((character) => {
+ const code = character.codePointAt(0) ?? 0;
+
+ return code < 0x20 || code === 0x7f;
+ });
+
+/**
+ * accepts same-origin paths only
+ *
+ * `//evil.example` and `https://evil.example` would turn the callback into an open redirect
+ */
+export const safeReturnTo = (value: string | null | undefined): string => {
+ if (!value || !value.startsWith("/")) {
+ return "/";
+ }
+
+ // `//host` and `/\host` are protocol-relative; control characters can smuggle a header break
+ if (
+ value.startsWith("//") ||
+ value.startsWith("/\\") ||
+ hasControlCharacter(value)
+ ) {
+ return "/";
+ }
+
+ // a fragment belongs to the browser, not to the `Location` header
+ return value.split("#")[0] ?? "/";
+};
+
+/** browsers key their cache on the url alone, so pages cached before signing in or out would outlive it */
+export const SESSION_CHANGE_HEADERS = { "clear-site-data": '"cache"' } as const;
+
+export const returnToRedirect = (returnTo: string): Response =>
+ new Response(null, {
+ status: 302,
+ headers: {
+ location: returnTo,
+ // carries Set-Cookie with the token; a cached copy would hand it to the next visitor
+ "cache-control": "no-store",
+ ...SESSION_CHANGE_HEADERS,
+ },
+ });
+
+const sharedCookieOptions = (url: URL) => ({
+ // `strict` would prevent the cookie from being sent right after access token exchange
+ sameSite: "lax" as const,
+ secure: url.protocol === "https:",
+});
+
+/** for cookies that scripts don't need access to */
+export const secretCookieOptions = (
+ url: URL,
+ path: string,
+ maxAge: number,
+): AstroCookieSetOptions => ({
+ ...sharedCookieOptions(url),
+ httpOnly: true,
+ path,
+ maxAge,
+});
+
+/** for cookies that scripts need access to */
+export const readableCookieOptions = (
+ url: URL,
+ maxAge: number,
+): AstroCookieSetOptions & { maxAge: number } => ({
+ ...sharedCookieOptions(url),
+ httpOnly: false,
+ path: "/",
+ maxAge,
+});
+
+/** client authentication for the endpoints that act as the app rather than as a user */
+export const basicAuthorization = (
+ clientId: string,
+ clientSecret: string,
+): string => `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
+
+export const authorizeUrl = (params: {
+ endpoint: string;
+ clientId: string;
+ redirectUri: string;
+ scope: string;
+ state: string;
+}): string => {
+ const url = new URL(params.endpoint);
+ url.searchParams.set("client_id", params.clientId);
+ url.searchParams.set("redirect_uri", params.redirectUri);
+ url.searchParams.set("scope", params.scope);
+ url.searchParams.set("state", params.state);
+
+ return url.toString();
+};
+
+const TokenResponse = z.object({
+ access_token: z.string().min(1),
+ scope: z.string().optional(),
+ expires_in: z.number().optional(),
+ refresh_token: z.string().optional(),
+ refresh_token_expires_in: z.number().optional(),
+});
+
+// rfc 6749 answers errors with 400, github answers them with 200
+const TokenErrorResponse = z.object({
+ error: z.string().min(1),
+ error_description: z.string().optional(),
+});
+
+export type OAuthToken = {
+ accessToken: string;
+ scope?: string | undefined;
+ expiresIn: number | undefined;
+ refreshToken?: string | undefined;
+ refreshTokenExpiresIn?: number | undefined;
+};
+
+/** helper for token endpoint call */
+export const requestToken = async (
+ endpoint: string,
+ body: Record,
+ options: { userAgent: string; timeoutMs: number },
+): Promise => {
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ accept: "application/json",
+ "content-type": "application/json",
+ "user-agent": options.userAgent,
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(options.timeoutMs),
+ });
+
+ const payload: unknown = await response.json().catch(() => undefined);
+
+ const rejected = TokenErrorResponse.safeParse(payload);
+ if (rejected.success) {
+ throw new OAuthError(rejected.data.error, rejected.data.error_description);
+ }
+
+ if (!response.ok) {
+ throw new OAuthError(
+ "exchange_failed",
+ `the provider answered <${response.status}>`,
+ );
+ }
+
+ const parsed = TokenResponse.safeParse(payload);
+ if (!parsed.success) {
+ throw new OAuthError("exchange_failed", "unexpected token response");
+ }
+
+ return {
+ accessToken: parsed.data.access_token,
+ scope: parsed.data.scope,
+ expiresIn: parsed.data.expires_in,
+ refreshToken: parsed.data.refresh_token,
+ refreshTokenExpiresIn: parsed.data.refresh_token_expires_in,
+ };
+};
diff --git a/src/io/vendor/github/oauth.ts b/src/io/vendor/github/oauth.ts
new file mode 100644
index 0000000..8de2079
--- /dev/null
+++ b/src/io/vendor/github/oauth.ts
@@ -0,0 +1,258 @@
+import { z } from "astro/zod";
+import type { AstroCookies, AstroCookieSetOptions } from "astro";
+
+import {
+ authorizeUrl,
+ basicAuthorization,
+ OAuthError,
+ requestToken as oauthRequestToken,
+ readableCookieOptions,
+ secretCookieOptions,
+} from "../../oauth";
+import { GITHUB_ROUTE_BASE } from "./routes";
+import { GITHUB_COOKIE_TOKEN, GITHUB_COOKIE_USER } from "./session";
+import type { OAuthToken } from "../../oauth";
+
+// browser can issue api requests once authenticated
+// it can't authenticate on it's own, necessitating server-side support
+
+const AUTHORIZE_ENDPOINT = "https://github.com/login/oauth/authorize";
+const TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token";
+const API_ENDPOINT = "https://api.github.com";
+
+/** for csrf state + the page to return to */
+export const GITHUB_COOKIE_OAUTH_HELLO = "github-oauth-hello";
+/** for refresh token */
+export const GITHUB_COOKIE_REFRESH = "github-refresh";
+
+/** scoped to segment containing all other github-related routes so that only those can access cookie value */
+export const GITHUB_OAUTH_COOKIE_PATH = GITHUB_ROUTE_BASE;
+
+/** `public_repo` is enough to fork, write contents and open pull requests on public repositories */
+export const GITHUB_OAUTH_SCOPE = "public_repo";
+
+/** required for server-side requests, as github rejects requests without proided user-agent */
+const USER_AGENT = "device-database-frontend";
+
+const STATE_TTL_SECONDS = 600;
+const EXCHANGE_TIMEOUT_MS = 8000;
+
+/**
+ * account name is not a credential and can be kept around long after the token died
+ * chrome caps maximum lifetime to 400 days (https://developer.chrome.com/blog/cookie-max-age-expires/), so use that
+ */
+const TTL_USER_SECONDS = 400 * 24 * 60 * 60;
+
+/** only reached if token expiration is ever turned off, where `expires_in` is absent */
+const TTL_TOKEN_FALLBACK_SECONDS = 8 * 60 * 60;
+
+/**
+ * github normally returns the ttl of their refresh token, fallback for when it doesn't.
+ * they should generally last 6 months
+ */
+const REFRESH_FALLBACK_TTL_SECONDS = 180 * 24 * 60 * 60;
+
+export type GithubOAuthToken = OAuthToken & {
+ scope: string;
+ expiresIn: number;
+};
+
+type AppRevocation = {
+ clientId: string;
+ clientSecret: string;
+ accessToken: string;
+};
+
+const requestToken = async (
+ body: Record,
+): Promise => {
+ const token = await oauthRequestToken(TOKEN_ENDPOINT, body, {
+ userAgent: USER_AGENT,
+ timeoutMs: EXCHANGE_TIMEOUT_MS,
+ });
+
+ return {
+ ...token,
+ scope: token.scope ?? GITHUB_OAUTH_SCOPE,
+ // `expires_in` is there while token expiration is enabled, which it is
+ expiresIn: token.expiresIn ?? TTL_TOKEN_FALLBACK_SECONDS,
+ };
+};
+
+/**
+ * - `token` revokes exactly the one token passed in
+ * - `grant` revokes the authorization, so the next sign-in asks for consent again
+ *
+ * https://docs.github.com/en/rest/apps/oauth-applications?apiVersion=2022-11-28
+ */
+const revokeResource = async (
+ resource: "token" | "grant",
+ params: AppRevocation,
+): Promise => {
+ const endpoint = `${API_ENDPOINT}/applications/${encodeURIComponent(params.clientId)}/${resource}`;
+
+ const response = await fetch(endpoint, {
+ method: "DELETE",
+ headers: {
+ accept: "application/vnd.github+json",
+ authorization: basicAuthorization(params.clientId, params.clientSecret),
+ "content-type": "application/json",
+ "user-agent": USER_AGENT,
+ },
+ body: JSON.stringify({ access_token: params.accessToken }),
+ signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
+ });
+
+ if (response.status !== 204 && response.status !== 404) {
+ throw new OAuthError(
+ "revoke_failed",
+ `github answered <${response.status}>`,
+ );
+ }
+};
+
+const GitHubUser = z.object({ login: z.string().min(1) });
+
+export class GitHubOAuth {
+ static readonly authorize = {
+ url: (params: {
+ clientId: string;
+ redirectUri: string;
+ state: string;
+ }): string =>
+ authorizeUrl({
+ endpoint: AUTHORIZE_ENDPOINT,
+ scope: GITHUB_OAUTH_SCOPE,
+ ...params,
+ }),
+
+ cookieOptions: (url: URL): AstroCookieSetOptions =>
+ secretCookieOptions(url, GITHUB_OAUTH_COOKIE_PATH, STATE_TTL_SECONDS),
+ };
+
+ /** the token endpoint, and what becomes of what it hands out */
+ static readonly token = {
+ exchange: (params: {
+ clientId: string;
+ clientSecret: string;
+ code: string;
+ redirectUri: string;
+ }): Promise =>
+ requestToken({
+ client_id: params.clientId,
+ client_secret: params.clientSecret,
+ code: params.code,
+ redirect_uri: params.redirectUri,
+ }),
+
+ refresh: (params: {
+ clientId: string;
+ clientSecret: string;
+ refreshToken: string;
+ }): Promise =>
+ requestToken({
+ client_id: params.clientId,
+ client_secret: params.clientSecret,
+ grant_type: "refresh_token",
+ refresh_token: params.refreshToken,
+ }),
+
+ /**
+ * whether github turned the refresh token down for good (spent, expired or revoked),
+ * as opposed to a timeout or outage where it may still be valid
+ */
+ isRefreshRejected: (error: unknown): boolean =>
+ error instanceof OAuthError &&
+ (error.code === "bad_refresh_token" || error.code === "invalid_grant"),
+
+ /** for signing out: this one token stops working, the authorization stands */
+ revoke: (params: AppRevocation): Promise =>
+ revokeResource("token", params),
+
+ /** the token as the browser half expects to find it in the cookie */
+ cookieValue: (token: GithubOAuthToken) => ({
+ token: token.accessToken,
+ scope: token.scope,
+ // absolute and from the server's clock; a browser that is minutes off
+ // renews minutes early or late, which the refresh route absorbs
+ expiresAt: Date.now() + token.expiresIn * 1000,
+ }),
+ };
+
+ /** the grant as a resource of its own, outliving every token issued under it */
+ static readonly authorization = {
+ /** for disconnecting: the authorization goes, and every token issued under it with it */
+ revoke: (params: AppRevocation): Promise =>
+ revokeResource("grant", params),
+ };
+
+ static readonly session = {
+ /** writes what a sign-in or a refresh produced */
+ write: (
+ value: {
+ url: URL;
+ token: GithubOAuthToken;
+ login: string;
+ },
+ cookies: AstroCookies,
+ ): void => {
+ cookies.set(
+ GITHUB_COOKIE_USER,
+ value.login,
+ readableCookieOptions(value.url, TTL_USER_SECONDS),
+ );
+
+ // `Max-Age` tracks the token's own lifetime, cookie disappears exactly when the token stops working
+ cookies.set(
+ GITHUB_COOKIE_TOKEN,
+ GitHubOAuth.token.cookieValue(value.token),
+ readableCookieOptions(value.url, value.token.expiresIn),
+ );
+
+ if (value.token.refreshToken) {
+ cookies.set(
+ GITHUB_COOKIE_REFRESH,
+ value.token.refreshToken,
+ secretCookieOptions(
+ value.url,
+ GITHUB_OAUTH_COOKIE_PATH,
+ value.token.refreshTokenExpiresIn ?? REFRESH_FALLBACK_TTL_SECONDS,
+ ),
+ );
+ }
+ },
+
+ /** drops the whole session, account name included */
+ clear: (cookies: AstroCookies): void => {
+ cookies.delete(GITHUB_COOKIE_TOKEN, { path: "/" });
+ cookies.delete(GITHUB_COOKIE_REFRESH, { path: GITHUB_OAUTH_COOKIE_PATH });
+ cookies.delete(GITHUB_COOKIE_USER, { path: "/" });
+ },
+ };
+
+ static async fetchLogin(accessToken: string): Promise {
+ const response = await fetch(`${API_ENDPOINT}/user`, {
+ headers: {
+ accept: "application/vnd.github+json",
+ authorization: `Bearer ${accessToken}`,
+ "user-agent": USER_AGENT,
+ "cache-control": "no-store",
+ },
+ signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
+ });
+
+ if (!response.ok) {
+ throw new OAuthError(
+ "user_lookup_failed",
+ `github answered <${response.status}>`,
+ );
+ }
+
+ const parsed = GitHubUser.safeParse(await response.json());
+ if (!parsed.success) {
+ throw new OAuthError("user_lookup_failed", "unexpected user response");
+ }
+
+ return parsed.data.login;
+ }
+}
diff --git a/src/io/vendor/github/operations.ts b/src/io/vendor/github/operations.ts
new file mode 100644
index 0000000..671d5a0
--- /dev/null
+++ b/src/io/vendor/github/operations.ts
@@ -0,0 +1,118 @@
+import {
+ GITHUB_AUTHORIZATION_PATH,
+ GITHUB_REFRESH_PATH,
+ GITHUB_SIGN_OUT_PATH,
+} from "./routes";
+import { GitHubSession } from "./session";
+import type { GitHubToken } from "./session";
+
+export type GitHubRenewal =
+ | { status: "renewed"; token: GitHubToken }
+ /** the refresh token is gone or was revoked, new new sign-in required */
+ | { status: "signed-out" }
+ | { status: "unavailable" };
+
+const requestRenewal = async (stale?: string): Promise => {
+ // another request may have renewed while this one waited
+ const current = GitHubSession.token.read();
+ if (current && current.token !== stale) {
+ return { status: "renewed", token: current };
+ }
+
+ let response: Response;
+ try {
+ response = await fetch(GITHUB_REFRESH_PATH, {
+ method: "POST",
+ headers: { accept: "application/json" },
+ });
+ } catch {
+ return { status: "unavailable" };
+ }
+
+ if (response.status === 401) {
+ // the refresh token is dead, so the name is no longer the name of anyone
+ // signed in: greeting a visitor who cannot act would only mislead
+ GitHubSession.forget();
+ return { status: "signed-out" };
+ }
+
+ if (!response.ok) {
+ return { status: "unavailable" };
+ }
+
+ // obtain renewed token from cookie that was set through fetch above
+ const renewed = GitHubSession.token.read();
+ if (!renewed) {
+ return { status: "unavailable" };
+ }
+
+ GitHubSession.announce();
+
+ return { status: "renewed", token: renewed };
+};
+
+let collapsed: Promise | undefined;
+
+export class GitHubOperation {
+ /**
+ * trades the refresh token for a new access token.
+ * access tokens are short-lived, so operations need to be able to deal with expiry.
+ *
+ * safe to call concurrently, calls are collapsed
+ *
+ * {@link GitHubSession.token.read} will return refreshed access token upon successful renewal
+ */
+ static renew(stale?: string): Promise {
+ collapsed ??= requestRenewal(stale).finally(() => {
+ collapsed = undefined;
+ });
+
+ return collapsed;
+ }
+
+ /**
+ * `false` if the server could not be reached or refused: the http-only refresh
+ * cookie may still be there, so the session is left as it was
+ */
+ static async signOut(): Promise {
+ let response: Response;
+ try {
+ // survives the navigation when sign-out is the last thing a page does
+ // the token cookie stays until the request is done, the server needs it to revoke
+ response = await fetch(GITHUB_SIGN_OUT_PATH, {
+ method: "POST",
+ keepalive: true,
+ });
+ } catch {
+ return false;
+ }
+
+ if (!response.ok) {
+ return false;
+ }
+
+ GitHubSession.forget();
+
+ return true;
+ }
+
+ static async revoke(): Promise {
+ let response: Response;
+ try {
+ response = await fetch(GITHUB_AUTHORIZATION_PATH, {
+ method: "DELETE",
+ headers: { accept: "application/json" },
+ });
+ } catch {
+ return false;
+ }
+
+ if (!response.ok) {
+ return false;
+ }
+
+ GitHubSession.forget();
+
+ return true;
+ }
+}
diff --git a/src/io/vendor/github/routes.ts b/src/io/vendor/github/routes.ts
new file mode 100644
index 0000000..cb89c5c
--- /dev/null
+++ b/src/io/vendor/github/routes.ts
@@ -0,0 +1,9 @@
+export const GITHUB_ROUTE_BASE = "/vendor/github";
+
+export const GITHUB_AUTHORIZE_PATH = `${GITHUB_ROUTE_BASE}/authorize`;
+export const GITHUB_CALLBACK_PATH = `${GITHUB_ROUTE_BASE}/callback`;
+export const GITHUB_REFRESH_PATH = `${GITHUB_ROUTE_BASE}/refresh`;
+export const GITHUB_SIGN_OUT_PATH = `${GITHUB_ROUTE_BASE}/sign-out`;
+
+/** the grant itself, as a resource: `DELETE` revokes it at GitHub */
+export const GITHUB_AUTHORIZATION_PATH = `${GITHUB_ROUTE_BASE}/authorization`;
diff --git a/src/io/vendor/github/session.ts b/src/io/vendor/github/session.ts
new file mode 100644
index 0000000..1614889
--- /dev/null
+++ b/src/io/vendor/github/session.ts
@@ -0,0 +1,141 @@
+import { GITHUB_AUTHORIZE_PATH } from "./routes";
+import type {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in comment
+ GITHUB_SIGN_OUT_PATH,
+} from "./routes";
+
+export const GITHUB_COOKIE_USER = "github-user";
+export const GITHUB_COOKIE_TOKEN = "github-token";
+
+/** fired on `window` whenever the token is renewed, lost or signed out */
+export const GITHUB_EVENT_SESSION = "github-session-change";
+
+/**
+ * Treat a token that is about to expire as expired: a request started inside
+ * this window would likely come back 401, and renewing first is cheaper than
+ * failing and retrying.
+ */
+const EXPIRY_SKEW_MS = 60_000;
+
+export interface GitHubToken {
+ token: string;
+ scope: string;
+ /** epoch milliseconds, from the server's clock */
+ expiresAt: number;
+}
+
+const cookieValue = (name: string): string | undefined => {
+ for (const part of document.cookie.split(";")) {
+ const separator = part.indexOf("=");
+ if (separator > 0 && part.slice(0, separator).trim() === name) {
+ const raw = part.slice(separator + 1);
+
+ try {
+ return decodeURIComponent(raw);
+ } catch {
+ // a stray `%` makes decoding throw; the value is unusable either way
+ return raw;
+ }
+ }
+ }
+
+ return undefined;
+};
+
+const expireCookie = (name: string): void => {
+ const secure = window.location.protocol === "https:" ? "; secure" : "";
+
+ document.cookie = `${name}=; path=/; max-age=0; samesite=lax${secure}`;
+};
+
+const isToken = (value: unknown): value is GitHubToken => {
+ if (typeof value !== "object" || value === null) {
+ return false;
+ }
+
+ const candidate = value as Partial>;
+
+ return (
+ typeof candidate.token === "string" &&
+ typeof candidate.scope === "string" &&
+ typeof candidate.expiresAt === "number"
+ );
+};
+
+/**
+ * The session as the page sees it: what the cookies say, what to do when they
+ * stop saying it, and the way back to a new one.
+ */
+export class GitHubSession {
+ static readonly token = {
+ read: (): GitHubToken | undefined => {
+ const raw = cookieValue(GITHUB_COOKIE_TOKEN);
+ const token = GitHubSession.token.deserialize(raw);
+
+ // expire token that could not be parsed
+ if (typeof raw !== "undefined" && typeof token === "undefined") {
+ expireCookie(GITHUB_COOKIE_TOKEN);
+ }
+
+ return token;
+ },
+
+ /**
+ * `skewMs` defaults to treating a token close to expiry as expired, which suits
+ * requests about to be made with it. revocation passes `0`: the token still works
+ */
+ deserialize: (
+ serialized: string | undefined,
+ now: number = Date.now(),
+ skewMs: number = EXPIRY_SKEW_MS,
+ ): GitHubToken | undefined => {
+ if (!serialized) {
+ return undefined;
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(serialized);
+ } catch {
+ return undefined;
+ }
+
+ if (!isToken(parsed)) {
+ return undefined;
+ }
+
+ return parsed.expiresAt - skewMs <= now ? undefined : parsed;
+ },
+ };
+
+ /** account name, outlives token */
+ static login(): string | undefined {
+ return cookieValue(GITHUB_COOKIE_USER);
+ }
+
+ /** lets every part of the page react to a session that changed underneath it */
+ static announce(): void {
+ window.dispatchEvent(new CustomEvent(GITHUB_EVENT_SESSION));
+ }
+
+ /**
+ * marks client-accessible cookies as expired and dispatches event
+ * non-client accessible cookies can be cleared through the {@link GITHUB_SIGN_OUT_PATH} route
+ */
+ static forget(): void {
+ expireCookie(GITHUB_COOKIE_USER);
+ expireCookie(GITHUB_COOKIE_TOKEN);
+
+ GitHubSession.announce();
+ }
+
+ /** leaves the page; the callback brings the visitor back to `returnTo`, signed in */
+ static signIn(
+ returnTo: string = window.location.pathname + window.location.search,
+ ): void {
+ const url = new URL(GITHUB_AUTHORIZE_PATH, window.location.origin);
+ url.searchParams.set("return-to", returnTo);
+
+ window.location.assign(url.toString());
+ }
+}
diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro
index 00a1354..9720841 100644
--- a/src/layouts/Layout.astro
+++ b/src/layouts/Layout.astro
@@ -44,6 +44,20 @@ const featureAttributes = featureDataset(features());
}
+ {/* Before first paint, so signed-in visitors don't see "Sign In" flash */}
+
+
diff --git a/src/pages/vendor/github/authorization.ts b/src/pages/vendor/github/authorization.ts
new file mode 100644
index 0000000..420a352
--- /dev/null
+++ b/src/pages/vendor/github/authorization.ts
@@ -0,0 +1,122 @@
+import {
+ GITHUB_OAUTH_CLIENT_ID,
+ GITHUB_OAUTH_CLIENT_SECRET,
+} from "astro:env/server";
+import type { APIRoute } from "astro";
+
+import { SESSION_CHANGE_HEADERS } from "../../../io/oauth";
+import {
+ GITHUB_COOKIE_REFRESH,
+ GitHubOAuth,
+} from "../../../io/vendor/github/oauth";
+import {
+ GITHUB_COOKIE_TOKEN,
+ GITHUB_COOKIE_USER,
+ GitHubSession,
+} from "../../../io/vendor/github/session";
+import type { GithubOAuthToken } from "../../../io/vendor/github/oauth";
+
+export const prerender = false;
+
+/** crafts uncached response with empty body and provided status code */
+const status = (code: number, headers: Record = {}): Response =>
+ new Response(null, {
+ status: code,
+ headers: { "cache-control": "no-store", ...headers },
+ });
+
+/**
+ * access token to revoke with.
+ * usually the one in cookie, refreshed one if it has expired
+ */
+const revocableToken = async (
+ cookie: string | undefined,
+ refreshToken: string | undefined,
+ clientId: string,
+ clientSecret: string,
+): Promise<
+ { accessToken: string; renewed?: GithubOAuthToken | undefined } | undefined
+> => {
+ const current = GitHubSession.token.deserialize(cookie, Date.now(), 0);
+ if (typeof current !== "undefined") {
+ return { accessToken: current.token };
+ }
+
+ if (!refreshToken) {
+ return undefined;
+ }
+
+ const renewed = await GitHubOAuth.token.refresh({
+ clientId,
+ clientSecret,
+ refreshToken,
+ });
+
+ return { accessToken: renewed.accessToken, renewed };
+};
+
+/**
+ * undoes app authorization grant
+ *
+ * needs client secret, therefor happens on server
+ *
+ * `204` on success, `503` if github could not be reached, `500` if grant revocation fails
+ */
+export const DELETE: APIRoute = async ({ cookies, url }) => {
+ if (!GITHUB_OAUTH_CLIENT_ID || !GITHUB_OAUTH_CLIENT_SECRET) {
+ return status(503);
+ }
+
+ let revocable: Awaited>;
+ try {
+ revocable = await revocableToken(
+ cookies.get(GITHUB_COOKIE_TOKEN)?.value,
+ cookies.get(GITHUB_COOKIE_REFRESH)?.value,
+ GITHUB_OAUTH_CLIENT_ID,
+ GITHUB_OAUTH_CLIENT_SECRET,
+ );
+ } catch (error) {
+ // timeout or outage, the grant may well still stand
+ if (!GitHubOAuth.token.isRefreshRejected(error)) {
+ return status(503);
+ }
+
+ // the refresh token was spent or revoked: there is no grant left to
+ // address, and holding on to dead credentials helps nobody
+ GitHubOAuth.session.clear(cookies);
+
+ return status(204, SESSION_CHANGE_HEADERS);
+ }
+
+ if (typeof revocable === "undefined") {
+ GitHubOAuth.session.clear(cookies);
+
+ return status(204, SESSION_CHANGE_HEADERS);
+ }
+
+ try {
+ await GitHubOAuth.authorization.revoke({
+ clientId: GITHUB_OAUTH_CLIENT_ID,
+ clientSecret: GITHUB_OAUTH_CLIENT_SECRET,
+ accessToken: revocable.accessToken,
+ });
+ } catch {
+ // the refresh above used up the old refresh token, keep the new one
+ const login = cookies.get(GITHUB_COOKIE_USER)?.value;
+ if (
+ typeof revocable.renewed !== "undefined" &&
+ typeof login !== "undefined"
+ ) {
+ GitHubOAuth.session.write(
+ { url, token: revocable.renewed, login },
+ cookies,
+ );
+ }
+
+ return status(500);
+ }
+
+ GitHubOAuth.session.clear(cookies);
+
+ return status(204, SESSION_CHANGE_HEADERS);
+};
diff --git a/src/pages/vendor/github/authorize.ts b/src/pages/vendor/github/authorize.ts
new file mode 100644
index 0000000..985d887
--- /dev/null
+++ b/src/pages/vendor/github/authorize.ts
@@ -0,0 +1,46 @@
+import { GITHUB_OAUTH_CLIENT_ID } from "astro:env/server";
+import type { APIRoute } from "astro";
+
+import { safeReturnTo } from "../../../io/oauth";
+import {
+ GITHUB_COOKIE_OAUTH_HELLO,
+ GitHubOAuth,
+} from "../../../io/vendor/github/oauth";
+import { GITHUB_CALLBACK_PATH } from "../../../io/vendor/github/routes";
+
+export const prerender = false;
+
+/**
+ * starts the oauth web flow
+ * sits beside `/callback` so that csrf state can be tightly scoped
+ */
+export const GET: APIRoute = ({ cookies, url }) => {
+ if (!GITHUB_OAUTH_CLIENT_ID) {
+ return new Response("github oauth is not configured\n", {
+ status: 503,
+ headers: { "content-type": "text/plain; charset=utf-8" },
+ });
+ }
+
+ const state = crypto.randomUUID();
+
+ cookies.set(
+ GITHUB_COOKIE_OAUTH_HELLO,
+ { state, returnTo: safeReturnTo(url.searchParams.get("return-to")) },
+ GitHubOAuth.authorize.cookieOptions(url),
+ );
+
+ const redirectUri = new URL(GITHUB_CALLBACK_PATH, url).toString();
+
+ return new Response(null, {
+ status: 302,
+ headers: {
+ location: GitHubOAuth.authorize.url({
+ clientId: GITHUB_OAUTH_CLIENT_ID,
+ redirectUri,
+ state,
+ }),
+ "cache-control": "no-store",
+ },
+ });
+};
diff --git a/src/pages/vendor/github/callback.ts b/src/pages/vendor/github/callback.ts
new file mode 100644
index 0000000..60683e6
--- /dev/null
+++ b/src/pages/vendor/github/callback.ts
@@ -0,0 +1,200 @@
+import {
+ GITHUB_OAUTH_CLIENT_ID,
+ GITHUB_OAUTH_CLIENT_SECRET,
+} from "astro:env/server";
+import type { APIRoute, AstroCookies } from "astro";
+
+import { OAuthError, OAuthState, returnToRedirect } from "../../../io/oauth";
+import {
+ GITHUB_COOKIE_OAUTH_HELLO,
+ GITHUB_OAUTH_COOKIE_PATH,
+ GitHubOAuth,
+} from "../../../io/vendor/github/oauth";
+import { GITHUB_CALLBACK_PATH } from "../../../io/vendor/github/routes";
+
+export const prerender = false;
+
+const HTML_ESCAPES: Record = {
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+};
+
+const escapeHtml = (value: string): string =>
+ value.replaceAll(
+ /[&<>"]/g,
+ (character) => HTML_ESCAPES[character] ?? character,
+ );
+
+const errorPage = (params: {
+ status: number;
+ message: string;
+ detail?: string | undefined;
+ returnTo?: string | undefined;
+}): Response => {
+ const back = escapeHtml(params.returnTo ?? "/");
+ const detail = params.detail
+ ? `\n\t\t${escapeHtml(params.detail)}
`
+ : "";
+
+ const body = `
+
+
+
+
+
+ Sign-in failed
+
+
+
+ GitHub sign-in failed
+ ${escapeHtml(params.message)}
${detail}
+ Go back
+
+
+`;
+
+ return new Response(body, {
+ status: params.status,
+ headers: {
+ "content-type": "text/html; charset=utf-8",
+ // the failure belongs to this one attempt, by this one visitor
+ "cache-control": "no-store",
+ },
+ });
+};
+
+/** reads and immediately invalidates the pending authorization */
+const takePending = (cookies: AstroCookies): OAuthState | undefined => {
+ const cookie = cookies.get(GITHUB_COOKIE_OAUTH_HELLO);
+ cookies.delete(GITHUB_COOKIE_OAUTH_HELLO, { path: GITHUB_OAUTH_COOKIE_PATH });
+
+ if (typeof cookie === "undefined") {
+ return undefined;
+ }
+
+ try {
+ const parsed = OAuthState.safeParse(cookie.json());
+ return parsed.success ? parsed.data : undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+/** exchanges authorization code for access / refresh token */
+export const GET: APIRoute = async ({ cookies, url }) => {
+ if (!GITHUB_OAUTH_CLIENT_ID || !GITHUB_OAUTH_CLIENT_SECRET) {
+ return errorPage({
+ status: 503,
+ message: "GitHub sign-in is not configured for this site.",
+ });
+ }
+
+ const pending = takePending(cookies);
+
+ if (typeof pending === "undefined") {
+ return errorPage({
+ status: 400,
+ message:
+ "Sign-in did not start here, or it sat around for too long. Please try again.",
+ });
+ }
+
+ const fail = (
+ status: number,
+ message: string,
+ code: string,
+ description?: string,
+ ): Response =>
+ errorPage({
+ status,
+ message,
+ detail: description ? `${code}: ${description}` : code,
+ returnTo: pending.returnTo,
+ });
+
+ // csrf state mismatch
+ if (url.searchParams.get("state") !== pending.state) {
+ return errorPage({
+ status: 400,
+ message:
+ "This sign-in could not be verified as the one that started here. Please try again.",
+ returnTo: pending.returnTo,
+ });
+ }
+
+ // GitHub sends `error=access_denied` when the visitor declines
+ // fixed message, the url's `error_description` would let anyone put text on this page
+ if (url.searchParams.has("error")) {
+ return errorPage({
+ status: 400,
+ message: "GitHub access was not granted.",
+ returnTo: pending.returnTo,
+ });
+ }
+
+ const code = url.searchParams.get("code");
+ if (code === null) {
+ return errorPage({
+ status: 400,
+ message: "GitHub sent no authorization code to sign in with.",
+ returnTo: pending.returnTo,
+ });
+ }
+
+ try {
+ const token = await GitHubOAuth.token.exchange({
+ clientId: GITHUB_OAUTH_CLIENT_ID,
+ clientSecret: GITHUB_OAUTH_CLIENT_SECRET,
+ code,
+ redirectUri: new URL(GITHUB_CALLBACK_PATH, url).toString(),
+ });
+
+ const login = await GitHubOAuth.fetchLogin(token.accessToken);
+
+ GitHubOAuth.session.write({ url, token, login }, cookies);
+
+ return returnToRedirect(pending.returnTo);
+ } catch (error) {
+ const message = "The sign-in could not be completed.";
+
+ return error instanceof OAuthError
+ ? fail(500, message, error.code, error.description)
+ : fail(500, message, "exchange_failed");
+ }
+};
diff --git a/src/pages/vendor/github/refresh.ts b/src/pages/vendor/github/refresh.ts
new file mode 100644
index 0000000..3d415da
--- /dev/null
+++ b/src/pages/vendor/github/refresh.ts
@@ -0,0 +1,63 @@
+import {
+ GITHUB_OAUTH_CLIENT_ID,
+ GITHUB_OAUTH_CLIENT_SECRET,
+} from "astro:env/server";
+import type { APIRoute } from "astro";
+
+import { SESSION_CHANGE_HEADERS } from "../../../io/oauth";
+import {
+ GITHUB_COOKIE_REFRESH,
+ GitHubOAuth,
+} from "../../../io/vendor/github/oauth";
+import { GITHUB_COOKIE_USER } from "../../../io/vendor/github/session";
+
+export const prerender = false;
+
+const status = (code: number, headers: Record = {}): Response =>
+ new Response(null, {
+ status: code,
+ headers: { "cache-control": "no-store", ...headers },
+ });
+
+/** trades the refresh token for a new access token */
+export const POST: APIRoute = async ({ cookies, url }) => {
+ if (!GITHUB_OAUTH_CLIENT_ID || !GITHUB_OAUTH_CLIENT_SECRET) {
+ return status(503);
+ }
+
+ const refreshToken = cookies.get(GITHUB_COOKIE_REFRESH)?.value;
+ if (typeof refreshToken === "undefined") {
+ GitHubOAuth.session.clear(cookies);
+ return status(401, SESSION_CHANGE_HEADERS);
+ }
+
+ // account name missing, re-authentication required
+ // checked before refreshing, which would use up the refresh token
+ const login = cookies.get(GITHUB_COOKIE_USER)?.value;
+ if (typeof login === "undefined") {
+ GitHubOAuth.session.clear(cookies);
+ return status(401, SESSION_CHANGE_HEADERS);
+ }
+
+ try {
+ const token = await GitHubOAuth.token.refresh({
+ clientId: GITHUB_OAUTH_CLIENT_ID,
+ clientSecret: GITHUB_OAUTH_CLIENT_SECRET,
+ refreshToken,
+ });
+
+ GitHubOAuth.session.write({ url, token, login }, cookies);
+
+ return status(204);
+ } catch (error) {
+ // timeout or outage, the refresh token may still be good
+ if (!GitHubOAuth.token.isRefreshRejected(error)) {
+ return status(503);
+ }
+
+ // spent, expired or revoked, re-authentication required
+ GitHubOAuth.session.clear(cookies);
+
+ return status(401, SESSION_CHANGE_HEADERS);
+ }
+};
diff --git a/src/pages/vendor/github/sign-out.ts b/src/pages/vendor/github/sign-out.ts
new file mode 100644
index 0000000..3e79ddc
--- /dev/null
+++ b/src/pages/vendor/github/sign-out.ts
@@ -0,0 +1,47 @@
+import {
+ GITHUB_OAUTH_CLIENT_ID,
+ GITHUB_OAUTH_CLIENT_SECRET,
+} from "astro:env/server";
+import type { APIRoute } from "astro";
+
+import { SESSION_CHANGE_HEADERS } from "../../../io/oauth";
+import { GitHubOAuth } from "../../../io/vendor/github/oauth";
+import {
+ GITHUB_COOKIE_TOKEN,
+ GitHubSession,
+} from "../../../io/vendor/github/session";
+
+export const prerender = false;
+
+/** ends the current session */
+export const POST: APIRoute = async ({ cookies }) => {
+ const token = GitHubSession.token.deserialize(
+ cookies.get(GITHUB_COOKIE_TOKEN)?.value,
+ Date.now(),
+ 0,
+ );
+
+ // don't revoke when token is expired / absent
+ if (
+ typeof token !== "undefined" &&
+ GITHUB_OAUTH_CLIENT_ID &&
+ GITHUB_OAUTH_CLIENT_SECRET
+ ) {
+ try {
+ await GitHubOAuth.token.revoke({
+ clientId: GITHUB_OAUTH_CLIENT_ID,
+ clientSecret: GITHUB_OAUTH_CLIENT_SECRET,
+ accessToken: token.token,
+ });
+ } catch {
+ // revocation failed, still terminate session
+ }
+ }
+
+ GitHubOAuth.session.clear(cookies);
+
+ return new Response(null, {
+ status: 204,
+ headers: { "cache-control": "no-store", ...SESSION_CHANGE_HEADERS },
+ });
+};
diff --git a/src/styles/account.css b/src/styles/account.css
new file mode 100644
index 0000000..310e2a8
--- /dev/null
+++ b/src/styles/account.css
@@ -0,0 +1,239 @@
+.account {
+ display: none;
+ align-items: center;
+}
+html[data-feature-account="on"] .account {
+ display: flex;
+}
+html[data-github-session=""] .account {
+ display: flex;
+}
+.account-trigger {
+ anchor-name: --account;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: 6px 10px;
+ border: 1px solid transparent;
+ border-radius: var(--radius-4);
+ background: transparent;
+ color: var(--fg);
+ font: var(--fw-medium) var(--fs-14) / 1 var(--font-sans);
+ white-space: nowrap;
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+.account-trigger:hover,
+.account-trigger:has(+ :popover-open) {
+ background: var(--surface-tint-soft);
+}
+.account-trigger:has(+ :popover-open) {
+ border-color: var(--border);
+}
+.account-trigger:focus-visible {
+ outline: 0;
+ box-shadow: var(--ring-focus);
+}
+
+.account-popover {
+ position-anchor: --account;
+ position-area: bottom span-left;
+ position-try-fallbacks: flip-inline;
+ inset: auto;
+ margin: var(--space-1) 0 0;
+ width: 360px;
+ max-width: calc(100vw - 2 * var(--space-4));
+ padding: var(--space-4);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-3);
+ background: var(--surface);
+ color: var(--fg);
+ box-shadow: var(--shadow-2);
+}
+@supports not (position-area: bottom) {
+ .account-popover {
+ inset: var(--nav-h) var(--space-4) auto auto;
+ }
+}
+.account-popover-title {
+ margin: 0;
+ font-size: var(--fs-14);
+ line-height: 1.4;
+}
+.account-popover-consent {
+ margin: var(--space-2) 0 0;
+ color: var(--fg-muted);
+ font-size: var(--fs-12);
+ line-height: 1.45;
+}
+.account-popover-consent a {
+ color: inherit;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+.account-popover-continue {
+ margin: var(--space-4) 0 var(--space-2);
+ font-size: var(--fs-14);
+ font-weight: var(--fw-semibold);
+}
+.account-provider,
+.account-provider:visited {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-2);
+ background: var(--primary);
+ color: var(--fg-on-primary);
+ font-size: var(--fs-14);
+ font-weight: var(--fw-medium);
+ text-decoration: none;
+ transition: var(--transition-fast);
+}
+.account-provider:hover {
+ background: var(--primary-hover);
+ color: var(--fg-on-primary);
+ text-decoration: none;
+}
+/* GitHub's brand guidelines only allow the mark in pure white or black */
+.account-provider svg path {
+ fill: #fff;
+}
+:root[data-resolved-theme="dark"] .account-provider svg path {
+ fill: #000;
+}
+.account-provider:focus-visible {
+ outline: 0;
+ box-shadow: var(--ring-focus);
+}
+
+.account-avatar {
+ display: none;
+ border-radius: var(--radius-pill);
+}
+.account-avatar:focus-visible {
+ outline: 0;
+ box-shadow: var(--ring-focus);
+}
+.account-avatar img {
+ display: block;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-pill);
+ background: var(--surface-tint-mid);
+}
+html[data-github-session] .account-trigger,
+html[data-github-session] .account-popover {
+ display: none;
+}
+html[data-github-session] .account-avatar {
+ display: block;
+}
+
+@media (max-width: 899px) {
+ .account {
+ gap: var(--space-3);
+ }
+ /* separates the icon-only trigger from the language picker */
+ .account::before {
+ content: "";
+ width: 1px;
+ height: 20px;
+ background: var(--border-strong);
+ }
+ .account-trigger {
+ padding: 6px;
+ }
+ .account-trigger-label {
+ display: none;
+ }
+}
+
+/* only full width where a 360px popover wouldn't fit comfortably */
+@media (max-width: 767px) {
+ .account-popover {
+ position-area: bottom span-all;
+ justify-self: center;
+ width: calc(100vw - 2 * var(--space-4));
+ }
+}
+
+html:not([data-feature-account="on"]) .account-page {
+ display: none;
+}
+.account-page {
+ padding-top: var(--space-10);
+ padding-bottom: var(--space-10);
+}
+.account-stack {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
+.account-action {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+.account-action p {
+ margin: 0;
+ color: var(--fg-muted);
+ font-size: var(--fs-14);
+ line-height: 1.45;
+}
+.account-button {
+ width: 100%;
+ padding: var(--space-3) var(--space-3);
+ border: 0;
+ border-radius: var(--radius-2);
+ background: var(--fg);
+ color: var(--fg-on-primary);
+ font: var(--fw-medium) var(--fs-16) / 1.2 var(--font-sans);
+ text-align: start;
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+.account-button:hover {
+ opacity: 0.9;
+}
+.account-button:focus-visible {
+ outline: 0;
+ box-shadow: var(--ring-focus);
+}
+.account-button:disabled {
+ opacity: 0.5;
+ cursor: progress;
+}
+.account-button-danger {
+ background: var(--error);
+ /* white in light theme, dark in dark theme, where the error red is light */
+ color: var(--fg-on-primary);
+}
+.account-danger {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-5);
+ padding: var(--space-6);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-2);
+}
+.account-danger h2 {
+ margin: 0;
+ color: var(--fg-muted);
+ font-size: var(--fs-16);
+ font-weight: var(--fw-semibold);
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+.account-error {
+ margin: 0;
+ color: var(--error);
+ font-size: var(--fs-14);
+}
+.account-error:empty {
+ display: none;
+}
+html[data-github-session] .account-signed-out,
+html:not([data-github-session]) .account-signed-in {
+ display: none;
+}
diff --git a/src/styles/global.css b/src/styles/global.css
index de4e3bb..9b67b87 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -3,6 +3,7 @@
@import "./base.css";
@import "./nav.css";
@import "./language-picker.css";
+@import "./account.css";
@import "./footer.css";
@import "./controls.css";
@import "./hero.css";
diff --git a/src/types/feature.ts b/src/types/feature.ts
index d927239..aafec2c 100644
--- a/src/types/feature.ts
+++ b/src/types/feature.ts
@@ -5,7 +5,12 @@ export type FeatureDefinition = {
description: string;
};
-export const FEATURES = {} as const satisfies Record;
+export const FEATURES = {
+ account: {
+ label: "Account",
+ description: "Sign in with GitHub and the account page",
+ },
+} as const satisfies Record;
export type FeatureName = keyof typeof FEATURES;
export type FeatureState = Record;
diff --git a/src/utilities/presentation/index.ts b/src/utilities/presentation/index.ts
index c11392a..30800b2 100644
--- a/src/utilities/presentation/index.ts
+++ b/src/utilities/presentation/index.ts
@@ -23,6 +23,7 @@ import {
Link,
List,
Lock,
+ LogIn,
PawPrint,
Pencil,
Printer,
@@ -175,6 +176,7 @@ const PRESENTATION_GENERIC = {
barcode: renderable(Barcode),
imagePlus: renderable(ImagePlus),
undo: renderable(Undo2),
+ logIn: renderable(LogIn),
} as const;
export const device = {
diff --git a/test/github-operations.test.ts b/test/github-operations.test.ts
new file mode 100644
index 0000000..eacad65
--- /dev/null
+++ b/test/github-operations.test.ts
@@ -0,0 +1,58 @@
+import { afterEach, beforeEach, describe, test, vi } from "vitest";
+
+import { GitHubOperation } from "../src/io/vendor/github/operations";
+
+describe("signing out", () => {
+ let expired: string[];
+
+ beforeEach(() => {
+ expired = [];
+
+ vi.stubGlobal("document", {
+ get cookie() {
+ return "github-user=octocat";
+ },
+ set cookie(value: string) {
+ expired.push(value.slice(0, value.indexOf("=")));
+ },
+ });
+ vi.stubGlobal("window", {
+ location: { protocol: "https:" },
+ dispatchEvent: vi.fn(),
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ test("forgets the session once the server confirms", async (t) => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))),
+ );
+
+ t.expect(await GitHubOperation.signOut()).toBe(true);
+ t.expect(expired).toEqual(["github-user", "github-token"]);
+ });
+
+ test("keeps the session when the server can't be reached", async (t) => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.reject(new TypeError("offline"))),
+ );
+
+ t.expect(await GitHubOperation.signOut()).toBe(false);
+ t.expect(expired).toEqual([]);
+ });
+
+ test("keeps the session when the server fails", async (t) => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve(new Response(null, { status: 500 }))),
+ );
+
+ t.expect(await GitHubOperation.signOut()).toBe(false);
+ t.expect(expired).toEqual([]);
+ });
+});
diff --git a/test/github-routes.test.ts b/test/github-routes.test.ts
new file mode 100644
index 0000000..2852ea8
--- /dev/null
+++ b/test/github-routes.test.ts
@@ -0,0 +1,218 @@
+import { afterEach, describe, test, vi } from "vitest";
+import type { APIContext } from "astro";
+
+import { DELETE as revokeAuthorization } from "../src/pages/vendor/github/authorization";
+import { GET as callback } from "../src/pages/vendor/github/callback";
+import { POST as refresh } from "../src/pages/vendor/github/refresh";
+import { POST as signOut } from "../src/pages/vendor/github/sign-out";
+
+const env = vi.hoisted(() => ({ clientId: "Iv1.abc", clientSecret: "s3cret" }));
+
+vi.mock("astro:env/server", () => ({
+ get GITHUB_OAUTH_CLIENT_ID() {
+ return env.clientId;
+ },
+ get GITHUB_OAUTH_CLIENT_SECRET() {
+ return env.clientSecret;
+ },
+}));
+
+const STATE = "a-state";
+
+const context = (
+ cookies: Record,
+ url = "https://example.com/",
+): APIContext => {
+ const jar = { ...cookies };
+
+ return {
+ url: new URL(url),
+ cookies: {
+ get: (name: string) => {
+ if (!(name in jar)) {
+ return undefined;
+ }
+
+ const value = jar[name];
+
+ return {
+ value: typeof value === "string" ? value : JSON.stringify(value),
+ json: () => value,
+ };
+ },
+ set: (name: string, value: unknown) => {
+ jar[name] = value;
+ },
+ delete: (name: string) => {
+ delete jar[name];
+ },
+ },
+ } as unknown as APIContext;
+};
+
+const githubAnswers = (...responses: (() => Response)[]) => {
+ const queue = [...responses];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() =>
+ Promise.resolve(queue.shift()?.() ?? new Response(null, { status: 500 })),
+ ),
+ );
+};
+
+const tokenResponse = () =>
+ Response.json({
+ access_token: "ghu_x",
+ scope: "public_repo",
+ expires_in: 28_800,
+ refresh_token: "ghr_x",
+ refresh_token_expires_in: 15_897_600,
+ });
+
+const liveToken = JSON.stringify({
+ token: "ghu_x",
+ scope: "public_repo",
+ expiresAt: Date.now() + 3_600_000,
+});
+
+const clearsCache = (response: Response): boolean =>
+ response.headers.get("clear-site-data") === '"cache"';
+
+describe("clearing the browser cache when the session changes", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ test("on sign-in", async (t) => {
+ githubAnswers(tokenResponse, () => Response.json({ login: "octocat" }));
+
+ const response = await callback(
+ context(
+ { "github-oauth-hello": { state: STATE, returnTo: "/browse" } },
+ `https://example.com/vendor/github/callback?code=c&state=${STATE}`,
+ ),
+ );
+
+ t.expect(response.status).toBe(302);
+ t.expect(clearsCache(response)).toBe(true);
+ });
+
+ test("not on a failed sign-in", async (t) => {
+ const response = await callback(
+ context(
+ { "github-oauth-hello": { state: STATE, returnTo: "/browse" } },
+ "https://example.com/vendor/github/callback?code=c&state=other",
+ ),
+ );
+
+ t.expect(response.status).toBe(400);
+ t.expect(response.headers.has("clear-site-data")).toBe(false);
+ });
+
+ test("on sign-out", async (t) => {
+ githubAnswers(() => new Response(null, { status: 204 }));
+
+ const response = await signOut(context({ "github-token": liveToken }));
+
+ t.expect(clearsCache(response)).toBe(true);
+ });
+
+ test("on disconnect", async (t) => {
+ githubAnswers(() => new Response(null, { status: 204 }));
+
+ const response = await revokeAuthorization(
+ context({ "github-token": liveToken }),
+ );
+
+ t.expect(response.status).toBe(204);
+ t.expect(clearsCache(response)).toBe(true);
+ });
+
+ test("not when disconnecting fails and the session stays", async (t) => {
+ githubAnswers(() => new Response(null, { status: 500 }));
+
+ const response = await revokeAuthorization(
+ context({ "github-token": liveToken }),
+ );
+
+ t.expect(response.status).toBe(500);
+ t.expect(response.headers.has("clear-site-data")).toBe(false);
+ });
+
+ test("when a failed refresh ends the session", async (t) => {
+ githubAnswers(() => Response.json({ error: "bad_refresh_token" }));
+
+ const response = await refresh(
+ context({ "github-refresh": "ghr_x", "github-user": "octocat" }),
+ );
+
+ t.expect(response.status).toBe(401);
+ t.expect(clearsCache(response)).toBe(true);
+ });
+
+ test("not when a refresh keeps the same account", async (t) => {
+ githubAnswers(tokenResponse);
+
+ const response = await refresh(
+ context({ "github-refresh": "ghr_x", "github-user": "octocat" }),
+ );
+
+ t.expect(response.status).toBe(204);
+ t.expect(response.headers.has("clear-site-data")).toBe(false);
+ });
+});
+
+describe("without an oauth app configured", () => {
+ afterEach(() => {
+ env.clientId = "Iv1.abc";
+ env.clientSecret = "s3cret";
+ vi.unstubAllGlobals();
+ });
+
+ const unconfigure = () => {
+ // the env schema defaults to an empty string, not `undefined`
+ env.clientId = "";
+ env.clientSecret = "";
+
+ const fetched = vi.fn();
+ vi.stubGlobal("fetch", fetched);
+
+ return fetched;
+ };
+
+ test("refresh answers 503 without calling github", async (t) => {
+ const fetched = unconfigure();
+
+ const response = await refresh(
+ context({ "github-refresh": "ghr_x", "github-user": "octocat" }),
+ );
+
+ t.expect(response.status).toBe(503);
+ t.expect(fetched).not.toHaveBeenCalled();
+ });
+
+ test("sign-out still ends the session without calling github", async (t) => {
+ const fetched = unconfigure();
+
+ const response = await signOut(context({ "github-token": liveToken }));
+
+ t.expect(response.status).toBe(204);
+ t.expect(fetched).not.toHaveBeenCalled();
+ });
+});
+
+describe("declining on github", () => {
+ test("leads back to the page sign-in started from", async (t) => {
+ const response = await callback(
+ context(
+ { "github-oauth-hello": { state: STATE, returnTo: "/browse" } },
+ `https://example.com/vendor/github/callback?error=access_denied&state=${STATE}`,
+ ),
+ );
+
+ t.expect(response.status).toBe(400);
+ const body = await response.text();
+ t.expect(body).toContain("GitHub access was not granted.");
+ t.expect(body).toContain('href="/browse"');
+ });
+});
diff --git a/test/github.test.ts b/test/github.test.ts
new file mode 100644
index 0000000..ec19ba3
--- /dev/null
+++ b/test/github.test.ts
@@ -0,0 +1,145 @@
+import { afterEach, describe, test, vi } from "vitest";
+
+import { safeReturnTo } from "../src/io/oauth";
+import { GitHubOAuth } from "../src/io/vendor/github/oauth";
+import { GitHubSession } from "../src/io/vendor/github/session";
+
+describe("safeReturnTo", () => {
+ test("keeps a same-origin path", (t) => {
+ t.expect(safeReturnTo("/devices/42?tab=specs")).toBe(
+ "/devices/42?tab=specs",
+ );
+ });
+
+ test("falls back without a value", (t) => {
+ t.expect(safeReturnTo(null)).toBe("/");
+ t.expect(safeReturnTo("")).toBe("/");
+ });
+
+ test("rejects absolute and protocol-relative targets", (t) => {
+ t.expect(safeReturnTo("https://evil.example/x")).toBe("/");
+ t.expect(safeReturnTo("//evil.example/x")).toBe("/");
+ t.expect(safeReturnTo("/\\evil.example/x")).toBe("/");
+ });
+
+ test("rejects control characters", (t) => {
+ t.expect(safeReturnTo("/about\r\nset-cookie: a=b")).toBe("/");
+ });
+
+ test("drops an existing fragment", (t) => {
+ t.expect(safeReturnTo("/about#anything")).toBe("/about");
+ });
+});
+
+describe("github token cookie", () => {
+ const issued = {
+ accessToken: "ghu_x",
+ scope: "public_repo",
+ expiresIn: 28_800,
+ };
+
+ test("round-trips what the callback writes", (t) => {
+ const value = JSON.stringify(GitHubOAuth.token.cookieValue(issued));
+
+ t.expect(GitHubSession.token.deserialize(value)).toMatchObject({
+ token: "ghu_x",
+ scope: "public_repo",
+ });
+ });
+
+ test("turns expires-in into an absolute deadline", (t) => {
+ const value = GitHubOAuth.token.cookieValue(issued);
+
+ t.expect(value.expiresAt).toBeGreaterThan(Date.now());
+ t.expect(value.expiresAt).toBeLessThanOrEqual(Date.now() + 28_800_000);
+ });
+
+ test("ignores an expired token", (t) => {
+ const value = JSON.stringify({ ...issued, token: "ghu_x", expiresAt: 500 });
+
+ t.expect(GitHubSession.token.deserialize(value, 1000)).toBeUndefined();
+ });
+
+ test("treats a token that is about to expire as expired", (t) => {
+ const now = 1_000_000;
+ const value = JSON.stringify({
+ token: "ghu_x",
+ scope: "public_repo",
+ expiresAt: now + 30_000,
+ });
+
+ t.expect(GitHubSession.token.deserialize(value, now)).toBeUndefined();
+ });
+
+ test("ignores a malformed or truncated cookie", (t) => {
+ t.expect(GitHubSession.token.deserialize(undefined)).toBeUndefined();
+ t.expect(GitHubSession.token.deserialize("not json")).toBeUndefined();
+ t.expect(
+ GitHubSession.token.deserialize('{"token":"ghu_x"}'),
+ ).toBeUndefined();
+ });
+});
+
+describe("revoking app credentials", () => {
+ const grant = {
+ clientId: "Iv1.abc",
+ clientSecret: "s3cret",
+ accessToken: "ghu_x",
+ };
+
+ const answer = (status: number) => {
+ const fetched = vi.fn(() =>
+ Promise.resolve(new Response(null, { status })),
+ );
+ vi.stubGlobal("fetch", fetched);
+
+ return fetched;
+ };
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ test("authenticates as the app, not as the user", async (t) => {
+ const fetched = answer(204);
+
+ await GitHubOAuth.authorization.revoke(grant);
+
+ const [url, init] = fetched.mock.calls[0] as unknown as [
+ string,
+ RequestInit,
+ ];
+ const headers = init.headers as Record;
+
+ t.expect(url).toBe("https://api.github.com/applications/Iv1.abc/grant");
+ t.expect(init.method).toBe("DELETE");
+ t.expect(headers.authorization).toBe(`Basic ${btoa("Iv1.abc:s3cret")}`);
+ t.expect(init.body).toBe('{"access_token":"ghu_x"}');
+ });
+
+ test("takes an already revoked grant for done", async (t) => {
+ answer(404);
+
+ await t
+ .expect(GitHubOAuth.authorization.revoke(grant))
+ .resolves.toBeUndefined();
+ });
+
+ test("signing out revokes the one token, not the grant", async (t) => {
+ const fetched = answer(204);
+
+ await GitHubOAuth.token.revoke(grant);
+
+ const [url] = fetched.mock.calls[0] as unknown as [string, RequestInit];
+
+ t.expect(url).toBe("https://api.github.com/applications/Iv1.abc/token");
+ });
+
+ test("throws when the grant may still stand", async (t) => {
+ answer(500);
+
+ await t
+ .expect(GitHubOAuth.authorization.revoke(grant))
+ .rejects.toThrow("revoke_failed");
+ });
+});