Extract auth handler runtime

master
dev 5 months ago
parent 413353e034
commit 2e1923fe6a

@ -8,6 +8,7 @@ Estado al cierre:
- `auth` verde: `npx vitest run src/arts/auth src/svrs/auth src/libs/auth` -> 4 archivos, 14 tests.
- Refactor tecnico posterior:
- `svrs/auth/engine-auth.ts` ya delega CSRF en `csrf-flow.ts`, coherente con password/session/recovery/device flows.
- `svrs/auth/handlers.ts` delega helpers HTTP/CSRF/error-safe en `handler-runtime.ts`; conserva solo rutas y delegacion al engine.
- `libs/cach/engine.ts` centraliza eventos de lectura con `emitForContext(...)`.
- `arts/conn/connection.ts` usa `createConnectionIdFactory(...)` desde helpers.
- `arts/conn/connection.ts` delega auth/request/ACK request-reply en `connection-requests.ts`.

@ -0,0 +1,131 @@
import {
AUTH_CONTENT_TYPES,
AUTH_COOKIE_NAMES,
AUTH_ERROR_CODES,
AUTH_HEADER_NAMES,
AUTH_HTTP_STATUS,
AUTH_SAFE_MESSAGES
} from '$libs/auth/consts';
import { parseAuthCookieHeader, serializeAuthSetCookie } from '$libs/auth/cookies';
import { toAuthSafeError } from '$libs/auth/errors';
import type {
AuthCsrfIssueInput,
AuthCurrentInput,
AuthEmailVerificationCompleteInput,
AuthEmailVerificationRequestInput,
AuthPasswordResetCompleteInput,
AuthPasswordResetRequestInput,
AuthServerRequestLike,
AuthSetCookie,
AuthSignInPasswordInput,
AuthSignUpPasswordInput,
AuthTenantId
} from '$libs/auth/types';
import type { AuthErrorCode } from '$libs/auth/types';
export interface AuthRouteRequest {
readonly request: Request;
readonly tenantId: AuthTenantId;
}
export interface AuthHandlerEngine {
current(input: AuthCurrentInput): Promise<unknown>;
issueCsrf(input: AuthCsrfIssueInput): Promise<{
readonly token: string;
readonly cookie: AuthSetCookie;
readonly expiresAt: number;
}>;
verifyCsrf(input: {
readonly tenantId: AuthTenantId;
readonly token?: string | null;
readonly cookie?: string | null;
}): Promise<unknown>;
signUpPassword(input: AuthSignUpPasswordInput): Promise<unknown>;
signInPassword(input: AuthSignInPasswordInput): Promise<unknown>;
signOut(input: {
readonly tenantId: AuthTenantId;
readonly request: AuthServerRequestLike;
}): Promise<unknown>;
signOutGlobal(input: {
readonly tenantId: AuthTenantId;
readonly request: AuthServerRequestLike;
}): Promise<unknown>;
requestEmailVerification(input: AuthEmailVerificationRequestInput): Promise<unknown>;
completeEmailVerification(input: AuthEmailVerificationCompleteInput): Promise<unknown>;
requestPasswordReset(input: AuthPasswordResetRequestInput): Promise<unknown>;
completePasswordReset(input: AuthPasswordResetCompleteInput): Promise<unknown>;
}
export async function verifyAuthRequestCsrf(
engine: AuthHandlerEngine,
input: AuthRouteRequest
): Promise<void> {
const cookieHeader = input.request.headers.get(AUTH_HEADER_NAMES.COOKIE);
const cookies = parseAuthCookieHeader(cookieHeader);
await engine.verifyCsrf({
tenantId: input.tenantId,
token: input.request.headers.get(AUTH_HEADER_NAMES.CSRF),
cookie: cookies[AUTH_COOKIE_NAMES.CSRF]
});
}
export async function protectAuthHandler(run: () => Promise<Response>): Promise<Response> {
try {
return await run();
} catch (error) {
const safe = toAuthSafeError(error);
return authJson({ error: safe }, { status: statusForAuthError(safe.code) });
}
}
export function authRouteNotFound(): Response {
return authJson(
{
error: {
code: AUTH_ERROR_CODES.ROUTE_NOT_FOUND,
messageKey: AUTH_SAFE_MESSAGES.ROUTE_NOT_FOUND
}
},
{ status: AUTH_HTTP_STATUS.NOT_FOUND }
);
}
export function toAuthRequestLike(request: Request): AuthServerRequestLike {
return {
method: request.method,
url: request.url,
headers: request.headers
};
}
export function authJson(
body: unknown,
options: { readonly status?: number; readonly cookies?: readonly AuthSetCookie[] } = {}
): Response {
const headers = new Headers({ [AUTH_HEADER_NAMES.CONTENT_TYPE]: AUTH_CONTENT_TYPES.JSON });
for (const cookie of options.cookies ?? [])
headers.append(AUTH_HEADER_NAMES.SET_COOKIE, serializeAuthSetCookie(cookie));
return new Response(JSON.stringify(body), {
status: options.status ?? AUTH_HTTP_STATUS.OK,
headers
});
}
function statusForAuthError(code: AuthErrorCode): number {
switch (code) {
case AUTH_ERROR_CODES.CSRF_INVALID:
case AUTH_ERROR_CODES.ASSURANCE_REQUIRED:
case AUTH_ERROR_CODES.SESSION_REVOKED:
case AUTH_ERROR_CODES.TOKEN_REUSE_DETECTED:
case AUTH_ERROR_CODES.TENANT_BOUNDARY:
return AUTH_HTTP_STATUS.FORBIDDEN;
case AUTH_ERROR_CODES.SESSION_REQUIRED:
return AUTH_HTTP_STATUS.UNAUTHORIZED;
case AUTH_ERROR_CODES.RATE_LIMITED:
return AUTH_HTTP_STATUS.TOO_MANY_REQUESTS;
case AUTH_ERROR_CODES.ROUTE_NOT_FOUND:
return AUTH_HTTP_STATUS.NOT_FOUND;
default:
return AUTH_HTTP_STATUS.BAD_REQUEST;
}
}

@ -1,34 +1,24 @@
import {
AUTH_CONTENT_TYPES,
AUTH_COOKIE_NAMES,
AUTH_ERROR_CODES,
AUTH_HEADER_NAMES,
AUTH_HTTP_METHODS,
AUTH_HTTP_STATUS,
AUTH_ROUTE_PATHS,
AUTH_SAFE_MESSAGES
AUTH_ROUTE_PATHS
} from '$libs/auth/consts';
import { parseAuthCookieHeader, serializeAuthSetCookie } from '$libs/auth/cookies';
import { toAuthSafeError } from '$libs/auth/errors';
import {
authJson,
authRouteNotFound,
protectAuthHandler,
toAuthRequestLike,
verifyAuthRequestCsrf,
type AuthHandlerEngine,
type AuthRouteRequest
} from './handler-runtime.ts';
import type {
AuthCsrfIssueInput,
AuthCurrentInput,
AuthEmailVerificationCompleteInput,
AuthEmailVerificationRequestInput,
AuthPasswordResetCompleteInput,
AuthPasswordResetRequestInput,
AuthServerRequestLike,
AuthSetCookie,
AuthSignInPasswordInput,
AuthSignUpPasswordInput,
AuthTenantId
AuthSignUpPasswordInput
} from '$libs/auth/types';
import type { AuthErrorCode } from '$libs/auth/types';
export interface AuthRouteRequest {
readonly request: Request;
readonly tenantId: AuthTenantId;
}
export interface AuthRouteHandlers {
readonly current: (input: AuthRouteRequest) => Promise<Response>;
@ -44,47 +34,21 @@ export interface AuthRouteHandlers {
readonly handle: (input: AuthRouteRequest) => Promise<Response>;
}
interface EngineForHandlers {
current(input: AuthCurrentInput): Promise<unknown>;
issueCsrf(input: AuthCsrfIssueInput): Promise<{
readonly token: string;
readonly cookie: AuthSetCookie;
readonly expiresAt: number;
}>;
verifyCsrf(input: {
readonly tenantId: AuthTenantId;
readonly token?: string | null;
readonly cookie?: string | null;
}): Promise<unknown>;
signUpPassword(input: AuthSignUpPasswordInput): Promise<unknown>;
signInPassword(input: AuthSignInPasswordInput): Promise<unknown>;
signOut(input: {
readonly tenantId: AuthTenantId;
readonly request: AuthServerRequestLike;
}): Promise<unknown>;
signOutGlobal(input: {
readonly tenantId: AuthTenantId;
readonly request: AuthServerRequestLike;
}): Promise<unknown>;
requestEmailVerification(input: AuthEmailVerificationRequestInput): Promise<unknown>;
completeEmailVerification(input: AuthEmailVerificationCompleteInput): Promise<unknown>;
requestPasswordReset(input: AuthPasswordResetRequestInput): Promise<unknown>;
completePasswordReset(input: AuthPasswordResetCompleteInput): Promise<unknown>;
}
export type { AuthRouteRequest } from './handler-runtime.ts';
export function createAuthRouteHandlers(engine: EngineForHandlers): AuthRouteHandlers {
export function createAuthRouteHandlers(engine: AuthHandlerEngine): AuthRouteHandlers {
async function current(input: AuthRouteRequest): Promise<Response> {
return protect(async () =>
json(
await engine.current({ tenantId: input.tenantId, request: toRequestLike(input.request) })
return protectAuthHandler(async () =>
authJson(
await engine.current({ tenantId: input.tenantId, request: toAuthRequestLike(input.request) })
)
);
}
async function csrf(input: AuthRouteRequest): Promise<Response> {
return protect(async () => {
return protectAuthHandler(async () => {
const result = await engine.issueCsrf({ tenantId: input.tenantId });
return json(
return authJson(
{ token: result.token, expiresAt: result.expiresAt },
{ cookies: [result.cookie] }
);
@ -92,77 +56,79 @@ export function createAuthRouteHandlers(engine: EngineForHandlers): AuthRouteHan
}
async function signUpPassword(input: AuthRouteRequest): Promise<Response> {
return protect(async () => {
await verifyRequestCsrf(engine, input);
return protectAuthHandler(async () => {
await verifyAuthRequestCsrf(engine, input);
const body = (await input.request.json()) as Omit<AuthSignUpPasswordInput, 'tenantId'>;
return json(await engine.signUpPassword({ ...body, tenantId: input.tenantId }));
return authJson(await engine.signUpPassword({ ...body, tenantId: input.tenantId }));
});
}
async function signInPassword(input: AuthRouteRequest): Promise<Response> {
return protect(async () => {
await verifyRequestCsrf(engine, input);
return protectAuthHandler(async () => {
await verifyAuthRequestCsrf(engine, input);
const body = (await input.request.json()) as Omit<AuthSignInPasswordInput, 'tenantId'>;
return json(await engine.signInPassword({ ...body, tenantId: input.tenantId }));
return authJson(await engine.signInPassword({ ...body, tenantId: input.tenantId }));
});
}
async function signOut(input: AuthRouteRequest): Promise<Response> {
return protect(async () => {
await verifyRequestCsrf(engine, input);
return json(
await engine.signOut({ tenantId: input.tenantId, request: toRequestLike(input.request) })
return protectAuthHandler(async () => {
await verifyAuthRequestCsrf(engine, input);
return authJson(
await engine.signOut({ tenantId: input.tenantId, request: toAuthRequestLike(input.request) })
);
});
}
async function signOutGlobal(input: AuthRouteRequest): Promise<Response> {
return protect(async () => {
await verifyRequestCsrf(engine, input);
return json(
return protectAuthHandler(async () => {
await verifyAuthRequestCsrf(engine, input);
return authJson(
await engine.signOutGlobal({
tenantId: input.tenantId,
request: toRequestLike(input.request)
request: toAuthRequestLike(input.request)
})
);
});
}
async function requestEmailVerification(input: AuthRouteRequest): Promise<Response> {
return protect(async () => {
await verifyRequestCsrf(engine, input);
return protectAuthHandler(async () => {
await verifyAuthRequestCsrf(engine, input);
const body = (await input.request.json()) as Omit<
AuthEmailVerificationRequestInput,
'tenantId'
>;
return json(await engine.requestEmailVerification({ ...body, tenantId: input.tenantId }));
return authJson(await engine.requestEmailVerification({ ...body, tenantId: input.tenantId }));
});
}
async function completeEmailVerification(input: AuthRouteRequest): Promise<Response> {
return protect(async () => {
await verifyRequestCsrf(engine, input);
return protectAuthHandler(async () => {
await verifyAuthRequestCsrf(engine, input);
const body = (await input.request.json()) as Omit<
AuthEmailVerificationCompleteInput,
'tenantId'
>;
return json(await engine.completeEmailVerification({ ...body, tenantId: input.tenantId }));
return authJson(
await engine.completeEmailVerification({ ...body, tenantId: input.tenantId })
);
});
}
async function requestPasswordReset(input: AuthRouteRequest): Promise<Response> {
return protect(async () => {
await verifyRequestCsrf(engine, input);
return protectAuthHandler(async () => {
await verifyAuthRequestCsrf(engine, input);
const body = (await input.request.json()) as Omit<AuthPasswordResetRequestInput, 'tenantId'>;
return json(await engine.requestPasswordReset({ ...body, tenantId: input.tenantId }));
return authJson(await engine.requestPasswordReset({ ...body, tenantId: input.tenantId }));
});
}
async function completePasswordReset(input: AuthRouteRequest): Promise<Response> {
return protect(async () => {
await verifyRequestCsrf(engine, input);
return protectAuthHandler(async () => {
await verifyAuthRequestCsrf(engine, input);
const body = (await input.request.json()) as Omit<AuthPasswordResetCompleteInput, 'tenantId'>;
return json(await engine.completePasswordReset({ ...body, tenantId: input.tenantId }));
return authJson(await engine.completePasswordReset({ ...body, tenantId: input.tenantId }));
});
}
@ -188,15 +154,7 @@ export function createAuthRouteHandlers(engine: EngineForHandlers): AuthRouteHan
return requestPasswordReset(input);
if (pathname === AUTH_ROUTE_PATHS.PASSWORD_RESET_COMPLETE && method === AUTH_HTTP_METHODS.POST)
return completePasswordReset(input);
return json(
{
error: {
code: AUTH_ERROR_CODES.ROUTE_NOT_FOUND,
messageKey: AUTH_SAFE_MESSAGES.ROUTE_NOT_FOUND
}
},
{ status: AUTH_HTTP_STATUS.NOT_FOUND }
);
return authRouteNotFound();
}
return {
@ -213,65 +171,3 @@ export function createAuthRouteHandlers(engine: EngineForHandlers): AuthRouteHan
handle
};
}
async function verifyRequestCsrf(
engine: EngineForHandlers,
input: AuthRouteRequest
): Promise<void> {
const cookieHeader = input.request.headers.get(AUTH_HEADER_NAMES.COOKIE);
const cookies = parseAuthCookieHeader(cookieHeader);
await engine.verifyCsrf({
tenantId: input.tenantId,
token: input.request.headers.get(AUTH_HEADER_NAMES.CSRF),
cookie: cookies[AUTH_COOKIE_NAMES.CSRF]
});
}
async function protect(run: () => Promise<Response>): Promise<Response> {
try {
return await run();
} catch (error) {
const safe = toAuthSafeError(error);
return json({ error: safe }, { status: statusForAuthError(safe.code) });
}
}
function statusForAuthError(code: AuthErrorCode): number {
switch (code) {
case AUTH_ERROR_CODES.CSRF_INVALID:
case AUTH_ERROR_CODES.ASSURANCE_REQUIRED:
case AUTH_ERROR_CODES.SESSION_REVOKED:
case AUTH_ERROR_CODES.TOKEN_REUSE_DETECTED:
case AUTH_ERROR_CODES.TENANT_BOUNDARY:
return AUTH_HTTP_STATUS.FORBIDDEN;
case AUTH_ERROR_CODES.SESSION_REQUIRED:
return AUTH_HTTP_STATUS.UNAUTHORIZED;
case AUTH_ERROR_CODES.RATE_LIMITED:
return AUTH_HTTP_STATUS.TOO_MANY_REQUESTS;
case AUTH_ERROR_CODES.ROUTE_NOT_FOUND:
return AUTH_HTTP_STATUS.NOT_FOUND;
default:
return AUTH_HTTP_STATUS.BAD_REQUEST;
}
}
function toRequestLike(request: Request): AuthServerRequestLike {
return {
method: request.method,
url: request.url,
headers: request.headers
};
}
function json(
body: unknown,
options: { readonly status?: number; readonly cookies?: readonly AuthSetCookie[] } = {}
): Response {
const headers = new Headers({ [AUTH_HEADER_NAMES.CONTENT_TYPE]: AUTH_CONTENT_TYPES.JSON });
for (const cookie of options.cookies ?? [])
headers.append(AUTH_HEADER_NAMES.SET_COOKIE, serializeAuthSetCookie(cookie));
return new Response(JSON.stringify(body), {
status: options.status ?? AUTH_HTTP_STATUS.OK,
headers
});
}

Loading…
Cancel
Save

Powered by TurnKey Linux.