Compare commits

..

7 Commits

Author SHA1 Message Date
standardci
7516ba7028 chore(release): publish new version
- @standardnotes/auth-server@1.94.1
2023-04-04 07:21:21 +00:00
Karol Sójko
3417407cbe fix(auth): change status code for updating a subscription setting without a subscription 2023-04-04 09:08:27 +02:00
standardci
720d046c00 chore(release): publish new version
- @standardnotes/auth-server@1.94.0
2023-04-03 14:08:48 +00:00
Karol Sójko
b88f560b07 fix(auth): feature service specs 2023-04-03 15:53:04 +02:00
Karol Sójko
51b264ca13 feat(auth): feature entitlement check for u2f endpoints 2023-04-03 15:43:32 +02:00
standardci
0309aeab34 chore(release): publish new version
- @standardnotes/auth-server@1.93.14
2023-04-03 10:56:30 +00:00
Karol Sójko
aca8d2948d fix(auth): relying party id 2023-04-03 12:43:05 +02:00
20 changed files with 435 additions and 28 deletions

14
.pnp.cjs generated
View File

@@ -4175,7 +4175,7 @@ const RAW_RUNTIME_STATE =
["@standardnotes/domain-core", "workspace:packages/domain-core"],\
["@standardnotes/domain-events", "workspace:packages/domain-events"],\
["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\
["@standardnotes/features", "npm:1.58.9"],\
["@standardnotes/features", "npm:1.58.12"],\
["@standardnotes/predicates", "workspace:packages/predicates"],\
["@standardnotes/responses", "npm:1.13.9"],\
["@standardnotes/security", "workspace:packages/security"],\
@@ -4360,10 +4360,10 @@ const RAW_RUNTIME_STATE =
}]\
]],\
["@standardnotes/features", [\
["npm:1.58.8", {\
"packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.58.8-d97ff2aae1-77bac7d0a0.zip/node_modules/@standardnotes/features/",\
["npm:1.58.12", {\
"packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.58.12-9778b78276-3fcd9a9488.zip/node_modules/@standardnotes/features/",\
"packageDependencies": [\
["@standardnotes/features", "npm:1.58.8"],\
["@standardnotes/features", "npm:1.58.12"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/domain-core", "workspace:packages/domain-core"],\
["@standardnotes/security", "workspace:packages/security"],\
@@ -4371,10 +4371,10 @@ const RAW_RUNTIME_STATE =
],\
"linkType": "HARD"\
}],\
["npm:1.58.9", {\
"packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.58.9-c278f712cd-218350ee55.zip/node_modules/@standardnotes/features/",\
["npm:1.58.8", {\
"packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.58.8-d97ff2aae1-77bac7d0a0.zip/node_modules/@standardnotes/features/",\
"packageDependencies": [\
["@standardnotes/features", "npm:1.58.9"],\
["@standardnotes/features", "npm:1.58.8"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/domain-core", "workspace:packages/domain-core"],\
["@standardnotes/security", "workspace:packages/security"],\

View File

@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.94.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.94.0...@standardnotes/auth-server@1.94.1) (2023-04-04)
### Bug Fixes
* **auth:** change status code for updating a subscription setting without a subscription ([3417407](https://github.com/standardnotes/server/commit/3417407cbe3b8e19069f6003e767d707e14b4501))
# [1.94.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.93.14...@standardnotes/auth-server@1.94.0) (2023-04-03)
### Bug Fixes
* **auth:** feature service specs ([b88f560](https://github.com/standardnotes/server/commit/b88f560b07de183d4101220626785d3ba994b44c))
### Features
* **auth:** feature entitlement check for u2f endpoints ([51b264c](https://github.com/standardnotes/server/commit/51b264ca13fffc66e2dc31e87b0934ba61a48435))
## [1.93.14](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.93.13...@standardnotes/auth-server@1.93.14) (2023-04-03)
### Bug Fixes
* **auth:** relying party id ([aca8d29](https://github.com/standardnotes/server/commit/aca8d2948da67b32445dc8da54b561ff08bf5c62))
## [1.93.13](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.93.12...@standardnotes/auth-server@1.93.13) (2023-03-30)
**Note:** Version bump only for package @standardnotes/auth-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/auth-server",
"version": "1.93.13",
"version": "1.94.1",
"engines": {
"node": ">=18.0.0 <19.0.0"
},
@@ -46,7 +46,7 @@
"@standardnotes/domain-core": "workspace:^",
"@standardnotes/domain-events": "workspace:*",
"@standardnotes/domain-events-infra": "workspace:*",
"@standardnotes/features": "^1.58.9",
"@standardnotes/features": "^1.58.12",
"@standardnotes/predicates": "workspace:*",
"@standardnotes/responses": "^1.13.9",
"@standardnotes/security": "workspace:*",

View File

@@ -452,7 +452,7 @@ export class ContainerConfigLoader {
.toConstantValue(env.get('U2F_RELYING_PARTY_NAME', true) ?? 'Standard Notes')
container
.bind(TYPES.U2F_RELYING_PARTY_ID)
.toConstantValue(env.get('U2F_RELYING_PARTY_ID', true) ?? 'standardnotes.com')
.toConstantValue(env.get('U2F_RELYING_PARTY_ID', true) ?? 'app.standardnotes.com')
container
.bind(TYPES.U2F_EXPECTED_ORIGIN)
.toConstantValue(
@@ -563,6 +563,8 @@ export class ContainerConfigLoader {
container.get(TYPES.AuthenticatorChallengeRepository),
container.get(TYPES.U2F_RELYING_PARTY_NAME),
container.get(TYPES.U2F_RELYING_PARTY_ID),
container.get(TYPES.UserRepository),
container.get(TYPES.FeatureService),
),
)
container
@@ -574,6 +576,8 @@ export class ContainerConfigLoader {
container.get(TYPES.U2F_RELYING_PARTY_ID),
container.get(TYPES.U2F_EXPECTED_ORIGIN),
container.get(TYPES.U2F_REQUIRE_USER_VERIFICATION),
container.get(TYPES.UserRepository),
container.get(TYPES.FeatureService),
),
)
container
@@ -599,10 +603,22 @@ export class ContainerConfigLoader {
)
container
.bind<ListAuthenticators>(TYPES.ListAuthenticators)
.toConstantValue(new ListAuthenticators(container.get(TYPES.AuthenticatorRepository)))
.toConstantValue(
new ListAuthenticators(
container.get(TYPES.AuthenticatorRepository),
container.get(TYPES.UserRepository),
container.get(TYPES.FeatureService),
),
)
container
.bind<DeleteAuthenticator>(TYPES.DeleteAuthenticator)
.toConstantValue(new DeleteAuthenticator(container.get(TYPES.AuthenticatorRepository)))
.toConstantValue(
new DeleteAuthenticator(
container.get(TYPES.AuthenticatorRepository),
container.get(TYPES.UserRepository),
container.get(TYPES.FeatureService),
),
)
container
.bind<GenerateRecoveryCodes>(TYPES.GenerateRecoveryCodes)
.toConstantValue(

View File

@@ -34,6 +34,17 @@ export class AuthenticatorsController {
userUuid: params.userUuid,
})
if (result.isFailed()) {
return {
status: HttpStatusCode.Unauthorized,
data: {
error: {
message: result.getError(),
},
},
}
}
return {
status: HttpStatusCode.Success,
data: {
@@ -50,6 +61,17 @@ export class AuthenticatorsController {
authenticatorId: params.authenticatorId,
})
if (result.isFailed()) {
return {
status: HttpStatusCode.Unauthorized,
data: {
error: {
message: result.getError(),
},
},
}
}
return {
status: HttpStatusCode.Success,
data: {

View File

@@ -30,7 +30,7 @@ jest.mock('@standardnotes/features', () => {
const { GetFeatures } = jest.requireMock('@standardnotes/features')
import { FeatureService } from './FeatureService'
import { Permission, PermissionName } from '@standardnotes/features'
import { FeatureIdentifier, Permission, PermissionName } from '@standardnotes/features'
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
import { TimerInterface } from '@standardnotes/time'
import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription'
@@ -201,6 +201,62 @@ describe('FeatureService', () => {
})
describe('online subscribers', () => {
it('should tell if a user is entitled to a feature', async () => {
expect(await createService().userIsEntitledToFeature(user, FeatureIdentifier.AutobiographyTheme)).toBe(true)
expect(await createService().userIsEntitledToFeature(user, FeatureIdentifier.DeprecatedBoldEditor)).toBe(false)
})
it('should tell if a user is not entitled to a feature because it is expired', async () => {
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(777)
expect(await createService().userIsEntitledToFeature(user, FeatureIdentifier.AutobiographyTheme)).toBe(false)
})
it('should tell if a user is entitled to a feature that does not expire', async () => {
const nonSubscriptionPermission = {
uuid: 'files-beta-permission-1-1-1',
name: 'files-beta' as PermissionName,
} as jest.Mocked<Permission>
GetFeatures.mockImplementation(() => [
{
identifier: 'org.standardnotes.theme-autobiography',
permission_name: PermissionName.AutobiographyTheme,
expires_at: 555,
},
{
identifier: 'org.standardnotes.bold-editor',
permission_name: PermissionName.BoldEditor,
expires_at: 777,
},
{
identifier: 'files-beta',
permission_name: 'files-beta' as PermissionName,
expires_at: undefined,
no_expire: true,
},
])
const nonSubscriptionRole = {
name: RoleName.NAMES.InternalTeamUser,
uuid: 'role-files-beta',
permissions: Promise.resolve([nonSubscriptionPermission]),
} as jest.Mocked<Role>
roleToSubscriptionMap.filterNonSubscriptionRoles = jest.fn().mockReturnValue([nonSubscriptionRole])
roleToSubscriptionMap.getSubscriptionNameForRoleName = jest
.fn()
.mockReturnValueOnce(SubscriptionName.PlusPlan)
.mockReturnValueOnce(SubscriptionName.ProPlan)
user = {
uuid: 'user-1-1-1',
roles: Promise.resolve([role1, role2, nonSubscriptionRole]),
subscriptions: Promise.resolve([subscription1, subscription2]),
} as jest.Mocked<User>
expect(await createService().userIsEntitledToFeature(user, 'files-beta')).toBe(true)
})
it('should return user features with `expires_at` field', async () => {
const features = await createService().getFeaturesForUser(user)
expect(features).toEqual(
@@ -321,7 +377,7 @@ describe('FeatureService', () => {
it('should return user features along with features related to non subscription roles', async () => {
const nonSubscriptionPermission = {
uuid: 'files-beta-permission-1-1-1',
name: PermissionName.FilesBeta,
name: 'files-beta' as PermissionName,
} as jest.Mocked<Permission>
GetFeatures.mockImplementation(() => [
@@ -336,7 +392,8 @@ describe('FeatureService', () => {
expires_at: 777,
},
{
permission_name: PermissionName.FilesBeta,
identifier: 'files-beta',
permission_name: 'files-beta' as PermissionName,
expires_at: undefined,
no_expire: true,
},

View File

@@ -21,6 +21,25 @@ export class FeatureService implements FeatureServiceInterface {
@inject(TYPES.Timer) private timer: TimerInterface,
) {}
async userIsEntitledToFeature(user: User, featureIdentifier: string): Promise<boolean> {
const userFeatures = await this.getFeaturesForUser(user)
const feature = userFeatures.find((userFeature) => userFeature.identifier === featureIdentifier)
if (feature === undefined) {
return false
}
if (feature.no_expire) {
return true
}
const featureIsExpired =
feature.expires_at !== undefined && feature.expires_at < this.timer.getTimestampInMicroseconds()
return !featureIsExpired
}
async getFeaturesForOfflineUser(email: string): Promise<{ features: FeatureDescription[]; roles: string[] }> {
const userSubscriptions = await this.offlineUserSubscriptionRepository.findByEmail(
email,

View File

@@ -4,5 +4,6 @@ import { User } from '../User/User'
export interface FeatureServiceInterface {
getFeaturesForUser(user: User): Promise<Array<FeatureDescription>>
userIsEntitledToFeature(user: User, featureIdentifier: string): Promise<boolean>
getFeaturesForOfflineUser(email: string): Promise<{ features: FeatureDescription[]; roles: string[] }>
}

View File

@@ -2,12 +2,18 @@ import { Dates, Uuid } from '@standardnotes/domain-core'
import { Authenticator } from '../../Authenticator/Authenticator'
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface'
import { User } from '../../User/User'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { DeleteAuthenticator } from './DeleteAuthenticator'
describe('DeleteAuthenticator', () => {
let authenticatorRepository: AuthenticatorRepositoryInterface
let authenticator: Authenticator
const createUseCase = () => new DeleteAuthenticator(authenticatorRepository)
let userRepository: UserRepositoryInterface
let featureService: FeatureServiceInterface
const createUseCase = () => new DeleteAuthenticator(authenticatorRepository, userRepository, featureService)
beforeEach(() => {
authenticator = Authenticator.create({
@@ -24,6 +30,12 @@ describe('DeleteAuthenticator', () => {
authenticatorRepository = {} as jest.Mocked<AuthenticatorRepositoryInterface>
authenticatorRepository.findById = jest.fn().mockReturnValue(authenticator)
authenticatorRepository.remove = jest.fn()
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByUuid = jest.fn().mockReturnValue({} as jest.Mocked<User>)
featureService = {} as jest.Mocked<FeatureServiceInterface>
featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(true)
})
it('should return error if authenticator not found', async () => {
@@ -38,6 +50,40 @@ describe('DeleteAuthenticator', () => {
expect(result.getError()).toEqual('Authenticator not found')
})
it('should return error if user is not found', async () => {
userRepository.findOneByUuid = jest.fn().mockReturnValue(null)
const result = await createUseCase().execute({
userUuid: '00000000-0000-0000-0000-000000000000',
authenticatorId: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toEqual('Could not delete authenticator: user not found.')
})
it('should return error if user is not entitled to U2F', async () => {
featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(false)
const result = await createUseCase().execute({
userUuid: '00000000-0000-0000-0000-000000000000',
authenticatorId: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toEqual('Could not delete authenticator: user is not entitled to U2F.')
})
it('should return error if user uuid is not valid', async () => {
const result = await createUseCase().execute({
userUuid: 'invalid',
authenticatorId: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toEqual('Could not delete authenticator: Given value is not a valid uuid: invalid')
})
it('should return error if authenticator does not belong to user', async () => {
authenticatorRepository.findById = jest.fn().mockReturnValue({
...authenticator,

View File

@@ -1,12 +1,41 @@
import { Result, UniqueEntityId, UseCaseInterface } from '@standardnotes/domain-core'
import { Result, UniqueEntityId, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { FeatureIdentifier } from '@standardnotes/features'
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { DeleteAuthenticatorDTO } from './DeleteAuthenticatorDTO'
export class DeleteAuthenticator implements UseCaseInterface<string> {
constructor(private authenticatorRepository: AuthenticatorRepositoryInterface) {}
constructor(
private authenticatorRepository: AuthenticatorRepositoryInterface,
private userRepository: UserRepositoryInterface,
private featureService: FeatureServiceInterface,
) {}
async execute(dto: DeleteAuthenticatorDTO): Promise<Result<string>> {
const userUuidOrError = Uuid.create(dto.userUuid)
if (userUuidOrError.isFailed()) {
return Result.fail(`Could not delete authenticator: ${userUuidOrError.getError()}`)
}
const userUuid = userUuidOrError.getValue()
const user = await this.userRepository.findOneByUuid(userUuid.value)
if (user === null) {
return Result.fail('Could not delete authenticator: user not found.')
}
const userIsEntitledToU2F = await this.featureService.userIsEntitledToFeature(
user,
FeatureIdentifier.UniversalSecondFactor,
)
if (!userIsEntitledToU2F) {
return Result.fail('Could not delete authenticator: user is not entitled to U2F.')
}
const authenticator = await this.authenticatorRepository.findById(new UniqueEntityId(dto.authenticatorId))
if (!authenticator || authenticator.props.userUuid.value !== dto.userUuid) {
if (!authenticator || authenticator.props.userUuid.value !== userUuid.value) {
return Result.fail('Authenticator not found')
}

View File

@@ -4,11 +4,16 @@ import { Authenticator } from '../../Authenticator/Authenticator'
import { AuthenticatorChallenge } from '../../Authenticator/AuthenticatorChallenge'
import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface'
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface'
import { User } from '../../User/User'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { GenerateAuthenticatorRegistrationOptions } from './GenerateAuthenticatorRegistrationOptions'
describe('GenerateAuthenticatorRegistrationOptions', () => {
let authenticatorRepository: AuthenticatorRepositoryInterface
let authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface
let userRepository: UserRepositoryInterface
let featureService: FeatureServiceInterface
const createUseCase = () =>
new GenerateAuthenticatorRegistrationOptions(
@@ -16,6 +21,8 @@ describe('GenerateAuthenticatorRegistrationOptions', () => {
authenticatorChallengeRepository,
'Standard Notes',
'standardnotes.com',
userRepository,
featureService,
)
beforeEach(() => {
@@ -35,6 +42,12 @@ describe('GenerateAuthenticatorRegistrationOptions', () => {
authenticatorChallengeRepository = {} as jest.Mocked<AuthenticatorChallengeRepositoryInterface>
authenticatorChallengeRepository.save = jest.fn()
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByUuid = jest.fn().mockReturnValue({} as jest.Mocked<User>)
featureService = {} as jest.Mocked<FeatureServiceInterface>
featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(true)
})
it('should return error if userUuid is invalid', async () => {
@@ -63,6 +76,36 @@ describe('GenerateAuthenticatorRegistrationOptions', () => {
expect(result.getError()).toBe('Could not generate authenticator registration options: Username cannot be empty')
})
it('should return error if user is not entitled to u2f feature', async () => {
featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(false)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
username: 'username',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe(
'Could not generate authenticator registration options: user is not entitled to U2F.',
)
})
it('should return error if user is not found', async () => {
userRepository.findOneByUuid = jest.fn().mockReturnValue(null)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
username: 'username',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Could not generate authenticator registration options: user not found.')
})
it('should return error if authenticator challenge is invalid', async () => {
const mock = jest.spyOn(AuthenticatorChallenge, 'create')
mock.mockReturnValue(Result.fail('Oops'))

View File

@@ -5,6 +5,9 @@ import { GenerateAuthenticatorRegistrationOptionsDTO } from './GenerateAuthentic
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface'
import { AuthenticatorChallenge } from '../../Authenticator/AuthenticatorChallenge'
import { FeatureIdentifier } from '@standardnotes/features'
import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
export class GenerateAuthenticatorRegistrationOptions implements UseCaseInterface<Record<string, unknown>> {
constructor(
@@ -12,6 +15,8 @@ export class GenerateAuthenticatorRegistrationOptions implements UseCaseInterfac
private authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface,
private relyingPartyName: string,
private relyingPartyId: string,
private userRepository: UserRepositoryInterface,
private featureService: FeatureServiceInterface,
) {}
async execute(dto: GenerateAuthenticatorRegistrationOptionsDTO): Promise<Result<Record<string, unknown>>> {
@@ -27,6 +32,20 @@ export class GenerateAuthenticatorRegistrationOptions implements UseCaseInterfac
}
const username = usernameOrError.getValue()
const user = await this.userRepository.findOneByUuid(userUuid.value)
if (user === null) {
return Result.fail('Could not generate authenticator registration options: user not found.')
}
const userIsEntitledToU2F = await this.featureService.userIsEntitledToFeature(
user,
FeatureIdentifier.UniversalSecondFactor,
)
if (!userIsEntitledToU2F) {
return Result.fail('Could not generate authenticator registration options: user is not entitled to U2F.')
}
const authenticators = await this.authenticatorRepository.findByUserUuid(userUuid)
const options = generateRegistrationOptions({
rpID: this.relyingPartyId,

View File

@@ -1,14 +1,25 @@
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface'
import { User } from '../../User/User'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { ListAuthenticators } from './ListAuthenticators'
describe('ListAuthenticators', () => {
let authenticatorRepository: AuthenticatorRepositoryInterface
let userRepository: UserRepositoryInterface
let featureService: FeatureServiceInterface
const createUseCase = () => new ListAuthenticators(authenticatorRepository)
const createUseCase = () => new ListAuthenticators(authenticatorRepository, userRepository, featureService)
beforeEach(() => {
authenticatorRepository = {} as jest.Mocked<AuthenticatorRepositoryInterface>
authenticatorRepository.findByUserUuid = jest.fn().mockReturnValue([])
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByUuid = jest.fn().mockReturnValue({} as jest.Mocked<User>)
featureService = {} as jest.Mocked<FeatureServiceInterface>
featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(true)
})
it('should list authenticators', async () => {
@@ -27,4 +38,24 @@ describe('ListAuthenticators', () => {
expect(result.isFailed()).toBeTruthy()
})
it('should fail if user is not found', async () => {
userRepository.findOneByUuid = jest.fn().mockReturnValue(null)
const useCase = createUseCase()
const result = await useCase.execute({ userUuid: '00000000-0000-0000-0000-000000000000' })
expect(result.isFailed()).toBeTruthy()
})
it('should fail if user is not entitled to U2F', async () => {
featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(false)
const useCase = createUseCase()
const result = await useCase.execute({ userUuid: '00000000-0000-0000-0000-000000000000' })
expect(result.isFailed()).toBeTruthy()
})
})

View File

@@ -1,11 +1,19 @@
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { FeatureIdentifier } from '@standardnotes/features'
import { Authenticator } from '../../Authenticator/Authenticator'
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { ListAuthenticatorsDTO } from './ListAuthenticatorsDTO'
export class ListAuthenticators implements UseCaseInterface<Authenticator[]> {
constructor(private authenticatorRepository: AuthenticatorRepositoryInterface) {}
constructor(
private authenticatorRepository: AuthenticatorRepositoryInterface,
private userRepository: UserRepositoryInterface,
private featureService: FeatureServiceInterface,
) {}
async execute(dto: ListAuthenticatorsDTO): Promise<Result<Authenticator[]>> {
const userUuidOrError = Uuid.create(dto.userUuid)
if (userUuidOrError.isFailed()) {
@@ -13,6 +21,20 @@ export class ListAuthenticators implements UseCaseInterface<Authenticator[]> {
}
const userUuid = userUuidOrError.getValue()
const user = await this.userRepository.findOneByUuid(userUuid.value)
if (user === null) {
return Result.fail('Could not list authenticators: user not found.')
}
const userIsEntitledToU2F = await this.featureService.userIsEntitledToFeature(
user,
FeatureIdentifier.UniversalSecondFactor,
)
if (!userIsEntitledToU2F) {
return Result.fail('Could not list authenticators: user is not entitled to U2F.')
}
const authenticators = await this.authenticatorRepository.findByUserUuid(userUuid)
return Result.ok(authenticators)

View File

@@ -168,7 +168,7 @@ describe('UpdateSetting', () => {
error: {
message: 'User 1-2-3 has no subscription to change a subscription setting.',
},
statusCode: 401,
statusCode: 400,
})
})

View File

@@ -83,7 +83,7 @@ export class UpdateSetting implements UseCaseInterface {
error: {
message: `User ${userUuid} has no subscription to change a subscription setting.`,
},
statusCode: 401,
statusCode: 400,
}
}

View File

@@ -7,11 +7,16 @@ import { Authenticator } from '../../Authenticator/Authenticator'
import { AuthenticatorChallenge } from '../../Authenticator/AuthenticatorChallenge'
import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface'
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface'
import { User } from '../../User/User'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { VerifyAuthenticatorRegistrationResponse } from './VerifyAuthenticatorRegistrationResponse'
describe('VerifyAuthenticatorRegistrationResponse', () => {
let authenticatorRepository: AuthenticatorRepositoryInterface
let authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface
let userRepository: UserRepositoryInterface
let featureService: FeatureServiceInterface
const createUseCase = () =>
new VerifyAuthenticatorRegistrationResponse(
@@ -20,6 +25,8 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
'standardnotes.com',
['localhost', 'https://app.standardnotes.com'],
true,
userRepository,
featureService,
)
beforeEach(() => {
@@ -32,6 +39,12 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
challenge: Buffer.from('challenge'),
},
} as jest.Mocked<AuthenticatorChallenge>)
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByUuid = jest.fn().mockReturnValue({} as jest.Mocked<User>)
featureService = {} as jest.Mocked<FeatureServiceInterface>
featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(true)
})
it('should return error if user uuid is invalid', async () => {
@@ -57,6 +70,54 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
)
})
it('should return error if user is not entitled to feature', async () => {
featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(false)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
attestationResponse: {
id: 'id',
rawId: 'rawId',
response: {
attestationObject: 'attestationObject',
clientDataJSON: 'clientDataJSON',
},
type: 'public-key',
clientExtensionResults: {},
} as jest.Mocked<RegistrationResponseJSON>,
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual(
'Could not verify authenticator registration response: user is not entitled to U2F.',
)
})
it('should return error if user is not found', async () => {
userRepository.findOneByUuid = jest.fn().mockReturnValue(null)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
attestationResponse: {
id: 'id',
rawId: 'rawId',
response: {
attestationObject: 'attestationObject',
clientDataJSON: 'clientDataJSON',
},
type: 'public-key',
clientExtensionResults: {},
} as jest.Mocked<RegistrationResponseJSON>,
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Could not verify authenticator registration response: user not found.')
})
it('should return error if challenge is not found', async () => {
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue(null)

View File

@@ -1,10 +1,13 @@
import { Dates, Result, UniqueEntityId, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { VerifiedRegistrationResponse, verifyRegistrationResponse } from '@simplewebauthn/server'
import { FeatureIdentifier } from '@standardnotes/features'
import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface'
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
import { Authenticator } from '../../Authenticator/Authenticator'
import { VerifyAuthenticatorRegistrationResponseDTO } from './VerifyAuthenticatorRegistrationResponseDTO'
import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
export class VerifyAuthenticatorRegistrationResponse implements UseCaseInterface<UniqueEntityId> {
constructor(
@@ -13,6 +16,8 @@ export class VerifyAuthenticatorRegistrationResponse implements UseCaseInterface
private relyingPartyId: string,
private expectedOrigin: string[],
private requireUserVerification: boolean,
private userRepository: UserRepositoryInterface,
private featureService: FeatureServiceInterface,
) {}
async execute(dto: VerifyAuthenticatorRegistrationResponseDTO): Promise<Result<UniqueEntityId>> {
@@ -22,6 +27,20 @@ export class VerifyAuthenticatorRegistrationResponse implements UseCaseInterface
}
const userUuid = userUuidOrError.getValue()
const user = await this.userRepository.findOneByUuid(userUuid.value)
if (user === null) {
return Result.fail('Could not verify authenticator registration response: user not found.')
}
const userIsEntitledToU2F = await this.featureService.userIsEntitledToFeature(
user,
FeatureIdentifier.UniversalSecondFactor,
)
if (!userIsEntitledToU2F) {
return Result.fail('Could not verify authenticator registration response: user is not entitled to U2F.')
}
const authenticatorChallenge = await this.authenticatorChallengeRepository.findByUserUuid(userUuid)
if (!authenticatorChallenge) {
return Result.fail('Could not verify authenticator registration response: challenge not found')

View File

@@ -3299,7 +3299,7 @@ __metadata:
"@standardnotes/domain-core": "workspace:^"
"@standardnotes/domain-events": "workspace:*"
"@standardnotes/domain-events-infra": "workspace:*"
"@standardnotes/features": "npm:^1.58.9"
"@standardnotes/features": "npm:^1.58.12"
"@standardnotes/predicates": "workspace:*"
"@standardnotes/responses": "npm:^1.13.9"
"@standardnotes/security": "workspace:*"
@@ -3482,15 +3482,15 @@ __metadata:
languageName: node
linkType: hard
"@standardnotes/features@npm:^1.58.9":
version: 1.58.9
resolution: "@standardnotes/features@npm:1.58.9"
"@standardnotes/features@npm:^1.58.12":
version: 1.58.12
resolution: "@standardnotes/features@npm:1.58.12"
dependencies:
"@standardnotes/common": "npm:^1.46.6"
"@standardnotes/domain-core": "npm:^1.11.3"
"@standardnotes/security": "npm:^1.7.6"
reflect-metadata: "npm:^0.1.13"
checksum: 218350ee55d2f920e26c4041e1e307655cf9e755b83c7fd2165be2222d95b40154c0d325a362cc84ce960ccf8c07c6d95c6a8558ddabf6ee335462cf6bd22508
checksum: 3fcd9a948848cf6fe567390a7740222fd96d10b8a9bceeaf608befcd7e24ac7374e1c87ed51c12ab62a9ed6036b3c6da82c78ab58f6b0c3f0c3c9aaa2b7ffdfe
languageName: node
linkType: hard