feat: centralize application framework primitives
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user