From 85ac5fe67b3c863149ce400c102d79d13af8f5f0 Mon Sep 17 00:00:00 2001 From: Philip Trauner Date: Thu, 22 Oct 2026 19:00:06 +0200 Subject: [PATCH 01/16] feat: server-side portion of github oauth --- README.md | 22 +- astro.config.mjs | 12 ++ src/io/oauth.ts | 168 +++++++++++++++ src/io/vendor/github/oauth.ts | 250 +++++++++++++++++++++++ src/io/vendor/github/operations.ts | 101 +++++++++ src/io/vendor/github/routes.ts | 9 + src/io/vendor/github/session.ts | 136 ++++++++++++ src/pages/vendor/github/authorization.ts | 100 +++++++++ src/pages/vendor/github/authorize.ts | 46 +++++ src/pages/vendor/github/callback.ts | 201 ++++++++++++++++++ src/pages/vendor/github/refresh.ts | 59 ++++++ src/pages/vendor/github/sign-out.ts | 44 ++++ test/github.test.ts | 145 +++++++++++++ 13 files changed, 1288 insertions(+), 5 deletions(-) create mode 100644 src/io/oauth.ts create mode 100644 src/io/vendor/github/oauth.ts create mode 100644 src/io/vendor/github/operations.ts create mode 100644 src/io/vendor/github/routes.ts create mode 100644 src/io/vendor/github/session.ts create mode 100644 src/pages/vendor/github/authorization.ts create mode 100644 src/pages/vendor/github/authorize.ts create mode 100644 src/pages/vendor/github/callback.ts create mode 100644 src/pages/vendor/github/refresh.ts create mode 100644 src/pages/vendor/github/sign-out.ts create mode 100644 test/github.test.ts diff --git a/README.md b/README.md index 3f91ad3..36836ca 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 6e7c09e..80731dd 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/src/io/oauth.ts b/src/io/oauth.ts new file mode 100644 index 0000000..63c36bf --- /dev/null +++ b/src/io/oauth.ts @@ -0,0 +1,168 @@ +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] ?? "/"; +}; + +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", + }, + }); + +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(), + refresh_token: z.string().optional(), + refresh_token_expires_in: z.number().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), + }); + + if (!response.ok) { + throw new OAuthError( + "exchange_failed", + `the provider answered <${response.status}>`, + ); + } + + const parsed = TokenResponse.safeParse(await response.json()); + 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..132e2d2 --- /dev/null +++ b/src/io/vendor/github/oauth.ts @@ -0,0 +1,250 @@ +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, + }), + + /** 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 everything but the account name, which is kept so the page can still greet by name */ + 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..9240b11 --- /dev/null +++ b/src/io/vendor/github/operations.ts @@ -0,0 +1,101 @@ +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; + } + + static signOut(): void { + // survives the navigation when sign-out is the last thing a page does + void fetch(GITHUB_SIGN_OUT_PATH, { method: "POST", keepalive: true }).catch( + () => undefined, + ); + + GitHubSession.forget(); + } + + 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..86e377b --- /dev/null +++ b/src/io/vendor/github/session.ts @@ -0,0 +1,136 @@ +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; + }, + + deserialize: ( + serialized: string | undefined, + now: number = Date.now(), + ): 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 - EXPIRY_SKEW_MS <= 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/pages/vendor/github/authorization.ts b/src/pages/vendor/github/authorization.ts new file mode 100644 index 0000000..1a65588 --- /dev/null +++ b/src/pages/vendor/github/authorization.ts @@ -0,0 +1,100 @@ +import { + GITHUB_OAUTH_CLIENT_ID, + GITHUB_OAUTH_CLIENT_SECRET, +} from "astro:env/server"; +import type { APIRoute } from "astro"; + +import { + GITHUB_COOKIE_REFRESH, + GitHubOAuth, +} from "../../../io/vendor/github/oauth"; +import { + GITHUB_COOKIE_TOKEN, + GitHubSession, +} from "../../../io/vendor/github/session"; + +export const prerender = false; + +/** crafts uncached response with empty body and provided status code */ +const status = (code: number): Response => + new Response(null, { + status: code, + headers: { "cache-control": "no-store" }, + }); + +/** + * 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 => { + const current = GitHubSession.token.deserialize(cookie); + if (typeof current !== "undefined") { + return current.token; + } + + if (!refreshToken) { + return undefined; + } + + const renewed = await GitHubOAuth.token.refresh({ + clientId, + clientSecret, + refreshToken, + }); + + return renewed.accessToken; +}; + +/** + * undoes app authorization grant + * + * needs client secret, therefor happens on server + * + * `204` on success, 500 if grant revocation fails + */ +export const DELETE: APIRoute = async ({ cookies }) => { + if (!GITHUB_OAUTH_CLIENT_ID || !GITHUB_OAUTH_CLIENT_SECRET) { + return status(503); + } + + let accessToken: string | undefined; + try { + accessToken = await revocableToken( + cookies.get(GITHUB_COOKIE_TOKEN)?.value, + cookies.get(GITHUB_COOKIE_REFRESH)?.value, + GITHUB_OAUTH_CLIENT_ID, + GITHUB_OAUTH_CLIENT_SECRET, + ); + } catch { + // 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); + } + + if (!accessToken) { + GitHubOAuth.session.clear(cookies); + + return status(204); + } + + try { + await GitHubOAuth.authorization.revoke({ + clientId: GITHUB_OAUTH_CLIENT_ID, + clientSecret: GITHUB_OAUTH_CLIENT_SECRET, + accessToken, + }); + } catch { + return status(500); + } + + GitHubOAuth.session.clear(cookies); + + return status(204); +}; 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..95a58b8 --- /dev/null +++ b/src/pages/vendor/github/callback.ts @@ -0,0 +1,201 @@ +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 clears 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, + }); + + // GitHub sends `error=access_denied` when the visitor declines + const denied = url.searchParams.get("error"); + if (denied !== null) { + return errorPage({ + status: 400, + message: + url.searchParams.get("error_description") ?? + "GitHub access was not granted.", + }); + } + + // 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, + }); + } + + 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..fd9f0d2 --- /dev/null +++ b/src/pages/vendor/github/refresh.ts @@ -0,0 +1,59 @@ +import { + GITHUB_OAUTH_CLIENT_ID, + GITHUB_OAUTH_CLIENT_SECRET, +} from "astro:env/server"; +import type { APIRoute } from "astro"; + +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): Response => + new Response(null, { + status: code, + headers: { "cache-control": "no-store" }, + }); + +/** trades the refresh token for a new access token */ +export const POST: APIRoute = async ({ cookies, url }) => { + if ( + typeof GITHUB_OAUTH_CLIENT_ID === "undefined" || + typeof GITHUB_OAUTH_CLIENT_SECRET === "undefined" + ) { + return status(503); + } + + const refreshToken = cookies.get(GITHUB_COOKIE_REFRESH)?.value; + if (typeof refreshToken === "undefined") { + GitHubOAuth.session.clear(cookies); + return status(401); + } + + try { + const token = await GitHubOAuth.token.refresh({ + clientId: GITHUB_OAUTH_CLIENT_ID, + clientSecret: GITHUB_OAUTH_CLIENT_SECRET, + refreshToken, + }); + + // account name missing, re-authentication required + const login = cookies.get(GITHUB_COOKIE_USER)?.value; + if (typeof login === "undefined") { + GitHubOAuth.session.clear(cookies); + return status(401); + } + + GitHubOAuth.session.write({ url, token, login }, cookies); + + return status(204); + } catch { + // a refresh token is single-use, re-authentication required + GitHubOAuth.session.clear(cookies); + + return status(401); + } +}; diff --git a/src/pages/vendor/github/sign-out.ts b/src/pages/vendor/github/sign-out.ts new file mode 100644 index 0000000..30cf9bd --- /dev/null +++ b/src/pages/vendor/github/sign-out.ts @@ -0,0 +1,44 @@ +import { + GITHUB_OAUTH_CLIENT_ID, + GITHUB_OAUTH_CLIENT_SECRET, +} from "astro:env/server"; +import type { APIRoute } from "astro"; + +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, + ); + + // don't revoke when token is expired / absent + if ( + typeof token !== "undefined" && + typeof GITHUB_OAUTH_CLIENT_ID !== "undefined" && + typeof GITHUB_OAUTH_CLIENT_SECRET !== "undefined" + ) { + 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" }, + }); +}; 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"); + }); +}); From 0435f885c66d56d0d69560111ba59302567816a7 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:08:16 +0200 Subject: [PATCH 02/16] Clear the browser cache when the GitHub session changes --- src/io/oauth.ts | 4 + src/pages/vendor/github/authorization.ts | 11 +- src/pages/vendor/github/refresh.ts | 11 +- src/pages/vendor/github/sign-out.ts | 3 +- test/github-routes.test.ts | 163 +++++++++++++++++++++++ 5 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 test/github-routes.test.ts diff --git a/src/io/oauth.ts b/src/io/oauth.ts index 63c36bf..961764a 100644 --- a/src/io/oauth.ts +++ b/src/io/oauth.ts @@ -52,6 +52,9 @@ export const safeReturnTo = (value: string | null | undefined): string => { 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, @@ -59,6 +62,7 @@ export const returnToRedirect = (returnTo: string): Response => 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, }, }); diff --git a/src/pages/vendor/github/authorization.ts b/src/pages/vendor/github/authorization.ts index 1a65588..b88a9a5 100644 --- a/src/pages/vendor/github/authorization.ts +++ b/src/pages/vendor/github/authorization.ts @@ -4,6 +4,7 @@ import { } from "astro:env/server"; import type { APIRoute } from "astro"; +import { SESSION_CHANGE_HEADERS } from "../../../io/oauth"; import { GITHUB_COOKIE_REFRESH, GitHubOAuth, @@ -16,10 +17,10 @@ import { export const prerender = false; /** crafts uncached response with empty body and provided status code */ -const status = (code: number): Response => +const status = (code: number, headers: Record = {}): Response => new Response(null, { status: code, - headers: { "cache-control": "no-store" }, + headers: { "cache-control": "no-store", ...headers }, }); /** @@ -75,13 +76,13 @@ export const DELETE: APIRoute = async ({ cookies }) => { // address, and holding on to dead credentials helps nobody GitHubOAuth.session.clear(cookies); - return status(204); + return status(204, SESSION_CHANGE_HEADERS); } if (!accessToken) { GitHubOAuth.session.clear(cookies); - return status(204); + return status(204, SESSION_CHANGE_HEADERS); } try { @@ -96,5 +97,5 @@ export const DELETE: APIRoute = async ({ cookies }) => { GitHubOAuth.session.clear(cookies); - return status(204); + return status(204, SESSION_CHANGE_HEADERS); }; diff --git a/src/pages/vendor/github/refresh.ts b/src/pages/vendor/github/refresh.ts index fd9f0d2..270a241 100644 --- a/src/pages/vendor/github/refresh.ts +++ b/src/pages/vendor/github/refresh.ts @@ -4,6 +4,7 @@ import { } from "astro:env/server"; import type { APIRoute } from "astro"; +import { SESSION_CHANGE_HEADERS } from "../../../io/oauth"; import { GITHUB_COOKIE_REFRESH, GitHubOAuth, @@ -12,10 +13,10 @@ import { GITHUB_COOKIE_USER } from "../../../io/vendor/github/session"; export const prerender = false; -const status = (code: number): Response => +const status = (code: number, headers: Record = {}): Response => new Response(null, { status: code, - headers: { "cache-control": "no-store" }, + headers: { "cache-control": "no-store", ...headers }, }); /** trades the refresh token for a new access token */ @@ -30,7 +31,7 @@ export const POST: APIRoute = async ({ cookies, url }) => { const refreshToken = cookies.get(GITHUB_COOKIE_REFRESH)?.value; if (typeof refreshToken === "undefined") { GitHubOAuth.session.clear(cookies); - return status(401); + return status(401, SESSION_CHANGE_HEADERS); } try { @@ -44,7 +45,7 @@ export const POST: APIRoute = async ({ cookies, url }) => { const login = cookies.get(GITHUB_COOKIE_USER)?.value; if (typeof login === "undefined") { GitHubOAuth.session.clear(cookies); - return status(401); + return status(401, SESSION_CHANGE_HEADERS); } GitHubOAuth.session.write({ url, token, login }, cookies); @@ -54,6 +55,6 @@ export const POST: APIRoute = async ({ cookies, url }) => { // a refresh token is single-use, re-authentication required GitHubOAuth.session.clear(cookies); - return status(401); + return status(401, SESSION_CHANGE_HEADERS); } }; diff --git a/src/pages/vendor/github/sign-out.ts b/src/pages/vendor/github/sign-out.ts index 30cf9bd..118ebcd 100644 --- a/src/pages/vendor/github/sign-out.ts +++ b/src/pages/vendor/github/sign-out.ts @@ -4,6 +4,7 @@ import { } 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, @@ -39,6 +40,6 @@ export const POST: APIRoute = async ({ cookies }) => { return new Response(null, { status: 204, - headers: { "cache-control": "no-store" }, + headers: { "cache-control": "no-store", ...SESSION_CHANGE_HEADERS }, }); }; diff --git a/test/github-routes.test.ts b/test/github-routes.test.ts new file mode 100644 index 0000000..fa0a4ab --- /dev/null +++ b/test/github-routes.test.ts @@ -0,0 +1,163 @@ +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(() => new Response(null, { status: 400 })); + + 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); + }); +}); From e9921e597fefc60020c8e3f623843e4d73ca303b Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:08:22 +0200 Subject: [PATCH 03/16] Fix GitHub routes without a configured app and after declining sign-in --- src/io/vendor/github/oauth.ts | 2 +- src/pages/vendor/github/callback.ts | 1 + src/pages/vendor/github/refresh.ts | 5 +-- src/pages/vendor/github/sign-out.ts | 4 +-- test/github-routes.test.ts | 53 +++++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/io/vendor/github/oauth.ts b/src/io/vendor/github/oauth.ts index 132e2d2..f2d6f0e 100644 --- a/src/io/vendor/github/oauth.ts +++ b/src/io/vendor/github/oauth.ts @@ -214,7 +214,7 @@ export class GitHubOAuth { } }, - /** drops everything but the account name, which is kept so the page can still greet by name */ + /** 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 }); diff --git a/src/pages/vendor/github/callback.ts b/src/pages/vendor/github/callback.ts index 95a58b8..352ce1c 100644 --- a/src/pages/vendor/github/callback.ts +++ b/src/pages/vendor/github/callback.ts @@ -156,6 +156,7 @@ export const GET: APIRoute = async ({ cookies, url }) => { message: url.searchParams.get("error_description") ?? "GitHub access was not granted.", + returnTo: pending.returnTo, }); } diff --git a/src/pages/vendor/github/refresh.ts b/src/pages/vendor/github/refresh.ts index 270a241..ab59e91 100644 --- a/src/pages/vendor/github/refresh.ts +++ b/src/pages/vendor/github/refresh.ts @@ -21,10 +21,7 @@ const status = (code: number, headers: Record = {}): Response => /** trades the refresh token for a new access token */ export const POST: APIRoute = async ({ cookies, url }) => { - if ( - typeof GITHUB_OAUTH_CLIENT_ID === "undefined" || - typeof GITHUB_OAUTH_CLIENT_SECRET === "undefined" - ) { + if (!GITHUB_OAUTH_CLIENT_ID || !GITHUB_OAUTH_CLIENT_SECRET) { return status(503); } diff --git a/src/pages/vendor/github/sign-out.ts b/src/pages/vendor/github/sign-out.ts index 118ebcd..6aef5e9 100644 --- a/src/pages/vendor/github/sign-out.ts +++ b/src/pages/vendor/github/sign-out.ts @@ -22,8 +22,8 @@ export const POST: APIRoute = async ({ cookies }) => { // don't revoke when token is expired / absent if ( typeof token !== "undefined" && - typeof GITHUB_OAUTH_CLIENT_ID !== "undefined" && - typeof GITHUB_OAUTH_CLIENT_SECRET !== "undefined" + GITHUB_OAUTH_CLIENT_ID && + GITHUB_OAUTH_CLIENT_SECRET ) { try { await GitHubOAuth.token.revoke({ diff --git a/test/github-routes.test.ts b/test/github-routes.test.ts index fa0a4ab..fda9c9d 100644 --- a/test/github-routes.test.ts +++ b/test/github-routes.test.ts @@ -161,3 +161,56 @@ describe("clearing the browser cache when the session changes", () => { 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", + ), + ); + + t.expect(response.status).toBe(400); + t.expect(await response.text()).toContain('href="/browse"'); + }); +}); From d813146f992ede1425963541ac5937309b1ddef6 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:10:53 +0100 Subject: [PATCH 04/16] Keep the GitHub session through timeouts and outages --- src/io/oauth.ts | 15 ++++++++- src/io/vendor/github/oauth.ts | 8 +++++ src/pages/vendor/github/authorization.ts | 41 ++++++++++++++++++------ src/pages/vendor/github/refresh.ts | 9 ++++-- test/github-routes.test.ts | 2 +- 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/io/oauth.ts b/src/io/oauth.ts index 961764a..e4968f6 100644 --- a/src/io/oauth.ts +++ b/src/io/oauth.ts @@ -125,6 +125,12 @@ const TokenResponse = z.object({ 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; @@ -150,6 +156,13 @@ export const requestToken = async ( 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", @@ -157,7 +170,7 @@ export const requestToken = async ( ); } - const parsed = TokenResponse.safeParse(await response.json()); + const parsed = TokenResponse.safeParse(payload); if (!parsed.success) { throw new OAuthError("exchange_failed", "unexpected token response"); } diff --git a/src/io/vendor/github/oauth.ts b/src/io/vendor/github/oauth.ts index f2d6f0e..8de2079 100644 --- a/src/io/vendor/github/oauth.ts +++ b/src/io/vendor/github/oauth.ts @@ -157,6 +157,14 @@ export class GitHubOAuth { 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), diff --git a/src/pages/vendor/github/authorization.ts b/src/pages/vendor/github/authorization.ts index b88a9a5..d5512eb 100644 --- a/src/pages/vendor/github/authorization.ts +++ b/src/pages/vendor/github/authorization.ts @@ -11,8 +11,10 @@ import { } 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; @@ -32,10 +34,12 @@ const revocableToken = async ( refreshToken: string | undefined, clientId: string, clientSecret: string, -): Promise => { +): Promise< + { accessToken: string; renewed?: GithubOAuthToken | undefined } | undefined +> => { const current = GitHubSession.token.deserialize(cookie); if (typeof current !== "undefined") { - return current.token; + return { accessToken: current.token }; } if (!refreshToken) { @@ -48,7 +52,7 @@ const revocableToken = async ( refreshToken, }); - return renewed.accessToken; + return { accessToken: renewed.accessToken, renewed }; }; /** @@ -56,22 +60,27 @@ const revocableToken = async ( * * needs client secret, therefor happens on server * - * `204` on success, 500 if grant revocation fails + * `204` on success, `503` if github could not be reached, `500` if grant revocation fails */ -export const DELETE: APIRoute = async ({ cookies }) => { +export const DELETE: APIRoute = async ({ cookies, url }) => { if (!GITHUB_OAUTH_CLIENT_ID || !GITHUB_OAUTH_CLIENT_SECRET) { return status(503); } - let accessToken: string | undefined; + let revocable: Awaited>; try { - accessToken = await revocableToken( + revocable = await revocableToken( cookies.get(GITHUB_COOKIE_TOKEN)?.value, cookies.get(GITHUB_COOKIE_REFRESH)?.value, GITHUB_OAUTH_CLIENT_ID, GITHUB_OAUTH_CLIENT_SECRET, ); - } catch { + } 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); @@ -79,7 +88,7 @@ export const DELETE: APIRoute = async ({ cookies }) => { return status(204, SESSION_CHANGE_HEADERS); } - if (!accessToken) { + if (typeof revocable === "undefined") { GitHubOAuth.session.clear(cookies); return status(204, SESSION_CHANGE_HEADERS); @@ -89,9 +98,21 @@ export const DELETE: APIRoute = async ({ cookies }) => { await GitHubOAuth.authorization.revoke({ clientId: GITHUB_OAUTH_CLIENT_ID, clientSecret: GITHUB_OAUTH_CLIENT_SECRET, - accessToken, + 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); } diff --git a/src/pages/vendor/github/refresh.ts b/src/pages/vendor/github/refresh.ts index ab59e91..8dbab1f 100644 --- a/src/pages/vendor/github/refresh.ts +++ b/src/pages/vendor/github/refresh.ts @@ -48,8 +48,13 @@ export const POST: APIRoute = async ({ cookies, url }) => { GitHubOAuth.session.write({ url, token, login }, cookies); return status(204); - } catch { - // a refresh token is single-use, re-authentication required + } 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/test/github-routes.test.ts b/test/github-routes.test.ts index fda9c9d..44d55b5 100644 --- a/test/github-routes.test.ts +++ b/test/github-routes.test.ts @@ -140,7 +140,7 @@ describe("clearing the browser cache when the session changes", () => { }); test("when a failed refresh ends the session", async (t) => { - githubAnswers(() => new Response(null, { status: 400 })); + githubAnswers(() => Response.json({ error: "bad_refresh_token" })); const response = await refresh( context({ "github-refresh": "ghr_x", "github-user": "octocat" }), From 66f5520fb6d26d350fd27757b2939257b1ef6e3a Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:10:53 +0100 Subject: [PATCH 05/16] Check the account name before using up the refresh token --- src/pages/vendor/github/refresh.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/pages/vendor/github/refresh.ts b/src/pages/vendor/github/refresh.ts index 8dbab1f..3d415da 100644 --- a/src/pages/vendor/github/refresh.ts +++ b/src/pages/vendor/github/refresh.ts @@ -31,6 +31,14 @@ export const POST: APIRoute = async ({ cookies, url }) => { 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, @@ -38,13 +46,6 @@ export const POST: APIRoute = async ({ cookies, url }) => { refreshToken, }); - // account name missing, re-authentication required - const login = cookies.get(GITHUB_COOKIE_USER)?.value; - if (typeof login === "undefined") { - GitHubOAuth.session.clear(cookies); - return status(401, SESSION_CHANGE_HEADERS); - } - GitHubOAuth.session.write({ url, token, login }, cookies); return status(204); From e7cc24591e9818898eb767b6f409271102aebe0f Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:10:53 +0100 Subject: [PATCH 06/16] Revoke the GitHub token on sign-out before the cookie is cleared --- src/io/vendor/github/operations.ts | 10 ++++++---- src/io/vendor/github/session.ts | 7 ++++++- src/pages/vendor/github/authorization.ts | 2 +- src/pages/vendor/github/sign-out.ts | 2 ++ 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/io/vendor/github/operations.ts b/src/io/vendor/github/operations.ts index 9240b11..51ae97e 100644 --- a/src/io/vendor/github/operations.ts +++ b/src/io/vendor/github/operations.ts @@ -70,11 +70,13 @@ export class GitHubOperation { return collapsed; } - static signOut(): void { + static async signOut(): Promise { // survives the navigation when sign-out is the last thing a page does - void fetch(GITHUB_SIGN_OUT_PATH, { method: "POST", keepalive: true }).catch( - () => undefined, - ); + // the token cookie stays until the request is done, the server needs it to revoke + await fetch(GITHUB_SIGN_OUT_PATH, { + method: "POST", + keepalive: true, + }).catch(() => undefined); GitHubSession.forget(); } diff --git a/src/io/vendor/github/session.ts b/src/io/vendor/github/session.ts index 86e377b..1614889 100644 --- a/src/io/vendor/github/session.ts +++ b/src/io/vendor/github/session.ts @@ -80,9 +80,14 @@ export class GitHubSession { 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; @@ -99,7 +104,7 @@ export class GitHubSession { return undefined; } - return parsed.expiresAt - EXPIRY_SKEW_MS <= now ? undefined : parsed; + return parsed.expiresAt - skewMs <= now ? undefined : parsed; }, }; diff --git a/src/pages/vendor/github/authorization.ts b/src/pages/vendor/github/authorization.ts index d5512eb..420a352 100644 --- a/src/pages/vendor/github/authorization.ts +++ b/src/pages/vendor/github/authorization.ts @@ -37,7 +37,7 @@ const revocableToken = async ( ): Promise< { accessToken: string; renewed?: GithubOAuthToken | undefined } | undefined > => { - const current = GitHubSession.token.deserialize(cookie); + const current = GitHubSession.token.deserialize(cookie, Date.now(), 0); if (typeof current !== "undefined") { return { accessToken: current.token }; } diff --git a/src/pages/vendor/github/sign-out.ts b/src/pages/vendor/github/sign-out.ts index 6aef5e9..3e79ddc 100644 --- a/src/pages/vendor/github/sign-out.ts +++ b/src/pages/vendor/github/sign-out.ts @@ -17,6 +17,8 @@ export const prerender = false; 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 From 4b39d13a6bca2d15899f88200946f639f6eded40 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:10:53 +0100 Subject: [PATCH 07/16] Accept token responses without expires_in --- src/io/oauth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/io/oauth.ts b/src/io/oauth.ts index e4968f6..f8c1d47 100644 --- a/src/io/oauth.ts +++ b/src/io/oauth.ts @@ -120,7 +120,7 @@ export const authorizeUrl = (params: { const TokenResponse = z.object({ access_token: z.string().min(1), scope: z.string().optional(), - expires_in: z.number(), + expires_in: z.number().optional(), refresh_token: z.string().optional(), refresh_token_expires_in: z.number().optional(), }); From e5bdf3117c8110c159fcc5d16200f9774b8a55ec Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:10:54 +0100 Subject: [PATCH 08/16] Check the sign-in state before showing GitHub's error --- src/pages/vendor/github/callback.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/pages/vendor/github/callback.ts b/src/pages/vendor/github/callback.ts index 352ce1c..3916ac6 100644 --- a/src/pages/vendor/github/callback.ts +++ b/src/pages/vendor/github/callback.ts @@ -148,24 +148,22 @@ export const GET: APIRoute = async ({ cookies, url }) => { returnTo: pending.returnTo, }); - // GitHub sends `error=access_denied` when the visitor declines - const denied = url.searchParams.get("error"); - if (denied !== null) { + // csrf state mismatch + if (url.searchParams.get("state") !== pending.state) { return errorPage({ status: 400, message: - url.searchParams.get("error_description") ?? - "GitHub access was not granted.", + "This sign-in could not be verified as the one that started here. Please try again.", returnTo: pending.returnTo, }); } - // csrf state mismatch - if (url.searchParams.get("state") !== pending.state) { + // 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: - "This sign-in could not be verified as the one that started here. Please try again.", + message: "GitHub access was not granted.", returnTo: pending.returnTo, }); } From 2697e30812e141e0111a9e3986a0f79f2dc3adcb Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:31:09 +0100 Subject: [PATCH 09/16] Fix declined sign-in test --- test/github-routes.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/github-routes.test.ts b/test/github-routes.test.ts index 44d55b5..2852ea8 100644 --- a/test/github-routes.test.ts +++ b/test/github-routes.test.ts @@ -206,11 +206,13 @@ describe("declining on github", () => { const response = await callback( context( { "github-oauth-hello": { state: STATE, returnTo: "/browse" } }, - "https://example.com/vendor/github/callback?error=access_denied", + `https://example.com/vendor/github/callback?error=access_denied&state=${STATE}`, ), ); t.expect(response.status).toBe(400); - t.expect(await response.text()).toContain('href="/browse"'); + const body = await response.text(); + t.expect(body).toContain("GitHub access was not granted."); + t.expect(body).toContain('href="/browse"'); }); }); From fe9bbd84fb5f05b9bcd4c052658ada5eda194cce Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:31:09 +0100 Subject: [PATCH 10/16] Keep session when sign-out fails --- src/io/vendor/github/operations.ts | 29 +++++++++++---- test/github-operations.test.ts | 58 ++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 test/github-operations.test.ts diff --git a/src/io/vendor/github/operations.ts b/src/io/vendor/github/operations.ts index 51ae97e..671d5a0 100644 --- a/src/io/vendor/github/operations.ts +++ b/src/io/vendor/github/operations.ts @@ -70,15 +70,30 @@ export class GitHubOperation { return collapsed; } - static async signOut(): Promise { - // 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 - await fetch(GITHUB_SIGN_OUT_PATH, { - method: "POST", - keepalive: true, - }).catch(() => undefined); + /** + * `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 { 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([]); + }); +}); From 552cd51131b0efa751c6db2775b4ae563312f02e Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:31:09 +0100 Subject: [PATCH 11/16] Add sign-in menu --- messages/en.json | 5 + src/assets/github-mark.svg | 10 ++ src/components/AccountMenu.astro | 93 +++++++++++++++++ src/components/Nav.astro | 2 + src/layouts/Layout.astro | 14 +++ src/styles/account.css | 152 ++++++++++++++++++++++++++++ src/styles/global.css | 1 + src/utilities/presentation/index.ts | 2 + 8 files changed, 279 insertions(+) create mode 100755 src/assets/github-mark.svg create mode 100644 src/components/AccountMenu.astro create mode 100644 src/styles/account.css diff --git a/messages/en.json b/messages/en.json index 22990a7..e2004af 100644 --- a/messages/en.json +++ b/messages/en.json @@ -13,6 +13,11 @@ "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}", "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/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/styles/account.css b/src/styles/account.css index 64308c2..bc8bdda 100644 --- a/src/styles/account.css +++ b/src/styles/account.css @@ -150,3 +150,80 @@ html[data-github-session] .account-avatar { width: calc(100vw - 2 * var(--space-4)); } } + +.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; +} From ac6f618064550204ea94c7710594d863e9c4e57a Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:31:09 +0100 Subject: [PATCH 13/16] Put account behind feature flag --- src/styles/account.css | 8 +++++++- src/types/feature.ts | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/styles/account.css b/src/styles/account.css index bc8bdda..e75fd97 100644 --- a/src/styles/account.css +++ b/src/styles/account.css @@ -1,7 +1,10 @@ .account { - display: flex; + display: none; align-items: center; } +html[data-feature-account="on"] .account { + display: flex; +} .account-trigger { anchor-name: --account; display: inline-flex; @@ -151,6 +154,9 @@ html[data-github-session] .account-avatar { } } +html:not([data-feature-account="on"]) .account-page { + display: none; +} .account-page { padding-top: var(--space-10); padding-bottom: var(--space-10); 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; From e343803eabed3d0882f8f21d59afc9f820edfc26 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:23:50 +0100 Subject: [PATCH 14/16] Update src/pages/vendor/github/callback.ts Co-authored-by: philip trauner --- src/pages/vendor/github/callback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/vendor/github/callback.ts b/src/pages/vendor/github/callback.ts index 3916ac6..60683e6 100644 --- a/src/pages/vendor/github/callback.ts +++ b/src/pages/vendor/github/callback.ts @@ -99,7 +99,7 @@ const errorPage = (params: { }); }; -/** reads and immediately invalidates the clears pending authorization */ +/** 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 }); From 2ce826e7229768de633d99585d2973273bf2eb19 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:40:27 +0100 Subject: [PATCH 15/16] Fix account-provider visited state style --- src/styles/account.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/styles/account.css b/src/styles/account.css index e75fd97..ce0000d 100644 --- a/src/styles/account.css +++ b/src/styles/account.css @@ -73,7 +73,8 @@ html[data-feature-account="on"] .account { font-size: var(--fs-14); font-weight: var(--fw-semibold); } -.account-provider { +.account-provider, +.account-provider:visited { display: flex; align-items: center; gap: var(--space-2); From 18b64dfd160e98a1f3a8f60f373cd122d48fba99 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:00:12 +0100 Subject: [PATCH 16/16] Update src/styles/account.css Co-authored-by: philip trauner --- src/styles/account.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/styles/account.css b/src/styles/account.css index ce0000d..310e2a8 100644 --- a/src/styles/account.css +++ b/src/styles/account.css @@ -5,6 +5,9 @@ html[data-feature-account="on"] .account { display: flex; } +html[data-github-session=""] .account { + display: flex; +} .account-trigger { anchor-name: --account; display: inline-flex;