diff --git a/.changeset/brave-links-arrive.md b/.changeset/brave-links-arrive.md new file mode 100644 index 0000000000..a494ff93f5 --- /dev/null +++ b/.changeset/brave-links-arrive.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/auth": patch +"emdash": patch +--- + +Fixes email-verification signup, which could not be completed: the verification email linked to the JSON API endpoint instead of the signup page, the signup page itself redirected anonymous visitors to login, and that redirect dropped the `?token=` from the URL. The email now links to `/_emdash/admin/signup?token=…` (as the invite email already did), the page is reachable without a session, and the login redirect preserves the query string of the page it returns to. diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts index a6baab7693..773affad3d 100644 --- a/e2e/tests/auth.spec.ts +++ b/e2e/tests/auth.spec.ts @@ -23,7 +23,7 @@ const SECURITY_SETTINGS_URL_PATTERN = /\/settings\/security/; const LOGIN_OR_ADMIN_URL_PATTERN = /\/(login|admin)/; const SECURITY_MENUITEM_REGEX = /Security/i; const ADD_PASSKEY_REGEX = /Add Passkey/i; -const SIGN_HEADING_REGEX = /sign/i; +const SIGNUP_OR_LOGIN_HEADING_REGEX = /create an account|sign in/i; test.describe("Authentication", () => { test.describe("Login Page", () => { @@ -238,10 +238,10 @@ test.describe("Signup Page", () => { // Navigate directly (not through admin which has auth) await admin.page.goto("/_emdash/admin/signup"); - // Wait for the React app to hydrate and render a heading with sign-related content. + // Wait for the React app to hydrate and render the signup heading. // The SPA may render the login page if signup is disabled, so accept either. await expect( - admin.page.getByRole("heading", { level: 1, name: SIGN_HEADING_REGEX }), + admin.page.getByRole("heading", { level: 1, name: SIGNUP_OR_LOGIN_HEADING_REGEX }), ).toBeVisible({ timeout: 15000, }); diff --git a/packages/auth/src/signup.ts b/packages/auth/src/signup.ts index fea7dd21e9..cd58e69c13 100644 --- a/packages/auth/src/signup.ts +++ b/packages/auth/src/signup.ts @@ -90,8 +90,10 @@ export async function requestSignup( expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MS), }); - // Build verification URL - const url = new URL("/_emdash/api/auth/signup/verify", config.baseUrl); + // Build the verification URL pointing at the admin UI page, not the API + // endpoint: the page reads `?token=` and calls the API itself. Same shape + // as the invite link (see invite.ts). + const url = new URL(`${config.baseUrl}/admin/signup`); url.searchParams.set("token", token); // Send email diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index 7aaf0fa24d..11882b63be 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -331,8 +331,11 @@ async function handleEmDashAuth( const { url, locals } = context; const { emdash } = locals; + // Pages an anonymous visitor must be able to reach: login itself, and the + // two token-bearing pages that emails link to. const isPublicAdminRoute = url.pathname.startsWith("/_emdash/admin/login") || + url.pathname.startsWith("/_emdash/admin/signup") || url.pathname.startsWith("/_emdash/admin/invite/accept"); const isApiRoute = url.pathname.startsWith("/_emdash/api"); @@ -681,7 +684,9 @@ async function handlePasskeyAuth( return apiError("NOT_AUTHENTICATED", "Not authenticated", 401); } const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config)); - loginUrl.searchParams.set("redirect", url.pathname); + // Keep the query string: a token-bearing link that lands here must + // still carry its token after login. + loginUrl.searchParams.set("redirect", url.pathname + url.search); return context.redirect(loginUrl.toString()); } diff --git a/packages/core/tests/unit/auth/signup.test.ts b/packages/core/tests/unit/auth/signup.test.ts index c4d9e2eb80..43790eb11d 100644 --- a/packages/core/tests/unit/auth/signup.test.ts +++ b/packages/core/tests/unit/auth/signup.test.ts @@ -111,9 +111,7 @@ describe("Self-Signup", () => { expect(mockEmailSend).toHaveBeenCalledTimes(1); expect(sentEmails[0]!.to).toBe("newuser@allowed.com"); expect(sentEmails[0]!.subject).toContain("Test Site"); - expect(sentEmails[0]!.text).toContain( - "https://example.com/_emdash/api/auth/signup/verify?token=", - ); + expect(sentEmails[0]!.text).toContain("https://example.com/admin/signup?token="); expect(sentEmails[0]!.text).toContain("verify"); }); diff --git a/packages/core/tests/unit/middleware/admin-public-routes.test.ts b/packages/core/tests/unit/middleware/admin-public-routes.test.ts new file mode 100644 index 0000000000..f4ed7e19c1 --- /dev/null +++ b/packages/core/tests/unit/middleware/admin-public-routes.test.ts @@ -0,0 +1,79 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +vi.mock("virtual:emdash/auth", () => ({ authenticate: vi.fn() })); +vi.mock("virtual:emdash/config", () => ({ default: {} })); +vi.mock("astro:middleware", () => ({ + defineMiddleware: (handler: unknown) => handler, +})); +vi.mock("@emdash-cms/auth", () => ({ + TOKEN_PREFIXES: {}, + generatePrefixedToken: vi.fn(), + hashPrefixedToken: vi.fn(), + VALID_SCOPES: [], + validateScopes: vi.fn(), + hasScope: vi.fn(() => false), + computeS256Challenge: vi.fn(), + Role: { ADMIN: 50 }, +})); +vi.mock("@emdash-cms/auth/adapters/kysely", () => ({ + createKyselyAdapter: vi.fn(() => ({ + getUserById: vi.fn(), + getUserByEmail: vi.fn(), + })), +})); + +type AuthMiddlewareModule = typeof import("../../../src/astro/middleware/auth.js"); + +let onRequest: AuthMiddlewareModule["onRequest"]; + +beforeAll(async () => { + ({ onRequest } = await import("../../../src/astro/middleware/auth.js")); +}); + +/** An anonymous GET to an admin page. */ +async function visit( + pathname: string, +): Promise<{ response: Response; next: ReturnType }> { + const url = new URL(pathname, "https://site.example.com"); + const session = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn(), + destroy: vi.fn(), + }; + const next = vi.fn(async () => new Response("ok")); + const response = await onRequest( + { + url, + request: new Request(url, { method: "GET" }), + locals: { emdash: { db: {}, config: {} } }, + session, + redirect: (location: string) => + new Response(null, { status: 302, headers: { Location: location } }), + } as Parameters[0], + next, + ); + return { response, next }; +} + +describe("Anonymous access to admin pages", () => { + it.each([ + "/_emdash/admin/login", + "/_emdash/admin/signup?token=abc", + "/_emdash/admin/invite/accept?token=abc", + ])("serves %s without a session — emails link there", async (pathname) => { + const { response, next } = await visit(pathname); + expect(next).toHaveBeenCalledOnce(); + expect(response.status).toBe(200); + }); + + it("redirects other admin pages to login, keeping the full URL to return to", async () => { + const { response, next } = await visit("/_emdash/admin/content/posts?token=abc&x=1"); + expect(next).not.toHaveBeenCalled(); + expect(response.status).toBe(302); + const location = new URL(response.headers.get("Location")!); + expect(location.pathname).toBe("/_emdash/admin/login"); + expect(location.searchParams.get("redirect")).toBe( + "/_emdash/admin/content/posts?token=abc&x=1", + ); + }); +});