diff --git a/src/main/ac-auth.ts b/src/main/ac-auth.ts index 853b2c1..5dea778 100644 --- a/src/main/ac-auth.ts +++ b/src/main/ac-auth.ts @@ -1,4 +1,6 @@ -import { ClientError } from './exception.js'; +import { AccessDeniedError, AuthenticationRequiredError } from '@nodescript/errors'; + +import { AuthContext } from './services/auth-context.js'; export interface AcJwtContext { organisation_id?: string; @@ -45,31 +47,36 @@ export interface AcJobAccessToken { clientName: string; } -export class AcAuth { +export class AcAuth extends AuthContext { + + actor: AcActor | null; - actor: AcActor; + constructor(readonly jwtContext: AcJwtContext | null) { + super(); + this.actor = jwtContext ? this.parseActor(jwtContext) : null; + } - constructor(jwtContext: AcJwtContext) { - this.actor = this.parseActor(jwtContext); + isAuthenticated() { + return this.actor != null; } getOrganisationId(): string | null { - return this.actor.organisationId ?? null; + return this.actor?.organisationId ?? null; } requireOrganisationId(): string { const organisationId = this.getOrganisationId(); if (!organisationId) { - throw new AccessForbidden('organisationId is required'); + throw new AccessDeniedError('organisationId is required'); } return organisationId; } getClientId(): string | null { - if (this.actor.type === 'Client') { + if (this.actor?.type === 'Client') { return this.actor.id; } - if (this.actor.type === 'ServiceAccount' && this.actor.clientId) { + if (this.actor?.type === 'ServiceAccount' && this.actor?.clientId) { return this.actor.clientId; } return null; @@ -78,7 +85,7 @@ export class AcAuth { requireClientId(): string { const clientId = this.getClientId(); if (!clientId) { - throw new AccessForbidden('clientId is required'); + throw new AccessDeniedError('clientId is required'); } return clientId; } @@ -92,8 +99,7 @@ export class AcAuth { this.parseClient(jwtContext) ?? this.parseUser(jwtContext); if (actor == null) { - // TODO find what AcAuthProvider throws when isAuthenticated check is not met - throw new InvalidJwtTokenError('Could not parse actor from JWT payload'); + throw new AuthenticationRequiredError('Could not parse actor from JWT payload'); } return actor; } @@ -157,15 +163,3 @@ export class AcAuth { } } - -export class AccessForbidden extends ClientError { - - override status = 403; - -} - -export class InvalidJwtTokenError extends ClientError { - - override status = 401; - -} diff --git a/src/main/exception.ts b/src/main/exception.ts deleted file mode 100644 index 06bd25c..0000000 --- a/src/main/exception.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Utility class for creating error subclasses with more specific name and details. - * - * Use this class when you need to include more debugging details in logs, - * but still prefer to not expose them via HTTP response. - * - * If you need to communicate additional details to clients, use `ClientError` instead. - */ -export class Exception extends Error { - - override name = this.constructor.name; - status = 500; - details: any = {}; - -} - -/** - * Standard error middleware presents instances of ClientError class - * as { object: 'error', name, message, details }, with appropriate http status. - * All other errors are presented as a generic 'ServerError'. - * - * Use this class to create more specific error classes. - * Class name should be interpreted as error code. - */ -export class ClientError extends Exception { - - override status = 400; - -} - -export class ServerError extends Exception { - - override status = 500; - override message = 'The request cannot be processed'; - -} diff --git a/src/main/http.ts b/src/main/http.ts index 564d870..b831592 100644 --- a/src/main/http.ts +++ b/src/main/http.ts @@ -1,4 +1,5 @@ import cors from '@koa/cors'; +import { ClientError } from '@nodescript/errors'; import { LogData, Logger, LogLevel } from '@nodescript/logger'; import http from 'http'; import https from 'https'; @@ -12,10 +13,9 @@ import { dep, Mesh } from 'mesh-ioc'; import stoppable, { StoppableServer } from 'stoppable'; import { constants } from 'zlib'; -import { ClientError } from './exception.js'; import { standardMiddleware } from './middleware.js'; import { Router } from './router.js'; -import { AuthContext, AuthProvider } from './services/index.js'; +import { AuthProvider } from './services/index.js'; import { findMeshInstances } from './util.js'; interface MiddlewareSpec { @@ -185,8 +185,7 @@ export class HttpServer extends Koa { return async (ctx: Koa.Context, next: Koa.Next) => { const mesh: Mesh = ctx.mesh; const provider = mesh.resolve(AuthProvider); - const authContext = await provider.provide(ctx.headers); - mesh.constant(AuthContext, authContext); + await provider.provide(ctx, mesh); return next(); }; } diff --git a/src/main/index.ts b/src/main/index.ts index 68605f3..c67e8f8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,7 +3,6 @@ import * as util from './util.js'; export * from './ac-auth.js'; export * from './application.js'; export * from './doc.js'; -export * from './exception.js'; export * from './http.js'; export * from './logger.js'; export * from './metrics/index.js'; @@ -13,6 +12,7 @@ export * from './schema.js'; export * from './services/index.js'; export * from './trial.js'; export * from './util.js'; +export * from '@nodescript/errors'; export * from '@nodescript/logger'; export * from 'mesh-config'; diff --git a/src/main/jwks.ts b/src/main/jwks.ts index 1a57e61..aab8891 100644 --- a/src/main/jwks.ts +++ b/src/main/jwks.ts @@ -1,7 +1,7 @@ +import { BaseError } from '@nodescript/errors'; import { Request } from '@ubio/request'; import Ajv from 'ajv'; -import { ClientError, Exception } from './exception.js'; import { ajvErrorToMessage } from './util.js'; const ajv = new Ajv.default({ @@ -40,7 +40,7 @@ const jwksSchema = { additionalProperties: true }; -const validateFunction = ajv.compile(jwksSchema); +const validateFunction = ajv.compile(jwksSchema); export class JwksClient { @@ -95,9 +95,9 @@ export class JwksClient { this._cache = null; } - protected validateResponse(res: Record): SigningKeySets { + protected validateResponse(res: any): SigningKeySets { if (validateFunction(res) === true) { - return res as SigningKeySets; + return res; } const errors = validateFunction.errors || []; const messages = errors.map(e => ajvErrorToMessage(e)); @@ -127,20 +127,22 @@ export interface SigningKey { k: string; } -export class SigningKeyNotFoundError extends Exception { +export class SigningKeyNotFoundError extends BaseError { override message = 'Expected signing key not found in JWKS response'; } -export class JwksValidationError extends ClientError { +export class JwksValidationError extends BaseError { override message = 'JWKS validation failed'; + + details: Record; + constructor(messages: string[]) { super(); - this.details = { - messages - }; + this.message = `JWKS validation failed: ${messages.join(', ')}`; + this.details = { messages }; } } diff --git a/src/main/middleware.ts b/src/main/middleware.ts index 6696217..a2ddfd9 100644 --- a/src/main/middleware.ts +++ b/src/main/middleware.ts @@ -1,9 +1,8 @@ +import { ServerError } from '@nodescript/errors'; import { StructuredLogHttpRequest } from '@nodescript/logger'; import { Context } from 'koa'; import { v4 as uuid } from 'uuid'; -import { ServerError } from './exception.js'; - export async function standardMiddleware(ctx: Context, next: () => Promise) { let error: any = undefined; const startedAt = Date.now(); diff --git a/src/main/router.ts b/src/main/router.ts index 6edeff8..138603c 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -1,3 +1,4 @@ +import { ClientError, InitializationError } from '@nodescript/errors'; import { Logger } from '@nodescript/logger'; import { matchTokens, parsePath, PathToken } from '@nodescript/pathmatcher'; import Ajv, { ValidateFunction as AjvValidateFunction } from 'ajv'; @@ -6,7 +7,6 @@ import * as koa from 'koa'; import { config } from 'mesh-config'; import { dep } from 'mesh-ioc'; -import { ClientError, Exception } from './exception.js'; import { GlobalMetrics } from './metrics/global.js'; import { ajvErrorToMessage, AnyConstructor, Constructor, deepClone } from './util.js'; @@ -77,7 +77,7 @@ function routeDecorator(method: string, spec: RouteSpec, role = RouteRole.ENDPOI const bodyParams = params.filter(_ => _.source === 'body'); if (spec.requestBodySchema) { if (bodyParams.length > 0) { - throw new Exception( + throw new InitializationError( `${method} ${path}: BodyParams are only supported if requestBodySchema is not specified`); } } @@ -274,7 +274,7 @@ function validateRouteDefinition(ep: RouteDefinition) { const paramNamesSet = new Set(); for (const param of ep.params) { if (paramNamesSet.has(param.name)) { - throw new Exception( + throw new InitializationError( `${ep.method} ${ep.path}: Parameter ${param.name} is declared more than once` ); } @@ -420,6 +420,8 @@ export class RequestParametersValidationError extends ClientError { override status = 400; + details: Record; + constructor(messages: string[]) { super(`Invalid request parameters:\n${messages.map(_ => ` - ${_}`).join('\n')}`); this.details = { messages }; @@ -431,6 +433,8 @@ export class ResponseValidationError extends ClientError { override status = 500; + details: Record; + constructor(messages: string[]) { super(`Response body is not valid:\n${messages.map(_ => ` - ${_}`).join('\n')}`); this.details = { messages }; diff --git a/src/main/schema.ts b/src/main/schema.ts index e2d479e..098ec6e 100644 --- a/src/main/schema.ts +++ b/src/main/schema.ts @@ -1,7 +1,7 @@ +import { ClientError } from '@nodescript/errors'; import Ajv, { ErrorObject, Options, ValidateFunction } from 'ajv'; import addFormats from 'ajv-formats'; -import { ClientError } from './exception.js'; import { JsonSchema } from './schema-types.js'; import { ajvErrorToMessage } from './util.js'; @@ -98,6 +98,9 @@ export class Schema { export class ValidationError extends ClientError { override status = 400; + + details: Record; + constructor(messages: string[]) { super(`Validation failed:\n${messages.map(_ => ` - ${_}`).join('\n')}`); this.details = { diff --git a/src/main/services/ac-auth-provider.ts b/src/main/services/ac-auth-provider.ts index 7ec8ab3..8b96f2f 100644 --- a/src/main/services/ac-auth-provider.ts +++ b/src/main/services/ac-auth-provider.ts @@ -1,12 +1,13 @@ +import { AuthenticationRequiredError } from '@nodescript/errors'; import { Logger } from '@nodescript/logger'; import { Request } from '@ubio/request'; +import Koa from 'koa'; import { config } from 'mesh-config'; import { dep } from 'mesh-ioc'; import { AcAuth } from '../ac-auth.js'; import { getSingleValue } from '../util.js'; -import { AuthContext, AuthenticationError } from './auth-context.js'; -import { AuthHeaders, AuthProvider } from './auth-provider.js'; +import { AuthProvider } from './auth-provider.js'; import { JwtService } from './jwt.js'; export class AcAuthProvider extends AuthProvider { @@ -30,44 +31,45 @@ export class AcAuthProvider extends AuthProvider { }); } - async provide(headers: AuthHeaders) { - const token = await this.getToken(headers); + authContextClass = AcAuth; + + async createAuthContext(ctx: Koa.Context) { + const token = await this.getToken(ctx); if (token) { - const acAuth = await this.createAuthFromToken(headers, token); - return new AuthContext(acAuth); + const organisationId = ctx.headers['x-ubio-organisation-id'] as string | undefined; + return await this.createAuthFromToken(organisationId, token); } - return new AuthContext(null); + return new AcAuth(null); } - protected async createAuthFromToken(headers: AuthHeaders, token: string): Promise { - const organisationIdHeader = headers['x-ubio-organisation-id'] as string | undefined; + protected async createAuthFromToken(organisationId: string | undefined, token: string): Promise { try { const payload = await this.jwt.decodeAndVerify(token); const data = { - organisation_id: organisationIdHeader, + organisation_id: organisationId, ...payload.context }; return new AcAuth(data); } catch (error) { this.logger.warn(`Authentication from token failed`, { error }); - throw new AuthenticationError(); + throw new AuthenticationRequiredError(); } } - protected async getToken(headers: AuthHeaders) { + protected async getToken(ctx: Koa.Context) { const authHeaderName = this.AC_AUTH_HEADER_NAME; - const upstreamAuth = getSingleValue(headers[authHeaderName]); + const upstreamAuth = getSingleValue(ctx.headers[authHeaderName]); if (upstreamAuth) { const [prefix, token] = upstreamAuth.split(' '); if (prefix !== 'Bearer' || !token) { this.logger.warn(`Incorrect authorization header`, { details: { prefix, token } }); - throw new AuthenticationError('Incorrect authorization header'); + throw new AuthenticationRequiredError('Incorrect authorization header'); } return token; } - const authorization = getSingleValue(headers['authorization']); + const authorization = getSingleValue(ctx.headers['authorization']); if (authorization) { return await this.getTokenFromAuthMiddleware(authorization); } @@ -91,7 +93,7 @@ export class AcAuthProvider extends AuthProvider { return token; } catch (error: any) { this.logger.warn('AuthMiddleware authentication failed', { ...error }); - throw new AuthenticationError(); + throw new AuthenticationRequiredError(); } } return cached.token; @@ -108,11 +110,3 @@ export class AcAuthProvider extends AuthProvider { } } - -export class BypassAcAuthProvider extends AuthProvider { - - async provide() { - return new AuthContext(null); - } - -} diff --git a/src/main/services/auth-context.ts b/src/main/services/auth-context.ts index 8cd64e7..4e44b39 100644 --- a/src/main/services/auth-context.ts +++ b/src/main/services/auth-context.ts @@ -1,32 +1,13 @@ -import { ClientError } from '@nodescript/errors'; +import { AuthenticationRequiredError } from '@nodescript/errors'; -export class AuthContext { +export abstract class AuthContext { - constructor(private authToken: T) {} - - isAuthenticated() { - return this.authToken != null; - } + abstract isAuthenticated(): boolean; checkAuthenticated(): void { if (!this.isAuthenticated()) { - throw new AuthenticationError(); + throw new AuthenticationRequiredError(); } } - getAuthToken(): T { - return this.authToken; - } - - setAuthToken(authToken: T): void { - this.authToken = authToken; - } - -} - -export class AuthenticationError extends ClientError { - - override status = 401; - override message = 'Authentication is required'; - } diff --git a/src/main/services/auth-provider.ts b/src/main/services/auth-provider.ts index b21f3bd..ece4bf4 100644 --- a/src/main/services/auth-provider.ts +++ b/src/main/services/auth-provider.ts @@ -1,9 +1,18 @@ +import Koa from 'koa'; +import { Constructor, Mesh } from 'mesh-ioc'; + import { AuthContext } from './auth-context.js'; -export type AuthHeaders = Record; +export abstract class AuthProvider { + + abstract authContextClass: Constructor; -export abstract class AuthProvider { + abstract createAuthContext(ctx: Koa.Context): Promise; - abstract provide(headers?: AuthHeaders): Promise>; + async provide(ctx: Koa.Context, scope: Mesh) { + const authContext = await this.createAuthContext(ctx); + scope.constant(this.authContextClass, authContext); + scope.alias(AuthContext, this.authContextClass); + } } diff --git a/src/main/trial.ts b/src/main/trial.ts index 86a9fdd..105ae07 100644 --- a/src/main/trial.ts +++ b/src/main/trial.ts @@ -1,10 +1,9 @@ +import { AccessDeniedError } from '@nodescript/errors'; import { Logger } from '@nodescript/logger'; import { Redis } from 'ioredis'; import { config } from 'mesh-config'; import { dep } from 'mesh-ioc'; -import { AccessForbidden } from './ac-auth.js'; - export interface TokenServiceRestriction { serviceName: string; requestLimit: number; @@ -54,11 +53,11 @@ export class TrialClient { async requireValidServiceRestriction(token: TrialToken, serviceName: string) { const serviceRestriction = token.serviceRestrictions.find((s: TokenServiceRestriction) => s.serviceName === serviceName); if (!serviceRestriction) { - throw new AccessForbidden('Service access not configured on token'); + throw new AccessDeniedError('Service access not configured on token'); } const requestCount = await this.getRequestCount(token.clientId, serviceName); if (requestCount >= serviceRestriction.requestLimit) { - throw new AccessForbidden('Trial token has exceeded request limit for service'); + throw new AccessDeniedError('Trial token has exceeded request limit for service'); } } @@ -71,7 +70,7 @@ export class TrialClient { const redisKey = this.getServiceKey(clientId, serviceName); const requestCountStr = await this.redisClient.hget(redisKey, 'requestCount'); if (requestCountStr == null) { - throw new AccessForbidden('Service access for token not configured'); + throw new AccessDeniedError('Service access for token not configured'); } return Number(requestCountStr); } diff --git a/src/main/util.ts b/src/main/util.ts index 79c7048..3384d15 100644 --- a/src/main/util.ts +++ b/src/main/util.ts @@ -1,13 +1,12 @@ import 'reflect-metadata'; +import { InitializationError } from '@nodescript/errors'; import { ErrorObject as AjvErrorObject } from 'ajv'; import { promises as fs } from 'fs'; import { Mesh, ServiceConstructor } from 'mesh-ioc'; import path from 'path'; import { v4 as uuid } from 'uuid'; -import { Exception } from './exception.js'; - export type Constructor = new (...args: any[]) => T; export type AnyConstructor = new (...args: any[]) => {}; @@ -88,7 +87,7 @@ async function getPackageJson() { const reason = error instanceof SyntaxError ? 'package.json is malformed' : error.code === 'ENOENT' ? 'package.json not found' : error.message; - throw new Exception(`Cannot get App Details: ${reason}`); + throw new InitializationError(`Cannot get App Details: ${reason}`); } } diff --git a/src/test/integration/ac-auth-mocking.test.ts b/src/test/integration/ac-auth-mocking.test.ts index be6074e..222f9a4 100644 --- a/src/test/integration/ac-auth-mocking.test.ts +++ b/src/test/integration/ac-auth-mocking.test.ts @@ -2,19 +2,22 @@ import assert from 'assert'; import { dep } from 'mesh-ioc'; import supertest from 'supertest'; -import { AcAuth, Application, AuthContext, AuthProvider, Get, Router } from '../../main/index.js'; +import { AcAuth, Application, AuthProvider, Get, Router } from '../../main/index.js'; describe('Mocking AcAuth', () => { class MyRouter extends Router { - @dep() protected auth!: AuthContext; + @dep() protected auth!: AcAuth; @Get({ path: '/foo' }) foo() { - return { ...this.auth.getAuthToken() }; + return { + actor: this.auth.actor, + jwtContext: this.auth.jwtContext, + }; } } @@ -23,14 +26,14 @@ describe('Mocking AcAuth', () => { override createGlobalScope() { const mesh = super.createGlobalScope(); - mesh.constant(AuthProvider, { - async provide() { - const token = new AcAuth({ + mesh.service(AuthProvider, class extends AuthProvider { + override authContextClass = AcAuth; + async createAuthContext() { + return new AcAuth({ organisation_id: 'foo', service_account_id: 'service-account-worker', service_account_name: 'Bot', }); - return new AuthContext(token); } }); return mesh; @@ -57,7 +60,12 @@ describe('Mocking AcAuth', () => { id: 'service-account-worker', name: 'Bot', organisationId: 'foo', - } + }, + jwtContext: { + organisation_id: 'foo', + service_account_id: 'service-account-worker', + service_account_name: 'Bot', + }, }); }); }); diff --git a/src/test/routes/access.ts b/src/test/routes/access.ts index bd18df3..033f046 100644 --- a/src/test/routes/access.ts +++ b/src/test/routes/access.ts @@ -1,10 +1,10 @@ import { dep } from 'mesh-ioc'; -import { AcAuth, AuthContext, Get, Router } from '../../main/index.js'; +import { AuthContext, Get, Router } from '../../main/index.js'; export class AccessRouter extends Router { - @dep() protected auth!: AuthContext; + @dep() protected auth!: AuthContext; @Get({ path: '/public', diff --git a/src/test/routes/foo.ts b/src/test/routes/foo.ts index d783035..934406b 100644 --- a/src/test/routes/foo.ts +++ b/src/test/routes/foo.ts @@ -1,4 +1,4 @@ -import { AfterHook, BodyParam, Exception, Get, Middleware, PathParam, Post, Put, Router } from '../../main/index.js'; +import { AfterHook, BodyParam, Get, Middleware, PathParam, Post, Put, Router } from '../../main/index.js'; export class FooRouter extends Router { @@ -78,12 +78,12 @@ export class FooRouter extends Router { @Get({ path: '/foo-error' }) async throwError() { - throw new Exception(); + throw new Error(); } @Get({ path: '/foo-error-handled' }) async throwErrorHandled() { - throw new Exception(); + throw new Error(); } @Get({ path: '/foo/{fooId}' }) diff --git a/src/test/specs/exception.test.ts b/src/test/specs/exception.test.ts deleted file mode 100644 index ec277a3..0000000 --- a/src/test/specs/exception.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import assert from 'assert'; - -import { ClientError, Exception } from '../../main/index.js'; - -describe('Exception', () => { - - it('infers name from class name', () => { - class MyCustomError extends Exception {} - const err = new MyCustomError(); - assert.strictEqual(err.name, 'MyCustomError'); - }); - - it('allows overriding message by property assignment', () => { - class MyCustomError extends Exception { - override message = 'Custom message'; - } - const err = new MyCustomError(); - assert.strictEqual(err.message, 'Custom message'); - }); - - it('supports custom details', () => { - class MyCustomError extends Exception { - override message = 'Custom message'; - constructor(foo: string) { - super(); - this.details = { foo }; - } - } - const err = new MyCustomError('blah'); - assert.strictEqual(err.message, 'Custom message'); - assert.deepStrictEqual(err.details, { foo: 'blah' }); - }); - -}); - -describe('ClientError', () => { - - it('supports custom status code', () => { - class MyCustomError extends ClientError { - override status = 400; - } - const err = new MyCustomError(); - assert.strictEqual(err.status, 400); - }); - - it('default status is 400', () => { - class MyCustomError extends ClientError {} - const err = new MyCustomError(); - assert.strictEqual(err.status, 400); - }); - -}); diff --git a/src/test/specs/jwks.test.ts b/src/test/specs/jwks.test.ts index c78a5ff..f91bda0 100644 --- a/src/test/specs/jwks.test.ts +++ b/src/test/specs/jwks.test.ts @@ -4,6 +4,7 @@ import assert from 'assert'; import { JwksClient } from '../../main/jwks.js'; describe('JwksClient', () => { + describe('getSigningKey', () => { let jwksClient: JwksClient; let fetch: FetchMock; diff --git a/src/test/specs/services/ac-auth.test.ts b/src/test/specs/services/ac-auth.test.ts index 8a0d091..8600536 100644 --- a/src/test/specs/services/ac-auth.test.ts +++ b/src/test/specs/services/ac-auth.test.ts @@ -6,7 +6,7 @@ import { Mesh } from 'mesh-ioc'; import { AcAuthProvider, - AuthenticationError, + AuthenticationRequiredError, JwtService, StandardLogger, } from '../../../main/index.js'; @@ -16,7 +16,7 @@ describe('AcAuthProvider', () => { let mesh: Mesh; let fetchMock: request.FetchMock; let authProvider: AcAuthProvider; - let headers: any = {}; + let ctx: any = {}; let jwt: any = {}; beforeEach(() => { @@ -27,7 +27,7 @@ describe('AcAuthProvider', () => { mesh.constant(JwtService, { async decodeAndVerify(token: string) { if (token !== 'jwt-token-here') { - throw new AuthenticationError(); + throw new AuthenticationRequiredError(); } return jwt; } @@ -45,34 +45,44 @@ describe('AcAuthProvider', () => { afterEach(() => { jwt = {}; - headers = {}; + ctx = { + headers: {} + }; AcAuthProvider.middlewareTokensCache = new Map(); }); describe('x-ubio-auth header exists', () => { beforeEach(() => { const authHeader = mesh.resolve(AcAuthProvider).AC_AUTH_HEADER_NAME; - headers[authHeader] = 'Bearer jwt-token-here'; + ctx = { + headers: { + [authHeader]: 'Bearer jwt-token-here' + } + }; }); it('does not send request to authMiddleware', async () => { - await authProvider.provide(headers); + await authProvider.createAuthContext(ctx); assert.strictEqual(fetchMock.spy.called, false); }); it('sets authenticated = true', async () => { - const auth = await authProvider.provide(headers); + const auth = await authProvider.createAuthContext(ctx); assert(auth.isAuthenticated()); }); it('throws when jwt is not valid', async () => { const authHeader = mesh.resolve(AcAuthProvider).AC_AUTH_HEADER_NAME; - headers[authHeader] = 'Bearer unknown-jwt-token'; + ctx = { + headers: { + [authHeader]: 'Bearer unknown-jwt-token', + } + }; try { - await authProvider.provide(headers); + await authProvider.createAuthContext(ctx); throw new Error('UnexpectedSuccess'); } catch (err: any) { - assert.strictEqual(err.name, 'AuthenticationError'); + assert.strictEqual(err.name, 'AuthenticationRequiredError'); } }); @@ -80,9 +90,13 @@ describe('AcAuthProvider', () => { describe('middleware auth', () => { it('sends a request to auth middleware with Authorization header', async () => { - headers['authorization'] = 'AUTH'; + ctx = { + headers: { + 'authorization': 'AUTH' + } + }; assert.strictEqual(fetchMock.spy.called, false); - const auth = await authProvider.provide(headers); + const auth = await authProvider.createAuthContext(ctx); assert.strictEqual(fetchMock.spy.called, true); const requestHeaders = fetchMock.spy.params[0]?.fetchOptions.headers; assert.strictEqual(requestHeaders?.authorization, 'AUTH'); @@ -97,9 +111,13 @@ describe('AcAuthProvider', () => { token: 'jwt-token-here', authorisedAt: Date.now() - ttl + margin }); - headers['authorization'] = 'AUTH'; + ctx = { + headers: { + 'authorization': 'AUTH' + } + }; assert.strictEqual(fetchMock.spy.called, false); - const auth = await authProvider.provide(headers); + const auth = await authProvider.createAuthContext(ctx); assert.strictEqual(fetchMock.spy.called, false); assert.strictEqual(auth.isAuthenticated(), true); }); @@ -108,19 +126,30 @@ describe('AcAuthProvider', () => { const ttl = 60000; const margin = 1000; AcAuthProvider.middlewareCacheTtl = ttl; - AcAuthProvider.middlewareTokensCache.set('AUTH', { token: 'jwt-token-here', authorisedAt: Date.now() - ttl - margin }); - headers['authorization'] = 'AUTH'; + AcAuthProvider.middlewareTokensCache.set('AUTH', { + token: 'jwt-token-here', + authorisedAt: Date.now() - ttl - margin + }); + ctx = { + headers: { + 'authorization': 'AUTH' + } + }; assert.strictEqual(fetchMock.spy.called, false); - const auth = await authProvider.provide(headers); + const auth = await authProvider.createAuthContext(ctx); assert.strictEqual(fetchMock.spy.called, true); assert.strictEqual(auth.isAuthenticated(), true); }); it('throws 401 if upstream request fails', async () => { authProvider.clientRequest.config.fetch = request.fetchMock({ status: 400 }, {}, new Error('RequestFailed')); - headers['authorization'] = 'AUTH'; + ctx = { + headers: { + 'authorization': 'AUTH' + } + }; try { - await authProvider.provide(headers); + await authProvider.createAuthContext(ctx); throw new Error('UnexpectedSuccess'); } catch (err: any) { assert.strictEqual(err.status, 401); @@ -130,43 +159,60 @@ describe('AcAuthProvider', () => { context('authorization header does not exist', () => { it('leaves auth unauthenticated without throwing', async () => { - const auth = await authProvider.provide(headers); + const auth = await authProvider.createAuthContext(ctx); assert.strictEqual(auth.isAuthenticated(), false); }); }); describe('acAuth', () => { + beforeEach(() => { const authHeader = mesh.resolve(AcAuthProvider).AC_AUTH_HEADER_NAME; - headers[authHeader] = 'Bearer jwt-token-here'; + ctx = { + headers: { + [authHeader]: 'Bearer jwt-token-here' + } + }; }); describe('organisation_id', () => { context('jwt has `organisation_id`', () => { it('sets auth.organisationId', async () => { jwt.context.organisation_id = 'some-user-org-id'; - const auth = await authProvider.provide(headers); - const organisationId = auth.getAuthToken()?.getOrganisationId(); + const auth = await authProvider.createAuthContext(ctx); + const organisationId = auth.getOrganisationId(); assert.strictEqual(organisationId, 'some-user-org-id'); }); }); - context('x-ubio-organisation-id presents in header', () => { + context('x-ubio-organisation-id exists in headers', () => { it('sets auth.organisationId', async () => { - headers['x-ubio-organisation-id'] = 'org-id-from-header'; - const auth = await authProvider.provide(headers); - const organisationId = auth.getAuthToken()?.getOrganisationId(); + const authHeader = mesh.resolve(AcAuthProvider).AC_AUTH_HEADER_NAME; + ctx = { + headers: { + 'x-ubio-organisation-id': 'org-id-from-header', + [authHeader]: 'Bearer jwt-token-here' + } + }; + const auth = await authProvider.createAuthContext(ctx); + const organisationId = auth.getOrganisationId(); assert.strictEqual(organisationId, 'org-id-from-header'); }); }); context('both jwt and x-ubio-organisation-id present', () => { it('sets auth.organisationId with value from jwt', async () => { + const authHeader = mesh.resolve(AcAuthProvider).AC_AUTH_HEADER_NAME; jwt.context['organisation_id'] = 'org-id-from-jwt'; - headers['x-ubio-organisation-id'] = 'org-id-from-header'; - const auth = await authProvider.provide(headers); - const organisationId = auth.getAuthToken()?.getOrganisationId(); + ctx = { + headers: { + 'x-ubio-organisation-id': 'org-id-from-header', + [authHeader]: 'Bearer jwt-token-here' + } + }; + const auth = await authProvider.createAuthContext(ctx); + const organisationId = auth.getOrganisationId(); assert.strictEqual(organisationId, 'org-id-from-jwt'); }); }); @@ -179,8 +225,8 @@ describe('AcAuthProvider', () => { service_account_id: 'some-service-account-id', service_account_name: 'Bot' }; - const auth = await authProvider.provide(headers); - const serviceAccount = auth.getAuthToken()?.actor; + const auth = await authProvider.createAuthContext(ctx); + const serviceAccount = auth.actor; assert.ok(serviceAccount?.type === 'ServiceAccount'); assert.strictEqual(serviceAccount.id, 'some-service-account-id'); assert.strictEqual(serviceAccount.name, 'Bot'); @@ -192,8 +238,8 @@ describe('AcAuthProvider', () => { service_account_name: 'Bot', organisation_id: 'ubio-organisation-id', }; - const auth = await authProvider.provide(headers); - const serviceAccount = auth.getAuthToken()?.actor; + const auth = await authProvider.createAuthContext(ctx); + const serviceAccount = auth.actor; assert.ok(serviceAccount?.type === 'ServiceAccount'); assert.strictEqual(serviceAccount.id, 'some-service-account-id'); assert.strictEqual(serviceAccount.name, 'Bot'); @@ -206,8 +252,8 @@ describe('AcAuthProvider', () => { client_id: 'ClientA', client_name: 'Ron Swanson', }; - const auth = await authProvider.provide(headers); - const serviceAccount = auth.getAuthToken()?.actor; + const auth = await authProvider.createAuthContext(ctx); + const serviceAccount = auth.actor; assert.ok(serviceAccount?.type === 'ServiceAccount'); assert.strictEqual(serviceAccount.clientId, 'ClientA'); assert.strictEqual(serviceAccount.clientName, 'Ron Swanson'); @@ -223,23 +269,27 @@ describe('AcAuthProvider', () => { client_name: 'UbioAir', organisation_id: 'ubio-organisation-id', }; - const auth = await authProvider.provide(headers); - const client = auth.getAuthToken()?.actor; + const auth = await authProvider.createAuthContext(ctx); + const client = auth.actor; assert.ok(client?.type === 'Client'); assert.strictEqual(client.id, 'some-client-id'); assert.strictEqual(client.name, 'UbioAir'); }); it('does not return Client actor if job_id is present', async () => { - headers['authorization'] = 'AUTH'; + ctx = { + headers: { + 'authorization': 'AUTH' + } + }; jwt.context = { job_id: 'some-job-id-from-cliend-ubio-air', organisation_id: 'ubio-organisation-id', client_id: 'some-client-id', client_name: 'UbioAir', }; - const auth = await authProvider.provide(headers); - const actor = auth.getAuthToken()?.actor; + const auth = await authProvider.createAuthContext(ctx); + const actor = auth.actor; assert.ok(actor?.type === 'JobAccessToken'); }); }); @@ -253,8 +303,8 @@ describe('AcAuthProvider', () => { user_name: 'Travel Aggregator', organisation_id: 'ubio-organisation-id', }; - const auth = await authProvider.provide(headers); - const user = auth.getAuthToken()?.actor; + const auth = await authProvider.createAuthContext(ctx); + const user = auth.actor; assert.ok(user?.type === 'User'); assert.strictEqual(user.id, 'some-user-id'); assert.strictEqual(user.name, 'Travel Aggregator'); @@ -266,8 +316,8 @@ describe('AcAuthProvider', () => { user_id: 'some-user-id', organisation_id: 'ubio-organisation-id', }; - const auth = await authProvider.provide(headers); - const user = auth.getAuthToken()?.actor; + const auth = await authProvider.createAuthContext(ctx); + const user = auth.actor; assert.ok(user?.type === 'User'); assert.strictEqual(user.id, 'some-user-id'); assert.strictEqual(user.name, ''); @@ -282,7 +332,7 @@ describe('AcAuthProvider', () => { user_id: 'some-user-id', user_name: 'some-user-name' }; - await authProvider.provide(headers); + await authProvider.createAuthContext(ctx); throw new Error('UnexpectedSuccess'); } catch (error: any) { assert.strictEqual(error.status, 401); @@ -300,8 +350,8 @@ describe('AcAuthProvider', () => { client_name: 'UbioAir', organisation_id: 'ubio-organisation-id', }; - const auth = await authProvider.provide(headers); - const jobAccessToken = auth.getAuthToken()?.actor; + const auth = await authProvider.createAuthContext(ctx); + const jobAccessToken = auth.actor; assert.ok(jobAccessToken?.type === 'JobAccessToken'); assert.strictEqual(jobAccessToken.jobId, 'some-job-id'); assert.strictEqual(jobAccessToken.clientId, 'some-client-id'); @@ -316,7 +366,7 @@ describe('AcAuthProvider', () => { job_id: 'some-job-id', organisation_id: 'ubio-organisation-id', }; - await authProvider.provide(headers); + await authProvider.createAuthContext(ctx); throw new Error('UnexpectedSuccess'); } catch (error: any) { assert.strictEqual(error.status, 401); @@ -330,7 +380,7 @@ describe('AcAuthProvider', () => { client_id: 'some-client-id', client_name: 'Travel Aggregator', }; - await authProvider.provide(headers); + await authProvider.createAuthContext(ctx); throw new Error('UnexpectedSuccess'); } catch (error: any) { assert.strictEqual(error.status, 401); diff --git a/src/test/specs/services/bypass-auth-provider.test.ts b/src/test/specs/services/bypass-auth-provider.test.ts deleted file mode 100644 index c277916..0000000 --- a/src/test/specs/services/bypass-auth-provider.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import assert from 'assert'; -import supertest from 'supertest'; - -import { - Application, - AuthProvider, - BypassAcAuthProvider, -} from '../../../main/index.js'; -import { AccessRouter } from '../../routes/access.js'; - -describe('BypassAuthProvider', () => { - - class App extends Application { - - override createGlobalScope() { - const mesh = super.createGlobalScope(); - mesh.service(AuthProvider, BypassAcAuthProvider); - return mesh; - } - - override createHttpRequestScope() { - const mesh = super.createHttpRequestScope(); - mesh.service(AccessRouter); - return mesh; - } - - override async beforeStart() { - await this.httpServer.startServer(); - } - - override async afterStop() { - await this.httpServer.stopServer(); - } - } - - const app = new App(); - beforeEach(() => app.start()); - afterEach(() => app.stop()); - - context('route do not require authentication', () => { - context('auth headers not provided', () => { - it('does not return 401 when Authorization not provided', async () => { - const request = supertest(app.httpServer.callback()); - const res = await request.get('/public'); - assert.ok(res.ok); - assert.strictEqual(res.status, 200); - }); - - it('does not return 401 when x-ubio-auth not provided', async () => { - const request = supertest(app.httpServer.callback()); - const res = await request.get('/public'); - assert.ok(res.ok); - assert.strictEqual(res.status, 200); - }); - }); - - context('auth headers provided', () => { - it('does not return 401 when Authorization found (does not try to authenticate)', async () => { - const request = supertest(app.httpServer.callback()); - const res = await request - .get('/public') - .set('Authorization', 'Bearer some-token'); - assert.ok(res.ok); - assert.strictEqual(res.status, 200); - }); - - it('does not return 401 when x-ubio-auth header found (does not try to authenticate)', async () => { - const request = supertest(app.httpServer.callback()); - const res = await request - .get('/public') - .set('x-ubio-auth', 'Bearer some-token'); - assert.ok(res.ok); - assert.strictEqual(res.status, 200); - }); - }); - }); - - context('route requires authentication (checkAuthenticated is called)', () => { - it('returns 401 when auth headers not provided', async () => { - const request = supertest(app.httpServer.callback()); - const res = await request.get('/secret'); - - assert.ok(!res.ok); - assert.strictEqual(res.status, 401); - }); - - it('returns 401 when auth headers provided', async () => { - const request = supertest(app.httpServer.callback()); - const res = await request - .get('/secret') - .set('Authorization', 'Bearer some-token') - .set('x-ubio-auth', 'Bearer some-token'); - assert.ok(!res.ok); - assert.strictEqual(res.status, 401); - }); - }); -});