release: WRNexusJS 0.5.0
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { captchaHandlers } from "../../lib/captcha.ts";
|
||||
|
||||
async function handle(ctx: Context): Promise<Response> {
|
||||
return (await captchaHandlers.handle(ctx.req, ctx)) ?? new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
export const GET = handle;
|
||||
export const HEAD = handle;
|
||||
export const POST = handle;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createAuthEngine, MemoryAuthStore } from "@wrnexus/auth";
|
||||
|
||||
export const outbox: unknown[] = [];
|
||||
|
||||
export const auth = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "development-auth-showcase-secret-change-in-production",
|
||||
issuer: "WRNexus Auth Showcase",
|
||||
sendLoginAlerts: true,
|
||||
onSignedIn(ctx, returnTo) {
|
||||
const safe = returnTo?.startsWith("/") && !returnTo.startsWith("//") ? returnTo : "/account";
|
||||
return Response.redirect(new URL(safe, ctx.url), 303);
|
||||
},
|
||||
onSignedOut(ctx) {
|
||||
return Response.redirect(new URL("/sign-in", ctx.url), 303);
|
||||
},
|
||||
onSuccessfulSignUp() {
|
||||
return {
|
||||
autoSignIn: true,
|
||||
redirectTo: "/account",
|
||||
};
|
||||
},
|
||||
delivery: {
|
||||
async send(message) {
|
||||
outbox.push(message);
|
||||
console.info(`[auth-showcase] queued ${message.template} for ${message.destination}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
createCaptchaEngine,
|
||||
createCaptchaHttpHandlers,
|
||||
MemoryCaptchaStore,
|
||||
} from "@wrnexus/captcha/server";
|
||||
|
||||
export const captchaEngine = createCaptchaEngine({
|
||||
secret: process.env.CAPTCHA_SECRET ?? "development-auth-showcase-captcha-secret-change-this",
|
||||
store: new MemoryCaptchaStore(),
|
||||
basePath: "/api/captcha",
|
||||
challengeTtlMs: 2 * 60_000,
|
||||
responseTokenTtlMs: 5 * 60_000,
|
||||
maxAttempts: 3,
|
||||
minCompletionMs: 800,
|
||||
});
|
||||
|
||||
export const captchaHandlers = createCaptchaHttpHandlers(captchaEngine, {
|
||||
createLimit: 60,
|
||||
verifyLimit: 60,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { authSession, requireAuth } from "@wrnexus/auth";
|
||||
import type { Middleware } from "@wrnexus/core";
|
||||
import { auth } from "../lib/auth.ts";
|
||||
|
||||
const hydrate = authSession(auth);
|
||||
const accountGuard = requireAuth({ loginPath: "/sign-in" });
|
||||
|
||||
const middleware: Middleware = async (ctx, next) => {
|
||||
return hydrate(ctx, () =>
|
||||
ctx.url.pathname.startsWith("/account") ? accountGuard(ctx, next) : next(),
|
||||
);
|
||||
};
|
||||
|
||||
export default middleware;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Context, Next } from "@wrnexus/core";
|
||||
import { captchaGuard } from "@wrnexus/captcha/server";
|
||||
import { captchaEngine } from "../lib/captcha.ts";
|
||||
|
||||
const protectLogin = captchaGuard({
|
||||
action: "auth-login",
|
||||
engine: captchaEngine,
|
||||
responseField: "wrn-captcha-response",
|
||||
bindHostname: true,
|
||||
bindSession: true,
|
||||
verifiedForMs: 5 * 60_000,
|
||||
onFailure(_ctx, result) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
code: result.code ?? "captcha-invalid",
|
||||
message: result.message ?? "Complete the security check below before signing in.",
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
headers: { "cache-control": "no-store" },
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default function captchaLogin(ctx: Context, next: Next) {
|
||||
return ctx.req.method === "POST" && ctx.url.pathname === "/api/auth/login"
|
||||
? protectLogin(ctx, next)
|
||||
: next();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
page AccountPage {
|
||||
state user = ctx.user
|
||||
seo { title = "Account" }
|
||||
view {
|
||||
<main class="min-h-screen bg-[var(--wire-color-bg)] p-6 text-[var(--wire-color-text)]">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-3xl font-bold">Account security</h1>
|
||||
<p class="text-[var(--wire-color-muted)]">Signed in as {user.displayName || user.username || user.id}</p>
|
||||
<div class="mt-8 grid gap-5">
|
||||
<AccountStatus status='{user.status}' />
|
||||
<PasskeyButton mode="register" />
|
||||
<form method="post" action="/api/auth/logout" data-schema="auth-empty" novalidate>
|
||||
<p data-error="_form" role="alert" class="mb-3 hidden text-sm text-[var(--wire-color-danger)]"></p>
|
||||
<button type="submit" class="h-11 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-danger)] px-5 font-semibold text-white disabled:cursor-wait disabled:opacity-60">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
page AuthenticatorPage {
|
||||
seo { title = "Authenticator setup" }
|
||||
view { <main class="flex min-h-screen items-center justify-center bg-[var(--wire-color-bg)] p-4"><AuthenticatorSetup credentialId="demo" secret="JBSWY3DPEHPK3PXP" uri="otpauth://totp/WRNexusJS:developer@example.com?secret=JBSWY3DPEHPK3PXP&issuer=WRNexusJS" /></main> }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
page ImpersonationPage {
|
||||
seo { title = "Impersonation safety banner" }
|
||||
view { <main class="min-h-screen bg-[var(--wire-color-bg)] text-[var(--wire-color-text)]"><ImpersonationBanner targetName="Demo Customer" /><div class="mx-auto max-w-4xl p-8"><h1 class="text-3xl font-bold">Support impersonation</h1><p class="text-[var(--wire-color-muted)]">The banner remains visible while an audited support session is active.</p></div></main> }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
page AuthShowcase {
|
||||
seo { title = "Authentication system showcase" }
|
||||
view {
|
||||
<main class="min-h-screen bg-[var(--wire-color-bg)] px-4 py-12 text-[var(--wire-color-text)] sm:px-6">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<header class="max-w-3xl">
|
||||
<p class="mb-2 text-sm font-semibold text-[var(--wire-color-primary)]">@wrnexus/auth</p>
|
||||
<h1 class="m-0 text-4xl font-bold tracking-tight sm:text-6xl">Complete authentication for WRNexusJS</h1>
|
||||
<p class="mt-5 text-lg leading-8 text-[var(--wire-color-muted)]">Password login, verification, recovery, OTP, authenticator apps, recovery codes, passkeys, OAuth linking, devices, sessions, risk, and audit events.</p>
|
||||
<div class="mt-6 flex flex-wrap gap-3"><a href="/sign-up" class="inline-flex h-11 items-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-5 font-semibold text-white">Create account</a><a href="/sign-in" class="inline-flex h-11 items-center rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] px-5 font-semibold">Sign in</a><a href="/otp" class="inline-flex h-11 items-center rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] px-5 font-semibold">OTP login</a><a href="/magic-link" class="inline-flex h-11 items-center rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] px-5 font-semibold">Magic link</a></div>
|
||||
</header>
|
||||
<section class="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each ["Password and magic links", "Email, SMS, TOTP and recovery", "Passkeys and OAuth", "Sessions and trusted devices", "Adaptive CAPTCHA risk", "Security event audit"] as feature}
|
||||
<article class="rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"><span class="icon-[lucide--shield-check] size-5 text-[var(--wire-color-primary)]"></span><h2 class="mb-0 mt-3 text-base font-semibold">{feature}</h2></article>
|
||||
{/each}
|
||||
</section>
|
||||
<nav class="mt-8 flex flex-wrap gap-x-5 gap-y-2 text-sm font-semibold text-[var(--wire-color-primary)]"><a href="/two-factor">Two-factor</a><a href="/authenticator">Authenticator</a><a href="/invitation">Invitation</a><a href="/impersonation">Impersonation</a><a href="/account">Sessions</a></nav>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
page InvitationPage {
|
||||
seo { title = "Accept invitation" }
|
||||
view { <main class="flex min-h-screen items-center justify-center bg-[var(--wire-color-bg)] p-4"><InvitationAccept token="demo-invitation" organization="WRNexus Workspace" inviter="Ajay" /></main> }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
page MagicLinkPage {
|
||||
seo { title = "Magic-link sign-in" }
|
||||
view { <main class="flex min-h-screen items-center justify-center bg-[var(--wire-color-bg)] p-4"><MagicLinkSignIn /></main> }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
page OtpSignInPage {
|
||||
seo { title = "Passwordless OTP sign-in" }
|
||||
view { <main class="flex min-h-screen items-center justify-center bg-[var(--wire-color-bg)] p-4"><OtpSignIn method="email-otp" /></main> }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
page RecoverPage {
|
||||
seo { title = "Recover account" }
|
||||
view { <main class="flex min-h-screen items-center justify-center bg-[var(--wire-color-bg)] p-4"><ForgotPassword /></main> }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
page SignInPage {
|
||||
seo { title = "Sign in" }
|
||||
view {
|
||||
<main class="flex min-h-screen items-center justify-center bg-[var(--wire-color-bg)] p-4">
|
||||
<SignIn action="/api/auth/login" returnTo="/account" showPasskey="false">
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="not-robot"
|
||||
action="auth-login"
|
||||
required="true"
|
||||
resetOnError="false"
|
||||
requiredMessage="Complete the security check below before signing in."
|
||||
/>
|
||||
</SignIn>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
page SignUpPage {
|
||||
seo { title = "Create account" }
|
||||
view {
|
||||
<main class="flex min-h-screen items-center justify-center bg-[var(--wire-color-bg)] p-4">
|
||||
<SignUp redirect="/sign-in" />
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
page TwoFactorPage {
|
||||
seo { title = "Two-factor authentication" }
|
||||
view { <main class="flex min-h-screen items-center justify-center bg-[var(--wire-color-bg)] p-4"><TwoFactorChallenge mfaToken="demo-transaction" /></main> }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// AUTO-GENERATED by `wrnexus dev` - do not edit.
|
||||
// Typed routes support required, optional, and catch-all parameters.
|
||||
|
||||
export interface Routes {
|
||||
"/": Record<string, never>;
|
||||
"/account": Record<string, never>;
|
||||
"/authenticator": Record<string, never>;
|
||||
"/impersonation": Record<string, never>;
|
||||
"/invitation": Record<string, never>;
|
||||
"/magic-link": Record<string, never>;
|
||||
"/otp": Record<string, never>;
|
||||
"/recover": Record<string, never>;
|
||||
"/sign-in": Record<string, never>;
|
||||
"/sign-up": Record<string, never>;
|
||||
"/two-factor": Record<string, never>;
|
||||
}
|
||||
|
||||
export type RoutePath = keyof Routes;
|
||||
type RouteValue = string | readonly string[] | undefined;
|
||||
|
||||
function encodeRouteValue(value: RouteValue, catchAll: boolean): string {
|
||||
if (value === undefined) return "";
|
||||
const values = Array.isArray(value) ? value : catchAll ? String(value).split("/") : [String(value)];
|
||||
return values.map((part) => encodeURIComponent(part)).join("/");
|
||||
}
|
||||
|
||||
export function href<P extends RoutePath>(
|
||||
path: P,
|
||||
...args: keyof Routes[P] extends never
|
||||
? []
|
||||
: Record<string, never> extends Routes[P]
|
||||
? [params?: Routes[P]]
|
||||
: [params: Routes[P]]
|
||||
): string {
|
||||
const params = (args[0] ?? {}) as Record<string, RouteValue>;
|
||||
const output: string[] = [];
|
||||
for (const segment of String(path).split("/").filter(Boolean)) {
|
||||
let name: string | undefined;
|
||||
let optional = false;
|
||||
let catchAll = false;
|
||||
if (segment.startsWith("[[") && segment.endsWith("]]")) {
|
||||
optional = true;
|
||||
name = segment.slice(2, -2);
|
||||
} else if (segment.startsWith("[") && segment.endsWith("]")) {
|
||||
name = segment.slice(1, -1);
|
||||
if (name.endsWith("?")) {
|
||||
optional = true;
|
||||
name = name.slice(0, -1);
|
||||
}
|
||||
}
|
||||
if (!name) {
|
||||
output.push(segment);
|
||||
continue;
|
||||
}
|
||||
if (name.startsWith("...")) {
|
||||
catchAll = true;
|
||||
name = name.slice(3);
|
||||
}
|
||||
const value = params[name];
|
||||
if (value === undefined && optional) continue;
|
||||
if (value === undefined) throw new Error(`WRN-ROUTE-MISSING-PARAM: Missing route parameter '${name}'.`);
|
||||
output.push(encodeRouteValue(value, catchAll));
|
||||
}
|
||||
return "/" + output.filter(Boolean).join("/");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@iconify/tailwind4";
|
||||
@source "../**/*.wrn";
|
||||
@source "../../../packages/auth/components/*.wrn";
|
||||
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--wire-color-bg);
|
||||
color: var(--wire-color-text);
|
||||
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
Reference in New Issue
Block a user