Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):

Expand Down
12 changes: 12 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/assets/github-mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
93 changes: 93 additions & 0 deletions src/components/AccountMenu.astro
Original file line number Diff line number Diff line change
@@ -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,
})}`;
---

<div class="account">
<button
type="button"
class="account-trigger"
popovertarget="account-sign-in"
aria-label={m.account_sign_in()}
>
<Fragment
set:html={render(
generic("logIn"),
PresentationRenderPresetRoleIcon.withSize(18),
)}
/>
<span class="account-trigger-label">{m.account_sign_in()}</span>
</button>
<div id="account-sign-in" class="account-popover" popover>
<p class="account-popover-title">{m.account_sign_in_required()}</p>
<p class="account-popover-consent">
<Message message={m.account_sign_in_consent}>
<fragment slot="termsLink">
{(children: unknown[]) => <a {...termsLink}>{children}</a>}
</fragment>
<fragment slot="privacyLink">
{(children: unknown[]) => <a {...privacyLink}>{children}</a>}
</fragment>
</Message>
</p>
<p class="account-popover-continue">{m.account_sign_in_continue_with()}</p>
<a
class="account-provider"
href={signInHref}
data-astro-reload
data-astro-prefetch="false"
>
<GitHubMark width="18" height="18" class="icon" aria-hidden="true" />
GitHub
</a>
Comment on lines +49 to +57

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image Image

text is currently not visible until hovering 😅

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how I missed this! Fixed

</div>
<a class="account-avatar" href={localizeHref("/account")} data-account-avatar>
<img alt="" width="32" height="32" decoding="async" />
</a>
</div>

<script>
import {
GITHUB_EVENT_SESSION,
GitHubSession,
} from "../io/vendor/github/session";
import { m } from "../paraglide/messages.js";

// the page may be cached for everyone, so the signed-in state is filled in here
const sync = () => {
const login = GitHubSession.login();
document.documentElement.toggleAttribute(
"data-github-session",
typeof login !== "undefined",
);

const avatar = document.querySelector<HTMLAnchorElement>(
"[data-account-avatar]",
);
const image = avatar?.querySelector("img");
if (!avatar || !image || typeof login === "undefined") {
return;
}

avatar.setAttribute("aria-label", m.account_menu_label({ login }));
image.src = `https://avatars.githubusercontent.com/${encodeURIComponent(login)}?s=64`;
};

document.addEventListener("astro:page-load", sync);
window.addEventListener(GITHUB_EVENT_SESSION, sync);
</script>
2 changes: 2 additions & 0 deletions src/components/Nav.astro
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
---
import AccountMenu from "./AccountMenu.astro";
import LanguagePicker from "./LanguagePicker.astro";
import SearchBox from "./SearchBox.astro";

Expand Down Expand Up @@ -73,6 +74,7 @@ const showSearch = page !== "home";
}
<div class="appnav-right">
<LanguagePicker />
<AccountMenu />
</div>
</div>
</header>
Expand Down
185 changes: 185 additions & 0 deletions src/io/oauth.ts
Original file line number Diff line number Diff line change
@@ -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<typeof OAuthState>;

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<string, string>,
options: { userAgent: string; timeoutMs: number },
): Promise<OAuthToken> => {
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,
};
};
Loading
Loading