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, +})}`; +--- + +
+ +
+ + + + +
+ + + +
+ + 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"); + }); +});