release: WRNexusJS 0.5.0

This commit is contained in:
2026-07-29 12:51:10 +05:30
parent 76c768099d
commit 6afe32f63f
456 changed files with 40879 additions and 8850 deletions
+525
View File
@@ -0,0 +1,525 @@
import type { Context } from "@wrnexus/core";
import { invalid, parseBody, type ObjectSchema } from "@wrnexus/validation";
import type { AuthEngine } from "../engine.ts";
import { safeAuthReturnTo } from "../normalize.ts";
import {
clearAuthSession,
establishAuthSession,
getAuthSession,
getAuthUser,
} from "../middleware.ts";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
import type { AuthSignedInHandler, AuthSignedOutHandler } from "../types.ts";
function text(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value);
}
function boolean(value: unknown): boolean {
return value === true || value === "true" || value === "on" || value === "1";
}
function json(data: unknown, status = 200): Response {
return Response.json(data, {
status,
headers: { "cache-control": "no-store" },
});
}
function parseValues<T = Record<string, unknown>>(
schema: ObjectSchema,
input: Record<string, unknown>,
): { ok: true; value: T } | { ok: false; response: Response } {
const result = schema.parse(input);
if (!result.ok) return { ok: false, response: invalid(result.errors) };
return { ok: true, value: result.value as T };
}
export interface AuthPasskeyHttpOptions {
/** Relying-party ID accepted by the server. Defaults to the request hostname. */
rpId?: string;
/** Display name included in registration options. */
rpName?: string;
/** Exact WebAuthn origin accepted by the server. Defaults to the request origin. */
origin?: string;
}
export interface AuthHttpOptions {
engine: AuthEngine;
baseUrl?: string;
schemas?: AuthSchemaOverrides | AuthSchemaSet;
passkey?: AuthPasskeyHttpOptions;
/** @deprecated Prefer createAuthEngine({ onSignedIn }). */
onSignedIn?: AuthSignedInHandler;
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
onSignedOut?: AuthSignedOutHandler;
}
export function createAuthHttpHandlers(options: AuthHttpOptions) {
const engine = options.engine;
const schemas = resolveAuthSchemas(options.schemas);
const onSignedIn = options.onSignedIn ?? engine.onSignedIn;
const onSignedOut = options.onSignedOut ?? engine.onSignedOut;
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
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);
}
function passkeyConfig(ctx: Context): { rpId: string; rpName: string; origin: string } {
const origin = options.passkey?.origin ?? ctx.url.origin;
let originHostname = ctx.url.hostname;
try {
originHostname = new URL(origin).hostname;
} catch {
// Configuration validation belongs to application startup; retain a safe request fallback.
}
return {
rpId: options.passkey?.rpId ?? originHostname,
rpName: options.passkey?.rpName ?? "WRNexusJS",
origin,
};
}
return {
async register(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.register, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.register({
email: text(input.email) || undefined,
phone: text(input.phone) || undefined,
username: text(input.username) || undefined,
password: text(input.password),
displayName: text(input.displayName) || undefined,
locale: text(input.locale) || undefined,
timezone: text(input.timezone) || undefined,
});
if (!result.ok || !result.user) return json(result, 400);
const action = await onSuccessfulSignUp?.(ctx, result.user);
if (action instanceof Response) return action;
if (action?.autoSignIn) {
const identifier = text(input.email) || text(input.phone) || text(input.username);
const loginResult = await engine.login({
identifier,
password: text(input.password),
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
captchaVerified:
Boolean((ctx.locals.captcha as { success?: boolean } | undefined)?.success) ||
ctx.locals.captchaVerified === true,
});
if (!loginResult.ok || !loginResult.session || !loginResult.user) {
return json(loginResult, 401);
}
establishAuthSession(ctx, loginResult.session, loginResult.user);
return signupRedirect(ctx, action.redirectTo, "/account");
}
return signupRedirect(ctx, action?.redirectTo, "/sign-in");
},
async login(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.login, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.login({
identifier: text(input.identifier),
password: text(input.password),
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
fingerprint: text(input.deviceFingerprint) || undefined,
deviceName: text(input.deviceName) || undefined,
rememberDevice: boolean(input.rememberDevice),
captchaVerified:
Boolean((ctx.locals.captcha as { success?: boolean } | undefined)?.success) ||
ctx.locals.captchaVerified === true,
signals: {
automationSuspected: Boolean(
ctx.locals.captchaRisk && (ctx.locals.captchaRisk as { challenge?: boolean }).challenge,
),
},
});
if (!result.ok || !result.session || !result.user) return json(result, 401);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) {
return onSignedIn(ctx, safeAuthReturnTo(text(input.returnTo) || undefined, ctx.url.origin));
}
return json(result);
},
async logout(ctx: Context): Promise<Response> {
const session = getAuthSession(ctx);
if (session) await engine.logout(session.id);
clearAuthSession(ctx);
return onSignedOut ? onSignedOut(ctx) : json({ ok: true });
},
async requestVerification(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.verificationRequest, ctx.req);
if (!validation.ok) return validation.response;
const type = text(validation.value.type) === "phone" ? "phone" : "email";
const current = getAuthUser(ctx);
const identifier = text(validation.value.identifier);
const user =
current ?? (identifier ? await engine.findUserByIdentifier(identifier) : undefined);
if (user) {
await engine.requestVerification(user.id, type, options.baseUrl ?? ctx.url.origin);
}
// Always return the same response to avoid revealing account existence.
return json({ ok: true });
},
async verifyEmail(ctx: Context): Promise<Response> {
const validation =
ctx.req.method === "GET"
? parseValues(schemas.verificationToken, {
token: ctx.url.searchParams.get("token"),
})
: await parseBody(schemas.verificationToken, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.verifyEmail(text(validation.value.token));
return json(result, result.ok ? 200 : 400);
},
async verifyPhone(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.verificationToken, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.verifyPhone(text(validation.value.token));
return json(result, result.ok ? 200 : 400);
},
async requestPasswordReset(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.passwordResetRequest, ctx.req);
if (!validation.ok) return validation.response;
await engine.requestPasswordReset(
text(validation.value.identifier),
options.baseUrl ?? ctx.url.origin,
);
return json({ ok: true });
},
async resetPassword(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.passwordReset, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.resetPassword(
text(validation.value.token),
text(validation.value.password),
);
return json(result, result.ok ? 200 : 400);
},
async acceptInvitation(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.invitationAccept, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.acceptInvitation(text(input.token), {
password: text(input.password) || undefined,
displayName: text(input.displayName) || undefined,
});
return json(result, result.ok ? 200 : 400);
},
async requestMagicLink(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.magicLinkRequest, ctx.req);
if (!validation.ok) return validation.response;
await engine.requestMagicLink(
text(validation.value.identifier),
options.baseUrl ?? ctx.url.origin,
);
return json({ ok: true });
},
async consumeMagicLink(ctx: Context): Promise<Response> {
const validation =
ctx.req.method === "GET"
? parseValues(schemas.magicLinkConsume, {
token: ctx.url.searchParams.get("token"),
returnTo: ctx.url.searchParams.get("returnTo") ?? undefined,
})
: await parseBody(schemas.magicLinkConsume, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.consumeMagicLink(text(input.token), {
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
});
if (result.code === "mfa-required") return json(result, 401);
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 json(result);
},
async requestOtpLogin(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.otpLoginRequest, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const method = text(input.method) === "sms-otp" ? "sms-otp" : "email-otp";
const challenge = await engine.requestOtpLogin(text(input.identifier), method);
return json({ ok: true, challenge: challenge ?? null });
},
async completeOtpLogin(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.otpLoginComplete, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.completeOtpLogin(text(input.challengeId), text(input.code), {
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
});
if (result.code === "mfa-required") return json(result, 401);
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 json(result);
},
async issueOtp(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.otpIssue, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const method = text(input.method) === "sms-otp" ? "sms-otp" : "email-otp";
try {
return json({
ok: true,
...(await engine.issueOtp(user.id, method, text(input.destination) || undefined)),
});
} catch (error) {
return json(
{
ok: false,
error: error instanceof Error ? error.message : "Unable to issue OTP",
},
400,
);
}
},
async verifyOtp(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.otpVerify, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.verifyOtp(
text(validation.value.challengeId),
text(validation.value.code),
"verification",
);
return json(result, result.ok ? 200 : 400);
},
async beginTotp(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.authenticatorSetup, ctx.req);
if (!validation.ok) return validation.response;
const setup = await engine.beginTotp(
user.id,
text(validation.value.label) || "Authenticator",
);
return json({ ok: true, ...setup });
},
async confirmTotp(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.authenticatorConfirm, ctx.req);
if (!validation.ok) return validation.response;
const ok = await engine.confirmTotp(
user.id,
text(validation.value.credentialId),
text(validation.value.code),
);
return ok
? json({ ok: true })
: json({ ok: false, error: "Authenticator code is invalid" }, 400);
},
async disableTotp(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.authenticatorDisable, ctx.req);
if (!validation.ok) return validation.response;
const ok = await engine.disableTotp(user.id, text(validation.value.credentialId));
return ok
? json({ ok: true })
: json({ ok: false, error: "Authenticator credential was not found" }, 404);
},
async recoveryCodes(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.recoveryCodes, ctx.req);
if (!validation.ok) return validation.response;
const count = typeof validation.value.count === "number" ? validation.value.count : undefined;
const codes = await engine.generateRecoveryCodes(user.id, count);
return json({ ok: true, codes });
},
async changePassword(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.changePassword, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.changePassword(
user.id,
text(validation.value.currentPassword),
text(validation.value.nextPassword),
);
if (result.ok) clearAuthSession(ctx);
return json(result, result.ok ? 200 : 400);
},
async beginMfaOtp(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.mfaOtpRequest, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const method = text(input.method) === "sms-otp" ? "sms-otp" : "email-otp";
const challenge = await engine.beginMfaOtp(text(input.mfaToken), method);
return challenge
? json({ ok: true, ...challenge })
: json({ ok: false, error: "MFA transaction is invalid or expired" }, 400);
},
async completeMfa(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.mfaComplete, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
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),
method,
code: text(input.code),
challengeId: text(input.challengeId) || undefined,
session: {
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
},
});
if (result.code === "mfa-required") return json(result, 401);
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 json(result);
},
async startImpersonation(ctx: Context): Promise<Response> {
const actor = getAuthUser(ctx);
const current = getAuthSession(ctx);
if (!actor) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.impersonationStart, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.startImpersonation(actor.id, text(input.targetUserId), {
reason: text(input.reason) || undefined,
sessionId: current?.id,
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
});
if (!result.ok || !result.session || !result.user) return json(result, 403);
establishAuthSession(ctx, result.session, result.user);
return json(result);
},
async stopImpersonation(ctx: Context): Promise<Response> {
const current = getAuthSession(ctx);
if (!current) return json({ ok: false, error: "Unauthorized" }, 401);
const result = await engine.stopImpersonation(current.id);
if (result.code === "mfa-required") return json(result, 401);
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
return json(result);
},
async sessions(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
return json({
ok: true,
sessions: await engine.listSessions(user.id),
currentSessionId: getAuthSession(ctx)?.id,
});
},
async revokeSession(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.sessionRevoke, ctx.req);
if (!validation.ok) return validation.response;
return json({
ok: await engine.revokeSession(user.id, text(validation.value.sessionId)),
});
},
async passkeyRegistrationOptions(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.passkeyRegistrationOptions, ctx.req);
if (!validation.ok) return validation.response;
const config = passkeyConfig(ctx);
return json({
ok: true,
...(await engine.beginPasskeyRegistration(user.id, config)),
});
},
async passkeyRegistrationVerify(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.passkeyRegistrationVerify, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const ok = await engine.finishPasskeyRegistration(user.id, {
key: text(input.key),
response: input.response,
name: text(input.name) || undefined,
});
return json({ ok }, ok ? 200 : 400);
},
async passkeyAuthenticationOptions(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.passkeyAuthenticationOptions, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const config = passkeyConfig(ctx);
return json({
ok: true,
...(await engine.beginPasskeyAuthentication({
identifier: text(input.identifier) || undefined,
rpId: config.rpId,
origin: config.origin,
})),
});
},
async passkeyAuthenticationVerify(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.passkeyAuthenticationVerify, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.finishPasskeyAuthentication({
key: text(input.key),
response: input.response,
session: {
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
},
});
if (result.code === "mfa-required") return json(result, 401);
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) return onSignedIn(ctx);
return json(result);
},
};
}