diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.spec.ts b/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.spec.ts index b203c5937..2f22d5611 100644 --- a/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.spec.ts +++ b/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.spec.ts @@ -9,6 +9,8 @@ import { SessionServiceInterface } from '../Session/SessionServiceInterface' import { KeyParamsFactoryInterface } from '../User/KeyParamsFactoryInterface' import { User } from '../User/User' import { AuthResponseFactory20200115 } from './AuthResponseFactory20200115' +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' describe('AuthResponseFactory20200115', () => { let sessionService: SessionServiceInterface @@ -18,13 +20,24 @@ describe('AuthResponseFactory20200115', () => { let sessionPayload: SessionBody let logger: Logger let tokenEncoder: TokenEncoderInterface + let domainEventFactory: DomainEventFactoryInterface + let domainEventPublisher: DomainEventPublisherInterface const createFactory = () => - new AuthResponseFactory20200115(sessionService, keyParamsFactory, userProjector, tokenEncoder, logger) + new AuthResponseFactory20200115( + sessionService, + keyParamsFactory, + userProjector, + tokenEncoder, + domainEventFactory, + domainEventPublisher, + logger, + ) beforeEach(() => { logger = {} as jest.Mocked logger.debug = jest.fn() + logger.error = jest.fn() sessionPayload = { access_token: 'access_token', @@ -52,6 +65,12 @@ describe('AuthResponseFactory20200115', () => { tokenEncoder = {} as jest.Mocked> tokenEncoder.encodeToken = jest.fn().mockReturnValue('foobar') + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createSessionCreatedEvent = jest.fn().mockReturnValue({}) + + domainEventPublisher = {} as jest.Mocked + domainEventPublisher.publish = jest.fn() }) it('should create a 20161215 auth response if user does not support sessions', async () => { @@ -82,6 +101,37 @@ describe('AuthResponseFactory20200115', () => { readonlyAccess: false, }) + expect(response).toEqual({ + key_params: { + key1: 'value1', + key2: 'value2', + }, + session: { + access_token: 'access_token', + refresh_token: 'refresh_token', + access_expiration: 123, + refresh_expiration: 234, + readonly_access: false, + }, + user: { + foo: 'bar', + }, + }) + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) + + it('should create a 20200115 auth response even if publishing the domain event fails', async () => { + domainEventPublisher.publish = jest.fn().mockRejectedValue(new Error('test')) + user.supportsSessions = jest.fn().mockReturnValue(true) + + const response = await createFactory().createResponse({ + user, + apiVersion: '20200115', + userAgent: 'Google Chrome', + ephemeralSession: false, + readonlyAccess: false, + }) + expect(response).toEqual({ key_params: { key1: 'value1', diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts b/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts index 0eb71037c..f62faa5d4 100644 --- a/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts +++ b/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts @@ -3,18 +3,22 @@ import { SessionTokenData, TokenEncoderInterface, } from '@standardnotes/security' +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' import { ProtocolVersion } from '@standardnotes/common' import { SessionBody } from '@standardnotes/responses' import { inject, injectable } from 'inversify' import { Logger } from 'winston' + import TYPES from '../../Bootstrap/Types' import { ProjectorInterface } from '../../Projection/ProjectorInterface' import { SessionServiceInterface } from '../Session/SessionServiceInterface' import { KeyParamsFactoryInterface } from '../User/KeyParamsFactoryInterface' import { User } from '../User/User' +import { AuthResponseFactory20190520 } from './AuthResponseFactory20190520' +import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' + import { AuthResponse20161215 } from './AuthResponse20161215' import { AuthResponse20200115 } from './AuthResponse20200115' -import { AuthResponseFactory20190520 } from './AuthResponseFactory20190520' @injectable() export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 { @@ -23,6 +27,8 @@ export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 { @inject(TYPES.KeyParamsFactory) private keyParamsFactory: KeyParamsFactoryInterface, @inject(TYPES.UserProjector) userProjector: ProjectorInterface, @inject(TYPES.SessionTokenEncoder) protected override tokenEncoder: TokenEncoderInterface, + @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, @inject(TYPES.Logger) logger: Logger, ) { super(userProjector, tokenEncoder, logger) @@ -67,6 +73,16 @@ export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 { return this.sessionService.createNewEphemeralSessionForUser(dto) } - return this.sessionService.createNewSessionForUser(dto) + const session = this.sessionService.createNewSessionForUser(dto) + + try { + await this.domainEventPublisher.publish( + this.domainEventFactory.createSessionCreatedEvent({ userUuid: dto.user.uuid }), + ) + } catch (error) { + this.logger.error(`Failed to publish session created event: ${(error as Error).message}`) + } + + return session } } diff --git a/packages/auth/src/Domain/Event/DomainEventFactory.ts b/packages/auth/src/Domain/Event/DomainEventFactory.ts index 9311a7eca..57e4419d0 100644 --- a/packages/auth/src/Domain/Event/DomainEventFactory.ts +++ b/packages/auth/src/Domain/Event/DomainEventFactory.ts @@ -19,6 +19,8 @@ import { MuteEmailsSettingChangedEvent, EmailRequestedEvent, StatisticPersistenceRequestedEvent, + SessionCreatedEvent, + SessionRefreshedEvent, } from '@standardnotes/domain-events' import { Predicate, PredicateVerificationResult } from '@standardnotes/predicates' import { TimerInterface } from '@standardnotes/time' @@ -31,6 +33,36 @@ import { DomainEventFactoryInterface } from './DomainEventFactoryInterface' export class DomainEventFactory implements DomainEventFactoryInterface { constructor(@inject(TYPES.Timer) private timer: TimerInterface) {} + createSessionCreatedEvent(dto: { userUuid: string }): SessionCreatedEvent { + return { + type: 'SESSION_CREATED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: dto.userUuid, + userIdentifierType: 'uuid', + }, + origin: DomainEventService.Auth, + }, + payload: dto, + } + } + + createSessionRefreshedEvent(dto: { userUuid: string }): SessionRefreshedEvent { + return { + type: 'SESSION_REFRESHED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: dto.userUuid, + userIdentifierType: 'uuid', + }, + origin: DomainEventService.Auth, + }, + payload: dto, + } + } + createStatisticPersistenceRequestedEvent(dto: { statisticMeasureName: string value: number diff --git a/packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts b/packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts index 091f500bd..db6f231c6 100644 --- a/packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts +++ b/packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts @@ -17,6 +17,8 @@ import { MuteEmailsSettingChangedEvent, EmailRequestedEvent, StatisticPersistenceRequestedEvent, + SessionCreatedEvent, + SessionRefreshedEvent, } from '@standardnotes/domain-events' import { InviteeIdentifierType } from '../SharedSubscription/InviteeIdentifierType' @@ -92,4 +94,6 @@ export interface DomainEventFactoryInterface { value: number date: number }): StatisticPersistenceRequestedEvent + createSessionCreatedEvent(dto: { userUuid: string }): SessionCreatedEvent + createSessionRefreshedEvent(dto: { userUuid: string }): SessionRefreshedEvent } diff --git a/packages/auth/src/Domain/UseCase/RefreshSessionToken.spec.ts b/packages/auth/src/Domain/UseCase/RefreshSessionToken.spec.ts index 32cc0fed8..8b0d7034d 100644 --- a/packages/auth/src/Domain/UseCase/RefreshSessionToken.spec.ts +++ b/packages/auth/src/Domain/UseCase/RefreshSessionToken.spec.ts @@ -1,20 +1,28 @@ import 'reflect-metadata' -import * as dayjs from 'dayjs' import { Session } from '../Session/Session' import { SessionServiceInterface } from '../Session/SessionServiceInterface' import { RefreshSessionToken } from './RefreshSessionToken' +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { TimerInterface } from '@standardnotes/time' +import { Logger } from 'winston' +import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' describe('RefreshSessionToken', () => { let sessionService: SessionServiceInterface let session: Session + let domainEventFactory: DomainEventFactoryInterface + let domainEventPublisher: DomainEventPublisherInterface + let timer: TimerInterface + let logger: Logger - const createUseCase = () => new RefreshSessionToken(sessionService) + const createUseCase = () => + new RefreshSessionToken(sessionService, domainEventFactory, domainEventPublisher, timer, logger) beforeEach(() => { session = {} as jest.Mocked session.uuid = '1-2-3' - session.refreshExpiration = dayjs.utc().add(1, 'day').toDate() + session.refreshExpiration = new Date(123) sessionService = {} as jest.Mocked sessionService.isRefreshTokenValid = jest.fn().mockReturnValue(true) @@ -25,6 +33,18 @@ describe('RefreshSessionToken', () => { access_expiration: 123, refresh_expiration: 234, }) + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createSessionRefreshedEvent = jest.fn().mockReturnValue({}) + + domainEventPublisher = {} as jest.Mocked + domainEventPublisher.publish = jest.fn() + + timer = {} as jest.Mocked + timer.getUTCDate = jest.fn().mockReturnValue(new Date(100)) + + logger = {} as jest.Mocked + logger.error = jest.fn() }) it('should refresh session token', async () => { @@ -44,6 +64,29 @@ describe('RefreshSessionToken', () => { refresh_expiration: 234, }, }) + + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) + + it('should refresh a session token even if publishing domain event fails', async () => { + domainEventPublisher.publish = jest.fn().mockRejectedValue(new Error('test')) + + const result = await createUseCase().execute({ + accessToken: '123', + refreshToken: '234', + }) + + expect(sessionService.refreshTokens).toHaveBeenCalledWith(session) + + expect(result).toEqual({ + success: true, + sessionPayload: { + access_token: 'token1', + refresh_token: 'token2', + access_expiration: 123, + refresh_expiration: 234, + }, + }) }) it('should not refresh a session token if session is not found', async () => { @@ -77,7 +120,7 @@ describe('RefreshSessionToken', () => { }) it('should not refresh a session token if refresh token is expired', async () => { - session.refreshExpiration = dayjs.utc().subtract(1, 'day').toDate() + timer.getUTCDate = jest.fn().mockReturnValue(new Date(200)) const result = await createUseCase().execute({ accessToken: '123', diff --git a/packages/auth/src/Domain/UseCase/RefreshSessionToken.ts b/packages/auth/src/Domain/UseCase/RefreshSessionToken.ts index fdf709882..3f94b58ae 100644 --- a/packages/auth/src/Domain/UseCase/RefreshSessionToken.ts +++ b/packages/auth/src/Domain/UseCase/RefreshSessionToken.ts @@ -1,14 +1,24 @@ -import * as dayjs from 'dayjs' - import { inject, injectable } from 'inversify' +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { TimerInterface } from '@standardnotes/time' +import { Logger } from 'winston' + import TYPES from '../../Bootstrap/Types' import { SessionServiceInterface } from '../Session/SessionServiceInterface' +import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' + import { RefreshSessionTokenResponse } from './RefreshSessionTokenResponse' import { RefreshSessionTokenDTO } from './RefreshSessionTokenDTO' @injectable() export class RefreshSessionToken { - constructor(@inject(TYPES.SessionService) private sessionService: SessionServiceInterface) {} + constructor( + @inject(TYPES.SessionService) private sessionService: SessionServiceInterface, + @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} async execute(dto: RefreshSessionTokenDTO): Promise { const session = await this.sessionService.getSessionFromToken(dto.accessToken) @@ -28,7 +38,7 @@ export class RefreshSessionToken { } } - if (session.refreshExpiration < dayjs.utc().toDate()) { + if (session.refreshExpiration < this.timer.getUTCDate()) { return { success: false, errorTag: 'expired-refresh-token', @@ -38,6 +48,14 @@ export class RefreshSessionToken { const sessionPayload = await this.sessionService.refreshTokens(session) + try { + await this.domainEventPublisher.publish( + this.domainEventFactory.createSessionRefreshedEvent({ userUuid: session.userUuid }), + ) + } catch (error) { + this.logger.error(`Failed to publish session refreshed event: ${(error as Error).message}`) + } + return { success: true, sessionPayload, diff --git a/packages/domain-events/src/Domain/Event/SessionCreatedEvent.ts b/packages/domain-events/src/Domain/Event/SessionCreatedEvent.ts new file mode 100644 index 000000000..db6c2ba2c --- /dev/null +++ b/packages/domain-events/src/Domain/Event/SessionCreatedEvent.ts @@ -0,0 +1,7 @@ +import { DomainEventInterface } from './DomainEventInterface' +import { SessionCreatedEventPayload } from './SessionCreatedEventPayload' + +export interface SessionCreatedEvent extends DomainEventInterface { + type: 'SESSION_CREATED' + payload: SessionCreatedEventPayload +} diff --git a/packages/domain-events/src/Domain/Event/SessionCreatedEventPayload.ts b/packages/domain-events/src/Domain/Event/SessionCreatedEventPayload.ts new file mode 100644 index 000000000..40a8f389e --- /dev/null +++ b/packages/domain-events/src/Domain/Event/SessionCreatedEventPayload.ts @@ -0,0 +1,3 @@ +export interface SessionCreatedEventPayload { + userUuid: string +} diff --git a/packages/domain-events/src/Domain/Event/SessionRefreshedEvent.ts b/packages/domain-events/src/Domain/Event/SessionRefreshedEvent.ts new file mode 100644 index 000000000..bd78d60f4 --- /dev/null +++ b/packages/domain-events/src/Domain/Event/SessionRefreshedEvent.ts @@ -0,0 +1,7 @@ +import { DomainEventInterface } from './DomainEventInterface' +import { SessionRefreshedEventPayload } from './SessionRefreshedEventPayload' + +export interface SessionRefreshedEvent extends DomainEventInterface { + type: 'SESSION_REFRESHED' + payload: SessionRefreshedEventPayload +} diff --git a/packages/domain-events/src/Domain/Event/SessionRefreshedEventPayload.ts b/packages/domain-events/src/Domain/Event/SessionRefreshedEventPayload.ts new file mode 100644 index 000000000..3e4386914 --- /dev/null +++ b/packages/domain-events/src/Domain/Event/SessionRefreshedEventPayload.ts @@ -0,0 +1,3 @@ +export interface SessionRefreshedEventPayload { + userUuid: string +} diff --git a/packages/domain-events/src/Domain/index.ts b/packages/domain-events/src/Domain/index.ts index 78a8e2e32..3bdeb0aeb 100644 --- a/packages/domain-events/src/Domain/index.ts +++ b/packages/domain-events/src/Domain/index.ts @@ -54,6 +54,10 @@ export * from './Event/RefundProcessedEvent' export * from './Event/RefundProcessedEventPayload' export * from './Event/RevisionsCopyRequestedEvent' export * from './Event/RevisionsCopyRequestedEventPayload' +export * from './Event/SessionCreatedEvent' +export * from './Event/SessionCreatedEventPayload' +export * from './Event/SessionRefreshedEvent' +export * from './Event/SessionRefreshedEventPayload' export * from './Event/SharedSubscriptionInvitationCanceledEvent' export * from './Event/SharedSubscriptionInvitationCanceledEventPayload' export * from './Event/SharedSubscriptionInvitationCreatedEvent'