Bladeren bron

feat(auth): invalidate other sessions for user if the email or password are changed (#684)

* feat(auth): invalidate other sessions for user if the email or password are changed

* fix(auth): handling credentials change in a legacy protocol scenario

* fix(auth): leave only the newly created session when changing credentials
Karol Sójko 1 jaar geleden
bovenliggende
commit
f39d3aca5b
35 gewijzigde bestanden met toevoegingen van 488 en 335 verwijderingen
  1. 3 5
      packages/auth/src/Bootstrap/Container.ts
  2. 1 1
      packages/auth/src/Bootstrap/Types.ts
  3. 2 2
      packages/auth/src/Domain/Auth/AuthResponseFactory20161215.spec.ts
  4. 9 6
      packages/auth/src/Domain/Auth/AuthResponseFactory20161215.ts
  5. 2 2
      packages/auth/src/Domain/Auth/AuthResponseFactory20190520.spec.ts
  6. 22 14
      packages/auth/src/Domain/Auth/AuthResponseFactory20200115.spec.ts
  7. 17 9
      packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts
  8. 2 1
      packages/auth/src/Domain/Auth/AuthResponseFactoryInterface.ts
  9. 3 1
      packages/auth/src/Domain/Session/SessionRepositoryInterface.ts
  10. 14 14
      packages/auth/src/Domain/Session/SessionService.spec.ts
  11. 10 4
      packages/auth/src/Domain/Session/SessionService.ts
  12. 2 2
      packages/auth/src/Domain/Session/SessionServiceInterface.ts
  13. 157 108
      packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.spec.ts
  14. 45 33
      packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.ts
  15. 0 8
      packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentialsResponse.ts
  16. 82 0
      packages/auth/src/Domain/UseCase/DeleteOtherSessionsForUser.spec.ts
  17. 46 0
      packages/auth/src/Domain/UseCase/DeleteOtherSessionsForUser.ts
  18. 5 0
      packages/auth/src/Domain/UseCase/DeleteOtherSessionsForUserDTO.ts
  19. 0 39
      packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUser.spec.ts
  20. 0 32
      packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUser.ts
  21. 0 4
      packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUserDTO.ts
  22. 0 3
      packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUserResponse.ts
  23. 4 1
      packages/auth/src/Domain/UseCase/Register.spec.ts
  24. 9 7
      packages/auth/src/Domain/UseCase/Register.ts
  25. 4 1
      packages/auth/src/Domain/UseCase/SignIn.spec.ts
  26. 9 7
      packages/auth/src/Domain/UseCase/SignIn.ts
  27. 1 1
      packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts
  28. 4 1
      packages/auth/src/Domain/UseCase/UpdateUser.spec.ts
  29. 9 7
      packages/auth/src/Domain/UseCase/UpdateUser.ts
  30. 8 7
      packages/auth/src/Infra/InversifyExpressUtils/AnnotatedSessionController.spec.ts
  31. 4 4
      packages/auth/src/Infra/InversifyExpressUtils/AnnotatedSessionController.ts
  32. 3 2
      packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.spec.ts
  33. 4 3
      packages/auth/src/Infra/InversifyExpressUtils/Base/BaseSessionController.ts
  34. 3 3
      packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts
  35. 4 3
      packages/auth/src/Infra/TypeORM/TypeORMSessionRepository.ts

+ 3 - 5
packages/auth/src/Bootstrap/Container.ts

@@ -38,7 +38,7 @@ import { GetUserKeyParams } from '../Domain/UseCase/GetUserKeyParams/GetUserKeyP
 import { UpdateUser } from '../Domain/UseCase/UpdateUser'
 import { RedisEphemeralSessionRepository } from '../Infra/Redis/RedisEphemeralSessionRepository'
 import { GetActiveSessionsForUser } from '../Domain/UseCase/GetActiveSessionsForUser'
-import { DeletePreviousSessionsForUser } from '../Domain/UseCase/DeletePreviousSessionsForUser'
+import { DeleteOtherSessionsForUser } from '../Domain/UseCase/DeleteOtherSessionsForUser'
 import { DeleteSessionForUser } from '../Domain/UseCase/DeleteSessionForUser'
 import { Register } from '../Domain/UseCase/Register'
 import { LockRepository } from '../Infra/Redis/LockRepository'
@@ -827,9 +827,7 @@ export class ContainerConfigLoader {
     container.bind<UpdateUser>(TYPES.Auth_UpdateUser).to(UpdateUser)
     container.bind<Register>(TYPES.Auth_Register).to(Register)
     container.bind<GetActiveSessionsForUser>(TYPES.Auth_GetActiveSessionsForUser).to(GetActiveSessionsForUser)
-    container
-      .bind<DeletePreviousSessionsForUser>(TYPES.Auth_DeletePreviousSessionsForUser)
-      .to(DeletePreviousSessionsForUser)
+    container.bind<DeleteOtherSessionsForUser>(TYPES.Auth_DeleteOtherSessionsForUser).to(DeleteOtherSessionsForUser)
     container.bind<DeleteSessionForUser>(TYPES.Auth_DeleteSessionForUser).to(DeleteSessionForUser)
     container.bind<ChangeCredentials>(TYPES.Auth_ChangeCredentials).to(ChangeCredentials)
     container.bind<GetSettings>(TYPES.Auth_GetSettings).to(GetSettings)
@@ -1178,7 +1176,7 @@ export class ContainerConfigLoader {
         .toConstantValue(
           new BaseSessionController(
             container.get(TYPES.Auth_DeleteSessionForUser),
-            container.get(TYPES.Auth_DeletePreviousSessionsForUser),
+            container.get(TYPES.Auth_DeleteOtherSessionsForUser),
             container.get(TYPES.Auth_RefreshSessionToken),
             container.get(TYPES.Auth_ControllerContainer),
           ),

+ 1 - 1
packages/auth/src/Bootstrap/Types.ts

@@ -113,7 +113,7 @@ const TYPES = {
   Auth_UpdateUser: Symbol.for('Auth_UpdateUser'),
   Auth_Register: Symbol.for('Auth_Register'),
   Auth_GetActiveSessionsForUser: Symbol.for('Auth_GetActiveSessionsForUser'),
-  Auth_DeletePreviousSessionsForUser: Symbol.for('Auth_DeletePreviousSessionsForUser'),
+  Auth_DeleteOtherSessionsForUser: Symbol.for('Auth_DeleteOtherSessionsForUser'),
   Auth_DeleteSessionForUser: Symbol.for('Auth_DeleteSessionForUser'),
   Auth_ChangeCredentials: Symbol.for('Auth_ChangePassword'),
   Auth_GetSettings: Symbol.for('Auth_GetSettings'),

+ 2 - 2
packages/auth/src/Domain/Auth/AuthResponseFactory20161215.spec.ts

@@ -30,7 +30,7 @@ describe('AuthResponseFactory20161215', () => {
   })
 
   it('should create a 20161215 auth response', async () => {
-    const response = await createFactory().createResponse({
+    const result = await createFactory().createResponse({
       user,
       apiVersion: '20161215',
       userAgent: 'Google Chrome',
@@ -38,7 +38,7 @@ describe('AuthResponseFactory20161215', () => {
       readonlyAccess: false,
     })
 
-    expect(response).toEqual({
+    expect(result.response).toEqual({
       user: { foo: 'bar' },
       token: 'foobar',
     })

+ 9 - 6
packages/auth/src/Domain/Auth/AuthResponseFactory20161215.ts

@@ -11,6 +11,7 @@ import { User } from '../User/User'
 import { AuthResponse20161215 } from './AuthResponse20161215'
 import { AuthResponse20200115 } from './AuthResponse20200115'
 import { AuthResponseFactoryInterface } from './AuthResponseFactoryInterface'
+import { Session } from '../Session/Session'
 
 @injectable()
 export class AuthResponseFactory20161215 implements AuthResponseFactoryInterface {
@@ -26,7 +27,7 @@ export class AuthResponseFactory20161215 implements AuthResponseFactoryInterface
     userAgent: string
     ephemeralSession: boolean
     readonlyAccess: boolean
-  }): Promise<AuthResponse20161215 | AuthResponse20200115> {
+  }): Promise<{ response: AuthResponse20161215 | AuthResponse20200115; session?: Session }> {
     this.logger.debug(`Creating JWT auth response for user ${dto.user.uuid}`)
 
     const data: SessionTokenData = {
@@ -39,12 +40,14 @@ export class AuthResponseFactory20161215 implements AuthResponseFactoryInterface
     this.logger.debug(`Created JWT token for user ${dto.user.uuid}: ${token}`)
 
     return {
-      user: this.userProjector.projectSimple(dto.user) as {
-        uuid: string
-        email: string
-        protocolVersion: ProtocolVersion
+      response: {
+        user: this.userProjector.projectSimple(dto.user) as {
+          uuid: string
+          email: string
+          protocolVersion: ProtocolVersion
+        },
+        token,
       },
-      token,
     }
   }
 }

+ 2 - 2
packages/auth/src/Domain/Auth/AuthResponseFactory20190520.spec.ts

@@ -29,7 +29,7 @@ describe('AuthResponseFactory20190520', () => {
   })
 
   it('should create a 20161215 auth response', async () => {
-    const response = await createFactory().createResponse({
+    const result = await createFactory().createResponse({
       user,
       apiVersion: '20161215',
       userAgent: 'Google Chrome',
@@ -37,7 +37,7 @@ describe('AuthResponseFactory20190520', () => {
       readonlyAccess: false,
     })
 
-    expect(response).toEqual({
+    expect(result.response).toEqual({
       user: { foo: 'bar' },
       token: 'foobar',
     })

+ 22 - 14
packages/auth/src/Domain/Auth/AuthResponseFactory20200115.spec.ts

@@ -11,6 +11,7 @@ import { User } from '../User/User'
 import { AuthResponseFactory20200115 } from './AuthResponseFactory20200115'
 import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
 import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
+import { Session } from '../Session/Session'
 
 describe('AuthResponseFactory20200115', () => {
   let sessionService: SessionServiceInterface
@@ -48,8 +49,12 @@ describe('AuthResponseFactory20200115', () => {
     }
 
     sessionService = {} as jest.Mocked<SessionServiceInterface>
-    sessionService.createNewSessionForUser = jest.fn().mockReturnValue(sessionPayload)
-    sessionService.createNewEphemeralSessionForUser = jest.fn().mockReturnValue(sessionPayload)
+    sessionService.createNewSessionForUser = jest
+      .fn()
+      .mockReturnValue({ sessionHttpRepresentation: sessionPayload, session: {} as jest.Mocked<Session> })
+    sessionService.createNewEphemeralSessionForUser = jest
+      .fn()
+      .mockReturnValue({ sessionHttpRepresentation: sessionPayload, session: {} as jest.Mocked<Session> })
 
     keyParamsFactory = {} as jest.Mocked<KeyParamsFactoryInterface>
     keyParamsFactory.create = jest.fn().mockReturnValue({
@@ -76,7 +81,7 @@ describe('AuthResponseFactory20200115', () => {
   it('should create a 20161215 auth response if user does not support sessions', async () => {
     user.supportsSessions = jest.fn().mockReturnValue(false)
 
-    const response = await createFactory().createResponse({
+    const result = await createFactory().createResponse({
       user,
       apiVersion: '20161215',
       userAgent: 'Google Chrome',
@@ -84,7 +89,7 @@ describe('AuthResponseFactory20200115', () => {
       readonlyAccess: false,
     })
 
-    expect(response).toEqual({
+    expect(result.response).toEqual({
       user: { foo: 'bar' },
       token: expect.any(String),
     })
@@ -93,7 +98,7 @@ describe('AuthResponseFactory20200115', () => {
   it('should create a 20200115 auth response', async () => {
     user.supportsSessions = jest.fn().mockReturnValue(true)
 
-    const response = await createFactory().createResponse({
+    const result = await createFactory().createResponse({
       user,
       apiVersion: '20200115',
       userAgent: 'Google Chrome',
@@ -101,7 +106,7 @@ describe('AuthResponseFactory20200115', () => {
       readonlyAccess: false,
     })
 
-    expect(response).toEqual({
+    expect(result.response).toEqual({
       key_params: {
         key1: 'value1',
         key2: 'value2',
@@ -124,7 +129,7 @@ describe('AuthResponseFactory20200115', () => {
     domainEventPublisher.publish = jest.fn().mockRejectedValue(new Error('test'))
     user.supportsSessions = jest.fn().mockReturnValue(true)
 
-    const response = await createFactory().createResponse({
+    const result = await createFactory().createResponse({
       user,
       apiVersion: '20200115',
       userAgent: 'Google Chrome',
@@ -132,7 +137,7 @@ describe('AuthResponseFactory20200115', () => {
       readonlyAccess: false,
     })
 
-    expect(response).toEqual({
+    expect(result.response).toEqual({
       key_params: {
         key1: 'value1',
         key2: 'value2',
@@ -153,7 +158,7 @@ describe('AuthResponseFactory20200115', () => {
   it('should create a 20200115 auth response with an ephemeral session', async () => {
     user.supportsSessions = jest.fn().mockReturnValue(true)
 
-    const response = await createFactory().createResponse({
+    const result = await createFactory().createResponse({
       user,
       apiVersion: '20200115',
       userAgent: 'Google Chrome',
@@ -161,7 +166,7 @@ describe('AuthResponseFactory20200115', () => {
       readonlyAccess: false,
     })
 
-    expect(response).toEqual({
+    expect(result.response).toEqual({
       key_params: {
         key1: 'value1',
         key2: 'value2',
@@ -183,11 +188,14 @@ describe('AuthResponseFactory20200115', () => {
     user.supportsSessions = jest.fn().mockReturnValue(true)
 
     sessionService.createNewSessionForUser = jest.fn().mockReturnValue({
-      ...sessionPayload,
-      readonly_access: true,
+      sessionHttpRepresentation: {
+        ...sessionPayload,
+        readonly_access: true,
+      },
+      session: {} as jest.Mocked<Session>,
     })
 
-    const response = await createFactory().createResponse({
+    const result = await createFactory().createResponse({
       user,
       apiVersion: '20200115',
       userAgent: 'Google Chrome',
@@ -195,7 +203,7 @@ describe('AuthResponseFactory20200115', () => {
       readonlyAccess: true,
     })
 
-    expect(response).toEqual({
+    expect(result.response).toEqual({
       key_params: {
         key1: 'value1',
         key2: 'value2',

+ 17 - 9
packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts

@@ -19,6 +19,7 @@ import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterfac
 
 import { AuthResponse20161215 } from './AuthResponse20161215'
 import { AuthResponse20200115 } from './AuthResponse20200115'
+import { Session } from '../Session/Session'
 
 @injectable()
 export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 {
@@ -40,21 +41,28 @@ export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 {
     userAgent: string
     ephemeralSession: boolean
     readonlyAccess: boolean
-  }): Promise<AuthResponse20161215 | AuthResponse20200115> {
+  }): Promise<{ response: AuthResponse20161215 | AuthResponse20200115; session?: Session }> {
     if (!dto.user.supportsSessions()) {
       this.logger.debug(`User ${dto.user.uuid} does not support sessions. Falling back to JWT auth response`)
 
       return super.createResponse(dto)
     }
 
-    const sessionPayload = await this.createSession(dto)
+    const sessionCreationResult = await this.createSession(dto)
 
-    this.logger.debug('Created session payload for user %s: %O', dto.user.uuid, sessionPayload)
+    this.logger.debug(
+      'Created session payload for user %s: %O',
+      dto.user.uuid,
+      sessionCreationResult.sessionHttpRepresentation,
+    )
 
     return {
-      session: sessionPayload,
-      key_params: this.keyParamsFactory.create(dto.user, true),
-      user: this.userProjector.projectSimple(dto.user) as SimpleUserProjection,
+      response: {
+        session: sessionCreationResult.sessionHttpRepresentation,
+        key_params: this.keyParamsFactory.create(dto.user, true),
+        user: this.userProjector.projectSimple(dto.user) as SimpleUserProjection,
+      },
+      session: sessionCreationResult.session,
     }
   }
 
@@ -64,12 +72,12 @@ export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 {
     userAgent: string
     ephemeralSession: boolean
     readonlyAccess: boolean
-  }): Promise<SessionBody> {
+  }): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }> {
     if (dto.ephemeralSession) {
       return this.sessionService.createNewEphemeralSessionForUser(dto)
     }
 
-    const session = this.sessionService.createNewSessionForUser(dto)
+    const sessionCreationResult = await this.sessionService.createNewSessionForUser(dto)
 
     try {
       await this.domainEventPublisher.publish(
@@ -79,6 +87,6 @@ export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 {
       this.logger.error(`Failed to publish session created event: ${(error as Error).message}`)
     }
 
-    return session
+    return sessionCreationResult
   }
 }

+ 2 - 1
packages/auth/src/Domain/Auth/AuthResponseFactoryInterface.ts

@@ -1,3 +1,4 @@
+import { Session } from '../Session/Session'
 import { User } from '../User/User'
 import { AuthResponse20161215 } from './AuthResponse20161215'
 import { AuthResponse20200115 } from './AuthResponse20200115'
@@ -9,5 +10,5 @@ export interface AuthResponseFactoryInterface {
     userAgent: string
     ephemeralSession: boolean
     readonlyAccess: boolean
-  }): Promise<AuthResponse20161215 | AuthResponse20200115>
+  }): Promise<{ response: AuthResponse20161215 | AuthResponse20200115; session?: Session }>
 }

+ 3 - 1
packages/auth/src/Domain/Session/SessionRepositoryInterface.ts

@@ -1,3 +1,5 @@
+import { Uuid } from '@standardnotes/domain-core'
+
 import { Session } from './Session'
 
 export interface SessionRepositoryInterface {
@@ -5,7 +7,7 @@ export interface SessionRepositoryInterface {
   findOneByUuidAndUserUuid(uuid: string, userUuid: string): Promise<Session | null>
   findAllByRefreshExpirationAndUserUuid(userUuid: string): Promise<Array<Session>>
   findAllByUserUuid(userUuid: string): Promise<Array<Session>>
-  deleteAllByUserUuid(userUuid: string, currentSessionUuid: string): Promise<void>
+  deleteAllByUserUuidExceptOne(dto: { userUuid: Uuid; currentSessionUuid: Uuid }): Promise<void>
   deleteOneByUuid(uuid: string): Promise<void>
   updateHashedTokens(uuid: string, hashedAccessToken: string, hashedRefreshToken: string): Promise<void>
   updatedTokenExpirationDates(uuid: string, accessExpiration: Date, refreshExpiration: Date): Promise<void>

+ 14 - 14
packages/auth/src/Domain/Session/SessionService.spec.ts

@@ -154,7 +154,7 @@ describe('SessionService', () => {
     const user = {} as jest.Mocked<User>
     user.uuid = '123'
 
-    const sessionPayload = await createService().createNewSessionForUser({
+    const result = await createService().createNewSessionForUser({
       user,
       apiVersion: '003',
       userAgent: 'Google Chrome',
@@ -176,7 +176,7 @@ describe('SessionService', () => {
       readonlyAccess: false,
     })
 
-    expect(sessionPayload).toEqual({
+    expect(result.sessionHttpRepresentation).toEqual({
       access_expiration: 123,
       access_token: expect.any(String),
       refresh_expiration: 123,
@@ -190,7 +190,7 @@ describe('SessionService', () => {
     user.email = 'demo@standardnotes.com'
     user.uuid = '123'
 
-    const sessionPayload = await createService().createNewSessionForUser({
+    const result = await createService().createNewSessionForUser({
       user,
       apiVersion: '003',
       userAgent: 'Google Chrome',
@@ -212,7 +212,7 @@ describe('SessionService', () => {
       readonlyAccess: true,
     })
 
-    expect(sessionPayload).toEqual({
+    expect(result.sessionHttpRepresentation).toEqual({
       access_expiration: 123,
       access_token: expect.any(String),
       refresh_expiration: 123,
@@ -229,7 +229,7 @@ describe('SessionService', () => {
       value: LogSessionUserAgentOption.Disabled,
     } as jest.Mocked<Setting>)
 
-    const sessionPayload = await createService().createNewSessionForUser({
+    const result = await createService().createNewSessionForUser({
       user,
       apiVersion: '003',
       userAgent: 'Google Chrome',
@@ -250,7 +250,7 @@ describe('SessionService', () => {
       readonlyAccess: false,
     })
 
-    expect(sessionPayload).toEqual({
+    expect(result.sessionHttpRepresentation).toEqual({
       access_expiration: 123,
       access_token: expect.any(String),
       refresh_expiration: 123,
@@ -305,7 +305,7 @@ describe('SessionService', () => {
     user.uuid = '123'
     user.email = 'test@test.te'
 
-    const sessionPayload = await createService().createNewSessionForUser({
+    const result = await createService().createNewSessionForUser({
       user,
       apiVersion: '003',
       userAgent: 'Google Chrome',
@@ -317,7 +317,7 @@ describe('SessionService', () => {
       username: 'test@test.te',
       subscriptionPlanName: null,
     })
-    expect(sessionPayload).toEqual({
+    expect(result.sessionHttpRepresentation).toEqual({
       access_expiration: 123,
       access_token: expect.any(String),
       refresh_expiration: 123,
@@ -333,7 +333,7 @@ describe('SessionService', () => {
     user.uuid = '123'
     user.email = 'test@test.te'
 
-    const sessionPayload = await createService().createNewSessionForUser({
+    const result = await createService().createNewSessionForUser({
       user,
       apiVersion: '003',
       userAgent: 'Google Chrome',
@@ -345,7 +345,7 @@ describe('SessionService', () => {
       username: 'test@test.te',
       subscriptionPlanName: null,
     })
-    expect(sessionPayload).toEqual({
+    expect(result.sessionHttpRepresentation).toEqual({
       access_expiration: 123,
       access_token: expect.any(String),
       refresh_expiration: 123,
@@ -361,7 +361,7 @@ describe('SessionService', () => {
     user.uuid = '123'
     user.email = 'test@test.te'
 
-    const sessionPayload = await createService().createNewSessionForUser({
+    const result = await createService().createNewSessionForUser({
       user,
       apiVersion: '003',
       userAgent: 'Google Chrome',
@@ -373,7 +373,7 @@ describe('SessionService', () => {
       username: 'test@test.te',
       subscriptionPlanName: null,
     })
-    expect(sessionPayload).toEqual({
+    expect(result.sessionHttpRepresentation).toEqual({
       access_expiration: 123,
       access_token: expect.any(String),
       refresh_expiration: 123,
@@ -386,7 +386,7 @@ describe('SessionService', () => {
     const user = {} as jest.Mocked<User>
     user.uuid = '123'
 
-    const sessionPayload = await createService().createNewEphemeralSessionForUser({
+    const result = await createService().createNewEphemeralSessionForUser({
       user,
       apiVersion: '003',
       userAgent: 'Google Chrome',
@@ -408,7 +408,7 @@ describe('SessionService', () => {
       readonlyAccess: false,
     })
 
-    expect(sessionPayload).toEqual({
+    expect(result.sessionHttpRepresentation).toEqual({
       access_expiration: 123,
       access_token: expect.any(String),
       refresh_expiration: 123,

+ 10 - 4
packages/auth/src/Domain/Session/SessionService.ts

@@ -49,7 +49,7 @@ export class SessionService implements SessionServiceInterface {
     apiVersion: string
     userAgent: string
     readonlyAccess: boolean
-  }): Promise<SessionBody> {
+  }): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }> {
     const session = await this.createSession({
       ephemeral: false,
       ...dto,
@@ -73,7 +73,10 @@ export class SessionService implements SessionServiceInterface {
       this.logger.error(`Could not trace session while creating cross service token.: ${(error as Error).message}`)
     }
 
-    return sessionPayload
+    return {
+      sessionHttpRepresentation: sessionPayload,
+      session,
+    }
   }
 
   async createNewEphemeralSessionForUser(dto: {
@@ -81,7 +84,7 @@ export class SessionService implements SessionServiceInterface {
     apiVersion: string
     userAgent: string
     readonlyAccess: boolean
-  }): Promise<SessionBody> {
+  }): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }> {
     const ephemeralSession = await this.createSession({
       ephemeral: true,
       ...dto,
@@ -91,7 +94,10 @@ export class SessionService implements SessionServiceInterface {
 
     await this.ephemeralSessionRepository.save(ephemeralSession)
 
-    return sessionPayload
+    return {
+      sessionHttpRepresentation: sessionPayload,
+      session: ephemeralSession,
+    }
   }
 
   async refreshTokens(session: Session): Promise<SessionBody> {

+ 2 - 2
packages/auth/src/Domain/Session/SessionServiceInterface.ts

@@ -9,13 +9,13 @@ export interface SessionServiceInterface {
     apiVersion: string
     userAgent: string
     readonlyAccess: boolean
-  }): Promise<SessionBody>
+  }): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }>
   createNewEphemeralSessionForUser(dto: {
     user: User
     apiVersion: string
     userAgent: string
     readonlyAccess: boolean
-  }): Promise<SessionBody>
+  }): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }>
   refreshTokens(session: Session): Promise<SessionBody>
   getSessionFromToken(token: string): Promise<Session | undefined>
   getRevokedSessionFromToken(token: string): Promise<RevokedSession | null>

+ 157 - 108
packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.spec.ts

@@ -11,7 +11,10 @@ import { User } from '../../User/User'
 import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
 
 import { ChangeCredentials } from './ChangeCredentials'
-import { Username } from '@standardnotes/domain-core'
+import { Result, Username } from '@standardnotes/domain-core'
+import { DeleteOtherSessionsForUser } from '../DeleteOtherSessionsForUser'
+import { ApiVersion } from '../../Api/ApiVersion'
+import { Session } from '../../Session/Session'
 
 describe('ChangeCredentials', () => {
   let userRepository: UserRepositoryInterface
@@ -21,13 +24,23 @@ describe('ChangeCredentials', () => {
   let domainEventFactory: DomainEventFactoryInterface
   let timer: TimerInterface
   let user: User
+  let deleteOtherSessionsForUser: DeleteOtherSessionsForUser
 
   const createUseCase = () =>
-    new ChangeCredentials(userRepository, authResponseFactoryResolver, domainEventPublisher, domainEventFactory, timer)
+    new ChangeCredentials(
+      userRepository,
+      authResponseFactoryResolver,
+      domainEventPublisher,
+      domainEventFactory,
+      timer,
+      deleteOtherSessionsForUser,
+    )
 
   beforeEach(() => {
     authResponseFactory = {} as jest.Mocked<AuthResponseFactoryInterface>
-    authResponseFactory.createResponse = jest.fn().mockReturnValue({ foo: 'bar' })
+    authResponseFactory.createResponse = jest
+      .fn()
+      .mockReturnValue({ response: { foo: 'bar' }, session: { uuid: '1-2-3' } as jest.Mocked<Session> })
 
     authResponseFactoryResolver = {} as jest.Mocked<AuthResponseFactoryResolverInterface>
     authResponseFactoryResolver.resolveAuthResponseFactoryVersion = jest.fn().mockReturnValue(authResponseFactory)
@@ -49,27 +62,25 @@ describe('ChangeCredentials', () => {
 
     timer = {} as jest.Mocked<TimerInterface>
     timer.getUTCDate = jest.fn().mockReturnValue(new Date(1))
+
+    deleteOtherSessionsForUser = {} as jest.Mocked<DeleteOtherSessionsForUser>
+    deleteOtherSessionsForUser.execute = jest.fn().mockReturnValue(Result.ok())
   })
 
   it('should change password', async () => {
-    expect(
-      await createUseCase().execute({
-        username: Username.create('test@test.te').getValue(),
-        apiVersion: '20190520',
-        currentPassword: 'qweqwe123123',
-        newPassword: 'test234',
-        pwNonce: 'asdzxc',
-        updatedWithUserAgent: 'Google Chrome',
-        kpCreated: '123',
-        kpOrigination: 'password-change',
-      }),
-    ).toEqual({
-      success: true,
-      authResponse: {
-        foo: 'bar',
-      },
+    const result = await createUseCase().execute({
+      username: Username.create('test@test.te').getValue(),
+      apiVersion: ApiVersion.v20200115,
+      currentPassword: 'qweqwe123123',
+      newPassword: 'test234',
+      pwNonce: 'asdzxc',
+      updatedWithUserAgent: 'Google Chrome',
+      kpCreated: '123',
+      kpOrigination: 'password-change',
     })
 
+    expect(result.isFailed()).toBeFalsy()
+
     expect(userRepository.save).toHaveBeenCalledWith({
       encryptedPassword: expect.any(String),
       pwNonce: 'asdzxc',
@@ -81,29 +92,24 @@ describe('ChangeCredentials', () => {
     })
     expect(domainEventPublisher.publish).not.toHaveBeenCalled()
     expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled()
+    expect(deleteOtherSessionsForUser.execute).toHaveBeenCalled()
   })
 
   it('should change email', async () => {
     userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValueOnce(user).mockReturnValueOnce(null)
 
-    expect(
-      await createUseCase().execute({
-        username: Username.create('test@test.te').getValue(),
-        apiVersion: '20190520',
-        currentPassword: 'qweqwe123123',
-        newPassword: 'test234',
-        newEmail: 'new@test.te',
-        pwNonce: 'asdzxc',
-        updatedWithUserAgent: 'Google Chrome',
-        kpCreated: '123',
-        kpOrigination: 'password-change',
-      }),
-    ).toEqual({
-      success: true,
-      authResponse: {
-        foo: 'bar',
-      },
+    const result = await createUseCase().execute({
+      username: Username.create('test@test.te').getValue(),
+      apiVersion: ApiVersion.v20200115,
+      currentPassword: 'qweqwe123123',
+      newPassword: 'test234',
+      newEmail: 'new@test.te',
+      pwNonce: 'asdzxc',
+      updatedWithUserAgent: 'Google Chrome',
+      kpCreated: '123',
+      kpOrigination: 'password-change',
     })
+    expect(result.isFailed()).toBeFalsy()
 
     expect(userRepository.save).toHaveBeenCalledWith({
       encryptedPassword: expect.any(String),
@@ -116,6 +122,7 @@ describe('ChangeCredentials', () => {
     })
     expect(domainEventFactory.createUserEmailChangedEvent).toHaveBeenCalledWith('1-2-3', 'test@test.te', 'new@test.te')
     expect(domainEventPublisher.publish).toHaveBeenCalled()
+    expect(deleteOtherSessionsForUser.execute).toHaveBeenCalled()
   })
 
   it('should not change email if already taken', async () => {
@@ -124,22 +131,19 @@ describe('ChangeCredentials', () => {
       .mockReturnValueOnce(user)
       .mockReturnValueOnce({} as jest.Mocked<User>)
 
-    expect(
-      await createUseCase().execute({
-        username: Username.create('test@test.te').getValue(),
-        apiVersion: '20190520',
-        currentPassword: 'qweqwe123123',
-        newPassword: 'test234',
-        newEmail: 'new@test.te',
-        pwNonce: 'asdzxc',
-        updatedWithUserAgent: 'Google Chrome',
-        kpCreated: '123',
-        kpOrigination: 'password-change',
-      }),
-    ).toEqual({
-      success: false,
-      errorMessage: 'The email you entered is already taken. Please try again.',
+    const result = await createUseCase().execute({
+      username: Username.create('test@test.te').getValue(),
+      apiVersion: ApiVersion.v20200115,
+      currentPassword: 'qweqwe123123',
+      newPassword: 'test234',
+      newEmail: 'new@test.te',
+      pwNonce: 'asdzxc',
+      updatedWithUserAgent: 'Google Chrome',
+      kpCreated: '123',
+      kpOrigination: 'password-change',
     })
+    expect(result.isFailed()).toBeTruthy()
+    expect(result.getError()).toEqual('The email you entered is already taken. Please try again.')
 
     expect(userRepository.save).not.toHaveBeenCalled()
     expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled()
@@ -147,22 +151,19 @@ describe('ChangeCredentials', () => {
   })
 
   it('should not change email if the new email is invalid', async () => {
-    expect(
-      await createUseCase().execute({
-        username: Username.create('test@test.te').getValue(),
-        apiVersion: '20190520',
-        currentPassword: 'qweqwe123123',
-        newPassword: 'test234',
-        newEmail: '',
-        pwNonce: 'asdzxc',
-        updatedWithUserAgent: 'Google Chrome',
-        kpCreated: '123',
-        kpOrigination: 'password-change',
-      }),
-    ).toEqual({
-      success: false,
-      errorMessage: 'Username cannot be empty',
+    const result = await createUseCase().execute({
+      username: Username.create('test@test.te').getValue(),
+      apiVersion: ApiVersion.v20200115,
+      currentPassword: 'qweqwe123123',
+      newPassword: 'test234',
+      newEmail: '',
+      pwNonce: 'asdzxc',
+      updatedWithUserAgent: 'Google Chrome',
+      kpCreated: '123',
+      kpOrigination: 'password-change',
     })
+    expect(result.isFailed()).toBeTruthy()
+    expect(result.getError()).toEqual('Username cannot be empty')
 
     expect(userRepository.save).not.toHaveBeenCalled()
     expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled()
@@ -172,63 +173,52 @@ describe('ChangeCredentials', () => {
   it('should not change email if the user is not found', async () => {
     userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(null)
 
-    expect(
-      await createUseCase().execute({
-        username: Username.create('test@test.te').getValue(),
-        apiVersion: '20190520',
-        currentPassword: 'qweqwe123123',
-        newPassword: 'test234',
-        newEmail: '',
-        pwNonce: 'asdzxc',
-        updatedWithUserAgent: 'Google Chrome',
-        kpCreated: '123',
-        kpOrigination: 'password-change',
-      }),
-    ).toEqual({
-      success: false,
-      errorMessage: 'User not found.',
+    const result = await createUseCase().execute({
+      username: Username.create('test@test.te').getValue(),
+      apiVersion: ApiVersion.v20200115,
+      currentPassword: 'qweqwe123123',
+      newPassword: 'test234',
+      newEmail: '',
+      pwNonce: 'asdzxc',
+      updatedWithUserAgent: 'Google Chrome',
+      kpCreated: '123',
+      kpOrigination: 'password-change',
     })
 
+    expect(result.isFailed()).toBeTruthy()
+    expect(result.getError()).toEqual('User not found.')
+
     expect(userRepository.save).not.toHaveBeenCalled()
     expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled()
     expect(domainEventPublisher.publish).not.toHaveBeenCalled()
   })
 
   it('should not change password if current password is incorrect', async () => {
-    expect(
-      await createUseCase().execute({
-        username: Username.create('test@test.te').getValue(),
-        apiVersion: '20190520',
-        currentPassword: 'test123',
-        newPassword: 'test234',
-        pwNonce: 'asdzxc',
-        updatedWithUserAgent: 'Google Chrome',
-      }),
-    ).toEqual({
-      success: false,
-      errorMessage: 'The current password you entered is incorrect. Please try again.',
+    const result = await createUseCase().execute({
+      username: Username.create('test@test.te').getValue(),
+      apiVersion: ApiVersion.v20200115,
+      currentPassword: 'test123',
+      newPassword: 'test234',
+      pwNonce: 'asdzxc',
+      updatedWithUserAgent: 'Google Chrome',
     })
+    expect(result.isFailed()).toBeTruthy()
+    expect(result.getError()).toEqual('The current password you entered is incorrect. Please try again.')
 
     expect(userRepository.save).not.toHaveBeenCalled()
   })
 
   it('should update protocol version while changing password', async () => {
-    expect(
-      await createUseCase().execute({
-        username: Username.create('test@test.te').getValue(),
-        apiVersion: '20190520',
-        currentPassword: 'qweqwe123123',
-        newPassword: 'test234',
-        pwNonce: 'asdzxc',
-        updatedWithUserAgent: 'Google Chrome',
-        protocolVersion: '004',
-      }),
-    ).toEqual({
-      success: true,
-      authResponse: {
-        foo: 'bar',
-      },
+    const result = await createUseCase().execute({
+      username: Username.create('test@test.te').getValue(),
+      apiVersion: ApiVersion.v20200115,
+      currentPassword: 'qweqwe123123',
+      newPassword: 'test234',
+      pwNonce: 'asdzxc',
+      updatedWithUserAgent: 'Google Chrome',
+      protocolVersion: '004',
     })
+    expect(result.isFailed()).toBeFalsy()
 
     expect(userRepository.save).toHaveBeenCalledWith({
       encryptedPassword: expect.any(String),
@@ -239,4 +229,63 @@ describe('ChangeCredentials', () => {
       updatedAt: new Date(1),
     })
   })
+
+  it('should not delete other sessions for user if neither passoword nor email are changed', async () => {
+    userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValueOnce(user)
+
+    const result = await createUseCase().execute({
+      username: Username.create('test@test.te').getValue(),
+      apiVersion: ApiVersion.v20200115,
+      currentPassword: 'qweqwe123123',
+      newPassword: 'qweqwe123123',
+      newEmail: undefined,
+      pwNonce: 'asdzxc',
+      updatedWithUserAgent: 'Google Chrome',
+      kpCreated: '123',
+      kpOrigination: 'password-change',
+    })
+    expect(result.isFailed()).toBeFalsy()
+
+    expect(userRepository.save).toHaveBeenCalledWith({
+      encryptedPassword: expect.any(String),
+      email: 'test@test.te',
+      uuid: '1-2-3',
+      pwNonce: 'asdzxc',
+      kpCreated: '123',
+      kpOrigination: 'password-change',
+      updatedAt: new Date(1),
+    })
+    expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled()
+    expect(domainEventPublisher.publish).not.toHaveBeenCalled()
+    expect(deleteOtherSessionsForUser.execute).not.toHaveBeenCalled()
+  })
+
+  it('should not delete other sessions for user if the caller does not support sessions', async () => {
+    authResponseFactory.createResponse = jest.fn().mockReturnValue({ response: { foo: 'bar' } })
+
+    const result = await createUseCase().execute({
+      username: Username.create('test@test.te').getValue(),
+      apiVersion: ApiVersion.v20200115,
+      currentPassword: 'qweqwe123123',
+      newPassword: 'test234',
+      pwNonce: 'asdzxc',
+      updatedWithUserAgent: 'Google Chrome',
+      kpCreated: '123',
+      kpOrigination: 'password-change',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+
+    expect(userRepository.save).toHaveBeenCalledWith({
+      encryptedPassword: expect.any(String),
+      pwNonce: 'asdzxc',
+      kpCreated: '123',
+      email: 'test@test.te',
+      uuid: '1-2-3',
+      kpOrigination: 'password-change',
+      updatedAt: new Date(1),
+    })
+
+    expect(deleteOtherSessionsForUser.execute).not.toHaveBeenCalled()
+  })
 })

+ 45 - 33
packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.ts

@@ -1,20 +1,22 @@
 import * as bcrypt from 'bcryptjs'
 import { inject, injectable } from 'inversify'
+import { DomainEventPublisherInterface, UserEmailChangedEvent } from '@standardnotes/domain-events'
+import { TimerInterface } from '@standardnotes/time'
+import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core'
+
 import TYPES from '../../../Bootstrap/Types'
 import { AuthResponseFactoryResolverInterface } from '../../Auth/AuthResponseFactoryResolverInterface'
-
 import { User } from '../../User/User'
 import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
 import { ChangeCredentialsDTO } from './ChangeCredentialsDTO'
-import { ChangeCredentialsResponse } from './ChangeCredentialsResponse'
-import { UseCaseInterface } from '../UseCaseInterface'
 import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
-import { DomainEventPublisherInterface, UserEmailChangedEvent } from '@standardnotes/domain-events'
-import { TimerInterface } from '@standardnotes/time'
-import { Username } from '@standardnotes/domain-core'
+import { DeleteOtherSessionsForUser } from '../DeleteOtherSessionsForUser'
+import { AuthResponse20161215 } from '../../Auth/AuthResponse20161215'
+import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
+import { Session } from '../../Session/Session'
 
 @injectable()
-export class ChangeCredentials implements UseCaseInterface {
+export class ChangeCredentials implements UseCaseInterface<AuthResponse20161215 | AuthResponse20200115> {
   constructor(
     @inject(TYPES.Auth_UserRepository) private userRepository: UserRepositoryInterface,
     @inject(TYPES.Auth_AuthResponseFactoryResolver)
@@ -22,22 +24,18 @@ export class ChangeCredentials implements UseCaseInterface {
     @inject(TYPES.Auth_DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface,
     @inject(TYPES.Auth_DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
     @inject(TYPES.Auth_Timer) private timer: TimerInterface,
+    @inject(TYPES.Auth_DeleteOtherSessionsForUser)
+    private deleteOtherSessionsForUserUseCase: DeleteOtherSessionsForUser,
   ) {}
 
-  async execute(dto: ChangeCredentialsDTO): Promise<ChangeCredentialsResponse> {
+  async execute(dto: ChangeCredentialsDTO): Promise<Result<AuthResponse20161215 | AuthResponse20200115>> {
     const user = await this.userRepository.findOneByUsernameOrEmail(dto.username)
     if (!user) {
-      return {
-        success: false,
-        errorMessage: 'User not found.',
-      }
+      return Result.fail('User not found.')
     }
 
     if (!(await bcrypt.compare(dto.currentPassword, user.encryptedPassword))) {
-      return {
-        success: false,
-        errorMessage: 'The current password you entered is incorrect. Please try again.',
-      }
+      return Result.fail('The current password you entered is incorrect. Please try again.')
     }
 
     user.encryptedPassword = await bcrypt.hash(dto.newPassword, User.PASSWORD_HASH_COST)
@@ -46,19 +44,13 @@ export class ChangeCredentials implements UseCaseInterface {
     if (dto.newEmail !== undefined) {
       const newUsernameOrError = Username.create(dto.newEmail)
       if (newUsernameOrError.isFailed()) {
-        return {
-          success: false,
-          errorMessage: newUsernameOrError.getError(),
-        }
+        return Result.fail(newUsernameOrError.getError())
       }
       const newUsername = newUsernameOrError.getValue()
 
       const existingUser = await this.userRepository.findOneByUsernameOrEmail(newUsername)
       if (existingUser !== null) {
-        return {
-          success: false,
-          errorMessage: 'The email you entered is already taken. Please try again.',
-        }
+        return Result.fail('The email you entered is already taken. Please try again.')
       }
 
       userEmailChangedEvent = this.domainEventFactory.createUserEmailChangedEvent(
@@ -90,15 +82,35 @@ export class ChangeCredentials implements UseCaseInterface {
 
     const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(dto.apiVersion)
 
-    return {
-      success: true,
-      authResponse: await authResponseFactory.createResponse({
-        user: updatedUser,
-        apiVersion: dto.apiVersion,
-        userAgent: dto.updatedWithUserAgent,
-        ephemeralSession: false,
-        readonlyAccess: false,
-      }),
+    const authResponse = await authResponseFactory.createResponse({
+      user: updatedUser,
+      apiVersion: dto.apiVersion,
+      userAgent: dto.updatedWithUserAgent,
+      ephemeralSession: false,
+      readonlyAccess: false,
+    })
+
+    if (authResponse.session) {
+      await this.deleteOtherSessionsForUserIfNeeded(user.uuid, authResponse.session, dto)
+    }
+
+    return Result.ok(authResponse.response)
+  }
+
+  private async deleteOtherSessionsForUserIfNeeded(
+    userUuid: string,
+    session: Session,
+    dto: ChangeCredentialsDTO,
+  ): Promise<void> {
+    const passwordHasChanged = dto.newPassword !== dto.currentPassword
+    const userEmailChanged = dto.newEmail !== undefined
+
+    if (passwordHasChanged || userEmailChanged) {
+      await this.deleteOtherSessionsForUserUseCase.execute({
+        userUuid,
+        currentSessionUuid: session.uuid,
+        markAsRevoked: false,
+      })
     }
   }
 }

+ 0 - 8
packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentialsResponse.ts

@@ -1,8 +0,0 @@
-import { AuthResponse20161215 } from '../../Auth/AuthResponse20161215'
-import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
-
-export type ChangeCredentialsResponse = {
-  success: boolean
-  authResponse?: AuthResponse20161215 | AuthResponse20200115
-  errorMessage?: string
-}

+ 82 - 0
packages/auth/src/Domain/UseCase/DeleteOtherSessionsForUser.spec.ts

@@ -0,0 +1,82 @@
+import 'reflect-metadata'
+
+import { Session } from '../Session/Session'
+import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface'
+import { SessionServiceInterface } from '../Session/SessionServiceInterface'
+
+import { DeleteOtherSessionsForUser } from './DeleteOtherSessionsForUser'
+
+describe('DeleteOtherSessionsForUser', () => {
+  let sessionRepository: SessionRepositoryInterface
+  let sessionService: SessionServiceInterface
+  let session: Session
+  let currentSession: Session
+
+  const createUseCase = () => new DeleteOtherSessionsForUser(sessionRepository, sessionService)
+
+  beforeEach(() => {
+    session = {} as jest.Mocked<Session>
+    session.uuid = '00000000-0000-0000-0000-000000000000'
+
+    currentSession = {} as jest.Mocked<Session>
+    currentSession.uuid = '00000000-0000-0000-0000-000000000001'
+
+    sessionRepository = {} as jest.Mocked<SessionRepositoryInterface>
+    sessionRepository.deleteAllByUserUuidExceptOne = jest.fn()
+    sessionRepository.findAllByUserUuid = jest.fn().mockReturnValue([session, currentSession])
+
+    sessionService = {} as jest.Mocked<SessionServiceInterface>
+    sessionService.createRevokedSession = jest.fn()
+  })
+
+  it('should delete all sessions except current for a given user', async () => {
+    const result = await createUseCase().execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      currentSessionUuid: '00000000-0000-0000-0000-000000000001',
+      markAsRevoked: true,
+    })
+    expect(result.isFailed()).toBeFalsy()
+
+    expect(sessionRepository.deleteAllByUserUuidExceptOne).toHaveBeenCalled()
+
+    expect(sessionService.createRevokedSession).toHaveBeenCalledWith(session)
+    expect(sessionService.createRevokedSession).not.toHaveBeenCalledWith(currentSession)
+  })
+
+  it('should delete all sessions except current for a given user without marking as revoked', async () => {
+    const result = await createUseCase().execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      currentSessionUuid: '00000000-0000-0000-0000-000000000001',
+      markAsRevoked: false,
+    })
+    expect(result.isFailed()).toBeFalsy()
+
+    expect(sessionRepository.deleteAllByUserUuidExceptOne).toHaveBeenCalled()
+
+    expect(sessionService.createRevokedSession).not.toHaveBeenCalled()
+  })
+
+  it('should not delete any sessions if the user uuid is invalid', async () => {
+    const result = await createUseCase().execute({
+      userUuid: 'invalid',
+      currentSessionUuid: '00000000-0000-0000-0000-000000000001',
+      markAsRevoked: true,
+    })
+    expect(result.isFailed()).toBeTruthy()
+
+    expect(sessionRepository.deleteAllByUserUuidExceptOne).not.toHaveBeenCalled()
+    expect(sessionService.createRevokedSession).not.toHaveBeenCalled()
+  })
+
+  it('should not delete any sessions if the current session uuid is invalid', async () => {
+    const result = await createUseCase().execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      currentSessionUuid: 'invalid',
+      markAsRevoked: true,
+    })
+    expect(result.isFailed()).toBeTruthy()
+
+    expect(sessionRepository.deleteAllByUserUuidExceptOne).not.toHaveBeenCalled()
+    expect(sessionService.createRevokedSession).not.toHaveBeenCalled()
+  })
+})

+ 46 - 0
packages/auth/src/Domain/UseCase/DeleteOtherSessionsForUser.ts

@@ -0,0 +1,46 @@
+import { inject, injectable } from 'inversify'
+import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+
+import TYPES from '../../Bootstrap/Types'
+import { Session } from '../Session/Session'
+import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface'
+import { SessionServiceInterface } from '../Session/SessionServiceInterface'
+import { DeleteOtherSessionsForUserDTO } from './DeleteOtherSessionsForUserDTO'
+
+@injectable()
+export class DeleteOtherSessionsForUser implements UseCaseInterface<void> {
+  constructor(
+    @inject(TYPES.Auth_SessionRepository) private sessionRepository: SessionRepositoryInterface,
+    @inject(TYPES.Auth_SessionService) private sessionService: SessionServiceInterface,
+  ) {}
+
+  async execute(dto: DeleteOtherSessionsForUserDTO): Promise<Result<void>> {
+    const userUuidOrError = Uuid.create(dto.userUuid)
+    if (userUuidOrError.isFailed()) {
+      return Result.fail(userUuidOrError.getError())
+    }
+    const userUuid = userUuidOrError.getValue()
+
+    const currentSessionUuidOrError = Uuid.create(dto.currentSessionUuid)
+    if (currentSessionUuidOrError.isFailed()) {
+      return Result.fail(currentSessionUuidOrError.getError())
+    }
+    const currentSessionUuid = currentSessionUuidOrError.getValue()
+
+    const sessions = await this.sessionRepository.findAllByUserUuid(dto.userUuid)
+
+    if (dto.markAsRevoked) {
+      await Promise.all(
+        sessions.map(async (session: Session) => {
+          if (session.uuid !== currentSessionUuid.value) {
+            await this.sessionService.createRevokedSession(session)
+          }
+        }),
+      )
+    }
+
+    await this.sessionRepository.deleteAllByUserUuidExceptOne({ userUuid, currentSessionUuid })
+
+    return Result.ok()
+  }
+}

+ 5 - 0
packages/auth/src/Domain/UseCase/DeleteOtherSessionsForUserDTO.ts

@@ -0,0 +1,5 @@
+export type DeleteOtherSessionsForUserDTO = {
+  userUuid: string
+  currentSessionUuid: string
+  markAsRevoked: boolean
+}

+ 0 - 39
packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUser.spec.ts

@@ -1,39 +0,0 @@
-import 'reflect-metadata'
-import { Session } from '../Session/Session'
-import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface'
-import { SessionServiceInterface } from '../Session/SessionServiceInterface'
-
-import { DeletePreviousSessionsForUser } from './DeletePreviousSessionsForUser'
-
-describe('DeletePreviousSessionsForUser', () => {
-  let sessionRepository: SessionRepositoryInterface
-  let sessionService: SessionServiceInterface
-  let session: Session
-  let currentSession: Session
-
-  const createUseCase = () => new DeletePreviousSessionsForUser(sessionRepository, sessionService)
-
-  beforeEach(() => {
-    session = {} as jest.Mocked<Session>
-    session.uuid = '1-2-3'
-
-    currentSession = {} as jest.Mocked<Session>
-    currentSession.uuid = '2-3-4'
-
-    sessionRepository = {} as jest.Mocked<SessionRepositoryInterface>
-    sessionRepository.deleteAllByUserUuid = jest.fn()
-    sessionRepository.findAllByUserUuid = jest.fn().mockReturnValue([session, currentSession])
-
-    sessionService = {} as jest.Mocked<SessionServiceInterface>
-    sessionService.createRevokedSession = jest.fn()
-  })
-
-  it('should delete all sessions except current for a given user', async () => {
-    expect(await createUseCase().execute({ userUuid: '1-2-3', currentSessionUuid: '2-3-4' })).toEqual({ success: true })
-
-    expect(sessionRepository.deleteAllByUserUuid).toHaveBeenCalledWith('1-2-3', '2-3-4')
-
-    expect(sessionService.createRevokedSession).toHaveBeenCalledWith(session)
-    expect(sessionService.createRevokedSession).not.toHaveBeenCalledWith(currentSession)
-  })
-})

+ 0 - 32
packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUser.ts

@@ -1,32 +0,0 @@
-import { inject, injectable } from 'inversify'
-import TYPES from '../../Bootstrap/Types'
-import { Session } from '../Session/Session'
-import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface'
-import { SessionServiceInterface } from '../Session/SessionServiceInterface'
-import { DeletePreviousSessionsForUserDTO } from './DeletePreviousSessionsForUserDTO'
-import { DeletePreviousSessionsForUserResponse } from './DeletePreviousSessionsForUserResponse'
-import { UseCaseInterface } from './UseCaseInterface'
-
-@injectable()
-export class DeletePreviousSessionsForUser implements UseCaseInterface {
-  constructor(
-    @inject(TYPES.Auth_SessionRepository) private sessionRepository: SessionRepositoryInterface,
-    @inject(TYPES.Auth_SessionService) private sessionService: SessionServiceInterface,
-  ) {}
-
-  async execute(dto: DeletePreviousSessionsForUserDTO): Promise<DeletePreviousSessionsForUserResponse> {
-    const sessions = await this.sessionRepository.findAllByUserUuid(dto.userUuid)
-
-    await Promise.all(
-      sessions.map(async (session: Session) => {
-        if (session.uuid !== dto.currentSessionUuid) {
-          await this.sessionService.createRevokedSession(session)
-        }
-      }),
-    )
-
-    await this.sessionRepository.deleteAllByUserUuid(dto.userUuid, dto.currentSessionUuid)
-
-    return { success: true }
-  }
-}

+ 0 - 4
packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUserDTO.ts

@@ -1,4 +0,0 @@
-export type DeletePreviousSessionsForUserDTO = {
-  userUuid: string
-  currentSessionUuid: string
-}

+ 0 - 3
packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUserResponse.ts

@@ -1,3 +0,0 @@
-export type DeletePreviousSessionsForUserResponse = {
-  success: boolean
-}

+ 4 - 1
packages/auth/src/Domain/UseCase/Register.spec.ts

@@ -10,6 +10,7 @@ import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
 import { Register } from './Register'
 import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
 import { AuthResponseFactory20200115 } from '../Auth/AuthResponseFactory20200115'
+import { Session } from '../Session/Session'
 
 describe('Register', () => {
   let userRepository: UserRepositoryInterface
@@ -32,7 +33,9 @@ describe('Register', () => {
     roleRepository.findOneByName = jest.fn().mockReturnValue(null)
 
     authResponseFactory = {} as jest.Mocked<AuthResponseFactory20200115>
-    authResponseFactory.createResponse = jest.fn().mockReturnValue({ foo: 'bar' })
+    authResponseFactory.createResponse = jest
+      .fn()
+      .mockReturnValue({ response: { foo: 'bar' }, session: {} as jest.Mocked<Session> })
 
     crypter = {} as jest.Mocked<CrypterInterface>
     crypter.generateEncryptedUserServerKey = jest.fn().mockReturnValue('test')

+ 9 - 7
packages/auth/src/Domain/UseCase/Register.ts

@@ -83,15 +83,17 @@ export class Register implements UseCaseInterface {
 
     await this.settingService.applyDefaultSettingsUponRegistration(user)
 
+    const result = await this.authResponseFactory20200115.createResponse({
+      user,
+      apiVersion,
+      userAgent: dto.updatedWithUserAgent,
+      ephemeralSession,
+      readonlyAccess: false,
+    })
+
     return {
       success: true,
-      authResponse: (await this.authResponseFactory20200115.createResponse({
-        user,
-        apiVersion,
-        userAgent: dto.updatedWithUserAgent,
-        ephemeralSession,
-        readonlyAccess: false,
-      })) as AuthResponse20200115,
+      authResponse: result.response as AuthResponse20200115,
     }
   }
 }

+ 4 - 1
packages/auth/src/Domain/UseCase/SignIn.spec.ts

@@ -13,6 +13,7 @@ import { SignIn } from './SignIn'
 import { PKCERepositoryInterface } from '../User/PKCERepositoryInterface'
 import { CrypterInterface } from '../Encryption/CrypterInterface'
 import { ProtocolVersion } from '@standardnotes/common'
+import { Session } from '../Session/Session'
 
 describe('SignIn', () => {
   let user: User
@@ -50,7 +51,9 @@ describe('SignIn', () => {
     userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(user)
 
     authResponseFactory = {} as jest.Mocked<AuthResponseFactoryInterface>
-    authResponseFactory.createResponse = jest.fn().mockReturnValue({ foo: 'bar' })
+    authResponseFactory.createResponse = jest
+      .fn()
+      .mockReturnValue({ response: { foo: 'bar' }, session: {} as jest.Mocked<Session> })
 
     authResponseFactoryResolver = {} as jest.Mocked<AuthResponseFactoryResolverInterface>
     authResponseFactoryResolver.resolveAuthResponseFactoryVersion = jest.fn().mockReturnValue(authResponseFactory)

+ 9 - 7
packages/auth/src/Domain/UseCase/SignIn.ts

@@ -95,15 +95,17 @@ export class SignIn implements UseCaseInterface {
 
     await this.sendSignInEmailNotification(user, dto.userAgent)
 
+    const result = await authResponseFactory.createResponse({
+      user,
+      apiVersion: dto.apiVersion,
+      userAgent: dto.userAgent,
+      ephemeralSession: dto.ephemeralSession,
+      readonlyAccess: false,
+    })
+
     return {
       success: true,
-      authResponse: await authResponseFactory.createResponse({
-        user,
-        apiVersion: dto.apiVersion,
-        userAgent: dto.userAgent,
-        ephemeralSession: dto.ephemeralSession,
-        readonlyAccess: false,
-      }),
+      authResponse: result.response,
     }
   }
 

+ 1 - 1
packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts

@@ -124,7 +124,7 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse202
 
     await this.clearLoginAttempts.execute({ email: username.value })
 
-    return Result.ok(authResponse as AuthResponse20200115)
+    return Result.ok(authResponse.response as AuthResponse20200115)
   }
 
   private async validateCodeVerifier(codeVerifier: string): Promise<boolean> {

+ 4 - 1
packages/auth/src/Domain/UseCase/UpdateUser.spec.ts

@@ -8,6 +8,7 @@ import { AuthResponseFactoryInterface } from '../Auth/AuthResponseFactoryInterfa
 import { AuthResponseFactoryResolverInterface } from '../Auth/AuthResponseFactoryResolverInterface'
 
 import { UpdateUser } from './UpdateUser'
+import { Session } from '../Session/Session'
 
 describe('UpdateUser', () => {
   let userRepository: UserRepositoryInterface
@@ -24,7 +25,9 @@ describe('UpdateUser', () => {
     userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(undefined)
 
     authResponseFactory = {} as jest.Mocked<AuthResponseFactoryInterface>
-    authResponseFactory.createResponse = jest.fn().mockReturnValue({ foo: 'bar' })
+    authResponseFactory.createResponse = jest
+      .fn()
+      .mockReturnValue({ response: { foo: 'bar' }, session: {} as jest.Mocked<Session> })
 
     authResponseFactoryResolver = {} as jest.Mocked<AuthResponseFactoryResolverInterface>
     authResponseFactoryResolver.resolveAuthResponseFactoryVersion = jest.fn().mockReturnValue(authResponseFactory)

+ 9 - 7
packages/auth/src/Domain/UseCase/UpdateUser.ts

@@ -23,15 +23,17 @@ export class UpdateUser implements UseCaseInterface {
 
     const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(dto.apiVersion)
 
+    const result = await authResponseFactory.createResponse({
+      user: updatedUser,
+      apiVersion: dto.apiVersion,
+      userAgent: dto.updatedWithUserAgent,
+      ephemeralSession: false,
+      readonlyAccess: false,
+    })
+
     return {
       success: true,
-      authResponse: await authResponseFactory.createResponse({
-        user: updatedUser,
-        apiVersion: dto.apiVersion,
-        userAgent: dto.updatedWithUserAgent,
-        ephemeralSession: false,
-        readonlyAccess: false,
-      }),
+      authResponse: result.response,
     }
   }
 }

+ 8 - 7
packages/auth/src/Infra/InversifyExpressUtils/AnnotatedSessionController.spec.ts

@@ -4,26 +4,26 @@ import * as express from 'express'
 
 import { AnnotatedSessionController } from './AnnotatedSessionController'
 import { results } from 'inversify-express-utils'
-import { DeletePreviousSessionsForUser } from '../../Domain/UseCase/DeletePreviousSessionsForUser'
+import { DeleteOtherSessionsForUser } from '../../Domain/UseCase/DeleteOtherSessionsForUser'
 import { DeleteSessionForUser } from '../../Domain/UseCase/DeleteSessionForUser'
 import { RefreshSessionToken } from '../../Domain/UseCase/RefreshSessionToken'
 
 describe('AnnotatedSessionController', () => {
   let deleteSessionForUser: DeleteSessionForUser
-  let deletePreviousSessionsForUser: DeletePreviousSessionsForUser
+  let deleteOtherSessionsForUser: DeleteOtherSessionsForUser
   let refreshSessionToken: RefreshSessionToken
   let request: express.Request
   let response: express.Response
 
   const createController = () =>
-    new AnnotatedSessionController(deleteSessionForUser, deletePreviousSessionsForUser, refreshSessionToken)
+    new AnnotatedSessionController(deleteSessionForUser, deleteOtherSessionsForUser, refreshSessionToken)
 
   beforeEach(() => {
     deleteSessionForUser = {} as jest.Mocked<DeleteSessionForUser>
     deleteSessionForUser.execute = jest.fn().mockReturnValue({ success: true })
 
-    deletePreviousSessionsForUser = {} as jest.Mocked<DeletePreviousSessionsForUser>
-    deletePreviousSessionsForUser.execute = jest.fn()
+    deleteOtherSessionsForUser = {} as jest.Mocked<DeleteOtherSessionsForUser>
+    deleteOtherSessionsForUser.execute = jest.fn()
 
     refreshSessionToken = {} as jest.Mocked<RefreshSessionToken>
     refreshSessionToken.execute = jest.fn()
@@ -196,9 +196,10 @@ describe('AnnotatedSessionController', () => {
     const httpResult = <results.JsonResult>await createController().deleteAllSessions(request, response)
     const result = await httpResult.executeAsync()
 
-    expect(deletePreviousSessionsForUser.execute).toHaveBeenCalledWith({
+    expect(deleteOtherSessionsForUser.execute).toHaveBeenCalledWith({
       userUuid: '123',
       currentSessionUuid: '234',
+      markAsRevoked: true,
     })
 
     expect(result.statusCode).toEqual(204)
@@ -218,7 +219,7 @@ describe('AnnotatedSessionController', () => {
     const httpResponse = <results.JsonResult>await createController().deleteAllSessions(request, response)
     const result = await httpResponse.executeAsync()
 
-    expect(deletePreviousSessionsForUser.execute).not.toHaveBeenCalled()
+    expect(deleteOtherSessionsForUser.execute).not.toHaveBeenCalled()
 
     expect(result.statusCode).toEqual(401)
   })

+ 4 - 4
packages/auth/src/Infra/InversifyExpressUtils/AnnotatedSessionController.ts

@@ -8,7 +8,7 @@ import {
   results,
 } from 'inversify-express-utils'
 import TYPES from '../../Bootstrap/Types'
-import { DeletePreviousSessionsForUser } from '../../Domain/UseCase/DeletePreviousSessionsForUser'
+import { DeleteOtherSessionsForUser } from '../../Domain/UseCase/DeleteOtherSessionsForUser'
 import { DeleteSessionForUser } from '../../Domain/UseCase/DeleteSessionForUser'
 import { RefreshSessionToken } from '../../Domain/UseCase/RefreshSessionToken'
 import { BaseSessionController } from './Base/BaseSessionController'
@@ -17,11 +17,11 @@ import { BaseSessionController } from './Base/BaseSessionController'
 export class AnnotatedSessionController extends BaseSessionController {
   constructor(
     @inject(TYPES.Auth_DeleteSessionForUser) override deleteSessionForUser: DeleteSessionForUser,
-    @inject(TYPES.Auth_DeletePreviousSessionsForUser)
-    override deletePreviousSessionsForUser: DeletePreviousSessionsForUser,
+    @inject(TYPES.Auth_DeleteOtherSessionsForUser)
+    override deleteOtherSessionsForUser: DeleteOtherSessionsForUser,
     @inject(TYPES.Auth_RefreshSessionToken) override refreshSessionToken: RefreshSessionToken,
   ) {
-    super(deleteSessionForUser, deletePreviousSessionsForUser, refreshSessionToken)
+    super(deleteSessionForUser, deleteOtherSessionsForUser, refreshSessionToken)
   }
 
   @httpDelete('/', TYPES.Auth_RequiredCrossServiceTokenMiddleware, TYPES.Auth_SessionMiddleware)

+ 3 - 2
packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.spec.ts

@@ -332,7 +332,7 @@ describe('AnnotatedUsersController', () => {
     request.headers['user-agent'] = 'Google Chrome'
     response.locals.user = user
 
-    changeCredentials.execute = jest.fn().mockReturnValue({ success: true, authResponse: { foo: 'bar' } })
+    changeCredentials.execute = jest.fn().mockReturnValue(Result.ok({ foo: 'bar' }))
 
     const httpResponse = <results.JsonResult>await createController().changeCredentials(request, response)
     const result = await httpResponse.executeAsync()
@@ -346,6 +346,7 @@ describe('AnnotatedUsersController', () => {
       kpOrigination: 'change-password',
       pwNonce: 'asdzxc',
       protocolVersion: '004',
+      newEmail: undefined,
       username: Username.create('test@test.te').getValue(),
     })
 
@@ -385,7 +386,7 @@ describe('AnnotatedUsersController', () => {
     request.headers['user-agent'] = 'Google Chrome'
     response.locals.user = user
 
-    changeCredentials.execute = jest.fn().mockReturnValue({ success: false, errorMessage: 'Something bad happened' })
+    changeCredentials.execute = jest.fn().mockReturnValue(Result.fail('Something bad happened'))
 
     const httpResponse = <results.JsonResult>await createController().changeCredentials(request, response)
     const result = await httpResponse.executeAsync()

+ 4 - 3
packages/auth/src/Infra/InversifyExpressUtils/Base/BaseSessionController.ts

@@ -3,14 +3,14 @@ import { Request, Response } from 'express'
 import { BaseHttpController, results } from 'inversify-express-utils'
 import { ErrorTag } from '@standardnotes/responses'
 
-import { DeletePreviousSessionsForUser } from '../../../Domain/UseCase/DeletePreviousSessionsForUser'
+import { DeleteOtherSessionsForUser } from '../../../Domain/UseCase/DeleteOtherSessionsForUser'
 import { DeleteSessionForUser } from '../../../Domain/UseCase/DeleteSessionForUser'
 import { RefreshSessionToken } from '../../../Domain/UseCase/RefreshSessionToken'
 
 export class BaseSessionController extends BaseHttpController {
   constructor(
     protected deleteSessionForUser: DeleteSessionForUser,
-    protected deletePreviousSessionsForUser: DeletePreviousSessionsForUser,
+    protected deleteOtherSessionsForUser: DeleteOtherSessionsForUser,
     protected refreshSessionToken: RefreshSessionToken,
     private controllerContainer?: ControllerContainerInterface,
   ) {
@@ -106,9 +106,10 @@ export class BaseSessionController extends BaseHttpController {
       )
     }
 
-    await this.deletePreviousSessionsForUser.execute({
+    await this.deleteOtherSessionsForUser.execute({
       userUuid: response.locals.user.uuid,
       currentSessionUuid: response.locals.session.uuid,
+      markAsRevoked: true,
     })
 
     response.setHeader('x-invalidate-cache', response.locals.user.uuid)

+ 3 - 3
packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts

@@ -228,13 +228,13 @@ export class BaseUsersController extends BaseHttpController {
       protocolVersion: request.body.version,
     })
 
-    if (!changeCredentialsResult.success) {
+    if (changeCredentialsResult.isFailed()) {
       await this.increaseLoginAttempts.execute({ email: response.locals.user.email })
 
       return this.json(
         {
           error: {
-            message: changeCredentialsResult.errorMessage,
+            message: changeCredentialsResult.getError(),
           },
         },
         401,
@@ -245,6 +245,6 @@ export class BaseUsersController extends BaseHttpController {
 
     response.setHeader('x-invalidate-cache', response.locals.user.uuid)
 
-    return this.json(changeCredentialsResult.authResponse)
+    return this.json(changeCredentialsResult.getValue())
   }
 }

+ 4 - 3
packages/auth/src/Infra/TypeORM/TypeORMSessionRepository.ts

@@ -7,6 +7,7 @@ import TYPES from '../../Bootstrap/Types'
 
 import { Session } from '../../Domain/Session/Session'
 import { SessionRepositoryInterface } from '../../Domain/Session/SessionRepositoryInterface'
+import { Uuid } from '@standardnotes/domain-core'
 
 @injectable()
 export class TypeORMSessionRepository implements SessionRepositoryInterface {
@@ -100,13 +101,13 @@ export class TypeORMSessionRepository implements SessionRepositoryInterface {
       .getMany()
   }
 
-  async deleteAllByUserUuid(userUuid: string, currentSessionUuid: string): Promise<void> {
+  async deleteAllByUserUuidExceptOne(dto: { userUuid: Uuid; currentSessionUuid: Uuid }): Promise<void> {
     await this.ormRepository
       .createQueryBuilder('session')
       .delete()
       .where('user_uuid = :user_uuid AND uuid != :current_session_uuid', {
-        user_uuid: userUuid,
-        current_session_uuid: currentSessionUuid,
+        user_uuid: dto.userUuid.value,
+        current_session_uuid: dto.currentSessionUuid.value,
       })
       .execute()
   }