Open sign-up behind SIGNUP=open (off by default) (#126)
With SIGNUP=open, /signup asks for an email address (with a Cloudflare Turnstile widget), emails a 6-digit code (stored as an HMAC, 10 minutes, 5 tries; 5 a day per address, 20 per IP), and a right code leads to the invite-style passkey step, bound to a single-use 30-minute ticket. The passkey register endpoints create an active account, start the session and audit `signup`. An address that already has an account gets the same page and a "someone tried to sign up" email instead of a code. Without Turnstile keys sign-up answers 503, except on a localhost PUBLIC_URL. Off, /signup is 404 and the sign-in page still says invite-only. Migration 0020_signup.sql, new mail kinds signup_code and signup_attempt, /signup POSTs in the RL_AUTH bucket, docs, unit tests and a passkeys e2e test. Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent a9eccbbcommit cdef7045bfda816de3a469e0842d262ae25b189dBrowse files
24 files changed+839−44
Files (24)
- apps/diffinery/docs/accounts/invitations.md+2−2
- apps/diffinery/docs/accounts/notifications.md+5−2
- apps/diffinery/docs/accounts/passkeys-and-sign-in.md+23−5
- apps/diffinery/docs/reference/limits.md+5−1
- apps/diffinery/docs/reference/rest-api.md+1−1
- apps/diffinery/docs/security/audit-log.md+1−0
- apps/diffinery/docs/self-hosting/configuration.md+20−3
- apps/diffinery/migrations/0020_signup.sql+27−0
- apps/diffinery/src/auth/dev.ts+2−1
- apps/diffinery/src/auth/sessions.ts+1−0
- apps/diffinery/src/auth/signup.ts+169−0
- apps/diffinery/src/env.ts+9−0
- apps/diffinery/src/lib/directory.ts+7−3
- apps/diffinery/src/lib/rate-limit.ts+2−2
- apps/diffinery/src/mail/notify.ts+2−1
- apps/diffinery/src/mail/samples.ts+2−0
- apps/diffinery/src/mail/templates.ts+28−0
- apps/diffinery/src/mail/types.ts+6−1
- apps/diffinery/src/web/auth.tsx+214−18
- apps/diffinery/src/web/ui.tsx+8−3
- apps/diffinery/test/e2e/passkeys.test.mjs+131−0
- apps/diffinery/test/unit/email.test.ts+13−1
- apps/diffinery/test/unit/rate-limit.test.ts+3−0
- apps/diffinery/test/unit/signup.test.ts+158−0
modifiedapps/diffinery/docs/accounts/invitations.md+2−2View file
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | description: How people join an invite-only Diffinery instance, who can invite, Add people by username or email, accepting or declining invitations, revoking them, the CLI and API, and access requests. |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | -Diffinery is invite-only: nobody can sign up from the sign-in page. A person joins by opening an invite link sent to their email address, picking a username and creating a passkey. Organizations and repositories have an **Add people** form that takes a username or an email address. Either way the person gets an invitation, as on GitHub: someone added by username accepts it under **Settings → Invitations**, and an email address gets an invite link that also creates the account if there is none. | |
| 6 | +Diffinery is invite-only by default: nobody can sign up from the sign-in page. (An operator can turn on [open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up) with `SIGNUP=open`, where people create an account by confirming their email with a code; invitations work the same either way.) A person joins by opening an invite link sent to their email address, picking a username and creating a passkey. Organizations and repositories have an **Add people** form that takes a username or an email address. Either way the person gets an invitation, as on GitHub: someone added by username accepts it under **Settings → Invitations**, and an email address gets an invite link that also creates the account if there is none. | |
| 7 | 7 | |
| 8 | 8 | ## How sign-up works |
| 9 | 9 | |
| @@ -331,7 +331,7 @@ | ||
| 331 | 331 | |
| 332 | 332 | ## How invitations differ from GitHub |
| 333 | 333 | |
| 334 | -- On GitHub anyone can create an account. On Diffinery the only ways in are an invite, an approved access request, or being listed as a bootstrap administrator of the instance. | |
| 334 | +- On GitHub anyone can create an account. On an invite-only Diffinery instance (the default) the only ways in are an invite, an approved access request, or being listed as a bootstrap administrator of the instance; with open sign-up, anyone who can read mail at their address can join too. | |
| 335 | 335 | - GitHub organization and repository invitations go to existing GitHub users. Diffinery invites can also go to an email address, and the same link creates the account. |
| 336 | 336 | - **Add people** takes a username or an email address, as on GitHub. Someone added by username gets an invitation to accept under **Settings → Invitations**, as on GitHub. An email address always gets an invitation link, whether or not it has an account. |
| 337 | 337 | - Pending invitations of every kind can be revoked, in the web UI, the API or over MCP; otherwise they expire after 14 days. |
modifiedapps/diffinery/docs/accounts/notifications.md+5−2View file
| @@ -47,7 +47,7 @@ | ||
| 47 | 47 | | Group | Emails | Can be turned off | Default | |
| 48 | 48 | | --- | --- | --- | --- | |
| 49 | 49 | | Security | New sign-in from a browser or device not seen before (and every email-recovery sign-in), passkey added or removed, access token created or about to expire, agent added, model API key added, push blocked or push protection bypassed, account disabled or enabled | No | On | |
| 50 | -| Account | Invitations, welcome, sign-in links, test email, agent review's daily limit reached | No | On | | |
| 50 | +| Account | Invitations, welcome, sign-in links, sign-up codes, test email, agent review's daily limit reached | No | On | | |
| 51 | 51 | | Participating | Issues and pull requests you opened, commented on, reviewed, were assigned, were asked to review, were @mentioned in or subscribed to: new issues and pull requests, comments, reviews, pushes, merges, closes, reopens, assignments and review requests | Yes | On | |
| 52 | 52 | | Watching | Everything else in repositories you watch with **All activity** | Yes | On | |
| 53 | 53 | | Actions | Workflow runs you started (failed runs only, unless you turn off **Only failed runs**), and scheduled runs in repositories you watch | Yes | On, failures only | |
| @@ -219,6 +219,7 @@ | ||
| 219 | 219 | | `invitation` | Someone invited this email address or your account to Diffinery, an organization or a repository. | |
| 220 | 220 | | `admin` | You are an administrator (of the organization, repository or instance). | |
| 221 | 221 | | `account` | You have a Diffinery account (account and membership changes). | |
| 222 | +| `signup` | Someone asked to create an account with this address through open sign-up (the sign-up code). | | |
| 222 | 223 | |
| 223 | 224 | ## Every email Diffinery sends |
| 224 | 225 | |
| @@ -240,7 +241,9 @@ | ||
| 240 | 241 | | `push_bypassed` | `[acme/website] Push protection was bypassed` | Security | `security_alert` | A repository admin pushed past push protection, or committed a flagged file through the commits API with `allow_secrets`; sent to that admin and the repository's admins | |
| 241 | 242 | | `account_status` | `[Diffinery] Your account was disabled` or `Your account was enabled` | Security | `account` | A site administrator disabled your account, or enabled it again | |
| 242 | 243 | | `invite` | `@diffy invited you to join the acme organization on Diffinery` | Account | `invitation` | Someone invited your email address (organization, repository or instance), or your username (organization or repository; the button is **View invitation** and opens **Settings → Invitations**) | |
| 243 | -| `welcome` | `Welcome to Diffinery, @diffy` | Account | `account` | You created your account from an invite | | |
| 244 | +| `welcome` | `Welcome to Diffinery, @diffy` | Account | `account` | You created your account from an invite or through open sign-up | | |
| 245 | +| `signup_code` | `[Diffinery] Your sign-up code` | Account | `signup` | With [open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up), someone asked for a code for an address without an account. The code is in the message, not the subject. | | |
| 246 | +| `signup_attempt` | `[Diffinery] Someone tried to sign up with your email address` | Account | `account` | With open sign-up, someone asked for a code for your address, which already has an account (no code is sent) | | |
| 244 | 247 | | `signin_link` | `[Diffinery] Your one-time sign-in link` | Account | `account` | A site administrator created a sign-in link for you | |
| 245 | 248 | | `review_limit_reached` | `[acme/website] Agent review reached its daily limit` | Account | `your_activity` | [Agent review](/docs/agents/review) on a repository whose reviews use your model key reached its daily limit (at most once a day per repository) | |
| 246 | 249 | | `test` | `[Diffinery] Test email` | Account | `account` | You clicked **Send me a test email** (or used the API) | |
modifiedapps/diffinery/docs/accounts/passkeys-and-sign-in.md+23−5View file
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | --- |
| 2 | 2 | title: Passkeys and sign-in |
| 3 | -description: How people sign in to Diffinery with passkeys, manage passkeys and browser sessions, and recover an account when a passkey is lost. | |
| 3 | +description: How people sign in to Diffinery with passkeys, join from an invite or open sign-up, manage passkeys and browser sessions, and recover a lost passkey. | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | -People sign in to Diffinery in the browser with a passkey (WebAuthn). There are no passwords. This page covers signing in, joining from an invite, adding and removing passkeys, managing signed-in browsers, the security emails you get, and how to get back in when you lose a passkey. | |
| 6 | +People sign in to Diffinery in the browser with a passkey (WebAuthn). There are no passwords. This page covers signing in, joining from an invite or through open sign-up, adding and removing passkeys, managing signed-in browsers, the security emails you get, and how to get back in when you lose a passkey. | |
| 7 | 7 | |
| 8 | 8 | Passkeys are only for the web UI. Git, the `diffinery` CLI, scripts and MCP clients use personal access tokens instead (see [Personal access tokens](/docs/accounts/tokens)). For your username, display name, profile picture and light or dark theme, see [Profile and preferences](/docs/accounts/profile-and-preferences). |
| 9 | 9 | |
| @@ -22,7 +22,7 @@ | ||
| 22 | 22 | | Time to complete the browser prompt | 2 minutes. The sign-in attempt itself expires after 5 minutes. | |
| 23 | 23 | | Browser session length | 30 days, extended by another 30 days on each day you use it, up to 90 days after you signed in. | |
| 24 | 24 | |
| 25 | -Diffinery is invite-only. You can't create an account from the sign-in page: new people create their first passkey from an invite link (see [Join from an invite](#join-from-an-invite)). | |
| 25 | +Diffinery is invite-only unless the instance turns on open sign-up. On an invite-only instance (the default) you can't create an account from the sign-in page: new people create their first passkey from an invite link (see [Join from an invite](#join-from-an-invite)). With open sign-up, the sign-in page also links to **Create an account** (see [Open sign-up](#open-sign-up)). | |
| 26 | 26 | |
| 27 | 27 | ## Sign in |
| 28 | 28 | |
| @@ -47,6 +47,20 @@ | ||
| 47 | 47 | |
| 48 | 48 | If the email address already has an account, the invite page asks you to sign in with that account's passkey first; after that the same page shows an **Accept invite** button. See [Invitations](/docs/accounts/invitations) for how invites are created, how long they last and what each kind grants. |
| 49 | 49 | |
| 50 | +## Open sign-up | |
| 51 | + | |
| 52 | +An instance whose operator set `SIGNUP=open` (see [Configuration](/docs/self-hosting/configuration#signup)) lets anyone create an account. The sign-in page then says **New here? Create an account**, which leads to `/signup`: | |
| 53 | + | |
| 54 | +1. Enter your email address and, if the page shows one, complete the Cloudflare Turnstile check. Click **Email me a code**. | |
| 55 | +2. The page says "We sent a code to <address>". Find the email "Your sign-up code" and enter its 6 digits under **Code**, then click **Continue**. The code works once, for 10 minutes; a wrong code says how many tries are left (5 in all), and **send a new code** sends another one (up to 5 a day for an address). | |
| 56 | +3. Pick a **Username** and an optional **Display name**, as when joining from an invite, and click **Create a passkey and join**. This step must be finished within 30 minutes. | |
| 57 | + | |
| 58 | +Diffinery creates your account (active right away, no invite needed), saves the passkey, signs you in and sends a welcome email. Any open invites for your address are accepted at the same time. | |
| 59 | + | |
| 60 | +If the address already has an account, the page reads exactly the same, so nobody can use it to find out who has an account. No code is sent: the account's owner gets an email "Someone tried to sign up with your email address" with a link to sign in instead. | |
| 61 | + | |
| 62 | +On an invite-only instance `/signup` doesn't exist (`404`). Limits on codes are listed in [Limits](/docs/reference/limits#sign-in-and-sessions). | |
| 63 | + | |
| 50 | 64 | ## Manage your passkeys |
| 51 | 65 | |
| 52 | 66 | Go to **Settings → Passkeys and sessions** (`/settings/security`). The **Passkeys** box lists every passkey on your account with: |
| @@ -100,7 +114,8 @@ | ||
| 100 | 114 | | New sign-in to your account | You sign in with a passkey from a browser and passkey combination Diffinery hasn't seen for your account before, and every time you sign in through email recovery. It shows when, the device, the location (city and country) and the IP address. | |
| 101 | 115 | | A passkey was added to your account | You add a passkey in settings, or with a sign-in link from an administrator. | |
| 102 | 116 | | A passkey was removed from your account | You remove a passkey. | |
| 103 | -| Welcome to Diffinery | You create your account from an invite. | | |
| 117 | +| Welcome to Diffinery | You create your account from an invite or through open sign-up. | | |
| 118 | +| Someone tried to sign up with your email address | With open sign-up, someone asked for a sign-up code for your address. No account was created and no code was sent. | | |
| 104 | 119 | | Your one-time sign-in link | A site administrator creates a sign-in link for you. | |
| 105 | 120 | |
| 106 | 121 | A browser is identified by the passkey used plus its operating system and browser name (for example `Mac · Chrome`). Signing in again from the same browser with the same passkey doesn't send another email. The device you joined or added a passkey from counts as known. |
| @@ -164,7 +179,7 @@ | ||
| 164 | 179 | ## How Diffinery sign-in differs from GitHub |
| 165 | 180 | |
| 166 | 181 | - Diffinery has no passwords and no username-and-password sign-in, so there is no password reset and no separate two-factor setup: the passkey's user verification is the second factor. |
| 167 | -- You can't sign up from the sign-in page. Accounts come from invites. | |
| 182 | +- Unless the instance turns on open sign-up, you can't sign up from the sign-in page: accounts come from invites. With open sign-up, an emailed code confirms your address instead of a password. | |
| 168 | 183 | - Account recovery is a one-time link from a site administrator, or email recovery when the instance has it set up. There are no recovery codes. |
| 169 | 184 | - Git over HTTPS and the API never accept a passkey or browser session; they take a personal access token, much like GitHub. |
| 170 | 185 | |
| @@ -180,6 +195,9 @@ | ||
| 180 | 195 | | "This device already has a passkey for this account." | When adding a passkey: pick a different authenticator. | |
| 181 | 196 | | "This browser does not support passkeys..." | Update your browser or use another one. | |
| 182 | 197 | | "this invite is invalid, already used, or expired" | Ask the person who invited you for a new invite. | |
| 198 | +| "That code has expired or was entered wrong too many times. Send a new code." | Sign-up codes last 10 minutes and allow 5 tries. Click **send a new code**. | | |
| 199 | +| "We have sent several codes to <address> today." | At most 5 sign-up codes a day go to one address. Check your spam folder, or try again tomorrow. | | |
| 200 | +| "Sign-up needs Turnstile configured" | The instance turned on open sign-up without Turnstile keys. Ask its administrators for an invite. | | |
| 183 | 201 | | "this sign-in link is invalid, already used, or expired" | Sign-in links work once within 24 hours. Ask a site administrator for a new one. | |
| 184 | 202 | | "Waiting for an invite" page after email recovery | Your email address has no Diffinery account. Ask someone to invite that address, or click **Request access**. | |
| 185 | 203 | | Lost every passkey | Use [email recovery](#email-recovery-through-authaccess) if the sign-in page offers it, otherwise ask a site administrator for a [sign-in link](#sign-in-link-from-an-administrator). | |
modifiedapps/diffinery/docs/reference/limits.md+5−1View file
| @@ -17,8 +17,12 @@ | ||
| 17 | 17 | | Passkey sign-in attempt | 5 minutes | A sign-in or registration that started more than 5 minutes ago can't be completed; start again. | |
| 18 | 18 | | Sign-in link lifetime | 24 hours, single use | Links a site administrator sends with **Sign-in link** in **Settings → People and invitations**. | |
| 19 | 19 | | Invitation lifetime | 14 days | Invitations to the site, an organization or a repository. | |
| 20 | +| Sign-up code | 10 minutes, single use, 5 tries | With open sign-up (`SIGNUP=open`): the 6-digit code emailed from `/signup`. After 5 codes entered for it, right or wrong, it stops working (`That code has expired or was entered wrong too many times`). Sending a new code ends the old one. | | |
| 21 | +| Sign-up codes per address | 5 in 24 hours | Codes asked for one email address, counting the note sent instead when the address already has an account. The next one gets `429 We have sent several codes to <address> today`. | | |
| 22 | +| Sign-up codes per IP address | 20 in 24 hours | Codes asked for from one IP address. The next one gets `429 Too many sign-ups from your network today`. | | |
| 23 | +| Sign-up passkey step | 30 minutes, single use | After the code is accepted, the page for picking a username and creating a passkey. Start again at `/signup` once it runs out. | | |
| 20 | 24 | | Email recovery session at Cloudflare Access | 15 minutes | The Cloudflare Access application that guards `/auth/access` (set by `scripts/bootstrap.sh`). It only covers the email check; the Diffinery session that follows lasts 30 days. | |
| 21 | -| Username when joining from an invite | 39 characters | Letters, digits, `-` and `_`. Some names are reserved (for example `api`, `settings`, `login`, `admin`, `docs`). | | |
| 25 | +| Username when joining from an invite or signing up | 39 characters | Letters, digits, `-` and `_`. Some names are reserved (for example `api`, `settings`, `login`, `admin`, `docs`). | | |
| 22 | 26 | | Display name when joining | 100 characters | Longer names are cut off. | |
| 23 | 27 | | Passkey name | 100 characters | Longer names are cut off. | |
| 24 | 28 | | Repository, organization and agent names | 1 to 100 characters | Start with a letter or digit; then letters, digits, `.`, `_` and `-`. Repository names can't end in `.git`. Organization and agent names can't contain `.` and can't be a reserved name. | |
modifiedapps/diffinery/docs/reference/rest-api.md+1−1View file
| @@ -404,7 +404,7 @@ | ||
| 404 | 404 | |
| 405 | 405 | | What | Limit | Counted by | |
| 406 | 406 | | --- | --- | --- | |
| 407 | -| Sign-in, passkey registration, invitation and sign-in links | 20 a minute | IP address | | |
| 407 | +| Sign-in, passkey registration, invitation and sign-in links, open sign-up (`POST /signup`, `POST /signup/verify`) | 20 a minute | IP address | | |
| 408 | 408 | | Anything else without signing in (web pages, API, git) | 300 a minute | IP address | |
| 409 | 409 | | Anything with a session or token (web, API, MCP, git) | 2,000 a minute | Account | |
| 410 | 410 | | Creating repositories, organizations, forks, issues, pull requests, releases, comments and reviews, and MCP tools that change something | 60 a minute | Account | |
modifiedapps/diffinery/docs/security/audit-log.md+1−0View file
| @@ -52,6 +52,7 @@ | ||
| 52 | 52 | | `passkey.add` | A passkey is added from **Passkeys and sessions**, or through a one-time sign-in link | none | `name`, `passkeyId`, `device`, `ip`, `location`; `via: "signin-link"` when added through a link | |
| 53 | 53 | | `passkey.remove` | A passkey is removed | none | `name` | |
| 54 | 54 | | `session.revoke` | You sign out one of your other browsers with **Sign out** on **Passkeys and sessions** | none | none | |
| 55 | +| `signup` | Someone creates an account and passkey through open sign-up (`SIGNUP=open`), after confirming their email with a code | none | `method` (`passkey`), `email`, `passkeyId`, `device`, `ip`, `location` | | |
| 55 | 56 | | `invite.accept` | Someone creates an account and passkey from an invite link, or an existing user accepts an invite (from its link, **Settings → Invitations**, the **Waiting for an invite** page or the API) | for an existing user who opened the link, the path the invite led to (such as `/acme/website`) | for a new account: `method` (`passkey`), `passkeyId`, `device`, `ip`, `location`; from **Settings → Invitations** or the API: `id` (the invitation id), and `via: "api"` for the API | |
| 56 | 57 | | `invite.decline` | Someone declines an invitation on **Settings → Invitations**, the **Waiting for an invite** page, or with `DELETE /api/v3/user/repository_invitations/{id}` | none | `id` (the invitation id), and `via: "api"` for the API | |
| 57 | 58 | | `token.create` | A personal access token is created on **Access tokens** or with `POST /api/v3/diffinery/tokens` | your login (web) | `name` and `scope`/`scopes`, and for the web the number of repositories it is restricted to | |
modifiedapps/diffinery/docs/self-hosting/configuration.md+20−3View file
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | | --- | --- | --- | |
| 12 | 12 | | `apps/diffinery/wrangler.jsonc` | Worker name, route, variables, bindings, container settings, cron, and the `dev` environment. | Yes. This is the source of truth. | |
| 13 | 13 | | `apps/diffinery/wrangler.deploy.jsonc` | `wrangler.jsonc` without the `dev` environment, plus the real D1 database ID, `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD`, and the route, `PUBLIC_URL` and `MAIL_FROM` for your hostname. Written by `scripts/render-config.mjs` on every deploy and ignored by git. | No. It is regenerated on each deploy. | |
| 14 | -| Worker secrets | `MASTER_KEY` and `OIDC_PRIVATE_KEY`, created by `scripts/ensure-secrets.sh`, and optionally `GITHUB_OAUTH_CLIENT_SECRET`, which you set yourself. | Only to restore them, or to add the optional one. Never change `MASTER_KEY`. | | |
| 14 | +| Worker secrets | `MASTER_KEY` and `OIDC_PRIVATE_KEY`, created by `scripts/ensure-secrets.sh`, and optionally `GITHUB_OAUTH_CLIENT_SECRET` and `TURNSTILE_SECRET`, which you set yourself. | Only to restore them, or to add the optional one. Never change `MASTER_KEY`. | | |
| 15 | 15 | | Repository secrets and variables | Cloudflare credentials and options for the Deploy workflow, and a token for the live test and sync workflows. | Yes, in your forge's repository settings. | |
| 16 | 16 | | `apps/diffinery/.dev.vars` | Local secrets for `wrangler dev`, such as `MASTER_KEY`. Ignored by git. | Yes, for local development only. | |
| 17 | 17 | |
| @@ -28,6 +28,8 @@ | ||
| 28 | 28 | | `ACCESS_AUD` | Empty (filled in on deploy) | For email sign-in | The audience tag of the Access application that guards `/auth/access`. | |
| 29 | 29 | | `ARTIFACT_QUOTA_BYTES` | Not set | No | Workflow artifact storage per repository, in bytes: artifacts that haven't expired plus uploads in progress. Default `10737418240` (10 GB). Anything that isn't a positive whole number counts as not set. Add it under `vars` in `wrangler.jsonc`; the deploy keeps it. When a repository reaches it, uploads fail with `413 the repository's artifact storage quota (N bytes) is used up; delete artifacts or wait for them to expire`. See [Caches and artifacts](/docs/reference/limits#caches-and-artifacts). | |
| 30 | 30 | | `DEV_AUTH` | Not set in production; `dev@diffinery.local` in the `dev` environment | Never in production | Local development only. See [DEV_AUTH](#dev_auth). | |
| 31 | +| `SIGNUP` | Not set | No | `open` lets anyone create an account at `/signup`. See [SIGNUP](#signup). Any other value, or none, keeps the instance invite-only. | | |
| 32 | +| `TURNSTILE_SITE_KEY` | Not set | With `SIGNUP=open` | The site key of the Cloudflare Turnstile widget on the sign-up page. See [SIGNUP](#signup). | | |
| 31 | 33 | | `IMPORT_MAX_REPO_KB` | Not set | No | The largest GitHub repository a one-repository import accepts, in KB as GitHub reports a repository's `size`. Default `10485760` (10 GB). An empty value, `0`, a negative number or text that isn't a number counts as not set. A larger source is refused before the import starts with `422 the repository is about N GB, over the M GB import limit; ask an administrator to raise IMPORT_MAX_REPO_KB or split the repository`. See [GitHub imports](#github-imports). | |
| 32 | 34 | | `QUOTA_HOSTED_MINUTES`, `QUOTA_HOSTED_JOBS`, `QUOTA_SANDBOXES`, `QUOTA_REPOSITORIES` | Not set | No | The default per-account quotas: hosted minutes per month (default `3000`), hosted Actions jobs at once (`5`), agent sandboxes open at once (`5`) and repositories (`500`). `-1` is unlimited; text that isn't a whole number counts as not set. Site administrators change them for one account with `PATCH /api/v3/admin/accounts/{login}/quotas`, and accounts run by a site administrator have none. See [Limits](/docs/reference/limits#account-quotas). | |
| 33 | 35 | | `IMPORT_GHE_HOST` | Not set | No | The host name of a GitHub Enterprise Server (like `ghe.example.com`) that imports may download release assets from. Without it, imports download release assets only from github.com and its asset hosts, and assets of an Enterprise import are skipped with a warning. | |
| @@ -55,8 +57,22 @@ | ||
| 55 | 57 | |
| 56 | 58 | A comma-separated list of email addresses; spaces around entries are ignored and matching is case-insensitive. Logins in the list are ignored, because anyone could pick a free login, while an email address has to be proven (by email sign-in or by opening the link in an invitation email). |
| 57 | 59 | |
| 58 | -An account becomes a site administrator from this list only when it is created (through email sign-in at `/auth/access`, by joining from an invitation, or through `DEV_AUTH` locally), or when an account that is still waiting for an invite signs in through `/auth/access`. Accounts that already exist are not promoted or demoted when you change the list later, and there is no page for granting or removing site administration. See [Site administration](/docs/admin/site-administration). | |
| 60 | +An account becomes a site administrator from this list only when it is created (through email sign-in at `/auth/access`, by joining from an invitation or through open sign-up, or through `DEV_AUTH` locally), or when an account that is still waiting for an invite signs in through `/auth/access`. Accounts that already exist are not promoted or demoted when you change the list later, and there is no page for granting or removing site administration. See [Site administration](/docs/admin/site-administration). | |
| 59 | 61 | |
| 62 | +### SIGNUP | |
| 63 | + | |
| 64 | +With `SIGNUP=open`, the sign-in page links to **Create an account** (`/signup`). Someone enters their email address, gets a 6-digit code by email, enters it, picks a username and creates a passkey; their account is active at once (see [Open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up)). Unset, or any other value, the instance is invite-only as before: `/signup` answers `404` and the sign-in page says so. | |
| 65 | + | |
| 66 | +Open sign-up needs a Cloudflare Turnstile widget, so scripts can't use it to send email: | |
| 67 | + | |
| 68 | +1. In the Cloudflare dashboard, open **Turnstile**, add a widget for your hostname (the one in `PUBLIC_URL`) and copy its site key and secret key. | |
| 69 | +2. Add `"SIGNUP": "open"` and `"TURNSTILE_SITE_KEY": "<site key>"` under `vars` in `wrangler.jsonc`; the deploy keeps them. | |
| 70 | +3. Store the secret: `printf '%s' "$TURNSTILE_SECRET" | pnpm exec wrangler secret put TURNSTILE_SECRET -c wrangler.deploy.jsonc`. | |
| 71 | + | |
| 72 | +Each code request is checked with Turnstile's `siteverify` API, with the visitor's IP address. Without both keys, `/signup` answers `503` with "Sign-up needs Turnstile configured", except when `PUBLIC_URL` is on the local machine (`localhost`, `127.x.x.x` or `[::1]`): local development and the end-to-end tests skip the check. Only the sign-up page's content security policy allows scripts and frames from `https://challenges.cloudflare.com`. | |
| 73 | + | |
| 74 | +The limits on codes (5 a day per address, 20 a day per IP address, 10 minutes and 5 tries each) are in [Limits](/docs/reference/limits#sign-in-and-sessions). Sign-up emails always send: they need the `EMAIL` binding and the `MAIL` queue like every other email. | |
| 75 | + | |
| 60 | 76 | ### DEV_AUTH |
| 61 | 77 | |
| 62 | 78 | When `DEV_AUTH` is set, `ACCESS_AUD` is empty and both `PUBLIC_URL` and the request's own address are on the local machine (`localhost`, `127.x.x.x` or `[::1]`), every request without credentials (no token, no session) acts as the account with the email in `DEV_AUTH`, with full access. That account is created on first use and is a site administrator if the email is also in `BOOTSTRAP_ADMINS`. While `DEV_AUTH` is set, the email log also keeps each whole message, and `GET /api/v3/diffinery/emails` returns it, so tests can read emails without delivering them. |
| @@ -69,9 +85,10 @@ | ||
| 69 | 85 | |
| 70 | 86 | | Secret | Format | Created by | What it does | If it is missing | |
| 71 | 87 | | --- | --- | --- | --- | --- | |
| 72 | -| `MASTER_KEY` | 32 random bytes, base64 | `ensure-secrets.sh`, once | Root key for envelope encryption of Actions secrets: each repository, organization and deployment environment gets its own AES-GCM key derived from it with HKDF. The GitHub token of a running import is encrypted the same way. It also signs the unsubscribe links in emails, the codes and OAuth state for linking GitHub accounts, and a key derived from it (HKDF) signs each job's `ACTIONS_RUNTIME_TOKEN` and the upload and download URLs of the Actions cache and artifact services. | Repository, organization and environment secrets can't be stored or read (`could not store the secret: ... (is MASTER_KEY configured?)`). Notification emails that carry unsubscribe links can't be built, so they end up in the email log as failed; security and account emails still send. Jobs get no `ACTIONS_RUNTIME_TOKEN`: `actions/cache` and the built-in cache step skip caching (`The cache service is not available to this job; skipping.`), and uploading or downloading artifacts fails (`The artifact service is not available to this job.`). Imports that come with a GitHub token can't be started. | | |
| 88 | +| `MASTER_KEY` | 32 random bytes, base64 | `ensure-secrets.sh`, once | Root key for envelope encryption of Actions secrets: each repository, organization and deployment environment gets its own AES-GCM key derived from it with HKDF. The GitHub token of a running import is encrypted the same way. It also signs the unsubscribe links in emails, the codes and OAuth state for linking GitHub accounts, keys the stored hashes of sign-up codes, and a key derived from it (HKDF) signs each job's `ACTIONS_RUNTIME_TOKEN` and the upload and download URLs of the Actions cache and artifact services. | Repository, organization and environment secrets can't be stored or read (`could not store the secret: ... (is MASTER_KEY configured?)`). Notification emails that carry unsubscribe links can't be built, so they end up in the email log as failed; security and account emails still send. Jobs get no `ACTIONS_RUNTIME_TOKEN`: `actions/cache` and the built-in cache step skip caching (`The cache service is not available to this job; skipping.`), and uploading or downloading artifacts fails (`The artifact service is not available to this job.`). Imports that come with a GitHub token can't be started. Open sign-up can't send codes. | | |
| 73 | 89 | | `OIDC_PRIVATE_KEY` | An ECDSA P-256 private key as a JWK (JSON), with a `kid` | `ensure-secrets.sh`, once | Signs the ID tokens that workflow jobs request with `permissions: id-token: write` (ES256). Its public half is served at `/.well-known/jwks`. | Jobs that request an ID token fail with `OIDC is not configured on this Diffinery instance`, `/.well-known/jwks` returns no keys, and `GET /api/v3/meta` returns `"oidc": false`. | |
| 74 | 90 | | `GITHUB_OAUTH_CLIENT_SECRET` | The client secret of a GitHub OAuth app | You, optionally | With `GITHUB_OAUTH_CLIENT_ID`, turns on the **Link with GitHub** option on **Settings → Linked accounts**. | People link GitHub accounts with the gist code only. | |
| 91 | +| `TURNSTILE_SECRET` | The secret key of a Cloudflare Turnstile widget | You, optionally | With `SIGNUP=open` and `TURNSTILE_SITE_KEY`, checks that whoever asks for a sign-up code passed the Turnstile widget. See [SIGNUP](#signup). | With `SIGNUP=open`, sign-up answers `503` "Sign-up needs Turnstile configured" (local development skips the check). Without `SIGNUP=open` it isn't used. | | |
| 75 | 92 | |
| 76 | 93 | Neither secret is rotated automatically. Changing `MASTER_KEY` makes every stored secret undecryptable and invalidates unsubscribe links already sent, along with the runtime tokens of running jobs and cache and artifact links already handed out; keep a copy somewhere safe. To rotate the OIDC key, delete `OIDC_PRIVATE_KEY` and deploy: a new key with a new `kid` (`diffinery-<date>`) is generated, and relying parties pick it up from `/.well-known/jwks`. |
| 77 | 94 |
addedapps/diffinery/migrations/0020_signup.sql+27−0View file
| @@ -1,0 +1,27 @@ | ||
| 1 | +-- Open sign-up (SIGNUP=open): emailed one-time codes and the tickets that let a verified | |
| 2 | +-- address create its account with a passkey. Only hashes are stored. | |
| 3 | + | |
| 4 | +-- One row per code request. An address that already has an account gets a row too (its code is | |
| 5 | +-- never sent), so the daily limits count every sign-up email and wrong codes answer the same. | |
| 6 | +CREATE TABLE signup_codes ( | |
| 7 | + id TEXT PRIMARY KEY, | |
| 8 | + email TEXT NOT NULL, -- lower case | |
| 9 | + ip TEXT NOT NULL, | |
| 10 | + code_hash TEXT NOT NULL, -- HMAC of the 6-digit code | |
| 11 | + attempts INTEGER NOT NULL DEFAULT 0, -- codes entered, right or wrong | |
| 12 | + created_at TEXT NOT NULL, | |
| 13 | + expires_at TEXT NOT NULL, | |
| 14 | + used_at TEXT | |
| 15 | +); | |
| 16 | +CREATE INDEX signup_codes_email ON signup_codes(email, created_at); | |
| 17 | +CREATE INDEX signup_codes_ip ON signup_codes(ip, created_at); | |
| 18 | + | |
| 19 | +-- A verified address may create its account (pick a username, create a passkey) once. | |
| 20 | +CREATE TABLE signup_tickets ( | |
| 21 | + id TEXT PRIMARY KEY, | |
| 22 | + token_hash TEXT NOT NULL UNIQUE, -- sha256 of the token in the browser's form | |
| 23 | + email TEXT NOT NULL, | |
| 24 | + created_at TEXT NOT NULL, | |
| 25 | + expires_at TEXT NOT NULL, | |
| 26 | + used_at TEXT | |
| 27 | +); |
modifiedapps/diffinery/src/auth/dev.ts+2−1View file
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import type { Env } from "../env.js"; |
| 2 | 2 | |
| 3 | 3 | const LOOPBACK = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/; |
| 4 | -const loopback = (url: string | undefined): boolean => { | |
| 4 | +/** Whether `url` is on this machine (localhost, 127.x or [::1]). */ | |
| 5 | +export const loopback = (url: string | undefined): boolean => { | |
| 5 | 6 | try { |
| 6 | 7 | return !!url && LOOPBACK.test(new URL(url).hostname); |
| 7 | 8 | } catch { |
modifiedapps/diffinery/src/auth/sessions.ts+1−0View file
| @@ -44,6 +44,7 @@ | ||
| 44 | 44 | | { kind: "account"; accountId: string } |
| 45 | 45 | | { kind: "invite"; inviteHash: string; email: string; login: string; name: string; accountId: string } |
| 46 | 46 | | { kind: "link"; linkHash: string; accountId: string } |
| 47 | + | { kind: "signup"; ticketHash: string; email: string; login: string; name: string; accountId: string } | |
| 47 | 48 | | { kind: "login" }; |
| 48 | 49 | |
| 49 | 50 | const days = (n: number): string => new Date(Date.now() + n * 86400_000).toISOString(); |
addedapps/diffinery/src/auth/signup.ts+169−0View file
| @@ -1,0 +1,169 @@ | ||
| 1 | +import { loopback } from "./dev.js"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { Account } from "../lib/directory.js"; | |
| 4 | +import { HttpError, b64url, newId, now, randomToken, safeEqual, sha256 } from "../lib/util.js"; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Open sign-up (SIGNUP=open). Someone asks for a code at /signup, proves they read mail at | |
| 8 | + * the address by entering it, and gets a short-lived ticket that lets them pick a username | |
| 9 | + * and create a passkey, the way an invite link does. Off, the instance stays invite-only. | |
| 10 | + * Codes and tickets are stored only as hashes. | |
| 11 | + */ | |
| 12 | + | |
| 13 | +export const CODE_MINUTES = 10; | |
| 14 | +export const TICKET_MINUTES = 30; | |
| 15 | +/** Codes entered for one code, right or wrong; after this many it stops working. */ | |
| 16 | +export const MAX_CODE_ATTEMPTS = 5; | |
| 17 | +/** Sign-up emails (a code, or the "you already have an account" note) per address and per IP address in 24 hours. */ | |
| 18 | +export const CODES_PER_EMAIL_PER_DAY = 5; | |
| 19 | +export const CODES_PER_IP_PER_DAY = 20; | |
| 20 | + | |
| 21 | +export const TURNSTILE_SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js"; | |
| 22 | +export const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com"; | |
| 23 | +const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; | |
| 24 | + | |
| 25 | +export function signupOpen(env: Pick<Env, "SIGNUP">): boolean { | |
| 26 | + return env.SIGNUP === "open"; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * How sign-up checks for a person: "verify" with Turnstile, "skip" in local development | |
| 31 | + * (PUBLIC_URL on this machine) without it, and "missing" when a deployed instance has no | |
| 32 | + * Turnstile keys, which refuses sign-up rather than leave it open to scripts. | |
| 33 | + */ | |
| 34 | +export function turnstileMode(env: Pick<Env, "TURNSTILE_SITE_KEY" | "TURNSTILE_SECRET" | "PUBLIC_URL">): "verify" | "skip" | "missing" { | |
| 35 | + if (env.TURNSTILE_SITE_KEY && env.TURNSTILE_SECRET) return "verify"; | |
| 36 | + return loopback(env.PUBLIC_URL) ? "skip" : "missing"; | |
| 37 | +} | |
| 38 | + | |
| 39 | +/** Ask Cloudflare whether the widget's token is good. Any failure counts as no. */ | |
| 40 | +export async function verifyTurnstile(secret: string, token: string, ip: string | null): Promise<boolean> { | |
| 41 | + if (!token || token.length > 2048) return false; | |
| 42 | + const body = new URLSearchParams({ secret, response: token }); | |
| 43 | + if (ip) body.set("remoteip", ip); | |
| 44 | + try { | |
| 45 | + const r = await fetch(SITEVERIFY, { method: "POST", body }); | |
| 46 | + if (!r.ok) return false; | |
| 47 | + return ((await r.json()) as { success?: boolean }).success === true; | |
| 48 | + } catch (e) { | |
| 49 | + console.error("turnstile siteverify failed", e); | |
| 50 | + return false; | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** An address worth sending a code to, lower-cased; null when it isn't one. */ | |
| 55 | +export function normalEmail(v: string): string | null { | |
| 56 | + const e = v.trim().toLowerCase(); | |
| 57 | + return e.length <= 254 && /^[^\s@<>()",;]+@[^\s@<>()",;]+\.[^\s@<>()",;]+$/.test(e) ? e : null; | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** Six random digits, each code equally likely. */ | |
| 61 | +export function newCode(): string { | |
| 62 | + const buf = new Uint32Array(1); | |
| 63 | + // 4,294,000,000 is the largest multiple of a million below 2^32: drawing below it avoids modulo bias. | |
| 64 | + for (;;) { | |
| 65 | + crypto.getRandomValues(buf); | |
| 66 | + if (buf[0]! < 4_294_000_000) return String(buf[0]! % 1_000_000).padStart(6, "0"); | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * The stored form of a code: an HMAC keyed from MASTER_KEY over the code's row id and the | |
| 72 | + * code, so a copy of the database alone can't try the million codes. | |
| 73 | + */ | |
| 74 | +export async function codeHash(env: Pick<Env, "MASTER_KEY" | "PUBLIC_URL">, id: string, code: string): Promise<string> { | |
| 75 | + // Local development has no MASTER_KEY unless one is passed; production always does. | |
| 76 | + const secret = env.MASTER_KEY ?? (loopback(env.PUBLIC_URL) ? "diffinery-dev-signup" : null); | |
| 77 | + if (!secret) throw new Error("MASTER_KEY is not set, so sign-up codes can't be stored"); | |
| 78 | + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(`diffinery-signup-code-v1:${secret}`), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); | |
| 79 | + return b64url(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${id}:${code}`)))); | |
| 80 | +} | |
| 81 | + | |
| 82 | +const minutes = (n: number): string => new Date(Date.now() + n * 60_000).toISOString(); | |
| 83 | + | |
| 84 | +export type CodeCheck = { ok: true } | { ok: false; left: number }; | |
| 85 | + | |
| 86 | +/** Sign-up codes and tickets in D1. */ | |
| 87 | +export class Signups { | |
| 88 | + constructor(private env: Env) {} | |
| 89 | + | |
| 90 | + private get db() { | |
| 91 | + return this.env.DB; | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** The person account using this address, whatever its case. */ | |
| 95 | + async accountFor(email: string): Promise<Account | null> { | |
| 96 | + return this.db.prepare("SELECT * FROM accounts WHERE kind = 'user' AND lower(email) = ? LIMIT 1").bind(email.toLowerCase()).first<Account>(); | |
| 97 | + } | |
| 98 | + | |
| 99 | + /** | |
| 100 | + * A new code for `email`, requested from `ip`, after the daily limits; older codes for the | |
| 101 | + * address stop working. An address that already has an account gets a code too, which is | |
| 102 | + * never sent: wrong guesses then answer exactly as they would for a new address. | |
| 103 | + */ | |
| 104 | + async issue(email: string, ip: string): Promise<{ code: string; expiresAt: string }> { | |
| 105 | + const since = new Date(Date.now() - 86400_000).toISOString(); | |
| 106 | + const sent = await this.db | |
| 107 | + .prepare("SELECT (SELECT count(*) FROM signup_codes WHERE email = ? AND created_at > ?) AS email, (SELECT count(*) FROM signup_codes WHERE ip = ? AND created_at > ?) AS ip") | |
| 108 | + .bind(email, since, ip, since) | |
| 109 | + .first<{ email: number; ip: number }>(); | |
| 110 | + if ((sent?.email ?? 0) >= CODES_PER_EMAIL_PER_DAY) throw new HttpError(429, `We have sent several codes to ${email} today. Check that inbox (and its spam folder), or try again tomorrow.`); | |
| 111 | + if ((sent?.ip ?? 0) >= CODES_PER_IP_PER_DAY) throw new HttpError(429, "Too many sign-ups from your network today. Try again tomorrow."); | |
| 112 | + const id = newId(); | |
| 113 | + const code = newCode(); | |
| 114 | + const at = now(); | |
| 115 | + const expiresAt = minutes(CODE_MINUTES); | |
| 116 | + await this.db.batch([ | |
| 117 | + this.db.prepare("UPDATE signup_codes SET expires_at = ? WHERE email = ? AND used_at IS NULL AND expires_at > ?").bind(at, email, at), | |
| 118 | + this.db.prepare("INSERT INTO signup_codes (id, email, ip, code_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)").bind(id, email, ip, await codeHash(this.env, id, code), at, expiresAt), | |
| 119 | + // Rows older than a day no longer count towards any limit. | |
| 120 | + this.db.prepare("DELETE FROM signup_codes WHERE created_at < ?").bind(since), | |
| 121 | + ]); | |
| 122 | + return { code, expiresAt }; | |
| 123 | + } | |
| 124 | + | |
| 125 | + /** | |
| 126 | + * Check a code against the address's live one. Every try counts, so at most | |
| 127 | + * MAX_CODE_ATTEMPTS codes are ever compared; a right one works once. | |
| 128 | + */ | |
| 129 | + async check(email: string, code: string): Promise<CodeCheck> { | |
| 130 | + if (!/^\d{6}$/.test(code)) throw new HttpError(422, "Enter the 6-digit code from the email."); | |
| 131 | + const t = now(); | |
| 132 | + const row = await this.db | |
| 133 | + .prepare( | |
| 134 | + "UPDATE signup_codes SET attempts = attempts + 1 WHERE id = (SELECT id FROM signup_codes WHERE email = ? AND used_at IS NULL AND expires_at > ? ORDER BY created_at DESC LIMIT 1) AND attempts < ? RETURNING id, code_hash, attempts", | |
| 135 | + ) | |
| 136 | + .bind(email, t, MAX_CODE_ATTEMPTS) | |
| 137 | + .first<{ id: string; code_hash: string; attempts: number }>(); | |
| 138 | + if (!row) return { ok: false, left: 0 }; | |
| 139 | + if (safeEqual(row.code_hash, await codeHash(this.env, row.id, code))) { | |
| 140 | + const r = await this.db.prepare("UPDATE signup_codes SET used_at = ? WHERE id = ? AND used_at IS NULL").bind(t, row.id).run(); | |
| 141 | + if ((r.meta.changes ?? 0) > 0) return { ok: true }; | |
| 142 | + } | |
| 143 | + return { ok: false, left: Math.max(0, MAX_CODE_ATTEMPTS - row.attempts) }; | |
| 144 | + } | |
| 145 | + | |
| 146 | + /** A single-use ticket for a verified address; returns the token for the passkey form. */ | |
| 147 | + async createTicket(email: string): Promise<string> { | |
| 148 | + const token = randomToken(32); | |
| 149 | + await this.db.batch([ | |
| 150 | + this.db.prepare("DELETE FROM signup_tickets WHERE expires_at < ?").bind(now()), | |
| 151 | + this.db.prepare("INSERT INTO signup_tickets (id, token_hash, email, created_at, expires_at) VALUES (?, ?, ?, ?, ?)").bind(newId(), await sha256(token), email, now(), minutes(TICKET_MINUTES)), | |
| 152 | + ]); | |
| 153 | + return token; | |
| 154 | + } | |
| 155 | + | |
| 156 | + /** An unused, unexpired ticket by its token. */ | |
| 157 | + async ticket(token: string): Promise<{ hash: string; email: string } | null> { | |
| 158 | + if (!/^[A-Za-z0-9_-]{20,64}$/.test(token)) return null; | |
| 159 | + const hash = await sha256(token); | |
| 160 | + const row = await this.db.prepare("SELECT email FROM signup_tickets WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(hash, now()).first<{ email: string }>(); | |
| 161 | + return row ? { hash, email: row.email } : null; | |
| 162 | + } | |
| 163 | + | |
| 164 | + /** Mark a ticket used; false when it was used first or expired. */ | |
| 165 | + async useTicket(hash: string): Promise<boolean> { | |
| 166 | + const r = await this.db.prepare("UPDATE signup_tickets SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(now(), hash, now()).run(); | |
| 167 | + return (r.meta.changes ?? 0) > 0; | |
| 168 | + } | |
| 169 | +} |
modifiedapps/diffinery/src/env.ts+9−0View file
| @@ -71,11 +71,20 @@ | ||
| 71 | 71 | ARTIFACT_QUOTA_BYTES?: string; |
| 72 | 72 | /** Local development only: act as this email when Access is not configured. */ |
| 73 | 73 | DEV_AUTH?: string; |
| 74 | + /** | |
| 75 | + * "open": anyone can create an account at /signup by confirming an emailed code (auth/signup.ts). | |
| 76 | + * Anything else, or unset, keeps the instance invite-only. | |
| 77 | + */ | |
| 78 | + SIGNUP?: string; | |
| 79 | + /** Cloudflare Turnstile widget on the sign-up page; open sign-up needs it and TURNSTILE_SECRET outside local development. */ | |
| 80 | + TURNSTILE_SITE_KEY?: string; | |
| 74 | 81 | |
| 75 | 82 | /** Secret: base64 32 bytes; root key for envelope encryption of secrets. */ |
| 76 | 83 | MASTER_KEY?: string; |
| 77 | 84 | /** Secret: ES256 private JWK (JSON) used to sign OIDC tokens for jobs. */ |
| 78 | 85 | OIDC_PRIVATE_KEY?: string; |
| 86 | + /** Secret: Cloudflare Turnstile secret key, to check the sign-up page's widget (siteverify). */ | |
| 87 | + TURNSTILE_SECRET?: string; | |
| 79 | 88 | /** Optional GitHub Enterprise host (e.g. github.example.com) the importer may download release assets from. */ |
| 80 | 89 | IMPORT_GHE_HOST?: string; |
| 81 | 90 | } |
modifiedapps/diffinery/src/lib/directory.ts+7−3View file
| @@ -113,8 +113,12 @@ | ||
| 113 | 113 | return candidate; |
| 114 | 114 | } |
| 115 | 115 | |
| 116 | - /** Find or create the user behind a verified email (Access recovery, invite links, dev sign-in). */ | |
| 117 | - async userForEmail(email: string, hints: { name?: string; login?: string; id?: string } = {}): Promise<Account> { | |
| 116 | + /** | |
| 117 | + * Find or create the user behind a verified email (Access recovery, invite links, open sign-up, | |
| 118 | + * dev sign-in). A new account waits for an invite unless it has one, is a bootstrap | |
| 119 | + * administrator or `hints.active` says so (open sign-up). | |
| 120 | + */ | |
| 121 | + async userForEmail(email: string, hints: { name?: string; login?: string; id?: string; active?: boolean } = {}): Promise<Account> { | |
| 118 | 122 | const existing = await this.accountByEmail(email); |
| 119 | 123 | if (existing) { |
| 120 | 124 | if (existing.status === "pending" && this.isBootstrapAdmin(email)) { |
| @@ -129,7 +133,7 @@ | ||
| 129 | 133 | const hasInvite = await this.db.prepare("SELECT 1 FROM invites WHERE email = ? AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).first(); |
| 130 | 134 | await this.db |
| 131 | 135 | .prepare("INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES (?, ?, 'user', ?, ?, ?, ?, ?)") |
| 132 | - .bind(id, login, hints.name ?? null, email, admin || hasInvite ? "active" : "pending", admin ? 1 : 0, now()) | |
| 136 | + .bind(id, login, hints.name ?? null, email, admin || hasInvite || hints.active ? "active" : "pending", admin ? 1 : 0, now()) | |
| 133 | 137 | .run(); |
| 134 | 138 | if (hasInvite) await this.acceptInvitesForEmail(id, email); |
| 135 | 139 | return (await this.account(id))!; |
modifiedapps/diffinery/src/lib/rate-limit.ts+2−2View file
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | * the limits stop floods and scripted abuse rather than meter use exactly. A binding that isn't |
| 9 | 9 | * configured (local development) or that fails lets the request through. |
| 10 | 10 | * |
| 11 | - * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, per IP address. | |
| 11 | + * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, open sign-up, per IP address. | |
| 12 | 12 | * - RL_ANON: everything else from someone who isn't signed in, per IP address. |
| 13 | 13 | * - RL_ACCOUNT: everything from a signed-in person, agent or personal token, per account. |
| 14 | 14 | * - RL_CREATE: creating things other people see or that cost storage (repositories, |
| @@ -30,7 +30,7 @@ | ||
| 30 | 30 | |
| 31 | 31 | /** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */ |
| 32 | 32 | const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/; |
| 33 | -const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/))/; | |
| 33 | +const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/))/; | |
| 34 | 34 | const CREATE: Array<[method: string, path: RegExp]> = [ |
| 35 | 35 | ["POST", /^\/api\/v3\/(?:user|orgs\/[^/]+)\/repos$/], |
| 36 | 36 | ["POST", /^\/api\/v3\/(?:user\/orgs|admin\/organizations)$/], |
modifiedapps/diffinery/src/mail/notify.ts+2−1View file
| @@ -330,7 +330,8 @@ | ||
| 330 | 330 | break; |
| 331 | 331 | } |
| 332 | 332 | case "invite.accept": |
| 333 | - // A new account made from an invite (passkey sign-up) gets a welcome email. | |
| 333 | + case "signup": | |
| 334 | + // A new account made from an invite or open sign-up (with a passkey) gets a welcome email. | |
| 334 | 335 | if (e.actorId && s("method") === "passkey") { |
| 335 | 336 | if (s("device")) await store.seeDevice(e.actorId, await deviceFingerprint(s("device")!, s("passkeyId")), s("device")!); |
| 336 | 337 | jobs = to([e.actorId], { kind: "welcome", data: { login: e.actorLogin ?? "" } }, "account"); |
modifiedapps/diffinery/src/mail/samples.ts+2−0View file
| @@ -78,6 +78,8 @@ | ||
| 78 | 78 | item("invite-accepted", "Invitation accepted", { kind: "invite_accepted", reason: "account", data: { login: "rivera", email: "rivera@example.com", org: repo.split("/")[0] } }), |
| 79 | 79 | item("welcome", "Welcome", { kind: "welcome", reason: "account", data: { login: me } }), |
| 80 | 80 | item("signin-link", "Sign-in link", { kind: "signin_link", reason: "account", data: { url: `${pub}/signin/sample`, admin: "admin", expiresAt: new Date(Date.now() + 86400_000).toISOString() } }), |
| 81 | + item("signup-code", "Sign-up code", { kind: "signup_code", reason: "signup", data: { code: "042917", expiresAt: new Date(Date.now() + 600_000).toISOString() } }), | |
| 82 | + item("signup-attempt", "Sign-up with an existing address", { kind: "signup_attempt", reason: "account", data: { email: "diffy@example.com" } }), | |
| 81 | 83 | item("access-request", "Access request", { kind: "access_request", reason: "admin", data: { email: "newcomer@example.com", login: "newcomer", note: "I'm joining the platform team next week." } }), |
| 82 | 84 | item("org-member", "Organization membership", { kind: "org_membership", reason: "account", data: { org: repo.split("/")[0]!, role: "admin", previous: "member", by: "diffy-dev" } }), |
| 83 | 85 | item("repo-access", "Repository access", { kind: "repo_access", reason: "account", data: { repo, role: "write", previous: null } }), |
modifiedapps/diffinery/src/mail/templates.ts+28−0View file
| @@ -165,6 +165,7 @@ | ||
| 165 | 165 | invitation: "You are receiving this because someone invited this email address to Diffinery. If you weren't expecting it, you can ignore this email.", |
| 166 | 166 | admin: "You are receiving this because you are an administrator.", |
| 167 | 167 | account: "You are receiving this because you have a Diffinery account.", |
| 168 | + signup: "You are receiving this because someone asked to create a Diffinery account with this email address. If it wasn't you, you can ignore this email.", | |
| 168 | 169 | }; |
| 169 | 170 | |
| 170 | 171 | const LOGO = (url: string) => `${url}/icon-512.png`; |
| @@ -623,6 +624,33 @@ | ||
| 623 | 624 | ], |
| 624 | 625 | url: job.data.url, |
| 625 | 626 | }; |
| 627 | + case "signup_code": | |
| 628 | + return { | |
| 629 | + // The code stays out of the subject, which the email log keeps. | |
| 630 | + subject: "[Diffinery] Your sign-up code", | |
| 631 | + preheader: `Your code is ${job.data.code}. It expires in 10 minutes.`, | |
| 632 | + eyebrow: { text: "Sign-up", tone: "blue" }, | |
| 633 | + title: ["Confirm your email address"], | |
| 634 | + blocks: [ | |
| 635 | + { p: ["Enter this code on the Diffinery sign-up page to confirm your email address. Then you pick a username and create a passkey."] }, | |
| 636 | + { code: job.data.code }, | |
| 637 | + { p: ["It works once and expires ", at(job.data.expiresAt), ". If you didn't ask to sign up, ignore this email: nothing happens without the code."], muted: true }, | |
| 638 | + ], | |
| 639 | + }; | |
| 640 | + case "signup_attempt": | |
| 641 | + return { | |
| 642 | + subject: "[Diffinery] Someone tried to sign up with your email address", | |
| 643 | + preheader: "No new account was created. Sign in with your passkey instead.", | |
| 644 | + eyebrow: { text: "Account", tone: "amber" }, | |
| 645 | + title: ["Someone tried to sign up with your email address"], | |
| 646 | + blocks: [ | |
| 647 | + { p: ["Someone asked to create a new Diffinery account for ", { b: job.data.email }, ", which already belongs to your account", ...(login ? [" ", { b: user(login) }] : []), ". No account was created and no code was sent."] }, | |
| 648 | + { p: ["If it was you, sign in with your passkey. If you lost it, the sign-in page shows how to recover your account."] }, | |
| 649 | + { button: { label: "Sign in", url: `${pub}/login` } }, | |
| 650 | + { p: ["If it wasn't you, you can ignore this email: nobody can sign up with your address without reading your mail."], muted: true }, | |
| 651 | + ], | |
| 652 | + url: `${pub}/login`, | |
| 653 | + }; | |
| 626 | 654 | case "access_request": |
| 627 | 655 | return { |
| 628 | 656 | subject: `[Diffinery] ${job.data.email} asked for access`, |
modifiedapps/diffinery/src/mail/types.ts+6−1View file
| @@ -19,7 +19,8 @@ | ||
| 19 | 19 | | "your_activity" |
| 20 | 20 | | "invitation" |
| 21 | 21 | | "admin" |
| 22 | - | "account"; | |
| 22 | + | "account" | |
| 23 | + | "signup"; | |
| 23 | 24 | |
| 24 | 25 | /** |
| 25 | 26 | * Settings groups. `security` and `account` always send: they protect the |
| @@ -86,6 +87,8 @@ | ||
| 86 | 87 | | { kind: "invite_accepted"; data: { login: string; email: string; org?: string; repo?: string } } |
| 87 | 88 | | { kind: "welcome"; data: { login: string } } |
| 88 | 89 | | { kind: "signin_link"; data: { url: string; admin: string; expiresAt: string } } |
| 90 | + | { kind: "signup_code"; data: { code: string; expiresAt: string } } | |
| 91 | + | { kind: "signup_attempt"; data: { email: string } } | |
| 89 | 92 | | { kind: "access_request"; data: { email: string; login: string; note: string } } |
| 90 | 93 | | { kind: "org_membership"; data: { org: string; role: string | null; previous: string | null; by?: string } } |
| 91 | 94 | | { kind: "repo_access"; data: { repo: string; role: string | null; previous: string | null } } |
| @@ -124,6 +127,8 @@ | ||
| 124 | 127 | case "invite": |
| 125 | 128 | case "welcome": |
| 126 | 129 | case "signin_link": |
| 130 | + case "signup_code": | |
| 131 | + case "signup_attempt": | |
| 127 | 132 | // It is about the payer's own model spend, so it always sends. |
| 128 | 133 | case "review_limit_reached": |
| 129 | 134 | case "test": |
modifiedapps/diffinery/src/web/auth.tsx+214−18View file
| @@ -15,17 +15,19 @@ | ||
| 15 | 15 | import { Auth, CHALLENGE_COOKIE, SESSION_COOKIE, SESSION_DAYS, type PasskeyRow, type Subject } from "../auth/sessions.js"; |
| 16 | 16 | import type { Account } from "../lib/directory.js"; |
| 17 | 17 | import { verifyAccessJwt } from "../lib/jwt.js"; |
| 18 | -import { HttpError, b64url, fromB64url, isValidLogin, newId } from "../lib/util.js"; | |
| 18 | +import { HttpError, b64url, fromB64url, isValidLogin, newId, now } from "../lib/util.js"; | |
| 19 | 19 | import { ICONS } from "./assets.js"; |
| 20 | 20 | import { Form, act, confirmUrl, flash, post, prm, render, statusOf, type WebApp } from "./ui.js"; |
| 21 | 21 | import { csrfToken } from "./csrf.js"; |
| 22 | 22 | import { validInviteProof } from "../auth/invite-proof.js"; |
| 23 | +import { CODE_MINUTES, Signups, normalEmail, signupOpen, turnstileMode, verifyTurnstile } from "../auth/signup.js"; | |
| 24 | +import { enqueueMail } from "../mail/send.js"; | |
| 23 | 25 | |
| 24 | 26 | /** |
| 25 | 27 | * Passkey (WebAuthn) sign-in. Browsers sign in with a passkey and get a |
| 26 | - * session cookie. New people create their passkey from an invite link, and | |
| 27 | - * admins can issue one-time sign-in links to add a passkey to an existing | |
| 28 | - * account. Cloudflare Access, when configured, protects only /auth/access and | |
| 28 | + * session cookie. New people create their passkey from an invite link (or, | |
| 29 | + * with SIGNUP=open, after confirming their email at /signup), and admins can | |
| 30 | + * issue one-time sign-in links to add a passkey to an existing account. Cloudflare Access, when configured, protects only /auth/access and | |
| 29 | 31 | * serves as an email-based recovery path. |
| 30 | 32 | */ |
| 31 | 33 | |
| @@ -131,6 +133,29 @@ | ||
| 131 | 133 | return !!(c.env.ACCESS_TEAM_DOMAIN && c.env.ACCESS_AUD); |
| 132 | 134 | } |
| 133 | 135 | |
| 136 | +/** Username and display name for a new account (invites and open sign-up). */ | |
| 137 | +const NewAccountFields: FC<{ email: string }> = ({ email }) => ( | |
| 138 | + <> | |
| 139 | + <div class="field"> | |
| 140 | + <label for="login">Username</label> | |
| 141 | + <input id="login" name="login" type="text" value={loginFromEmail(email)} required pattern="[A-Za-z0-9][A-Za-z0-9_\-]{0,38}" autocomplete="username" /> | |
| 142 | + <div class="hint">Your repositories live at /{"{username}"}/…</div> | |
| 143 | + </div> | |
| 144 | + <div class="field"> | |
| 145 | + <label for="name">Display name</label> | |
| 146 | + <input id="name" name="name" type="text" maxlength={100} autocomplete="name" /> | |
| 147 | + </div> | |
| 148 | + </> | |
| 149 | +); | |
| 150 | + | |
| 151 | +/** The username and display name a new account asked for, once they check out. */ | |
| 152 | +async function newAccountNames(c: AppContext, body: Record<string, unknown>, email: string): Promise<{ login: string; name: string }> { | |
| 153 | + const login = str(body.login).trim() || loginFromEmail(email); | |
| 154 | + if (!isValidLogin(login) || login.length > 39) throw new HttpError(422, "Usernames use letters, numbers and dashes (up to 39), and some names are reserved."); | |
| 155 | + if (await c.get("dir").accountByLogin(login)) throw new HttpError(409, `The username ${login} is taken. Pick another one.`); | |
| 156 | + return { login, name: str(body.name).trim().slice(0, 100) }; | |
| 157 | +} | |
| 158 | + | |
| 134 | 159 | /** Invite landing for someone without an account: create a passkey and join in one step. */ |
| 135 | 160 | export async function invitePasskeyPage(c: AppContext, token: string): Promise<Response> { |
| 136 | 161 | const auth = new Auth(c.env); |
| @@ -188,15 +213,7 @@ | ||
| 188 | 213 | <form data-passkey="register" data-next="/"> |
| 189 | 214 | <input type="hidden" name="invite" value={token} /> |
| 190 | 215 | <input type="hidden" name="proof" value={proof} /> |
| 191 | - <div class="field"> | |
| 192 | - <label for="login">Username</label> | |
| 193 | - <input id="login" name="login" type="text" value={loginFromEmail(inv.email)} required pattern="[A-Za-z0-9][A-Za-z0-9_\-]{0,38}" autocomplete="username" /> | |
| 194 | - <div class="hint">Your repositories live at /{"{username}"}/…</div> | |
| 195 | - </div> | |
| 196 | - <div class="field"> | |
| 197 | - <label for="name">Display name</label> | |
| 198 | - <input id="name" name="name" type="text" maxlength={100} autocomplete="name" /> | |
| 199 | - </div> | |
| 216 | + <NewAccountFields email={inv.email} /> | |
| 200 | 217 | <PasskeyMessage /> |
| 201 | 218 | <button class="btn primary block">Create a passkey and join</button> |
| 202 | 219 | </form> |
| @@ -210,6 +227,103 @@ | ||
| 210 | 227 | </noscript> |
| 211 | 228 | ); |
| 212 | 229 | |
| 230 | +// ---------------------------------------------------------------- open sign-up (SIGNUP=open, auth/signup.ts) | |
| 231 | + | |
| 232 | +const TURNSTILE_MISSING = "Sign-up needs Turnstile configured: this instance has no TURNSTILE_SITE_KEY and TURNSTILE_SECRET. Ask its administrators for an invite."; | |
| 233 | + | |
| 234 | +const StepError: FC<{ error?: string }> = ({ error }) => (error ? <div class="flash error" role="alert">{error}</div> : null); | |
| 235 | + | |
| 236 | +/** Step 1: the email address (and the Turnstile widget). */ | |
| 237 | +function signupPage(c: AppContext, o: { email?: string; error?: string; status?: number } = {}): Response | Promise<Response> { | |
| 238 | + const mode = turnstileMode(c.env); | |
| 239 | + const siteKey = mode === "verify" ? c.env.TURNSTILE_SITE_KEY : undefined; | |
| 240 | + return render( | |
| 241 | + c, | |
| 242 | + { title: "Create an account", status: mode === "missing" ? 503 : o.status, turnstile: !!siteKey }, | |
| 243 | + <AuthCard title="Create your Diffinery account"> | |
| 244 | + {mode === "missing" ? ( | |
| 245 | + <StepError error={TURNSTILE_MISSING} /> | |
| 246 | + ) : ( | |
| 247 | + <> | |
| 248 | + <p class="muted">Enter your email address. We'll send you a code to confirm it, then you pick a username and create a passkey. There is no password.</p> | |
| 249 | + <StepError error={o.error} /> | |
| 250 | + <Form action="/signup" t={csrfToken(c)}> | |
| 251 | + <div class="field"> | |
| 252 | + <label for="email">Email address</label> | |
| 253 | + <input id="email" name="email" type="email" value={o.email} required maxlength={254} autocomplete="email" /> | |
| 254 | + </div> | |
| 255 | + {siteKey ? <div class="cf-turnstile" data-sitekey={siteKey} data-theme="auto" style="margin-bottom:12px"></div> : null} | |
| 256 | + <button class="btn primary block">Email me a code</button> | |
| 257 | + </Form> | |
| 258 | + </> | |
| 259 | + )} | |
| 260 | + <div class="authfoot muted small"> | |
| 261 | + <p> | |
| 262 | + Already have an account? <a href="/login">Sign in</a>. | |
| 263 | + </p> | |
| 264 | + </div> | |
| 265 | + </AuthCard>, | |
| 266 | + ); | |
| 267 | +} | |
| 268 | + | |
| 269 | +/** Step 2: the code from the email. */ | |
| 270 | +function signupCodePage(c: AppContext, o: { email: string; error?: string; status?: number }): Response | Promise<Response> { | |
| 271 | + return render( | |
| 272 | + c, | |
| 273 | + { title: "Enter your code", status: o.status }, | |
| 274 | + <AuthCard title="Check your email"> | |
| 275 | + <p class="muted"> | |
| 276 | + We sent a code to <b>{o.email}</b>. Enter it here within {CODE_MINUTES} minutes. | |
| 277 | + </p> | |
| 278 | + <StepError error={o.error} /> | |
| 279 | + <Form action="/signup/verify" t={csrfToken(c)}> | |
| 280 | + <input type="hidden" name="email" value={o.email} /> | |
| 281 | + <div class="field"> | |
| 282 | + <label for="code">Code</label> | |
| 283 | + <input id="code" name="code" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength={6} required autocomplete="one-time-code" /> | |
| 284 | + </div> | |
| 285 | + <button class="btn primary block">Continue</button> | |
| 286 | + </Form> | |
| 287 | + <div class="authfoot muted small"> | |
| 288 | + <p> | |
| 289 | + No email? Check your spam folder, or <a href="/signup">send a new code</a>. | |
| 290 | + </p> | |
| 291 | + </div> | |
| 292 | + </AuthCard>, | |
| 293 | + ); | |
| 294 | +} | |
| 295 | + | |
| 296 | +/** Step 3: a username and a passkey, bound to the single-use ticket for the confirmed address. */ | |
| 297 | +function signupPasskeyPage(c: AppContext, email: string, ticket: string): Response | Promise<Response> { | |
| 298 | + return render( | |
| 299 | + c, | |
| 300 | + { title: "Join Diffinery" }, | |
| 301 | + <AuthCard title="Join Diffinery"> | |
| 302 | + <p class="muted"> | |
| 303 | + <b>{email}</b> is confirmed. Pick a username, then create a passkey. Your device keeps it and unlocks it with your fingerprint, face or screen lock, so there is no password to remember. | |
| 304 | + </p> | |
| 305 | + <PasskeyUnsupported /> | |
| 306 | + <form data-passkey="register" data-next="/"> | |
| 307 | + <input type="hidden" name="signup" value={ticket} /> | |
| 308 | + <NewAccountFields email={email} /> | |
| 309 | + <PasskeyMessage /> | |
| 310 | + <button class="btn primary block">Create a passkey and join</button> | |
| 311 | + </form> | |
| 312 | + </AuthCard>, | |
| 313 | + ); | |
| 314 | +} | |
| 315 | + | |
| 316 | +/** Run a sign-up step. A refusal shows on that step's page with its status, keeping what was typed. */ | |
| 317 | +async function signupStep(fn: () => Promise<Response>, page: (error: string, status: number) => Response | Promise<Response>): Promise<Response> { | |
| 318 | + try { | |
| 319 | + return await fn(); | |
| 320 | + } catch (e) { | |
| 321 | + const s = statusOf(e); | |
| 322 | + if (s.status >= 500 && s.status !== 503) throw e; | |
| 323 | + return page(s.message, s.status); | |
| 324 | + } | |
| 325 | +} | |
| 326 | + | |
| 213 | 327 | export function registerAuth(web: WebApp): void { |
| 214 | 328 | web.get("/login", async (c) => { |
| 215 | 329 | const next = safeNext(c.req.query("next")); |
| @@ -253,7 +367,13 @@ | ||
| 253 | 367 | <button class="btn primary block">Sign in with a passkey</button> |
| 254 | 368 | </form> |
| 255 | 369 | <div class="authfoot muted small"> |
| 256 | - <p>New here? Diffinery is invite-only. Open the invite link someone sent you to create your passkey.</p> | |
| 370 | + {signupOpen(c.env) ? ( | |
| 371 | + <p> | |
| 372 | + New here? <a href="/signup">Create an account</a>. | |
| 373 | + </p> | |
| 374 | + ) : ( | |
| 375 | + <p>New here? Diffinery is invite-only. Open the invite link someone sent you to create your passkey.</p> | |
| 376 | + )} | |
| 257 | 377 | {accessConfigured(c) ? ( |
| 258 | 378 | <p> |
| 259 | 379 | Lost your passkey? <a href={`/auth/access?next=${encodeURIComponent(next)}`}>Recover your account by email</a>. |
| @@ -344,19 +464,27 @@ | ||
| 344 | 464 | let existing: PasskeyRow[] = []; |
| 345 | 465 | const inviteToken = str(body.invite); |
| 346 | 466 | const linkToken = str(body.link); |
| 467 | + const signupToken = str(body.signup); | |
| 347 | 468 | if (inviteToken) { |
| 348 | 469 | const inv = await auth.invite(inviteToken); |
| 349 | 470 | if (!inv) throw new HttpError(404, "This invite is invalid, already used, or expired."); |
| 350 | 471 | if (!inv.email) throw new HttpError(422, "This invite has no email address. Sign in first, then open it again."); |
| 351 | 472 | if (!(await validInviteProof(c.env, inv.hash, str(body.proof)))) throw new HttpError(403, `Open the link in the invitation email sent to ${inv.email} to create your account.`); |
| 352 | 473 | if (await dir.accountByEmail(inv.email)) throw new HttpError(409, `${inv.email} already has an account. Sign in with its passkey, then open this invite again.`); |
| 353 | - const login = str(body.login).trim() || loginFromEmail(inv.email); | |
| 354 | - if (!isValidLogin(login) || login.length > 39) throw new HttpError(422, "Usernames use letters, numbers and dashes (up to 39), and some names are reserved."); | |
| 355 | - if (await dir.accountByLogin(login)) throw new HttpError(409, `The username ${login} is taken. Pick another one.`); | |
| 356 | - const name = str(body.name).trim().slice(0, 100); | |
| 474 | + const { login, name } = await newAccountNames(c, body, inv.email); | |
| 357 | 475 | const id = newId(); |
| 358 | 476 | subject = { kind: "invite", inviteHash: inv.hash, email: inv.email, login, name, accountId: id }; |
| 359 | 477 | user = { id, name: login, displayName: name || login }; |
| 478 | + } else if (signupToken) { | |
| 479 | + if (!signupOpen(c.env)) throw new HttpError(404, "Sign-up is closed on this instance."); | |
| 480 | + const signups = new Signups(c.env); | |
| 481 | + const ticket = await signups.ticket(signupToken); | |
| 482 | + if (!ticket) throw new HttpError(404, "This sign-up expired or was already used. Start again at /signup."); | |
| 483 | + if (await signups.accountFor(ticket.email)) throw new HttpError(409, `${ticket.email} already has an account. Sign in with its passkey.`); | |
| 484 | + const { login, name } = await newAccountNames(c, body, ticket.email); | |
| 485 | + const id = newId(); | |
| 486 | + subject = { kind: "signup", ticketHash: ticket.hash, email: ticket.email, login, name, accountId: id }; | |
| 487 | + user = { id, name: login, displayName: name || login }; | |
| 360 | 488 | } else if (linkToken) { |
| 361 | 489 | const link = await auth.signinLink(linkToken); |
| 362 | 490 | if (!link) throw new HttpError(404, "This sign-in link is invalid, already used, or expired."); |
| @@ -438,6 +566,19 @@ | ||
| 438 | 566 | const repo = inv.repo_id ? await dir.repoById(inv.repo_id) : null; |
| 439 | 567 | const org = !repo && inv.org_id ? await dir.account(inv.org_id) : null; |
| 440 | 568 | redirect = repo ? `/${repo.owner_login}/${repo.name}` : org ? `/${org.login}` : "/"; |
| 569 | + } else if (s.kind === "signup") { | |
| 570 | + if (!signupOpen(c.env)) throw new HttpError(404, "Sign-up is closed on this instance."); | |
| 571 | + const signups = new Signups(c.env); | |
| 572 | + if (await signups.accountFor(s.email)) throw new HttpError(409, `${s.email} already has an account.`); | |
| 573 | + if (await dir.accountByLogin(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`); | |
| 574 | + if (!(await signups.useTicket(s.ticketHash))) throw new HttpError(404, "This sign-up was used or expired in the meantime. Start again at /signup."); | |
| 575 | + // Open sign-up makes an active account (and accepts any open invites for the address). | |
| 576 | + account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId, active: true }); | |
| 577 | + if (account.id !== s.accountId) throw new HttpError(409, `${s.email} already has an account.`); | |
| 578 | + await auth.addPasskey({ ...key, account_id: account.id }); | |
| 579 | + c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "signup", detail: { method: "passkey", email: s.email, passkeyId: key.id, ...signinContext(c) } })); | |
| 580 | + flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`); | |
| 581 | + redirect = "/"; | |
| 441 | 582 | } else if (s.kind === "link") { |
| 442 | 583 | if (!(await auth.useSigninLink(s.linkHash))) throw new HttpError(404, "This sign-in link was used or expired in the meantime."); |
| 443 | 584 | const found = await dir.account(s.accountId); |
| @@ -475,6 +616,61 @@ | ||
| 475 | 616 | }, `We emailed the link to ${inv.email}. Open it from that inbox to create your account.`); |
| 476 | 617 | }); |
| 477 | 618 | |
| 619 | + // ------------------------------------------------------------ open sign-up | |
| 620 | + | |
| 621 | + // Off, sign-up doesn't exist: its pages answer 404 like any unknown path, before any CSRF check. | |
| 622 | + for (const path of ["/signup", "/signup/*"]) { | |
| 623 | + web.use(path, async (c, next) => { | |
| 624 | + if (!signupOpen(c.env)) throw new HttpError(404, "not found"); | |
| 625 | + await next(); | |
| 626 | + }); | |
| 627 | + } | |
| 628 | + | |
| 629 | + web.get("/signup", async (c) => { | |
| 630 | + const p = c.get("principal"); | |
| 631 | + if (p.type === "account" && p.via !== "dev") return c.redirect("/", 302); | |
| 632 | + return signupPage(c); | |
| 633 | + }); | |
| 634 | + | |
| 635 | + post(web, "/signup", async (c, form) => { | |
| 636 | + const typed = form.s("email").trim().slice(0, 254); | |
| 637 | + return signupStep( | |
| 638 | + async () => { | |
| 639 | + const mode = turnstileMode(c.env); | |
| 640 | + if (mode === "missing") throw new HttpError(503, TURNSTILE_MISSING); | |
| 641 | + const email = normalEmail(typed); | |
| 642 | + if (!email) throw new HttpError(422, "Enter a valid email address."); | |
| 643 | + const ip = c.req.header("cf-connecting-ip") ?? null; | |
| 644 | + if (mode === "verify" && !(await verifyTurnstile(c.env.TURNSTILE_SECRET!, form.s("cf-turnstile-response"), ip))) throw new HttpError(403, "We couldn't confirm that you're a person. Please try again."); | |
| 645 | + const signups = new Signups(c.env); | |
| 646 | + const existing = await signups.accountFor(email); | |
| 647 | + const issued = await signups.issue(email, ip ?? "unknown"); | |
| 648 | + // The next page reads the same either way, so it never tells whether an address has an account. | |
| 649 | + // Its owner learns from the email: a code, or (no code) a note that they already have one. | |
| 650 | + await enqueueMail(c.env, [ | |
| 651 | + existing ? { kind: "signup_attempt", data: { email }, to: { accountId: existing.id }, reason: "account", at: now() } : { kind: "signup_code", data: issued, to: { email }, reason: "signup", at: now() }, | |
| 652 | + ]); | |
| 653 | + return signupCodePage(c, { email }); | |
| 654 | + }, | |
| 655 | + (error, status) => signupPage(c, { email: typed, error, status }), | |
| 656 | + ); | |
| 657 | + }); | |
| 658 | + | |
| 659 | + post(web, "/signup/verify", async (c, form) => { | |
| 660 | + const email = normalEmail(form.s("email")); | |
| 661 | + if (!email) return signupPage(c, { error: "Enter a valid email address.", status: 422 }); | |
| 662 | + return signupStep( | |
| 663 | + async () => { | |
| 664 | + const signups = new Signups(c.env); | |
| 665 | + const r = await signups.check(email, form.s("code").trim()); | |
| 666 | + if (!r.ok && r.left > 0) throw new HttpError(400, `That code is wrong. You can try ${r.left} more time${r.left === 1 ? "" : "s"}.`); | |
| 667 | + if (!r.ok) throw new HttpError(410, "That code has expired or was entered wrong too many times. Send a new code."); | |
| 668 | + return signupPasskeyPage(c, email, await signups.createTicket(email)); | |
| 669 | + }, | |
| 670 | + (error, status) => signupCodePage(c, { email, error, status }), | |
| 671 | + ); | |
| 672 | + }); | |
| 673 | + | |
| 478 | 674 | // ------------------------------------------------------------ sign out |
| 479 | 675 | |
| 480 | 676 | post(web, "/auth/logout", async (c) => { |
modifiedapps/diffinery/src/web/ui.tsx+8−3View file
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | import { MailStore, type WatchLevel } from "../mail/prefs.js"; |
| 18 | 18 | import { SeoHead, SITE } from "./seo.js"; |
| 19 | 19 | import { avatarPath } from "../http/avatars.js"; |
| 20 | +import { TURNSTILE_ORIGIN, TURNSTILE_SCRIPT } from "../auth/signup.js"; | |
| 20 | 21 | |
| 21 | 22 | export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>; |
| 22 | 23 | export type Stub = ReturnType<typeof repoStub>; |
| @@ -296,6 +297,8 @@ | ||
| 296 | 297 | scriptSrc?: string[]; |
| 297 | 298 | /** Reload the page every this many seconds (progress pages without live updates). */ |
| 298 | 299 | refresh?: number; |
| 300 | + /** Load Cloudflare Turnstile (the sign-up page): its script and iframe come from challenges.cloudflare.com. */ | |
| 301 | + turnstile?: boolean; | |
| 299 | 302 | } |
| 300 | 303 | |
| 301 | 304 | export const WATCH_OPTIONS = [ |
| @@ -367,11 +370,12 @@ | ||
| 367 | 370 | ); |
| 368 | 371 | }; |
| 369 | 372 | |
| 370 | -function csp(nonce: string, c: AppContext): string { | |
| 373 | +function csp(nonce: string, c: AppContext, turnstile?: boolean): string { | |
| 371 | 374 | const host = new URL(c.req.url).host; |
| 372 | 375 | return [ |
| 373 | 376 | "default-src 'self'", |
| 374 | - `script-src 'nonce-${nonce}'`, | |
| 377 | + `script-src 'nonce-${nonce}'${turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}`, | |
| 378 | + ...(turnstile ? [`frame-src ${TURNSTILE_ORIGIN}`] : []), | |
| 375 | 379 | "style-src 'self' 'unsafe-inline'", |
| 376 | 380 | // blob: previews a picture before it is uploaded. |
| 377 | 381 | "img-src 'self' https: data: blob:", |
| @@ -414,7 +418,7 @@ | ||
| 414 | 418 | const p = c.get("principal"); |
| 415 | 419 | const me = p?.type === "account" ? p.account : null; |
| 416 | 420 | const active = me?.status === "active"; |
| 417 | - c.header("content-security-policy", csp(nonce, c)); | |
| 421 | + c.header("content-security-policy", csp(nonce, c, opts.turnstile)); | |
| 418 | 422 | c.header("x-frame-options", "DENY"); |
| 419 | 423 | c.header("x-content-type-options", "nosniff"); |
| 420 | 424 | c.header("referrer-policy", "same-origin"); |
| @@ -440,6 +444,7 @@ | ||
| 440 | 444 | <style>{raw(CSS)}</style> |
| 441 | 445 | <link rel="stylesheet" href={kumoAssets().css} /> |
| 442 | 446 | {opts.head ?? null} |
| 447 | + {opts.turnstile ? <script nonce={nonce} src={TURNSTILE_SCRIPT} async defer></script> : null} | |
| 443 | 448 | </head> |
| 444 | 449 | <body |
| 445 | 450 | data-repo={r ? `${r.repo.owner_login}/${r.repo.name}` : undefined} |
modifiedapps/diffinery/test/e2e/passkeys.test.mjs+131−0View file
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | // its org membership), adding and removing |
| 9 | 9 | // passkeys, signing out and back in, admin-issued one-time sign-in links, |
| 10 | 10 | // single-use invites and links, and refusing cross-site and token-driven calls. |
| 11 | +// Open sign-up (SIGNUP=open) runs on a second Worker: an emailed code, then a | |
| 12 | +// passkey, and the sign-up pages answering 404 while it is off. | |
| 11 | 13 | import { spawn, execFileSync } from "node:child_process"; |
| 12 | 14 | import { createHash, createHmac, randomBytes } from "node:crypto"; |
| 13 | 15 | import { mkdtempSync, rmSync } from "node:fs"; |
| @@ -346,6 +348,135 @@ | ||
| 346 | 348 | } |
| 347 | 349 | }); |
| 348 | 350 | |
| 351 | +test("open sign-up: off by default; on, an emailed code, then a passkey", async () => { | |
| 352 | + // ------------------------------------------------------------ off (this file's Worker has no SIGNUP) | |
| 353 | + assert.equal((await fetch(`${base}/signup`)).status, 404); | |
| 354 | + for (const path of ["/signup", "/signup/verify"]) { | |
| 355 | + const r = await fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "email=a%40example.com&code=123456" }); | |
| 356 | + assert.equal(r.status, 404, path); | |
| 357 | + } | |
| 358 | + const closed = await (await fetch(`${base}/login`)).text(); | |
| 359 | + assert.match(closed, /Diffinery is invite-only/); | |
| 360 | + assert.doesNotMatch(closed, /href="\/signup"/); | |
| 361 | + | |
| 362 | + // ------------------------------------------------------------ on: a second Worker with SIGNUP=open | |
| 363 | + // It keeps env.dev's DEV_AUTH, so emails are kept whole (for reading the code) and its dev account | |
| 364 | + // (dev@diffinery.local, a site admin) can list them. Without Turnstile keys, localhost skips the check. | |
| 365 | + const port = await freePort(); | |
| 366 | + const site = `http://localhost:${port}`; | |
| 367 | + const persist = join(work, "signup-state"); | |
| 368 | + execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 369 | + const open = spawn( | |
| 370 | + "pnpm", | |
| 371 | + ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${site}`, "--var", `MASTER_KEY:${MASTER_KEY}`, "--var", "SIGNUP:open", "--show-interactive-dev-session=false"], | |
| 372 | + { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, | |
| 373 | + ); | |
| 374 | + open.stdout.on("data", (d) => (devLog += d)); | |
| 375 | + open.stderr.on("data", (d) => (devLog += d)); | |
| 376 | + try { | |
| 377 | + await until("wrangler dev with SIGNUP=open", async () => { | |
| 378 | + try { | |
| 379 | + return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok; | |
| 380 | + } catch { | |
| 381 | + return false; | |
| 382 | + } | |
| 383 | + }, 120_000); | |
| 384 | + const minted = await fetch(`${site}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "signup-e2e", scopes: ["admin"] }) }); | |
| 385 | + assert.equal(minted.status, 201, await minted.clone().text()); | |
| 386 | + const adminToken = (await minted.json()).token; | |
| 387 | + /** Every email the Worker sent, newest first, with the whole message. */ | |
| 388 | + const emails = async () => { | |
| 389 | + const r = await fetch(`${site}/api/v3/diffinery/emails?all=1&per_page=200`, { headers: { authorization: `Bearer ${adminToken}` } }); | |
| 390 | + assert.equal(r.status, 200); | |
| 391 | + return r.json(); | |
| 392 | + }; | |
| 393 | + const email = (what, pred) => until(`email: ${what}`, async () => (await emails()).find(pred)); | |
| 394 | + | |
| 395 | + const newbie = await person(); | |
| 396 | + const { page } = newbie; | |
| 397 | + await page.goto(`${site}/login`); | |
| 398 | + assert.match(await text(page), /New here\? Create an account/); | |
| 399 | + await Promise.all([page.waitForURL(`${site}/signup`), page.click("text=Create an account")]); | |
| 400 | + assert.match(await text(page), /We'll send you a code/); | |
| 401 | + assert.equal(await page.locator("select, input[type=checkbox]").count(), 0); | |
| 402 | + await page.fill("#email", "Newbie@Example.com"); | |
| 403 | + await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Email me a code')")]); | |
| 404 | + assert.match(await text(page), /We sent a code to newbie@example\.com/); | |
| 405 | + const sent = await email("sign-up code", (e) => e.kind === "signup_code" && e.to_addr === "newbie@example.com"); | |
| 406 | + assert.doesNotMatch(sent.subject, /\d{6}/); | |
| 407 | + const code = /^ {4}(\d{6})$/m.exec(sent.dev_copy.text)?.[1]; | |
| 408 | + assert.ok(code, sent.dev_copy.text); | |
| 409 | + | |
| 410 | + // A wrong code is refused and counted. | |
| 411 | + const other = String((Number(code) + 1) % 1_000_000).padStart(6, "0"); | |
| 412 | + await page.fill("#code", other); | |
| 413 | + const [refused] = await Promise.all([page.waitForResponse((r) => r.url().endsWith("/signup/verify")), page.click("button:has-text('Continue')")]); | |
| 414 | + assert.equal(refused.status(), 400); | |
| 415 | + assert.match(await text(page), /That code is wrong\. You can try 4 more times\./); | |
| 416 | + | |
| 417 | + // The right one leads to the passkey step, bound to a single-use ticket. | |
| 418 | + await page.fill("#code", code); | |
| 419 | + await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Continue')")]); | |
| 420 | + assert.match(await text(page), /newbie@example\.com is confirmed/); | |
| 421 | + const ticket = await page.inputValue("input[name=signup]"); | |
| 422 | + assert.equal(await page.inputValue("#login"), "newbie"); | |
| 423 | + await page.fill("#login", "newbie"); | |
| 424 | + await page.fill("#name", "New Person"); | |
| 425 | + await Promise.all([page.waitForURL(`${site}/`), page.click("text=Create a passkey and join")]); | |
| 426 | + assert.match(await text(page), /Welcome to Diffinery, newbie/); | |
| 427 | + assert.doesNotMatch(await text(page), /Waiting for an invite/); | |
| 428 | + assert.equal((await newbie.credentials()).length, 1); | |
| 429 | + await page.goto(`${site}/api/v3/user`); | |
| 430 | + const me = JSON.parse(await page.textContent("body")); | |
| 431 | + assert.equal(me.login, "newbie"); | |
| 432 | + assert.equal(me.site_admin, false); | |
| 433 | + await page.goto(`${site}/new`); | |
| 434 | + assert.match(await text(page), /Create a new repository|Repository name/); | |
| 435 | + await email("welcome", (e) => e.kind === "welcome" && e.to_addr === "newbie@example.com"); | |
| 436 | + // The ticket and the code worked once. | |
| 437 | + const again = await fetch(`${site}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ signup: ticket, login: "newbie2" }) }); | |
| 438 | + assert.equal(again.status, 404); | |
| 439 | + | |
| 440 | + // The audit log records the sign-up (this browser is the dev site admin). | |
| 441 | + const stranger = await person(); | |
| 442 | + await stranger.page.goto(`${site}/settings/audit`); | |
| 443 | + assert.ok(await stranger.page.locator("tr:has-text('newbie') .badge:text-is('signup')").count()); | |
| 444 | + | |
| 445 | + // ------------------------------------------------------------ an address with an account | |
| 446 | + // Same page as for a new address; its owner gets a note instead of a code. | |
| 447 | + await stranger.page.goto(`${site}/signup`); | |
| 448 | + await stranger.page.fill("#email", "dev@diffinery.local"); | |
| 449 | + await Promise.all([stranger.page.waitForLoadState("load"), stranger.page.click("button:has-text('Email me a code')")]); | |
| 450 | + assert.match(await text(stranger.page), /We sent a code to dev@diffinery\.local/); | |
| 451 | + const note = await email("someone tried to sign up", (e) => e.kind === "signup_attempt"); | |
| 452 | + assert.equal(note.to_addr, "dev@diffinery.local"); | |
| 453 | + assert.match(note.dev_copy.text, /already belongs to your account/); | |
| 454 | + assert.doesNotMatch(note.dev_copy.text, /^ {4}\d{6}$/m); | |
| 455 | + assert.equal((await emails()).filter((e) => e.kind === "signup_code" && e.to_addr === "dev@diffinery.local").length, 0); | |
| 456 | + // A guess there answers like any wrong code. | |
| 457 | + await stranger.page.fill("#code", "000000"); | |
| 458 | + const [guess] = await Promise.all([stranger.page.waitForResponse((r) => r.url().endsWith("/signup/verify")), stranger.page.click("button:has-text('Continue')")]); | |
| 459 | + assert.equal(guess.status(), 400); | |
| 460 | + | |
| 461 | + // ------------------------------------------------------------ at most 5 codes a day for an address | |
| 462 | + await stranger.page.goto(`${site}/signup`); | |
| 463 | + const csrf = await stranger.page.inputValue("input[name=_csrf]"); | |
| 464 | + const ask = () => stranger.page.request.post(`${site}/signup`, { form: { _csrf: csrf, email: "limit@example.com" }, maxRedirects: 0 }); | |
| 465 | + for (let i = 0; i < 5; i++) { | |
| 466 | + const r = await ask(); | |
| 467 | + assert.equal(r.status(), 200, `request ${i + 1}`); | |
| 468 | + assert.match(await r.text(), /We sent a code to <b>limit@example\.com<\/b>/); | |
| 469 | + } | |
| 470 | + const limited = await ask(); | |
| 471 | + assert.equal(limited.status(), 429); | |
| 472 | + assert.match(await limited.text(), /We have sent several codes to limit@example\.com today/); | |
| 473 | + } finally { | |
| 474 | + try { | |
| 475 | + process.kill(-open.pid, "SIGTERM"); | |
| 476 | + } catch {} | |
| 477 | + } | |
| 478 | +}); | |
| 479 | + | |
| 349 | 480 | test("favicons, robots.txt, sitemap and search metadata", async () => { |
| 350 | 481 | const get = (p) => fetch(`${base}${p}`, { redirect: "manual" }); |
| 351 | 482 | const ico = await get("/favicon.ico"); |
modifiedapps/diffinery/test/unit/email.test.ts+13−1View file
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | describe("email templates", () => { |
| 16 | 16 | it("has a sample for every kind of email", () => { |
| 17 | 17 | const kinds = new Set(samples.map((s) => s.job.kind)); |
| 18 | - for (const k of ["signin_new", "passkey_added", "passkey_removed", "token_created", "token_expiring", "agent_created", "model_key_added", "review_limit_reached", "push_bypassed", "push_blocked", "account_status", "invite", "invite_accepted", "welcome", "signin_link", "access_request", "org_membership", "repo_access", "repo_created", "repo_deleted", "repo_transferred", "thread", "run", "test"]) { | |
| 18 | + for (const k of ["signin_new", "passkey_added", "passkey_removed", "token_created", "token_expiring", "agent_created", "model_key_added", "review_limit_reached", "push_bypassed", "push_blocked", "account_status", "invite", "invite_accepted", "welcome", "signin_link", "signup_code", "signup_attempt", "access_request", "org_membership", "repo_access", "repo_created", "repo_deleted", "repo_transferred", "thread", "run", "test"]) { | |
| 19 | 19 | expect(kinds.has(k as MailJob["kind"]), k).toBe(true); |
| 20 | 20 | } |
| 21 | 21 | }); |
| @@ -41,6 +41,18 @@ | ||
| 41 | 41 | } |
| 42 | 42 | }); |
| 43 | 43 | |
| 44 | + it("puts a sign-up code in the message but not the subject (the email log keeps subjects)", async () => { | |
| 45 | + const job = samples.find((s) => s.id === "signup-code")!.job; | |
| 46 | + const msg = await buildMessage(env, job, { email: "new@example.com" }); | |
| 47 | + expect(msg.subject).not.toContain("042917"); | |
| 48 | + expect(msg.text).toMatch(/^ {4}042917$/m); | |
| 49 | + expect(msg.html).toContain("042917"); | |
| 50 | + expect(categoryOf(job)).toBe("account"); | |
| 51 | + const attempt = await buildMessage(env, samples.find((s) => s.id === "signup-attempt")!.job, { email: "diffy@example.com", login: "diffy", accountId: "acc1" }); | |
| 52 | + expect(attempt.text).toMatch(/already belongs to your account @diffy/); | |
| 53 | + expect(attempt.text).not.toMatch(/\b\d{6}\b/); | |
| 54 | + }); | |
| 55 | + | |
| 44 | 56 | it("threads issue and pull request emails like GitHub", async () => { |
| 45 | 57 | const opened = samples.find((s) => s.id === "pr-opened")!.job; |
| 46 | 58 | const comment = samples.find((s) => s.id === "comment")!.job; |
modifiedapps/diffinery/test/unit/rate-limit.test.ts+3−0View file
| @@ -27,6 +27,9 @@ | ||
| 27 | 27 | it("counts sign-in attempts per IP address, and other anonymous requests separately", () => { |
| 28 | 28 | expect(bucketsFor("POST", "/auth/passkey/login/options", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]); |
| 29 | 29 | expect(bucketsFor("POST", "/invite/abc/email", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]); |
| 30 | + expect(bucketsFor("POST", "/signup", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]); | |
| 31 | + expect(bucketsFor("POST", "/signup/verify", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]); | |
| 32 | + expect(bucketsFor("GET", "/signup", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]); | |
| 30 | 33 | expect(bucketsFor("GET", "/login", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]); |
| 31 | 34 | expect(bucketsFor("GET", "/api/v3/repos/acme/app", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]); |
| 32 | 35 | expect(bucketsFor("GET", "/acme/app.git/info/refs", anon, "198.51.100.1")).toEqual([["RL_ANON", "ip:198.51.100.1"]]); |
addedapps/diffinery/test/unit/signup.test.ts+158−0View file
| @@ -1,0 +1,158 @@ | ||
| 1 | +import { readFileSync } from "node:fs"; | |
| 2 | +import { DatabaseSync } from "node:sqlite"; | |
| 3 | +import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 4 | +import type { Env } from "../../src/env.js"; | |
| 5 | +import { CODES_PER_EMAIL_PER_DAY, CODES_PER_IP_PER_DAY, MAX_CODE_ATTEMPTS, Signups, codeHash, newCode, normalEmail, signupOpen, turnstileMode, verifyTurnstile } from "../../src/auth/signup.js"; | |
| 6 | + | |
| 7 | +/** A minimal D1 over node:sqlite with the sign-up tables (migration 0019) and accounts. */ | |
| 8 | +function d1(): { DB: D1Database; db: DatabaseSync } { | |
| 9 | + const db = new DatabaseSync(":memory:"); | |
| 10 | + db.exec("CREATE TABLE accounts (id TEXT PRIMARY KEY, login TEXT, kind TEXT, email TEXT, status TEXT)"); | |
| 11 | + db.exec(readFileSync(new URL("../../migrations/0020_signup.sql", import.meta.url), "utf8")); | |
| 12 | + const stmt = (sql: string, args: unknown[] = []) => ({ | |
| 13 | + bind: (...a: unknown[]) => stmt(sql, a), | |
| 14 | + first: async () => (db.prepare(sql).get(...(args as never[])) as unknown) ?? null, | |
| 15 | + all: async () => ({ results: db.prepare(sql).all(...(args as never[])) }), | |
| 16 | + run: async () => ({ meta: { changes: Number(db.prepare(sql).run(...(args as never[])).changes) } }), | |
| 17 | + }); | |
| 18 | + const DB = { prepare: (sql: string) => stmt(sql), batch: async (list: Array<ReturnType<typeof stmt>>) => Promise.all(list.map((s) => s.run())) }; | |
| 19 | + return { DB: DB as unknown as D1Database, db }; | |
| 20 | +} | |
| 21 | + | |
| 22 | +const setup = () => { | |
| 23 | + const { DB, db } = d1(); | |
| 24 | + const env = { DB, PUBLIC_URL: "https://diffinery.example", MASTER_KEY: "test-master-key" } as unknown as Env; | |
| 25 | + return { env, db, signups: new Signups(env) }; | |
| 26 | +}; | |
| 27 | + | |
| 28 | +const wrong = (code: string) => String((Number(code) + 1) % 1_000_000).padStart(6, "0"); | |
| 29 | + | |
| 30 | +describe("open sign-up switch", () => { | |
| 31 | + it("is on only for SIGNUP=open", () => { | |
| 32 | + expect(signupOpen({ SIGNUP: "open" })).toBe(true); | |
| 33 | + for (const v of [undefined, "", "closed", "OPEN", "true", "1"]) expect(signupOpen({ SIGNUP: v }), String(v)).toBe(false); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + it("needs both Turnstile keys, except in local development", () => { | |
| 37 | + expect(turnstileMode({ PUBLIC_URL: "https://diffinery.example", TURNSTILE_SITE_KEY: "site", TURNSTILE_SECRET: "secret" })).toBe("verify"); | |
| 38 | + expect(turnstileMode({ PUBLIC_URL: "https://diffinery.example", TURNSTILE_SITE_KEY: "site" })).toBe("missing"); | |
| 39 | + expect(turnstileMode({ PUBLIC_URL: "https://diffinery.example", TURNSTILE_SECRET: "secret" })).toBe("missing"); | |
| 40 | + expect(turnstileMode({ PUBLIC_URL: "https://diffinery.example" })).toBe("missing"); | |
| 41 | + expect(turnstileMode({ PUBLIC_URL: "http://localhost:8787" })).toBe("skip"); | |
| 42 | + expect(turnstileMode({ PUBLIC_URL: "http://127.0.0.1:8787" })).toBe("skip"); | |
| 43 | + expect(turnstileMode({ PUBLIC_URL: "http://localhost.evil.example" })).toBe("missing"); | |
| 44 | + }); | |
| 45 | +}); | |
| 46 | + | |
| 47 | +describe("Turnstile", () => { | |
| 48 | + afterEach(() => vi.unstubAllGlobals()); | |
| 49 | + | |
| 50 | + it("asks siteverify with the secret, the token and the visitor's IP, and trusts only success", async () => { | |
| 51 | + const calls: Array<{ url: string; body: string }> = []; | |
| 52 | + let answer: unknown = { success: true }; | |
| 53 | + vi.stubGlobal("fetch", async (url: string, init: RequestInit) => { | |
| 54 | + calls.push({ url, body: String(init.body) }); | |
| 55 | + return Response.json(answer); | |
| 56 | + }); | |
| 57 | + expect(await verifyTurnstile("s3cret", "tok", "203.0.113.9")).toBe(true); | |
| 58 | + expect(calls[0]!.url).toBe("https://challenges.cloudflare.com/turnstile/v0/siteverify"); | |
| 59 | + expect(Object.fromEntries(new URLSearchParams(calls[0]!.body))).toEqual({ secret: "s3cret", response: "tok", remoteip: "203.0.113.9" }); | |
| 60 | + answer = { success: false, "error-codes": ["invalid-input-response"] }; | |
| 61 | + expect(await verifyTurnstile("s3cret", "tok", null)).toBe(false); | |
| 62 | + expect(await verifyTurnstile("s3cret", "", null)).toBe(false); | |
| 63 | + expect(calls.length).toBe(2); | |
| 64 | + vi.stubGlobal("fetch", async () => { | |
| 65 | + throw new Error("network down"); | |
| 66 | + }); | |
| 67 | + expect(await verifyTurnstile("s3cret", "tok", null)).toBe(false); | |
| 68 | + }); | |
| 69 | +}); | |
| 70 | + | |
| 71 | +describe("sign-up codes", () => { | |
| 72 | + it("are six random digits, stored only as a keyed hash", async () => { | |
| 73 | + const codes = new Set(Array.from({ length: 200 }, () => newCode())); | |
| 74 | + for (const c of codes) expect(c).toMatch(/^\d{6}$/); | |
| 75 | + expect(codes.size).toBeGreaterThan(190); | |
| 76 | + const { env, db, signups } = setup(); | |
| 77 | + const { code } = await signups.issue("new@example.com", "203.0.113.9"); | |
| 78 | + const row = db.prepare("SELECT * FROM signup_codes").get() as Record<string, string>; | |
| 79 | + expect(JSON.stringify(row)).not.toContain(code); | |
| 80 | + expect(row.code_hash).toBe(await codeHash(env, row.id!, code)); | |
| 81 | + expect(await codeHash({ ...env, MASTER_KEY: "another" }, row.id!, code)).not.toBe(row.code_hash); | |
| 82 | + expect(await codeHash(env, "another-row", code)).not.toBe(row.code_hash); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + it("accepts the right code once", async () => { | |
| 86 | + const { signups } = setup(); | |
| 87 | + const { code, expiresAt } = await signups.issue("new@example.com", "203.0.113.9"); | |
| 88 | + expect(Date.parse(expiresAt) - Date.now()).toBeGreaterThan(9 * 60_000); | |
| 89 | + expect(Date.parse(expiresAt) - Date.now()).toBeLessThanOrEqual(10 * 60_000); | |
| 90 | + expect(await signups.check("new@example.com", code)).toEqual({ ok: true }); | |
| 91 | + expect(await signups.check("new@example.com", code)).toEqual({ ok: false, left: 0 }); | |
| 92 | + // Another address's code is another address's. | |
| 93 | + const other = await signups.issue("other@example.com", "203.0.113.9"); | |
| 94 | + expect((await signups.check("new@example.com", other.code)).ok).toBe(false); | |
| 95 | + }); | |
| 96 | + | |
| 97 | + it("dies after five wrong codes", async () => { | |
| 98 | + const { signups } = setup(); | |
| 99 | + const { code } = await signups.issue("new@example.com", "203.0.113.9"); | |
| 100 | + for (let left = MAX_CODE_ATTEMPTS - 1; left >= 0; left--) expect(await signups.check("new@example.com", wrong(code))).toEqual({ ok: false, left }); | |
| 101 | + expect(await signups.check("new@example.com", code)).toEqual({ ok: false, left: 0 }); | |
| 102 | + await expect(signups.check("new@example.com", "12345")).rejects.toThrow(/6-digit/); | |
| 103 | + }); | |
| 104 | + | |
| 105 | + it("expires after 10 minutes, and when a new code is sent", async () => { | |
| 106 | + const { db, signups } = setup(); | |
| 107 | + const first = await signups.issue("new@example.com", "203.0.113.9"); | |
| 108 | + const second = await signups.issue("new@example.com", "203.0.113.9"); | |
| 109 | + expect((await signups.check("new@example.com", first.code)).ok).toBe(first.code === second.code); | |
| 110 | + db.prepare("UPDATE signup_codes SET expires_at = ?").run(new Date(Date.now() - 1000).toISOString()); | |
| 111 | + expect(await signups.check("new@example.com", second.code)).toEqual({ ok: false, left: 0 }); | |
| 112 | + }); | |
| 113 | + | |
| 114 | + it("sends at most 5 a day to an address and 20 a day from an IP address", async () => { | |
| 115 | + const { db, signups } = setup(); | |
| 116 | + for (let i = 0; i < CODES_PER_EMAIL_PER_DAY; i++) await signups.issue("busy@example.com", "198.51.100.1"); | |
| 117 | + await expect(signups.issue("busy@example.com", "198.51.100.2")).rejects.toMatchObject({ status: 429 }); | |
| 118 | + for (let i = 0; i < CODES_PER_IP_PER_DAY - CODES_PER_EMAIL_PER_DAY; i++) await signups.issue(`p${i}@example.com`, "198.51.100.1"); | |
| 119 | + await expect(signups.issue("fresh@example.com", "198.51.100.1")).rejects.toMatchObject({ status: 429 }); | |
| 120 | + await signups.issue("fresh@example.com", "198.51.100.3"); | |
| 121 | + // A day later the count starts over. | |
| 122 | + db.prepare("UPDATE signup_codes SET created_at = ?").run(new Date(Date.now() - 86400_000 - 1000).toISOString()); | |
| 123 | + await signups.issue("busy@example.com", "198.51.100.1"); | |
| 124 | + }); | |
| 125 | + | |
| 126 | + it("finds an existing account whatever the case of its address", async () => { | |
| 127 | + const { db, signups } = setup(); | |
| 128 | + db.prepare("INSERT INTO accounts VALUES ('a1', 'pal', 'user', 'Pal@Example.com', 'active')").run(); | |
| 129 | + expect((await signups.accountFor("pal@example.com"))?.id).toBe("a1"); | |
| 130 | + expect(await signups.accountFor("nobody@example.com")).toBeNull(); | |
| 131 | + }); | |
| 132 | +}); | |
| 133 | + | |
| 134 | +describe("sign-up tickets", () => { | |
| 135 | + it("work once, and only for 30 minutes", async () => { | |
| 136 | + const { db, signups } = setup(); | |
| 137 | + const token = await signups.createTicket("new@example.com"); | |
| 138 | + const t = await signups.ticket(token); | |
| 139 | + expect(t?.email).toBe("new@example.com"); | |
| 140 | + expect(JSON.stringify(db.prepare("SELECT * FROM signup_tickets").all())).not.toContain(token); | |
| 141 | + expect(await signups.useTicket(t!.hash)).toBe(true); | |
| 142 | + expect(await signups.useTicket(t!.hash)).toBe(false); | |
| 143 | + expect(await signups.ticket(token)).toBeNull(); | |
| 144 | + const late = await signups.createTicket("new@example.com"); | |
| 145 | + const row = db.prepare("SELECT expires_at FROM signup_tickets WHERE used_at IS NULL").get() as { expires_at: string }; | |
| 146 | + expect(Date.parse(row.expires_at) - Date.now()).toBeGreaterThan(29 * 60_000); | |
| 147 | + db.prepare("UPDATE signup_tickets SET expires_at = ?").run(new Date(Date.now() - 1000).toISOString()); | |
| 148 | + expect(await signups.ticket(late)).toBeNull(); | |
| 149 | + expect(await signups.ticket("not a token")).toBeNull(); | |
| 150 | + }); | |
| 151 | +}); | |
| 152 | + | |
| 153 | +describe("sign-up addresses", () => { | |
| 154 | + it("lower-cases addresses and refuses what isn't one", () => { | |
| 155 | + expect(normalEmail(" New.Person@Example.COM ")).toBe("new.person@example.com"); | |
| 156 | + for (const bad of ["", "nobody", "a@b", "a b@example.com", "<a@example.com>", `${"x".repeat(250)}@example.com`]) expect(normalEmail(bad), bad).toBeNull(); | |
| 157 | + }); | |
| 158 | +}); |