Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 61 additions & 2 deletions src/auth/gating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();

/**
* 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 ─────────────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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=<url>`
* - Unauthenticated user with a spec present → 302 to the login path
* (`/auth/login` unless `site.auth.login_path` is set) with `?next=<url>`
* - Authenticated user with insufficient roles → 403 plain text
*
* Uses `authz.check()` when a polizy AuthSystem is configured (wired via
Expand All @@ -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}` },
});
}

Expand Down
7 changes: 6 additions & 1 deletion src/auth/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion src/auth/password-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -94,6 +96,7 @@ function json(body: unknown, status: number, headers: Record<string, string> = {
/** 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> | Response => {
const status = error === "rate_limited" ? 429 : 401;
Expand All @@ -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 } });
};

Expand Down
27 changes: 22 additions & 5 deletions src/auth/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -44,6 +45,12 @@ export interface AuthRoutesConfig {
userStoreType?: "local" | "session" | "db";
sendEmail?: (to: string, subject: string, text: string, html: string) => Promise<void>;
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.
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────────
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -825,14 +842,14 @@ function magicEmailHtml(link: string): string {
</div>`.trim();
}

function loginErrorResponse(message: string): Response {
function loginErrorResponse(message: string, loginPath: string): Response {
const html = `<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Login error</title></head>
<body style="font-family:system-ui,sans-serif;max-width:400px;margin:80px auto;padding:0 16px">
<h1 style="font-size:1.5rem">Login error</h1>
<p>${escHtml(message)}</p>
<p><a href="/auth/login">Try again</a></p>
<p><a href="${escHtml(loginPath)}">Try again</a></p>
</body>
</html>`;
return new Response(html, {
Expand Down
9 changes: 9 additions & 0 deletions src/config/site-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
10 changes: 10 additions & 0 deletions src/config/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
38 changes: 35 additions & 3 deletions src/content/index-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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".
*
Expand All @@ -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<string>();
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<string, PageIndex[]>();
for (const page of pages) {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/core/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading