# Authentication

_a sign-in in front of a wiki published on Cloudflare_

**Intent.** This guide exists so that a project can deploy its wiki on Cloudflare behind a sign-in in the browser, so that only the readers the project chooses can read it. A reader who has not signed in should see none of the wiki.

A wiki published on Cloudflare Pages can ask for a sign-in before it shows any page, in one of two ways:
**Cloudflare Access**, which emails each allowed reader a sign-in code, or a **Pages Function** that asks
the browser for a shared user name and password.[^pin][^middleware] Cloudflare recommends Access for a
production sign-in, and a password prompt is safe only over HTTPS, because it sends the password
unencrypted.[^basic] Publishing the wiki is described on [Cloudflare](../index.md).

`wiki publish` writes a markdown copy beside each page and an `llms.txt` listing them, and each holds the
wiki's text, so a sign-in guards those files as well as the pages.[^copies]

## Cloudflare Access

**Access lets in only the email addresses its policy allows**, and each reader signs in with a code sent to
that address, with no identity provider to connect.[^pin][^policy] The code expires ten minutes after the
reader asks for it.[^pin] A policy's Allow action admits the readers its rules match: named addresses
under Emails, or a whole domain under Emails ending in.[^policy] Access protects a whole address, so the
markdown copies and `llms.txt` sit behind it with the pages.[^hostname][^copies]

Each address the wiki is served from is protected on its own.[^preview][^pagesdev]

| Address | Setting |
|---|---|
| Preview deployments | the project's Settings, then Enable access policy[^preview] |
| `PROJECT.pages.dev` | edit the policy that Enable access policy creates, delete the `*` from its Subdomain field and save, then enable the access policy again[^pagesdev] |
| Custom domain | under Zero Trust, Access controls, then Applications: Create new application, Self-hosted and private, Add public hostname, and the domain[^custom] |

**A custom domain without its own Access policy shows a sign-in that does not work.**[^custom] The Zero
Trust Free plan asks for a payment method when the account is set up, and does not charge it.[^free]

## Password prompt

A Pages Function at `functions/_middleware.js` runs in front of every request, static files included, and
passes an accepted request on to the page.[^middleware] The `functions` folder sits at the root of the
repository Cloudflare builds, not inside `_site`.[^functions] The user name and password are Pages secrets,
added with Encrypt selected under the project's Settings, then Variables and Secrets, before the
deployment that reads them.[^secrets]

The middleware below adapts Cloudflare's Basic Authentication example to Pages: it reads `WIKI_USER` and
`WIKI_PASSWORD` from those secrets, compares both in constant time, and answers a missing or wrong password
with the `401` that makes the browser ask again.[^basic][^secrets]

```js
// functions/_middleware.js
const encoder = new TextEncoder();

// Compares in constant time, so the time taken reveals nothing about the password.
function timingSafeEqual(a, b) {
  const aBytes = encoder.encode(a);
  const bBytes = encoder.encode(b);
  if (aBytes.byteLength !== bBytes.byteLength) {
    return !crypto.subtle.timingSafeEqual(aBytes, aBytes);
  }
  return crypto.subtle.timingSafeEqual(aBytes, bBytes);
}

function signIn() {
  return new Response("Sign in to read the wiki.", {
    status: 401,
    headers: { "WWW-Authenticate": 'Basic realm="wiki", charset="UTF-8"' },
  });
}

export async function onRequest(context) {
  const { WIKI_USER, WIKI_PASSWORD } = context.env;
  if (!WIKI_USER || !WIKI_PASSWORD) {
    return new Response("WIKI_USER and WIKI_PASSWORD are not set.", { status: 500 });
  }
  const [scheme, encoded] = (context.request.headers.get("Authorization") || "").split(" ");
  if (scheme !== "Basic" || !encoded) {
    return signIn();
  }
  let credentials;
  try {
    credentials = atob(encoded);
  } catch {
    return signIn();
  }
  const colon = credentials.indexOf(":");
  const user = credentials.substring(0, colon);
  const password = credentials.substring(colon + 1);
  if (colon < 0 || !timingSafeEqual(WIKI_USER, user) || !timingSafeEqual(WIKI_PASSWORD, password)) {
    return signIn();
  }
  return context.next();
}
```

## External links

- [One-time PIN login](https://developers.cloudflare.com/cloudflare-one/identity/one-time-pin/)
- [Access policies](https://developers.cloudflare.com/cloudflare-one/policies/access/)
- [Known issues](https://developers.cloudflare.com/pages/platform/known-issues/)
- [Middleware](https://developers.cloudflare.com/pages/functions/middleware/)
- [HTTP Basic Authentication](https://developers.cloudflare.com/workers/examples/basic-auth/)

[^pin]: Cloudflare Docs — [One-time PIN login](https://developers.cloudflare.com/cloudflare-one/identity/one-time-pin/):
    a reader enters an email address and, when an Access policy allows it, receives a PIN that expires
    10 minutes after the request; no identity provider is needed.
[^policy]: Cloudflare Docs — [Access policies](https://developers.cloudflare.com/cloudflare-one/policies/access/):
    the Allow action lets in the users a policy's rules match, with the Emails selector for named
    addresses and Emails ending in for a domain.
[^hostname]: Cloudflare Docs — [Publish a self-hosted application to the Internet](https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/self-hosted-public-app/):
    an Access application is added for a public hostname on a domain active on Cloudflare.
[^preview]: Cloudflare Docs — [Preview deployments](https://developers.cloudflare.com/pages/configuration/preview-deployments/):
    Enable access policy protects the preview deployments and not the `pages.dev` domain or a custom
    domain.
[^pagesdev]: Cloudflare Docs — [Known issues](https://developers.cloudflare.com/pages/platform/known-issues/):
    the `pages.dev` domain is protected by deleting the `*` from the Subdomain field of the policy Enable
    access policy creates, saving, and enabling the access policy again.
[^custom]: Cloudflare Docs — [Known issues](https://developers.cloudflare.com/pages/platform/known-issues/):
    a custom domain needs a Self-hosted and private application under Zero Trust, Access controls,
    Applications, and without its policy the Access sign-in renders but does not work.
[^free]: Cloudflare Docs — [Get started](https://developers.cloudflare.com/cloudflare-one/setup/): the
    Zero Trust Free plan still asks for a payment method, and does not charge it.
[^middleware]: Cloudflare Docs — [Middleware](https://developers.cloudflare.com/pages/functions/middleware/):
    `functions/_middleware.js` runs on the entire application, including in front of static files, and
    `context.next()` passes the request on.
[^functions]: Cloudflare Docs — [Functions - Get started](https://developers.cloudflare.com/pages/functions/get-started/):
    the `/functions` directory is at the root of the Pages project, not in the static root.
[^secrets]: Cloudflare Docs — [Bindings](https://developers.cloudflare.com/pages/functions/bindings/): a
    secret is added under Settings, Variables and Secrets with Encrypt selected, is read from
    `context.env`, and is set before the deployment that uses it.
[^basic]: Cloudflare Docs — [HTTP Basic Authentication](https://developers.cloudflare.com/workers/examples/basic-auth/):
    Basic Authentication sends credentials unencrypted and needs HTTPS, Cloudflare Access is recommended
    for production, credentials are compared with `crypto.subtle.timingSafeEqual`, and a `401` carrying
    `WWW-Authenticate` prompts the browser for credentials.
[^copies]: `src/builder/build.py` — `write_site()` writes each page's `index.md` with `markdown_copy()` and
    `llms.txt` with `agent_index()` in every build but a user build; `src/builder/cli.py` — `run()` builds
    `wiki publish` for the internal audience.
