421 lines
12 KiB
TypeScript
421 lines
12 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
|
|
import type { AuthEngine } from "./engine.ts";
|
|
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
|
|
import type { AuthSessionVerificationHandler } from "./types.ts";
|
|
import { AUTH_ROUTE_DEFINITIONS, type AuthRouteGroup } from "./routes/definitions.ts";
|
|
import {
|
|
clearDefaultAuthEngine,
|
|
setDefaultAuthEngine,
|
|
setDefaultAuthRouteOptions,
|
|
setDefaultAuthSchemas,
|
|
} from "./runtime.ts";
|
|
import {
|
|
authBrowserSchemaDescriptors,
|
|
resolveAuthSchemas,
|
|
type AuthSchemaOverrides,
|
|
type AuthSchemaSet,
|
|
} from "./validation.ts";
|
|
|
|
export interface AuthRoutesConfig {
|
|
enabled?: boolean;
|
|
registration?: boolean;
|
|
login?: boolean;
|
|
verification?: boolean;
|
|
password?: boolean;
|
|
invitations?: boolean;
|
|
magicLink?: boolean;
|
|
otp?: boolean;
|
|
mfa?: boolean;
|
|
sessions?: boolean;
|
|
impersonation?: boolean;
|
|
passkeys?: boolean;
|
|
}
|
|
|
|
export interface AuthConfig {
|
|
enabled?: boolean;
|
|
engine?: AuthEngine;
|
|
routes?: boolean | AuthRoutesConfig;
|
|
migrations?: boolean;
|
|
middleware?: boolean;
|
|
components?: boolean;
|
|
client?: boolean;
|
|
devToolbar?: boolean;
|
|
componentDir?: string;
|
|
schemas?: AuthSchemaOverrides;
|
|
baseUrl?: string;
|
|
csrf?: boolean;
|
|
passkey?: AuthPasskeyHttpOptions;
|
|
/** Shared SSO cookie whose value is an AuthEngine session id. */
|
|
sessionCookieName?: string;
|
|
/** Customize the package forward-auth verification response. */
|
|
onSessionVerification?: AuthSessionVerificationHandler;
|
|
}
|
|
|
|
/** Explicit plugin options remain supported for compatibility. Prefer config.auth. */
|
|
export interface AuthPluginOptions {
|
|
componentDir?: string;
|
|
exposeComponentDirectory?: boolean;
|
|
enableDevToolbar?: boolean;
|
|
includeMigrations?: boolean;
|
|
includeRoutes?: boolean;
|
|
includeMiddleware?: boolean;
|
|
}
|
|
|
|
export interface AuthAuditIssue {
|
|
id: string;
|
|
severity: "error" | "warning" | "suggestion";
|
|
title: string;
|
|
message: string;
|
|
file: string;
|
|
}
|
|
|
|
interface ResolvedAuthConfig {
|
|
enabled: boolean;
|
|
engine?: AuthEngine;
|
|
routes: boolean | AuthRoutesConfig;
|
|
migrations: boolean;
|
|
middleware: boolean;
|
|
components: boolean;
|
|
client: boolean;
|
|
devToolbar: boolean;
|
|
componentDir: string;
|
|
schemas: AuthSchemaSet;
|
|
baseUrl?: string;
|
|
csrf: boolean;
|
|
passkey?: AuthPasskeyHttpOptions;
|
|
sessionCookieName?: string;
|
|
onSessionVerification?: AuthSessionVerificationHandler;
|
|
}
|
|
|
|
const moduleRoot = dirname(fileURLToPath(import.meta.url));
|
|
const packageRoot = dirname(moduleRoot);
|
|
const compiledPackage = moduleRoot === join(packageRoot, "dist");
|
|
const clientRuntime = join(packageRoot, "assets", "client", "auth.js");
|
|
const migrationsFile = join(packageRoot, "migrations", "001_auth.sql");
|
|
const otpPurposeMigrationFile = join(packageRoot, "migrations", "002_auth_otp_purpose.sql");
|
|
const apiRoutesDir = compiledPackage
|
|
? join(moduleRoot, "routes", "api")
|
|
: join(packageRoot, "src", "routes", "api");
|
|
const middlewareFile = compiledPackage
|
|
? join(moduleRoot, "routes", "middleware.js")
|
|
: join(packageRoot, "src", "routes", "middleware.ts");
|
|
const resolvedConfigKey = "@wrnexus/auth:resolved-config";
|
|
|
|
export function authComponentsDir(): string {
|
|
return join(packageRoot, "components");
|
|
}
|
|
|
|
function authApiRouteEntry(path: string): string {
|
|
const routeName = path.replace(/^\/api\/auth\/?/, "").replace(/\//g, "-") || "index";
|
|
return join(apiRoutesDir, routeName + (compiledPackage ? ".js" : ".ts"));
|
|
}
|
|
|
|
function resolveConfig(
|
|
config: Record<string, unknown>,
|
|
options: AuthPluginOptions,
|
|
): ResolvedAuthConfig {
|
|
const raw = (config.auth ?? {}) as AuthConfig;
|
|
const enabled = raw.enabled !== false;
|
|
const hasEngine = Boolean(raw.engine);
|
|
const hasDefaultDb = Boolean(config.db);
|
|
|
|
return {
|
|
enabled,
|
|
engine: raw.engine,
|
|
routes: options.includeRoutes !== undefined ? options.includeRoutes : (raw.routes ?? hasEngine),
|
|
migrations:
|
|
options.includeMigrations !== undefined
|
|
? options.includeMigrations
|
|
: (raw.migrations ?? (hasEngine && hasDefaultDb)),
|
|
middleware:
|
|
options.includeMiddleware !== undefined
|
|
? options.includeMiddleware
|
|
: (raw.middleware ?? hasEngine),
|
|
components:
|
|
options.exposeComponentDirectory !== undefined
|
|
? options.exposeComponentDirectory
|
|
: (raw.components ?? true),
|
|
client: raw.client ?? true,
|
|
devToolbar:
|
|
options.enableDevToolbar !== undefined ? options.enableDevToolbar : (raw.devToolbar ?? true),
|
|
componentDir: options.componentDir ?? raw.componentDir ?? authComponentsDir(),
|
|
schemas: resolveAuthSchemas(raw.schemas),
|
|
baseUrl: raw.baseUrl,
|
|
csrf: raw.csrf ?? true,
|
|
passkey: raw.passkey,
|
|
sessionCookieName: raw.sessionCookieName,
|
|
onSessionVerification: raw.onSessionVerification,
|
|
};
|
|
}
|
|
|
|
function fallbackConfig(options: AuthPluginOptions): ResolvedAuthConfig {
|
|
return {
|
|
enabled: true,
|
|
|
|
// Automatic package discovery must never expose authentication
|
|
// endpoints unless the application configures auth or the developer
|
|
// explicitly enables the routes through plugin options.
|
|
routes: options.includeRoutes ?? false,
|
|
|
|
migrations: options.includeMigrations ?? false,
|
|
|
|
middleware: options.includeMiddleware ?? false,
|
|
|
|
// Components and browser assets are safe to expose automatically.
|
|
components: options.exposeComponentDirectory ?? true,
|
|
|
|
client: true,
|
|
|
|
devToolbar: options.enableDevToolbar ?? true,
|
|
|
|
componentDir: options.componentDir ?? authComponentsDir(),
|
|
|
|
schemas: resolveAuthSchemas(),
|
|
|
|
csrf: true,
|
|
};
|
|
}
|
|
|
|
function resolved(context: PluginContext, options: AuthPluginOptions): ResolvedAuthConfig {
|
|
return (
|
|
(context.metadata.get(resolvedConfigKey) as ResolvedAuthConfig | undefined) ??
|
|
fallbackConfig(options)
|
|
);
|
|
}
|
|
|
|
function routeEnabled(routes: boolean | AuthRoutesConfig, group: AuthRouteGroup): boolean {
|
|
if (typeof routes === "boolean") return routes;
|
|
if (routes.enabled === false) return false;
|
|
return routes[group] !== false;
|
|
}
|
|
|
|
function authClientSource(schemas: AuthSchemaSet): string {
|
|
const descriptors = JSON.stringify(authBrowserSchemaDescriptors(schemas));
|
|
|
|
const runtime = readFileSync(clientRuntime, "utf8");
|
|
|
|
return `
|
|
(function () {
|
|
var defaults = ${descriptors};
|
|
|
|
window.__wrnSchemas =
|
|
window.__wrnSchemas || {};
|
|
|
|
Object.keys(defaults).forEach(
|
|
function (name) {
|
|
if (!(name in window.__wrnSchemas)) {
|
|
window.__wrnSchemas[name] =
|
|
defaults[name];
|
|
}
|
|
}
|
|
);
|
|
|
|
if (
|
|
window.__wrnValidate &&
|
|
typeof window.__wrnValidate
|
|
.registerSchemas === "function"
|
|
) {
|
|
window.__wrnValidate.registerSchemas(
|
|
defaults,
|
|
document
|
|
);
|
|
} else if (
|
|
window.__wrnValidate &&
|
|
typeof window.__wrnValidate.init ===
|
|
"function"
|
|
) {
|
|
window.__wrnValidate.init(document);
|
|
}
|
|
})();
|
|
|
|
${runtime}
|
|
`;
|
|
}
|
|
|
|
function audit(code: string, file: string): AuthAuditIssue[] {
|
|
const issues: AuthAuditIssue[] = [];
|
|
const push = (id: string, severity: AuthAuditIssue["severity"], title: string, message: string) =>
|
|
issues.push({ id: `${id}:${file}`, severity, title, message, file });
|
|
|
|
if (/<SignIn|<SignUp|data-component=["']SignIn|data-component=["']SignUp/.test(code)) {
|
|
if (!/<Captcha|captchaGuard|captchaPageGate/.test(code)) {
|
|
push(
|
|
"captcha-escalation",
|
|
"suggestion",
|
|
"Add adaptive CAPTCHA",
|
|
"Authentication forms should connect suspicious attempts to @wrnexus/captcha.",
|
|
);
|
|
}
|
|
if (!/autocomplete=/.test(code)) {
|
|
push(
|
|
"autocomplete",
|
|
"warning",
|
|
"Credential autocomplete is missing",
|
|
"Use username, current-password, and new-password autocomplete values.",
|
|
);
|
|
}
|
|
}
|
|
if (/secret\s*=|clientSecret\s*=|privateKey\s*=/.test(code) && /\.wrn$/.test(file)) {
|
|
push(
|
|
"client-secret",
|
|
"error",
|
|
"Authentication secret exposed",
|
|
"Never pass server secrets to a .wrn component.",
|
|
);
|
|
}
|
|
if (
|
|
/returnTo|redirect/.test(code) &&
|
|
!/validateOAuthReturnTo|safeReturnTo|startsWith\(["']\//.test(code)
|
|
) {
|
|
push(
|
|
"open-redirect",
|
|
"suggestion",
|
|
"Confirm redirects are same-origin",
|
|
"Validate returnTo values before redirecting after sign-in.",
|
|
);
|
|
}
|
|
return issues;
|
|
}
|
|
|
|
export function authPlugin(options: AuthPluginOptions = {}) {
|
|
const metadataKey = "@wrnexus/auth:audit";
|
|
|
|
return definePlugin({
|
|
name: "@wrnexus/auth",
|
|
version: "0.8.0",
|
|
enforce: "post",
|
|
|
|
componentDirs(context) {
|
|
const value = resolved(context, options);
|
|
return value.enabled && value.components ? [value.componentDir] : [];
|
|
},
|
|
|
|
clientRuntimes(context) {
|
|
const value = resolved(context, options);
|
|
if (!value.enabled || !value.client) return [];
|
|
return [
|
|
{
|
|
id: "auth",
|
|
source: authClientSource(value.schemas),
|
|
type: "script" as const,
|
|
load: "defer" as const,
|
|
singleton: true,
|
|
bundle: false,
|
|
},
|
|
];
|
|
},
|
|
|
|
styleSources(context) {
|
|
const value = resolved(context, options);
|
|
return value.enabled && value.components
|
|
? [{ id: "auth-components", source: value.componentDir, order: "normal" as const }]
|
|
: [];
|
|
},
|
|
|
|
routeEntries(context) {
|
|
const value = resolved(context, options);
|
|
|
|
if (!value.enabled) {
|
|
return [];
|
|
}
|
|
|
|
return AUTH_ROUTE_DEFINITIONS.filter((route) => routeEnabled(value.routes, route.group)).map(
|
|
({ path }) => ({
|
|
kind: "api" as const,
|
|
path,
|
|
entry: authApiRouteEntry(path),
|
|
}),
|
|
);
|
|
},
|
|
|
|
middleware(context) {
|
|
const value = resolved(context, options);
|
|
return value.enabled && value.middleware ? [middlewareFile] : [];
|
|
},
|
|
|
|
migrations(context) {
|
|
const value = resolved(context, options);
|
|
return value.enabled && value.migrations
|
|
? [
|
|
{
|
|
id: "wrnexus-auth-001",
|
|
source: readFileSync(migrationsFile, "utf8"),
|
|
},
|
|
{
|
|
id: "wrnexus-auth-002-otp-purpose",
|
|
source: readFileSync(otpPurposeMigrationFile, "utf8"),
|
|
},
|
|
]
|
|
: [];
|
|
},
|
|
|
|
configure(config, context) {
|
|
const value = resolveConfig(config, options);
|
|
|
|
context.metadata.set(resolvedConfigKey, value);
|
|
|
|
config.auth = {
|
|
...((config.auth ?? {}) as Record<string, unknown>),
|
|
|
|
componentDir: value.componentDir,
|
|
|
|
schemas: value.schemas,
|
|
};
|
|
|
|
if (value.engine) {
|
|
setDefaultAuthEngine(value.engine);
|
|
} else {
|
|
clearDefaultAuthEngine();
|
|
}
|
|
|
|
setDefaultAuthSchemas(value.schemas);
|
|
|
|
setDefaultAuthRouteOptions({
|
|
baseUrl: value.baseUrl,
|
|
|
|
csrf: value.csrf,
|
|
|
|
passkey: value.passkey,
|
|
|
|
sessionCookieName: value.sessionCookieName,
|
|
|
|
onSessionVerification: value.onSessionVerification,
|
|
});
|
|
|
|
context.metadata.set("@wrnexus/auth:component-dir", value.componentDir);
|
|
|
|
context.metadata.set("@wrnexus/auth:configured", Boolean(value.engine));
|
|
},
|
|
|
|
transformCode(code, context) {
|
|
if (context.mode !== "development") return;
|
|
const previous = (context.metadata.get(metadataKey) as AuthAuditIssue[] | undefined) ?? [];
|
|
context.metadata.set(metadataKey, [
|
|
...previous.filter((issue) => issue.file !== context.file),
|
|
...audit(code, context.file),
|
|
]);
|
|
},
|
|
|
|
devToolbarPanels(context) {
|
|
const value = resolved(context, options);
|
|
if (!value.enabled || !value.devToolbar) return [];
|
|
const issues = (context.metadata.get(metadataKey) as AuthAuditIssue[] | undefined) ?? [];
|
|
return [
|
|
{
|
|
id: "wrnexus-auth",
|
|
title: "Authentication",
|
|
icon: "shield-user",
|
|
badge: issues.length,
|
|
description: "Authentication security, session, passkey, and recovery checks",
|
|
issues,
|
|
},
|
|
];
|
|
},
|
|
});
|
|
}
|
|
|
|
export default authPlugin;
|