feat: centralize application framework primitives
Quality / quality (ubuntu-latest) (push) Failing after 14m38s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-22 23:07:46 +05:30
parent 96e082b943
commit a3ddd39b7b
73 changed files with 1429 additions and 84 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/auth",
"version": "0.8.14",
"version": "0.8.15",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module",
"sideEffects": false,
+23 -1
View File
@@ -266,7 +266,29 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
if (options.secret.length < MIN_SECRET_LENGTH) {
throw new TypeError(`auth secret must be at least ${MIN_SECRET_LENGTH} characters`);
}
const store = options.store;
let resolvedStore: AuthStore | undefined;
const resolveStore = (): AuthStore => {
if (resolvedStore) return resolvedStore;
resolvedStore = typeof options.store === "function" ? options.store() : options.store;
if (!resolvedStore || typeof resolvedStore !== "object") {
throw new TypeError("WRN-AUTH-STORE: the auth store factory did not return an AuthStore");
}
return resolvedStore;
};
// AuthEngine.store remains source-compatible while deferring the factory
// until the first actual property read or method call.
const store = new Proxy({} as AuthStore, {
get(_target, property) {
const value = Reflect.get(resolveStore() as object, property);
return typeof value === "function" ? value.bind(resolveStore()) : value;
},
set(_target, property, value) {
return Reflect.set(resolveStore() as object, property, value);
},
has(_target, property) {
return Reflect.has(resolveStore() as object, property);
},
});
const now = () => {
const value = options.clock?.now() ?? Date.now();
if (!Number.isFinite(value)) throw new Error("WRN-AUTH-CLOCK: clock returned an invalid time");
+94 -2
View File
@@ -10,6 +10,8 @@ import {
} from "../middleware.ts";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
import type { AuthSessionVerificationHandler } from "../types.ts";
import { completeAuth, startAuth, type OAuthProvider } from "@wrnexus/oauth";
import { assignDefaultAuthzRoles } from "@wrnexus/authz";
function text(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value);
@@ -50,6 +52,8 @@ export interface AuthHttpOptions {
schemas?: AuthSchemaOverrides | AuthSchemaSet;
passkey?: AuthPasskeyHttpOptions;
onSessionVerification?: AuthSessionVerificationHandler;
oauth?: Record<string, OAuthProvider>;
oauthMfaPath?: string;
}
export function createAuthHttpHandlers(options: AuthHttpOptions) {
@@ -59,6 +63,17 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
const onSignedOut = engine.onSignedOut;
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
function oauthProvider(ctx: Context): OAuthProvider | undefined {
return options.oauth?.[String(ctx.params.provider ?? "").toLowerCase()];
}
function oauthRedirectUri(ctx: Context, provider: OAuthProvider): string {
return new URL(
`/api/auth/oauth/${encodeURIComponent(provider.name)}/callback`,
options.baseUrl ?? ctx.url.origin,
).toString();
}
function signupRedirect(ctx: Context, value: string | undefined, fallback: string): Response {
const path = safeAuthReturnTo(value, ctx.url.origin) ?? fallback;
return Response.redirect(new URL(path, ctx.url), 303);
@@ -80,6 +95,71 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
}
return {
async oauthProviders(ctx: Context): Promise<Response> {
return json({
ok: true,
providers: Object.values(options.oauth ?? {}).map((provider) => ({
id: provider.name,
name: provider.name,
label: `Continue with ${provider.name.charAt(0).toUpperCase()}${provider.name.slice(1)}`,
href: `/api/auth/oauth/${encodeURIComponent(provider.name)}?returnTo=${encodeURIComponent(
safeAuthReturnTo(ctx.url.searchParams.get("returnTo") ?? undefined, ctx.url.origin) ??
"/",
)}`,
})),
});
},
async startOAuth(ctx: Context): Promise<Response> {
const provider = oauthProvider(ctx);
if (!provider) return json({ ok: false, error: "OAuth provider not configured" }, 404);
const returnTo =
safeAuthReturnTo(ctx.url.searchParams.get("returnTo") ?? undefined, ctx.url.origin) ?? "/";
const started = await startAuth(provider, { redirectUri: oauthRedirectUri(ctx, provider) });
ctx.cookies.transaction(`wrnexus.oauth.${provider.name}`).set({
state: started.state,
verifier: started.verifier,
returnTo,
});
return Response.redirect(started.url, 302);
},
async completeOAuth(ctx: Context): Promise<Response> {
const provider = oauthProvider(ctx);
if (!provider) return json({ ok: false, error: "OAuth provider not configured" }, 404);
const transaction = ctx.cookies
.transaction<{
state: string;
verifier: string;
returnTo: string;
}>(`wrnexus.oauth.${provider.name}`)
.consume();
const state = ctx.url.searchParams.get("state");
const code = ctx.url.searchParams.get("code");
if (!transaction || !state || transaction.state !== state || !code) {
return json({ ok: false, error: "OAuth transaction is invalid or expired" }, 400);
}
const completed = await completeAuth(provider, {
code,
verifier: transaction.verifier,
redirectUri: oauthRedirectUri(ctx, provider),
});
const result = await engine.loginWithOAuth(
provider.name,
completed.profile,
completed.tokens,
);
if (result.code === "mfa-required" && result.mfaToken) {
ctx.cookies.transaction("wrnexus.auth.mfa", { sameSite: "Lax", maxAge: 300 }).set({
mfaToken: result.mfaToken,
returnTo: transaction.returnTo,
});
return Response.redirect(new URL(options.oauthMfaPath ?? "/two-factor", ctx.url), 303);
}
if (!result.ok || !result.session || !result.user) return json(result, 401);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) return onSignedIn(ctx, transaction.returnTo);
return Response.redirect(new URL(transaction.returnTo, ctx.url), 303);
},
async verifySession(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (options.onSessionVerification) {
@@ -110,6 +190,8 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
});
if (!result.ok || !result.user) return json(result, 400);
await assignDefaultAuthzRoles(result.user.id, "signup");
const action = await onSuccessfulSignUp?.(ctx, result.user);
if (action instanceof Response) return action;
if (action?.autoSignIn) {
@@ -230,6 +312,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
password: text(input.password) || undefined,
displayName: text(input.displayName) || undefined,
});
if (result.ok && result.user) await assignDefaultAuthzRoles(result.user.id, "invitation");
return json(result, result.ok ? 200 : 400);
},
@@ -403,12 +486,18 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
const validation = await parseBody(schemas.mfaComplete, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const continuation = ctx.cookies
.transaction<{ mfaToken: string; returnTo?: string }>("wrnexus.auth.mfa", {
sameSite: "Lax",
maxAge: 300,
})
.consume();
const methodValue = text(input.method);
const method = ["totp", "recovery-code", "email-otp", "sms-otp"].includes(methodValue)
? (methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp")
: "totp";
const result = await engine.completeMfa({
mfaToken: text(input.mfaToken),
mfaToken: text(input.mfaToken) || continuation?.mfaToken || "",
method,
code: text(input.code),
challengeId: text(input.challengeId) || undefined,
@@ -421,7 +510,10 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) {
return onSignedIn(ctx, safeAuthReturnTo(text(input.returnTo) || undefined, ctx.url.origin));
return onSignedIn(
ctx,
safeAuthReturnTo(text(input.returnTo) || continuation?.returnTo, ctx.url.origin),
);
}
return json(result);
},
+3
View File
@@ -9,6 +9,8 @@ export {
clearAuthSession,
getAuthUser,
getAuthSession,
requireAuthUser,
AuthRequiredError,
isAuthenticatedContext,
AUTH_SESSION_KEY,
} from "./middleware.ts";
@@ -20,6 +22,7 @@ export {
export {
authPlugin,
authComponentsDir,
validateProductionAuthConfig,
type AuthConfig,
type AuthRoutesConfig,
type AuthPluginOptions,
+17
View File
@@ -79,6 +79,23 @@ export function getAuthUser(ctx: Context): AuthPublicUser | null {
);
}
/** Return a typed authenticated user or fail closed for direct handler use. */
export function requireAuthUser(ctx: Context): AuthPublicUser {
const user = getAuthUser(ctx);
if (!user) throw new AuthRequiredError();
return user;
}
export class AuthRequiredError extends Error {
readonly code = "WRN-AUTH-REQUIRED";
readonly status = 401;
constructor() {
super("Authentication is required");
this.name = "AuthRequiredError";
}
}
export function getAuthSession(ctx: Context): AuthSession | null {
return (ctx.locals.authSession as AuthSession | undefined) ?? null;
}
+69
View File
@@ -2,6 +2,13 @@ import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
import {
discord,
github,
google,
type OAuthProvider,
type ProviderCredentials,
} from "@wrnexus/oauth";
import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import type { AuthSessionVerificationHandler } from "./types.ts";
@@ -32,8 +39,11 @@ export interface AuthRoutesConfig {
sessions?: boolean;
impersonation?: boolean;
passkeys?: boolean;
oauth?: boolean;
}
export type AuthOAuthProviderConfig = OAuthProvider | ProviderCredentials;
export interface AuthConfig {
enabled?: boolean;
engine?: AuthEngine;
@@ -48,12 +58,36 @@ export interface AuthConfig {
baseUrl?: string;
csrf?: boolean;
passkey?: AuthPasskeyHttpOptions;
oauth?: Partial<Record<"google" | "github" | "discord", AuthOAuthProviderConfig>> &
Record<string, AuthOAuthProviderConfig>;
oauthMfaPath?: string;
/** Disable only when an external deployment gate performs equivalent checks. */
productionValidation?: boolean;
/** Shared SSO cookie whose value is an AuthEngine session id. */
sessionCookieName?: string;
/** Customize the package forward-auth verification response. */
onSessionVerification?: AuthSessionVerificationHandler;
}
export function validateProductionAuthConfig(
config: Pick<AuthConfig, "baseUrl" | "oauth">,
env: Record<string, string | undefined> = process.env,
): void {
const secret = env.AUTH_SECRET?.trim() ?? "";
if (secret.length < 32 || /^(?:change-me|development|test-only)/i.test(secret)) {
throw new Error(
"WRN-AUTH-CONFIG: AUTH_SECRET must be a non-development secret of at least 32 characters in production",
);
}
if (!config.baseUrl) throw new Error("WRN-AUTH-CONFIG: auth.baseUrl is required in production");
const base = new URL(config.baseUrl);
if (base.protocol !== "https:")
throw new Error("WRN-AUTH-CONFIG: auth.baseUrl must use HTTPS in production");
if (["localhost", "127.0.0.1", "::1"].includes(base.hostname)) {
throw new Error("WRN-AUTH-CONFIG: auth.baseUrl must not use localhost in production");
}
}
/** Explicit plugin options remain supported for compatibility. Prefer config.auth. */
export interface AuthPluginOptions {
componentDir?: string;
@@ -88,6 +122,8 @@ interface ResolvedAuthConfig {
passkey?: AuthPasskeyHttpOptions;
sessionCookieName?: string;
onSessionVerification?: AuthSessionVerificationHandler;
oauth: Record<string, OAuthProvider>;
oauthMfaPath?: string;
}
const moduleRoot = dirname(fileURLToPath(import.meta.url));
@@ -121,6 +157,24 @@ function resolveConfig(
const enabled = raw.enabled !== false;
const hasEngine = Boolean(raw.engine);
const hasDefaultDb = Boolean(config.db);
const oauth: Record<string, OAuthProvider> = {};
for (const [name, provider] of Object.entries(raw.oauth ?? {})) {
if (!provider || !provider.clientId?.trim() || !provider.clientSecret?.trim()) continue;
oauth[name] =
"authorizeUrl" in provider
? provider
: name === "google"
? google(provider)
: name === "github"
? github(provider)
: name === "discord"
? discord(provider)
: (() => {
throw new Error(
`WRN-AUTH-OAUTH-CONFIG: custom provider '${name}' requires a complete OAuthProvider`,
);
})();
}
return {
enabled,
@@ -148,6 +202,8 @@ function resolveConfig(
passkey: raw.passkey,
sessionCookieName: raw.sessionCookieName,
onSessionVerification: raw.onSessionVerification,
oauth,
oauthMfaPath: raw.oauthMfaPath,
};
}
@@ -176,6 +232,7 @@ function fallbackConfig(options: AuthPluginOptions): ResolvedAuthConfig {
schemas: resolveAuthSchemas(),
csrf: true,
oauth: {},
};
}
@@ -353,6 +410,16 @@ export function authPlugin(options: AuthPluginOptions = {}) {
},
configure(config, context) {
const raw = (config.auth ?? {}) as AuthConfig;
if (
context.mode === "production" &&
process.env.NODE_ENV === "production" &&
raw.enabled !== false &&
raw.engine &&
raw.productionValidation !== false
) {
validateProductionAuthConfig(raw);
}
const value = resolveConfig(config, options);
context.metadata.set(resolvedConfigKey, value);
@@ -383,6 +450,8 @@ export function authPlugin(options: AuthPluginOptions = {}) {
sessionCookieName: value.sessionCookieName,
onSessionVerification: value.onSessionVerification,
oauth: value.oauth,
oauthMfaPath: value.oauthMfaPath,
});
context.metadata.set("@wrnexus/auth:component-dir", value.componentDir);
+2
View File
@@ -61,6 +61,8 @@ function handlersFor(ctx: Context): AuthHttpHandlers | undefined {
baseUrl: routeOptions.baseUrl ?? ctx.url.origin,
passkey: routeOptions.passkey,
onSessionVerification: routeOptions.onSessionVerification,
oauth: routeOptions.oauth,
oauthMfaPath: routeOptions.oauthMfaPath,
});
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("completeOAuth", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("startOAuth", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("oauthProviders", ctx);
}
+20 -1
View File
@@ -12,7 +12,8 @@ export type AuthRouteGroup =
| "mfa"
| "sessions"
| "impersonation"
| "passkeys";
| "passkeys"
| "oauth";
export interface AuthRouteDefinition {
path: string;
@@ -23,6 +24,24 @@ export interface AuthRouteDefinition {
/** Single source of truth for package-contributed auth endpoints. */
export const AUTH_ROUTE_DEFINITIONS = [
{
path: "/api/auth/oauth/providers",
group: "oauth",
handler: "oauthProviders",
methods: ["GET"],
},
{
path: "/api/auth/oauth/[provider]",
group: "oauth",
handler: "startOAuth",
methods: ["GET"],
},
{
path: "/api/auth/oauth/[provider]/callback",
group: "oauth",
handler: "completeOAuth",
methods: ["GET"],
},
{
path: "/api/auth/session/verify",
group: "sessions",
+3
View File
@@ -1,6 +1,7 @@
import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import type { AuthSessionVerificationHandler } from "./types.ts";
import type { OAuthProvider } from "@wrnexus/oauth";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "./validation.ts";
export interface DefaultAuthRouteOptions {
@@ -9,6 +10,8 @@ export interface DefaultAuthRouteOptions {
passkey?: AuthPasskeyHttpOptions;
sessionCookieName?: string;
onSessionVerification?: AuthSessionVerificationHandler;
oauth?: Record<string, OAuthProvider>;
oauthMfaPath?: string;
}
interface AuthRuntimeState {
+7 -2
View File
@@ -403,7 +403,12 @@ export interface AuthRandom {
}
export interface AuthEngineOptions {
store: import("./store.ts").AuthStore;
/**
* Authentication storage may be created lazily. This is the preferred form
* for database-backed stores because application configuration is imported
* before the runtime database connection is installed.
*/
store: import("./store.ts").AuthStore | (() => import("./store.ts").AuthStore);
secret: string;
issuer?: string;
delivery?: AuthDeliveryProvider;
@@ -501,7 +506,7 @@ export interface AuthResult {
};
}
export interface AuthenticatedContext extends Context {
export interface AuthenticatedContext extends Context<Record<string, string>, AuthPublicUser> {
user: AuthPublicUser;
locals: Context["locals"] & {
authUser: AuthPublicUser;
+1 -5
View File
@@ -160,11 +160,7 @@ export const mfaOtpRequestSchema = v.object({
});
export const mfaSchema = v.object({
mfaToken: v
.string()
.required("MFA transaction is missing")
.min(20, "MFA transaction is invalid")
.max(512),
mfaToken: v.string().min(20, "MFA transaction is invalid").max(512).optional(),
method: v
.string()
.required("Choose a verification method")
+19
View File
@@ -4,6 +4,25 @@ import { MemoryAuthStore } from "../src/stores/memory.ts";
import { generateTotp } from "../src/totp/index.ts";
import type { AuthDeliveryMessage, PasskeyProvider } from "../src/types.ts";
test("authentication storage factories resolve lazily and only once", async () => {
let calls = 0;
const backing = new MemoryAuthStore();
const engine = createAuthEngine({
store: () => {
calls++;
return backing;
},
secret: "a secure test secret that is longer than thirty-two characters",
});
expect(calls).toBe(0);
expect(engine.store).toBeDefined();
expect(calls).toBe(0);
await engine.getUser("missing");
await engine.getUser("still-missing");
expect(calls).toBe(1);
});
function fixture() {
let time = 1_720_000_000_000;
let seed = 11;
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, expect, test } from "bun:test";
import { createContext, withContextHeaders } from "@wrnexus/core";
import type { OAuthProvider } from "@wrnexus/oauth";
import { createAuthEngine } from "../src/engine.ts";
import { createAuthHttpHandlers } from "../src/http/index.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
test("configured OAuth owns PKCE state, callback, and session establishment", async () => {
const provider: OAuthProvider = {
name: "example",
authorizeUrl: "https://identity.example/authorize",
tokenUrl: "https://identity.example/token",
userInfoUrl: "https://identity.example/user",
scopes: ["openid", "email"],
clientId: "client",
clientSecret: "secret",
mapProfile: (raw) => ({
id: String(raw.id),
email: String(raw.email),
raw,
}),
};
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "oauth-http-secret-that-is-longer-than-thirty-two-characters",
});
const handlers = createAuthHttpHandlers({
engine,
baseUrl: "https://app.example",
oauth: { example: provider },
});
const startRequest = new Request(
"https://app.example/api/auth/oauth/example?returnTo=%2Fdashboard",
);
const startCtx = createContext(startRequest, new URL(startRequest.url));
startCtx.params = { provider: "example" };
const start = await handlers.startOAuth(startCtx);
expect(start.status).toBe(302);
const location = new URL(start.headers.get("location")!);
expect(location.searchParams.get("code_challenge_method")).toBe("S256");
const issued = withContextHeaders(startCtx, start).headers.get("set-cookie")!;
const replies = [
Response.json({ access_token: "access", token_type: "Bearer" }),
Response.json({ id: "provider-user", email: "oauth@example.test" }),
];
const mock: typeof fetch = Object.assign(async () => replies.shift()!, {
preconnect: () => undefined,
});
globalThis.fetch = mock;
const callbackRequest = new Request(
`https://app.example/api/auth/oauth/example/callback?code=code&state=${encodeURIComponent(location.searchParams.get("state")!)}`,
{ headers: { cookie: issued.split(";", 1)[0]! } },
);
const callbackCtx = createContext(callbackRequest, new URL(callbackRequest.url));
callbackCtx.params = { provider: "example" };
const callback = await handlers.completeOAuth(callbackCtx);
expect(callback.status).toBe(303);
expect(callback.headers.get("location")).toBe("https://app.example/dashboard");
expect(callbackCtx.user).toMatchObject({ id: expect.any(String) });
});
+16 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test";
import type { Context } from "@wrnexus/core";
import { createPluginRunner } from "@wrnexus/plugin";
import { authPlugin } from "../src/plugin.ts";
import { authPlugin, validateProductionAuthConfig } from "../src/plugin.ts";
import { AUTH_ROUTE_DEFINITIONS } from "../src/routes/definitions.ts";
import { readFileSync } from "node:fs";
import { createAuthEngine } from "../src/engine.ts";
@@ -65,6 +65,21 @@ test("plugin contributes components, runtime, styles, migration, and toolbar", a
expect(contributions.middleware).toHaveLength(1);
});
test("production auth validation rejects development secrets and origins", () => {
expect(() =>
validateProductionAuthConfig(
{ baseUrl: "http://localhost:3000" },
{ AUTH_SECRET: "change-me" },
),
).toThrow("AUTH_SECRET");
expect(() =>
validateProductionAuthConfig(
{ baseUrl: "http://app.example" },
{ AUTH_SECRET: "a-production-secret-that-is-longer-than-thirty-two-characters" },
),
).toThrow("HTTPS");
});
test("unconfigured automatic discovery fails closed for routes, middleware, and migrations", async () => {
const metadata = new Map<string, unknown>();
const runner = createPluginRunner(authPlugin(), {