From 17b3d2fa2fb98c66418eb17cf9c846de601e53fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= Date: Mon, 15 Jan 2024 10:52:55 +0100 Subject: [PATCH] impl --- .../src/infra/oauth-provider/dto/index.ts | 10 - .../dto/interface/oauth-client.interface.ts | 95 --- .../dto/interface/oidc-context.interface.ts | 11 - .../dto/response/consent.response.ts | 30 - .../dto/response/introspect.response.ts | 29 - .../dto/response/login.response.ts | 22 - .../dto/response/redirect.response.ts | 3 - .../hydra/hydra.adapter.spec.ts | 709 ---------------- .../oauth-provider/hydra/hydra.adapter.ts | 188 ----- apps/server/src/infra/oauth-provider/index.ts | 2 - .../infra/oauth-provider/loggable/index.ts | 1 - .../oauth-provider-service.module.ts | 11 - .../oauth-provider/oauth-provider.service.ts | 50 -- apps/server/src/infra/redis/index.ts | 2 - .../infra/redis/interface/redis.constants.ts | 1 - apps/server/src/infra/redis/redis.module.ts | 32 - .../controller/api-test/group.api.spec.ts | 20 +- ....enum.ts => class-sort-query-type.enum.ts} | 2 +- .../group/controller/dto/interface/index.ts | 4 +- .../dto/request/class-sort-params.ts | 10 +- .../group/controller/group.controller.ts | 6 +- .../mapper/group-response.mapper.ts | 54 +- .../modules/oauth-provider/api/dto/index.ts | 17 + .../dto/request/accept.query.ts | 4 +- .../dto/request/challenge.params.ts | 2 +- .../dto/request/consent-request.body.ts | 2 +- .../dto/request/id.params.ts | 2 +- .../dto/request/list-oauth-clients.params.ts | 2 +- .../dto/request/login-request.body.ts | 2 +- .../dto/request/oauth-client-create.body.ts} | 51 +- .../dto/request/oauth-client-update.body.ts | 74 ++ .../dto/request/oauth-rejectable.body.ts | 2 +- .../dto/request/revoke-consent.params.ts | 2 +- .../dto/request/user.params.ts | 2 +- .../dto/response/consent-session.response.ts | 18 + .../dto/response/consent.response.ts | 65 +- .../dto/response/login.response.ts | 46 +- .../api/dto/response/oauth-client.response.ts | 209 +++++ .../api/dto/response/oidc-context.response.ts | 28 + .../dto/response/redirect.response.ts | 0 .../src/modules/oauth-provider/api/index.ts | 6 + .../oauth-provider/api/mapper/index.ts | 2 + .../mapper/oauth-provider-request.mapper.ts | 6 +- .../mapper/oauth-provider-response.mapper.ts | 51 ++ .../api/oauth-provider.client-crud.uc.spec.ts | 376 +++++++++ .../oauth-provider.client-crud.uc.ts | 51 +- .../api/oauth-provider.consent-flow.uc.ts | 87 ++ .../oauth-provider.controller.ts | 128 +-- .../oauth-provider.login-flow.uc.spec.ts | 125 +-- .../oauth-provider.login-flow.uc.ts | 24 +- .../api/oauth-provider.logout-flow.uc.spec.ts | 63 ++ .../api/oauth-provider.logout-flow.uc.ts | 14 + .../api/oauth-provider.uc.spec.ts | 87 ++ .../oauth-provider/api/oauth-provider.uc.ts | 19 + .../oauth-provider.controller.api.spec.ts | 613 ++++++++++++++ .../oauth-provider/controller/dto/index.ts | 16 - .../dto/response/consent-session.response.ts | 20 - .../dto/response/oauth-client.response.ts | 226 ----- .../dto/response/oidc-context.response.ts | 20 - .../oauth-provider.controller.spec.ts | 401 --------- ...ra-oauth-failed-loggable-exception.spec.ts | 0 .../hydra-oauth-failed-loggable-exception.ts | 2 +- ...-token-creation-exception.loggable.spec.ts | 0 .../id-token-creation-exception.loggable.ts | 0 .../oauth-provider/domain/error/index.ts | 2 + .../modules/oauth-provider/domain/index.ts | 18 + .../{ => domain}/interface/id-token.ts | 6 + .../oauth-provider/domain/interface/index.ts | 14 + .../interface/oauth-client.interface.ts | 98 +++ .../interface/oauth-scope.enum.ts | 0 .../interface/oidc-context.interface.ts | 14 + .../request/accept-consent-request.body.ts | 5 +- .../request/accept-login-request.body.ts | 3 + .../interface}/request/reject-request.body.ts | 3 + .../response/consent-session.response.ts | 15 +- .../interface/response/consent.response.ts | 33 + .../interface/response/introspect.response.ts | 32 + .../interface/response/login.response.ts | 25 + .../interface/response/redirect.response.ts | 6 + .../interface/subject-type.enum.ts | 0 .../interface/token-auth-method.enum.ts | 0 .../domain/service/hydra.adapter.spec.ts | 782 ++++++++++++++++++ .../domain/service/hydra.adapter.ts | 238 ++++++ .../service/id-token.service.spec.ts | 12 +- .../{ => domain}/service/id-token.service.ts | 2 +- .../oauth-provider.login-flow.service.spec.ts | 8 +- .../oauth-provider.login-flow.service.ts | 6 +- .../domain/service/oauth-provider.service.ts | 56 ++ .../src/modules/oauth-provider/index.ts | 5 +- .../modules/oauth-provider/interface/index.ts | 4 - .../oauth-provider-request.mapper.spec.ts | 29 - .../oauth-provider-response.mapper.spec.ts | 161 ---- .../mapper/oauth-provider-response.mapper.ts | 43 - .../oauth-provider-api.module.ts | 16 +- .../oauth-provider-config.module.ts | 13 + .../oauth-provider/oauth-provider-config.ts | 13 + .../oauth-provider-service.module.ts | 17 + .../oauth-provider/oauth-provider.module.ts | 16 +- .../accept-consent-request-body.factory.ts | 17 + .../accept-login-request-body.factory.ts | 14 + .../testing/id-token.factory.ts | 9 + .../modules/oauth-provider/testing/index.ts | 10 + .../testing/introspect-response.factory.ts | 21 + .../provider-consent-response.factory.ts | 22 + ...ovider-consent-session-response.factory.ts | 18 + .../provider-login-response.factory.ts | 18 + .../testing/provider-oauth-client.factory.ts | 54 ++ .../testing/provider-oidc-context.factory.ts | 12 + .../testing/reject-request-body.factory.ts | 12 + .../src/modules/oauth-provider/uc/index.ts | 5 - .../uc/oauth-provider.client-crud.uc.spec.ts | 236 ------ .../uc/oauth-provider.consent-flow.uc.spec.ts | 166 ---- .../uc/oauth-provider.consent-flow.uc.ts | 85 -- .../uc/oauth-provider.logout-flow.uc.spec.ts | 37 - .../uc/oauth-provider.logout-flow.uc.ts | 13 - .../uc/oauth-provider.uc.spec.ts | 54 -- .../oauth-provider/uc/oauth-provider.uc.ts | 19 - .../src/modules/server/server.module.ts | 77 +- .../src/modules/tldraw/tldraw-test.module.ts | 14 +- .../external-tool/external-tool.module.ts | 12 +- .../external-tool-service.mapper.spec.ts | 8 +- .../service/external-tool-service.mapper.ts | 4 +- .../service/external-tool.service.spec.ts | 25 +- .../service/external-tool.service.ts | 10 +- .../src/modules/user-login-migration/index.ts | 10 +- config/default.json | 7 - config/default.schema.json | 65 -- config/development.json | 7 - config/test.json | 7 - 129 files changed, 3638 insertions(+), 3282 deletions(-) delete mode 100644 apps/server/src/infra/oauth-provider/dto/index.ts delete mode 100644 apps/server/src/infra/oauth-provider/dto/interface/oauth-client.interface.ts delete mode 100644 apps/server/src/infra/oauth-provider/dto/interface/oidc-context.interface.ts delete mode 100644 apps/server/src/infra/oauth-provider/dto/response/consent.response.ts delete mode 100644 apps/server/src/infra/oauth-provider/dto/response/introspect.response.ts delete mode 100644 apps/server/src/infra/oauth-provider/dto/response/login.response.ts delete mode 100644 apps/server/src/infra/oauth-provider/dto/response/redirect.response.ts delete mode 100644 apps/server/src/infra/oauth-provider/hydra/hydra.adapter.spec.ts delete mode 100644 apps/server/src/infra/oauth-provider/hydra/hydra.adapter.ts delete mode 100644 apps/server/src/infra/oauth-provider/index.ts delete mode 100644 apps/server/src/infra/oauth-provider/loggable/index.ts delete mode 100644 apps/server/src/infra/oauth-provider/oauth-provider-service.module.ts delete mode 100644 apps/server/src/infra/oauth-provider/oauth-provider.service.ts delete mode 100644 apps/server/src/infra/redis/index.ts delete mode 100644 apps/server/src/infra/redis/interface/redis.constants.ts delete mode 100644 apps/server/src/infra/redis/redis.module.ts rename apps/server/src/modules/group/controller/dto/interface/{class-sort-by.enum.ts => class-sort-query-type.enum.ts} (65%) create mode 100644 apps/server/src/modules/oauth-provider/api/dto/index.ts rename apps/server/src/modules/oauth-provider/{controller => api}/dto/request/accept.query.ts (82%) rename apps/server/src/modules/oauth-provider/{controller => api}/dto/request/challenge.params.ts (100%) rename apps/server/src/modules/oauth-provider/{controller => api}/dto/request/consent-request.body.ts (100%) rename apps/server/src/modules/oauth-provider/{controller => api}/dto/request/id.params.ts (100%) rename apps/server/src/modules/oauth-provider/{controller => api}/dto/request/list-oauth-clients.params.ts (100%) rename apps/server/src/modules/oauth-provider/{controller => api}/dto/request/login-request.body.ts (100%) rename apps/server/src/modules/oauth-provider/{controller/dto/request/oauth-client.body.ts => api/dto/request/oauth-client-create.body.ts} (51%) create mode 100644 apps/server/src/modules/oauth-provider/api/dto/request/oauth-client-update.body.ts rename apps/server/src/modules/oauth-provider/{controller => api}/dto/request/oauth-rejectable.body.ts (100%) rename apps/server/src/modules/oauth-provider/{controller => api}/dto/request/revoke-consent.params.ts (100%) rename apps/server/src/modules/oauth-provider/{controller => api}/dto/request/user.params.ts (100%) create mode 100644 apps/server/src/modules/oauth-provider/api/dto/response/consent-session.response.ts rename apps/server/src/modules/oauth-provider/{controller => api}/dto/response/consent.response.ts (54%) rename apps/server/src/modules/oauth-provider/{controller => api}/dto/response/login.response.ts (50%) create mode 100644 apps/server/src/modules/oauth-provider/api/dto/response/oauth-client.response.ts create mode 100644 apps/server/src/modules/oauth-provider/api/dto/response/oidc-context.response.ts rename apps/server/src/modules/oauth-provider/{controller => api}/dto/response/redirect.response.ts (100%) create mode 100644 apps/server/src/modules/oauth-provider/api/index.ts create mode 100644 apps/server/src/modules/oauth-provider/api/mapper/index.ts rename apps/server/src/modules/oauth-provider/{ => api}/mapper/oauth-provider-request.mapper.ts (66%) create mode 100644 apps/server/src/modules/oauth-provider/api/mapper/oauth-provider-response.mapper.ts create mode 100644 apps/server/src/modules/oauth-provider/api/oauth-provider.client-crud.uc.spec.ts rename apps/server/src/modules/oauth-provider/{uc => api}/oauth-provider.client-crud.uc.ts (65%) create mode 100644 apps/server/src/modules/oauth-provider/api/oauth-provider.consent-flow.uc.ts rename apps/server/src/modules/oauth-provider/{controller => api}/oauth-provider.controller.ts (55%) rename apps/server/src/modules/oauth-provider/{uc => api}/oauth-provider.login-flow.uc.spec.ts (84%) rename apps/server/src/modules/oauth-provider/{uc => api}/oauth-provider.login-flow.uc.ts (83%) create mode 100644 apps/server/src/modules/oauth-provider/api/oauth-provider.logout-flow.uc.spec.ts create mode 100644 apps/server/src/modules/oauth-provider/api/oauth-provider.logout-flow.uc.ts create mode 100644 apps/server/src/modules/oauth-provider/api/oauth-provider.uc.spec.ts create mode 100644 apps/server/src/modules/oauth-provider/api/oauth-provider.uc.ts create mode 100644 apps/server/src/modules/oauth-provider/api/test/oauth-provider.controller.api.spec.ts delete mode 100644 apps/server/src/modules/oauth-provider/controller/dto/index.ts delete mode 100644 apps/server/src/modules/oauth-provider/controller/dto/response/consent-session.response.ts delete mode 100644 apps/server/src/modules/oauth-provider/controller/dto/response/oauth-client.response.ts delete mode 100644 apps/server/src/modules/oauth-provider/controller/dto/response/oidc-context.response.ts delete mode 100644 apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.spec.ts rename apps/server/src/{infra/oauth-provider/loggable => modules/oauth-provider/domain/error}/hydra-oauth-failed-loggable-exception.spec.ts (100%) rename apps/server/src/{infra/oauth-provider/loggable => modules/oauth-provider/domain/error}/hydra-oauth-failed-loggable-exception.ts (73%) rename apps/server/src/modules/oauth-provider/{ => domain}/error/id-token-creation-exception.loggable.spec.ts (100%) rename apps/server/src/modules/oauth-provider/{ => domain}/error/id-token-creation-exception.loggable.ts (100%) create mode 100644 apps/server/src/modules/oauth-provider/domain/error/index.ts create mode 100644 apps/server/src/modules/oauth-provider/domain/index.ts rename apps/server/src/modules/oauth-provider/{ => domain}/interface/id-token.ts (97%) create mode 100644 apps/server/src/modules/oauth-provider/domain/interface/index.ts create mode 100644 apps/server/src/modules/oauth-provider/domain/interface/oauth-client.interface.ts rename apps/server/src/modules/oauth-provider/{ => domain}/interface/oauth-scope.enum.ts (100%) create mode 100644 apps/server/src/modules/oauth-provider/domain/interface/oidc-context.interface.ts rename apps/server/src/{infra/oauth-provider/dto => modules/oauth-provider/domain/interface}/request/accept-consent-request.body.ts (69%) rename apps/server/src/{infra/oauth-provider/dto => modules/oauth-provider/domain/interface}/request/accept-login-request.body.ts (73%) rename apps/server/src/{infra/oauth-provider/dto => modules/oauth-provider/domain/interface}/request/reject-request.body.ts (68%) rename apps/server/src/{infra/oauth-provider/dto => modules/oauth-provider/domain/interface}/response/consent-session.response.ts (50%) create mode 100644 apps/server/src/modules/oauth-provider/domain/interface/response/consent.response.ts create mode 100644 apps/server/src/modules/oauth-provider/domain/interface/response/introspect.response.ts create mode 100644 apps/server/src/modules/oauth-provider/domain/interface/response/login.response.ts create mode 100644 apps/server/src/modules/oauth-provider/domain/interface/response/redirect.response.ts rename apps/server/src/modules/oauth-provider/{ => domain}/interface/subject-type.enum.ts (100%) rename apps/server/src/modules/oauth-provider/{ => domain}/interface/token-auth-method.enum.ts (100%) create mode 100644 apps/server/src/modules/oauth-provider/domain/service/hydra.adapter.spec.ts create mode 100644 apps/server/src/modules/oauth-provider/domain/service/hydra.adapter.ts rename apps/server/src/modules/oauth-provider/{ => domain}/service/id-token.service.spec.ts (94%) rename apps/server/src/modules/oauth-provider/{ => domain}/service/id-token.service.ts (96%) rename apps/server/src/modules/oauth-provider/{ => domain}/service/oauth-provider.login-flow.service.spec.ts (95%) rename apps/server/src/modules/oauth-provider/{ => domain}/service/oauth-provider.login-flow.service.ts (83%) create mode 100644 apps/server/src/modules/oauth-provider/domain/service/oauth-provider.service.ts delete mode 100644 apps/server/src/modules/oauth-provider/interface/index.ts delete mode 100644 apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.spec.ts delete mode 100644 apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.spec.ts delete mode 100644 apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.ts create mode 100644 apps/server/src/modules/oauth-provider/oauth-provider-config.module.ts create mode 100644 apps/server/src/modules/oauth-provider/oauth-provider-config.ts create mode 100644 apps/server/src/modules/oauth-provider/oauth-provider-service.module.ts create mode 100644 apps/server/src/modules/oauth-provider/testing/accept-consent-request-body.factory.ts create mode 100644 apps/server/src/modules/oauth-provider/testing/accept-login-request-body.factory.ts create mode 100644 apps/server/src/modules/oauth-provider/testing/id-token.factory.ts create mode 100644 apps/server/src/modules/oauth-provider/testing/index.ts create mode 100644 apps/server/src/modules/oauth-provider/testing/introspect-response.factory.ts create mode 100644 apps/server/src/modules/oauth-provider/testing/provider-consent-response.factory.ts create mode 100644 apps/server/src/modules/oauth-provider/testing/provider-consent-session-response.factory.ts create mode 100644 apps/server/src/modules/oauth-provider/testing/provider-login-response.factory.ts create mode 100644 apps/server/src/modules/oauth-provider/testing/provider-oauth-client.factory.ts create mode 100644 apps/server/src/modules/oauth-provider/testing/provider-oidc-context.factory.ts create mode 100644 apps/server/src/modules/oauth-provider/testing/reject-request-body.factory.ts delete mode 100644 apps/server/src/modules/oauth-provider/uc/index.ts delete mode 100644 apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.spec.ts delete mode 100644 apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.spec.ts delete mode 100644 apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.ts delete mode 100644 apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.spec.ts delete mode 100644 apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.ts delete mode 100644 apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.spec.ts delete mode 100644 apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.ts diff --git a/apps/server/src/infra/oauth-provider/dto/index.ts b/apps/server/src/infra/oauth-provider/dto/index.ts deleted file mode 100644 index 2bba7c9a28b..00000000000 --- a/apps/server/src/infra/oauth-provider/dto/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export * from './request/accept-consent-request.body'; -export * from './request/accept-login-request.body'; -export * from './request/reject-request.body'; -export * from './response/redirect.response'; -export * from './response/consent.response'; -export * from './interface/oauth-client.interface'; -export * from './interface/oidc-context.interface'; -export * from './response/introspect.response'; -export * from './response/consent-session.response'; -export * from './response/login.response'; diff --git a/apps/server/src/infra/oauth-provider/dto/interface/oauth-client.interface.ts b/apps/server/src/infra/oauth-provider/dto/interface/oauth-client.interface.ts deleted file mode 100644 index 87a313fc533..00000000000 --- a/apps/server/src/infra/oauth-provider/dto/interface/oauth-client.interface.ts +++ /dev/null @@ -1,95 +0,0 @@ -export interface ProviderOauthClient { - allowed_cors_origins?: string[]; - - audience?: string[]; - - authorization_code_grant_access_token_lifespan?: string; - - authorization_code_grant_id_token_lifespan?: string; - - authorization_code_grant_refresh_token_lifespan?: string; - - backchannel_logout_session_required?: boolean; - - backchannel_logout_uri?: string; - - client_credentials_grant_access_token_lifespan?: string; - - client_id?: string; - - client_name?: string; - - client_secret?: string; - - client_secret_expires_at?: number; - - client_uri?: string; - - contacts?: string[]; - - created_at?: string; - - frontchannel_logout_session_required?: boolean; - - frontchannel_logout_uri?: string; - - grant_types?: string[]; - - implicit_grant_access_token_lifespan?: string; - - implicit_grant_id_token_lifespan?: string; - - jwks?: object; - - jwks_uri?: string; - - jwt_bearer_grant_access_token_lifespan?: string; - - logo_uri?: string; - - metadata?: object; - - owner?: string; - - password_grant_access_token_lifespan?: string; - - password_grant_refresh_token_lifespan?: string; - - policy_uri?: string; - - post_logout_redirect_uris?: string[]; - - redirect_uris?: string[]; - - refresh_token_grant_access_token_lifespan?: string; - - refresh_token_grant_id_token_lifespan?: string; - - refresh_token_grant_refresh_token_lifespan?: string; - - registration_access_token?: string; - - registration_client_uri?: string; - - request_object_signing_alg?: string; - - request_uris?: string[]; - - response_types?: string[]; - - scope?: string; - - sector_identifier_uri?: string; - - subject_type?: string; - - token_endpoint_auth_method?: string; - - token_endpoint_auth_signing_alg?: string; - - tos_uri?: string; - - updated_at?: string; - - userinfo_signed_response_alg?: string; -} diff --git a/apps/server/src/infra/oauth-provider/dto/interface/oidc-context.interface.ts b/apps/server/src/infra/oauth-provider/dto/interface/oidc-context.interface.ts deleted file mode 100644 index e0b7924eacf..00000000000 --- a/apps/server/src/infra/oauth-provider/dto/interface/oidc-context.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface ProviderOidcContext { - acr_values?: string[]; - - display?: string; - - id_token_hint_claims?: object; - - login_hint?: string; - - ui_locales?: string[]; -} diff --git a/apps/server/src/infra/oauth-provider/dto/response/consent.response.ts b/apps/server/src/infra/oauth-provider/dto/response/consent.response.ts deleted file mode 100644 index 0c06f6ba055..00000000000 --- a/apps/server/src/infra/oauth-provider/dto/response/consent.response.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ProviderOauthClient } from '../interface/oauth-client.interface'; -import { ProviderOidcContext } from '../interface/oidc-context.interface'; - -export interface ProviderConsentResponse { - acr?: string; - - amr?: string[]; - - challenge: string; - - client?: ProviderOauthClient; - - context?: object; - - login_challenge?: string; - - login_session_id?: string; - - oidc_context?: ProviderOidcContext; - - request_url?: string; - - requested_access_token_audience?: string[]; - - requested_scope?: string[]; - - skip?: boolean; - - subject?: string; -} diff --git a/apps/server/src/infra/oauth-provider/dto/response/introspect.response.ts b/apps/server/src/infra/oauth-provider/dto/response/introspect.response.ts deleted file mode 100644 index 45bcaae5551..00000000000 --- a/apps/server/src/infra/oauth-provider/dto/response/introspect.response.ts +++ /dev/null @@ -1,29 +0,0 @@ -export interface IntrospectResponse { - active: boolean; - - aud?: string[]; - - client_id?: string; - - exp?: number; - - ext?: object; - - iat?: number; - - iss?: string; - - nbf?: number; - - obfuscated_subject?: string; - - scope?: string; - - sub?: string; - - token_type?: string; - - token_use?: string; - - username?: string; -} diff --git a/apps/server/src/infra/oauth-provider/dto/response/login.response.ts b/apps/server/src/infra/oauth-provider/dto/response/login.response.ts deleted file mode 100644 index 95ecec82bf3..00000000000 --- a/apps/server/src/infra/oauth-provider/dto/response/login.response.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ProviderOauthClient } from '../interface/oauth-client.interface'; -import { ProviderOidcContext } from '../interface/oidc-context.interface'; - -export interface ProviderLoginResponse { - challenge: string; - - client: ProviderOauthClient; - - oidc_context?: ProviderOidcContext; - - request_url: string; - - requested_access_token_audience: string[]; - - requested_scope: string[]; - - session_id?: string; - - skip: boolean; - - subject: string; -} diff --git a/apps/server/src/infra/oauth-provider/dto/response/redirect.response.ts b/apps/server/src/infra/oauth-provider/dto/response/redirect.response.ts deleted file mode 100644 index 9498ee321a3..00000000000 --- a/apps/server/src/infra/oauth-provider/dto/response/redirect.response.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface ProviderRedirectResponse { - redirect_to: string; -} diff --git a/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.spec.ts b/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.spec.ts deleted file mode 100644 index 2a373195bc6..00000000000 --- a/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.spec.ts +++ /dev/null @@ -1,709 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { - AcceptConsentRequestBody, - AcceptLoginRequestBody, - IntrospectResponse, - ProviderConsentResponse, - ProviderLoginResponse, - ProviderOauthClient, - ProviderRedirectResponse, - RejectRequestBody, -} from '@infra/oauth-provider/dto'; -import { HttpService } from '@nestjs/axios'; -import { Test, TestingModule } from '@nestjs/testing'; -import { axiosResponseFactory } from '@shared/testing'; -import { axiosErrorFactory } from '@shared/testing/factory'; -import { AxiosError, AxiosRequestConfig, Method, RawAxiosRequestHeaders } from 'axios'; -import { of, throwError } from 'rxjs'; -import { ProviderConsentSessionResponse } from '../dto'; -import { HydraOauthFailedLoggableException } from '../loggable'; -import { HydraAdapter } from './hydra.adapter'; -import resetAllMocks = jest.resetAllMocks; - -class HydraAdapterSpec extends HydraAdapter { - public async requestSpec( - method: Method, - url: string, - data?: unknown, - additionalHeaders?: RawAxiosRequestHeaders - ): Promise { - return super.request(method, url, data, additionalHeaders); - } -} - -const createAxiosResponse = (data: T) => - axiosResponseFactory.build({ - data, - }); - -describe('HydraService', () => { - let module: TestingModule; - let service: HydraAdapterSpec; - - let httpService: DeepMocked; - - const hydraUri = 'http://hydra.uri'; - - beforeAll(async () => { - jest.spyOn(Configuration, 'get').mockReturnValue(hydraUri); - - module = await Test.createTestingModule({ - providers: [ - HydraAdapterSpec, - { - provide: HttpService, - useValue: createMock(), - }, - ], - }).compile(); - - service = module.get(HydraAdapterSpec); - httpService = module.get(HttpService); - }); - - afterAll(async () => { - await module.close(); - jest.clearAllMocks(); - }); - - describe('request', () => { - describe('when called with all parameters', () => { - const setup = () => { - const data: { test: string } = { - test: 'data', - }; - - httpService.request.mockReturnValue(of(createAxiosResponse(data))); - - return { - data, - }; - }; - - it('should return data', async () => { - const { data } = setup(); - - const result: { test: string } = await service.requestSpec( - 'GET', - 'testUrl', - { dataKey: 'dataValue' }, - { headerKey: 'headerValue' } - ); - - expect(result).toEqual(data); - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'testUrl', - method: 'GET', - headers: { - 'X-Forwarded-Proto': 'https', - headerKey: 'headerValue', - }, - data: { dataKey: 'dataValue' }, - }) - ); - }); - }); - - describe('when called with only necessary parameters', () => { - const setup = () => { - const data: { test: string } = { - test: 'data', - }; - - httpService.request.mockReturnValue(of(createAxiosResponse(data))); - - return { - data, - }; - }; - - it('should return data', async () => { - const { data } = setup(); - - const result: { test: string } = await service.requestSpec('GET', 'testUrl'); - - expect(result).toEqual(data); - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'testUrl', - method: 'GET', - headers: { - 'X-Forwarded-Proto': 'https', - }, - }) - ); - }); - }); - - describe('when error occurs', () => { - describe('when error is an axios error', () => { - const setup = () => { - const error = { - error: 'invalid_request', - }; - const axiosError: AxiosError = axiosErrorFactory.withError(error).build({}); - - httpService.request.mockReturnValueOnce(throwError(() => axiosError)); - - return { - axiosError, - }; - }; - - it('should throw hydra oauth loggable exception', async () => { - const { axiosError } = setup(); - - await expect(service.listOAuth2Clients()).rejects.toThrow(new HydraOauthFailedLoggableException(axiosError)); - }); - }); - - describe('when error is any other error', () => { - const setup = () => { - httpService.request.mockReturnValueOnce(throwError(() => new Error('unknown error'))); - }; - - it('should throw the error', async () => { - setup(); - - await expect(service.listOAuth2Clients()).rejects.toThrow(new Error('unknown error')); - }); - }); - }); - }); - - describe('Client Flow', () => { - describe('listOAuth2Clients', () => { - describe('when only clientIds are given', () => { - const setup = () => { - const data: ProviderOauthClient[] = [ - { - client_id: 'client1', - }, - { - client_id: 'client2', - }, - ]; - - httpService.request.mockReturnValue(of(createAxiosResponse(data))); - - return { - data, - }; - }; - - it('should list all oauth2 clients', async () => { - const { data } = setup(); - - const result: ProviderOauthClient[] = await service.listOAuth2Clients(); - - expect(result).toEqual(data); - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: `${hydraUri}/clients`, - method: 'GET', - headers: { - 'X-Forwarded-Proto': 'https', - }, - }) - ); - }); - }); - - describe('when clientId and other parameters are given', () => { - const setup = () => { - const data: ProviderOauthClient[] = [ - { - client_id: 'client1', - owner: 'clientOwner', - }, - ]; - - httpService.request.mockReturnValue(of(createAxiosResponse(data))); - - return { - data, - }; - }; - - it('should list all oauth2 clients within parameters', async () => { - const { data } = setup(); - - const result: ProviderOauthClient[] = await service.listOAuth2Clients(1, 0, 'client1', 'clientOwner'); - - expect(result).toEqual(data); - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: `${hydraUri}/clients?limit=1&offset=0&client_name=client1&owner=clientOwner`, - method: 'GET', - headers: { - 'X-Forwarded-Proto': 'https', - }, - }) - ); - }); - }); - }); - - describe('getOAuth2Client', () => { - const setup = () => { - const data: ProviderOauthClient = { - client_id: 'client', - }; - httpService.request.mockReturnValue(of(createAxiosResponse(data))); - - return { - data, - }; - }; - - it('should get oauth2 client', async () => { - const { data } = setup(); - - const result: ProviderOauthClient = await service.getOAuth2Client('clientId'); - - expect(result).toEqual(data); - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: `${hydraUri}/clients/clientId`, - method: 'GET', - headers: { - 'X-Forwarded-Proto': 'https', - }, - }) - ); - }); - }); - - describe('createOAuth2Client', () => { - const setup = () => { - const data: ProviderOauthClient = { - client_id: 'client', - }; - httpService.request.mockReturnValue(of(createAxiosResponse(data))); - - return { - data, - }; - }; - - it('should create oauth2 client', async () => { - const { data } = setup(); - - const result: ProviderOauthClient = await service.createOAuth2Client(data); - - expect(result).toEqual(data); - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: `${hydraUri}/clients`, - method: 'POST', - headers: { - 'X-Forwarded-Proto': 'https', - }, - data, - }) - ); - }); - }); - - describe('updateOAuth2Client', () => { - const setup = () => { - const data: ProviderOauthClient = { - client_id: 'client', - }; - httpService.request.mockReturnValue(of(createAxiosResponse(data))); - - return { - data, - }; - }; - - it('should update oauth2 client', async () => { - const { data } = setup(); - - const result: ProviderOauthClient = await service.updateOAuth2Client('clientId', data); - - expect(result).toEqual(data); - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: `${hydraUri}/clients/clientId`, - method: 'PUT', - headers: { - 'X-Forwarded-Proto': 'https', - }, - data, - }) - ); - }); - }); - - describe('deleteOAuth2Client', () => { - const setup = () => { - httpService.request.mockReturnValue(of(createAxiosResponse({}))); - }; - - it('should delete oauth2 client', async () => { - setup(); - - await service.deleteOAuth2Client('clientId'); - - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: `${hydraUri}/clients/clientId`, - method: 'DELETE', - headers: { - 'X-Forwarded-Proto': 'https', - }, - }) - ); - }); - }); - }); - - describe('Consent Flow', () => { - let challenge: string; - - beforeEach(() => { - challenge = 'challengexyz'; - }); - - afterEach(() => { - resetAllMocks(); - }); - - describe('getConsentRequest', () => { - const setup = () => { - const config: AxiosRequestConfig = { - method: 'GET', - url: `${hydraUri}/oauth2/auth/requests/consent?consent_challenge=${challenge}`, - }; - httpService.request.mockReturnValue(of(createAxiosResponse({ challenge }))); - - return { - config, - }; - }; - - it('should make http request', async () => { - const { config } = setup(); - - const result: ProviderConsentResponse = await service.getConsentRequest(challenge); - - expect(result.challenge).toEqual(challenge); - expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); - }); - }); - - describe('acceptConsentRequest', () => { - const setup = () => { - const body: AcceptConsentRequestBody = { - grant_scope: ['offline', 'openid'], - }; - const config: AxiosRequestConfig = { - method: 'PUT', - url: `${hydraUri}/oauth2/auth/requests/consent/accept?consent_challenge=${challenge}`, - data: body, - }; - const expectedRedirectTo = 'redirectTo'; - httpService.request.mockReturnValue( - of(createAxiosResponse({ redirect_to: expectedRedirectTo })) - ); - - return { - body, - config, - expectedRedirectTo, - }; - }; - - it('should make http request', async () => { - const { body, config, expectedRedirectTo } = setup(); - - const result: ProviderRedirectResponse = await service.acceptConsentRequest(challenge, body); - - expect(result.redirect_to).toEqual(expectedRedirectTo); - expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); - }); - }); - - describe('rejectConsentRequest', () => { - const setup = () => { - const body: RejectRequestBody = { - error: 'error', - }; - const config: AxiosRequestConfig = { - method: 'PUT', - url: `${hydraUri}/oauth2/auth/requests/consent/reject?consent_challenge=${challenge}`, - data: body, - }; - const expectedRedirectTo = 'redirectTo'; - httpService.request.mockReturnValue( - of(createAxiosResponse({ redirect_to: expectedRedirectTo })) - ); - - return { - body, - config, - expectedRedirectTo, - }; - }; - - it('should make http request', async () => { - const { body, config, expectedRedirectTo } = setup(); - - const result: ProviderRedirectResponse = await service.rejectConsentRequest(challenge, body); - - expect(result.redirect_to).toEqual(expectedRedirectTo); - expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); - }); - }); - - describe('listConsentSessions', () => { - const setup = () => { - const response: ProviderConsentSessionResponse[] = [{ consent_request: { challenge: 'challenge' } }]; - httpService.request.mockReturnValue(of(createAxiosResponse(response))); - - return { - response, - }; - }; - - it('should list all consent sessions', async () => { - const { response } = setup(); - - const result: ProviderConsentSessionResponse[] = await service.listConsentSessions('userId'); - - expect(result).toEqual(response); - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: `${hydraUri}/oauth2/auth/sessions/consent?subject=userId`, - method: 'GET', - headers: { - 'X-Forwarded-Proto': 'https', - }, - }) - ); - }); - }); - - describe('revokeConsentSession', () => { - const setup = () => { - httpService.request.mockReturnValue(of(createAxiosResponse({}))); - }; - - it('should revoke all consent sessions', async () => { - setup(); - - await service.revokeConsentSession('userId', 'clientId'); - - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: `${hydraUri}/oauth2/auth/sessions/consent?subject=userId&client=clientId`, - method: 'DELETE', - headers: { - 'X-Forwarded-Proto': 'https', - }, - }) - ); - }); - }); - - describe('Logout Flow', () => { - describe('acceptLogoutRequest', () => { - const setup = () => { - const responseMock: ProviderRedirectResponse = { redirect_to: 'redirect_mock' }; - httpService.request.mockReturnValue(of(createAxiosResponse(responseMock))); - const config: AxiosRequestConfig = { - method: 'PUT', - url: `${hydraUri}/oauth2/auth/requests/logout/accept?logout_challenge=challenge_mock`, - headers: { 'X-Forwarded-Proto': 'https' }, - }; - - return { - responseMock, - config, - }; - }; - - it('should make http request', async () => { - const { responseMock, config } = setup(); - - const response: ProviderRedirectResponse = await service.acceptLogoutRequest('challenge_mock'); - - expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); - expect(response).toEqual(responseMock); - }); - }); - }); - - describe('Miscellaneous', () => { - describe('introspectOAuth2Token', () => { - const setup = () => { - const response: IntrospectResponse = { - active: true, - }; - httpService.request.mockReturnValue(of(createAxiosResponse(response))); - - return { - response, - }; - }; - - it('should return introspect', async () => { - const { response } = setup(); - - const result: IntrospectResponse = await service.introspectOAuth2Token('token', 'scope'); - - expect(result).toEqual(response); - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: `${hydraUri}/oauth2/introspect`, - method: 'POST', - headers: { - 'X-Forwarded-Proto': 'https', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - data: 'token=token&scope=scope', - }) - ); - }); - }); - - describe('isInstanceAlive', () => { - const setup = () => { - httpService.request.mockReturnValue(of(createAxiosResponse(true))); - }; - - it('should check if hydra is alive', async () => { - setup(); - - const result: boolean = await service.isInstanceAlive(); - - expect(result).toEqual(true); - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: `${hydraUri}/health/alive`, - method: 'GET', - headers: { - 'X-Forwarded-Proto': 'https', - }, - }) - ); - }); - }); - }); - - describe('Login Flow', () => { - const providerLoginResponse: ProviderLoginResponse = { - challenge: 'challenge', - client: { - client_id: 'client_id', - created_at: '2020-01-01T00:00:00.000Z', - metadata: {}, - }, - oidc_context: {}, - request_url: 'request_url', - requested_access_token_audience: ['requested_access_token_audience'], - requested_scope: ['requested_scope'], - session_id: 'session_id', - skip: true, - subject: 'subject', - }; - - afterEach(() => { - resetAllMocks(); - }); - - describe('getLoginRequest', () => { - const setup = () => { - const requestConfig: AxiosRequestConfig = { - method: 'GET', - url: `${hydraUri}/oauth2/auth/requests/login?login_challenge=${challenge}`, - }; - httpService.request.mockReturnValue(of(createAxiosResponse(providerLoginResponse))); - - return { - requestConfig, - }; - }; - - it('should send login request', async () => { - const { requestConfig } = setup(); - - const response: ProviderLoginResponse = await service.getLoginRequest(challenge); - - expect(response).toEqual(providerLoginResponse); - expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(requestConfig)); - }); - }); - - describe('acceptLoginRequest', () => { - const setup = () => { - const body: AcceptLoginRequestBody = { - subject: '', - force_subject_identifier: '', - remember_for: 0, - remember: true, - }; - const config: AxiosRequestConfig = { - method: 'PUT', - url: `${hydraUri}/oauth2/auth/requests/login/accept?login_challenge=${challenge}`, - data: body, - }; - const expectedRedirectTo = 'redirectTo'; - httpService.request.mockReturnValue( - of(createAxiosResponse({ redirect_to: expectedRedirectTo })) - ); - - return { - body, - config, - expectedRedirectTo, - }; - }; - - it('should send accept login request', async () => { - const { body, config, expectedRedirectTo } = setup(); - - const result: ProviderRedirectResponse = await service.acceptLoginRequest(challenge, body); - - expect(result.redirect_to).toEqual(expectedRedirectTo); - expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); - }); - }); - - describe('rejectLoginRequest', () => { - const setup = () => { - const body: RejectRequestBody = { - error: 'error', - }; - const config: AxiosRequestConfig = { - method: 'PUT', - url: `${hydraUri}/oauth2/auth/requests/login/reject?login_challenge=${challenge}`, - data: body, - }; - const expectedRedirectTo = 'redirectTo'; - httpService.request.mockReturnValue( - of(createAxiosResponse({ redirect_to: expectedRedirectTo })) - ); - - return { - body, - config, - expectedRedirectTo, - }; - }; - - it('should send reject login request', async () => { - const { body, config, expectedRedirectTo } = setup(); - - const result: ProviderRedirectResponse = await service.rejectLoginRequest(challenge, body); - - expect(result.redirect_to).toEqual(expectedRedirectTo); - expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); - }); - }); - }); - }); -}); diff --git a/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.ts b/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.ts deleted file mode 100644 index 3e2d389d643..00000000000 --- a/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { HttpService } from '@nestjs/axios'; -import { Injectable } from '@nestjs/common'; -import { AxiosResponse, isAxiosError, Method, RawAxiosRequestHeaders } from 'axios'; -import QueryString from 'qs'; -import { firstValueFrom, Observable } from 'rxjs'; -import { catchError } from 'rxjs/operators'; -import { URL } from 'url'; -import { - AcceptConsentRequestBody, - AcceptLoginRequestBody, - IntrospectResponse, - ProviderConsentResponse, - ProviderConsentSessionResponse, - ProviderLoginResponse, - ProviderOauthClient, - ProviderRedirectResponse, - RejectRequestBody, -} from '../dto'; -import { HydraOauthFailedLoggableException } from '../loggable'; -import { OauthProviderService } from '../oauth-provider.service'; - -@Injectable() -export class HydraAdapter extends OauthProviderService { - private readonly hydraUri: string; - - constructor(private readonly httpService: HttpService) { - super(); - this.hydraUri = Configuration.get('HYDRA_URI') as string; - } - - acceptConsentRequest(challenge: string, body: AcceptConsentRequestBody): Promise { - return this.put('consent', 'accept', challenge, body); - } - - acceptLoginRequest(challenge: string, body: AcceptLoginRequestBody): Promise { - return this.put('login', 'accept', challenge, body); - } - - async acceptLogoutRequest(challenge: string): Promise { - const url = `${this.hydraUri}/oauth2/auth/requests/logout/accept?logout_challenge=${challenge}`; - const response: Promise = this.request('PUT', url); - return response; - } - - getConsentRequest(challenge: string): Promise { - const response: Promise = this.get('consent', challenge); - return response; - } - - getLoginRequest(challenge: string): Promise { - return this.get('login', challenge); - } - - introspectOAuth2Token(token: string, scope: string): Promise { - const response: Promise = this.request( - 'POST', - `${this.hydraUri}/oauth2/introspect`, - `token=${token}&scope=${scope}`, - { 'Content-Type': 'application/x-www-form-urlencoded' } - ); - return response; - } - - isInstanceAlive(): Promise { - const response: Promise = this.request('GET', `${this.hydraUri}/health/alive`); - return response; - } - - listConsentSessions(user: string): Promise { - const response: Promise = this.request( - 'GET', - `${this.hydraUri}/oauth2/auth/sessions/consent?subject=${user}` - ); - return response; - } - - rejectConsentRequest(challenge: string, body: RejectRequestBody): Promise { - return this.put('consent', 'reject', challenge, body); - } - - rejectLoginRequest(challenge: string, body: RejectRequestBody): Promise { - return this.put('login', 'reject', challenge, body); - } - - revokeConsentSession(user: string, client: string): Promise { - const response: Promise = this.request( - 'DELETE', - `${this.hydraUri}/oauth2/auth/sessions/consent?subject=${user}&client=${client}` - ); - return response; - } - - listOAuth2Clients( - limit?: number, - offset?: number, - client_name?: string, - owner?: string - ): Promise { - const url: URL = new URL(`${this.hydraUri}/clients`); - url.search = QueryString.stringify({ - limit, - offset, - client_name, - owner, - }); - const response: Promise = this.request('GET', url.toString()); - return response; - } - - getOAuth2Client(id: string): Promise { - const response: Promise = this.request( - 'GET', - `${this.hydraUri}/clients/${id}` - ); - return response; - } - - createOAuth2Client(data: ProviderOauthClient): Promise { - const response: Promise = this.request( - 'POST', - `${this.hydraUri}/clients`, - data - ); - return response; - } - - updateOAuth2Client(id: string, data: ProviderOauthClient): Promise { - const response: Promise = this.request( - 'PUT', - `${this.hydraUri}/clients/${id}`, - data - ); - return response; - } - - deleteOAuth2Client(id: string): Promise { - const response: Promise = this.request('DELETE', `${this.hydraUri}/clients/${id}`); - return response; - } - - protected async put( - flow: string, - action: string, - challenge: string, - body: AcceptConsentRequestBody | AcceptLoginRequestBody | RejectRequestBody - ): Promise { - return this.request( - 'PUT', - `${this.hydraUri}/oauth2/auth/requests/${flow}/${action}?${flow}_challenge=${challenge}`, - body - ); - } - - protected async get(flow: string, challenge: string): Promise { - return this.request('GET', `${this.hydraUri}/oauth2/auth/requests/${flow}?${flow}_challenge=${challenge}`); - } - - protected async request( - method: Method, - url: string, - data?: unknown, - additionalHeaders: RawAxiosRequestHeaders = {} - ): Promise { - const observable: Observable> = this.httpService - .request({ - url, - method, - headers: { - 'X-Forwarded-Proto': 'https', - ...additionalHeaders, - }, - data, - }) - .pipe( - catchError((error: unknown) => { - if (isAxiosError(error)) { - throw new HydraOauthFailedLoggableException(error); - } else { - throw error; - } - }) - ); - - const response: AxiosResponse = await firstValueFrom(observable); - return response.data; - } -} diff --git a/apps/server/src/infra/oauth-provider/index.ts b/apps/server/src/infra/oauth-provider/index.ts deleted file mode 100644 index f60bd34d615..00000000000 --- a/apps/server/src/infra/oauth-provider/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './oauth-provider-service.module'; -export * from './oauth-provider.service'; diff --git a/apps/server/src/infra/oauth-provider/loggable/index.ts b/apps/server/src/infra/oauth-provider/loggable/index.ts deleted file mode 100644 index 677fe4f84e6..00000000000 --- a/apps/server/src/infra/oauth-provider/loggable/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './hydra-oauth-failed-loggable-exception'; diff --git a/apps/server/src/infra/oauth-provider/oauth-provider-service.module.ts b/apps/server/src/infra/oauth-provider/oauth-provider-service.module.ts deleted file mode 100644 index 521f9216050..00000000000 --- a/apps/server/src/infra/oauth-provider/oauth-provider-service.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { OauthProviderService } from './oauth-provider.service'; -import { HydraAdapter } from './hydra/hydra.adapter'; - -@Module({ - imports: [HttpModule], - providers: [{ provide: OauthProviderService, useClass: HydraAdapter }], - exports: [OauthProviderService], -}) -export class OauthProviderServiceModule {} diff --git a/apps/server/src/infra/oauth-provider/oauth-provider.service.ts b/apps/server/src/infra/oauth-provider/oauth-provider.service.ts deleted file mode 100644 index e4ed4e97bb0..00000000000 --- a/apps/server/src/infra/oauth-provider/oauth-provider.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - AcceptConsentRequestBody, - AcceptLoginRequestBody, - IntrospectResponse, - ProviderConsentResponse, - ProviderConsentSessionResponse, - ProviderLoginResponse, - ProviderOauthClient, - ProviderRedirectResponse, - RejectRequestBody, -} from './dto'; - -export abstract class OauthProviderService { - abstract getLoginRequest(challenge: string): Promise; - - abstract acceptLoginRequest(challenge: string, body: AcceptLoginRequestBody): Promise; - - abstract rejectLoginRequest(challenge: string, body: RejectRequestBody): Promise; - - abstract getConsentRequest(challenge: string): Promise; - - abstract acceptConsentRequest(challenge: string, body: AcceptConsentRequestBody): Promise; - - abstract rejectConsentRequest(challenge: string, body: RejectRequestBody): Promise; - - abstract acceptLogoutRequest(challenge: string): Promise; - - abstract introspectOAuth2Token(token: string, scope?: string): Promise; - - abstract isInstanceAlive(): Promise; - - abstract listOAuth2Clients( - limit?: number, - offset?: number, - client_name?: string, - owner?: string - ): Promise; - - abstract createOAuth2Client(data: ProviderOauthClient): Promise; - - abstract getOAuth2Client(id: string): Promise; - - abstract updateOAuth2Client(id: string, data: ProviderOauthClient): Promise; - - abstract deleteOAuth2Client(id: string): Promise; - - abstract listConsentSessions(user: string): Promise; - - abstract revokeConsentSession(user: string, client: string): Promise; -} diff --git a/apps/server/src/infra/redis/index.ts b/apps/server/src/infra/redis/index.ts deleted file mode 100644 index 2465e50ce81..00000000000 --- a/apps/server/src/infra/redis/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './redis.module'; -export * from './interface/redis.constants'; diff --git a/apps/server/src/infra/redis/interface/redis.constants.ts b/apps/server/src/infra/redis/interface/redis.constants.ts deleted file mode 100644 index d7acc566ca5..00000000000 --- a/apps/server/src/infra/redis/interface/redis.constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const REDIS_CLIENT = Symbol('INFRA:REDIS'); diff --git a/apps/server/src/infra/redis/redis.module.ts b/apps/server/src/infra/redis/redis.module.ts deleted file mode 100644 index 569fad4e101..00000000000 --- a/apps/server/src/infra/redis/redis.module.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { Module } from '@nestjs/common'; -import { LegacyLogger, LoggerModule } from '@src/core/logger'; -import { createClient, RedisClientType } from 'redis'; -import { REDIS_CLIENT } from './interface/redis.constants'; - -@Module({ - imports: [LoggerModule], - providers: [ - { - provide: REDIS_CLIENT, - useFactory: (logger: LegacyLogger) => { - logger.setContext(RedisModule.name); - - if (Configuration.has('REDIS_URI')) { - const redisUrl: string = Configuration.get('REDIS_URI') as string; - const client: RedisClientType = createClient({ url: redisUrl }); - - client.on('error', (error) => logger.error(error)); - client.on('connect', (msg) => logger.log(msg)); - - return client; - } - - return undefined; - }, - inject: [LegacyLogger], - }, - ], - exports: [REDIS_CLIENT], -}) -export class RedisModule {} diff --git a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts index 32a9740cd7d..f84abb95033 100644 --- a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts +++ b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts @@ -7,20 +7,20 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Role, SchoolEntity, SchoolYearEntity, SystemEntity, User } from '@shared/domain/entity'; import { RoleName, SortOrder } from '@shared/domain/interface'; import { - TestApiClient, - UserAndAccountTestFactory, groupEntityFactory, roleFactory, schoolFactory, schoolYearFactory, systemEntityFactory, + TestApiClient, + UserAndAccountTestFactory, userFactory, } from '@shared/testing'; import { ObjectId } from 'bson'; import { GroupEntity, GroupEntityTypes } from '../../entity'; import { ClassRootType } from '../../uc/dto/class-root-type'; import { ClassInfoSearchListResponse } from '../dto'; -import { ClassSortBy } from '../dto/interface'; +import { ClassSortQueryType } from '../dto/interface'; const baseRouteName = '/groups'; @@ -54,7 +54,7 @@ describe('Group (API)', () => { const teacherRole: Role = roleFactory.buildWithId({ name: RoleName.TEACHER }); const teacherUser: User = userFactory.buildWithId({ school, roles: [teacherRole] }); const system: SystemEntity = systemEntityFactory.buildWithId(); - const clazz: ClassEntity = classEntityFactory.buildWithId({ + const classEntity: ClassEntity = classEntityFactory.buildWithId({ name: 'Group A', schoolId: school._id, teacherIds: [teacherUser._id], @@ -84,7 +84,7 @@ describe('Group (API)', () => { teacherRole, teacherUser, system, - clazz, + classEntity, group, schoolYear, ]); @@ -95,7 +95,7 @@ describe('Group (API)', () => { return { adminClient, group, - clazz, + classEntity, system, adminUser, teacherUser, @@ -104,12 +104,12 @@ describe('Group (API)', () => { }; it('should return the classes of his school', async () => { - const { adminClient, group, clazz, system, adminUser, teacherUser, schoolYear } = await setup(); + const { adminClient, group, classEntity, system, adminUser, teacherUser, schoolYear } = await setup(); const response = await adminClient.get(`/class`).query({ skip: 0, limit: 2, - sortBy: ClassSortBy.NAME, + sortBy: ClassSortQueryType.NAME, sortOrder: SortOrder.desc, }); @@ -125,9 +125,9 @@ describe('Group (API)', () => { studentCount: 0, }, { - id: clazz.id, + id: classEntity.id, type: ClassRootType.CLASS, - name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + name: classEntity.gradeLevel ? `${classEntity.gradeLevel}${classEntity.name}` : classEntity.name, teachers: [teacherUser.lastName], schoolYear: schoolYear.name, isUpgradable: false, diff --git a/apps/server/src/modules/group/controller/dto/interface/class-sort-by.enum.ts b/apps/server/src/modules/group/controller/dto/interface/class-sort-query-type.enum.ts similarity index 65% rename from apps/server/src/modules/group/controller/dto/interface/class-sort-by.enum.ts rename to apps/server/src/modules/group/controller/dto/interface/class-sort-query-type.enum.ts index 24dda3c9382..ec609f53852 100644 --- a/apps/server/src/modules/group/controller/dto/interface/class-sort-by.enum.ts +++ b/apps/server/src/modules/group/controller/dto/interface/class-sort-query-type.enum.ts @@ -1,4 +1,4 @@ -export enum ClassSortBy { +export enum ClassSortQueryType { NAME = 'name', EXTERNAL_SOURCE_NAME = 'externalSourceName', } diff --git a/apps/server/src/modules/group/controller/dto/interface/index.ts b/apps/server/src/modules/group/controller/dto/interface/index.ts index a94213271b7..fab033e118d 100644 --- a/apps/server/src/modules/group/controller/dto/interface/index.ts +++ b/apps/server/src/modules/group/controller/dto/interface/index.ts @@ -1,3 +1,3 @@ -export * from './class-sort-by.enum'; -export * from './school-year-query-type.enum'; +export { ClassSortQueryType } from './class-sort-query-type.enum'; +export { SchoolYearQueryType } from './school-year-query-type.enum'; export { ClassRequestContext } from './class-request-context.enum'; diff --git a/apps/server/src/modules/group/controller/dto/request/class-sort-params.ts b/apps/server/src/modules/group/controller/dto/request/class-sort-params.ts index 980146c92d4..3e08bd1c508 100644 --- a/apps/server/src/modules/group/controller/dto/request/class-sort-params.ts +++ b/apps/server/src/modules/group/controller/dto/request/class-sort-params.ts @@ -1,11 +1,11 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { SortingParams } from '@shared/controller'; import { IsEnum, IsOptional } from 'class-validator'; -import { ClassSortBy } from '../interface'; +import { ClassSortQueryType } from '../interface'; -export class ClassSortParams extends SortingParams { +export class ClassSortParams extends SortingParams { @IsOptional() - @IsEnum(ClassSortBy) - @ApiPropertyOptional({ enum: ClassSortBy }) - sortBy?: ClassSortBy; + @IsEnum(ClassSortQueryType) + @ApiPropertyOptional({ enum: ClassSortQueryType }) + sortBy?: ClassSortQueryType; } diff --git a/apps/server/src/modules/group/controller/group.controller.ts b/apps/server/src/modules/group/controller/group.controller.ts index e5df1dec514..de0f0644efd 100644 --- a/apps/server/src/modules/group/controller/group.controller.ts +++ b/apps/server/src/modules/group/controller/group.controller.ts @@ -6,13 +6,13 @@ import { ErrorResponse } from '@src/core/error/dto'; import { GroupUc } from '../uc'; import { ClassInfoDto, ResolvedGroupDto } from '../uc/dto'; import { + ClassCallerParams, ClassFilterParams, ClassInfoSearchListResponse, ClassSortParams, GroupIdParams, - GroupResponse, GroupPaginationParams, - ClassCallerParams, + GroupResponse, } from './dto'; import { GroupResponseMapper } from './mapper'; @@ -45,7 +45,7 @@ export class GroupController { sortingQuery.sortOrder ); - const response: ClassInfoSearchListResponse = GroupResponseMapper.mapToClassInfosToListResponse( + const response: ClassInfoSearchListResponse = GroupResponseMapper.mapToClassInfoSearchListResponse( board, pagination.skip, pagination.limit diff --git a/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts b/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts index 3b715b7db01..c477f094d9e 100644 --- a/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts +++ b/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts @@ -1,6 +1,6 @@ import { Page } from '@shared/domain/domainobject'; import { GroupTypes } from '../../domain'; -import { ClassInfoDto, ResolvedGroupDto } from '../../uc/dto'; +import { ClassInfoDto, ResolvedGroupDto, ResolvedGroupUser } from '../../uc/dto'; import { ClassInfoResponse, ClassInfoSearchListResponse, @@ -17,17 +17,17 @@ const typeMapping: Record = { }; export class GroupResponseMapper { - static mapToClassInfosToListResponse( + public static mapToClassInfoSearchListResponse( classInfos: Page, skip?: number, limit?: number ): ClassInfoSearchListResponse { - const mappedData: ClassInfoResponse[] = classInfos.data.map((classInfo) => - this.mapToClassInfoToResponse(classInfo) + const classInfoResponses: ClassInfoResponse[] = classInfos.data.map((classInfo) => + this.mapToClassInfoResponse(classInfo) ); const response: ClassInfoSearchListResponse = new ClassInfoSearchListResponse( - mappedData, + classInfoResponses, classInfos.total, skip, limit @@ -36,8 +36,8 @@ export class GroupResponseMapper { return response; } - private static mapToClassInfoToResponse(classInfo: ClassInfoDto): ClassInfoResponse { - const mapped = new ClassInfoResponse({ + private static mapToClassInfoResponse(classInfo: ClassInfoDto): ClassInfoResponse { + const classInfoResponse: ClassInfoResponse = new ClassInfoResponse({ id: classInfo.id, type: classInfo.type, name: classInfo.name, @@ -48,32 +48,36 @@ export class GroupResponseMapper { studentCount: classInfo.studentCount, }); - return mapped; + return classInfoResponse; } static mapToGroupResponse(resolvedGroup: ResolvedGroupDto): GroupResponse { - const mapped: GroupResponse = new GroupResponse({ + const externalSource: ExternalSourceResponse | undefined = resolvedGroup.externalSource + ? new ExternalSourceResponse({ + externalId: resolvedGroup.externalSource.externalId, + systemId: resolvedGroup.externalSource.systemId, + }) + : undefined; + + const users: GroupUserResponse[] = resolvedGroup.users.map( + (user: ResolvedGroupUser): GroupUserResponse => + new GroupUserResponse({ + id: user.user.id as string, + role: user.role.name, + firstName: user.user.firstName, + lastName: user.user.lastName, + }) + ); + + const groupResponse: GroupResponse = new GroupResponse({ id: resolvedGroup.id, name: resolvedGroup.name, type: typeMapping[resolvedGroup.type], - externalSource: resolvedGroup.externalSource - ? new ExternalSourceResponse({ - externalId: resolvedGroup.externalSource.externalId, - systemId: resolvedGroup.externalSource.systemId, - }) - : undefined, - users: resolvedGroup.users.map( - (user) => - new GroupUserResponse({ - id: user.user.id as string, - role: user.role.name, - firstName: user.user.firstName, - lastName: user.user.lastName, - }) - ), + externalSource, + users, organizationId: resolvedGroup.organizationId, }); - return mapped; + return groupResponse; } } diff --git a/apps/server/src/modules/oauth-provider/api/dto/index.ts b/apps/server/src/modules/oauth-provider/api/dto/index.ts new file mode 100644 index 00000000000..0a12964d790 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/dto/index.ts @@ -0,0 +1,17 @@ +export { AcceptQuery } from './request/accept.query'; +export { ChallengeParams } from './request/challenge.params'; +export { ConsentRequestBody } from './request/consent-request.body'; +export { IdParams } from './request/id.params'; +export { ListOauthClientsParams } from './request/list-oauth-clients.params'; +export { LoginRequestBody } from './request/login-request.body'; +export { OauthClientCreateBody } from './request/oauth-client-create.body'; +export { OauthClientUpdateBody } from './request/oauth-client-update.body'; +export { RevokeConsentParams } from './request/revoke-consent.params'; +export { UserParams } from './request/user.params'; +export { OauthClientResponse } from './response/oauth-client.response'; +export { ConsentResponse } from './response/consent.response'; +export { RedirectResponse } from './response/redirect.response'; +export { OidcContextResponse } from './response/oidc-context.response'; +export { ConsentSessionResponse } from './response/consent-session.response'; +export { LoginResponse } from './response/login.response'; +export { OAuthRejectableBody } from './request/oauth-rejectable.body'; diff --git a/apps/server/src/modules/oauth-provider/controller/dto/request/accept.query.ts b/apps/server/src/modules/oauth-provider/api/dto/request/accept.query.ts similarity index 82% rename from apps/server/src/modules/oauth-provider/controller/dto/request/accept.query.ts rename to apps/server/src/modules/oauth-provider/api/dto/request/accept.query.ts index e8b8aa493a0..59877c84a65 100644 --- a/apps/server/src/modules/oauth-provider/controller/dto/request/accept.query.ts +++ b/apps/server/src/modules/oauth-provider/api/dto/request/accept.query.ts @@ -1,6 +1,6 @@ -import { IsBoolean } from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; -import { StringToBoolean } from '@shared/controller/index'; +import { StringToBoolean } from '@shared/controller'; +import { IsBoolean } from 'class-validator'; export class AcceptQuery { @IsBoolean() diff --git a/apps/server/src/modules/oauth-provider/controller/dto/request/challenge.params.ts b/apps/server/src/modules/oauth-provider/api/dto/request/challenge.params.ts similarity index 100% rename from apps/server/src/modules/oauth-provider/controller/dto/request/challenge.params.ts rename to apps/server/src/modules/oauth-provider/api/dto/request/challenge.params.ts index ce8e724d271..c4699d5a4aa 100644 --- a/apps/server/src/modules/oauth-provider/controller/dto/request/challenge.params.ts +++ b/apps/server/src/modules/oauth-provider/api/dto/request/challenge.params.ts @@ -1,5 +1,5 @@ -import { IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; export class ChallengeParams { @IsString() diff --git a/apps/server/src/modules/oauth-provider/controller/dto/request/consent-request.body.ts b/apps/server/src/modules/oauth-provider/api/dto/request/consent-request.body.ts similarity index 100% rename from apps/server/src/modules/oauth-provider/controller/dto/request/consent-request.body.ts rename to apps/server/src/modules/oauth-provider/api/dto/request/consent-request.body.ts index 09a1ee4d2b2..989e189f1b3 100644 --- a/apps/server/src/modules/oauth-provider/controller/dto/request/consent-request.body.ts +++ b/apps/server/src/modules/oauth-provider/api/dto/request/consent-request.body.ts @@ -1,5 +1,5 @@ -import { IsArray, IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; import { OAuthRejectableBody } from './oauth-rejectable.body'; export class ConsentRequestBody extends OAuthRejectableBody { diff --git a/apps/server/src/modules/oauth-provider/controller/dto/request/id.params.ts b/apps/server/src/modules/oauth-provider/api/dto/request/id.params.ts similarity index 100% rename from apps/server/src/modules/oauth-provider/controller/dto/request/id.params.ts rename to apps/server/src/modules/oauth-provider/api/dto/request/id.params.ts index 49dddfd9999..ea0227e88e5 100644 --- a/apps/server/src/modules/oauth-provider/controller/dto/request/id.params.ts +++ b/apps/server/src/modules/oauth-provider/api/dto/request/id.params.ts @@ -1,5 +1,5 @@ -import { IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; export class IdParams { @IsString() diff --git a/apps/server/src/modules/oauth-provider/controller/dto/request/list-oauth-clients.params.ts b/apps/server/src/modules/oauth-provider/api/dto/request/list-oauth-clients.params.ts similarity index 100% rename from apps/server/src/modules/oauth-provider/controller/dto/request/list-oauth-clients.params.ts rename to apps/server/src/modules/oauth-provider/api/dto/request/list-oauth-clients.params.ts index d7c5e4b5733..34a25d312cb 100644 --- a/apps/server/src/modules/oauth-provider/controller/dto/request/list-oauth-clients.params.ts +++ b/apps/server/src/modules/oauth-provider/api/dto/request/list-oauth-clients.params.ts @@ -1,5 +1,5 @@ -import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; export class ListOauthClientsParams { @IsNumber() diff --git a/apps/server/src/modules/oauth-provider/controller/dto/request/login-request.body.ts b/apps/server/src/modules/oauth-provider/api/dto/request/login-request.body.ts similarity index 100% rename from apps/server/src/modules/oauth-provider/controller/dto/request/login-request.body.ts rename to apps/server/src/modules/oauth-provider/api/dto/request/login-request.body.ts index 93fb2d9a546..e66a9c999c5 100644 --- a/apps/server/src/modules/oauth-provider/controller/dto/request/login-request.body.ts +++ b/apps/server/src/modules/oauth-provider/api/dto/request/login-request.body.ts @@ -1,5 +1,5 @@ -import { IsBoolean, IsInt, IsOptional } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional } from 'class-validator'; import { OAuthRejectableBody } from './oauth-rejectable.body'; export class LoginRequestBody extends OAuthRejectableBody { diff --git a/apps/server/src/modules/oauth-provider/controller/dto/request/oauth-client.body.ts b/apps/server/src/modules/oauth-provider/api/dto/request/oauth-client-create.body.ts similarity index 51% rename from apps/server/src/modules/oauth-provider/controller/dto/request/oauth-client.body.ts rename to apps/server/src/modules/oauth-provider/api/dto/request/oauth-client-create.body.ts index 277922526f7..c2c0e02d872 100644 --- a/apps/server/src/modules/oauth-provider/controller/dto/request/oauth-client.body.ts +++ b/apps/server/src/modules/oauth-provider/api/dto/request/oauth-client-create.body.ts @@ -1,65 +1,56 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsArray, IsEnum, IsOptional, IsString } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; -import { SubjectTypeEnum } from '@modules/oauth-provider/interface/subject-type.enum'; -import { TokenAuthMethod } from '@modules/oauth-provider/interface/token-auth-method.enum'; +import { SubjectTypeEnum, TokenAuthMethod } from '../../../domain/interface'; -export class OauthClientBody { +export class OauthClientCreateBody { @IsString() - @IsOptional() - @ApiProperty({ description: 'The Oauth2 client id.', required: false, nullable: false }) - client_id?: string; + @ApiProperty({ description: 'The Oauth2 client id.', nullable: false }) + client_id!: string; @IsString() - @IsOptional() - @ApiProperty({ description: 'The Oauth2 client name.', required: false, nullable: false }) - client_name?: string; + @ApiProperty({ description: 'The Oauth2 client name.', nullable: false }) + client_name!: string; @IsString() - @IsOptional() - @ApiProperty({ description: 'The Oauth2 client secret.', required: false, nullable: false }) - client_secret?: string; + @ApiProperty({ description: 'The Oauth2 client secret.', nullable: false }) + client_secret!: string; @IsArray() @IsOptional() @IsString({ each: true }) - @ApiProperty({ description: 'The allowed redirect urls of the Oauth2 client.', required: false, nullable: false }) + @ApiPropertyOptional({ description: 'The allowed redirect urls of the Oauth2 client.', nullable: false, default: [] }) redirect_uris?: string[]; @IsEnum(TokenAuthMethod) - @IsOptional() @ApiProperty({ description: 'Requested Client Authentication method for the Token Endpoint. The options are client_secret_post, client_secret_basic, private_key_jwt, and none.', - required: false, nullable: false, }) - token_endpoint_auth_method?: TokenAuthMethod; + token_endpoint_auth_method!: TokenAuthMethod; @IsEnum(SubjectTypeEnum) - @IsOptional() @ApiProperty({ description: 'SubjectType requested for responses to this Client. The subject_types_supported Discovery parameter contains a list of the supported subject_type values for this server. Valid types include pairwise and public.', - required: false, nullable: false, }) - subject_type?: SubjectTypeEnum; + subject_type!: SubjectTypeEnum; @IsString() @IsOptional() - @ApiProperty({ + @ApiPropertyOptional({ description: 'Scope is a string containing a space-separated list of scope values (as described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use when requesting access tokens.', - required: false, nullable: false, + default: 'openid offline', }) scope?: string; @IsString() @IsOptional() - @ApiProperty({ + @ApiPropertyOptional({ description: 'Thr frontchannel logout uri.', - required: false, nullable: false, }) frontchannel_logout_uri?: string; @@ -67,12 +58,20 @@ export class OauthClientBody { @IsArray() @IsOptional() @IsString({ each: true }) - @ApiProperty({ description: 'The grant types of the Oauth2 client.', required: false, nullable: false }) + @ApiPropertyOptional({ + description: 'The grant types of the Oauth2 client.', + nullable: false, + default: ['authorization_code', 'refresh_token'], + }) grant_types?: string[]; @IsArray() @IsOptional() @IsString({ each: true }) - @ApiProperty({ description: 'The response types of the Oauth2 client.', required: false, nullable: false }) + @ApiPropertyOptional({ + description: 'The response types of the Oauth2 client.', + nullable: false, + default: ['code', 'token', 'id_token'], + }) response_types?: string[]; } diff --git a/apps/server/src/modules/oauth-provider/api/dto/request/oauth-client-update.body.ts b/apps/server/src/modules/oauth-provider/api/dto/request/oauth-client-update.body.ts new file mode 100644 index 00000000000..f7a4173bba8 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/dto/request/oauth-client-update.body.ts @@ -0,0 +1,74 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsEnum, IsOptional, IsString } from 'class-validator'; +import { SubjectTypeEnum, TokenAuthMethod } from '../../../domain/interface'; + +export class OauthClientUpdateBody { + @IsString() + @ApiProperty({ description: 'The Oauth2 client name.', nullable: false }) + client_name!: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ description: 'The Oauth2 client secret.', nullable: false }) + client_secret?: string; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + @ApiPropertyOptional({ description: 'The allowed redirect urls of the Oauth2 client.', nullable: false, default: [] }) + redirect_uris!: string[]; + + @IsEnum(TokenAuthMethod) + @ApiProperty({ + description: + 'Requested Client Authentication method for the Token Endpoint. The options are client_secret_post, client_secret_basic, private_key_jwt, and none.', + nullable: false, + }) + token_endpoint_auth_method!: TokenAuthMethod; + + @IsEnum(SubjectTypeEnum) + @ApiProperty({ + description: + 'SubjectType requested for responses to this Client. The subject_types_supported Discovery parameter contains a list of the supported subject_type values for this server. Valid types include pairwise and public.', + nullable: false, + }) + subject_type!: SubjectTypeEnum; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ + description: + 'Scope is a string containing a space-separated list of scope values (as described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use when requesting access tokens.', + nullable: false, + default: 'openid offline', + }) + scope?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ + description: 'Thr frontchannel logout uri.', + nullable: false, + }) + frontchannel_logout_uri?: string; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + @ApiPropertyOptional({ + description: 'The grant types of the Oauth2 client.', + nullable: false, + default: ['authorization_code', 'refresh_token'], + }) + grant_types?: string[]; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + @ApiPropertyOptional({ + description: 'The response types of the Oauth2 client.', + nullable: false, + default: ['code', 'token', 'id_token'], + }) + response_types?: string[]; +} diff --git a/apps/server/src/modules/oauth-provider/controller/dto/request/oauth-rejectable.body.ts b/apps/server/src/modules/oauth-provider/api/dto/request/oauth-rejectable.body.ts similarity index 100% rename from apps/server/src/modules/oauth-provider/controller/dto/request/oauth-rejectable.body.ts rename to apps/server/src/modules/oauth-provider/api/dto/request/oauth-rejectable.body.ts index 6875210be93..9387b9da4c3 100644 --- a/apps/server/src/modules/oauth-provider/controller/dto/request/oauth-rejectable.body.ts +++ b/apps/server/src/modules/oauth-provider/api/dto/request/oauth-rejectable.body.ts @@ -1,5 +1,5 @@ -import { IsNumber, IsOptional, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsString } from 'class-validator'; export class OAuthRejectableBody { @IsString() diff --git a/apps/server/src/modules/oauth-provider/controller/dto/request/revoke-consent.params.ts b/apps/server/src/modules/oauth-provider/api/dto/request/revoke-consent.params.ts similarity index 100% rename from apps/server/src/modules/oauth-provider/controller/dto/request/revoke-consent.params.ts rename to apps/server/src/modules/oauth-provider/api/dto/request/revoke-consent.params.ts index 354c263dc7d..6afc0861a41 100644 --- a/apps/server/src/modules/oauth-provider/controller/dto/request/revoke-consent.params.ts +++ b/apps/server/src/modules/oauth-provider/api/dto/request/revoke-consent.params.ts @@ -1,5 +1,5 @@ -import { IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; export class RevokeConsentParams { @IsString() diff --git a/apps/server/src/modules/oauth-provider/controller/dto/request/user.params.ts b/apps/server/src/modules/oauth-provider/api/dto/request/user.params.ts similarity index 100% rename from apps/server/src/modules/oauth-provider/controller/dto/request/user.params.ts rename to apps/server/src/modules/oauth-provider/api/dto/request/user.params.ts index ee751dc27bf..db96b3f084b 100644 --- a/apps/server/src/modules/oauth-provider/controller/dto/request/user.params.ts +++ b/apps/server/src/modules/oauth-provider/api/dto/request/user.params.ts @@ -1,5 +1,5 @@ -import { IsMongoId } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId } from 'class-validator'; export class UserParams { @IsMongoId() diff --git a/apps/server/src/modules/oauth-provider/api/dto/response/consent-session.response.ts b/apps/server/src/modules/oauth-provider/api/dto/response/consent-session.response.ts new file mode 100644 index 00000000000..37b4fb2a3d9 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/dto/response/consent-session.response.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ConsentSessionResponse { + constructor(props: ConsentSessionResponse) { + this.client_id = props.client_id; + this.client_name = props.client_name; + this.challenge = props.challenge; + } + + @ApiProperty({ description: 'The id of the client.' }) + client_id: string; + + @ApiProperty({ description: 'The name of the client.' }) + client_name: string; + + @ApiProperty({ description: 'The id/challenge of the consent authorization request.' }) + challenge: string; +} diff --git a/apps/server/src/modules/oauth-provider/controller/dto/response/consent.response.ts b/apps/server/src/modules/oauth-provider/api/dto/response/consent.response.ts similarity index 54% rename from apps/server/src/modules/oauth-provider/controller/dto/response/consent.response.ts rename to apps/server/src/modules/oauth-provider/api/dto/response/consent.response.ts index a5c90fe11a2..a48f232225e 100644 --- a/apps/server/src/modules/oauth-provider/controller/dto/response/consent.response.ts +++ b/apps/server/src/modules/oauth-provider/api/dto/response/consent.response.ts @@ -1,77 +1,70 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, IsOptional, IsString } from 'class-validator'; -import { OidcContextResponse } from '@modules/oauth-provider/controller/dto/response/oidc-context.response'; -import { OauthClientResponse } from '@modules/oauth-provider/controller/dto/response/oauth-client.response'; +import { OauthClientResponse } from './oauth-client.response'; +import { OidcContextResponse } from './oidc-context.response'; export class ConsentResponse { - constructor(consentResponse: ConsentResponse) { - Object.assign(this, consentResponse); + constructor(props: ConsentResponse) { + this.acr = props.acr; + this.amr = props.amr; + this.challenge = props.challenge; + this.client = props.client; + this.context = props.context; + this.login_challenge = props.login_challenge; + this.login_session_id = props.login_session_id; + this.oidc_context = props.oidc_context; + this.request_url = props.request_url; + this.requested_access_token_audience = props.requested_access_token_audience; + this.requested_scope = props.requested_scope; + this.skip = props.skip; + this.subject = props.subject; } - @IsOptional() @ApiProperty({ description: 'ACR represents the Authentication AuthorizationContext Class Reference value for this authentication session', }) - acr?: string; + acr: string; - @IsArray() - @IsOptional() - @IsString({ each: true }) @ApiProperty({ required: false, nullable: false }) - amr?: string[]; + amr: string[]; @ApiProperty({ description: 'Is the id/authorization challenge of the consent authorization request. It is used to identify the session.', }) - challenge: string | undefined; + challenge: string; - @IsOptional() @ApiProperty() - client?: OauthClientResponse; + client: OauthClientResponse; - @IsOptional() @ApiProperty() - context?: object; + context: object; - @IsOptional() @ApiProperty({ description: 'LoginChallenge is the login challenge this consent challenge belongs to.' }) - login_challenge?: string; + login_challenge: string; - @IsOptional() @ApiProperty({ description: 'LoginSessionID is the login session ID.' }) - login_session_id?: string; + login_session_id: string; - @IsOptional() @ApiProperty() - oidc_context?: OidcContextResponse; + oidc_context: OidcContextResponse; - @IsOptional() @ApiProperty({ description: 'RequestUrl is the original OAuth 2.0 Authorization URL requested by the OAuth 2.0 client.', }) - request_url?: string; + request_url: string; - @IsArray() - @IsOptional() - @IsString({ each: true }) @ApiProperty({ required: false, nullable: false }) - requested_access_token_audience?: string[]; + requested_access_token_audience: string[]; - @IsArray() - @IsOptional() - @IsString({ each: true }) @ApiProperty({ description: 'The request scopes of the login request.', required: false, nullable: false }) - requested_scope?: string[]; + requested_scope: string[]; - @IsOptional() @ApiProperty({ description: 'Skip, if true, implies that the client has requested the same scopes from the same user previously.', }) - skip?: boolean; + skip: boolean; - @IsOptional() @ApiProperty({ description: 'Subject is the user id of the end-user that is authenticated.' }) - subject?: string; + subject: string; } diff --git a/apps/server/src/modules/oauth-provider/controller/dto/response/login.response.ts b/apps/server/src/modules/oauth-provider/api/dto/response/login.response.ts similarity index 50% rename from apps/server/src/modules/oauth-provider/controller/dto/response/login.response.ts rename to apps/server/src/modules/oauth-provider/api/dto/response/login.response.ts index 761276bdc5c..38d239e793e 100644 --- a/apps/server/src/modules/oauth-provider/controller/dto/response/login.response.ts +++ b/apps/server/src/modules/oauth-provider/api/dto/response/login.response.ts @@ -1,52 +1,52 @@ import { ApiProperty } from '@nestjs/swagger'; -import { OauthClientResponse } from '@modules/oauth-provider/controller/dto/response/oauth-client.response'; -import { OidcContextResponse } from '@modules/oauth-provider/controller/dto/response/oidc-context.response'; -import { IsArray, IsOptional, IsString } from 'class-validator'; +import { OauthClientResponse } from './oauth-client.response'; +import { OidcContextResponse } from './oidc-context.response'; export class LoginResponse { - constructor(loginResponse: LoginResponse) { - Object.assign(this, loginResponse); + constructor(props: LoginResponse) { + this.client = props.client; + this.client_id = props.client_id; + this.challenge = props.challenge; + this.oidc_context = props.oidc_context; + this.request_url = props.request_url; + this.skip = props.skip; + this.requested_access_token_audience = props.requested_access_token_audience; + this.requested_scope = props.requested_scope; + this.subject = props.subject; + this.session_id = props.session_id; } - @IsOptional() @ApiProperty({ description: 'Id of the corresponding client.' }) - client_id?: string; + client_id: string; @ApiProperty({ description: 'The id/challenge of the consent login request.' }) - challenge: string | undefined; + challenge: string; @ApiProperty() - client: OauthClientResponse | undefined; + client: OauthClientResponse; - @IsOptional() @ApiProperty() - oidc_context?: OidcContextResponse; + oidc_context: OidcContextResponse; - @IsOptional() @ApiProperty({ description: 'The original oauth2.0 authorization url request by the client.' }) - request_url?: string; + request_url: string; - @IsOptional() @ApiProperty() - requested_access_token_audience?: string[]; + requested_access_token_audience: string[]; - @IsArray() - @IsOptional() - @IsString({ each: true }) @ApiProperty({ description: 'The request scopes of the login request.', required: false, nullable: false }) - requested_scope?: string[]; + requested_scope: string[]; - @IsOptional() @ApiProperty({ description: 'The login session id. This parameter is used as sid for the oidc front-/backchannel logout.', }) - session_id?: string; + session_id: string; @ApiProperty({ description: 'Skip, if true, implies that the client has requested the same scopes from the same user previously.', }) - skip: boolean | undefined; + skip: boolean; @ApiProperty({ description: 'User id of the end-user that is authenticated.' }) - subject: string | undefined; + subject: string; } diff --git a/apps/server/src/modules/oauth-provider/api/dto/response/oauth-client.response.ts b/apps/server/src/modules/oauth-provider/api/dto/response/oauth-client.response.ts new file mode 100644 index 00000000000..ed89f9dd435 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/dto/response/oauth-client.response.ts @@ -0,0 +1,209 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class OauthClientResponse { + constructor(props: OauthClientResponse) { + this.allowed_cors_origins = props.allowed_cors_origins; + this.audience = props.audience; + this.authorization_code_grant_access_token_lifespan = props.authorization_code_grant_access_token_lifespan; + this.authorization_code_grant_id_token_lifespan = props.authorization_code_grant_id_token_lifespan; + this.authorization_code_grant_refresh_token_lifespan = props.authorization_code_grant_refresh_token_lifespan; + this.backchannel_logout_session_required = props.backchannel_logout_session_required; + this.backchannel_logout_uri = props.backchannel_logout_uri; + this.client_credentials_grant_access_token_lifespan = props.client_credentials_grant_access_token_lifespan; + this.client_id = props.client_id; + this.client_name = props.client_name; + this.client_secret_expires_at = props.client_secret_expires_at; + this.client_uri = props.client_uri; + this.contacts = props.contacts; + this.created_at = props.created_at; + this.frontchannel_logout_session_required = props.frontchannel_logout_session_required; + this.frontchannel_logout_uri = props.frontchannel_logout_uri; + this.grant_types = props.grant_types; + this.implicit_grant_access_token_lifespan = props.implicit_grant_access_token_lifespan; + this.implicit_grant_id_token_lifespan = props.implicit_grant_id_token_lifespan; + this.jwks = props.jwks; + this.jwks_uri = props.jwks_uri; + this.jwt_bearer_grant_access_token_lifespan = props.jwt_bearer_grant_access_token_lifespan; + this.logo_uri = props.logo_uri; + this.metadata = props.metadata; + this.owner = props.owner; + this.password_grant_access_token_lifespan = props.password_grant_access_token_lifespan; + this.password_grant_refresh_token_lifespan = props.password_grant_refresh_token_lifespan; + this.policy_uri = props.policy_uri; + this.post_logout_redirect_uris = props.post_logout_redirect_uris; + this.redirect_uris = props.redirect_uris; + this.refresh_token_grant_access_token_lifespan = props.refresh_token_grant_access_token_lifespan; + this.refresh_token_grant_id_token_lifespan = props.refresh_token_grant_id_token_lifespan; + this.refresh_token_grant_refresh_token_lifespan = props.refresh_token_grant_refresh_token_lifespan; + this.registration_access_token = props.registration_access_token; + this.registration_client_uri = props.registration_client_uri; + this.request_object_signing_alg = props.request_object_signing_alg; + this.request_uris = props.request_uris; + this.response_types = props.response_types; + this.scope = props.scope; + this.sector_identifier_uri = props.sector_identifier_uri; + this.subject_type = props.subject_type; + this.token_endpoint_auth_method = props.token_endpoint_auth_method; + this.token_endpoint_auth_signing_alg = props.token_endpoint_auth_signing_alg; + this.tos_uri = props.tos_uri; + this.updated_at = props.updated_at; + this.userinfo_signed_response_alg = props.userinfo_signed_response_alg; + } + + @ApiProperty({ required: false, nullable: false }) + allowed_cors_origins: string[]; + + @ApiProperty() + audience: string[]; + + @ApiProperty() + authorization_code_grant_access_token_lifespan: string; + + @ApiProperty() + authorization_code_grant_id_token_lifespan: string; + + @ApiProperty() + authorization_code_grant_refresh_token_lifespan: string; + + @ApiProperty({ description: 'Boolean value specifying whether the RP requires that a sid (session ID) Claim.' }) + backchannel_logout_session_required: boolean; + + @ApiProperty({ description: 'RP URL that will cause the RP to log itself out when sent a Logout Token by the OP.' }) + backchannel_logout_uri: string; + + @ApiProperty() + client_credentials_grant_access_token_lifespan: string; + + @ApiProperty({ description: 'Id of the client.' }) + client_id: string; + + @ApiProperty({ description: 'Human-readable string name of the client presented to the end-user.' }) + client_name: string; + + @ApiProperty({ + description: + 'SecretExpiresAt is an integer holding the time at which the client secret will expire or 0 if it will not expire.', + }) + client_secret_expires_at: number; + + @ApiProperty({ description: 'ClientUri is an URL string of a web page providing information about the client.' }) + client_uri: string; + + @ApiProperty({ required: false, nullable: false }) + contacts: string[]; + + @ApiProperty({ description: 'CreatedAt returns the timestamp of the clients creation.' }) + created_at: string; + + @ApiProperty({ + description: + 'Boolean value specifying whether the RP requires that iss (issuer) and sid (session ID) query parameters.', + }) + frontchannel_logout_session_required: boolean; + + @ApiProperty({ description: 'RP URL that will cause the RP to log itself out when rendered in an iframe by the OP.' }) + frontchannel_logout_uri: string; + + @ApiProperty({ description: 'The grant types of the Oauth2 client.', required: false, nullable: false }) + grant_types: string[]; + + @ApiProperty() + implicit_grant_access_token_lifespan: string; + + @ApiProperty() + implicit_grant_id_token_lifespan: string; + + @ApiProperty() + jwks: object; + + @ApiProperty({ description: 'URL for the clients JSON Web Key Set [JWK] document' }) + jwks_uri: string; + + @ApiProperty() + jwt_bearer_grant_access_token_lifespan: string; + + @ApiProperty({ description: 'LogoUri is an URL string that references a logo for the client.' }) + logo_uri: string; + + @ApiProperty() + metadata: object; + + @ApiProperty({ description: 'Owner is a string identifying the owner of the OAuth 2.0 Client.' }) + owner: string; + + @ApiProperty() + password_grant_access_token_lifespan: string; + + @ApiProperty() + password_grant_refresh_token_lifespan: string; + + @ApiProperty({ description: 'PolicyUri is a URL string that points to a human-readable privacy policy document' }) + policy_uri: string; + + @ApiProperty({ required: false, nullable: false }) + post_logout_redirect_uris: string[]; + + @ApiProperty({ required: false, nullable: false }) + redirect_uris: string[]; + + @ApiProperty() + refresh_token_grant_access_token_lifespan: string; + + @ApiProperty() + refresh_token_grant_id_token_lifespan: string; + + @ApiProperty() + refresh_token_grant_refresh_token_lifespan: string; + + @ApiProperty({ description: 'RegistrationAccessToken can be used to update, get, or delete the OAuth2 Client.' }) + registration_access_token: string; + + @ApiProperty({ description: 'RegistrationClientURI is the URL used to update, get, or delete the OAuth2 Client.' }) + registration_client_uri: string; + + @ApiProperty({ + description: 'JWS [JWS] alg algorithm [JWA] that MUST be used for signing Request Objects sent to the OP.', + }) + request_object_signing_alg: string; + + @ApiProperty({ required: false, nullable: false }) + request_uris: string[]; + + @ApiProperty({ description: 'The response types of the Oauth2 client.', required: false, nullable: false }) + response_types: string[]; + + @ApiProperty({ + description: + 'Scope is a string containing a space-separated list of scope values (as described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use when requesting access tokens.', + }) + scope: string; + + @ApiProperty({ + description: 'URL using the https scheme to be used in calculating Pseudonymous Identifiers by the OP.', + }) + sector_identifier_uri: string; + + @ApiProperty({ + description: + 'SubjectType requested for responses to this Client. The subject_types_supported Discovery parameter contains a list of the supported subject_type values for this server. Valid types include pairwise and public.', + }) + subject_type: string; + + @ApiProperty() + token_endpoint_auth_method: string; + + @ApiProperty() + token_endpoint_auth_signing_alg: string; + + @ApiProperty({ + description: + 'TermsOfServiceUri is a URL string that points to a human-readable terms of service document for the client.', + }) + tos_uri: string; + + @ApiProperty({ description: 'UpdatedAt returns the timestamp of the last update.' }) + updated_at: string; + + @ApiProperty({ description: 'JWS alg algorithm [JWA] REQUIRED for signing UserInfo Responses. ' }) + userinfo_signed_response_alg: string; +} diff --git a/apps/server/src/modules/oauth-provider/api/dto/response/oidc-context.response.ts b/apps/server/src/modules/oauth-provider/api/dto/response/oidc-context.response.ts new file mode 100644 index 00000000000..68e039b7e20 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/dto/response/oidc-context.response.ts @@ -0,0 +1,28 @@ +import { Optional } from '@nestjs/common'; +import { ApiProperty } from '@nestjs/swagger'; + +export class OidcContextResponse { + constructor(props: OidcContextResponse) { + this.acr_values = props.acr_values; + this.display = props.display; + this.id_token_hint_claims = props.id_token_hint_claims; + this.login_hint = props.login_hint; + this.ui_locales = props.ui_locales; + } + + @ApiProperty() + acr_values: string[]; + + @ApiProperty() + display: string; + + @ApiProperty() + id_token_hint_claims: object; + + @ApiProperty() + login_hint: string; + + @Optional() + @ApiProperty() + ui_locales: string[]; +} diff --git a/apps/server/src/modules/oauth-provider/controller/dto/response/redirect.response.ts b/apps/server/src/modules/oauth-provider/api/dto/response/redirect.response.ts similarity index 100% rename from apps/server/src/modules/oauth-provider/controller/dto/response/redirect.response.ts rename to apps/server/src/modules/oauth-provider/api/dto/response/redirect.response.ts diff --git a/apps/server/src/modules/oauth-provider/api/index.ts b/apps/server/src/modules/oauth-provider/api/index.ts new file mode 100644 index 00000000000..f70ec1fcd3f --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/index.ts @@ -0,0 +1,6 @@ +export { OauthProviderLogoutFlowUc } from './oauth-provider.logout-flow.uc'; +export { OauthProviderUc } from './oauth-provider.uc'; +export { OauthProviderClientCrudUc } from './oauth-provider.client-crud.uc'; +export { OauthProviderController } from './oauth-provider.controller'; +export { OauthProviderLoginFlowUc } from './oauth-provider.login-flow.uc'; +export { OauthProviderConsentFlowUc } from './oauth-provider.consent-flow.uc'; diff --git a/apps/server/src/modules/oauth-provider/api/mapper/index.ts b/apps/server/src/modules/oauth-provider/api/mapper/index.ts new file mode 100644 index 00000000000..1786f10ec1a --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/mapper/index.ts @@ -0,0 +1,2 @@ +export { OauthProviderResponseMapper } from './oauth-provider-response.mapper'; +export { OauthProviderRequestMapper } from './oauth-provider-request.mapper'; diff --git a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.ts b/apps/server/src/modules/oauth-provider/api/mapper/oauth-provider-request.mapper.ts similarity index 66% rename from apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.ts rename to apps/server/src/modules/oauth-provider/api/mapper/oauth-provider-request.mapper.ts index aa8aa988408..d09da6cf583 100644 --- a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.ts +++ b/apps/server/src/modules/oauth-provider/api/mapper/oauth-provider-request.mapper.ts @@ -1,8 +1,8 @@ -import { AcceptLoginRequestBody } from '@infra/oauth-provider/dto'; -import { LoginRequestBody } from '@modules/oauth-provider/controller/dto'; +import { AcceptLoginRequestBody } from '../../domain'; +import { LoginRequestBody } from '../dto'; export class OauthProviderRequestMapper { - static mapCreateAcceptLoginRequestBody( + public static mapCreateAcceptLoginRequestBody( loginRequestBody: LoginRequestBody, currentUserId: string, pseudonym: string, diff --git a/apps/server/src/modules/oauth-provider/api/mapper/oauth-provider-response.mapper.ts b/apps/server/src/modules/oauth-provider/api/mapper/oauth-provider-response.mapper.ts new file mode 100644 index 00000000000..d732f077e6f --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/mapper/oauth-provider-response.mapper.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { + ProviderConsentResponse, + ProviderConsentSessionResponse, + ProviderLoginResponse, + ProviderOauthClient, + ProviderRedirectResponse, +} from '../../domain'; +import { ConsentResponse, ConsentSessionResponse, LoginResponse, OauthClientResponse, RedirectResponse } from '../dto'; + +@Injectable() +export class OauthProviderResponseMapper { + public static mapRedirectResponse(redirect: ProviderRedirectResponse): RedirectResponse { + const response: RedirectResponse = new RedirectResponse({ ...redirect }); + + return response; + } + + public static mapConsentResponse(consent: ProviderConsentResponse): ConsentResponse { + const response: ConsentResponse = new ConsentResponse({ ...consent }); + + return response; + } + + public static mapOauthClientResponse(oauthClient: ProviderOauthClient): OauthClientResponse { + delete oauthClient.client_secret; + + const response: OauthClientResponse = new OauthClientResponse({ ...oauthClient }); + + return response; + } + + public static mapConsentSessionsToResponse(session: ProviderConsentSessionResponse): ConsentSessionResponse { + const response: ConsentSessionResponse = new ConsentSessionResponse({ + client_id: session.consent_request.client.client_id, + client_name: session.consent_request.client.client_name, + challenge: session.consent_request.challenge, + }); + + return response; + } + + public static mapLoginResponse(providerLoginResponse: ProviderLoginResponse): LoginResponse { + const response: LoginResponse = new LoginResponse({ + client_id: providerLoginResponse.client.client_id, + ...providerLoginResponse, + }); + + return response; + } +} diff --git a/apps/server/src/modules/oauth-provider/api/oauth-provider.client-crud.uc.spec.ts b/apps/server/src/modules/oauth-provider/api/oauth-provider.client-crud.uc.spec.ts new file mode 100644 index 00000000000..f81265d4267 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/oauth-provider.client-crud.uc.spec.ts @@ -0,0 +1,376 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { UnauthorizedException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission } from '@shared/domain/interface'; +import { setupEntities, userFactory } from '@shared/testing'; +import { AuthorizationService } from '../../authorization'; +import { ProviderOauthClient } from '../domain'; +import { OauthProviderService } from '../domain/service/oauth-provider.service'; +import { providerOauthClientFactory } from '../testing'; +import { OauthProviderClientCrudUc } from './oauth-provider.client-crud.uc'; + +describe('OauthProviderUc', () => { + let module: TestingModule; + let uc: OauthProviderClientCrudUc; + + let providerService: DeepMocked; + let authorizationService: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + OauthProviderClientCrudUc, + { + provide: OauthProviderService, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(OauthProviderClientCrudUc); + providerService = module.get(OauthProviderService); + authorizationService = module.get(AuthorizationService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('listOAuth2Clients', () => { + describe('when listing all oauth2 clients', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const providerOauthClients: ProviderOauthClient[] = providerOauthClientFactory.buildList(1); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + providerService.listOAuth2Clients.mockResolvedValueOnce(providerOauthClients); + + return { + user, + providerOauthClients, + }; + }; + + it('should return list of oauth2 clients', async () => { + const { user, providerOauthClients } = setup(); + + const result: ProviderOauthClient[] = await uc.listOAuth2Clients(user.id, 1, 0, 'clientId', 'owner'); + + expect(result).toEqual(providerOauthClients); + }); + + it('should check permissions', async () => { + const { user } = setup(); + + await uc.listOAuth2Clients(user.id, 1, 0, 'clientId', 'owner'); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.OAUTH_CLIENT_VIEW]); + }); + + it('should call the external provider', async () => { + const { user } = setup(); + + await uc.listOAuth2Clients(user.id, 1, 0, 'clientId', 'owner'); + + expect(providerService.listOAuth2Clients).toHaveBeenCalledWith(1, 0, 'clientId', 'owner'); + }); + }); + + describe('when the user is not authorized', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const error = new UnauthorizedException(); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.checkAllPermissions.mockImplementation(() => { + throw error; + }); + + return { + user, + error, + }; + }; + + it('should throw an error', async () => { + const { user, error } = setup(); + + await expect(uc.listOAuth2Clients(user.id)).rejects.toThrow(error); + }); + }); + }); + + describe('getOAuth2Client', () => { + describe('when fetching a oauth2 client', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const providerOauthClient: ProviderOauthClient = providerOauthClientFactory.build(); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + providerService.getOAuth2Client.mockResolvedValueOnce(providerOauthClient); + + return { + user, + providerOauthClient, + }; + }; + + it('should return the oauth2 client', async () => { + const { user, providerOauthClient } = setup(); + + const result: ProviderOauthClient = await uc.getOAuth2Client(user.id, providerOauthClient.client_id); + + expect(result).toEqual(providerOauthClient); + }); + + it('should check permissions', async () => { + const { user, providerOauthClient } = setup(); + + await uc.getOAuth2Client(user.id, providerOauthClient.client_id); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.OAUTH_CLIENT_VIEW]); + }); + + it('should call the external provider', async () => { + const { user, providerOauthClient } = setup(); + + await uc.getOAuth2Client(user.id, providerOauthClient.client_id); + + expect(providerService.getOAuth2Client).toHaveBeenCalledWith(providerOauthClient.client_id); + }); + }); + + describe('when the user is not authorized', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const providerOauthClient: ProviderOauthClient = providerOauthClientFactory.build(); + const error = new UnauthorizedException(); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.checkAllPermissions.mockImplementation(() => { + throw error; + }); + + return { + user, + providerOauthClient, + error, + }; + }; + + it('should throw an error', async () => { + const { user, providerOauthClient, error } = setup(); + + await expect(uc.getOAuth2Client(user.id, providerOauthClient.client_id)).rejects.toThrow(error); + }); + }); + }); + + describe('createOAuth2Client', () => { + describe('when creating a oauth2 client', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const providerOauthClient: ProviderOauthClient = providerOauthClientFactory.build(); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + providerService.createOAuth2Client.mockResolvedValueOnce(providerOauthClient); + + return { + user, + providerOauthClient, + }; + }; + + it('should return the oauth2 client', async () => { + const { user, providerOauthClient } = setup(); + + const result: ProviderOauthClient = await uc.createOAuth2Client(user.id, providerOauthClient); + + expect(result).toEqual(providerOauthClient); + }); + + it('should check permissions', async () => { + const { user, providerOauthClient } = setup(); + + await uc.createOAuth2Client(user.id, providerOauthClient); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.OAUTH_CLIENT_EDIT]); + }); + + it('should call the external provider', async () => { + const { user, providerOauthClient } = setup(); + + await uc.createOAuth2Client(user.id, providerOauthClient); + + expect(providerService.createOAuth2Client).toHaveBeenCalledWith(providerOauthClient); + }); + }); + + describe('when the user is not authorized', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const providerOauthClient: ProviderOauthClient = providerOauthClientFactory.build(); + const error = new UnauthorizedException(); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.checkAllPermissions.mockImplementation(() => { + throw error; + }); + + return { + user, + providerOauthClient, + error, + }; + }; + + it('should throw an error', async () => { + const { user, providerOauthClient, error } = setup(); + + await expect(uc.createOAuth2Client(user.id, providerOauthClient)).rejects.toThrow(error); + }); + }); + }); + + describe('updateOAuth2Client', () => { + describe('when updating a oauth2 client', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const providerOauthClient: ProviderOauthClient = providerOauthClientFactory.build(); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + providerService.updateOAuth2Client.mockResolvedValueOnce(providerOauthClient); + + return { + user, + providerOauthClient, + }; + }; + + it('should return the oauth2 client', async () => { + const { user, providerOauthClient } = setup(); + + const result: ProviderOauthClient = await uc.updateOAuth2Client( + user.id, + providerOauthClient.client_id, + providerOauthClient + ); + + expect(result).toEqual(providerOauthClient); + }); + + it('should check permissions', async () => { + const { user, providerOauthClient } = setup(); + + await uc.updateOAuth2Client(user.id, providerOauthClient.client_id, providerOauthClient); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.OAUTH_CLIENT_EDIT]); + }); + + it('should call the external provider', async () => { + const { user, providerOauthClient } = setup(); + + await uc.updateOAuth2Client(user.id, providerOauthClient.client_id, providerOauthClient); + + expect(providerService.updateOAuth2Client).toHaveBeenCalledWith( + providerOauthClient.client_id, + providerOauthClient + ); + }); + }); + + describe('when the user is not authorized', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const providerOauthClient: ProviderOauthClient = providerOauthClientFactory.build(); + const error = new UnauthorizedException(); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.checkAllPermissions.mockImplementation(() => { + throw error; + }); + + return { + user, + providerOauthClient, + error, + }; + }; + + it('should throw an error', async () => { + const { user, providerOauthClient, error } = setup(); + + await expect( + uc.updateOAuth2Client(user.id, providerOauthClient.client_id, providerOauthClient) + ).rejects.toThrow(error); + }); + }); + }); + + describe('deleteOAuth2Client', () => { + describe('when updating a oauth2 client', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const providerOauthClient: ProviderOauthClient = providerOauthClientFactory.build(); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + return { + user, + providerOauthClient, + }; + }; + + it('should check permissions', async () => { + const { user, providerOauthClient } = setup(); + + await uc.deleteOAuth2Client(user.id, providerOauthClient.client_id); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.OAUTH_CLIENT_EDIT]); + }); + + it('should call the external provider', async () => { + const { user, providerOauthClient } = setup(); + + await uc.deleteOAuth2Client(user.id, providerOauthClient.client_id); + + expect(providerService.deleteOAuth2Client).toHaveBeenCalledWith(providerOauthClient.client_id); + }); + }); + + describe('when the user is not authorized', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const providerOauthClient: ProviderOauthClient = providerOauthClientFactory.build(); + const error = new UnauthorizedException(); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.checkAllPermissions.mockImplementation(() => { + throw error; + }); + + return { + user, + providerOauthClient, + error, + }; + }; + + it('should throw an error', async () => { + const { user, providerOauthClient, error } = setup(); + + await expect(uc.deleteOAuth2Client(user.id, providerOauthClient.client_id)).rejects.toThrow(error); + }); + }); + }); +}); diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.ts b/apps/server/src/modules/oauth-provider/api/oauth-provider.client-crud.uc.ts similarity index 65% rename from apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.ts rename to apps/server/src/modules/oauth-provider/api/oauth-provider.client-crud.uc.ts index 3dc637f89c1..bd7c5170a9d 100644 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.ts +++ b/apps/server/src/modules/oauth-provider/api/oauth-provider.client-crud.uc.ts @@ -1,10 +1,10 @@ -import { ProviderOauthClient } from '@infra/oauth-provider/dto'; -import { OauthProviderService } from '@infra/oauth-provider/index'; -import { ICurrentUser } from '@modules/authentication'; import { AuthorizationService } from '@modules/authorization'; import { Injectable } from '@nestjs/common'; import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { ProviderOauthClient } from '../domain'; +import { OauthProviderService } from '../domain/service/oauth-provider.service'; @Injectable() export class OauthProviderClientCrudUc { @@ -13,21 +13,21 @@ export class OauthProviderClientCrudUc { private readonly authorizationService: AuthorizationService ) {} - private readonly defaultOauthClientBody: ProviderOauthClient = { + private readonly defaultOauthClientBody: Readonly> = { scope: 'openid offline', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code', 'token', 'id_token'], redirect_uris: [], }; - async listOAuth2Clients( - currentUser: ICurrentUser, + public async listOAuth2Clients( + userId: EntityId, limit?: number, offset?: number, client_name?: string, owner?: string ): Promise { - const user: User = await this.authorizationService.getUserWithPermissions(currentUser.userId); + const user: User = await this.authorizationService.getUserWithPermissions(userId); this.authorizationService.checkAllPermissions(user, [Permission.OAUTH_CLIENT_VIEW]); const client: ProviderOauthClient[] = await this.oauthProviderService.listOAuth2Clients( @@ -36,11 +36,12 @@ export class OauthProviderClientCrudUc { client_name, owner ); + return client; } - async getOAuth2Client(currentUser: ICurrentUser, id: string): Promise { - const user: User = await this.authorizationService.getUserWithPermissions(currentUser.userId); + public async getOAuth2Client(userId: EntityId, id: string): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); this.authorizationService.checkAllPermissions(user, [Permission.OAUTH_CLIENT_VIEW]); const client: ProviderOauthClient = await this.oauthProviderService.getOAuth2Client(id); @@ -48,32 +49,42 @@ export class OauthProviderClientCrudUc { return client; } - async createOAuth2Client(currentUser: ICurrentUser, data: ProviderOauthClient): Promise { - const user: User = await this.authorizationService.getUserWithPermissions(currentUser.userId); + public async createOAuth2Client(userId: EntityId, data: Partial): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); this.authorizationService.checkAllPermissions(user, [Permission.OAUTH_CLIENT_EDIT]); - const dataWithDefaults: ProviderOauthClient = { ...this.defaultOauthClientBody, ...data }; + const dataWithDefaults: Partial = { + ...this.defaultOauthClientBody, + ...data, + }; + const client: ProviderOauthClient = await this.oauthProviderService.createOAuth2Client(dataWithDefaults); + return client; } - async updateOAuth2Client( - currentUser: ICurrentUser, + public async updateOAuth2Client( + userId: EntityId, id: string, - data: ProviderOauthClient + data: Partial ): Promise { - const user: User = await this.authorizationService.getUserWithPermissions(currentUser.userId); + const user: User = await this.authorizationService.getUserWithPermissions(userId); this.authorizationService.checkAllPermissions(user, [Permission.OAUTH_CLIENT_EDIT]); - const dataWithDefaults: ProviderOauthClient = { ...this.defaultOauthClientBody, ...data }; + const dataWithDefaults: Partial = { + ...this.defaultOauthClientBody, + ...data, + }; + const client: ProviderOauthClient = await this.oauthProviderService.updateOAuth2Client(id, dataWithDefaults); + return client; } - async deleteOAuth2Client(currentUser: ICurrentUser, id: string): Promise { - const user: User = await this.authorizationService.getUserWithPermissions(currentUser.userId); + public async deleteOAuth2Client(userId: EntityId, id: string): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); this.authorizationService.checkAllPermissions(user, [Permission.OAUTH_CLIENT_EDIT]); - return this.oauthProviderService.deleteOAuth2Client(id); + await this.oauthProviderService.deleteOAuth2Client(id); } } diff --git a/apps/server/src/modules/oauth-provider/api/oauth-provider.consent-flow.uc.ts b/apps/server/src/modules/oauth-provider/api/oauth-provider.consent-flow.uc.ts new file mode 100644 index 00000000000..1e20d3bf3b1 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/oauth-provider.consent-flow.uc.ts @@ -0,0 +1,87 @@ +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { + AcceptConsentRequestBody, + IdToken, + ProviderConsentResponse, + ProviderRedirectResponse, + RejectRequestBody, +} from '../domain'; +import { IdTokenService } from '../domain/service/id-token.service'; +import { OauthProviderService } from '../domain/service/oauth-provider.service'; +import { ConsentRequestBody } from './dto'; + +@Injectable() +export class OauthProviderConsentFlowUc { + constructor( + private readonly oauthProviderService: OauthProviderService, + private readonly idTokenService: IdTokenService + ) {} + + public async getConsentRequest(challenge: string): Promise { + const consentResponse: ProviderConsentResponse = await this.oauthProviderService.getConsentRequest(challenge); + + return consentResponse; + } + + public async patchConsentRequest( + userId: EntityId, + challenge: string, + accept: boolean, + body: ConsentRequestBody + ): Promise { + const consentResponse: ProviderConsentResponse = await this.oauthProviderService.getConsentRequest(challenge); + + this.validateSubject(userId, consentResponse); + + let response: ProviderRedirectResponse; + if (accept) { + response = await this.acceptConsentRequest( + userId, + challenge, + body, + consentResponse.requested_scope, + consentResponse.client.client_id + ); + } else { + response = await this.rejectConsentRequest(challenge, body); + } + + return response; + } + + private async rejectConsentRequest(challenge: string, body: RejectRequestBody): Promise { + const redirectResponse: ProviderRedirectResponse = await this.oauthProviderService.rejectConsentRequest( + challenge, + body + ); + + return redirectResponse; + } + + private async acceptConsentRequest( + userId: EntityId, + challenge: string, + body: AcceptConsentRequestBody, + requested_scope: string[], + client_id: string + ): Promise { + const idToken: IdToken = await this.idTokenService.createIdToken(userId, requested_scope, client_id); + body.session = { + id_token: idToken, + }; + + const redirectResponse: ProviderRedirectResponse = await this.oauthProviderService.acceptConsentRequest( + challenge, + body + ); + + return redirectResponse; + } + + private validateSubject(userId: EntityId, response: ProviderConsentResponse): void { + if (response.subject !== userId) { + throw new ForbiddenException("You want to patch another user's consent"); + } + } +} diff --git a/apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.ts b/apps/server/src/modules/oauth-provider/api/oauth-provider.controller.ts similarity index 55% rename from apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.ts rename to apps/server/src/modules/oauth-provider/api/oauth-provider.controller.ts index 29644154a3a..bebc4771e5e 100644 --- a/apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.ts +++ b/apps/server/src/modules/oauth-provider/api/oauth-provider.controller.ts @@ -1,36 +1,35 @@ -import { Configuration } from '@hpi-schul-cloud/commons'; import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; -import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; -// import should be @infra/oauth-provider +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { ProviderConsentResponse, ProviderConsentSessionResponse, ProviderLoginResponse, ProviderOauthClient, ProviderRedirectResponse, -} from '@infra/oauth-provider/dto'; -import { ApiTags } from '@nestjs/swagger'; -import { OauthProviderResponseMapper } from '../mapper/oauth-provider-response.mapper'; -import { OauthProviderClientCrudUc } from '../uc/oauth-provider.client-crud.uc'; -import { OauthProviderConsentFlowUc } from '../uc/oauth-provider.consent-flow.uc'; -import { OauthProviderLoginFlowUc } from '../uc/oauth-provider.login-flow.uc'; -import { OauthProviderLogoutFlowUc } from '../uc/oauth-provider.logout-flow.uc'; -import { OauthProviderUc } from '../uc/oauth-provider.uc'; +} from '../domain'; import { AcceptQuery, ChallengeParams, ConsentRequestBody, + ConsentResponse, ConsentSessionResponse, IdParams, ListOauthClientsParams, LoginRequestBody, LoginResponse, - OauthClientBody, + OauthClientCreateBody, OauthClientResponse, + OauthClientUpdateBody, + RedirectResponse, RevokeConsentParams, } from './dto'; -import { ConsentResponse } from './dto/response/consent.response'; -import { RedirectResponse } from './dto/response/redirect.response'; +import { OauthProviderResponseMapper } from './mapper'; +import { OauthProviderClientCrudUc } from './oauth-provider.client-crud.uc'; +import { OauthProviderConsentFlowUc } from './oauth-provider.consent-flow.uc'; +import { OauthProviderLoginFlowUc } from './oauth-provider.login-flow.uc'; +import { OauthProviderLogoutFlowUc } from './oauth-provider.logout-flow.uc'; +import { OauthProviderUc } from './oauth-provider.uc'; @Controller('oauth2') @ApiTags('Oauth2') @@ -40,81 +39,91 @@ export class OauthProviderController { private readonly logoutFlowUc: OauthProviderLogoutFlowUc, private readonly crudUc: OauthProviderClientCrudUc, private readonly oauthProviderUc: OauthProviderUc, - private readonly oauthProviderLoginFlowUc: OauthProviderLoginFlowUc, - private readonly oauthProviderResponseMapper: OauthProviderResponseMapper + private readonly oauthProviderLoginFlowUc: OauthProviderLoginFlowUc ) {} @Authenticate('jwt') @Get('clients/:id') - async getOAuth2Client( + public async getOAuth2Client( @CurrentUser() currentUser: ICurrentUser, @Param() params: IdParams ): Promise { - const client: ProviderOauthClient = await this.crudUc.getOAuth2Client(currentUser, params.id); - const mapped: OauthClientResponse = this.oauthProviderResponseMapper.mapOauthClientResponse(client); + const client: ProviderOauthClient = await this.crudUc.getOAuth2Client(currentUser.userId, params.id); + + const mapped: OauthClientResponse = OauthProviderResponseMapper.mapOauthClientResponse(client); + return mapped; } @Authenticate('jwt') @Get('clients') - async listOAuth2Clients( + public async listOAuth2Clients( @CurrentUser() currentUser: ICurrentUser, @Param() params: ListOauthClientsParams ): Promise { const clients: ProviderOauthClient[] = await this.crudUc.listOAuth2Clients( - currentUser, + currentUser.userId, params.limit, params.offset, params.client_name, params.owner ); + const mapped: OauthClientResponse[] = clients.map( - (client: ProviderOauthClient): OauthClientResponse => - this.oauthProviderResponseMapper.mapOauthClientResponse(client) + (client: ProviderOauthClient): OauthClientResponse => OauthProviderResponseMapper.mapOauthClientResponse(client) ); + return mapped; } @Authenticate('jwt') @Post('clients') - async createOAuth2Client( + public async createOAuth2Client( @CurrentUser() currentUser: ICurrentUser, - @Body() body: OauthClientBody + @Body() body: OauthClientCreateBody ): Promise { - const client: ProviderOauthClient = await this.crudUc.createOAuth2Client(currentUser, body); - const mapped: OauthClientResponse = this.oauthProviderResponseMapper.mapOauthClientResponse(client); + const client: ProviderOauthClient = await this.crudUc.createOAuth2Client(currentUser.userId, body); + + const mapped: OauthClientResponse = OauthProviderResponseMapper.mapOauthClientResponse(client); + return mapped; } @Authenticate('jwt') @Put('clients/:id') - async updateOAuth2Client( + public async updateOAuth2Client( @CurrentUser() currentUser: ICurrentUser, @Param() params: IdParams, - @Body() body: OauthClientBody + @Body() body: OauthClientUpdateBody ): Promise { - const client: ProviderOauthClient = await this.crudUc.updateOAuth2Client(currentUser, params.id, body); - const mapped: OauthClientResponse = this.oauthProviderResponseMapper.mapOauthClientResponse(client); + const client: ProviderOauthClient = await this.crudUc.updateOAuth2Client(currentUser.userId, params.id, body); + + const mapped: OauthClientResponse = OauthProviderResponseMapper.mapOauthClientResponse(client); + return mapped; } + @HttpCode(HttpStatus.NO_CONTENT) @Authenticate('jwt') @Delete('clients/:id') - deleteOAuth2Client(@CurrentUser() currentUser: ICurrentUser, @Param() params: IdParams): Promise { - const promise: Promise = this.crudUc.deleteOAuth2Client(currentUser, params.id); + public deleteOAuth2Client(@CurrentUser() currentUser: ICurrentUser, @Param() params: IdParams): Promise { + const promise: Promise = this.crudUc.deleteOAuth2Client(currentUser.userId, params.id); + return promise; } @Get('loginRequest/:challenge') - async getLoginRequest(@Param() params: ChallengeParams): Promise { + public async getLoginRequest(@Param() params: ChallengeParams): Promise { const loginResponse: ProviderLoginResponse = await this.oauthProviderLoginFlowUc.getLoginRequest(params.challenge); - const mapped: LoginResponse = this.oauthProviderResponseMapper.mapLoginResponse(loginResponse); + + const mapped: LoginResponse = OauthProviderResponseMapper.mapLoginResponse(loginResponse); + return mapped; } @Authenticate('jwt') @Patch('loginRequest/:challenge') - async patchLoginRequest( + public async patchLoginRequest( @Param() params: ChallengeParams, @Query() query: AcceptQuery, @Body() body: LoginRequestBody, @@ -126,66 +135,75 @@ export class OauthProviderController { body, query ); - const mapped: RedirectResponse = this.oauthProviderResponseMapper.mapRedirectResponse(redirectResponse); + + const mapped: RedirectResponse = OauthProviderResponseMapper.mapRedirectResponse(redirectResponse); + return mapped; } @Authenticate('jwt') @Patch('logoutRequest/:challenge') - async acceptLogoutRequest(@Param() params: ChallengeParams): Promise { + public async acceptLogoutRequest(@Param() params: ChallengeParams): Promise { const redirect: ProviderRedirectResponse = await this.logoutFlowUc.logoutFlow(params.challenge); - const mapped: RedirectResponse = this.oauthProviderResponseMapper.mapRedirectResponse(redirect); + + const mapped: RedirectResponse = OauthProviderResponseMapper.mapRedirectResponse(redirect); + return mapped; } @Authenticate('jwt') @Get('consentRequest/:challenge') - async getConsentRequest(@Param() params: ChallengeParams): Promise { + public async getConsentRequest(@Param() params: ChallengeParams): Promise { const consentRequest: ProviderConsentResponse = await this.consentFlowUc.getConsentRequest(params.challenge); - const mapped: ConsentResponse = this.oauthProviderResponseMapper.mapConsentResponse(consentRequest); + + const mapped: ConsentResponse = OauthProviderResponseMapper.mapConsentResponse(consentRequest); + return mapped; } @Authenticate('jwt') @Patch('consentRequest/:challenge') - async patchConsentRequest( + public async patchConsentRequest( @Param() params: ChallengeParams, @Query() query: AcceptQuery, @Body() body: ConsentRequestBody, @CurrentUser() currentUser: ICurrentUser ): Promise { const redirectResponse: ProviderRedirectResponse = await this.consentFlowUc.patchConsentRequest( + currentUser.userId, params.challenge, - query, - body, - currentUser + query.accept, + body ); - const response: RedirectResponse = this.oauthProviderResponseMapper.mapRedirectResponse(redirectResponse); + + const response: RedirectResponse = OauthProviderResponseMapper.mapRedirectResponse(redirectResponse); + return response; } @Authenticate('jwt') @Get('auth/sessions/consent') - async listConsentSessions(@CurrentUser() currentUser: ICurrentUser): Promise { + public async listConsentSessions(@CurrentUser() currentUser: ICurrentUser): Promise { const sessions: ProviderConsentSessionResponse[] = await this.oauthProviderUc.listConsentSessions( currentUser.userId ); + const mapped: ConsentSessionResponse[] = sessions.map( (session: ProviderConsentSessionResponse): ConsentSessionResponse => - this.oauthProviderResponseMapper.mapConsentSessionsToResponse(session) + OauthProviderResponseMapper.mapConsentSessionsToResponse(session) ); + return mapped; } @Authenticate('jwt') @Delete('auth/sessions/consent') - revokeConsentSession(@CurrentUser() currentUser: ICurrentUser, @Param() params: RevokeConsentParams): Promise { - const promise: Promise = this.oauthProviderUc.revokeConsentSession(currentUser.userId, params.client); - return promise; - } + public revokeConsentSession( + @CurrentUser() currentUser: ICurrentUser, + @Query() query: RevokeConsentParams + ): Promise { + const promise: Promise = this.oauthProviderUc.revokeConsentSession(currentUser.userId, query.client); - @Get('baseUrl') - getUrl(): Promise { - return Promise.resolve(Configuration.get('HYDRA_URI') as string); + return promise; } } diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.login-flow.uc.spec.ts b/apps/server/src/modules/oauth-provider/api/oauth-provider.login-flow.uc.spec.ts similarity index 84% rename from apps/server/src/modules/oauth-provider/uc/oauth-provider.login-flow.uc.spec.ts rename to apps/server/src/modules/oauth-provider/api/oauth-provider.login-flow.uc.spec.ts index 8133b73aae2..698e2a9908e 100644 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.login-flow.uc.spec.ts +++ b/apps/server/src/modules/oauth-provider/api/oauth-provider.login-flow.uc.spec.ts @@ -1,10 +1,4 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { OauthProviderService } from '@infra/oauth-provider'; -import { ProviderLoginResponse, ProviderRedirectResponse } from '@infra/oauth-provider/dto'; -import { AuthorizationService } from '@modules/authorization'; -import { PseudonymService } from '@modules/pseudonym'; -import { ExternalTool } from '@modules/tool/external-tool/domain'; -import { UserService } from '@modules/user'; import { InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { LtiToolDO, Pseudonym, UserDO } from '@shared/domain/domainobject'; @@ -17,8 +11,15 @@ import { userDoFactory, userFactory, } from '@shared/testing'; -import { AcceptQuery, LoginRequestBody, OAuthRejectableBody } from '../controller/dto'; -import { OauthProviderLoginFlowService } from '../service/oauth-provider.login-flow.service'; +import { AuthorizationService } from '../../authorization'; +import { PseudonymService } from '../../pseudonym'; +import { ExternalTool } from '../../tool/external-tool/domain'; +import { UserService } from '../../user'; +import { ProviderLoginResponse, ProviderRedirectResponse } from '../domain'; +import { OauthProviderLoginFlowService } from '../domain/service/oauth-provider.login-flow.service'; +import { OauthProviderService } from '../domain/service/oauth-provider.service'; +import { providerLoginResponseFactory } from '../testing'; +import { AcceptQuery, LoginRequestBody, OAuthRejectableBody } from './dto'; import { OauthProviderLoginFlowUc } from './oauth-provider.login-flow.uc'; describe('OauthProviderLoginFlowUc', () => { @@ -82,35 +83,37 @@ describe('OauthProviderLoginFlowUc', () => { }); describe('getLoginRequest', () => { - const setup = () => { - const providerLoginResponse: ProviderLoginResponse = { - challenge: 'challenge', - client: { - client_id: 'clientId', - }, - oidc_context: {}, - request_url: 'request_url', - requested_access_token_audience: ['requested_access_token_audience'], - requested_scope: ['requested_scope'], - session_id: 'session_id', - skip: true, - subject: 'subject', - }; + describe('when fetching a login request', () => { + const setup = () => { + const providerLoginResponse: ProviderLoginResponse = providerLoginResponseFactory.build({ + challenge: 'challenge', + client: { + client_id: 'clientId', + }, + oidc_context: {}, + request_url: 'request_url', + requested_access_token_audience: ['requested_access_token_audience'], + requested_scope: ['requested_scope'], + session_id: 'session_id', + skip: true, + subject: 'subject', + }); - oauthProviderService.getLoginRequest.mockResolvedValue(providerLoginResponse); + oauthProviderService.getLoginRequest.mockResolvedValue(providerLoginResponse); - return { - providerLoginResponse, + return { + providerLoginResponse, + }; }; - }; - it('should get the login request', async () => { - const { providerLoginResponse } = setup(); + it('should get the login request', async () => { + const { providerLoginResponse } = setup(); - const result: ProviderLoginResponse = await uc.getLoginRequest('challenge'); + const result: ProviderLoginResponse = await uc.getLoginRequest('challenge'); - expect(oauthProviderService.getLoginRequest).toHaveBeenCalledWith('challenge'); - expect(result).toEqual(providerLoginResponse); + expect(oauthProviderService.getLoginRequest).toHaveBeenCalledWith('challenge'); + expect(result).toEqual(providerLoginResponse); + }); }); }); @@ -124,7 +127,7 @@ describe('OauthProviderLoginFlowUc', () => { remember_for: 0, }; - const providerLoginResponse: ProviderLoginResponse = { + const providerLoginResponse: ProviderLoginResponse = providerLoginResponseFactory.build({ challenge: 'challenge', client: { client_id: 'clientId', @@ -136,7 +139,7 @@ describe('OauthProviderLoginFlowUc', () => { session_id: 'session_id', skip: true, subject: 'subject', - }; + }); const user: UserDO = userDoFactory.buildWithId(); const tool: ExternalTool = externalToolFactory.withOauth2Config({ skipConsent: true }).buildWithId(); @@ -211,7 +214,7 @@ describe('OauthProviderLoginFlowUc', () => { remember_for: 0, }; - const providerLoginResponse: ProviderLoginResponse = { + const providerLoginResponse: ProviderLoginResponse = providerLoginResponseFactory.build({ challenge: 'challenge', client: { client_id: 'clientId', @@ -223,7 +226,7 @@ describe('OauthProviderLoginFlowUc', () => { session_id: 'session_id', skip: true, subject: 'subject', - }; + }); const tool: LtiToolDO = ltiToolDOFactory.buildWithId({ skipConsent: true }); @@ -277,7 +280,7 @@ describe('OauthProviderLoginFlowUc', () => { remember_for: 0, }; - const providerLoginResponse: ProviderLoginResponse = { + const providerLoginResponse: ProviderLoginResponse = providerLoginResponseFactory.build({ challenge: 'challenge', client: { client_id: 'clientId', @@ -289,7 +292,7 @@ describe('OauthProviderLoginFlowUc', () => { session_id: 'session_id', skip: true, subject: 'subject', - }; + }); const tool: ExternalTool = externalToolFactory.withOauth2Config().buildWithId({ name: 'SchulcloudNextcloud' }); @@ -318,46 +321,6 @@ describe('OauthProviderLoginFlowUc', () => { }); }); - describe('when login response has no client id', () => { - const setup = () => { - const query: AcceptQuery = { accept: true }; - - const loginRequestBodyMock: LoginRequestBody = { - remember: true, - remember_for: 0, - }; - - const providerLoginResponse: ProviderLoginResponse = { - challenge: 'challenge', - client: {}, - oidc_context: {}, - request_url: 'request_url', - requested_access_token_audience: ['requested_access_token_audience'], - requested_scope: ['requested_scope'], - session_id: 'session_id', - skip: true, - subject: 'subject', - }; - - oauthProviderService.getLoginRequest.mockResolvedValue(providerLoginResponse); - - return { - query, - loginRequestBodyMock, - }; - }; - - it('should throw an InternalServerErrorException', async () => { - const { query, loginRequestBodyMock } = setup(); - - const func = async () => uc.patchLoginRequest('userId', 'challenge', loginRequestBodyMock, query); - - await expect(func).rejects.toThrow( - new InternalServerErrorException(`Cannot find oAuthClientId in login response for challenge: challenge`) - ); - }); - }); - describe('when the loaded tool has no id', () => { const setup = () => { const query: AcceptQuery = { accept: true }; @@ -367,7 +330,7 @@ describe('OauthProviderLoginFlowUc', () => { remember_for: 0, }; - const providerLoginResponse: ProviderLoginResponse = { + const providerLoginResponse: ProviderLoginResponse = providerLoginResponseFactory.build({ challenge: 'challenge', client: { client_id: 'clientId', @@ -379,7 +342,7 @@ describe('OauthProviderLoginFlowUc', () => { session_id: 'session_id', skip: true, subject: 'subject', - }; + }); const tool: ExternalTool = externalToolFactory.withOauth2Config().build({ id: undefined }); @@ -410,7 +373,7 @@ describe('OauthProviderLoginFlowUc', () => { remember_for: 0, }; - const providerLoginResponse: ProviderLoginResponse = { + const providerLoginResponse: ProviderLoginResponse = providerLoginResponseFactory.build({ challenge: 'challenge', client: { client_id: 'clientId', @@ -422,7 +385,7 @@ describe('OauthProviderLoginFlowUc', () => { session_id: 'session_id', skip: true, subject: 'subject', - }; + }); const tool: ExternalTool = externalToolFactory.buildWithId(); diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.login-flow.uc.ts b/apps/server/src/modules/oauth-provider/api/oauth-provider.login-flow.uc.ts similarity index 83% rename from apps/server/src/modules/oauth-provider/uc/oauth-provider.login-flow.uc.ts rename to apps/server/src/modules/oauth-provider/api/oauth-provider.login-flow.uc.ts index 0fd72d5dff1..33905135046 100644 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.login-flow.uc.ts +++ b/apps/server/src/modules/oauth-provider/api/oauth-provider.login-flow.uc.ts @@ -1,8 +1,4 @@ -import { OauthProviderService } from '@infra/oauth-provider'; -import { AcceptLoginRequestBody, ProviderLoginResponse, ProviderRedirectResponse } from '@infra/oauth-provider/dto'; import { AuthorizationService } from '@modules/authorization'; -import { AcceptQuery, LoginRequestBody, OAuthRejectableBody } from '@modules/oauth-provider/controller/dto'; -import { OauthProviderRequestMapper } from '@modules/oauth-provider/mapper/oauth-provider-request.mapper'; import { PseudonymService } from '@modules/pseudonym/service'; import { ExternalTool, Oauth2ToolConfig } from '@modules/tool/external-tool/domain'; import { UserService } from '@modules/user'; @@ -11,7 +7,11 @@ import { Pseudonym, UserDO } from '@shared/domain/domainobject'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { OauthProviderLoginFlowService } from '../service/oauth-provider.login-flow.service'; +import { AcceptLoginRequestBody, ProviderLoginResponse, ProviderRedirectResponse } from '../domain'; +import { OauthProviderLoginFlowService } from '../domain/service/oauth-provider.login-flow.service'; +import { OauthProviderService } from '../domain/service/oauth-provider.service'; +import { AcceptQuery, LoginRequestBody, OAuthRejectableBody } from './dto'; +import { OauthProviderRequestMapper } from './mapper'; @Injectable() export class OauthProviderLoginFlowUc { @@ -23,23 +23,26 @@ export class OauthProviderLoginFlowUc { private readonly userService: UserService ) {} - async getLoginRequest(challenge: string): Promise { - const loginResponse: Promise = this.oauthProviderService.getLoginRequest(challenge); + public async getLoginRequest(challenge: string): Promise { + const loginResponse: ProviderLoginResponse = await this.oauthProviderService.getLoginRequest(challenge); + return loginResponse; } - async patchLoginRequest( + public async patchLoginRequest( currentUserId: string, challenge: string, body: LoginRequestBody, query: AcceptQuery ): Promise { let redirectResponse: ProviderRedirectResponse; + if (query.accept) { redirectResponse = await this.acceptLoginRequest(currentUserId, challenge, body); } else { redirectResponse = await this.rejectLoginRequest(challenge, body); } + return redirectResponse; } @@ -50,10 +53,6 @@ export class OauthProviderLoginFlowUc { ): Promise { const loginResponse: ProviderLoginResponse = await this.oauthProviderService.getLoginRequest(challenge); - if (!loginResponse.client.client_id) { - throw new InternalServerErrorException(`Cannot find oAuthClientId in login response for challenge: ${challenge}`); - } - const tool: ExternalTool | LtiToolDO = await this.oauthProviderLoginFlowService.findToolByClientId( loginResponse.client.client_id ); @@ -109,6 +108,7 @@ export class OauthProviderLoginFlowUc { challenge, rejectRequestBody ); + return redirectResponse; } } diff --git a/apps/server/src/modules/oauth-provider/api/oauth-provider.logout-flow.uc.spec.ts b/apps/server/src/modules/oauth-provider/api/oauth-provider.logout-flow.uc.spec.ts new file mode 100644 index 00000000000..61fb1e9d6a9 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/oauth-provider.logout-flow.uc.spec.ts @@ -0,0 +1,63 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ProviderRedirectResponse } from '../domain'; +import { OauthProviderService } from '../domain/service/oauth-provider.service'; +import { OauthProviderLogoutFlowUc } from './oauth-provider.logout-flow.uc'; + +describe('OauthProviderUc', () => { + let module: TestingModule; + let uc: OauthProviderLogoutFlowUc; + + let oauthProviderService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + OauthProviderLogoutFlowUc, + { + provide: OauthProviderService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(OauthProviderLogoutFlowUc); + oauthProviderService = module.get(OauthProviderService); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('logoutFlow', () => { + describe('when logging out a user', () => { + const setup = () => { + const challenge = 'challenge_mock'; + const redirectResponse: ProviderRedirectResponse = { redirect_to: 'mockredirect' }; + + oauthProviderService.acceptLogoutRequest.mockResolvedValueOnce(redirectResponse); + + return { + challenge, + redirectResponse, + }; + }; + + it('should call the external provider', async () => { + const { challenge } = setup(); + + await uc.logoutFlow(challenge); + + expect(oauthProviderService.acceptLogoutRequest).toHaveBeenCalledWith(challenge); + }); + + it('should return a logout response', async () => { + const { challenge, redirectResponse } = setup(); + + const result: ProviderRedirectResponse = await uc.logoutFlow(challenge); + + expect(result).toEqual(redirectResponse); + }); + }); + }); +}); diff --git a/apps/server/src/modules/oauth-provider/api/oauth-provider.logout-flow.uc.ts b/apps/server/src/modules/oauth-provider/api/oauth-provider.logout-flow.uc.ts new file mode 100644 index 00000000000..e70a1f9fabc --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/oauth-provider.logout-flow.uc.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { ProviderRedirectResponse } from '../domain'; +import { OauthProviderService } from '../domain/service/oauth-provider.service'; + +@Injectable() +export class OauthProviderLogoutFlowUc { + constructor(private readonly oauthProviderService: OauthProviderService) {} + + public async logoutFlow(challenge: string): Promise { + const logoutResponse: ProviderRedirectResponse = await this.oauthProviderService.acceptLogoutRequest(challenge); + + return logoutResponse; + } +} diff --git a/apps/server/src/modules/oauth-provider/api/oauth-provider.uc.spec.ts b/apps/server/src/modules/oauth-provider/api/oauth-provider.uc.spec.ts new file mode 100644 index 00000000000..99fba39267a --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/oauth-provider.uc.spec.ts @@ -0,0 +1,87 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ProviderConsentSessionResponse } from '../domain'; +import { OauthProviderService } from '../domain/service/oauth-provider.service'; +import { providerConsentSessionResponseFactory } from '../testing'; +import { OauthProviderUc } from './oauth-provider.uc'; + +describe('OauthProviderUc', () => { + let module: TestingModule; + let uc: OauthProviderUc; + + let providerService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + OauthProviderUc, + { + provide: OauthProviderService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(OauthProviderUc); + providerService = module.get(OauthProviderService); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('listConsentSessions', () => { + describe('when listing a users consent sessions', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const sessions: ProviderConsentSessionResponse[] = providerConsentSessionResponseFactory.buildList(2); + + providerService.listConsentSessions.mockResolvedValueOnce(sessions); + + return { + userId, + sessions, + }; + }; + + it('should call the external provider', async () => { + const { userId } = setup(); + + await uc.listConsentSessions(userId); + + expect(providerService.listConsentSessions).toHaveBeenCalledWith(userId); + }); + + it('should list all consent sessions', async () => { + const { userId, sessions } = setup(); + + const result: ProviderConsentSessionResponse[] = await uc.listConsentSessions(userId); + + expect(result).toEqual(sessions); + }); + }); + }); + + describe('revokeConsentSession', () => { + describe('when revoking a users consent session', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const clientId = 'clientId'; + + return { + userId, + clientId, + }; + }; + + it('should revoke all consent sessions', async () => { + const { userId, clientId } = setup(); + + await uc.revokeConsentSession(userId, clientId); + + expect(providerService.revokeConsentSession).toHaveBeenCalledWith(userId, clientId); + }); + }); + }); +}); diff --git a/apps/server/src/modules/oauth-provider/api/oauth-provider.uc.ts b/apps/server/src/modules/oauth-provider/api/oauth-provider.uc.ts new file mode 100644 index 00000000000..29141af07e8 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/oauth-provider.uc.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { ProviderConsentSessionResponse } from '../domain'; +import { OauthProviderService } from '../domain/service/oauth-provider.service'; + +@Injectable() +export class OauthProviderUc { + constructor(private readonly oauthProviderService: OauthProviderService) {} + + public async listConsentSessions(userId: EntityId): Promise { + const sessions: ProviderConsentSessionResponse[] = await this.oauthProviderService.listConsentSessions(userId); + + return sessions; + } + + public async revokeConsentSession(userId: EntityId, clientId: string): Promise { + await this.oauthProviderService.revokeConsentSession(userId, clientId); + } +} diff --git a/apps/server/src/modules/oauth-provider/api/test/oauth-provider.controller.api.spec.ts b/apps/server/src/modules/oauth-provider/api/test/oauth-provider.controller.api.spec.ts new file mode 100644 index 00000000000..2656ac8b452 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/api/test/oauth-provider.controller.api.spec.ts @@ -0,0 +1,613 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { Permission } from '@shared/domain/interface'; +import { + cleanupCollections, + externalToolEntityFactory, + externalToolPseudonymEntityFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { + ProviderConsentResponse, + ProviderConsentSessionResponse, + ProviderLoginResponse, + ProviderOauthClient, + ProviderRedirectResponse, +} from '../../domain'; +import { + providerConsentResponseFactory, + providerConsentSessionResponseFactory, + providerLoginResponseFactory, + providerOauthClientFactory, +} from '../../testing'; +import { OauthProviderController } from '../oauth-provider.controller'; + +describe(OauthProviderController.name, () => { + let app: INestApplication; + let em: EntityManager; + let axiosMock: MockAdapter; + let testApiClient: TestApiClient; + + let hydraUri: string; + + beforeAll(async () => { + hydraUri = Configuration.get('HYDRA_URI') as string; + + const moduleFixture = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'oauth2'); + }); + + beforeEach(async () => { + await cleanupCollections(em); + axiosMock = new MockAdapter(axios); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getOAuth2Client', () => { + describe('when no user is logged in', () => { + it('should return unauthorized', async () => { + const clientId = 'oauth2ClientId'; + + const response = await testApiClient.get(`clients/${clientId}`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when reading an oauth2 client', () => { + const setup = async () => { + const clientId = 'oauth2ClientId'; + const client: ProviderOauthClient = providerOauthClientFactory.build({ + client_id: clientId, + }); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin(undefined, [ + Permission.OAUTH_CLIENT_VIEW, + Permission.OAUTH_CLIENT_EDIT, + ]); + + axiosMock.onGet(`${hydraUri}/clients/${clientId}`).replyOnce(HttpStatus.OK, client); + + await em.persistAndFlush([adminAccount, adminUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + clientId, + loggedInClient, + client, + }; + }; + + it('should return the client', async () => { + const { loggedInClient, clientId, client } = await setup(); + + const response = await loggedInClient.get(`clients/${clientId}`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ ...client, client_secret: undefined }); + expect('client_secret' in response.body).toEqual(false); + }); + }); + }); + + describe('listOAuth2Clients', () => { + describe('when no user is logged in', () => { + it('should return unauthorized', async () => { + const response = await testApiClient.get(`clients`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when reading all oauth2 clients', () => { + const setup = async () => { + const clients: ProviderOauthClient[] = providerOauthClientFactory.buildList(2, { + client_secret: undefined, + }); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin(undefined, [ + Permission.OAUTH_CLIENT_VIEW, + Permission.OAUTH_CLIENT_EDIT, + ]); + + axiosMock.onGet(`${hydraUri}/clients`).replyOnce(HttpStatus.OK, clients); + + await em.persistAndFlush([adminAccount, adminUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + clients, + }; + }; + + it('should return the client', async () => { + const { loggedInClient, clients } = await setup(); + + const response = await loggedInClient.get(`clients`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual(clients); + }); + }); + }); + + describe('createOAuth2Client', () => { + describe('when no user is logged in', () => { + it('should return unauthorized', async () => { + const response = await testApiClient.post(`clients`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when creating an oauth2 client', () => { + const setup = async () => { + const client: ProviderOauthClient = providerOauthClientFactory.build(); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin(undefined, [ + Permission.OAUTH_CLIENT_VIEW, + Permission.OAUTH_CLIENT_EDIT, + ]); + + axiosMock.onPost(`${hydraUri}/clients`).replyOnce(HttpStatus.OK, client); + + await em.persistAndFlush([adminAccount, adminUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + client, + }; + }; + + it('should return the client', async () => { + const { loggedInClient, client } = await setup(); + + const response = await loggedInClient.post(`clients`, client); + + expect(response.status).toEqual(HttpStatus.CREATED); + expect(response.body).toEqual({ ...client, client_secret: undefined }); + expect('client_secret' in response.body).toEqual(false); + }); + }); + }); + + describe('updateOAuth2Client', () => { + describe('when no user is logged in', () => { + it('should return unauthorized', async () => { + const clientId = 'oauth2ClientId'; + + const response = await testApiClient.put(`clients/${clientId}`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when updating an oauth2 client', () => { + const setup = async () => { + const clientId = 'oauth2ClientId'; + const client: ProviderOauthClient = providerOauthClientFactory.build({ + client_id: undefined, + }); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin(undefined, [ + Permission.OAUTH_CLIENT_VIEW, + Permission.OAUTH_CLIENT_EDIT, + ]); + + axiosMock.onPut(`${hydraUri}/clients/${clientId}`).replyOnce(HttpStatus.OK, client); + + await em.persistAndFlush([adminAccount, adminUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + client, + clientId, + }; + }; + + it('should return the client', async () => { + const { loggedInClient, client, clientId } = await setup(); + + const response = await loggedInClient.put(`clients/${clientId}`, client); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ ...client, client_secret: undefined }); + expect('client_secret' in response.body).toEqual(false); + }); + }); + }); + + describe('deleteOAuth2Client', () => { + describe('when no user is logged in', () => { + it('should return unauthorized', async () => { + const clientId = 'oauth2ClientId'; + + const response = await testApiClient.delete(`clients/${clientId}`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when deleting an oauth2 client', () => { + const setup = async () => { + const clientId = 'oauth2ClientId'; + const client: ProviderOauthClient = providerOauthClientFactory.build({ + client_id: undefined, + }); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin(undefined, [ + Permission.OAUTH_CLIENT_VIEW, + Permission.OAUTH_CLIENT_EDIT, + ]); + + axiosMock.onDelete(`${hydraUri}/clients/${clientId}`).replyOnce(HttpStatus.OK); + + await em.persistAndFlush([adminAccount, adminUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + client, + clientId, + }; + }; + + it('should delete the client', async () => { + const { loggedInClient, clientId } = await setup(); + + const response = await loggedInClient.delete(`clients/${clientId}`); + + expect(response.status).toEqual(HttpStatus.NO_CONTENT); + }); + }); + }); + + describe('getLoginRequest', () => { + describe('when getting a login request', () => { + const setup = async () => { + const challenge = 'challenge'; + const loginResponse: ProviderLoginResponse = providerLoginResponseFactory.build({ + challenge, + }); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + axiosMock + .onGet(`${hydraUri}/oauth2/auth/requests/login?login_challenge=${challenge}`) + .replyOnce(HttpStatus.OK, loginResponse); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { + loggedInClient, + challenge, + loginResponse, + }; + }; + + it('should return the login response', async () => { + const { loggedInClient, challenge, loginResponse } = await setup(); + + const response = await loggedInClient.get(`loginRequest/${challenge}`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ ...loginResponse, client_id: loginResponse.client.client_id }); + }); + }); + }); + + describe('patchLoginRequest', () => { + describe('when no user is logged in', () => { + it('should return unauthorized', async () => { + const challenge = 'challenge'; + + const response = await testApiClient.patch(`loginRequest/${challenge}`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when accepting a login request', () => { + const setup = async () => { + const challenge = 'challenge'; + const loginResponse: ProviderLoginResponse = providerLoginResponseFactory.build({ + challenge, + }); + const redirectResponse: ProviderRedirectResponse = { redirect_to: 'redirect' }; + const externalTool = externalToolEntityFactory.withOauth2Config(loginResponse.client.client_id).buildWithId(); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + axiosMock + .onGet(`${hydraUri}/oauth2/auth/requests/login?login_challenge=${challenge}`) + .replyOnce(HttpStatus.OK, loginResponse) + .onPut(`${hydraUri}/oauth2/auth/requests/login/accept?login_challenge=${challenge}`) + .replyOnce(HttpStatus.OK, redirectResponse); + + await em.persistAndFlush([studentAccount, studentUser, externalTool]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { + loggedInClient, + challenge, + redirectResponse, + }; + }; + + it('should return a redirect', async () => { + const { loggedInClient, challenge, redirectResponse } = await setup(); + + const response = await loggedInClient.patch(`loginRequest/${challenge}?accept=true`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual(redirectResponse); + }); + }); + }); + + describe('acceptLogoutRequest', () => { + describe('when no user is logged in', () => { + it('should return unauthorized', async () => { + const challenge = 'challenge'; + + const response = await testApiClient.patch(`logoutRequest/${challenge}`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when accepting a logout request', () => { + const setup = async () => { + const challenge = 'challenge'; + const redirectResponse: ProviderRedirectResponse = { redirect_to: 'redirect' }; + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + axiosMock + .onPut(`${hydraUri}/oauth2/auth/requests/logout/accept?logout_challenge=${challenge}`) + .replyOnce(HttpStatus.OK, redirectResponse); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { + loggedInClient, + challenge, + redirectResponse, + }; + }; + + it('should return a redirect', async () => { + const { loggedInClient, challenge, redirectResponse } = await setup(); + + const response = await loggedInClient.patch(`logoutRequest/${challenge}`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual(redirectResponse); + }); + }); + }); + + describe('getConsentRequest', () => { + describe('when no user is logged in', () => { + it('should return unauthorized', async () => { + const challenge = 'challenge'; + + const response = await testApiClient.patch(`consentRequest/${challenge}`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when getting a consent request', () => { + const setup = async () => { + const challenge = 'challenge'; + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const consentResponse: ProviderConsentResponse = providerConsentResponseFactory.build({ + challenge, + subject: studentUser.id, + }); + + axiosMock + .onGet(`${hydraUri}/oauth2/auth/requests/consent?consent_challenge=${challenge}`) + .replyOnce(HttpStatus.OK, consentResponse); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { + loggedInClient, + challenge, + consentResponse, + }; + }; + + it('should delete the client', async () => { + const { loggedInClient, challenge, consentResponse } = await setup(); + + const response = await loggedInClient.get(`consentRequest/${challenge}`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual(consentResponse); + }); + }); + }); + + describe('patchConsentRequest', () => { + describe('when no user is logged in', () => { + it('should return unauthorized', async () => { + const challenge = 'challenge'; + + const response = await testApiClient.patch(`consentRequest/${challenge}`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when accepting a consent request', () => { + const setup = async () => { + const challenge = 'challenge'; + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const consentResponse: ProviderConsentResponse = providerConsentResponseFactory.build({ + challenge, + subject: studentUser.id, + }); + const redirectResponse: ProviderRedirectResponse = { redirect_to: 'redirect' }; + const externalTool = externalToolEntityFactory.withOauth2Config(consentResponse.client.client_id).buildWithId(); + const pseudonym = externalToolPseudonymEntityFactory.buildWithId({ + toolId: externalTool.id, + userId: studentUser.id, + }); + + axiosMock + .onGet(`${hydraUri}/oauth2/auth/requests/consent?consent_challenge=${challenge}`) + .replyOnce(HttpStatus.OK, consentResponse) + .onPut(`${hydraUri}/oauth2/auth/requests/consent/accept?consent_challenge=${challenge}`) + .replyOnce(HttpStatus.OK, redirectResponse); + + await em.persistAndFlush([studentAccount, studentUser, externalTool, pseudonym]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { + loggedInClient, + challenge, + redirectResponse, + }; + }; + + it('should return a redirect', async () => { + const { loggedInClient, challenge, redirectResponse } = await setup(); + + const response = await loggedInClient.patch(`consentRequest/${challenge}?accept=true`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual(redirectResponse); + }); + }); + }); + + describe('listConsentSessions', () => { + describe('when no user is logged in', () => { + it('should return unauthorized', async () => { + const response = await testApiClient.get(`auth/sessions/consent`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when listing all consent sessions', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const consentListResponse: ProviderConsentSessionResponse = providerConsentSessionResponseFactory.build(); + const externalTool = externalToolEntityFactory.withOauth2Config('clientId').buildWithId(); + const pseudonym = externalToolPseudonymEntityFactory.buildWithId({ + toolId: externalTool.id, + userId: studentUser.id, + }); + + axiosMock + .onGet(`${hydraUri}/oauth2/auth/sessions/consent?subject=${studentUser.id}`) + .replyOnce(HttpStatus.OK, [consentListResponse]); + + await em.persistAndFlush([studentAccount, studentUser, externalTool, pseudonym]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { + loggedInClient, + consentListResponse, + }; + }; + + it('should return a list of all consent sessions', async () => { + const { loggedInClient, consentListResponse } = await setup(); + + const response = await loggedInClient.get(`auth/sessions/consent`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual([ + { + challenge: consentListResponse.consent_request.challenge, + client_id: consentListResponse.consent_request.client.client_id, + client_name: consentListResponse.consent_request.client.client_name, + }, + ]); + }); + }); + }); + + describe('revokeConsentSession', () => { + describe('when no user is logged in', () => { + it('should return unauthorized', async () => { + const response = await testApiClient.delete(`auth/sessions/consent`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when revoking all consent sessions for a client', () => { + const setup = async () => { + const clientId = 'clientId'; + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + axiosMock + .onDelete(`${hydraUri}/oauth2/auth/sessions/consent?subject=${studentUser.id}&client=${clientId}`) + .replyOnce(HttpStatus.OK); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { + loggedInClient, + clientId, + }; + }; + + it('should delete all sessions for a client', async () => { + const { loggedInClient, clientId } = await setup(); + + const response = await loggedInClient.delete(`auth/sessions/consent?client=${clientId}`); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + }); +}); diff --git a/apps/server/src/modules/oauth-provider/controller/dto/index.ts b/apps/server/src/modules/oauth-provider/controller/dto/index.ts deleted file mode 100644 index 7406d553fce..00000000000 --- a/apps/server/src/modules/oauth-provider/controller/dto/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export * from './request/accept.query'; -export * from './request/challenge.params'; -export * from './request/consent-request.body'; -export * from './request/id.params'; -export * from './request/list-oauth-clients.params'; -export * from './request/login-request.body'; -export * from './request/oauth-client.body'; -export * from './request/revoke-consent.params'; -export * from './request/user.params'; -export * from './response/oauth-client.response'; -export * from './response/consent.response'; -export * from './response/redirect.response'; -export * from './response/oidc-context.response'; -export * from './response/consent-session.response'; -export * from './response/login.response'; -export * from './request/oauth-rejectable.body'; diff --git a/apps/server/src/modules/oauth-provider/controller/dto/response/consent-session.response.ts b/apps/server/src/modules/oauth-provider/controller/dto/response/consent-session.response.ts deleted file mode 100644 index 101eff68c4d..00000000000 --- a/apps/server/src/modules/oauth-provider/controller/dto/response/consent-session.response.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional } from 'class-validator'; - -export class ConsentSessionResponse { - constructor(clientId: string | undefined, clientName: string | undefined, challenge: string | undefined) { - this.client_id = clientId; - this.client_name = clientName; - this.challenge = challenge; - } - - @IsOptional() - @ApiProperty({ description: 'The id of the client.' }) - client_id?: string; - - @ApiProperty({ description: 'The name of the client.' }) - client_name?: string; - - @ApiProperty({ description: 'The id/challenge of the consent authorization request.' }) - challenge?: string; -} diff --git a/apps/server/src/modules/oauth-provider/controller/dto/response/oauth-client.response.ts b/apps/server/src/modules/oauth-provider/controller/dto/response/oauth-client.response.ts deleted file mode 100644 index a9ccb0956b2..00000000000 --- a/apps/server/src/modules/oauth-provider/controller/dto/response/oauth-client.response.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Optional } from '@nestjs/common'; -import { IsArray, IsOptional, IsString } from 'class-validator'; - -export class OauthClientResponse { - constructor(oauthClientResponse: OauthClientResponse) { - Object.assign(this, oauthClientResponse); - } - - @IsArray() - @IsOptional() - @IsString({ each: true }) - @ApiProperty({ required: false, nullable: false }) - allowed_cors_origins?: string[]; - - @Optional() - @ApiProperty() - audience?: string[]; - - @Optional() - @ApiProperty() - authorization_code_grant_access_token_lifespan?: string; - - @Optional() - @ApiProperty() - authorization_code_grant_id_token_lifespan?: string; - - @Optional() - @ApiProperty() - authorization_code_grant_refresh_token_lifespan?: string; - - @Optional() - @ApiProperty({ description: 'Boolean value specifying whether the RP requires that a sid (session ID) Claim.' }) - backchannel_logout_session_required?: boolean; - - @Optional() - @ApiProperty({ description: 'RP URL that will cause the RP to log itself out when sent a Logout Token by the OP.' }) - backchannel_logout_uri?: string; - - @Optional() - @ApiProperty() - client_credentials_grant_access_token_lifespan?: string; - - @Optional() - @ApiProperty({ description: 'Id of the client.' }) - client_id?: string; - - @Optional() - @ApiProperty({ description: 'Human-readable string name of the client presented to the end-user.' }) - client_name?: string; - - @Optional() - @ApiProperty({ - description: - 'SecretExpiresAt is an integer holding the time at which the client secret will expire or 0 if it will not expire.', - }) - client_secret_expires_at?: number; - - @Optional() - @ApiProperty({ description: 'ClientUri is an URL string of a web page providing information about the client.' }) - client_uri?: string; - - @IsArray() - @IsOptional() - @IsString({ each: true }) - @ApiProperty({ required: false, nullable: false }) - contacts?: string[]; - - @Optional() - @ApiProperty({ description: 'CreatedAt returns the timestamp of the clients creation.' }) - created_at?: string; - - @Optional() - @ApiProperty({ - description: - 'Boolean value specifying whether the RP requires that iss (issuer) and sid (session ID) query parameters.', - }) - frontchannel_logout_session_required?: boolean; - - @Optional() - @ApiProperty({ description: 'RP URL that will cause the RP to log itself out when rendered in an iframe by the OP.' }) - frontchannel_logout_uri?: string; - - @IsArray() - @IsOptional() - @IsString({ each: true }) - @ApiProperty({ description: 'The grant types of the Oauth2 client.', required: false, nullable: false }) - grant_types?: string[]; - - @Optional() - @ApiProperty() - implicit_grant_access_token_lifespan?: string; - - @Optional() - @ApiProperty() - implicit_grant_id_token_lifespan?: string; - - @Optional() - @ApiProperty() - jwks?: object; - - @Optional() - @ApiProperty({ description: 'URL for the clients JSON Web Key Set [JWK] document' }) - jwks_uri?: string; - - @Optional() - @ApiProperty() - jwt_bearer_grant_access_token_lifespan?: string; - - @Optional() - @ApiProperty({ description: 'LogoUri is an URL string that references a logo for the client.' }) - logo_uri?: string; - - @Optional() - @ApiProperty() - metadata?: object; - - @Optional() - @ApiProperty({ description: 'Owner is a string identifying the owner of the OAuth 2.0 Client.' }) - owner?: string; - - @Optional() - @ApiProperty() - password_grant_access_token_lifespan?: string; - - @Optional() - @ApiProperty() - password_grant_refresh_token_lifespan?: string; - - @Optional() - @ApiProperty({ description: 'PolicyUri is a URL string that points to a human-readable privacy policy document' }) - policy_uri?: string; - - @IsArray() - @IsOptional() - @IsString({ each: true }) - @ApiProperty({ required: false, nullable: false }) - post_logout_redirect_uris?: string[]; - - @IsArray() - @IsOptional() - @IsString({ each: true }) - @ApiProperty({ required: false, nullable: false }) - redirect_uris?: string[]; - - @Optional() - @ApiProperty() - refresh_token_grant_access_token_lifespan?: string; - - @Optional() - @ApiProperty() - refresh_token_grant_id_token_lifespan?: string; - - @Optional() - @ApiProperty() - refresh_token_grant_refresh_token_lifespan?: string; - - @Optional() - @ApiProperty({ description: 'RegistrationAccessToken can be used to update, get, or delete the OAuth2 Client.' }) - registration_access_token?: string; - - @Optional() - @ApiProperty({ description: 'RegistrationClientURI is the URL used to update, get, or delete the OAuth2 Client.' }) - registration_client_uri?: string; - - @Optional() - @ApiProperty({ - description: 'JWS [JWS] alg algorithm [JWA] that MUST be used for signing Request Objects sent to the OP.', - }) - request_object_signing_alg?: string; - - @IsArray() - @IsOptional() - @IsString({ each: true }) - @ApiProperty({ required: false, nullable: false }) - request_uris?: string[]; - - @IsArray() - @IsOptional() - @IsString({ each: true }) - @ApiProperty({ description: 'The response types of the Oauth2 client.', required: false, nullable: false }) - response_types?: string[]; - - @Optional() - @ApiProperty({ - description: - 'Scope is a string containing a space-separated list of scope values (as described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use when requesting access tokens.', - }) - scope?: string; - - @Optional() - @ApiProperty({ - description: 'URL using the https scheme to be used in calculating Pseudonymous Identifiers by the OP.', - }) - sector_identifier_uri?: string; - - @Optional() - @ApiProperty({ - description: - 'SubjectType requested for responses to this Client. The subject_types_supported Discovery parameter contains a list of the supported subject_type values for this server. Valid types include pairwise and public.', - }) - subject_type?: string; - - @Optional() - @ApiProperty() - token_endpoint_auth_method?: string; - - @Optional() - @ApiProperty() - token_endpoint_auth_signing_alg?: string; - - @Optional() - @ApiProperty({ - description: - 'TermsOfServiceUri is a URL string that points to a human-readable terms of service document for the client.', - }) - tos_uri?: string; - - @Optional() - @ApiProperty({ description: 'UpdatedAt returns the timestamp of the last update.' }) - updated_at?: string; - - @Optional() - @ApiProperty({ description: 'JWS alg algorithm [JWA] REQUIRED for signing UserInfo Responses. ' }) - userinfo_signed_response_alg?: string; -} diff --git a/apps/server/src/modules/oauth-provider/controller/dto/response/oidc-context.response.ts b/apps/server/src/modules/oauth-provider/controller/dto/response/oidc-context.response.ts deleted file mode 100644 index 5a73749c865..00000000000 --- a/apps/server/src/modules/oauth-provider/controller/dto/response/oidc-context.response.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Optional } from '@nestjs/common'; - -export class OidcContextResponse { - @ApiProperty() - acr_values?: string[]; - - @ApiProperty() - display?: string; - - @ApiProperty() - id_token_hint_claims?: object; - - @ApiProperty() - login_hint?: string; - - @Optional() - @ApiProperty() - ui_locales?: string[]; -} diff --git a/apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.spec.ts b/apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.spec.ts deleted file mode 100644 index 515f54f0a39..00000000000 --- a/apps/server/src/modules/oauth-provider/controller/oauth-provider.controller.spec.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { - ProviderConsentResponse, - ProviderConsentSessionResponse, - ProviderLoginResponse, - ProviderRedirectResponse, -} from '@infra/oauth-provider/dto'; -import { ICurrentUser } from '@modules/authentication'; -import { - AcceptQuery, - ChallengeParams, - ConsentRequestBody, - ConsentResponse, - ConsentSessionResponse, - LoginRequestBody, - LoginResponse, - OauthClientBody, - OauthClientResponse, - RedirectResponse, -} from '@modules/oauth-provider/controller/dto'; -import { OauthProviderResponseMapper } from '@modules/oauth-provider/mapper/oauth-provider-response.mapper'; -import { OauthProviderConsentFlowUc } from '@modules/oauth-provider/uc/oauth-provider.consent-flow.uc'; -import { OauthProviderLogoutFlowUc } from '@modules/oauth-provider/uc/oauth-provider.logout-flow.uc'; -import { OauthProviderUc } from '@modules/oauth-provider/uc/oauth-provider.uc'; -import { Test, TestingModule } from '@nestjs/testing'; -import { OauthProviderClientCrudUc } from '../uc/oauth-provider.client-crud.uc'; -import { OauthProviderLoginFlowUc } from '../uc/oauth-provider.login-flow.uc'; -import { OauthProviderController } from './oauth-provider.controller'; - -describe('OauthProviderController', () => { - let module: TestingModule; - let controller: OauthProviderController; - - let oauthProviderUc: DeepMocked; - let logoutUc: DeepMocked; - let loginUc: DeepMocked; - let consentUc: DeepMocked; - let crudUc: DeepMocked; - let responseMapper: DeepMocked; - - const hydraUri = 'http://hydra.uri'; - const currentUser: ICurrentUser = { userId: 'userId' } as ICurrentUser; - - beforeAll(async () => { - jest.spyOn(Configuration, 'get').mockReturnValue(hydraUri); - - module = await Test.createTestingModule({ - providers: [ - OauthProviderController, - { - provide: OauthProviderUc, - useValue: createMock(), - }, - { - provide: OauthProviderClientCrudUc, - useValue: createMock(), - }, - { - provide: OauthProviderLogoutFlowUc, - useValue: createMock(), - }, - { - provide: OauthProviderConsentFlowUc, - useValue: createMock(), - }, - { - provide: OauthProviderResponseMapper, - useValue: createMock(), - }, - { - provide: OauthProviderLoginFlowUc, - useValue: createMock(), - }, - ], - }).compile(); - - controller = module.get(OauthProviderController); - oauthProviderUc = module.get(OauthProviderUc); - crudUc = module.get(OauthProviderClientCrudUc); - logoutUc = module.get(OauthProviderLogoutFlowUc); - responseMapper = module.get(OauthProviderResponseMapper); - consentUc = module.get(OauthProviderConsentFlowUc); - loginUc = module.get(OauthProviderLoginFlowUc); - }); - - afterAll(async () => { - await module.close(); - }); - - describe('Client Flow', () => { - describe('getOAuth2Client', () => { - it('should get oauth2 client', async () => { - const data: OauthClientBody = { - client_id: 'clientId', - }; - crudUc.getOAuth2Client.mockResolvedValue(data); - responseMapper.mapOauthClientResponse.mockReturnValue(new OauthClientResponse({ ...data })); - - const result: OauthClientResponse = await controller.getOAuth2Client(currentUser, { id: 'clientId' }); - - expect(result).toEqual(data); - expect(crudUc.getOAuth2Client).toHaveBeenCalledWith(currentUser, 'clientId'); - }); - }); - - describe('listOAuth2Clients', () => { - it('should list oauth2 clients when uc is called with all parameters', async () => { - const data: OauthClientBody = { - client_id: 'clientId', - }; - crudUc.listOAuth2Clients.mockResolvedValue([data]); - responseMapper.mapOauthClientResponse.mockReturnValue(new OauthClientResponse({ ...data })); - - const result: OauthClientResponse[] = await controller.listOAuth2Clients(currentUser, { - limit: 1, - offset: 0, - client_name: 'clientId', - owner: 'clientOwner', - }); - - expect(result).toEqual([data]); - expect(crudUc.listOAuth2Clients).toHaveBeenCalledWith(currentUser, 1, 0, 'clientId', 'clientOwner'); - }); - - it('should list oauth2 clients when uc is called without parameters', async () => { - const data: OauthClientBody = { - client_id: 'clientId', - }; - crudUc.listOAuth2Clients.mockResolvedValue([data]); - responseMapper.mapOauthClientResponse.mockReturnValue(new OauthClientResponse({ ...data })); - - const result: OauthClientResponse[] = await controller.listOAuth2Clients(currentUser, {}); - - expect(result).toEqual([data]); - expect(crudUc.listOAuth2Clients).toHaveBeenCalledWith(currentUser, undefined, undefined, undefined, undefined); - }); - }); - - describe('createOAuth2Client', () => { - it('should create oauth2 client with defaults', async () => { - const data: OauthClientBody = { - client_id: 'clientId', - }; - crudUc.createOAuth2Client.mockResolvedValue(data); - responseMapper.mapOauthClientResponse.mockReturnValue(new OauthClientResponse({ ...data })); - - const result: OauthClientResponse = await controller.createOAuth2Client(currentUser, data); - - expect(crudUc.createOAuth2Client).toHaveBeenCalledWith(currentUser, data); - expect(result).toEqual(data); - }); - }); - - describe('updateOAuth2Client', () => { - it('should update oauth2 client with defaults', async () => { - const data: OauthClientBody = { - client_id: 'clientId', - }; - crudUc.updateOAuth2Client.mockResolvedValue(data); - responseMapper.mapOauthClientResponse.mockReturnValue(new OauthClientResponse({ ...data })); - - const result: OauthClientResponse = await controller.updateOAuth2Client( - currentUser, - { id: 'clientId' }, - { client_id: 'clientId' } - ); - - expect(crudUc.updateOAuth2Client).toHaveBeenCalledWith(currentUser, 'clientId', data); - expect(result).toEqual(data); - }); - }); - - describe('deleteOAuth2Client', () => { - it('should delete oauth2 client', async () => { - await controller.deleteOAuth2Client(currentUser, { id: 'clientId' }); - - expect(crudUc.deleteOAuth2Client).toHaveBeenCalledWith(currentUser, 'clientId'); - }); - }); - }); - - describe('Consent Flow', () => { - let challengeParams: ChallengeParams; - - beforeEach(() => { - challengeParams = { challenge: 'challengexyz' }; - }); - - describe('getConsentRequest', () => { - let consentResponse: ProviderConsentResponse; - - beforeEach(() => { - consentResponse = { - challenge: challengeParams.challenge, - subject: 'subject', - }; - }); - - it('should return a consentResponse', async () => { - consentUc.getConsentRequest.mockResolvedValue(consentResponse); - responseMapper.mapConsentResponse.mockReturnValue(new ConsentResponse({ ...consentResponse })); - - const result: ConsentResponse = await controller.getConsentRequest(challengeParams); - - expect(result.challenge).toEqual(consentResponse.challenge); - expect(result.subject).toEqual(consentResponse.subject); - }); - - it('should call mapper', async () => { - consentUc.getConsentRequest.mockResolvedValue(consentResponse); - responseMapper.mapConsentResponse.mockReturnValue(new ConsentResponse({ ...consentResponse })); - - await controller.getConsentRequest(challengeParams); - - expect(responseMapper.mapConsentResponse).toHaveBeenCalledWith(consentResponse); - }); - - it('should call uc', async () => { - consentUc.getConsentRequest.mockResolvedValue(consentResponse); - responseMapper.mapConsentResponse.mockReturnValue(new ConsentResponse({ ...consentResponse })); - - await controller.getConsentRequest(challengeParams); - - expect(consentUc.getConsentRequest).toHaveBeenCalledWith(consentResponse.challenge); - }); - }); - - describe('patchConsentRequest', () => { - let acceptQuery: AcceptQuery; - let consentRequestBody: ConsentRequestBody; - - beforeEach(() => { - acceptQuery = { accept: true }; - consentRequestBody = { - grant_scope: ['openid', 'offline'], - remember: false, - remember_for: 0, - }; - }); - - it('should call uc', async () => { - await controller.patchConsentRequest(challengeParams, acceptQuery, consentRequestBody, currentUser); - - expect(consentUc.patchConsentRequest).toHaveBeenCalledWith( - challengeParams.challenge, - acceptQuery, - consentRequestBody, - currentUser - ); - }); - - it('should call mapper', async () => { - const expectedRedirectResponse: RedirectResponse = { redirect_to: 'anywhere' }; - consentUc.patchConsentRequest.mockResolvedValue(expectedRedirectResponse); - - await controller.patchConsentRequest(challengeParams, acceptQuery, consentRequestBody, currentUser); - - expect(responseMapper.mapRedirectResponse).toHaveBeenCalledWith(expectedRedirectResponse); - }); - - it('should return redirect response', async () => { - const expectedRedirectResponse: RedirectResponse = { redirect_to: 'anywhere' }; - consentUc.patchConsentRequest.mockResolvedValue(expectedRedirectResponse); - responseMapper.mapRedirectResponse.mockReturnValue(expectedRedirectResponse); - - const result: RedirectResponse = await controller.patchConsentRequest( - challengeParams, - acceptQuery, - consentRequestBody, - currentUser - ); - - expect(result.redirect_to).toEqual(expectedRedirectResponse.redirect_to); - }); - }); - - describe('listConsentSessions', () => { - it('should list all consent sessions', async () => { - const session: ProviderConsentSessionResponse = { - consent_request: { - challenge: 'challenge', - client: { - client_id: 'clientId', - client_name: 'clientName', - }, - }, - }; - const response: ConsentSessionResponse = new ConsentSessionResponse( - session.consent_request.challenge, - session.consent_request.client?.client_id, - session.consent_request.client?.client_name - ); - - oauthProviderUc.listConsentSessions.mockResolvedValue([session]); - responseMapper.mapConsentSessionsToResponse.mockReturnValue(response); - - const result: ConsentSessionResponse[] = await controller.listConsentSessions(currentUser); - - expect(result).toEqual([response]); - expect(oauthProviderUc.listConsentSessions).toHaveBeenCalledWith(currentUser.userId); - }); - }); - - describe('revokeConsentSession', () => { - it('should revoke consent sessions', async () => { - await controller.revokeConsentSession(currentUser, { client: 'clientId' }); - - expect(oauthProviderUc.revokeConsentSession).toHaveBeenCalledWith(currentUser.userId, 'clientId'); - }); - }); - }); - - describe('Logout Flow', () => { - describe('acceptLogoutRequest', () => { - it('should call uc and return redirect string', async () => { - const expectedRedirect: RedirectResponse = new RedirectResponse({ redirect_to: 'www.mock.de' }); - logoutUc.logoutFlow.mockResolvedValue(expectedRedirect); - responseMapper.mapRedirectResponse.mockReturnValue(expectedRedirect); - - const redirect = await controller.acceptLogoutRequest({ challenge: 'challenge_mock' }); - - expect(logoutUc.logoutFlow).toHaveBeenCalledWith('challenge_mock'); - expect(redirect.redirect_to).toEqual(expectedRedirect.redirect_to); - }); - }); - }); - - describe('Login Flow', () => { - let params: ChallengeParams; - describe('getLoginRequest', () => { - it('should get the login request response', async () => { - params = { challenge: 'challenge' }; - const loginResponse: LoginResponse = { - challenge: 'challenge', - client: {}, - oidc_context: {}, - request_url: 'request_url', - requested_access_token_audience: ['requested_access_token_audience'], - requested_scope: ['requested_scope'], - session_id: 'session_id', - skip: true, - subject: 'subject', - } as LoginResponse; - const providerLoginResponse: ProviderLoginResponse = { - challenge: 'challenge', - client: {}, - oidc_context: {}, - request_url: 'request_url', - requested_access_token_audience: ['requested_access_token_audience'], - requested_scope: ['requested_scope'], - session_id: 'session_id', - skip: true, - subject: 'subject', - } as ProviderLoginResponse; - - loginUc.getLoginRequest.mockResolvedValue(providerLoginResponse); - responseMapper.mapLoginResponse.mockReturnValue(loginResponse); - - const response = await controller.getLoginRequest(params); - - expect(loginUc.getLoginRequest).toHaveBeenCalledWith(params.challenge); - expect(response).toEqual(providerLoginResponse); - }); - }); - - describe('patchLoginRequest', () => { - it('should patch the login request', async () => { - const query: AcceptQuery = { - accept: true, - }; - const loginRequestBody: LoginRequestBody = { - remember: true, - remember_for: 0, - }; - const providerRedirectResponse: ProviderRedirectResponse = { - redirect_to: 'redirect_to', - }; - const redirectResponse: RedirectResponse = { - redirect_to: providerRedirectResponse.redirect_to, - }; - const expected = [currentUser.userId, params.challenge, loginRequestBody, query]; - - loginUc.patchLoginRequest.mockResolvedValue(providerRedirectResponse); - responseMapper.mapRedirectResponse.mockReturnValue(redirectResponse); - - const response = await controller.patchLoginRequest(params, query, loginRequestBody, currentUser); - expect(loginUc.patchLoginRequest).toHaveBeenCalledWith(...expected); - expect(response.redirect_to).toStrictEqual(redirectResponse.redirect_to); - }); - }); - }); - - describe('getUrl', () => { - it('should return hydra uri', async () => { - const result: string = await controller.getUrl(); - - expect(result).toEqual(hydraUri); - }); - }); -}); diff --git a/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.spec.ts b/apps/server/src/modules/oauth-provider/domain/error/hydra-oauth-failed-loggable-exception.spec.ts similarity index 100% rename from apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.spec.ts rename to apps/server/src/modules/oauth-provider/domain/error/hydra-oauth-failed-loggable-exception.spec.ts diff --git a/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.ts b/apps/server/src/modules/oauth-provider/domain/error/hydra-oauth-failed-loggable-exception.ts similarity index 73% rename from apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.ts rename to apps/server/src/modules/oauth-provider/domain/error/hydra-oauth-failed-loggable-exception.ts index c92dd3c7fff..16c7e29169c 100644 --- a/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.ts +++ b/apps/server/src/modules/oauth-provider/domain/error/hydra-oauth-failed-loggable-exception.ts @@ -1,5 +1,5 @@ -import { AxiosErrorLoggable } from '@src/core/error/loggable'; import { AxiosError } from 'axios'; +import { AxiosErrorLoggable } from '../../../../core/error/loggable'; export class HydraOauthFailedLoggableException extends AxiosErrorLoggable { constructor(error: AxiosError) { diff --git a/apps/server/src/modules/oauth-provider/error/id-token-creation-exception.loggable.spec.ts b/apps/server/src/modules/oauth-provider/domain/error/id-token-creation-exception.loggable.spec.ts similarity index 100% rename from apps/server/src/modules/oauth-provider/error/id-token-creation-exception.loggable.spec.ts rename to apps/server/src/modules/oauth-provider/domain/error/id-token-creation-exception.loggable.spec.ts diff --git a/apps/server/src/modules/oauth-provider/error/id-token-creation-exception.loggable.ts b/apps/server/src/modules/oauth-provider/domain/error/id-token-creation-exception.loggable.ts similarity index 100% rename from apps/server/src/modules/oauth-provider/error/id-token-creation-exception.loggable.ts rename to apps/server/src/modules/oauth-provider/domain/error/id-token-creation-exception.loggable.ts diff --git a/apps/server/src/modules/oauth-provider/domain/error/index.ts b/apps/server/src/modules/oauth-provider/domain/error/index.ts new file mode 100644 index 00000000000..fee29697ddf --- /dev/null +++ b/apps/server/src/modules/oauth-provider/domain/error/index.ts @@ -0,0 +1,2 @@ +export { HydraOauthFailedLoggableException } from './hydra-oauth-failed-loggable-exception'; +export { IdTokenCreationLoggableException } from './id-token-creation-exception.loggable'; diff --git a/apps/server/src/modules/oauth-provider/domain/index.ts b/apps/server/src/modules/oauth-provider/domain/index.ts new file mode 100644 index 00000000000..5dd14b7e4bd --- /dev/null +++ b/apps/server/src/modules/oauth-provider/domain/index.ts @@ -0,0 +1,18 @@ +export { IdTokenCreationLoggableException, HydraOauthFailedLoggableException } from './error'; +export { + ProviderOauthClient, + ProviderRedirectResponse, + RejectRequestBody, + ProviderLoginResponse, + ProviderConsentResponse, + ProviderConsentSessionResponse, + GroupNameIdTuple, + IntrospectResponse, + ProviderOidcContext, + TokenAuthMethod, + AcceptLoginRequestBody, + AcceptConsentRequestBody, + IdToken, + OauthScope, + SubjectTypeEnum, +} from './interface'; diff --git a/apps/server/src/modules/oauth-provider/interface/id-token.ts b/apps/server/src/modules/oauth-provider/domain/interface/id-token.ts similarity index 97% rename from apps/server/src/modules/oauth-provider/interface/id-token.ts rename to apps/server/src/modules/oauth-provider/domain/interface/id-token.ts index c36d98bfe98..7fbedd859eb 100644 --- a/apps/server/src/modules/oauth-provider/interface/id-token.ts +++ b/apps/server/src/modules/oauth-provider/domain/interface/id-token.ts @@ -1,13 +1,19 @@ export interface IdToken { iframe?: string; + email?: string; + name?: string; + userId?: string; + schoolId: string; + groups?: GroupNameIdTuple[]; } export interface GroupNameIdTuple { displayName: string; + gid: string; } diff --git a/apps/server/src/modules/oauth-provider/domain/interface/index.ts b/apps/server/src/modules/oauth-provider/domain/interface/index.ts new file mode 100644 index 00000000000..56e348885c8 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/domain/interface/index.ts @@ -0,0 +1,14 @@ +export { IdToken, GroupNameIdTuple } from './id-token'; +export { OauthScope } from './oauth-scope.enum'; +export { SubjectTypeEnum } from './subject-type.enum'; +export { TokenAuthMethod } from './token-auth-method.enum'; +export { AcceptConsentRequestBody } from './request/accept-consent-request.body'; +export { AcceptLoginRequestBody } from './request/accept-login-request.body'; +export { RejectRequestBody } from './request/reject-request.body'; +export { ProviderRedirectResponse } from './response/redirect.response'; +export { ProviderConsentResponse } from './response/consent.response'; +export { ProviderOauthClient } from './oauth-client.interface'; +export { ProviderOidcContext } from './oidc-context.interface'; +export { IntrospectResponse } from './response/introspect.response'; +export { ProviderConsentSessionResponse } from './response/consent-session.response'; +export { ProviderLoginResponse } from './response/login.response'; diff --git a/apps/server/src/modules/oauth-provider/domain/interface/oauth-client.interface.ts b/apps/server/src/modules/oauth-provider/domain/interface/oauth-client.interface.ts new file mode 100644 index 00000000000..227ca205bca --- /dev/null +++ b/apps/server/src/modules/oauth-provider/domain/interface/oauth-client.interface.ts @@ -0,0 +1,98 @@ +/** + * @see https://www.ory.sh/docs/hydra/reference/api#tag/oAuth2 + */ +export interface ProviderOauthClient { + allowed_cors_origins: string[]; + + audience: string[]; + + authorization_code_grant_access_token_lifespan: string; + + authorization_code_grant_id_token_lifespan: string; + + authorization_code_grant_refresh_token_lifespan: string; + + backchannel_logout_session_required: boolean; + + backchannel_logout_uri: string; + + client_credentials_grant_access_token_lifespan: string; + + client_id: string; + + client_name: string; + + client_secret?: string; + + client_secret_expires_at: number; + + client_uri: string; + + contacts: string[]; + + created_at: string; + + frontchannel_logout_session_required: boolean; + + frontchannel_logout_uri: string; + + grant_types: string[]; + + implicit_grant_access_token_lifespan: string; + + implicit_grant_id_token_lifespan: string; + + jwks: object; + + jwks_uri: string; + + jwt_bearer_grant_access_token_lifespan: string; + + logo_uri: string; + + metadata: object; + + owner: string; + + password_grant_access_token_lifespan: string; + + password_grant_refresh_token_lifespan: string; + + policy_uri: string; + + post_logout_redirect_uris: string[]; + + redirect_uris: string[]; + + refresh_token_grant_access_token_lifespan: string; + + refresh_token_grant_id_token_lifespan: string; + + refresh_token_grant_refresh_token_lifespan: string; + + registration_access_token: string; + + registration_client_uri: string; + + request_object_signing_alg: string; + + request_uris: string[]; + + response_types: string[]; + + scope: string; + + sector_identifier_uri: string; + + subject_type: string; + + token_endpoint_auth_method: string; + + token_endpoint_auth_signing_alg: string; + + tos_uri: string; + + updated_at: string; + + userinfo_signed_response_alg: string; +} diff --git a/apps/server/src/modules/oauth-provider/interface/oauth-scope.enum.ts b/apps/server/src/modules/oauth-provider/domain/interface/oauth-scope.enum.ts similarity index 100% rename from apps/server/src/modules/oauth-provider/interface/oauth-scope.enum.ts rename to apps/server/src/modules/oauth-provider/domain/interface/oauth-scope.enum.ts diff --git a/apps/server/src/modules/oauth-provider/domain/interface/oidc-context.interface.ts b/apps/server/src/modules/oauth-provider/domain/interface/oidc-context.interface.ts new file mode 100644 index 00000000000..3de9b94bc2c --- /dev/null +++ b/apps/server/src/modules/oauth-provider/domain/interface/oidc-context.interface.ts @@ -0,0 +1,14 @@ +/** + * @see https://www.ory.sh/docs/hydra/reference/api#tag/oAuth2 + */ +export interface ProviderOidcContext { + acr_values: string[]; + + display: string; + + id_token_hint_claims: object; + + login_hint: string; + + ui_locales: string[]; +} diff --git a/apps/server/src/infra/oauth-provider/dto/request/accept-consent-request.body.ts b/apps/server/src/modules/oauth-provider/domain/interface/request/accept-consent-request.body.ts similarity index 69% rename from apps/server/src/infra/oauth-provider/dto/request/accept-consent-request.body.ts rename to apps/server/src/modules/oauth-provider/domain/interface/request/accept-consent-request.body.ts index 235cb9191ea..c3975364a94 100644 --- a/apps/server/src/infra/oauth-provider/dto/request/accept-consent-request.body.ts +++ b/apps/server/src/modules/oauth-provider/domain/interface/request/accept-consent-request.body.ts @@ -1,5 +1,8 @@ -import { IdToken } from '@modules/oauth-provider/interface/id-token'; +import { IdToken } from '../id-token'; +/** + * @see https://www.ory.sh/docs/hydra/reference/api#tag/oAuth2 + */ export interface AcceptConsentRequestBody { grant_access_token_audience?: string[]; diff --git a/apps/server/src/infra/oauth-provider/dto/request/accept-login-request.body.ts b/apps/server/src/modules/oauth-provider/domain/interface/request/accept-login-request.body.ts similarity index 73% rename from apps/server/src/infra/oauth-provider/dto/request/accept-login-request.body.ts rename to apps/server/src/modules/oauth-provider/domain/interface/request/accept-login-request.body.ts index 8b1952393e0..0f5c0c7a8f7 100644 --- a/apps/server/src/infra/oauth-provider/dto/request/accept-login-request.body.ts +++ b/apps/server/src/modules/oauth-provider/domain/interface/request/accept-login-request.body.ts @@ -1,3 +1,6 @@ +/** + * @see https://www.ory.sh/docs/hydra/reference/api#tag/oAuth2 + */ export interface AcceptLoginRequestBody { subject?: string; diff --git a/apps/server/src/infra/oauth-provider/dto/request/reject-request.body.ts b/apps/server/src/modules/oauth-provider/domain/interface/request/reject-request.body.ts similarity index 68% rename from apps/server/src/infra/oauth-provider/dto/request/reject-request.body.ts rename to apps/server/src/modules/oauth-provider/domain/interface/request/reject-request.body.ts index 70e36128795..c210b2e81f1 100644 --- a/apps/server/src/infra/oauth-provider/dto/request/reject-request.body.ts +++ b/apps/server/src/modules/oauth-provider/domain/interface/request/reject-request.body.ts @@ -1,3 +1,6 @@ +/** + * @see https://www.ory.sh/docs/hydra/reference/api#tag/oAuth2 + */ export interface RejectRequestBody { error?: string; diff --git a/apps/server/src/infra/oauth-provider/dto/response/consent-session.response.ts b/apps/server/src/modules/oauth-provider/domain/interface/response/consent-session.response.ts similarity index 50% rename from apps/server/src/infra/oauth-provider/dto/response/consent-session.response.ts rename to apps/server/src/modules/oauth-provider/domain/interface/response/consent-session.response.ts index bb2f3221731..8d6df86cba3 100644 --- a/apps/server/src/infra/oauth-provider/dto/response/consent-session.response.ts +++ b/apps/server/src/modules/oauth-provider/domain/interface/response/consent-session.response.ts @@ -1,19 +1,22 @@ import { ProviderConsentResponse } from './consent.response'; +/** + * @see https://www.ory.sh/docs/hydra/reference/api#tag/oAuth2 + */ export interface ProviderConsentSessionResponse { consent_request: ProviderConsentResponse; - grant_access_token_audience?: string[]; + grant_access_token_audience: string[]; - grant_scope?: string[]; + grant_scope: string[]; - handled_at?: string; + handled_at: string; - remember?: boolean; + remember: boolean; - remember_for?: number; + remember_for: number; - session?: { + session: { access_token: string; id_token: string; diff --git a/apps/server/src/modules/oauth-provider/domain/interface/response/consent.response.ts b/apps/server/src/modules/oauth-provider/domain/interface/response/consent.response.ts new file mode 100644 index 00000000000..187012e01b1 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/domain/interface/response/consent.response.ts @@ -0,0 +1,33 @@ +import { ProviderOauthClient } from '../oauth-client.interface'; +import { ProviderOidcContext } from '../oidc-context.interface'; + +/** + * @see https://www.ory.sh/docs/hydra/reference/api#tag/oAuth2 + */ +export interface ProviderConsentResponse { + acr: string; + + amr: string[]; + + challenge: string; + + client: ProviderOauthClient; + + context: object; + + login_challenge: string; + + login_session_id: string; + + oidc_context: ProviderOidcContext; + + request_url: string; + + requested_access_token_audience: string[]; + + requested_scope: string[]; + + skip: boolean; + + subject: string; +} diff --git a/apps/server/src/modules/oauth-provider/domain/interface/response/introspect.response.ts b/apps/server/src/modules/oauth-provider/domain/interface/response/introspect.response.ts new file mode 100644 index 00000000000..bd982ba1a8b --- /dev/null +++ b/apps/server/src/modules/oauth-provider/domain/interface/response/introspect.response.ts @@ -0,0 +1,32 @@ +/** + * @see https://www.ory.sh/docs/hydra/reference/api#tag/oAuth2 + */ +export interface IntrospectResponse { + active: boolean; + + aud: string[]; + + client_id: string; + + exp: number; + + ext: object; + + iat: number; + + iss: string; + + nbf: number; + + obfuscated_subject: string; + + scope: string; + + sub: string; + + token_type: string; + + token_use: string; + + username: string; +} diff --git a/apps/server/src/modules/oauth-provider/domain/interface/response/login.response.ts b/apps/server/src/modules/oauth-provider/domain/interface/response/login.response.ts new file mode 100644 index 00000000000..a3f1ea2c4e3 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/domain/interface/response/login.response.ts @@ -0,0 +1,25 @@ +import { ProviderOauthClient } from '../oauth-client.interface'; +import { ProviderOidcContext } from '../oidc-context.interface'; + +/** + * @see https://www.ory.sh/docs/hydra/reference/api#tag/oAuth2 + */ +export interface ProviderLoginResponse { + challenge: string; + + client: ProviderOauthClient; + + oidc_context: ProviderOidcContext; + + request_url: string; + + requested_access_token_audience: string[]; + + requested_scope: string[]; + + session_id: string; + + skip: boolean; + + subject: string; +} diff --git a/apps/server/src/modules/oauth-provider/domain/interface/response/redirect.response.ts b/apps/server/src/modules/oauth-provider/domain/interface/response/redirect.response.ts new file mode 100644 index 00000000000..bc53e9daf6f --- /dev/null +++ b/apps/server/src/modules/oauth-provider/domain/interface/response/redirect.response.ts @@ -0,0 +1,6 @@ +/** + * @see https://www.ory.sh/docs/hydra/reference/api#tag/oAuth2 + */ +export interface ProviderRedirectResponse { + redirect_to: string; +} diff --git a/apps/server/src/modules/oauth-provider/interface/subject-type.enum.ts b/apps/server/src/modules/oauth-provider/domain/interface/subject-type.enum.ts similarity index 100% rename from apps/server/src/modules/oauth-provider/interface/subject-type.enum.ts rename to apps/server/src/modules/oauth-provider/domain/interface/subject-type.enum.ts diff --git a/apps/server/src/modules/oauth-provider/interface/token-auth-method.enum.ts b/apps/server/src/modules/oauth-provider/domain/interface/token-auth-method.enum.ts similarity index 100% rename from apps/server/src/modules/oauth-provider/interface/token-auth-method.enum.ts rename to apps/server/src/modules/oauth-provider/domain/interface/token-auth-method.enum.ts diff --git a/apps/server/src/modules/oauth-provider/domain/service/hydra.adapter.spec.ts b/apps/server/src/modules/oauth-provider/domain/service/hydra.adapter.spec.ts new file mode 100644 index 00000000000..cf0ee9f0d01 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/domain/service/hydra.adapter.spec.ts @@ -0,0 +1,782 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { HttpService } from '@nestjs/axios'; +import { Test, TestingModule } from '@nestjs/testing'; +import { axiosResponseFactory } from '@shared/testing'; +import { AxiosRequestConfig } from 'axios'; +import { of } from 'rxjs'; +import { OauthProviderFeatures } from '../../oauth-provider-config'; +import { + acceptConsentRequestBodyFactory, + acceptLoginRequestBodyFactory, + introspectResponseFactory, + providerConsentResponseFactory, + providerConsentSessionResponseFactory, + providerLoginResponseFactory, + providerOauthClientFactory, + rejectRequestBodyFactory, +} from '../../testing'; +import { + AcceptConsentRequestBody, + AcceptLoginRequestBody, + IntrospectResponse, + ProviderConsentResponse, + ProviderConsentSessionResponse, + ProviderLoginResponse, + ProviderOauthClient, + ProviderRedirectResponse, + RejectRequestBody, +} from '../interface'; +import { HydraAdapter } from './hydra.adapter'; + +describe('HydraService', () => { + let module: TestingModule; + let service: HydraAdapter; + + let httpService: DeepMocked; + + const hydraUri = 'http://hydra.uri'; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + HydraAdapter, + { + provide: HttpService, + useValue: createMock(), + }, + { + provide: OauthProviderFeatures, + useValue: { + hydraUri, + }, + }, + ], + }).compile(); + + service = module.get(HydraAdapter); + httpService = module.get(HttpService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('listOAuth2Clients', () => { + describe('when only clientIds are given', () => { + const setup = () => { + const data: ProviderOauthClient[] = providerOauthClientFactory.buildList(2); + + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data, + }) + ) + ); + + return { + data, + }; + }; + + it('should call the external provider', async () => { + setup(); + + await service.listOAuth2Clients(); + + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${hydraUri}/clients`, + method: 'GET', + headers: { + 'X-Forwarded-Proto': 'https', + }, + }) + ); + }); + + it('should list all oauth2 clients', async () => { + const { data } = setup(); + + const result: ProviderOauthClient[] = await service.listOAuth2Clients(); + + expect(result).toEqual(data); + }); + }); + + describe('when clientId and other parameters are given', () => { + const setup = () => { + const data: ProviderOauthClient[] = providerOauthClientFactory.buildList(2, { + client_name: 'client1', + owner: 'clientOwner', + }); + + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data, + }) + ) + ); + + return { + data, + }; + }; + + it('should call the external provider', async () => { + setup(); + + await service.listOAuth2Clients(1, 0, 'client1', 'clientOwner'); + + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${hydraUri}/clients?limit=1&offset=0&client_name=client1&owner=clientOwner`, + method: 'GET', + headers: { + 'X-Forwarded-Proto': 'https', + }, + }) + ); + }); + + it('should list all oauth2 clients within parameters', async () => { + const { data } = setup(); + + const result: ProviderOauthClient[] = await service.listOAuth2Clients(1, 0, 'client1', 'clientOwner'); + + expect(result).toEqual(data); + }); + }); + }); + + describe('getOAuth2Client', () => { + describe('when fetching an oauth2 client', () => { + const setup = () => { + const data: ProviderOauthClient = providerOauthClientFactory.build(); + + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data, + }) + ) + ); + + return { + data, + }; + }; + + it('should call the external provider', async () => { + setup(); + + await service.getOAuth2Client('clientId'); + + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${hydraUri}/clients/clientId`, + method: 'GET', + headers: { + 'X-Forwarded-Proto': 'https', + }, + }) + ); + }); + + it('should get oauth2 client', async () => { + const { data } = setup(); + + const result: ProviderOauthClient = await service.getOAuth2Client('clientId'); + + expect(result).toEqual(data); + }); + }); + }); + + describe('createOAuth2Client', () => { + describe('when creating an oauth2 client', () => { + const setup = () => { + const data: ProviderOauthClient = providerOauthClientFactory.build(); + + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data, + }) + ) + ); + + return { + data, + }; + }; + + it('should call the external provider', async () => { + const { data } = setup(); + + await service.createOAuth2Client(data); + + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${hydraUri}/clients`, + method: 'POST', + headers: { + 'X-Forwarded-Proto': 'https', + }, + data, + }) + ); + }); + + it('should create oauth2 client', async () => { + const { data } = setup(); + + const result: ProviderOauthClient = await service.createOAuth2Client(data); + + expect(result).toEqual(data); + }); + }); + }); + + describe('updateOAuth2Client', () => { + describe('when updating an oauth2 client', () => { + const setup = () => { + const data: ProviderOauthClient = providerOauthClientFactory.build(); + + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data, + }) + ) + ); + + return { + data, + }; + }; + + it('should call the external provider', async () => { + const { data } = setup(); + + await service.updateOAuth2Client('clientId', data); + + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${hydraUri}/clients/clientId`, + method: 'PUT', + headers: { + 'X-Forwarded-Proto': 'https', + }, + data, + }) + ); + }); + + it('should update the oauth2 client', async () => { + const { data } = setup(); + + const result: ProviderOauthClient = await service.updateOAuth2Client('clientId', data); + + expect(result).toEqual(data); + }); + }); + }); + + describe('deleteOAuth2Client', () => { + describe('when deleting an oauth2 client', () => { + const setup = () => { + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data: {}, + }) + ) + ); + }; + + it('should delete the oauth2 client', async () => { + setup(); + + await service.deleteOAuth2Client('clientId'); + + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${hydraUri}/clients/clientId`, + method: 'DELETE', + headers: { + 'X-Forwarded-Proto': 'https', + }, + }) + ); + }); + }); + }); + + describe('getConsentRequest', () => { + describe('when fetching a consent request', () => { + const setup = () => { + const challenge = 'challengexyz'; + const config: AxiosRequestConfig = { + method: 'GET', + url: `${hydraUri}/oauth2/auth/requests/consent?consent_challenge=${challenge}`, + }; + const providerConsentResponse: ProviderConsentResponse = providerConsentResponseFactory.build({ challenge }); + + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data: providerConsentResponse, + }) + ) + ); + + return { + config, + challenge, + }; + }; + + it('should call the external provider', async () => { + const { config, challenge } = setup(); + + await service.getConsentRequest(challenge); + + expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); + }); + + it('should return the consent request', async () => { + const { challenge } = setup(); + + const result: ProviderConsentResponse = await service.getConsentRequest(challenge); + + expect(result.challenge).toEqual(challenge); + }); + }); + }); + + describe('acceptConsentRequest', () => { + describe('when accepting a consent request', () => { + const setup = () => { + const challenge = 'challengexyz'; + const body: AcceptConsentRequestBody = acceptConsentRequestBodyFactory.build(); + const config: AxiosRequestConfig = { + method: 'PUT', + url: `${hydraUri}/oauth2/auth/requests/consent/accept?consent_challenge=${challenge}`, + data: body, + }; + const expectedRedirectTo = 'redirectTo'; + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data: { redirect_to: expectedRedirectTo }, + }) + ) + ); + + return { + body, + config, + expectedRedirectTo, + challenge, + }; + }; + + it('should call the external provider', async () => { + const { body, config, challenge } = setup(); + + await service.acceptConsentRequest(challenge, body); + + expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); + }); + + it('should return a redirect', async () => { + const { body, expectedRedirectTo, challenge } = setup(); + + const result: ProviderRedirectResponse = await service.acceptConsentRequest(challenge, body); + + expect(result.redirect_to).toEqual(expectedRedirectTo); + }); + }); + }); + + describe('rejectConsentRequest', () => { + describe('when rejecting a consent request', () => { + const setup = () => { + const challenge = 'challengexyz'; + const body: RejectRequestBody = rejectRequestBodyFactory.build(); + const config: AxiosRequestConfig = { + method: 'PUT', + url: `${hydraUri}/oauth2/auth/requests/consent/reject?consent_challenge=${challenge}`, + data: body, + }; + const expectedRedirectTo = 'redirectTo'; + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data: { redirect_to: expectedRedirectTo }, + }) + ) + ); + + return { + body, + config, + expectedRedirectTo, + challenge, + }; + }; + + it('should call the external provider', async () => { + const { body, config, challenge } = setup(); + + await service.rejectConsentRequest(challenge, body); + + expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); + }); + + it('should return a redirect', async () => { + const { body, expectedRedirectTo, challenge } = setup(); + + const result: ProviderRedirectResponse = await service.rejectConsentRequest(challenge, body); + + expect(result.redirect_to).toEqual(expectedRedirectTo); + }); + }); + }); + + describe('listConsentSessions', () => { + describe('when listing all consent requests of a user', () => { + const setup = () => { + const response: ProviderConsentSessionResponse[] = providerConsentSessionResponseFactory.buildList(1); + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data: response, + }) + ) + ); + + return { + response, + }; + }; + + it('should call the external provider', async () => { + setup(); + + await service.listConsentSessions('userId'); + + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${hydraUri}/oauth2/auth/sessions/consent?subject=userId`, + method: 'GET', + headers: { + 'X-Forwarded-Proto': 'https', + }, + }) + ); + }); + + it('should list all consent sessions', async () => { + const { response } = setup(); + + const result: ProviderConsentSessionResponse[] = await service.listConsentSessions('userId'); + + expect(result).toEqual(response); + }); + }); + }); + + describe('revokeConsentSession', () => { + describe('when revoking a consent session', () => { + const setup = () => { + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data: {}, + }) + ) + ); + }; + + it('should should call the external provider to revoke all consent sessions', async () => { + setup(); + + await service.revokeConsentSession('userId', 'clientId'); + + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${hydraUri}/oauth2/auth/sessions/consent?subject=userId&client=clientId`, + method: 'DELETE', + headers: { + 'X-Forwarded-Proto': 'https', + }, + }) + ); + }); + }); + }); + + describe('acceptLogoutRequest', () => { + describe('when accepting a logout request', () => { + const setup = () => { + const responseMock: ProviderRedirectResponse = { redirect_to: 'redirect_mock' }; + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data: responseMock, + }) + ) + ); + const config: AxiosRequestConfig = { + method: 'PUT', + url: `${hydraUri}/oauth2/auth/requests/logout/accept?logout_challenge=challenge_mock`, + headers: { 'X-Forwarded-Proto': 'https' }, + }; + + return { + responseMock, + config, + }; + }; + + it('should call the external provider', async () => { + const { config } = setup(); + + await service.acceptLogoutRequest('challenge_mock'); + + expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); + }); + + it('should return a redirect', async () => { + const { responseMock } = setup(); + + const response: ProviderRedirectResponse = await service.acceptLogoutRequest('challenge_mock'); + + expect(response).toEqual(responseMock); + }); + }); + }); + + describe('introspectOAuth2Token', () => { + describe('when fetching information about a token', () => { + const setup = () => { + const response: IntrospectResponse = introspectResponseFactory.build(); + + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data: response, + }) + ) + ); + + return { + response, + }; + }; + + it('should call the external provider', async () => { + setup(); + + await service.introspectOAuth2Token('token', 'scope'); + + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${hydraUri}/oauth2/introspect`, + method: 'POST', + headers: { + 'X-Forwarded-Proto': 'https', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data: 'token=token&scope=scope', + }) + ); + }); + + it('should return introspect', async () => { + const { response } = setup(); + + const result: IntrospectResponse = await service.introspectOAuth2Token('token', 'scope'); + + expect(result).toEqual(response); + }); + }); + }); + + describe('isInstanceAlive', () => { + describe('when checking if the external provider is alive', () => { + const setup = () => { + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data: true, + }) + ) + ); + }; + + it('should call the external provider', async () => { + setup(); + + await service.isInstanceAlive(); + + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${hydraUri}/health/alive`, + method: 'GET', + headers: { + 'X-Forwarded-Proto': 'https', + }, + }) + ); + }); + + it('should return if the external provider is alive', async () => { + setup(); + + const result: boolean = await service.isInstanceAlive(); + + expect(result).toEqual(true); + }); + }); + }); + + describe('getLoginRequest', () => { + describe('when fetching a login request', () => { + const setup = () => { + const providerLoginResponse: ProviderLoginResponse = providerLoginResponseFactory.build(); + const challenge = 'challengexyz'; + const requestConfig: AxiosRequestConfig = { + method: 'GET', + url: `${hydraUri}/oauth2/auth/requests/login?login_challenge=${challenge}`, + }; + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data: providerLoginResponse, + }) + ) + ); + + return { + requestConfig, + challenge, + providerLoginResponse, + }; + }; + + it('should call the external provider', async () => { + const { requestConfig, challenge } = setup(); + + await service.getLoginRequest(challenge); + + expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(requestConfig)); + }); + + it('should return a login request', async () => { + const { challenge, providerLoginResponse } = setup(); + + const response: ProviderLoginResponse = await service.getLoginRequest(challenge); + + expect(response).toEqual(providerLoginResponse); + }); + }); + }); + + describe('acceptLoginRequest', () => { + describe('when accepting a login request', () => { + const setup = () => { + const challenge = 'challengexyz'; + const body: AcceptLoginRequestBody = acceptLoginRequestBodyFactory.build(); + const config: AxiosRequestConfig = { + method: 'PUT', + url: `${hydraUri}/oauth2/auth/requests/login/accept?login_challenge=${challenge}`, + data: body, + }; + const expectedRedirectTo = 'redirectTo'; + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data: { redirect_to: expectedRedirectTo }, + }) + ) + ); + + return { + body, + config, + expectedRedirectTo, + challenge, + }; + }; + + it('should call the external provider', async () => { + const { body, config, challenge } = setup(); + + await service.acceptLoginRequest(challenge, body); + + expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); + }); + + it('should return a redirect', async () => { + const { body, expectedRedirectTo, challenge } = setup(); + + const result: ProviderRedirectResponse = await service.acceptLoginRequest(challenge, body); + + expect(result.redirect_to).toEqual(expectedRedirectTo); + }); + }); + }); + + describe('rejectLoginRequest', () => { + describe('when rejecting a login request', () => { + const setup = () => { + const challenge = 'challengexyz'; + const body: RejectRequestBody = rejectRequestBodyFactory.build(); + const config: AxiosRequestConfig = { + method: 'PUT', + url: `${hydraUri}/oauth2/auth/requests/login/reject?login_challenge=${challenge}`, + data: body, + }; + const expectedRedirectTo = 'redirectTo'; + httpService.request.mockReturnValueOnce( + of( + axiosResponseFactory.build({ + data: { redirect_to: expectedRedirectTo }, + }) + ) + ); + + return { + body, + config, + expectedRedirectTo, + challenge, + }; + }; + + it('should call the external provider', async () => { + const { body, config, challenge } = setup(); + + await service.rejectLoginRequest(challenge, body); + + expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); + }); + + it('should return a redirect', async () => { + const { body, expectedRedirectTo, challenge } = setup(); + + const result: ProviderRedirectResponse = await service.rejectLoginRequest(challenge, body); + + expect(result.redirect_to).toEqual(expectedRedirectTo); + }); + }); + }); +}); diff --git a/apps/server/src/modules/oauth-provider/domain/service/hydra.adapter.ts b/apps/server/src/modules/oauth-provider/domain/service/hydra.adapter.ts new file mode 100644 index 00000000000..ea0d84cbfca --- /dev/null +++ b/apps/server/src/modules/oauth-provider/domain/service/hydra.adapter.ts @@ -0,0 +1,238 @@ +import { HttpService } from '@nestjs/axios'; +import { Inject, Injectable } from '@nestjs/common'; +import { AxiosResponse, isAxiosError, Method, RawAxiosRequestHeaders } from 'axios'; +import QueryString from 'qs'; +import { firstValueFrom, Observable } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { URL } from 'url'; +import { IOauthProviderFeatures, OauthProviderFeatures } from '../../oauth-provider-config'; +import { + AcceptConsentRequestBody, + AcceptLoginRequestBody, + HydraOauthFailedLoggableException, + IntrospectResponse, + ProviderConsentResponse, + ProviderConsentSessionResponse, + ProviderLoginResponse, + ProviderOauthClient, + ProviderRedirectResponse, + RejectRequestBody, +} from '../index'; +import { OauthProviderService } from './oauth-provider.service'; + +@Injectable() +export class HydraAdapter extends OauthProviderService { + constructor( + private readonly httpService: HttpService, + @Inject(OauthProviderFeatures) private readonly oauthProviderFeatures: IOauthProviderFeatures + ) { + super(); + } + + public async acceptConsentRequest( + challenge: string, + body: AcceptConsentRequestBody + ): Promise { + const response: ProviderRedirectResponse = await this.put( + 'consent', + 'accept', + challenge, + body + ); + + return response; + } + + public async acceptLoginRequest(challenge: string, body: AcceptLoginRequestBody): Promise { + const response: ProviderRedirectResponse = await this.put( + 'login', + 'accept', + challenge, + body + ); + + return response; + } + + public async acceptLogoutRequest(challenge: string): Promise { + const response: ProviderRedirectResponse = await this.put('logout', 'accept', challenge); + + return response; + } + + public async getConsentRequest(challenge: string): Promise { + const response: ProviderConsentResponse = await this.get('consent', challenge); + + return response; + } + + public async getLoginRequest(challenge: string): Promise { + const response: ProviderLoginResponse = await this.get('login', challenge); + + return response; + } + + public async introspectOAuth2Token(token: string, scope: string): Promise { + const response: IntrospectResponse = await this.request( + 'POST', + `${this.oauthProviderFeatures.hydraUri}/oauth2/introspect`, + `token=${token}&scope=${scope}`, + { 'Content-Type': 'application/x-www-form-urlencoded' } + ); + + return response; + } + + public async isInstanceAlive(): Promise { + const response: boolean = await this.request('GET', `${this.oauthProviderFeatures.hydraUri}/health/alive`); + + return response; + } + + public async listConsentSessions(user: string): Promise { + const response: ProviderConsentSessionResponse[] = await this.request( + 'GET', + `${this.oauthProviderFeatures.hydraUri}/oauth2/auth/sessions/consent?subject=${user}` + ); + + return response; + } + + public async rejectConsentRequest(challenge: string, body: RejectRequestBody): Promise { + const response: ProviderRedirectResponse = await this.put( + 'consent', + 'reject', + challenge, + body + ); + + return response; + } + + public async rejectLoginRequest(challenge: string, body: RejectRequestBody): Promise { + const response: ProviderRedirectResponse = await this.put( + 'login', + 'reject', + challenge, + body + ); + + return response; + } + + public async revokeConsentSession(user: string, client: string): Promise { + await this.request( + 'DELETE', + `${this.oauthProviderFeatures.hydraUri}/oauth2/auth/sessions/consent?subject=${user}&client=${client}` + ); + } + + public async listOAuth2Clients( + limit?: number, + offset?: number, + client_name?: string, + owner?: string + ): Promise { + const url: URL = new URL(`${this.oauthProviderFeatures.hydraUri}/clients`); + url.search = QueryString.stringify({ + limit, + offset, + client_name, + owner, + }); + + const response: ProviderOauthClient[] = await this.request('GET', url.toString()); + + return response; + } + + public async getOAuth2Client(id: string): Promise { + console.log(`${this.oauthProviderFeatures.hydraUri}/clients/${id}`); + + const response: ProviderOauthClient = await this.request( + 'GET', + `${this.oauthProviderFeatures.hydraUri}/clients/${id}` + ); + + return response; + } + + public async createOAuth2Client(data: Partial): Promise { + const response: ProviderOauthClient = await this.request( + 'POST', + `${this.oauthProviderFeatures.hydraUri}/clients`, + data + ); + + return response; + } + + public async updateOAuth2Client(id: string, data: Partial): Promise { + const response: ProviderOauthClient = await this.request( + 'PUT', + `${this.oauthProviderFeatures.hydraUri}/clients/${id}`, + data + ); + + return response; + } + + public async deleteOAuth2Client(id: string): Promise { + await this.request('DELETE', `${this.oauthProviderFeatures.hydraUri}/clients/${id}`); + } + + private async put( + flow: string, + action: string, + challenge: string, + body?: AcceptConsentRequestBody | AcceptLoginRequestBody | RejectRequestBody + ): Promise { + const putResponse: T = await this.request( + 'PUT', + `${this.oauthProviderFeatures.hydraUri}/oauth2/auth/requests/${flow}/${action}?${flow}_challenge=${challenge}`, + body + ); + + return putResponse; + } + + private async get(flow: string, challenge: string): Promise { + const getResponse: T = await this.request( + 'GET', + `${this.oauthProviderFeatures.hydraUri}/oauth2/auth/requests/${flow}?${flow}_challenge=${challenge}` + ); + + return getResponse; + } + + private async request( + method: Method, + url: string, + data?: unknown, + additionalHeaders: RawAxiosRequestHeaders = {} + ): Promise { + const observable: Observable> = this.httpService + .request({ + url, + method, + headers: { + 'X-Forwarded-Proto': 'https', + ...additionalHeaders, + }, + data, + }) + .pipe( + catchError((error: unknown) => { + if (isAxiosError(error)) { + throw new HydraOauthFailedLoggableException(error); + } else { + throw error; + } + }) + ); + + const response: AxiosResponse = await firstValueFrom(observable); + + return response.data; + } +} diff --git a/apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts b/apps/server/src/modules/oauth-provider/domain/service/id-token.service.spec.ts similarity index 94% rename from apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts rename to apps/server/src/modules/oauth-provider/domain/service/id-token.service.spec.ts index 4a58a3e8336..c6a15f59ef5 100644 --- a/apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts +++ b/apps/server/src/modules/oauth-provider/domain/service/id-token.service.spec.ts @@ -1,17 +1,17 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { IdToken } from '@modules/oauth-provider/interface/id-token'; -import { OauthScope } from '@modules/oauth-provider/interface/oauth-scope.enum'; -import { IdTokenService } from '@modules/oauth-provider/service/id-token.service'; -import { PseudonymService } from '@modules/pseudonym/service'; -import { ExternalTool } from '@modules/tool/external-tool/domain'; -import { UserService } from '@modules/user/service/user.service'; import { Test, TestingModule } from '@nestjs/testing'; import { Pseudonym, UserDO } from '@shared/domain/domainobject'; import { TeamEntity } from '@shared/domain/entity'; import { TeamsRepo } from '@shared/repo'; import { externalToolFactory, pseudonymFactory, setupEntities, userDoFactory } from '@shared/testing'; import { teamFactory } from '@shared/testing/factory/team.factory'; +import { PseudonymService } from '../../../pseudonym/service'; +import { ExternalTool } from '../../../tool/external-tool/domain'; +import { UserService } from '../../../user/service/user.service'; import { IdTokenCreationLoggableException } from '../error/id-token-creation-exception.loggable'; +import { IdToken } from '../interface/id-token'; +import { OauthScope } from '../interface/oauth-scope.enum'; +import { IdTokenService } from './id-token.service'; import { OauthProviderLoginFlowService } from './oauth-provider.login-flow.service'; import resetAllMocks = jest.resetAllMocks; diff --git a/apps/server/src/modules/oauth-provider/service/id-token.service.ts b/apps/server/src/modules/oauth-provider/domain/service/id-token.service.ts similarity index 96% rename from apps/server/src/modules/oauth-provider/service/id-token.service.ts rename to apps/server/src/modules/oauth-provider/domain/service/id-token.service.ts index 57a69e7dc6e..4117b530a22 100644 --- a/apps/server/src/modules/oauth-provider/service/id-token.service.ts +++ b/apps/server/src/modules/oauth-provider/domain/service/id-token.service.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import { LtiToolDO, Pseudonym, UserDO } from '@shared/domain/domainobject'; import { TeamEntity } from '@shared/domain/entity'; import { TeamsRepo } from '@shared/repo'; -import { IdTokenCreationLoggableException } from '../error/id-token-creation-exception.loggable'; +import { IdTokenCreationLoggableException } from '../error'; import { GroupNameIdTuple, IdToken, OauthScope } from '../interface'; import { OauthProviderLoginFlowService } from './oauth-provider.login-flow.service'; diff --git a/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.spec.ts b/apps/server/src/modules/oauth-provider/domain/service/oauth-provider.login-flow.service.spec.ts similarity index 95% rename from apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.spec.ts rename to apps/server/src/modules/oauth-provider/domain/service/oauth-provider.login-flow.service.spec.ts index b25709dbb69..c90ce745fec 100644 --- a/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.spec.ts +++ b/apps/server/src/modules/oauth-provider/domain/service/oauth-provider.login-flow.service.spec.ts @@ -1,12 +1,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { LtiToolService } from '@modules/lti-tool'; -import { ExternalTool } from '@modules/tool/external-tool/domain'; -import { ExternalToolService } from '@modules/tool/external-tool/service'; -import { IToolFeatures, ToolFeatures } from '@modules/tool/tool-config'; import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { LtiToolDO } from '@shared/domain/domainobject'; import { externalToolFactory, ltiToolDOFactory, setupEntities } from '@shared/testing'; +import { LtiToolService } from '../../../lti-tool'; +import { ExternalTool } from '../../../tool/external-tool/domain'; +import { ExternalToolService } from '../../../tool/external-tool/service'; +import { IToolFeatures, ToolFeatures } from '../../../tool/tool-config'; import { OauthProviderLoginFlowService } from './oauth-provider.login-flow.service'; describe('OauthProviderLoginFlowService', () => { diff --git a/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.ts b/apps/server/src/modules/oauth-provider/domain/service/oauth-provider.login-flow.service.ts similarity index 83% rename from apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.ts rename to apps/server/src/modules/oauth-provider/domain/service/oauth-provider.login-flow.service.ts index adf363415fd..c2c3f5eecae 100644 --- a/apps/server/src/modules/oauth-provider/service/oauth-provider.login-flow.service.ts +++ b/apps/server/src/modules/oauth-provider/domain/service/oauth-provider.login-flow.service.ts @@ -2,16 +2,14 @@ import { LtiToolService } from '@modules/lti-tool/service'; import { ExternalTool } from '@modules/tool/external-tool/domain'; import { ExternalToolService } from '@modules/tool/external-tool/service'; import { IToolFeatures, ToolFeatures } from '@modules/tool/tool-config'; -import { Inject } from '@nestjs/common'; -import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; -import { NotFoundException } from '@nestjs/common/exceptions/not-found.exception'; +import { forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { LtiToolDO } from '@shared/domain/domainobject/ltitool.do'; @Injectable() export class OauthProviderLoginFlowService { constructor( private readonly ltiToolService: LtiToolService, - private readonly externalToolService: ExternalToolService, + @Inject(forwardRef(() => ExternalToolService)) private readonly externalToolService: ExternalToolService, @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures ) {} diff --git a/apps/server/src/modules/oauth-provider/domain/service/oauth-provider.service.ts b/apps/server/src/modules/oauth-provider/domain/service/oauth-provider.service.ts new file mode 100644 index 00000000000..0fc437d7d56 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/domain/service/oauth-provider.service.ts @@ -0,0 +1,56 @@ +import { + AcceptConsentRequestBody, + AcceptLoginRequestBody, + IntrospectResponse, + ProviderConsentResponse, + ProviderConsentSessionResponse, + ProviderLoginResponse, + ProviderOauthClient, + ProviderRedirectResponse, + RejectRequestBody, +} from '../interface'; + +export abstract class OauthProviderService { + public abstract getLoginRequest(challenge: string): Promise; + + public abstract acceptLoginRequest( + challenge: string, + body: AcceptLoginRequestBody + ): Promise; + + public abstract rejectLoginRequest(challenge: string, body: RejectRequestBody): Promise; + + public abstract getConsentRequest(challenge: string): Promise; + + public abstract acceptConsentRequest( + challenge: string, + body: AcceptConsentRequestBody + ): Promise; + + public abstract rejectConsentRequest(challenge: string, body: RejectRequestBody): Promise; + + public abstract acceptLogoutRequest(challenge: string): Promise; + + public abstract introspectOAuth2Token(token: string, scope?: string): Promise; + + public abstract isInstanceAlive(): Promise; + + public abstract listOAuth2Clients( + limit?: number, + offset?: number, + client_name?: string, + owner?: string + ): Promise; + + public abstract createOAuth2Client(data: Partial): Promise; + + public abstract getOAuth2Client(id: string): Promise; + + public abstract updateOAuth2Client(id: string, data: Partial): Promise; + + public abstract deleteOAuth2Client(id: string): Promise; + + public abstract listConsentSessions(user: string): Promise; + + public abstract revokeConsentSession(user: string, client: string): Promise; +} diff --git a/apps/server/src/modules/oauth-provider/index.ts b/apps/server/src/modules/oauth-provider/index.ts index f92dccb6db1..f45d424d499 100644 --- a/apps/server/src/modules/oauth-provider/index.ts +++ b/apps/server/src/modules/oauth-provider/index.ts @@ -1,3 +1,2 @@ -export * from './oauth-provider.module'; -export * from './oauth-provider-api.module'; -export * from './interface/token-auth-method.enum'; +export { OauthProviderServiceModule } from './oauth-provider-service.module'; +export { TokenAuthMethod } from './domain/interface/token-auth-method.enum'; diff --git a/apps/server/src/modules/oauth-provider/interface/index.ts b/apps/server/src/modules/oauth-provider/interface/index.ts deleted file mode 100644 index 8b7f57126e8..00000000000 --- a/apps/server/src/modules/oauth-provider/interface/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './id-token'; -export * from './oauth-scope.enum'; -export * from './subject-type.enum'; -export * from './token-auth-method.enum'; diff --git a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.spec.ts b/apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.spec.ts deleted file mode 100644 index d34571dcbb7..00000000000 --- a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-request.mapper.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { AcceptLoginRequestBody } from '@infra/oauth-provider/dto'; -import { LoginRequestBody } from '../controller/dto'; -import { OauthProviderRequestMapper } from './oauth-provider-request.mapper'; - -describe('OauthProviderRequestMapper', () => { - describe('mapCreateAcceptLoginRequestBody', () => { - it('should create the AcceptLoginRequestBody', () => { - const loginRequestBodyMock: LoginRequestBody = { - remember: true, - remember_for: 0, - }; - - const result: AcceptLoginRequestBody = OauthProviderRequestMapper.mapCreateAcceptLoginRequestBody( - loginRequestBodyMock, - 'currentUserId', - 'pseudonym', - { test: '123' } - ); - - expect(result).toEqual({ - remember: true, - remember_for: 0, - subject: 'currentUserId', - force_subject_identifier: 'pseudonym', - context: { test: '123' }, - }); - }); - }); -}); diff --git a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.spec.ts b/apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.spec.ts deleted file mode 100644 index f28ab378771..00000000000 --- a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.spec.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { OauthProviderResponseMapper } from '@modules/oauth-provider/mapper/oauth-provider-response.mapper'; -import { - ProviderConsentResponse, - ProviderConsentSessionResponse, - ProviderLoginResponse, - ProviderOauthClient, - ProviderRedirectResponse, -} from '@infra/oauth-provider/dto'; -import { - ConsentResponse, - ConsentSessionResponse, - LoginResponse, - OauthClientResponse, - RedirectResponse, -} from '@modules/oauth-provider/controller/dto/'; - -describe('OauthProviderResponseMapper', () => { - let mapper: OauthProviderResponseMapper; - - beforeAll(() => { - mapper = new OauthProviderResponseMapper(); - }); - - it('mapRedirectResponse', () => { - const providerResponse: ProviderRedirectResponse = { redirect_to: 'anywhere' }; - - const result: RedirectResponse = mapper.mapRedirectResponse(providerResponse); - - expect(result.redirect_to).toEqual(providerResponse.redirect_to); - }); - - it('mapConsentResponse', () => { - const providerConsentResponse: ProviderConsentResponse = { - acr: 'acr', - amr: ['amr'], - challenge: 'challenge', - client: {}, - context: {}, - login_challenge: 'login_challenge', - login_session_id: 'login_session_id', - oidc_context: {}, - request_url: 'request_url', - requested_access_token_audience: ['requested_access_token_audience'], - requested_scope: ['requested_scope'], - skip: true, - subject: 'subject', - }; - - const result: ConsentResponse = mapper.mapConsentResponse(providerConsentResponse); - - expect(result).toEqual(new ConsentResponse({ ...providerConsentResponse })); - }); - - it('mapOauthClientResponseSpec', () => { - const providerOauthClientResponse: ProviderOauthClient = { - allowed_cors_origins: ['allowed_cors_origins'], - audience: ['audience'], - authorization_code_grant_access_token_lifespan: 'authorization_code_grant_access_token_lifespan', - authorization_code_grant_id_token_lifespan: 'authorization_code_grant_id_token_lifespan', - authorization_code_grant_refresh_token_lifespan: 'authorization_code_grant_refresh_token_lifespan', - backchannel_logout_session_required: true, - backchannel_logout_uri: 'backchannel_logout_uri', - client_credentials_grant_access_token_lifespan: 'client_credentials_grant_access_token_lifespan', - client_id: 'client_id', - client_name: 'client_name', - client_secret: 'client_secret', - client_secret_expires_at: 5, - client_uri: 'client_uri', - contacts: ['contacts'], - created_at: 'created_at', - frontchannel_logout_session_required: true, - frontchannel_logout_uri: 'frontchannel_logout_uri', - grant_types: ['grant_types'], - implicit_grant_access_token_lifespan: 'implicit_grant_access_token_lifespan', - implicit_grant_id_token_lifespan: 'implicit_grant_id_token_lifespan', - jwks: {}, - jwks_uri: 'jwks_uri', - jwt_bearer_grant_access_token_lifespan: 'jwt_bearer_grant_access_token_lifespan', - logo_uri: 'logo_uri', - metadata: {}, - owner: 'owner', - password_grant_access_token_lifespan: 'password_grant_access_token_lifespan', - password_grant_refresh_token_lifespan: 'password_grant_refresh_token_lifespan', - policy_uri: 'policy_uri', - post_logout_redirect_uris: ['post_logout_redirect_uris'], - redirect_uris: ['redirect_uris'], - refresh_token_grant_access_token_lifespan: 'refresh_token_grant_access_token_lifespan', - refresh_token_grant_id_token_lifespan: 'refresh_token_grant_id_token_lifespan', - refresh_token_grant_refresh_token_lifespan: 'refresh_token_grant_refresh_token_lifespan', - registration_access_token: 'registration_access_token', - registration_client_uri: 'registration_client_uri', - request_object_signing_alg: 'request_object_signing_alg', - request_uris: ['request_uris'], - response_types: ['response_types'], - scope: 'scope', - sector_identifier_uri: 'sector_identifier_uri', - subject_type: 'subject_type', - token_endpoint_auth_method: 'token_endpoint_auth_method', - token_endpoint_auth_signing_alg: 'token_endpoint_auth_signing_alg', - tos_uri: 'tos_uri', - updated_at: 'updated_at', - userinfo_signed_response_alg: 'userinfo_signed_response_alg', - }; - - const result: OauthClientResponse = mapper.mapOauthClientResponse(providerOauthClientResponse); - - expect(result).toEqual(expect.objectContaining(providerOauthClientResponse)); - }); - - describe('mapConsentSessionsToResponse', () => { - it('should map all attributes', () => { - const session: ProviderConsentSessionResponse = { - consent_request: { - challenge: 'challenge', - client: { - client_id: 'clientId', - client_name: 'clientName', - }, - }, - }; - - const response: ConsentSessionResponse = mapper.mapConsentSessionsToResponse(session); - - expect(response).toEqual( - expect.objectContaining({ - challenge: 'challenge', - client_id: 'clientId', - client_name: 'clientName', - }) - ); - }); - - it('should map empty response', () => { - const session: ProviderConsentSessionResponse = { - consent_request: { - challenge: 'challenge', - }, - }; - - const response: ConsentSessionResponse = mapper.mapConsentSessionsToResponse(session); - - expect(response).toEqual(new ConsentSessionResponse(undefined, undefined, 'challenge')); - }); - }); - - it('mapOauthClientResponse', () => { - const providerLoginResponse: ProviderLoginResponse = { - challenge: 'challenge', - client: {}, - oidc_context: {}, - request_url: 'request_url', - requested_access_token_audience: ['requested_access_token_audience'], - requested_scope: ['requested_scope'], - session_id: 'session_id', - } as ProviderLoginResponse; - - const result: LoginResponse = mapper.mapLoginResponse(providerLoginResponse); - - expect(result).toEqual(expect.objectContaining(providerLoginResponse)); - }); -}); diff --git a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.ts b/apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.ts deleted file mode 100644 index c97b86366b0..00000000000 --- a/apps/server/src/modules/oauth-provider/mapper/oauth-provider-response.mapper.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { - ProviderConsentResponse, - ProviderConsentSessionResponse, - ProviderLoginResponse, - ProviderOauthClient, - ProviderRedirectResponse, -} from '@infra/oauth-provider/dto'; -import { - ConsentResponse, - ConsentSessionResponse, - LoginResponse, - OauthClientResponse, - RedirectResponse, -} from '@modules/oauth-provider/controller/dto'; - -@Injectable() -export class OauthProviderResponseMapper { - mapRedirectResponse(redirect: ProviderRedirectResponse): RedirectResponse { - return new RedirectResponse({ ...redirect }); - } - - mapConsentResponse(consent: ProviderConsentResponse): ConsentResponse { - return new ConsentResponse({ ...consent }); - } - - mapOauthClientResponse(oauthClient: ProviderOauthClient): OauthClientResponse { - delete oauthClient.client_secret; - return new OauthClientResponse({ ...oauthClient }); - } - - mapConsentSessionsToResponse(session: ProviderConsentSessionResponse): ConsentSessionResponse { - return new ConsentSessionResponse( - session.consent_request.client?.client_id, - session.consent_request.client?.client_name, - session.consent_request.challenge - ); - } - - mapLoginResponse(providerLoginResponse: ProviderLoginResponse): LoginResponse { - return new LoginResponse({ ...providerLoginResponse }); - } -} diff --git a/apps/server/src/modules/oauth-provider/oauth-provider-api.module.ts b/apps/server/src/modules/oauth-provider/oauth-provider-api.module.ts index bf131e22eee..acffe805f6a 100644 --- a/apps/server/src/modules/oauth-provider/oauth-provider-api.module.ts +++ b/apps/server/src/modules/oauth-provider/oauth-provider-api.module.ts @@ -1,24 +1,23 @@ -import { Module } from '@nestjs/common'; -import { OauthProviderServiceModule } from '@infra/oauth-provider'; -import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '@modules/authorization'; import { PseudonymModule } from '@modules/pseudonym'; import { UserModule } from '@modules/user'; -import { OauthProviderController } from './controller/oauth-provider.controller'; -import { OauthProviderResponseMapper } from './mapper/oauth-provider-response.mapper'; -import { OauthProviderModule } from './oauth-provider.module'; +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; import { OauthProviderClientCrudUc, OauthProviderConsentFlowUc, + OauthProviderController, OauthProviderLoginFlowUc, OauthProviderLogoutFlowUc, OauthProviderUc, -} from './uc'; +} from './api'; +import { OauthProviderServiceModule } from './oauth-provider-service.module'; +import { OauthProviderModule } from './oauth-provider.module'; @Module({ imports: [ - OauthProviderServiceModule, OauthProviderModule, + OauthProviderServiceModule, PseudonymModule, LoggerModule, AuthorizationModule, @@ -30,7 +29,6 @@ import { OauthProviderConsentFlowUc, OauthProviderLogoutFlowUc, OauthProviderLoginFlowUc, - OauthProviderResponseMapper, ], controllers: [OauthProviderController], }) diff --git a/apps/server/src/modules/oauth-provider/oauth-provider-config.module.ts b/apps/server/src/modules/oauth-provider/oauth-provider-config.module.ts new file mode 100644 index 00000000000..1bc4466e24b --- /dev/null +++ b/apps/server/src/modules/oauth-provider/oauth-provider-config.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import OauthProviderConfiguration, { OauthProviderFeatures } from './oauth-provider-config'; + +@Module({ + providers: [ + { + provide: OauthProviderFeatures, + useValue: OauthProviderConfiguration.features, + }, + ], + exports: [OauthProviderFeatures], +}) +export class OauthProviderConfigModule {} diff --git a/apps/server/src/modules/oauth-provider/oauth-provider-config.ts b/apps/server/src/modules/oauth-provider/oauth-provider-config.ts new file mode 100644 index 00000000000..6196b60295b --- /dev/null +++ b/apps/server/src/modules/oauth-provider/oauth-provider-config.ts @@ -0,0 +1,13 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; + +export const OauthProviderFeatures = Symbol('OauthProviderFeatures'); + +export interface IOauthProviderFeatures { + hydraUri: string; +} + +export default class OauthProviderConfiguration { + static features: IOauthProviderFeatures = { + hydraUri: Configuration.get('HYDRA_URI') as string, + }; +} diff --git a/apps/server/src/modules/oauth-provider/oauth-provider-service.module.ts b/apps/server/src/modules/oauth-provider/oauth-provider-service.module.ts new file mode 100644 index 00000000000..73798e3c585 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/oauth-provider-service.module.ts @@ -0,0 +1,17 @@ +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; +import { HydraAdapter } from './domain/service/hydra.adapter'; +import { OauthProviderService } from './domain/service/oauth-provider.service'; +import { OauthProviderConfigModule } from './oauth-provider-config.module'; + +@Module({ + imports: [HttpModule, OauthProviderConfigModule], + providers: [ + { + provide: OauthProviderService, + useClass: HydraAdapter, + }, + ], + exports: [OauthProviderService], +}) +export class OauthProviderServiceModule {} diff --git a/apps/server/src/modules/oauth-provider/oauth-provider.module.ts b/apps/server/src/modules/oauth-provider/oauth-provider.module.ts index 7483ad140e5..7073929c055 100644 --- a/apps/server/src/modules/oauth-provider/oauth-provider.module.ts +++ b/apps/server/src/modules/oauth-provider/oauth-provider.module.ts @@ -1,24 +1,26 @@ -import { Module } from '@nestjs/common'; -import { OauthProviderServiceModule } from '@infra/oauth-provider'; -import { TeamsRepo } from '@shared/repo'; -import { LoggerModule } from '@src/core/logger'; import { LtiToolModule } from '@modules/lti-tool'; import { PseudonymModule } from '@modules/pseudonym'; import { ToolModule } from '@modules/tool'; import { ToolConfigModule } from '@modules/tool/tool-config.module'; import { UserModule } from '@modules/user'; -import { IdTokenService } from './service/id-token.service'; -import { OauthProviderLoginFlowService } from './service/oauth-provider.login-flow.service'; +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; +import { TeamsRepo } from '@shared/repo'; +import { LoggerModule } from '@src/core/logger'; +import { IdTokenService } from './domain/service/id-token.service'; +import { OauthProviderLoginFlowService } from './domain/service/oauth-provider.login-flow.service'; +import { OauthProviderConfigModule } from './oauth-provider-config.module'; @Module({ imports: [ - OauthProviderServiceModule, + HttpModule, UserModule, LoggerModule, PseudonymModule, LtiToolModule, ToolModule, ToolConfigModule, + OauthProviderConfigModule, ], providers: [OauthProviderLoginFlowService, IdTokenService, TeamsRepo], exports: [OauthProviderLoginFlowService, IdTokenService], diff --git a/apps/server/src/modules/oauth-provider/testing/accept-consent-request-body.factory.ts b/apps/server/src/modules/oauth-provider/testing/accept-consent-request-body.factory.ts new file mode 100644 index 00000000000..4e991307917 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/testing/accept-consent-request-body.factory.ts @@ -0,0 +1,17 @@ +import { Factory } from 'fishery'; +import { AcceptConsentRequestBody } from '../domain/interface'; +import { idTokenFactory } from './id-token.factory'; + +export const acceptConsentRequestBodyFactory = Factory.define(() => { + return { + grant_access_token_audience: [], + grant_scope: ['offline', 'openid'], + handled_at: '', + remember: true, + session: { + access_token: '', + id_token: idTokenFactory.build(), + }, + remember_for: 0, + }; +}); diff --git a/apps/server/src/modules/oauth-provider/testing/accept-login-request-body.factory.ts b/apps/server/src/modules/oauth-provider/testing/accept-login-request-body.factory.ts new file mode 100644 index 00000000000..c8a5094848c --- /dev/null +++ b/apps/server/src/modules/oauth-provider/testing/accept-login-request-body.factory.ts @@ -0,0 +1,14 @@ +import { Factory } from 'fishery'; +import { AcceptLoginRequestBody } from '../domain/interface'; + +export const acceptLoginRequestBodyFactory = Factory.define(() => { + return { + amr: [], + acr: '', + context: {}, + force_subject_identifier: '', + remember: true, + subject: '', + remember_for: 0, + }; +}); diff --git a/apps/server/src/modules/oauth-provider/testing/id-token.factory.ts b/apps/server/src/modules/oauth-provider/testing/id-token.factory.ts new file mode 100644 index 00000000000..ff6813f35c2 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/testing/id-token.factory.ts @@ -0,0 +1,9 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Factory } from 'fishery'; +import { IdToken } from '../domain/interface'; + +export const idTokenFactory = Factory.define(() => { + return { + schoolId: new ObjectId().toHexString(), + }; +}); diff --git a/apps/server/src/modules/oauth-provider/testing/index.ts b/apps/server/src/modules/oauth-provider/testing/index.ts new file mode 100644 index 00000000000..d94fd47cd05 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/testing/index.ts @@ -0,0 +1,10 @@ +export { idTokenFactory } from './id-token.factory'; +export { introspectResponseFactory } from './introspect-response.factory'; +export { rejectRequestBodyFactory } from './reject-request-body.factory'; +export { providerOauthClientFactory } from './provider-oauth-client.factory'; +export { providerOidcContextFactory } from './provider-oidc-context.factory'; +export { providerLoginResponseFactory } from './provider-login-response.factory'; +export { acceptLoginRequestBodyFactory } from './accept-login-request-body.factory'; +export { providerConsentResponseFactory } from './provider-consent-response.factory'; +export { acceptConsentRequestBodyFactory } from './accept-consent-request-body.factory'; +export { providerConsentSessionResponseFactory } from './provider-consent-session-response.factory'; diff --git a/apps/server/src/modules/oauth-provider/testing/introspect-response.factory.ts b/apps/server/src/modules/oauth-provider/testing/introspect-response.factory.ts new file mode 100644 index 00000000000..54e4c2cc380 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/testing/introspect-response.factory.ts @@ -0,0 +1,21 @@ +import { Factory } from 'fishery'; +import { IntrospectResponse } from '../domain/interface'; + +export const introspectResponseFactory = Factory.define(() => { + return { + active: true, + aud: [], + exp: 1, + ext: {}, + iat: 1, + iss: '', + nbf: 1, + client_id: '', + sub: '', + obfuscated_subject: '', + scope: '', + token_type: '', + token_use: '', + username: '', + }; +}); diff --git a/apps/server/src/modules/oauth-provider/testing/provider-consent-response.factory.ts b/apps/server/src/modules/oauth-provider/testing/provider-consent-response.factory.ts new file mode 100644 index 00000000000..9fbb25a5ca6 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/testing/provider-consent-response.factory.ts @@ -0,0 +1,22 @@ +import { Factory } from 'fishery'; +import { ProviderConsentResponse } from '../domain/interface'; +import { providerOauthClientFactory } from './provider-oauth-client.factory'; +import { providerOidcContextFactory } from './provider-oidc-context.factory'; + +export const providerConsentResponseFactory = Factory.define(() => { + return { + acr: '', + amr: [], + challenge: '', + client: providerOauthClientFactory.build(), + context: {}, + login_challenge: '', + login_session_id: '', + oidc_context: providerOidcContextFactory.build(), + request_url: '', + skip: false, + subject: '', + requested_scope: ['offline', 'openid'], + requested_access_token_audience: [], + }; +}); diff --git a/apps/server/src/modules/oauth-provider/testing/provider-consent-session-response.factory.ts b/apps/server/src/modules/oauth-provider/testing/provider-consent-session-response.factory.ts new file mode 100644 index 00000000000..ac5beb62c90 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/testing/provider-consent-session-response.factory.ts @@ -0,0 +1,18 @@ +import { Factory } from 'fishery'; +import { ProviderConsentSessionResponse } from '../domain'; +import { providerConsentResponseFactory } from './provider-consent-response.factory'; + +export const providerConsentSessionResponseFactory = Factory.define(() => { + return { + session: { + access_token: '', + id_token: '', + }, + consent_request: providerConsentResponseFactory.build(), + grant_access_token_audience: [], + grant_scope: ['offline', 'openid'], + handled_at: '', + remember: false, + remember_for: 1, + }; +}); diff --git a/apps/server/src/modules/oauth-provider/testing/provider-login-response.factory.ts b/apps/server/src/modules/oauth-provider/testing/provider-login-response.factory.ts new file mode 100644 index 00000000000..9a019400d33 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/testing/provider-login-response.factory.ts @@ -0,0 +1,18 @@ +import { Factory } from 'fishery'; +import { ProviderLoginResponse } from '../domain/interface'; +import { providerOauthClientFactory } from './provider-oauth-client.factory'; +import { providerOidcContextFactory } from './provider-oidc-context.factory'; + +export const providerLoginResponseFactory = Factory.define(() => { + return { + challenge: 'challenge', + client: providerOauthClientFactory.build(), + oidc_context: providerOidcContextFactory.build(), + request_url: 'request_url', + requested_access_token_audience: ['requested_access_token_audience'], + requested_scope: ['requested_scope'], + session_id: 'session_id', + skip: true, + subject: 'subject', + }; +}); diff --git a/apps/server/src/modules/oauth-provider/testing/provider-oauth-client.factory.ts b/apps/server/src/modules/oauth-provider/testing/provider-oauth-client.factory.ts new file mode 100644 index 00000000000..72f654f2251 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/testing/provider-oauth-client.factory.ts @@ -0,0 +1,54 @@ +import { Factory } from 'fishery'; +import { ProviderOauthClient, SubjectTypeEnum, TokenAuthMethod } from '../domain'; + +export const providerOauthClientFactory = Factory.define(({ sequence }) => { + return { + client_id: `client_${sequence}`, + authorization_code_grant_id_token_lifespan: 'authorization_code_grant_id_token_lifespan', + client_name: 'client_name', + allowed_cors_origins: [], + audience: [], + authorization_code_grant_access_token_lifespan: 'authorization_code_grant_access_token_lifespan', + authorization_code_grant_refresh_token_lifespan: 'authorization_code_grant_refresh_token_lifespan', + client_uri: 'client_uri', + backchannel_logout_session_required: false, + backchannel_logout_uri: 'backchannel_logout_uri', + client_secret: 'client_secret', + client_credentials_grant_access_token_lifespan: 'client_credentials_grant_access_token_lifespan', + client_secret_expires_at: 1, + contacts: [], + created_at: 'created_at', + frontchannel_logout_session_required: false, + frontchannel_logout_uri: 'frontchannel_logout_uri', + grant_types: [], + jwks: {}, + implicit_grant_access_token_lifespan: 'implicit_grant_access_token_lifespan', + implicit_grant_id_token_lifespan: 'implicit_grant_id_token_lifespan', + jwks_uri: 'jwks_uri', + jwt_bearer_grant_access_token_lifespan: 'jwt_bearer_grant_access_token_lifespan', + logo_uri: 'logo_uri', + metadata: {}, + owner: 'owner', + password_grant_access_token_lifespan: 'password_grant_access_token_lifespan', + password_grant_refresh_token_lifespan: 'password_grant_refresh_token_lifespan', + policy_uri: 'policy_uri', + post_logout_redirect_uris: [], + redirect_uris: [], + refresh_token_grant_access_token_lifespan: 'refresh_token_grant_access_token_lifespan', + refresh_token_grant_id_token_lifespan: 'refresh_token_grant_id_token_lifespan', + refresh_token_grant_refresh_token_lifespan: 'refresh_token_grant_refresh_token_lifespan', + registration_access_token: 'registration_access_token', + registration_client_uri: 'registration_client_uri', + request_object_signing_alg: 'request_object_signing_alg', + request_uris: [], + response_types: [], + scope: 'scope', + sector_identifier_uri: 'sector_identifier_uri', + subject_type: SubjectTypeEnum.PAIRWISE, + token_endpoint_auth_method: TokenAuthMethod.CLIENT_SECRET_BASIC, + tos_uri: 'tos_uri', + token_endpoint_auth_signing_alg: 'token_endpoint_auth_signing_alg', + updated_at: 'updated_at', + userinfo_signed_response_alg: 'userinfo_signed_response_alg', + }; +}); diff --git a/apps/server/src/modules/oauth-provider/testing/provider-oidc-context.factory.ts b/apps/server/src/modules/oauth-provider/testing/provider-oidc-context.factory.ts new file mode 100644 index 00000000000..6a9d39c1805 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/testing/provider-oidc-context.factory.ts @@ -0,0 +1,12 @@ +import { Factory } from 'fishery'; +import { ProviderOidcContext } from '../domain/interface'; + +export const providerOidcContextFactory = Factory.define(() => { + return { + acr_values: [], + display: '', + id_token_hint_claims: {}, + login_hint: '', + ui_locales: [], + }; +}); diff --git a/apps/server/src/modules/oauth-provider/testing/reject-request-body.factory.ts b/apps/server/src/modules/oauth-provider/testing/reject-request-body.factory.ts new file mode 100644 index 00000000000..4840b704ba8 --- /dev/null +++ b/apps/server/src/modules/oauth-provider/testing/reject-request-body.factory.ts @@ -0,0 +1,12 @@ +import { Factory } from 'fishery'; +import { RejectRequestBody } from '../domain/interface'; + +export const rejectRequestBodyFactory = Factory.define(() => { + return { + error: 'error', + error_debug: '', + error_description: '', + error_hint: '', + status_code: 500, + }; +}); diff --git a/apps/server/src/modules/oauth-provider/uc/index.ts b/apps/server/src/modules/oauth-provider/uc/index.ts deleted file mode 100644 index fbfa56c251d..00000000000 --- a/apps/server/src/modules/oauth-provider/uc/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './oauth-provider.client-crud.uc'; -export * from './oauth-provider.consent-flow.uc'; -export * from './oauth-provider.login-flow.uc'; -export * from './oauth-provider.logout-flow.uc'; -export * from './oauth-provider.uc'; diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.spec.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.spec.ts deleted file mode 100644 index 99be39f54de..00000000000 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.spec.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { OauthProviderService } from '@infra/oauth-provider'; -import { ProviderOauthClient } from '@infra/oauth-provider/dto'; -import { ICurrentUser } from '@modules/authentication'; -import { AuthorizationService } from '@modules/authorization'; -import { UnauthorizedException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { User } from '@shared/domain/entity'; -import { Permission } from '@shared/domain/interface'; -import { setupEntities, userFactory } from '@shared/testing'; -import { OauthProviderClientCrudUc } from './oauth-provider.client-crud.uc'; -import resetAllMocks = jest.resetAllMocks; - -describe('OauthProviderUc', () => { - let module: TestingModule; - let uc: OauthProviderClientCrudUc; - - let providerService: DeepMocked; - let authorizationService: DeepMocked; - - let user: User; - - const currentUser: ICurrentUser = { userId: 'userId' } as ICurrentUser; - const defaultOauthClientBody: ProviderOauthClient = { - scope: 'openid offline', - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code', 'token', 'id_token'], - redirect_uris: [], - }; - - beforeAll(async () => { - await setupEntities(); - - module = await Test.createTestingModule({ - providers: [ - OauthProviderClientCrudUc, - { - provide: OauthProviderService, - useValue: createMock(), - }, - { - provide: AuthorizationService, - useValue: createMock(), - }, - ], - }).compile(); - - uc = module.get(OauthProviderClientCrudUc); - providerService = module.get(OauthProviderService); - authorizationService = module.get(AuthorizationService); - }); - - afterAll(async () => { - await module.close(); - }); - - beforeEach(() => { - user = userFactory.buildWithId(); - authorizationService.getUserWithPermissions.mockResolvedValue(user); - }); - - afterEach(() => { - resetAllMocks(); - }); - - describe('Client Flow', () => { - describe('listOAuth2Clients', () => { - const data: ProviderOauthClient[] = [{ client_id: 'clientId' }]; - - it('should list oauth2 clients in return value', async () => { - providerService.listOAuth2Clients.mockResolvedValue(data); - - const result: ProviderOauthClient[] = await uc.listOAuth2Clients(currentUser); - - expect(result).toEqual(data); - }); - - it('should call the authorization service to check permissions', async () => { - providerService.listOAuth2Clients.mockResolvedValue(data); - - await uc.listOAuth2Clients(currentUser, 1, 0, 'clientId', 'owner'); - - expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.OAUTH_CLIENT_VIEW]); - }); - - it('should list oauth2 clients when service is called with all parameters', async () => { - providerService.listOAuth2Clients.mockResolvedValue(data); - - await uc.listOAuth2Clients(currentUser, 1, 0, 'clientId', 'owner'); - - expect(providerService.listOAuth2Clients).toHaveBeenCalledWith(1, 0, 'clientId', 'owner'); - }); - - it('should list oauth2 clients when service is called without parameters', async () => { - providerService.listOAuth2Clients.mockResolvedValue(data); - - await uc.listOAuth2Clients(currentUser); - - expect(providerService.listOAuth2Clients).toHaveBeenCalledWith(undefined, undefined, undefined, undefined); - }); - - it('should throw if user is not authorized', async () => { - authorizationService.checkAllPermissions.mockImplementation(() => { - throw new UnauthorizedException(); - }); - - await expect(uc.listOAuth2Clients(currentUser)).rejects.toThrow(UnauthorizedException); - }); - }); - - describe('getOAuth2Client', () => { - it('should get oauth2 client', async () => { - const data: ProviderOauthClient = { client_id: 'clientId' }; - - providerService.getOAuth2Client.mockResolvedValue(data); - - const result: ProviderOauthClient = await uc.getOAuth2Client(currentUser, 'clientId'); - - expect(result).toEqual(data); - expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.OAUTH_CLIENT_VIEW]); - expect(providerService.getOAuth2Client).toHaveBeenCalledWith('clientId'); - }); - - it('should throw if user is not authorized', async () => { - authorizationService.checkAllPermissions.mockImplementation(() => { - throw new UnauthorizedException(); - }); - - await expect(uc.getOAuth2Client(currentUser, 'clientId')).rejects.toThrow(UnauthorizedException); - }); - }); - - describe('createOAuth2Client', () => { - it('should create oauth2 client with defaults', async () => { - const data: ProviderOauthClient = { client_id: 'clientId' }; - const dataWithDefaults: ProviderOauthClient = { ...defaultOauthClientBody, ...data }; - - providerService.createOAuth2Client.mockResolvedValue(dataWithDefaults); - - const result: ProviderOauthClient = await uc.createOAuth2Client(currentUser, data); - - expect(result).toEqual(dataWithDefaults); - expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.OAUTH_CLIENT_EDIT]); - expect(providerService.createOAuth2Client).toHaveBeenCalledWith(dataWithDefaults); - }); - - it('should create oauth2 client without defaults', async () => { - const data: ProviderOauthClient = { - client_id: 'clientId', - scope: 'openid', - grant_types: ['authorization_code'], - response_types: ['code'], - redirect_uris: ['url'], - }; - - providerService.createOAuth2Client.mockResolvedValue(data); - - const result: ProviderOauthClient = await uc.createOAuth2Client(currentUser, data); - - expect(result).toEqual(data); - expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.OAUTH_CLIENT_EDIT]); - expect(providerService.createOAuth2Client).toHaveBeenCalledWith(data); - }); - - it('should throw if user is not authorized', async () => { - const data: ProviderOauthClient = { client_id: 'clientId' }; - - authorizationService.checkAllPermissions.mockImplementation(() => { - throw new UnauthorizedException(); - }); - - await expect(uc.createOAuth2Client(currentUser, data)).rejects.toThrow(UnauthorizedException); - }); - }); - - describe('updateOAuth2Client', () => { - it('should update oauth2 client with defaults', async () => { - const data: ProviderOauthClient = { client_id: 'clientId' }; - const dataWithDefaults = { ...defaultOauthClientBody, ...data }; - - providerService.updateOAuth2Client.mockResolvedValue(dataWithDefaults); - - const result: ProviderOauthClient = await uc.updateOAuth2Client(currentUser, 'clientId', data); - - expect(result).toEqual(dataWithDefaults); - expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.OAUTH_CLIENT_EDIT]); - expect(providerService.updateOAuth2Client).toHaveBeenCalledWith('clientId', dataWithDefaults); - }); - - it('should update oauth2 client without defaults', async () => { - const data: ProviderOauthClient = { - client_id: 'clientId', - scope: 'openid', - grant_types: ['authorization_code'], - response_types: ['code'], - redirect_uris: ['url'], - }; - - providerService.updateOAuth2Client.mockResolvedValue(data); - - const result: ProviderOauthClient = await uc.updateOAuth2Client(currentUser, 'clientId', data); - - expect(result).toEqual(data); - expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.OAUTH_CLIENT_EDIT]); - expect(providerService.updateOAuth2Client).toHaveBeenCalledWith('clientId', data); - }); - - it('should throw if user is not authorized', async () => { - const data: ProviderOauthClient = { client_id: 'clientId' }; - - authorizationService.checkAllPermissions.mockImplementation(() => { - throw new UnauthorizedException(); - }); - - await expect(uc.updateOAuth2Client(currentUser, 'clientId', data)).rejects.toThrow(UnauthorizedException); - }); - }); - - describe('deleteOAuth2Client', () => { - it('should delete oauth2 client', async () => { - await uc.deleteOAuth2Client(currentUser, 'clientId'); - - expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.OAUTH_CLIENT_EDIT]); - expect(providerService.deleteOAuth2Client).toHaveBeenCalledWith('clientId'); - }); - - it('should throw if user is not authorized', async () => { - authorizationService.checkAllPermissions.mockImplementation(() => { - throw new UnauthorizedException(); - }); - - await expect(uc.deleteOAuth2Client(currentUser, 'clientId')).rejects.toThrow(UnauthorizedException); - }); - }); - }); -}); diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.spec.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.spec.ts deleted file mode 100644 index e1a1663818e..00000000000 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.spec.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { OauthProviderService } from '@infra/oauth-provider'; -import { AcceptConsentRequestBody, ProviderConsentResponse, ProviderRedirectResponse } from '@infra/oauth-provider/dto'; -import { ICurrentUser } from '@modules/authentication'; -import { AcceptQuery, ConsentRequestBody } from '@modules/oauth-provider/controller/dto'; -import { IdToken } from '@modules/oauth-provider/interface/id-token'; -import { IdTokenService } from '@modules/oauth-provider/service/id-token.service'; -import { OauthProviderConsentFlowUc } from '@modules/oauth-provider/uc/oauth-provider.consent-flow.uc'; -import { ForbiddenException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -describe('OauthProviderConsentFlowUc', () => { - let module: TestingModule; - let uc: OauthProviderConsentFlowUc; - let service: DeepMocked; - let idTokenService: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - OauthProviderConsentFlowUc, - { - provide: OauthProviderService, - useValue: createMock(), - }, - { - provide: IdTokenService, - useValue: createMock(), - }, - ], - }).compile(); - - uc = module.get(OauthProviderConsentFlowUc); - service = module.get(OauthProviderService); - idTokenService = module.get(IdTokenService); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('consent', () => { - let challenge: string; - let currentUser: ICurrentUser; - let consentResponse: ProviderConsentResponse; - - beforeEach(() => { - challenge = 'challengexyz'; - currentUser = { userId: 'userId' } as ICurrentUser; - consentResponse = { - challenge, - subject: currentUser.userId, - }; - }); - - describe('getConsentRequest', () => { - it('should call service', async () => { - await uc.getConsentRequest(challenge); - - expect(service.getConsentRequest).toHaveBeenCalledWith(challenge); - }); - }); - - describe('patchConsentRequest', () => { - let requestBody: ConsentRequestBody; - let acceptQuery: AcceptQuery; - - beforeEach(() => { - requestBody = { - grant_scope: ['openid', 'offline'], - remember: false, - remember_for: 0, - }; - acceptQuery = { accept: true }; - consentResponse.requested_scope = requestBody.grant_scope; - service.getConsentRequest.mockResolvedValue(consentResponse); - }); - - describe('acceptConsentRequest', () => { - it('validateSubject should fail and throws forbidden if the subject doesn not equals the user', async () => { - consentResponse.subject = 'notValidSubject'; - - await expect(uc.patchConsentRequest(challenge, acceptQuery, requestBody, currentUser)).rejects.toThrow( - ForbiddenException - ); - - expect(service.getConsentRequest).toHaveBeenCalledWith(challenge); - expect(service.acceptConsentRequest).not.toHaveBeenCalledWith(); - expect(service.rejectConsentRequest).not.toHaveBeenCalled(); - }); - - it('should call service', async () => { - const expectedResult: ProviderRedirectResponse = { redirect_to: 'http://blub' }; - - service.acceptConsentRequest.mockResolvedValue(expectedResult); - - const providerRedirectResponse: ProviderRedirectResponse = await uc.patchConsentRequest( - challenge, - acceptQuery, - requestBody, - currentUser - ); - - expect(providerRedirectResponse.redirect_to).toEqual(expectedResult.redirect_to); - expect(service.getConsentRequest).toHaveBeenCalledWith(challenge); - expect(service.acceptConsentRequest).toHaveBeenCalledWith(challenge, requestBody); - expect(service.rejectConsentRequest).not.toHaveBeenCalled(); - }); - - it('should generate idtoken and set this as json to session', async () => { - const idToken: IdToken = { userId: currentUser.userId, schoolId: 'schoolId' }; - consentResponse = { ...consentResponse, client: { client_id: 'clientId' }, requested_scope: ['openid'] }; - - idTokenService.createIdToken.mockResolvedValue(idToken); - service.getConsentRequest.mockResolvedValue(consentResponse); - - await uc.patchConsentRequest(challenge, acceptQuery, requestBody, currentUser); - - expect(idTokenService.createIdToken).toHaveBeenCalledWith( - currentUser.userId, - consentResponse.requested_scope, - consentResponse.client?.client_id - ); - expect(service.acceptConsentRequest).toHaveBeenCalledWith( - challenge, - expect.objectContaining({ session: { id_token: idToken } }) - ); - }); - - it('should generate idtoken when requested_scope and client_id are undefined', async () => { - const idToken: IdToken = { userId: currentUser.userId, schoolId: 'schoolId' }; - const consentResponse2: ProviderConsentResponse = { - challenge: 'challenge', - subject: currentUser.userId, - }; - - idTokenService.createIdToken.mockResolvedValue(idToken); - service.getConsentRequest.mockResolvedValue(consentResponse2); - - await uc.patchConsentRequest(challenge, acceptQuery, requestBody, currentUser); - - expect(idTokenService.createIdToken).toHaveBeenCalledWith(currentUser.userId, [], ''); - expect(service.acceptConsentRequest).toHaveBeenCalledWith( - challenge, - expect.objectContaining({ session: { id_token: idToken } }) - ); - }); - }); - - describe('rejectConsentRequest', () => { - it('rejectConsentRequest: reject when accept in query is false', async () => { - acceptQuery = { accept: false }; - - await uc.patchConsentRequest(challenge, acceptQuery, requestBody, currentUser); - - expect(service.rejectConsentRequest).toHaveBeenCalledWith(challenge, requestBody); - expect(service.acceptConsentRequest).not.toHaveBeenCalled(); - }); - }); - }); - }); -}); diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.ts deleted file mode 100644 index a9c54b4f502..00000000000 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.consent-flow.uc.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { OauthProviderService } from '@infra/oauth-provider'; -import { - AcceptConsentRequestBody, - ProviderConsentResponse, - ProviderRedirectResponse, - RejectRequestBody, -} from '@infra/oauth-provider/dto'; -import { ICurrentUser } from '@modules/authentication'; -import { AcceptQuery, ConsentRequestBody } from '@modules/oauth-provider/controller/dto'; -import { IdToken } from '@modules/oauth-provider/interface/id-token'; -import { IdTokenService } from '@modules/oauth-provider/service/id-token.service'; -import { ForbiddenException, Injectable } from '@nestjs/common'; - -@Injectable() -export class OauthProviderConsentFlowUc { - constructor( - private readonly oauthProviderService: OauthProviderService, - private readonly idTokenService: IdTokenService - ) {} - - async getConsentRequest(challenge: string): Promise { - const consentResponse: ProviderConsentResponse = await this.oauthProviderService.getConsentRequest(challenge); - return consentResponse; - } - - async patchConsentRequest( - challenge: string, - query: AcceptQuery, - body: ConsentRequestBody, - currentUser: ICurrentUser - ): Promise { - const consentResponse = await this.oauthProviderService.getConsentRequest(challenge); - this.validateSubject(currentUser, consentResponse); - - let response: Promise; - if (query.accept) { - response = this.acceptConsentRequest( - challenge, - body, - currentUser.userId, - consentResponse.requested_scope, - consentResponse.client?.client_id - ); - } else { - response = this.rejectConsentRequest(challenge, body); - } - return response; - } - - private rejectConsentRequest(challenge: string, body: RejectRequestBody): Promise { - const redirectResponse: Promise = this.oauthProviderService.rejectConsentRequest( - challenge, - body - ); - return redirectResponse; - } - - private async acceptConsentRequest( - challenge: string, - body: AcceptConsentRequestBody, - userId: string, - requested_scope: string[] | undefined, - client_id: string | undefined - ): Promise { - const idToken: IdToken = await this.idTokenService.createIdToken(userId, requested_scope || [], client_id || ''); - if (idToken) { - body.session = { - id_token: idToken, - }; - } - - const redirectResponse: ProviderRedirectResponse = await this.oauthProviderService.acceptConsentRequest( - challenge, - body - ); - - return redirectResponse; - } - - private validateSubject(currentUser: ICurrentUser, response: ProviderConsentResponse): void { - if (response.subject !== currentUser.userId) { - throw new ForbiddenException("You want to patch another user's consent"); - } - } -} diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.spec.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.spec.ts deleted file mode 100644 index 62171565cda..00000000000 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { OauthProviderLogoutFlowUc } from '@modules/oauth-provider/uc/oauth-provider.logout-flow.uc'; -import { OauthProviderService } from '@infra/oauth-provider/index'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; - -describe('OauthProviderUc', () => { - let module: TestingModule; - let uc: OauthProviderLogoutFlowUc; - let service: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - OauthProviderLogoutFlowUc, - { - provide: OauthProviderService, - useValue: createMock(), - }, - ], - }).compile(); - - uc = module.get(OauthProviderLogoutFlowUc); - service = module.get(OauthProviderService); - }); - - afterAll(async () => { - await module.close(); - }); - - describe('logoutFlow', () => { - it('should call service', async () => { - await uc.logoutFlow('challenge_mock'); - - expect(service.acceptLogoutRequest).toHaveBeenCalledWith('challenge_mock'); - }); - }); -}); diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.ts deleted file mode 100644 index 30f45ba4188..00000000000 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.logout-flow.uc.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { OauthProviderService } from '@infra/oauth-provider'; -import { ProviderRedirectResponse } from '@infra/oauth-provider/dto'; - -@Injectable() -export class OauthProviderLogoutFlowUc { - constructor(private readonly oauthProviderService: OauthProviderService) {} - - logoutFlow(challenge: string): Promise { - const logoutResponse: Promise = this.oauthProviderService.acceptLogoutRequest(challenge); - return logoutResponse; - } -} diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.spec.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.spec.ts deleted file mode 100644 index 2faf242f0e5..00000000000 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { OauthProviderUc } from '@modules/oauth-provider/uc/oauth-provider.uc'; -import { OauthProviderService } from '@infra/oauth-provider/index'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ProviderConsentSessionResponse } from '@infra/oauth-provider/dto'; - -describe('OauthProviderUc', () => { - let module: TestingModule; - let uc: OauthProviderUc; - - let providerService: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - OauthProviderUc, - { - provide: OauthProviderService, - useValue: createMock(), - }, - ], - }).compile(); - - uc = module.get(OauthProviderUc); - providerService = module.get(OauthProviderService); - }); - - afterAll(async () => { - await module.close(); - }); - - describe('Consent Flow', () => { - describe('listConsentSessions', () => { - it('should list all consent sessions', async () => { - const data: ProviderConsentSessionResponse[] = [{ consent_request: { challenge: 'challenge' } }]; - - providerService.listConsentSessions.mockResolvedValue(data); - - const result: ProviderConsentSessionResponse[] = await uc.listConsentSessions('userId'); - - expect(result).toEqual(data); - expect(providerService.listConsentSessions).toHaveBeenCalledWith('userId'); - }); - }); - - describe('revokeConsentSession', () => { - it('should revoke all consent sessions', async () => { - await uc.revokeConsentSession('userId', 'clientId'); - - expect(providerService.revokeConsentSession).toHaveBeenCalledWith('userId', 'clientId'); - }); - }); - }); -}); diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.ts deleted file mode 100644 index f48170463d3..00000000000 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.uc.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { OauthProviderService } from '@infra/oauth-provider'; -import { ProviderConsentSessionResponse } from '@infra/oauth-provider/dto/'; -import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain/types'; - -@Injectable() -export class OauthProviderUc { - constructor(private readonly oauthProviderService: OauthProviderService) {} - - listConsentSessions(userId: EntityId): Promise { - const sessions: Promise = this.oauthProviderService.listConsentSessions(userId); - return sessions; - } - - revokeConsentSession(userId: EntityId, clientId: string): Promise { - const promise: Promise = this.oauthProviderService.revokeConsentSession(userId, clientId); - return promise; - } -} diff --git a/apps/server/src/modules/server/server.module.ts b/apps/server/src/modules/server/server.module.ts index 1f4bcc608df..db9268778c8 100644 --- a/apps/server/src/modules/server/server.module.ts +++ b/apps/server/src/modules/server/server.module.ts @@ -2,7 +2,6 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@infra/database'; import { MailModule } from '@infra/mail'; import { RabbitMQWrapperModule, RabbitMQWrapperTestModule } from '@infra/rabbitmq'; -import { RedisModule, REDIS_CLIENT } from '@infra/redis'; import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; import { AccountApiModule } from '@modules/account/account-api.module'; @@ -16,7 +15,7 @@ import { LegacySchoolApiModule } from '@modules/legacy-school/legacy-school.api- import { LessonApiModule } from '@modules/lesson/lesson-api.module'; import { MetaTagExtractorApiModule, MetaTagExtractorModule } from '@modules/meta-tag-extractor'; import { NewsModule } from '@modules/news'; -import { OauthProviderApiModule } from '@modules/oauth-provider'; +import { OauthProviderApiModule } from '@modules/oauth-provider/oauth-provider-api.module'; import { OauthApiModule } from '@modules/oauth/oauth-api.module'; import { PseudonymApiModule } from '@modules/pseudonym/pseudonym-api.module'; import { RocketChatModule } from '@modules/rocketchat'; @@ -30,15 +29,12 @@ import { ImportUserModule } from '@modules/user-import'; import { UserLoginMigrationApiModule } from '@modules/user-login-migration/user-login-migration-api.module'; import { UserApiModule } from '@modules/user/user-api.module'; import { VideoConferenceApiModule } from '@modules/video-conference/video-conference-api.module'; -import { DynamicModule, Inject, MiddlewareConsumer, Module, NestModule, NotFoundException } from '@nestjs/common'; +import { DynamicModule, Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ALL_ENTITIES } from '@shared/domain/entity'; import { createConfigModuleOptions, DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { CoreModule } from '@src/core'; -import { LegacyLogger, LoggerModule } from '@src/core/logger'; -import RedisStore from 'connect-redis'; -import session from 'express-session'; -import { RedisClientType } from 'redis'; +import { LoggerModule } from '@src/core/logger'; import { ServerController } from './controller/server.controller'; import { serverConfig } from './server.config'; @@ -89,45 +85,6 @@ export const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { new NotFoundException(`The requested ${entityName}: ${where} has not been found.`), }; -const setupSessions = ( - consumer: MiddlewareConsumer, - redisClient: RedisClientType | undefined, - logger: LegacyLogger -) => { - const sessionDuration: number = Configuration.get('SESSION__EXPIRES_SECONDS') as number; - - let store: RedisStore | undefined; - if (redisClient) { - store = new RedisStore({ - client: redisClient, - ttl: sessionDuration, - }); - } else { - logger.warn( - 'The RedisStore for sessions is not setup, since the environment variable REDIS_URI is not defined. Sessions are using the build-in MemoryStore. This should not be used in production!' - ); - } - - consumer - .apply( - session({ - store, - secret: Configuration.get('SESSION__SECRET') as string, - resave: false, - saveUninitialized: false, - name: Configuration.has('SESSION__NAME') ? (Configuration.get('SESSION__NAME') as string) : undefined, - proxy: Configuration.has('SESSION__PROXY') ? (Configuration.get('SESSION__PROXY') as boolean) : undefined, - cookie: { - secure: Configuration.get('SESSION__SECURE') as boolean, - sameSite: Configuration.get('SESSION__SAME_SITE') as boolean | 'lax' | 'strict' | 'none', - httpOnly: Configuration.get('SESSION__HTTP_ONLY') as boolean, - maxAge: sessionDuration * 1000, - }, - }) - ) - .forRoutes('*'); -}; - /** * Server Module used for production */ @@ -147,22 +104,10 @@ const setupSessions = ( // debug: true, // use it for locally debugging of queries }), LoggerModule, - RedisModule, ], controllers: [ServerController], }) -export class ServerModule implements NestModule { - constructor( - @Inject(REDIS_CLIENT) private readonly redisClient: RedisClientType | undefined, - private readonly logger: LegacyLogger - ) { - logger.setContext(ServerModule.name); - } - - configure(consumer: MiddlewareConsumer) { - setupSessions(consumer, this.redisClient, this.logger); - } -} +export class ServerModule {} /** * Server module used for testing. @@ -178,22 +123,10 @@ export class ServerModule implements NestModule { MongoMemoryDatabaseModule.forRoot({ ...defaultMikroOrmOptions }), RabbitMQWrapperTestModule, LoggerModule, - RedisModule, ], controllers: [ServerController], }) -export class ServerTestModule implements NestModule { - constructor( - @Inject(REDIS_CLIENT) private readonly redisClient: RedisClientType | undefined, - private readonly logger: LegacyLogger - ) { - logger.setContext(ServerTestModule.name); - } - - configure(consumer: MiddlewareConsumer) { - setupSessions(consumer, undefined, this.logger); - } - +export class ServerTestModule { static forRoot(options?: MongoDatabaseModuleOptions): DynamicModule { return { module: ServerTestModule, diff --git a/apps/server/src/modules/tldraw/tldraw-test.module.ts b/apps/server/src/modules/tldraw/tldraw-test.module.ts index 59c8af72f74..21a3d73f28a 100644 --- a/apps/server/src/modules/tldraw/tldraw-test.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-test.module.ts @@ -1,22 +1,20 @@ +import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@infra/database'; +import { defaultMikroOrmOptions } from '@modules/server'; +import { HttpModule } from '@nestjs/axios'; import { DynamicModule, Module } from '@nestjs/common'; -import { MongoMemoryDatabaseModule, MongoDatabaseModuleOptions } from '@infra/database'; -import { Logger, LoggerModule } from '@src/core/logger'; import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions } from '@src/config'; -import { RedisModule } from '@infra/redis'; -import { defaultMikroOrmOptions } from '@modules/server'; -import { HttpModule } from '@nestjs/axios'; -import { MetricsService } from './metrics'; +import { Logger, LoggerModule } from '@src/core/logger'; import { config } from './config'; import { TldrawController } from './controller/tldraw.controller'; -import { TldrawService } from './service/tldraw.service'; +import { MetricsService } from './metrics'; import { TldrawRepo } from './repo/tldraw.repo'; +import { TldrawService } from './service/tldraw.service'; const imports = [ MongoMemoryDatabaseModule.forRoot({ ...defaultMikroOrmOptions }), LoggerModule, ConfigModule.forRoot(createConfigModuleOptions(config)), - RedisModule, HttpModule, ]; const providers = [Logger, TldrawService, TldrawRepo, MetricsService]; diff --git a/apps/server/src/modules/tool/external-tool/external-tool.module.ts b/apps/server/src/modules/tool/external-tool/external-tool.module.ts index a84734fecb2..f174337b54f 100644 --- a/apps/server/src/modules/tool/external-tool/external-tool.module.ts +++ b/apps/server/src/modules/tool/external-tool/external-tool.module.ts @@ -1,23 +1,23 @@ +import { EncryptionModule } from '@infra/encryption'; +import { OauthProviderServiceModule } from '@modules/oauth-provider'; import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; -import { LoggerModule } from '@src/core/logger'; -import { OauthProviderServiceModule } from '@infra/oauth-provider'; -import { EncryptionModule } from '@infra/encryption'; import { ExternalToolRepo } from '@shared/repo'; +import { LoggerModule } from '@src/core/logger'; +import { CommonToolModule } from '../common'; +import { ToolContextMapper } from '../common/mapper/tool-context.mapper'; import { ToolConfigModule } from '../tool-config.module'; import { ExternalToolMetadataMapper } from './mapper'; -import { ToolContextMapper } from '../common/mapper/tool-context.mapper'; import { ExternalToolConfigurationService, ExternalToolLogoService, + ExternalToolMetadataService, ExternalToolParameterValidationService, ExternalToolService, ExternalToolServiceMapper, ExternalToolValidationService, ExternalToolVersionIncrementService, - ExternalToolMetadataService, } from './service'; -import { CommonToolModule } from '../common'; @Module({ imports: [CommonToolModule, ToolConfigModule, LoggerModule, OauthProviderServiceModule, EncryptionModule, HttpModule], diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.spec.ts index fb506a22f8c..30fca4b310a 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.spec.ts @@ -1,8 +1,8 @@ +import { ProviderOauthClient } from '@modules/oauth-provider/domain'; import { Test, TestingModule } from '@nestjs/testing'; -import { ProviderOauthClient } from '@infra/oauth-provider/dto'; -import { ExternalToolServiceMapper } from './external-tool-service.mapper'; import { TokenEndpointAuthMethod, ToolConfigType } from '../../common/enum'; import { Oauth2ToolConfig } from '../domain'; +import { ExternalToolServiceMapper } from './external-tool-service.mapper'; describe('ExternalToolServiceMapper', () => { let module: TestingModule; @@ -34,7 +34,7 @@ describe('ExternalToolServiceMapper', () => { frontchannelLogoutUri: 'frontchannelLogoutUri', skipConsent: false, }); - const expected: ProviderOauthClient = { + const expected: Partial = { client_name: toolName, client_id: 'clientId', client_secret: 'clientSecret', @@ -45,7 +45,7 @@ describe('ExternalToolServiceMapper', () => { subject_type: 'pairwise', }; - const result: ProviderOauthClient = mapper.mapDoToProviderOauthClient(toolName, oauth2Config); + const result: Partial = mapper.mapDoToProviderOauthClient(toolName, oauth2Config); expect(result).toEqual(expected); }); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.ts index c531f33c483..5b54683bda9 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-service.mapper.ts @@ -1,10 +1,10 @@ -import { ProviderOauthClient } from '@infra/oauth-provider/dto'; import { Injectable } from '@nestjs/common'; +import { ProviderOauthClient } from '@src/modules/oauth-provider/domain'; import { Oauth2ToolConfig } from '../domain'; @Injectable() export class ExternalToolServiceMapper { - mapDoToProviderOauthClient(name: string, oauth2Config: Oauth2ToolConfig): ProviderOauthClient { + mapDoToProviderOauthClient(name: string, oauth2Config: Oauth2ToolConfig): Partial { return { client_name: name, client_id: oauth2Config.clientId, diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts index 41593d34fe4..fb5fa3eca35 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts @@ -1,7 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; -import { OauthProviderService } from '@infra/oauth-provider'; -import { ProviderOauthClient } from '@infra/oauth-provider/dto'; +import { ProviderOauthClient } from '@modules/oauth-provider/domain'; import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -14,6 +13,8 @@ import { oauth2ToolConfigFactory, } from '@shared/testing/factory/domainobject/tool/external-tool.factory'; import { LegacyLogger } from '@src/core/logger'; +import { OauthProviderService } from '../../../oauth-provider/domain/service/oauth-provider.service'; +import { providerOauthClientFactory } from '../../../oauth-provider/testing'; import { ExternalToolSearchQuery } from '../../common/interface'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { ExternalTool, Lti11ToolConfig, Oauth2ToolConfig } from '../domain'; @@ -96,13 +97,13 @@ describe('ExternalToolService', () => { const oauth2ToolConfigWithoutExternalData: Oauth2ToolConfig = oauth2ToolConfigFactory.build(); const lti11ToolConfig: Lti11ToolConfig = lti11ToolConfigFactory.build(); - const oauthClient: ProviderOauthClient = { + const oauthClient: ProviderOauthClient = providerOauthClientFactory.build({ client_id: oauth2ToolConfig.clientId, scope: oauth2ToolConfig.scope, token_endpoint_auth_method: oauth2ToolConfig.tokenEndpointAuthMethod, redirect_uris: oauth2ToolConfig.redirectUris, frontchannel_logout_uri: oauth2ToolConfig.frontchannelLogoutUri, - }; + }); return { externalTool, @@ -501,9 +502,9 @@ describe('ExternalToolService', () => { const oauthClientId: string = existingTool.config instanceof Oauth2ToolConfig ? existingTool.config.clientId : 'undefined'; - const providerOauthClient: ProviderOauthClient = { + const providerOauthClient: ProviderOauthClient = providerOauthClientFactory.build({ client_id: oauthClientId, - }; + }); oauthProviderService.getOAuth2Client.mockResolvedValue(providerOauthClient); mapper.mapDoToProviderOauthClient.mockReturnValue(providerOauthClient); @@ -534,9 +535,9 @@ describe('ExternalToolService', () => { const oauthClientId: string = existingTool.config instanceof Oauth2ToolConfig ? existingTool.config.clientId : 'undefined'; - const providerOauthClient: ProviderOauthClient = { + const providerOauthClient: ProviderOauthClient = providerOauthClientFactory.build({ client_id: oauthClientId, - }; + }); oauthProviderService.getOAuth2Client.mockResolvedValue(providerOauthClient); mapper.mapDoToProviderOauthClient.mockReturnValue(providerOauthClient); @@ -567,9 +568,9 @@ describe('ExternalToolService', () => { const oauthClientId: string = existingTool.config instanceof Oauth2ToolConfig ? existingTool.config.clientId : 'undefined'; - const providerOauthClient: ProviderOauthClient = { + const providerOauthClient: ProviderOauthClient = providerOauthClientFactory.build({ client_id: oauthClientId, - }; + }); oauthProviderService.getOAuth2Client.mockResolvedValue(providerOauthClient); mapper.mapDoToProviderOauthClient.mockReturnValue(providerOauthClient); @@ -598,9 +599,9 @@ describe('ExternalToolService', () => { .withOauth2Config() .build({ id: existingTool.id, name: 'newName' }); - const providerOauthClient: ProviderOauthClient = { + const providerOauthClient: ProviderOauthClient = providerOauthClientFactory.build({ client_id: undefined, - }; + }); oauthProviderService.getOAuth2Client.mockResolvedValue(providerOauthClient); mapper.mapDoToProviderOauthClient.mockReturnValue(providerOauthClient); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts index 67f7edd161b..a90faf5f633 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts @@ -1,6 +1,6 @@ import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; -import { OauthProviderService } from '@infra/oauth-provider'; -import { ProviderOauthClient } from '@infra/oauth-provider/dto'; +import { ProviderOauthClient } from '@modules/oauth-provider/domain'; +import { OauthProviderService } from '@modules/oauth-provider/domain/service/oauth-provider.service'; import { Inject, Injectable, UnprocessableEntityException } from '@nestjs/common'; import { Page } from '@shared/domain/domainobject'; import { IFindOptions } from '@shared/domain/interface'; @@ -31,7 +31,7 @@ export class ExternalToolService { if (ExternalTool.isLti11Config(externalTool.config) && externalTool.config.secret) { externalTool.config.secret = this.encryptionService.encrypt(externalTool.config.secret); } else if (ExternalTool.isOauth2Config(externalTool.config)) { - const oauthClient: ProviderOauthClient = this.mapper.mapDoToProviderOauthClient( + const oauthClient: Partial = this.mapper.mapDoToProviderOauthClient( externalTool.name, externalTool.config ); @@ -119,7 +119,7 @@ export class ExternalToolService { private async updateOauth2ToolConfig(toUpdate: ExternalTool) { if (ExternalTool.isOauth2Config(toUpdate.config)) { - const toUpdateOauthClient: ProviderOauthClient = this.mapper.mapDoToProviderOauthClient( + const toUpdateOauthClient: Partial = this.mapper.mapDoToProviderOauthClient( toUpdate.name, toUpdate.config ); @@ -132,7 +132,7 @@ export class ExternalToolService { private async updateOauthClientOrThrow( loadedOauthClient: ProviderOauthClient, - toUpdateOauthClient: ProviderOauthClient, + toUpdateOauthClient: Partial, toUpdate: ExternalTool ) { if (loadedOauthClient && loadedOauthClient.client_id) { diff --git a/apps/server/src/modules/user-login-migration/index.ts b/apps/server/src/modules/user-login-migration/index.ts index c3841ebf249..aa5548b0cbd 100644 --- a/apps/server/src/modules/user-login-migration/index.ts +++ b/apps/server/src/modules/user-login-migration/index.ts @@ -1,2 +1,8 @@ -export * from './user-login-migration.module'; -export * from './service'; +export { UserLoginMigrationModule } from './user-login-migration.module'; +export { + UserMigrationService, + SchoolMigrationService, + MigrationCheckService, + UserLoginMigrationService, + UserLoginMigrationRevertService, +} from './service'; diff --git a/config/default.json b/config/default.json index 05499b0fc0e..24c2e9db895 100644 --- a/config/default.json +++ b/config/default.json @@ -25,13 +25,6 @@ "SECURE": true, "EXPIRES_SECONDS": 2592000000 }, - "SESSION": { - "SAME_SITE": "lax", - "SECURE": false, - "HTTP_ONLY": true, - "EXPIRES_SECONDS": 2592000000, - "SECRET": "devSecret" - }, "TSP_SCHOOL_SYNCER": { "SCHOOL_LIMIT": 10, "STUDENTS_TEACHERS_CLASSES_LIMIT": 150 diff --git a/config/default.schema.json b/config/default.schema.json index c11a49968ba..741433c2c86 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1242,71 +1242,6 @@ } } }, - "SESSION": { - "type": "object", - "description": "Cookie properties, required always to be defined", - "properties": { - "SAME_SITE": { - "type": "string", - "default": "lax", - "enum": ["none", "lax", "strict"], - "description": "Value for session cookies sameSite property. When SECURE flag is false, 'None' is not allowed in SAME_SITE and Lax should be used as default instead" - }, - "SECURE": { - "type": "boolean", - "default": true, - "description": "Value for session cookies httpOnly property" - }, - "HTTP_ONLY": { - "type": "boolean", - "default": true, - "description": "Value for session cookies httpOnly property" - }, - "EXPIRES_SECONDS": { - "type": "integer", - "default": 300, - "description": "Expiration in seconds from now" - }, - "NAME": { - "type": "string", - "default": "nest.sid", - "description": "Value for session cookies name" - }, - "PROXY": { - "type": "boolean", - "default": true, - "description": "Trust the reverse proxy when setting secure cookies (via the X-Forwarded-Proto header)" - }, - "SECRET": { - "type": "string", - "description": "This is the secret used to sign the session cookie." - } - }, - "required": ["SAME_SITE", "HTTP_ONLY", "SECURE", "EXPIRES_SECONDS"], - "allOf": [ - { - "$ref": "#/properties/SESSION/definitions/SAME_SITE_SECURE_VALID" - } - ], - "definitions": { - "SAME_SITE_SECURE_VALID": { - "if": { - "properties": { - "SECURE": { - "const": false - } - } - }, - "then": { - "properties": { - "SAME_SITE": { - "enum": ["lax", "strict"] - } - } - } - } - } - }, "FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED": { "type": "boolean", "default": false, diff --git a/config/development.json b/config/development.json index eb106993b10..ce1f062ecca 100644 --- a/config/development.json +++ b/config/development.json @@ -61,13 +61,6 @@ "SECURE": false, "EXPIRES_SECONDS": 2592000000 }, - "SESSION": { - "SAME_SITE": "lax", - "SECURE": false, - "HTTP_ONLY": true, - "EXPIRES_SECONDS": 2592000000, - "SECRET": "devSecret" - }, "HYDRA_URI": "http://localhost:9001", "FEATURE_IMSCC_COURSE_EXPORT_ENABLED": true, "NEST_LOG_LEVEL": "debug", diff --git a/config/test.json b/config/test.json index 08b9d373609..e215d43292a 100644 --- a/config/test.json +++ b/config/test.json @@ -44,13 +44,6 @@ "CALENDAR_URI": "https://schul.tech:3000", "HYDRA_URI": "http://hydra:9000", "FEATURE_IMSCC_COURSE_EXPORT_ENABLED": true, - "SESSION": { - "SAME_SITE": "lax", - "SECURE": false, - "HTTP_ONLY": true, - "EXPIRES_SECONDS": 2592000000, - "SECRET": "testSecret" - }, "FWU_CONTENT": { "S3_ENDPOINT": "http://localhost:4568", "S3_REGION": "eu-central-1",