From 8d979ed8946bfda5ec4a4f3f650362519645415b Mon Sep 17 00:00:00 2001 From: getlarge Date: Tue, 9 Apr 2024 10:30:43 +0200 Subject: [PATCH 01/23] chore: update Hydra config to send webhooks --- .env.example | 2 ++ infra/ory/hydra/hydra-template.yaml | 10 ++++++++++ tools/ory/mappings.ts | 10 ++++++++++ 3 files changed, 22 insertions(+) diff --git a/.env.example b/.env.example index 8b34e453..a90e6cc3 100644 --- a/.env.example +++ b/.env.example @@ -76,3 +76,5 @@ urls_identity_provider_publicUrl="http://127.0.0.1:4433" urls_identity_provider_url="http://kratos:4434" secrets_system="system_secret_not_good_not_secure" oidc_subject_identifiers_pairwise_salt="not_secure_salt" +oauth2_token_hook_url="http://host.docker.internal:8080/api/clients/on-token-create" +oauth2_token_hook_auth_config_value="unsecure_api_key" diff --git a/infra/ory/hydra/hydra-template.yaml b/infra/ory/hydra/hydra-template.yaml index 553755c2..b33148b9 100644 --- a/infra/ory/hydra/hydra-template.yaml +++ b/infra/ory/hydra/hydra-template.yaml @@ -22,6 +22,16 @@ secrets: system: - '##secrets_system##' +oauth2: + token_hook: + url: '##oauth2_token_hook_url##' + auth: + type: api_key + config: + in: header + name: X-Ory-API-Key + value: '##oauth2_token_hook_auth_config_value##' + oidc: subject_identifiers: supported_types: diff --git a/tools/ory/mappings.ts b/tools/ory/mappings.ts index cb406d79..0de6c668 100644 --- a/tools/ory/mappings.ts +++ b/tools/ory/mappings.ts @@ -362,6 +362,16 @@ export class HydraMappings extends KeywordMappings { @IsOptional() @IsString() oidc_subject_identifiers_pairwise_salt?: string; + + @Expose() + @IsOptional() + @IsUrl(isUrlOptions) + oauth2_token_hook_url?: string; + + @Expose() + @IsOptional() + @IsString() + oauth2_token_hook_auth_config_value?: string; } export class KetoMappings extends KeywordMappings { From daa02213840322ab657aa0f1394a6c52a3684c98 Mon Sep 17 00:00:00 2001 From: getlarge Date: Tue, 9 Apr 2024 10:30:59 +0200 Subject: [PATCH 02/23] chore: update Ory wrappers --- package.json | 8 +- yarn.lock | 206 +++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 189 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index fc60f035..e2d457ee 100644 --- a/package.json +++ b/package.json @@ -126,9 +126,11 @@ "@fastify/secure-session": "7.1.0", "@fastify/static": "^6.12.0", "@fastify/swagger": "8.10.0", - "@getlarge/keto-client-wrapper": "0.1.0", - "@getlarge/keto-relations-parser": "0.0.3", - "@getlarge/kratos-client-wrapper": "0.1.0", + "@getlarge/hydra-client-wrapper": "0.1.1", + "@getlarge/keto-cli": "0.2.1", + "@getlarge/keto-client-wrapper": "0.2.5", + "@getlarge/keto-relations-parser": "0.0.9", + "@getlarge/kratos-client-wrapper": "0.1.7", "@nestjs/axios": "3.0.0", "@nestjs/bull": "10.0.1", "@nestjs/common": "10.2.4", diff --git a/yarn.lock b/yarn.lock index 155616a1..5cc4f73a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3923,13 +3923,45 @@ __metadata: languageName: node linkType: hard -"@getlarge/keto-client-wrapper@npm:0.1.0": - version: 0.1.0 - resolution: "@getlarge/keto-client-wrapper@npm:0.1.0" +"@getlarge/hydra-client-wrapper@npm:0.1.1": + version: 0.1.1 + resolution: "@getlarge/hydra-client-wrapper@npm:0.1.1" + dependencies: + "@nestjs/axios": ^3.0.1 + "@nestjs/common": ^10.0.2 + "@ory/client": ^1.4.9 + axios: 1.6.5 + tslib: ^2.3.0 + checksum: fe30c0e6d6c2badeadb7d3fad0952675abeeb11ca1278a7abcf4a139369c54c5b06a1ba03e6b78927d70e59672547c765f6eeb971a975371e52b8199645154cf + languageName: node + linkType: hard + +"@getlarge/keto-cli@npm:0.2.1": + version: 0.2.1 + resolution: "@getlarge/keto-cli@npm:0.2.1" + dependencies: + "@getlarge/keto-client-wrapper": 0.2.5 + "@getlarge/keto-relations-parser": 0.0.9 + "@nestjs/common": ^10.0.2 + "@nestjs/config": ^3.1.1 + "@ory/client": ^1.4.9 + class-transformer: ^0.5.1 + class-validator: ^0.14.1 + nest-commander: ^3.12.5 + tslib: ^2.3.0 + bin: + keto-cli: src/index.js + checksum: 72c1089bf1164d8f0fa86fe6a741d491b99b17254e46bfedb48fae278aa443655d8188dc7efb967e36076263560c67a170f7fb5e7c2dcbb2373b38b755fcf15a + languageName: node + linkType: hard + +"@getlarge/keto-client-wrapper@npm:0.2.5": + version: 0.2.5 + resolution: "@getlarge/keto-client-wrapper@npm:0.2.5" dependencies: - "@getlarge/keto-relations-parser": 0.0.3 + "@getlarge/keto-relations-parser": 0.0.9 defekt: ^9.3.1 - lodash.get: ^4.4.2 + rxjs: ^7.8.0 tslib: ^2.3.0 peerDependencies: "@nestjs/axios": ^3.0.1 @@ -3937,24 +3969,23 @@ __metadata: "@nestjs/core": ^10.0.2 "@ory/client": ^1.4.9 axios: 1.6.5 - checksum: 2964831b73e7d7fb6ba7eb403f861d456994db7a04a89ef90a79f72fa131d14b43d016866bd2f3cd44abde6e2ab6c518435b8092c043c5c415bcc1601ff3dad5 + checksum: 8684c24de40c89bf29a5cda268d18076deb744f6dd2966b37f43b90ff148081e0dca89d5e669258df02fd10a4888f6ab43a116c7f91e4fa06c090ce02fad46e1 languageName: node linkType: hard -"@getlarge/keto-relations-parser@npm:0.0.3": - version: 0.0.3 - resolution: "@getlarge/keto-relations-parser@npm:0.0.3" +"@getlarge/keto-relations-parser@npm:0.0.9": + version: 0.0.9 + resolution: "@getlarge/keto-relations-parser@npm:0.0.9" dependencies: defekt: ^9.3.1 - lodash.get: ^4.4.2 tslib: ^2.3.0 - checksum: 053d00f618f556c08e88d9b4b2937d5f5aa2a2054383e12e38a93e51614f61c64a7bcdec870d649b6b170cdc5aacd3513b535bf11b33980c37efc92354d9bb6f + checksum: 4f139e53837e0e40ef06998e471d02fa0bc28dbdb7e2e1655544d8649e5304f9e4f31ba33aa28ade6b3a3dd528095ee683ac8b84afe6785defc2d4b146be3d5b languageName: node linkType: hard -"@getlarge/kratos-client-wrapper@npm:0.1.0": - version: 0.1.0 - resolution: "@getlarge/kratos-client-wrapper@npm:0.1.0" +"@getlarge/kratos-client-wrapper@npm:0.1.7": + version: 0.1.7 + resolution: "@getlarge/kratos-client-wrapper@npm:0.1.7" dependencies: tslib: ^2.3.0 peerDependencies: @@ -3962,7 +3993,7 @@ __metadata: "@nestjs/common": ^10.0.2 "@ory/client": ^1.4.9 axios: 1.6.5 - checksum: bbadf3d43e046d11fcb10d8f1f9b95bff6bba10ba9d2561981c51594ddd269379ee3a05402e5d841b7c514e52a1bf29d95d6cd6dee42404042d780efc2b68a48 + checksum: 9f8611e8fbd766e79ba099fd9c1ff4d217c9a68b40194f4b9db3d5352dc670390f46415b3dc5a7548980eaf89a719809fed7ba5c2487a77d622bd34d600528b7 languageName: node linkType: hard @@ -4554,6 +4585,17 @@ __metadata: languageName: node linkType: hard +"@nestjs/axios@npm:^3.0.1": + version: 3.0.2 + resolution: "@nestjs/axios@npm:3.0.2" + peerDependencies: + "@nestjs/common": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + axios: ^1.3.1 + rxjs: ^6.0.0 || ^7.0.0 + checksum: 285a735fb5db602b63aa4a37e161f609b2cec05b69f4bffe983617c2136ac29c0a33bb96e6276d22a656907bed5d53460e740310bc05c043dcd39c37db7cda29 + languageName: node + linkType: hard + "@nestjs/bull-shared@npm:^10.0.1": version: 10.0.1 resolution: "@nestjs/bull-shared@npm:10.0.1" @@ -4601,6 +4643,27 @@ __metadata: languageName: node linkType: hard +"@nestjs/common@npm:^10.0.2": + version: 10.3.7 + resolution: "@nestjs/common@npm:10.3.7" + dependencies: + iterare: 1.2.1 + tslib: 2.6.2 + uid: 2.0.2 + peerDependencies: + class-transformer: "*" + class-validator: "*" + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + checksum: 9f4b43b3818861a9a3ae356cba3b7670f7960baf01f9215c155c1ebfd56b77346fc55c7c373abb018b07eccddefed349b2ff58ae3d889ba66364c9c2500393a0 + languageName: node + linkType: hard + "@nestjs/config@npm:3.1.1": version: 3.1.1 resolution: "@nestjs/config@npm:3.1.1" @@ -4616,6 +4679,21 @@ __metadata: languageName: node linkType: hard +"@nestjs/config@npm:^3.1.1": + version: 3.2.2 + resolution: "@nestjs/config@npm:3.2.2" + dependencies: + dotenv: 16.4.5 + dotenv-expand: 10.0.0 + lodash: 4.17.21 + uuid: 9.0.1 + peerDependencies: + "@nestjs/common": ^8.0.0 || ^9.0.0 || ^10.0.0 + rxjs: ^7.1.0 + checksum: 1f677ff4ea13dc7d9c27e094965101ea661dd1ec362aed8f188413037dac21c08f71967add551cad5a3d21301c66d4c61815ed709bf101afaa0fd0aa0f4bf14c + languageName: node + linkType: hard + "@nestjs/core@npm:10.2.4": version: 10.2.4 resolution: "@nestjs/core@npm:10.2.4" @@ -5706,6 +5784,15 @@ __metadata: languageName: node linkType: hard +"@ory/client@npm:^1.4.9": + version: 1.9.0 + resolution: "@ory/client@npm:1.9.0" + dependencies: + axios: ^1.6.1 + checksum: c732911c967a67a96ae8acbd5f83b3e5dc38ffbc8b2d8c8f865c2659533762939ee6fe5594540aef3cd91ea5be24af708a46950f338009b3ea2ed6356aeb7a5e + languageName: node + linkType: hard + "@phenomnomnominal/tsquery@npm:~5.0.1": version: 5.0.1 resolution: "@phenomnomnominal/tsquery@npm:5.0.1" @@ -7196,6 +7283,13 @@ __metadata: languageName: node linkType: hard +"@types/validator@npm:^13.11.8": + version: 13.11.9 + resolution: "@types/validator@npm:13.11.9" + checksum: c8d53c9e45479328ed72136d13ac80f13e8bb72ab0a6ae2e82802a55b6d84e045473267d8bf66546961f96ed0bbd0f310f317592dccc9fd11a2a81025c3571ce + languageName: node + linkType: hard + "@types/webidl-conversions@npm:*": version: 7.0.3 resolution: "@types/webidl-conversions@npm:7.0.3" @@ -8403,6 +8497,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:1.6.5": + version: 1.6.5 + resolution: "axios@npm:1.6.5" + dependencies: + follow-redirects: ^1.15.4 + form-data: ^4.0.0 + proxy-from-env: ^1.1.0 + checksum: e28d67b2d9134cb4608c44d8068b0678cfdccc652742e619006f27264a30c7aba13b2cd19c6f1f52ae195b5232734925928fb192d5c85feea7edd2f273df206d + languageName: node + linkType: hard + "axios@npm:^0.27.2": version: 0.27.2 resolution: "axios@npm:0.27.2" @@ -8413,6 +8518,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.6.1": + version: 1.6.8 + resolution: "axios@npm:1.6.8" + dependencies: + follow-redirects: ^1.15.6 + form-data: ^4.0.0 + proxy-from-env: ^1.1.0 + checksum: bf007fa4b207d102459300698620b3b0873503c6d47bf5a8f6e43c0c64c90035a4f698b55027ca1958f61ab43723df2781c38a99711848d232cad7accbcdfcdd + languageName: node + linkType: hard + "axobject-query@npm:4.0.0": version: 4.0.0 resolution: "axobject-query@npm:4.0.0" @@ -9389,7 +9505,7 @@ __metadata: languageName: node linkType: hard -"class-transformer@npm:0.5.1": +"class-transformer@npm:0.5.1, class-transformer@npm:^0.5.1": version: 0.5.1 resolution: "class-transformer@npm:0.5.1" checksum: f191c8b4cc4239990f5efdd790cabdd852c243ed66248e39f6616a349c910c6eed2d9fd1fbf7ee6ea89f69b4f1d0b493b27347fe0cd0ae26b47c3745a805b6d3 @@ -9406,6 +9522,17 @@ __metadata: languageName: node linkType: hard +"class-validator@npm:^0.14.1": + version: 0.14.1 + resolution: "class-validator@npm:0.14.1" + dependencies: + "@types/validator": ^13.11.8 + libphonenumber-js: ^1.10.53 + validator: ^13.9.0 + checksum: bea808145c81ba3b185e1174d92f97a2d6ffef0558261217042552e9027222eadb9a9731a4418a07eaaa72ac334347df7a1079ff48eaadaa3ee6848a6a88995c + languageName: node + linkType: hard + "classnames@npm:^2.3.1": version: 2.3.2 resolution: "classnames@npm:2.3.2" @@ -11943,6 +12070,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:16.4.5": + version: 16.4.5 + resolution: "dotenv@npm:16.4.5" + checksum: 301a12c3d44fd49888b74eb9ccf9f07a1f5df43f489e7fcb89647a2edcd84c42d6bc349dc8df099cd18f07c35c7b04685c1a4f3e6a6a9e6b30f8d48c15b7f49c + languageName: node + linkType: hard + "duplexer@npm:^0.1.1": version: 0.1.2 resolution: "duplexer@npm:0.1.2" @@ -13619,6 +13753,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.4, follow-redirects@npm:^1.15.6": + version: 1.15.6 + resolution: "follow-redirects@npm:1.15.6" + peerDependenciesMeta: + debug: + optional: true + checksum: a62c378dfc8c00f60b9c80cab158ba54e99ba0239a5dd7c81245e5a5b39d10f0c35e249c3379eae719ff0285fff88c365dd446fab19dee771f1d76252df1bbf5 + languageName: node + linkType: hard + "for-each@npm:^0.3.3": version: 0.3.3 resolution: "for-each@npm:0.3.3" @@ -16780,6 +16924,13 @@ __metadata: languageName: node linkType: hard +"libphonenumber-js@npm:^1.10.53": + version: 1.10.60 + resolution: "libphonenumber-js@npm:1.10.60" + checksum: 4267fe6d2a26cacd0d313ccb57fd2f605e915be8dbe7f2b023dd2814da24848e69df273262747df3f5d2cf288616936abb8082fc8bc55c46bc139bca1bd8e2fd + languageName: node + linkType: hard + "libphonenumber-js@npm:^1.9.43": version: 1.10.51 resolution: "libphonenumber-js@npm:1.10.51" @@ -17002,7 +17153,7 @@ __metadata: languageName: node linkType: hard -"lodash.get@npm:^4, lodash.get@npm:^4.4.2": +"lodash.get@npm:^4": version: 4.4.2 resolution: "lodash.get@npm:4.4.2" checksum: e403047ddb03181c9d0e92df9556570e2b67e0f0a930fcbbbd779370972368f5568e914f913e93f3b08f6d492abc71e14d4e9b7a18916c31fa04bd2306efe545 @@ -18117,7 +18268,7 @@ __metadata: languageName: node linkType: hard -"nest-commander@npm:3.12.5": +"nest-commander@npm:3.12.5, nest-commander@npm:^3.12.5": version: 3.12.5 resolution: "nest-commander@npm:3.12.5" dependencies: @@ -22864,9 +23015,11 @@ __metadata: "@fastify/secure-session": 7.1.0 "@fastify/static": ^6.12.0 "@fastify/swagger": 8.10.0 - "@getlarge/keto-client-wrapper": 0.1.0 - "@getlarge/keto-relations-parser": 0.0.3 - "@getlarge/kratos-client-wrapper": 0.1.0 + "@getlarge/hydra-client-wrapper": 0.1.1 + "@getlarge/keto-cli": 0.2.1 + "@getlarge/keto-client-wrapper": 0.2.5 + "@getlarge/keto-relations-parser": 0.0.9 + "@getlarge/kratos-client-wrapper": 0.1.7 "@golevelup/ts-jest": ^0.4.0 "@jscutlery/semver": ^3.1.0 "@mermaid-js/mermaid-cli": ^8.13.4 @@ -23842,6 +23995,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:9.0.1": + version: 9.0.1 + resolution: "uuid@npm:9.0.1" + bin: + uuid: dist/bin/uuid + checksum: 39931f6da74e307f51c0fb463dc2462807531dc80760a9bff1e35af4316131b4fc3203d16da60ae33f07fdca5b56f3f1dd662da0c99fea9aaeab2004780cc5f4 + languageName: node + linkType: hard + "v8-compile-cache-lib@npm:^3.0.1": version: 3.0.1 resolution: "v8-compile-cache-lib@npm:3.0.1" @@ -23926,7 +24088,7 @@ __metadata: languageName: node linkType: hard -"validator@npm:^13.7.0": +"validator@npm:^13.7.0, validator@npm:^13.9.0": version: 13.11.0 resolution: "validator@npm:13.11.0" checksum: d1e0c27022681420756da25bc03eb08d5f0c66fb008f8ff02ebc95812b77c6be6e03d3bd05cf80ca702e23eeb73dadd66b4b3683173ea2a0bc7cc72820bee131 From 7552efbf008cff4021e1e53afb05bf1a630f6e85 Mon Sep 17 00:00:00 2001 From: getlarge Date: Tue, 9 Apr 2024 19:54:24 +0200 Subject: [PATCH 03/23] feat(shared-models): create client model --- libs/shared/constants/src/context.ts | 2 ++ libs/shared/models/src/client.ts | 16 ++++++++++++++++ libs/shared/models/src/index.ts | 1 + 3 files changed, 19 insertions(+) create mode 100644 libs/shared/models/src/client.ts diff --git a/libs/shared/constants/src/context.ts b/libs/shared/constants/src/context.ts index 36ee5056..2c1a4666 100644 --- a/libs/shared/constants/src/context.ts +++ b/libs/shared/constants/src/context.ts @@ -14,6 +14,7 @@ export enum Resources { PAYMENTS = 'payments', TICKETS = 'tickets', USERS = 'users', + CLIENTS = 'clients', } export enum Actions { @@ -50,4 +51,5 @@ export enum Actions { } export const CURRENT_USER_KEY = 'user'; +export const CURRENT_CLIENT_KEY = 'client'; export const SESSION_ACCESS_TOKEN = 'access_token'; diff --git a/libs/shared/models/src/client.ts b/libs/shared/models/src/client.ts new file mode 100644 index 00000000..21392637 --- /dev/null +++ b/libs/shared/models/src/client.ts @@ -0,0 +1,16 @@ +import { Expose } from 'class-transformer'; +import { IsMongoId, IsUUID } from 'class-validator'; + +export class Client { + @Expose() + @IsMongoId() + id: string; + + @Expose() + @IsUUID() + clientId: string; + + @Expose() + @IsMongoId() + userId: string; +} diff --git a/libs/shared/models/src/index.ts b/libs/shared/models/src/index.ts index 8eff1fcf..f4fec7f1 100644 --- a/libs/shared/models/src/index.ts +++ b/libs/shared/models/src/index.ts @@ -1,3 +1,4 @@ +export * from './client'; export * from './order'; export * from './payment'; export * from './ticket'; From 7d2789af808dd989c5ba08e2180f0c42b3ac7160 Mon Sep 17 00:00:00 2001 From: getlarge Date: Tue, 9 Apr 2024 20:09:05 +0200 Subject: [PATCH 04/23] refactor(microservices-shared-guards): move Ory guards mixin in shared lib --- apps/auth/src/app/users/users.controller.ts | 44 ++------------- .../src/app/orders/orders.controller.ts | 53 ++++--------------- .../src/app/payments/payments.controller.ts | 43 +++------------ .../src/app/tickets/tickets.controller.ts | 42 +++------------ libs/microservices/shared/guards/src/index.ts | 3 ++ .../guards/src/ory-authentication.guard.ts | 35 ++++++++++++ .../guards/src/ory-authorization.guard.ts | 5 ++ .../src/ory-oauth2-authentication.guard.ts | 29 ++++++++++ package.json | 2 +- yarn.lock | 10 ++-- 10 files changed, 105 insertions(+), 161 deletions(-) create mode 100644 libs/microservices/shared/guards/src/ory-authentication.guard.ts create mode 100644 libs/microservices/shared/guards/src/ory-authorization.guard.ts create mode 100644 libs/microservices/shared/guards/src/ory-oauth2-authentication.guard.ts diff --git a/apps/auth/src/app/users/users.controller.ts b/apps/auth/src/app/users/users.controller.ts index 93633bfa..48106414 100644 --- a/apps/auth/src/app/users/users.controller.ts +++ b/apps/auth/src/app/users/users.controller.ts @@ -1,4 +1,3 @@ -import { OryAuthenticationGuard } from '@getlarge/kratos-client-wrapper'; import { Body, Controller, @@ -20,14 +19,12 @@ import { } from '@nestjs/swagger'; import { SecurityRequirements } from '@ticketing/microservices/shared/constants'; import { CurrentUser } from '@ticketing/microservices/shared/decorators'; -import { OryActionAuthGuard } from '@ticketing/microservices/shared/guards'; import { - Actions, - CURRENT_USER_KEY, - Resources, -} from '@ticketing/shared/constants'; + OryActionAuthGuard, + OryAuthenticationGuard, +} from '@ticketing/microservices/shared/guards'; +import { Actions, Resources } from '@ticketing/shared/constants'; import { requestValidationErrorFactory } from '@ticketing/shared/errors'; -import { FastifyRequest } from 'fastify/types/request'; import { User, UserCredentials, UserCredentialsDto, UserDto } from './models'; import { OnOrySignInDto, OnOrySignUpDto } from './models/ory-identity.dto'; @@ -111,38 +108,7 @@ export class UsersController { return this.usersService.signUp(credentials); } - @UseGuards( - OryAuthenticationGuard({ - cookieResolver: (ctx) => - ctx.switchToHttp().getRequest().headers.cookie, - isValidSession: (x) => { - return ( - !!x?.identity && - typeof x.identity.traits === 'object' && - !!x.identity.traits && - 'email' in x.identity.traits && - typeof x.identity.metadata_public === 'object' && - !!x.identity.metadata_public && - 'id' in x.identity.metadata_public && - typeof x.identity.metadata_public.id === 'string' - ); - }, - sessionTokenResolver: (ctx) => - ctx - .switchToHttp() - .getRequest() - .headers?.authorization?.replace('Bearer ', ''), - postValidationHook: (ctx, session) => { - ctx.switchToHttp().getRequest().session = session; - // eslint-disable-next-line security/detect-object-injection - ctx.switchToHttp().getRequest()[CURRENT_USER_KEY] = { - id: session.identity.metadata_public['id'], - email: session.identity.traits.email, - identityId: session.identity.id, - }; - }, - }), - ) + @UseGuards(OryAuthenticationGuard()) @ApiOperation({ description: 'Get details about currently signed in user', summary: `Get current user - Scope : ${Resources.USERS}:${Actions.READ_ONE}`, diff --git a/apps/orders/src/app/orders/orders.controller.ts b/apps/orders/src/app/orders/orders.controller.ts index 168793bd..b7197def 100644 --- a/apps/orders/src/app/orders/orders.controller.ts +++ b/apps/orders/src/app/orders/orders.controller.ts @@ -1,19 +1,13 @@ -import { - OryAuthorizationGuard, - OryPermissionChecks, -} from '@getlarge/keto-client-wrapper'; +import { OryPermissionChecks } from '@getlarge/keto-client-wrapper'; import { relationTupleBuilder } from '@getlarge/keto-relations-parser'; -import { OryAuthenticationGuard } from '@getlarge/kratos-client-wrapper'; import { Body, - CanActivate, Controller, Delete, Get, HttpStatus, Param, Post, - Type, UseGuards, UsePipes, ValidationPipe, @@ -28,6 +22,10 @@ import { } from '@nestjs/swagger'; import { SecurityRequirements } from '@ticketing/microservices/shared/constants'; import { CurrentUser } from '@ticketing/microservices/shared/decorators'; +import { + OryAuthenticationGuard, + OryAuthorizationGuard, +} from '@ticketing/microservices/shared/guards'; import { PermissionNamespaces } from '@ticketing/microservices/shared/models'; import { ParseObjectId } from '@ticketing/microservices/shared/pipes'; import { @@ -42,39 +40,6 @@ import type { FastifyRequest } from 'fastify/types/request'; import { CreateOrder, CreateOrderDto, Order, OrderDto } from './models'; import { OrdersService } from './orders.service'; -const AuthenticationGuard = (): Type => - OryAuthenticationGuard({ - cookieResolver: (ctx) => - ctx.switchToHttp().getRequest().headers.cookie, - isValidSession: (x) => { - return ( - !!x?.identity && - typeof x.identity.traits === 'object' && - !!x.identity.traits && - 'email' in x.identity.traits && - typeof x.identity.metadata_public === 'object' && - !!x.identity.metadata_public && - 'id' in x.identity.metadata_public && - typeof x.identity.metadata_public.id === 'string' - ); - }, - sessionTokenResolver: (ctx) => - ctx - .switchToHttp() - .getRequest() - .headers?.authorization?.replace('Bearer ', ''), - postValidationHook: (ctx, session) => { - ctx.switchToHttp().getRequest().session = session; - ctx.switchToHttp().getRequest()[CURRENT_USER_KEY] = { - id: session.identity.metadata_public['id'], - email: session.identity.traits.email, - identityId: session.identity.id, - }; - }, - }); - -const AuthorizationGuard = (): Type => OryAuthorizationGuard({}); - @Controller(Resources.ORDERS) @ApiTags(Resources.ORDERS) export class OrdersController { @@ -91,7 +56,7 @@ export class OrdersController { .of(PermissionNamespaces[Resources.TICKETS], resourceId) .toString(); }) - @UseGuards(AuthenticationGuard(), AuthorizationGuard()) + @UseGuards(OryAuthenticationGuard(), OryAuthorizationGuard()) @UsePipes( new ValidationPipe({ transform: true, @@ -120,7 +85,7 @@ export class OrdersController { return this.ordersService.create(order, currentUser); } - @UseGuards(AuthenticationGuard()) + @UseGuards(OryAuthenticationGuard()) @ApiBearerAuth(SecurityRequirements.Bearer) @ApiCookieAuth(SecurityRequirements.Session) @ApiOperation({ @@ -148,7 +113,7 @@ export class OrdersController { .of(PermissionNamespaces[Resources.ORDERS], resourceId) .toString(); }) - @UseGuards(AuthenticationGuard(), AuthorizationGuard()) + @UseGuards(OryAuthenticationGuard(), OryAuthorizationGuard()) @ApiBearerAuth(SecurityRequirements.Bearer) @ApiCookieAuth(SecurityRequirements.Session) @ApiOperation({ @@ -175,7 +140,7 @@ export class OrdersController { .of(PermissionNamespaces[Resources.ORDERS], resourceId) .toString(); }) - @UseGuards(AuthenticationGuard(), AuthorizationGuard()) + @UseGuards(OryAuthenticationGuard(), OryAuthorizationGuard()) @UsePipes( new ValidationPipe({ transform: true, diff --git a/apps/payments/src/app/payments/payments.controller.ts b/apps/payments/src/app/payments/payments.controller.ts index 0e804161..f6d563c5 100644 --- a/apps/payments/src/app/payments/payments.controller.ts +++ b/apps/payments/src/app/payments/payments.controller.ts @@ -1,9 +1,5 @@ -import { - OryAuthorizationGuard, - OryPermissionChecks, -} from '@getlarge/keto-client-wrapper'; +import { OryPermissionChecks } from '@getlarge/keto-client-wrapper'; import { relationTupleToString } from '@getlarge/keto-relations-parser'; -import { OryAuthenticationGuard } from '@getlarge/kratos-client-wrapper'; import { Body, Controller, @@ -23,6 +19,10 @@ import { } from '@nestjs/swagger'; import { SecurityRequirements } from '@ticketing/microservices/shared/constants'; import { CurrentUser } from '@ticketing/microservices/shared/decorators'; +import { + OryAuthenticationGuard, + OryAuthorizationGuard, +} from '@ticketing/microservices/shared/guards'; import { PermissionNamespaces } from '@ticketing/microservices/shared/models'; import { Actions, @@ -55,38 +55,7 @@ export class PaymentsController { }, }); }) - @UseGuards( - OryAuthenticationGuard({ - cookieResolver: (ctx) => - ctx.switchToHttp().getRequest().headers.cookie, - isValidSession: (x) => { - return ( - !!x?.identity && - typeof x.identity.traits === 'object' && - !!x.identity.traits && - 'email' in x.identity.traits && - typeof x.identity.metadata_public === 'object' && - !!x.identity.metadata_public && - 'id' in x.identity.metadata_public && - typeof x.identity.metadata_public.id === 'string' - ); - }, - sessionTokenResolver: (ctx) => - ctx - .switchToHttp() - .getRequest() - .headers?.authorization?.replace('Bearer ', ''), - postValidationHook: (ctx, session) => { - ctx.switchToHttp().getRequest().session = session; - ctx.switchToHttp().getRequest()[CURRENT_USER_KEY] = { - id: session.identity.metadata_public['id'], - email: session.identity.traits.email, - identityId: session.identity.id, - }; - }, - }), - OryAuthorizationGuard({}), - ) + @UseGuards(OryAuthenticationGuard(), OryAuthorizationGuard()) @UsePipes( new ValidationPipe({ transform: true, diff --git a/apps/tickets/src/app/tickets/tickets.controller.ts b/apps/tickets/src/app/tickets/tickets.controller.ts index b5ca6a8f..7ed9021b 100644 --- a/apps/tickets/src/app/tickets/tickets.controller.ts +++ b/apps/tickets/src/app/tickets/tickets.controller.ts @@ -1,9 +1,7 @@ import { - OryAuthorizationGuard, OryPermissionChecks, } from '@getlarge/keto-client-wrapper'; import { relationTupleBuilder } from '@getlarge/keto-relations-parser'; -import { OryAuthenticationGuard } from '@getlarge/kratos-client-wrapper'; import { Body, Controller, @@ -33,6 +31,10 @@ import { ApiPaginatedDto, CurrentUser, } from '@ticketing/microservices/shared/decorators'; +import { + OryAuthenticationGuard, + OryAuthorizationGuard, +} from '@ticketing/microservices/shared/guards'; import { PaginatedDto, PaginateDto, @@ -62,38 +64,8 @@ import { } from './models'; import { TicketsService } from './tickets.service'; -const AuthenticationGuard = OryAuthenticationGuard({ - cookieResolver: (ctx) => - ctx.switchToHttp().getRequest().headers.cookie, - isValidSession: (x) => { - return ( - !!x?.identity && - typeof x.identity.traits === 'object' && - !!x.identity.traits && - 'email' in x.identity.traits && - typeof x.identity.metadata_public === 'object' && - !!x.identity.metadata_public && - 'id' in x.identity.metadata_public && - typeof x.identity.metadata_public.id === 'string' - ); - }, - sessionTokenResolver: (ctx) => - ctx - .switchToHttp() - .getRequest() - .headers?.authorization?.replace('Bearer ', ''), - postValidationHook: (ctx, session) => { - ctx.switchToHttp().getRequest().session = session; - // eslint-disable-next-line security/detect-object-injection - ctx.switchToHttp().getRequest()[CURRENT_USER_KEY] = { - id: session.identity.metadata_public['id'], - email: session.identity.traits.email, - identityId: session.identity.id, - }; - }, -}); -const AuthorizationGuard = OryAuthorizationGuard({}); + const validationPipeOptions: ValidationPipeOptions = { transform: true, @@ -108,7 +80,7 @@ const validationPipeOptions: ValidationPipeOptions = { export class TicketsController { constructor(private readonly ticketsService: TicketsService) {} - @UseGuards(AuthenticationGuard) + @UseGuards(OryAuthenticationGuard()) @UsePipes(new ValidationPipe(validationPipeOptions)) @ApiBearerAuth(SecurityRequirements.Bearer) @ApiCookieAuth(SecurityRequirements.Session) @@ -176,7 +148,7 @@ export class TicketsController { .of(PermissionNamespaces[Resources.TICKETS], resourceId) .toString(); }) - @UseGuards(AuthenticationGuard, AuthorizationGuard) + @UseGuards(OryAuthenticationGuard(), OryAuthorizationGuard()) @UsePipes(new ValidationPipe(validationPipeOptions)) @ApiBearerAuth(SecurityRequirements.Bearer) @ApiCookieAuth(SecurityRequirements.Session) diff --git a/libs/microservices/shared/guards/src/index.ts b/libs/microservices/shared/guards/src/index.ts index 1472d203..a2c2d7b2 100644 --- a/libs/microservices/shared/guards/src/index.ts +++ b/libs/microservices/shared/guards/src/index.ts @@ -1,3 +1,6 @@ export * from './jwt.strategy'; export * from './jwt-auth.guard'; export * from './ory-action-auth.guard'; +export * from './ory-authentication.guard'; +export * from './ory-authorization.guard'; +export * from './ory-oauth2-authentication.guard'; diff --git a/libs/microservices/shared/guards/src/ory-authentication.guard.ts b/libs/microservices/shared/guards/src/ory-authentication.guard.ts new file mode 100644 index 00000000..12c8cf99 --- /dev/null +++ b/libs/microservices/shared/guards/src/ory-authentication.guard.ts @@ -0,0 +1,35 @@ +import { OryAuthenticationGuard as oryAuthenticationGuard } from '@getlarge/kratos-client-wrapper'; +import { CanActivate, Type } from '@nestjs/common'; +import { CURRENT_USER_KEY } from '@ticketing/shared/constants'; +import type { FastifyRequest } from 'fastify'; + +export const OryAuthenticationGuard = (): Type => + oryAuthenticationGuard({ + cookieResolver: (ctx) => + ctx.switchToHttp().getRequest().headers.cookie, + isValidSession: (x) => { + return ( + !!x?.identity && + typeof x.identity.traits === 'object' && + !!x.identity.traits && + 'email' in x.identity.traits && + typeof x.identity.metadata_public === 'object' && + !!x.identity.metadata_public && + 'id' in x.identity.metadata_public && + typeof x.identity.metadata_public.id === 'string' + ); + }, + sessionTokenResolver: (ctx) => + ctx + .switchToHttp() + .getRequest() + .headers?.authorization?.replace('Bearer ', ''), + postValidationHook: (ctx, session) => { + ctx.switchToHttp().getRequest().session = session; + ctx.switchToHttp().getRequest()[CURRENT_USER_KEY] = { + id: session.identity.metadata_public['id'], + email: session.identity.traits.email, + identityId: session.identity.id, + }; + }, + }); diff --git a/libs/microservices/shared/guards/src/ory-authorization.guard.ts b/libs/microservices/shared/guards/src/ory-authorization.guard.ts new file mode 100644 index 00000000..6dc1f8cd --- /dev/null +++ b/libs/microservices/shared/guards/src/ory-authorization.guard.ts @@ -0,0 +1,5 @@ +import { OryAuthorizationGuard as oryAuthorizationGuard } from '@getlarge/keto-client-wrapper'; +import { CanActivate, Type } from '@nestjs/common'; + +export const OryAuthorizationGuard = (): Type => + oryAuthorizationGuard({}); diff --git a/libs/microservices/shared/guards/src/ory-oauth2-authentication.guard.ts b/libs/microservices/shared/guards/src/ory-oauth2-authentication.guard.ts new file mode 100644 index 00000000..42002878 --- /dev/null +++ b/libs/microservices/shared/guards/src/ory-oauth2-authentication.guard.ts @@ -0,0 +1,29 @@ +import { OryOAuth2AuthenticationGuard as oryOAuth2AuthenticationGuard } from '@getlarge/hydra-client-wrapper'; +import { CanActivate, Type } from '@nestjs/common'; +import { + CURRENT_CLIENT_KEY, + CURRENT_USER_KEY, +} from '@ticketing/shared/constants'; +import type { Client, User } from '@ticketing/shared/models'; +import type { FastifyRequest } from 'fastify'; + +export const OryOAuth2AuthenticationGuard = (): Type => + oryOAuth2AuthenticationGuard({ + accessTokenResolver: (ctx) => + ctx + .switchToHttp() + .getRequest() + .headers?.authorization?.replace('Bearer ', ''), + postValidationHook(ctx, token) { + ctx.switchToHttp().getRequest()[CURRENT_CLIENT_KEY] = { + id: token.ext.clientId, + clientId: token.client_id, + userId: token.ext.userId, + } satisfies Client; + ctx.switchToHttp().getRequest()[CURRENT_USER_KEY] = { + id: token.ext.userId, + email: token.ext.userEmail, + identityId: token.ext.userIdentityId, + } satisfies User; + }, + }); diff --git a/package.json b/package.json index e2d457ee..b9451b4d 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "@fastify/secure-session": "7.1.0", "@fastify/static": "^6.12.0", "@fastify/swagger": "8.10.0", - "@getlarge/hydra-client-wrapper": "0.1.1", + "@getlarge/hydra-client-wrapper": "0.2.0", "@getlarge/keto-cli": "0.2.1", "@getlarge/keto-client-wrapper": "0.2.5", "@getlarge/keto-relations-parser": "0.0.9", diff --git a/yarn.lock b/yarn.lock index 5cc4f73a..c3af731a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3923,16 +3923,16 @@ __metadata: languageName: node linkType: hard -"@getlarge/hydra-client-wrapper@npm:0.1.1": - version: 0.1.1 - resolution: "@getlarge/hydra-client-wrapper@npm:0.1.1" +"@getlarge/hydra-client-wrapper@npm:0.2.0": + version: 0.2.0 + resolution: "@getlarge/hydra-client-wrapper@npm:0.2.0" dependencies: "@nestjs/axios": ^3.0.1 "@nestjs/common": ^10.0.2 "@ory/client": ^1.4.9 axios: 1.6.5 tslib: ^2.3.0 - checksum: fe30c0e6d6c2badeadb7d3fad0952675abeeb11ca1278a7abcf4a139369c54c5b06a1ba03e6b78927d70e59672547c765f6eeb971a975371e52b8199645154cf + checksum: 1c0b8318f16ddcbca67ea8ca8de4f830fd766feffc83e0d0861585a39cc69c1c63c41b548e538ce7cf6a8abf6eff6f96a46ad5411c674a0527555f9308d155b6 languageName: node linkType: hard @@ -23015,7 +23015,7 @@ __metadata: "@fastify/secure-session": 7.1.0 "@fastify/static": ^6.12.0 "@fastify/swagger": 8.10.0 - "@getlarge/hydra-client-wrapper": 0.1.1 + "@getlarge/hydra-client-wrapper": 0.2.0 "@getlarge/keto-cli": 0.2.1 "@getlarge/keto-client-wrapper": 0.2.5 "@getlarge/keto-relations-parser": 0.0.9 From 96fdd619cd285668f8155dd3bf04b8abfe18ae97 Mon Sep 17 00:00:00 2001 From: getlarge Date: Tue, 9 Apr 2024 20:10:02 +0200 Subject: [PATCH 05/23] feat(auth): create models for Clients module --- .../auth/src/app/clients/models/client.dto.ts | 18 +++ .../app/clients/models/create-client.dto.ts | 9 ++ apps/auth/src/app/clients/models/index.ts | 4 + .../src/app/clients/models/ory-token.dto.ts | 129 ++++++++++++++++++ .../src/app/clients/schemas/client.schema.ts | 34 +++++ apps/auth/src/app/clients/schemas/index.ts | 1 + 6 files changed, 195 insertions(+) create mode 100644 apps/auth/src/app/clients/models/client.dto.ts create mode 100644 apps/auth/src/app/clients/models/create-client.dto.ts create mode 100644 apps/auth/src/app/clients/models/index.ts create mode 100644 apps/auth/src/app/clients/models/ory-token.dto.ts create mode 100644 apps/auth/src/app/clients/schemas/client.schema.ts create mode 100644 apps/auth/src/app/clients/schemas/index.ts diff --git a/apps/auth/src/app/clients/models/client.dto.ts b/apps/auth/src/app/clients/models/client.dto.ts new file mode 100644 index 00000000..56570cc2 --- /dev/null +++ b/apps/auth/src/app/clients/models/client.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty, PickType } from '@nestjs/swagger'; +import { Client } from '@ticketing/shared/models'; + +export class ClientDto extends PickType(Client, ['id', 'clientId', 'userId']) { + @ApiProperty() + id: string; + + @ApiProperty({ + description: 'Ory client id', + format: 'uuid', + }) + clientId: string; + + @ApiProperty({ + description: 'Owner id', + }) + userId: string; +} diff --git a/apps/auth/src/app/clients/models/create-client.dto.ts b/apps/auth/src/app/clients/models/create-client.dto.ts new file mode 100644 index 00000000..b7b6877b --- /dev/null +++ b/apps/auth/src/app/clients/models/create-client.dto.ts @@ -0,0 +1,9 @@ +import { Expose } from 'class-transformer'; +import { IsOptional, IsString } from 'class-validator'; + +export class CreateClientDto { + @Expose() + @IsOptional() + @IsString() + scope?: string; +} diff --git a/apps/auth/src/app/clients/models/index.ts b/apps/auth/src/app/clients/models/index.ts new file mode 100644 index 00000000..884652ec --- /dev/null +++ b/apps/auth/src/app/clients/models/index.ts @@ -0,0 +1,4 @@ +export * from './client.dto'; +export * from './create-client.dto'; +export * from './ory-token.dto'; +export { Client } from '@ticketing/shared/models'; diff --git a/apps/auth/src/app/clients/models/ory-token.dto.ts b/apps/auth/src/app/clients/models/ory-token.dto.ts new file mode 100644 index 00000000..12ac1caa --- /dev/null +++ b/apps/auth/src/app/clients/models/ory-token.dto.ts @@ -0,0 +1,129 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsNotEmptyObject, + IsString, + ValidateNested, +} from 'class-validator'; + +class OryOAuth2WebhookPayloadIDTokenClaims { + @IsString() + jti: string; + + @IsString() + iss: string; + + @IsString() + sub: string; + + @IsArray() + @IsString({ each: true }) + aud: string[]; + + @IsString() + nonce: string; + + @IsString() + at_hash: string; + + @IsString() + acr: string; + + @IsString() + amr: null; + + @IsString() + c_hash: string; + + ext: object; +} + +class OryOAuth2WebhookPayloadIDToken { + @Type(() => OryOAuth2WebhookPayloadIDTokenClaims) + @ValidateNested() + @IsNotEmptyObject() + id_token_claims: OryOAuth2WebhookPayloadIDTokenClaims; + + headers: { + extra: object; + }; + + @IsString() + username: string; + + @IsString() + subject: string; +} + +class OryOAuth2WebhookPayloadSession { + @Type(() => OryOAuth2WebhookPayloadIDToken) + @ValidateNested() + @IsNotEmptyObject() + id_token: OryOAuth2WebhookPayloadIDToken; + + extra: object; + + @IsString() + client_id: string; + + @IsString() + consent_challenge: string; + + @IsBoolean() + exclude_not_before_claim: boolean; + + @IsArray() + @IsString({ each: true }) + allowed_top_level_claims: string[]; +} + +class OryOAuth2WebhookPayloadRequestDto { + @IsString() + client_id: string; + + @IsArray() + @IsString({ each: true }) + granted_scopes: string[]; + + @IsArray() + @IsString({ each: true }) + granted_audience: string[]; + + @IsArray() + @IsString({ each: true }) + grant_types: string[]; + + payload: object; +} + +/** + * @see https://www.ory.sh/docs/hydra/guides/claims-at-refresh#webhook-payload + */ +export class OryOAuth2WebhookPayloadDto { + @Type(() => OryOAuth2WebhookPayloadSession) + @ValidateNested() + @IsNotEmptyObject() + session: OryOAuth2WebhookPayloadSession; + + @Type(() => OryOAuth2WebhookPayloadRequestDto) + @ValidateNested() + @IsNotEmptyObject() + request: OryOAuth2WebhookPayloadRequestDto; +} + +/** + * @see https://www.ory.sh/docs/hydra/guides/claims-at-refresh#webhook-responses + */ +export class OryOAuth2WebhookResponseDto { + session: { + access_token?: { + [key: string]: string; + }; + id_token?: { + [key: string]: string; + }; + }; +} diff --git a/apps/auth/src/app/clients/schemas/client.schema.ts b/apps/auth/src/app/clients/schemas/client.schema.ts new file mode 100644 index 00000000..20c6cc69 --- /dev/null +++ b/apps/auth/src/app/clients/schemas/client.schema.ts @@ -0,0 +1,34 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Model, ObjectId, Schema as MongooseSchema } from 'mongoose'; + +import { User } from '../../users/schemas'; +import { Client as ClientAttrs } from '../models'; + +@Schema({ + toJSON: { + transform(doc: ClientDocument, ret: ClientAttrs & { _id: ObjectId }) { + ret.id = doc._id.toString(); + ret.userId = doc.user._id.toString(); + const { _id, ...rest } = ret; + return rest; + }, + }, +}) +export class Client extends ClientAttrs { + @Prop({ + type: MongooseSchema.Types.ObjectId, + ref: 'User', + }) + user: User & Document; + + @Prop({ type: String, required: true, unique: true, index: true }) + declare clientId: string; +} + +export type ClientDocument = Client & Document; + +export const ClientSchema = SchemaFactory.createForClass(Client); + +export interface ClientModel extends Model { + build(attr: ClientAttrs): ClientDocument; +} diff --git a/apps/auth/src/app/clients/schemas/index.ts b/apps/auth/src/app/clients/schemas/index.ts new file mode 100644 index 00000000..6264a238 --- /dev/null +++ b/apps/auth/src/app/clients/schemas/index.ts @@ -0,0 +1 @@ +export * from './client.schema'; From 123bcc1737ccd934e44550da5cbe7f00436a2e4d Mon Sep 17 00:00:00 2001 From: getlarge Date: Tue, 9 Apr 2024 20:13:52 +0200 Subject: [PATCH 06/23] feat(auth): create initial ClientsModule --- .env.example | 2 +- .../src/app/clients/clients.controller.ts | 114 ++++++++++++++++++ apps/auth/src/app/clients/clients.module.ts | 41 +++++++ apps/auth/src/app/clients/clients.service.ts | 80 ++++++++++++ .../src/current-client.decorator.ts | 10 ++ .../shared/decorators/src/index.ts | 1 + .../env/src/ory-environment-variables.ts | 61 ++-------- 7 files changed, 260 insertions(+), 49 deletions(-) create mode 100644 apps/auth/src/app/clients/clients.controller.ts create mode 100644 apps/auth/src/app/clients/clients.module.ts create mode 100644 apps/auth/src/app/clients/clients.service.ts create mode 100644 libs/microservices/shared/decorators/src/current-client.decorator.ts diff --git a/.env.example b/.env.example index a90e6cc3..5a487a9a 100644 --- a/.env.example +++ b/.env.example @@ -76,5 +76,5 @@ urls_identity_provider_publicUrl="http://127.0.0.1:4433" urls_identity_provider_url="http://kratos:4434" secrets_system="system_secret_not_good_not_secure" oidc_subject_identifiers_pairwise_salt="not_secure_salt" -oauth2_token_hook_url="http://host.docker.internal:8080/api/clients/on-token-create" +oauth2_token_hook_url="http://host.docker.internal:8080/api/clients/on-token-request" oauth2_token_hook_auth_config_value="unsecure_api_key" diff --git a/apps/auth/src/app/clients/clients.controller.ts b/apps/auth/src/app/clients/clients.controller.ts new file mode 100644 index 00000000..8f5bbc1f --- /dev/null +++ b/apps/auth/src/app/clients/clients.controller.ts @@ -0,0 +1,114 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { SecurityRequirements } from '@ticketing/microservices/shared/constants'; +import { + CurrentClient, + CurrentUser, +} from '@ticketing/microservices/shared/decorators'; +import { + OryActionAuthGuard, + OryAuthenticationGuard, + OryOAuth2AuthenticationGuard, +} from '@ticketing/microservices/shared/guards'; +import { Actions, Resources } from '@ticketing/shared/constants'; +import { requestValidationErrorFactory } from '@ticketing/shared/errors'; + +import { User } from '../users/models'; +import { ClientsService } from './clients.service'; +import { + Client, + ClientDto, + CreateClientDto, + OryOAuth2WebhookPayloadDto, + OryOAuth2WebhookResponseDto, +} from './models'; + +@Controller(Resources.CLIENTS) +@ApiTags(Resources.CLIENTS) +export class ClientsController { + constructor(private readonly clientsService: ClientsService) {} + + @UseGuards(OryAuthenticationGuard()) + @UsePipes( + new ValidationPipe({ + transform: true, + exceptionFactory: requestValidationErrorFactory, + forbidUnknownValues: true, + }), + ) + @ApiOperation({ + description: 'Register a new client', + summary: `Register a new client - Scope : ${Resources.CLIENTS}:${Actions.CREATE_ONE}`, + }) + @ApiBody({ type: CreateClientDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Client created', + type: ClientDto, + }) + @Post('') + @HttpCode(HttpStatus.CREATED) + create( + @CurrentUser() user: User, + @Body() body: CreateClientDto, + ): Promise { + return this.clientsService.create(body, user); + } + + @UseGuards(OryActionAuthGuard) + @UsePipes( + new ValidationPipe({ + transform: true, + exceptionFactory: requestValidationErrorFactory, + forbidUnknownValues: true, + }), + ) + @ApiOperation({ + description: 'Triggered when a client request an OAuth2 token', + }) + @ApiBody({ type: OryOAuth2WebhookPayloadDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Token session update', + type: OryOAuth2WebhookResponseDto, + }) + @Post('on-token-request') + @HttpCode(HttpStatus.OK) + onSignUp( + @Body() body: OryOAuth2WebhookPayloadDto, + ): Promise { + return this.clientsService.onTokenRequest(body); + } + + @UseGuards(OryOAuth2AuthenticationGuard()) + @ApiOperation({ + description: 'Get details about currently authenticated client', + summary: `Get current user - Scope : ${Resources.CLIENTS}:${Actions.READ_ONE}`, + }) + @ApiBearerAuth(SecurityRequirements.Bearer) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Current client authenticated', + type: ClientDto, + }) + @Get('current-client') + getCurrentClient(@CurrentClient() client: Client): Client { + return client; + } +} diff --git a/apps/auth/src/app/clients/clients.module.ts b/apps/auth/src/app/clients/clients.module.ts new file mode 100644 index 00000000..a41d3c34 --- /dev/null +++ b/apps/auth/src/app/clients/clients.module.ts @@ -0,0 +1,41 @@ +import { OryOAuth2Module } from '@getlarge/hydra-client-wrapper'; +import { OryFrontendModule } from '@getlarge/kratos-client-wrapper'; +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MongooseModule } from '@nestjs/mongoose'; + +import { EnvironmentVariables } from '../env'; +import { ClientsController } from './clients.controller'; +import { ClientsService } from './clients.service'; +import { Client, ClientSchema } from './schemas/client.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { + name: Client.name, + schema: ClientSchema, + }, + ]), + OryFrontendModule.forRootAsync({ + inject: [ConfigService], + useFactory: ( + configService: ConfigService, + ) => ({ + basePath: configService.get('ORY_KRATOS_PUBLIC_URL'), + }), + }), + OryOAuth2Module.forRootAsync({ + inject: [ConfigService], + useFactory: ( + configService: ConfigService, + ) => ({ + basePath: configService.get('ORY_HYDRA_PUBLIC_URL'), + accessToken: configService.get('ORY_HYDRA_API_KEY'), + }), + }), + ], + controllers: [ClientsController], + providers: [ClientsService], +}) +export class ClientsModule {} diff --git a/apps/auth/src/app/clients/clients.service.ts b/apps/auth/src/app/clients/clients.service.ts new file mode 100644 index 00000000..757133fb --- /dev/null +++ b/apps/auth/src/app/clients/clients.service.ts @@ -0,0 +1,80 @@ +import { OryOAuth2Service } from '@getlarge/hydra-client-wrapper'; +import { + HttpException, + HttpStatus, + Inject, + Injectable, + Logger, +} from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { inspect } from 'util'; + +import { User } from '../users/models'; +import { + Client, + CreateClientDto, + OryOAuth2WebhookPayloadDto, + OryOAuth2WebhookResponseDto, +} from './models'; +import { Client as ClientSchema, ClientDocument } from './schemas'; + +@Injectable() +export class ClientsService { + readonly logger = new Logger(ClientsService.name); + + constructor( + @Inject(OryOAuth2Service) + private readonly oryOAuth2Service: OryOAuth2Service, + @InjectModel(ClientSchema.name) + private readonly clientModel: Model, + ) {} + + async create(body: CreateClientDto, user: User): Promise { + const { scope = 'offline' } = body; + const { data: oryClient } = await this.oryOAuth2Service.createOAuth2Client({ + oAuth2Client: { + owner: user.id, + access_token_strategy: 'opaque', + grant_types: ['client_credentials'], + scope, + }, + }); + const client = await this.clientModel.create({ + clientId: oryClient.client_id, + user: Types.ObjectId.createFromHexString(user.id), + }); + return client.toJSON(); + } + + /** + * @see https://www.ory.sh/docs/hydra/guides/claims-at-refresh#updated-tokens + **/ + async onTokenRequest( + body: OryOAuth2WebhookPayloadDto, + ): Promise { + this.logger.debug(`onTokenRequest`, inspect(body, { depth: null })); + const clientId = body.session.client_id; + const existingClient = await this.clientModel + .findOne({ + clientId, + }) + .populate('user'); + if (!existingClient) { + throw new HttpException( + `Client ${clientId} not found`, + HttpStatus.NOT_FOUND, + ); + } + return { + session: { + access_token: { + clientId: existingClient.id, + userId: existingClient.user._id.toString(), + userEmail: existingClient.user.email, + userIdentityId: existingClient.user.identityId, + }, + }, + }; + } +} diff --git a/libs/microservices/shared/decorators/src/current-client.decorator.ts b/libs/microservices/shared/decorators/src/current-client.decorator.ts new file mode 100644 index 00000000..a52c6af9 --- /dev/null +++ b/libs/microservices/shared/decorators/src/current-client.decorator.ts @@ -0,0 +1,10 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { CURRENT_CLIENT_KEY } from '@ticketing/shared/constants'; +import { FastifyRequest } from 'fastify'; + +export const CurrentClient = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request[CURRENT_CLIENT_KEY]; + } +); diff --git a/libs/microservices/shared/decorators/src/index.ts b/libs/microservices/shared/decorators/src/index.ts index da898843..19ada637 100644 --- a/libs/microservices/shared/decorators/src/index.ts +++ b/libs/microservices/shared/decorators/src/index.ts @@ -1,3 +1,4 @@ export * from './api-nested-query.decorator'; export * from './api-paginated-dto.decorator'; +export * from './current-client.decorator'; export * from './current-user.decorator'; diff --git a/libs/microservices/shared/env/src/ory-environment-variables.ts b/libs/microservices/shared/env/src/ory-environment-variables.ts index 9abe16b9..9f035c2b 100644 --- a/libs/microservices/shared/env/src/ory-environment-variables.ts +++ b/libs/microservices/shared/env/src/ory-environment-variables.ts @@ -2,28 +2,21 @@ import { Expose } from 'class-transformer'; import { IsOptional, IsString, IsUrl } from 'class-validator'; import { decorate } from 'ts-mixer'; +const isUrlOptions = { + require_protocol: true, + require_valid_protocol: true, + require_host: true, + require_tld: false, +}; + export class OryKratosEnvironmentVariables { @decorate(Expose()) - @decorate( - IsUrl({ - require_protocol: true, - require_valid_protocol: true, - require_host: true, - require_tld: false, - }), - ) + @decorate(IsUrl(isUrlOptions)) @decorate(IsOptional()) ORY_KRATOS_ADMIN_URL?: string = 'http://localhost:4434'; @decorate(Expose()) - @decorate( - IsUrl({ - require_protocol: true, - require_valid_protocol: true, - require_host: true, - require_tld: false, - }), - ) + @decorate(IsUrl(isUrlOptions)) @decorate(IsOptional()) ORY_KRATOS_PUBLIC_URL?: string = 'http://localhost:4433'; @@ -35,26 +28,12 @@ export class OryKratosEnvironmentVariables { export class OryHydraEnvironmentVariables { @decorate(Expose()) - @decorate( - IsUrl({ - require_protocol: true, - require_valid_protocol: true, - require_host: true, - require_tld: false, - }), - ) + @decorate(IsUrl(isUrlOptions)) @decorate(IsOptional()) ORY_HYDRA_ADMIN_URL?: string = 'http://localhost:4445'; @decorate(Expose()) - @decorate( - IsUrl({ - require_protocol: true, - require_valid_protocol: true, - require_host: true, - require_tld: false, - }), - ) + @decorate(IsUrl(isUrlOptions)) @decorate(IsOptional()) ORY_HYDRA_PUBLIC_URL?: string = 'http://localhost:4444'; @@ -66,25 +45,11 @@ export class OryHydraEnvironmentVariables { export class OryKetoEnvironmentVariables { @decorate(Expose()) - @decorate( - IsUrl({ - require_protocol: true, - require_valid_protocol: true, - require_host: true, - require_tld: false, - }), - ) + @decorate(IsUrl(isUrlOptions)) ORY_KETO_ADMIN_URL?: string = 'http://localhost:4467'; @decorate(Expose()) - @decorate( - IsUrl({ - require_protocol: true, - require_valid_protocol: true, - require_host: true, - require_tld: false, - }), - ) + @decorate(IsUrl(isUrlOptions)) ORY_KETO_PUBLIC_URL?: string = 'http://localhost:4466'; @decorate(Expose()) From 081d3a378a31a93c40802849c9addd163bbd58ff Mon Sep 17 00:00:00 2001 From: getlarge Date: Tue, 9 Apr 2024 20:19:08 +0200 Subject: [PATCH 07/23] chore: regenerate OpenAPI specs --- apps/auth/openapi.json | 132 ++++++++++++ apps/auth/src/app/app.module.ts | 2 + .../src/app/clients/clients.controller.ts | 2 +- apps/auth/src/main.ts | 1 + .../src/app/tickets/tickets.controller.ts | 7 +- docs/openapi.json | 161 +++++++++++++- .../src/current-client.decorator.ts | 2 +- .../src/lib/generated/auth/api.module.ts | 39 ++-- .../open-api/src/lib/generated/auth/models.ts | 4 + .../lib/generated/auth/models/client-dto.ts | 14 ++ .../auth/models/create-client-dto.ts | 3 + .../ory-o-auth-2-webhook-payload-dto.ts | 3 + .../ory-o-auth-2-webhook-response-dto.ts | 3 + .../src/lib/generated/auth/services.ts | 1 + .../auth/services/clients.service.ts | 202 ++++++++++++++++++ .../generated/payments/models/payment-dto.ts | 3 +- 16 files changed, 551 insertions(+), 28 deletions(-) create mode 100644 libs/ng/open-api/src/lib/generated/auth/models/client-dto.ts create mode 100644 libs/ng/open-api/src/lib/generated/auth/models/create-client-dto.ts create mode 100644 libs/ng/open-api/src/lib/generated/auth/models/ory-o-auth-2-webhook-payload-dto.ts create mode 100644 libs/ng/open-api/src/lib/generated/auth/models/ory-o-auth-2-webhook-response-dto.ts create mode 100644 libs/ng/open-api/src/lib/generated/auth/services/clients.service.ts diff --git a/apps/auth/openapi.json b/apps/auth/openapi.json index c6b90640..68f395cb 100644 --- a/apps/auth/openapi.json +++ b/apps/auth/openapi.json @@ -130,6 +130,100 @@ } ] } + }, + "/api/clients": { + "post": { + "operationId": "ClientsController_create", + "summary": "Register a new client - Scope : clients:create_one", + "description": "Register a new client", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateClientDto" + } + } + } + }, + "responses": { + "200": { + "description": "Client created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientDto" + } + } + } + } + }, + "tags": [ + "clients" + ] + } + }, + "/api/clients/on-token-request": { + "post": { + "operationId": "ClientsController_onSignUp", + "summary": "", + "description": "Triggered when a client request an OAuth2 token", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OryOAuth2WebhookPayloadDto" + } + } + } + }, + "responses": { + "200": { + "description": "Token session update", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OryOAuth2WebhookResponseDto" + } + } + } + } + }, + "tags": [ + "clients" + ] + } + }, + "/api/clients/current-client": { + "get": { + "operationId": "ClientsController_getCurrentClient", + "summary": "Get current client - Scope : clients:read_one", + "description": "Get details about currently authenticated client", + "parameters": [], + "responses": { + "201": { + "description": "Current client authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientDto" + } + } + } + } + }, + "tags": [ + "clients" + ], + "security": [ + { + "bearer": [] + } + ] + } } }, "info": { @@ -142,6 +236,10 @@ { "name": "users", "description": "" + }, + { + "name": "clients", + "description": "" } ], "servers": [ @@ -308,6 +406,40 @@ "id", "identityId" ] + }, + "CreateClientDto": { + "type": "object", + "properties": {} + }, + "ClientDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "clientId": { + "type": "string", + "description": "Ory client id", + "format": "uuid" + }, + "userId": { + "type": "string", + "description": "Owner id" + } + }, + "required": [ + "id", + "clientId", + "userId" + ] + }, + "OryOAuth2WebhookPayloadDto": { + "type": "object", + "properties": {} + }, + "OryOAuth2WebhookResponseDto": { + "type": "object", + "properties": {} } } }, diff --git a/apps/auth/src/app/app.module.ts b/apps/auth/src/app/app.module.ts index 9eaeb068..c657dd53 100644 --- a/apps/auth/src/app/app.module.ts +++ b/apps/auth/src/app/app.module.ts @@ -9,6 +9,7 @@ import { LoggerModule } from 'nestjs-pino'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { ClientsModule } from './clients/clients.module'; import { EnvironmentVariables } from './env'; import { HealthModule } from './health/health.module'; import { UsersModule } from './users/users.module'; @@ -48,6 +49,7 @@ import { UsersModule } from './users/users.module'; }), HealthModule, UsersModule, + ClientsModule, ], controllers: [AppController], providers: [ diff --git a/apps/auth/src/app/clients/clients.controller.ts b/apps/auth/src/app/clients/clients.controller.ts index 8f5bbc1f..f02b3ef2 100644 --- a/apps/auth/src/app/clients/clients.controller.ts +++ b/apps/auth/src/app/clients/clients.controller.ts @@ -99,7 +99,7 @@ export class ClientsController { @UseGuards(OryOAuth2AuthenticationGuard()) @ApiOperation({ description: 'Get details about currently authenticated client', - summary: `Get current user - Scope : ${Resources.CLIENTS}:${Actions.READ_ONE}`, + summary: `Get current client - Scope : ${Resources.CLIENTS}:${Actions.READ_ONE}`, }) @ApiBearerAuth(SecurityRequirements.Bearer) @ApiResponse({ diff --git a/apps/auth/src/main.ts b/apps/auth/src/main.ts index ce276737..ce0abc58 100644 --- a/apps/auth/src/main.ts +++ b/apps/auth/src/main.ts @@ -102,6 +102,7 @@ async function bootstrap(): Promise { .addSecurityRequirements(SecurityRequirements.Session) .addSecurityRequirements(SecurityRequirements.Bearer) .addTag(Resources.USERS) + .addTag(Resources.CLIENTS) .addServer(configService.get('SERVER_URL')); if (proxyServerUrls.length) { diff --git a/apps/tickets/src/app/tickets/tickets.controller.ts b/apps/tickets/src/app/tickets/tickets.controller.ts index 7ed9021b..fc1a9f4f 100644 --- a/apps/tickets/src/app/tickets/tickets.controller.ts +++ b/apps/tickets/src/app/tickets/tickets.controller.ts @@ -1,6 +1,4 @@ -import { - OryPermissionChecks, -} from '@getlarge/keto-client-wrapper'; +import { OryPermissionChecks } from '@getlarge/keto-client-wrapper'; import { relationTupleBuilder } from '@getlarge/keto-relations-parser'; import { Body, @@ -64,9 +62,6 @@ import { } from './models'; import { TicketsService } from './tickets.service'; - - - const validationPipeOptions: ValidationPipeOptions = { transform: true, exceptionFactory: requestValidationErrorFactory, diff --git a/docs/openapi.json b/docs/openapi.json index 4f060739..b3797824 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -31,6 +31,10 @@ "name": "users", "description": "" }, + { + "name": "clients", + "description": "" + }, { "name": "tickets", "description": "" @@ -482,6 +486,127 @@ ] } }, + "/api/clients": { + "post": { + "operationId": "ClientsController_create", + "summary": "Register a new client - Scope : clients:create_one", + "description": "Register a new client", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + } + }, + "responses": { + "200": { + "description": "Client created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "clientId": { + "type": "string", + "description": "Ory client id", + "format": "uuid" + }, + "userId": { + "type": "string", + "description": "Owner id" + } + }, + "required": ["id", "clientId", "userId"] + } + } + } + } + }, + "tags": ["clients"] + } + }, + "/api/clients/on-token-request": { + "post": { + "operationId": "ClientsController_onSignUp", + "summary": "", + "description": "Triggered when a client request an OAuth2 token", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + } + }, + "responses": { + "200": { + "description": "Token session update", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + } + } + }, + "tags": ["clients"] + } + }, + "/api/clients/current-client": { + "get": { + "operationId": "ClientsController_getCurrentClient", + "summary": "Get current user - Scope : clients:read_one", + "description": "Get details about currently authenticated client", + "parameters": [], + "responses": { + "201": { + "description": "Current client authenticated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "clientId": { + "type": "string", + "description": "Ory client id", + "format": "uuid" + }, + "userId": { + "type": "string", + "description": "Owner id" + } + }, + "required": ["id", "clientId", "userId"] + } + } + } + } + }, + "tags": ["clients"], + "security": [ + { + "bearer": [] + } + ] + } + }, "/api/tickets": { "post": { "operationId": "TicketsController_create", @@ -895,7 +1020,7 @@ }, "tags": ["tickets"] }, - "put": { + "patch": { "operationId": "TicketsController_updateById", "summary": "Update a ticket - Scope : tickets:update_one", "description": "Update a ticket by id", @@ -1433,7 +1558,7 @@ }, "version": { "type": "number", - "description": "Payment version represented by a number incremented at each updated" + "description": "Payment version represented by a number incremented at each update" } }, "required": ["id", "orderId", "stripeId", "version"] @@ -1693,6 +1818,36 @@ }, "required": ["email", "id", "identityId"] }, + "CreateClientDto": { + "type": "object", + "properties": {} + }, + "ClientDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "clientId": { + "type": "string", + "description": "Ory client id", + "format": "uuid" + }, + "userId": { + "type": "string", + "description": "Owner id" + } + }, + "required": ["id", "clientId", "userId"] + }, + "OryOAuth2WebhookPayloadDto": { + "type": "object", + "properties": {} + }, + "OryOAuth2WebhookResponseDto": { + "type": "object", + "properties": {} + }, "NextPaginationDto": { "type": "object", "properties": { @@ -2049,7 +2204,7 @@ }, "version": { "type": "number", - "description": "Payment version represented by a number incremented at each updated" + "description": "Payment version represented by a number incremented at each update" } }, "required": ["id", "orderId", "stripeId", "version"] diff --git a/libs/microservices/shared/decorators/src/current-client.decorator.ts b/libs/microservices/shared/decorators/src/current-client.decorator.ts index a52c6af9..40a6b996 100644 --- a/libs/microservices/shared/decorators/src/current-client.decorator.ts +++ b/libs/microservices/shared/decorators/src/current-client.decorator.ts @@ -6,5 +6,5 @@ export const CurrentClient = createParamDecorator( (data: unknown, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); return request[CURRENT_CLIENT_KEY]; - } + }, ); diff --git a/libs/ng/open-api/src/lib/generated/auth/api.module.ts b/libs/ng/open-api/src/lib/generated/auth/api.module.ts index c39cd520..e074afbb 100644 --- a/libs/ng/open-api/src/lib/generated/auth/api.module.ts +++ b/libs/ng/open-api/src/lib/generated/auth/api.module.ts @@ -1,10 +1,16 @@ /* tslint:disable */ /* eslint-disable */ -import { NgModule, ModuleWithProviders, SkipSelf, Optional } from '@angular/core'; +import { + NgModule, + ModuleWithProviders, + SkipSelf, + Optional, +} from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { ApiConfiguration, ApiConfigurationParams } from './api-configuration'; import { UsersService } from './services/users.service'; +import { ClientsService } from './services/clients.service'; /** * Module that provides all services and configuration. @@ -13,34 +19,37 @@ import { UsersService } from './services/users.service'; imports: [], exports: [], declarations: [], - providers: [ - UsersService, - ApiConfiguration - ], + providers: [UsersService, ClientsService, ApiConfiguration], }) export class ApiModule { - static forRoot(params: ApiConfigurationParams): ModuleWithProviders { + static forRoot( + params: ApiConfigurationParams, + ): ModuleWithProviders { return { ngModule: ApiModule, providers: [ { provide: ApiConfiguration, - useValue: params - } - ] - } + useValue: params, + }, + ], + }; } - constructor( + constructor( @Optional() @SkipSelf() parentModule: ApiModule, - @Optional() http: HttpClient + @Optional() http: HttpClient, ) { if (parentModule) { - throw new Error('ApiModule is already loaded. Import in your base AppModule only.'); + throw new Error( + 'ApiModule is already loaded. Import in your base AppModule only.', + ); } if (!http) { - throw new Error('You need to import the HttpClientModule in your AppModule! \n' + - 'See also https://github.com/angular/angular/issues/20575'); + throw new Error( + 'You need to import the HttpClientModule in your AppModule! \n' + + 'See also https://github.com/angular/angular/issues/20575', + ); } } } diff --git a/libs/ng/open-api/src/lib/generated/auth/models.ts b/libs/ng/open-api/src/lib/generated/auth/models.ts index dacf8e76..edfd8f3b 100644 --- a/libs/ng/open-api/src/lib/generated/auth/models.ts +++ b/libs/ng/open-api/src/lib/generated/auth/models.ts @@ -4,3 +4,7 @@ export { OnOrySignUpDto } from './models/on-ory-sign-up-dto'; export { OnOrySignInDto } from './models/on-ory-sign-in-dto'; export { UserCredentialsDto } from './models/user-credentials-dto'; export { UserDto } from './models/user-dto'; +export { CreateClientDto } from './models/create-client-dto'; +export { ClientDto } from './models/client-dto'; +export { OryOAuth2WebhookPayloadDto } from './models/ory-o-auth-2-webhook-payload-dto'; +export { OryOAuth2WebhookResponseDto } from './models/ory-o-auth-2-webhook-response-dto'; diff --git a/libs/ng/open-api/src/lib/generated/auth/models/client-dto.ts b/libs/ng/open-api/src/lib/generated/auth/models/client-dto.ts new file mode 100644 index 00000000..690e5e1d --- /dev/null +++ b/libs/ng/open-api/src/lib/generated/auth/models/client-dto.ts @@ -0,0 +1,14 @@ +/* tslint:disable */ +/* eslint-disable */ +export interface ClientDto { + /** + * Ory client id + */ + clientId: string; + id: string; + + /** + * Owner id + */ + userId: string; +} diff --git a/libs/ng/open-api/src/lib/generated/auth/models/create-client-dto.ts b/libs/ng/open-api/src/lib/generated/auth/models/create-client-dto.ts new file mode 100644 index 00000000..12b432f1 --- /dev/null +++ b/libs/ng/open-api/src/lib/generated/auth/models/create-client-dto.ts @@ -0,0 +1,3 @@ +/* tslint:disable */ +/* eslint-disable */ +export interface CreateClientDto {} diff --git a/libs/ng/open-api/src/lib/generated/auth/models/ory-o-auth-2-webhook-payload-dto.ts b/libs/ng/open-api/src/lib/generated/auth/models/ory-o-auth-2-webhook-payload-dto.ts new file mode 100644 index 00000000..3831db88 --- /dev/null +++ b/libs/ng/open-api/src/lib/generated/auth/models/ory-o-auth-2-webhook-payload-dto.ts @@ -0,0 +1,3 @@ +/* tslint:disable */ +/* eslint-disable */ +export interface OryOAuth2WebhookPayloadDto {} diff --git a/libs/ng/open-api/src/lib/generated/auth/models/ory-o-auth-2-webhook-response-dto.ts b/libs/ng/open-api/src/lib/generated/auth/models/ory-o-auth-2-webhook-response-dto.ts new file mode 100644 index 00000000..cd594dc8 --- /dev/null +++ b/libs/ng/open-api/src/lib/generated/auth/models/ory-o-auth-2-webhook-response-dto.ts @@ -0,0 +1,3 @@ +/* tslint:disable */ +/* eslint-disable */ +export interface OryOAuth2WebhookResponseDto {} diff --git a/libs/ng/open-api/src/lib/generated/auth/services.ts b/libs/ng/open-api/src/lib/generated/auth/services.ts index 290b27e5..9e0440cd 100644 --- a/libs/ng/open-api/src/lib/generated/auth/services.ts +++ b/libs/ng/open-api/src/lib/generated/auth/services.ts @@ -1 +1,2 @@ export { UsersService } from './services/users.service'; +export { ClientsService } from './services/clients.service'; diff --git a/libs/ng/open-api/src/lib/generated/auth/services/clients.service.ts b/libs/ng/open-api/src/lib/generated/auth/services/clients.service.ts new file mode 100644 index 00000000..ef790ce9 --- /dev/null +++ b/libs/ng/open-api/src/lib/generated/auth/services/clients.service.ts @@ -0,0 +1,202 @@ +/* tslint:disable */ +/* eslint-disable */ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { BaseService } from '../base-service'; +import { ApiConfiguration } from '../api-configuration'; +import { StrictHttpResponse } from '../strict-http-response'; +import { RequestBuilder } from '../request-builder'; +import { Observable } from 'rxjs'; +import { map, filter } from 'rxjs/operators'; + +import { ClientDto } from '../models/client-dto'; +import { CreateClientDto } from '../models/create-client-dto'; +import { OryOAuth2WebhookPayloadDto } from '../models/ory-o-auth-2-webhook-payload-dto'; +import { OryOAuth2WebhookResponseDto } from '../models/ory-o-auth-2-webhook-response-dto'; + +@Injectable({ + providedIn: 'root', +}) +export class ClientsService extends BaseService { + constructor(config: ApiConfiguration, http: HttpClient) { + super(config, http); + } + + /** + * Path part for operation clientsControllerCreate + */ + static readonly ClientsControllerCreatePath = '/api/clients'; + + /** + * Register a new client - Scope : clients:create_one. + * + * Register a new client + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `clientsControllerCreate()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + clientsControllerCreate$Response(params: { + body: CreateClientDto; + }): Observable> { + const rb = new RequestBuilder( + this.rootUrl, + ClientsService.ClientsControllerCreatePath, + 'post', + ); + if (params) { + rb.body(params.body, 'application/json'); + } + + return this.http + .request( + rb.build({ + responseType: 'json', + accept: 'application/json', + }), + ) + .pipe( + filter((r: any) => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }), + ); + } + + /** + * Register a new client - Scope : clients:create_one. + * + * Register a new client + * + * This method provides access to only to the response body. + * To access the full response (for headers, for example), `clientsControllerCreate$Response()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + clientsControllerCreate(params: { + body: CreateClientDto; + }): Observable { + return this.clientsControllerCreate$Response(params).pipe( + map((r: StrictHttpResponse) => r.body as ClientDto), + ); + } + + /** + * Path part for operation clientsControllerOnSignUp + */ + static readonly ClientsControllerOnSignUpPath = + '/api/clients/on-token-request'; + + /** + * Triggered when a client request an OAuth2 token + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `clientsControllerOnSignUp()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + clientsControllerOnSignUp$Response(params: { + body: OryOAuth2WebhookPayloadDto; + }): Observable> { + const rb = new RequestBuilder( + this.rootUrl, + ClientsService.ClientsControllerOnSignUpPath, + 'post', + ); + if (params) { + rb.body(params.body, 'application/json'); + } + + return this.http + .request( + rb.build({ + responseType: 'json', + accept: 'application/json', + }), + ) + .pipe( + filter((r: any) => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }), + ); + } + + /** + * Triggered when a client request an OAuth2 token + * + * This method provides access to only to the response body. + * To access the full response (for headers, for example), `clientsControllerOnSignUp$Response()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + clientsControllerOnSignUp(params: { + body: OryOAuth2WebhookPayloadDto; + }): Observable { + return this.clientsControllerOnSignUp$Response(params).pipe( + map( + (r: StrictHttpResponse) => + r.body as OryOAuth2WebhookResponseDto, + ), + ); + } + + /** + * Path part for operation clientsControllerGetCurrentClient + */ + static readonly ClientsControllerGetCurrentClientPath = + '/api/clients/current-client'; + + /** + * Get current client - Scope : clients:read_one. + * + * Get details about currently authenticated client + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `clientsControllerGetCurrentClient()` instead. + * + * This method doesn't expect any request body. + */ + clientsControllerGetCurrentClient$Response(params?: {}): Observable< + StrictHttpResponse + > { + const rb = new RequestBuilder( + this.rootUrl, + ClientsService.ClientsControllerGetCurrentClientPath, + 'get', + ); + if (params) { + } + + return this.http + .request( + rb.build({ + responseType: 'json', + accept: 'application/json', + }), + ) + .pipe( + filter((r: any) => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }), + ); + } + + /** + * Get current client - Scope : clients:read_one. + * + * Get details about currently authenticated client + * + * This method provides access to only to the response body. + * To access the full response (for headers, for example), `clientsControllerGetCurrentClient$Response()` instead. + * + * This method doesn't expect any request body. + */ + clientsControllerGetCurrentClient(params?: {}): Observable { + return this.clientsControllerGetCurrentClient$Response(params).pipe( + map((r: StrictHttpResponse) => r.body as ClientDto), + ); + } +} diff --git a/libs/ng/open-api/src/lib/generated/payments/models/payment-dto.ts b/libs/ng/open-api/src/lib/generated/payments/models/payment-dto.ts index 8843b9ec..f8d63f54 100644 --- a/libs/ng/open-api/src/lib/generated/payments/models/payment-dto.ts +++ b/libs/ng/open-api/src/lib/generated/payments/models/payment-dto.ts @@ -1,7 +1,6 @@ /* tslint:disable */ /* eslint-disable */ export interface PaymentDto { - /** * Charge internal identifier */ @@ -18,7 +17,7 @@ export interface PaymentDto { stripeId: string; /** - * Payment version represented by a number incremented at each updated + * Payment version represented by a number incremented at each update */ version: number; } From e513a30aa3bbd0fb61488e21222784531900d0f6 Mon Sep 17 00:00:00 2001 From: getlarge Date: Tue, 9 Apr 2024 20:30:37 +0200 Subject: [PATCH 08/23] chore: replace internal Ory CLIs --- apps/permissions-manager/.eslintrc.json | 39 ----- apps/permissions-manager/jest.config.ts | 18 --- apps/permissions-manager/project.json | 104 ------------- .../permissions-manager/src/app/app.module.ts | 54 ------- .../src/app/check-permission.command.ts | 41 ----- .../src/app/create-relation.command.spec.ts | 64 -------- .../src/app/create-relation.command.ts | 45 ------ .../src/app/delete-relation.command.ts | 44 ------ apps/permissions-manager/src/app/env/index.ts | 15 -- .../src/app/expand-permissions.command.ts | 56 ------- .../src/app/get-relations.command.ts | 140 ------------------ apps/permissions-manager/src/assets/.gitkeep | 0 apps/permissions-manager/src/main.ts | 19 --- apps/permissions-manager/tsconfig.app.json | 21 --- apps/permissions-manager/tsconfig.json | 16 -- apps/permissions-manager/tsconfig.spec.json | 22 --- apps/permissions-manager/webpack.config.cjs | 43 ------ package.json | 1 + tools/ory/self-service/login.js | 81 ---------- tools/ory/self-service/registration.js | 79 ---------- tools/ory/self-service/utils.js | 48 ------ tools/ory/self-service/verification.js | 74 --------- yarn.lock | 33 +++++ 23 files changed, 34 insertions(+), 1023 deletions(-) delete mode 100644 apps/permissions-manager/.eslintrc.json delete mode 100644 apps/permissions-manager/jest.config.ts delete mode 100644 apps/permissions-manager/project.json delete mode 100644 apps/permissions-manager/src/app/app.module.ts delete mode 100644 apps/permissions-manager/src/app/check-permission.command.ts delete mode 100644 apps/permissions-manager/src/app/create-relation.command.spec.ts delete mode 100644 apps/permissions-manager/src/app/create-relation.command.ts delete mode 100644 apps/permissions-manager/src/app/delete-relation.command.ts delete mode 100644 apps/permissions-manager/src/app/env/index.ts delete mode 100644 apps/permissions-manager/src/app/expand-permissions.command.ts delete mode 100644 apps/permissions-manager/src/app/get-relations.command.ts delete mode 100644 apps/permissions-manager/src/assets/.gitkeep delete mode 100644 apps/permissions-manager/src/main.ts delete mode 100644 apps/permissions-manager/tsconfig.app.json delete mode 100644 apps/permissions-manager/tsconfig.json delete mode 100644 apps/permissions-manager/tsconfig.spec.json delete mode 100644 apps/permissions-manager/webpack.config.cjs delete mode 100644 tools/ory/self-service/login.js delete mode 100644 tools/ory/self-service/registration.js delete mode 100644 tools/ory/self-service/utils.js delete mode 100644 tools/ory/self-service/verification.js diff --git a/apps/permissions-manager/.eslintrc.json b/apps/permissions-manager/.eslintrc.json deleted file mode 100644 index d10c8914..00000000 --- a/apps/permissions-manager/.eslintrc.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "extends": ["../../.eslintrc.json"], - "ignorePatterns": ["!**/*"], - "overrides": [ - { - "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], - "rules": {} - }, - { - "files": ["*.ts", "*.tsx"], - "rules": {} - }, - { - "files": ["*.js", "*.jsx"], - "rules": {} - }, - { - "files": ["*.json"], - "parser": "jsonc-eslint-parser", - "rules": { - "@nx/dependency-checks": [ - "warn", - { - "buildTargets": ["build"], - "checkMissingDependencies": true, - "checkObsoleteDependencies": true, - "checkVersionMismatches": true, - "includeTransitiveDependencies": true, - "ignoredDependencies": [ - "@golevelup/ts-jest", - "@jest/globals", - "@nestjs/testing" - ] - } - ] - } - } - ] -} diff --git a/apps/permissions-manager/jest.config.ts b/apps/permissions-manager/jest.config.ts deleted file mode 100644 index f3325cbb..00000000 --- a/apps/permissions-manager/jest.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* eslint-disable */ -export default { - displayName: 'permissions-manager', - preset: '../../jest.preset.js', - testEnvironment: 'node', - transform: { - '^.+\\.[t]s$': [ - 'ts-jest', - { - tsconfig: '/tsconfig.spec.json', - useESM: true, - }, - ], - }, - moduleFileExtensions: ['ts', 'js', 'mjs', 'html'], - extensionsToTreatAsEsm: ['.ts'], - coverageDirectory: '../../coverage/apps/permissions-manager', -}; diff --git a/apps/permissions-manager/project.json b/apps/permissions-manager/project.json deleted file mode 100644 index 738967d2..00000000 --- a/apps/permissions-manager/project.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "name": "permissions-manager", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "apps/permissions-manager/src", - "projectType": "application", - "targets": { - "build": { - "executor": "@nx/webpack:webpack", - "outputs": ["{options.outputPath}"], - "defaultConfiguration": "production", - "options": { - "target": "node", - "compiler": "tsc", - "outputPath": "dist/apps/permissions-manager", - "main": "apps/permissions-manager/src/main.ts", - "tsConfig": "apps/permissions-manager/tsconfig.app.json", - "assets": ["apps/permissions-manager/src/assets"], - "isolatedConfig": true, - "webpackConfig": "apps/permissions-manager/webpack.config.cjs", - "generatePackageJson": true - }, - "configurations": { - "development": {}, - "production": {} - } - }, - "serve": { - "executor": "@nx/js:node", - "defaultConfiguration": "development", - "options": { - "buildTarget": "permissions-manager:build" - }, - "configurations": { - "development": { - "buildTarget": "permissions-manager:build:development" - }, - "production": { - "buildTarget": "permissions-manager:build:production" - } - } - }, - "run": { - "executor": "nx:run-commands", - "options": { - "command": "nx run permissions-manager:build && node dist/apps/permissions-manager/main.mjs", - "env": { - "NODE_ENV": "development" - } - } - }, - "lint": { - "executor": "@nx/eslint:lint", - "outputs": ["{options.outputFile}"], - "options": { - "lintFilePatterns": [ - "apps/permissions-manager/**/*.ts", - "apps/permissions-manager/**/package.json", - "apps/permissions-manager/**/project.json" - ] - } - }, - "test": { - "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], - "options": { - "jestConfig": "apps/permissions-manager/jest.config.ts", - "passWithNoTests": true - } - }, - "dotenv-push": { - "executor": "nx:run-commands", - "options": { - "commands": ["cd apps/payments && dotenv-vault push"] - }, - "parallel": false, - "cwd": "." - }, - "dotenv-pull": { - "executor": "nx:run-commands", - "options": { - "commands": ["node tools/utils/dotenv-pull.js -p payments -v"] - }, - "parallel": false, - "cwd": "." - }, - "dotenv-build": { - "executor": "nx:run-commands", - "options": { - "commands": ["cd apps/payments && dotenv-vault build"] - }, - "cwd": ".", - "parallel": false - }, - "dotenv-keys": { - "executor": "nx:run-commands", - "options": { - "commands": ["cd apps/payments && dotenv-vault keys"] - }, - "cwd": ".", - "parallel": false - } - }, - "tags": ["scope:permissions", "type:app", "platform:cli"] -} diff --git a/apps/permissions-manager/src/app/app.module.ts b/apps/permissions-manager/src/app/app.module.ts deleted file mode 100644 index ecd5a28e..00000000 --- a/apps/permissions-manager/src/app/app.module.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - OryPermissionsModule, - OryRelationshipsModule, -} from '@getlarge/keto-client-wrapper'; -import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { validate } from '@ticketing/microservices/shared/env'; - -import { CheckPermissionCommand } from './check-permission.command'; -import { CreateRelationCommand } from './create-relation.command'; -import { DeleteRelationCommand } from './delete-relation.command'; -import { EnvironmentVariables } from './env'; -import { ExpandPermissionsCommand } from './expand-permissions.command'; -import { GetRelationsCommand } from './get-relations.command'; - -@Module({ - imports: [ - OryPermissionsModule.forRootAsync({ - imports: [ - ConfigModule.forRoot({ - validate: validate(EnvironmentVariables), - }), - ], - inject: [ConfigService], - useFactory: ( - configService: ConfigService, - ) => ({ - basePath: configService.get('ORY_KETO_PUBLIC_URL'), - }), - }), - OryRelationshipsModule.forRootAsync({ - imports: [ - ConfigModule.forRoot({ - validate: validate(EnvironmentVariables), - }), - ], - inject: [ConfigService], - useFactory: ( - configService: ConfigService, - ) => ({ - accessToken: configService.get('ORY_KETO_API_KEY'), - basePath: configService.get('ORY_KETO_ADMIN_URL'), - }), - }), - ], - providers: [ - CreateRelationCommand, - DeleteRelationCommand, - CheckPermissionCommand, - ExpandPermissionsCommand, - GetRelationsCommand, - ], -}) -export class AppModule {} diff --git a/apps/permissions-manager/src/app/check-permission.command.ts b/apps/permissions-manager/src/app/check-permission.command.ts deleted file mode 100644 index 3aa94a85..00000000 --- a/apps/permissions-manager/src/app/check-permission.command.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { OryPermissionsService } from '@getlarge/keto-client-wrapper'; -import { - createPermissionCheckQuery, - parseRelationTuple, -} from '@getlarge/keto-relations-parser'; -import { Logger } from '@nestjs/common'; -import { PermissionApiCheckPermissionRequest } from '@ory/client'; -import { Command, CommandRunner, Option } from 'nest-commander'; - -interface CommandOptions { - tuple: PermissionApiCheckPermissionRequest; -} - -@Command({ name: 'check', description: 'Check permission on Ory Keto' }) -export class CheckPermissionCommand extends CommandRunner { - readonly logger = new Logger(CheckPermissionCommand.name); - - constructor(private readonly oryPermissionsService: OryPermissionsService) { - super(); - } - - async run(passedParams: string[], options: CommandOptions): Promise { - const { tuple } = options; - const isAllowed = await this.oryPermissionsService.checkPermission(tuple); - this.logger.log(`Permission ${isAllowed ? 'granted' : 'denied'}`); - } - - @Option({ - flags: '-t, --tuple [string]', - description: - 'Relationship tuple to check permission from, using Zanzibar notation', - required: true, - }) - parseRelationTuple(val: string): PermissionApiCheckPermissionRequest { - const res = parseRelationTuple(val); - if (res.hasError()) { - throw res.error; - } - return createPermissionCheckQuery(res.value).unwrapOrThrow(); - } -} diff --git a/apps/permissions-manager/src/app/create-relation.command.spec.ts b/apps/permissions-manager/src/app/create-relation.command.spec.ts deleted file mode 100644 index 30a2fd0a..00000000 --- a/apps/permissions-manager/src/app/create-relation.command.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - OryRelationshipsModule, - OryRelationshipsService, -} from '@getlarge/keto-client-wrapper'; -import { - createRelationQuery, - RelationTuple, -} from '@getlarge/keto-relations-parser'; -import { jest } from '@jest/globals'; -import { MockOryRelationshipsService } from '@ticketing/microservices/shared/testing'; -import { CommandTestFactory } from 'nest-commander-testing'; - -import { CreateRelationCommand } from './create-relation.command'; - -describe('CreateRelationCommand', () => { - let service: CreateRelationCommand; - const mockOryRelationshipsService = new MockOryRelationshipsService(); - - beforeAll(async () => { - const app = await CommandTestFactory.createTestingCommand({ - imports: [ - OryRelationshipsModule.forRootAsync({ - useFactory: () => ({ - basePath: 'http://localhost:4467', - accessToken: '', - }), - }), - ], - providers: [CreateRelationCommand], - }) - .overrideProvider(OryRelationshipsService) - .useValue(mockOryRelationshipsService) - .compile(); - - service = app.get(CreateRelationCommand); - }); - - describe('run', () => { - it('should process tuple and create relationship', async () => { - const tuple: RelationTuple = { - namespace: 'Group', - object: 'admin', - relation: 'members', - subjectIdOrSet: { - namespace: 'User', - object: '1', - }, - }; - const expectedQuery = createRelationQuery(tuple).unwrapOrThrow(); - mockOryRelationshipsService.createRelationship = jest - .fn(() => Promise.resolve(true)) - .mockResolvedValue(true); - - await expect( - service.run(['--tuple', 'Group:admin#members@User:1'], { - tuple: expectedQuery, - }), - ).resolves.toBeUndefined(); - expect(mockOryRelationshipsService.createRelationship).toBeCalledWith({ - createRelationshipBody: expectedQuery, - }); - }); - }); -}); diff --git a/apps/permissions-manager/src/app/create-relation.command.ts b/apps/permissions-manager/src/app/create-relation.command.ts deleted file mode 100644 index abc80646..00000000 --- a/apps/permissions-manager/src/app/create-relation.command.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { OryRelationshipsService } from '@getlarge/keto-client-wrapper'; -import { - createRelationQuery, - parseRelationTuple, -} from '@getlarge/keto-relations-parser'; -import { Logger } from '@nestjs/common'; -import { RelationQuery } from '@ory/client'; -import { Command, CommandRunner, Option } from 'nest-commander'; - -interface CommandOptions { - tuple: RelationQuery; -} - -@Command({ name: 'create', description: 'Create relationship on Ory Keto' }) -export class CreateRelationCommand extends CommandRunner { - readonly logger = new Logger(CreateRelationCommand.name); - - constructor( - private readonly oryRelationshipsService: OryRelationshipsService, - ) { - super(); - } - - async run(passedParams: string[], options: CommandOptions): Promise { - const { tuple } = options; - await this.oryRelationshipsService.createRelationship({ - createRelationshipBody: tuple, - }); - this.logger.debug('Created relation'); - this.logger.log(tuple); - } - - @Option({ - flags: '-t, --tuple [string]', - description: 'Relationship tuple to create, using Zanzibar notation', - required: true, - }) - parseRelationTuple(val: string): RelationQuery { - const res = parseRelationTuple(val); - if (res.hasError()) { - throw res.error; - } - return createRelationQuery(res.value).unwrapOrThrow(); - } -} diff --git a/apps/permissions-manager/src/app/delete-relation.command.ts b/apps/permissions-manager/src/app/delete-relation.command.ts deleted file mode 100644 index 13331cba..00000000 --- a/apps/permissions-manager/src/app/delete-relation.command.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { OryRelationshipsService } from '@getlarge/keto-client-wrapper'; -import { - createRelationQuery, - parseRelationTuple, -} from '@getlarge/keto-relations-parser'; -import { Logger } from '@nestjs/common'; -import { RelationQuery } from '@ory/client'; -import { Command, CommandRunner, Option } from 'nest-commander'; - -interface CommandOptions { - tuple: RelationQuery; -} - -@Command({ name: 'delete', description: 'Delete relationship on Ory Keto' }) -export class DeleteRelationCommand extends CommandRunner { - readonly logger = new Logger(DeleteRelationCommand.name); - - constructor( - private readonly oryRelationshipsService: OryRelationshipsService, - ) { - super(); - } - - async run(passedParams: string[], options: CommandOptions): Promise { - const { tuple } = options; - await this.oryRelationshipsService.deleteRelationships(tuple); - this.logger.debug('Deleted relation'); - this.logger.log(tuple); - } - - @Option({ - flags: '-t, --tuple [string]', - description: 'Relationship tuple to delete, using Zanzibar notation', - required: true, - }) - parseRelationTuple(val: string): RelationQuery { - const res = parseRelationTuple(val); - if (res.hasError()) { - throw res.error; - } - const relationQuery = createRelationQuery(res.value); - return relationQuery.unwrapOrThrow(); - } -} diff --git a/apps/permissions-manager/src/app/env/index.ts b/apps/permissions-manager/src/app/env/index.ts deleted file mode 100644 index 1156ca1b..00000000 --- a/apps/permissions-manager/src/app/env/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ConfigService } from '@nestjs/config'; -import { OryKetoEnvironmentVariables } from '@ticketing/microservices/shared/env'; -import { Exclude } from 'class-transformer'; -import { Mixin } from 'ts-mixer'; - -export type AppConfigService = ConfigService; - -export class EnvironmentVariables extends Mixin(OryKetoEnvironmentVariables) { - @Exclude() - // private pkg: { [key: string]: unknown; name?: string; version?: string } = - // JSON.parse(readFileSync(pkgPath, 'utf8')); - APP_NAME?: string = 'payments'; - - APP_VERSION?: string = '0.0.1'; -} diff --git a/apps/permissions-manager/src/app/expand-permissions.command.ts b/apps/permissions-manager/src/app/expand-permissions.command.ts deleted file mode 100644 index 93464d0a..00000000 --- a/apps/permissions-manager/src/app/expand-permissions.command.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { OryPermissionsService } from '@getlarge/keto-client-wrapper'; -import { - createExpandPermissionQuery, - parseRelationTuple, -} from '@getlarge/keto-relations-parser'; -import { Logger } from '@nestjs/common'; -import { PermissionApiExpandPermissionsRequest } from '@ory/client'; -import { Command, CommandRunner, Option } from 'nest-commander'; - -interface CommandOptions { - tuple: PermissionApiExpandPermissionsRequest; - depth: number; -} - -@Command({ name: 'expand', description: 'Expand permissions on Ory Keto' }) -export class ExpandPermissionsCommand extends CommandRunner { - readonly logger = new Logger(ExpandPermissionsCommand.name); - - constructor(private readonly oryPermissionsService: OryPermissionsService) { - super(); - } - - async run(passedParams: string[], options: CommandOptions): Promise { - const { depth, tuple } = options; - const tree = await this.oryPermissionsService.expandPermissions({ - ...tuple, - maxDepth: depth, - }); - this.logger.log(tree); - } - - @Option({ - flags: '-t, --tuple [string]', - description: - 'Relationship tuple to expand from, using Zanzibar notation (without subject)', - required: true, - }) - parseRelationTuple(val: string): PermissionApiExpandPermissionsRequest { - const res = parseRelationTuple(val); - if (res.hasError()) { - throw res.error; - } - const tuple = res.unwrapOrThrow(); - delete tuple.subjectIdOrSet; - return createExpandPermissionQuery(tuple).unwrapOrThrow(); - } - - @Option({ - flags: '-d, --depth [string]', - description: 'Max depth of the tree', - required: false, - }) - parseDepth(val: string): number { - return val ? parseInt(val, 10) : 3; - } -} diff --git a/apps/permissions-manager/src/app/get-relations.command.ts b/apps/permissions-manager/src/app/get-relations.command.ts deleted file mode 100644 index 7788137a..00000000 --- a/apps/permissions-manager/src/app/get-relations.command.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { OryRelationshipsService } from '@getlarge/keto-client-wrapper'; -import { - createFlattenRelationQuery, - RelationTuple, -} from '@getlarge/keto-relations-parser'; -import { Logger } from '@nestjs/common'; -import type { Relationship } from '@ory/client'; -import { Command, CommandRunner, Option } from 'nest-commander'; - -interface CommandOptions { - namespace: string; - object?: string; - relation?: string; - subjectNamespace?: string; - subjectObject?: string; - subjectRelation?: string; -} - -@Command({ name: 'get', description: 'Get relationships on Ory Keto' }) -export class GetRelationsCommand extends CommandRunner { - readonly logger = new Logger(GetRelationsCommand.name); - - constructor( - private readonly oryRelationshipsService: OryRelationshipsService, - ) { - super(); - } - - async run(passedParams: string[], options: CommandOptions): Promise { - const { - namespace, - object, - relation, - subjectNamespace, - subjectObject, - subjectRelation, - } = options; - const tuple: Partial = { - namespace, - object, - relation, - ...(!!subjectNamespace || !!subjectObject || !!subjectRelation - ? { - subjectIdOrSet: { - namespace: subjectNamespace, - object: subjectObject, - relation: subjectRelation, - }, - } - : {}), - }; - const result: Relationship[] = []; - for await (const { relationships } of this.fetchPaginatedRelationships( - tuple, - )) { - result.push(...relationships); - } - this.logger.debug('Found relationships'); - this.logger.log(result); - } - - private async *fetchPaginatedRelationships( - tuple: Partial, - options: { pageToken?: string; pageSize?: number } = { pageSize: 50 }, - ): AsyncIterable<{ relationships: Relationship[]; pageToken: string }> { - const relationQuery = createFlattenRelationQuery(tuple).unwrapOrThrow(); - const { data } = await this.oryRelationshipsService.getRelationships({ - ...relationQuery, - ...options, - }); - const pageToken = data.next_page_token; - yield { relationships: data.relation_tuples, pageToken }; - - if (pageToken) { - return this.fetchPaginatedRelationships( - { - ...tuple, - }, - { pageToken, pageSize: options.pageSize }, - ); - } - } - - @Option({ - flags: '-n, --namespace [string]', - description: 'namespace of the relationship tuple to get relations from', - required: true, - }) - parseNamespace(val: string): string { - return val; - } - - @Option({ - flags: '-o, --object [string]', - description: 'object of the relationship tuple to get relations from', - required: false, - }) - parseObject(val: string): string { - return val; - } - - @Option({ - flags: '-r, --relation [string]', - description: 'relation of the relationship tuple to get relations from', - required: false, - }) - parseRelation(val: string): string { - return val; - } - - @Option({ - flags: '-sn, --subject-namespace [string]', - description: - 'namespace of the subject of the relationship tuple to get relations from', - required: false, - }) - parseSubjectNamespace(val: string): string { - return val; - } - - @Option({ - flags: '-so, --subject-object [string]', - description: - 'object of the subject of the relationship tuple to get relations from', - required: false, - }) - parseSubjectObject(val: string): string { - return val; - } - - @Option({ - flags: '-sr, --subject-relation [string]', - description: - 'relation of the subject of the relationship tuple to get relations from', - required: false, - }) - parseSubjectRelation(val: string): string { - return val; - } -} diff --git a/apps/permissions-manager/src/assets/.gitkeep b/apps/permissions-manager/src/assets/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/permissions-manager/src/main.ts b/apps/permissions-manager/src/main.ts deleted file mode 100644 index c7d34110..00000000 --- a/apps/permissions-manager/src/main.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CommandFactory } from 'nest-commander'; - -import { AppModule } from './app/app.module'; - -async function bootstrap(): Promise { - await CommandFactory.run(AppModule, { - logger: ['log', 'error', 'warn', 'debug', 'verbose'], - enablePositionalOptions: true, - enablePassThroughOptions: true, - cliName: 'permissions-manager', - version: '0.0.1', - usePlugins: true, - }); -} - -bootstrap().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/apps/permissions-manager/tsconfig.app.json b/apps/permissions-manager/tsconfig.app.json deleted file mode 100644 index 768f4bc0..00000000 --- a/apps/permissions-manager/tsconfig.app.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../dist/out-tsc", - "emitDecoratorMetadata": true, - "moduleResolution": "Bundler", - "module": "esnext", - "target": "es2022", - "types": [ - "node" - ] - }, - "exclude": [ - "**/*.spec.ts", - "**/*.e2e-spec.ts", - "jest.config.ts" - ], - "include": [ - "**/*.ts" - ] -} diff --git a/apps/permissions-manager/tsconfig.json b/apps/permissions-manager/tsconfig.json deleted file mode 100644 index c1e2dd4e..00000000 --- a/apps/permissions-manager/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "files": [], - "include": [], - "references": [ - { - "path": "./tsconfig.app.json" - }, - { - "path": "./tsconfig.spec.json" - } - ], - "compilerOptions": { - "esModuleInterop": true - } -} diff --git a/apps/permissions-manager/tsconfig.spec.json b/apps/permissions-manager/tsconfig.spec.json deleted file mode 100644 index 26966339..00000000 --- a/apps/permissions-manager/tsconfig.spec.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../dist/out-tsc", - "moduleResolution": "Bundler", - "module": "esnext", - "target": "es2022", - "emitDecoratorMetadata": true, - "types": [ - "jest", - "node" - ], - "allowJs": true - }, - "include": [ - "**/*.ts", - "**/*.e2e-spec.ts", - "**/*.spec.ts", - "**/*.d.ts", - "jest.config.ts" - ] -} diff --git a/apps/permissions-manager/webpack.config.cjs b/apps/permissions-manager/webpack.config.cjs deleted file mode 100644 index 1e080d5e..00000000 --- a/apps/permissions-manager/webpack.config.cjs +++ /dev/null @@ -1,43 +0,0 @@ -const { composePlugins, withNx } = require('@nx/webpack'); -const nodeExternals = require('webpack-node-externals'); - -// workaround to load ESM modules in node -// @see https://github.com/nrwl/nx/pull/10414 -// @see https://github.com/nrwl/nx/issues/7872#issuecomment-997460397 - -// Nx plugins for webpack. -module.exports = composePlugins(withNx(), (config) => { - config.resolve.extensionAlias = { - ...config.resolve.extensionAlias, - '.js': ['.ts', '.js'], - '.mjs': ['.mts', '.mjs'], - }; - return { - ...config, - externalsPresets: { - node: true, - }, - output: { - ...config.output, - module: true, - libraryTarget: 'module', - chunkFormat: 'module', - filename: '[name].mjs', - chunkFilename: '[name].mjs', - library: { - type: 'module', - }, - environment: { - module: true, - }, - }, - experiments: { - ...config.experiments, - outputModule: true, - topLevelAwait: true, - }, - externals: nodeExternals({ - importType: 'module', - }), - }; -}); diff --git a/package.json b/package.json index b9451b4d..3fcc5ef7 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "@getlarge/keto-cli": "0.2.1", "@getlarge/keto-client-wrapper": "0.2.5", "@getlarge/keto-relations-parser": "0.0.9", + "@getlarge/kratos-cli": "0.2.1", "@getlarge/kratos-client-wrapper": "0.1.7", "@nestjs/axios": "3.0.0", "@nestjs/bull": "10.0.1", diff --git a/tools/ory/self-service/login.js b/tools/ory/self-service/login.js deleted file mode 100644 index b09ff7a0..00000000 --- a/tools/ory/self-service/login.js +++ /dev/null @@ -1,81 +0,0 @@ -const { isEmail } = require('class-validator'); -const { inspect } = require('node:util'); -const { askPassword, axiosInstance, checkSession } = require('./utils'); - -async function initLoginFlow() { - const url = `/self-service/login/api`; - const response = await axiosInstance.get(url, { responseType: 'json' }); - return { - flowId: response.data.id, - flowUrl: response.data.ui.action, - setCookie: response.headers['set-cookie'], - }; -} - -async function completeLoginFlow( - { flowId, flowUrl }, - { identifier, password }, -) { - const response = await axiosInstance.post( - // `/self-service/login/flow=${flowId}`, - flowUrl, - { - identifier, - password, - method: 'password', - }, - { - headers: { - 'content-type': 'application/json', - accept: 'application/json', - }, - }, - ); - return response.data; -} - -async function login({ identifier, password }) { - console.log('init login flow'); - const { flowId, flowUrl } = await initLoginFlow(); - console.log('complete login flow'); - const { session_token: sessionToken } = await completeLoginFlow( - { flowId, flowUrl }, - { - identifier, - password, - }, - ); - console.log('checking session token : ', sessionToken); - const session = await checkSession(sessionToken); - console.log('User loggedin'); - console.log( - inspect(session, { - depth: 10, - colors: true, - }), - ); -} - -async function main() { - const identifier = process.env.ORY_USER ?? process.argv[2]; - if (!isEmail(identifier)) { - throw new TypeError('Identifier must be an email address'); - } - - const password = await askPassword(); - await login({ - identifier, - password, - }); -} - -main().catch((e) => - console.error( - e?.response?.data - ? inspect(e.response.data, { - depth: 10, - colors: true, - }) - : e.message, - ), -); diff --git a/tools/ory/self-service/registration.js b/tools/ory/self-service/registration.js deleted file mode 100644 index 7df61107..00000000 --- a/tools/ory/self-service/registration.js +++ /dev/null @@ -1,79 +0,0 @@ -const { isEmail } = require('class-validator'); -const { inspect } = require('node:util'); -const { askPassword, axiosInstance, checkSession } = require('./utils'); - -async function initRegistrationFlow() { - const url = `/self-service/registration/api`; - const response = await axiosInstance.get(url, { responseType: 'json' }); - return { - flowId: response.data.id, - flowUrl: response.data.ui.action, - setCookie: response.headers['set-cookie'], - }; -} - -async function completeRegistrationFlow( - { flowId, flowUrl }, - { email, password }, -) { - const response = await axiosInstance.post( - // `/self-service/registration/flow=${flowId}`, - flowUrl, - { - traits: { email }, - password, - method: 'password', - }, - { - headers: { - 'content-type': 'application/json', - accept: 'application/json', - }, - }, - ); - return response.data; -} - -async function register({ email, password }) { - console.log('init registration flow'); - const { flowId, flowUrl } = await initRegistrationFlow(); - console.log('complete registration flow'); - const body = await completeRegistrationFlow( - { flowId, flowUrl }, - { - email, - password, - }, - ); - console.log('Registration completed'); - console.log( - inspect(body, { - depth: 10, - colors: true, - }), - ); -} - -async function main() { - const email = process.env.ORY_USER ?? process.argv[2]; - if (!isEmail(email)) { - throw new TypeError('Identifier must be an email address'); - } - - const password = await askPassword(); - await register({ - email, - password, - }); -} - -main().catch((e) => - console.error( - e?.response?.data - ? inspect(e.response.data, { - depth: 10, - colors: true, - }) - : e.message, - ), -); diff --git a/tools/ory/self-service/utils.js b/tools/ory/self-service/utils.js deleted file mode 100644 index 616aae33..00000000 --- a/tools/ory/self-service/utils.js +++ /dev/null @@ -1,48 +0,0 @@ -const axios = require('axios'); -const readline = require('node:readline'); - -const axiosInstance = axios.create({ - baseURL: process.env.ORY_BASE_PATH || 'http://localhost:4433', -}); - -async function askPassword() { - const password = await new Promise((resolve) => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - rl.stdoutMuted = true; - rl.question('Password: ', (answer) => { - resolve(answer); - rl.close(); - }); - rl._writeToOutput = function (stringToWrite) { - if (rl.stdoutMuted) { - rl.output.write('*'); - } else { - rl.output.write(stringToWrite); - } - }; - }); - if (!password) { - throw new TypeError('Password must be a non-empty string'); - } - return password; -} - -function checkSession(sessionToken) { - return axiosInstance - .get(`/sessions/whoami`, { - headers: { - accept: 'application/json', - authorization: `Bearer ${sessionToken}`, - }, - }) - .then(({ data }) => data); -} - -module.exports = { - askPassword, - axiosInstance, - checkSession, -}; diff --git a/tools/ory/self-service/verification.js b/tools/ory/self-service/verification.js deleted file mode 100644 index d87edf56..00000000 --- a/tools/ory/self-service/verification.js +++ /dev/null @@ -1,74 +0,0 @@ -const { isEmail } = require('class-validator'); -const { inspect } = require('node:util'); -const { axiosInstance } = require('./utils'); - -async function initVerificationFlow() { - const url = `/self-service/verification/api`; - const response = await axiosInstance.get(url, { responseType: 'json' }); - console.log( - inspect(response.data, { - depth: 10, - colors: true, - }), - ); - return { - flowId: response.data.id, - flowUrl: response.data.ui.action, - }; -} - -// TODO: if `code` provided allow completion via /self-service/verification?code=&flow=> -async function completeVerificationFlow({ flowId, flowUrl }, { email }) { - const response = await axiosInstance.post( - // `/self-service/verification/flow=${flowId}`, - flowUrl, - { - email, - // method: 'link', - method: 'code', - }, - { - headers: { - 'content-type': 'application/json', - accept: 'application/json', - }, - }, - ); - console.log( - inspect(response.data, { - depth: 10, - colors: true, - }), - ); - return response.data; -} - -async function verify({ email }) { - console.log('init verification flow'); - const { flowId, flowUrl } = await initVerificationFlow(); - console.log('complete verification flow'); - await completeVerificationFlow({ flowId, flowUrl }, { email }); - console.log('Verification email sent'); -} - -async function main() { - const email = process.env.ORY_USER ?? process.argv[2]; - if (!isEmail(email)) { - throw new TypeError('Identifier must be an email address'); - } - - await verify({ - email, - }); -} - -main().catch((e) => - console.error( - e?.response?.data - ? inspect(e.response.data, { - depth: 10, - colors: true, - }) - : e.message, - ), -); diff --git a/yarn.lock b/yarn.lock index c3af731a..ddfbb524 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3983,6 +3983,38 @@ __metadata: languageName: node linkType: hard +"@getlarge/kratos-cli@npm:0.2.1": + version: 0.2.1 + resolution: "@getlarge/kratos-cli@npm:0.2.1" + dependencies: + "@getlarge/kratos-client-wrapper": 0.1.6 + "@nestjs/common": ^10.0.2 + "@nestjs/config": ^3.1.1 + "@ory/client": ^1.4.9 + class-transformer: ^0.5.1 + class-validator: ^0.14.1 + nest-commander: ^3.12.5 + tslib: ^2.3.0 + bin: + kratos-cli: src/index.js + checksum: cb900d1db4ea28adc2a403ab606b230a2b46ee475923bcf1038e95325e5c7969d6f5e91947509518e39b611c62d336f53de8052ed95391311bb482fa882cf3d7 + languageName: node + linkType: hard + +"@getlarge/kratos-client-wrapper@npm:0.1.6": + version: 0.1.6 + resolution: "@getlarge/kratos-client-wrapper@npm:0.1.6" + dependencies: + tslib: ^2.3.0 + peerDependencies: + "@nestjs/axios": ^3.0.1 + "@nestjs/common": ^10.0.2 + "@ory/client": ^1.4.9 + axios: 1.6.5 + checksum: 27da844b0550660c4c15d54122bbf980620b0bc3c8da0546b9c232da630a5e840f54beb7178b2dabf286d38a0dbc9ff5fd155a48f763020e0ae86c9e479ab296 + languageName: node + linkType: hard + "@getlarge/kratos-client-wrapper@npm:0.1.7": version: 0.1.7 resolution: "@getlarge/kratos-client-wrapper@npm:0.1.7" @@ -23019,6 +23051,7 @@ __metadata: "@getlarge/keto-cli": 0.2.1 "@getlarge/keto-client-wrapper": 0.2.5 "@getlarge/keto-relations-parser": 0.0.9 + "@getlarge/kratos-cli": 0.2.1 "@getlarge/kratos-client-wrapper": 0.1.7 "@golevelup/ts-jest": ^0.4.0 "@jscutlery/semver": ^3.1.0 From 0fb77ddebc1f3910fee657198480a105a96e3f18 Mon Sep 17 00:00:00 2001 From: getlarge Date: Tue, 9 Apr 2024 21:02:41 +0200 Subject: [PATCH 09/23] chore: add nginx endpoints --- infra/nginx/nginx.template | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/infra/nginx/nginx.template b/infra/nginx/nginx.template index cb473f92..7a6f4dc2 100644 --- a/infra/nginx/nginx.template +++ b/infra/nginx/nginx.template @@ -97,6 +97,13 @@ server { include /etc/nginx/snippets/proxy.conf; } + location /api/clients { + proxy_pass http://auth_service; + proxy_set_header X-Version $req_version; + proxy_set_header Host $AUTH_SERVICE; + include /etc/nginx/snippets/proxy.conf; + } + location /api/orders { proxy_pass http://orders_service; proxy_set_header X-Version $req_version; From 0d1ec62c43af14a8cfedf300941b430d43444ff5 Mon Sep 17 00:00:00 2001 From: getlarge Date: Tue, 9 Apr 2024 21:03:23 +0200 Subject: [PATCH 10/23] fix(auth): refine OAuth2 client creation --- apps/auth/openapi.json | 32 +++++++++++++++++-- .../src/app/clients/clients.controller.ts | 5 +-- apps/auth/src/app/clients/clients.module.ts | 2 +- apps/auth/src/app/clients/clients.service.ts | 11 +++++-- .../app/clients/models/created-client.dto.ts | 12 +++++++ apps/auth/src/app/clients/models/index.ts | 1 + .../src/app/clients/schemas/client.schema.ts | 7 ++-- 7 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 apps/auth/src/app/clients/models/created-client.dto.ts diff --git a/apps/auth/openapi.json b/apps/auth/openapi.json index 68f395cb..d1ddb116 100644 --- a/apps/auth/openapi.json +++ b/apps/auth/openapi.json @@ -153,7 +153,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ClientDto" + "$ref": "#/components/schemas/CreatedClientDto" } } } @@ -411,7 +411,7 @@ "type": "object", "properties": {} }, - "ClientDto": { + "CreatedClientDto": { "type": "object", "properties": { "id": { @@ -425,12 +425,16 @@ "userId": { "type": "string", "description": "Owner id" + }, + "clientSecret": { + "type": "string" } }, "required": [ "id", "clientId", - "userId" + "userId", + "clientSecret" ] }, "OryOAuth2WebhookPayloadDto": { @@ -440,6 +444,28 @@ "OryOAuth2WebhookResponseDto": { "type": "object", "properties": {} + }, + "ClientDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "clientId": { + "type": "string", + "description": "Ory client id", + "format": "uuid" + }, + "userId": { + "type": "string", + "description": "Owner id" + } + }, + "required": [ + "id", + "clientId", + "userId" + ] } } }, diff --git a/apps/auth/src/app/clients/clients.controller.ts b/apps/auth/src/app/clients/clients.controller.ts index f02b3ef2..9522b017 100644 --- a/apps/auth/src/app/clients/clients.controller.ts +++ b/apps/auth/src/app/clients/clients.controller.ts @@ -35,6 +35,7 @@ import { Client, ClientDto, CreateClientDto, + CreatedClientDto, OryOAuth2WebhookPayloadDto, OryOAuth2WebhookResponseDto, } from './models'; @@ -60,14 +61,14 @@ export class ClientsController { @ApiResponse({ status: HttpStatus.OK, description: 'Client created', - type: ClientDto, + type: CreatedClientDto, }) @Post('') @HttpCode(HttpStatus.CREATED) create( @CurrentUser() user: User, @Body() body: CreateClientDto, - ): Promise { + ): Promise { return this.clientsService.create(body, user); } diff --git a/apps/auth/src/app/clients/clients.module.ts b/apps/auth/src/app/clients/clients.module.ts index a41d3c34..8cd63238 100644 --- a/apps/auth/src/app/clients/clients.module.ts +++ b/apps/auth/src/app/clients/clients.module.ts @@ -30,7 +30,7 @@ import { Client, ClientSchema } from './schemas/client.schema'; useFactory: ( configService: ConfigService, ) => ({ - basePath: configService.get('ORY_HYDRA_PUBLIC_URL'), + basePath: configService.get('ORY_HYDRA_ADMIN_URL'), accessToken: configService.get('ORY_HYDRA_API_KEY'), }), }), diff --git a/apps/auth/src/app/clients/clients.service.ts b/apps/auth/src/app/clients/clients.service.ts index 757133fb..31a96de8 100644 --- a/apps/auth/src/app/clients/clients.service.ts +++ b/apps/auth/src/app/clients/clients.service.ts @@ -14,6 +14,7 @@ import { User } from '../users/models'; import { Client, CreateClientDto, + CreatedClientDto, OryOAuth2WebhookPayloadDto, OryOAuth2WebhookResponseDto, } from './models'; @@ -30,13 +31,14 @@ export class ClientsService { private readonly clientModel: Model, ) {} - async create(body: CreateClientDto, user: User): Promise { + async create(body: CreateClientDto, user: User): Promise { const { scope = 'offline' } = body; const { data: oryClient } = await this.oryOAuth2Service.createOAuth2Client({ oAuth2Client: { - owner: user.id, + owner: user.identityId, access_token_strategy: 'opaque', grant_types: ['client_credentials'], + token_endpoint_auth_method: 'client_secret_post', scope, }, }); @@ -44,7 +46,10 @@ export class ClientsService { clientId: oryClient.client_id, user: Types.ObjectId.createFromHexString(user.id), }); - return client.toJSON(); + return { + ...client.toJSON(), + clientSecret: oryClient.client_secret, + }; } /** diff --git a/apps/auth/src/app/clients/models/created-client.dto.ts b/apps/auth/src/app/clients/models/created-client.dto.ts new file mode 100644 index 00000000..281a761d --- /dev/null +++ b/apps/auth/src/app/clients/models/created-client.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsString } from 'class-validator'; + +import { ClientDto } from './client.dto'; + +export class CreatedClientDto extends ClientDto { + @ApiProperty() + @Expose() + @IsString() + clientSecret: string; +} diff --git a/apps/auth/src/app/clients/models/index.ts b/apps/auth/src/app/clients/models/index.ts index 884652ec..acbda52e 100644 --- a/apps/auth/src/app/clients/models/index.ts +++ b/apps/auth/src/app/clients/models/index.ts @@ -1,4 +1,5 @@ export * from './client.dto'; export * from './create-client.dto'; +export * from './created-client.dto'; export * from './ory-token.dto'; export { Client } from '@ticketing/shared/models'; diff --git a/apps/auth/src/app/clients/schemas/client.schema.ts b/apps/auth/src/app/clients/schemas/client.schema.ts index 20c6cc69..c9fb7526 100644 --- a/apps/auth/src/app/clients/schemas/client.schema.ts +++ b/apps/auth/src/app/clients/schemas/client.schema.ts @@ -6,10 +6,13 @@ import { Client as ClientAttrs } from '../models'; @Schema({ toJSON: { - transform(doc: ClientDocument, ret: ClientAttrs & { _id: ObjectId }) { + transform( + doc: ClientDocument, + ret: ClientAttrs & { _id: ObjectId; __v: number }, + ) { ret.id = doc._id.toString(); ret.userId = doc.user._id.toString(); - const { _id, ...rest } = ret; + const { _id, __v, ...rest } = ret; return rest; }, }, From f27aadedf6a8c235dee083cd94eb2779628fa84d Mon Sep 17 00:00:00 2001 From: getlarge Date: Tue, 9 Apr 2024 21:03:54 +0200 Subject: [PATCH 11/23] fix(auth): update OryOAuth2WebhookPayload validators --- apps/auth/src/app/clients/models/ory-token.dto.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/auth/src/app/clients/models/ory-token.dto.ts b/apps/auth/src/app/clients/models/ory-token.dto.ts index 12ac1caa..2611279b 100644 --- a/apps/auth/src/app/clients/models/ory-token.dto.ts +++ b/apps/auth/src/app/clients/models/ory-token.dto.ts @@ -6,6 +6,7 @@ import { IsBoolean, IsNotEmptyObject, IsString, + ValidateIf, ValidateNested, } from 'class-validator'; @@ -19,9 +20,10 @@ class OryOAuth2WebhookPayloadIDTokenClaims { @IsString() sub: string; + @ValidateIf((object, value) => value != null) @IsArray() @IsString({ each: true }) - aud: string[]; + aud: string[] | null; @IsString() nonce: string; @@ -32,8 +34,9 @@ class OryOAuth2WebhookPayloadIDTokenClaims { @IsString() acr: string; + @ValidateIf((object, value) => value != null) @IsString() - amr: null; + amr: string | null; @IsString() c_hash: string; From 6cf0585baba39632da82bf95dd36dd66a2b483eb Mon Sep 17 00:00:00 2001 From: getlarge Date: Mon, 15 Apr 2024 13:15:09 +0200 Subject: [PATCH 12/23] chore: update Nestjs Ory libs --- package.json | 9 ++++---- yarn.lock | 61 ++++++++++++++++++++++++++++++++++------------------ 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 3fcc5ef7..5740906b 100644 --- a/package.json +++ b/package.json @@ -126,12 +126,13 @@ "@fastify/secure-session": "7.1.0", "@fastify/static": "^6.12.0", "@fastify/swagger": "8.10.0", - "@getlarge/hydra-client-wrapper": "0.2.0", - "@getlarge/keto-cli": "0.2.1", - "@getlarge/keto-client-wrapper": "0.2.5", + "@getlarge/hydra-client-wrapper": "0.2.1", + "@getlarge/keto-cli": "0.2.2", + "@getlarge/keto-client-wrapper": "0.2.6", "@getlarge/keto-relations-parser": "0.0.9", "@getlarge/kratos-cli": "0.2.1", - "@getlarge/kratos-client-wrapper": "0.1.7", + "@getlarge/kratos-client-wrapper": "0.1.8", + "@nest-lab/or-guard": "2.4.1", "@nestjs/axios": "3.0.0", "@nestjs/bull": "10.0.1", "@nestjs/common": "10.2.4", diff --git a/yarn.lock b/yarn.lock index ddfbb524..84beccfb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3923,24 +3923,24 @@ __metadata: languageName: node linkType: hard -"@getlarge/hydra-client-wrapper@npm:0.2.0": - version: 0.2.0 - resolution: "@getlarge/hydra-client-wrapper@npm:0.2.0" +"@getlarge/hydra-client-wrapper@npm:0.2.1": + version: 0.2.1 + resolution: "@getlarge/hydra-client-wrapper@npm:0.2.1" dependencies: "@nestjs/axios": ^3.0.1 "@nestjs/common": ^10.0.2 "@ory/client": ^1.4.9 axios: 1.6.5 tslib: ^2.3.0 - checksum: 1c0b8318f16ddcbca67ea8ca8de4f830fd766feffc83e0d0861585a39cc69c1c63c41b548e538ce7cf6a8abf6eff6f96a46ad5411c674a0527555f9308d155b6 + checksum: aabdd5b36b67b1163f49561d7bdb372e0b7ad28d4cff57da7ebe04738529d405ef4ba1e5ca0aaaac23065f1b04412b96639b782536f3744561d05dfafaab71b5 languageName: node linkType: hard -"@getlarge/keto-cli@npm:0.2.1": - version: 0.2.1 - resolution: "@getlarge/keto-cli@npm:0.2.1" +"@getlarge/keto-cli@npm:0.2.2": + version: 0.2.2 + resolution: "@getlarge/keto-cli@npm:0.2.2" dependencies: - "@getlarge/keto-client-wrapper": 0.2.5 + "@getlarge/keto-client-wrapper": 0.2.6 "@getlarge/keto-relations-parser": 0.0.9 "@nestjs/common": ^10.0.2 "@nestjs/config": ^3.1.1 @@ -3951,13 +3951,13 @@ __metadata: tslib: ^2.3.0 bin: keto-cli: src/index.js - checksum: 72c1089bf1164d8f0fa86fe6a741d491b99b17254e46bfedb48fae278aa443655d8188dc7efb967e36076263560c67a170f7fb5e7c2dcbb2373b38b755fcf15a + checksum: 593e89ef6d723c36a9a542dee63119f87ac97549adaa326a2ee9fe9093cc1613883d94b4099df110dc46920595f1775ccfb1f37f5c2c6c96ee7c7d006639fdf5 languageName: node linkType: hard -"@getlarge/keto-client-wrapper@npm:0.2.5": - version: 0.2.5 - resolution: "@getlarge/keto-client-wrapper@npm:0.2.5" +"@getlarge/keto-client-wrapper@npm:0.2.6": + version: 0.2.6 + resolution: "@getlarge/keto-client-wrapper@npm:0.2.6" dependencies: "@getlarge/keto-relations-parser": 0.0.9 defekt: ^9.3.1 @@ -3969,7 +3969,7 @@ __metadata: "@nestjs/core": ^10.0.2 "@ory/client": ^1.4.9 axios: 1.6.5 - checksum: 8684c24de40c89bf29a5cda268d18076deb744f6dd2966b37f43b90ff148081e0dca89d5e669258df02fd10a4888f6ab43a116c7f91e4fa06c090ce02fad46e1 + checksum: e95f2198eb1b3148b74dcda0d4ac9f479c846f592b1d79e629849f019cbb41d145d535b63ea23fe3977e502d753a0f725d86c45f7bad5a74a5b7ec22829ee697 languageName: node linkType: hard @@ -4015,9 +4015,9 @@ __metadata: languageName: node linkType: hard -"@getlarge/kratos-client-wrapper@npm:0.1.7": - version: 0.1.7 - resolution: "@getlarge/kratos-client-wrapper@npm:0.1.7" +"@getlarge/kratos-client-wrapper@npm:0.1.8": + version: 0.1.8 + resolution: "@getlarge/kratos-client-wrapper@npm:0.1.8" dependencies: tslib: ^2.3.0 peerDependencies: @@ -4025,7 +4025,7 @@ __metadata: "@nestjs/common": ^10.0.2 "@ory/client": ^1.4.9 axios: 1.6.5 - checksum: 9f8611e8fbd766e79ba099fd9c1ff4d217c9a68b40194f4b9db3d5352dc670390f46415b3dc5a7548980eaf89a719809fed7ba5c2487a77d622bd34d600528b7 + checksum: 65d475966d63d285d4c211b7892df1f001f201266edeab4adaaf245395be9980a4c7da9964f04761d06c13918a4ccab4ea8c95a1a5ede146d3c48145e261ea06 languageName: node linkType: hard @@ -4605,6 +4605,24 @@ __metadata: languageName: node linkType: hard +"@nest-lab/or-guard@npm:2.4.1": + version: 2.4.1 + resolution: "@nest-lab/or-guard@npm:2.4.1" + peerDependencies: + "@nestjs/common": " ^8.0.0 || ^9.0.0 || ^10.0.0" + "@nestjs/core": ^8.0.0 || ^9.0.0 || ^10.0.0 + rxjs: ^7.0.0 + peerDependenciesMeta: + "@nestjs/common": + optional: false + "@nestjs/core": + optional: false + rxjs: + optional: false + checksum: 34a83836ffeb3206dd6a039ff090893f5da93cd067db9b6e9c1244aec00c3f1eba075cde697988452fa3b46329cd115298738110dff8d5d86957628011b40874 + languageName: node + linkType: hard + "@nestjs/axios@npm:3.0.0": version: 3.0.0 resolution: "@nestjs/axios@npm:3.0.0" @@ -23047,15 +23065,16 @@ __metadata: "@fastify/secure-session": 7.1.0 "@fastify/static": ^6.12.0 "@fastify/swagger": 8.10.0 - "@getlarge/hydra-client-wrapper": 0.2.0 - "@getlarge/keto-cli": 0.2.1 - "@getlarge/keto-client-wrapper": 0.2.5 + "@getlarge/hydra-client-wrapper": 0.2.1 + "@getlarge/keto-cli": 0.2.2 + "@getlarge/keto-client-wrapper": 0.2.6 "@getlarge/keto-relations-parser": 0.0.9 "@getlarge/kratos-cli": 0.2.1 - "@getlarge/kratos-client-wrapper": 0.1.7 + "@getlarge/kratos-client-wrapper": 0.1.8 "@golevelup/ts-jest": ^0.4.0 "@jscutlery/semver": ^3.1.0 "@mermaid-js/mermaid-cli": ^8.13.4 + "@nest-lab/or-guard": 2.4.1 "@nestjs/axios": 3.0.0 "@nestjs/bull": 10.0.1 "@nestjs/common": 10.2.4 From 2aee923a116c2005a60da46431e95762cf2cf0da Mon Sep 17 00:00:00 2001 From: getlarge Date: Mon, 15 Apr 2024 13:15:47 +0200 Subject: [PATCH 13/23] feat(tickets): enable client to access Tickets API --- .../src/app/tickets/tickets.controller.ts | 11 +- infra/ory/hydra/hydra-template.yaml | 2 + tools/ory/login.js | 114 ------------------ 3 files changed, 11 insertions(+), 116 deletions(-) delete mode 100644 tools/ory/login.js diff --git a/apps/tickets/src/app/tickets/tickets.controller.ts b/apps/tickets/src/app/tickets/tickets.controller.ts index fc1a9f4f..156a2a57 100644 --- a/apps/tickets/src/app/tickets/tickets.controller.ts +++ b/apps/tickets/src/app/tickets/tickets.controller.ts @@ -1,5 +1,6 @@ import { OryPermissionChecks } from '@getlarge/keto-client-wrapper'; import { relationTupleBuilder } from '@getlarge/keto-relations-parser'; +import { OrGuard } from '@nest-lab/or-guard'; import { Body, Controller, @@ -32,6 +33,7 @@ import { import { OryAuthenticationGuard, OryAuthorizationGuard, + OryOAuth2AuthenticationGuard, } from '@ticketing/microservices/shared/guards'; import { PaginatedDto, @@ -75,7 +77,9 @@ const validationPipeOptions: ValidationPipeOptions = { export class TicketsController { constructor(private readonly ticketsService: TicketsService) {} - @UseGuards(OryAuthenticationGuard()) + @UseGuards( + OrGuard([OryAuthenticationGuard(), OryOAuth2AuthenticationGuard()]), + ) @UsePipes(new ValidationPipe(validationPipeOptions)) @ApiBearerAuth(SecurityRequirements.Bearer) @ApiCookieAuth(SecurityRequirements.Session) @@ -143,7 +147,10 @@ export class TicketsController { .of(PermissionNamespaces[Resources.TICKETS], resourceId) .toString(); }) - @UseGuards(OryAuthenticationGuard(), OryAuthorizationGuard()) + @UseGuards( + OrGuard([OryAuthenticationGuard(), OryOAuth2AuthenticationGuard()]), + OryAuthorizationGuard(), + ) @UsePipes(new ValidationPipe(validationPipeOptions)) @ApiBearerAuth(SecurityRequirements.Bearer) @ApiCookieAuth(SecurityRequirements.Session) diff --git a/infra/ory/hydra/hydra-template.yaml b/infra/ory/hydra/hydra-template.yaml index b33148b9..ed051e48 100644 --- a/infra/ory/hydra/hydra-template.yaml +++ b/infra/ory/hydra/hydra-template.yaml @@ -31,6 +31,8 @@ oauth2: in: header name: X-Ory-API-Key value: '##oauth2_token_hook_auth_config_value##' + client_credentials: + default_grant_allowed_scope: true oidc: subject_identifiers: diff --git a/tools/ory/login.js b/tools/ory/login.js deleted file mode 100644 index 53fb47bf..00000000 --- a/tools/ory/login.js +++ /dev/null @@ -1,114 +0,0 @@ -const axios = require('axios'); -const { isEmail } = require('class-validator'); -const readline = require('node:readline'); -const { inspect } = require('node:util'); - -const axiosInstance = axios.create({ - baseURL: process.env.ORY_BASE_PATH || 'http://localhost:4433', -}); - -async function initLoginFlow() { - const url = `/self-service/login/api`; - const response = await axiosInstance.get(url, { responseType: 'json' }); - return { - flowId: response.data.id, - flowUrl: response.data.ui.action, - setCookie: response.headers['set-cookie'], - }; -} - -async function completeLoginFlow( - { flowId, flowUrl }, - { identifier, password }, -) { - const response = await axiosInstance.post( - // `/self-service/login/flow=${flowId}`, - flowUrl, - { - identifier, - password, - method: 'password', - }, - { - headers: { - 'content-type': 'application/json', - accept: 'application/json', - }, - }, - ); - console.log( - inspect(response.data, { - depth: 10, - colors: true, - }), - ); - return response.data.session_token; -} - -function checkSession(sessionToken) { - return axiosInstance - .get(`/sessions/whoami`, { - headers: { - accept: 'application/json', - authorization: `Bearer ${sessionToken}`, - }, - }) - .then(({ data }) => data); -} - -async function login({ identifier, password }) { - console.log('init login flow'); - const { flowId, flowUrl } = await initLoginFlow(); - console.log('complete login flow'); - const sessionToken = await completeLoginFlow( - { flowId, flowUrl }, - { - identifier, - password, - }, - ); - console.log('checking session token : ', sessionToken); - const session = await checkSession(sessionToken); - console.log('loggedin!', session); -} - -async function main() { - if (!process.env.ORY_BASE_PATH) { - throw new TypeError( - 'ORY_BASE_PATH must be set in the environment variables', - ); - } - const identifier = process.env.ORY_USER ?? process.argv[2]; - if (!isEmail(identifier)) { - throw new TypeError('Identifier must be an email address'); - } - - const password = await new Promise((resolve) => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - rl.question('Password: ', (answer) => { - resolve(answer); - rl.close(); - }); - }); - if (!password) { - throw new TypeError('Password must be a non-empty string'); - } - await login({ - identifier, - password, - }); -} - -main().catch((e) => - console.error( - e?.response?.data - ? inspect(e.response.data, { - depth: 10, - colors: true, - }) - : e.message, - ), -); From 3d9136bb0a961c8968dd9a861e5e552850e1f901 Mon Sep 17 00:00:00 2001 From: getlarge Date: Mon, 15 Apr 2024 16:57:38 +0200 Subject: [PATCH 14/23] refactor(microservices-shared-events): create separate event data classes --- .../shared/events/src/events-map.ts | 12 +++--- .../shared/events/src/expiration-events.ts | 11 +++-- .../shared/events/src/order-events.spec.ts | 36 ++++++++++++++++ .../shared/events/src/order-events.ts | 41 ++++++++++++++----- .../shared/events/src/payment-events.ts | 8 ++-- .../shared/events/src/ticket-events.ts | 16 +++++--- 6 files changed, 95 insertions(+), 29 deletions(-) create mode 100644 libs/microservices/shared/events/src/order-events.spec.ts diff --git a/libs/microservices/shared/events/src/events-map.ts b/libs/microservices/shared/events/src/events-map.ts index 2e9f8274..8b4eecc6 100644 --- a/libs/microservices/shared/events/src/events-map.ts +++ b/libs/microservices/shared/events/src/events-map.ts @@ -5,10 +5,10 @@ import { PaymentCreatedEvent } from './payment-events'; import { TicketCreatedEvent, TicketUpdatedEvent } from './ticket-events'; export type EventsMap = { - [Patterns.TicketCreated]: TicketCreatedEvent['data']; - [Patterns.TicketUpdated]: TicketUpdatedEvent['data']; - [Patterns.OrderCreated]: OrderCreatedEvent['data']; - [Patterns.OrderCancelled]: OrderCancelledEvent['data']; - [Patterns.ExpirationCompleted]: ExpirationCompletedEvent['data']; - [Patterns.PaymentCreated]: PaymentCreatedEvent['data']; + readonly [Patterns.TicketCreated]: TicketCreatedEvent['data']; + readonly [Patterns.TicketUpdated]: TicketUpdatedEvent['data']; + readonly [Patterns.OrderCreated]: OrderCreatedEvent['data']; + readonly [Patterns.OrderCancelled]: OrderCancelledEvent['data']; + readonly [Patterns.ExpirationCompleted]: ExpirationCompletedEvent['data']; + readonly [Patterns.PaymentCreated]: PaymentCreatedEvent['data']; }; diff --git a/libs/microservices/shared/events/src/expiration-events.ts b/libs/microservices/shared/events/src/expiration-events.ts index 952f7624..3799967b 100644 --- a/libs/microservices/shared/events/src/expiration-events.ts +++ b/libs/microservices/shared/events/src/expiration-events.ts @@ -1,9 +1,14 @@ +import { PickType } from '@nestjs/mapped-types'; import { Order } from '@ticketing/shared/models'; import { Event } from './event'; import { Patterns } from './patterns'; -export interface ExpirationCompletedEvent extends Event { - name: Patterns.ExpirationCompleted; - data: Pick; +export class ExpirationCompletedEventData extends PickType(Order, ['id']) {} + +export class ExpirationCompletedEvent implements Event { + static readonly name = Patterns.ExpirationCompleted; + static readonly data = ExpirationCompletedEventData; + name = ExpirationCompletedEvent.name; + data = new ExpirationCompletedEvent.data(); } diff --git a/libs/microservices/shared/events/src/order-events.spec.ts b/libs/microservices/shared/events/src/order-events.spec.ts new file mode 100644 index 00000000..8d8302c3 --- /dev/null +++ b/libs/microservices/shared/events/src/order-events.spec.ts @@ -0,0 +1,36 @@ +import 'reflect-metadata'; + +import { OrderStatus } from '@ticketing/shared/models'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { Types } from 'mongoose'; + +import { OrderCreatedEvent } from './order-events'; +import { Patterns } from './patterns'; + +describe('OrderCreatedEvent', () => { + it('should be defined', () => { + expect(new OrderCreatedEvent()).toBeDefined(); + }); + + it('should have name OrderCreated', () => { + expect(new OrderCreatedEvent().name).toBe(Patterns.OrderCreated); + }); + + it('should have data', () => { + expect(new OrderCreatedEvent().data).toBeDefined(); + }); + + it('should be useable to validate instance', async () => { + const event = plainToInstance(OrderCreatedEvent.data, { + id: new Types.ObjectId().toHexString(), + userId: new Types.ObjectId().toHexString(), + status: OrderStatus.Created, + version: 0, + expiresAt: new Date().toISOString(), + }); + + const errors = await validate(event); + expect(errors).toHaveLength(0); + }); +}); diff --git a/libs/microservices/shared/events/src/order-events.ts b/libs/microservices/shared/events/src/order-events.ts index 78caa37f..c9fa44fa 100644 --- a/libs/microservices/shared/events/src/order-events.ts +++ b/libs/microservices/shared/events/src/order-events.ts @@ -1,18 +1,37 @@ -import { Order } from '@ticketing/shared/models'; +import { OmitType, PickType } from '@nestjs/mapped-types'; +import { Order, Ticket } from '@ticketing/shared/models'; +import { Type } from 'class-transformer'; +import { ValidateNested } from 'class-validator'; import { Event } from './event'; import { Patterns } from './patterns'; -export interface OrderCreatedEvent extends Event { - name: Patterns.OrderCreated; - data: Omit & { - ticket: { id: string; price: number }; - }; +class OrderCreatedTicket extends PickType(Ticket, ['id', 'price']) {} + +export class OrderCreatedEventData extends OmitType(Order, ['ticket']) { + @Type(() => OrderCreatedTicket) + @ValidateNested() + ticket: OrderCreatedTicket; +} + +export class OrderCreatedEvent implements Event { + static readonly data = OrderCreatedEventData; + static readonly name = Patterns.OrderCreated as const; + name = OrderCreatedEvent.name; + data = new OrderCreatedEvent.data(); +} + +class OrderCancelledTicket extends PickType(Ticket, ['id']) {} + +class OrderCancelledEventData extends OmitType(Order, ['ticket']) { + @Type(() => OrderCancelledTicket) + @ValidateNested() + ticket: OrderCancelledTicket; } -export interface OrderCancelledEvent extends Event { - subject: Patterns.OrderCancelled; - data: Omit & { - ticket: { id: string }; - }; +export class OrderCancelledEvent implements Event { + static readonly data = OrderCreatedEventData; + static readonly name = Patterns.OrderCancelled as const; + name = OrderCancelledEvent.name; + data = new OrderCancelledEventData(); } diff --git a/libs/microservices/shared/events/src/payment-events.ts b/libs/microservices/shared/events/src/payment-events.ts index 3c3ecfab..3f004379 100644 --- a/libs/microservices/shared/events/src/payment-events.ts +++ b/libs/microservices/shared/events/src/payment-events.ts @@ -3,7 +3,9 @@ import { Payment } from '@ticketing/shared/models'; import { Event } from './event'; import { Patterns } from './patterns'; -export interface PaymentCreatedEvent extends Event { - name: Patterns.PaymentCreated; - data: Payment; +export class PaymentCreatedEvent implements Event { + static readonly name = Patterns.PaymentCreated; + static readonly data = Payment; + name = PaymentCreatedEvent.name; + data = new PaymentCreatedEvent.data(); } diff --git a/libs/microservices/shared/events/src/ticket-events.ts b/libs/microservices/shared/events/src/ticket-events.ts index 876b9554..29629edb 100644 --- a/libs/microservices/shared/events/src/ticket-events.ts +++ b/libs/microservices/shared/events/src/ticket-events.ts @@ -3,12 +3,16 @@ import { Ticket } from '@ticketing/shared/models'; import { Event } from './event'; import { Patterns } from './patterns'; -export interface TicketCreatedEvent extends Event { - name: Patterns.TicketCreated; - data: Ticket; +export class TicketCreatedEvent implements Event { + static readonly data = Ticket; + static readonly name = Patterns.TicketCreated as const; + name = TicketCreatedEvent.name; + data = new TicketCreatedEvent.data(); } -export interface TicketUpdatedEvent extends Event { - name: Patterns.TicketUpdated; - data: Ticket; +export class TicketUpdatedEvent implements Event { + static readonly data = Ticket; + static readonly name = Patterns.TicketUpdated as const; + name = TicketUpdatedEvent.name; + data = new TicketUpdatedEvent.data(); } From 4b3e228facb857f4e870f6e2ab2f41096531ce84 Mon Sep 17 00:00:00 2001 From: getlarge Date: Mon, 15 Apr 2024 16:58:08 +0200 Subject: [PATCH 15/23] fix(auth): create proper Ory exceptions in webhook responses --- apps/auth/src/app/users/models/index.ts | 1 + .../src/app/users/models/ory-webhook.error.ts | 39 ++++++++++ apps/auth/src/app/users/users.service.ts | 72 ++++++++++++++++--- 3 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 apps/auth/src/app/users/models/ory-webhook.error.ts diff --git a/apps/auth/src/app/users/models/index.ts b/apps/auth/src/app/users/models/index.ts index 26686a17..abdbaf8c 100644 --- a/apps/auth/src/app/users/models/index.ts +++ b/apps/auth/src/app/users/models/index.ts @@ -1,4 +1,5 @@ export * from './ory-identity.dto'; +export * from './ory-webhook.error'; export * from './user.dto'; export * from './user-credentials.dto'; export { diff --git a/apps/auth/src/app/users/models/ory-webhook.error.ts b/apps/auth/src/app/users/models/ory-webhook.error.ts new file mode 100644 index 00000000..ff2ace5f --- /dev/null +++ b/apps/auth/src/app/users/models/ory-webhook.error.ts @@ -0,0 +1,39 @@ +import { HttpException } from '@nestjs/common'; + +export type OryWebhookErrorMessages = { + instance_ptr: string; + messages: { + id: number; + text: string; + type: string; + context: { + value: string; + }; + }[]; +}; + +export class OryWebhookError extends HttpException { + constructor( + message: string, + public readonly messages: OryWebhookErrorMessages[], + status: number, + ) { + super(OryWebhookError.toJSON(messages, status), status); + this.name = 'OryWebhookError'; + this.message = message; + Error.captureStackTrace(this, OryWebhookError); + } + + static toJSON( + messages: OryWebhookErrorMessages[], + statusCode: number, + ): { + statusCode: number; + messages: OryWebhookErrorMessages[]; + } { + return { + statusCode, + messages, + }; + } +} diff --git a/apps/auth/src/app/users/users.service.ts b/apps/auth/src/app/users/users.service.ts index a3f4be11..b5a97880 100644 --- a/apps/auth/src/app/users/users.service.ts +++ b/apps/auth/src/app/users/users.service.ts @@ -2,7 +2,7 @@ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; -import { User, UserCredentials } from './models'; +import { OryWebhookError, User, UserCredentials } from './models'; import { OnOrySignInDto, OnOrySignUpDto } from './models/ory-identity.dto'; import { User as UserSchema, UserDocument } from './schemas'; @@ -27,7 +27,25 @@ export class UsersService { email, }); if (existingUser) { - throw new HttpException('email already used', HttpStatus.BAD_REQUEST); + throw new OryWebhookError( + `email (${email}) already used`, + [ + { + instance_ptr: '#/traits/email', + messages: [ + { + id: 123, + text: 'email already used', + type: 'validation', + context: { + value: email, + }, + }, + ], + }, + ], + HttpStatus.BAD_REQUEST, + ); } const result = await this.userModel.create({ email }); const user = result.toJSON(); @@ -38,7 +56,6 @@ export class UsersService { /** * @description To workaround the issue with Ory's not offering transactional hooks, we need to check if the user exists in our database * if not we use it as a second chance to create it - * @todo: throw error in format supported by Ory hooks response handler + create specific error class * @see https://www.ory.sh/docs/guides/integrate-with-ory-cloud-through-webhooks#flow-interrupting-webhooks **/ async onSignIn(body: OnOrySignInDto): Promise { @@ -52,20 +69,54 @@ export class UsersService { }); if (!user) { - throw new HttpException('user not found', HttpStatus.NOT_FOUND); + throw new OryWebhookError( + 'user not found', + [ + { + instance_ptr: '#/traits/email', + messages: [ + { + id: 123, + text: 'user not found', + type: 'validation', + context: { + value: email, + }, + }, + ], + }, + ], + HttpStatus.NOT_FOUND, + ); } // logic from original require_verified_address hook https://github.com/ory/kratos/blob/34751a1a3ad9b217af2de7b435b9ee70df510265/selfservice/hook/address_verifier.go if (!identity.verifiable_addresses?.length) { - throw new HttpException( - 'A misconfiguration prevents login. Expected to find a verification address but this identity does not have one assigned.', - HttpStatus.NOT_FOUND, - ); + // this means the identity schema does not require email verification + return { identity }; } const hasAddressVerified = identity.verifiable_addresses.some( (address) => address.verified, ); if (!hasAddressVerified) { - throw new HttpException('Email not verified', HttpStatus.UNAUTHORIZED); + throw new OryWebhookError( + 'Email not verified', + [ + { + instance_ptr: '#/verifiable_addresses', + messages: [ + { + id: 123, + text: 'Email not verified', + type: 'validation', + context: { + value: email, + }, + }, + ], + }, + ], + HttpStatus.UNAUTHORIZED, + ); } if (!user.identityId || user.identityId !== identity.id) { user.set({ identityId: identity.id }); @@ -74,6 +125,9 @@ export class UsersService { return { identity }; } + /** + * @deprecated Account creation is now handled by Ory and the backend is only involved during the on-sign-up webhook + */ async signUp(credentials: UserCredentials): Promise { const existingUser = await this.userModel.findOne({ email: credentials.email, From 7bf935847538763451b163905b77a5db18d178c1 Mon Sep 17 00:00:00 2001 From: getlarge Date: Mon, 15 Apr 2024 16:59:01 +0200 Subject: [PATCH 16/23] fix(tickets): move combined guards in providers to use `OrGuard` --- apps/tickets/src/app/shared/constants.ts | 5 +++- .../src/app/tickets/tickets.controller.ts | 13 +++------ .../tickets/src/app/tickets/tickets.module.ts | 28 ++++++++++++++++++- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/apps/tickets/src/app/shared/constants.ts b/apps/tickets/src/app/shared/constants.ts index 781cfb8a..64f13916 100644 --- a/apps/tickets/src/app/shared/constants.ts +++ b/apps/tickets/src/app/shared/constants.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { join } from 'node:path'; export const APP_FOLDER = join('apps', 'tickets'); export const DEFAULT_PORT = 3010; @@ -6,3 +6,6 @@ export const DEFAULT_SERVER_URL = `http://localhost:${DEFAULT_PORT}`; export const TICKETS_CLIENT = 'TICKETS_CLIENT'; export const ORDERS_CLIENT = 'ORDERS_CLIENT'; + +export const ORY_AUTH_GUARD = 'ORY_AUTH_GUARD'; +export const ORY_OAUTH2_GUARD = 'ORY_OAUTH2_GUARD'; diff --git a/apps/tickets/src/app/tickets/tickets.controller.ts b/apps/tickets/src/app/tickets/tickets.controller.ts index 156a2a57..2ac9bb53 100644 --- a/apps/tickets/src/app/tickets/tickets.controller.ts +++ b/apps/tickets/src/app/tickets/tickets.controller.ts @@ -30,11 +30,7 @@ import { ApiPaginatedDto, CurrentUser, } from '@ticketing/microservices/shared/decorators'; -import { - OryAuthenticationGuard, - OryAuthorizationGuard, - OryOAuth2AuthenticationGuard, -} from '@ticketing/microservices/shared/guards'; +import { OryAuthorizationGuard } from '@ticketing/microservices/shared/guards'; import { PaginatedDto, PaginateDto, @@ -54,6 +50,7 @@ import { requestValidationErrorFactory } from '@ticketing/shared/errors'; import { User } from '@ticketing/shared/models'; import type { FastifyRequest } from 'fastify/types/request'; +import { ORY_AUTH_GUARD, ORY_OAUTH2_GUARD } from '../shared/constants'; import { CreateTicket, CreateTicketDto, @@ -77,9 +74,7 @@ const validationPipeOptions: ValidationPipeOptions = { export class TicketsController { constructor(private readonly ticketsService: TicketsService) {} - @UseGuards( - OrGuard([OryAuthenticationGuard(), OryOAuth2AuthenticationGuard()]), - ) + @UseGuards(OrGuard([ORY_AUTH_GUARD, ORY_OAUTH2_GUARD])) @UsePipes(new ValidationPipe(validationPipeOptions)) @ApiBearerAuth(SecurityRequirements.Bearer) @ApiCookieAuth(SecurityRequirements.Session) @@ -148,7 +143,7 @@ export class TicketsController { .toString(); }) @UseGuards( - OrGuard([OryAuthenticationGuard(), OryOAuth2AuthenticationGuard()]), + OrGuard([ORY_AUTH_GUARD, ORY_OAUTH2_GUARD]), OryAuthorizationGuard(), ) @UsePipes(new ValidationPipe(validationPipeOptions)) diff --git a/apps/tickets/src/app/tickets/tickets.module.ts b/apps/tickets/src/app/tickets/tickets.module.ts index e2891b88..a5fe49d0 100644 --- a/apps/tickets/src/app/tickets/tickets.module.ts +++ b/apps/tickets/src/app/tickets/tickets.module.ts @@ -1,3 +1,4 @@ +import { OryOAuth2Module } from '@getlarge/hydra-client-wrapper'; import { OryPermissionsModule, OryRelationshipsModule, @@ -14,12 +15,20 @@ import { import { MongooseModule } from '@nestjs/mongoose'; import { AmqpClient, AmqpOptions } from '@s1seven/nestjs-tools-amqp-transport'; import { GlobalErrorFilter } from '@ticketing/microservices/shared/filters'; +import { + OryAuthenticationGuard, + OryOAuth2AuthenticationGuard, +} from '@ticketing/microservices/shared/guards'; import { getReplyQueueName } from '@ticketing/microservices/shared/rmq'; import { Services } from '@ticketing/shared/constants'; import { updateIfCurrentPlugin } from 'mongoose-update-if-current'; import { AppConfigService, EnvironmentVariables } from '../env'; -import { ORDERS_CLIENT } from '../shared/constants'; +import { + ORDERS_CLIENT, + ORY_AUTH_GUARD, + ORY_OAUTH2_GUARD, +} from '../shared/constants'; import { Ticket, TicketSchema } from './schemas/ticket.schema'; import { TicketsController } from './tickets.controller'; import { TicketsService } from './tickets.service'; @@ -101,6 +110,15 @@ const OrdersClient = ClientsModule.registerAsync([ basePath: configService.get('ORY_KETO_ADMIN_URL'), }), }), + OryOAuth2Module.forRootAsync({ + inject: [ConfigService], + useFactory: ( + configService: ConfigService, + ) => ({ + basePath: configService.get('ORY_HYDRA_PUBLIC_URL'), + accessToken: configService.get('ORY_HYDRA_API_KEY'), + }), + }), ], controllers: [TicketsController], providers: [ @@ -110,6 +128,14 @@ const OrdersClient = ClientsModule.registerAsync([ }, GlobalErrorFilter, TicketsService, + { + provide: ORY_AUTH_GUARD, + useClass: OryAuthenticationGuard(), + }, + { + provide: ORY_OAUTH2_GUARD, + useClass: OryOAuth2AuthenticationGuard(), + }, ], exports: [MongooseFeatures, OrdersClient, TicketsService], }) From b52964c3adc352726dd4c172096fcb7117a810a2 Mon Sep 17 00:00:00 2001 From: getlarge Date: Mon, 15 Apr 2024 19:34:08 +0200 Subject: [PATCH 17/23] refactor: use classes explictly to enable validation pipe transformation --- .../src/app/orders/orders-ms.controller.ts | 16 +++++---- .../src/app/orders/orders.service.ts | 2 +- .../src/app/orders/orders-ms.controller.ts | 15 +++++--- .../src/app/tickets/tickets-ms.controller.ts | 14 +++++--- .../src/app/orders/orders-ms.controller.ts | 13 ++++--- .../src/app/orders/orders-ms.controller.ts | 15 +++++--- .../shared/events/src/order-events.ts | 2 +- .../shared/events/src/payment-events.ts | 4 ++- .../shared/events/src/ticket-events.ts | 5 +-- package.json | 4 +-- yarn.lock | 36 ++++++------------- 11 files changed, 68 insertions(+), 58 deletions(-) diff --git a/apps/expiration/src/app/orders/orders-ms.controller.ts b/apps/expiration/src/app/orders/orders-ms.controller.ts index 7df9a43f..70bae654 100644 --- a/apps/expiration/src/app/orders/orders-ms.controller.ts +++ b/apps/expiration/src/app/orders/orders-ms.controller.ts @@ -14,10 +14,14 @@ import { Transport, } from '@nestjs/microservices'; import { ApiExcludeEndpoint } from '@nestjs/swagger'; -import { EventsMap, Patterns } from '@ticketing/microservices/shared/events'; +import { + OrderCancelledEvent, + OrderCancelledEventData, + OrderCreatedEvent, + OrderCreatedEventData, +} from '@ticketing/microservices/shared/events'; import { GlobalErrorFilter } from '@ticketing/microservices/shared/filters'; import { requestValidationErrorFactory } from '@ticketing/shared/errors'; -import { Order } from '@ticketing/shared/models'; import type { Channel } from 'amqp-connection-manager'; import type { Message } from 'amqplib'; @@ -41,10 +45,10 @@ export class OrdersMSController { ) {} @ApiExcludeEndpoint() - @EventPattern(Patterns.OrderCreated, Transport.RMQ) + @EventPattern(OrderCreatedEvent.name, Transport.RMQ) async onCreated( @Payload(new ValidationPipe(validationPipeOptions)) - data: EventsMap[Patterns.OrderCreated], + data: OrderCreatedEventData, @Ctx() context: RmqContext, ): Promise<{ ok: boolean; @@ -67,10 +71,10 @@ export class OrdersMSController { } @ApiExcludeEndpoint() - @EventPattern(Patterns.OrderCancelled, Transport.RMQ) + @EventPattern(OrderCancelledEvent.name, Transport.RMQ) async onCancelled( @Payload(new ValidationPipe(validationPipeOptions)) - data: Order, + data: OrderCancelledEventData, @Ctx() context: RmqContext, ): Promise<{ ok: boolean; diff --git a/apps/expiration/src/app/orders/orders.service.ts b/apps/expiration/src/app/orders/orders.service.ts index cc573838..0dea89e4 100644 --- a/apps/expiration/src/app/orders/orders.service.ts +++ b/apps/expiration/src/app/orders/orders.service.ts @@ -13,7 +13,7 @@ import { OrdersProcessorData } from './orders.processor'; export class OrderService { constructor( @InjectQueue(ORDERS_QUEUE) - private expirationQueue: Queue + private expirationQueue: Queue, ) {} async createJob(order: OrderCreatedEvent['data']): Promise { diff --git a/apps/orders/src/app/orders/orders-ms.controller.ts b/apps/orders/src/app/orders/orders-ms.controller.ts index 7f2430a6..9d22f902 100644 --- a/apps/orders/src/app/orders/orders-ms.controller.ts +++ b/apps/orders/src/app/orders/orders-ms.controller.ts @@ -14,7 +14,12 @@ import { Transport, } from '@nestjs/microservices'; import { ApiExcludeEndpoint } from '@nestjs/swagger'; -import { EventsMap, Patterns } from '@ticketing/microservices/shared/events'; +import { + ExpirationCompletedEvent, + ExpirationCompletedEventData, + PaymentCreatedEvent, + PaymentCreatedEventData, +} from '@ticketing/microservices/shared/events'; import { requestValidationErrorFactory } from '@ticketing/shared/errors'; import type { Channel } from 'amqp-connection-manager'; import type { Message } from 'amqplib'; @@ -38,9 +43,9 @@ export class OrdersMSController { ) {} @ApiExcludeEndpoint() - @EventPattern(Patterns.ExpirationCompleted, Transport.RMQ) + @EventPattern(ExpirationCompletedEvent.name, Transport.RMQ) async onExpiration( - @Payload() data: EventsMap[Patterns.ExpirationCompleted], + @Payload() data: ExpirationCompletedEventData, @Ctx() context: RmqContext, ): Promise { const channel = context.getChannelRef() as Channel; @@ -60,10 +65,10 @@ export class OrdersMSController { } @ApiExcludeEndpoint() - @MessagePattern(Patterns.PaymentCreated, Transport.RMQ) + @MessagePattern(PaymentCreatedEvent.name, Transport.RMQ) async onPaymentCreated( @Payload(new ValidationPipe(validationPipeOptions)) - data: EventsMap[Patterns.PaymentCreated], + data: PaymentCreatedEventData, @Ctx() context: RmqContext, ): Promise { const channel = context.getChannelRef() as Channel; diff --git a/apps/orders/src/app/tickets/tickets-ms.controller.ts b/apps/orders/src/app/tickets/tickets-ms.controller.ts index 140b412a..b8b469f4 100644 --- a/apps/orders/src/app/tickets/tickets-ms.controller.ts +++ b/apps/orders/src/app/tickets/tickets-ms.controller.ts @@ -13,7 +13,11 @@ import { Transport, } from '@nestjs/microservices'; import { ApiExcludeEndpoint } from '@nestjs/swagger'; -import { EventsMap, Patterns } from '@ticketing/microservices/shared/events'; +import { + TicketCreatedEvent, + TicketEventData, + TicketUpdatedEvent, +} from '@ticketing/microservices/shared/events'; import { requestValidationErrorFactory } from '@ticketing/shared/errors'; import type { Channel } from 'amqp-connection-manager'; import type { Message } from 'amqplib'; @@ -38,10 +42,10 @@ export class TicketsMSController { ) {} @ApiExcludeEndpoint() - @MessagePattern(Patterns.TicketCreated, Transport.RMQ) + @MessagePattern(TicketCreatedEvent.name, Transport.RMQ) async onCreated( @Payload(new ValidationPipe(validationPipeOptions)) - data: EventsMap[Patterns.TicketCreated], + data: TicketEventData, @Ctx() context: RmqContext, ): Promise { const channel = context.getChannelRef() as Channel; @@ -62,10 +66,10 @@ export class TicketsMSController { } @ApiExcludeEndpoint() - @MessagePattern(Patterns.TicketUpdated, Transport.RMQ) + @MessagePattern(TicketUpdatedEvent.name, Transport.RMQ) async onUpdated( @Payload(new ValidationPipe(validationPipeOptions)) - data: EventsMap[Patterns.TicketUpdated], + data: TicketEventData, @Ctx() context: RmqContext, ): Promise { const channel = context.getChannelRef() as Channel; diff --git a/apps/payments/src/app/orders/orders-ms.controller.ts b/apps/payments/src/app/orders/orders-ms.controller.ts index c1dac634..502ebd7a 100644 --- a/apps/payments/src/app/orders/orders-ms.controller.ts +++ b/apps/payments/src/app/orders/orders-ms.controller.ts @@ -7,7 +7,10 @@ import { Transport, } from '@nestjs/microservices'; import { ApiExcludeEndpoint } from '@nestjs/swagger'; -import { EventsMap, Patterns } from '@ticketing/microservices/shared/events'; +import { + OrderCancelledEvent, + OrderCreatedEvent, +} from '@ticketing/microservices/shared/events'; import type { Channel } from 'amqp-connection-manager'; import type { Message } from 'amqplib'; @@ -22,9 +25,9 @@ export class OrdersMSController { ) {} @ApiExcludeEndpoint() - @MessagePattern(Patterns.OrderCreated, Transport.RMQ) + @MessagePattern(OrderCreatedEvent.name, Transport.RMQ) async onCreated( - @Payload() data: EventsMap[Patterns.OrderCreated], + @Payload() data: OrderCreatedEvent['data'], @Ctx() context: RmqContext, ): Promise<{ ok: boolean; @@ -46,9 +49,9 @@ export class OrdersMSController { } @ApiExcludeEndpoint() - @MessagePattern(Patterns.OrderCancelled, Transport.RMQ) + @MessagePattern(OrderCancelledEvent.name, Transport.RMQ) async onCancelled( - @Payload() data: EventsMap[Patterns.OrderCancelled], + @Payload() data: OrderCancelledEvent['data'], @Ctx() context: RmqContext, ): Promise<{ ok: boolean; diff --git a/apps/tickets/src/app/orders/orders-ms.controller.ts b/apps/tickets/src/app/orders/orders-ms.controller.ts index f566bfec..a8007524 100644 --- a/apps/tickets/src/app/orders/orders-ms.controller.ts +++ b/apps/tickets/src/app/orders/orders-ms.controller.ts @@ -13,7 +13,12 @@ import { Transport, } from '@nestjs/microservices'; import { ApiExcludeEndpoint } from '@nestjs/swagger'; -import { EventsMap, Patterns } from '@ticketing/microservices/shared/events'; +import { + OrderCancelledEvent, + OrderCancelledEventData, + OrderCreatedEvent, + OrderCreatedEventData, +} from '@ticketing/microservices/shared/events'; import { requestValidationErrorFactory } from '@ticketing/shared/errors'; import type { Channel } from 'amqp-connection-manager'; import type { Message } from 'amqplib'; @@ -38,10 +43,10 @@ export class OrdersMSController { ) {} @ApiExcludeEndpoint() - @MessagePattern(Patterns.OrderCreated, Transport.RMQ) + @MessagePattern(OrderCreatedEvent.name, Transport.RMQ) async onCreated( @Payload(new ValidationPipe(validationPipeOptions)) - data: EventsMap[Patterns.OrderCreated], + data: OrderCreatedEventData, @Ctx() context: RmqContext, ): Promise { const channel = context.getChannelRef() as Channel; @@ -62,10 +67,10 @@ export class OrdersMSController { } @ApiExcludeEndpoint() - @MessagePattern(Patterns.OrderCancelled, Transport.RMQ) + @MessagePattern(OrderCancelledEvent.name, Transport.RMQ) async onCancelled( @Payload(new ValidationPipe(validationPipeOptions)) - data: EventsMap[Patterns.OrderCancelled], + data: OrderCancelledEventData, @Ctx() context: RmqContext, ): Promise { const channel = context.getChannelRef() as Channel; diff --git a/libs/microservices/shared/events/src/order-events.ts b/libs/microservices/shared/events/src/order-events.ts index c9fa44fa..6fa6b49e 100644 --- a/libs/microservices/shared/events/src/order-events.ts +++ b/libs/microservices/shared/events/src/order-events.ts @@ -23,7 +23,7 @@ export class OrderCreatedEvent implements Event { class OrderCancelledTicket extends PickType(Ticket, ['id']) {} -class OrderCancelledEventData extends OmitType(Order, ['ticket']) { +export class OrderCancelledEventData extends OmitType(Order, ['ticket']) { @Type(() => OrderCancelledTicket) @ValidateNested() ticket: OrderCancelledTicket; diff --git a/libs/microservices/shared/events/src/payment-events.ts b/libs/microservices/shared/events/src/payment-events.ts index 3f004379..b19ad209 100644 --- a/libs/microservices/shared/events/src/payment-events.ts +++ b/libs/microservices/shared/events/src/payment-events.ts @@ -3,9 +3,11 @@ import { Payment } from '@ticketing/shared/models'; import { Event } from './event'; import { Patterns } from './patterns'; +export class PaymentCreatedEventData extends Payment {} + export class PaymentCreatedEvent implements Event { static readonly name = Patterns.PaymentCreated; - static readonly data = Payment; + static readonly data = PaymentCreatedEventData; name = PaymentCreatedEvent.name; data = new PaymentCreatedEvent.data(); } diff --git a/libs/microservices/shared/events/src/ticket-events.ts b/libs/microservices/shared/events/src/ticket-events.ts index 29629edb..450b4779 100644 --- a/libs/microservices/shared/events/src/ticket-events.ts +++ b/libs/microservices/shared/events/src/ticket-events.ts @@ -3,15 +3,16 @@ import { Ticket } from '@ticketing/shared/models'; import { Event } from './event'; import { Patterns } from './patterns'; +export class TicketEventData extends Ticket {} export class TicketCreatedEvent implements Event { - static readonly data = Ticket; + static readonly data = TicketEventData; static readonly name = Patterns.TicketCreated as const; name = TicketCreatedEvent.name; data = new TicketCreatedEvent.data(); } export class TicketUpdatedEvent implements Event { - static readonly data = Ticket; + static readonly data = TicketEventData; static readonly name = Patterns.TicketUpdated as const; name = TicketUpdatedEvent.name; data = new TicketUpdatedEvent.data(); diff --git a/package.json b/package.json index 5740906b..bc365fe6 100644 --- a/package.json +++ b/package.json @@ -127,10 +127,10 @@ "@fastify/static": "^6.12.0", "@fastify/swagger": "8.10.0", "@getlarge/hydra-client-wrapper": "0.2.1", - "@getlarge/keto-cli": "0.2.2", + "@getlarge/keto-cli": "0.2.3", "@getlarge/keto-client-wrapper": "0.2.6", "@getlarge/keto-relations-parser": "0.0.9", - "@getlarge/kratos-cli": "0.2.1", + "@getlarge/kratos-cli": "0.3.0", "@getlarge/kratos-client-wrapper": "0.1.8", "@nest-lab/or-guard": "2.4.1", "@nestjs/axios": "3.0.0", diff --git a/yarn.lock b/yarn.lock index 84beccfb..bf84a053 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3936,9 +3936,9 @@ __metadata: languageName: node linkType: hard -"@getlarge/keto-cli@npm:0.2.2": - version: 0.2.2 - resolution: "@getlarge/keto-cli@npm:0.2.2" +"@getlarge/keto-cli@npm:0.2.3": + version: 0.2.3 + resolution: "@getlarge/keto-cli@npm:0.2.3" dependencies: "@getlarge/keto-client-wrapper": 0.2.6 "@getlarge/keto-relations-parser": 0.0.9 @@ -3951,7 +3951,7 @@ __metadata: tslib: ^2.3.0 bin: keto-cli: src/index.js - checksum: 593e89ef6d723c36a9a542dee63119f87ac97549adaa326a2ee9fe9093cc1613883d94b4099df110dc46920595f1775ccfb1f37f5c2c6c96ee7c7d006639fdf5 + checksum: 5796d976c7db3caa10f8a78cf558d0f365c8388b4f0846d8503102379bf5d7102ebf13a2eed4b649cd7498911f80e3c7dfe8f4f703426608264a2fe868ee1caa languageName: node linkType: hard @@ -3983,11 +3983,11 @@ __metadata: languageName: node linkType: hard -"@getlarge/kratos-cli@npm:0.2.1": - version: 0.2.1 - resolution: "@getlarge/kratos-cli@npm:0.2.1" +"@getlarge/kratos-cli@npm:0.3.0": + version: 0.3.0 + resolution: "@getlarge/kratos-cli@npm:0.3.0" dependencies: - "@getlarge/kratos-client-wrapper": 0.1.6 + "@getlarge/kratos-client-wrapper": 0.1.8 "@nestjs/common": ^10.0.2 "@nestjs/config": ^3.1.1 "@ory/client": ^1.4.9 @@ -3997,21 +3997,7 @@ __metadata: tslib: ^2.3.0 bin: kratos-cli: src/index.js - checksum: cb900d1db4ea28adc2a403ab606b230a2b46ee475923bcf1038e95325e5c7969d6f5e91947509518e39b611c62d336f53de8052ed95391311bb482fa882cf3d7 - languageName: node - linkType: hard - -"@getlarge/kratos-client-wrapper@npm:0.1.6": - version: 0.1.6 - resolution: "@getlarge/kratos-client-wrapper@npm:0.1.6" - dependencies: - tslib: ^2.3.0 - peerDependencies: - "@nestjs/axios": ^3.0.1 - "@nestjs/common": ^10.0.2 - "@ory/client": ^1.4.9 - axios: 1.6.5 - checksum: 27da844b0550660c4c15d54122bbf980620b0bc3c8da0546b9c232da630a5e840f54beb7178b2dabf286d38a0dbc9ff5fd155a48f763020e0ae86c9e479ab296 + checksum: b9c1e02203c25fdbb9f0dd07cae7552d9e22dc186951a8aa82fc837e17d0d5d5ccd9bf2267f2cf3a37996b320c5a4a90a21aa50f0bbfe80a021ea49adb856ab1 languageName: node linkType: hard @@ -23066,10 +23052,10 @@ __metadata: "@fastify/static": ^6.12.0 "@fastify/swagger": 8.10.0 "@getlarge/hydra-client-wrapper": 0.2.1 - "@getlarge/keto-cli": 0.2.2 + "@getlarge/keto-cli": 0.2.3 "@getlarge/keto-client-wrapper": 0.2.6 "@getlarge/keto-relations-parser": 0.0.9 - "@getlarge/kratos-cli": 0.2.1 + "@getlarge/kratos-cli": 0.3.0 "@getlarge/kratos-client-wrapper": 0.1.8 "@golevelup/ts-jest": ^0.4.0 "@jscutlery/semver": ^3.1.0 From c38650d65893eed62acc53c3e87af13d509d1db7 Mon Sep 17 00:00:00 2001 From: getlarge Date: Mon, 15 Apr 2024 20:13:36 +0200 Subject: [PATCH 18/23] feat(microservices-shared-filters): create message ack interceptor --- .../microservices/shared/filters/src/index.ts | 1 + .../filters/src/message-ack.interceptor.ts | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 libs/microservices/shared/filters/src/message-ack.interceptor.ts diff --git a/libs/microservices/shared/filters/src/index.ts b/libs/microservices/shared/filters/src/index.ts index 0b3484e8..246ba5d3 100644 --- a/libs/microservices/shared/filters/src/index.ts +++ b/libs/microservices/shared/filters/src/index.ts @@ -1,3 +1,4 @@ export * from './error-response.dto'; export * from './global-error.filter'; export * from './http-error.filter'; +export * from './message-ack.interceptor'; diff --git a/libs/microservices/shared/filters/src/message-ack.interceptor.ts b/libs/microservices/shared/filters/src/message-ack.interceptor.ts new file mode 100644 index 00000000..57bc4249 --- /dev/null +++ b/libs/microservices/shared/filters/src/message-ack.interceptor.ts @@ -0,0 +1,37 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, + NestInterceptor, +} from '@nestjs/common'; +import { RmqContext } from '@nestjs/microservices'; +import { Channel, Message } from 'amqplib'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +@Injectable() +export class MessageAckInterceptor implements NestInterceptor { + readonly logger = new Logger(MessageAckInterceptor.name); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType() !== 'rpc') { + return next.handle(); + } + const ctx = context.switchToRpc().getContext(); + const channel = ctx.getChannelRef() as Channel; + const message = ctx.getMessage() as Message; + this.logger.debug(`received message on ${ctx.getPattern()}`); + return next.handle().pipe( + tap({ + next: () => { + channel.ack(message); + }, + error: () => { + // TODO: requeue when error is timeout or connection error + channel.nack(message, false, false); + }, + }), + ); + } +} From e6134f9f41e96c7eaacc26b72bd41680f5d45493 Mon Sep 17 00:00:00 2001 From: getlarge Date: Mon, 15 Apr 2024 20:14:00 +0200 Subject: [PATCH 19/23] fix(microservices-shared-filters): correclty handle ErrorResponse --- .../shared/filters/src/global-error.filter.ts | 22 +++++++++++++------ libs/shared/errors/src/custom-error.ts | 9 ++++++++ libs/shared/errors/src/error-response.ts | 14 ++++++++---- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/libs/microservices/shared/filters/src/global-error.filter.ts b/libs/microservices/shared/filters/src/global-error.filter.ts index 9b6b1d27..64673279 100644 --- a/libs/microservices/shared/filters/src/global-error.filter.ts +++ b/libs/microservices/shared/filters/src/global-error.filter.ts @@ -1,7 +1,11 @@ import { Catch, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { ArgumentsHost } from '@nestjs/common/interfaces'; import { RmqContext, RpcException } from '@nestjs/microservices'; -import { ErrorResponse, isCustomError } from '@ticketing/shared/errors'; +import { + ErrorResponse, + isCustomError, + isErrorResponse, +} from '@ticketing/shared/errors'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { Observable, throwError } from 'rxjs'; @@ -55,13 +59,13 @@ export class GlobalErrorFilter { const status = this.getExceptionStatus(exception); const message = this.getExceptionMessage(exception); const details = this.getExceptionDetails(exception); - const errorResponse: ErrorResponse = { + const errorResponse = new ErrorResponse({ statusCode: status, path: request.url, errors: message, timestamp: new Date().toISOString(), details, - }; + }); this.logger.warn(errorResponse); if (response.sent) { return; @@ -77,19 +81,21 @@ export class GlobalErrorFilter { const status = this.getExceptionStatus(exception); const message = this.getExceptionMessage(exception); const details = this.getExceptionDetails(exception); - const errorResponse: ErrorResponse = { + const errorResponse = new ErrorResponse({ statusCode: status, path: pattern, errors: message, timestamp: new Date().toISOString(), details, - }; + }); this.logger.warn(errorResponse); return throwError(() => errorResponse); } + // TODO: handle OryError + getExceptionStatus(exception: T): HttpStatus { - if (isCustomError(exception)) { + if (isCustomError(exception) || isErrorResponse(exception)) { return exception.statusCode; } else if (isHttpException(exception)) { return exception.getStatus(); @@ -102,6 +108,8 @@ export class GlobalErrorFilter { getExceptionMessage(exception: T): { message: string; field?: string }[] { if (isCustomError(exception)) { return exception.serializeErrors(); + } else if (isErrorResponse(exception)) { + return exception.errors; } else if (isHttpException(exception)) { return [{ message: exception.message }]; } else if (isRpcException(exception)) { @@ -120,7 +128,7 @@ export class GlobalErrorFilter { return exception.getResponse(); } else if (isRpcException(exception)) { return exception.getError(); - } else if (hasDetailsProperty(exception)) { + } else if (isErrorResponse(exception) || hasDetailsProperty(exception)) { return exception.details; } return null; diff --git a/libs/shared/errors/src/custom-error.ts b/libs/shared/errors/src/custom-error.ts index 432407dd..f638038d 100644 --- a/libs/shared/errors/src/custom-error.ts +++ b/libs/shared/errors/src/custom-error.ts @@ -10,6 +10,15 @@ export abstract class CustomError extends Error { abstract getDetails(): Record; abstract serializeErrors(): { message: string; field?: string }[]; + + toJSON(): Record { + return { + name: this.name, + message: this.message, + details: this.getDetails(), + errors: this.serializeErrors(), + }; + } } export const isCustomError = (error: unknown): error is CustomError => diff --git a/libs/shared/errors/src/error-response.ts b/libs/shared/errors/src/error-response.ts index 21bb2a5f..5bfe6168 100644 --- a/libs/shared/errors/src/error-response.ts +++ b/libs/shared/errors/src/error-response.ts @@ -1,11 +1,9 @@ -import { plainToInstance } from 'class-transformer'; import { IsDateString, IsNotEmpty, IsNumber, IsOptional, IsString, - validateSync, } from 'class-validator'; type ErrorResponsePartial = Pick< @@ -40,5 +38,13 @@ export class ErrorResponse { } } -export const isErrorResponse = (error: unknown): error is ErrorResponse => - validateSync(plainToInstance(ErrorResponse, error)).length === 0; +export const isErrorResponse = (x: unknown): x is ErrorResponse => + typeof x === 'object' && + x != null && + 'statusCode' in x && + typeof x?.statusCode === 'number' && + 'errors' in x && + Array.isArray(x?.errors) && + 'path' in x && + typeof x?.path === 'string' && + 'timestamp' in x; From e5d9811b46d2574221526fcdf183b6808e1766df Mon Sep 17 00:00:00 2001 From: getlarge Date: Mon, 15 Apr 2024 20:20:00 +0200 Subject: [PATCH 20/23] refactor: integrate MessageAckInterceptor and GlobalErrorFilter in RMQ listeners --- .../src/app/orders/orders-ms.controller.ts | 52 ++++------------- .../src/app/orders/orders-ms.controller.ts | 47 ++++----------- .../src/app/tickets/tickets-ms.controller.ts | 56 +++++------------- apps/orders/src/app/tickets/tickets.module.ts | 11 +--- .../src/app/orders/orders-ms.controller.ts | 57 +++++-------------- apps/tickets/src/app/orders/orders.module.ts | 10 +--- 6 files changed, 50 insertions(+), 183 deletions(-) diff --git a/apps/expiration/src/app/orders/orders-ms.controller.ts b/apps/expiration/src/app/orders/orders-ms.controller.ts index 70bae654..cc6ee968 100644 --- a/apps/expiration/src/app/orders/orders-ms.controller.ts +++ b/apps/expiration/src/app/orders/orders-ms.controller.ts @@ -3,16 +3,11 @@ import { Inject, Logger, UseFilters, + UseInterceptors, ValidationPipe, ValidationPipeOptions, } from '@nestjs/common'; -import { - Ctx, - EventPattern, - Payload, - RmqContext, - Transport, -} from '@nestjs/microservices'; +import { EventPattern, Payload, Transport } from '@nestjs/microservices'; import { ApiExcludeEndpoint } from '@nestjs/swagger'; import { OrderCancelledEvent, @@ -20,10 +15,11 @@ import { OrderCreatedEvent, OrderCreatedEventData, } from '@ticketing/microservices/shared/events'; -import { GlobalErrorFilter } from '@ticketing/microservices/shared/filters'; +import { + GlobalErrorFilter, + MessageAckInterceptor, +} from '@ticketing/microservices/shared/filters'; import { requestValidationErrorFactory } from '@ticketing/shared/errors'; -import type { Channel } from 'amqp-connection-manager'; -import type { Message } from 'amqplib'; import { OrderService } from './orders.service'; @@ -36,6 +32,7 @@ const validationPipeOptions: ValidationPipeOptions = { }; @UseFilters(GlobalErrorFilter) +@UseInterceptors(MessageAckInterceptor) @Controller() export class OrdersMSController { readonly logger = new Logger(OrdersMSController.name); @@ -49,25 +46,11 @@ export class OrdersMSController { async onCreated( @Payload(new ValidationPipe(validationPipeOptions)) data: OrderCreatedEventData, - @Ctx() context: RmqContext, ): Promise<{ ok: boolean; }> { - const channel = context.getChannelRef() as Channel; - const message = context.getMessage() as Message; - const pattern = context.getPattern(); - this.logger.debug(`received message on ${pattern}`, { - data, - }); - - try { - await this.orderService.createJob(data); - channel.ack(message); - return { ok: true }; - } catch (e) { - channel.nack(message, false, false); - throw e; - } + await this.orderService.createJob(data); + return { ok: true }; } @ApiExcludeEndpoint() @@ -75,23 +58,10 @@ export class OrdersMSController { async onCancelled( @Payload(new ValidationPipe(validationPipeOptions)) data: OrderCancelledEventData, - @Ctx() context: RmqContext, ): Promise<{ ok: boolean; }> { - const channel = context.getChannelRef() as Channel; - const message = context.getMessage() as Message; - const pattern = context.getPattern(); - this.logger.debug(`received message on ${pattern}`, { - data, - }); - try { - await this.orderService.cancelJob(data); - channel.ack(message); - return { ok: true }; - } catch (e) { - channel.nack(message, false, false); - throw e; - } + await this.orderService.cancelJob(data); + return { ok: true }; } } diff --git a/apps/orders/src/app/orders/orders-ms.controller.ts b/apps/orders/src/app/orders/orders-ms.controller.ts index 9d22f902..d5a5df65 100644 --- a/apps/orders/src/app/orders/orders-ms.controller.ts +++ b/apps/orders/src/app/orders/orders-ms.controller.ts @@ -2,15 +2,15 @@ import { Controller, Inject, Logger, + UseFilters, + UseInterceptors, ValidationPipe, ValidationPipeOptions, } from '@nestjs/common'; import { - Ctx, EventPattern, MessagePattern, Payload, - RmqContext, Transport, } from '@nestjs/microservices'; import { ApiExcludeEndpoint } from '@nestjs/swagger'; @@ -20,9 +20,11 @@ import { PaymentCreatedEvent, PaymentCreatedEventData, } from '@ticketing/microservices/shared/events'; +import { + GlobalErrorFilter, + MessageAckInterceptor, +} from '@ticketing/microservices/shared/filters'; import { requestValidationErrorFactory } from '@ticketing/shared/errors'; -import type { Channel } from 'amqp-connection-manager'; -import type { Message } from 'amqplib'; import { OrderDto } from './models'; import { OrdersService } from './orders.service'; @@ -34,6 +36,8 @@ const validationPipeOptions: ValidationPipeOptions = { forbidUnknownValues: true, }; +@UseInterceptors(MessageAckInterceptor) +@UseFilters(GlobalErrorFilter) @Controller() export class OrdersMSController { readonly logger = new Logger(OrdersMSController.name); @@ -46,45 +50,16 @@ export class OrdersMSController { @EventPattern(ExpirationCompletedEvent.name, Transport.RMQ) async onExpiration( @Payload() data: ExpirationCompletedEventData, - @Ctx() context: RmqContext, ): Promise { - const channel = context.getChannelRef() as Channel; - const message = context.getMessage() as Message; - const pattern = context.getPattern(); - this.logger.debug(`received message on ${pattern}`, { - data, - }); - try { - await this.ordersService.expireById(data.id); - channel.ack(message); - } catch (e) { - // TODO: requeue when error is timeout or connection error - channel.nack(message, false, false); - throw e; - } + await this.ordersService.expireById(data.id); } @ApiExcludeEndpoint() @MessagePattern(PaymentCreatedEvent.name, Transport.RMQ) - async onPaymentCreated( + onPaymentCreated( @Payload(new ValidationPipe(validationPipeOptions)) data: PaymentCreatedEventData, - @Ctx() context: RmqContext, ): Promise { - const channel = context.getChannelRef() as Channel; - const message = context.getMessage() as Message; - const pattern = context.getPattern(); - this.logger.debug(`received message on ${pattern}`, { - data, - }); - try { - const order = await this.ordersService.complete(data); - channel.ack(message); - return order; - } catch (e) { - // TODO: requeue when error is timeout or connection error - channel.nack(message, false, false); - throw e; - } + return this.ordersService.complete(data); } } diff --git a/apps/orders/src/app/tickets/tickets-ms.controller.ts b/apps/orders/src/app/tickets/tickets-ms.controller.ts index b8b469f4..65192dc6 100644 --- a/apps/orders/src/app/tickets/tickets-ms.controller.ts +++ b/apps/orders/src/app/tickets/tickets-ms.controller.ts @@ -2,25 +2,23 @@ import { Controller, Inject, Logger, + UseFilters, + UseInterceptors, ValidationPipe, ValidationPipeOptions, } from '@nestjs/common'; -import { - Ctx, - MessagePattern, - Payload, - RmqContext, - Transport, -} from '@nestjs/microservices'; +import { MessagePattern, Payload, Transport } from '@nestjs/microservices'; import { ApiExcludeEndpoint } from '@nestjs/swagger'; import { TicketCreatedEvent, TicketEventData, TicketUpdatedEvent, } from '@ticketing/microservices/shared/events'; +import { + GlobalErrorFilter, + MessageAckInterceptor, +} from '@ticketing/microservices/shared/filters'; import { requestValidationErrorFactory } from '@ticketing/shared/errors'; -import type { Channel } from 'amqp-connection-manager'; -import type { Message } from 'amqplib'; import { TicketDto } from './models'; import { TicketsService } from './tickets.service'; @@ -33,6 +31,8 @@ const validationPipeOptions: ValidationPipeOptions = { whitelist: true, }; +@UseInterceptors(MessageAckInterceptor) +@UseFilters(GlobalErrorFilter) @Controller() export class TicketsMSController { readonly logger = new Logger(TicketsMSController.name); @@ -43,49 +43,19 @@ export class TicketsMSController { @ApiExcludeEndpoint() @MessagePattern(TicketCreatedEvent.name, Transport.RMQ) - async onCreated( + onCreated( @Payload(new ValidationPipe(validationPipeOptions)) data: TicketEventData, - @Ctx() context: RmqContext, ): Promise { - const channel = context.getChannelRef() as Channel; - const message = context.getMessage() as Message; - const pattern = context.getPattern(); - this.logger.debug(`received message on ${pattern}`, { - data, - }); - try { - const ticket = await this.ticketsService.create(data); - channel.ack(message); - return ticket; - } catch (e) { - // TODO: requeue when error is timeout or connection error - channel.nack(message, false, false); - throw e; - } + return this.ticketsService.create(data); } @ApiExcludeEndpoint() @MessagePattern(TicketUpdatedEvent.name, Transport.RMQ) - async onUpdated( + onUpdated( @Payload(new ValidationPipe(validationPipeOptions)) data: TicketEventData, - @Ctx() context: RmqContext, ): Promise { - const channel = context.getChannelRef() as Channel; - const message = context.getMessage() as Message; - const pattern = context.getPattern(); - this.logger.debug(`received message on ${pattern}`, { - data, - }); - try { - const ticket = await this.ticketsService.updateById(data.id, data); - channel.ack(message); - return ticket; - } catch (e) { - // TODO: requeue when error is timeout or connection error - channel.nack(message, false, false); - throw e; - } + return this.ticketsService.updateById(data.id, data); } } diff --git a/apps/orders/src/app/tickets/tickets.module.ts b/apps/orders/src/app/tickets/tickets.module.ts index 79891c85..4d729b7c 100644 --- a/apps/orders/src/app/tickets/tickets.module.ts +++ b/apps/orders/src/app/tickets/tickets.module.ts @@ -1,6 +1,4 @@ import { Module } from '@nestjs/common'; -import { APP_FILTER } from '@nestjs/core'; -import { GlobalErrorFilter } from '@ticketing/microservices/shared/filters'; import { MongooseFeatures } from '../shared/mongoose.module'; import { TicketsService } from './tickets.service'; @@ -9,14 +7,7 @@ import { TicketsMSController } from './tickets-ms.controller'; @Module({ imports: [MongooseFeatures], controllers: [TicketsMSController], - providers: [ - TicketsService, - { - provide: APP_FILTER, - useExisting: GlobalErrorFilter, - }, - GlobalErrorFilter, - ], + providers: [TicketsService], exports: [MongooseFeatures, TicketsService], }) export class TicketsModule {} diff --git a/apps/tickets/src/app/orders/orders-ms.controller.ts b/apps/tickets/src/app/orders/orders-ms.controller.ts index a8007524..9d88d1ed 100644 --- a/apps/tickets/src/app/orders/orders-ms.controller.ts +++ b/apps/tickets/src/app/orders/orders-ms.controller.ts @@ -2,16 +2,12 @@ import { Controller, Inject, Logger, + UseFilters, + UseInterceptors, ValidationPipe, ValidationPipeOptions, } from '@nestjs/common'; -import { - Ctx, - MessagePattern, - Payload, - RmqContext, - Transport, -} from '@nestjs/microservices'; +import { MessagePattern, Payload, Transport } from '@nestjs/microservices'; import { ApiExcludeEndpoint } from '@nestjs/swagger'; import { OrderCancelledEvent, @@ -19,9 +15,11 @@ import { OrderCreatedEvent, OrderCreatedEventData, } from '@ticketing/microservices/shared/events'; +import { + GlobalErrorFilter, + MessageAckInterceptor, +} from '@ticketing/microservices/shared/filters'; import { requestValidationErrorFactory } from '@ticketing/shared/errors'; -import type { Channel } from 'amqp-connection-manager'; -import type { Message } from 'amqplib'; import { TicketDto } from '../tickets/models'; import { TicketsService } from '../tickets/tickets.service'; @@ -33,7 +31,8 @@ const validationPipeOptions: ValidationPipeOptions = { forbidUnknownValues: true, whitelist: true, }; - +@UseInterceptors(MessageAckInterceptor) +@UseFilters(GlobalErrorFilter) @Controller() export class OrdersMSController { readonly logger = new Logger(OrdersMSController.name); @@ -44,49 +43,19 @@ export class OrdersMSController { @ApiExcludeEndpoint() @MessagePattern(OrderCreatedEvent.name, Transport.RMQ) - async onCreated( + onCreated( @Payload(new ValidationPipe(validationPipeOptions)) data: OrderCreatedEventData, - @Ctx() context: RmqContext, ): Promise { - const channel = context.getChannelRef() as Channel; - const message = context.getMessage() as Message; - const pattern = context.getPattern(); - this.logger.debug(`received message on ${pattern}`, { - data, - }); - try { - const ticket = await this.ticketsService.createOrder(data); - channel.ack(message); - return ticket; - } catch (e) { - // TODO: requeue when error is timeout or connection error - channel.nack(message, false, false); - throw e; - } + return this.ticketsService.createOrder(data); } @ApiExcludeEndpoint() @MessagePattern(OrderCancelledEvent.name, Transport.RMQ) - async onCancelled( + onCancelled( @Payload(new ValidationPipe(validationPipeOptions)) data: OrderCancelledEventData, - @Ctx() context: RmqContext, ): Promise { - const channel = context.getChannelRef() as Channel; - const message = context.getMessage() as Message; - const pattern = context.getPattern(); - this.logger.debug(`received message on ${pattern}`, { - data, - }); - try { - const ticket = await this.ticketsService.cancelOrder(data); - channel.ack(message); - return ticket; - } catch (e) { - // TODO: requeue when error is timeout or connection error - channel.nack(message, false, false); - throw e; - } + return this.ticketsService.cancelOrder(data); } } diff --git a/apps/tickets/src/app/orders/orders.module.ts b/apps/tickets/src/app/orders/orders.module.ts index 6eb48d63..de5931d6 100644 --- a/apps/tickets/src/app/orders/orders.module.ts +++ b/apps/tickets/src/app/orders/orders.module.ts @@ -1,6 +1,4 @@ import { Module } from '@nestjs/common'; -import { APP_FILTER } from '@nestjs/core'; -import { GlobalErrorFilter } from '@ticketing/microservices/shared/filters'; import { TicketsModule } from '../tickets/tickets.module'; import { OrdersMSController } from './orders-ms.controller'; @@ -8,12 +6,6 @@ import { OrdersMSController } from './orders-ms.controller'; @Module({ imports: [TicketsModule], controllers: [OrdersMSController], - providers: [ - { - provide: APP_FILTER, - useExisting: GlobalErrorFilter, - }, - GlobalErrorFilter, - ], + providers: [], }) export class OrdersModule {} From 04c5d045e9d709d53369a328037a97ee128ec7f2 Mon Sep 17 00:00:00 2001 From: getlarge Date: Mon, 15 Apr 2024 20:20:51 +0200 Subject: [PATCH 21/23] fix(tickets): correctly handle ticket creation failures --- .../src/app/tickets/tickets.service.ts | 67 +++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/apps/tickets/src/app/tickets/tickets.service.ts b/apps/tickets/src/app/tickets/tickets.service.ts index bbcc08d1..09ea52fd 100644 --- a/apps/tickets/src/app/tickets/tickets.service.ts +++ b/apps/tickets/src/app/tickets/tickets.service.ts @@ -12,6 +12,7 @@ import { } from '@nestjs/common'; import { ClientProxy } from '@nestjs/microservices'; import { InjectModel } from '@nestjs/mongoose'; +import { Relationship } from '@ory/client'; import { EventsMap, OrderCancelledEvent, @@ -54,38 +55,48 @@ export class TicketsService { } async create(ticket: CreateTicket, currentUser: User): Promise { - await using manager = await transactionManager(this.ticketModel); - const res = await manager.wrap(async (session) => { - const doc: CreateTicket & { userId: string } = { - ...ticket, - userId: currentUser.id, - }; - const docs = await this.ticketModel.create([doc], { - session, - }); - const newTicket = docs[0].toJSON(); - this.logger.debug(`Created ticket ${newTicket.id}`); + let createdRelation: Relationship | undefined; + try { + await using manager = await transactionManager(this.ticketModel); + const res = await manager.wrap(async (session) => { + const doc: CreateTicket & { userId: string } = { + ...ticket, + userId: currentUser.id, + }; + const docs = await this.ticketModel.create([doc], { + session, + }); + const newTicket = docs[0].toJSON(); + this.logger.debug(`Created ticket ${newTicket.id}`); + + const relationTuple = relationTupleBuilder() + .subject(PermissionNamespaces[Resources.USERS], currentUser.id) + .isIn('owners') + .of(PermissionNamespaces[Resources.TICKETS], newTicket.id); + const createRelationshipBody = createRelationQuery( + relationTuple.toJSON(), + ).unwrapOrThrow(); + const { data } = await this.oryRelationshipsService.createRelationship({ + createRelationshipBody, + }); + createdRelation = data; + this.logger.debug(`Created relation ${relationTuple.toString()}`); - const relationTuple = relationTupleBuilder() - .subject(PermissionNamespaces[Resources.USERS], currentUser.id) - .isIn('owners') - .of(PermissionNamespaces[Resources.TICKETS], newTicket.id) - .toJSON(); - const createRelationshipBody = - createRelationQuery(relationTuple).unwrapOrThrow(); - await this.oryRelationshipsService.createRelationship({ - createRelationshipBody, + await lastValueFrom(this.sendEvent(Patterns.TicketCreated, newTicket)); + this.logger.debug(`Sent event ${Patterns.TicketCreated}`); + return newTicket; }); - this.logger.debug(`Created relation ${relationTuple.toString()}`); - await lastValueFrom(this.sendEvent(Patterns.TicketCreated, newTicket)); - this.logger.debug(`Sent event ${Patterns.TicketCreated}`); - return newTicket; - }); - if (res.error) { - throw res.error; + if (res.error) { + throw res.error; + } + return res.value; + } catch (error) { + if (createdRelation) { + await this.oryRelationshipsService.deleteRelationships(createdRelation); + } + throw error; } - return res.value; } paginate(params: PaginateDto = {}): Promise<{ From 9b53ffaedd474ced6d5acbe77020fd1896758f62 Mon Sep 17 00:00:00 2001 From: getlarge Date: Mon, 15 Apr 2024 20:46:15 +0200 Subject: [PATCH 22/23] test: remove obsolete assertions --- .../app/tickets/tickets-ms.controller.spec.ts | 36 ++++------------ .../app/orders/orders-ms.controller.spec.ts | 42 +++++-------------- 2 files changed, 18 insertions(+), 60 deletions(-) diff --git a/apps/orders/src/app/tickets/tickets-ms.controller.spec.ts b/apps/orders/src/app/tickets/tickets-ms.controller.spec.ts index a0f3d97a..804862ee 100644 --- a/apps/orders/src/app/tickets/tickets-ms.controller.spec.ts +++ b/apps/orders/src/app/tickets/tickets-ms.controller.spec.ts @@ -4,8 +4,6 @@ import { createMock } from '@golevelup/ts-jest'; import { jest } from '@jest/globals'; import { Test, TestingModule } from '@nestjs/testing'; -import { createRmqContext } from '@ticketing/microservices/shared/testing'; -import { Channel } from 'amqp-connection-manager'; import { Model } from 'mongoose'; import { mockTicketEvent } from '../../../test/models/ticket.mock'; @@ -32,22 +30,18 @@ describe('TicketsMSController', () => { it('should call "TicketsService.create" and in case of success ack RMQ message', async () => { // ticket coming from tickets-service const ticket = mockTicketEvent(); - const context = createRmqContext(); const ticketsController = app.get(TicketsMSController); const ticketsService = app.get(TicketsService); ticketsService.create = jest.fn(() => Promise.resolve(ticket)); - context.getChannelRef().ack = jest.fn(); // - await ticketsController.onCreated(ticket, context); + await ticketsController.onCreated(ticket); expect(ticketsService.create).toBeCalledWith(ticket); - expect(context.getChannelRef().ack).toBeCalled(); }); it('should call "TicketsService.create" and in case of error NOT ack RMQ message', async () => { // ticket coming from tickets-service const ticket = mockTicketEvent(); - const context = createRmqContext(); const expectedError = new Error('Cannot create ticket'); const ticketsController = app.get(TicketsMSController); @@ -57,16 +51,11 @@ describe('TicketsMSController', () => { throw expectedError; }) .mockRejectedValueOnce(expectedError); - const channel = context.getChannelRef() as Channel; - channel.ack = jest.fn(); - channel.nack = jest.fn(); // - await expect( - ticketsController.onCreated(ticket, context), - ).rejects.toThrowError(expectedError); + await expect(ticketsController.onCreated(ticket)).rejects.toThrowError( + expectedError, + ); expect(ticketsService.create).toBeCalledWith(ticket); - expect(channel.ack).not.toBeCalled(); - expect(channel.nack).toBeCalled(); }); }); @@ -74,22 +63,18 @@ describe('TicketsMSController', () => { it('should call "TicketsService.updatedById" and in case of success, ack RMQ message', async () => { // ticket coming from tickets-service const ticket = mockTicketEvent(); - const context = createRmqContext(); const ticketsController = app.get(TicketsMSController); const ticketsService = app.get(TicketsService); ticketsService.updateById = jest.fn(() => Promise.resolve(ticket)); - context.getChannelRef().ack = jest.fn(); // - await ticketsController.onUpdated(ticket, context); + await ticketsController.onUpdated(ticket); expect(ticketsService.updateById).toBeCalledWith(ticket.id, ticket); - expect(context.getChannelRef().ack).toBeCalled(); }); it('should call "TicketsService.updatedById" and in case of error, NOT ack RMQ message', async () => { // ticket coming from tickets-service const ticket = mockTicketEvent(); - const context = createRmqContext(); const expectedError = new Error('Cannot create ticket'); const ticketsController = app.get(TicketsMSController); @@ -99,16 +84,11 @@ describe('TicketsMSController', () => { throw expectedError; }) .mockRejectedValueOnce(expectedError); - const channel = context.getChannelRef() as Channel; - channel.ack = jest.fn(); - channel.nack = jest.fn(); // - await expect( - ticketsController.onUpdated(ticket, context), - ).rejects.toThrowError(expectedError); + await expect(ticketsController.onUpdated(ticket)).rejects.toThrowError( + expectedError, + ); expect(ticketsService.updateById).toBeCalledWith(ticket.id, ticket); - expect(channel.ack).not.toBeCalled(); - expect(channel.nack).toBeCalled(); }); }); }); diff --git a/apps/tickets/src/app/orders/orders-ms.controller.spec.ts b/apps/tickets/src/app/orders/orders-ms.controller.spec.ts index 17e73fbd..d6bbe868 100644 --- a/apps/tickets/src/app/orders/orders-ms.controller.spec.ts +++ b/apps/tickets/src/app/orders/orders-ms.controller.spec.ts @@ -5,11 +5,7 @@ import { createMock } from '@golevelup/ts-jest'; import { jest } from '@jest/globals'; import { ClientProxy } from '@nestjs/microservices'; import { Test, TestingModule } from '@nestjs/testing'; -import { - createRmqContext, - MockOryRelationshipsService, -} from '@ticketing/microservices/shared/testing'; -import { Channel } from 'amqp-connection-manager'; +import { MockOryRelationshipsService } from '@ticketing/microservices/shared/testing'; import { Model } from 'mongoose'; import { mockOrderEvent } from '../../../test/models/order.mock'; @@ -42,21 +38,17 @@ describe('OrdersMSController', () => { // ticket coming from tickets-service const order = mockOrderEvent(); const ticket = mockTicket(order.ticket); - const context = createRmqContext(); const ordersMSController = app.get(OrdersMSController); const ticketsService = app.get(TicketsService); ticketsService.createOrder = jest.fn(() => Promise.resolve(ticket)); - context.getChannelRef().ack = jest.fn(); // - await ordersMSController.onCreated(order, context); + await ordersMSController.onCreated(order); expect(ticketsService.createOrder).toBeCalledWith(order); - expect(context.getChannelRef().ack).toBeCalled(); }); it('should call "TicketsService.createOrder" and in case of error NOT ack RMQ message', async () => { // ticket coming from tickets-service const order = mockOrderEvent(); - const context = createRmqContext(); const expectedError = new Error('Cannot create ticket'); const ordersMSController = app.get(OrdersMSController); const ticketsService = app.get(TicketsService); @@ -65,40 +57,31 @@ describe('OrdersMSController', () => { throw expectedError; }) .mockRejectedValueOnce(expectedError); - const channel = context.getChannelRef() as Channel; - channel.ack = jest.fn(); - channel.nack = jest.fn(); // - await expect( - ordersMSController.onCreated(order, context), - ).rejects.toThrowError(expectedError); + await expect(ordersMSController.onCreated(order)).rejects.toThrowError( + expectedError, + ); expect(ticketsService.createOrder).toBeCalledWith(order); - expect(channel.ack).not.toBeCalled(); - expect(channel.nack).toBeCalled(); }); }); describe('onCancelled()', () => { - it('should call "TicketsService.cancelOrder" and in case of success, ack RMQ message', async () => { + it('should call "TicketsService.cancelOrder"', async () => { // ticket coming from tickets-service const order = mockOrderEvent(); - const context = createRmqContext(); const ordersMSController = app.get(OrdersMSController); const ticketsService = app.get(TicketsService); ticketsService.cancelOrder = jest.fn(() => Promise.resolve(mockTicket(order.ticket)), ); - context.getChannelRef().ack = jest.fn(); // - await ordersMSController.onCancelled(order, context); + await ordersMSController.onCancelled(order); expect(ticketsService.cancelOrder).toBeCalledWith(order); - expect(context.getChannelRef().ack).toBeCalled(); }); it('should call "TicketsService.cancelOrder" and in case of error, NOT ack RMQ message', async () => { // ticket coming from tickets-service const order = mockOrderEvent(); - const context = createRmqContext(); const expectedError = new Error('Cannot create ticket'); const ordersMSController = app.get(OrdersMSController); const ticketsService = app.get(TicketsService); @@ -107,16 +90,11 @@ describe('OrdersMSController', () => { throw expectedError; }) .mockRejectedValueOnce(expectedError); - const channel = context.getChannelRef() as Channel; - channel.ack = jest.fn(); - channel.nack = jest.fn(); // - await expect( - ordersMSController.onCancelled(order, context), - ).rejects.toThrowError(expectedError); + await expect(ordersMSController.onCancelled(order)).rejects.toThrowError( + expectedError, + ); expect(ticketsService.cancelOrder).toBeCalledWith(order); - expect(channel.ack).not.toBeCalled(); - expect(channel.nack).toBeCalled(); }); }); }); From aa72877c55bae216d361e678f1a13926e59699ff Mon Sep 17 00:00:00 2001 From: getlarge Date: Mon, 15 Apr 2024 20:59:34 +0200 Subject: [PATCH 23/23] test: remove obsolete assertions --- .../app/orders/orders-ms.controller.spec.ts | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/apps/orders/src/app/orders/orders-ms.controller.spec.ts b/apps/orders/src/app/orders/orders-ms.controller.spec.ts index dc3a22d6..ce081f37 100644 --- a/apps/orders/src/app/orders/orders-ms.controller.spec.ts +++ b/apps/orders/src/app/orders/orders-ms.controller.spec.ts @@ -8,11 +8,9 @@ import { ConfigService } from '@nestjs/config'; import { ClientProxy } from '@nestjs/microservices'; import { Test, TestingModule } from '@nestjs/testing'; import { - createRmqContext, MockOryPermissionsService, MockOryRelationshipsService, } from '@ticketing/microservices/shared/testing'; -import { Channel } from 'amqp-connection-manager'; import { Model, Types } from 'mongoose'; import { TicketDocument } from '../tickets/schemas'; @@ -53,21 +51,17 @@ describe('OrdersMSController', () => { it('should call "OrdersService.expireById" and in case of success ack RMQ message', async () => { // order coming from expiration-service const order = { id: new Types.ObjectId().toHexString() } as Order; - const context = createRmqContext(); const ordersController = app.get(OrdersMSController); const ordersService = app.get(OrdersService); ordersService.expireById = jest.fn(() => Promise.resolve(order)); - context.getChannelRef().ack = jest.fn(); // - await ordersController.onExpiration(order, context); + await ordersController.onExpiration(order); expect(ordersService.expireById).toBeCalledWith(order.id); - expect(context.getChannelRef().ack).toBeCalled(); }); it('should call "OrdersService.expireById" and in case of error NOT ack RMQ message', async () => { // order coming from expiration-service const order = { id: new Types.ObjectId().toHexString() }; - const context = createRmqContext(); const expectedError = new Error('Cannot find order'); const ordersController = app.get(OrdersMSController); const ordersService = app.get(OrdersService); @@ -76,17 +70,11 @@ describe('OrdersMSController', () => { throw expectedError; }) .mockRejectedValueOnce(expectedError); - context.getChannelRef().ack = jest.fn(); - const channel = context.getChannelRef() as Channel; - channel.ack = jest.fn(); - channel.nack = jest.fn(); // - await expect( - ordersController.onExpiration(order, context), - ).rejects.toThrowError(expectedError); + await expect(ordersController.onExpiration(order)).rejects.toThrowError( + expectedError, + ); expect(ordersService.expireById).toBeCalledWith(order.id); - expect(channel.ack).not.toBeCalled(); - expect(channel.nack).toBeCalled(); }); }); });