feat(auth): add publishing session created and session refreshed events

This commit is contained in:
Karol Sójko
2023-02-23 11:10:15 +01:00
parent f13944badc
commit 5b98924561
11 changed files with 198 additions and 11 deletions

View File

@@ -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<SessionTokenData>
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>
logger.debug = jest.fn()
logger.error = jest.fn()
sessionPayload = {
access_token: 'access_token',
@@ -52,6 +65,12 @@ describe('AuthResponseFactory20200115', () => {
tokenEncoder = {} as jest.Mocked<TokenEncoderInterface<SessionTokenData>>
tokenEncoder.encodeToken = jest.fn().mockReturnValue('foobar')
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createSessionCreatedEvent = jest.fn().mockReturnValue({})
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
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',

View File

@@ -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<User>,
@inject(TYPES.SessionTokenEncoder) protected override tokenEncoder: TokenEncoderInterface<SessionTokenData>,
@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
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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>
session.uuid = '1-2-3'
session.refreshExpiration = dayjs.utc().add(1, 'day').toDate()
session.refreshExpiration = new Date(123)
sessionService = {} as jest.Mocked<SessionServiceInterface>
sessionService.isRefreshTokenValid = jest.fn().mockReturnValue(true)
@@ -25,6 +33,18 @@ describe('RefreshSessionToken', () => {
access_expiration: 123,
refresh_expiration: 234,
})
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createSessionRefreshedEvent = jest.fn().mockReturnValue({})
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
domainEventPublisher.publish = jest.fn()
timer = {} as jest.Mocked<TimerInterface>
timer.getUTCDate = jest.fn().mockReturnValue(new Date(100))
logger = {} as jest.Mocked<Logger>
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',

View File

@@ -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<RefreshSessionTokenResponse> {
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,

View File

@@ -0,0 +1,7 @@
import { DomainEventInterface } from './DomainEventInterface'
import { SessionCreatedEventPayload } from './SessionCreatedEventPayload'
export interface SessionCreatedEvent extends DomainEventInterface {
type: 'SESSION_CREATED'
payload: SessionCreatedEventPayload
}

View File

@@ -0,0 +1,3 @@
export interface SessionCreatedEventPayload {
userUuid: string
}

View File

@@ -0,0 +1,7 @@
import { DomainEventInterface } from './DomainEventInterface'
import { SessionRefreshedEventPayload } from './SessionRefreshedEventPayload'
export interface SessionRefreshedEvent extends DomainEventInterface {
type: 'SESSION_REFRESHED'
payload: SessionRefreshedEventPayload
}

View File

@@ -0,0 +1,3 @@
export interface SessionRefreshedEventPayload {
userUuid: string
}

View File

@@ -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'