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>( schema: ObjectSchema, input: Record, ): { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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); }, }; }