diff --git a/CHANGELOG.md b/CHANGELOG.md index f85e8b4..b5969da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what ### Added +- **`auth.login_path`: a site can serve its own login page (#159).** Set `auth.login_path: /join` in `site.yaml` and + every redirect that sends a visitor to sign in (the page gate, a logged-out visitor opening a gated file, a failed + password login, the "Try again" link after a bad magic link) goes there, with `?next=` (and `?error=`) as before. + `GET /auth/login` redirects to it, keeping the query, so there is one login page. `POST /auth/login`, the OAuth + callbacks, magic-link verification and logout stay mounted under `/auth/*`; the custom page posts to them. The + value must be a local absolute path (one leading `/`, no URL, no query); anything else fails config validation. + A trailing slash is ignored (`/join/` is `/join`). The page served at `login_path`, and its translations (the other + language files in the same folder), are never gated by `auth.default_roles` or an ancestor's `roles:` (their own + `roles:` still applies), so a gated site cannot loop on its own login page. A login page that is a flat file + (`01.join.md`) is exempt for its own route only. Default `/auth/login`, unchanged. - **Page gating is inherited, and a site can gate everything by default (#157).** A page's `roles:` now applies to everything beneath it, so gating a section is one line on its top page, not one per page and language. `site.auth.default_roles` (same values as `roles:`, e.g. `any`) gates every page that neither diff --git a/src/auth/gating.ts b/src/auth/gating.ts index 057a87a..d83a6ee 100644 --- a/src/auth/gating.ts +++ b/src/auth/gating.ts @@ -84,6 +84,64 @@ function resolveAuthzForOrigin( return null; } +/** Where visitors who need to sign in are sent unless `site.auth.login_path` says otherwise. */ +export const DEFAULT_LOGIN_PATH = "/auth/login"; + +/** + * Whether `value` can be used as `site.auth.login_path`: a local absolute path + * (one leading `/`, so never `//host` or a URL), made of plain path characters, + * with no query, fragment, or `..` segment. + */ +export function isValidLoginPath(value: unknown): value is string { + return typeof value === "string" && + /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/.test(value) && + !value.split("/").includes(".."); +} + +/** + * The effective login path for a configured value: the value (without a + * trailing slash, so it matches page routes) when valid, else + * the default. (Invalid values are reported by config validation; this keeps a + * bad value from ever reaching a redirect.) + */ +export function resolveLoginPath(configured: unknown): string { + return isValidLoginPath(configured) + ? normalizeRoutePath(configured) + : DEFAULT_LOGIN_PATH; +} + +/** A path without its trailing slash (`/join/` → `/join`); `/` stays `/`. Page routes have none. */ +export function normalizeRoutePath(path: string): string { + return path.length > 1 ? path.replace(/\/+$/, "") || "/" : path; +} + +const _loginPathByOrigin = new Map(); + +/** + * Register a site's login path for gating redirects, keyed by origin like + * {@link setGatingAuthz}. Pass `null` to remove it. + */ +export function setGatingLoginPath(path: string | null, origin?: string): void { + const key = origin ?? "_default"; + if (path === null) _loginPathByOrigin.delete(key); + else _loginPathByOrigin.set(key, resolveLoginPath(path)); +} + +/** The login path for a request's site (same origin selection as authz). */ +export function loginPathForRequest(req: Request): string { + let origin: string | null = null; + try { + origin = new URL(req.url).origin; + } catch { /* fall through to the single-site fallback */ } + if (origin !== null && _loginPathByOrigin.has(origin)) { + return _loginPathByOrigin.get(origin)!; + } + if (_loginPathByOrigin.size === 1) { + return _loginPathByOrigin.values().next().value as string; + } + return DEFAULT_LOGIN_PATH; +} + // ── Types ───────────────────────────────────────────────────────────────────── /** @@ -302,7 +360,8 @@ export async function checkRolesAsync( * * Returns `null` when access is granted. * Returns a `Response` when access is denied: - * - Unauthenticated user with a spec present → 302 to `/auth/login?next=` + * - Unauthenticated user with a spec present → 302 to the login path + * (`/auth/login` unless `site.auth.login_path` is set) with `?next=` * - Authenticated user with insufficient roles → 403 plain text * * Uses `authz.check()` when a polizy AuthSystem is configured (wired via @@ -327,7 +386,7 @@ export async function enforceRoles( const next = encodeURIComponent(url.pathname + url.search); return new Response(null, { status: 302, - headers: { Location: `/auth/login?next=${next}` }, + headers: { Location: `${loginPathForRequest(req)}?next=${next}` }, }); } diff --git a/src/auth/mount.ts b/src/auth/mount.ts index be0f0c3..6d834a5 100644 --- a/src/auth/mount.ts +++ b/src/auth/mount.ts @@ -32,7 +32,7 @@ import { resolveRoleGrants } from "./role-grants.ts"; import { withRoleSync } from "./sync-roles.ts"; import type { DuneAuthSystem } from "./authz.ts"; import type { AuthzLocalAdapter } from "./authz-adapter-local.ts"; -import { setGatingAuthz } from "./gating.ts"; +import { DEFAULT_LOGIN_PATH, setGatingAuthz, setGatingLoginPath } from "./gating.ts"; import { loadHmacKeyFromEnv } from "./authz-hmac.ts"; import { handleWebhook } from "./webhook.ts"; import { logger } from "../core/logger.ts"; @@ -104,6 +104,10 @@ export async function mountDuneAuth( try { siteOrigin = new URL(config.site.url).origin; } catch { /* ignore */ } + // Gating redirects (pages, media) go to the site's own login page when set. + // Always register, the default included: with no entry, a site in a multisite + // process would fall back to another site's path. + setGatingLoginPath(authConfig?.login_path ?? DEFAULT_LOGIN_PATH, siteOrigin); // ── User store ────────────────────────────────────────────────────────────── // data/users/ — shared with @dune/plugin-admin's admin accounts since @@ -351,6 +355,7 @@ export async function mountDuneAuth( trustForwardedFor, magicTokenStore, passwordLoginEnabled: passwordEnabled, + loginPath: authConfig?.login_path, rateLimitStore: passwordEnabled ? new LocalRateLimitStore() : undefined, loginPage: (req, view, renderJsx, status) => renderLoginPage(ctx.engine, req, view, renderJsx, status, ctx.contentApi), diff --git a/src/auth/password-login.ts b/src/auth/password-login.ts index 7668aac..2f8d263 100644 --- a/src/auth/password-login.ts +++ b/src/auth/password-login.ts @@ -54,6 +54,8 @@ export interface PasswordLoginDeps { middleware: SiteAuthMiddleware; rateLimitStore: RateLimitStore; trustForwardedFor: boolean; + /** Where a failed attempt is sent back to (`site.auth.login_path`); default `/auth/login`. */ + loginPath?: string; } /** What the handler needs to show the login page again (a 429 renders it in place). */ @@ -94,6 +96,7 @@ function json(body: unknown, status: number, headers: Record = { /** Build the `POST /auth/login` handler. */ export function createPasswordLogin(deps: PasswordLoginDeps) { const { userStore, middleware, rateLimitStore, trustForwardedFor } = deps; + const loginPath = deps.loginPath ?? "/auth/login"; const fail = (req: Request, error: LoginError, next: string, render?: RenderLogin): Promise | Response => { const status = error === "rate_limited" ? 429 : 401; @@ -110,7 +113,7 @@ export function createPasswordLogin(deps: PasswordLoginDeps) { ? render({ error, next }, 429) : new Response("Too many attempts", { status: 429, headers: { "Retry-After": String(PASSWORD_LOGIN_WINDOW_MS / 1000) } }); } - const location = `/auth/login?error=invalid${next && next !== "/" ? `&next=${encodeURIComponent(next)}` : ""}`; + const location = `${loginPath}?error=invalid${next && next !== "/" ? `&next=${encodeURIComponent(next)}` : ""}`; return new Response(null, { status: 303, headers: { Location: location } }); }; diff --git a/src/auth/routes.ts b/src/auth/routes.ts index c111e6a..25e26e6 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -29,6 +29,7 @@ import { RateLimiter, clientIp, rateLimitResponse } from "../security/rate-limit import { checkSameOriginCsrf } from "../security/csrf.ts"; import { logger } from "../core/logger.ts"; import { sanitizeNext } from "./redirect.ts"; +import { DEFAULT_LOGIN_PATH, resolveLoginPath } from "./gating.ts"; import { createPasswordLogin, type LoginError } from "./password-login.ts"; import type { RateLimitStore } from "../security/rate-limit-store.ts"; @@ -44,6 +45,12 @@ export interface AuthRoutesConfig { userStoreType?: "local" | "session" | "db"; sendEmail?: (to: string, subject: string, text: string, html: string) => Promise; trustForwardedFor?: boolean; + /** + * `site.auth.login_path`: the site's own login page. When it differs from + * `/auth/login`, `GET /auth/login` redirects there (query kept) and failures + * that send the visitor back to the login page go there. Default `/auth/login`. + */ + loginPath?: string; /** * Optional single-use token store for magic links. * When provided, each magic link token can only be used once. @@ -126,11 +133,12 @@ export function createAuthRoutes(config: AuthRoutesConfig): AuthRouteHandlers { rateLimitStore, loginPage, } = config; + const loginPath = resolveLoginPath(config.loginPath); const passwordLoginActive = passwordLoginEnabled && mode === "dune" && userStoreType !== "session" && rateLimitStore !== undefined; const passwordLoginHandler = passwordLoginActive - ? createPasswordLogin({ userStore, middleware, rateLimitStore: rateLimitStore!, trustForwardedFor }) + ? createPasswordLogin({ userStore, middleware, rateLimitStore: rateLimitStore!, trustForwardedFor, loginPath }) : null; // ── Login page ───────────────────────────────────────────────────────────── @@ -142,6 +150,15 @@ export function createAuthRoutes(config: AuthRoutesConfig): AuthRouteHandlers { const next = sanitizeNext(url.searchParams.get("next")); return new Response(null, { status: 302, headers: { Location: next } }); } + // The site serves its own login page: one login page, so send visitors + // there, keeping `next` and `error`. (loginPath is never `/auth/login` + // here, so this cannot loop.) + if (loginPath !== DEFAULT_LOGIN_PATH) { + return new Response(null, { + status: 302, + headers: { Location: `${loginPath}${new URL(req.url).search}` }, + }); + } if (mode === "external-jwt") { return new Response( @@ -627,12 +644,12 @@ export function createAuthRoutes(config: AuthRoutesConfig): AuthRouteHandlers { const url = new URL(req.url); const token = url.searchParams.get("token"); if (!token) { - return loginErrorResponse("Missing token"); + return loginErrorResponse("Missing token", loginPath); } const result = await verifyMagicToken(token, magicLinkSecret, magicTokenStore); if (!result) { - return loginErrorResponse("Invalid or expired link — please request a new one"); + return loginErrorResponse("Invalid or expired link — please request a new one", loginPath); } const { email } = result; @@ -825,14 +842,14 @@ function magicEmailHtml(link: string): string { `.trim(); } -function loginErrorResponse(message: string): Response { +function loginErrorResponse(message: string, loginPath: string): Response { const html = ` Login error

Login error

${escHtml(message)}

-

Try again

+

Try again

`; return new Response(html, { diff --git a/src/config/site-config.ts b/src/config/site-config.ts index 59ef5fa..502fbc8 100644 --- a/src/config/site-config.ts +++ b/src/config/site-config.ts @@ -257,6 +257,15 @@ export interface SiteConfig { * ``` */ default_roles?: unknown; + /** + * Where visitors who must sign in are sent (page gate, gated media, + * `GET /auth/login`), for a site that serves its own login or join page. + * A local absolute path such as `/join`; default `/auth/login`. The site + * provides the page; it posts to `/auth/login` (password), `/auth/magic/*` + * and the OAuth routes, which stay mounted. Failures redirect back here + * with `?error=`. The page at this path is never gated by `default_roles`. + */ + login_path?: string; /** OAuth provider credentials. */ providers?: { github?: { client_id: string; client_secret: string }; diff --git a/src/config/validator.ts b/src/config/validator.ts index d649140..9d6e996 100644 --- a/src/config/validator.ts +++ b/src/config/validator.ts @@ -9,6 +9,7 @@ import type { DuneConfig, PluginEntry, SiteConfig, SystemConfig, ThemeConfig, ThemePackageEntry } from "./types.ts"; import { roleGrantProblems } from "../auth/role-grants.ts"; +import { isValidLoginPath } from "../auth/gating.ts"; import { assertPinnedThemeSpecifier, assertThemeName, @@ -97,6 +98,15 @@ function validateSite(site: SiteConfig, errors: ValidationError[]): void { } } + const loginPath = (site.auth as { login_path?: unknown } | undefined)?.login_path; + if (loginPath !== undefined && !isValidLoginPath(loginPath)) { + errors.push({ + path: "site.auth.login_path", + message: "must be a local absolute path such as /join (one leading /, no URL, no query)", + got: loginPath, + }); + } + if (site.author && typeof site.author !== "object") { errors.push({ path: "site.author", diff --git a/src/content/index-builder.ts b/src/content/index-builder.ts index 027a0a8..6fbf28a 100644 --- a/src/content/index-builder.ts +++ b/src/content/index-builder.ts @@ -20,7 +20,7 @@ import type { ContentFormat, PageFrontmatter, PageIndex } from "./types.ts"; import type { FormatRegistry } from "./formats/registry.ts"; import type { BlueprintMap } from "../blueprints/types.ts"; import { validateFrontmatter } from "../blueprints/validator.ts"; -import { parseRolesDirective, parseRolesSpec } from "../auth/gating.ts"; +import { normalizeRoutePath, parseRolesDirective, parseRolesSpec } from "../auth/gating.ts"; import type { RolesDirective } from "../auth/gating.ts"; import { calculateDepth, @@ -89,6 +89,12 @@ export interface IndexBuilderOptions { * out with `roles: public`. */ defaultRoles?: unknown; + /** + * `site.auth.login_path`. The page served at this route is never gated by + * inheritance or the site default (a visitor must be able to reach the page + * they are sent to in order to sign in); its own `roles:` still applies. + */ + loginPath?: string; } /** Return type of {@link buildIndex} — the page index, taxonomy map, and build statistics. */ @@ -226,7 +232,7 @@ export async function buildIndex( // Deduplicate routes: directory-based pages win over flat files. const finalPages = deduplicateRoutes(pages); - applyGatingInheritance(finalPages, options.defaultRoles); + applyGatingInheritance(finalPages, options.defaultRoles, options.loginPath); const duration = performance.now() - start; const homeSlug = siteHome ?? detectHomeSlug(finalPages); @@ -374,7 +380,7 @@ export async function updateIndex( // Deduplicate routes: directory-based pages win over flat files. const finalPages = deduplicateRoutes(pages); - applyGatingInheritance(finalPages, options.defaultRoles); + applyGatingInheritance(finalPages, options.defaultRoles, options.loginPath); const duration = performance.now() - start; const homeSlug = siteHome ?? detectHomeSlug(finalPages); @@ -416,6 +422,9 @@ function isFlatFilePath(sp: string): boolean { * 2. the nearest ancestor directory's `roles:` * 3. the site default (`site.auth.default_roles`) * + * The page at `site.auth.login_path` (if any), and its translations, count as + * `roles: public` unless they declare their own rule. + * * `roles: public` is an explicit opt-out at any level; it ends the search * with "open". A value that is absent or malformed means "keep looking". * @@ -430,9 +439,27 @@ function isFlatFilePath(sp: string): boolean { export function applyGatingInheritance( pages: PageIndex[], defaultRoles: unknown, + loginPath?: string, ): void { const fallback = parseRolesDirective(defaultRoles); + // The login page and its translations: every page in a folder whose page + // sits at the login route (translations share the folder, not the route). + const loginRoute = loginPath === undefined ? null : normalizeRoutePath(loginPath); + const loginDirs = new Set(); + if (loginRoute !== null) { + for (const page of pages) { + if (!isFlatFilePath(page.sourcePath) && normalizeRoutePath(page.route) === loginRoute) { + loginDirs.add(dirname(page.sourcePath)); + } + } + } + const isLoginPage = (page: PageIndex): boolean => + loginRoute !== null && ( + normalizeRoutePath(page.route) === loginRoute || + (!isFlatFilePath(page.sourcePath) && loginDirs.has(dirname(page.sourcePath))) + ); + // Directory → the pages that represent it (folder pages, not flat siblings). const byDir = new Map(); for (const page of pages) { @@ -464,6 +491,11 @@ export function applyGatingInheritance( for (const page of pages) { let directive = parseRolesDirective(page.ownRoles); + // The login page itself is reachable by anyone unless it says otherwise. + if (directive.kind === "inherit" && isLoginPage(page)) { + directive = { kind: "public" }; + } + // Walk up from the page's parent folder. Stop at the first decision. let dir = page.parentPath; while (directive.kind === "inherit" && dir) { diff --git a/src/core/engine.ts b/src/core/engine.ts index 116b5f8..b9a91e1 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -465,6 +465,7 @@ export async function createDuneEngine( facetFields: config.system.search?.facets?.map((f) => f.field), templateNames: await discoverThemeTemplateNames(), defaultRoles: config.site.auth?.default_roles, + loginPath: config.site.auth?.login_path, }); pages = result.pages; taxonomyMap = result.taxonomyMap; @@ -681,6 +682,7 @@ export async function createDuneEngine( facetFields: config.system.search?.facets?.map((f) => f.field), templateNames: await discoverThemeTemplateNames(), defaultRoles: config.site.auth?.default_roles, + loginPath: config.site.auth?.login_path, }); pages = result.pages; taxonomyMap = result.taxonomyMap; diff --git a/tests/auth/login_path_test.ts b/tests/auth/login_path_test.ts new file mode 100644 index 0000000..c45e906 --- /dev/null +++ b/tests/auth/login_path_test.ts @@ -0,0 +1,238 @@ +/** + * `site.auth.login_path` (#159): a site serving its own login page. + */ +import { assertEquals, assertStrictEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { + DEFAULT_LOGIN_PATH, + enforceRoles, + isValidLoginPath, + loginPathForRequest, + normalizeRoutePath, + mediaDenialResponse, + resolveLoginPath, + setGatingLoginPath, +} from "../../src/auth/gating.ts"; +import { applyGatingInheritance } from "../../src/content/index-builder.ts"; +import { validateConfig } from "../../src/config/validator.ts"; +import { DEFAULT_CONFIG } from "../../src/config/defaults.ts"; +import { createAuthRoutes } from "../../src/auth/routes.ts"; +import { createLocalUserStore } from "../../src/auth/user-store.ts"; +import { createSiteAuthMiddleware } from "../../src/auth/middleware.ts"; +import { createSessionManager, createSessionStore } from "../../src/session/mod.ts"; +import { LocalRateLimitStore } from "../../src/security/rate-limit-store.ts"; +import type { PageIndex } from "../../src/content/types.ts"; + +Deno.test("isValidLoginPath: local absolute paths only", () => { + for (const ok of ["/join", "/auth/login", "/members/sign-in", "/a_b.c~d", "/"]) { + assertStrictEquals(isValidLoginPath(ok), true, ok); + } + for ( + const bad of [ + "", "join", "//evil.com", "//evil.com/x", "https://evil.com", "/join?x=1", "/join#x", "/a/../b", + "/..", "/a b", "/a\\b", "/a\nb", 5, null, undefined, + ] + ) { + assertStrictEquals(isValidLoginPath(bad), false, String(bad)); + } + assertEquals(resolveLoginPath("//evil.com"), DEFAULT_LOGIN_PATH); + assertEquals(resolveLoginPath(undefined), DEFAULT_LOGIN_PATH); + assertEquals(resolveLoginPath("/join"), "/join"); +}); + +Deno.test("validateConfig: rejects a bad site.auth.login_path", () => { + const config = structuredClone(DEFAULT_CONFIG); + config.site.auth = { login_path: "//evil.com" }; + assertEquals(validateConfig(config).some((e) => e.includes("site.auth.login_path")), true); + config.site.auth = { login_path: "/join" }; + assertEquals(validateConfig(config).length, 0); +}); + +Deno.test("gating redirects go to the configured login path, with next", async () => { + const origin = "https://members.example"; + setGatingLoginPath("/join", origin); + try { + const req = new Request(`${origin}/secret/page?x=1`); + assertEquals(loginPathForRequest(req), "/join"); + const denied = (await enforceRoles(req, null, []))!; + assertEquals(denied.status, 302); + assertEquals(denied.headers.get("location"), "/join?next=%2Fsecret%2Fpage%3Fx%3D1"); + + // A logged-out document navigation to gated media is sent to the same page. + const media = new Request(`${origin}/content-media/05.a/doc.pdf`, { + headers: { "sec-fetch-dest": "document" }, + }); + const mediaDenied = (await enforceRoles(media, null, []))!; + assertEquals( + mediaDenialResponse(media, mediaDenied).headers.get("location"), + "/join?next=%2Fcontent-media%2F05.a%2Fdoc.pdf", + ); + } finally { + setGatingLoginPath(null, origin); + } + // Unset: back to the default. + const plain = new Request("https://other.example/x"); + assertEquals(loginPathForRequest(plain), DEFAULT_LOGIN_PATH); + assertEquals( + (await enforceRoles(plain, null, []))!.headers.get("location"), + "/auth/login?next=%2Fx", + ); +}); + +function page(route: string, sourcePath: string, ownRoles?: unknown): PageIndex { + return { + route, + sourcePath, + language: "en", + parentPath: null, + ownRoles, + roles: ownRoles, + gated: ownRoles !== undefined, + } as unknown as PageIndex; +} + +Deno.test("applyGatingInheritance: the login_path page is exempt from the site default", () => { + const login = page("/join", "01.join/default.md"); + const other = page("/about", "02.about/default.md"); + applyGatingInheritance([login, other], "any", "/join"); + assertEquals(login.gated, false); + assertEquals(other.gated, true); + + // Without login_path, the same page is gated by the default (the loop this prevents) + const unexempt = page("/join", "01.join/default.md"); + applyGatingInheritance([unexempt], "any"); + assertEquals(unexempt.gated, true); + + // Its own roles: still applies. + const own = page("/join", "01.join/default.md", "member"); + applyGatingInheritance([own], "any", "/join"); + assertEquals(own.gated, true); +}); + +async function harness(loginPath?: string) { + const files = new Map(); + // deno-lint-ignore no-explicit-any + const storage: any = { + read: async (p: string) => { + const d = files.get(p); + if (!d) throw new Error("nf"); + return d; + }, + readText: async (p: string) => new TextDecoder().decode(files.get(p)!), + write: async (p: string, d: Uint8Array | string) => { + files.set(p, typeof d === "string" ? new TextEncoder().encode(d) : d); + }, + exists: async (p: string) => files.has(p), + delete: async (p: string) => { + files.delete(p); + }, + list: async () => [], + rename: async () => {}, + listRecursive: async () => [], + stat: async () => ({ size: 0, mtime: 0, isFile: true, isDirectory: false }), + getJSON: async () => null, + setJSON: async () => {}, + deleteJSON: async () => {}, + }; + const userStore = createLocalUserStore({ storage, usersDir: "data/users" }); + const sessionStore = await createSessionStore({ + type: "local", + storage, + sessionsDir: "data/sessions", + lifetimeMs: 3600_000, + }); + const sessions = createSessionManager(sessionStore, 3600_000); + const middleware = createSiteAuthMiddleware({ userStore, sessions, secure: false }); + return createAuthRoutes({ + userStore, + middleware, + providers: new Map(), + magicLinkEnabled: true, + magicLinkSecret: "test-secret", + siteUrl: "https://example.com", + mode: "dune", + passwordLoginEnabled: true, + rateLimitStore: new LocalRateLimitStore(), + loginPath, + }); +} + +Deno.test("GET /auth/login redirects to login_path, keeping the query", async () => { + const routes = await harness("/join"); + const res = await routes.login(new Request("https://example.com/auth/login?next=%2Fa&error=invalid"), null); + assertEquals(res.status, 302); + assertEquals(res.headers.get("location"), "/join?next=%2Fa&error=invalid"); +}); + +Deno.test("GET /auth/login still renders when login_path is unset or is /auth/login (no loop)", async () => { + for (const lp of [undefined, "/auth/login"]) { + const routes = await harness(lp); + const res = await routes.login(new Request("https://example.com/auth/login"), null); + assertEquals(res.status, 200, String(lp)); + } +}); + +Deno.test("a failed password login goes back to login_path", async () => { + const routes = await harness("/join"); + const form = new URLSearchParams({ identifier: "nobody", password: "wrong", next: "/members" }); + const res = await routes.passwordLogin( + new Request("https://example.com/auth/login", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded", origin: "https://example.com" }, + body: form, + }), + ); + assertEquals(res.status, 303); + assertEquals(res.headers.get("location"), "/join?error=invalid&next=%2Fmembers"); +}); + +Deno.test("a bad magic link's Try again points at login_path", async () => { + const routes = await harness("/join"); + const res = await routes.magicVerify(new Request("https://example.com/auth/magic/verify")); + const html = await res.text(); + assertEquals(html.includes('href="/join"'), true); +}); + +Deno.test("multisite: a site without login_path never gets another site's path", async () => { + const a = "https://a.example"; + const b = "https://b.example"; + // mountDuneAuth registers every site, the default included. + setGatingLoginPath("/join", a); + setGatingLoginPath(DEFAULT_LOGIN_PATH, b); + try { + assertEquals(loginPathForRequest(new Request(`${a}/x`)), "/join"); + assertEquals(loginPathForRequest(new Request(`${b}/x`)), "/auth/login"); + assertEquals( + (await enforceRoles(new Request(`${b}/x`), null, []))!.headers.get("location"), + "/auth/login?next=%2Fx", + ); + } finally { + setGatingLoginPath(null, a); + setGatingLoginPath(null, b); + } +}); + +Deno.test("a trailing slash on login_path is normalized for redirects and the exemption", () => { + assertEquals(resolveLoginPath("/join/"), "/join"); + assertEquals(resolveLoginPath("/"), "/"); + assertEquals(normalizeRoutePath("/a/b//"), "/a/b"); + + const login = page("/join", "01.join/default.md"); + const other = page("/about", "02.about/default.md"); + applyGatingInheritance([login, other], "any", "/join/"); + assertEquals(login.gated, false); + assertEquals(other.gated, true); +}); + +Deno.test("the login page's translations are exempt too", () => { + const en = page("/join", "01.join/default.md"); + const de = page("/de/join", "01.join/default.de.md"); + de.language = "de"; + const sibling = page("/join/faq", "01.join/02.faq/default.md"); + const other = page("/about", "02.about/default.md"); + applyGatingInheritance([en, de, sibling, other], "any", "/join"); + assertEquals(en.gated, false); + assertEquals(de.gated, false); + // A child page of the login page is an ordinary page: default_roles applies. + assertEquals(sibling.gated, true); + assertEquals(other.gated, true); +});