From 961623029015ea8f81274901b96316befaa588a8 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 25 Aug 2026 16:58:18 +0000 Subject: [PATCH] fix(auth): route helpers through selected providers Co-authored-by: Upd4ting --- package.json | 4 +- pnpm-lock.yaml | 16 +-- src/index.ts | 199 +++++++++++++++++++++++------ src/tests/interface-facade.test.ts | 146 +++++++++++++++++++++ 4 files changed, 317 insertions(+), 48 deletions(-) create mode 100644 src/tests/interface-facade.test.ts diff --git a/package.json b/package.json index 20dfa5a..259f1a2 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "devDependencies": { "@antelopejs/interface-api": "^0.0.5", - "@antelopejs/interface-core": "^0.0.5", + "@antelopejs/interface-core": ">=0.0.13 <1.0.0", "@biomejs/biome": "2.3.2", "@types/chai": "^4.3.20", "@types/mocha": "^10.0.10", @@ -54,7 +54,7 @@ }, "peerDependencies": { "@antelopejs/interface-api": ">=0.0.3 <1.0.0", - "@antelopejs/interface-core": ">=0.0.3 <1.0.0" + "@antelopejs/interface-core": ">=0.0.13 <1.0.0" }, "publishConfig": { "access": "public" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d893660..d95b2e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,10 @@ importers: devDependencies: '@antelopejs/interface-api': specifier: ^0.0.5 - version: 0.0.5(@antelopejs/interface-core@0.0.5) + version: 0.0.5(@antelopejs/interface-core@0.0.13) '@antelopejs/interface-core': - specifier: ^0.0.5 - version: 0.0.5 + specifier: '>=0.0.13 <1.0.0' + version: 0.0.13 '@biomejs/biome': specifier: 2.3.2 version: 2.3.2 @@ -55,8 +55,8 @@ packages: peerDependencies: '@antelopejs/interface-core': '>=0.0.3 <1.0.0' - '@antelopejs/interface-core@0.0.5': - resolution: {integrity: sha512-4H6hpFfiK2+DE8vL2mEtPbq1WNch9lt40QNr9KjBrOAtnuq/zMfIlzhbEsda39m+FVkMWXfUYoVolRogVKCLIQ==} + '@antelopejs/interface-core@0.0.13': + resolution: {integrity: sha512-/QdBq0jcEqQtjkzdZkmmkjn76T8RqiM9ZTFlh8+O6N3vxGW+UxJAGF9ZEJzAhOgtzuLDtnhSLrIYuEZmYQJH8g==} '@biomejs/biome@2.3.2': resolution: {integrity: sha512-8e9tzamuDycx7fdrcJ/F/GDZ8SYukc5ud6tDicjjFqURKYFSWMl0H0iXNXZEGmcmNUmABgGuHThPykcM41INgg==} @@ -1371,11 +1371,11 @@ packages: snapshots: - '@antelopejs/interface-api@0.0.5(@antelopejs/interface-core@0.0.5)': + '@antelopejs/interface-api@0.0.5(@antelopejs/interface-core@0.0.13)': dependencies: - '@antelopejs/interface-core': 0.0.5 + '@antelopejs/interface-core': 0.0.13 - '@antelopejs/interface-core@0.0.5': + '@antelopejs/interface-core@0.0.13': dependencies: reflect-metadata: 0.2.2 diff --git a/src/index.ts b/src/index.ts index 2560e90..9b26e84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { SetParameterProvider } from "@antelopejs/interface-api"; import { InterfaceFunction } from "@antelopejs/interface-core"; import { MakeParameterAndPropertyAndClassDecorator } from "@antelopejs/interface-core/decorators"; +import type { InterfaceFacadeScope } from "@antelopejs/interface-core/facades"; const AuthHeaderName = "x-antelopejs-auth"; const AuthCookieName = "ANTELOPEJS_AUTH"; @@ -186,6 +187,15 @@ interface ClassDecoratorTarget { prototype: object; } +type CheckAuthenticationHandler = ( + req: IncomingMessage, + res: ServerResponse, + source?: AuthSource, + authenticator?: AuthVerifier, + authenticatorOptions?: VerifyOptions, + validator?: AuthValidator, +) => Promise; + type DecoratorTarget = object; type DecoratorKey = string | number | symbol | undefined; type DecoratorIndex = number | undefined; @@ -246,12 +256,12 @@ function createSetCookieHeader( function resolveAuthDecoratorCallbacks( callbacks: AuthDecoratorCallbacks, + defaultSource: AuthSource, + defaultAuthenticator: AuthVerifier, ): ResolvedAuthDecoratorCallbacks { return { - source: callbacks.source ?? internal.defaultSource, - authenticator: - callbacks.authenticator ?? - (internal.defaultAuthenticator as AuthVerifier), + source: callbacks.source ?? defaultSource, + authenticator: callbacks.authenticator ?? defaultAuthenticator, authenticatorOptions: callbacks.authenticatorOptions ?? {}, validator: callbacks.validator, }; @@ -259,10 +269,11 @@ function resolveAuthDecoratorCallbacks( function createAuthParameterProvider( callbacks: ResolvedAuthDecoratorCallbacks, + checkAuthentication: CheckAuthenticationHandler, validator?: AuthValidator, ): AuthParameterProvider { return (context: AuthenticationContext) => - internal.CheckAuthentication( + checkAuthentication( context.rawRequest, context.rawResponse, callbacks.source, @@ -284,6 +295,25 @@ function registerClassParameterProvider( ); } +async function performAuthentication( + req: IncomingMessage, + res: ServerResponse, + source: AuthSource, + authenticator: AuthVerifier, + authenticatorOptions: VerifyOptions, + validator?: AuthValidator, +): Promise { + const verifiedData = await authenticator( + source(req, res), + authenticatorOptions, + ); + if (!validator) { + return verifiedData; + } + + return validator(verifiedData); +} + /** * @internal */ @@ -321,15 +351,14 @@ export namespace internal { authenticatorOptions: VerifyOptions = {}, validator?: AuthValidator, ): Promise { - const verifiedData = await authenticator( - source(req, res), + return performAuthentication( + req, + res, + source, + authenticator, authenticatorOptions, + validator, ); - if (!validator) { - return verifiedData; - } - - return validator(verifiedData); } } @@ -398,12 +427,61 @@ export function SignServerResponse( signOptions?: SignOptions, cookieOptions?: CookieOptions, ): Promise { - return SignRaw(data, signOptions).then((token) => { + return signServerResponse(SignRaw, res, data, signOptions, cookieOptions); +} + +function signServerResponse( + signRaw: typeof SignRaw, + res: ServerResponse, + data: AuthPayload, + signOptions?: SignOptions, + cookieOptions?: CookieOptions, +): Promise { + return signRaw(data, signOptions).then((token) => { res.setHeader("Set-Cookie", createSetCookieHeader(token, cookieOptions)); return res; }); } +function createAuthDecorator( + callbacks: AuthDecoratorCallbacks, + defaultSource: AuthSource, + defaultAuthenticator: AuthVerifier, + checkAuthentication: CheckAuthenticationHandler, +) { + const resolvedCallbacks = resolveAuthDecoratorCallbacks( + callbacks, + defaultSource, + defaultAuthenticator, + ); + + return MakeParameterAndPropertyAndClassDecorator( + ( + target: DecoratorTarget, + key: DecoratorKey, + index: DecoratorIndex, + validator?: AuthValidator, + ) => { + const authValidator = validator ?? resolvedCallbacks.validator; + const provider = createAuthParameterProvider( + resolvedCallbacks, + checkAuthentication, + authValidator, + ); + + if (key === undefined) { + registerClassParameterProvider( + target as ClassDecoratorTarget, + provider, + ); + return; + } + + SetParameterProvider(target, key, index, provider); + }, + ); +} + /** * Creates a Parameter Provider using the specified source and signature verification callbacks. * @@ -444,31 +522,11 @@ export function SignServerResponse( export function CreateAuthDecorator( callbacks: AuthDecoratorCallbacks, ) { - const resolvedCallbacks = resolveAuthDecoratorCallbacks(callbacks); - - return MakeParameterAndPropertyAndClassDecorator( - ( - target: DecoratorTarget, - key: DecoratorKey, - index: DecoratorIndex, - validator?: AuthValidator, - ) => { - const authValidator = validator ?? resolvedCallbacks.validator; - const provider = createAuthParameterProvider( - resolvedCallbacks, - authValidator, - ); - - if (key === undefined) { - registerClassParameterProvider( - target as ClassDecoratorTarget, - provider, - ); - return; - } - - SetParameterProvider(target, key, index, provider); - }, + return createAuthDecorator( + callbacks, + internal.defaultSource, + internal.defaultAuthenticator as AuthVerifier, + internal.CheckAuthentication, ); } @@ -503,3 +561,68 @@ export function CreateAuthDecorator( * @see {@link CreateAuthDecorator} */ export const Authentication = CreateAuthDecorator({}); + +/** @internal */ +export function BuildInterfaceFacade( + _scope: InterfaceFacadeScope, + facade: Record, +) { + const boundInternal = facade.internal as typeof internal; + const Verify = boundInternal.Verify; + const Sign = boundInternal.Sign; + const validateRaw = ( + token?: string, + options?: VerifyOptions, + ): Promise => Verify(token, options) as Promise; + const signRaw = (data: AuthPayload, options?: SignOptions) => + Sign(data, options); + const defaultAuthenticator: AuthVerifier = validateRaw; + const checkAuthentication: CheckAuthenticationHandler = < + T = unknown, + R = unknown, + >( + req: IncomingMessage, + res: ServerResponse, + source = internal.defaultSource, + authenticator: AuthVerifier = defaultAuthenticator as AuthVerifier, + authenticatorOptions = {}, + validator?: AuthValidator, + ) => + performAuthentication( + req, + res, + source, + authenticator, + authenticatorOptions, + validator, + ); + const createDecorator = ( + callbacks: AuthDecoratorCallbacks, + ) => + createAuthDecorator( + callbacks, + internal.defaultSource, + defaultAuthenticator as AuthVerifier, + checkAuthentication, + ); + + return { + internal: { + ...internal, + Verify, + Sign, + defaultAuthenticator, + CheckAuthentication: checkAuthentication, + }, + ValidateRaw: validateRaw, + SignRaw: signRaw, + SignServerResponse: ( + res: ServerResponse, + data: AuthPayload, + signOptions?: SignOptions, + cookieOptions?: CookieOptions, + ) => signServerResponse(signRaw, res, data, signOptions, cookieOptions), + CreateAuthDecorator: createDecorator, + Authentication: createDecorator({}), + }; +} diff --git a/src/tests/interface-facade.test.ts b/src/tests/interface-facade.test.ts new file mode 100644 index 0000000..42ab421 --- /dev/null +++ b/src/tests/interface-facade.test.ts @@ -0,0 +1,146 @@ +import assert from "node:assert"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import * as Api from "@antelopejs/interface-api"; +import { + AmbiguousProviderError, + GetInterfaceProxyIdentity, +} from "@antelopejs/interface-core"; +import { CreateInterfaceFacade } from "@antelopejs/interface-core/facades"; +import { + type ModuleExecutionContext, + RunWithModuleContext, +} from "@antelopejs/interface-core/modules"; +import * as Auth from "../index"; + +function providerContext(provider: string): ModuleExecutionContext { + return { + module: provider, + owner: `${provider}#1`, + provider, + }; +} + +function consumerContext( + owner: string, + authProvider: string, + apiProvider: string, +): ModuleExecutionContext { + const verifyIdentity = GetInterfaceProxyIdentity(Auth.internal.Verify.proxy); + const routesIdentity = GetInterfaceProxyIdentity(Api.routesProxy); + assert(verifyIdentity); + assert(routesIdentity); + return { + module: "auth-consumer", + owner, + providerRoutes: { + [verifyIdentity]: authProvider, + [routesIdentity]: apiProvider, + }, + }; +} + +function requestWithToken(token: string): IncomingMessage { + return { + headers: { "x-antelopejs-auth": token }, + } as unknown as IncomingMessage; +} + +describe("Auth interface facade", () => { + it("routes default Authentication through the consumer provider after await", async () => { + const authLeaseA = RunWithModuleContext( + providerContext("auth-provider-a"), + () => + Auth.internal.Verify.proxy.onCall(async (token) => `a:${token}`, true), + ); + const authLeaseB = RunWithModuleContext( + providerContext("auth-provider-b"), + () => + Auth.internal.Verify.proxy.onCall(async (token) => `b:${token}`, true), + ); + const registered: Api.RouteHandler[] = []; + const apiLease = RunWithModuleContext(providerContext("api-provider"), () => + Api.routesProxy.onHandlers( + (_id, handler) => registered.push(handler), + () => {}, + true, + ), + ); + const contextA = consumerContext( + "auth-consumer#old", + "auth-provider-a", + "api-provider", + ); + const contextB = consumerContext( + "auth-consumer#new", + "auth-provider-b", + "api-provider", + ); + const authA = CreateInterfaceFacade(Auth, contextA); + const authB = CreateInterfaceFacade(Auth, contextB); + const apiA = CreateInterfaceFacade(Api, contextA); + const apiB = CreateInterfaceFacade(Api, contextB); + class ControllerA { + handler(authenticated: unknown) { + return authenticated; + } + } + class ControllerB { + handler(authenticated: unknown) { + return authenticated; + } + } + const targetA = ControllerA.prototype; + const targetB = ControllerB.prototype; + const descriptorA = Object.getOwnPropertyDescriptor(targetA, "handler"); + const descriptorB = Object.getOwnPropertyDescriptor(targetB, "handler"); + assert(descriptorA); + assert(descriptorB); + + try { + const ambiguous = await Auth.ValidateRaw("token").then( + () => undefined, + (error: unknown) => error, + ); + assert(ambiguous instanceof AmbiguousProviderError); + + authA.Authentication()(targetA, "handler", 0); + authB.Authentication()(targetB, "handler", 0); + apiA.Get("/auth/a")(targetA, "handler", descriptorA); + apiB.Get("/auth/b")(targetB, "handler", descriptorB); + + assert.equal(registered.length, 2); + assert.strictEqual(registered[0].callback, targetA.handler); + assert.strictEqual(registered[1].callback, targetB.handler); + const providerA = registered[0].parameters[0]?.provider; + const providerB = registered[1].parameters[0]?.provider; + assert(providerA); + assert(providerB); + + await Promise.resolve(); + + const response = {} as ServerResponse; + assert.equal( + await providerA({ + rawRequest: requestWithToken("token-a"), + rawResponse: response, + } as Api.RequestContext), + "a:token-a", + ); + assert.equal( + await providerB({ + rawRequest: requestWithToken("token-b"), + rawResponse: response, + } as Api.RequestContext), + "b:token-b", + ); + assert.strictEqual(apiA.HTTPResult, Api.HTTPResult); + assert.strictEqual(apiB.HTTPResult, Api.HTTPResult); + } finally { + Api.routesProxy.unregisterOwner(contextA.owner as string); + Api.routesProxy.unregisterOwner(contextB.owner as string); + Api.routesProxy.detach(apiLease); + Auth.internal.Verify.proxy.detach(authLeaseA); + Auth.internal.Verify.proxy.detach(authLeaseB); + } + }); +});