feat: add direct event handling for home server (#608)

* feat(domain-events-infra): add direct call event message handler

* feat: add direct publishing of events into handlers

* fix: validating sessions with direct calls
This commit is contained in:
Karol Sójko 2023-05-17 09:23:48 +02:00 committed by GitHub
parent 7ae9f5694d
commit 8a47d81936
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 239 additions and 83 deletions

1
.pnp.cjs generated
View file

@ -4625,6 +4625,7 @@ const RAW_RUNTIME_STATE =
["@standardnotes/api-gateway", "workspace:packages/api-gateway"],\
["@standardnotes/auth-server", "workspace:packages/auth"],\
["@standardnotes/domain-core", "workspace:packages/domain-core"],\
["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\
["@types/cors", "npm:2.8.13"],\
["@types/express", "npm:4.17.17"],\
["@types/prettyjson", "npm:0.0.30"],\

View file

@ -5,17 +5,17 @@ import { NextFunction, Request, Response } from 'express'
import { inject, injectable } from 'inversify'
import { BaseMiddleware } from 'inversify-express-utils'
import { verify } from 'jsonwebtoken'
import { AxiosError, AxiosInstance } from 'axios'
import { AxiosError } from 'axios'
import { Logger } from 'winston'
import { TYPES } from '../Bootstrap/Types'
import { CrossServiceTokenCacheInterface } from '../Service/Cache/CrossServiceTokenCacheInterface'
import { ServiceProxyInterface } from '../Service/Http/ServiceProxyInterface'
@injectable()
export class AuthMiddleware extends BaseMiddleware {
constructor(
@inject(TYPES.HTTPClient) private httpClient: AxiosInstance,
@inject(TYPES.AUTH_SERVER_URL) private authServerUrl: string,
@inject(TYPES.ServiceProxy) private serviceProxy: ServiceProxyInterface,
@inject(TYPES.AUTH_JWT_SECRET) private jwtSecret: string,
@inject(TYPES.CROSS_SERVICE_TOKEN_CACHE_TTL) private crossServiceTokenCacheTTL: number,
@inject(TYPES.CrossServiceTokenCache) private crossServiceTokenCache: CrossServiceTokenCacheInterface,
@ -47,26 +47,16 @@ export class AuthMiddleware extends BaseMiddleware {
}
if (crossServiceToken === null) {
const authResponse = await this.httpClient.request({
method: 'POST',
headers: {
Authorization: authHeaderValue,
Accept: 'application/json',
},
validateStatus: (status: number) => {
return status >= 200 && status < 500
},
url: `${this.authServerUrl}/sessions/validate`,
})
const authResponse = await this.serviceProxy.validateSession(authHeaderValue)
if (authResponse.status > 200) {
response.setHeader('content-type', authResponse.headers['content-type'] as string)
response.setHeader('content-type', authResponse.headers.contentType)
response.status(authResponse.status).send(authResponse.data)
return
}
crossServiceToken = authResponse.data.authToken
crossServiceToken = (authResponse.data as { authToken: string }).authToken
crossServiceTokenFetchedFromCache = false
}
@ -94,9 +84,7 @@ export class AuthMiddleware extends BaseMiddleware {
? JSON.stringify((error as AxiosError).response?.data)
: (error as Error).message
this.logger.error(
`Could not pass the request to ${this.authServerUrl}/sessions/validate on underlying service: ${errorMessage}`,
)
this.logger.error(`Could not pass the request to sessions/validate on underlying service: ${errorMessage}`)
this.logger.debug('Response error: %O', (error as AxiosError).response ?? error)

View file

@ -24,6 +24,30 @@ export class HttpServiceProxy implements ServiceProxyInterface {
@inject(TYPES.Logger) private logger: Logger,
) {}
async validateSession(
authorizationHeaderValue: string,
): Promise<{ status: number; data: unknown; headers: { contentType: string } }> {
const authResponse = await this.httpClient.request({
method: 'POST',
headers: {
Authorization: authorizationHeaderValue,
Accept: 'application/json',
},
validateStatus: (status: number) => {
return status >= 200 && status < 500
},
url: `${this.authServerUrl}/sessions/validate`,
})
return {
status: authResponse.status,
data: authResponse.data,
headers: {
contentType: authResponse.headers['content-type'] as string,
},
}
}
async callSyncingServer(
request: Request,
response: Response,

View file

@ -49,4 +49,11 @@ export interface ServiceProxyInterface {
endpointOrMethodIdentifier: string,
payload?: Record<string, unknown> | string,
): Promise<void>
validateSession(authorizationHeaderValue: string): Promise<{
status: number
data: unknown
headers: {
contentType: string
}
}>
}

View file

@ -6,6 +6,34 @@ import { ServiceContainerInterface, ServiceIdentifier } from '@standardnotes/dom
export class DirectCallServiceProxy implements ServiceProxyInterface {
constructor(private serviceContainer: ServiceContainerInterface) {}
async validateSession(
authorizationHeaderValue: string,
): Promise<{ status: number; data: unknown; headers: { contentType: string } }> {
const authService = this.serviceContainer.get(ServiceIdentifier.create(ServiceIdentifier.NAMES.Auth).getValue())
if (!authService) {
throw new Error('Auth service not found')
}
const serviceResponse = (await authService.handleRequest(
{
headers: {
authorization: authorizationHeaderValue,
},
} as never,
{} as never,
'auth.sessions.validate',
)) as {
statusCode: number
json: Record<string, unknown>
}
return {
status: serviceResponse.statusCode,
data: serviceResponse.json,
headers: { contentType: 'application/json' },
}
}
async callEmailServer(_request: Request, _response: Response, _endpointOrMethodIdentifier: string): Promise<void> {
throw new Error('Email server is not available.')
}

View file

@ -4,6 +4,8 @@ export class EndpointResolver implements EndpointResolverInterface {
constructor(private isConfiguredForHomeServer: boolean) {}
private readonly endpointToIdentifierMap: Map<string, string> = new Map([
// Auth Middleware
['[POST]:sessions/validate', 'auth.sessions.validate'],
// Actions Controller
['[POST]:auth/sign_in', 'auth.signIn'],
['[GET]:auth/params', 'auth.params'],

View file

@ -4,7 +4,6 @@ import 'newrelic'
import '../src/Controller/HealthCheckController'
import '../src/Controller/SessionController'
import '../src/Controller/SessionsController'
import '../src/Controller/UsersController'
import '../src/Controller/SettingsController'
import '../src/Controller/FeaturesController'
@ -18,6 +17,7 @@ import '../src/Controller/SubscriptionSettingsController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressAuthController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressAuthenticatorsController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressSessionsController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressSubscriptionInvitesController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressUserRequestsController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressWebSocketsController'

View file

@ -6,6 +6,7 @@ import { Container } from 'inversify'
import {
DomainEventHandlerInterface,
DomainEventMessageHandlerInterface,
DomainEventPublisherInterface,
DomainEventSubscriberFactoryInterface,
} from '@standardnotes/domain-events'
import { TimerInterface, Timer } from '@standardnotes/time'
@ -90,6 +91,8 @@ import { FeatureService } from '../Domain/Feature/FeatureService'
import { SettingServiceInterface } from '../Domain/Setting/SettingServiceInterface'
import { ExtensionKeyGrantedEventHandler } from '../Domain/Handler/ExtensionKeyGrantedEventHandler'
import {
DirectCallDomainEventPublisher,
DirectCallEventMessageHandler,
SNSDomainEventPublisher,
SQSDomainEventSubscriberFactory,
SQSEventMessageHandler,
@ -237,12 +240,19 @@ import { InversifyExpressAuthenticatorsController } from '../Infra/InversifyExpr
import { InversifyExpressSubscriptionInvitesController } from '../Infra/InversifyExpressUtils/InversifyExpressSubscriptionInvitesController'
import { InversifyExpressUserRequestsController } from '../Infra/InversifyExpressUtils/InversifyExpressUserRequestsController'
import { InversifyExpressWebSocketsController } from '../Infra/InversifyExpressUtils/InversifyExpressWebSocketsController'
import { InversifyExpressSessionsController } from '../Infra/InversifyExpressUtils/InversifyExpressSessionsController'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher')
export class ContainerConfigLoader {
async load(controllerConatiner?: ControllerContainerInterface): Promise<Container> {
async load(configuration?: {
controllerConatiner?: ControllerContainerInterface
directCallDomainEventPublisher?: DirectCallDomainEventPublisher
}): Promise<Container> {
const directCallDomainEventPublisher =
configuration?.directCallDomainEventPublisher ?? new DirectCallDomainEventPublisher()
const env: Env = new Env()
env.load()
@ -280,6 +290,7 @@ export class ContainerConfigLoader {
container.bind<TimerInterface>(TYPES.Auth_Timer).toConstantValue(new Timer())
if (!isConfiguredForHomeServer) {
const snsConfig: SNSClientConfig = {
region: env.get('SNS_AWS_REGION', true),
}
@ -307,6 +318,7 @@ export class ContainerConfigLoader {
}
}
container.bind<SQSClient>(TYPES.Auth_SQS).toConstantValue(new SQSClient(sqsConfig))
}
// Mapping
container
@ -649,9 +661,11 @@ export class ContainerConfigLoader {
container.bind<UserSubscriptionServiceInterface>(TYPES.Auth_UserSubscriptionService).to(UserSubscriptionService)
container
.bind<SNSDomainEventPublisher>(TYPES.Auth_DomainEventPublisher)
.bind<DomainEventPublisherInterface>(TYPES.Auth_DomainEventPublisher)
.toConstantValue(
new SNSDomainEventPublisher(container.get(TYPES.Auth_SNS), container.get(TYPES.Auth_SNS_TOPIC_ARN)),
isConfiguredForHomeServer
? directCallDomainEventPublisher
: new SNSDomainEventPublisher(container.get(TYPES.Auth_SNS), container.get(TYPES.Auth_SNS_TOPIC_ARN)),
)
// use cases
@ -836,7 +850,7 @@ export class ContainerConfigLoader {
// Controller
container
.bind<ControllerContainerInterface>(TYPES.Auth_ControllerContainer)
.toConstantValue(controllerConatiner ?? new ControllerContainer())
.toConstantValue(configuration?.controllerConatiner ?? new ControllerContainer())
container
.bind<AuthController>(TYPES.Auth_AuthController)
.toConstantValue(
@ -956,6 +970,16 @@ export class ContainerConfigLoader {
['EMAIL_SUBSCRIPTION_UNSUBSCRIBED', container.get(TYPES.Auth_EmailSubscriptionUnsubscribedEventHandler)],
])
if (isConfiguredForHomeServer) {
const directCallEventMessageHandler = new DirectCallEventMessageHandler(
eventHandlers,
container.get(TYPES.Auth_Logger),
)
directCallDomainEventPublisher.register(directCallEventMessageHandler)
container
.bind<DomainEventMessageHandlerInterface>(TYPES.Auth_DomainEventMessageHandler)
.toConstantValue(directCallEventMessageHandler)
} else {
container
.bind<DomainEventMessageHandlerInterface>(TYPES.Auth_DomainEventMessageHandler)
.toConstantValue(
@ -963,6 +987,7 @@ export class ContainerConfigLoader {
? new SQSNewRelicEventMessageHandler(eventHandlers, container.get(TYPES.Auth_Logger))
: new SQSEventMessageHandler(eventHandlers, container.get(TYPES.Auth_Logger)),
)
container
.bind<DomainEventSubscriberFactoryInterface>(TYPES.Auth_DomainEventSubscriberFactory)
.toConstantValue(
@ -972,6 +997,7 @@ export class ContainerConfigLoader {
container.get(TYPES.Auth_DomainEventMessageHandler),
),
)
}
container
.bind<InversifyExpressAuthController>(TYPES.Auth_InversifyExpressAuthController)
@ -987,6 +1013,8 @@ export class ContainerConfigLoader {
container.get(TYPES.Auth_ControllerContainer),
),
)
// Inversify Controllers
container
.bind<InversifyExpressAuthenticatorsController>(TYPES.Auth_InversifyExpressAuthenticatorsController)
.toConstantValue(
@ -1020,6 +1048,17 @@ export class ContainerConfigLoader {
container.get(TYPES.Auth_ControllerContainer),
),
)
container
.bind<InversifyExpressSessionsController>(TYPES.Auth_SessionsController)
.toConstantValue(
new InversifyExpressSessionsController(
container.get(TYPES.Auth_GetActiveSessionsForUser),
container.get(TYPES.Auth_AuthenticateRequest),
container.get(TYPES.Auth_SessionProjector),
container.get(TYPES.Auth_CreateCrossServiceToken),
container.get(TYPES.Auth_ControllerContainer),
),
)
return container
}

View file

@ -6,11 +6,13 @@ import {
} from '@standardnotes/domain-core'
import { ContainerConfigLoader } from './Container'
import { DirectCallDomainEventPublisher } from '@standardnotes/domain-events-infra'
export class Service implements ServiceInterface {
constructor(
private serviceContainer: ServiceContainerInterface,
private controllerContainer: ControllerContainerInterface,
private directCallDomainEventPublisher: DirectCallDomainEventPublisher,
) {
this.serviceContainer.register(ServiceIdentifier.create(ServiceIdentifier.NAMES.Auth).getValue(), this)
}
@ -28,7 +30,10 @@ export class Service implements ServiceInterface {
async getContainer(): Promise<unknown> {
const config = new ContainerConfigLoader()
return config.load(this.controllerContainer)
return config.load({
controllerConatiner: this.controllerContainer,
directCallDomainEventPublisher: this.directCallDomainEventPublisher,
})
}
getId(): ServiceIdentifier {

View file

@ -222,6 +222,7 @@ const TYPES = {
Auth_InversifyExpressSubscriptionInvitesController: Symbol.for('Auth_InversifyExpressSubscriptionInvitesController'),
Auth_InversifyExpressUserRequestsController: Symbol.for('Auth_InversifyExpressUserRequestsController'),
Auth_InversifyExpressWebSocketsController: Symbol.for('Auth_InversifyExpressWebSocketsController'),
Auth_SessionsController: Symbol.for('Auth_SessionsController'),
}
export default TYPES

View file

@ -2,17 +2,18 @@ import 'reflect-metadata'
import * as express from 'express'
import { SessionsController } from './SessionsController'
import { InversifyExpressSessionsController } from './InversifyExpressSessionsController'
import { results } from 'inversify-express-utils'
import { Session } from '../Domain/Session/Session'
import { ProjectorInterface } from '../Projection/ProjectorInterface'
import { GetActiveSessionsForUser } from '../Domain/UseCase/GetActiveSessionsForUser'
import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest'
import { User } from '../Domain/User/User'
import { CreateCrossServiceToken } from '../Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken'
import { ControllerContainerInterface } from '@standardnotes/domain-core'
import { User } from '@standardnotes/responses'
describe('SessionsController', () => {
import { AuthenticateRequest } from '../../Domain/UseCase/AuthenticateRequest'
import { CreateCrossServiceToken } from '../../Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken'
import { GetActiveSessionsForUser } from '../../Domain/UseCase/GetActiveSessionsForUser'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { Session } from '../../Domain/Session/Session'
describe('InversifyExpressSessionsController', () => {
let getActiveSessionsForUser: GetActiveSessionsForUser
let authenticateRequest: AuthenticateRequest
let sessionProjector: ProjectorInterface<Session>
@ -24,7 +25,7 @@ describe('SessionsController', () => {
let controllerContainer: ControllerContainerInterface
const createController = () =>
new SessionsController(
new InversifyExpressSessionsController(
getActiveSessionsForUser,
authenticateRequest,
sessionProjector,

View file

@ -8,18 +8,19 @@ import {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
results,
} from 'inversify-express-utils'
import TYPES from '../Bootstrap/Types'
import { Session } from '../Domain/Session/Session'
import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest'
import { GetActiveSessionsForUser } from '../Domain/UseCase/GetActiveSessionsForUser'
import { User } from '../Domain/User/User'
import { ProjectorInterface } from '../Projection/ProjectorInterface'
import { SessionProjector } from '../Projection/SessionProjector'
import { CreateCrossServiceToken } from '../Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken'
import { ControllerContainerInterface } from '@standardnotes/domain-core'
import TYPES from '../../Bootstrap/Types'
import { AuthenticateRequest } from '../../Domain/UseCase/AuthenticateRequest'
import { CreateCrossServiceToken } from '../../Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken'
import { GetActiveSessionsForUser } from '../../Domain/UseCase/GetActiveSessionsForUser'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { SessionProjector } from '../../Projection/SessionProjector'
import { User } from '../../Domain/User/User'
import { Session } from '../../Domain/Session/Session'
@controller('/sessions')
export class SessionsController extends BaseHttpController {
export class InversifyExpressSessionsController extends BaseHttpController {
constructor(
@inject(TYPES.Auth_GetActiveSessionsForUser) private getActiveSessionsForUser: GetActiveSessionsForUser,
@inject(TYPES.Auth_AuthenticateRequest) private authenticateRequest: AuthenticateRequest,
@ -30,6 +31,7 @@ export class SessionsController extends BaseHttpController {
super()
this.controllerContainer.register('auth.sessions.list', this.getSessions.bind(this))
this.controllerContainer.register('auth.sessions.validate', this.validate.bind(this))
}
@httpPost('/validate')

View file

@ -0,0 +1,17 @@
import {
DomainEventInterface,
DomainEventMessageHandlerInterface,
DomainEventPublisherInterface,
} from '@standardnotes/domain-events'
export class DirectCallDomainEventPublisher implements DomainEventPublisherInterface {
private handlers: Array<DomainEventMessageHandlerInterface> = []
register(domainEventMessageHandler: DomainEventMessageHandlerInterface): void {
this.handlers.push(domainEventMessageHandler)
}
async publish(event: DomainEventInterface): Promise<void> {
await Promise.all(this.handlers.map((handler) => handler.handleMessage(event)))
}
}

View file

@ -0,0 +1,32 @@
import { Logger } from 'winston'
import {
DomainEventHandlerInterface,
DomainEventInterface,
DomainEventMessageHandlerInterface,
} from '@standardnotes/domain-events'
export class DirectCallEventMessageHandler implements DomainEventMessageHandlerInterface {
constructor(private handlers: Map<string, DomainEventHandlerInterface>, private logger: Logger) {}
async handleMessage(messageOrEvent: string | DomainEventInterface): Promise<void> {
if (typeof messageOrEvent === 'string') {
throw new Error('DirectCallEventMessageHandler does not support string messages')
}
const handler = this.handlers.get(messageOrEvent.type)
if (!handler) {
this.logger.debug(`Event handler for event type ${messageOrEvent.type} does not exist`)
return
}
this.logger.debug(`Received event: ${messageOrEvent.type}`)
await handler.handle(messageOrEvent)
}
async handleError(error: Error): Promise<void> {
this.logger.error('Error occured while handling SQS message: %O', error)
}
}

View file

@ -1,3 +1,6 @@
export * from './DirectCall/DirectCallDomainEventPublisher'
export * from './DirectCall/DirectCallEventMessageHandler'
export * from './Redis/RedisDomainEventPublisher'
export * from './Redis/RedisDomainEventSubscriber'
export * from './Redis/RedisDomainEventSubscriberFactory'

View file

@ -1,4 +1,6 @@
import { DomainEventInterface } from '../Event/DomainEventInterface'
export interface DomainEventMessageHandlerInterface {
handleMessage(message: string): Promise<void>
handleMessage(messageOrEvent: string | DomainEventInterface): Promise<void>
handleError(error: Error): Promise<void>
}

View file

@ -2,6 +2,7 @@ import 'reflect-metadata'
import { ControllerContainer, ServiceContainer } from '@standardnotes/domain-core'
import { Service as ApiGatewayService, TYPES as ApiGatewayTYPES } from '@standardnotes/api-gateway'
import { DirectCallDomainEventPublisher } from '@standardnotes/domain-events-infra'
import { Service as AuthService } from '@standardnotes/auth-server'
import { Container } from 'inversify'
import { InversifyExpressServer } from 'inversify-express-utils'
@ -17,9 +18,10 @@ import { Env } from '../src/Bootstrap/Env'
const startServer = async (): Promise<void> => {
const controllerContainer = new ControllerContainer()
const serviceContainer = new ServiceContainer()
const directCallDomainEventPublisher = new DirectCallDomainEventPublisher()
const apiGatewayService = new ApiGatewayService(serviceContainer, controllerContainer)
const authService = new AuthService(serviceContainer, controllerContainer)
const authService = new AuthService(serviceContainer, controllerContainer, directCallDomainEventPublisher)
const container = Container.merge(
(await apiGatewayService.getContainer()) as Container,

View file

@ -21,6 +21,7 @@
"@standardnotes/api-gateway": "workspace:^",
"@standardnotes/auth-server": "workspace:^",
"@standardnotes/domain-core": "workspace:^",
"@standardnotes/domain-events-infra": "workspace:^",
"cors": "2.8.5",
"dotenv": "^16.0.1",
"express": "^4.18.2",

View file

@ -4248,6 +4248,7 @@ __metadata:
"@standardnotes/api-gateway": "workspace:^"
"@standardnotes/auth-server": "workspace:^"
"@standardnotes/domain-core": "workspace:^"
"@standardnotes/domain-events-infra": "workspace:^"
"@types/cors": "npm:^2.8.9"
"@types/express": "npm:^4.17.14"
"@types/prettyjson": "npm:^0.0.30"