release: WRNexusJS 0.5.0
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
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 { AuthSignedInHandler, AuthSignedOutHandler } 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;
|
||||
/** @deprecated Prefer createAuthEngine({ onSignedIn }). */
|
||||
onSignedIn?: AuthSignedInHandler;
|
||||
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
|
||||
onSignedOut?: AuthSignedOutHandler;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
onSignedIn?: AuthSignedInHandler;
|
||||
onSignedOut?: AuthSignedOutHandler;
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
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 = join(packageRoot, "src", "routes", "api");
|
||||
const middlewareFile = 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 + ".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,
|
||||
onSignedIn: raw.onSignedIn ?? raw.engine?.onSignedIn,
|
||||
onSignedOut: raw.onSignedOut ?? raw.engine?.onSignedOut,
|
||||
};
|
||||
}
|
||||
|
||||
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.__wireSchemas =
|
||||
window.__wireSchemas || {};
|
||||
|
||||
Object.keys(defaults).forEach(
|
||||
function (name) {
|
||||
if (!(name in window.__wireSchemas)) {
|
||||
window.__wireSchemas[name] =
|
||||
defaults[name];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
window.__wireValidate &&
|
||||
typeof window.__wireValidate
|
||||
.registerSchemas === "function"
|
||||
) {
|
||||
window.__wireValidate.registerSchemas(
|
||||
defaults,
|
||||
document
|
||||
);
|
||||
} else if (
|
||||
window.__wireValidate &&
|
||||
typeof window.__wireValidate.init ===
|
||||
"function"
|
||||
) {
|
||||
window.__wireValidate.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.5.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,
|
||||
|
||||
onSignedIn: value.onSignedIn,
|
||||
|
||||
onSignedOut: value.onSignedOut,
|
||||
});
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user