diff --git a/AGENTS.md b/AGENTS.md
index 046809b2..578e608a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -99,8 +99,8 @@ make run / start / stop # dev stack foreground / background / stop
make dev # backend containers + web dev server on :3000
make check-env / sync-env # report / reconcile *.local.env against templates
make lint / check-lint # fix / verify lint, format and types everywhere
-make prod-build / prod-run / prod-stop # all-in-one image on :8080 (mailpit :8025)
-make e2e-{dev,prod}[-stack-build|-stack-stop] # dev stack :18080, prod image :19080
+make prod-build / prod-run[-no-email] / prod-stop # all-in-one image on :8080 (mailpit :8025)
+make e2e-{dev,prod}[-stack-build|-stack-stop] # dev stack :18080, prod image :19080 (no email :19081)
```
`make prod-publish` is release-only (`.github/workflows/release.yml`).
diff --git a/Makefile b/Makefile
index 0b9e2871..2104c29e 100644
--- a/Makefile
+++ b/Makefile
@@ -12,6 +12,7 @@ E2E_DEV_COMPOSE := docker compose -f e2e/docker-compose.dev.yaml $(E2E_DEV_COMPO
E2E_PROD_COMPOSE := docker compose -f e2e/docker-compose.prod.yaml
PROD_COMPOSE := docker compose -f docker/prod/docker-compose.example.yaml \
-f docker/prod/docker-compose.local.yaml
+PROD_NO_EMAIL_COMPOSE := docker compose -f docker/prod/docker-compose.example.yaml
# a tag names the stack an image belongs to. The e2e targets override these
# to :e2e-dev and :e2e-prod so a run cannot replace the image `make start`
@@ -185,13 +186,20 @@ prod-publish:
# build the image, then run the example deployment against it on
# http://localhost:8080 (ctrl-c stops it). The local override adds the mail
-# sink the example only describes in a comment: signup requires a verified
-# address, so without it the verification link is only logged. Read the
-# delivered mail at http://localhost:8025.
+# sink the example only describes in a comment, so the product runs with
+# email. Read the delivered mail at http://localhost:8025.
.PHONY: prod-run
prod-run: prod-build
OXYNOTE_IMAGE_TAG=$(PROD_IMAGE_TAG) $(PROD_COMPOSE) up
+# the same without the local override: the example as it stands, which runs
+# the product without email. Both targets share one compose project and its
+# data volume, so prod-stop stops either, and --remove-orphans takes down a
+# mailpit an earlier prod-run left behind.
+.PHONY: prod-run-no-email
+prod-run-no-email: prod-build
+ OXYNOTE_IMAGE_TAG=$(PROD_IMAGE_TAG) $(PROD_NO_EMAIL_COMPOSE) up --remove-orphans
+
.PHONY: prod-stop
prod-stop:
$(PROD_COMPOSE) down
diff --git a/README.md b/README.md
index 163374f5..4cc73ea0 100644
--- a/README.md
+++ b/README.md
@@ -120,50 +120,9 @@ docker run -d --name oxynote \
Open http://localhost:8080 and sign up.
-
-Want email? This compose file starts Mailpit next to Oxynote
-
-```yaml
-name: oxynote
-
-services:
- oxynote:
- image: ghcr.io/oxynote/oxynote:latest
- ports:
- - "8080:8080"
- environment:
- # point these at a real relay to have mail delivered; here they go
- # to the mailpit service below.
- - OXYNOTE_SMTP_DSN=smtp://mailpit:1025?tls=none
- - OXYNOTE_EMAIL_FROM_ADDRESS=Oxynote
- volumes:
- - oxynote_data:/oxynote/data
- depends_on:
- mailpit:
- condition: service_started
- # the image shuts its services down in order, flushing open documents
- # last; docker's default 10s grace would kill that mid-flush.
- stop_grace_period: 60s
- restart: unless-stopped
-
- # catches every mail Oxynote sends and shows it at
- # http://localhost:8025.
- mailpit:
- image: axllent/mailpit:v1.31.0
- ports:
- - "8025:8025"
- restart: unless-stopped
-
-volumes:
- oxynote_data:
-```
-
-
-
-Everything else, from email to object storage, the GitHub and Slack
-apps, social login and Rubber Duck, is one variable away and
-listed in [docker/prod/README.md](docker/prod/README.md). To run from
-source, see [CONTRIBUTING.md](CONTRIBUTING.md).
+Everything else is one variable away and listed in
+[docker/prod/README.md](docker/prod/README.md). To run from source, see
+[CONTRIBUTING.md](CONTRIBUTING.md).
## What is in the box
diff --git a/docker/env/auth-realtime.example.env b/docker/env/auth-realtime.example.env
index e29309e7..4ade7988 100644
--- a/docker/env/auth-realtime.example.env
+++ b/docker/env/auth-realtime.example.env
@@ -46,3 +46,9 @@ OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS=50
# (a test run, or a proxy that hides the original IP) and rate-limit
# upstream instead.
OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED=false
+
+# email
+# "true" where core has no SMTP relay: signup skips verification, password
+# reset is refused, and email changes and account deletions are confirmed
+# with the password instead of an emailed link.
+OXYNOTE_AUTH_REALTIME_EMAIL_DISABLED=false
diff --git a/docker/prod/AGENTS.md b/docker/prod/AGENTS.md
index cd027caf..df68bcc7 100644
--- a/docker/prod/AGENTS.md
+++ b/docker/prod/AGENTS.md
@@ -15,7 +15,7 @@ volume, as does the search index, which core rebuilds from Postgres when
missing). `docker-compose.example.yaml` is the canonical deployment and
`README.md` the operator reference. `docker-compose.local.yaml` is the
override `make prod-run` layers on it to build the image locally and add a
-mailpit, since signup needs a verified address.
+mailpit, since the example runs without email.
- **`make prod-build`** (repo root) builds it. Core's binary must come from
goreleaser, never `go build`; the target stages it into `.build/` for the
diff --git a/docker/prod/README.md b/docker/prod/README.md
index 8531a907..917b4dc3 100644
--- a/docker/prod/README.md
+++ b/docker/prod/README.md
@@ -20,9 +20,10 @@ docker compose -f my-deployment.yaml up -d
From a repository checkout, `make prod-run` builds the image locally
(goreleaser builds the core binary first — a bare `docker build` is not
supported) and runs this example on `http://localhost:8080`, with
-`docker-compose.local.yaml` layered on top so signup mail lands in a mailpit
-at `http://localhost:8025` instead of being logged. `make prod-stop` stops
-it.
+`docker-compose.local.yaml` layered on top to add an email sender: a mailpit
+that shows the delivered mail at `http://localhost:8025`. `make
+prod-run-no-email` runs the example without that override, as a deployment
+without an email sender. `make prod-stop` stops either.
The image serves plain HTTP on port **8080** and assumes it is reached at
`http://localhost:8080` unless told otherwise, so publish the container as
@@ -56,13 +57,33 @@ cleanly. An incomplete group fails the boot with the missing name.
| --- | --- |
| Valkey/Redis | `OXYNOTE_VALKEY_DSN` (`redis[s]://[user:pass@]host:port[/db]`, credentials supported). Without it the assistant keeps its conversations in the core process and sessions are not cached outside Postgres. |
| Object storage | `OXYNOTE_OBJECT_STORAGE_DSN` — an S3-compatible store as one URL: `http(s)://ACCESS_KEY:SECRET_KEY@host:port/bucket[?region=...]` (the bucket defaults to `oxynote` and is created if missing). Without it uploaded images are kept on the data volume, which is the right choice for a single-node deployment. |
-| Email | `OXYNOTE_SMTP_DSN` (`smtp[s]://[user:pass@]host:port[?tls=none\|starttls\|tls]`), `OXYNOTE_EMAIL_FROM_ADDRESS`. Without email, verification mails are logged instead of sent. |
+| Email | `OXYNOTE_SMTP_DSN` (`smtp[s]://[user:pass@]host:port[?tls=none\|starttls\|tls]`), `OXYNOTE_EMAIL_FROM_ADDRESS`. Without it the product runs without email, as described below. |
| GitHub App | `OXYNOTE_GITHUB_APP_ID`, `OXYNOTE_GITHUB_APP_SLUG`, `OXYNOTE_GITHUB_APP_SIGNATURE_SECRET`; mount the app's private key at `/oxynote/github/private-key.pem` |
| Slack app | `OXYNOTE_SLACK_APP_CLIENT_ID`, `OXYNOTE_SLACK_APP_CLIENT_SECRET`, `OXYNOTE_SLACK_APP_SIGNATURE_SECRET` |
| Social login | `OXYNOTE_SOCIAL_LOGIN_{GITHUB,GOOGLE,SLACK}_CLIENT_ID` + `_CLIENT_SECRET` (both halves per provider) |
| AI assistant | `OXYNOTE_AI_ASSISTANT_PROVIDER` (`anthropic`, `openai`, `google`, `ollama`, `openrouter`) plus the vendor's credentials, detailed in [docs/ai.md](../../docs/ai.md): `OXYNOTE_AI_ASSISTANT_API_KEY`, `_MODEL`, `_BASE_URL`, `_MAX_TOKENS`, `_REQUEST_TIMEOUT`, `_SUMMARY_MODEL`, `_AZURE_API_VERSION`, `_BEDROCK_{REGION,ACCESS_KEY,SECRET_ACCESS_KEY,SESSION_TOKEN}`, `_VERTEX_{PROJECT_ID,REGION,SERVICE_ACCOUNT_JSON}` |
| URL watching | `OXYNOTE_CHANGE_DETECTION_URL`, `OXYNOTE_CHANGE_DETECTION_API_KEY` (a changedetection.io instance) |
+### Without an email sender
+
+Without `OXYNOTE_SMTP_DSN` nothing is emailed, and the flows that would wait
+for a link work without one:
+
+- Signup skips email verification and goes straight to workspace creation.
+- Invitations are still created, but their links have to be passed on by
+ hand. Workspace settings offer **Copy invitation link** on each pending
+ invitation, and show a notice about it.
+- Changing the email address or deleting the account asks for the current
+ password. Accounts that sign in only through a social login have no
+ password, so they can do neither.
+- Password reset is switched off, and there is no other way to recover a
+ forgotten password. The login page tells users to contact their
+ administrator.
+
+Setting `OXYNOTE_SMTP_DSN` later brings the email flows back after a
+restart. Accounts created in the meantime have unverified addresses, so
+their next login sends a verification link first.
+
### Tuning
`OXYNOTE_MAX_ORGANIZATIONS`, `OXYNOTE_MAX_ORGANIZATION_MEMBERS`,
diff --git a/docker/prod/docker-compose.example.yaml b/docker/prod/docker-compose.example.yaml
index 967c75f3..7f1099a2 100644
--- a/docker/prod/docker-compose.example.yaml
+++ b/docker/prod/docker-compose.example.yaml
@@ -1,7 +1,7 @@
# `make prod-run` from the repository root builds the image and runs this
-# file against it, with docker-compose.local.yaml layered on top to add the
-# mail sink a browser trial needs. The full variable reference lives in
-# README.md next to this file.
+# file against it, with docker-compose.local.yaml layered on top to add a
+# mail sink, so the email flows can be tried as well. The full variable
+# reference lives in README.md next to this file.
name: oxynote-prod
@@ -10,12 +10,13 @@ services:
image: ghcr.io/oxynote/oxynote:${OXYNOTE_IMAGE_TAG:-latest}
ports:
- "8080:8080"
- environment:
- # optional: outgoing email (signup verification, invites). Point it
- # at a real relay, or uncomment the mailpit service below for a
- # trial run and use these two lines:
- - OXYNOTE_SMTP_DSN=smtp://mailpit:1025
- - OXYNOTE_EMAIL_FROM_ADDRESS=Oxynote
+ # optional: outgoing email (signup verification, invitations, password
+ # reset). Without it the product runs without email, as README.md
+ # describes. Point it at a real relay, or uncomment the mailpit service
+ # below for a trial run and use these lines:
+ # environment:
+ # - OXYNOTE_SMTP_DSN=smtp://mailpit:1025
+ # - OXYNOTE_EMAIL_FROM_ADDRESS=Oxynote
volumes:
# the embedded database, the generated secrets, every uploaded image
# and the search index live here. Losing this volume loses every
@@ -28,12 +29,14 @@ services:
stop_grace_period: 60s
restart: unless-stopped
- # a trial-run mail sink: uncomment it together with the two OXYNOTE_SMTP
- # variables above and read the delivered mail at http://localhost:8025.
- mailpit:
- image: axllent/mailpit:v1.31.0
- ports:
- - "8025:8025"
+ # a trial-run mail sink: uncomment it together with the email variables
+ # above and read the delivered mail at http://localhost:8025. It accepts
+ # mail for any address and shows it to anyone who opens it, so it has no
+ # place in a real deployment.
+ # mailpit:
+ # image: axllent/mailpit:v1.31.0
+ # ports:
+ # - "8025:8025"
volumes:
oxynote_data:
diff --git a/docker/prod/docker-compose.local.yaml b/docker/prod/docker-compose.local.yaml
index f799fe7e..d350af76 100644
--- a/docker/prod/docker-compose.local.yaml
+++ b/docker/prod/docker-compose.local.yaml
@@ -3,11 +3,11 @@
# deployment operators copy — docker-compose.example.yaml is that file, and
# it stays as documented.
#
-# the one thing a local run cannot do without is mail: signup requires a
-# verified address, so without a relay the verification link is only ever
-# logged. This starts the mail sink the example already describes in a
-# comment and points the image at it; the delivered mail is read at
-# http://localhost:8025.
+# it adds mail. The example runs without an email sender, which switches off
+# signup verification and password reset; this starts the mail sink the
+# example describes in a comment and points the image at it, so a local run
+# goes through the same flows as a deployment with a relay. The delivered
+# mail is read at http://localhost:8025.
name: oxynote-prod
diff --git a/docker/prod/launcher/src/mapping.test.ts b/docker/prod/launcher/src/mapping.test.ts
index 458a1da4..ff4ab849 100644
--- a/docker/prod/launcher/src/mapping.test.ts
+++ b/docker/prod/launcher/src/mapping.test.ts
@@ -289,6 +289,9 @@ describe("buildChildEnvs", () => {
const envs = build()
expect(envs.core.OXYNOTE_CORE_EMAIL_SMTP_HOST).toBe("")
+ expect(
+ envs.authRealtime.OXYNOTE_AUTH_REALTIME_EMAIL_DISABLED,
+ ).toBe("true")
expect(envs.core.OXYNOTE_CORE_GITHUB_APP_ID).toBe("")
expect(envs.core.OXYNOTE_CORE_SLACK_CLIENT_ID).toBe("")
expect(envs.core.OXYNOTE_CORE_ASSISTANT_PROVIDER).toBe("")
@@ -344,6 +347,9 @@ describe("buildChildEnvs", () => {
expect(envs.core.OXYNOTE_CORE_EMAIL_FROM_ADDRESS).toBe(
"Oxynote ",
)
+ expect(
+ envs.authRealtime.OXYNOTE_AUTH_REALTIME_EMAIL_DISABLED,
+ ).toBe("false")
expect(envs.core.OXYNOTE_CORE_GITHUB_APP_ID).toBe("123")
expect(envs.core.OXYNOTE_CORE_GITHUB_PRIVATE_KEY_PATH).toBe(
"/oxynote/github/private-key.pem",
diff --git a/docker/prod/launcher/src/mapping.ts b/docker/prod/launcher/src/mapping.ts
index aeedfb46..a1f84e76 100644
--- a/docker/prod/launcher/src/mapping.ts
+++ b/docker/prod/launcher/src/mapping.ts
@@ -200,6 +200,11 @@ export function buildChildEnvs(
config.maxOrganizationMembers,
OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED:
config.rateLimitDisabled ? "true" : "false",
+ // core sends every email, so without a relay nothing auth-realtime
+ // asks for can arrive; it has to know at boot, because
+ // better-auth's verification options are fixed then.
+ OXYNOTE_AUTH_REALTIME_EMAIL_DISABLED:
+ config.smtp === undefined ? "true" : "false",
OXYNOTE_AUTH_REALTIME_LOG_LEVEL: config.logLevel,
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_ID:
config.socialLogin.github?.clientId ?? "",
diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md
index 1756a323..2426d85a 100644
--- a/e2e/AGENTS.md
+++ b/e2e/AGENTS.md
@@ -81,7 +81,8 @@ differing from `docker/docker-compose.dev.yaml` in:
polls it through the front door, treating any response under 500 as
ready.
-Mailpit makes the auth flows testable: verification is mandatory, so tests
+Mailpit makes the auth flows testable: with an email sender, verification
+is mandatory, so tests
fetch the real message via mailpit's REST API and follow the link. **Neither
stack runs valkey or an object store**; that is the configuration most
installs run. A test that needs either says why.
@@ -90,7 +91,10 @@ installs run. A test that needs either says why.
`docker-compose.prod.yaml` is `docker/prod/docker-compose.example.yaml`
retuned as a fixture (`:19080`, `:19025`, inlined env, pinned images). It
-sets no `OXYNOTE_DB_DSN`, so the image runs its embedded Postgres.
+sets no `OXYNOTE_DB_DSN`, so the image runs its embedded Postgres. A second
+instance, `oxynote-without-email` (`:19081`), sets no `OXYNOTE_SMTP_DSN`;
+only `prod-without-email.test.ts` drives it, through the origin in
+`helpers/without-email.ts`.
Compose builds nothing; the image comes from `make prod-build`, tagged
`:e2e-prod`, and CI's cache reaches it through `PROD_BUILD_EXTRA`. A `probe`
container (idle alpine on the private network) exists for the
diff --git a/e2e/docker-compose.prod.yaml b/e2e/docker-compose.prod.yaml
index eedcf358..13fe5ee9 100644
--- a/e2e/docker-compose.prod.yaml
+++ b/e2e/docker-compose.prod.yaml
@@ -11,8 +11,9 @@
# the same fixture rules apply:
# - no named volumes, so every run starts from an empty database and an
# empty data volume (the generated secrets are regenerated per run)
-# - no container_name, and its own ports (:19080 front door, :19025
-# mailpit), so it coexists with the dev stack and the e2e dev stack alike
+# - no container_name, and its own ports (:19080 front door, :19081 the
+# instance without email, :19025 mailpit), so it coexists with the dev
+# stack and the e2e dev stack alike
# - env is inlined and committed; there is nothing secret to protect
# - every image is pinned
# - no OXYNOTE_DB_DSN, OXYNOTE_VALKEY_DSN or OXYNOTE_OBJECT_STORAGE_DSN, which
@@ -54,6 +55,22 @@ services:
# last, and docker's default 10s grace would SIGKILL that mid-flush.
stop_grace_period: 60s
+ # the same image with no email sender, which is all it takes to run the
+ # product without email: the launcher derives the switch from the missing
+ # OXYNOTE_SMTP_DSN. tests/prod-without-email.test.ts drives it through its
+ # own front door; every other test stays on the instance above.
+ oxynote-without-email:
+ image: ghcr.io/oxynote/oxynote:e2e-prod
+ ports:
+ - "19081:8080"
+ environment:
+ - OXYNOTE_PUBLIC_URL=http://localhost:19081
+ - OXYNOTE_MAX_ORGANIZATION_MEMBERS=5
+ # the same two allowances as the instance above, for the same reasons.
+ - OXYNOTE_MAX_ORGANIZATIONS=100000
+ - OXYNOTE_RATE_LIMIT_DISABLED=true
+ stop_grace_period: 60s
+
# a sibling on the private network, and the only honest way to ask the
# question the trust boundary answers: can a container that is not caddy
# reach core, auth-realtime or the web server directly? The prod tests
diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts
index 825d70d0..10177146 100644
--- a/e2e/helpers/auth.ts
+++ b/e2e/helpers/auth.ts
@@ -39,12 +39,14 @@ async function revealForm(button: Locator, field: Locator): Promise {
}
// submitSignupForm walks the email-password branch of the signup page and
-// leaves the browser wherever the app sends it.
+// leaves the browser wherever the app sends it. The origin is the stack's
+// front door unless a test drives another instance.
export async function submitSignupForm(
page: Page,
credentials: Credentials,
+ origin = BASE_URL,
): Promise {
- await visit(page, "/signup")
+ await visit(page, `${origin}/signup`)
const email = page.getByPlaceholder(
t("onboarding.signup.email-password-form.email-placeholder"),
@@ -71,12 +73,14 @@ export async function submitSignupForm(
}
// submitLoginForm walks the email-password branch of the login page and
-// leaves the browser wherever the app sends it.
+// leaves the browser wherever the app sends it. The origin is the stack's
+// front door unless a test drives another instance.
export async function submitLoginForm(
page: Page,
credentials: Credentials,
+ origin = BASE_URL,
): Promise {
- await visit(page, "/login")
+ await visit(page, `${origin}/login`)
const email = page.getByPlaceholder(
t("onboarding.login.email-password-form.email-placeholder"),
diff --git a/e2e/helpers/without-email.ts b/e2e/helpers/without-email.ts
new file mode 100644
index 00000000..5ca95663
--- /dev/null
+++ b/e2e/helpers/without-email.ts
@@ -0,0 +1,30 @@
+import { expect, type Page } from "@playwright/test"
+import { newCredentials, submitSignupForm, type Credentials } from "./auth"
+import { waitForEditor } from "./editor"
+import { newWorkspace, submitWorkspaceForm, type Workspace } from "./workspace"
+
+// the prod stack's second instance of the image, started with no email
+// sender (oxynote-without-email in docker-compose.prod.yaml). Changing the
+// port means changing that service.
+export const WITHOUT_EMAIL_URL = "http://localhost:19081"
+
+// signUpWithWorkspaceWithoutEmail is signUpWithWorkspace for that instance.
+// Signup signs the new account straight in, so there is no link to follow
+// and no login step before the workspace is created.
+export async function signUpWithWorkspaceWithoutEmail(
+ page: Page,
+): Promise<{ credentials: Credentials; workspace: Workspace }> {
+ const credentials = newCredentials()
+ await submitSignupForm(page, credentials, WITHOUT_EMAIL_URL)
+ await expect(page).toHaveURL(`${WITHOUT_EMAIL_URL}/welcome`, {
+ timeout: 15_000,
+ })
+
+ const workspace = newWorkspace()
+ await submitWorkspaceForm(page, workspace)
+
+ await expect(page).toHaveURL(/-[a-z0-9]{20}$/, { timeout: 30_000 })
+ await waitForEditor(page)
+
+ return { credentials, workspace }
+}
diff --git a/e2e/tests/login.test.ts b/e2e/tests/login.test.ts
index 159d2771..05e8b365 100644
--- a/e2e/tests/login.test.ts
+++ b/e2e/tests/login.test.ts
@@ -26,6 +26,27 @@ test.describe("login", () => {
await expect(page.getByText(credentials.email)).toBeVisible()
})
+ test("takes a returning user back to their workspace", async ({
+ page,
+ request,
+ browser,
+ }) => {
+ const { credentials, workspace } = await signUpWithWorkspace(page, request)
+
+ const context = await browser.newContext()
+ const returning = await context.newPage()
+ await submitLoginForm(returning, credentials)
+
+ // the login redirect ends on the workspace's first document, a cold
+ // document load in a browser that has never opened it
+ await expect(returning).toHaveURL(
+ new RegExp(`/${workspace.slug}/.+-[a-z0-9]{20}$`),
+ { timeout: 30_000 },
+ )
+
+ await context.close()
+ })
+
test("rejects an unknown email and password", async ({ page }) => {
await submitLoginForm(page, newCredentials())
diff --git a/e2e/tests/prod-without-email.test.ts b/e2e/tests/prod-without-email.test.ts
new file mode 100644
index 00000000..004d0330
--- /dev/null
+++ b/e2e/tests/prod-without-email.test.ts
@@ -0,0 +1,245 @@
+import { expect, test, type Page } from "@playwright/test"
+import {
+ newCredentials,
+ submitLoginForm,
+ submitSignupForm,
+} from "../helpers/auth"
+import { sidebarDocument } from "../helpers/editor"
+import { t } from "../helpers/i18n"
+import { visit } from "../helpers/page"
+import { openSettings } from "../helpers/settings"
+import {
+ signUpWithWorkspaceWithoutEmail,
+ WITHOUT_EMAIL_URL,
+} from "../helpers/without-email"
+
+// confirms a deletion from settings with the given password and leaves the
+// browser wherever the app sends it.
+async function confirmAccountDeletion(
+ page: Page,
+ workspaceName: string,
+ password: string,
+): Promise {
+ const settings = await openSettings(page, workspaceName)
+ await settings
+ .getByRole("button", {
+ name: t("settings.profile.account-deletion-button"),
+ })
+ .click()
+
+ const action = page.getByRole("dialog", {
+ name: t("settings.action-modals.account-deletion.title"),
+ })
+ await action
+ .getByLabel(t("settings.action-modals.account-deletion.password-label"))
+ .fill(password)
+ await action
+ .getByRole("button", {
+ name: t("settings.action-modals.account-deletion.confirm-button"),
+ })
+ .click()
+}
+
+// the image started with no email sender. The launcher derives that mode
+// from the missing OXYNOTE_SMTP_DSN, which only the all-in-one image does.
+test.describe("without an email sender", () => {
+ test("signs a new account straight in", async ({ page }) => {
+ await submitSignupForm(page, newCredentials(), WITHOUT_EMAIL_URL)
+
+ await expect(page).toHaveURL(`${WITHOUT_EMAIL_URL}/welcome`, {
+ timeout: 15_000,
+ })
+ })
+
+ test("refuses to send a password reset link", async ({ page, request }) => {
+ const credentials = newCredentials()
+ await submitSignupForm(page, credentials, WITHOUT_EMAIL_URL)
+ await expect(page).toHaveURL(`${WITHOUT_EMAIL_URL}/welcome`, {
+ timeout: 15_000,
+ })
+
+ // the login page offers no reset at all, so the request goes
+ // straight to the endpoint it would have called
+ const response = await request.post(
+ `${WITHOUT_EMAIL_URL}/auth-realtime/api/auth/request-password-reset`,
+ {
+ headers: { Origin: WITHOUT_EMAIL_URL },
+ data: {
+ email: credentials.email,
+ redirectTo: `${WITHOUT_EMAIL_URL}/reset-password`,
+ },
+ },
+ )
+
+ expect(response.status()).toBe(400)
+ expect((await response.json()) as unknown).toMatchObject({
+ code: "RESET_PASSWORD_DISABLED",
+ })
+ })
+
+ test("takes a teammate in through a copied invitation link", async ({
+ page,
+ browser,
+ }) => {
+ // two signups, a workspace and an invitation run before the first
+ // assertion
+ test.slow()
+
+ await page.context().grantPermissions(["clipboard-read", "clipboard-write"])
+ const { workspace } = await signUpWithWorkspaceWithoutEmail(page)
+ const invitee = newCredentials()
+
+ await openSettings(page, workspace.name)
+ await page
+ .getByRole("button", {
+ name: t("settings.workspace.invitation-button"),
+ exact: true,
+ })
+ .click()
+ await page
+ .getByPlaceholder(
+ t("settings.action-modals.workspace-invitation.email-placeholder"),
+ )
+ .fill(invitee.email)
+ await page
+ .getByRole("button", {
+ name: t("settings.action-modals.workspace-invitation.submit-button"),
+ })
+ .click()
+
+ await page
+ .getByRole("row")
+ .filter({ hasText: invitee.email })
+ .getByRole("button", {
+ name: t("settings.workspace.members-option-button-screen-reader-hint"),
+ })
+ .click()
+ await page
+ .getByRole("menuitem", {
+ name: t("settings.workspace.member-options.copy-link.title"),
+ })
+ .click()
+ await expect(
+ page.getByText(t("settings.workspace.invitation-link-copied")),
+ ).toBeVisible()
+ const link = await page.evaluate(() => navigator.clipboard.readText())
+
+ const context = await browser.newContext()
+ const other = await context.newPage()
+ await submitSignupForm(other, invitee, WITHOUT_EMAIL_URL)
+ // a fresh account has no workspace yet, so signup lands on
+ // onboarding — the invitation is what gets them out of it
+ await expect(other).toHaveURL(`${WITHOUT_EMAIL_URL}/welcome`, {
+ timeout: 15_000,
+ })
+
+ await visit(other, link)
+ await other
+ .getByRole("button", {
+ name: t("onboarding.accept-invite.accept-button"),
+ })
+ .click()
+
+ await expect(other).toHaveURL(new RegExp(`/${workspace.slug}/`), {
+ timeout: 30_000,
+ })
+ await expect(sidebarDocument(other, "Welcome to Oxynote!")).toBeVisible()
+
+ await context.close()
+ })
+
+ test("moves the login to a new address once the password confirms it", async ({
+ page,
+ browser,
+ }) => {
+ const { credentials, workspace } =
+ await signUpWithWorkspaceWithoutEmail(page)
+ const newEmail = newCredentials().email
+
+ const settings = await openSettings(page, workspace.name)
+ await settings
+ .getByRole("button", {
+ name: t("settings.profile.email-change-button-screen-reader-hint"),
+ })
+ .click()
+
+ const action = page.getByRole("dialog", {
+ name: t("settings.action-modals.email-change.title"),
+ })
+ await action
+ .getByPlaceholder(
+ t("settings.action-modals.email-change.new-email-placeholder"),
+ )
+ .fill(newEmail)
+ await action
+ .getByPlaceholder(
+ t("settings.action-modals.email-change.password-placeholder"),
+ )
+ .fill(credentials.password)
+ await action
+ .getByRole("button", {
+ name: t("settings.action-modals.email-change.submit-button"),
+ })
+ .click()
+
+ await expect(
+ page.getByText(
+ t(
+ "settings.action-modals.email-change.success-message-without-email.title",
+ ),
+ ),
+ ).toBeVisible()
+
+ // a browser of its own proves the server took the new address,
+ // not just the form
+ const context = await browser.newContext()
+ const fresh = await context.newPage()
+ await submitLoginForm(
+ fresh,
+ { email: newEmail, password: credentials.password },
+ WITHOUT_EMAIL_URL,
+ )
+ await expect(fresh).toHaveURL(/-[a-z0-9]{20}$/, { timeout: 30_000 })
+
+ await context.close()
+ })
+
+ test("deletes the account once the password confirms it", async ({
+ page,
+ }) => {
+ const { credentials, workspace } =
+ await signUpWithWorkspaceWithoutEmail(page)
+
+ await confirmAccountDeletion(page, workspace.name, credentials.password)
+
+ await expect(page).toHaveURL(`${WITHOUT_EMAIL_URL}/signup?deletion=success`)
+
+ await submitLoginForm(page, credentials, WITHOUT_EMAIL_URL)
+ await expect(
+ page.getByText(t("onboarding.login.errors.invalid-credentials")),
+ ).toBeVisible()
+ })
+
+ test("keeps the account when the deletion password is wrong", async ({
+ page,
+ browser,
+ }) => {
+ const { credentials, workspace } =
+ await signUpWithWorkspaceWithoutEmail(page)
+
+ await confirmAccountDeletion(page, workspace.name, "not-the-Passw0rd!")
+
+ await expect(
+ page.getByText(
+ t("settings.action-modals.account-deletion.errors.invalid-password"),
+ ),
+ ).toBeVisible()
+
+ const context = await browser.newContext()
+ const fresh = await context.newPage()
+ await submitLoginForm(fresh, credentials, WITHOUT_EMAIL_URL)
+ await expect(fresh).toHaveURL(/-[a-z0-9]{20}$/, { timeout: 30_000 })
+
+ await context.close()
+ })
+})
diff --git a/server/auth-realtime/src/auth.test.ts b/server/auth-realtime/src/auth.test.ts
index 31fe67b8..1f819b25 100644
--- a/server/auth-realtime/src/auth.test.ts
+++ b/server/auth-realtime/src/auth.test.ts
@@ -1,6 +1,7 @@
import { describe, it, vi } from "vitest"
import {
authMethods,
+ confirmWithPassword,
createAuth,
createOrganizationHooks,
createSecondaryStorage,
@@ -8,6 +9,7 @@ import {
invitationLink,
organizationClaims,
toPublicAuthUrl,
+ verificationTemplate,
type SecondaryStorageClient,
} from "./auth.js"
import { createDatabase, type Store } from "./db.js"
@@ -71,6 +73,16 @@ function buildAuth(
return { auth, core, log }
}
+// an email callback is left out when no email can be sent, so its type
+// admits undefined even on an instance built with email enabled.
+function configured(callback: T | undefined): T {
+ if (!callback) {
+ throw new Error("callback is not configured")
+ }
+
+ return callback
+}
+
describe("authMethods", () => {
it("always offers email and password", ({ expect }) => {
expect(authMethods(testEnv())).toEqual(["email-password"])
@@ -116,6 +128,51 @@ describe("toPublicAuthUrl", () => {
})
})
+describe("verificationTemplate", () => {
+ // better-auth's verification tokens are JWTs; only the payload is read
+ function token(payload: Record): string {
+ const body = Buffer.from(JSON.stringify(payload)).toString(
+ "base64url",
+ )
+
+ return `header.${body}.signature`
+ }
+
+ it("sends the new-address wording for a change of address", ({
+ expect,
+ }) => {
+ expect(
+ verificationTemplate(
+ token({
+ email: "old@b.c",
+ updateTo: "new@b.c",
+ requestType:
+ "change-email-verification",
+ }),
+ ),
+ ).toBe("email_verification")
+ })
+
+ it.for([
+ {
+ name: "a signup token, which names no other address",
+ input: "header.eyJlbWFpbCI6ImFAYi5jIn0.signature",
+ },
+ { name: "a token with no payload at all", input: "opaque" },
+ {
+ name: "a payload that is not JSON",
+ input: "header.@@@.signature",
+ },
+ ])(
+ "sends the activation wording for $name",
+ ({ input }, { expect }) => {
+ expect(verificationTemplate(input)).toBe(
+ "signup_verification",
+ )
+ },
+ )
+})
+
describe("electronCallbackOverride", () => {
const electronContext = {
path: "/sign-in/social",
@@ -347,6 +404,219 @@ describe("createOrganizationHooks", () => {
})
})
+describe("confirmWithPassword", () => {
+ const signedIn = () =>
+ Promise.resolve({ id: "u1", email: "ada@oxynote.test" })
+
+ // an email change by an account whose password checks out, to an
+ // address nobody holds, unless a test says otherwise.
+ function passwordContext(
+ overrides: {
+ path?: string
+ body?: Record
+ account?: { password?: string | null } | null
+ verifies?: boolean
+ holder?: unknown
+ } = {},
+ ) {
+ const internalAdapter = {
+ findCredentialAccount: vi
+ .fn()
+ .mockResolvedValue(
+ overrides.account === undefined
+ ? { password: "hash" }
+ : overrides.account,
+ ),
+ findUserByEmail: vi
+ .fn()
+ .mockResolvedValue(overrides.holder ?? null),
+ updateUser: vi.fn().mockResolvedValue({}),
+ }
+ const password = {
+ verify: vi
+ .fn()
+ .mockResolvedValue(overrides.verifies ?? true),
+ }
+ const json = vi.fn((body: { status: boolean }) => body)
+
+ return {
+ ctx: {
+ path: overrides.path ?? "/change-email",
+ body: overrides.body ?? {
+ newEmail: "new@oxynote.test",
+ password: "secret",
+ },
+ context: { internalAdapter, password },
+ json,
+ },
+ internalAdapter,
+ password,
+ json,
+ }
+ }
+
+ it("leaves every other endpoint alone", async ({ expect }) => {
+ const { ctx, internalAdapter } = passwordContext({
+ path: "/sign-in/email",
+ })
+ const currentUser = vi.fn(signedIn)
+
+ const answer = await confirmWithPassword(ctx, currentUser)
+
+ expect(answer).toBeUndefined()
+ expect.soft(currentUser).not.toHaveBeenCalled()
+ expect.soft(
+ internalAdapter.findCredentialAccount,
+ ).not.toHaveBeenCalled()
+ })
+
+ it("leaves a caller without a session to the endpoint", async ({
+ expect,
+ }) => {
+ const { ctx, internalAdapter } = passwordContext()
+
+ const answer = await confirmWithPassword(ctx, () =>
+ Promise.resolve(undefined),
+ )
+
+ expect(answer).toBeUndefined()
+ expect.soft(
+ internalAdapter.findCredentialAccount,
+ ).not.toHaveBeenCalled()
+ expect.soft(internalAdapter.updateUser).not.toHaveBeenCalled()
+ })
+
+ it("refuses an account that has no password", async ({ expect }) => {
+ const { ctx, internalAdapter, password } = passwordContext({
+ account: null,
+ })
+
+ await expect(
+ confirmWithPassword(ctx, signedIn),
+ ).rejects.toMatchObject({
+ body: { code: "CREDENTIAL_ACCOUNT_NOT_FOUND" },
+ })
+
+ expect.soft(
+ internalAdapter.findCredentialAccount,
+ ).toHaveBeenCalledWith("u1")
+ expect.soft(password.verify).not.toHaveBeenCalled()
+ expect.soft(internalAdapter.updateUser).not.toHaveBeenCalled()
+ })
+
+ it("refuses a request that carries no password", async ({ expect }) => {
+ const { ctx, internalAdapter, password } = passwordContext({
+ body: { newEmail: "new@oxynote.test" },
+ })
+
+ await expect(
+ confirmWithPassword(ctx, signedIn),
+ ).rejects.toMatchObject({ body: { code: "INVALID_PASSWORD" } })
+
+ expect.soft(password.verify).not.toHaveBeenCalled()
+ expect.soft(internalAdapter.updateUser).not.toHaveBeenCalled()
+ })
+
+ it("refuses a wrong password", async ({ expect }) => {
+ const { ctx, internalAdapter, password } = passwordContext({
+ verifies: false,
+ })
+
+ await expect(
+ confirmWithPassword(ctx, signedIn),
+ ).rejects.toMatchObject({ body: { code: "INVALID_PASSWORD" } })
+
+ expect.soft(password.verify).toHaveBeenCalledWith({
+ hash: "hash",
+ password: "secret",
+ })
+ expect.soft(internalAdapter.updateUser).not.toHaveBeenCalled()
+ })
+
+ it("lets a deletion through to better-auth once the password checks out", async ({
+ expect,
+ }) => {
+ const { ctx, internalAdapter, password, json } =
+ passwordContext({
+ path: "/delete-user",
+ body: { password: "secret" },
+ })
+
+ const answer = await confirmWithPassword(ctx, signedIn)
+
+ expect(answer).toBeUndefined()
+ expect.soft(password.verify).toHaveBeenCalledTimes(1)
+ expect.soft(
+ internalAdapter.findUserByEmail,
+ ).not.toHaveBeenCalled()
+ expect.soft(internalAdapter.updateUser).not.toHaveBeenCalled()
+ expect.soft(json).not.toHaveBeenCalled()
+ })
+
+ it("stores the new address unverified once the password checks out", async ({
+ expect,
+ }) => {
+ const { ctx, internalAdapter, password } = passwordContext({
+ body: {
+ newEmail: "New@Oxynote.test",
+ password: "secret",
+ },
+ })
+
+ const answer = await confirmWithPassword(ctx, signedIn)
+
+ expect(answer).toEqual({ status: true })
+ expect.soft(password.verify).toHaveBeenCalledTimes(1)
+ expect.soft(
+ internalAdapter.findUserByEmail,
+ ).toHaveBeenCalledWith("new@oxynote.test")
+ expect.soft(internalAdapter.updateUser).toHaveBeenCalledWith(
+ "u1",
+ {
+ email: "new@oxynote.test",
+ emailVerified: false,
+ },
+ )
+ })
+
+ it("refuses an address another account holds", async ({ expect }) => {
+ const { ctx, internalAdapter } = passwordContext({
+ holder: { user: { id: "u2" } },
+ })
+
+ await expect(
+ confirmWithPassword(ctx, signedIn),
+ ).rejects.toMatchObject({
+ body: { code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL" },
+ })
+
+ expect.soft(internalAdapter.updateUser).not.toHaveBeenCalled()
+ })
+
+ it.for([
+ { name: "a malformed address", input: "not-an-email" },
+ { name: "the current address", input: "ada@oxynote.test" },
+ ])(
+ "leaves $name to the endpoint's own validation",
+ async ({ input }, { expect }) => {
+ const { ctx, internalAdapter, json } = passwordContext({
+ body: { newEmail: input, password: "secret" },
+ })
+
+ const answer = await confirmWithPassword(ctx, signedIn)
+
+ expect(answer).toBeUndefined()
+ expect.soft(
+ internalAdapter.findUserByEmail,
+ ).not.toHaveBeenCalled()
+ expect.soft(
+ internalAdapter.updateUser,
+ ).not.toHaveBeenCalled()
+ expect.soft(json).not.toHaveBeenCalled()
+ },
+ )
+})
+
describe("createSecondaryStorage", () => {
it("reads through to the redis client", async ({ expect }) => {
const redis = stubRedis()
@@ -575,7 +845,9 @@ describe("createAuth", () => {
}) => {
const { auth, core } = buildAuth()
- await auth.options.emailAndPassword.sendResetPassword({
+ await configured(
+ auth.options.emailAndPassword.sendResetPassword,
+ )({
user: { email: "a@b.c" },
url: "http://localhost:8080/api/auth/reset/tok",
token: "tok",
@@ -616,13 +888,14 @@ describe("createAuth", () => {
}) => {
const { auth, core } = buildAuth()
- await auth.options.emailVerification.sendVerificationEmail(
- {
- user: { email: "a@b.c" },
- url: "http://localhost:8080/api/auth/verify/tok",
- token: "tok",
- } as never,
- )
+ await configured(
+ auth.options.emailVerification
+ .sendVerificationEmail,
+ )({
+ user: { email: "a@b.c" },
+ url: "http://localhost:8080/api/auth/verify/tok",
+ token: "tok",
+ } as never)
expect(core.sendEmail).toHaveBeenCalledWith(
"signup_verification",
@@ -633,25 +906,59 @@ describe("createAuth", () => {
)
})
- it("confirms a change of address at the new address", async ({
+ // the first step of a change of address asks the current
+ // address to approve it; sending this to the new address would
+ // ask it to approve its own claim
+ it("asks the current address to approve a change of address", async ({
expect,
}) => {
const { auth, core } = buildAuth()
- await auth.options.user.changeEmail.sendChangeEmailConfirmation(
+ await configured(
+ auth.options.user.changeEmail
+ .sendChangeEmailConfirmation,
+ )({
+ user: { email: "old@b.c" },
+ newEmail: "new@b.c",
+ url: "http://localhost:8080/api/auth/change/tok",
+ token: "tok",
+ } as never)
+
+ expect(core.sendEmail).toHaveBeenCalledWith(
+ "email_change_confirmation",
{
- user: { email: "old@b.c" },
- newEmail: "new@b.c",
- url: "http://localhost:8080/api/auth/change/tok",
- token: "tok",
- } as never,
+ email: "old@b.c",
+ link: "http://localhost:8080/auth-realtime/api/auth/change/tok",
+ },
)
+ })
+
+ // the second step, which better-auth sends through the same
+ // callback as a signup activation
+ it("asks the new address to verify itself", async ({
+ expect,
+ }) => {
+ const { auth, core } = buildAuth()
+
+ await configured(
+ auth.options.emailVerification
+ .sendVerificationEmail,
+ )({
+ user: { email: "new@b.c" },
+ url: "http://localhost:8080/api/auth/verify/tok",
+ token: `header.${Buffer.from(
+ JSON.stringify({
+ email: "old@b.c",
+ updateTo: "new@b.c",
+ }),
+ ).toString("base64url")}.signature`,
+ } as never)
expect(core.sendEmail).toHaveBeenCalledWith(
"email_verification",
{
email: "new@b.c",
- link: "http://localhost:8080/auth-realtime/api/auth/change/tok",
+ link: "http://localhost:8080/auth-realtime/api/auth/verify/tok",
},
)
})
@@ -661,13 +968,14 @@ describe("createAuth", () => {
}) => {
const { auth, core } = buildAuth()
- await auth.options.user.deleteUser.sendDeleteAccountVerification(
- {
- user: { email: "a@b.c" },
- url: "http://localhost:8080/api/auth/delete/tok",
- token: "tok",
- } as never,
- )
+ await configured(
+ auth.options.user.deleteUser
+ .sendDeleteAccountVerification,
+ )({
+ user: { email: "a@b.c" },
+ url: "http://localhost:8080/api/auth/delete/tok",
+ token: "tok",
+ } as never)
expect(core.sendEmail).toHaveBeenCalledWith(
"user_deletion",
@@ -687,15 +995,44 @@ describe("createAuth", () => {
const { auth } = buildAuth({ core })
await expect(
- auth.options.emailAndPassword.sendResetPassword(
- {
- user: { email: "a@b.c" },
- url: "http://localhost:8080/api/auth/reset/tok",
- token: "tok",
- } as never,
- ),
+ configured(
+ auth.options.emailAndPassword
+ .sendResetPassword,
+ )({
+ user: { email: "a@b.c" },
+ url: "http://localhost:8080/api/auth/reset/tok",
+ token: "tok",
+ } as never),
).rejects.toBe(failure)
})
+
+ // nothing can reach an inbox, so no flow may wait on one
+ it("sends nothing and verifies nothing when email is disabled", ({
+ expect,
+ }) => {
+ const { auth } = buildAuth({
+ env: testEnv({ emailEnabled: false }),
+ })
+
+ expect(
+ auth.options.emailAndPassword
+ .requireEmailVerification,
+ ).toBe(false)
+ expect(
+ auth.options.emailVerification.sendOnSignUp,
+ ).toBe(false)
+ expect(
+ auth.options.emailVerification
+ .sendVerificationEmail,
+ ).toBeUndefined()
+ expect(
+ auth.options.emailAndPassword.sendResetPassword,
+ ).toBeUndefined()
+ expect(
+ auth.options.user.deleteUser
+ .sendDeleteAccountVerification,
+ ).toBeUndefined()
+ })
})
// core scopes every request by the session's organization, so a
@@ -808,6 +1145,82 @@ describe("createAuth", () => {
})
})
+ describe("before hook", () => {
+ // the request context as better-auth hands it to the hook, with the
+ // session already resolved, which is where getSessionFromCtx reads
+ // it from before it would look at any cookie.
+ function changeEmailRequest(updateUser = vi.fn()) {
+ return {
+ path: "/change-email",
+ body: {
+ newEmail: "new@oxynote.test",
+ password: "secret",
+ },
+ context: {
+ session: {
+ user: {
+ id: "u1",
+ email: "ada@oxynote.test",
+ },
+ },
+ internalAdapter: {
+ findCredentialAccount: vi
+ .fn()
+ .mockResolvedValue({
+ password: "hash",
+ }),
+ findUserByEmail: vi
+ .fn()
+ .mockResolvedValue(
+ null,
+ ),
+ updateUser,
+ },
+ password: {
+ verify: vi
+ .fn()
+ .mockResolvedValue(
+ true,
+ ),
+ },
+ },
+ }
+ }
+
+ it("changes the address itself once the password checks out when email is disabled", async ({
+ expect,
+ }) => {
+ const { auth } = buildAuth({
+ env: testEnv({ emailEnabled: false }),
+ })
+ const updateUser = vi.fn().mockResolvedValue({})
+
+ const answer = await auth.options.hooks.before(
+ changeEmailRequest(updateUser) as never,
+ )
+
+ expect(answer).toEqual({ status: true })
+ expect.soft(updateUser).toHaveBeenCalledWith("u1", {
+ email: "new@oxynote.test",
+ emailVerified: false,
+ })
+ })
+
+ it("leaves an address change to better-auth while email is enabled", async ({
+ expect,
+ }) => {
+ const { auth } = buildAuth()
+ const updateUser = vi.fn().mockResolvedValue({})
+
+ const answer = await auth.options.hooks.before(
+ changeEmailRequest(updateUser) as never,
+ )
+
+ expect(answer).toBeUndefined()
+ expect.soft(updateUser).not.toHaveBeenCalled()
+ })
+ })
+
describe("password policy", () => {
it("requires a verified address and a long passphrase", ({
expect,
diff --git a/server/auth-realtime/src/auth.ts b/server/auth-realtime/src/auth.ts
index 3babbe5b..461bf7e2 100644
--- a/server/auth-realtime/src/auth.ts
+++ b/server/auth-realtime/src/auth.ts
@@ -1,11 +1,16 @@
import { betterAuth } from "better-auth"
-import { createAuthMiddleware } from "better-auth/api"
+import {
+ APIError,
+ createAuthMiddleware,
+ getSessionFromCtx,
+} from "better-auth/api"
import { jwt, organization } from "better-auth/plugins"
import { electron } from "@better-auth/electron"
import { mcp } from "@better-auth/mcp"
import type { PostgresDialect } from "kysely"
+import { z } from "zod"
import type { Store } from "./db.js"
-import type { CoreClient } from "./core.js"
+import type { CoreClient, EmailTemplate } from "./core.js"
import type { Env, SocialProviderName } from "./env.js"
import type { Logger, LogLevel } from "./logging.js"
import { reported } from "./reporting.js"
@@ -69,6 +74,36 @@ export function toPublicAuthUrl(env: Env, url: string): string {
return url.replace(env.authOrigin, env.publicAuthBaseUrl)
}
+// the activation link a signup sends and the link that moves an account to
+// a new address both arrive at sendVerificationEmail, and only the second
+// one's token names the address it switches to. A token that cannot be read
+// is treated as a signup, the flow every account goes through first.
+export function verificationTemplate(token: string): EmailTemplate {
+ const payload = token.split(".")[1]
+ if (!payload) {
+ return "signup_verification"
+ }
+
+ let decoded: unknown
+
+ try {
+ decoded = JSON.parse(
+ Buffer.from(payload, "base64url").toString(),
+ )
+ } catch {
+ return "signup_verification"
+ }
+
+ const updateTo =
+ decoded && typeof decoded === "object"
+ ? (decoded as { updateTo?: unknown }).updateTo
+ : undefined
+
+ return typeof updateTo === "string" && updateTo
+ ? "email_verification"
+ : "signup_verification"
+}
+
export interface SocialSignInContext {
path: string
query?: Record | null
@@ -201,6 +236,111 @@ export function createOrganizationHooks({
}
}
+// the part of better-auth's endpoint context confirmWithPassword reads.
+// Declared structurally so a test can drive it with plain stubs.
+export interface PasswordConfirmationContext {
+ path: string
+ body?: Record | null
+ context: {
+ internalAdapter: {
+ findCredentialAccount(
+ userId: string,
+ ): Promise<{ password?: string | null } | null>
+ findUserByEmail(email: string): Promise
+ updateUser(
+ userId: string,
+ data: { email: string; emailVerified: boolean },
+ ): Promise
+ }
+ password: {
+ verify(data: {
+ hash: string
+ password: string
+ }): Promise
+ }
+ }
+ json(body: { status: boolean }): unknown
+}
+
+export interface SessionUser {
+ id: string
+ email: string
+}
+
+// better-auth confirms an email change or an account deletion through an
+// emailed link. Without email the current password confirms it instead,
+// checked before either endpoint runs; an account without a password
+// (social login only) has nothing to confirm with and is refused. Resolves
+// to the hook's own answer, or undefined to let the endpoint run.
+export async function confirmWithPassword(
+ ctx: PasswordConfirmationContext,
+ currentUser: () => Promise,
+): Promise {
+ if (ctx.path !== "/change-email" && ctx.path !== "/delete-user") {
+ return undefined
+ }
+
+ // the endpoint's own session check answers a caller without one.
+ const user = await currentUser()
+ if (!user) {
+ return undefined
+ }
+
+ const account = await ctx.context.internalAdapter.findCredentialAccount(
+ user.id,
+ )
+ if (!account?.password) {
+ throw APIError.from("FORBIDDEN", {
+ code: "CREDENTIAL_ACCOUNT_NOT_FOUND",
+ message: "Credential account not found",
+ })
+ }
+
+ const password = ctx.body?.password
+ if (
+ typeof password !== "string" ||
+ !(await ctx.context.password.verify({
+ hash: account.password,
+ password,
+ }))
+ ) {
+ throw APIError.from("BAD_REQUEST", {
+ code: "INVALID_PASSWORD",
+ message: "Invalid password",
+ })
+ }
+
+ // with no deletion email configured, better-auth deletes the account
+ // itself.
+ if (ctx.path === "/delete-user") {
+ return undefined
+ }
+
+ // better-auth changes an address without a link only for an
+ // unverified account, so the change is made here, and the new address
+ // is stored unverified because nothing has proven it. A malformed or
+ // unchanged address is left to the endpoint's own validation.
+ const parsed = z.email().safeParse(ctx.body?.newEmail)
+ const newEmail = parsed.success ? parsed.data.toLowerCase() : undefined
+ if (!newEmail || newEmail === user.email) {
+ return undefined
+ }
+
+ if (await ctx.context.internalAdapter.findUserByEmail(newEmail)) {
+ throw APIError.from("UNPROCESSABLE_ENTITY", {
+ code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL",
+ message: "User already exists. Use another email.",
+ })
+ }
+
+ await ctx.context.internalAdapter.updateUser(user.id, {
+ email: newEmail,
+ emailVerified: false,
+ })
+
+ return ctx.json({ status: true })
+}
+
export function createSecondaryStorage(redis: SecondaryStorageClient) {
return {
get: (key: string) => reported(() => redis.get(key)),
@@ -306,7 +446,9 @@ export function createAuth({
},
emailAndPassword: {
enabled: true,
- requireEmailVerification: true,
+ // without email no verification link can arrive, so a new
+ // account is signed in straight away.
+ requireEmailVerification: env.emailEnabled,
minPasswordLength: 16,
maxPasswordLength: 128,
// a reset always happens outside an authenticated session
@@ -316,17 +458,28 @@ export function createAuth({
// revokes its other sessions via revokeOtherSessions on
// the client call instead.
revokeSessionsOnPasswordReset: true,
- sendResetPassword: async ({ user, url }) => {
- await reported(() =>
- core.sendEmail("password_reset", {
- email: user.email,
- link: toPublicAuthUrl(env, url),
- }),
- )
- },
- // duplicate signups get better-auth's synthetic success so
- // the browser can't probe which emails have accounts. The
- // real owner is told through their inbox instead.
+ // with no sender better-auth refuses a reset request
+ // (RESET_PASSWORD_DISABLED) instead of promising a link.
+ sendResetPassword: env.emailEnabled
+ ? async ({ user, url }) => {
+ await reported(() =>
+ core.sendEmail(
+ "password_reset",
+ {
+ email: user.email,
+ link: toPublicAuthUrl(
+ env,
+ url,
+ ),
+ },
+ ),
+ )
+ }
+ : undefined,
+ // where verification is required, duplicate signups get
+ // better-auth's synthetic success so the browser can't probe
+ // which emails have accounts. The real owner is told through
+ // their inbox instead.
onExistingUserSignUp: async ({ user }) => {
await reported(() =>
core.sendEmail("account_exists", {
@@ -337,23 +490,35 @@ export function createAuth({
},
},
emailVerification: {
- sendOnSignUp: true,
+ sendOnSignUp: env.emailEnabled,
// re-send the verification link when an unverified user
// tries to log in — the login page forwards them to the
// check-your-inbox page, which would otherwise lie about
// an email being sent.
sendOnSignIn: true,
- // signup/sign-in verification gets its own
- // account-activation template; the change-email flow
- // below keeps the "new email address" one.
- sendVerificationEmail: async ({ user, url }) => {
- await reported(() =>
- core.sendEmail("signup_verification", {
- email: user.email,
- link: toPublicAuthUrl(env, url),
- }),
- )
- },
+ // signup/sign-in activation and the change-email link both
+ // come through here, so the token decides which wording is
+ // sent. With no sender, better-auth's change-email endpoint
+ // has no link to offer either, and confirmWithPassword takes
+ // the change over.
+ sendVerificationEmail: env.emailEnabled
+ ? async ({ user, url, token }) => {
+ await reported(() =>
+ core.sendEmail(
+ verificationTemplate(
+ token,
+ ),
+ {
+ email: user.email,
+ link: toPublicAuthUrl(
+ env,
+ url,
+ ),
+ },
+ ),
+ )
+ }
+ : undefined,
},
rateLimit: {
enabled: env.rateLimitEnabled,
@@ -391,43 +556,48 @@ export function createAuth({
},
changeEmail: {
enabled: true,
- sendChangeEmailConfirmation: async ({
- newEmail,
- url,
- }) => {
- await reported(() =>
- core.sendEmail(
- "email_verification",
- {
- email: newEmail,
- link: toPublicAuthUrl(
- env,
- url,
+ // the first step of a change of address: the current
+ // address approves it, and better-auth then emails the
+ // new one through sendVerificationEmail above. Sending
+ // this to the new address instead would ask it to
+ // approve its own claim.
+ sendChangeEmailConfirmation: env.emailEnabled
+ ? async ({ user, url }) => {
+ await reported(() =>
+ core.sendEmail(
+ "email_change_confirmation",
+ {
+ email: user.email,
+ link: toPublicAuthUrl(
+ env,
+ url,
+ ),
+ },
),
- },
- ),
- )
- },
+ )
+ }
+ : undefined,
},
deleteUser: {
enabled: true,
- sendDeleteAccountVerification: async ({
- user,
- url,
- }) => {
- await reported(() =>
- core.sendEmail(
- "user_deletion",
- {
- email: user.email,
- link: toPublicAuthUrl(
- env,
- url,
+ // with no sender better-auth deletes the account at once,
+ // after confirmWithPassword has checked the password.
+ sendDeleteAccountVerification: env.emailEnabled
+ ? async ({ user, url }) => {
+ await reported(() =>
+ core.sendEmail(
+ "user_deletion",
+ {
+ email: user.email,
+ link: toPublicAuthUrl(
+ env,
+ url,
+ ),
+ },
),
- },
- ),
- )
- },
+ )
+ }
+ : undefined,
},
},
account: {
@@ -694,14 +864,28 @@ export function createAuth({
}),
],
hooks: {
- before: createAuthMiddleware((ctx) =>
- Promise.resolve(
- electronCallbackOverride(
- ctx,
- env.frontendUrl,
- ),
- ),
- ),
+ before: createAuthMiddleware(async (ctx) => {
+ if (!env.emailEnabled) {
+ const answer =
+ await confirmWithPassword(
+ ctx,
+ async () =>
+ (
+ await getSessionFromCtx(
+ ctx,
+ )
+ )?.user,
+ )
+ if (answer !== undefined) {
+ return answer
+ }
+ }
+
+ return electronCallbackOverride(
+ ctx,
+ env.frontendUrl,
+ )
+ }),
},
databaseHooks: {
session: {
diff --git a/server/auth-realtime/src/core.ts b/server/auth-realtime/src/core.ts
index 5e341ad9..bf4ba102 100644
--- a/server/auth-realtime/src/core.ts
+++ b/server/auth-realtime/src/core.ts
@@ -6,6 +6,7 @@ import type { AxiosHeaders } from "axios"
// dropped email.
export type EmailTemplate =
| "email_verification"
+ | "email_change_confirmation"
| "password_reset"
| "account_exists"
| "signup_verification"
diff --git a/server/auth-realtime/src/env.test.ts b/server/auth-realtime/src/env.test.ts
index 779cbb91..96b6cdcb 100644
--- a/server/auth-realtime/src/env.test.ts
+++ b/server/auth-realtime/src/env.test.ts
@@ -249,6 +249,26 @@ describe("loadEnv", () => {
})
})
+ describe("email", () => {
+ it.for([
+ { name: "unset", input: undefined, expected: true },
+ { name: '"false"', input: "false", expected: true },
+ { name: '"true"', input: "true", expected: false },
+ ])(
+ "is enabled=$expected when the disable flag is $name",
+ ({ input, expected }, { expect }) => {
+ const env = loadEnv(
+ completeEnv({
+ OXYNOTE_AUTH_REALTIME_EMAIL_DISABLED:
+ input,
+ }),
+ )
+
+ expect(env.emailEnabled).toBe(expected)
+ },
+ )
+ })
+
describe("trusted origins", () => {
it("splits the list on commas", ({ expect }) => {
const env = loadEnv(completeEnv())
diff --git a/server/auth-realtime/src/env.ts b/server/auth-realtime/src/env.ts
index ed44a9c6..1b14bf7a 100644
--- a/server/auth-realtime/src/env.ts
+++ b/server/auth-realtime/src/env.ts
@@ -41,6 +41,9 @@ export interface Env {
maxOrganizations: number
maxOrganizationMembers: number
rateLimitEnabled: boolean
+ // false on a deployment with no email relay, where no link better-auth
+ // sends can reach an inbox.
+ emailEnabled: boolean
logLevel: LogLevel
}
@@ -155,6 +158,11 @@ const schema = z
// does not pass the original IP through — so it can be
// switched off and left to whatever sits in front.
OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED: flag(false),
+ // core logs what it cannot send, so this is set where core has no
+ // SMTP relay: signup then skips verification, password reset is
+ // refused, and email changes and account deletions are confirmed
+ // with the password instead of a link.
+ OXYNOTE_AUTH_REALTIME_EMAIL_DISABLED: flag(false),
// the floor for better-auth's and hocuspocus's output too.
OXYNOTE_AUTH_REALTIME_LOG_LEVEL: z
.enum(["DEBUG", "INFO", "WARN", "ERROR"])
@@ -280,6 +288,7 @@ export function loadEnv(source: Record): Env {
values.OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS,
rateLimitEnabled:
!values.OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED,
+ emailEnabled: !values.OXYNOTE_AUTH_REALTIME_EMAIL_DISABLED,
logLevel: values.OXYNOTE_AUTH_REALTIME_LOG_LEVEL,
}
}
diff --git a/server/auth-realtime/src/routes.test.ts b/server/auth-realtime/src/routes.test.ts
index 30e95acb..1ad35fd0 100644
--- a/server/auth-realtime/src/routes.test.ts
+++ b/server/auth-realtime/src/routes.test.ts
@@ -218,6 +218,23 @@ describe("createRoutes", () => {
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
methods: ["email-password", "github"],
+ emailEnabled: true,
+ })
+ })
+
+ it("tells the pages when no email can be delivered", async ({
+ expect,
+ }) => {
+ const { app } = build({
+ env: testEnv({ emailEnabled: false }),
+ })
+
+ const res = await app.request("/auth-config")
+
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({
+ methods: ["email-password"],
+ emailEnabled: false,
})
})
})
diff --git a/server/auth-realtime/src/routes.ts b/server/auth-realtime/src/routes.ts
index 5518f5a2..75e2210e 100644
--- a/server/auth-realtime/src/routes.ts
+++ b/server/auth-realtime/src/routes.ts
@@ -223,11 +223,13 @@ export function createRoutes({
})
// public auth configuration for the login/signup pages: which social
- // providers are configured (and, later, other signup-related
- // capability flags). Lives outside the better-auth /auth/**
- // namespace.
+ // providers are configured, and whether emailed links can arrive at
+ // all. Lives outside the better-auth /auth/** namespace.
app.get("/auth-config", (c) => {
- return c.json({ methods: authMethods(env) })
+ return c.json({
+ methods: authMethods(env),
+ emailEnabled: env.emailEnabled,
+ })
})
app.get("/organizations/stats", async (c) => {
diff --git a/server/auth-realtime/src/test-helpers.ts b/server/auth-realtime/src/test-helpers.ts
index b8a63d10..088bbfbd 100644
--- a/server/auth-realtime/src/test-helpers.ts
+++ b/server/auth-realtime/src/test-helpers.ts
@@ -109,6 +109,7 @@ export function testEnv(overrides: Partial = {}): Env {
maxOrganizations: 100,
maxOrganizationMembers: 5,
rateLimitEnabled: true,
+ emailEnabled: true,
logLevel: "INFO",
...overrides,
}
diff --git a/server/core/internal/email/email.go b/server/core/internal/email/email.go
index 168f8136..fcd8855d 100644
--- a/server/core/internal/email/email.go
+++ b/server/core/internal/email/email.go
@@ -252,6 +252,19 @@ func (s *Sender) SendEmailVerification(eml, link string) {
)
}
+// SendEmailChangeConfirmation sends an email asking the current address to
+// approve a change of address, with the approval link.
+func (s *Sender) SendEmailChangeConfirmation(eml, link string) {
+ s.send(
+ eml,
+ "Approve your email address change",
+ TemplateEmailChangeConfirmation,
+ map[string]string{
+ _linkKey: link,
+ },
+ )
+}
+
// SendOrganizationInvitation sends an email regarding
// organization invitation with the token, embedded into a full URL, to the
// specified email address.
diff --git a/server/core/internal/email/email_test.go b/server/core/internal/email/email_test.go
index 3eeefca0..a54a867a 100644
--- a/server/core/internal/email/email_test.go
+++ b/server/core/internal/email/email_test.go
@@ -237,6 +237,22 @@ func Test_Sender_SendEmailVerification(t *testing.T) {
assert.Contains(t, msgBody(t, msg), "https://example.com/verify")
}
+func Test_Sender_SendEmailChangeConfirmation(t *testing.T) {
+ t.Parallel()
+
+ client := &clientMock{}
+ s := stubSender(client)
+
+ s.SendEmailChangeConfirmation("user@example.com", "https://example.com/approve")
+
+ s.supv.Wait()
+
+ msg := sentMsg(t, client)
+ assert.Equal(t, "user@example.com", msgTo(t, msg))
+ assert.Equal(t, []string{"Approve your email address change"}, msg.GetGenHeader(mail.HeaderSubject))
+ assert.Contains(t, msgBody(t, msg), "https://example.com/approve")
+}
+
func Test_Sender_SendOrganizationInvitation(t *testing.T) {
t.Parallel()
diff --git a/server/core/internal/email/templates.go b/server/core/internal/email/templates.go
index c4396da0..0b81cf50 100644
--- a/server/core/internal/email/templates.go
+++ b/server/core/internal/email/templates.go
@@ -14,6 +14,10 @@ const (
// verification template.
TemplateEmailVerification Template = "email_verification"
+ // TemplateEmailChangeConfirmation specifies the template asking the
+ // current address to approve a change of address.
+ TemplateEmailChangeConfirmation Template = "email_change_confirmation"
+
// TemplateOrganizationInvitation specifies the organization
// invitation template.
TemplateOrganizationInvitation Template = "organization_invitation"
diff --git a/server/core/internal/email/templates/email_change_confirmation.html b/server/core/internal/email/templates/email_change_confirmation.html
new file mode 100644
index 00000000..684d89dc
--- /dev/null
+++ b/server/core/internal/email/templates/email_change_confirmation.html
@@ -0,0 +1,197 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Hi there,
Someone asked to move your Oxynote account to a different email address. This message went to your current address, so nothing changes until you approve it.
To approve the change, click the button below. We will then email the new address to confirm it belongs to you: