Repository: malach-it/boruta-server Branch: master Commit: 0b879b9d49cc Files: 669 Total size: 1.6 MB Directory structure: gitextract_c41fuwm_/ ├── .credo.exs ├── .formatter.exs ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── deploy.yml │ └── elixir.yml ├── .gitignore ├── .gitlab-ci.yml ├── .tool-versions ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Dockerfile.admin ├── Dockerfile.auth ├── Dockerfile.full ├── Dockerfile.gateway ├── GENERAL_TERMS_AND_CONDITIONS.md ├── LICENSE.md ├── README.md ├── ansible/ │ ├── .kube/ │ │ └── kubeconfig-k8s-boruta.yaml │ ├── deploy.yml │ ├── hosts │ └── inventories/ │ ├── gke │ ├── local │ └── scaleway ├── apps/ │ ├── boruta_admin/ │ │ ├── .formatter.exs │ │ ├── .gitignore │ │ ├── assets/ │ │ │ ├── .browserslistrc │ │ │ ├── .editorconfig │ │ │ ├── .eslintrc.js │ │ │ ├── .gitignore │ │ │ ├── index.html │ │ │ ├── jsconfig.json │ │ │ ├── package.json │ │ │ ├── postcss.config.js │ │ │ ├── src/ │ │ │ │ ├── App.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── Breadcrumb.vue │ │ │ │ │ ├── Feedback.vue │ │ │ │ │ ├── Forms/ │ │ │ │ │ │ ├── BackendForm.vue │ │ │ │ │ │ ├── ClientForm.vue │ │ │ │ │ │ ├── FormErrors.vue │ │ │ │ │ │ ├── GatewayScopesField.vue │ │ │ │ │ │ ├── IdentityProviderField.vue │ │ │ │ │ │ ├── IdentityProviderForm.vue │ │ │ │ │ │ ├── OrganizationForm.vue │ │ │ │ │ │ ├── OrganizationsField.vue │ │ │ │ │ │ ├── RoleForm.vue │ │ │ │ │ │ ├── RolesField.vue │ │ │ │ │ │ ├── ScopesField.vue │ │ │ │ │ │ ├── ScopesFieldByName.vue │ │ │ │ │ │ ├── TextEditor.vue │ │ │ │ │ │ ├── UpstreamForm.vue │ │ │ │ │ │ └── UserForm.vue │ │ │ │ │ ├── Header.vue │ │ │ │ │ ├── Toaster.vue │ │ │ │ │ └── VerifiableCredentialClaim.vue │ │ │ │ ├── main.js │ │ │ │ ├── models/ │ │ │ │ │ ├── backend.model.js │ │ │ │ │ ├── business-log-stats.model.js │ │ │ │ │ ├── client.model.js │ │ │ │ │ ├── email-template.model.js │ │ │ │ │ ├── error-template.model.js │ │ │ │ │ ├── identity-provider.model.js │ │ │ │ │ ├── key-pair.model.js │ │ │ │ │ ├── organization.model.js │ │ │ │ │ ├── request-log-stats.model.js │ │ │ │ │ ├── role.model.js │ │ │ │ │ ├── scope.model.js │ │ │ │ │ ├── template.model.js │ │ │ │ │ ├── upstream.model.js │ │ │ │ │ ├── user.model.js │ │ │ │ │ └── utils.js │ │ │ │ ├── router.js │ │ │ │ ├── services/ │ │ │ │ │ ├── configuration-file.service.js │ │ │ │ │ └── oauth.service.js │ │ │ │ ├── store.js │ │ │ │ └── views/ │ │ │ │ ├── BadRequest.vue │ │ │ │ ├── Clients/ │ │ │ │ │ ├── Client.vue │ │ │ │ │ ├── ClientList.vue │ │ │ │ │ ├── EditClient.vue │ │ │ │ │ ├── KeyPairList.vue │ │ │ │ │ └── NewClient.vue │ │ │ │ ├── Clients.vue │ │ │ │ ├── Configuration/ │ │ │ │ │ ├── ConfigurationFileUpload.vue │ │ │ │ │ ├── EditBadRequestTemplate.vue │ │ │ │ │ ├── EditForbiddenTemplate.vue │ │ │ │ │ ├── EditInternalServerErrorTemplate.vue │ │ │ │ │ ├── EditNotFoundTemplate.vue │ │ │ │ │ └── ErrorTemplateList.vue │ │ │ │ ├── Configuration.vue │ │ │ │ ├── Dashboard/ │ │ │ │ │ ├── BusinessEvents.vue │ │ │ │ │ └── Requests.vue │ │ │ │ ├── Dashboard.vue │ │ │ │ ├── Home.vue │ │ │ │ ├── IdentityProviders/ │ │ │ │ │ ├── BackendList.vue │ │ │ │ │ ├── Backends/ │ │ │ │ │ │ ├── Backend.vue │ │ │ │ │ │ ├── EditConfirmationInstructionsEmailTemplate.vue │ │ │ │ │ │ ├── EditResetPasswordInstructionsEmailTemplate.vue │ │ │ │ │ │ └── EditTxCodeEmailTemplate.vue │ │ │ │ │ ├── Backends.vue │ │ │ │ │ ├── EditBackend.vue │ │ │ │ │ ├── EditCredentialOfferTemplate.vue │ │ │ │ │ ├── EditCrossDevicePresentationTemplate.vue │ │ │ │ │ ├── EditEditResetPasswordTemplate.vue │ │ │ │ │ ├── EditEditUserTemplate.vue │ │ │ │ │ ├── EditIdentityProvider.vue │ │ │ │ │ ├── EditLayoutTemplate.vue │ │ │ │ │ ├── EditNewChooseSessionTemplate.vue │ │ │ │ │ ├── EditNewConfirmationTemplate.vue │ │ │ │ │ ├── EditNewConsentTemplate.vue │ │ │ │ │ ├── EditNewResetPasswordTemplate.vue │ │ │ │ │ ├── EditOrganization.vue │ │ │ │ │ ├── EditRegistrationTemplate.vue │ │ │ │ │ ├── EditSessionTemplate.vue │ │ │ │ │ ├── EditTotpAuthenticationTemplate.vue │ │ │ │ │ ├── EditTotpRegistrationTemplate.vue │ │ │ │ │ ├── EditUser.vue │ │ │ │ │ ├── EditWebauthnAuthenticationTemplate.vue │ │ │ │ │ ├── EditWebauthnRegistrationTemplate.vue │ │ │ │ │ ├── IdentityProvider.vue │ │ │ │ │ ├── IdentityProviderList.vue │ │ │ │ │ ├── NewBackend.vue │ │ │ │ │ ├── NewIdentityProvider.vue │ │ │ │ │ ├── NewOrganization.vue │ │ │ │ │ ├── NewUser.vue │ │ │ │ │ ├── OrganizationList.vue │ │ │ │ │ ├── Organizations.vue │ │ │ │ │ ├── UserImport.vue │ │ │ │ │ ├── UserList.vue │ │ │ │ │ └── Users.vue │ │ │ │ ├── IdentityProviders.vue │ │ │ │ ├── Layouts/ │ │ │ │ │ └── Main.vue │ │ │ │ ├── NotFound.vue │ │ │ │ ├── OauthCallback.vue │ │ │ │ ├── Roles/ │ │ │ │ │ ├── EditRole.vue │ │ │ │ │ ├── NewRole.vue │ │ │ │ │ ├── Role.vue │ │ │ │ │ └── RoleList.vue │ │ │ │ ├── Roles.vue │ │ │ │ ├── Scopes/ │ │ │ │ │ └── ScopeList.vue │ │ │ │ ├── Scopes.vue │ │ │ │ ├── Upstreams/ │ │ │ │ │ ├── EditUpstream.vue │ │ │ │ │ ├── NewUpstream.vue │ │ │ │ │ ├── Upstream.vue │ │ │ │ │ └── UpstreamList.vue │ │ │ │ └── Upstreams.vue │ │ │ ├── tests/ │ │ │ │ └── unit/ │ │ │ │ └── .eslintrc.js │ │ │ └── vite.config.js │ │ ├── config/ │ │ │ ├── config.exs │ │ │ ├── dev.exs │ │ │ ├── prod.exs │ │ │ └── test.exs │ │ ├── lib/ │ │ │ ├── boruta_admin/ │ │ │ │ ├── application.ex │ │ │ │ ├── configuration_loader/ │ │ │ │ │ └── schema.ex │ │ │ │ ├── configuration_loader.ex │ │ │ │ ├── configurations/ │ │ │ │ │ └── configuration.ex │ │ │ │ ├── configurations.ex │ │ │ │ ├── logs.ex │ │ │ │ ├── release.ex │ │ │ │ └── repo.ex │ │ │ ├── boruta_admin.ex │ │ │ ├── boruta_admin_web/ │ │ │ │ ├── controllers/ │ │ │ │ │ ├── backend_controller.ex │ │ │ │ │ ├── boruta/ │ │ │ │ │ │ ├── client_controller.ex │ │ │ │ │ │ └── scope_controller.ex │ │ │ │ │ ├── configuration_controller.ex │ │ │ │ │ ├── fallback_controller.ex │ │ │ │ │ ├── identity_provider_controller.ex │ │ │ │ │ ├── key_pair_controller.ex │ │ │ │ │ ├── logs_controller.ex │ │ │ │ │ ├── organization_controller.ex │ │ │ │ │ ├── page_controller.ex │ │ │ │ │ ├── role_controller.ex │ │ │ │ │ ├── upstream_controller.ex │ │ │ │ │ └── user_controller.ex │ │ │ │ ├── endpoint.ex │ │ │ │ ├── gettext.ex │ │ │ │ ├── plugs/ │ │ │ │ │ ├── authorization.ex │ │ │ │ │ └── logger.ex │ │ │ │ ├── router.ex │ │ │ │ ├── telemetry.ex │ │ │ │ ├── templates/ │ │ │ │ │ ├── error/ │ │ │ │ │ │ ├── 404.html.eex │ │ │ │ │ │ └── 500.html.eex │ │ │ │ │ └── page/ │ │ │ │ │ └── admin.html.eex │ │ │ │ └── views/ │ │ │ │ ├── backend_view.ex │ │ │ │ ├── changeset_view.ex │ │ │ │ ├── client_view.ex │ │ │ │ ├── configuration_view.ex │ │ │ │ ├── error_helpers.ex │ │ │ │ ├── error_view.ex │ │ │ │ ├── identity_provider_view.ex │ │ │ │ ├── key_pair_view.ex │ │ │ │ ├── logs_view.ex │ │ │ │ ├── organization_view.ex │ │ │ │ ├── page_view.ex │ │ │ │ ├── role_view.ex │ │ │ │ ├── scope_view.ex │ │ │ │ ├── upstream_view.ex │ │ │ │ └── user_view.ex │ │ │ └── boruta_admin_web.ex │ │ ├── mix.exs │ │ ├── priv/ │ │ │ ├── examples/ │ │ │ │ └── configuration.yml │ │ │ ├── gettext/ │ │ │ │ ├── en/ │ │ │ │ │ └── LC_MESSAGES/ │ │ │ │ │ └── errors.po │ │ │ │ └── errors.pot │ │ │ ├── repo/ │ │ │ │ ├── migrations/ │ │ │ │ │ ├── .formatter.exs │ │ │ │ │ └── 20240508054424_create_configurations.exs │ │ │ │ └── seeds.exs │ │ │ └── test/ │ │ │ └── configuration_files/ │ │ │ ├── bad_backend_configuration.yml │ │ │ ├── bad_client_configuration.yml │ │ │ ├── bad_error_template_configuration.yml │ │ │ ├── bad_identity_provider_configuration.yml │ │ │ ├── bad_organization_configuration.yml │ │ │ ├── bad_role_configuration.yml │ │ │ ├── bad_scope_configuration.yml │ │ │ └── full_configuration.yml │ │ └── test/ │ │ ├── boruta_admin/ │ │ │ └── configuration_loader_test.exs │ │ ├── boruta_admin_web/ │ │ │ ├── controllers/ │ │ │ │ ├── backend_controller_test.exs │ │ │ │ ├── client_controller_test.exs │ │ │ │ ├── configuration_controller_test.exs │ │ │ │ ├── identity_provider_controller_test.exs │ │ │ │ ├── key_pair_controller_test.exs │ │ │ │ ├── logs_controller_test.exs │ │ │ │ ├── organization_controller_test.exs │ │ │ │ ├── page_controller_test.exs │ │ │ │ ├── role_controller_test.exs │ │ │ │ ├── scope_controller_test.exs │ │ │ │ ├── upstream_controller_test.exs │ │ │ │ └── user_controller_test.exs │ │ │ └── views/ │ │ │ ├── error_view_test.exs │ │ │ └── page_view_test.exs │ │ ├── data/ │ │ │ ├── import_users_hashed_password_valid.csv │ │ │ ├── import_users_password_custom_headers_valid.csv │ │ │ ├── import_users_password_invalid.csv │ │ │ └── import_users_password_valid.csv │ │ ├── support/ │ │ │ ├── boruta_factory.ex │ │ │ ├── boruta_identity_factory.ex │ │ │ ├── conn_case.ex │ │ │ └── data_case.ex │ │ └── test_helper.exs │ ├── boruta_auth/ │ │ ├── .formatter.exs │ │ ├── .gitignore │ │ ├── config/ │ │ │ ├── config.exs │ │ │ ├── dev.exs │ │ │ ├── prod.exs │ │ │ └── test.exs │ │ ├── lib/ │ │ │ ├── boruta_auth/ │ │ │ │ ├── application.ex │ │ │ │ ├── key_pairs/ │ │ │ │ │ └── schemas/ │ │ │ │ │ └── key_pair.ex │ │ │ │ ├── key_pairs.ex │ │ │ │ ├── log_rotate.ex │ │ │ │ ├── repo.ex │ │ │ │ └── scheduler.ex │ │ │ └── boruta_auth.ex │ │ ├── mix.exs │ │ ├── priv/ │ │ │ └── repo/ │ │ │ ├── boruta.seeds.exs │ │ │ └── migrations/ │ │ │ ├── 20201129024828_create_boruta.exs │ │ │ ├── 20210114202055_usec_timestamps.exs │ │ │ ├── 20210202095024_add_key_pair_to_clients.exs │ │ │ ├── 20210301123331_add_label_to_scopes.exs │ │ │ ├── 20210514211510_add_default_redirect_uris_to_clients.exs │ │ │ ├── 20210919174149_openid_connect.exs │ │ │ ├── 20210919174150_clients_refresh_tokens.exs │ │ │ ├── 20211013161324_clients_public_revoke.exs │ │ │ ├── 20220113221532_store_previous_token.exs │ │ │ ├── 20220603211852_id_token_signature_alg_configuration.exs │ │ │ ├── 20220625203958_confidential_clients.exs │ │ │ ├── 20220824105115_refresh_token_rotation.exs │ │ │ ├── 20221025084535_authorization_code_chains.exs │ │ │ ├── 20221122131429_client_authentication_methods.exs │ │ │ ├── 20221129120152_signed_userinfo_response.exs │ │ │ ├── 20230506151359_optional_public_key_for_oauth_clients.exs │ │ │ ├── 20230514134306_clients_jwks_uri.exs │ │ │ ├── 20230515093131_create_key_pairs.exs │ │ │ ├── 20230515152140_client_id_token_kid.exs │ │ │ ├── 20231217091349_add_metadata_to_clients.exs │ │ │ ├── 20231217144905_oid4vci_implementation.exs │ │ │ ├── 20240127081327_siopv2_implementation.exs │ │ │ ├── 20240321101558_dpop_implementation.exs │ │ │ ├── 20240417052138_par_implementation.exs │ │ │ ├── 20240506083712_c_nonce_implementation.exs │ │ │ ├── 20240812111902_defered_credentials.exs │ │ │ ├── 20240824191208_clients_did.exs │ │ │ ├── 20240908082918_verifiable_presentation_definitions.exs │ │ │ ├── 20240914084657_clients_response_mode.exs │ │ │ ├── 20241021132955_clients_key_pair_types.exs │ │ │ ├── 20241209110846_tokens_tx_code.exs │ │ │ ├── 20241220104923_clients_signatures_adapters.exs │ │ │ ├── 20250315084213_fix_oauth_clients_did.exs │ │ │ ├── 20250413070457_agent_credentials.exs │ │ │ ├── 20250524160749_public_client_id.exs │ │ │ ├── 20260324152715_codes_response_type.exs │ │ │ ├── 20260324152716_code_metadata_policy.exs │ │ │ ├── 20260330112657_siopv2_encryption.exs │ │ │ └── 20260428121802_requested_scope.exs │ │ └── test/ │ │ ├── boruta_auth/ │ │ │ └── key_pairs_test.exs │ │ └── test_helper.exs │ ├── boruta_gateway/ │ │ ├── .formatter.exs │ │ ├── .gitignore │ │ ├── config/ │ │ │ ├── config.exs │ │ │ ├── dev.exs │ │ │ ├── prod.exs │ │ │ └── test.exs │ │ ├── lib/ │ │ │ ├── boruta_gateway/ │ │ │ │ ├── application.ex │ │ │ │ ├── configuration_loader.ex │ │ │ │ ├── configuration_schemas/ │ │ │ │ │ └── gateway.ex │ │ │ │ ├── gateway_pipeline.ex │ │ │ │ ├── logger.ex │ │ │ │ ├── microgateway_pipeline.ex │ │ │ │ ├── plugs/ │ │ │ │ │ ├── assign_sidecar_upstream.ex │ │ │ │ │ ├── assign_upstream.ex │ │ │ │ │ ├── authorize.ex │ │ │ │ │ ├── handler.ex │ │ │ │ │ └── metrics.ex │ │ │ │ ├── release.ex │ │ │ │ ├── repo.ex │ │ │ │ ├── router.ex │ │ │ │ ├── server.ex │ │ │ │ ├── sidecar_router.ex │ │ │ │ ├── upstreams/ │ │ │ │ │ ├── client/ │ │ │ │ │ │ └── supervisor.ex │ │ │ │ │ ├── client.ex │ │ │ │ │ ├── store.ex │ │ │ │ │ └── upstream.ex │ │ │ │ └── upstreams.ex │ │ │ ├── boruta_gateway.ex │ │ │ └── mix/ │ │ │ └── tasks/ │ │ │ └── server.ex │ │ ├── mix.exs │ │ ├── priv/ │ │ │ ├── repo/ │ │ │ │ ├── migrations/ │ │ │ │ │ ├── 20200219201345_create_upstreams.exs │ │ │ │ │ ├── 20200326185929_upstreams_notify.exs │ │ │ │ │ ├── 20210111144958_change_upstreams_required_scopes_type.exs │ │ │ │ │ ├── 20220319220305_add_pool_size_to_upstreams.exs │ │ │ │ │ ├── 20220728122802_add_max_idle_time_to_upstreams.exs │ │ │ │ │ ├── 20220729040405_add_pool_count_to_upstreams.exs │ │ │ │ │ ├── 20220810082956_add_forbidden_response_to_upstreams.exs │ │ │ │ │ ├── 20220810084238_add_error_content_type_to_upstreams.exs │ │ │ │ │ ├── 20220810084450_add_unauthorized_response_to_upstreams.exs │ │ │ │ │ ├── 20221024100810_add_forwarded_token_signature_algorithm_to_upstreams.exs │ │ │ │ │ ├── 20221024122642_add_forwarded_token_secret_to_upstreams.exs │ │ │ │ │ ├── 20221024132312_add_forwarded_token_key_pair_to_upstreams.exs │ │ │ │ │ ├── 20230413190522_upstreams_unique_indices.exs │ │ │ │ │ ├── 20230421135202_add_node_name_to_upstreams.exs │ │ │ │ │ └── 20230422083455_update_upstreams_unique_constraint.exs │ │ │ │ └── seeds.exs │ │ │ └── test/ │ │ │ └── configuration_files/ │ │ │ ├── authorized.yml │ │ │ ├── authorized_introspect.yml │ │ │ ├── bad_configuration.yml │ │ │ ├── bad_gateway_configuration.yml │ │ │ ├── bad_microgateway_configuration.yml │ │ │ ├── forbidden.yml │ │ │ ├── full_configuration.yml │ │ │ ├── not_found.yml │ │ │ └── unauthorized.yml │ │ └── test/ │ │ ├── boruta_gateway/ │ │ │ ├── configuration_loader_test.exs │ │ │ ├── integration/ │ │ │ │ └── requests_test.exs │ │ │ ├── upstreams/ │ │ │ │ ├── client_test.exs │ │ │ │ └── store_test.exs │ │ │ └── upstreams_test.exs │ │ ├── support/ │ │ │ └── data_case.ex │ │ └── test_helper.exs │ ├── boruta_identity/ │ │ ├── .formatter.exs │ │ ├── .gitignore │ │ ├── assets/ │ │ │ └── wallet/ │ │ │ ├── .browserslistrc │ │ │ ├── .gitignore │ │ │ ├── Dockerfile │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── public/ │ │ │ │ ├── index.html │ │ │ │ └── robots.txt │ │ │ ├── src/ │ │ │ │ ├── App.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── Consent.vue │ │ │ │ │ ├── Credentials.vue │ │ │ │ │ ├── HelloWorld.vue │ │ │ │ │ └── KeySelect.vue │ │ │ │ ├── main.ts │ │ │ │ ├── registerServiceWorker.ts │ │ │ │ ├── router/ │ │ │ │ │ └── index.ts │ │ │ │ ├── shims-vue.d.ts │ │ │ │ ├── store/ │ │ │ │ │ └── index.ts │ │ │ │ └── views/ │ │ │ │ ├── HomeView.vue │ │ │ │ ├── Oid4vcCallbackView.vue │ │ │ │ └── VerifiableCredentialsIssuanceView.vue │ │ │ ├── tsconfig.json │ │ │ └── vite.config.js │ │ ├── config/ │ │ │ ├── config.exs │ │ │ ├── dev.exs │ │ │ ├── prod.exs │ │ │ └── test.exs │ │ ├── lib/ │ │ │ ├── boruta_identity/ │ │ │ │ ├── accounts/ │ │ │ │ │ ├── backends/ │ │ │ │ │ │ ├── federated.ex │ │ │ │ │ │ ├── internal/ │ │ │ │ │ │ │ └── user.ex │ │ │ │ │ │ ├── internal.ex │ │ │ │ │ │ ├── ldap/ │ │ │ │ │ │ │ └── user.ex │ │ │ │ │ │ └── ldap.ex │ │ │ │ │ ├── choose_sessions.ex │ │ │ │ │ ├── confirmations.ex │ │ │ │ │ ├── consents.ex │ │ │ │ │ ├── deliveries/ │ │ │ │ │ │ ├── email_template.ex │ │ │ │ │ │ └── user_notifier.ex │ │ │ │ │ ├── deliveries.ex │ │ │ │ │ ├── registrations.ex │ │ │ │ │ ├── reset_passwords.ex │ │ │ │ │ ├── schemas/ │ │ │ │ │ │ ├── consent.ex │ │ │ │ │ │ ├── role.ex │ │ │ │ │ │ ├── role_scope.ex │ │ │ │ │ │ ├── user.ex │ │ │ │ │ │ ├── user_authorized_scope.ex │ │ │ │ │ │ ├── user_role.ex │ │ │ │ │ │ └── user_token.ex │ │ │ │ │ ├── sessions.ex │ │ │ │ │ ├── settings.ex │ │ │ │ │ ├── users.ex │ │ │ │ │ ├── verifiable_credentials.ex │ │ │ │ │ └── verifiable_presentations.ex │ │ │ │ ├── accounts.ex │ │ │ │ ├── admin.ex │ │ │ │ ├── application.ex │ │ │ │ ├── clients.ex │ │ │ │ ├── configuration/ │ │ │ │ │ └── error_template.ex │ │ │ │ ├── configuration.ex │ │ │ │ ├── federated_accounts.ex │ │ │ │ ├── hooks/ │ │ │ │ │ └── post_user_creation_hook.ex │ │ │ │ ├── identity_providers/ │ │ │ │ │ ├── backend.ex │ │ │ │ │ ├── backend_role.ex │ │ │ │ │ ├── client_identity_provider.ex │ │ │ │ │ ├── identity_provider.ex │ │ │ │ │ └── template.ex │ │ │ │ ├── identity_providers.ex │ │ │ │ ├── ldap_repo.ex │ │ │ │ ├── logger.ex │ │ │ │ ├── organizations/ │ │ │ │ │ ├── organization.ex │ │ │ │ │ └── organization_user.ex │ │ │ │ ├── organizations.ex │ │ │ │ ├── repo.ex │ │ │ │ ├── resource_owners.ex │ │ │ │ ├── totp.ex │ │ │ │ └── webauthn.ex │ │ │ ├── boruta_identity.ex │ │ │ ├── boruta_identity_web/ │ │ │ │ ├── concerns/ │ │ │ │ │ └── authenticable.ex │ │ │ │ ├── controllers/ │ │ │ │ │ ├── backends_controller.ex │ │ │ │ │ ├── choose_session_controller.ex │ │ │ │ │ ├── fallback_controller.ex │ │ │ │ │ ├── totp_controller.ex │ │ │ │ │ ├── user_confirmation_controller.ex │ │ │ │ │ ├── user_consent_controller.ex │ │ │ │ │ ├── user_registration_controller.ex │ │ │ │ │ ├── user_reset_password_controller.ex │ │ │ │ │ ├── user_session_controller.ex │ │ │ │ │ ├── user_settings_controller.ex │ │ │ │ │ ├── wallet_controller.ex │ │ │ │ │ └── webauthn_controller.ex │ │ │ │ ├── endpoint.ex │ │ │ │ ├── gettext.ex │ │ │ │ ├── plugs/ │ │ │ │ │ └── sessions.ex │ │ │ │ ├── router.ex │ │ │ │ ├── telemetry.ex │ │ │ │ ├── templates/ │ │ │ │ │ ├── error/ │ │ │ │ │ │ ├── 400.html.eex │ │ │ │ │ │ ├── 403.html.eex │ │ │ │ │ │ ├── 404.html.eex │ │ │ │ │ │ └── 500.html.eex │ │ │ │ │ ├── layout/ │ │ │ │ │ │ └── app.html.eex │ │ │ │ │ └── wallet/ │ │ │ │ │ └── index.html.eex │ │ │ │ ├── token.ex │ │ │ │ └── views/ │ │ │ │ ├── error_helpers.ex │ │ │ │ ├── error_view.ex │ │ │ │ ├── template_view.ex │ │ │ │ └── wallet_view.ex │ │ │ └── boruta_identity_web.ex │ │ ├── mix.exs │ │ ├── priv/ │ │ │ ├── gettext/ │ │ │ │ ├── en/ │ │ │ │ │ └── LC_MESSAGES/ │ │ │ │ │ └── errors.po │ │ │ │ └── errors.pot │ │ │ ├── repo/ │ │ │ │ ├── migrations/ │ │ │ │ │ ├── .formatter.exs │ │ │ │ │ ├── 20210127190501_create_users_auth_tables.exs │ │ │ │ │ ├── 20210128080043_create_users_authorized_scopes.exs │ │ │ │ │ ├── 20210208110903_user_authorized_scopes_unique_index.exs │ │ │ │ │ ├── 20210302213536_create_consents.exs │ │ │ │ │ ├── 20210806194842_add_last_login_at_to_users.exs │ │ │ │ │ ├── 20211002132445_modify_users_confirmed_at.exs │ │ │ │ │ ├── 20211129225646_create_relying_parties.exs │ │ │ │ │ ├── 20211130230927_create_clients_relying_parties.exs │ │ │ │ │ ├── 20220117220007_add_registrable_to_relying_parties.exs │ │ │ │ │ ├── 20220118122834_add_unique_name_to_relying_parties.exs │ │ │ │ │ ├── 20220120214356_create_relying_party_templates.exs │ │ │ │ │ ├── 20220131133951_add_confirmable_to_relying_parties.exs │ │ │ │ │ ├── 20220218144931_add_consentable_to_relying_parties.exs │ │ │ │ │ ├── 20220221123627_add_choose_session_to_relying_parties.exs │ │ │ │ │ ├── 20220520212652_add_user_editable_to_relying_parties.exs │ │ │ │ │ ├── 20220528155902_create_internal_users.exs │ │ │ │ │ ├── 20220607201657_add_user_authorized_scopes_scope_id.exs │ │ │ │ │ ├── 20220617195827_rename_relying_parties.exs │ │ │ │ │ ├── 20220628073937_create_error_templates.exs │ │ │ │ │ ├── 20220812123254_create_backends.exs │ │ │ │ │ ├── 20220815073225_add_backend_id_to_identity_providers.exs │ │ │ │ │ ├── 20220815091033_remove_type_from_identity_providers.exs │ │ │ │ │ ├── 20220815115719_add_default_to_backends.exs │ │ │ │ │ ├── 20220816074610_add_password_hashing_opts_to_backends.exs │ │ │ │ │ ├── 20220817134821_change_users_provider_to_backend_id.exs │ │ │ │ │ ├── 20220817150643_add_backend_id_to_internal_users.exs │ │ │ │ │ ├── 20220826055043_add_mail_configuration_to_backends.exs │ │ │ │ │ ├── 20220904073628_create_pg_trgm_extension.exs │ │ │ │ │ ├── 20220904183116_create_trgm_index.exs │ │ │ │ │ ├── 20220911195248_add_ldap_configuration_to_backends.exs │ │ │ │ │ ├── 20220915191039_add_ldap_ser_rdn_attribute_to_backends.exs │ │ │ │ │ ├── 20221008202236_add_ldap_master_credentials_to_backends.exs │ │ │ │ │ ├── 20221026092004_create_email_templates.exs │ │ │ │ │ ├── 20221026211130_add_smtp_ssl_to_backends.exs │ │ │ │ │ ├── 20221028083326_add_revoked_at_to_users_tokens.exs │ │ │ │ │ ├── 20221108140432_add_metadata_fields_to_backends.exs │ │ │ │ │ ├── 20221108144651_add_metadata_to_users.exs │ │ │ │ │ ├── 20230303151220_add_group_to_users.exs │ │ │ │ │ ├── 20230502111802_add_identity_federation_to_backends.exs │ │ │ │ │ ├── 20230605073651_create_roles.exs │ │ │ │ │ ├── 20230605074117_create_roles_scopes.exs │ │ │ │ │ ├── 20230615082520_create_roles_users.exs │ │ │ │ │ ├── 20230626064411_create_backends_roles.exs │ │ │ │ │ ├── 20230805134343_add_totpable_to_identity_providers.exs │ │ │ │ │ ├── 20230805160200_add_totp_to_users.exs │ │ │ │ │ ├── 20230810130509_add_enforce_totp_to_identity_providers.exs │ │ │ │ │ ├── 20230903150227_create_organizations.exs │ │ │ │ │ ├── 20230908094746_add_label_to_organizations.exs │ │ │ │ │ ├── 20230908113944_create_organizations_users.exs │ │ │ │ │ ├── 20230909103013_add_create_default_organization_to_backends.exs │ │ │ │ │ ├── 20240109125818_add_verifiable_credentails_to_backends.exs │ │ │ │ │ ├── 20240110094020_add_federated_metadata_to_users.exs │ │ │ │ │ ├── 20240426110841_organizations_users_reference.exs │ │ │ │ │ ├── 20240505104631_organizations_users_reference2.exs │ │ │ │ │ ├── 20240808195715_add_webauthn_challenge_to_users.exs │ │ │ │ │ ├── 20240820140733_add_webauthn_public_key_to_users.exs │ │ │ │ │ ├── 20240820233604_add_webauthn_to_identity_providers.exs │ │ │ │ │ ├── 20240907103609_add_verifiable_presentations_to_backends.exs │ │ │ │ │ ├── 20241017153124_add_account_type_to_users.exs │ │ │ │ │ ├── 20241130000259_add_check_password_to_identity_providers.exs │ │ │ │ │ └── 20250302193825_remove_global_email_unique_constraint.exs │ │ │ │ └── seeds.exs │ │ │ └── templates/ │ │ │ ├── choose_session/ │ │ │ │ └── index.mustache │ │ │ ├── confirmations/ │ │ │ │ └── new.mustache │ │ │ ├── consents/ │ │ │ │ └── new.mustache │ │ │ ├── emails/ │ │ │ │ ├── confirmation_instructions.html.mustache │ │ │ │ ├── confirmation_instructions.txt.mustache │ │ │ │ ├── reset_password_instructions.html.mustache │ │ │ │ ├── reset_password_instructions.txt.mustache │ │ │ │ ├── tx_code.html.mustache │ │ │ │ └── tx_code.txt.mustache │ │ │ ├── errors/ │ │ │ │ ├── 400.mustache │ │ │ │ ├── 401.mustache │ │ │ │ ├── 403.mustache │ │ │ │ ├── 404.mustache │ │ │ │ └── 500.mustache │ │ │ ├── layouts/ │ │ │ │ └── app.mustache │ │ │ ├── mfa/ │ │ │ │ ├── totp/ │ │ │ │ │ ├── authentication.mustache │ │ │ │ │ └── registration.mustache │ │ │ │ └── webauthn/ │ │ │ │ ├── authentication.mustache │ │ │ │ └── registration.mustache │ │ │ ├── registrations/ │ │ │ │ └── new.mustache │ │ │ ├── reset_passwords/ │ │ │ │ ├── edit.mustache │ │ │ │ └── new.mustache │ │ │ ├── sessions/ │ │ │ │ └── new.mustache │ │ │ └── settings/ │ │ │ ├── credential_offer.mustache │ │ │ ├── edit_user.mustache │ │ │ └── verifiable_presentation.mustache │ │ └── test/ │ │ ├── boruta_identity/ │ │ │ ├── accounts/ │ │ │ │ └── deliveries_test.exs │ │ │ ├── accounts_test.exs │ │ │ ├── admin_test.exs │ │ │ ├── configuration_test.exs │ │ │ ├── federated_accounts_test.exs │ │ │ ├── identity_providers/ │ │ │ │ ├── backend_test.exs │ │ │ │ └── identity_provider_test.exs │ │ │ ├── identity_providers_test.exs │ │ │ ├── resource_owners_test.exs │ │ │ ├── totp_test.exs │ │ │ └── webauthn_test.exs │ │ ├── boruta_identity_web/ │ │ │ ├── concerns/ │ │ │ │ └── authenticable_test.exs │ │ │ ├── controllers/ │ │ │ │ ├── choose_session_controller_test.exs │ │ │ │ ├── page_controller_test.exs │ │ │ │ ├── totp_controller_test.exs │ │ │ │ ├── user_confirmation_controller_test.exs │ │ │ │ ├── user_consent_controller_test.exs │ │ │ │ ├── user_registration_controller_test.exs │ │ │ │ ├── user_reset_password_controller_test.exs │ │ │ │ ├── user_session_controller_test.exs │ │ │ │ └── user_settings_controller_test.exs │ │ │ ├── plugs/ │ │ │ │ └── sessions_test.exs │ │ │ └── views/ │ │ │ ├── error_view_test.exs │ │ │ ├── layout_view_test.exs │ │ │ └── page_view_test.exs │ │ ├── support/ │ │ │ ├── boruta_factory.ex │ │ │ ├── boruta_identity_factory.ex │ │ │ ├── conn_case.ex │ │ │ ├── data_case.ex │ │ │ └── fixtures/ │ │ │ ├── accounts_fixtures.ex │ │ │ ├── admin_fixtures.ex │ │ │ └── identity_providers_fixtures.ex │ │ └── test_helper.exs │ └── boruta_web/ │ ├── .formatter.exs │ ├── .gitignore │ ├── config/ │ │ ├── config.exs │ │ ├── dev.exs │ │ ├── prod.exs │ │ └── test.exs │ ├── lib/ │ │ ├── boruta/ │ │ │ └── status_resolver.ex │ │ ├── boruta_web/ │ │ │ ├── application.ex │ │ │ ├── controllers/ │ │ │ │ ├── did_controller.ex │ │ │ │ ├── fallback_controller.ex │ │ │ │ ├── monitoring_controller.ex │ │ │ │ ├── oauth/ │ │ │ │ │ ├── authorize_controller.ex │ │ │ │ │ ├── introspect_controller.ex │ │ │ │ │ ├── pushed_authorization_request_controller.ex │ │ │ │ │ ├── revoke_controller.ex │ │ │ │ │ └── token_controller.ex │ │ │ │ ├── openid/ │ │ │ │ │ ├── credential_controller.ex │ │ │ │ │ ├── dynamic_registration_controller.ex │ │ │ │ │ ├── jwks_controller.ex │ │ │ │ │ └── userinfo_controller.ex │ │ │ │ └── openid_controller.ex │ │ │ ├── endpoint.ex │ │ │ ├── gettext.ex │ │ │ ├── logger.ex │ │ │ ├── plugs/ │ │ │ │ └── rate_limit.ex │ │ │ ├── presentation_server.ex │ │ │ ├── release.ex │ │ │ ├── repo.ex │ │ │ ├── router.ex │ │ │ ├── templates/ │ │ │ │ └── error/ │ │ │ │ ├── 404.html.eex │ │ │ │ └── 500.html.eex │ │ │ ├── token.ex │ │ │ └── views/ │ │ │ ├── error_helpers.ex │ │ │ ├── error_view.ex │ │ │ ├── oauth_view.ex │ │ │ └── openid_view.ex │ │ ├── boruta_web.ex │ │ └── mix/ │ │ └── tasks/ │ │ └── server.ex │ ├── mix.exs │ ├── priv/ │ │ ├── gettext/ │ │ │ ├── en/ │ │ │ │ └── LC_MESSAGES/ │ │ │ │ └── errors.po │ │ │ └── errors.pot │ │ └── repo/ │ │ └── migrations/ │ │ └── .keep │ └── test/ │ ├── boruta_web/ │ │ ├── controllers/ │ │ │ ├── credential_controller_test.exs │ │ │ ├── oauth/ │ │ │ │ ├── authorization_code_test.exs │ │ │ │ ├── authorize_controller_test.exs │ │ │ │ ├── client_credentials_test.exs │ │ │ │ ├── direct_post_test.exs │ │ │ │ ├── implicit_test.exs │ │ │ │ ├── introspect_test.exs │ │ │ │ ├── openid_connect_test.exs │ │ │ │ ├── password_test.exs │ │ │ │ └── revoke_test.exs │ │ │ └── pushed_authorization_request_controller_test.exs │ │ ├── plugs/ │ │ │ └── rate_limit_test.exs │ │ └── views/ │ │ ├── error_view_test.exs │ │ ├── layout_view_test.exs │ │ └── page_view_test.exs │ ├── support/ │ │ ├── boruta_factory.ex │ │ ├── boruta_identity_factory.ex │ │ └── conn_case.ex │ └── test_helper.exs ├── boruta-admin.openapi.json ├── config/ │ ├── config.exs │ ├── dev.exs │ ├── prod.exs │ ├── releases.exs │ └── test.exs ├── docker-compose.yml ├── examples/ │ ├── README.md │ └── sidecar-authorization-gateways/ │ ├── config/ │ │ ├── example-gateway-configuration.yml │ │ ├── example-httpbin-configuration.yml │ │ └── example-protected-httpbin-configuration.yml │ └── docker-compose.yml ├── mix.exs ├── rel/ │ ├── env.bat.eex │ ├── env.sh.eex │ └── vm.args.eex ├── scripts/ │ ├── prepare_assets.sh │ └── setup.debian.sh ├── static_config/ │ ├── example-gateway-configuration.yml │ ├── example-httpbin-configuration.yml │ └── example-protected-httpbin-configuration.yml └── vetur.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .credo.exs ================================================ # This file contains the configuration for Credo and you are probably reading # this after creating it with `mix credo.gen.config`. # # If you find anything wrong or unclear in this file, please report an # issue on GitHub: https://github.com/rrrene/credo/issues # %{ # # You can have as many configs as you like in the `configs:` field. configs: [ %{ # # Run any exec using `mix credo -C `. If no exec name is given # "default" is used. # name: "default", # # These are the files included in the analysis: files: %{ # # You can give explicit globs or simply directories. # In the latter case `**/*.{ex,exs}` will be used. # included: ["lib/", "src/", "test/", "web/", "apps/"], excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] }, # # Load and configure plugins here: # plugins: [], # # If you create your own checks, you must specify the source files for # them here, so they can be loaded by Credo before running the analysis. # requires: [], # # If you want to enforce a style guide and need a more traditional linting # experience, you can change `strict` to `true` below: # strict: true, # # If you want to use uncolored output by default, you can change `color` # to `false` below: # color: true, # # You can customize the parameters of any check by adding a second element # to the tuple. # # To disable a check put `false` as second element: # # {Credo.Check.Design.DuplicatedCode, false} # checks: [ # ## Consistency Checks # {Credo.Check.Consistency.ExceptionNames, []}, {Credo.Check.Consistency.LineEndings, []}, {Credo.Check.Consistency.ParameterPatternMatching, []}, {Credo.Check.Consistency.SpaceAroundOperators, []}, {Credo.Check.Consistency.SpaceInParentheses, []}, {Credo.Check.Consistency.TabsOrSpaces, []}, # ## Design Checks # # You can customize the priority of any check # Priority values are: `low, normal, high, higher` # {Credo.Check.Design.AliasUsage, [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 1]}, # You can also customize the exit_status of each check. # If you don't want TODO comments to cause `mix credo` to fail, just # set this value to 0 (zero). # {Credo.Check.Design.TagTODO, [exit_status: 0]}, {Credo.Check.Design.TagFIXME, []}, # ## Readability Checks # {Credo.Check.Readability.AliasOrder, []}, {Credo.Check.Readability.FunctionNames, []}, {Credo.Check.Readability.LargeNumbers, []}, {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, {Credo.Check.Readability.ModuleAttributeNames, []}, {Credo.Check.Readability.ModuleDoc, []}, {Credo.Check.Readability.ModuleNames, []}, {Credo.Check.Readability.ParenthesesInCondition, []}, {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, {Credo.Check.Readability.PredicateFunctionNames, []}, {Credo.Check.Readability.PreferImplicitTry, []}, {Credo.Check.Readability.RedundantBlankLines, []}, {Credo.Check.Readability.Semicolons, []}, {Credo.Check.Readability.SpaceAfterCommas, []}, {Credo.Check.Readability.StringSigils, []}, {Credo.Check.Readability.TrailingBlankLine, []}, {Credo.Check.Readability.TrailingWhiteSpace, []}, # TODO: enable by default in Credo 1.1 {Credo.Check.Readability.UnnecessaryAliasExpansion, false}, {Credo.Check.Readability.VariableNames, []}, # ## Refactoring Opportunities # {Credo.Check.Refactor.CondStatements, []}, {Credo.Check.Refactor.CyclomaticComplexity, []}, {Credo.Check.Refactor.FunctionArity, []}, {Credo.Check.Refactor.LongQuoteBlocks, []}, {Credo.Check.Refactor.MapInto, []}, {Credo.Check.Refactor.MatchInCondition, []}, {Credo.Check.Refactor.NegatedConditionsInUnless, []}, {Credo.Check.Refactor.NegatedConditionsWithElse, []}, {Credo.Check.Refactor.Nesting, [max_nesting: 3]}, {Credo.Check.Refactor.UnlessWithElse, []}, {Credo.Check.Refactor.WithClauses, []}, {Credo.Check.Refactor.RedundantWithClauseResult, false}, # ## Warnings # {Credo.Check.Warning.BoolOperationOnSameValues, []}, {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, {Credo.Check.Warning.IExPry, []}, {Credo.Check.Warning.IoInspect, []}, {Credo.Check.Warning.LazyLogging, []}, {Credo.Check.Warning.OperationOnSameValues, []}, {Credo.Check.Warning.OperationWithConstantResult, []}, {Credo.Check.Warning.RaiseInsideRescue, []}, {Credo.Check.Warning.UnusedEnumOperation, []}, {Credo.Check.Warning.UnusedFileOperation, []}, {Credo.Check.Warning.UnusedKeywordOperation, []}, {Credo.Check.Warning.UnusedListOperation, []}, {Credo.Check.Warning.UnusedPathOperation, []}, {Credo.Check.Warning.UnusedRegexOperation, []}, {Credo.Check.Warning.UnusedStringOperation, []}, {Credo.Check.Warning.UnusedTupleOperation, []}, {Credo.Check.Warning.SpecWithStruct, false}, {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, false}, # # Controversial and experimental checks (opt-in, just replace `false` with `[]`) # {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, {Credo.Check.Consistency.UnusedVariableNames, false}, {Credo.Check.Design.DuplicatedCode, false}, {Credo.Check.Readability.AliasAs, false}, {Credo.Check.Readability.MultiAlias, false}, {Credo.Check.Readability.Specs, false}, {Credo.Check.Readability.SinglePipe, false}, {Credo.Check.Refactor.ABCSize, false}, {Credo.Check.Refactor.AppendSingleItem, false}, {Credo.Check.Refactor.DoubleBooleanNegation, false}, {Credo.Check.Refactor.ModuleDependencies, false}, {Credo.Check.Refactor.PipeChainStart, false}, {Credo.Check.Refactor.VariableRebinding, false}, {Credo.Check.Refactor.Apply, false}, {Credo.Check.Warning.MapGetUnsafePass, false}, {Credo.Check.Warning.UnsafeToAtom, false} # # Custom checks can be created using `mix credo.gen.check`. # ] } ] } ================================================ FILE: .formatter.exs ================================================ [ inputs: ["mix.exs", "config/*.exs"], subdirectories: ["apps/*"] ] ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: boruta-server ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/workflows/deploy.yml ================================================ name: Deployment on: push: branches: - provider-policies-registration workflow_run: workflows: - Continuous Integration branches: - master - signatures-adapter types: - completed env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build-and-push-server-image: runs-on: ubuntu-22.04 permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v3 - name: Log in to the Container registry uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - uses: benjlevesque/short-sha@v1.2 id: short-sha with: length: 8 - name: Build and push server Docker image uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc with: file: Dockerfile.full context: . build-args: | BORUTA_OAUTH_BASE_URL=https://oauth.boruta.patatoid.fr push: true tags: | ${{ env.REGISTRY }}/malach-it/boruta-server:${{ steps.short-sha.outputs.sha }}, ${{ env.REGISTRY }}/malach-it/boruta-server:${{ github.head_ref || github.ref_name }} labels: ${{ steps.meta.outputs.labels }} build-and-push-gateway-image: runs-on: ubuntu-22.04 permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v3 - name: Log in to the Container registry uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - uses: benjlevesque/short-sha@v1.2 id: short-sha with: length: 8 - name: Build and push gateway Docker image uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc with: file: Dockerfile.gateway context: . push: true tags: | ${{ env.REGISTRY }}/malach-it/boruta-gateway:${{ steps.short-sha.outputs.sha }}, ${{ env.REGISTRY }}/malach-it/boruta-gateway:${{ github.head_ref || github.ref_name }} labels: ${{ steps.meta.outputs.labels }} build-and-push-auth-image: runs-on: ubuntu-22.04 permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v3 - name: Log in to the Container registry uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - uses: benjlevesque/short-sha@v1.2 id: short-sha with: length: 8 - name: Build and push auth Docker image uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc with: file: Dockerfile.auth context: . push: true tags: | ${{ env.REGISTRY }}/malach-it/boruta-auth:${{ steps.short-sha.outputs.sha }}, ${{ env.REGISTRY }}/malach-it/boruta-auth:${{ github.head_ref || github.ref_name }} labels: ${{ steps.meta.outputs.labels }} build-and-push-admin-image: runs-on: ubuntu-22.04 permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v3 - name: Log in to the Container registry uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - uses: benjlevesque/short-sha@v1.2 id: short-sha with: length: 8 - name: Build and push admin Docker image uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc with: file: Dockerfile.admin context: . push: true tags: | ${{ env.REGISTRY }}/malach-it/boruta-admin:${{ steps.short-sha.outputs.sha }}, ${{ env.REGISTRY }}/malach-it/boruta-admin:${{ github.head_ref || github.ref_name }} labels: ${{ steps.meta.outputs.labels }} ================================================ FILE: .github/workflows/elixir.yml ================================================ name: Continuous Integration on: push: branches: - master pull_request: branches: - master jobs: static_code_analysis: name: Static Code Analysis runs-on: ubuntu-22.04 container: elixir:1.14.5-otp-25 steps: - name: Cancel Previous Runs uses: styfle/cancel-workflow-action@0.11.0 with: access_token: ${{ github.token }} - name: Checkout uses: actions/checkout@v3 with: fetch-depth: 0 - name: Retrieve Cached Dependencies uses: actions/cache@v3 id: mix-cache with: path: | deps _build key: ${{ runner.os }}-${{ hashFiles('mix.lock') }} # - name: Check Code Format # run: mix format --check-formatted - name: Elixir prerequisites run: | mix local.rebar --force mix local.hex --force mix deps.get - name: Compilation warnings run: mix compile --force --warnings-as-errors - name: Run Credo run: mix credo --strict # - name: Run Dialyzer # run: mix dialyzer unit_tests: name: Unit Tests runs-on: ubuntu-22.04 container: elixir:1.14.5-otp-25 strategy: fail-fast: false services: postgres: image: postgres env: POSTGRES_PASSWORD: postgres options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - name: Cancel Previous Runs uses: styfle/cancel-workflow-action@0.6.0 with: access_token: ${{ github.token }} - name: Checkout uses: actions/checkout@v3 with: fetch-depth: 0 - name: Retrieve Cached Dependencies uses: actions/cache@v3 id: mix-cache with: path: | deps _build key: ${{ runner.os }}-${{ hashFiles('mix.lock') }} - name: Elixir prerequisites run: | mix local.rebar --force mix local.hex --force mix deps.get - name: Run test run: mix test --trace env: MIX_ENV: test POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DATABASE: boruta_test POSTGRES_HOST: postgres ================================================ FILE: .gitignore ================================================ /_build/ /cover/ /deps/ /doc/ /tmp/ /log/ /.fetch erl_crash.dump *.ez .env.sh .env *.swp *.swo *.orig tags* apps/*/priv/static/** apps/*/priv/ssl/** apps/*/log/** .vscode /ansible/group_vars ================================================ FILE: .gitlab-ci.yml ================================================ stages: - test - build - deploy services: - postgres:latest .elixir-task: image: elixir:1.12.1 before_script: - apt-get install -y libcurl4-openssl-dev libssl-dev libevent-dev - mix local.hex --force - mix local.rebar --force - mix deps.get # dialyzer: # stage: test # extends: .elixir-task # cache: # paths: # - _build/ # script: # - mix dialyzer credo: stage: test extends: .elixir-task script: - mix credo --strict test: stage: test extends: .elixir-task script: - mix test --trace variables: POSTGRES_DATABASE: boruta_test POSTGRES_HOST: postgres POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres MIX_ENV: test build: image: docker:19.03.12 stage: build services: - docker:19.03.12-dind before_script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker pull $CI_REGISTRY/patatoid/boruta/app:latest script: - docker build --cache-from $CI_REGISTRY/patatoid/boruta/app:latest -t $CI_REGISTRY/patatoid/boruta/app:$CI_COMMIT_SHORT_SHA . - docker tag $CI_REGISTRY/patatoid/boruta/app:$CI_COMMIT_SHORT_SHA $CI_REGISTRY/patatoid/boruta/app:latest - docker push $CI_REGISTRY/patatoid/boruta/app:$CI_COMMIT_SHORT_SHA - docker push $CI_REGISTRY/patatoid/boruta/app:latest only: - master deploy: stage: deploy image: debian:latest variables: PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" cache: paths: - .cache/pip before_script: - apt-get update - apt-get install -y curl python3-pip apt-transport-https - curl https://baltocdn.com/helm/signing.asc | apt-key add - - echo "deb https://baltocdn.com/helm/stable/debian/ all main" | tee /etc/apt/sources.list.d/helm-stable-debian.list - apt-get update - apt-get install -y helm - pip3 install --upgrade setuptools pip - pip3 install ansible pyhelm grpcio requests openshift kubernetes - ansible-galaxy collection install kubernetes.core - curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" - curl -LO "https://dl.k8s.io/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl.sha256" - echo "$( vault_pass.txt - ansible-vault decrypt ./.kube/kubeconfig-k8s-boruta.yaml --vault-password-file vault_pass.txt - KUBECONFIG=./.kube/kubeconfig-k8s-boruta.yaml ansible-playbook -i ./inventories/scaleway deploy.yml -e release_tag=$CI_COMMIT_SHORT_SHA --vault-password-file vault_pass.txt only: - master ================================================ FILE: .tool-versions ================================================ nodejs 25.2.1 erlang 25.3.2.21 elixir 1.14.5-otp-25 ================================================ FILE: CHANGELOG.md ================================================ # Changelog > Note that 0.X.X releases are reverved for the beta version of the server and may include breaking changes. ## [unreleased] ### Added - [ssi] code chains - verify verifiable presentation from code chains - issuance code chains - next flow redirection in case of presentation success - agent token management - [ssi] code metadata policies - restrict issuance / presentation key usage for enabled check public client id clients - [ssi] server sent events verifiable presentation page navigation - [identity] add resource owner in credentials templates - [ssi] credential issuance scope restriction - [wallet] display credential presentation purposes ### Changed - [ssi] remove resource owner constraint for openid4vc flows - [ssi] default backend authorization details for anonymous users - [ssi] issuance / presentation default templates open integrated wallet in a popup - [ssi] add client_id to credential offers - [identity] improve identity providers querying and cache - [admin] group direct post requests in dashboard ### Fixed - [admin] add credential offer and presentation in breadcrumb - [admin] update identity provider title in breadcrumb - [admin] feedback stars display - [wallet] qr code scan redirection - [ssi] public and unknown users presentation ### Security - [admin] only expose client name in templates - [auth] remove default client secret from seeds - [admin] set minimum oauth client private key modulus size ## [0.8.0] - 2025-07-12 ### Added - [auth] agent credentials / code flows - [wallet] key selection - [ssi] verify public client id oauth client option ### Changed - [auth] max authorization code ttl to 600 seconds - [ssi] remove authentication on siopv2 flow ### Fixed - [admin] file upload text editor update - [ssi] expose public credential configuration for authenticated users - [wallet] fix presentation duplicates ### Security - [auth] experimental request rate limiting - [auth] remove dynamic client registration ## [0.7.2] - 2025-04-13 ### Fixed - [auth] fix boruta core migration ## [0.7.1] - 2025-04-05 ### Fixed - [ssi] do not use ES256 alg to verify EdDSA JWTs - [identity] expose default templates static assets ## [0.7.0] - 2025-03-26 ### Added - [admin] signatures adapter - [wallet] display an error when no credential match presentation - [identity] add reload button in credentials temapltes - [wallet] close qr code scanner on click ### Security - [wallet] fix npm vulnerabilities - [admin] fix npm vulnerabilities ## [0.6.1] - 2025-03-15 ### Security - [admin] update verifiable presentations default template ## [0.6.0] - 2025-03-15 ### Added - [identity] passwordless user creation (WIP) - [identity] destroy user - [ssi] transaction code in OID4VCI preauthorized code flow - [ssi] vct configuration in verifiable credentials - [admin] feedback form - [wallet] web identity wallet bootstrap (PWA) - [identity] scope user emails per backend - [admin] decentralized identity example flows - [ssi] verifiable credentials nested claims ### Changed - [identity] remove user metadata value constraints - [admin] verifiable credentials claim format ### Fixed - [admin] defered configuration - [admin] example credential issuance link - [ssi] oauth clients did persistence - [admin] verifiable presentation definition text edition ### Security - [admin] remove cdnjs dependency - [identity] remove picsum dependency ## [0.5.1] - 2024-11-21 ### Added - [admin] user csv import metadata - [infra] organization creation in static configuration - [admin] client key pair configuration + support for EC keys ### Fixed - [ssi] several verifiable credentials issuance and presentation fixes - [auth] configurable status display in id_token claims - [admin] user with empty metadata save - [admin] federated users deletion ## [0.5.0] - 2024-10-17 ### Added - [ssi] OpenID for Verifiable Credentials Presentation implementation ## [0.4.2] - 2024-09-20 ### Fixed - [auth] fix authorize entrypoint ## [0.4.1] - 2024-09-18 ### Fixed - [admin] ipv6 log display ### Security - [infra] remove .env.example.sig as suspicious file ## [0.4.0] 2024-09-01 ### Added - [ssi] Configurable verifiable credentials issuance with oid4vci implementation - [ssi] Siopv2 same device implementation - [auth] Demonstration proof of possession implementation - [auth] Pushed Authorization Request implementation - [infra] Server ip address bindings configuration via environment variables - [infra]Infrastructure as Code with static file configuration - [admin] Admin ui improvements - [auth] Better identity federation - [identity] Webauthn integration - [infra] Remote IP logging ### Security - [admin] instance authenticated admins are sub or organization restricted ### Fixed - [infra] Fix organization and sub admin access restriction ## [0.3.0] 2024-01-18 ### Added - [identity] user organisation management - [identity] TOTP second factor support - [identity] user roles management - [infra] split auth/admin/gateway/all docker images - [infra] split gateway, admin, auth releases - [infra] system wide installation script - [infra] gather statistical info on installation ## [0.2.0] - 2023-05-17 ### Added - [gateway] introspected token forwarding to updatreams - [identity] email templates edition - [identity] configure, expose and edit user metadata - [identity] user metadata configuration - [gateway] static configuration - [gateway] microgateways - [identity] identity federation (login with button) - [auth] better well-known openid configuration - [auth] dynamic client registration - [auth] client authentication methods configuration - [auth] global signing key pairs ### Security - [identity] invalidate user reset password token at use ## [0.1.0] - 2022-10-25 Initial beta release ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Code of Conduct ## 1. Why share this code of conduct? A code of conduct aims to set the frame of community interactions around the corresponding open-source product. As collective work is at stake here, getting into it requires fitting the rules that make a safe and well-being place to work. This document aims the community wellness to give us the best opportunities to produce the relative facts and deliverables. From this perspective, the need for us to navigate with psychological safety is a must-have to deliver the best we can to improve and make evolve the code at the heart of the community. ## 2. What is a violation? The purpose of this document is to set the limits and how to mitigate when violations occur. First of all, any violation of applicable law is to be reported for the healthiness of our interactions. We would make focus on harassment and all kind of discrimination against ancestry, age, color, religion, caste, origin, race, personal appearance, nationality, socio-economic status, level of experience, gender identity and expression, sex characteristics, ethnicity, sexual identity and orientation, visible or invisible disability, body size, and other private or personal information that can be used for harming purposes. ## 3. What is a contribution? Are considered contributions, the produced code and all derived material propagated under the copyright provided by this repository of code. Other material out of this scope is not considered contributions to this project and we do have not any responsibility for them. Also, any official representation of the product may be considered as a contribution. Those need a written validation from the owners of the project. ## 4. In accordance with the product vision The contributions to this project may fit its vision, all contributions that may diverge from it would be considered as violations if not consented by the owner. The vision of the project is stated as : ENABLING ORGANIZATIONS THEN USERS TO MANAGE DIGITAL IDENTITIES WHERE DATA PROTECTION IMPROVE SYSTEMS SECURITY ## 5. The roles and responsibilities of contributors Within this frame arise three main responsibilities. From those can be derived roles that have to fulfill all of them within the community. The objective of them is to help community wellness. The first responsibility is to report violations that are described above (section 2). It is an overall member's duty to report such violations to the right person following an escalation process. That person will be named here as a facilitator of escalation, described next. In order to facilitate escalation, the facilitator starts from report and assess the situation to find the right way to mitigate the issue. By owning the report, he keeps the process alive until its resolution by facilitating interactions with the mediating people. The best is to have a written trace of the way the situation was solved step by step to reproduce if it occurs again. He is also responsible for the escalation process improvement. Within the community, mediation of the situations of violation is made by resolving the conflicts. The responsible person for this should be trained to conflict resolution using methods like Non-Violent Communication from Rosenberg or other peaceful ways of resolution. The responsibility includes finding external resources to solve the conflict, up to contact authorities in case of grave violation or violation of applicable law. Once again, it is an overall responsibility to report the issues as it is the basis for a healthy community. Starting by navigating within the frame, ensuring psychological safety is also needed for us to evolve as a community. For example, the minimal roles within a community around an open-source project start with an owner. The project owner can take both mediator and facilitator of escalation responsibilities. Those can be split then and spread across the community knowing that the responsibilities do not have to be unique within the community. Finding suitable roles according to the needed responsibilities of this framework is a work that may include community members to find a collective track toward them. ## 6. Escalation of violations Based on IT incident report patterns, escalation of violations aims to find the root cause, fix the issue, and prevent its reappearance. Also, one of the objectives is to avoid conflict between parties and find equitable solutions to solve the issue. For that, the mediation may be made using methodology such as Non-Violent Communication to enforce communication over violence being factual as a basis. The escalation process may suit the community integrating it, then as finding the roles, one may find a collective track to find the best way to mitigate issues. As a minimal process, the project owner must leave an explicit way to report abuse. As a reminder, it is an overall community member's responsibility to report violations of the present code of conduct. That report is to be done in an anonymous way to prevent retaliation. The people responsible for facilitation can keep track of the report, find the best people to mitigate it within the community (it can be himself), and the mediation can begin. Then comes the mediation, for a low-importance violation, the mediator can facilitate communication and take action against the abuse. In case of public violation, the impactful content is to be removed. Actions may be temporary or permanent bans from the community for example. The issue of the mediation may be tracked by the facilitator for audit purposes but also to mediate if the violation comes up again. That record may be public or private. He can also find ways to improve the escalation process or trigger a role redefinition within the community. In case of grave violation like any against applicable law, the authorities are to be warned and the overall community has the duty to facilitate their work. ## 7. Ethical goals This frame of contributions may help to promote ethical goals. Before this, contributions may not imply in any manner a step against those goals, knowing that going toward them aims for us to live in a better place which is the purpose of the current document. This community aims to: - fight against slavery and forced labor, empower labor law - empower human rights - empower diversity, equity, and inclusion The frame helps to have the tools to get mutual aid to go toward those goals. > Exemplarity is not required, goodwill is. OWNER CONTACT: pascal@malach.it

This code of conduct is licensed under Attribution-NonCommercial-ShareAlike 4.0 International

================================================ FILE: Dockerfile.admin ================================================ FROM node:25.2.1 AS assets # For packages not compatible with OpenSSL 3.0 https://nodejs.org/en/blog/release/v17.0.0/ ENV NODE_OPTIONS=--openssl-legacy-provider WORKDIR /app COPY ./apps/boruta_admin/assets /app RUN npm ci RUN npm run build FROM elixir:1.14-otp-25-alpine AS builder RUN apk --no-cache --update add build-base git ENV MIX_ENV=prod RUN mix local.hex --force RUN mix local.rebar --force WORKDIR /app COPY . . COPY --from=assets /priv/static/assets ./apps/boruta_admin/priv/static/assets RUN rm -rf deps RUN mix do clean, deps.get RUN mix compile WORKDIR /app/apps/boruta_admin RUN mix phx.digest WORKDIR /app RUN mix release boruta_admin --force --overwrite FROM elixir:1.14-otp-25-alpine WORKDIR /app COPY --from=builder /app/_build/prod/rel/boruta_admin ./ CMD ["/bin/sh", "-c", "/app/bin/boruta_admin start"] ================================================ FILE: Dockerfile.auth ================================================ FROM node:25.2.1 AS identity_assets ARG BORUTA_OAUTH_BASE_URL # For packages not compatible with OpenSSL 3.0 https://nodejs.org/en/blog/release/v17.0.0/ ENV NODE_OPTIONS=--openssl-legacy-provider WORKDIR /app/wallet COPY ./apps/boruta_identity/assets /app RUN npm ci RUN npm run build FROM elixir:1.14-otp-25-alpine AS builder RUN apk --no-cache --update add build-base git ENV MIX_ENV=prod RUN mix local.hex --force RUN mix local.rebar --force WORKDIR /app COPY . . RUN rm -rf deps RUN mix do clean, deps.get RUN mix compile COPY --from=identity_assets /priv ./apps/boruta_identity/priv/ WORKDIR /app/apps/boruta_identity RUN mix phx.digest WORKDIR /app/apps/boruta_web RUN mix phx.digest WORKDIR /app RUN mix release boruta_auth --force --overwrite FROM elixir:1.14-otp-25-alpine WORKDIR /app COPY --from=builder /app/_build/prod/rel/boruta_auth ./ CMD ["/bin/sh", "-c", "/app/bin/boruta_auth start"] ================================================ FILE: Dockerfile.full ================================================ FROM node:25.2.1 AS admin_assets # For packages not compatible with OpenSSL 3.0 https://nodejs.org/en/blog/release/v17.0.0/ ENV NODE_OPTIONS=--openssl-legacy-provider WORKDIR /app COPY ./apps/boruta_admin/assets /app RUN npm ci RUN npm run build FROM node:25.2.1 AS identity_assets ARG BORUTA_OAUTH_BASE_URL # For packages not compatible with OpenSSL 3.0 https://nodejs.org/en/blog/release/v17.0.0/ ENV NODE_OPTIONS=--openssl-legacy-provider WORKDIR /app/wallet COPY ./apps/boruta_identity/assets /app RUN npm ci RUN npm run build FROM elixir:1.14-otp-25-alpine AS builder RUN apk --no-cache --update add build-base git ENV MIX_ENV=prod RUN mix local.hex --force RUN mix local.rebar --force WORKDIR /app COPY . . RUN rm -rf deps RUN mix do clean, deps.get RUN mix compile COPY --from=admin_assets /priv/static/assets ./apps/boruta_admin/priv/static/assets COPY --from=identity_assets /priv/static/wallet ./apps/boruta_identity/priv/static/wallet WORKDIR /app/apps/boruta_admin RUN mix phx.digest WORKDIR /app/apps/boruta_identity RUN mix phx.digest WORKDIR /app/apps/boruta_web RUN mix phx.digest WORKDIR /app RUN mix release boruta --force --overwrite FROM elixir:1.14-otp-25-alpine WORKDIR /app COPY --from=builder /app/_build/prod/rel/boruta ./ # File used for gateway static configuration, used in combination with `BORUTA_GATEWAY_CONFIGURATION_PATH` environment variable COPY /static_config/example-gateway-configuration.yml config/example-gateway-configuration.yml COPY /static_config/example-httpbin-configuration.yml config/example-httpbin-configuration.yml COPY /static_config/example-protected-httpbin-configuration.yml config/example-protected-httpbin-configuration.yml CMD ["/bin/sh", "-c", "/app/bin/boruta start"] ================================================ FILE: Dockerfile.gateway ================================================ FROM elixir:1.14-otp-25-alpine AS builder RUN apk --no-cache --update add build-base git ENV MIX_ENV=prod RUN mix local.hex --force RUN mix local.rebar --force WORKDIR /app COPY . . RUN rm -rf deps RUN mix do clean, deps.get RUN mix compile WORKDIR /app RUN mix release boruta_gateway --force --overwrite FROM elixir:1.14-otp-25-alpine WORKDIR /app COPY --from=builder /app/_build/prod/rel/boruta_gateway ./ # File used for gateway static configuration, used in combination with `BORUTA_GATEWAY_CONFIGURATION_PATH` environment variable COPY /static_config/example-gateway-configuration.yml config/example-gateway-configuration.yml COPY /static_config/example-httpbin-configuration.yml config/example-httpbin-configuration.yml COPY /static_config/example-protected-httpbin-configuration.yml config/example-protected-httpbin-configuration.yml CMD ["/bin/sh", "-c", "/app/bin/boruta_gateway start"] ================================================ FILE: GENERAL_TERMS_AND_CONDITIONS.md ================================================ ### General Terms and Conditions for Boruta (Open Beta) #### **1. Introduction** Welcome to the open beta of Boruta, an identity and access management solution developed and maintained by Malachit EI. By participating in the beta, you agree to these General Terms and Conditions ("Terms"). Boruta is provided as-is for testing purposes and may not be fully optimized for all production use cases. These Terms are supplementary to the Apache 2.0 License under which Boruta is licensed. In the event of a conflict, the Apache 2.0 License governs the open-source aspects of Boruta. --- #### **2. Eligibility** Participation in the Boruta beta is open to all individuals and organizations, provided they comply with applicable laws and these Terms. By using Boruta, you affirm that you are at least 18 years old or have obtained consent from a legal guardian. --- #### **3. Scope of Use** The beta software is made available for testing and feedback purposes. Users are encouraged to explore Boruta's features, report issues, and share suggestions. Malachit recommends against using the beta version in production environments for critical applications. --- #### **4. User Responsibilities** Users agree to: - Use Boruta solely for lawful purposes and in compliance with all applicable laws and regulations. - Avoid using Boruta in ways that could harm, disable, overburden, or impair the software, its infrastructure, or third-party services connected to it. - Refrain from introducing malicious code (e.g., viruses, worms) or attempting to exploit vulnerabilities in Boruta or its associated systems. - Use Boruta in compliance with its documentation and avoid reverse engineering, decompiling, or disassembling the software unless expressly permitted under the Apache 2.0 License. - Ensure that any identity information collected through Boruta is handled in compliance with applicable data protection and privacy laws. Users acknowledge that they are solely responsible for the proper handling, storage, and use of such information. --- #### **5. Prohibited Activities** Users may not: - Use Boruta for unlawful, unethical, or harmful activities, including but not limited to unauthorized access to systems, identity theft, fraud, or spamming. - Attempt to circumvent security measures or protections implemented within Boruta. - Misrepresent the software or its capabilities to others, especially in production environments. - Share or distribute Boruta in a manner inconsistent with its open-source license (Apache 2.0). --- #### **6. Feedback and Contributions** Feedback is critical to improving Boruta. Users may report bugs, suggest features, and provide other feedback through GitHub issues. By submitting feedback: - You grant Malachit a non-exclusive, royalty-free, perpetual, and irrevocable license to use, modify, and distribute your contributions. - Significant contributors may be acknowledged publicly in the project documentation or release notes. For security-related or sensitive issues, please report directly via email at pascal@malach.it. Additionally, Boruta provides a feedback form under the io.malach.it [Privacy Policy](https://io.malach.it/privacy-policy.html), ensuring compliance with data protection standards. --- #### **7. Stability and Updates** While Boruta is stable for general testing, it may not fully support all production environments. Users should exercise caution when deploying Boruta in critical systems. - **Updates**: Monthly releases are planned during the beta phase. Updates may include new features, bug fixes, or breaking changes. - **Changelog**: Each release will be accompanied by a detailed changelog on GitHub, outlining updates, known issues, and any migration steps for breaking changes. - **Data Recovery**: While Malachit will make best efforts to preserve user data during updates, some updates may cause data loss. Such instances will be noted in the changelog. --- #### **8. Communication and Notifications** - **Updates**: All updates and announcements will be shared on the project’s GitHub repository. - **Future Notifications**: To receive notifications about future releases and updates, users can sign up via the contact form at [https://io.malach.it](https://io.malach.it). The contact form complies with the io.malach.it [Privacy Policy](https://io.malach.it/privacy-policy.html). - **Support**: For inquiries, users may contact pascal@malach.it. Malachit aims to respond within three business days but does not guarantee response times during the beta. --- #### **9. Privacy and Data Use** - The contact form on [https://io.malach.it](https://io.malach.it) collects personal data, such as email addresses, to facilitate notifications about Boruta updates. - Data collected through the contact form will be handled in accordance with the io.malach.it [Privacy Policy](https://io.malach.it/privacy-policy.html). - Malachit does not collect or retain sensitive user data through the beta software unless explicitly stated. --- #### **10. Indemnity** Users agree to indemnify and hold harmless Malachit, its affiliates, and contributors from any claims, damages, liabilities, or expenses arising from: - Misuse of Boruta. - Violation of these Terms. - Unauthorized deployment or alteration of Boruta. - Improper handling, collection, or storage of identity information collected by users through Boruta. --- #### **11. Liability Disclaimer** - Boruta is provided "as-is" during the beta phase without warranties of any kind, express or implied, including but not limited to fitness for a particular purpose or non-infringement. - Malachit disclaims liability for any damages arising from: - Improper use of Boruta or failure to adhere to its documentation. - Unauthorized modifications or alterations made by users. - Use of Boruta in high-risk environments where failure could lead to severe damages, such as critical infrastructure or life-support systems, without explicit prior approval from Malachit. - Malachit reserves the right to restrict access to the beta for users engaged in misuse or harmful activities. --- #### **12. Termination** Malachit reserves the right to terminate or modify the beta program at any time. Upon termination, Boruta will remain accessible as software under the Apache 2.0 license. --- #### **13. Transition to Full Release** At the end of the beta phase, Boruta will transition to its full release while remaining open source under the Apache 2.0 License. Malachit reserves the right to release additional features, tools, or related services under separate terms. Users will be notified of the transition and encouraged to update to the latest version. --- #### **14. Governing Law** These Terms are governed by the laws of France. Malachit is registered as an Entreprise Individuelle (EI) under French law. Any disputes arising from these Terms will be resolved under the exclusive jurisdiction of the courts in France. --- #### **15. Contact Information** For any questions or concerns regarding Boruta or these Terms, please contact: Pascal Knoth, Malachit Email: pascal@malach.it Website: [https://io.malach.it](https://io.malach.it) ================================================ FILE: LICENSE.md ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2022-2026 - Pascal Knoth (patatoid - malachit) Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ ![logo-yellow](images/logo-yellow.png) boruta is a standalone authorization server that aims to implement OAuth 2.0 and Openid Connect up to decentralized identity specifications. It provides administration tools and a customizable identity provider out of the box to manage authorization, but also an experimental gateway to apply access rules to incoming traffic. ## Status boruta is currently in an __open beta phase__, if you are interested in the project and the tools it provides, you are encourage to test the product within your context. Production readyness will depend on your feedback, helping to fix the possible bugs and improve the solution usability. While being mostly stable and security asessed for some of its parts, boruta is a work in progress, please read the [General Terms and Conditions](GENERAL_TERMS_AND_CONDITIONS.md). ## Implemented specifications and certification As it, boruta server aim to follow the RFCs from IETF: - [RFC 6749 - The OAuth 2.0 Authorization Framework](https://tools.ietf.org/html/rfc6749) - [RFC 7662 - OAuth 2.0 Token Introspection](https://tools.ietf.org/html/rfc7662) - [RFC 7009 - OAuth 2.0 Token Revocation](https://tools.ietf.org/html/rfc7009) - [RFC 7636 - Proof Key for Code Exchange by OAuth Public Clients](https://tools.ietf.org/html/rfc7636) - [RFC 7521 - Assertion Framework for OAuth 2.0 Client Authentication and Authorization Grants](https://www.rfc-editor.org/rfc/rfc7521) - [RFC 7523 - JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants](https://tools.ietf.org/html/rfc7523) - [RFC 9449 - OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop) - [RFC 9126 - OAuth 2.0 Pushed Authorization Requests](https://datatracker.ietf.org/doc/html/rfc9126) And the specifications from the OpenID Foundation: - [OpenID Connect core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) - [OpenID Connect Dynamic Client Registration 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-registration-1_0.html) - [OpenID for Verifiable Credential Issuance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html) - [Self-Issued OpenID Provider v2](https://openid.net/specs/openid-connect-self-issued-v2-1_0.html) - [OpenID for Verifiable Presentations - draft 21](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html) This server has been certified for the Basic, Implicit, and Hybrid OpenID Provider profiles by the OpenID Foundation on October, 18th 2022 for the tagged versions 0.1.0 and 0.5.0 This server has been certified for the Config and Dynamic OpenID Provider profiles by the OpenID Foundation on May, 16th 2023 for the tagged version 0.2.0 This server has also been certified against the [European Blockchain Service Infrastructure (EBSI)](https://ec.europa.eu/digital-building-blocks/sites/display/EBSI) issuance test suite for the tagged version 0.4.0 and for verifiable credential verification for the tagged version 0.5.0. ![EBSI certified - issue](https://github.com/malach-it/boruta-server/blob/master/images/ebsi-certification-issuance.png?raw=true) ![EBSI certified - verify](https://github.com/malach-it/boruta-server/blob/master/images/ebsi-certification-verify.png?raw=true) ![OpenID certified](https://github.com/malach-it/boruta-server/blob/master/images/oid-certification-mark.png?raw=true) ## Documentation Server documentation is available on github pages [here](https://malach-it.github.io/developers.boruta/docs/intro). It highlights how the server works, describing its architecture, parameters and the associated authentication / authorization flows. It is a Work In Progress, all feedback or contributions would be welcomed. ## DID creation and resolution boruta may use [Universal resolver](https://github.com/decentralized-identity/universal-resolver) for DID resolution and [Universal registrar](https://github.com/decentralized-identity/universal-registrar) for DID creation. Those are to be configured as environment variables, respectively `DID_RESOLVER_BASE_URL` and `DID_REGISTRAR_BASE_URL`. DIDs are used in the decentralized identity flows and are present as key identifier header of the other generated JWTs. ## Integrated wallet This project includes a demo wallet implemented for testing purposes. It is compliant with the decentralized identity flows but does not have neither secure storage nor portability features which makes it not production ready. Note that even if those features come in further releases, this wallet does not target to be an EUDI wallet as the European framework states. ## Installation A [loom presentation](https://www.loom.com/share/77006360fdac44bc9113fab9cf30aba5) about how to get a server up and running. Note that the easiest way to try the server is by using docker compose. ### Run an instance from docker > Note this image is built for x86_64 architecture, for other architectures build yourself the image or use docker compose install that will build the image for your architecture. A docker image is available at `malachit/boruta-server` on [DockerHub](https://hub.docker.com/r/malachit/boruta-server), you will need a postgres instance installed on your system with credentials provided as environment variables in `.env.*`. 1. Get environment file ```bash wget https://raw.githubusercontent.com/malach-it/boruta-server/master/.env.dev ``` Once done you will be able to launch the server. ```bash docker run -it --env-file .env.dev --network=host malachit/boruta-server:0.4.0 ``` The applications will be available on different ports (depending on the values provided in `.env.dev`): - http://localhost:4000 for the authorization server - http://localhost:4001 for the admin interface - http://localhost:4002 for the gateway - http://localhost:4003 for the microgateway Admin credentials are the one seeded and available in environment file. ### Run an instance from docker-compose You can build and run the docker images as follow: ```bash docker-compose up ``` The applications will be available on different ports (depending on the docker compose environment configuration): - http://localhost:8080 for the authorization server - http://localhost:8081 for the admin interface - http://localhost:8082 for the gateway - http://localhost:8083 for the microgateway Admin credentials are the one seeded and available in environment file. ### Requirements - Elixir >= 1.13 - postgreSQL >= 13 - node >= 16.5 (if you need to prepare assets) ### Run a release from scratch 1. first you need to get project dependencies ```bash mix deps.get ``` 2. you need to prepare assets in order for them to be included in the release ```bash ./scripts/prepare_assets.sh ``` 3. then you can craft the release ```bash MIX_ENV=prod mix release boruta ``` Once done, you can run the release as follow: ```bash env $(cat .env.example | xargs) _build/prod/rel/boruta/bin/boruta start ``` The applications will be available on different ports (depending on the values provided in `.env.example`): - http://localhost:8080 for the authorization server - http://localhost:8081 for the admin interface - http://localhost:8082 for the gateway - http://localhost:8083 for the microgateway Admin credentials are the one seeded and available in environment file. ### Run a development server 1. first you need to get project dependencies ```bash mix deps.get ``` 2. you need to prepare assets in order to fetch javascript dependencies ```bash ./scripts/prepare_assets.sh ``` 3. because of the forwarding of requests between web and identity modules, you need to add the `/accounts` path prefix in configuration ```diff --- a/apps/boruta_identity/config/config.exs +++ b/apps/boruta_identity/config/config.exs @@ -4,8 +4,8 @@ config :boruta_identity, ecto_repos: [BorutaAuth.Repo, BorutaIdentity.Repo] config :boruta_identity, BorutaIdentityWeb.Endpoint, - url: [host: "localhost"], - # url: [host: "localhost", path: "/accounts"], + # url: [host: "localhost"], + url: [host: "localhost", path: "/accounts"], ``` You now should be able to start the development server ```bash env $(cat .env.dev | xargs) MIX_ENV=dev mix boruta.server ``` The applications will be available on different ports (depending on the values provided in `.env.dev`): - http://localhost:4000 for the authorization server - http://localhost:4001 for the admin interface - http://localhost:4002 for the gateway - http://localhost:4003 for the microgateway Admin credentials are the one seeded and available in environment file. ### Default admin credentials In order to authenticate to the administration interface you will be asked for credentials that are by default (seeded from environment variables) `admin@test.test` / `imaynotknowthat`. ## Environment variables | Variable name | description | | ---------------------------------- | ------------------- | | `SECRET_KEY_BASE` | The Phoenix secret key base. It must be at least 64 cheracters long. | | `POSTGRES_USER` | The database user provided as credentials in postgreSQL connections. | | `POSTGRES_PASSWORD` | The database password provided as credentials in postgreSQL connections. | | `POSTGRES_DATABASE` | The database name provided in postgreSQL connections. | | `POSTGRES_HOST` | The database host provided in postgreSQL connections. | | `POOL_SIZE` | The postgreSQL pool size of each application, the real connection count will be twice that value. | | `MAX_LOG_RETENTION_DAYS` | The number of days the logs are kept to the server. This value defaults to 60. | | `K8S_NAMESPACE` | If set along with K8S_SELECTOR, it setups libcluster in order to connect boruta erlang nodes in kubernetes together. | | `K8S_SELECTOR` | If set along with K8S_NAMESPACE, it setups libcluster in order to connect boruta erlang nodes in kubernetes together. | | `BORUTA_ADMIN_OAUTH_CLIENT_ID` | An uuidv4 string representing the admin oauth client id. It will be part of the client seeded in the setup task. | | `BORUTA_ADMIN_OAUTH_CLIENT_SECRET` | The admin oauth client secret. It will be part of the client seeded in the setup task. | | `BORUTA_ADMIN_OAUTH_BASE_URL` | The URL base URL of the authorization server admin will use (linked to above client_id and secret, without trailing slash). | | `BORUTA_ADMIN_EMAIL` | The first admin email. It will be part of the user seeded in the setup task. | | `BORUTA_ADMIN_PASSWORD` | The first admin password. It will be part of the user seeded in the setup task. | | `BORUTA_ADMIN_HOST` | The host that represent the host where boruta admin server will be deployed to. | | `BORUTA_ADMIN_BIND` | The IP address the boruta admin server will be bound to. | | `BORUTA_ADMIN_PORT` | The port where boruta admin server will be exposed on. | | `BORUTA_ADMIN_BASE_URL` | The base URL where boruta admin server http endpoint will be deployed to (without trailing slash). | | `BORUTA_OAUTH_SCHEME` | The scheme that will be used for URL building, default to https. | | `BORUTA_OAUTH_HOST` | The host where boruta oauth server will be deployed to. | | `BORUTA_OAUTH_BIND` | The IP address the boruta oauth server will be bound to. | | `BORUTA_OAUTH_PORT` | The port where boruta oauth server will be exposed on. | | `BORUTA_OAUTH_BASE_URL` | The base URL where boruta oauth server http endpoint will be deployed to (without trailing slash). | | `BORUTA_GATEWAY_PORT` | The port where boruta gateway will be exposed on. | | `BORUTA_GATEWAY_SIDECAR_PORT` | The port where boruta microgateway will be exposed on. | | `BORUTA_GATEWAY_CONFIGURATION_PATH`| The path containing the gateway static configuration. | | `BORUTA_CONFIGURATION_PATH` | The path containing the boruta static configuration. | | `BORUTA_SUB_RESTRICTED` | If set, the uid of the only user to have access to the administration interface. | | `BORUTA_ORGANIZATION_RESTRICTED` | If set, the uid of the only organization to have access to the administration interface. | | `DID_RESOLVER_BASE_URL` | Did resolver API endpoint, accroding to the [W3C DID resolution specification](https://w3c.github.io/did-resolution/) | | `DID_REGISTRAR_BASE_URL` | Did registrar API endpoint, accroding to the [W3C DID registration specification](https://identity.foundation/did-registration/) | | `DID_SERVICES_API_KEY` | API key granting access to DID revolver and registrar services. | ## Code of Conduct This product community follows the code of conduct available [here](CODE_OF_CONDUCT.md) ## License This code is released under the [Apache 2.0](LICENSE.md) license. ## General Terms and Conditions By using Boruta, you agree to the [General Terms and Conditions](GENERAL_TERMS_AND_CONDITIONS.md), which complement the software's Apache 2.0 License. ## About boruta The name boruta comes from a polish legend where he is a gentle devil (an angel, maybe) that is such evil that having him at home makes you safe. He was living during the middle ages in the castle of the little town of Leczyca, since then the people from there have a little figurine of him at home helping the house to be protected from bad fate. ================================================ FILE: ansible/.kube/kubeconfig-k8s-boruta.yaml ================================================ $ANSIBLE_VAULT;1.1;AES256 61666431343966366336333965363036373037353566316363333465633762663931623438373666 6238346264616633303238616462393462373662343139360a613866366365613266373236383066 34663165333365633762623961333565396361353439303538363466376463383133653333663130 6236346365633562390a646266363130666465393530653864363832613533393862353931366238 31383135303361633766643963633436356331316333343164346563623532393235373432356261 31313035313235666337383030643766626236366263313537666163653436633332396531626430 34643964393564343732356262303962633764323330363738633634373436303434313239303833 66373835623834363165303062626236323034386464373236303233653263633661663465323237 37636463636363313132663434386134363433643464373131363765633562316366313537346130 36373065383430643731356362613166613432616336353538336137326331646564376334316430 37353761633665653937623138333931613564633366346331343438613665346638323762353266 32363834643236323937393364666531666432393666346634643861613134393137633338666431 62336361663731356139373433303130643334333831343537326238323863653465323265623963 64383766656436393336343838313634353731313133363335393235616664346564393862653334 61323961613132633036326138363166376534616331303433633435313237626133646665656230 35653264353137643530336365623937653662306535343863336230333732366333343166643930 62666561383331613335356331393738626163613432393630343431623738656439633536393364 33636634363663313763613535336264303465386539636530633463623931313730646439353939 38613434663137643530383165633666323963626262366162333831626363613965313631346163 35333930633435646239376134356138623431663530323330363335326237626365383436323836 65646363346137303032363666326134316332313962326438353336643233316239313535396435 37333937326334653432333931376630363339346435666632386138373230626562366666313163 31323931383066383762313266393535383165363464386439666461393538353138366265656331 30323362383634336334376466336565643430613436666265313939646534386563376336646562 38356562383464326162336639626265393133323533383338613036636237353264393665333862 34666434323133623030396263653230303534376239363866336366626364323632373663303437 36363466326533633361373465313635623432363864613866633739656232663038623730353736 35653338633561376235653761663433353034633630666564303238386437633137373034373836 39373537323539653832383635353231663162356139373065336430376330373532346164336536 61343165353365666535393534613366396439653338616333373836376234666335313866386636 64616636333635303230313632316162353033626364613865333934313764643839353963336561 38353238396138333066386530643762303064326363396638343731646465643731653335373330 34643035326533383633333465353132303235663532636239633935386339613037393437356434 37383538613065356133343038376534346665353136633530343763363935616437616632393438 32396562626165386633646436626262646132326161636163313231396533633866333564653066 37326236323335366638633234343763383665616265373064353033396234333937316661326637 38653434386463363637636566353563626539366637636635366362386561356461303961303830 64623334303564663931633431616439313939633562343561386665353264663631346634316234 62643762636531306163656436353664663231316339326663643064376464306433363332333864 32356161336635626566316362623332356636623062326333383936333130336163663965373134 34386136303962316131373165656562623766633339373039313931376463626463343366303061 31373734303034613565373431313736376463316435666136323536633661363433633237333739 37366638633066643930656133363137396662353765633132623766386339636639393465383133 61353938373736393439303461373133656537313361383839343038323232363130643333663564 35353963373136646332323436626337353962343766356235373633353936376333366164333934 39366366353966656237356438353432666534383039353931376435393761363634323166326336 66373738356331356132646435333964363230393361366335363633666132386339373138623133 33363431636333646638636162643434643039346239653930326636303132303535323866666264 33623834343230353232313832623437303065306637653733383364323332306361383232303831 39663361386333313238316364396162626161346333343934333565323833383162353734343962 38373731663034396563613130363861366664323961373437343739386436656432653765313437 61613966626536653766363162356132643263646235656263343538643035623938353064643431 34336431346165646339656633336130643336343134633633663638393964326666383231633835 39313966663064386234643464623966386436666464383636633837373433313663323165383739 64653963356565363433643338633039316566366630393764343165343134366264393933306339 34313536343632386565326235613664633261626263623931353938623265323237353338383535 38363633343838393163636361383835653334303162336632313038316235323565653166646131 32333462663964306436306461313330353932313034656334303839643135623135323832363232 34613234316530363439346165373539393231616534303639346163303135313462616231363034 38366266396238333366363936373763303537353737656330363333616239653430363861386538 35353232316435663130316134333363366564396639313166396439303065613637356434383666 34323230636235366262656238646263303936643434363337323561633063303134636134353137 33306633636135396361373364383533393761633533396361643131346564623562653762303235 32386262663936333436333634386338333739613665373439333533306362346264343431633339 34663864313231323565386131653537343037353261616561656635353363626133323362646136 61353961336136323363646132336266336637616436663535393963366362316634393430383731 61663065383461643636333938323533616630306332353831663933653062653365646666383030 39336534373831613834626664363132346530326431323866323163633630373061666436656230 31633735613130343961323061356461303464643839393565303837303639303864393433663436 30663834316538353934363439396632306564313631663431353638323633653434343936353936 62363939316464326366396464313039353266306533306365663963386666643633306238643436 64323337336130366564303766383137633562373837383963326539383733363139356135663766 39653631393839363530313034313866383033333861313239346236623935623264313333376330 62326233393863323365323537653336303661643131316665373538343332396165306463653064 61393463386331313336613164613630393031633865316139663932323465623263316234623563 66356363326562336133343163363661373062323531616333376363653362316630616631616335 66306266623730383935396134666639666266306636353338366332386164656135373166643838 64386235666265613463343631343164363133613063643466303435313934343965383337303137 36323133633330303539346539313164313863323363353462303635396233666638353164303834 63396539356439636432666562333339663666396139373661383836373439356332663337643539 33366631396662323936636238626266653333393362383963376336386530326664396538316334 63323263666161666461343334323966376639623439366231643137373733343436623564326332 32386461613238636664656635613530626239303862316566303530326233363666663162323334 64643161656166613963633733363636373736656166653062643765373731306533333964333264 36343063326466613230323638373635636538323932316231346230373963633238646239643134 32323635383035323638316231653736626666643939363839333461636332386338313532323065 63363663333764323734663665313036326438313330316338666665326261316132653362303037 65656138383961373664396463373562663438323864363739373464643632353632366266666465 30353733643264333837633161653932316135326665643434333230376366643039363165313530 36356636653965623761303833303533346630656561663531666132396633633861376637333939 61316536666335653563613032653839343437333664313364373836363263376465383963363139 34386561326634323062333265333736356232623630356666623061656261373136336566323961 35303764626462346361396438356634353430333661666138366362303039353066363637346164 64663232316333393334376335326163333861303536636163353364363362303566383063646465 30306538623231343137316362656632343032616432323636653638363463626437356538663033 37643666343166636638636639316331376463636362336531306461323935346236343163356330 30666662393938653766613336353134656636373366373538363463303861636164 ================================================ FILE: ansible/deploy.yml ================================================ - hosts: localhost tasks: - name: Add stable bitnami repo kubernetes.core.helm_repository: name: bitnami repo_url: https://charts.bitnami.com/bitnami - name: Add stable jetstack repo kubernetes.core.helm_repository: name: jetstack repo_url: https://charts.jetstack.io - name: Create a k8s namespace kubernetes.core.k8s: api_version: v1 name: boruta-staging kind: Namespace state: present # - name: Install cert-manager helm package # kubernetes.core.helm: # name: cert-manager # chart_ref: jetstack/cert-manager # chart_version: 1.11.1 # release_namespace: cert-manager # create_namespace: true # values: # installCRDs: true # upgrade looks to be complicated, variable names contain breaking changes # the master configuration file - https://github.com/bitnami/charts/blob/main/bitnami/postgresql/values.yaml # # - name: Install postgres helm package # kubernetes.core.helm: # name: postgres # chart_ref: bitnami/postgresql # chart_version: "12.2.7" # release_namespace: boruta-staging # values: # global.postgresql.auth.postgresPassword: "{{ postgresql_password }}" # primary.extendedConfiguration: | # max_connections = 1024 - name: Create libcluster role kubernetes.core.k8s: state: present definition: apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: libcluster-role namespace: boruta-staging rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "list", "watch"] - name: Create libcluster binding kubernetes.core.k8s: state: present definition: apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: libcluster-bindings namespace: boruta-staging subjects: - kind: ServiceAccount name: default namespace: boruta-staging roleRef: kind: Role name: libcluster-role apiGroup: rbac.authorization.k8s.io - name: Create SSL let's encrypt certificate kubernetes.core.k8s: state: present definition: apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: name: letsencrypt-production spec: acme: email: io.pascal.knoth@gmail.com server: https://acme-v02.api.letsencrypt.org/directory privateKeySecretRef: name: boruta-staging-ssl-account-key solvers: - http01: ingress: class: nginx selector: dnsZones: - 'boruta.patatoid.fr' - name: Create Boruta Ingress kubernetes.core.k8s: state: present definition: apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: main-ingress namespace: boruta-staging annotations: kubernetes.io/ingress.class: "nginx" cert-manager.io/cluster-issuer: letsencrypt-production # nginx.ingress.kubernetes.io/rewrite-target: / spec: rules: - host: "{{ gateway_host }}" http: paths: - path: / pathType: Prefix backend: service: name: boruta-gateway port: number: 5000 - host: "{{ httpbin_sidecar_host }}" http: paths: - path: / pathType: Prefix backend: service: name: boruta-httpbin-sidecar port: number: 5001 - host: "{{ protected_httpbin_sidecar_host }}" http: paths: - path: / pathType: Prefix backend: service: name: boruta-protected-httpbin-sidecar port: number: 5001 - host: "{{ oauth_host }}" http: paths: - path: / pathType: Prefix backend: service: name: boruta-oauth port: number: 4001 - host: "{{ admin_host }}" http: paths: - path: / pathType: Prefix backend: service: name: boruta-admin port: number: 4002 tls: - hosts: - "{{ oauth_host }}" - "{{ admin_host }}" - "{{ gateway_host }}" - "{{ httpbin_sidecar_host }}" - "{{ protected_httpbin_sidecar_host }}" secretName: boruta-staging-cert - name: create boruta app ConfigMap kubernetes.core.k8s: state: present definition: apiVersion: v1 kind: ConfigMap metadata: name: boruta-env namespace: boruta-staging data: SECRET_KEY_BASE: "{{ secret_key_base }}" POSTGRES_USER: postgres POSTGRES_PASSWORD: "{{ postgresql_password }}" POSTGRES_DATABASE: boruta POSTGRES_HOST: postgres-postgresql MAX_LOG_RETENTION_DAYS: "180" K8S_NAMESPACE: boruta-staging K8S_SELECTOR: app=boruta BORUTA_ADMIN_OAUTH_CLIENT_ID: "{{ admin_client_id }}" BORUTA_ADMIN_OAUTH_CLIENT_SECRET: "{{ admin_client_secret }}" BORUTA_ADMIN_OAUTH_BASE_URL: "{{ oauth_base_url }}" BORUTA_ADMIN_EMAIL: "{{ admin_email }}" BORUTA_ADMIN_PASSWORD: "{{ admin_password }}" BORUTA_ADMIN_HOST: "{{ admin_host }}" BORUTA_ADMIN_PORT: "4002" BORUTA_ADMIN_BASE_URL: "{{ boruta_base_url }}" BORUTA_OAUTH_HOST: "{{ oauth_host }}" BORUTA_OAUTH_PORT: "4001" BORUTA_OAUTH_BASE_URL: "{{ oauth_base_url }}" BORUTA_GATEWAY_PORT: "5000" BORUTA_GATEWAY_SIDECAR_PORT: "5001" BORUTA_ORGANIZATION_RESTRICTED: "{{ boruta_organization_restricted }}" DID_SERVICES_API_KEY: "{{ did_services_api_key }}" POOL_SIZE: "5" - name: Setup boruta auth database register: database_setup kubernetes.core.k8s: state: present definition: apiVersion: batch/v1 kind: Job metadata: name: boruta-auth-setup namespace: boruta-staging backoffLimit: 3 spec: template: spec: containers: - image: "ghcr.io/malach-it/boruta-server:master" command: ["/app/bin/boruta"] args: ["eval", "BorutaWeb.Release.setup"] envFrom: - configMapRef: name: boruta-env imagePullPolicy: Always name: boruta restartPolicy: OnFailure imagePullSecrets: - name: dockerconfigjson-github-com - name: Create Boruta logs persistent volume claim kubernetes.core.k8s: state: present definition: apiVersion: v1 kind: PersistentVolumeClaim metadata: name: logs-pvc namespace: boruta-staging spec: storageClassName: standard-rwo accessModes: - ReadWriteOnce # TODO will cause issues when running on multiple nodes resources: requests: storage: 5Gi - name: Get blue/green deployment status kubernetes.core.k8s_info: kind: Service namespace: boruta-staging label_selectors: - "app=boruta" register: boruta_services - name: Create Boruta blue/green deployment kubernetes.core.k8s: state: present definition: apiVersion: apps/v1 kind: Deployment metadata: name: "{{ 'boruta-green' if boruta_services.resources[0].spec.selector.deployment == 'boruta-blue' else 'boruta-blue' }}" namespace: boruta-staging spec: replicas: 1 strategy: type: Recreate rollingUpdate: null selector: matchLabels: deployment: "{{ 'boruta-green' if boruta_services.resources[0].spec.selector.deployment == 'boruta-blue' else 'boruta-blue' }}" template: metadata: name: boruta labels: deployment: "{{ 'boruta-green' if boruta_services.resources[0].spec.selector.deployment == 'boruta-blue' else 'boruta-blue' }}" app: boruta spec: volumes: - name: logs persistentVolumeClaim: claimName: logs-pvc containers: - image: "ghcr.io/malach-it/boruta-server:{{ release_tag }}" readinessProbe: httpGet: path: /healthcheck port: 4001 env: - name: BORUTA_GATEWAY_CONFIGURATION_PATH value: "./config/example-gateway-configuration.yml" envFrom: - configMapRef: name: boruta-env imagePullPolicy: Always name: boruta volumeMounts: - mountPath: "/app/log" name: logs imagePullSecrets: - name: dockerconfigjson-github-com - name: Wait for deployment readyness shell: "kubectl -n boruta-staging wait --for=condition=Ready po --all -l deployment={{ 'boruta-green' if boruta_services.resources[0].spec.selector.deployment == 'boruta-blue' else 'boruta-blue' }}" retries: 6 delay: 5 register: result until: result.rc == 0 - name: Create OAuth Service kubernetes.core.k8s: state: present definition: apiVersion: v1 kind: Service metadata: name: boruta-oauth namespace: boruta-staging labels: app: boruta spec: selector: deployment: "{{ 'boruta-green' if boruta_services.resources[0].spec.selector.deployment == 'boruta-blue' else 'boruta-blue' }}" ports: - protocol: TCP targetPort: 4001 name: oauth-tcp port: 4001 - name: Create Admin Service kubernetes.core.k8s: state: present definition: apiVersion: v1 kind: Service metadata: name: boruta-admin namespace: boruta-staging labels: app: boruta spec: selector: deployment: "{{ 'boruta-green' if boruta_services.resources[0].spec.selector.deployment == 'boruta-blue' else 'boruta-blue' }}" ports: - protocol: TCP targetPort: 4002 name: admin-tcp port: 4002 - name: Create Gateway Service kubernetes.core.k8s: state: present definition: apiVersion: v1 kind: Service metadata: name: boruta-gateway namespace: boruta-staging labels: app: boruta spec: selector: deployment: "{{ 'boruta-green' if boruta_services.resources[0].spec.selector.deployment == 'boruta-blue' else 'boruta-blue' }}" ports: - protocol: TCP targetPort: 5000 name: gateway-tcp port: 5000 - name: Create httpbin sidecar Service kubernetes.core.k8s: state: present definition: apiVersion: v1 kind: Service metadata: name: boruta-httpbin-sidecar namespace: boruta-staging labels: app: boruta-httpbin spec: selector: deployment: "{{ 'boruta-httpbin-green' if boruta_services.resources[0].spec.selector.deployment == 'boruta-httpbin-blue' else 'boruta-httpbin-blue' }}" ports: - protocol: TCP targetPort: 5001 name: httpbin-tcp port: 5001 - name: Create protected httpbin sidecar Service kubernetes.core.k8s: state: present definition: apiVersion: v1 kind: Service metadata: name: boruta-protected-httpbin-sidecar namespace: boruta-staging labels: app: boruta-protected-httpbin spec: selector: deployment: "{{ 'boruta-protected-httpbin-green' if boruta_services.resources[0].spec.selector.deployment == 'boruta-protected-httpbin-blue' else 'boruta-protected-httpbin-blue' }}" ports: - protocol: TCP targetPort: 5001 name: gateway-tcp port: 5001 - name: Delete Boruta blue/green deployment kubernetes.core.k8s: state: absent definition: apiVersion: apps/v1 kind: Deployment metadata: name: "{{ 'boruta-blue' if boruta_services.resources[0].spec.selector.deployment == 'boruta-blue' else 'boruta-green' }}" namespace: boruta-staging ================================================ FILE: ansible/hosts ================================================ [local] localhost ================================================ FILE: ansible/inventories/gke ================================================ [gke] localhost [gke:vars] ansible_connection=local ================================================ FILE: ansible/inventories/local ================================================ [local] localhost [local:vars] ansible_connection=local ================================================ FILE: ansible/inventories/scaleway ================================================ [scaleway] localhost [scaleway:vars] ansible_connection=local ================================================ FILE: apps/boruta_admin/.formatter.exs ================================================ [ import_deps: [:ecto, :phoenix], inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], subdirectories: ["priv/*/migrations"] ] ================================================ FILE: apps/boruta_admin/.gitignore ================================================ # The directory Mix will write compiled artifacts to. /_build/ # If you run "mix test --cover", coverage assets end up here. /cover/ # The directory Mix downloads your dependencies sources to. /deps/ # Where 3rd-party dependencies like ExDoc output generated docs. /doc/ # Ignore .fetch files in case you like to edit your project deps locally. /.fetch # If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump # Also ignore archive artifacts (built via "mix archive.build"). *.ez # Ignore package tarball (built via "mix hex.build"). boruta_admin-*.tar # If NPM crashes, it generates a log, let's ignore it too. npm-debug.log # The directory NPM downloads your dependencies sources to. /assets/node_modules/ # Since we are building assets from assets/, # we ignore priv/static. You may want to comment # this depending on your deployment strategy. /priv/static/ ================================================ FILE: apps/boruta_admin/assets/.browserslistrc ================================================ > 1% last 2 versions ================================================ FILE: apps/boruta_admin/assets/.editorconfig ================================================ [*.{js,jsx,ts,tsx,vue}] indent_style = space indent_size = 2 trim_trailing_whitespace = true insert_final_newline = true ================================================ FILE: apps/boruta_admin/assets/.eslintrc.js ================================================ module.exports = { root: true, env: { node: true }, 'extends': [ 'plugin:vue/essential', '@vue/standard' ], plugins: ['prettier'], rules: { 'prettier/prettier': 'error', 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 'camelcase': 'off', 'prefer-promise-reject-errors': 'off' }, parserOptions: { parser: '@babel/eslint-parser' }, requireConfigFile: false } ================================================ FILE: apps/boruta_admin/assets/.gitignore ================================================ .DS_Store node_modules /dist # local env files .env.local .env.*.local # Log files npm-debug.log* yarn-debug.log* yarn-error.log* # Editor directories and files .idea .vscode *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: apps/boruta_admin/assets/index.html ================================================ Boruta · Phoenix Framework
================================================ FILE: apps/boruta_admin/assets/jsconfig.json ================================================ { "include": [ "./src/**/*" ] } ================================================ FILE: apps/boruta_admin/assets/package.json ================================================ { "name": "boruta-admin", "version": "0.1.0", "private": true, "scripts": { "serve": "vite", "build": "vite build --emptyOutDir", "build:watch": "vite build --watch --emptyOutDir", "deploy": "vite build" }, "dependencies": { "axios": "^1.15.2", "boruta-client": "github:malach-it/boruta-client", "chartjs-adapter-moment": "^1.0.0", "codejar": "^3.5.0", "core-js": "^2.6.5", "google-palette": "^1.1.0", "jwt-decode": "^3.1.2", "lodash": "^4.18.1", "moment": "^2.29.4", "phoenix": "^1.5.7", "phoenix-socket": "^1.2.3", "prismjs": "^1.26.0", "semantic-ui-css": "^2.4.1", "socket.io-client": "^4.2.0", "vue": "^3.2.13", "vue-chart-3": "^3.1.8", "vue-router": "^4.0.12", "vuex": "^4.0.0" }, "devDependencies": { "@babel/eslint-parser": "^7.18.9", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@vitejs/plugin-vue": "^5.2.3", "@vue/eslint-config-standard": "^4.0.0", "autoprefixer": "^10.4.2", "chai": "^4.1.2", "eslint": "^8.7.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-vue": "^8.3.0", "prettier": "2.7.1", "sass": "^1.48.0", "vite": "^6.3.4", "vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-singlefile": "^2.2.0" } } ================================================ FILE: apps/boruta_admin/assets/postcss.config.js ================================================ module.exports = { plugins: { autoprefixer: {} } } ================================================ FILE: apps/boruta_admin/assets/src/App.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Breadcrumb.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Feedback.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Forms/FormErrors.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Forms/GatewayScopesField.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Forms/IdentityProviderField.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Forms/IdentityProviderForm.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Forms/OrganizationForm.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Forms/OrganizationsField.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Forms/RoleForm.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Forms/RolesField.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Forms/ScopesField.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Forms/ScopesFieldByName.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Forms/TextEditor.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Forms/UpstreamForm.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Forms/UserForm.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Header.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/Toaster.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/components/VerifiableCredentialClaim.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/main.js ================================================ import { createApp } from 'vue' import App from './App.vue' import router from './router' import store from './store' const app = createApp(App) app.use(store) app.use(router) app.mount('#app') ================================================ FILE: apps/boruta_admin/assets/src/models/backend.model.js ================================================ import axios from "axios"; import { addClientErrorInterceptor } from "./utils"; import Role from './role.model' const defaults = { name: null, roles: [], type: "Elixir.BorutaIdentity.Accounts.Internal", errors: null, password_hashing_alg: "argon2", password_hashing_opts: {}, features: [], metadata_fields: [], federated_servers: [], verifiable_credentials: [], verifiable_presentations: [], }; const assign = { id: function ({ id }) { this.id = id; }, name: function ({ name }) { this.name = name; }, roles: function ({ roles }) { this.roles = roles.map((scope) => { return { model: new Role(scope) } }) }, type: function ({ type }) { this.type = type; }, is_default: function ({ is_default }) { this.is_default = is_default; }, create_default_organization: function ({ create_default_organization }) { this.create_default_organization = create_default_organization; }, metadata_fields: function ({ metadata_fields }) { this.metadata_fields = metadata_fields.map((field) => { field.scopes ||= []; field.scopes = field.scopes.map((name) => ({ name })); return field; }); }, federated_servers: function ({ federated_servers }) { this.federated_servers = federated_servers.map((federatedServer) => { return { ...federatedServer, isDiscovery: !!federatedServer.discovery_path, }; }); }, verifiable_credentials: function ({ verifiable_credentials }) { this.verifiable_credentials = verifiable_credentials.map(credential => { return { ...credential, // NOTE for retrocompatibility issues claims: typeof credential.claims === "string" ? credential.claims.split(' ').map(claim => ({ pointer: claim })) : credential.claims.map(claim => { return new Claim(claim) }), scopes: credential.scopes?.map(name => ({ name })) } }); }, verifiable_presentations: function ({ verifiable_presentations }) { this.verifiable_presentations = verifiable_presentations; }, features: function ({ features }) { this.features = features; }, password_hashing_alg: function ({ password_hashing_alg }) { this.password_hashing_alg = password_hashing_alg; }, password_hashing_opts: function ({ password_hashing_opts }) { this.password_hashing_opts = password_hashing_opts; }, ldap_pool_size: function ({ ldap_pool_size }) { this.ldap_pool_size = ldap_pool_size; }, ldap_host: function ({ ldap_host }) { this.ldap_host = ldap_host; }, ldap_user_rdn_attribute: function ({ ldap_user_rdn_attribute }) { this.ldap_user_rdn_attribute = ldap_user_rdn_attribute; }, ldap_base_dn: function ({ ldap_base_dn }) { this.ldap_base_dn = ldap_base_dn; }, ldap_ou: function ({ ldap_ou }) { this.ldap_ou = ldap_ou; }, ldap_master_dn: function ({ ldap_master_dn }) { this.ldap_master_dn = ldap_master_dn; }, ldap_master_password: function ({ ldap_master_password }) { this.ldap_master_password = ldap_master_password; }, smtp_from: function ({ smtp_from }) { this.smtp_from = smtp_from; }, smtp_relay: function ({ smtp_relay }) { this.smtp_relay = smtp_relay; }, smtp_username: function ({ smtp_username }) { this.smtp_username = smtp_username; }, smtp_password: function ({ smtp_password }) { this.smtp_password = smtp_password; }, smtp_ssl: function ({ smtp_ssl }) { this.smtp_ssl = smtp_ssl; }, smtp_tls: function ({ smtp_tls }) { this.smtp_tls = smtp_tls; }, smtp_port: function ({ smtp_port }) { this.smtp_port = smtp_port; }, }; class Backend { constructor(params = {}) { Object.assign(this, defaults); Object.keys(params).forEach((key) => { this[key] = params[key]; assign[key].bind(this)(params); }); } get isPersisted() { return this.id } save() { this.errors = null; // TODO trigger validate let response; const { id, serialized } = this; if (this.isPersisted) { response = this.constructor .api() .patch(`/${id}`, { backend: serialized }); } else { response = this.constructor.api().post("/", { backend: serialized }); } return response .then(({ data }) => { const params = data.data; Object.keys(params).forEach((key) => { this[key] = params[key]; assign[key].bind(this)(params); }); return this; }) .catch((error) => { const { errors } = error.response.data; this.errors = errors; throw errors; }); } destroy() { return this.constructor .api() .delete(`/${this.id}`) .catch((error) => { const { code, message, errors } = error.response.data; this.errors = errors; throw { code, message, errors }; }); } get serialized() { const { id, name, type, roles, is_default, create_default_organization, password_hashing_alg, password_hashing_opts, metadata_fields, federated_servers, verifiable_credentials, verifiable_presentations, ldap_pool_size, ldap_host, ldap_user_rdn_attribute, ldap_base_dn, ldap_ou, ldap_master_dn, ldap_master_password, smtp_from, smtp_relay, smtp_username, smtp_password, smtp_ssl, smtp_tls, smtp_port, } = this; const formattedPasswordHashingOpts = {}; Object.keys(password_hashing_opts).forEach((key) => { const value = password_hashing_opts[key]; if (value !== "") { formattedPasswordHashingOpts[key] = value; } }); function serializeClaim (claim) { const { type, name, label, pointer, claims, items } = claim return { type, name, label, pointer, claims: claims.map(serializeClaim), items: items.map(serializeClaim) } } return { id, name, roles: roles.map(({ model }) => model.serialized), type, is_default, create_default_organization, password_hashing_alg, password_hashing_opts: formattedPasswordHashingOpts, metadata_fields: metadata_fields.map( ({ attribute_name, user_editable, scopes }) => ({ attribute_name, user_editable, scopes: scopes.map(({ name }) => name), }) ), federated_servers: federated_servers.map((federatedServer) => { const federated_server = Object.assign({}, federatedServer) if (!federated_server.isDiscovery) { delete federated_server.discovery_path; } delete federated_server.clientSecretVisible delete federated_server.isDiscovery; return federated_server; }), verifiable_credentials: verifiable_credentials.map(verifiableCredential => { verifiableCredential.claims = verifiableCredential.claims.map(serializeClaim) verifiableCredential.scopes = verifiableCredential.scopes?.map(({ name }) => name) return verifiableCredential }), verifiable_presentations, ldap_pool_size, ldap_host, ldap_user_rdn_attribute, ldap_base_dn, ldap_ou, ldap_master_dn, ldap_master_password, smtp_from, smtp_relay, smtp_username, smtp_password, smtp_ssl, smtp_tls, smtp_port, }; } resetPasswordAlgorithmOpts() { this.password_hashing_opts = {}; } static get passwordHashingAlgorithms() { return [ { name: "argon2", label: "Argon2" }, { name: "bcrypt", label: "Bcrypt" }, { name: "pbkdf2", label: "Pbkdf2" }, ]; } static get passwordHashingOpts() { return { argon2: [ { name: "salt_len", type: "number", label: "Length of the random salt (in bytes)", default: 16, }, { name: "t_cost", type: "number", label: "Time cost", default: 8 }, { name: "m_cost", type: "number", label: "Memory usage", default: 16 }, { name: "parallelism", type: "number", label: "Number of parralel threads", default: 2, }, { name: "format", type: "text", label: "Output format (encoded, raw_hash, or report)", default: "encoded", }, { name: "hashlen", type: "number", label: "Length of the hash (in bytes)", default: 32, }, { name: "argon2_type", type: "number", label: "Argon2 type (0 argon2d, 1 argon2i, 2 argon2id)", default: 2, }, ], bcrypt: [ { name: "log_rounds", type: "number", label: "The computational cost as number of log rounds", default: 12, }, { name: "legacy", type: "checkbox", label: 'Generate salts with the old "$2a$" prefix', default: false, }, ], pbkdf2: [ { name: "salt_len", type: "number", label: "The length of the random salt", default: 16, }, { name: "format", type: "text", label: "The output format of the hash (modular, django, or hex)", default: "modular", }, { name: "digest", type: "text", label: "The sha algorithm that pbkdf2 will use", default: "sha512", }, { name: "length", type: "number", label: "The length of the hash (in bytes)", default: 64, }, ], }; } static api() { const accessToken = localStorage.getItem("access_token"); const instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/backends`, headers: { Authorization: `Bearer ${accessToken}` }, }); return addClientErrorInterceptor(instance); } static all() { return this.api() .get("/") .then(({ data }) => { return data.data.map( (identityProvider) => new Backend(identityProvider) ); }); } static get(id) { return this.api() .get(`/${id}`) .then(({ data }) => { return new Backend(data.data); }); } } export class Claim { constructor (attrs = {}) { const claim = Object.assign(Claim.baseClaim(attrs.type), attrs) Object.assign(this, claim) mapClaims(this) mapItems(this) function mapClaims(claim) { if (!claim.claims.length) return claim const result = claim.claims.map(claim => new Claim(claim)) claim.claims = result.map(mapClaims) return claim } function mapItems(claim) { if (!claim.items.length) return claim const result = claim.items.map(claim => new Claim(claim)) claim.items = result.map(mapClaims) return claim } } static build (claimType) { return Object.assign(new Claim(), Claim.baseClaim(claimType)) } assignType (claimType) { Object.assign(this, Claim.baseClaim(claimType)) } static get attributeTypes () { return ['attribute'] } static get objectTypes () { return ['object'] } static get arrayTypes () { return ['array'] } get isAttribute () { return Claim.attributeTypes.includes(this.type) } get isObject () { return Claim.objectTypes.includes(this.type) } get isArray () { return Claim.arrayTypes.includes(this.type) } static baseClaim(claimType) { switch (claimType) { case 'attribute': return { type: 'attribute', name: '', label: '', freeze: false, claims: [], items: [] } case 'object': return { type: 'object', name: '', freeze: false, claims: [], items: [] } case 'array': return { type: 'array', name: '', freeze: false, claims: [], items: [] } default: return { claims: [], items: [] } } } } export default Backend; ================================================ FILE: apps/boruta_admin/assets/src/models/business-log-stats.model.js ================================================ import axios from 'axios' import moment from 'moment' import { addClientErrorInterceptor } from './utils' const defaults = { errors: null } const assign = { time_scale_unit: function ({ time_scale_unit }) { this.time_scale_unit = time_scale_unit }, overflow: function ({ overflow }) { this.overflow = overflow }, log_lines: function ({ log_lines }) { this.log_lines = log_lines }, log_count: function ({ log_count }) { this.log_count = log_count }, counts: function ({ counts }) { this.counts = counts }, business_event_counts: function ({ business_event_counts }) { this.business_event_counts = business_event_counts }, domains: function ({ domains }) { this.domains = domains }, actions: function ({ actions }) { this.actions = actions } } class LogStats { constructor (params = {}) { Object.assign(this, defaults) Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) } } LogStats.api = function () { const accessToken = localStorage.getItem('access_token') const instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/logs`, headers: { 'Authorization': `Bearer ${accessToken}` } }) return addClientErrorInterceptor(instance) } LogStats.all = function ({ startAt, endAt, application, domain, action }) { const params = new URLSearchParams() params.append('start_at', moment.utc(startAt).toISOString()) params.append('end_at', moment.utc(endAt).toISOString()) params.append('application', application) domain && params.append('query[domain]', domain) action && params.append('query[action]', action) params.append('type', 'business') return this.api().get(`?${params.toString()}`).then(({ data }) => { return new LogStats(data) }).catch(error => { throw error.response.data }) } export default LogStats ================================================ FILE: apps/boruta_admin/assets/src/models/client.model.js ================================================ import axios from 'axios' import Scope from './scope.model' import IdentityProvider from './identity-provider.model' import { addClientErrorInterceptor } from './utils' const allGrantTypes = [ 'client_credentials', 'agent_credentials', 'password', 'authorization_code', 'agent_code', 'refresh_token', 'implicit', 'preauthorized_code', 'id_token', 'vp_token', 'revoke', 'introspect' ] const keyPairTypes = { 'ec': { curve: ['P-256', 'P-384', 'P-512'] }, 'rsa': { modulus_size: '2048', exponent_size: '65537' } } const signaturesAdapters = [ 'Elixir.Boruta.Internal.Signatures', 'Elixir.Boruta.Universal.Signatures' ] const defaults = { errors: null, key_pair_id: null, key_pair_type: { type: 'rsa', modulus_size: '2048', exponent_size: '65537' }, signatures_adapter: 'Elixir.Boruta.Internal.Signatures', authorize_scopes: false, authorized_scopes: [], redirect_uris: [], id_token_signature_alg: 'RS512', token_endpoint_jwt_auth_alg: 'HS256', token_endpoint_auth_methods: ["client_secret_basic", "client_secret_post"], identity_provider: { model: new IdentityProvider() }, grantTypes: allGrantTypes.map((label) => { return { value: true, label } }) } const assign = { id: function ({ id }) { this.id = id }, public_client_id: function ({ public_client_id }) { this.public_client_id = public_client_id }, check_public_client_id: function ({ check_public_client_id }) { this.check_public_client_id = check_public_client_id }, name: function ({ name }) { this.name = name }, confidential: function ({ confidential }) { this.confidential = confidential }, pkce: function ({ pkce }) { this.pkce = pkce }, public_key: function ({ public_key }) { this.public_key = public_key }, key_pair_type: function ({ key_pair_type }) { this.key_pair_type = key_pair_type }, signatures_adapter: function ({ signatures_adapter }) { this.signatures_adapter = signatures_adapter }, did: function ({ did }) { this.did = did }, access_token_ttl: function ({ access_token_ttl }) { this.access_token_ttl = access_token_ttl }, authorization_code_ttl: function ({ authorization_code_ttl }) { this.authorization_code_ttl = authorization_code_ttl }, refresh_token_ttl: function ({ refresh_token_ttl }) { this.refresh_token_ttl = refresh_token_ttl }, id_token_ttl: function ({ id_token_ttl }) { this.id_token_ttl = id_token_ttl }, authorization_request_ttl: function ({ authorization_request_ttl }) { this.authorization_request_ttl = authorization_request_ttl }, secret: function ({ secret }) { this.secret = secret }, redirect_uris: function ({ redirect_uris }) { this.redirect_uris = redirect_uris.map((uri) => ({ uri })) }, public_refresh_token: function ({ public_refresh_token }) { this.public_refresh_token = public_refresh_token }, public_revoke: function ({ public_revoke }) { this.public_revoke = public_revoke }, identity_provider: function ({ identity_provider }) { this.identity_provider = { model: new IdentityProvider(identity_provider) } }, authorize_scope: function ({ authorize_scope }) { this.authorize_scope = authorize_scope }, enforce_dpop: function ({ enforce_dpop }) { this.enforce_dpop = enforce_dpop }, enforce_tx_code: function ({ enforce_tx_code }) { this.enforce_tx_code = enforce_tx_code }, authorized_scopes: function ({ authorized_scopes }) { this.authorized_scopes = authorized_scopes.map((scope) => { return { model: new Scope(scope) } }) }, supported_grant_types: function ({ supported_grant_types }) { this.supported_grant_types = supported_grant_types this.grantTypes = allGrantTypes.map((label) => { return { value: this.supported_grant_types.includes(label), label } }) }, token_endpoint_jwt_auth_alg: function ({ token_endpoint_jwt_auth_alg }) { this.token_endpoint_jwt_auth_alg = token_endpoint_jwt_auth_alg }, token_endpoint_auth_methods: function ({ token_endpoint_auth_methods }) { this.token_endpoint_auth_methods = token_endpoint_auth_methods }, jwt_public_key: function ({ jwt_public_key }) { this.jwt_public_key = jwt_public_key }, id_token_signature_alg: function ({ id_token_signature_alg }) { this.id_token_signature_alg = id_token_signature_alg }, userinfo_signed_response_alg: function ({ userinfo_signed_response_alg }) { this.userinfo_signed_response_alg = userinfo_signed_response_alg }, response_mode: function ({ response_mode }) { this.response_mode = response_mode }, } class Client { constructor (params = {}) { Object.assign(this, defaults) Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) } // TODO factorize with User#validate validate () { return new Promise((resolve, reject) => { this.authorized_scopes.forEach(({ model: scope }) => { if (!scope.persisted) { const errors = { authorized_scopes: [ 'cannot be empty' ] } this.errors = errors return reject(errors) } if (this.authorized_scopes.filter(({ model: e }) => e.id === scope.id).length > 1) { const errors = { authorized_scopes: [ 'must be unique' ] } this.errors = errors return reject(errors) } }) resolve() }) } async save () { this.errors = null await this.validate() // TODO trigger validate let response const { id, isPersisted, serialized } = this if (isPersisted) { response = this.constructor.api().patch(`/${id}`, { client: serialized }) } else { response = this.constructor.api().post('/', { client: serialized }) } return response .then(({ data }) => { const params = data.data Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) return this }) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } async regenerateDid () { const { id } = this this.constructor.api().post(`/${id}/regenerate_did`) .then(({ data }) => { const params = data.data Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) this.key_pair_id = null return this }) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } async regenerateKeyPair () { const { id } = this this.constructor.api().post(`/${id}/regenerate_key_pair`) .then(({ data }) => { const params = data.data Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) this.key_pair_id = null return this }) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } destroy () { return this.constructor.api().delete(`/${this.id}`) .catch((error) => { const { code, message, errors } = error.response.data this.errors = errors throw { code, message, errors } }) } get serialized () { const { access_token_ttl, authorization_code_ttl, authorization_request_ttl, authorize_scope, enforce_dpop, enforce_tx_code, authorized_scopes, confidential, grantTypes, id, public_client_id, check_public_client_id, id_token_ttl, name, pkce, public_refresh_token, public_revoke, redirect_uris, refresh_token_ttl, identity_provider, secret, id_token_signature_alg, userinfo_signed_response_alg, token_endpoint_jwt_auth_alg, token_endpoint_auth_methods, jwt_public_key, key_pair_id, key_pair_type, signatures_adapter, response_mode } = this return { access_token_ttl, authorization_code_ttl, authorization_request_ttl, authorize_scope, enforce_dpop, enforce_tx_code, authorized_scopes: authorized_scopes.map(({ model }) => model.serialized), confidential, id, public_client_id, check_public_client_id, id_token_ttl, name, pkce, public_refresh_token, public_revoke, redirect_uris: redirect_uris.map(({ uri }) => uri), refresh_token_ttl, identity_provider: identity_provider.model, secret, supported_grant_types: grantTypes .filter(({ value }) => value) .map(({ label }) => label), id_token_signature_alg, userinfo_signed_response_alg, token_endpoint_jwt_auth_alg, token_endpoint_auth_methods, jwt_public_key, key_pair_id, key_pair_type, signatures_adapter, response_mode } } } Client.keyPairTypes = keyPairTypes Client.signaturesAdapters = signaturesAdapters Client.api = function () { const accessToken = localStorage.getItem('access_token') const instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/clients`, headers: { 'Authorization': `Bearer ${accessToken}` } }) return addClientErrorInterceptor(instance) } Client.all = function () { return this.api().get('/').then(({ data }) => { return data.data .map((client) => new Client(client)) .map((client) => Object.assign(client, { isPersisted: true })) }) } Client.get = function (id) { return this.api().get(`/${id}`).then(({ data }) => { const client = new Client(data.data) client.isPersisted = true return client }) } Client.idTokenSignatureAlgorithms = [ "EdDSA", "ES256", "ES384", "ES512", "HS256", "HS384", "HS512", "RS256", "RS384", "RS512" ] Client.clientJwtAuthenticationSignatureAlgorithms = [ "ES256", "ES384", "ES512", "HS256", "HS384", "HS512", "RS256", "RS384", "RS512" ] Client.UserinfoResponseSignatureAlgorithms = [ null, "EdDSA", "ES256", "ES384", "ES512", "HS256", "HS384", "HS512", "RS256", "RS384", "RS512" ] Client.tokenEndpointAuthMethods = [ "client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt" ] export default Client ================================================ FILE: apps/boruta_admin/assets/src/models/email-template.model.js ================================================ import axios from 'axios' import { addClientErrorInterceptor } from './utils' const defaults = { id: null, txt_content: null, html_content: null, type: null, errors: null } const assign = { id: function ({ id }) { this.id = id }, type: function ({ type }) { this.type = type }, txt_content: function ({ txt_content }) { this.txt_content = txt_content }, html_content: function ({ html_content }) { this.html_content = html_content }, backend_id: function ({ backend_id }) { this.backend_id = backend_id }, } class EmailTemplate { constructor (params = {}) { Object.assign(this, defaults) Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) } save () { this.errors = null // TODO trigger validate const { type, backend_id: backendId, serialized } = this return this.constructor.api().patch(`/${backendId}/email-templates/${type}`, { template: serialized }) .then(({ data }) => { const params = data.data Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) return this }) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } destroy () { const { type, backend_id: backendId } = this return this.constructor.api().delete(`/${backendId}/email-templates/${type}`) .then(({ data }) => { const params = data.data Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) return this }) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } get serialized () { const { txt_content, html_content } = this return { txt_content, html_content } } static api () { const accessToken = localStorage.getItem('access_token') const instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/backends`, headers: { 'Authorization': `Bearer ${accessToken}` } }) return addClientErrorInterceptor(instance) } static get (backendId, type) { return this.api().get(`/${backendId}/email-templates/${type}`).then(({ data }) => { return new EmailTemplate(data.data) }) } } export default EmailTemplate ================================================ FILE: apps/boruta_admin/assets/src/models/error-template.model.js ================================================ import axios from 'axios' import { addClientErrorInterceptor } from './utils' const templates = [ { type: 400, name: "bad-request", label: "Bad request" }, { type: 403, name: "forbidden", label: "Forbidden" }, { type: 404, name: "not-found", label: "Not found" }, { type: 500, name: "internal-server-error", label: "Internal server error" } ] const defaults = { id: null, name: null, content: null, type: null, errors: null } const assign = { id: function ({ id }) { this.id = id }, name: function ({ name }) { this.name = name }, type: function ({ type }) { this.type = type }, content: function ({ content }) { this.content = content }, label: function ({ label }) { this.label = label } } class ErrorTemplate { constructor (params = {}) { Object.assign(this, defaults) Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) } save () { this.errors = null // TODO trigger validate const { type, serialized } = this return this.constructor.api().patch(`/${type}`, { template: serialized }) .then(({ data }) => { const params = data.data Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) return this }) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } destroy () { const { type } = this return this.constructor.api().delete(`/${type}`) .then(({ data }) => { const params = data.data Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) return this }) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } get serialized () { const { content } = this return { content } } static api () { const accessToken = localStorage.getItem('access_token') const instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/configuration/error-templates`, headers: { 'Authorization': `Bearer ${accessToken}` } }) return addClientErrorInterceptor(instance) } static get (type) { return this.api().get(`/${type}`).then(({ data }) => { return new ErrorTemplate(data.data) }) } static all () { return templates.map(data => new ErrorTemplate(data)) } } export default ErrorTemplate ================================================ FILE: apps/boruta_admin/assets/src/models/identity-provider.model.js ================================================ import axios from 'axios' import { addClientErrorInterceptor } from './utils' import Backend from './backend.model' const defaults = { name: null, type: 'internal', errors: null, backend: new Backend() } const assign = { id: function ({ id }) { this.id = id }, name: function ({ name }) { this.name = name }, type: function ({ type }) { this.type = type }, backend: function ({ backend }) { this.backend = new Backend(backend) }, backend_id: function ({ backend_id }) { this.backend_id = backend_id }, check_password: function ({ check_password }) { this.check_password = check_password }, choose_session: function ({ choose_session }) { this.choose_session = choose_session }, totpable: function ({ totpable }) { this.totpable = totpable }, enforce_totp: function ({ enforce_totp }) { this.enforce_totp = enforce_totp }, webauthnable: function ({ webauthnable }) { this.webauthnable = webauthnable }, enforce_webauthn: function ({ enforce_webauthn }) { this.enforce_webauthn = enforce_webauthn }, registrable: function ({ registrable }) { this.registrable = registrable }, user_editable: function ({ user_editable }) { this.user_editable = user_editable }, consentable: function ({ consentable }) { this.consentable = consentable }, confirmable: function ({ confirmable }) { this.confirmable = confirmable } } class IdentityProvider { constructor (params = {}) { Object.assign(this, defaults) Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) } get isPersisted () { return this.id } save () { this.errors = null // TODO trigger validate let response const { id, serialized } = this if (this.isPersisted) { response = this.constructor.api().patch(`/${id}`, { identity_provider: serialized }) } else { response = this.constructor.api().post('/', { identity_provider: serialized }) } return response .then(({ data }) => { const params = data.data Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) return this }) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } destroy () { return this.constructor.api().delete(`/${this.id}`) .catch((error) => { const { code, message, errors } = error.response.data this.errors = errors throw { code, message, errors } }) } get serialized () { const { id, name, backend_id, check_password, choose_session, totpable, enforce_totp, webauthnable, enforce_webauthn, registrable, user_editable, consentable, confirmable } = this return { id, name, backend_id, check_password, choose_session, user_editable, totpable, enforce_totp, webauthnable, enforce_webauthn, registrable, consentable, confirmable } } static api () { const accessToken = localStorage.getItem('access_token') const instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/identity-providers`, headers: { 'Authorization': `Bearer ${accessToken}` } }) return addClientErrorInterceptor(instance) } static all () { return this.api().get('/').then(({ data }) => { return data.data.map((identityProvider) => new IdentityProvider(identityProvider)) }) } static get (id) { return this.api().get(`/${id}`).then(({ data }) => { return new IdentityProvider(data.data) }) } } export default IdentityProvider ================================================ FILE: apps/boruta_admin/assets/src/models/key-pair.model.js ================================================ import axios from 'axios' import { addClientErrorInterceptor } from './utils' const defaults = { } const assign = { id: function ({ id }) { this.id = id }, public_key: function ({ public_key }) { this.public_key = public_key }, is_default: function ({ is_default }) { this.is_default = is_default } } class KeyPair { constructor (params = {}) { Object.assign(this, defaults) Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) } get persisted () { return !!this.id } rotate () { return this.constructor.api().post(`/${this.id}/rotate`) .then(({ data }) => Object.assign(this, data.data)) .catch((error) => { const { code, message, errors } = error.response.data this.errors = errors throw { code, message, errors } }) } save () { const { id, serialized } = this let response this.errors = null if (id) { response = this.constructor.api().patch(`/${id}`, { key_pair: serialized }) .then(({ data }) => Object.assign(this, data.data)) } else { response = this.constructor.api().post('/', { key_pair: serialized }) .then(({ data }) => Object.assign(this, data.data)) } return response.catch((error) => { const { code, message, errors } = error.response.data this.errors = errors throw { code, message, errors } }) } destroy () { return this.constructor.api().delete(`/${this.id}`) .catch((error) => { const { code, message, errors } = error.response.data this.errors = errors throw { code, message, errors } }) } get serialized () { const { id, is_default } = this return { id, is_default } } } KeyPair.api = function () { const accessToken = localStorage.getItem('access_token') const instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/key-pairs`, headers: { 'Authorization': `Bearer ${accessToken}` } }) return addClientErrorInterceptor(instance) } KeyPair.all = function () { return this.api().get('/').then(({ data }) => { return data.data.map((keyPair) => new KeyPair(keyPair)) }) } KeyPair.get = function (id) { return this.api().get(`/${id}`).then(({ data }) => { return new KeyPair(data.data) }) } export default KeyPair ================================================ FILE: apps/boruta_admin/assets/src/models/organization.model.js ================================================ import axios from 'axios' import Scope from './scope.model' import Role from './role.model' import Backend from './backend.model' import { addClientErrorInterceptor } from './utils' const defaults = { errors: null } const assign = { id: function ({ id }) { this.id = id }, name: function ({ name }) { this.name = name }, label: function ({ label }) { this.label = label } } class Organization { constructor (params = {}) { Object.assign(this, defaults) Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) } get isPersisted() { return this.id } async save () { this.errors = null const { id, serialized } = this let response if (this.isPersisted) { response = this.constructor.api().patch(`/${id}`, { organization: serialized }) } else { response = this.constructor.api().post('/', { organization: serialized }) } return response .then(({ data }) => { const params = data.data Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) return this }) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } destroy () { return this.constructor.api().delete(`/${this.id}`) } get serialized () { const { id, name, label } = this return { id, name, label } } } Organization.api = function () { const accessToken = localStorage.getItem('access_token') const instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/organizations`, headers: { 'Authorization': `Bearer ${accessToken}` } }) return addClientErrorInterceptor(instance) } Organization.all = function ({ query, pageNumber } = {}) { const searchParams = new URLSearchParams() pageNumber && searchParams.append('page', pageNumber) query && searchParams.append('q', query) return this.api().get(`/?${searchParams.toString()}`).then(({ data: { data, total_entries: totalEntries, page_number: currentPage, total_pages: totalPages, } }) => { return { data: data.map((user) => new Organization(user)), currentPage, totalPages, totalEntries } }) } Organization.get = function (id) { return this.api().get(`/${id}`).then(({ data }) => { return new Organization(data.data) }) } export default Organization ================================================ FILE: apps/boruta_admin/assets/src/models/request-log-stats.model.js ================================================ import axios from 'axios' import moment from 'moment' import { addClientErrorInterceptor } from './utils' const defaults = { errors: null } const assign = { time_scale_unit: function ({ time_scale_unit }) { this.time_scale_unit = time_scale_unit }, overflow: function ({ overflow }) { this.overflow = overflow }, log_lines: function ({ log_lines }) { this.log_lines = log_lines }, log_count: function ({ log_count }) { this.log_count = log_count }, status_codes: function ({ status_codes }) { this.status_codes = status_codes }, request_counts: function ({ request_counts }) { this.request_counts = request_counts }, request_times: function ({ request_times }) { this.request_times = request_times }, labels: function ({ labels }) { this.labels = labels }, } class LogStats { constructor (params = {}) { Object.assign(this, defaults) Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) } } LogStats.api = function () { const accessToken = localStorage.getItem('access_token') const instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/logs`, headers: { 'Authorization': `Bearer ${accessToken}` } }) return addClientErrorInterceptor(instance) } LogStats.all = function ({ startAt, endAt, application, label }) { const params = new URLSearchParams() params.append('start_at', moment.utc(startAt).toISOString()) params.append('end_at', moment.utc(endAt).toISOString()) params.append('application', application) label && params.append('query[label]', label) params.append('type', 'request') return this.api().get(`?${params.toString()}`).then(({ data }) => { return new LogStats(data) }).catch(error => { throw error.response.data }) } export default LogStats ================================================ FILE: apps/boruta_admin/assets/src/models/role.model.js ================================================ import axios from 'axios' import Scope from './scope.model' import { addClientErrorInterceptor } from './utils' const defaults = { scopes: [] } const assign = { id: function ({ id }) { this.id = id }, name: function ({ name }) { this.name = name }, scopes: function ({ scopes }) { this.scopes = scopes.map(scope => ({ model: new Scope(scope) })) } } class Role { constructor (params = {}) { Object.assign(this, defaults) Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) } save () { this.errors = null // TODO trigger validate let response const { id, serialized } = this if (id) { response = this.constructor.api().patch(`/${id}`, { role: serialized }) } else { response = this.constructor.api().post('/', { role: serialized }) } return response .then(({ data }) => { const params = data.data Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) return this }) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } destroy () { return this.constructor.api().delete(`/${this.id}`) .catch((error) => { const { code, message, errors } = error.response.data this.errors = errors throw { code, message, errors } }) } get serialized () { const { id, name, scopes } = this console.log(scopes) return { id, name, scopes: scopes.map(({ model: { id, name } }) => ({ id, name })) } } } Role.api = function () { const accessToken = localStorage.getItem('access_token') const instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/roles`, headers: { 'Authorization': `Bearer ${accessToken}` } }) return addClientErrorInterceptor(instance) } Role.all = function () { return this.api().get('/').then(({ data }) => { const result = data.data return result.map((role) => new Role(role)) }) } Role.get = function (id) { return this.api().get(`/${id}`).then(({ data }) => { return new Role(data.data) }) } export default Role ================================================ FILE: apps/boruta_admin/assets/src/models/scope.model.js ================================================ import axios from 'axios' import { addClientErrorInterceptor } from './utils' const defaults = { name: '', edit: false, errors: null } const assign = { id: function ({ id }) { this.id = id }, name: function ({ name }) { this.name = name }, label: function ({ label }) { this.label = label }, edit: function ({ edit }) { this.edit = edit }, public: function ({ public: e }) { this.public = e } } class Scope { constructor (params = {}) { Object.assign(this, defaults) Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) } get persisted () { return !!this.id } reset () { return this.constructor.api().get(`/${this.id}`).then(({ data }) => { Object.assign(this, defaults) return Object.assign(this, data.data) }) } save () { const { id, serialized } = this let response this.errors = null if (id) { response = this.constructor.api().patch(`/${id}`, { scope: serialized }) .then(({ data }) => Object.assign(this, data.data)) } else { response = this.constructor.api().post('/', { scope: serialized }) .then(({ data }) => Object.assign(this, data.data)) } return response.catch((error) => { const { code, message, errors } = error.response.data this.errors = errors throw { code, message, errors } }) } destroy () { return this.constructor.api().delete(`/${this.id}`) .catch((error) => { const { code, message, errors } = error.response.data this.errors = errors throw { code, message, errors } }) } get serialized () { const { id, label, name, public: p } = this return { id, label, name, public: p } } } Scope.api = function () { const accessToken = localStorage.getItem('access_token') const instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/scopes`, headers: { 'Authorization': `Bearer ${accessToken}` } }) return addClientErrorInterceptor(instance) } Scope.all = function () { return this.api().get('/').then(({ data }) => { return data.data.map((scope) => new Scope(scope)) }) } Scope.get = function (id) { return this.api().get(`/${id}`).then(({ data }) => { return new Scope(data.data) }) } export default Scope ================================================ FILE: apps/boruta_admin/assets/src/models/template.model.js ================================================ import axios from 'axios' import { addClientErrorInterceptor } from './utils' const defaults = { id: null, content: null, type: null, errors: null } const assign = { id: function ({ id }) { this.id = id }, type: function ({ type }) { this.type = type }, content: function ({ content }) { this.content = content }, identity_provider_id: function ({ identity_provider_id }) { this.identity_provider_id = identity_provider_id }, } class Template { constructor (params = {}) { Object.assign(this, defaults) Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) } save () { this.errors = null // TODO trigger validate const { type, identity_provider_id: identityProviderId, serialized } = this return this.constructor.api().patch(`/${identityProviderId}/templates/${type}`, { template: serialized }) .then(({ data }) => { const params = data.data Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) return this }) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } destroy () { const { type, identity_provider_id: identityProviderId } = this return this.constructor.api().delete(`/${identityProviderId}/templates/${type}`) .then(({ data }) => { const params = data.data Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) return this }) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } get serialized () { const { id, content } = this return { id, content } } static api () { const accessToken = localStorage.getItem('access_token') const instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/identity-providers`, headers: { 'Authorization': `Bearer ${accessToken}` } }) return addClientErrorInterceptor(instance) } static get (identityProviderId, type) { return this.api().get(`/${identityProviderId}/templates/${type}`).then(({ data }) => { return new Template(data.data) }) } } export default Template ================================================ FILE: apps/boruta_admin/assets/src/models/upstream.model.js ================================================ import axios from 'axios' import Scope from './scope.model' import { addClientErrorInterceptor } from './utils' const defaults = { errors: null, node_name: 'global', uris: [], required_scopes: [], pool_size: 10, pool_count: 1, max_idle_time: 10 } const assign = { id: function ({ id }) { this.id = id }, node_name: function ({ node_name }) { this.node_name = node_name }, scheme: function ({ scheme }) { this.scheme = scheme }, host: function ({ host }) { this.host = host }, port: function ({ port }) { this.port = port }, pool_size: function ({ pool_size }) { this.pool_size = pool_size }, pool_count: function ({ pool_count }) { this.pool_count = pool_count }, max_idle_time: function ({ max_idle_time }) { this.max_idle_time = max_idle_time }, strip_uri: function ({ strip_uri }) { this.strip_uri = strip_uri }, forwarded_token_signature_alg: function ({ forwarded_token_signature_alg }) { this.forwarded_token_signature_alg = forwarded_token_signature_alg }, forwarded_token_secret: function ({ forwarded_token_secret }) { this.forwarded_token_secret = forwarded_token_secret }, forwarded_token_public_key: function ({ forwarded_token_public_key }) { this.forwarded_token_public_key = forwarded_token_public_key }, forwarded_token_private_key: function ({ forwarded_token_private_key }) { this.forwarded_token_private_key = forwarded_token_private_key }, uris: function ({ uris }) { this.uris = uris.map((uri) => ({ uri })) }, authorize: function ({ authorize }) { this.authorize = authorize }, required_scopes: function ({ required_scopes }) { this.required_scopes = Object.keys(required_scopes).flatMap((method) => { return required_scopes[method].map(name => ({ model: new Scope({ name }), method: method })) }, {}) }, error_content_type: function ({ error_content_type }) { this.error_content_type = error_content_type }, forbidden_response: function ({ forbidden_response }) { this.forbidden_response = forbidden_response }, unauthorized_response: function ({ unauthorized_response }) { this.unauthorized_response = unauthorized_response } } class Upstream { constructor (params = {}) { Object.assign(this, defaults) Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) } get baseUrl () { const { scheme, host, port } = this return `${scheme}://${host}:${port}` } save () { this.errors = null // TODO trigger validate let response const { id, serialized } = this if (id) { response = this.constructor.api().patch(`/${id}`, { upstream: serialized }) } else { response = this.constructor.api().post('/', { upstream: serialized }) } return response .then(({ data }) => { const params = data.data Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) return this }) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } destroy () { return this.constructor.api().delete(`/${this.id}`) .catch((error) => { const { code, message, errors } = error.response.data this.errors = errors throw { code, message, errors } }) } get serialized () { const { id, node_name, scheme, host, port, pool_size, pool_count, max_idle_time, uris, strip_uri, authorize, required_scopes, error_content_type, forbidden_response, unauthorized_response, forwarded_token_signature_alg, forwarded_token_secret, forwarded_token_private_key, forwarded_token_public_key } = this return { id, node_name, scheme, host, port, pool_size, pool_count, max_idle_time, uris: uris.map(({ uri }) => uri), required_scopes: required_scopes.reduce((acc, { model: { name }, method }) => { acc[method] = acc[method] || [] acc[method].push(name) return acc }, {}), strip_uri, authorize, error_content_type, forbidden_response, unauthorized_response, forwarded_token_signature_alg, forwarded_token_secret, forwarded_token_private_key, forwarded_token_public_key } } } Upstream.api = function () { const accessToken = localStorage.getItem('access_token') const instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/upstreams`, headers: { 'Authorization': `Bearer ${accessToken}` } }) return addClientErrorInterceptor(instance) } Upstream.nodeList = function () { return this.api().get('/nodes').then(({ data }) => { return data.data }) } Upstream.all = function () { return this.api().get('/').then(({ data }) => { const result = data.data Object.keys(result).forEach((nodeName) => { result[nodeName] = result[nodeName].map((upstream) => new Upstream(upstream)) }) return result }) } Upstream.get = function (id) { return this.api().get(`/${id}`).then(({ data }) => { return new Upstream(data.data) }) } Upstream.forwardedTokenSignatureAlgorithms = [ "HS256", "HS384", "HS512", "RS256", "RS384", "RS512" ] export default Upstream ================================================ FILE: apps/boruta_admin/assets/src/models/user.model.js ================================================ import axios from 'axios' import Scope from './scope.model' import Role from './role.model' import Organization from './organization.model' import Backend from './backend.model' import { addClientErrorInterceptor } from './utils' const defaults = { errors: null, authorize_scopes: false, authorized_scopes: [], roles: [], organizations: [], backend_id: '', backend: new Backend(), metadata: {}, federated_metadata: {} } const assign = { id: function ({ id }) { this.id = id }, uid: function ({ uid }) { this.uid = uid }, backend: function ({ backend }) { this.backend = backend }, email: function ({ email }) { this.email = email }, totp_registered_at: function ({ totp_registered_at }) { this.totp_registered_at = totp_registered_at }, federated_metadata: function ({ federated_metadata }) { this.federated_metadata = federated_metadata }, metadata: function ({ metadata: rawMetadata }) { const metadata = {} for (const key in rawMetadata) { metadata[key] = { displayStatus: rawMetadata[key].display?.includes('status'), ...rawMetadata[key] } } this.metadata = metadata }, group: function ({ group }) { this.group = group }, authorized_scopes: function ({ authorized_scopes }) { this.authorized_scopes = authorized_scopes.map((scope) => { return { model: new Scope(scope) } }) }, roles: function ({ roles }) { this.roles = roles.map((role) => { return { model: new Role(role) } }) }, organizations: function ({ organizations }) { this.organizations = organizations.map((organization) => { return { model: new Organization(organization) } }) } } class User { constructor (params = {}) { Object.assign(this, defaults) Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) } get isPersisted() { return this.id } // TODO factorize with Client#validate validate () { return new Promise((resolve, reject) => { this.authorized_scopes.forEach(({ model: scope }) => { if (!scope.persisted) { const errors = { authorized_scopes: [ 'cannot be empty' ] } this.errors = errors return reject(errors) } if (this.authorized_scopes.filter(({ model: e }) => e.id === scope.id).length > 1) { const errors = { authorized_scopes: [ 'must be unique' ] } this.errors = errors return reject(errors) } }) resolve() }) } async save () { this.errors = null await this.validate() const { id, backend_id, serialized } = this let response if (this.isPersisted) { response = this.constructor.api().patch(`/${id}`, { user: serialized }) } else { response = this.constructor.api().post('/', { backend_id, user: serialized }) } return response .then(({ data }) => { const params = data.data Object.keys(params).forEach((key) => { this[key] = params[key] assign[key].bind(this)(params) }) return this }) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } destroy () { return this.constructor.api().delete(`/${this.id}`) } get serialized () { const { id, email, password, metadata: rawMetadata, group, authorized_scopes, roles, organizations } = this const metadata = {} for (const key in rawMetadata) { if (rawMetadata[key]?.value) { metadata[key] = { display: rawMetadata[key].displayStatus ? ['status'] : [], value: rawMetadata[key].value, status: rawMetadata[key].status } } } return { id, email, password, metadata, group, authorized_scopes: authorized_scopes.map(({ model }) => model.serialized), roles: roles.map(({ model }) => model.serialized), organizations: organizations.map(({ model }) => model.serialized) } } } User.api = function () { const accessToken = localStorage.getItem('access_token') const instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/users`, headers: { 'Authorization': `Bearer ${accessToken}` } }) return addClientErrorInterceptor(instance) } User.all = function ({ query, pageNumber }) { const searchParams = new URLSearchParams() pageNumber && searchParams.append('page', pageNumber) query && searchParams.append('q', query) return this.api().get(`/?${searchParams.toString()}`).then(({ data: { data, total_entries: totalEntries, page_number: currentPage, total_pages: totalPages, } }) => { return { data: data.map((user) => new User(user)), currentPage, totalPages, totalEntries } }) } User.upload = function ({ backendId, file, options }) { const formData = new FormData() formData.append("backend_id", backendId) formData.append("file", file) if (options.usernameHeader && options.usernameHeader !== '') formData.append("options[username_header]", options.usernameHeader) if (options.passwordHeader && options.passwordHeader !== '') formData.append("options[password_header]", options.passwordHeader) if (options.hashPassword && options.hashPassword !== '') formData.append("options[hash_password]", options.hashPassword) options.metadataHeaders.forEach(header => { formData.append("options[metadata_headers][]", `${header.origin}>${header.target}`) }) return this.api().post('/', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) .then(({ data }) => data) .catch((error) => { const { errors } = error.response.data this.errors = errors throw errors }) } User.get = function (id) { return this.api().get(`/${id}`).then(({ data }) => { return new User(data.data) }) } User.default = defaults export default User ================================================ FILE: apps/boruta_admin/assets/src/models/utils.js ================================================ import axios from 'axios' import router from '../router' import oauth from '../services/oauth.service' export function addClientErrorInterceptor(instance) { instance.interceptors.response.use(function (response) { return response; }, function (error) { if (error.response?.status === 401) { return new Promise((resolve, reject) => { oauth.silentRefresh() function retry() { window.removeEventListener('logged_in', retry) const accessToken = localStorage.getItem('access_token') const newRequest = Object.assign(error.config, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` } }) axios.request(newRequest).then(resolve).catch(reject) } window.addEventListener('logged_in', retry) setTimeout(() => { oauth.logout() reject() }, 2000) }) } return Promise.reject(error) } ) return instance } ================================================ FILE: apps/boruta_admin/assets/src/router.js ================================================ import { createWebHistory, createRouter } from "vue-router"; import oauth from "./services/oauth.service"; import Main from "./views/Layouts/Main.vue"; import Home from "./views/Home.vue"; import OauthCallback from "./views/OauthCallback.vue"; import NotFound from "./views/NotFound.vue"; import BadRequest from "./views/BadRequest.vue"; import Clients from "./views/Clients.vue"; import ClientList from "./views/Clients/ClientList.vue"; import KeyPairList from "./views/Clients/KeyPairList.vue"; import NewClient from "./views/Clients/NewClient.vue"; import Client from "./views/Clients/Client.vue"; import EditClient from "./views/Clients/EditClient.vue"; import Upstreams from "./views/Upstreams.vue"; import UpstreamList from "./views/Upstreams/UpstreamList.vue"; import Upstream from "./views/Upstreams/Upstream.vue"; import NewUpstream from "./views/Upstreams/NewUpstream.vue"; import EditUpstream from "./views/Upstreams/EditUpstream.vue"; import IdentityProviders from "./views/IdentityProviders.vue"; import IdentityProviderList from "./views/IdentityProviders/IdentityProviderList.vue"; import IdentityProvider from "./views/IdentityProviders/IdentityProvider.vue"; import EditIdentityProvider from "./views/IdentityProviders/EditIdentityProvider.vue"; import EditLayoutTemplate from "./views/IdentityProviders/EditLayoutTemplate.vue"; import EditSessionTemplate from "./views/IdentityProviders/EditSessionTemplate.vue"; import EditNewChooseSessionTemplate from "./views/IdentityProviders/EditNewChooseSessionTemplate.vue"; import EditTotpRegistrationTemplate from "./views/IdentityProviders/EditTotpRegistrationTemplate.vue"; import EditTotpAuthenticationTemplate from "./views/IdentityProviders/EditTotpAuthenticationTemplate.vue"; import EditWebauthnAuthenticationTemplate from "./views/IdentityProviders/EditWebauthnAuthenticationTemplate.vue"; import EditWebauthnRegistrationTemplate from "./views/IdentityProviders/EditWebauthnRegistrationTemplate.vue"; import EditRegistrationTemplate from "./views/IdentityProviders/EditRegistrationTemplate.vue"; import EditNewConsentTemplate from "./views/IdentityProviders/EditNewConsentTemplate.vue"; import EditNewConfirmationTemplate from "./views/IdentityProviders/EditNewConfirmationTemplate.vue"; import EditNewResetPasswordTemplate from "./views/IdentityProviders/EditNewResetPasswordTemplate.vue"; import EditEditResetPasswordTemplate from "./views/IdentityProviders/EditEditResetPasswordTemplate.vue"; import EditEditUserTemplate from "./views/IdentityProviders/EditEditUserTemplate.vue"; import EditCredentialOfferTemplate from "./views/IdentityProviders/EditCredentialOfferTemplate.vue"; import EditCrossDevicePresentationTemplate from "./views/IdentityProviders/EditCrossDevicePresentationTemplate.vue"; import NewIdentityProvider from "./views/IdentityProviders/NewIdentityProvider.vue"; import Users from "./views/IdentityProviders/Users.vue"; import UserList from "./views/IdentityProviders/UserList.vue"; import UserImport from "./views/IdentityProviders/UserImport.vue"; import NewUser from "./views/IdentityProviders/NewUser.vue"; import EditUser from "./views/IdentityProviders/EditUser.vue"; import Organizations from "./views/IdentityProviders/Organizations.vue"; import OrganizationList from "./views/IdentityProviders/OrganizationList.vue"; import NewOrganization from "./views/IdentityProviders/NewOrganization.vue"; import EditOrganization from "./views/IdentityProviders/EditOrganization.vue"; import Backends from "./views/IdentityProviders/Backends.vue"; import Backend from "./views/IdentityProviders/Backends/Backend.vue"; import BackendList from "./views/IdentityProviders/BackendList.vue"; import NewBackend from "./views/IdentityProviders/NewBackend.vue"; import EditBackend from "./views/IdentityProviders/EditBackend.vue"; import EditConfirmationInstructionsEmailTemplate from "./views/IdentityProviders/Backends/EditConfirmationInstructionsEmailTemplate.vue"; import EditResetPasswordInstructionsEmailTemplate from "./views/IdentityProviders/Backends/EditResetPasswordInstructionsEmailTemplate.vue"; import EditTxCodeEmailTemplate from "./views/IdentityProviders/Backends/EditTxCodeEmailTemplate.vue"; import Scopes from "./views/Scopes.vue"; import ScopeList from "./views/Scopes/ScopeList.vue"; import Roles from "./views/Roles.vue"; import RoleList from "./views/Roles/RoleList.vue"; import Role from "./views/Roles/Role.vue"; import NewRole from "./views/Roles/NewRole.vue"; import EditRole from "./views/Roles/EditRole.vue"; import Configuration from "./views/Configuration.vue"; import ConfigurationFileUpload from "./views/Configuration/ConfigurationFileUpload.vue"; import ErrorTemplateList from "./views/Configuration/ErrorTemplateList.vue"; import EditBadRequestTemplate from "./views/Configuration/EditBadRequestTemplate.vue"; import EditNotFoundTemplate from "./views/Configuration/EditNotFoundTemplate.vue"; import EditForbiddenTemplate from "./views/Configuration/EditForbiddenTemplate.vue"; import EditInternalServerErrorTemplate from "./views/Configuration/EditInternalServerErrorTemplate.vue"; import Dashboard from "./views/Dashboard.vue"; import Requests from "./views/Dashboard/Requests.vue"; import BusinessEvents from "./views/Dashboard/BusinessEvents.vue"; const router = createRouter({ history: createWebHistory(), linkActiveClass: "active", routes: [ { path: "/", component: Main, name: "root", redirect: "/", children: [ { path: "", name: "home", component: Home, }, { path: "not-found", name: "not-found", component: NotFound, }, { path: "bad-request", name: "bad-request", component: BadRequest, }, { path: "/oauth-callback", name: "oauth-callback", component: OauthCallback, }, { path: "/dashboard", name: "dashboard", component: Dashboard, redirect: "/dashboard/requests", children: [ { path: "requests", name: "request-logs", component: Requests, }, { path: "business-events", name: "business-event-logs", component: BusinessEvents, }, ], }, { path: "/identity-providers", component: IdentityProviders, name: "identity-providers", redirect: "/identity-providers/", children: [ { path: "", name: "identity-provider-list", component: IdentityProviderList, }, { path: "new", name: "new-identity-provider", component: NewIdentityProvider, }, { path: "/identity-providers/:identityProviderId", name: "identity-provider", component: IdentityProvider, redirect: (to) => ({ name: "edit-identity-provider", params: { identityProviderId: to.params.identityProviderId }, }), children: [ { path: "edit", name: "edit-identity-provider", component: EditIdentityProvider, }, { path: "edit/choose-session-template", name: "edit-choose-session-template", component: EditNewChooseSessionTemplate, }, { path: "edit/layout-template", name: "edit-layout-template", component: EditLayoutTemplate, }, { path: "edit/session-template", name: "edit-session-template", component: EditSessionTemplate, }, { path: "edit/totp-registration-template", name: "edit-totp-registration-template", component: EditTotpRegistrationTemplate, }, { path: "edit/totp-authentication-template", name: "edit-totp-authentication-template", component: EditTotpAuthenticationTemplate, }, { path: "edit/webauthn-registration-template", name: "edit-webauthn-registration-template", component: EditWebauthnRegistrationTemplate, }, { path: "edit/webauthn-authentication-template", name: "edit-webauthn-authentication-template", component: EditWebauthnAuthenticationTemplate, }, { path: "edit/registration-template", name: "edit-registration-template", component: EditRegistrationTemplate, }, { path: "edit/edit-user-template", name: "edit-edit-user-template", component: EditEditUserTemplate, }, { path: "edit/send-reset-password-instructions-template", name: "edit-new-reset-password-template", component: EditNewResetPasswordTemplate, }, { path: "edit/reset-password-template", name: "edit-edit-reset-password-template", component: EditEditResetPasswordTemplate, }, { path: "edit/consent-template", name: "edit-new-consent-template", component: EditNewConsentTemplate, }, { path: "edit/send-confirmation-instructions-template", name: "edit-new-confirmation-template", component: EditNewConfirmationTemplate, }, { path: "edit/credential-offer-template", name: "edit-credential-offer-template", component: EditCredentialOfferTemplate, }, { path: "edit/cross-device-presentation-template", name: "edit-cross-device-presentation-template", component: EditCrossDevicePresentationTemplate, }, ], }, { path: "backends", name: "backends", component: Backends, redirect: "/identity-providers/backends/", children: [ { path: "", name: "backend-list", component: BackendList, }, { path: "/backends/new", name: "new-backend", component: NewBackend, }, { path: "/backends/:backendId", name: "backend", component: Backend, redirect: (to) => ({ name: "edit-backend", params: { backendId: to.params.backendId }, }), children: [ { path: "edit", name: "edit-backend", component: EditBackend, }, { path: "edit/confirmation-instructions-email-template", name: "edit-confirmation-instructions-email-template", component: EditConfirmationInstructionsEmailTemplate, }, { path: "edit/reset-password-instructions-email-template", name: "edit-reset-password-instructions-email-template", component: EditResetPasswordInstructionsEmailTemplate, }, { path: "edit/tx-code-email-template", name: "edit-tx-code-email-template", component: EditTxCodeEmailTemplate, }, ], }, ], }, { path: "users", name: "users", component: Users, redirect: "/identity-providers/users/", children: [ { path: "", name: "user-list", component: UserList, }, { path: "import", name: "user-import", component: UserImport, }, { path: "/users/new", name: "new-user", component: NewUser, }, { path: "/users/:userId/edit", name: "edit-user", component: EditUser, }, ], }, { path: "organizations", name: "organizations", component: Organizations, redirect: "/identity-providers/organizations/", children: [ { path: "", name: "organization-list", component: OrganizationList, }, { path: "/organizations/new", name: "new-organization", component: NewOrganization, }, { path: "/organizations/:organizationId/edit", name: "edit-organization", component: EditOrganization, }, ], }, ], }, { path: "/clients", component: Clients, name: "clients", redirect: "/clients/", children: [ { path: "", name: "client-list", component: ClientList, }, { path: "key-pairs", name: "key-pair-list", component: KeyPairList, }, { path: "/clients/new", name: "new-client", component: NewClient, }, { path: "/clients/:clientId", name: "client", component: Client, redirect: (to) => ({ name: "edit-client", params: { clientId: to.params.clientId }, }), children: [ { path: "edit", name: "edit-client", component: EditClient, }, ], }, ], }, { path: "/upstreams", component: Upstreams, name: "upstreams", redirect: "/upstreams/", children: [ { path: "", name: "upstream-list", component: UpstreamList, }, { path: "/upstreams/new", name: "new-upstream", component: NewUpstream, }, { path: "/upstreams/:upstreamId", name: "upstream", component: Upstream, redirect: (to) => ({ name: "edit-upstream", params: { upstreamId: to.params.upstreamId }, }), children: [ { path: "edit", name: "edit-upstream", component: EditUpstream, }, ], }, ], }, { path: "/scopes", component: Scopes, name: "scopes", redirect: "/scopes/", children: [ { path: "", name: "scope-list", component: ScopeList, }, { path: "/roles", component: Roles, name: "roles", redirect: "/roles/", children: [ { path: "", name: "role-list", component: RoleList, }, { path: "/roles/new", name: "new-role", component: NewRole, }, { path: "/roles/:roleId", name: "role", component: Role, redirect: (to) => ({ name: "edit-role", params: { roleId: to.params.roleId }, }), children: [ { path: "edit", name: "edit-role", component: EditRole, }, ], }, ], }, ], }, { path: "/configuration", component: Configuration, name: "configuration", redirect: "/configuration/error-template-list", children: [ { path: "", name: "", component: Configuration, }, { path: "configuration-file-upload/:type(example-configuration-file)?", name: "configuration-file-upload", component: ConfigurationFileUpload, }, { path: "error-template-list", name: "error-template-list", component: ErrorTemplateList, }, { path: "edit-bad-request-template", name: "edit-bad-request-template", component: EditBadRequestTemplate, }, { path: "edit-forbidden-template", name: "edit-forbidden-template", component: EditForbiddenTemplate, }, { path: "edit-not-found-template", name: "edit-not-found-template", component: EditNotFoundTemplate, }, { path: "edit-internal-server-error-template", name: "edit-internal-server-error-template", component: EditInternalServerErrorTemplate, }, ], }, { path: "/:pathMatch(.*)*", name: "not-found", component: NotFound, }, ], }, ], }); router.beforeEach((to, _from, next) => { if (to.name === "oauth-callback") return next(); oauth.storeLocationName(to); if (!oauth.isAuthenticated) { // TODO find a way to remove event listener once triggered const continueNavigation = () => { router.push(oauth.storedLocation); window.removeEventListener("logged_in", continueNavigation); }; window.addEventListener("logged_in", continueNavigation); oauth.silentRefresh(); return next(new Error('Not logged in')); } else { return next(); } }); export default router; ================================================ FILE: apps/boruta_admin/assets/src/services/configuration-file.service.js ================================================ import axios from 'axios' import { addClientErrorInterceptor } from "../models/utils"; export default class ConfigurationFile { static get api () { const accessToken = localStorage.getItem("access_token"); let instance = axios.create({ baseURL: `${window.env.BORUTA_ADMIN_BASE_URL}/api/configuration`, headers: { Authorization: `Bearer ${accessToken}` }, }); return addClientErrorInterceptor(instance); } static upload (file) { const formData = new FormData() formData.append('file', file) return this.api.post('/upload-configuration-file', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).catch(({ response }) => { if (response.status == 400) { throw { errors: { file: ['is invalid'] } } } else { throw error } }).then(({ data }) => data) } static get (type = '') { return this.api.get(`/${type}`).then(({ data }) => { const configuration = data.data.find(({ name }) => name == 'configuration_file') return configuration && configuration.value || this.baseConfiguration }).catch(() => '') } static get baseConfiguration () { return ` --- version: "1.0" configuration: client: identity_provider: backend: role: scope: gateway: microgateway: error_template: ` } } ================================================ FILE: apps/boruta_admin/assets/src/services/oauth.service.js ================================================ import decode from 'jwt-decode' import { BorutaOauth } from 'boruta-client' class Oauth { constructor () { const oauth = new BorutaOauth({ window, host: window.env.BORUTA_ADMIN_OAUTH_BASE_URL, authorizePath: '/oauth/authorize', revokePath: '/oauth/revoke' }) this.implicitClient = new oauth.Implicit({ clientId: window.env.BORUTA_ADMIN_OAUTH_CLIENT_ID, redirectUri: `${window.env.BORUTA_ADMIN_BASE_URL}/oauth-callback`, scope: 'openid email profile scopes:manage:all clients:manage:all users:manage:all upstreams:manage:all identity-providers:manage:all configuration:manage:all logs:read:all', silentRefresh: true, silentRefreshCallback: this.authenticate.bind(this), responseType: 'id_token token' }) this.revokeClient = new oauth.Revoke({ clientId: window.env.BORUTA_ADMIN_OAUTH_CLIENT_ID }) } get idToken() { return localStorage.getItem('id_token') } get currentUser() { try { const { email } = decode(this.idToken) return { email } } catch { return {} } } authenticate (response) { if (window.frameElement) return if (response.error) { this.login() } const { access_token, id_token, expires_in } = response const expires_at = new Date().getTime() + expires_in * 1000 localStorage.setItem('access_token', access_token) localStorage.setItem('id_token', id_token) localStorage.setItem('token_expires_at', expires_at) setTimeout(() => { const loggedIn = new Event('logged_in') window.dispatchEvent(loggedIn) }, 500) } login () { window.location = this.implicitClient.loginUrl } silentRefresh () { this.implicitClient.silentRefresh() } async callback () { return this.implicitClient.callback().then(response => { this.authenticate(response) }).catch((error) => { console.log(error) this.login() throw error }) } logout () { return this.revokeClient.revoke(this.accessToken).then(() => { localStorage.removeItem('access_token') localStorage.removeItem('token_expires_at') }) } storeLocationName ({ name, params, query }) { localStorage.setItem('stored_location_name', name) localStorage.setItem('stored_location_params', JSON.stringify(params)) localStorage.setItem('stored_location_query', JSON.stringify(query)) } get storedLocation () { const name = localStorage.getItem('stored_location_name') || 'home' const params = JSON.parse(localStorage.getItem('stored_location_params') || '{}') const query = JSON.parse(localStorage.getItem('stored_location_query') || '{}') return { name, params, query } } get accessToken () { return localStorage.getItem('access_token') } get isAuthenticated () { const accessToken = localStorage.getItem('access_token') const expiresAt = localStorage.getItem('token_expires_at') return accessToken && parseInt(expiresAt) > new Date().getTime() } get expiresIn () { const expiresAt = localStorage.getItem('token_expires_at') return parseInt(expiresAt) - new Date().getTime() } } export default new Oauth() ================================================ FILE: apps/boruta_admin/assets/src/store.js ================================================ import { createStore } from 'vuex' import oauth from './services/oauth.service' export default createStore({ state: { isAuthenticated: false }, getters: { isAuthenticated (state) { return state.isAuthenticated } }, mutations: { SET_AUTHENTICATED (state, isAuthenticated) { state.isAuthenticated = isAuthenticated } }, actions: { logout ({ commit }) { oauth.logout().then(() => { commit('SET_AUTHENTICATED', false) return oauth.login() }) } } }) ================================================ FILE: apps/boruta_admin/assets/src/views/BadRequest.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Clients/Client.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Clients/ClientList.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Clients/EditClient.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Clients/KeyPairList.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Clients/NewClient.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Clients.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Configuration/ConfigurationFileUpload.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Configuration/EditBadRequestTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Configuration/EditForbiddenTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Configuration/EditInternalServerErrorTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Configuration/EditNotFoundTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Configuration/ErrorTemplateList.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Configuration.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Dashboard/BusinessEvents.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Dashboard/Requests.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Dashboard.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Home.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/BackendList.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/Backends/Backend.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/Backends/EditConfirmationInstructionsEmailTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/Backends/EditResetPasswordInstructionsEmailTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/Backends/EditTxCodeEmailTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/Backends.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditBackend.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditCredentialOfferTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditCrossDevicePresentationTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditEditResetPasswordTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditEditUserTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditIdentityProvider.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditLayoutTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditNewChooseSessionTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditNewConfirmationTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditNewConsentTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditNewResetPasswordTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditOrganization.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditRegistrationTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditSessionTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditTotpAuthenticationTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditTotpRegistrationTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditUser.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditWebauthnAuthenticationTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/EditWebauthnRegistrationTemplate.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/IdentityProvider.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/IdentityProviderList.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/NewBackend.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/NewIdentityProvider.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/NewOrganization.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/NewUser.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/OrganizationList.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/Organizations.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/UserImport.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/UserList.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders/Users.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/IdentityProviders.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Layouts/Main.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/NotFound.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/OauthCallback.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Roles/EditRole.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Roles/NewRole.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Roles/Role.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Roles/RoleList.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Roles.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Scopes/ScopeList.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Scopes.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Upstreams/EditUpstream.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Upstreams/NewUpstream.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Upstreams/Upstream.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Upstreams/UpstreamList.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/src/views/Upstreams.vue ================================================ ================================================ FILE: apps/boruta_admin/assets/tests/unit/.eslintrc.js ================================================ module.exports = { env: { mocha: true } } ================================================ FILE: apps/boruta_admin/assets/vite.config.js ================================================ import { defineConfig } from 'vite' import { viteSingleFile } from 'vite-plugin-singlefile' import vue from '@vitejs/plugin-vue' import path from 'path' import { nodePolyfills } from 'vite-plugin-node-polyfills' // https://vitejs.dev/config/ export default defineConfig({ define: { 'process.env.NODE_ENV': '"production"', }, plugins: [ vue(), viteSingleFile(), nodePolyfills({ // To exclude specific polyfills, add them to this list. exclude: [ 'fs', // Excludes the polyfill for `fs` and `node:fs`. ], // Whether to polyfill specific globals. globals: { Buffer: true, // can also be 'build', 'dev', or false global: true, process: true, }, // Whether to polyfill `node:` protocol imports. protocolImports: true, }), ], build: { outDir: path.resolve(__dirname, '../priv/static/assets'), lib: { entry: path.resolve(__dirname, './src/main.js'), name: 'Boruta', fileName: (format) => `app.${format}.js` }, target: 'esnext', assetsInlineLimit: 100000000, chunkSizeWarningLimit: 100000000, cssCodeSplit: false, brotliSize: false } }) ================================================ FILE: apps/boruta_admin/config/config.exs ================================================ import Config config :boruta_admin, ecto_repos: [ BorutaAdmin.Repo, BorutaAuth.Repo, BorutaIdentity.Repo, BorutaGateway.Repo, BorutaWeb.Repo ] config :boruta_admin, BorutaAdminWeb.Endpoint, url: [ host: "localhost", protocol_options: [idle_timeout: 3_600_000, inactivity_timeout: 3_600_000] ], secret_key_base: "Caq0kwgjLGwxoEVPOxUhEiZ3AG2nADaNYi+ceWh2RuAgKF6vv/FfwqM/P7cDcNrR", render_errors: [view: BorutaAdminWeb.ErrorView, accepts: ~w(html json), layout: false], pubsub_server: BorutaAdmin.PubSub, live_view: [signing_salt: "mtlt3we/"] config :boruta, Boruta.Oauth, repo: BorutaAuth.Repo, contexts: [ resource_owners: BorutaIdentity.ResourceOwners ], issuer: System.get_env("BORUTA_OAUTH_BASE_URL", "http://localhost:4000") config :phoenix, :json_library, Jason import_config "#{Mix.env()}.exs" ================================================ FILE: apps/boruta_admin/config/dev.exs ================================================ import Config config :boruta_admin, BorutaAdmin.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_dev", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 1 config :boruta_admin, BorutaAdminWeb.Endpoint, http: [ port: System.get_env("BORUTA_ADMIN_PORT", "4001") |> String.to_integer(), protocol_options: [idle_timeout: 3_600_000, inactivity_timeout: 3_600_000] ], debug_errors: true, code_reloader: true, check_origin: false, watchers: [ npm: [ "run", "build:watch", cd: Path.expand("../assets", __DIR__) ] ], live_reload: [ patterns: [ ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/gettext/.*(po)$", ~r"lib/boruta_admin_web/{live,views}/.*(ex)$", ~r"lib/boruta_admin_web/templates/.*(eex)$" ] ] config :boruta_web, BorutaAdminWeb.Authorization, oauth2: [ site: System.get_env("BORUTA_ADMIN_OAUTH_BASE_URL", "http://localhost:4000") ], sub_restricted: System.get_env("BORUTA_SUB_RESTRICTED", nil), organization_restricted: System.get_env("BORUTA_ORGANIZATION_RESTRICTED", nil) config :logger, :console, level: :debug config :phoenix, :stacktrace_depth, 20 config :phoenix, :plug_init_mode, :runtime ================================================ FILE: apps/boruta_admin/config/prod.exs ================================================ import Config ================================================ FILE: apps/boruta_admin/config/test.exs ================================================ import Config # Configure your database # # The MIX_TEST_PARTITION environment variable can be used # to provide built-in test partitioning in CI environment. # Run `mix help test` for more information. config :boruta_admin, BorutaAdmin.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_identity_test", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool: Ecto.Adapters.SQL.Sandbox config :boruta_identity, BorutaIdentity.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_identity_test", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool: Ecto.Adapters.SQL.Sandbox config :boruta_web, BorutaWeb.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_web_test", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool: Ecto.Adapters.SQL.Sandbox config :boruta_gateway, BorutaGateway.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_gateway_test", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool: Ecto.Adapters.SQL.Sandbox config :boruta_auth, BorutaAuth.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_gateway_test", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool: Ecto.Adapters.SQL.Sandbox config :boruta_gateway, port: 7777, server: true # We don't run a server during test. If one is required, # you can enable the server option below. config :boruta_admin, BorutaAdminWeb.Endpoint, http: [port: 4002], server: false config :boruta_web, BorutaAdminWeb.Authorization, oauth2: [ site: System.get_env("BORUTA_ADMIN_OAUTH_BASE_URL", "http://localhost:7000") ], sub_restricted: System.get_env("BORUTA_SUB_RESTRICTED", nil), organization_restricted: System.get_env("BORUTA_ORGANIZATION_RESTRICTED", nil) config :boruta_identity, BorutaIdentity.SMTP, adapter: Swoosh.Adapters.Test config :boruta_identity, BorutaIdentity.LdapRepo, adapter: BorutaIdentity.LdapRepoMock # Print only warnings and errors during test config :logger, level: :warn ================================================ FILE: apps/boruta_admin/lib/boruta_admin/application.ex ================================================ defmodule BorutaAdmin.Application do @moduledoc false require Logger use Application def start(_type, _args) do children = [ BorutaAdmin.Repo, BorutaAdminWeb.Telemetry, {Phoenix.PubSub, name: BorutaAdmin.PubSub}, BorutaAdminWeb.Endpoint ] :telemetry.attach( :boruta_admin_requests, [:boruta_admin, :endpoint, :stop], &__MODULE__.boruta_admin_request_handler/4, :ok ) setup_database() opts = [strategy: :one_for_one, name: BorutaAdmin.Supervisor] Supervisor.start_link(children, opts) end def setup_database do Enum.each([BorutaAdmin.Repo], fn repo -> repo.__adapter__.storage_up(repo.config) end) Enum.each([BorutaAdmin.Repo], fn repo -> Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) end) :ok end def config_change(changed, _new, removed) do BorutaAdminWeb.Endpoint.config_change(changed, removed) :ok end def boruta_admin_request_handler(_, %{duration: duration}, %{conn: conn} = metadata, _) do case log_level(metadata[:options][:log], conn) do false -> :ok level -> Logger.log( level, fn -> %{method: method, request_path: path, status: status, state: state} = conn status = Integer.to_string(status) [ "boruta_admin", ?\s, method, ?\s, path, " - ", connection_type(state), ?\s, status, " in ", duration(duration) ] end, type: :request ) end end # From Phoenix.Logger defp log_level(nil, _conn), do: :info defp log_level(level, _conn) when is_atom(level), do: level defp log_level({mod, fun, args}, conn) when is_atom(mod) and is_atom(fun) and is_list(args) do apply(mod, fun, [conn | args]) end defp connection_type(:set_chunked), do: "chunked" defp connection_type(_), do: "sent" defp duration(duration) do duration = System.convert_time_unit(duration, :native, :microsecond) if duration > 1000 do [duration |> div(1000) |> Integer.to_string(), "ms"] else [Integer.to_string(duration), "µs"] end end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin/configuration_loader/schema.ex ================================================ defmodule BorutaAdmin.ConfigurationLoader.Schema do @moduledoc false alias ExJsonSchema.Schema def gateway do %{ "type" => "object", "properties" => %{ "authorize" => %{"type" => "boolean"}, "error_content_type" => %{"type" => "string"}, "forbidden_response" => %{"type" => "string"}, "unauthorized_response" => %{"type" => "string"}, "forwarded_token_private_key" => %{"type" => "string"}, "forwarded_token_public_key" => %{"type" => "string"}, "forwarded_token_secret" => %{"type" => "string"}, "forwarded_token_signature_alg" => %{"type" => "string"}, "scheme" => %{"type" => "string", "pattern" => "^(http|https)$"}, "host" => %{"type" => "string"}, "port" => %{"type" => "number"}, "uris" => %{ "type" => "array", "items" => %{ "type" => "string" } }, "strip_uri" => %{"type" => "boolean"}, "pool_count" => %{"type" => "number"}, "pool_size" => %{"type" => "number"}, "max_idle_time" => %{"type" => "number"}, "required_scopes" => %{ "type" => "object", "patternProperties" => %{ "(GET|POST|PUT|HEAD|OPTIONS|PATCH|DELETE|\\*)" => %{ "type" => "array", "items" => %{ "type" => "string", "pattern" => ".+" }, "minItems" => 1 } }, "additionalProperties" => false } }, "required" => ["scheme", "host", "port", "uris"], "additionalProperties" => false } |> Schema.resolve() end def microgateway do %{ "type" => "object", "properties" => %{ "node_name" => %{"type" => "string"}, "authorize" => %{"type" => "boolean"}, "error_content_type" => %{"type" => "string"}, "forbidden_response" => %{"type" => "string"}, "unauthorized_response" => %{"type" => "string"}, "forwarded_token_private_key" => %{"type" => "string"}, "forwarded_token_public_key" => %{"type" => "string"}, "forwarded_token_secret" => %{"type" => "string"}, "forwarded_token_signature_alg" => %{"type" => "string"}, "scheme" => %{"type" => "string", "pattern" => "^(http|https)$"}, "host" => %{"type" => "string"}, "port" => %{"type" => "number"}, "uris" => %{ "type" => "array", "items" => %{ "type" => "string" } }, "strip_uri" => %{"type" => "boolean"}, "pool_count" => %{"type" => "number"}, "pool_size" => %{"type" => "number"}, "max_idle_time" => %{"type" => "number"}, "required_scopes" => %{ "type" => "object", "patternProperties" => %{ "(GET|POST|PUT|HEAD|OPTIONS|PATCH|DELETE|\\*)" => %{ "type" => "array", "items" => %{ "type" => "string", "pattern" => ".+" }, "minItems" => 1 } }, "additionalProperties" => false } }, "required" => ["node_name", "scheme", "host", "port", "uris"], "additionalProperties" => false } |> Schema.resolve() end def organization do %{ "type" => "object", "properties" => %{ "id" => %{"type" => "string"}, "name" => %{"type" => "string"}, "label" => %{"type" => "string"} }, "required" => ["name"], "additionalProperties" => false } end def backend do %{ "type" => "object", "properties" => %{ "create_default_organization" => %{"type" => "boolean"}, "federated_servers" => %{ "type" => "array", "items" => %{ "type" => "object", "properties" => %{ "name" => %{"type" => "string", "pattern" => "^[^\s]+$"}, "client_id" => %{"type" => "string"}, "client_secret" => %{"type" => "string"}, "base_url" => %{"type" => "string"}, "discovery_path" => %{"type" => "string"}, "userinfo_path" => %{"type" => "string"}, "authorize_path" => %{"type" => "string"}, "token_path" => %{"type" => "string"}, "scope" => %{"type" => "string"}, "federated_attributes" => %{"type" => "string"}, "metadata_endpoints" => %{ "type" => "array", "items" => %{ "type" => "object", "properties" => %{ "endpoint" => %{"type" => "string"}, "claims" => %{"type" => "string"} }, "required" => ["endpoint", "claims"] } } }, "required" => [ "name", "client_id", "client_secret", "base_url" ], "additionalProperties" => false } }, "id" => %{"type" => "string"}, "is_default" => %{"type" => "boolean"}, "ldap_base_dn" => %{"type" => "string"}, "ldap_host" => %{"type" => "string"}, "ldap_master_dn" => %{"type" => "string"}, "ldap_master_password" => %{"type" => "string"}, "ldap_ou" => %{"type" => "string"}, "ldap_pool_size" => %{"type" => "string"}, "ldap_user_rdn_attribute" => %{"type" => "string"}, "metadata_fields" => %{ "type" => "array", "items" => %{ "type" => "object", "properties" => %{ "attribute_name" => %{"type" => "string"}, "user_editable" => %{"type" => "boolean"}, "scopes" => %{"type" => "array", "items" => %{"type" => "string"}} }, "required" => ["attribute_name"], "additionalProperties" => false } }, "name" => %{"type" => "string"}, "password_hashing_alg" => %{"type" => "string"}, "password_hashing_opts" => %{"type" => "object"}, "roles" => %{ "type" => "array", "items" => %{ "type" => "object", "properties" => %{ "id" => %{"type" => "string"} } } }, "smtp_from" => %{"type" => "string"}, "smtp_password" => %{"type" => "string"}, "smtp_port" => %{"type" => "number"}, "smtp_relay" => %{"type" => "string"}, "smtp_ssl" => %{"type" => "boolean"}, "smtp_tls" => %{"type" => "string"}, "smtp_username" => %{"type" => "string"}, "type" => %{"type" => "string"}, "verifiable_credentials" => %{ "type" => "array", "items" => %{ "type" => "object", "properties" => %{ "version" => %{"type" => "string"}, "credential_identifier" => %{"type" => "string"}, "time_to_live" => %{"type" => "number"}, "types" => %{"type" => "string"}, "format" => %{"type" => "string", "pattern" => "jwt_vc|jwt_vc_json|vc\\+sd\\-jwt"}, "claims" => %{ "type" => "array", "items" => %{ "type" => "object", "properties" => %{ "name" => %{"type" => "string"}, "label" => %{"type" => "string"}, "pointer" => %{"type" => "string"} }, "required" => ["name", "label", "pointer"] } }, "display" => %{ "type" => "object", "properties" => %{ "name" => %{"type" => "string"}, "locale" => %{"type" => "string"}, "background_color" => %{"type" => "string"}, "text_color" => %{"type" => "string"}, "logo" => %{ "type" => "object", "properties" => %{ "url" => %{"type" => "string"}, "alt_text" => %{"type" => "string"} } } }, "required" => ["name"], "additionalProperties" => false } }, "required" => [ "version", "credential_identifier", "format", "types", "claims", "display" ], "additionalProperties" => false } }, "verifiable_presentations" => %{ "type" => "array", "items" => %{ "type" => "object", "properties" => %{ "presentation_identifier" => %{"type" => "string"}, "presentation_definition" => %{"type" => "string"} }, "required" => [ "presentation_identifier", "presentation_definition" ], "additionalProperties" => false } } }, "required" => [], "additionalProperties" => false } |> Schema.resolve() end def identity_provider do %{ "type" => "object", "properties" => %{ "backend_id" => %{"type" => "string"}, "choose_session" => %{"type" => "boolean"}, "confirmable" => %{"type" => "boolean"}, "consentable" => %{"type" => "boolean"}, "enforce_totp" => %{"type" => "boolean"}, "id" => %{"type" => "string"}, "name" => %{"type" => "string"}, "registrable" => %{"type" => "boolean"}, "totpable" => %{"type" => "boolean"}, "user_editable" => %{"type" => "boolean"}, "templates" => %{ "type" => "array", "items" => %{ "type" => "object", "properties" => %{ "type" => %{"type" => "string"}, "content" => %{"type" => "string"} } } } }, "additionalProperties" => false } |> Schema.resolve() end def client do %{ "type" => "object", "properties" => %{ "access_token_ttl" => %{"type" => "number"}, "authorization_code_ttl" => %{"type" => "number"}, "authorization_request_ttl" => %{"type" => "number"}, "authorize_scope" => %{"type" => "boolean"}, "authorized_scopes" => %{ "type" => "array", "items" => %{ "type" => "object", "properties" => %{ "id" => %{"type" => "string"}, "name" => %{"type" => "string"} } } }, "confidential" => %{"type" => "boolean"}, "enforce_dpop" => %{"type" => "boolean"}, "id" => %{"type" => "string"}, "id_token_signature_alg" => %{"type" => "string"}, "id_token_ttl" => %{"type" => "number"}, "identity_provider" => %{ "type" => "object", "properties" => %{ "id" => %{"type" => "string"} } }, "jwt_public_key" => %{"type" => "string"}, "name" => %{"type" => "string"}, "pkce" => %{"type" => "boolean"}, "public_refresh_token" => %{"type" => "boolean"}, "public_revoke" => %{"type" => "boolean"}, "redirect_uris" => %{ "type" => "array", "items" => %{"type" => "string"} }, "refresh_token_ttl" => %{"type" => "number"}, "secret" => %{"type" => "string"}, "supported_grant_types" => %{ "type" => "array", "items" => %{"type" => "string"} }, "token_endpoint_auth_methods" => %{ "type" => "array", "items" => %{"type" => "string"} }, "token_endpoint_jwt_auth_alg" => %{"type" => "string"}, "userinfo_signed_response_alg" => %{"type" => "string"} }, "required" => [], "additionalProperties" => false } |> Schema.resolve() end def scope do %{ "type" => "object", "properties" => %{ "id" => %{"type" => "string"}, "name" => %{"type" => "string"}, "label" => %{"type" => "string"}, "public" => %{"type" => "boolean"} }, "required" => [], "additionalProperties" => false } end def role do %{ "type" => "object", "properties" => %{ "id" => %{"type" => "string"}, "name" => %{"type" => "string"}, "scopes" => %{ "type" => "array", "items" => %{ "type" => "object", "properties" => %{ "id" => %{"type" => "string"} } } } }, "required" => [], "additionalProperties" => false } end def error_template do %{ "type" => "object", "properties" => %{ "type" => %{"type" => "string"}, "content" => %{"type" => "string"} }, "additionalProperties" => false } |> Schema.resolve() end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin/configuration_loader.ex ================================================ defmodule BorutaAdmin.ConfigurationLoader do @moduledoc false alias Boruta.Ecto.Admin alias BorutaAdmin.ConfigurationLoader.Schema alias BorutaGateway.Upstreams alias BorutaIdentity.Clients alias BorutaIdentity.Configuration alias BorutaIdentity.Configuration.ErrorTemplate alias BorutaIdentity.IdentityProviders alias ExJsonSchema.Validator.Error.BorutaFormatter @spec node_name() :: node_name :: String.t() def node_name do case Application.get_env(__MODULE__, :node_name) do nil -> path = Application.get_env(:boruta_admin, :configuration_path) %{ "configuration" => %{ "node_name" => node_name } } = YamlElixir.read_from_file!(path) Application.put_env(__MODULE__, :node_name, node_name) node_name node_name -> node_name end rescue _ -> node_name = Atom.to_string(node()) Application.put_env(__MODULE__, :node_name, node_name) node_name end @spec from_file!(configuration_file_path :: String.t()) :: {:ok, result :: map()} | {:error, reason :: String.t()} def from_file!(path) do case YamlElixir.read_from_file!(path) do %{"configuration" => configuration, "version" => "1.0"} -> {:ok, load_configuration(configuration)} _ -> {:error, "Bad configuration file."} end end def load_configuration(configuration) do load_configuration(configuration, %{}) end def load_configuration(%{"gateway" => gateway_configurations} = configuration, result) when is_list(gateway_configurations) do result = Map.put( result, :gateway, Enum.map(gateway_configurations, fn gateway_configuration -> with :ok <- ExJsonSchema.Validator.validate(Schema.gateway(), gateway_configuration, error_formatter: BorutaFormatter ), {:ok, upstream} <- Upstreams.create_upstream(gateway_configuration) do {:ok, upstream} else {:error, %Ecto.Changeset{} = changeset} -> {:error, [changeset]} {:error, errors} -> {:error, errors} end end) |> Enum.flat_map(fn {:ok, _upstream} -> [] {:error, errors} -> errors end) ) load_configuration(Map.delete(configuration, "gateway"), result) end def load_configuration(%{"microgateway" => gateway_configurations} = configuration, result) when is_list(gateway_configurations) do result = Map.put( result, :microgateway, Enum.map(gateway_configurations, fn gateway_configuration -> gateway_configuration = Map.put( gateway_configuration, "node_name", node_name() ) with :ok <- ExJsonSchema.Validator.validate( Schema.microgateway(), gateway_configuration, error_formatter: BorutaFormatter ), {:ok, upstream} <- Upstreams.create_upstream(gateway_configuration) do {:ok, upstream} else {:error, %Ecto.Changeset{} = changeset} -> {:error, [changeset]} {:error, errors} -> {:error, errors} end end) |> Enum.flat_map(fn {:ok, _upstream} -> [] {:error, errors} -> errors end) ) load_configuration(Map.delete(configuration, "microgateway"), result) end def load_configuration(%{"organization" => organization_configurations} = configuration, result) when is_list(organization_configurations) do result = Map.put( result, :organization, Enum.map(organization_configurations, fn organization_configuration -> with :ok <- ExJsonSchema.Validator.validate( Schema.organization(), organization_configuration, error_formatter: BorutaFormatter ), {:ok, organization} <- BorutaIdentity.Admin.create_organization(organization_configuration) do {:ok, organization} else {:error, %Ecto.Changeset{} = changeset} -> {:error, [changeset]} {:error, errors} -> {:error, errors} end end) |> Enum.flat_map(fn {:ok, _organization} -> [] {:error, errors} -> errors end) ) load_configuration(Map.delete(configuration, "organization"), result) end def load_configuration(%{"backend" => backend_configurations} = configuration, result) when is_list(backend_configurations) do result = Map.put( result, :backend, Enum.map(backend_configurations, fn backend_configuration -> with :ok <- ExJsonSchema.Validator.validate( Schema.backend(), backend_configuration, error_formatter: BorutaFormatter ), {:ok, backend} <- IdentityProviders.create_backend(backend_configuration) do {:ok, backend} else {:error, %Ecto.Changeset{} = changeset} -> {:error, [changeset]} {:error, errors} -> {:error, errors} end end) |> Enum.flat_map(fn {:ok, _backend} -> [] {:error, errors} -> errors end) ) load_configuration(Map.delete(configuration, "backend"), result) end def load_configuration( %{"identity_provider" => identity_provider_configurations} = configuration, result ) when is_list(identity_provider_configurations) do result = Map.put( result, :identity_provider, Enum.map(identity_provider_configurations, fn identity_provider_configuration -> with :ok <- ExJsonSchema.Validator.validate( Schema.identity_provider(), identity_provider_configuration, error_formatter: BorutaFormatter ), {:ok, identity_provider} <- IdentityProviders.create_identity_provider(identity_provider_configuration) do {:ok, identity_provider} else {:error, %Ecto.Changeset{} = changeset} -> {:error, [changeset]} {:error, errors} -> {:error, errors} end end) |> Enum.flat_map(fn {:ok, _identity_provider} -> [] {:error, errors} -> errors end) ) load_configuration(Map.delete(configuration, "identity_provider"), result) end def load_configuration(%{"client" => client_configurations} = configuration, result) when is_list(client_configurations) do result = Map.put( result, :client, Enum.map(client_configurations, fn client_configuration -> with :ok <- ExJsonSchema.Validator.validate( Schema.client(), client_configuration, error_formatter: BorutaFormatter ), {:ok, client} <- Clients.create_client(client_configuration) do {:ok, client} else {:error, %Ecto.Changeset{} = changeset} -> {:error, [changeset]} {:error, errors} -> {:error, errors} end end) |> Enum.flat_map(fn {:ok, _client} -> [] {:error, errors} -> errors end) ) load_configuration(Map.delete(configuration, "client"), result) end def load_configuration(%{"scope" => scope_configurations} = configuration, result) when is_list(scope_configurations) do result = Map.put( result, :scope, Enum.map(scope_configurations, fn scope_configuration -> with :ok <- ExJsonSchema.Validator.validate( Schema.scope(), scope_configuration, error_formatter: BorutaFormatter ), {:ok, scope} <- Admin.create_scope(scope_configuration) do {:ok, scope} else {:error, %Ecto.Changeset{} = changeset} -> {:error, [changeset]} {:error, errors} -> {:error, errors} end end) |> Enum.flat_map(fn {:ok, _scope} -> [] {:error, errors} -> errors end) ) load_configuration(Map.delete(configuration, "scope"), result) end def load_configuration(%{"role" => role_configurations} = configuration, result) when is_list(role_configurations) do result = Map.put( result, :role, Enum.map(role_configurations, fn role_configuration -> with :ok <- ExJsonSchema.Validator.validate( Schema.role(), role_configuration, error_formatter: BorutaFormatter ), {:ok, role} <- BorutaIdentity.Admin.create_role(role_configuration) do {:ok, role} else {:error, %Ecto.Changeset{} = changeset} -> {:error, [changeset]} {:error, errors} -> {:error, errors} end end) |> Enum.flat_map(fn {:ok, _role} -> [] {:error, errors} -> errors end) ) load_configuration(Map.delete(configuration, "role"), result) end def load_configuration( %{"error_template" => error_template_configurations} = configuration, result ) when is_list(error_template_configurations) do result = Map.put( result, :error_template, Enum.map(error_template_configurations, fn error_template_configuration -> with :ok <- ExJsonSchema.Validator.validate( Schema.error_template(), error_template_configuration, error_formatter: BorutaFormatter ), template <- Configuration.get_error_template!( String.to_integer(error_template_configuration["type"]) ), {:ok, %ErrorTemplate{} = template} <- Configuration.upsert_error_template(template, error_template_configuration) do {:ok, template} else {:error, %Ecto.Changeset{} = changeset} -> {:error, [changeset]} {:error, errors} -> {:error, errors} end end) |> Enum.flat_map(fn {:ok, _error_template} -> [] {:error, errors} -> errors end) ) load_configuration(Map.delete(configuration, "error_template"), result) rescue _e in Ecto.NoResultsError -> result = Map.put( result, :error_template, ["Error template does not exist."] ) load_configuration(Map.delete(configuration, "error_template"), result) end def load_configuration(%{}, result), do: result end ================================================ FILE: apps/boruta_admin/lib/boruta_admin/configurations/configuration.ex ================================================ defmodule BorutaAdmin.Configurations.Configuration do @moduledoc false use Ecto.Schema import Ecto.Changeset @type t :: %__MODULE__{ id: String.t(), name: String.t(), value: String.t(), inserted_at: DateTime.t(), updated_at: DateTime.t() } @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "configurations" do field(:name, :string) field(:value, :string) timestamps() end @doc false def changeset(upstream, attrs) do upstream |> cast(attrs, [ :name, :value ]) |> validate_required([:name, :value]) |> unique_constraint(:name) end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin/configurations.ex ================================================ defmodule BorutaAdmin.Configurations do @moduledoc false alias BorutaAdmin.Configurations.Configuration alias BorutaAdmin.Repo @spec upsert_configuration(name :: String.t(), value :: String.t()) :: {:ok, Configuration.t()} | {:error, Ecto.Changeset.t()} def upsert_configuration(name, value) do %Configuration{} |> Configuration.changeset(%{ name: name, value: value }) |> Repo.insert(on_conflict: :replace_all, conflict_target: [:name]) end def list_configurations do Repo.all(Configuration) end def get_configuration(name) do Repo.get_by(Configuration, name: name) end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin/logs.ex ================================================ defmodule BorutaAdmin.Logs.FileTooLargeError do @enforce_keys [:message] defexception [:message, plug_status: 422] @type t :: %__MODULE__{ message: String.t() } def exception(message) when is_binary(message) do %__MODULE__{message: message} end def message(exception) do exception.message end end defmodule BorutaAdmin.Logs do @moduledoc false alias BorutaAuth.LogRotate @max_file_size 100_000_000 @max_log_lines 10_000 @request_log_regex ~r/(\d{4}-\d{2}-\d{2}T[^Z]+Z) request_id=([^\s]+) \[info\] ([^\s]+) (\w+) ([^\s]+) - (\w+) (\d{3}) from ([^\s]+) in (\d+)(\w+)/ @business_event_log_regex ~r/(\d{4}-\d{2}-\d{2}T[^Z]+Z) request_id=([^\s]+) \[info\] ([^\s]+) (\w+) (\w+) - (\w+)(( ([^\=]+)\=((\".+\")|([^\s]+)))+)/ @spec read( start_at :: DateTime.t(), end_at :: DateTime.t(), application :: atom(), type :: atom(), query :: map() ) :: Enumerable.t() # credo:disable-for-next-line def read(start_at, end_at, application, :request = type, query) do time_scale_unit = time_scale_unit(start_at, end_at) log_stream(start_at, end_at, application, type) |> Stream.map(&parse_request_log/1) |> Stream.reject(&is_nil/1) |> apply_request_filters(query) |> Enum.reduce( %{ time_scale_unit: time_scale_unit, overflow: false, log_lines: [], log_count: 0, status_codes: %{}, request_counts: %{}, request_times: %{}, labels: [] }, fn %{ label: label, log_line: log_line, time: time, status_code: status_code, duration: duration, duration_unit: duration_unit }, %{ time_scale_unit: time_scale_unit, overflow: overflow, log_lines: log_lines, log_count: log_count, status_codes: status_codes, request_counts: request_counts, request_times: request_times, labels: labels } -> overflow = overflow || log_count >= @max_log_lines truncated_time = DateTime.truncate(time, :second) truncated_time = case time_scale_unit do :second -> truncated_time :minute -> %{truncated_time | second: 0} :hour -> %{truncated_time | second: 0, minute: 0} end normalized_duration = case duration_unit do "ms" -> duration "µs" -> duration / 1000 end %{ time_scale_unit: time_scale_unit, overflow: overflow, log_lines: case overflow do true -> log_lines false -> log_lines ++ [log_line] end, log_count: log_count + 1, status_codes: Map.merge(status_codes, %{label => %{status_code => 1}}, fn _, a, b -> Map.merge(a, b, fn _, i, j -> i + j end) end), request_counts: Map.merge(request_counts, %{label => %{truncated_time => 1}}, fn _, a, b -> Map.merge(a, b, fn _, i, j -> i + j end) end), request_times: Map.merge( request_times, %{label => %{truncated_time => normalized_duration}}, fn _, a, b -> Map.merge(a, b, fn _, i, j -> (i + j) / 2 end) end ), labels: case Enum.member?(labels, label) do false -> [label | labels] |> Enum.sort() true -> labels end } end ) end # credo:disable-for-next-line def read(start_at, end_at, application, :business = type, query) do time_scale_unit = time_scale_unit(start_at, end_at) log_stream(start_at, end_at, application, type) |> Stream.map(&parse_business_log/1) |> Stream.reject(&is_nil/1) |> apply_business_filters(query) |> Enum.reduce( %{ time_scale_unit: time_scale_unit, overflow: false, log_lines: [], log_count: 0, counts: %{}, business_event_counts: %{}, domains: [], actions: [] }, fn %{ log_line: log_line, time: time, label: label, status: status, domain: domain, action: action }, %{ time_scale_unit: time_scale_unit, overflow: overflow, log_lines: log_lines, log_count: log_count, counts: counts, business_event_counts: business_event_counts, domains: domains, actions: actions } -> overflow = overflow || log_count >= @max_log_lines truncated_time = DateTime.truncate(time, :second) truncated_time = case time_scale_unit do :second -> truncated_time :minute -> %{truncated_time | second: 0} :hour -> %{truncated_time | second: 0, minute: 0} end %{ time_scale_unit: time_scale_unit, overflow: overflow, log_lines: case overflow do true -> log_lines false -> log_lines ++ [log_line] end, log_count: log_count + 1, business_event_counts: Map.merge(business_event_counts, %{label => %{truncated_time => 1}}, fn _, a, b -> Map.merge(a, b, fn _, i, j -> i + j end) end), counts: Map.merge(counts, %{label => %{status => 1}}, fn _, a, b -> Map.merge(a, b, fn _, i, j -> i + j end) end), domains: case Enum.member?(domains, domain) do false -> [domain | domains] |> Enum.sort() true -> domains end, actions: case Enum.member?(actions, action) do false -> [action | actions] |> Enum.sort() true -> actions end } end ) end def read(_start_at, _end_at, _application, _type), do: %{} defp log_stream(start_at, end_at, application, type) do paths = log_dates(DateTime.to_date(start_at), DateTime.to_date(end_at)) |> Enum.map(&LogRotate.path(application, type, &1)) |> Enum.filter(&File.exists?/1) if Enum.reduce(paths, 0, fn path, _acc -> File.stat!(path).size end) > @max_file_size do raise BorutaAdmin.Logs.FileTooLargeError, "Requested for more than #{@max_file_size} bytes of logs, could not perform the request." end paths |> Enum.map(&File.stream!/1) |> Stream.concat() |> Stream.drop_while(fn log -> case DateTime.from_iso8601(String.split(log, " ") |> List.first()) do {:ok, log_time, _offset} -> DateTime.compare(log_time, start_at) == :lt _ -> true end end) |> Stream.take_while(fn log -> case DateTime.from_iso8601(String.split(log, " ") |> List.first()) do {:ok, log_time, _offset} -> DateTime.compare(log_time, end_at) == :lt _ -> true end end) end defp parse_request_log(log_line) do case Regex.run(@request_log_regex, log_line) do nil -> nil [ log_line, raw_time, request_id, application, method, path, _state, status_code, ip_address, duration, duration_unit ] -> with {:ok, time, _offset} <- DateTime.from_iso8601(raw_time) do label_path = normalize_request_label_path(path) %{ log_line: log_line, time: time, label: String.slice("#{application} - #{method} #{label_path}", 0..70), request_id: request_id, application: application, method: method, path: path, status_code: status_code, ip_address: ip_address, duration: String.to_integer(duration), duration_unit: duration_unit } end end end defp normalize_request_label_path("/openid/direct_post/" <> code_id) when code_id != "", do: "/openid/direct_post/:code_id" defp normalize_request_label_path(path), do: path def apply_request_filters(request_stream, query) do Enum.reduce(query, request_stream, fn {_key, nil}, stream -> stream {_key, ""}, stream -> stream {key, value}, stream when key in [:label] -> Stream.filter(stream, fn %{^key => ^value} -> true _ -> false end) _, stream -> stream end) end def apply_business_filters(request_stream, query) do Enum.reduce(query, request_stream, fn {_key, nil}, stream -> stream {_key, ""}, stream -> stream {key, value}, stream when key in [:domain, :action] -> Stream.filter(stream, fn %{^key => ^value} -> true _ -> false end) _, stream -> stream end) end defp parse_business_log(log_line) do case Regex.run(@business_event_log_regex, log_line) do nil -> nil [ log_line, raw_time, request_id, application, domain, action, status | _raw_attributes ] -> with {:ok, time, _offset} <- DateTime.from_iso8601(raw_time) do %{ log_line: log_line, time: time, request_id: request_id, label: String.slice("#{application} - #{domain} #{action}", 0..70), application: application, domain: String.slice("#{application} - #{domain}", 0..70), action: String.slice("#{application} - #{domain} #{action}", 0..70), status: status } end end end defp log_dates(start_date, end_date) do if Date.compare(start_date, end_date) == :gt do [] else [start_date | log_dates(Date.add(start_date, 1), end_date)] end end defp time_scale_unit(start_at, end_at) do case DateTime.diff(end_at, start_at, :second) do duration when duration < 60 * 60 -> :second duration when duration < 60 * 60 * 24 -> :minute _duration -> :hour end end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin/release.ex ================================================ defmodule BorutaAdmin.Release do @moduledoc false def load_configuration do Application.ensure_all_started(:boruta_admin) configuration_path = Application.get_env(:boruta_admin, :configuration_path) BorutaAdmin.ConfigurationLoader.from_file!(configuration_path) end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin/repo.ex ================================================ defmodule BorutaAdmin.Repo do use Ecto.Repo, otp_app: :boruta_admin, adapter: Ecto.Adapters.Postgres end ================================================ FILE: apps/boruta_admin/lib/boruta_admin.ex ================================================ defmodule BorutaAdmin do @moduledoc """ BorutaAdmin keeps the contexts that define your domain and business logic. Contexts are also responsible for managing your data, regardless if it comes from the database, an external API or others. """ end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/controllers/backend_controller.ex ================================================ defmodule BorutaAdminWeb.BackendController do use BorutaAdminWeb, :controller import BorutaAdminWeb.Authorization, only: [ authorize: 2 ] alias BorutaIdentity.Accounts.EmailTemplate alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.Backend action_fallback(BorutaAdminWeb.FallbackController) plug(:authorize, ["identity-providers:manage:all"]) def index(conn, _params) do backends = IdentityProviders.list_backends() render(conn, "index.json", backends: backends) end def create(conn, %{"backend" => backend_params}) do with {:ok, %Backend{} = backend} <- IdentityProviders.create_backend(backend_params) do conn |> put_status(:created) |> put_resp_header("location", Routes.admin_backend_path(conn, :show, backend)) |> render("show.json", backend: backend) end end def create(_conn, _params), do: {:error, :bad_request} def show(conn, %{"id" => id}) do backend = IdentityProviders.get_backend!(id) render(conn, "show.json", backend: backend) end def update(conn, %{"id" => id, "backend" => backend_params}) do backend = IdentityProviders.get_backend!(id) with {:ok, %Backend{} = backend} <- IdentityProviders.update_backend(backend, backend_params) do render(conn, "show.json", backend: backend) end end def update(_conn, _params), do: {:error, :bad_request} def delete(conn, %{"id" => id}) do backend = IdentityProviders.get_backend!(id) # with :ok <- ensure_open_for_edition(id), with {:ok, %Backend{}} <- IdentityProviders.delete_backend(backend) do send_resp(conn, :no_content, "") end end def email_template(conn, %{"backend_id" => id, "template_type" => template_type}) do template = IdentityProviders.get_backend_email_template!(id, String.to_atom(template_type)) render(conn, "show_email_template.json", email_template: template) end def update_email_template(conn, %{ "backend_id" => id, "template_type" => template_type, "template" => template_params }) do template = IdentityProviders.get_backend_email_template!(id, String.to_atom(template_type)) with {:ok, %EmailTemplate{} = template} <- IdentityProviders.upsert_email_template(template, template_params) do render(conn, "show_email_template.json", email_template: template) end end def delete_email_template(conn, %{"backend_id" => id, "template_type" => template_type}) do template = IdentityProviders.delete_email_template!(id, String.to_atom(template_type)) render(conn, "show_email_template.json", email_template: template) end # TODO client backend association # defp ensure_open_for_edition(backend_id) do # admin_ui_client_id = # System.get_env("BORUTA_ADMIN_OAUTH_CLIENT_ID", "6a2f41a3-c54c-fce8-32d2-0324e1c32e20") # case IdentityProviders.get_backend_by_client_id(admin_ui_client_id) do # %Backend{id: ^backend_id} -> # {:error, :protected_resource} # _ -> # :ok # end # end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/controllers/boruta/client_controller.ex ================================================ defmodule BorutaAdminWeb.ClientController do use BorutaAdminWeb, :controller import BorutaAdminWeb.Authorization, only: [ authorize: 2 ] alias Boruta.Ecto.Admin alias Boruta.Ecto.Client alias BorutaIdentity.Clients alias BorutaIdentity.IdentityProviders plug(:authorize, ["clients:manage:all"]) action_fallback(BorutaAdminWeb.FallbackController) def index(conn, _params) do clients = Admin.list_clients() render(conn, "index.json", clients: clients) end def create(conn, %{"client" => client_params}) do with {:ok, %Client{} = client} <- Clients.create_client(client_params) do conn |> put_status(:created) |> put_resp_header("location", Routes.admin_client_path(conn, :show, client)) |> render("show.json", client: client) end end def show(conn, %{"id" => client_id}) do client = get_client(client_id) render(conn, "show.json", client: client) end def update(conn, %{"id" => client_id, "client" => client_params}) do client = get_client(client_id) with :ok <- ensure_open_for_edition(client_id), {:ok, %Client{} = client} <- update_client(client, client_params), {:ok, client} <- Clients.insert_global_key_pair(client, client_params["key_pair_id"]) do render(conn, "show.json", client: client) end end defp update_client( client, %{"identity_provider" => %{"id" => identity_provider_id}} = client_params ) do BorutaWeb.Repo.transaction(fn -> with {:ok, client} <- Admin.update_client(client, client_params), {:ok, _client_identity_provider} <- IdentityProviders.upsert_client_identity_provider( client.id, identity_provider_id ) do client else {:error, error} -> BorutaWeb.Repo.rollback(error) end end) end defp update_client(client, client_params) do Admin.update_client(client, client_params) end def regenerate_did(conn, %{"id" => client_id}) do client = get_client(client_id) with {:ok, client} <- Admin.regenerate_client_did(client) do render(conn, "show.json", client: client) end end def regenerate_key_pair(conn, %{"id" => client_id}) do client = get_client(client_id) with :ok <- ensure_open_for_edition(client_id), {:ok, client} <- Admin.regenerate_client_key_pair(client) do render(conn, "show.json", client: client) end end def delete(conn, %{"id" => client_id}) do with :ok <- ensure_open_for_edition(client_id), {:ok, _result} <- delete_client_multi(client_id) do send_resp(conn, :no_content, "") else {:error, :protected_resource} -> {:error, :protected_resource} {:error, _failed_operation, changeset, _changes} -> {:error, changeset} end end defp get_client(client_id) do Admin.get_client!(client_id) end # TODO protect public client defp ensure_open_for_edition(client_id) do admin_ui_client_id = System.get_env("BORUTA_ADMIN_OAUTH_CLIENT_ID", "6a2f41a3-c54c-fce8-32d2-0324e1c32e20") case client_id do ^admin_ui_client_id -> {:error, :protected_resource} _ -> :ok end end defp delete_client_multi(client_id) do Ecto.Multi.new() |> Ecto.Multi.run(:delete_client, fn _repo, _changes -> client = get_client(client_id) Admin.delete_client(client) end) |> Ecto.Multi.run(:delete_client_identity_provider_association, fn _repo, _changes -> IdentityProviders.remove_client_identity_provider(client_id) end) |> BorutaAuth.Repo.transaction() end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/controllers/boruta/scope_controller.ex ================================================ defmodule BorutaAdminWeb.ScopeController do use BorutaAdminWeb, :controller import BorutaAdminWeb.Authorization, only: [ authorize: 2 ] alias Boruta.Ecto.Admin alias Boruta.Ecto.Scope @protected_scopes [ "users:manage:all", "clients:manage:all", "identity-providers:manage:all", "scopes:manage:all", "roles:manage:all", "upstreams:manage:all" ] plug(:authorize, ["scopes:manage:all"]) action_fallback(BorutaAdminWeb.FallbackController) def index(conn, _params) do scopes = Admin.list_scopes() render(conn, "index.json", scopes: scopes) end def create(conn, %{"scope" => scope_params}) do with {:ok, %Scope{} = scope} <- Admin.create_scope(scope_params) do conn |> put_status(:created) |> put_resp_header("location", Routes.admin_scope_path(conn, :show, scope)) |> render("show.json", scope: scope) end end def show(conn, %{"id" => id}) do scope = Admin.get_scope!(id) render(conn, "show.json", scope: scope) end def update(conn, %{"id" => id, "scope" => scope_params}) do scope = Admin.get_scope!(id) with :ok <- ensure_open_for_edition(scope), {:ok, %Scope{} = scope} <- Admin.update_scope(scope, scope_params) do render(conn, "show.json", scope: scope) end end def delete(conn, %{"id" => id}) do scope = Admin.get_scope!(id) with :ok <- ensure_open_for_edition(scope), {:ok, _changes} <- BorutaAuth.Repo.transaction(delete_scope_multi(scope)) do send_resp(conn, :no_content, "") end end def ensure_open_for_edition(scope) do case Enum.member?(@protected_scopes, scope.name) do true -> {:error, :protected_resource} false -> :ok end end defp delete_scope_multi(scope) do Ecto.Multi.new() |> Ecto.Multi.run(:delete_user_scopes, fn _repo, _changes -> with {_deleted, nil} <- BorutaIdentity.Admin.delete_user_authorized_scopes_by_id(scope.id) do {:ok, nil} end end) |> Ecto.Multi.run(:delete_scope, fn _repo, _changes -> Admin.delete_scope(scope) end) end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/controllers/configuration_controller.ex ================================================ defmodule BorutaAdminWeb.ConfigurationController do use BorutaAdminWeb, :controller import Boruta.Config, only: [issuer: 0] import BorutaAdminWeb.Authorization, only: [ authorize: 2 ] alias BorutaAdmin.ConfigurationLoader alias BorutaAdmin.Configurations alias BorutaIdentity.Configuration alias BorutaIdentity.Configuration.ErrorTemplate action_fallback(BorutaAdminWeb.FallbackController) plug(:authorize, ["configuration:manage:all"]) @resource %{ "client" => "clients", "identity_provider" => "identity-providers", "backend" => "identity-providers", "role" => "scopes", "scope" => "scopes", "gateway" => "upstreams", "microgateway" => "upstreams", "error_template" => "configuration" } def error_template(conn, %{"template_type" => template_type}) do template = Configuration.get_error_template!(String.to_integer(template_type)) render(conn, "show_error_template.json", template: template) end def update_error_template(conn, %{ "template_type" => template_type, "template" => template_params }) do template = Configuration.get_error_template!(String.to_integer(template_type)) with {:ok, %ErrorTemplate{} = template} <- Configuration.upsert_error_template(template, template_params) do render(conn, "show_error_template.json", template: template) end end def delete_error_template(conn, %{"template_type" => template_type}) do template = Configuration.delete_error_template!(String.to_integer(template_type)) render(conn, "show_error_template.json", template: template) end def example_configuration_file(conn, _params) do content = :code.priv_dir(:boruta_admin) |> Path.join("/examples/configuration.yml") |> File.read!() content = String.replace( content, "{{PREAUTHORIZED_CODE_REDIRECT_URI}}", issuer() <> # credo:disable-for-next-line BorutaIdentityWeb.Router.Helpers.wallet_path(BorutaIdentityWeb.Endpoint, :index) ) content = String.replace( content, "{{PRESENTATION_REDIRECT_URI}}", issuer() <> # credo:disable-for-next-line BorutaIdentityWeb.Router.Helpers.wallet_path(BorutaIdentityWeb.Endpoint, :index) ) configurations = [ %{ name: "configuration_file", value: content } ] render(conn, "configuration.json", configurations: configurations) end def configuration(conn, _params) do configurations = Configurations.list_configurations() render(conn, "configuration.json", configurations: configurations) end def upload_configuration_file(conn, %{"file" => %Plug.Upload{path: path}}) do file_content = File.read!(path) with %{"configuration" => %{} = configuration, "version" => "1.0"} <- YamlElixir.read_from_file!(path), configuration <- filter_configuration(configuration, conn.assigns[:authorization]), result <- ConfigurationLoader.load_configuration(configuration) do # TODO perform a transaction Configurations.upsert_configuration("configuration_file", file_content) render(conn, "file_upload.json", result: result, file_content: file_content) else _ -> {:error, :bad_request} end end defp filter_configuration(configuration, %{"scope" => scope}) do Enum.filter(configuration, fn {key, _value} -> Regex.match?(~r{#{@resource[key]}:manage:all}, scope) end) |> Enum.into(%{}) end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/controllers/fallback_controller.ex ================================================ defmodule BorutaAdminWeb.FallbackController do @moduledoc """ Translates controller action results into valid `Plug.Conn` responses. See `Phoenix.Controller.action_fallback/1` for more details. """ use BorutaAdminWeb, :controller def call(conn, {:error, %Ecto.Changeset{} = changeset}) do conn |> put_status(:unprocessable_entity) |> put_view(BorutaAdminWeb.ChangesetView) |> render("error.json", changeset: changeset) end def call(conn, {:error, :bad_request}) do conn |> put_status(:bad_request) |> put_view(BorutaAdminWeb.ErrorView) |> render("400." <> get_format(conn)) end def call(conn, {:error, :not_found}) do conn |> put_status(:not_found) |> put_view(BorutaAdminWeb.ErrorView) |> render("404." <> get_format(conn)) end def call(conn, {:error, :unauthorized}) do conn |> put_status(:unauthorized) |> put_view(BorutaAdminWeb.ErrorView) |> render("401." <> get_format(conn)) end def call(conn, {:error, :forbidden}) do conn |> put_status(:forbidden) |> put_view(BorutaAdminWeb.ErrorView) |> render("403." <> get_format(conn)) end def call(conn, {:error, :protected_resource}) do conn |> put_status(:forbidden) |> put_view(BorutaAdminWeb.ErrorView) |> render("protected_resource." <> get_format(conn)) end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/controllers/identity_provider_controller.ex ================================================ defmodule BorutaAdminWeb.IdentityProviderController do use BorutaAdminWeb, :controller import BorutaAdminWeb.Authorization, only: [ authorize: 2 ] alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaIdentity.IdentityProviders.Template action_fallback(BorutaAdminWeb.FallbackController) plug(:authorize, ["identity-providers:manage:all"]) def index(conn, _params) do identity_providers = IdentityProviders.list_identity_providers() render(conn, "index.json", identity_providers: identity_providers) end def create(conn, %{"identity_provider" => identity_provider_params}) do with {:ok, %IdentityProvider{} = identity_provider} <- IdentityProviders.create_identity_provider(identity_provider_params) do conn |> put_status(:created) |> put_resp_header("location", Routes.admin_identity_provider_path(conn, :show, identity_provider)) |> render("show.json", identity_provider: identity_provider) end end def show(conn, %{"id" => id}) do identity_provider = IdentityProviders.get_identity_provider!(id) render(conn, "show.json", identity_provider: identity_provider) end def update(conn, %{"id" => id, "identity_provider" => identity_provider_params}) do identity_provider = IdentityProviders.get_identity_provider!(id) with {:ok, %IdentityProvider{} = identity_provider} <- IdentityProviders.update_identity_provider(identity_provider, identity_provider_params) do render(conn, "show.json", identity_provider: identity_provider) end end def delete(conn, %{"id" => id}) do identity_provider = IdentityProviders.get_identity_provider!(id) with :ok <- ensure_open_for_edition(id), {:ok, %IdentityProvider{}} <- IdentityProviders.delete_identity_provider(identity_provider) do send_resp(conn, :no_content, "") end end def template(conn, %{"identity_provider_id" => id, "template_type" => template_type}) do template = IdentityProviders.get_identity_provider_template!(id, String.to_atom(template_type)) render(conn, "show_template.json", template: template) end def update_template(conn, %{ "identity_provider_id" => id, "template_type" => template_type, "template" => template_params }) do template = IdentityProviders.get_identity_provider_template!(id, String.to_atom(template_type)) with {:ok, %Template{} = template} <- IdentityProviders.upsert_template(template, template_params) do render(conn, "show_template.json", template: template) end end def delete_template(conn, %{"identity_provider_id" => id, "template_type" => template_type}) do template = IdentityProviders.delete_identity_provider_template!(id, String.to_atom(template_type)) render(conn, "show_template.json", template: template) end defp ensure_open_for_edition(identity_provider_id) do admin_ui_client_id = System.get_env("BORUTA_ADMIN_OAUTH_CLIENT_ID", "6a2f41a3-c54c-fce8-32d2-0324e1c32e20") case IdentityProviders.get_identity_provider_by_client_id(admin_ui_client_id) do %IdentityProvider{id: ^identity_provider_id} -> {:error, :protected_resource} _ -> :ok end end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/controllers/key_pair_controller.ex ================================================ defmodule BorutaAdminWeb.KeyPairController do use BorutaAdminWeb, :controller import BorutaAdminWeb.Authorization, only: [ authorize: 2 ] alias BorutaAuth.KeyPairs alias BorutaAuth.KeyPairs.KeyPair plug(:authorize, ["clients:manage:all"]) action_fallback(BorutaAdminWeb.FallbackController) def index(conn, _params) do key_pairs = KeyPairs.list_key_pairs() render(conn, "index.json", key_pairs: key_pairs) end def create(conn, %{"key_pair" => key_pair_params}) do with {:ok, %KeyPair{} = key_pair} <- KeyPairs.create_key_pair(key_pair_params) do conn |> put_status(:created) |> put_resp_header("location", Routes.admin_key_pair_path(conn, :show, key_pair)) |> render("show.json", key_pair: key_pair) end end def show(conn, %{"id" => id}) do key_pair = KeyPairs.get_key_pair!(id) render(conn, "show.json", key_pair: key_pair) end def rotate(conn, %{"id" => id}) do key_pair = KeyPairs.get_key_pair!(id) with {:ok, key_pair} <- KeyPairs.rotate(key_pair) do render(conn, "show.json", key_pair: key_pair) end end def update(conn, %{"id" => id, "key_pair" => key_pair_params}) do key_pair = KeyPairs.get_key_pair!(id) with {:ok, %KeyPair{} = key_pair} <- KeyPairs.update_key_pair(key_pair, key_pair_params) do render(conn, "show.json", key_pair: key_pair) end end def delete(conn, %{"id" => id}) do key_pair = KeyPairs.get_key_pair!(id) with {:ok, _key_pair} <- KeyPairs.delete_key_pair(key_pair) do send_resp(conn, :no_content, "") end end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/controllers/logs_controller.ex ================================================ defmodule BorutaAdminWeb.LogsController do use BorutaAdminWeb, :controller import BorutaAdminWeb.Authorization, only: [ authorize: 2 ] alias BorutaAdmin.Logs action_fallback(BorutaAdminWeb.FallbackController) plug(:authorize, ["logs:read:all"]) def index( conn, %{ "start_at" => start_at, "end_at" => end_at, "application" => application, "type" => type } = params ) do with {:ok, start_at, _offset} <- DateTime.from_iso8601(start_at), {:ok, end_at, _offset} <- DateTime.from_iso8601(end_at) do query = (params["query"] || %{}) |> Enum.map(fn {key, value} -> {String.to_atom(key), value} end) |> Enum.into(%{}) log_stream = Logs.read(start_at, end_at, String.to_atom(application), String.to_atom(type), query) conn |> render("index.json", stats: Enum.into(log_stream, %{})) else _ -> {:error, :bad_request} end end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/controllers/organization_controller.ex ================================================ defmodule BorutaAdminWeb.OrganizationController do use BorutaAdminWeb, :controller import BorutaAdminWeb.Authorization, only: [ authorize: 2 ] alias BorutaIdentity.Admin alias BorutaIdentity.Organizations.Organization plug(:authorize, ["users:manage:all"]) action_fallback(BorutaAdminWeb.FallbackController) def index(conn, params) do organizations = case params["q"] do nil -> Admin.list_organizations(params) # query -> Admin.search_organizations(query, params) end render(conn, "index.json", organizations: organizations.entries, page_number: organizations.page_number, page_size: organizations.page_size, total_pages: organizations.total_pages, total_entries: organizations.total_entries ) end def show(conn, %{"id" => id}) do case Admin.get_organization(id) do %Organization{} = organization -> render(conn, "show.json", organization: organization) nil -> {:error, :not_found} end end def create(conn, %{"organization" => organization_params}) do create_params = %{ name: organization_params["name"], label: organization_params["label"], } with {:ok, organization} <- Admin.create_organization(create_params) do render(conn, "show.json", organization: organization) end end def create(_conn, _params), do: {:error, :bad_request} def update(conn, %{"id" => id, "organization" => organization_params}) do update_params = %{ name: organization_params["name"], label: organization_params["label"], } with :ok <- ensure_open_for_edition(id, conn), %Organization{} = organization <- Admin.get_organization(id), {:ok, %Organization{} = organization} <- Admin.update_organization(organization, update_params) do render(conn, "show.json", organization: organization) else nil -> {:error, :not_found} error -> error end end def update(_conn, _params), do: {:error, :bad_request} def delete(conn, %{"id" => organization}) do with :ok <- ensure_open_for_edition(organization, conn), {:ok, _organization} <- Admin.delete_organization(organization) do send_resp(conn, 204, "") end end defp ensure_open_for_edition(_user_id, _conn) do :ok end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/controllers/page_controller.ex ================================================ defmodule BorutaAdminWeb.PageController do use BorutaAdminWeb, :controller def index(conn, _params) do conn |> put_layout(false) |> render("admin.html") end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/controllers/role_controller.ex ================================================ defmodule BorutaAdminWeb.RoleController do use BorutaAdminWeb, :controller import BorutaAdminWeb.Authorization, only: [ authorize: 2 ] alias BorutaIdentity.Accounts.Role alias BorutaIdentity.Admin plug(:authorize, ["scopes:manage:all"]) action_fallback(BorutaAdminWeb.FallbackController) def index(conn, _params) do roles = Admin.list_roles() render(conn, "index.json", roles: roles) end def create(conn, %{"role" => role_params}) do with {:ok, %Role{} = role} <- Admin.create_role(role_params) do conn |> put_status(:created) |> put_resp_header("location", Routes.admin_role_path(conn, :show, role)) |> render("show.json", role: role) end end def show(conn, %{"id" => id}) do role = Admin.get_role!(id) render(conn, "show.json", role: role) end def update(conn, %{"id" => id, "role" => role_params}) do role = Admin.get_role!(id) with :ok <- ensure_open_for_edition(role), {:ok, %Role{} = role} <- Admin.update_role(role, role_params) do render(conn, "show.json", role: role) end end def delete(conn, %{"id" => id}) do role = Admin.get_role!(id) with :ok <- ensure_open_for_edition(role), {:ok, _changes} <- Admin.delete_role(role) do send_resp(conn, :no_content, "") end end def ensure_open_for_edition(_role) do :ok end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/controllers/upstream_controller.ex ================================================ defmodule BorutaAdminWeb.UpstreamController do use BorutaAdminWeb, :controller import BorutaAdminWeb.Authorization, only: [ authorize: 2 ] alias BorutaGateway.ConfigurationLoader alias BorutaGateway.Upstreams alias BorutaGateway.Upstreams.Upstream plug :authorize, ["upstreams:manage:all"] action_fallback BorutaAdminWeb.FallbackController def index(conn, _params) do upstreams = Upstreams.list_upstreams() render(conn, "index.json", upstreams: upstreams) end def node_list(conn, _params) do nodes = [node() | Node.list()] |> Enum.map(fn node -> :rpc.call(node, ConfigurationLoader, :node_name, []) end) |> Enum.uniq() render(conn, "node_list.json", nodes: nodes) end def show(conn, %{"id" => id}) do upstream = Upstreams.get_upstream!(id) render(conn, "show.json", upstream: upstream) end def create(conn, %{"upstream" => upstream_params}) do with {:ok, %Upstream{} = upstream} <- Upstreams.create_upstream(upstream_params) do conn |> put_status(:created) |> put_resp_header("location", Routes.admin_upstream_path(conn, :show, upstream)) |> render("show.json", upstream: upstream) end end def update(conn, %{"id" => id, "upstream" => upstream_params}) do upstream = Upstreams.get_upstream!(id) with {:ok, %Upstream{} = upstream} <- Upstreams.update_upstream(upstream, upstream_params) do render(conn, "show.json", upstream: upstream) end end def delete(conn, %{"id" => id}) do upstream = Upstreams.get_upstream!(id) with {:ok, %Upstream{}} <- Upstreams.delete_upstream(upstream) do send_resp(conn, :no_content, "") end end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/controllers/user_controller.ex ================================================ defmodule BorutaAdminWeb.UserController do use BorutaAdminWeb, :controller import BorutaAdminWeb.Authorization, only: [ authorize: 2 ] alias BorutaIdentity.Accounts.LdapError alias BorutaIdentity.Accounts.User alias BorutaIdentity.Admin alias BorutaIdentity.IdentityProviders plug(:authorize, ["users:manage:all"]) action_fallback(BorutaAdminWeb.FallbackController) def index(conn, params) do users = case params["q"] do nil -> Admin.list_users(params) query -> Admin.search_users(query, params) end render(conn, "index.json", users: users.entries, page_number: users.page_number, page_size: users.page_size, total_pages: users.total_pages, total_entries: users.total_entries ) end def show(conn, %{"id" => id}) do case Admin.get_user(id) do %User{} = user -> render(conn, "show.json", user: user) nil -> {:error, :not_found} end end def create(conn, %{"backend_id" => backend_id, "user" => user_params}) do create_params = %{ username: user_params["email"], group: user_params["group"], password: user_params["password"], metadata: user_params["metadata"] || %{}, authorized_scopes: user_params["authorized_scopes"], organizations: user_params["organizations"], roles: user_params["roles"] } backend = IdentityProviders.get_backend!(backend_id) with {:ok, user} <- Admin.create_user(backend, create_params) do render(conn, "show.json", user: user) end rescue _e in Ecto.NoResultsError -> {:error, Ecto.Changeset.change(%User{}) |> Ecto.Changeset.add_error(:backend, "does not exist")} error in LdapError -> {:error, Ecto.Changeset.change(%User{}) |> Ecto.Changeset.add_error(:backend, error.message)} end def create(conn, %{"backend_id" => backend_id, "file" => file_params} = import_params) do backend = IdentityProviders.get_backend!(backend_id) import_users_opts = (import_params["options"] || %{}) |> Enum.map(fn {"metadata_headers" = k, v} -> {String.to_atom(k), v} {"username_header" = k, v} -> {String.to_atom(k), v} {"password_header" = k, v} -> {String.to_atom(k), v} {"hash_password" = k, "true"} -> {String.to_atom(k), true} {"hash_password" = k, "false"} -> {String.to_atom(k), false} {"hash_password" = k, v} when is_boolean(v) -> {String.to_atom(k), v} {_k, _v} -> nil end) |> Enum.reject(&is_nil/1) |> Enum.into(%{}) case file_params do %Plug.Upload{} -> result = Admin.import_users(backend, file_params.path, import_users_opts) render(conn, "import_result.json", import_result: result) _ -> {:error, Ecto.Changeset.change(%User{}) |> Ecto.Changeset.add_error(:file, "is invalid")} end rescue _e in Ecto.NoResultsError -> {:error, Ecto.Changeset.change(%User{}) |> Ecto.Changeset.add_error(:backend, "does not exist")} error in LdapError -> {:error, Ecto.Changeset.change(%User{}) |> Ecto.Changeset.add_error(:backend, error.message)} end def create(_conn, _params), do: {:error, :bad_request} def update(conn, %{"id" => id, "user" => user_params}) do update_params = %{ username: user_params["email"], group: user_params["group"], metadata: user_params["metadata"] || %{}, authorized_scopes: user_params["authorized_scopes"], organizations: user_params["organizations"], roles: user_params["roles"] } with :ok <- ensure_open_for_edition(id, conn), %User{} = user <- Admin.get_user(id), # TODO update user email and password {:ok, %User{} = user} <- Admin.update_user(user, update_params) do render(conn, "show.json", user: user) else nil -> {:error, :not_found} error -> error end end def update(_conn, _params), do: {:error, :bad_request} def delete(conn, %{"id" => user_id}) do with :ok <- ensure_open_for_edition(user_id, conn), {:ok, _user} <- Admin.delete_user(user_id) do send_resp(conn, 204, "") else {:error, "" <> reason} -> {:error, Ecto.Changeset.change(%User{}) |> Ecto.Changeset.add_error(:backend, reason)} error -> error end end defp ensure_open_for_edition(user_id, conn) do %{"sub" => sub} = conn.assigns[:authorization] case user_id == sub do true -> {:error, :protected_resource} false -> :ok end end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/endpoint.ex ================================================ defmodule BorutaAdminWeb.Endpoint do use Phoenix.Endpoint, otp_app: :boruta_admin # sets the same session as :boruta_web @session_options [ store: :cookie, key: "_boruta_web_key", signing_salt: "OCKBuS86" ] plug RemoteIp plug Plug.Static, at: "/", from: :boruta_admin, gzip: false, only: ~w(assets favicon.ico semantic-ui.min.css prism-dark.min.css themes) if code_reloading? do socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket plug Phoenix.LiveReloader plug Phoenix.CodeReloader plug Phoenix.Ecto.CheckRepoStatus, otp_app: :boruta_admin end plug Plug.RequestId plug BorutaAdminWeb.Logger plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], json_decoder: Phoenix.json_library() plug Plug.MethodOverride plug Plug.Head plug Plug.Session, @session_options plug BorutaAdminWeb.Router def log_level(%{path_info: ["healthcheck" | _]}), do: false def log_level(%{path_info: ["favicon.ico" | _]}), do: false def log_level(_), do: :info end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/gettext.ex ================================================ defmodule BorutaAdminWeb.Gettext do @moduledoc """ A module providing Internationalization with a gettext-based API. By using [Gettext](https://hexdocs.pm/gettext), your module gains a set of macros for translations, for example: import BorutaAdminWeb.Gettext # Simple translation gettext("Here is the string to translate") # Plural translation ngettext("Here is the string to translate", "Here are the strings to translate", 3) # Domain-based translation dgettext("errors", "Here is the error message to translate") See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. """ use Gettext.Backend, otp_app: :boruta_admin end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/plugs/authorization.ex ================================================ defmodule BorutaAdminWeb.Authorization do @moduledoc false @dialyzer {:no_unused, {:maybe_validate_user, 1}} @behaviour Boruta.Openid.UserinfoApplication require Logger alias Boruta.Oauth.Authorization.ResourceOwner use BorutaAdminWeb, :controller alias Boruta.Oauth.ResourceOwner alias Boruta.Openid.UserinfoResponse alias BorutaAdminWeb.ErrorView alias BorutaIdentity.ResourceOwners def require_authenticated(conn, _opts \\ []) do with [authorization_header] <- get_req_header(conn, "authorization"), [_authorization_header, access_token] <- Regex.run(~r/Bearer (.+)/, authorization_header), {:ok, token} <- Boruta.Oauth.Authorization.AccessToken.authorize(value: access_token) do userinfo = ResourceOwners.claims(%ResourceOwner{sub: token.sub}, "profile") case maybe_validate_user(userinfo) do :ok -> conn |> assign(:authorization, %{ "scope" => token.scope, "sub" => token.sub }) error -> respond_unauthorized(conn, error) end else {:error, _error} -> with [authorization_header] <- get_req_header(conn, "authorization"), [_authorization_header, access_token] <- Regex.run(~r/Bearer (.+)/, authorization_header), {:ok, userinfo} <- userinfo(access_token), :ok <- maybe_validate_user(userinfo) do assign(conn, :authorization, userinfo) else e -> respond_unauthorized(conn, e) end e -> respond_unauthorized(conn, e) end end @impl Boruta.Openid.UserinfoApplication def unauthorized(_conn, error) do {:error, error} end @impl Boruta.Openid.UserinfoApplication def userinfo_fetched(_conn, userinfo_response) do userinfo = UserinfoResponse.payload(%{userinfo_response | format: :json}) |> Enum.map(fn {k, v} -> {to_string(k), v} end) |> Enum.into(%{}) {:ok, userinfo} end def authorize(conn, [_h | _t] = scopes) do current_scopes = String.split(conn.assigns[:authorization]["scope"], " ") case Enum.empty?(scopes -- current_scopes) do true -> conn false -> conn |> put_status(:forbidden) |> put_view(ErrorView) |> render("403.json") |> halt() end end def authorize(conn, _opts) do conn |> put_status(:forbidden) |> put_view(ErrorView) |> render("403.json") |> halt() end # TODO cache token introspection def userinfo(access_token) do site = Application.get_env(:boruta_web, BorutaAdminWeb.Authorization)[:oauth2][:site] with {:ok, %Finch.Response{body: body}} <- Finch.build( :get, "#{site}/oauth/userinfo", [ {"accept", "application/json"}, {"authorization", "Bearer " <> access_token} ] ) |> Finch.request(FinchHttp) do Jason.decode(body) end end defp respond_unauthorized(conn, e) do Logger.debug("User unauthorized : #{inspect(e)}") conn |> put_status(:unauthorized) |> put_view(ErrorView) |> render("401.json") |> halt() end defp maybe_validate_user(userinfo) do case { Application.get_env(:boruta_web, BorutaAdminWeb.Authorization)[:sub_restricted], Application.get_env(:boruta_web, BorutaAdminWeb.Authorization)[:organization_restricted] } do {_, "" <> restricted_organization} -> case Map.get(userinfo, "organizations", []) |> Enum.map(fn %{"id" => id} -> id end) |> Enum.member?(restricted_organization) do true -> :ok false -> {:error, "Instance management is restricted to #{restricted_organization}"} end {"" <> restricted_sub, _} -> case userinfo["sub"] do ^restricted_sub -> :ok _ -> {:error, "Instance management is restricted to #{restricted_sub}"} end {_, _} -> :ok end end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/plugs/logger.ex ================================================ defmodule BorutaAdminWeb.Logger do @moduledoc false require Logger alias Plug.Conn @behaviour Plug @impl true def init(opts) do Keyword.get(opts, :log, :info) end @impl true def call(conn, level) do start = System.monotonic_time() Conn.register_before_send( conn, fn conn -> Logger.log( level, fn -> remote_ip = :inet.ntoa(conn.remote_ip) stop = System.monotonic_time() duration = System.convert_time_unit(stop - start, :native, :microsecond) status = Integer.to_string(conn.status) [ "boruta_admin", ?\s, conn.method, ?\s, conn.request_path, " - ", connection_type(conn.state), ?\s, status, " from ", remote_ip, " in ", duration(duration) ] end, type: :request ) conn end ) end defp duration(duration) do duration = System.convert_time_unit(duration, :native, :microsecond) if duration > 1000 do [duration |> div(1000) |> Integer.to_string(), "ms"] else [Integer.to_string(duration), "µs"] end end defp connection_type(%{state: :set_chunked}), do: "Chunked" defp connection_type(_), do: "Sent" end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/router.ex ================================================ defmodule BorutaAdminWeb.Router do use BorutaAdminWeb, :router use Plug.ErrorHandler alias Plug.Conn.Status import BorutaAdminWeb.Authorization, only: [ require_authenticated: 2 ] pipeline :authenticated_api do plug(:accepts, ["json"]) plug(:require_authenticated) end pipeline :browser do plug(:accepts, ["html"]) plug(:fetch_session) plug(:fetch_flash) plug(:protect_from_forgery) plug(:put_secure_browser_headers) end pipeline :api do plug(:accepts, ["json"]) end scope "/", BorutaAdminWeb do pipe_through(:browser) get("/", PageController, :index) end scope "/api", BorutaAdminWeb, as: :admin do pipe_through(:authenticated_api) resources("/logs", LogsController, only: [:index]) resources("/scopes", ScopeController, except: [:new, :edit]) resources("/roles", RoleController, except: [:new, :edit]) resources("/key-pairs", KeyPairController, except: [:new, :edit]) post("/key-pairs/:id/rotate", KeyPairController, :rotate) resources("/clients", ClientController, except: [:new, :edit]) post("/clients/:id/regenerate_did", ClientController, :regenerate_did) post("/clients/:id/regenerate_key_pair", ClientController, :regenerate_key_pair) resources("/users", UserController, except: [:new, :edit]) resources("/organizations", OrganizationController, except: [:new, :edit]) get("/upstreams/nodes", UpstreamController, :node_list) resources("/upstreams", UpstreamController, except: [:new, :edit]) scope "/configuration", as: :configuration do get("/", ConfigurationController, :configuration, as: :configuration_list) get("/example-configuration-file", ConfigurationController, :example_configuration_file) post("/upload-configuration-file", ConfigurationController, :upload_configuration_file, as: :upload_configuration_file ) get("/error-templates/:template_type", ConfigurationController, :error_template, as: :error_template ) patch("/error-templates/:template_type", ConfigurationController, :update_error_template, as: :error_template ) delete("/error-templates/:template_type", ConfigurationController, :delete_error_template, as: :error_template ) end resources "/identity-providers", IdentityProviderController, except: [:new, :edit] do get("/templates/:template_type", IdentityProviderController, :template, as: :template) patch("/templates/:template_type", IdentityProviderController, :update_template, as: :template ) delete("/templates/:template_type", IdentityProviderController, :delete_template, as: :template ) end resources "/backends", BackendController, except: [:new, :edit] do get("/email-templates/:template_type", BackendController, :email_template, as: :email_template) patch("/email-templates/:template_type", BackendController, :update_email_template, as: :email_template ) delete("/email-templates/:template_type", BackendController, :delete_email_template, as: :email_template ) end end scope "/", BorutaAdminWeb do pipe_through(:browser) match(:get, "/*path", PageController, :index) end @impl Plug.ErrorHandler def handle_errors(conn, %{kind: _kind, reason: reason, stack: _stack}) do message = %{ code: Status.reason_atom(conn.status) |> Atom.to_string() |> String.upcase(), message: reason.__struct__.message(reason) } conn |> put_resp_content_type("application/json") |> send_resp(conn.status, Jason.encode!(message)) end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/telemetry.ex ================================================ defmodule BorutaAdminWeb.Telemetry do @moduledoc false use Supervisor import Telemetry.Metrics def start_link(arg) do Supervisor.start_link(__MODULE__, arg, name: __MODULE__) end @impl true def init(_arg) do children = [ # Telemetry poller will execute the given period measurements # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} # Add reporters as children of your supervision tree. # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} ] Supervisor.init(children, strategy: :one_for_one) end def metrics do [ # Phoenix Metrics summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond} ), summary("phoenix.router_dispatch.stop.duration", tags: [:route], unit: {:native, :millisecond} ), # Database Metrics summary("boruta_admin.repo.query.total_time", unit: {:native, :millisecond}), summary("boruta_admin.repo.query.decode_time", unit: {:native, :millisecond}), summary("boruta_admin.repo.query.query_time", unit: {:native, :millisecond}), summary("boruta_admin.repo.query.queue_time", unit: {:native, :millisecond}), summary("boruta_admin.repo.query.idle_time", unit: {:native, :millisecond}), # VM Metrics summary("vm.memory.total", unit: {:byte, :kilobyte}), summary("vm.total_run_queue_lengths.total"), summary("vm.total_run_queue_lengths.cpu"), summary("vm.total_run_queue_lengths.io") ] end defp periodic_measurements do [ # A module, function and arguments to be invoked periodically. # This function must call :telemetry.execute/3 and a metric must be added above. # {BorutaAdminWeb, :count_users, []} ] end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/templates/error/404.html.eex ================================================ Boruta · Phoenix Framework

Page not found
The page you requested was not found. Please contact your administrator.

================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/templates/error/500.html.eex ================================================ Boruta · Phoenix Framework

Internal server error
An unexpected error occured. Please contact your administrator.

================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/templates/page/admin.html.eex ================================================ Boruta · Administration panel " media="all"/>
================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/backend_view.ex ================================================ defmodule BorutaAdminWeb.BackendView do use BorutaAdminWeb, :view alias BorutaAdminWeb.BackendView alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.Backend def render("index.json", %{backends: backends}) do %{data: render_many(backends, BackendView, "backend.json")} end def render("show.json", %{backend: backend}) do %{data: render_one(backend, BackendView, "backend.json")} end def render("show_email_template.json", %{email_template: template}) do %{data: render_one(template, __MODULE__, "email_template.json", template: template)} end def render("backend.json", %{backend: backend}) do %{ id: backend.id, name: backend.name, type: backend.type, is_default: backend.is_default, create_default_organization: backend.create_default_organization, roles: IdentityProviders.get_backend_roles(backend.id), metadata_fields: backend.metadata_fields, federated_servers: backend.federated_servers, verifiable_credentials: backend.verifiable_credentials, verifiable_presentations: backend.verifiable_presentations, password_hashing_alg: backend.password_hashing_alg, password_hashing_opts: backend.password_hashing_opts, ldap_pool_size: backend.ldap_pool_size, ldap_host: backend.ldap_host, ldap_user_rdn_attribute: backend.ldap_user_rdn_attribute, ldap_base_dn: backend.ldap_base_dn, ldap_ou: backend.ldap_ou, ldap_master_dn: backend.ldap_master_dn, ldap_master_password: backend.ldap_master_password, smtp_from: backend.smtp_from, smtp_relay: backend.smtp_relay, smtp_username: backend.smtp_username, smtp_password: backend.smtp_password, smtp_ssl: backend.smtp_ssl, smtp_tls: backend.smtp_tls, smtp_port: backend.smtp_port, features: Backend.features(backend) } end def render("email_template.json", %{template: template}) do %{ id: template.id, txt_content: template.txt_content, html_content: template.html_content, type: template.type, backend_id: template.backend_id } end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/changeset_view.ex ================================================ defmodule BorutaAdminWeb.ChangesetView do use BorutaWeb, :view @doc """ Traverses and translates changeset errors. See `Ecto.Changeset.traverse_errors/2` and `BorutaWeb.ErrorHelpers.translate_error/1` for more details. """ def translate_errors(changeset) do Ecto.Changeset.traverse_errors(changeset, &translate_error/1) end def render("error.json", %{changeset: changeset}) do %{ code: "UNPROCESSABLE_ENTITY", message: "Your request could not be processed.", errors: translate_errors(changeset) } end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/client_view.ex ================================================ defmodule BorutaAdminWeb.ClientView do use BorutaAdminWeb, :view alias BorutaAdminWeb.ClientView alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.IdentityProvider def render("index.json", %{clients: clients}) do %{data: render_many(clients, ClientView, "client.json")} end def render("show.json", %{client: client}) do %{data: render_one(client, ClientView, "client.json")} end def render("client.json", %{client: client}) do identity_provider = IdentityProviders.get_identity_provider_by_client_id(client.id) || %IdentityProvider{} %{ id: client.id, public_client_id: client.public_client_id, check_public_client_id: client.check_public_client_id, name: client.name, secret: client.secret, confidential: client.confidential, redirect_uris: client.redirect_uris, public_refresh_token: client.public_refresh_token, public_revoke: client.public_revoke, authorize_scope: client.authorize_scope, enforce_dpop: client.enforce_dpop, enforce_tx_code: client.enforce_tx_code, access_token_ttl: client.access_token_ttl, authorization_code_ttl: client.authorization_code_ttl, authorization_request_ttl: client.authorization_request_ttl, refresh_token_ttl: client.refresh_token_ttl, id_token_ttl: client.id_token_ttl, pkce: client.pkce, public_key: client.public_key, key_pair_type: client.key_pair_type, signatures_adapter: client.signatures_adapter, did: client.did, identity_provider: %{ id: identity_provider.id, name: identity_provider.name }, authorized_scopes: Enum.map(client.authorized_scopes, fn scope -> %{ id: scope.id, name: scope.name, public: scope.public } end), supported_grant_types: client.supported_grant_types, id_token_signature_alg: client.id_token_signature_alg, userinfo_signed_response_alg: client.userinfo_signed_response_alg, token_endpoint_jwt_auth_alg: client.token_endpoint_jwt_auth_alg, token_endpoint_auth_methods: client.token_endpoint_auth_methods, jwt_public_key: client.jwt_public_key, response_mode: client.response_mode } end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/configuration_view.ex ================================================ defmodule BorutaAdminWeb.ConfigurationView do use BorutaAdminWeb, :view alias BorutaAdminWeb.ChangesetView def render("show_error_template.json", %{template: template}) do %{data: render_one(template, __MODULE__, "error_template.json", template: template)} end def render("error_template.json", %{template: template}) do %{ id: template.id, content: template.content, type: template.type } end def render("configuration.json", %{configurations: configurations}) do %{ data: Enum.map(configurations, &Map.take(&1, [:name, :value])) } end def render("file_upload.json", %{result: result, file_content: file_content}) do errors = Enum.map(result, fn {key, errors} -> errors = Enum.map(errors, fn %Ecto.Changeset{} = changeset -> ChangesetView.translate_errors(changeset) "" <> error -> %{validation: [error]} end) {key, errors} end) |> Enum.into(%{}) %{ errors: errors, file_content: file_content } end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/error_helpers.ex ================================================ defmodule BorutaAdminWeb.ErrorHelpers do @moduledoc """ Conveniences for translating and building error messages. """ use Phoenix.HTML @doc """ Generates tag for inlined form input errors. """ def error_tag(form, field) do Enum.map(Keyword.get_values(form.errors, field), fn error -> content_tag(:span, translate_error(error), class: "invalid-feedback", phx_feedback_for: input_name(form, field) ) end) end @doc """ Translates an error message using gettext. """ def translate_error({msg, opts}) do if count = opts[:count] do Gettext.dngettext(BorutaAdminWeb.Gettext, "errors", msg, msg, count, opts) else Gettext.dgettext(BorutaAdminWeb.Gettext, "errors", msg, opts) end end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/error_view.ex ================================================ defmodule BorutaAdminWeb.ErrorView do use BorutaAdminWeb, :view def render("400.json", _assigns) do %{ code: "BAD_REQUEST", message: "The requested with given parameters cannot be processed.", errors: %{ resource: ["the requested with given parameters cannot be processed."] } } end def render("404.json", _assigns) do %{ code: "NOT_FOUND", message: "The requested resource could not be found.", errors: %{ resource: ["the requested resource could not be found."] } } end def render("401.json", _assigns) do %{ code: "UNAUTHORIZED", message: "You are unauthorized to access this resource.", errors: %{ resource: ["you are unauthorized to access this resource."] } } end def render("403.json", _assigns) do %{ code: "FORBIDDEN", message: "You are forbidden to access this resource.", errors: %{ resource: ["you are forbidden to access this resource."] } } end def render("protected_resource.json", _assigns) do %{ code: "FORBIDDEN", message: "The resource is write protected.", errors: %{ resource: ["is write protected."] } } end def template_not_found(template, _assigns) do Phoenix.Controller.status_message_from_template(template) end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/identity_provider_view.ex ================================================ defmodule BorutaAdminWeb.IdentityProviderView do use BorutaAdminWeb, :view alias BorutaAdminWeb.BackendView def render("index.json", %{identity_providers: identity_providers}) do %{data: render_many(identity_providers, __MODULE__, "identity_provider.json")} end def render("show.json", %{identity_provider: identity_provider}) do %{data: render_one(identity_provider, __MODULE__, "identity_provider.json")} end def render("show_template.json", %{template: template}) do %{data: render_one(template, __MODULE__, "template.json", template: template)} end def render("identity_provider.json", %{identity_provider: identity_provider}) do %{ id: identity_provider.id, name: identity_provider.name, backend: render_one(identity_provider.backend, BackendView, "backend.json", backend: identity_provider.backend), backend_id: identity_provider.backend_id, check_password: identity_provider.check_password, choose_session: identity_provider.choose_session, totpable: identity_provider.totpable, enforce_totp: identity_provider.enforce_totp, webauthnable: identity_provider.webauthnable, enforce_webauthn: identity_provider.enforce_webauthn, registrable: identity_provider.registrable, user_editable: identity_provider.user_editable, consentable: identity_provider.consentable, confirmable: identity_provider.confirmable } end def render("template.json", %{template: template}) do %{ id: template.id, content: template.content, type: template.type, identity_provider_id: template.identity_provider_id } end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/key_pair_view.ex ================================================ defmodule BorutaAdminWeb.KeyPairView do use BorutaAdminWeb, :view alias BorutaAdminWeb.KeyPairView def render("index.json", %{key_pairs: key_pairs}) do %{data: render_many(key_pairs, KeyPairView, "key_pair.json")} end def render("show.json", %{key_pair: key_pair}) do %{data: render_one(key_pair, KeyPairView, "key_pair.json")} end def render("key_pair.json", %{key_pair: key_pair}) do %{id: key_pair.id, public_key: key_pair.public_key, is_default: key_pair.is_default} end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/logs_view.ex ================================================ defmodule BorutaAdminWeb.LogsView do use BorutaAdminWeb, :view def render("index.json", %{stats: stats}) do stats end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/organization_view.ex ================================================ defmodule BorutaAdminWeb.OrganizationView do use BorutaAdminWeb, :view alias BorutaAdminWeb.OrganizationView def render("index.json", %{ organizations: organizations, page_number: page_number, page_size: page_size, total_pages: total_pages, total_entries: total_entries }) do %{ data: render_many(organizations, OrganizationView, "organization.json"), page_number: page_number, page_size: page_size, total_pages: total_pages, total_entries: total_entries } end def render("show.json", %{organization: organization}) do %{data: render_one(organization, OrganizationView, "organization.json")} end def render("organization.json", %{organization: organization}) do %{ id: organization.id, name: organization.name, label: organization.label } end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/page_view.ex ================================================ defmodule BorutaAdminWeb.PageView do use BorutaAdminWeb, :view end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/role_view.ex ================================================ defmodule BorutaAdminWeb.RoleView do use BorutaAdminWeb, :view alias BorutaAdminWeb.RoleView def render("index.json", %{roles: roles}) do %{data: render_many(roles, RoleView, "role.json")} end def render("show.json", %{role: role}) do %{data: render_one(role, RoleView, "role.json")} end def render("role.json", %{role: role}) do %{id: role.id, name: role.name, scopes: Enum.map(role.scopes, fn scope -> %{ id: scope.id, name: scope.name, public: scope.public } end) } end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/scope_view.ex ================================================ defmodule BorutaAdminWeb.ScopeView do use BorutaAdminWeb, :view alias BorutaAdminWeb.ScopeView def render("index.json", %{scopes: scopes}) do %{data: render_many(scopes, ScopeView, "scope.json")} end def render("show.json", %{scope: scope}) do %{data: render_one(scope, ScopeView, "scope.json")} end def render("scope.json", %{scope: scope}) do %{id: scope.id, name: scope.name, label: scope.label, public: scope.public} end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/upstream_view.ex ================================================ defmodule BorutaAdminWeb.UpstreamView do use BorutaAdminWeb, :view alias BorutaAdminWeb.UpstreamView def render("index.json", %{upstreams: upstreams}) do data = Enum.map(upstreams, fn {node_name, upstreams} -> {node_name, render_many(upstreams, UpstreamView, "upstream.json")} end) |> Enum.into(%{}) %{data: data} end def render("node_list.json", %{nodes: nodes}) do %{data: nodes} end def render("show.json", %{upstream: upstream}) do %{data: render_one(upstream, UpstreamView, "upstream.json")} end def render("upstream.json", %{upstream: upstream}) do %{ id: upstream.id, node_name: upstream.node_name, scheme: upstream.scheme, host: upstream.host, port: upstream.port, uris: upstream.uris, strip_uri: upstream.strip_uri, authorize: upstream.authorize, required_scopes: upstream.required_scopes, pool_size: upstream.pool_size, pool_count: upstream.pool_count, max_idle_time: upstream.max_idle_time, error_content_type: upstream.error_content_type, forbidden_response: upstream.forbidden_response, unauthorized_response: upstream.unauthorized_response, forwarded_token_signature_alg: upstream.forwarded_token_signature_alg, forwarded_token_secret: upstream.forwarded_token_secret, forwarded_token_private_key: upstream.forwarded_token_private_key, forwarded_token_public_key: upstream.forwarded_token_public_key } end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web/views/user_view.ex ================================================ defmodule BorutaAdminWeb.UserView do use BorutaAdminWeb, :view alias BorutaAdminWeb.BackendView alias BorutaAdminWeb.ChangesetView alias BorutaAdminWeb.UserView alias BorutaIdentity.Accounts alias BorutaIdentity.Accounts.Role alias BorutaIdentity.Accounts.UserRole def render("index.json", %{ users: users, page_number: page_number, page_size: page_size, total_pages: total_pages, total_entries: total_entries }) do %{ data: render_many(users, UserView, "user.json"), page_number: page_number, page_size: page_size, total_pages: total_pages, total_entries: total_entries } end def render("show.json", %{user: user}) do %{data: render_one(user, UserView, "user.json")} end def render("user.json", %{user: user}) do %{ id: user.id, uid: user.uid, email: user.username, totp_registered_at: user.totp_registered_at, metadata: user.metadata, federated_metadata: user.federated_metadata, group: user.group, authorized_scopes: Accounts.get_user_scopes(user.id), organizations: Accounts.get_user_organizations(user.id), roles: Accounts.get_user_roles(user.id) |> Enum.filter(fn %Role{id: id} -> Enum.find(user.roles, fn %UserRole{role_id: role_id} -> role_id == id end) _ -> false end), backend: render_one(user.backend, BackendView, "backend.json", backend: user.backend) } end def render("import_result.json", %{import_result: import_result}) do import_result end defimpl Jason.Encoder, for: Boruta.Oauth.Scope do def encode(scope, opts) do Jason.Encode.map(Map.take(scope, [:id, :name, :public]), opts) end end defimpl Jason.Encoder, for: BorutaIdentity.Accounts.Role do def encode(role, opts) do Jason.Encode.map(Map.take(role, [:id, :name, :scopes]), opts) end end defimpl Jason.Encoder, for: BorutaIdentity.Organizations.Organization do def encode(role, opts) do Jason.Encode.map(Map.take(role, [:id, :name, :label]), opts) end end defimpl Jason.Encoder, for: Ecto.Changeset do def encode(changeset, _opts) do changeset |> ChangesetView.translate_errors() |> Jason.encode!() end end end ================================================ FILE: apps/boruta_admin/lib/boruta_admin_web.ex ================================================ defmodule BorutaAdminWeb do @moduledoc """ The entrypoint for defining your web interface, such as controllers, views, channels and so on. This can be used in your application as: use BorutaAdminWeb, :controller use BorutaAdminWeb, :view The definitions below will be executed for every view, controller, etc, so keep them short and clean, focused on imports, uses and aliases. Do NOT define functions inside the quoted expressions below. Instead, define any helper function in modules and import those modules here. """ def controller do quote do use Phoenix.Controller, namespace: BorutaAdminWeb import Plug.Conn import BorutaAdminWeb.Gettext alias BorutaAdminWeb.Router.Helpers, as: Routes end end def view do quote do use Phoenix.View, root: "lib/boruta_admin_web/templates", namespace: BorutaAdminWeb # Import convenience functions from controllers import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] # Include shared imports and aliases for views unquote(view_helpers()) end end def router do quote do use Phoenix.Router import Plug.Conn import Phoenix.Controller end end def channel do quote do use Phoenix.Channel, log_join: false import BorutaAdminWeb.Gettext end end defp view_helpers do quote do # Use all HTML functionality (forms, tags, etc) use Phoenix.HTML # Import basic rendering functionality (render, render_layout, etc) import Phoenix.View import BorutaAdminWeb.ErrorHelpers import BorutaAdminWeb.Gettext alias BorutaAdminWeb.Router.Helpers, as: Routes end end @doc """ When used, dispatch to the appropriate controller/view/etc. """ defmacro __using__(which) when is_atom(which) do apply(__MODULE__, which, []) end end ================================================ FILE: apps/boruta_admin/mix.exs ================================================ defmodule BorutaAdmin.MixProject do use Mix.Project def project do [ app: :boruta_admin, version: "0.1.0", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", lockfile: "../../mix.lock", elixir: "~> 1.7", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix] ++ Mix.compilers(), start_permanent: Mix.env() == :prod, aliases: aliases(), deps: deps() ] end # Configuration for the OTP application. # # Type `mix help compile.app` for more information. def application do [ mod: {BorutaAdmin.Application, []}, extra_applications: [:logger, :runtime_tools] ] end # Specifies which paths to compile per environment. defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] # Specifies your project dependencies. # # Type `mix help deps` for examples and options. defp deps do [ {:boruta_auth, in_umbrella: true}, {:boruta_gateway, in_umbrella: true}, {:boruta_identity, in_umbrella: true}, {:boruta_web, in_umbrella: true}, {:bypass, "~> 2.1.0", only: :test}, {:decorator, "~> 1.4"}, {:ecto_sql, "~> 3.4"}, {:ex_machina, "~> 2.4", only: :test}, {:finch, "~> 0.8"}, {:gettext, "~> 0.11"}, {:jason, "~> 1.0"}, {:phoenix, "~> 1.6.0", override: true}, {:phoenix_ecto, "~> 4.1"}, {:phoenix_html, "~> 3.0"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, {:plug_cowboy, "~> 2.0"}, {:postgrex, ">= 0.0.0"}, {:remote_ip, "~> 1.1"}, {:telemetry_metrics, "~> 0.6"}, {:telemetry_poller, "~> 0.5"} ] end # Aliases are shortcuts or tasks specific to the current project. # For example, to install project dependencies and perform other setup tasks, run: # # $ mix setup # # See the documentation for `Mix` for more info on aliases. defp aliases do [ setup: ["deps.get", "ecto.setup", "cmd npm install --prefix assets"], "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] ] end end ================================================ FILE: apps/boruta_admin/priv/examples/configuration.yml ================================================ # Example configuration file --- version: "1.0" configuration: backend: - id: 00000000-0000-0000-0000-000000000001 name: Example backend verifiable_credentials: - version: "13" credential_identifier: BorutaCredential format: jwt_vc types: VerifiableCredential BorutaCredentialJwtVc claims: - type: attribute name: boruta_username label: boruta username pointer: email display: name: Boruta username (JWT VC) background_color: "#ffd758" text_color: "#333333" logo: url: https://io.malach.it/assets/images/logo.png alt_text: malachit logo verifiable_presentations: - presentation_identifier: BorutaCredentialJwtVc presentation_definition: | { "id": "credential", "input_descriptors": [ { "id": "boruta_username", "format": { "jwt_vc": {} }, "constraints": { "fields": [ { "path": [ "$.boruta_username" ], "id": "Boruta account information", "purpose": "Present account information to obtain access or further credentials" } ] } } ] } identity_provider: - id: 00000000-0000-0000-0000-000000000001 name: Example identity provider backend_id: 00000000-0000-0000-0000-000000000001 consentable: true choose_session: true registrable: true client: - id: 00000000-0000-0000-0000-000000000001 name: Example client identity_provider: id: 00000000-0000-0000-0000-000000000001 redirect_uris: - https://redirect.uri.boruta - "{{PREAUTHORIZED_CODE_REDIRECT_URI}}" - "{{PRESENTATION_REDIRECT_URI}}" - openid4vp:// - openid-credential-offer:// scope: - name: BorutaCredentialJwtVc label: boruta username public: true ================================================ FILE: apps/boruta_admin/priv/gettext/en/LC_MESSAGES/errors.po ================================================ ## `msgid`s in this file come from POT (.pot) files. ## ## Do not add, change, or remove `msgid`s manually here as ## they're tied to the ones in the corresponding POT file ## (with the same domain). ## ## Use `mix gettext.extract --merge` or `mix gettext.merge` ## to merge POT files into PO files. msgid "" msgstr "" "Language: en\n" ## From Ecto.Changeset.cast/4 msgid "can't be blank" msgstr "" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" msgstr "" ## From Ecto.Changeset.put_change/3 msgid "is invalid" msgstr "" ## From Ecto.Changeset.validate_acceptance/3 msgid "must be accepted" msgstr "" ## From Ecto.Changeset.validate_format/3 msgid "has invalid format" msgstr "" ## From Ecto.Changeset.validate_subset/3 msgid "has an invalid entry" msgstr "" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" msgstr "" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" msgstr "" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" msgstr "" msgid "are still associated with this entry" msgstr "" ## From Ecto.Changeset.validate_length/3 msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have %{count} item(s)" msgid_plural "should have %{count} item(s)" msgstr[0] "" msgstr[1] "" msgid "should be at least %{count} character(s)" msgid_plural "should be at least %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have at least %{count} item(s)" msgid_plural "should have at least %{count} item(s)" msgstr[0] "" msgstr[1] "" msgid "should be at most %{count} character(s)" msgid_plural "should be at most %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have at most %{count} item(s)" msgid_plural "should have at most %{count} item(s)" msgstr[0] "" msgstr[1] "" ## From Ecto.Changeset.validate_number/3 msgid "must be less than %{number}" msgstr "" msgid "must be greater than %{number}" msgstr "" msgid "must be less than or equal to %{number}" msgstr "" msgid "must be greater than or equal to %{number}" msgstr "" msgid "must be equal to %{number}" msgstr "" ================================================ FILE: apps/boruta_admin/priv/gettext/errors.pot ================================================ ## This is a PO Template file. ## ## `msgid`s here are often extracted from source code. ## Add new translations manually only if they're dynamic ## translations that can't be statically extracted. ## ## Run `mix gettext.extract` to bring this file up to ## date. Leave `msgstr`s empty as changing them here has no ## effect: edit them in PO (`.po`) files instead. ## From Ecto.Changeset.cast/4 msgid "can't be blank" msgstr "" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" msgstr "" ## From Ecto.Changeset.put_change/3 msgid "is invalid" msgstr "" ## From Ecto.Changeset.validate_acceptance/3 msgid "must be accepted" msgstr "" ## From Ecto.Changeset.validate_format/3 msgid "has invalid format" msgstr "" ## From Ecto.Changeset.validate_subset/3 msgid "has an invalid entry" msgstr "" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" msgstr "" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" msgstr "" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" msgstr "" msgid "are still associated with this entry" msgstr "" ## From Ecto.Changeset.validate_length/3 msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have %{count} item(s)" msgid_plural "should have %{count} item(s)" msgstr[0] "" msgstr[1] "" msgid "should be at least %{count} character(s)" msgid_plural "should be at least %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have at least %{count} item(s)" msgid_plural "should have at least %{count} item(s)" msgstr[0] "" msgstr[1] "" msgid "should be at most %{count} character(s)" msgid_plural "should be at most %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have at most %{count} item(s)" msgid_plural "should have at most %{count} item(s)" msgstr[0] "" msgstr[1] "" ## From Ecto.Changeset.validate_number/3 msgid "must be less than %{number}" msgstr "" msgid "must be greater than %{number}" msgstr "" msgid "must be less than or equal to %{number}" msgstr "" msgid "must be greater than or equal to %{number}" msgstr "" msgid "must be equal to %{number}" msgstr "" ================================================ FILE: apps/boruta_admin/priv/repo/migrations/.formatter.exs ================================================ [ import_deps: [:ecto_sql], inputs: ["*.exs"] ] ================================================ FILE: apps/boruta_admin/priv/repo/migrations/20240508054424_create_configurations.exs ================================================ defmodule BorutaAdmin.Repo.Migrations.CreateConfigurations do use Ecto.Migration def change do create table(:configurations, primary_key: false) do add :id, :uuid, primary_key: true add :name, :string, null: false add :value, :text, null: false timestamps() end create index(:configurations, [:name], unique: true) end end ================================================ FILE: apps/boruta_admin/priv/repo/seeds.exs ================================================ # Script for populating the database. You can run it as: # # mix run priv/repo/seeds.exs # # Inside the script, you can read and write to any of your # repositories directly: # # BorutaAdmin.Repo.insert!(%BorutaAdmin.SomeSchema{}) # # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. ================================================ FILE: apps/boruta_admin/priv/test/configuration_files/bad_backend_configuration.yml ================================================ --- version: "1.0" configuration: backend: - name: bad backend additional: error ================================================ FILE: apps/boruta_admin/priv/test/configuration_files/bad_client_configuration.yml ================================================ --- version: "1.0" configuration: client: - access_token_ttl: 10 ================================================ FILE: apps/boruta_admin/priv/test/configuration_files/bad_error_template_configuration.yml ================================================ --- version: "1.0" configuration: error_template: - type: "999" content: test ================================================ FILE: apps/boruta_admin/priv/test/configuration_files/bad_identity_provider_configuration.yml ================================================ --- version: "1.0" configuration: identity_provider: - name: bad backend additional: error ================================================ FILE: apps/boruta_admin/priv/test/configuration_files/bad_organization_configuration.yml ================================================ --- version: "1.0" configuration: organization: - name: "" label: bad organization additional: true ================================================ FILE: apps/boruta_admin/priv/test/configuration_files/bad_role_configuration.yml ================================================ --- version: "1.0" configuration: role: - name: "" scopes: [] ================================================ FILE: apps/boruta_admin/priv/test/configuration_files/bad_scope_configuration.yml ================================================ --- version: "1.0" configuration: scope: - name: "" label: bad scope ================================================ FILE: apps/boruta_admin/priv/test/configuration_files/full_configuration.yml ================================================ --- version: "1.0" configuration: node_name: "full-configuration" gateway: - authorize: true error_content_type: "test" forbidden_response: "test" unauthorized_response: "test" forwarded_token_secret: "test" forwarded_token_signature_alg: "HS384" host: "httpbin.patatoid.fr" port: 80 uris: ["/httpbin"] max_idle_time: 10 pool_count: 1 pool_size: 10 required_scopes: GET: ["test"] scheme: "http" strip_uri: true microgateway: - authorize: true error_content_type: "test" forbidden_response: "test" unauthorized_response: "test" forwarded_token_secret: "test" forwarded_token_signature_alg: "HS384" host: "httpbin.patatoid.fr" port: 80 uris: ["/httpbin"] max_idle_time: 10 pool_count: 1 pool_size: 10 required_scopes: GET: ["test"] scheme: "http" strip_uri: true backend: - id: 21b90c7e-8658-44c9-94c5-7a1af32045c4 name: test identity_provider: - id: dce4eec9-db5c-4f08-abbd-fc57c6a11f99 backend_id: 21b90c7e-8658-44c9-94c5-7a1af32045c4 name: test templates: - type: layout content: test client: - identity_provider: id: dce4eec9-db5c-4f08-abbd-fc57c6a11f99 name: test role: - id: 1582534e-098d-4221-9c53-4a3631da9d78 name: test scopes: - id: 9f163ee9-2d1b-4209-9dd2-f319cd063a9b scope: - id: 9f163ee9-2d1b-4209-9dd2-f319cd063a9b name: test name: test error_template: - type: "500" content: test organization: - name: test ================================================ FILE: apps/boruta_admin/test/boruta_admin/configuration_loader_test.exs ================================================ defmodule BorutaAdmin.ConfigurationLoaderTest do use BorutaAdmin.DataCase alias Boruta.Ecto.Client alias Boruta.Ecto.Scope alias BorutaAdmin.ConfigurationLoader alias BorutaGateway.Upstreams.Upstream alias BorutaIdentity.Accounts.Role alias BorutaIdentity.Configuration.ErrorTemplate alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaIdentity.IdentityProviders.Template alias BorutaIdentity.Organizations.Organization test "returns an error with a bad configuration file" do assert BorutaGateway.Repo.all(Upstream) |> Enum.empty?() configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/bad_configuration.yml") assert ConfigurationLoader.from_file!(configuration_file_path) == {:error, "Bad configuration file."} end test "returns an error with a bad gateway configuration file" do assert BorutaGateway.Repo.all(Upstream) |> Enum.empty?() configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/bad_gateway_configuration.yml") assert ConfigurationLoader.from_file!(configuration_file_path) == {:ok, %{ gateway: ["Required properties scheme, host, port, uris are missing at #."] }} end test "returns an error with a bad microgateway configuration file" do assert BorutaGateway.Repo.all(Upstream) |> Enum.empty?() configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/bad_microgateway_configuration.yml") assert ConfigurationLoader.from_file!(configuration_file_path) == {:ok, %{ gateway: [], microgateway: [ "Required properties scheme, host, port, uris are missing at #." ] }} end test "returns an error with a bad organization configuration file" do assert BorutaIdentity.Repo.all(Backend) |> Enum.count() == 1 configuration_file_path = :code.priv_dir(:boruta_admin) |> Path.join("/test/configuration_files/bad_organization_configuration.yml") assert ConfigurationLoader.from_file!(configuration_file_path) == {:ok, %{ organization: ["Schema does not allow additional properties: #/additional."] }} end test "returns an error with a bad identity provider configuration file" do assert BorutaIdentity.Repo.all(IdentityProvider) |> Enum.empty?() configuration_file_path = :code.priv_dir(:boruta_admin) |> Path.join("/test/configuration_files/bad_identity_provider_configuration.yml") assert ConfigurationLoader.from_file!(configuration_file_path) == {:ok, %{ identity_provider: ["Schema does not allow additional properties: #/additional."] }} end test "returns an error with a bad backend configuration file" do assert BorutaIdentity.Repo.all(Backend) |> Enum.count() == 1 configuration_file_path = :code.priv_dir(:boruta_admin) |> Path.join("/test/configuration_files/bad_backend_configuration.yml") assert ConfigurationLoader.from_file!(configuration_file_path) == {:ok, %{ backend: ["Schema does not allow additional properties: #/additional."] }} end test "returns an error with a bad client configuration file" do assert BorutaAuth.Repo.all(Client) |> Enum.count() == 1 configuration_file_path = :code.priv_dir(:boruta_admin) |> Path.join("/test/configuration_files/bad_client_configuration.yml") assert {:ok, %{ client: [ %Ecto.Changeset{ errors: [identity_provider_id: {"can't be blank", [validation: :required]}] } ] }} = ConfigurationLoader.from_file!(configuration_file_path) end test "returns an error with a bad scope configuration file" do assert BorutaAuth.Repo.all(Scope) |> Enum.empty?() configuration_file_path = :code.priv_dir(:boruta_admin) |> Path.join("/test/configuration_files/bad_scope_configuration.yml") assert {:ok, %{ scope: [ %Ecto.Changeset{errors: [name: {"can't be blank", [validation: :required]}]} ] }} = ConfigurationLoader.from_file!(configuration_file_path) end test "returns an error with a bad role configuration file" do assert BorutaIdentity.Repo.all(Role) |> Enum.empty?() configuration_file_path = :code.priv_dir(:boruta_admin) |> Path.join("/test/configuration_files/bad_role_configuration.yml") assert {:ok, %{ role: [%Ecto.Changeset{errors: [name: {"can't be blank", [validation: :required]}]}] }} = ConfigurationLoader.from_file!(configuration_file_path) end test "returns an error with a bad error template configuration file" do configuration_file_path = :code.priv_dir(:boruta_admin) |> Path.join("/test/configuration_files/bad_error_template_configuration.yml") assert {:ok, %{ error_template: ["Error template does not exist."] }} = ConfigurationLoader.from_file!(configuration_file_path) end test "loads a file" do assert BorutaGateway.Repo.all(Upstream) |> Enum.empty?() Application.delete_env(ConfigurationLoader, :node_name) configuration_file_path = :code.priv_dir(:boruta_admin) |> Path.join("/test/configuration_files/full_configuration.yml") ConfigurationLoader.from_file!(configuration_file_path) assert [ %Upstream{ scheme: "http", host: "httpbin.patatoid.fr", port: 80, uris: ["/httpbin"], required_scopes: %{"GET" => ["test"]}, strip_uri: true, authorize: true, pool_size: 10, pool_count: 1, max_idle_time: 10, error_content_type: "test", forbidden_response: "test", unauthorized_response: "test", forwarded_token_signature_alg: "HS384", forwarded_token_secret: "test", forwarded_token_public_key: nil, forwarded_token_private_key: nil }, %Upstream{ node_name: "nonode@nohost", scheme: "http", host: "httpbin.patatoid.fr", port: 80, uris: ["/httpbin"], required_scopes: %{"GET" => ["test"]}, strip_uri: true, authorize: true, pool_size: 10, pool_count: 1, max_idle_time: 10, error_content_type: "test", forbidden_response: "test", unauthorized_response: "test", forwarded_token_signature_alg: "HS384", forwarded_token_secret: "test", forwarded_token_public_key: nil, forwarded_token_private_key: nil } ] = BorutaGateway.Repo.all(Upstream) # TODO test all possible configurations assert %Backend{name: "test"} = BorutaIdentity.Repo.all(Backend) |> List.last() assert %IdentityProvider{ name: "test", templates: [%Template{content: "test", type: "layout"}] } = BorutaIdentity.Repo.all(IdentityProvider) |> List.last() |> BorutaIdentity.Repo.preload(:templates) assert %Client{name: "test"} = BorutaAuth.Repo.all(Client) |> List.last() assert %Scope{name: "test"} = BorutaAuth.Repo.all(Scope) |> List.last() assert %Role{name: "test"} = BorutaIdentity.Repo.all(Role) |> List.last() assert %Organization{name: "test"} = BorutaIdentity.Repo.all(Organization) |> List.last() assert %ErrorTemplate{type: "500", content: "test"} = BorutaIdentity.Repo.all(ErrorTemplate) |> List.last() end test "loads example file" do assert BorutaGateway.Repo.all(Upstream) |> Enum.empty?() Application.delete_env(ConfigurationLoader, :node_name) configuration_file_path = :code.priv_dir(:boruta_admin) |> Path.join("/examples/configuration.yml") assert {:ok, %{ client: [ %Ecto.Changeset{ errors: [ redirect_uris: {"`{{PREAUTHORIZED_CODE_REDIRECT_URI}}` is invalid", []}, redirect_uris: {"`{{PRESENTATION_REDIRECT_URI}}` is invalid", []} ] } ] }} = ConfigurationLoader.from_file!(configuration_file_path) assert %Backend{ name: "Example backend", id: "00000000-0000-0000-0000-000000000001", verifiable_credentials: [ %{ "claims" => [ %{ "label" => "boruta username", "name" => "boruta_username", "pointer" => "email" } ], "credential_identifier" => "BorutaCredentialJwtVc", "display" => %{ "background_color" => "#ffd758", "logo" => %{ "alt_text" => "malachit logo", "url" => "https://io.malach.it/assets/images/logo.png" }, "name" => "Boruta username (JWT VC)", "text_color" => "#333333" }, "format" => "jwt_vc", "types" => "VerifiableCredential BorutaCredentialJwtVc", "version" => "13" } ], verifiable_presentations: [ %{ "presentation_definition" => "{\n \"id\": \"credential\",\n \"input_descriptors\": [\n {\n \"id\": \"boruta_username\",\n \"format\": {\n \"jwt_vc\": {}\n },\n \"constraints\": {\n \"fields\": [\n {\n \"path\": [ \"$.boruta_username\" ]\n }\n ]\n }\n }\n ]\n}\n", "presentation_identifier" => "BorutaCredentialJwtVc" } ] } = BorutaIdentity.Repo.all(Backend) |> List.last() assert %IdentityProvider{ id: "00000000-0000-0000-0000-000000000001", backend_id: "00000000-0000-0000-0000-000000000001", name: "Example identity provider", consentable: true, choose_session: true, registrable: true } = BorutaIdentity.Repo.all(IdentityProvider) |> List.last() |> BorutaIdentity.Repo.preload(:templates) assert %Scope{ name: "BorutaCredentialJwtVc", label: "boruta username", public: true } = BorutaAuth.Repo.all(Scope) |> List.last() end end ================================================ FILE: apps/boruta_admin/test/boruta_admin_web/controllers/backend_controller_test.exs ================================================ defmodule BorutaAdminWeb.BackendControllerTest do use BorutaAdminWeb.ConnCase import BorutaIdentity.Factory alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.Repo @create_attrs %{name: "some name"} @update_attrs %{name: "some updated name"} @invalid_attrs %{name: nil, type: "other"} @update_email_template_attrs %{ txt_content: "some updated content" } @invalid_email_template_attrs %{ txt_content: nil } setup %{conn: conn} do {:ok, conn: put_req_header(conn, "accept", "application/json")} end # TODO test sub restriction test "returns a 401", %{conn: conn} do assert conn |> get(Routes.admin_backend_path(conn, :index)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> get(Routes.admin_backend_path(conn, :show, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> post(Routes.admin_backend_path(conn, :create)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> patch(Routes.admin_backend_path(conn, :update, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> delete(Routes.admin_backend_path(conn, :delete, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } end describe "with bad scope" do @tag authorized: ["bad:scope"] test "returns a 403", %{conn: conn} do assert conn |> get(Routes.admin_backend_path(conn, :index)) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> get(Routes.admin_backend_path(conn, :show, "id")) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> post(Routes.admin_backend_path(conn, :create)) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> patch(Routes.admin_backend_path(conn, :update, "id")) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> delete(Routes.admin_backend_path(conn, :delete, "id")) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } end end describe "index" do @tag authorized: ["identity-providers:manage:all"] test "lists all backends", %{conn: conn} do conn = get(conn, Routes.admin_backend_path(conn, :index)) assert is_list(json_response(conn, 200)["data"]) end end describe "show" do setup [:create_backend] @tag authorized: ["identity-providers:manage:all"] test "renders not found", %{conn: conn} do assert_raise Ecto.NoResultsError, fn -> get(conn, Routes.admin_backend_path(conn, :show, "unexisting")) end end @tag authorized: ["identity-providers:manage:all"] test "shows a backend", %{conn: conn, backend: backend} do conn = get(conn, Routes.admin_backend_path(conn, :show, backend)) name = backend.name assert %{ "id" => _id, "name" => ^name, "type" => "Elixir.BorutaIdentity.Accounts.Internal" } = json_response(conn, 200)["data"] end end describe "create" do @tag authorized: ["identity-providers:manage:all"] test "renders a bad request", %{conn: conn} do conn = post(conn, Routes.admin_backend_path(conn, :create), %{}) assert json_response(conn, 400) == %{ "code" => "BAD_REQUEST", "errors" => %{ "resource" => ["the requested with given parameters cannot be processed."] }, "message" => "The requested with given parameters cannot be processed." } end @tag authorized: ["identity-providers:manage:all"] test "renders an error when params are invalid", %{conn: conn} do conn = post(conn, Routes.admin_backend_path(conn, :create), %{ "backend" => @invalid_attrs }) assert json_response(conn, 422) == %{ "code" => "UNPROCESSABLE_ENTITY", "errors" => %{"name" => ["can't be blank"], "type" => ["is invalid"]}, "message" => "Your request could not be processed." } end @tag authorized: ["identity-providers:manage:all"] test "creates a backend", %{conn: conn} do conn = post(conn, Routes.admin_backend_path(conn, :create), %{"backend" => @create_attrs}) assert %{ "id" => _id, "name" => "some name", "type" => "Elixir.BorutaIdentity.Accounts.Internal" } = json_response(conn, 201)["data"] end end describe "update" do setup [:create_backend] @tag authorized: ["identity-providers:manage:all"] test "renders not found", %{conn: conn} do assert_raise Ecto.NoResultsError, fn -> patch(conn, Routes.admin_backend_path(conn, :update, "unexisting"), %{"backend" => %{}}) end end @tag authorized: ["identity-providers:manage:all"] test "renders a bad request", %{conn: conn} do conn = patch(conn, Routes.admin_backend_path(conn, :update, "id"), %{}) assert json_response(conn, 400) == %{ "code" => "BAD_REQUEST", "errors" => %{ "resource" => ["the requested with given parameters cannot be processed."] }, "message" => "The requested with given parameters cannot be processed." } end @tag authorized: ["identity-providers:manage:all"] test "renders an error when params are invalid", %{conn: conn, backend: backend} do conn = patch(conn, Routes.admin_backend_path(conn, :update, backend), %{ "backend" => @invalid_attrs }) assert json_response(conn, 422) == %{ "code" => "UNPROCESSABLE_ENTITY", "errors" => %{"name" => ["can't be blank"], "type" => ["is invalid"]}, "message" => "Your request could not be processed." } end @tag authorized: ["identity-providers:manage:all"] test "updates a backend", %{conn: conn, backend: backend} do conn = patch(conn, Routes.admin_backend_path(conn, :update, backend), %{"backend" => @update_attrs}) assert %{ "id" => _id, "name" => "some updated name", "type" => "Elixir.BorutaIdentity.Accounts.Internal" } = json_response(conn, 200)["data"] end end describe "delete" do setup [:create_backend] @tag authorized: ["identity-providers:manage:all"] test "renders not found", %{conn: conn} do assert_raise Ecto.NoResultsError, fn -> get(conn, Routes.admin_backend_path(conn, :show, "unexisting")) end end @tag authorized: ["identity-providers:manage:all"] test "deletes a backend", %{conn: conn, backend: backend} do conn = delete(conn, Routes.admin_backend_path(conn, :delete, backend)) assert response(conn, 204) refute Repo.get(Backend, backend.id) end end describe "show abckend email template" do setup [:create_backend] @tag authorized: ["identity-providers:manage:all"] test "renders not found", %{conn: conn, backend: %Backend{id: id}} do assert_raise Ecto.NoResultsError, fn -> get(conn, Routes.admin_backend_email_template_path(conn, :email_template, id, "unexisting")) end end @tag authorized: ["identity-providers:manage:all"] test "renders a backend email template", %{ conn: conn, backend: %Backend{id: id} } do conn = get( conn, Routes.admin_backend_email_template_path(conn, :email_template, id, "reset_password_instructions") ) assert %{"backend_id" => ^id, "type" => "reset_password_instructions"} = json_response(conn, 200)["data"] end end describe "update backend email template" do setup [:create_backend] @tag authorized: ["identity-providers:manage:all"] test "renders backend template when data is valid", %{ conn: conn, backend: %Backend{id: backend_id} } do conn = patch( conn, Routes.admin_backend_email_template_path( conn, :update_email_template, backend_id, "reset_password_instructions" ), template: @update_email_template_attrs ) assert %{"id" => template_id, "txt_content" => "some updated content"} = json_response(conn, 200)["data"] conn = get( conn, Routes.admin_backend_email_template_path( conn, :email_template, backend_id, "reset_password_instructions" ) ) assert %{ "id" => ^template_id, "txt_content" => "some updated content", "type" => "reset_password_instructions", "backend_id" => ^backend_id } = json_response(conn, 200)["data"] end @tag authorized: ["identity-providers:manage:all"] test "renders errors when data is invalid", %{ conn: conn, backend: backend } do conn = patch( conn, Routes.admin_backend_email_template_path( conn, :update_email_template, backend, "reset_password_instructions" ), template: @invalid_email_template_attrs ) assert json_response(conn, 422)["errors"] != %{} end end describe "delete backend email template" do setup [:create_backend] @tag authorized: ["identity-providers:manage:all"] test "respond a 404 when backend does not exist", %{ conn: conn } do backend_id = SecureRandom.uuid() type = "reset_password_instructions" assert_error_sent(404, fn -> delete( conn, Routes.admin_backend_email_template_path( conn, :delete_email_template, backend_id, type ) ) end) end @tag authorized: ["identity-providers:manage:all"] test "respond a 404 when template does not exist", %{ conn: conn, backend: %Backend{id: backend_id} } do type = "reset_password_instructions" assert_error_sent(404, fn -> delete( conn, Routes.admin_backend_email_template_path( conn, :delete_email_template, backend_id, type ) ) end) end @tag authorized: ["identity-providers:manage:all"] test "deletes backend template when template exists", %{ conn: conn, backend: %Backend{id: backend_id} = backend } do type = "reset_password_instructions" insert(:email_template, type: type, backend: backend) conn = delete( conn, Routes.admin_backend_email_template_path( conn, :delete_email_template, backend_id, type ) ) assert %{"id" => nil, "type" => "reset_password_instructions"} = json_response(conn, 200)["data"] end end def fixture(:backend) do {:ok, backend} = IdentityProviders.create_backend(@create_attrs) backend end defp create_backend(_) do backend = fixture(:backend) %{backend: backend} end end ================================================ FILE: apps/boruta_admin/test/boruta_admin_web/controllers/client_controller_test.exs ================================================ defmodule BorutaAdminWeb.ClientControllerTest do import Boruta.Factory use BorutaAdminWeb.ConnCase alias Boruta.Ecto.Client alias BorutaIdentity.IdentityProviders.ClientIdentityProvider @create_attrs %{ redirect_uris: ["http://redirect.uri"], access_token_ttl: 10, authorization_code_ttl: 10, identity_provider: nil } @update_attrs %{ redirect_uris: ["http://updated.redirect.uri"] } @invalid_attrs %{ redirect_uris: ["bad_uri"] } setup %{conn: conn} do {:ok, conn: put_req_header(conn, "accept", "application/json")} end # TODO test sub restriction test "returns a 401", %{conn: conn} do conn = get(conn, Routes.admin_client_path(conn, :index)) assert response(conn, 401) end describe "with bad scope" do @tag authorized: ["bad:scope"] test "returns a 403", %{conn: conn} do conn = get(conn, Routes.admin_client_path(conn, :index)) assert response(conn, 403) end end describe "index" do @tag authorized: ["clients:manage:all"] test "lists all clients", %{conn: conn} do conn = get(conn, Routes.admin_client_path(conn, :index)) assert length(json_response(conn, 200)["data"]) == 2 end end describe "create client" do setup %{conn: conn} do identity_provider = BorutaIdentity.Factory.insert(:identity_provider) {:ok, conn: conn, identity_provider: identity_provider} end @tag authorized: ["clients:manage:all"] test "renders errors when data is invalid", %{conn: conn} do conn = post(conn, Routes.admin_client_path(conn, :create), client: @invalid_attrs) assert json_response(conn, 422)["errors"] != %{} end @tag authorized: ["clients:manage:all"] test "renders errors when identity provider is missing", %{conn: conn} do create_attrs = %{@create_attrs | identity_provider: %{id: SecureRandom.uuid()}} create = post(conn, Routes.admin_client_path(conn, :create), client: create_attrs) assert %{"identity_provider_id" => ["does not exist"]} = json_response(create, 422)["errors"] end @tag authorized: ["clients:manage:all"] test "renders errors when identity provider has invalid uuid", %{conn: conn} do create_attrs = %{@create_attrs | identity_provider: %{id: "bad_uuid"}} create = post(conn, Routes.admin_client_path(conn, :create), client: create_attrs) assert %{"identity_provider_id" => ["has invalid format"]} = json_response(create, 422)["errors"] end @tag authorized: ["clients:manage:all"] test "renders client when data is valid", %{conn: conn, identity_provider: identity_provider} do create_attrs = %{@create_attrs | identity_provider: %{id: identity_provider.id}} create = post(conn, Routes.admin_client_path(conn, :create), client: create_attrs) assert %{"id" => _id} = json_response(create, 201)["data"] end end describe "update client" do setup %{conn: conn} do client = insert(:client) identity_provider = BorutaIdentity.Factory.insert(:identity_provider) BorutaIdentity.Factory.insert(:client_identity_provider, client_id: client.id, identity_provider: identity_provider ) {:ok, conn: conn, client: client} end @tag authorized: ["clients:manage:all"] test "renders errors when data is invalid", %{conn: conn, client: client} do conn = put(conn, Routes.admin_client_path(conn, :update, client), client: @invalid_attrs) assert json_response(conn, 422)["errors"] != %{} end @tag authorized: ["clients:manage:all"] test "renders errors when identity provider is invalid", %{conn: conn, client: client} do update_attrs = Map.put(@update_attrs, "identity_provider", %{"id" => SecureRandom.uuid()}) conn = put(conn, Routes.admin_client_path(conn, :update, client), client: update_attrs) assert %{"identity_provider_id" => ["does not exist"]} = json_response(conn, 422)["errors"] end @tag authorized: ["clients:manage:all"] test "cannot update administration ui client", %{conn: conn, client: client} do current_admin_ui_client_id = System.get_env("BORUTA_ADMIN_OAUTH_CLIENT_ID", "") System.put_env("BORUTA_ADMIN_OAUTH_CLIENT_ID", client.id) conn = put(conn, Routes.admin_client_path(conn, :update, client), client: @update_attrs) assert response(conn, 403) System.put_env("BORUTA_ADMIN_OAUTH_CLIENT_ID", current_admin_ui_client_id) end @tag authorized: ["clients:manage:all"] test "updates client identity provider when data is valid", %{ conn: conn, client: %Client{id: id} = client } do identity_provider = BorutaIdentity.Factory.insert(:identity_provider) update_attrs = Map.put(@update_attrs, "identity_provider", %{"id" => identity_provider.id}) conn = put(conn, Routes.admin_client_path(conn, :update, client), client: update_attrs) assert %{"id" => ^id} = json_response(conn, 200)["data"] assert BorutaIdentity.Repo.get_by(ClientIdentityProvider, client_id: id, identity_provider_id: identity_provider.id ) end @tag authorized: ["clients:manage:all"] test "renders client when data is valid", %{conn: conn, client: %Client{id: id} = client} do conn = put(conn, Routes.admin_client_path(conn, :update, client), client: @update_attrs) assert %{ "id" => ^id, "redirect_uris" => ["http://updated.redirect.uri"] } = json_response(conn, 200)["data"] end @tag :skip test "updates a client with a global key pair" end describe "regenerate client key pair" do setup %{conn: conn} do client = insert(:client) identity_provider = BorutaIdentity.Factory.insert(:identity_provider) BorutaIdentity.Factory.insert(:client_identity_provider, client_id: client.id, identity_provider: identity_provider ) {:ok, conn: conn, client: client} end @tag authorized: ["clients:manage:all"] test "regenerates client key pair", %{conn: conn, client: client} do public_key = client.public_key conn = post(conn, Routes.admin_client_path(conn, :regenerate_key_pair, client)) assert %{"data" => %{ "public_key" => new_public_key }} = json_response(conn, 200) assert new_public_key != public_key end end describe "delete client" do setup %{conn: conn} do client = insert(:client) {:ok, conn: conn, client: client} end @tag authorized: ["clients:manage:all"] test "cannot delete administration ui client", %{conn: conn, client: client} do current_admin_ui_client_id = System.get_env("BORUTA_ADMIN_OAUTH_CLIENT_ID", "") System.put_env("BORUTA_ADMIN_OAUTH_CLIENT_ID", client.id) conn = delete(conn, Routes.admin_client_path(conn, :delete, client)) assert response(conn, 403) System.put_env("BORUTA_ADMIN_OAUTH_CLIENT_ID", current_admin_ui_client_id) end @tag authorized: ["clients:manage:all"] test "returns an error when client does not exist", %{conn: conn} do assert_error_sent(404, fn -> delete(conn, Routes.admin_client_path(conn, :delete, SecureRandom.uuid())) end) end @tag authorized: ["clients:manage:all"] test "deletes chosen client", %{conn: conn, client: client} do conn = delete(conn, Routes.admin_client_path(conn, :delete, client)) assert response(conn, 204) assert_error_sent(404, fn -> get(conn, Routes.admin_client_path(conn, :show, client)) end) end @tag authorized: ["clients:manage:all"] test "deletes client identity provider association", %{conn: conn, client: client} do BorutaIdentity.Factory.insert(:client_identity_provider, client_id: client.id) conn = delete(conn, Routes.admin_client_path(conn, :delete, client)) assert response(conn, 204) refute BorutaIdentity.Repo.get_by(ClientIdentityProvider, client_id: client.id) end end end ================================================ FILE: apps/boruta_admin/test/boruta_admin_web/controllers/configuration_controller_test.exs ================================================ defmodule BorutaAdminWeb.ConfigurationControllerTest do use BorutaAdminWeb.ConnCase import BorutaIdentity.Factory @update_error_template_attrs %{ content: "some updated content" } @invalid_attrs %{content: nil} # TODO test sub restriction test "returns a 401", %{conn: conn} do assert conn |> get( Routes.admin_configuration_error_template_path( conn, :error_template, "template_type" ) ) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> patch( Routes.admin_configuration_error_template_path( conn, :update_error_template, "template_type" ) ) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> delete( Routes.admin_configuration_error_template_path( conn, :delete_error_template, "template_type" ) ) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } end describe "with bad scope" do @tag authorized: ["bad:scope"] test "returns a 403", %{conn: conn} do assert conn |> get( Routes.admin_configuration_error_template_path( conn, :error_template, "template_type" ) ) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> patch( Routes.admin_configuration_error_template_path( conn, :update_error_template, "template_type" ) ) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> delete( Routes.admin_configuration_error_template_path( conn, :delete_error_template, "template_type" ) ) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } end end @tag :skip test "get an configuration template" describe "update configuration template" do @tag authorized: ["configuration:manage:all"] test "renders configuration when data is valid", %{ conn: conn } do conn = patch( conn, Routes.admin_configuration_error_template_path( conn, :update_error_template, "400" ), template: @update_error_template_attrs ) assert %{"id" => template_id, "content" => "some updated content"} = json_response(conn, 200)["data"] conn = get( conn, Routes.admin_configuration_error_template_path( conn, :error_template, "400" ) ) assert %{ "id" => ^template_id, "content" => "some updated content", "type" => "400" } = json_response(conn, 200)["data"] end @tag authorized: ["configuration:manage:all"] test "renders errors when data is invalid", %{conn: conn} do conn = patch( conn, Routes.admin_configuration_error_template_path(conn, :update_error_template, "400"), template: @invalid_attrs ) assert json_response(conn, 422)["errors"] != %{} end end describe "delete error template" do @tag authorized: ["configuration:manage:all"] test "respond a 404 when error template does not exist", %{ conn: conn } do type = "400" assert_error_sent(404, fn -> delete( conn, Routes.admin_configuration_error_template_path( conn, :delete_error_template, type ) ) end) end @tag authorized: ["configuration:manage:all"] test "deletes configuration template when template exists", %{ conn: conn } do type = "400" insert(:error_template, type: type) conn = delete( conn, Routes.admin_configuration_error_template_path( conn, :delete_error_template, type ) ) assert %{"id" => nil, "type" => "400"} = json_response(conn, 200)["data"] end end describe "upsert configuration" do @tag authorized: ["configuration:manage:all", "clients:manage:all"] test "apply configuration file", %{conn: conn} do conn = post( conn, Routes.admin_configuration_upload_configuration_file_path( conn, :upload_configuration_file ), %{ "file" => %Plug.Upload{ path: :code.priv_dir(:boruta_admin) |> Path.join("/test/configuration_files/bad_client_configuration.yml"), filename: "file.yml" }, "options" => %{ "hash_password" => "true" } } ) assert json_response(conn, 200) == %{ "errors" => %{"client" => [%{"identity_provider_id" => ["can't be blank"]}]}, "file_content" => "---\nversion: \"1.0\"\nconfiguration:\n client:\n - access_token_ttl: 10\n" } end @tag authorized: ["configuration:manage:all"] test "does apply not authorized resources", %{conn: conn} do conn = post( conn, Routes.admin_configuration_upload_configuration_file_path( conn, :upload_configuration_file ), %{ "file" => %Plug.Upload{ path: :code.priv_dir(:boruta_admin) |> Path.join("/test/configuration_files/bad_client_configuration.yml"), filename: "file.yml" }, "options" => %{ "hash_password" => "true" } } ) assert json_response(conn, 200) == %{ "errors" => %{}, "file_content" => "---\nversion: \"1.0\"\nconfiguration:\n client:\n - access_token_ttl: 10\n" } end end end ================================================ FILE: apps/boruta_admin/test/boruta_admin_web/controllers/identity_provider_controller_test.exs ================================================ defmodule BorutaAdminWeb.IdentityProviderControllerTest do use BorutaAdminWeb.ConnCase import BorutaIdentity.Factory alias BorutaIdentity.IdentityProviders.IdentityProvider @create_attrs %{ name: "some name" } @update_attrs %{ name: "some updated name" } @update_template_attrs %{ content: "some updated content" } @invalid_attrs %{name: nil} @invalid_template_attrs %{content: nil} def fixture(:identity_provider) do insert(:identity_provider, @create_attrs) end setup %{conn: conn} do {:ok, conn: put_req_header(conn, "accept", "application/json")} end # TODO test sub restriction test "returns a 401", %{conn: conn} do assert conn |> get(Routes.admin_identity_provider_path(conn, :index)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> post(Routes.admin_identity_provider_path(conn, :create)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> patch(Routes.admin_identity_provider_path(conn, :update, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> delete(Routes.admin_identity_provider_path(conn, :delete, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> get( Routes.admin_identity_provider_template_path( conn, :template, "identity_provider_id", "template_type" ) ) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> patch( Routes.admin_identity_provider_template_path( conn, :update_template, "identity_provider_id", "template_type" ) ) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> delete( Routes.admin_identity_provider_template_path( conn, :delete_template, "identity_provider_id", "template_type" ) ) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } end describe "with bad scope" do @tag authorized: ["bad:scope"] test "returns a 403", %{conn: conn} do assert conn |> get(Routes.admin_identity_provider_path(conn, :index)) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> post(Routes.admin_identity_provider_path(conn, :create)) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> patch(Routes.admin_identity_provider_path(conn, :update, "id")) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> delete(Routes.admin_identity_provider_path(conn, :delete, "id")) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> get( Routes.admin_identity_provider_template_path( conn, :template, "identity_provider_id", "template_type" ) ) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> patch( Routes.admin_identity_provider_template_path( conn, :update_template, "identity_provider_id", "template_type" ) ) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> delete( Routes.admin_identity_provider_template_path( conn, :delete_template, "identity_provider_id", "template_type" ) ) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } end end describe "index" do @tag authorized: ["identity-providers:manage:all"] test "lists all identity_providers", %{conn: conn} do conn = get(conn, Routes.admin_identity_provider_path(conn, :index)) assert json_response(conn, 200)["data"] == [] end end describe "show" do setup [:create_identity_provider] @tag authorized: ["identity-providers:manage:all"] test "renders not found", %{conn: conn} do assert_raise Ecto.NoResultsError, fn -> get(conn, Routes.admin_identity_provider_path(conn, :show, SecureRandom.uuid())) end end @tag authorized: ["identity-providers:manage:all"] test "renders a identity provider", %{ conn: conn, identity_provider: %IdentityProvider{id: id} = identity_provider } do conn = get(conn, Routes.admin_identity_provider_path(conn, :show, identity_provider)) assert %{"id" => ^id} = json_response(conn, 200)["data"] end end describe "show template" do setup [:create_identity_provider] @tag authorized: ["identity-providers:manage:all"] test "renders not found", %{conn: conn, identity_provider: %IdentityProvider{id: id}} do assert_raise Ecto.NoResultsError, fn -> get(conn, Routes.admin_identity_provider_template_path(conn, :template, id, "unexisting")) end end @tag authorized: ["identity-providers:manage:all"] test "renders a identity provider template", %{ conn: conn, identity_provider: %IdentityProvider{id: id} } do conn = get( conn, Routes.admin_identity_provider_template_path(conn, :template, id, "new_registration") ) assert %{"identity_provider_id" => ^id, "type" => "new_registration"} = json_response(conn, 200)["data"] end end describe "create identity_provider" do @tag authorized: ["identity-providers:manage:all"] test "renders identity_provider when data is valid", %{conn: conn} do backend_id = insert(:backend).id conn = post(conn, Routes.admin_identity_provider_path(conn, :create), identity_provider: Map.put(@create_attrs, :backend_id, backend_id) ) assert %{"id" => id} = json_response(conn, 201)["data"] conn = get(conn, Routes.admin_identity_provider_path(conn, :show, id)) assert %{ "id" => ^id, "name" => "some name", "backend" => %{"id" => ^backend_id} } = json_response(conn, 200)["data"] end @tag authorized: ["identity-providers:manage:all"] test "renders errors when data is invalid", %{conn: conn} do conn = post(conn, Routes.admin_identity_provider_path(conn, :create), identity_provider: @invalid_attrs ) assert json_response(conn, 422)["errors"] != %{} end end describe "update identity_provider template" do setup [:create_identity_provider] @tag authorized: ["identity-providers:manage:all"] test "renders identity_provider template when data is valid", %{ conn: conn, identity_provider: %IdentityProvider{id: identity_provider_id} } do conn = patch( conn, Routes.admin_identity_provider_template_path( conn, :update_template, identity_provider_id, "new_registration" ), template: @update_template_attrs ) assert %{"id" => template_id, "content" => "some updated content"} = json_response(conn, 200)["data"] conn = get( conn, Routes.admin_identity_provider_template_path( conn, :template, identity_provider_id, "new_registration" ) ) assert %{ "id" => ^template_id, "content" => "some updated content", "type" => "new_registration", "identity_provider_id" => ^identity_provider_id } = json_response(conn, 200)["data"] end # NOTE the transaction sandbox avoids the test @tag :skip @tag authorized: ["identity-providers:manage:all"] test "do not reset when updated twice with same content", %{ conn: conn, identity_provider: %IdentityProvider{id: identity_provider_id} } do conn = patch( conn, Routes.admin_identity_provider_template_path( conn, :update_template, identity_provider_id, "new_registration" ), template: @update_template_attrs ) assert %{"id" => template_id, "content" => "some updated content"} = json_response(conn, 200)["data"] conn = patch( conn, Routes.admin_identity_provider_template_path( conn, :update_template, identity_provider_id, "new_registration" ), template: Map.put(@update_template_attrs, :id, template_id) ) assert %{"id" => template_id, "content" => "some updated content"} = json_response(conn, 200)["data"] conn = get( conn, Routes.admin_identity_provider_template_path( conn, :template, identity_provider_id, "new_registration" ) ) assert %{ "id" => ^template_id, "content" => "some updated content", "type" => "new_registration", "identity_provider_id" => ^identity_provider_id } = json_response(conn, 200)["data"] end @tag authorized: ["identity-providers:manage:all"] test "renders errors when data is invalid", %{ conn: conn, identity_provider: identity_provider } do conn = patch( conn, Routes.admin_identity_provider_template_path( conn, :update_template, identity_provider, "new_registration" ), template: @invalid_template_attrs ) assert json_response(conn, 422)["errors"] != %{} end end describe "delete identity_provider template" do setup [:create_identity_provider] @tag authorized: ["identity-providers:manage:all"] test "respond a 404 when identity provider does not exist", %{ conn: conn } do identity_provider_id = SecureRandom.uuid() type = "new_registration" assert_error_sent(404, fn -> delete( conn, Routes.admin_identity_provider_template_path( conn, :delete_template, identity_provider_id, type ) ) end) end @tag authorized: ["identity-providers:manage:all"] test "respond a 404 when template does not exist", %{ conn: conn, identity_provider: %IdentityProvider{id: identity_provider_id} } do type = "new_registration" assert_error_sent(404, fn -> delete( conn, Routes.admin_identity_provider_template_path( conn, :delete_template, identity_provider_id, type ) ) end) end @tag authorized: ["identity-providers:manage:all"] test "deletes identity_provider template when template exists", %{ conn: conn, identity_provider: %IdentityProvider{id: identity_provider_id} = identity_provider } do type = "new_registration" insert(:template, type: type, identity_provider: identity_provider) conn = delete( conn, Routes.admin_identity_provider_template_path( conn, :delete_template, identity_provider_id, type ) ) assert %{"id" => nil, "type" => "new_registration"} = json_response(conn, 200)["data"] end end describe "update identity_provider" do setup [:create_identity_provider] @tag authorized: ["identity-providers:manage:all"] test "renders identity_provider when data is valid", %{ conn: conn, identity_provider: %IdentityProvider{id: id} = identity_provider } do conn = put(conn, Routes.admin_identity_provider_path(conn, :update, identity_provider), identity_provider: @update_attrs ) assert %{"id" => ^id} = json_response(conn, 200)["data"] conn = get(conn, Routes.admin_identity_provider_path(conn, :show, id)) assert %{ "id" => ^id, "name" => "some updated name" } = json_response(conn, 200)["data"] end @tag authorized: ["identity-providers:manage:all"] test "renders errors when data is invalid", %{ conn: conn, identity_provider: identity_provider } do conn = put(conn, Routes.admin_identity_provider_path(conn, :update, identity_provider), identity_provider: @invalid_attrs ) assert json_response(conn, 422)["errors"] != %{} end end describe "delete identity_provider" do setup [:create_identity_provider] @tag authorized: ["identity-providers:manage:all"] test "cannot delete admin ui identity_provider", %{conn: conn} do client_identity_provider = insert(:client_identity_provider) current_admin_ui_client_id = System.get_env("BORUTA_ADMIN_OAUTH_CLIENT_ID", "") System.put_env("BORUTA_ADMIN_OAUTH_CLIENT_ID", client_identity_provider.client_id) conn = delete( conn, Routes.admin_identity_provider_path( conn, :delete, client_identity_provider.identity_provider ) ) assert response(conn, 403) System.put_env("BORUTA_ADMIN_OAUTH_CLIENT_ID", current_admin_ui_client_id) end @tag authorized: ["identity-providers:manage:all"] test "deletes chosen identity_provider", %{conn: conn, identity_provider: identity_provider} do conn = delete(conn, Routes.admin_identity_provider_path(conn, :delete, identity_provider)) assert response(conn, 204) assert_error_sent(404, fn -> get(conn, Routes.admin_identity_provider_path(conn, :show, identity_provider)) end) end end defp create_identity_provider(_) do identity_provider = fixture(:identity_provider) %{identity_provider: identity_provider} end end ================================================ FILE: apps/boruta_admin/test/boruta_admin_web/controllers/key_pair_controller_test.exs ================================================ defmodule BorutaAdminWeb.KeyPairControllerTest do use BorutaAdminWeb.ConnCase # TODO test sub restriction test "returns a 401", %{conn: conn} do assert conn |> get(Routes.admin_key_pair_path(conn, :index)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> post(Routes.admin_key_pair_path(conn, :create)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> post(Routes.admin_key_pair_path(conn, :rotate, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> delete(Routes.admin_key_pair_path(conn, :delete, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } end describe "with bad scope" do @tag authorized: ["bad:scope"] test "returns a 403", %{conn: conn} do assert conn |> get(Routes.admin_key_pair_path(conn, :index)) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> post(Routes.admin_key_pair_path(conn, :create)) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> post(Routes.admin_key_pair_path(conn, :rotate, "id")) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> delete(Routes.admin_key_pair_path(conn, :delete, "id")) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } end end describe "index" do @tag authorized: ["clients:manage:all"] test "lists all key pairs", %{conn: conn} do conn = get(conn, Routes.admin_key_pair_path(conn, :index)) assert json_response(conn, 200)["data"] == [] end end @tag :skip test "create a key pair" @tag :skip test "rotate a key pair" @tag :skip test "delete" end ================================================ FILE: apps/boruta_admin/test/boruta_admin_web/controllers/logs_controller_test.exs ================================================ defmodule BorutaAdminWeb.LogsControllerTest do use BorutaAdminWeb.ConnCase alias BorutaAuth.LogRotate @request_log_lines [ "request_id=Fwd0KILP8T4HsB4AAA3h [info] boruta_web POST /oauth/introspect - sent 200 from 0.0.0.0 in 2ms", "request_id=FweNn-2vW71XZiUAAljD [info] boruta_web GET /oauth/authorize - sent 200 from 0.0.0.0 in 16ms", "request_id=FweINeYU7G053agAAApG [info] boruta_web POST /oauth/token - sent 401 from 0.0.0.0 in 952µs" ] @business_log_lines [ "request_id=Fwh6kT_QfosEujUAAADC [info] boruta_web authorization authorize - success client_id=6a2f41a3-c54c-fce8-32d2-0324e1c32e20 sub=9b15219f-30a9-4a98-8c2e-296d0a53c638 type=token access_token=QjkypPrdh6iFsgYmp40wzqxTPs6JOFDRrRJXxKPNK0Kjp6LAF83tpHtqtCKlkzYByu3YvhwC1JJZbXBia0cwUF expires_in=3600", "request_id=Fwh6kXSdqY_TBZEAAA3B [info] boruta_web authorization introspect - success client_id=6a2f41a3-c54c-fce8-32d2-0324e1c32e20 sub=7133cbcc-3f1f-448b-bc5a-8f551a3d3883 access_token=QjkypPrdh6iFsgYmp40wzqxTPs6JOFDRrRJXxKPNK0Kjp6LAF83tpHtqtCKlkzYByu3YvhwC1JJZbXBia0cwUF active=true", "request_id=Fwh6liuATTbaqC4AAAJm [info] boruta_web authorization introspect - failure client_id=6a2f41a3-c54c-fce8-32d2-0324e1c32e20 sub=7133cbcc-3f1f-448b-bc5a-8f551a3d3883 access_token=QjkypPrdh6iFsgYmp40wzqxTPs6JOFDRrRJXxKPNK0Kjp6LAF83tpHtqtCKlkzYByu3YvhwC1JJZbXBia0cwUF active=true" ] setup %{conn: conn} do {:ok, conn: put_req_header(conn, "accept", "application/json")} end # TODO test sub restriction test "returns a 401", %{conn: conn} do assert conn |> get(Routes.admin_logs_path(conn, :index)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } end describe "with bad scope" do @tag authorized: ["bad:scope"] test "returns a 403", %{conn: conn} do assert conn |> get(Routes.admin_logs_path(conn, :index)) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } end end describe "index requesting requests logs" do @tag authorized: ["logs:read:all"] test "return today's logs", %{conn: conn} do File.mkdir("./log") File.rm(LogRotate.path(:boruta_web, :request, Date.utc_today())) before_lines = request_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(-1 * 20 * 60, :second) |> DateTime.add(i * 60, :second) end) log_lines = request_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(-1 * 10 * 60, :second) |> DateTime.add(i * 60, :second) end) after_lines = request_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(i * 60, :second) end) File.write!( LogRotate.path(:boruta_web, :request, Date.utc_today()), Enum.map_join([before_lines, log_lines, after_lines], fn serie -> Enum.join(serie, "\n") <> "\n" end) <> "\n" ) start_at = DateTime.utc_now() |> DateTime.add(-1 * 10 * 60, :second) |> DateTime.to_iso8601() end_at = DateTime.utc_now() |> DateTime.to_iso8601() conn = get(conn, Routes.admin_logs_path(conn, :index), %{ start_at: start_at, end_at: end_at, application: "boruta_web", type: "request" }) assert %{ "time_scale_unit" => "second", "overflow" => false, "log_lines" => ^log_lines, "log_count" => 30 } = json_response(conn, 200) File.rm!(LogRotate.path(:boruta_web, :request, Date.utc_today())) end @tag :skip test "compute request times" @tag :skip test "compute request counts" @tag :skip test "compute status codes" @tag :skip test "filter logs" @tag authorized: ["logs:read:all"] test "groups direct post requests by route in dashboard stats", %{conn: conn} do File.mkdir("./log") File.rm(LogRotate.path(:boruta_web, :request, Date.utc_today())) log_time = DateTime.utc_now() |> DateTime.add(-60, :second) |> DateTime.truncate(:second) direct_post_lines = [ "#{DateTime.to_iso8601(log_time)} request_id=direct-post-1 [info] boruta_web POST /openid/direct_post/code-1 - sent 200 from 0.0.0.0 in 2ms", "#{DateTime.to_iso8601(log_time)} request_id=direct-post-2 [info] boruta_web POST /openid/direct_post/code-2 - sent 302 from 0.0.0.0 in 4ms" ] File.write!( LogRotate.path(:boruta_web, :request, Date.utc_today()), Enum.join(direct_post_lines, "\n") <> "\n" ) start_at = log_time |> DateTime.add(-1, :second) |> DateTime.to_iso8601() end_at = log_time |> DateTime.add(1, :second) |> DateTime.to_iso8601() conn = get(conn, Routes.admin_logs_path(conn, :index), %{ start_at: start_at, end_at: end_at, application: "boruta_web", type: "request" }) direct_post_label = "boruta_web - POST /openid/direct_post/:code_id" assert %{ "labels" => [^direct_post_label], "log_lines" => ^direct_post_lines, "log_count" => 2, "status_codes" => %{ ^direct_post_label => %{ "200" => 1, "302" => 1 } }, "request_counts" => %{ ^direct_post_label => request_counts } } = json_response(conn, 200) assert Enum.sum(Map.values(request_counts)) == 2 File.rm!(LogRotate.path(:boruta_web, :request, Date.utc_today())) end @tag authorized: ["logs:read:all"] test "skips lines before start_at", %{conn: conn} do File.mkdir("./log") File.rm(LogRotate.path(:boruta_web, :request, Date.utc_today())) before_lines = request_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(-1 * 20 * 60, :second) |> DateTime.add(i * 60, :second) end) log_lines = request_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(-1 * 10 * 60, :second) |> DateTime.add(i * 60, :second) end) File.write!( LogRotate.path(:boruta_web, :request, Date.utc_today()), Enum.map_join([before_lines, log_lines], fn serie -> Enum.join(serie, "\n") <> "\n" end) <> "\n" ) start_at = DateTime.utc_now() |> DateTime.add(-1 * 10 * 60, :second) |> DateTime.to_iso8601() end_at = DateTime.utc_now() |> DateTime.to_iso8601() conn = get(conn, Routes.admin_logs_path(conn, :index), %{ start_at: start_at, end_at: end_at, application: "boruta_web", type: "request" }) assert %{ "time_scale_unit" => "second", "overflow" => false, "log_lines" => ^log_lines, "log_count" => 30 } = json_response(conn, 200) File.rm!(LogRotate.path(:boruta_web, :request, Date.utc_today())) end @tag authorized: ["logs:read:all"] test "skips lines after end_at", %{conn: conn} do File.mkdir("./log") File.rm(LogRotate.path(:boruta_web, :request, Date.utc_today())) log_lines = request_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(-1 * 10 * 60, :second) |> DateTime.add(i * 60, :second) end) after_lines = request_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(i * 60, :second) end) File.write!( LogRotate.path(:boruta_web, :request, Date.utc_today()), Enum.map_join([log_lines, after_lines], fn serie -> Enum.join(serie, "\n") <> "\n" end) <> "\n" ) start_at = DateTime.utc_now() |> DateTime.add(-1 * 10 * 60, :second) |> DateTime.to_iso8601() end_at = DateTime.utc_now() |> DateTime.to_iso8601() conn = get(conn, Routes.admin_logs_path(conn, :index), %{ start_at: start_at, end_at: end_at, application: "boruta_web", type: "request" }) assert %{ "time_scale_unit" => "second", "overflow" => false, "log_lines" => ^log_lines, "log_count" => 30 } = json_response(conn, 200) File.rm!(LogRotate.path(:boruta_web, :request, Date.utc_today())) end @tag authorized: ["logs:read:all"] test "return multiple day logs", %{conn: conn} do first_day = Date.utc_today() |> Date.add(-10) second_day = Date.utc_today() |> Date.add(-8) File.mkdir("./log") File.rm(LogRotate.path(:boruta_web, :request, first_day)) File.rm(LogRotate.path(:boruta_web, :request, second_day)) [first_day_log_lines, second_day_log_lines] = Enum.map([10, 8], fn day_shift -> request_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(-1 * 24 * 3600 * day_shift, :second) |> DateTime.add(i * 60, :second) end) end) File.write!( LogRotate.path(:boruta_web, :request, first_day), Enum.join(first_day_log_lines, "\n") ) File.write!( LogRotate.path(:boruta_web, :request, second_day), Enum.join(second_day_log_lines, "\n") ) start_at = DateTime.utc_now() |> DateTime.add(-1 * 10 * 24 * 3600 - 1, :second) |> DateTime.to_iso8601() end_at = DateTime.utc_now() |> DateTime.to_iso8601() conn = get(conn, Routes.admin_logs_path(conn, :index), %{ start_at: start_at, end_at: end_at, application: "boruta_web", type: "request" }) log_lines = first_day_log_lines ++ second_day_log_lines assert %{ "time_scale_unit" => "hour", "overflow" => false, "log_lines" => ^log_lines, "log_count" => 60 } = json_response(conn, 200) File.rm!(LogRotate.path(:boruta_web, :request, first_day)) File.rm!(LogRotate.path(:boruta_web, :request, second_day)) end end describe "index requesting business events logs" do @tag authorized: ["logs:read:all"] test "return today's logs", %{conn: conn} do File.mkdir("./log") File.rm(LogRotate.path(:boruta_web, :business, Date.utc_today())) before_lines = business_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(-1 * 20 * 60, :second) |> DateTime.add(i * 60, :second) end) log_lines = business_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(-1 * 10 * 60, :second) |> DateTime.add(i * 60, :second) end) after_lines = business_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(i * 60, :second) end) File.write!( LogRotate.path(:boruta_web, :business, Date.utc_today()), Enum.map_join([before_lines, log_lines, after_lines], fn serie -> Enum.join(serie, "\n") <> "\n" end) <> "\n" ) start_at = DateTime.utc_now() |> DateTime.add(-1 * 10 * 60, :second) |> DateTime.to_iso8601() end_at = DateTime.utc_now() |> DateTime.to_iso8601() conn = get(conn, Routes.admin_logs_path(conn, :index), %{ start_at: start_at, end_at: end_at, application: "boruta_web", type: "business" }) assert %{ "time_scale_unit" => "second", "overflow" => false, "log_lines" => ^log_lines, "log_count" => 30 } = json_response(conn, 200) File.rm!(LogRotate.path(:boruta_web, :business, Date.utc_today())) end @tag :skip test "compute business event counts" @tag :skip test "compute counts" @tag :skip test "filter logs" @tag authorized: ["logs:read:all"] test "skips lines before start_at", %{conn: conn} do File.mkdir("./log") File.rm(LogRotate.path(:boruta_web, :business, Date.utc_today())) before_lines = business_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(-1 * 20 * 60, :second) |> DateTime.add(i * 60, :second) end) log_lines = business_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(-1 * 10 * 60, :second) |> DateTime.add(i * 60, :second) end) File.write!( LogRotate.path(:boruta_web, :business, Date.utc_today()), Enum.map_join([before_lines, log_lines], fn serie -> Enum.join(serie, "\n") <> "\n" end) <> "\n" ) start_at = DateTime.utc_now() |> DateTime.add(-1 * 10 * 60, :second) |> DateTime.to_iso8601() end_at = DateTime.utc_now() |> DateTime.to_iso8601() conn = get(conn, Routes.admin_logs_path(conn, :index), %{ start_at: start_at, end_at: end_at, application: "boruta_web", type: "business" }) assert %{ "time_scale_unit" => "second", "overflow" => false, "log_lines" => ^log_lines, "log_count" => 30 } = json_response(conn, 200) File.rm!(LogRotate.path(:boruta_web, :business, Date.utc_today())) end @tag authorized: ["logs:read:all"] test "skips lines after end_at", %{conn: conn} do File.mkdir("./log") File.rm(LogRotate.path(:boruta_web, :business, Date.utc_today())) log_lines = business_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(-1 * 10 * 60, :second) |> DateTime.add(i * 60, :second) end) after_lines = business_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(i * 60, :second) end) File.write!( LogRotate.path(:boruta_web, :business, Date.utc_today()), Enum.map_join([log_lines, after_lines], fn serie -> Enum.join(serie, "\n") <> "\n" end) <> "\n" ) start_at = DateTime.utc_now() |> DateTime.add(-1 * 10 * 60, :second) |> DateTime.to_iso8601() end_at = DateTime.utc_now() |> DateTime.to_iso8601() conn = get(conn, Routes.admin_logs_path(conn, :index), %{ start_at: start_at, end_at: end_at, application: "boruta_web", type: "business" }) assert %{ "time_scale_unit" => "second", "overflow" => false, "log_lines" => ^log_lines, "log_count" => 30 } = json_response(conn, 200) File.rm!(LogRotate.path(:boruta_web, :business, Date.utc_today())) end @tag authorized: ["logs:read:all"] test "return multiple day logs", %{conn: conn} do first_day = Date.utc_today() |> Date.add(-10) second_day = Date.utc_today() |> Date.add(-8) File.mkdir("./log") File.rm(LogRotate.path(:boruta_web, :business, first_day)) File.rm(LogRotate.path(:boruta_web, :business, second_day)) [first_day_log_lines, second_day_log_lines] = Enum.map([10, 8], fn day_shift -> business_log_line_serie(fn i -> DateTime.utc_now() |> DateTime.add(-1 * 24 * 3600 * day_shift, :second) |> DateTime.add(i * 60, :second) end) end) File.write!( LogRotate.path(:boruta_web, :business, first_day), Enum.join(first_day_log_lines, "\n") ) File.write!( LogRotate.path(:boruta_web, :business, second_day), Enum.join(second_day_log_lines, "\n") ) start_at = DateTime.utc_now() |> DateTime.add(-1 * 10 * 24 * 3600 - 1, :second) |> DateTime.to_iso8601() end_at = DateTime.utc_now() |> DateTime.to_iso8601() conn = get(conn, Routes.admin_logs_path(conn, :index), %{ start_at: start_at, end_at: end_at, application: "boruta_web", type: "business" }) log_lines = first_day_log_lines ++ second_day_log_lines assert %{ "time_scale_unit" => "hour", "overflow" => false, "log_lines" => ^log_lines, "log_count" => 60 } = json_response(conn, 200) File.rm!(LogRotate.path(:boruta_web, :business, first_day)) File.rm!(LogRotate.path(:boruta_web, :business, second_day)) end end defp request_log_line_serie(fun) do Enum.flat_map(1..10, fn i -> log_time = fun.(i) Enum.map(@request_log_lines, fn log -> "#{DateTime.to_iso8601(log_time)} #{log}" end) end) end defp business_log_line_serie(fun) do Enum.flat_map(1..10, fn i -> log_time = fun.(i) Enum.map(@business_log_lines, fn log -> "#{DateTime.to_iso8601(log_time)} #{log}" end) end) end end ================================================ FILE: apps/boruta_admin/test/boruta_admin_web/controllers/organization_controller_test.exs ================================================ defmodule BorutaAdminWeb.OrganizationControllerTest do use BorutaAdminWeb.ConnCase import BorutaIdentity.Factory alias BorutaIdentity.Organizations.Organization alias BorutaIdentity.Repo setup %{conn: conn} do {:ok, conn: put_req_header(conn, "accept", "application/json")} end test "returns a 401", %{conn: conn} do assert conn |> get(Routes.admin_organization_path(conn, :index)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> post(Routes.admin_organization_path(conn, :create)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> patch(Routes.admin_organization_path(conn, :update, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> delete(Routes.admin_organization_path(conn, :delete, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } end describe "with bad scope" do @tag authorized: ["bad:scope"] test "returns a 403", %{conn: conn} do assert conn |> get(Routes.admin_organization_path(conn, :index)) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> post(Routes.admin_organization_path(conn, :create)) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> patch(Routes.admin_organization_path(conn, :update, "id")) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> delete(Routes.admin_organization_path(conn, :delete, "id")) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } end end describe "index" do @tag authorized: ["users:manage:all"] test "lists all organizations", %{conn: conn} do conn = get(conn, Routes.admin_organization_path(conn, :index)) assert json_response(conn, 200)["data"] == [] end end describe "create organization" do @tag authorized: ["users:manage:all"] test "renders bad request", %{ conn: conn } do conn = post(conn, Routes.admin_organization_path(conn, :create), %{}) assert json_response(conn, 400) end @tag authorized: ["users:manage:all"] test "renders an error when data is invalid", %{ conn: conn } do name = nil conn = post(conn, Routes.admin_organization_path(conn, :create), %{ "organization" => %{ "name" => name } }) assert json_response(conn, 422) == %{ "code" => "UNPROCESSABLE_ENTITY", "errors" => %{"name" => ["can't be blank"]}, "message" => "Your request could not be processed." } end @tag authorized: ["users:manage:all"] test "renders organization when data is valid", %{ conn: conn } do name = "Organization name" conn = post(conn, Routes.admin_organization_path(conn, :create), %{ "organization" => %{ "name" => name } }) assert %{"id" => _id, "name" => ^name} = json_response(conn, 200)["data"] end end describe "update organization" do setup do organization = insert(:organization) {:ok, organization: organization} end @tag authorized: ["users:manage:all"] test "renders an error when bad request", %{ conn: conn, organization: organization } do conn = put(conn, Routes.admin_organization_path(conn, :update, organization), %{}) assert json_response(conn, 400) end @tag authorized: ["users:manage:all"] test "updates organization with metadata", %{ conn: conn, organization: %Organization{id: id} = organization } do name = "Organization name" conn = put(conn, Routes.admin_organization_path(conn, :update, organization), organization: %{ "name" => name } ) assert %{"id" => ^id, "name" => ^name} = json_response(conn, 200)["data"] assert %Organization{name: ^name} = Repo.get!(Organization, id) end end describe "delete organization" do @tag authorized: ["users:manage:all"] test "returns a 404", %{conn: conn} do organization_id = SecureRandom.uuid() conn = delete(conn, Routes.admin_organization_path(conn, :delete, organization_id)) assert response(conn, 404) end @tag authorized: ["users:manage:all"] test "deletes the organization", %{conn: conn} do %Organization{id: organization_id} = insert(:organization) conn = delete(conn, Routes.admin_organization_path(conn, :delete, organization_id)) assert response(conn, 204) refute BorutaIdentity.Repo.get(Organization, organization_id) end end end ================================================ FILE: apps/boruta_admin/test/boruta_admin_web/controllers/page_controller_test.exs ================================================ defmodule BorutaAdminWeb.PageControllerTest do use BorutaAdminWeb.ConnCase test "GET /", %{conn: conn} do conn = get(conn, "/") assert html_response(conn, 200) =~ "app" end end ================================================ FILE: apps/boruta_admin/test/boruta_admin_web/controllers/role_controller_test.exs ================================================ defmodule BorutaAdminWeb.RoleControllerTest do import BorutaIdentity.Factory use BorutaAdminWeb.ConnCase alias BorutaIdentity.Accounts.Role @create_attrs %{ name: "some name", } @update_attrs %{ name: "some updated name" } @invalid_attrs %{name: nil} @protected_roles [] setup %{conn: conn} do {:ok, conn: put_req_header(conn, "accept", "application/json")} end # TODO test sub restriction test "returns a 401", %{conn: conn} do assert conn |> get(Routes.admin_role_path(conn, :index)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> post(Routes.admin_role_path(conn, :create)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> patch(Routes.admin_role_path(conn, :update, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> delete(Routes.admin_role_path(conn, :delete, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } end describe "with bad role" do @tag authorized: ["bad:scope"] test "returns a 403", %{conn: conn} do assert conn |> get(Routes.admin_role_path(conn, :index)) |> json_response(403) == %{ "code" =>"FORBIDDEN", "message" =>"You are forbidden to access this resource.", "errors" =>%{ "resource" =>["you are forbidden to access this resource."] } } assert conn |> post(Routes.admin_role_path(conn, :create)) |> json_response(403) == %{ "code" =>"FORBIDDEN", "message" =>"You are forbidden to access this resource.", "errors" =>%{ "resource" =>["you are forbidden to access this resource."] } } assert conn |> patch(Routes.admin_role_path(conn, :update, "id")) |> json_response(403) == %{ "code" =>"FORBIDDEN", "message" =>"You are forbidden to access this resource.", "errors" =>%{ "resource" =>["you are forbidden to access this resource."] } } assert conn |> delete(Routes.admin_role_path(conn, :delete, "id")) |> json_response(403) == %{ "code" =>"FORBIDDEN", "message" =>"You are forbidden to access this resource.", "errors" =>%{ "resource" =>["you are forbidden to access this resource."] } } end end describe "index" do @tag authorized: ["scopes:manage:all"] test "lists all roles", %{conn: conn} do conn = get(conn, Routes.admin_role_path(conn, :index)) assert json_response(conn, 200)["data"] == [] end end describe "create role" do @tag authorized: ["scopes:manage:all"] test "renders role when data is valid", %{conn: conn} do conn = post(conn, Routes.admin_role_path(conn, :create), role: @create_attrs) assert %{"id" => _id, "name" => "some name"} = json_response(conn, 201)["data"] end @tag authorized: ["scopes:manage:all"] test "renders errors when data is invalid", %{conn: conn} do conn = post(conn, Routes.admin_role_path(conn, :create), role: @invalid_attrs) assert json_response(conn, 422)["errors"] != %{} end end describe "update role" do setup %{conn: conn} do role = insert(:role) {:ok, conn: conn, existing_role: role} end @tag authorized: ["scopes:manage:all"] test "renders role when data is valid", %{conn: conn, existing_role: %Role{id: id} = role} do conn = put(conn, Routes.admin_role_path(conn, :update, role), role: @update_attrs) assert %{"id" => ^id} = json_response(conn, 200)["data"] end @tag authorized: ["scopes:manage:all"] test "cannot update protected roles", %{conn: conn} do Enum.map(@protected_roles, fn name -> conn = put(conn, Routes.admin_role_path(conn, :update, insert(:role, name: name)), role: @update_attrs) assert response(conn, 403) end) end @tag authorized: ["scopes:manage:all"] test "renders errors when data is invalid", %{conn: conn, existing_role: role} do conn = put(conn, Routes.admin_role_path(conn, :update, role), role: @invalid_attrs) assert json_response(conn, 422)["errors"] != %{} end end describe "delete role" do setup %{conn: conn} do role = insert(:role) {:ok, conn: conn, existing_role: role} end @tag authorized: ["scopes:manage:all"] test "cannot delete protected roles", %{conn: conn} do Enum.map(@protected_roles, fn name -> conn = delete(conn, Routes.admin_role_path(conn, :delete, insert(:role, name: name))) assert response(conn, 403) end) end @tag authorized: ["scopes:manage:all"] test "deletes chosen role", %{conn: conn, existing_role: role} do conn = delete(conn, Routes.admin_role_path(conn, :delete, role)) assert response(conn, 204) assert_error_sent(404, fn -> get(conn, Routes.admin_role_path(conn, :show, role)) end) end end end ================================================ FILE: apps/boruta_admin/test/boruta_admin_web/controllers/scope_controller_test.exs ================================================ defmodule BorutaAdminWeb.ScopeControllerTest do import Boruta.Factory use BorutaAdminWeb.ConnCase alias Boruta.Ecto.Scope @create_attrs %{ name: "some:name", public: true } @update_attrs %{ name: "some:updated:name", public: false } @invalid_attrs %{name: nil, public: nil} @protected_scopes [ "users:manage:all", "clients:manage:all", "identity-providers:manage:all", "scopes:manage:all", "upstreams:manage:all" ] setup %{conn: conn} do {:ok, conn: put_req_header(conn, "accept", "application/json")} end # TODO test sub restriction test "returns a 401", %{conn: conn} do assert conn |> get(Routes.admin_scope_path(conn, :index)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> post(Routes.admin_scope_path(conn, :create)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> patch(Routes.admin_scope_path(conn, :update, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> delete(Routes.admin_scope_path(conn, :delete, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } end describe "with bad scope" do @tag authorized: ["bad:scope"] test "returns a 403", %{conn: conn} do assert conn |> get(Routes.admin_scope_path(conn, :index)) |> json_response(403) == %{ "code" =>"FORBIDDEN", "message" =>"You are forbidden to access this resource.", "errors" =>%{ "resource" =>["you are forbidden to access this resource."] } } assert conn |> post(Routes.admin_scope_path(conn, :create)) |> json_response(403) == %{ "code" =>"FORBIDDEN", "message" =>"You are forbidden to access this resource.", "errors" =>%{ "resource" =>["you are forbidden to access this resource."] } } assert conn |> patch(Routes.admin_scope_path(conn, :update, "id")) |> json_response(403) == %{ "code" =>"FORBIDDEN", "message" =>"You are forbidden to access this resource.", "errors" =>%{ "resource" =>["you are forbidden to access this resource."] } } assert conn |> delete(Routes.admin_scope_path(conn, :delete, "id")) |> json_response(403) == %{ "code" =>"FORBIDDEN", "message" =>"You are forbidden to access this resource.", "errors" =>%{ "resource" =>["you are forbidden to access this resource."] } } end end describe "index" do @tag authorized: ["scopes:manage:all"] test "lists all scopes", %{conn: conn} do conn = get(conn, Routes.admin_scope_path(conn, :index)) assert json_response(conn, 200)["data"] == [] end end describe "create scope" do @tag authorized: ["scopes:manage:all"] test "renders scope when data is valid", %{conn: conn} do conn = post(conn, Routes.admin_scope_path(conn, :create), scope: @create_attrs) assert %{"id" => _id, "name" => "some:name", "public" => true} = json_response(conn, 201)["data"] end @tag authorized: ["scopes:manage:all"] test "renders errors when data is invalid", %{conn: conn} do conn = post(conn, Routes.admin_scope_path(conn, :create), scope: @invalid_attrs) assert json_response(conn, 422)["errors"] != %{} end end describe "update scope" do setup %{conn: conn} do scope = insert(:scope) {:ok, conn: conn, existing_scope: scope} end @tag authorized: ["scopes:manage:all"] test "renders scope when data is valid", %{conn: conn, existing_scope: %Scope{id: id} = scope} do conn = put(conn, Routes.admin_scope_path(conn, :update, scope), scope: @update_attrs) assert %{"id" => ^id} = json_response(conn, 200)["data"] end @tag authorized: ["scopes:manage:all"] test "cannot update protected scopes", %{conn: conn} do Enum.map(@protected_scopes, fn name -> conn = put(conn, Routes.admin_scope_path(conn, :update, insert(:scope, name: name)), scope: @update_attrs) assert response(conn, 403) end) end @tag authorized: ["scopes:manage:all"] test "renders errors when data is invalid", %{conn: conn, existing_scope: scope} do conn = put(conn, Routes.admin_scope_path(conn, :update, scope), scope: @invalid_attrs) assert json_response(conn, 422)["errors"] != %{} end end describe "delete scope" do setup %{conn: conn} do scope = insert(:scope) {:ok, conn: conn, existing_scope: scope} end @tag authorized: ["scopes:manage:all"] test "cannot delete protected scopes", %{conn: conn} do Enum.map(@protected_scopes, fn name -> conn = delete(conn, Routes.admin_scope_path(conn, :delete, insert(:scope, name: name))) assert response(conn, 403) end) end @tag authorized: ["scopes:manage:all"] test "deletes chosen scope", %{conn: conn, existing_scope: scope} do conn = delete(conn, Routes.admin_scope_path(conn, :delete, scope)) assert response(conn, 204) assert_error_sent(404, fn -> get(conn, Routes.admin_scope_path(conn, :show, scope)) end) end end end ================================================ FILE: apps/boruta_admin/test/boruta_admin_web/controllers/upstream_controller_test.exs ================================================ defmodule BorutaAdminWeb.UpstreamControllerTest do use BorutaAdminWeb.ConnCase, async: false alias BorutaGateway.Upstreams alias BorutaGateway.Upstreams.Upstream @create_attrs %{ scheme: "https", host: "host.test", port: 7777 } @update_attrs %{ host: "host.update" } @invalid_attrs %{ host: nil } def fixture(:upstream) do {:ok, upstream} = Upstreams.create_upstream(@create_attrs) upstream end setup %{conn: conn} do {:ok, conn: put_req_header(conn, "accept", "application/json")} end # TODO test sub restriction test "returns a 401", %{conn: conn} do assert conn |> get(Routes.admin_upstream_path(conn, :index)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> post(Routes.admin_upstream_path(conn, :create)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> patch(Routes.admin_upstream_path(conn, :update, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> delete(Routes.admin_upstream_path(conn, :delete, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } end describe "with bad scope" do @tag authorized: ["bad:scope"] test "returns a 403", %{conn: conn} do assert conn |> get(Routes.admin_upstream_path(conn, :index)) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> post(Routes.admin_upstream_path(conn, :create)) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> patch(Routes.admin_upstream_path(conn, :update, "id")) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> delete(Routes.admin_upstream_path(conn, :delete, "id")) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } end end describe "index" do @tag authorized: ["upstreams:manage:all"] test "lists all upstreams", %{conn: conn} do conn = get(conn, Routes.admin_upstream_path(conn, :index)) assert json_response(conn, 200)["data"] == %{} end end describe "node_list" do @tag authorized: ["upstreams:manage:all"] test "lists all boruta nodes", %{conn: conn} do configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/full_configuration.yml") Application.put_env(:boruta_gateway, :configuration_path, configuration_file_path) conn = get(conn, Routes.admin_upstream_path(conn, :node_list)) assert json_response(conn, 200)["data"] == ["full-configuration"] end @tag authorized: ["upstreams:manage:all"] test "lists all boruta nodes from config", %{conn: conn} do configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/full_configuration.yml") Application.put_env(:boruta_gateway, :configuration_path, configuration_file_path) conn = get(conn, Routes.admin_upstream_path(conn, :node_list)) assert json_response(conn, 200)["data"] == ["full-configuration"] end end describe "create upstream" do @tag authorized: ["upstreams:manage:all"] test "renders upstream when data is valid", %{conn: conn} do conn = post(conn, Routes.admin_upstream_path(conn, :create), upstream: @create_attrs) assert %{"id" => _id} = json_response(conn, 201)["data"] end @tag authorized: ["upstreams:manage:all"] test "renders errors when data is invalid", %{conn: conn} do conn = post(conn, Routes.admin_upstream_path(conn, :create), upstream: @invalid_attrs) assert json_response(conn, 422)["errors"] != %{} end end describe "update upstream" do setup %{conn: conn} do upstream = fixture(:upstream) {:ok, conn: conn, upstream: upstream} end @tag authorized: ["upstreams:manage:all"] test "renders upstream when data is valid", %{ conn: conn, upstream: %Upstream{id: id} = upstream } do conn = put(conn, Routes.admin_upstream_path(conn, :update, upstream), upstream: @update_attrs) assert %{"id" => ^id} = json_response(conn, 200)["data"] end @tag authorized: ["upstreams:manage:all"] test "renders errors when data is invalid", %{conn: conn, upstream: upstream} do conn = put(conn, Routes.admin_upstream_path(conn, :update, upstream), upstream: @invalid_attrs) assert json_response(conn, 422)["errors"] != %{} end end describe "delete upstream" do setup %{conn: conn} do upstream = fixture(:upstream) {:ok, conn: conn, upstream: upstream} end @tag authorized: ["upstreams:manage:all"] test "deletes chosen upstream", %{conn: conn, upstream: upstream} do conn = delete(conn, Routes.admin_upstream_path(conn, :delete, upstream)) assert response(conn, 204) assert_error_sent(404, fn -> get(conn, Routes.admin_upstream_path(conn, :show, upstream)) end) end end end ================================================ FILE: apps/boruta_admin/test/boruta_admin_web/controllers/user_controller_test.exs ================================================ defmodule BorutaAdminWeb.UserControllerTest do use BorutaAdminWeb.ConnCase import BorutaIdentity.AccountsFixtures import BorutaIdentity.Factory alias Boruta.Ecto.Admin alias BorutaIdentity.Accounts.Internal alias BorutaIdentity.Accounts.User alias BorutaIdentity.Repo setup %{conn: conn} do {:ok, conn: put_req_header(conn, "accept", "application/json")} end # TODO test sub restriction test "returns a 401", %{conn: conn} do assert conn |> get(Routes.admin_user_path(conn, :index)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> post(Routes.admin_user_path(conn, :create)) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> patch(Routes.admin_user_path(conn, :update, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } assert conn |> delete(Routes.admin_user_path(conn, :delete, "id")) |> json_response(401) == %{ "code" => "UNAUTHORIZED", "message" => "You are unauthorized to access this resource.", "errors" => %{ "resource" => ["you are unauthorized to access this resource."] } } end describe "with bad scope" do @tag authorized: ["bad:scope"] test "returns a 403", %{conn: conn} do assert conn |> get(Routes.admin_user_path(conn, :index)) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> post(Routes.admin_user_path(conn, :create)) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> patch(Routes.admin_user_path(conn, :update, "id")) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } assert conn |> delete(Routes.admin_user_path(conn, :delete, "id")) |> json_response(403) == %{ "code" => "FORBIDDEN", "message" => "You are forbidden to access this resource.", "errors" => %{ "resource" => ["you are forbidden to access this resource."] } } end end describe "index" do @tag authorized: ["users:manage:all"] test "lists all users", %{conn: conn} do conn = get(conn, Routes.admin_user_path(conn, :index)) assert json_response(conn, 200)["data"] == [] end end describe "create user" do setup %{conn: conn} do {:ok, scope} = Admin.create_scope(%{name: "some:scope"}) role = insert(:role) organization = insert(:organization) insert(:role_scope, role_id: role.id, scope_id: scope.id) {:ok, conn: conn, existing_scope: scope, existing_role: role, existing_organization: organization} end @tag authorized: ["users:manage:all"] test "renders bad request", %{ conn: conn } do conn = post(conn, Routes.admin_user_path(conn, :create), %{}) assert json_response(conn, 400) end @tag authorized: ["users:manage:all"] test "renders an error when data is invalid", %{ conn: conn } do email = unique_user_email() conn = post(conn, Routes.admin_user_path(conn, :create), %{ "backend_id" => insert(:backend).id, "user" => %{ "email" => email } }) assert json_response(conn, 422) == %{ "code" => "UNPROCESSABLE_ENTITY", "errors" => %{"password" => ["can't be blank"]}, "message" => "Your request could not be processed." } end @tag authorized: ["users:manage:all"] test "renders user when data is valid", %{ conn: conn } do email = unique_user_email() conn = post(conn, Routes.admin_user_path(conn, :create), %{ "backend_id" => insert(:backend).id, "user" => %{ "email" => email, "password" => valid_user_password() } }) assert %{"id" => _id, "email" => ^email} = json_response(conn, 200)["data"] end @tag authorized: ["users:manage:all"] test "creates user with authorized scopes", %{ conn: conn, existing_scope: scope } do conn = post(conn, Routes.admin_user_path(conn, :create), %{ "backend_id" => insert(:backend).id, "user" => %{ "email" => unique_user_email(), "password" => valid_user_password(), "authorized_scopes" => [%{"id" => scope.id}] } }) scope_id = scope.id assert %{"id" => _id, "authorized_scopes" => [%{"id" => ^scope_id}]} = json_response(conn, 200)["data"] end @tag authorized: ["users:manage:all"] test "creates user with organizations", %{ conn: conn, existing_organization: organization } do conn = post(conn, Routes.admin_user_path(conn, :create), %{ "backend_id" => insert(:backend).id, "user" => %{ "email" => unique_user_email(), "password" => valid_user_password(), "organizations" => [%{"id" => organization.id}] } }) organization_id = organization.id assert %{"id" => _id, "organizations" => [%{"id" => ^organization_id}]} = json_response(conn, 200)["data"] end @tag authorized: ["users:manage:all"] test "creates user with roles", %{ conn: conn, existing_scope: scope, existing_role: role } do conn = post(conn, Routes.admin_user_path(conn, :create), %{ "backend_id" => insert(:backend).id, "user" => %{ "email" => unique_user_email(), "password" => valid_user_password(), "roles" => [%{"id" => role.id}] } }) scope_id = scope.id role_id = role.id assert %{"id" => _id, "roles" => [%{"id" => ^role_id, "scopes" => [%{"id" => ^scope_id}]}]} = json_response(conn, 200)["data"] end end describe "import users" do @tag authorized: ["users:manage:all"] test "renders bad request", %{ conn: conn } do conn = post(conn, Routes.admin_user_path(conn, :create), %{}) assert json_response(conn, 400) end @tag authorized: ["users:manage:all"] test "renders an error when data is invalid", %{ conn: conn } do email = unique_user_email() conn = post(conn, Routes.admin_user_path(conn, :create), %{ "backend_id" => insert(:backend).id, "user" => %{ "email" => email } }) assert json_response(conn, 422) == %{ "code" => "UNPROCESSABLE_ENTITY", "errors" => %{"password" => ["can't be blank"]}, "message" => "Your request could not be processed." } end @tag authorized: ["users:manage:all"] test "renders import result when data is valid", %{ conn: conn } do conn = post(conn, Routes.admin_user_path(conn, :create), %{ "backend_id" => insert(:backend).id, "file" => %Plug.Upload{ path: Path.join(__DIR__, "./../../data/import_users_password_valid.csv"), filename: "users.csv" }, "options" => %{ "hash_password" => "true" } }) assert json_response(conn, 200) == %{ "error_count" => 0, "errors" => [], "success_count" => 2 } assert Repo.all(Internal.User) |> Enum.count() == 2 end @tag authorized: ["users:manage:all"] test "renders import result when data is valid with custom headers", %{ conn: conn } do conn = post(conn, Routes.admin_user_path(conn, :create), %{ "backend_id" => insert(:backend).id, "file" => %Plug.Upload{ path: Path.join(__DIR__, "./../../data/import_users_password_custom_headers_valid.csv"), filename: "users.csv" }, "options" => %{ "hash_password" => "true", "username_header" => "username_header", "password_header" => "password_header" } }) assert json_response(conn, 200) == %{ "error_count" => 0, "errors" => [], "success_count" => 2 } assert Repo.all(Internal.User) |> Enum.count() == 2 end @tag authorized: ["users:manage:all"] test "renders import result users when data is invalid", %{ conn: conn } do conn = post(conn, Routes.admin_user_path(conn, :create), %{ "backend_id" => insert(:backend).id, "file" => %Plug.Upload{ path: Path.join(__DIR__, "./../../data/import_users_password_invalid.csv"), filename: "users.csv" }, "options" => %{ "hash_password" => "true" } }) assert json_response(conn, 200) == %{ "error_count" => 3, "errors" => [ %{ "changeset" => %{"password" => ["should be at least 12 character(s)"]}, "line" => 1 }, %{ "changeset" => %{ "email" => ["can't be blank"], "password" => ["should be at least 12 character(s)"] }, "line" => 2 }, %{"changeset" => %{"email" => ["can't be blank"]}, "line" => 3} ], "success_count" => 1 } assert Repo.all(Internal.User) |> Enum.count() == 1 end @tag authorized: ["users:manage:all"] test "renders import result users when data is valid with hashed password", %{ conn: conn } do conn = post(conn, Routes.admin_user_path(conn, :create), %{ "backend_id" => insert(:backend).id, "file" => %Plug.Upload{ path: Path.join(__DIR__, "./../../data/import_users_hashed_password_valid.csv"), filename: "users.csv" }, "options" => %{ "hash_password" => false } }) assert json_response(conn, 200) == %{ "error_count" => 0, "errors" => [], "success_count" => 2 } assert Repo.all(Internal.User) |> Enum.count() == 2 end end describe "update user" do setup %{conn: conn} do user = user_fixture() {:ok, scope} = Admin.create_scope(%{name: "some:scope"}) role = insert(:role) insert(:role_scope, role_id: role.id, scope_id: scope.id) {:ok, conn: conn, user: user, existing_scope: scope, existing_role: role} end @tag authorized: ["users:manage:all"] test "renders an error when bad request", %{ conn: conn, user: user } do conn = put(conn, Routes.admin_user_path(conn, :update, user), %{}) assert json_response(conn, 400) end @tag authorized: ["users:manage:all"] test "updates user with metadata", %{ conn: conn, user: %User{id: id} = user } do {:ok, _backend} = Ecto.Changeset.change(user.backend, %{metadata_fields: [%{attribute_name: "test"}]}) |> Repo.update() metadata = %{"test" => %{"value" => "test value", "status" => "valid", "display" => []}} conn = put(conn, Routes.admin_user_path(conn, :update, user), user: %{ "metadata" => metadata } ) assert %{ "id" => ^id, "metadata" => %{ "test" => %{"value" => "test value", "status" => "valid", "display" => []} } } = json_response(conn, 200)["data"] assert %User{metadata: ^metadata} = Repo.get!(User, id) end @tag authorized: ["users:manage:all"] test "updates user with group", %{ conn: conn, user: %User{id: id} = user } do group = "group1 group2" conn = put(conn, Routes.admin_user_path(conn, :update, user), user: %{ "group" => group } ) assert %{"id" => ^id, "group" => ^group} = json_response(conn, 200)["data"] assert %User{group: ^group} = Repo.get!(User, id) end @tag authorized: ["users:manage:all"] test "updates user with authorized scopes", %{ conn: conn, user: %User{id: id} = user, existing_scope: scope } do conn = put(conn, Routes.admin_user_path(conn, :update, user), user: %{ "authorized_scopes" => [%{"id" => scope.id}] } ) assert %{"id" => ^id} = json_response(conn, 200)["data"] end @tag authorized: ["users:manage:all"] test "updates user with roles", %{ conn: conn, user: %User{id: id} = user, existing_scope: scope, existing_role: role } do conn = put(conn, Routes.admin_user_path(conn, :update, user), user: %{ "roles" => [%{"id" => role.id}] } ) scope_id = scope.id role_id = role.id assert %{"id" => ^id, "roles" => [%{"id" => ^role_id, "scopes" => [%{"id" => ^scope_id}]}]} = json_response(conn, 200)["data"] end @tag user_authorized: ["users:manage:all"] test "cannot update current user", %{ conn: conn, existing_scope: scope, resource_owner: resource_owner } do conn = put(conn, Routes.admin_user_path(conn, :update, resource_owner.sub), user: %{ "authorized_scopes" => [%{"name" => scope.name}] } ) assert response(conn, 403) end end describe "delete" do @tag authorized: ["users:manage:all"] test "returns a 404", %{conn: conn} do user_id = SecureRandom.uuid() conn = delete(conn, Routes.admin_user_path(conn, :delete, user_id)) assert response(conn, 404) end @tag authorized: ["users:manage:all"] test "deletes the user", %{conn: conn} do %{id: user_id, uid: user_uid} = user_fixture() conn = delete(conn, Routes.admin_user_path(conn, :delete, user_id)) assert response(conn, 204) refute BorutaIdentity.Repo.get(User, user_id) refute BorutaIdentity.Repo.get(Internal.User, user_uid) end @tag user_authorized: ["users:manage:all"] test "cannot delete current user", %{conn: conn, resource_owner: resource_owner} do conn = delete(conn, Routes.admin_user_path(conn, :delete, resource_owner.sub)) assert response(conn, 403) end end end ================================================ FILE: apps/boruta_admin/test/boruta_admin_web/views/error_view_test.exs ================================================ defmodule BorutaAdminWeb.ErrorViewTest do use BorutaAdminWeb.ConnCase, async: true import Phoenix.View test "renders 404.html" do assert render_to_string(BorutaAdminWeb.ErrorView, "404.html", []) =~ "Page not found" end test "renders 500.html" do assert render_to_string(BorutaAdminWeb.ErrorView, "500.html", []) =~ "Internal server error" end end ================================================ FILE: apps/boruta_admin/test/boruta_admin_web/views/page_view_test.exs ================================================ defmodule BorutaAdminWeb.PageViewTest do use BorutaAdminWeb.ConnCase, async: true import Phoenix.View test "renders admin page", %{conn: conn} do conn = get(conn, "/") assert render_to_string(BorutaAdminWeb.PageView, "admin.html", conn: conn) =~ "Administration panel" end end ================================================ FILE: apps/boruta_admin/test/data/import_users_hashed_password_valid.csv ================================================ username,password test1@test.test,"$argon2id$v=19$m=131072,t=8,p=4$5hVLIQVnI5VrT90Oox0G6A$SSP9eON/XDV9V/6iZ165ii9wK/Av4NPpyYFmfa0HBvc" test2@test.test,"$argon2id$v=19$m=131072,t=8,p=4$5hVLIQVnI5VrT90Oox0G6A$SSP9eON/XDV9V/6iZ165ii9wK/Av4NPpyYFmfa0HBvc" ================================================ FILE: apps/boruta_admin/test/data/import_users_password_custom_headers_valid.csv ================================================ username_header,password_header test1@test.test,averysecretpassword test2@test.test,averysecretpassword ================================================ FILE: apps/boruta_admin/test/data/import_users_password_invalid.csv ================================================ username,password test1@test.test,short ,short ,averysecretpassword test2@test.test,averysecretpassword ================================================ FILE: apps/boruta_admin/test/data/import_users_password_valid.csv ================================================ username,password test1@test.test,averysecretpassword test2@test.test,averysecretpassword ================================================ FILE: apps/boruta_admin/test/support/boruta_factory.ex ================================================ defmodule Boruta.Factory do @moduledoc false use ExMachina.Ecto, repo: BorutaAuth.Repo alias Boruta.Ecto def client_factory do %Ecto.Client{ secret: SecureRandom.urlsafe_base64(), redirect_uris: ["https://redirect.uri/oauth2-redirect-path"], access_token_ttl: 3600, authorization_code_ttl: 60, private_key: "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVO\nf8cU8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa\n9QyHsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8Wd\nSq3dGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/\nU8xDZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2t\npyQ0AEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQABAoIBAG0dg/upL8k1IWiv\n8BNphrXIYLYQmiiBQTPJWZGvWIC2sl7i40yvCXjDjiRnZNK9HwgL94XtALCXYRFR\nJD41bRA3MO5A0HSPIWwJXwS10/cU56HVCNHjwKa6Rz/QiG2kNASMZEMzlvHtrjna\ndx36/sjI3HH8gh1BaTZyiuDE72SMkPbL838jfL1YY9uJ0u6hWFDbdn3sqPfJ6Cnz\n1cu0piT35nkilnIGCNYA0i3lyMeo4XrdXaAJdN9nnqbCi5ewQWqaHbrIIY5LTgzJ\nYlOr3IiecyokFxHCbULXle60u0KqXYgBHmlQJJr1Dj4c9AkQmefjC2jRMlhOrIzo\nIkIUeMECgYEA+MNLB+w6vv1ogqzM3M1OLt6bziWJCn+XkziuMrCiY9KeDD+S70+E\nhfbhM5RjCE3wxC/k59039laT973BmdMHxrDd2zSjOFmCIORv5yrD5oBHMaMZcwuQ\n45Xisi4aoQoOhyznSnjo/RjeQB7qEDzXFznLLNT79HzqyAtCWD3UIu8CgYEA2yik\n9FKl7HJEY94D2K6vNh1AHGnkwIQC72pXzlUrVuwQYngj6/Gkhw8ayFBApHfwVCXj\no9rDYPdNrrAs0Zz0JsiJp6bOCEKCrMYE16UiejUUAg/OZ5eg6+3m3/iWatkzLUuK\n1LIkVBJlEyY0uPuAaBF0V0VleNvfCGhVYOn46+ECgYAUD4OsduNh5YOZDiBTKgdF\nBlSgMiyz+QgbKjX6Bn6B+EkgibvqqonwV7FffHbkA40H9SjLfe52YhL6poXHRtpY\nroillcAX2jgBOQrBJJS5sNyM5y81NNiRUdP/NHKXS/1R71ATlF6NkoTRvOx5NL7P\ns6xryB0tYSl5ylamUQ4bZwKBgHF6FB9mA//wErVbKcayfIqajq2nrwh30kVBXQG7\nW9uAE+PIrWDoF/bOvWFnHHGMoOYRUFNxXKUCqDiBhFNs34aNY6lpV1kzhxIK3ksC\neF2qyhdfM9Kz0mEXJ+pkfw4INNWJPfNv4hueArPtnnMB1rUMBJ+DkU0JG+zwiPTL\ncVZBAoGBAM6kOsh5KGn3aI83g9ZO0TrKLXXFotxJt31Wu11ydj9K33/Qj3UXcxd4\nJPXr600F0DkLeUKBob6BALeHFWcrSz5FGLGRqdRxdv+L6g18WH5m2xEs7o6M6e5I\nIhyUC60ZewJ2M8rV4KgCJJdZE2kENlSgjU92IDVPT9Oetrc7hQJd\n-----END RSA PRIVATE KEY-----\n\n", public_key: "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVOf8cU\n8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa9QyH\nsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8WdSq3d\nGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/U8xD\nZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2tpyQ0\nAEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQAB\n-----END RSA PUBLIC KEY-----\n\n" } end def scope_factory do %Ecto.Scope{ name: SecureRandom.hex(10), public: false } end def token_factory do %Ecto.Token{ client: build(:client), type: "access_token", value: Boruta.TokenGenerator.generate(), expires_at: :os.system_time(:seconds) + 10 } end end ================================================ FILE: apps/boruta_admin/test/support/boruta_identity_factory.ex ================================================ defmodule BorutaIdentity.Factory do @moduledoc false use ExMachina.Ecto, repo: BorutaIdentity.Repo alias BorutaIdentity.Accounts.Consent alias BorutaIdentity.Accounts.EmailTemplate alias BorutaIdentity.Accounts.Internal alias BorutaIdentity.Accounts.Role alias BorutaIdentity.Accounts.RoleScope alias BorutaIdentity.Accounts.User alias BorutaIdentity.Configuration.ErrorTemplate alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.IdentityProviders.ClientIdentityProvider alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaIdentity.IdentityProviders.Template alias BorutaIdentity.Organizations.Organization # @password "hello world!" @hashed_password "$argon2id$v=19$m=131072,t=8,p=4$9lPv7KsJogno0FlnhaRQXA$TeTY9FYjR1HJtZzg+N1z0oDC+0Mn7buPpOMhDP+M2Ik" def user_factory do %User{ username: "user#{System.unique_integer()}@example.com", uid: SecureRandom.hex(), backend: build(:backend) } end def internal_user_factory do %Internal.User{ email: "user#{System.unique_integer()}@example.com", hashed_password: @hashed_password, backend: build(:backend) } end def consent_factory do %Consent{ client_id: SecureRandom.uuid(), scopes: [] } end def client_identity_provider_factory do %ClientIdentityProvider{ client_id: SecureRandom.uuid(), identity_provider: build(:identity_provider) } end def identity_provider_factory do %IdentityProvider{ name: sequence(:name, &"identity provider #{&1}"), backend: build(:backend) } end def backend_factory do %Backend{ name: "backend name", type: "Elixir.BorutaIdentity.Accounts.Internal" } end def default_backend_factory do %Backend{ name: "backend name", type: "Elixir.BorutaIdentity.Accounts.Internal", is_default: true } end def template_factory do %Template{ type: "new_registration", content: "new registration template content", identity_provider: build(:identity_provider) } end def error_template_factory do %ErrorTemplate{ type: "400", content: "error template content" } end def email_template_factory do %EmailTemplate{ type: "template_type", txt_content: "template content", html_content: "template content" } end def reset_password_instructions_email_template_factory do %EmailTemplate{ type: "reset_password_instructions", txt_content: EmailTemplate.default_txt_content(:reset_password_instructions), html_content: EmailTemplate.default_html_content(:reset_password_instructions) } end def role_factory do %Role{ name: SecureRandom.hex(32) } end def role_scope_factory do %RoleScope{} end def organization_factory do %Organization{ name: "Organization " <> SecureRandom.hex() } end end ================================================ FILE: apps/boruta_admin/test/support/conn_case.ex ================================================ defmodule BorutaAdminWeb.ConnCase do @moduledoc """ This module defines the test case to be used by tests that require setting up a connection. Such tests rely on `Phoenix.ConnTest` and also import other functionality to make it easier to build common data structures and query the data layer. Finally, if the test case interacts with the database, we enable the SQL sandbox, so changes done to the database are reverted at the end of every test. If you are using PostgreSQL, you can even run database tests asynchronously by setting `use BorutaAdminWeb.ConnCase, async: true`, although this option is not recommended for other databases. """ use ExUnit.CaseTemplate import BorutaIdentity.AccountsFixtures alias Boruta.Ecto.OauthMapper alias Boruta.Oauth.IntrospectResponse alias Ecto.Adapters.SQL.Sandbox using do quote do # Import conveniences for testing with connections import Plug.Conn import Phoenix.ConnTest import BorutaAdminWeb.ConnCase alias BorutaAdminWeb.Router.Helpers, as: Routes # The default endpoint for testing @endpoint BorutaAdminWeb.Endpoint end end setup tags do :ok = Sandbox.checkout(BorutaAdmin.Repo) :ok = Sandbox.checkout(BorutaAuth.Repo) :ok = Sandbox.checkout(BorutaGateway.Repo) :ok = Sandbox.checkout(BorutaIdentity.Repo) :ok = Sandbox.checkout(BorutaWeb.Repo) unless tags[:async] do Sandbox.mode(BorutaAuth.Repo, {:shared, self()}) Sandbox.mode(BorutaAdmin.Repo, {:shared, self()}) Sandbox.mode(BorutaGateway.Repo, {:shared, self()}) Sandbox.mode(BorutaIdentity.Repo, {:shared, self()}) Sandbox.mode(BorutaWeb.Repo, {:shared, self()}) end conn = Phoenix.ConnTest.build_conn() {:ok, merge_tags_params(conn, tags)} end def merge_tags_params(conn, tags) do Enum.reduce(tags, [conn: conn], fn {:authorized, scopes}, params -> Keyword.merge( params, authorized_params(conn, scopes) ) {:user_authorized, scopes}, params -> Keyword.merge( params, user_authorized_params(conn, scopes) ) _, params -> params end) end def authorized_params(conn, scopes) do token = Boruta.Factory.insert( :token, type: "access_token", scope: Enum.join(scopes, " ") ) conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{token.value}") [conn: conn] end def user_authorized_params(conn, scopes) do %{id: sub} = user_fixture() resource_owner = %Boruta.Oauth.ResourceOwner{sub: sub} token = Boruta.Factory.insert(:token, type: "access_token", scope: Enum.join(scopes, " "), sub: resource_owner.sub ) conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{token.value}") # TODO test external oauth provider # %URI{port: port} = # URI.parse( # Application.get_env(:boruta_web, BorutaAdminWeb.Authorization)[:oauth2][ # :site # ] # ) # bypass = Bypass.open(port: port) # Bypass.up(bypass) # userinfo = # with {:ok, token} <- Boruta.Oauth.Token.userinfo(token) do # UserinfoResponse.from_userinfo(token, token.client) # |> UserinfoResponse.payload() # end # Bypass.stub(bypass, "POST", "/oauth/userinfo", fn conn -> # Plug.Conn.resp(conn, 200, Jason.encode!(userinfo)) # end) [conn: conn, resource_owner: resource_owner] end end ================================================ FILE: apps/boruta_admin/test/support/data_case.ex ================================================ defmodule BorutaAdmin.DataCase do @moduledoc """ This module defines the setup for tests requiring access to the application's data layer. You may define functions here to be used as helpers in your tests. Finally, if the test case interacts with the database, we enable the SQL sandbox, so changes done to the database are reverted at the end of every test. If you are using PostgreSQL, you can even run database tests asynchronously by setting `use BorutaAdmin.DataCase, async: true`, although this option is not recommended for other databases. """ use ExUnit.CaseTemplate alias Ecto.Adapters.SQL.Sandbox using do quote do alias BorutaAdmin.Repo import Ecto import Ecto.Changeset import Ecto.Query import BorutaAdmin.DataCase end end setup tags do :ok = Sandbox.checkout(BorutaAdmin.Repo) :ok = Sandbox.checkout(BorutaAuth.Repo) :ok = Sandbox.checkout(BorutaGateway.Repo) :ok = Sandbox.checkout(BorutaIdentity.Repo) :ok = Sandbox.checkout(BorutaWeb.Repo) unless tags[:async] do Sandbox.mode(BorutaAuth.Repo, {:shared, self()}) Sandbox.mode(BorutaAdmin.Repo, {:shared, self()}) Sandbox.mode(BorutaGateway.Repo, {:shared, self()}) Sandbox.mode(BorutaIdentity.Repo, {:shared, self()}) Sandbox.mode(BorutaWeb.Repo, {:shared, self()}) end :ok end @doc """ A helper that transforms changeset errors into a map of messages. assert {:error, changeset} = Accounts.create_user(%{password: "short"}) assert "password is too short" in errors_on(changeset).password assert %{password: ["password is too short"]} = errors_on(changeset) """ def errors_on(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> Regex.replace(~r"%{(\w+)}", message, fn _, key -> opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() end) end) end end ================================================ FILE: apps/boruta_admin/test/test_helper.exs ================================================ ExUnit.start() Mox.defmock(BorutaIdentity.LdapRepoMock, for: BorutaIdentity.LdapRepo) Ecto.Adapters.SQL.Sandbox.mode(BorutaAdmin.Repo, :manual) Ecto.Adapters.SQL.Sandbox.mode(BorutaAuth.Repo, :manual) Ecto.Adapters.SQL.Sandbox.mode(BorutaGateway.Repo, :manual) Ecto.Adapters.SQL.Sandbox.mode(BorutaIdentity.Repo, :manual) Ecto.Adapters.SQL.Sandbox.mode(BorutaWeb.Repo, :manual) ================================================ FILE: apps/boruta_auth/.formatter.exs ================================================ # Used by "mix format" [ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] ] ================================================ FILE: apps/boruta_auth/.gitignore ================================================ # The directory Mix will write compiled artifacts to. /_build/ # If you run "mix test --cover", coverage assets end up here. /cover/ # The directory Mix downloads your dependencies sources to. /deps/ # Where third-party dependencies like ExDoc output generated docs. /doc/ # Ignore .fetch files in case you like to edit your project deps locally. /.fetch # If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump # Also ignore archive artifacts (built via "mix archive.build"). *.ez # Ignore package tarball (built via "mix hex.build"). boruta_auth-*.tar # Temporary files, for example, from tests. /tmp/ ================================================ FILE: apps/boruta_auth/config/config.exs ================================================ import Config config :boruta_auth, ecto_repos: [BorutaAuth.Repo] config :boruta, Boruta.Oauth, repo: BorutaAuth.Repo, contexts: [ resource_owners: BorutaIdentity.ResourceOwners ], issuer: System.get_env("BORUTA_OAUTH_BASE_URL", "http://localhost:4000") config :boruta_auth, BorutaAuth.Scheduler, jobs: [ {"@daily", {BorutaAuth.LogRotate, :rotate, []}} ] config :boruta_auth, BorutaAuth.LogRotate, max_retention_days: String.to_integer(System.get_env("MAX_LOG_RETENTION_DAYS", "60")) import_config "#{Mix.env()}.exs" ================================================ FILE: apps/boruta_auth/config/dev.exs ================================================ import Config config :boruta_identity, BorutaIdentity.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_dev", hostname: System.get_env("POSTGRES_HOST") || "localhost" config :boruta_auth, BorutaAuth.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_dev", hostname: System.get_env("POSTGRES_HOST") || "localhost" ================================================ FILE: apps/boruta_auth/config/prod.exs ================================================ import Config ================================================ FILE: apps/boruta_auth/config/test.exs ================================================ import Config config :boruta_identity, BorutaIdentity.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_identity_test", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 5 config :boruta_auth, BorutaAuth.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_identity_test", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 5 ================================================ FILE: apps/boruta_auth/lib/boruta_auth/application.ex ================================================ defmodule BorutaAuth.Application do @moduledoc false use Application def start(_type, _args) do children = [ BorutaAuth.Repo, BorutaAuth.Scheduler ] BorutaAuth.LogRotate.rotate() setup_database() opts = [strategy: :one_for_one, name: BorutaAuth.Supervisor] Supervisor.start_link(children, opts) end def setup_database do Enum.each([BorutaAuth.Repo], fn repo -> repo.__adapter__.storage_up(repo.config) end) :ok end end ================================================ FILE: apps/boruta_auth/lib/boruta_auth/key_pairs/schemas/key_pair.ex ================================================ defmodule BorutaAuth.KeyPairs.KeyPair do @moduledoc false use Ecto.Schema import Ecto.Changeset alias BorutaAuth.Repo @type t :: %__MODULE__{ id: String.t(), public_key: String.t(), private_key: String.t(), is_default: boolean() | nil } @primary_key {:id, :binary_id, autogenerate: true} schema "key_pairs" do field(:public_key, :string) field(:private_key, :string) field(:is_default, :boolean) timestamps() end @spec default!() :: t() def default! do case Repo.get_by(__MODULE__, is_default: true) do nil -> {:ok, key_pair} = change(%__MODULE__{}, %{is_default: true}) |> generate_key_pair() |> Repo.insert() key_pair key_pair -> key_pair end end @doc false def changeset(key_pair, attrs) do key_pair |> cast(attrs, [:is_default]) |> generate_key_pair() |> validate_required([:public_key, :private_key]) |> set_default() end def delete_changeset(key_pair) do change(key_pair) |> check_default() end def rotate_changeset(key_pair) do private_key = JOSE.JWK.generate_key({:rsa, 2048, 65_537}) public_key = JOSE.JWK.to_public(private_key) {_type, public_pem} = JOSE.JWK.to_pem(public_key) {_type, private_pem} = JOSE.JWK.to_pem(private_key) change(key_pair) |> put_change(:public_key, public_pem) |> put_change(:private_key, private_pem) end defp set_default(%Ecto.Changeset{changes: %{is_default: true}} = changeset) do # TODO use a transaction to change default key_pair case change(default!(), %{is_default: false}) |> Repo.update() do {:ok, _key_pair} -> changeset {:error, changeset} -> add_error( changeset, :is_default, "Cannot remove value from the existing default key_pair." ) end rescue Ecto.NoResultsError -> changeset end defp set_default(changeset), do: changeset defp generate_key_pair(changeset) do case get_field(changeset, :private_key) do nil -> private_key = JOSE.JWK.generate_key({:rsa, 1024, 65_537}) public_key = JOSE.JWK.to_public(private_key) {_type, public_pem} = JOSE.JWK.to_pem(public_key) {_type, private_pem} = JOSE.JWK.to_pem(private_key) changeset |> put_change(:public_key, public_pem) |> put_change(:private_key, private_pem) _private_key -> changeset end end defp check_default(changeset) do case get_field(changeset, :is_default) do true -> add_error( changeset, :is_default, "Cannot delete a default key pair." ) _ -> changeset end end end ================================================ FILE: apps/boruta_auth/lib/boruta_auth/key_pairs.ex ================================================ defmodule BorutaAuth.KeyPairs do @moduledoc false import Ecto.Query alias Boruta.Ecto.Client alias Boruta.Oauth.Client.Crypto alias BorutaAuth.KeyPairs.KeyPair alias BorutaAuth.Repo def list_key_pairs do Repo.all(KeyPair) end def get_key_pair!(id) do Repo.get!(KeyPair, id) end def list_jwks do Repo.all(KeyPair) |> Enum.map(&rsa_key/1) end def create_key_pair(attrs \\ %{}) do KeyPair.changeset(%KeyPair{}, attrs) |> Repo.insert() end def update_key_pair(key_pair, attrs \\ %{}) do KeyPair.changeset(key_pair, attrs) |> Repo.update() end def delete_key_pair(%KeyPair{} = key_pair) do key_pair |> KeyPair.delete_changeset() |> Repo.delete() end def rotate(%KeyPair{private_key: private_key} = key_pair) do with {:ok, key_pair} <- KeyPair.rotate_changeset(key_pair) |> Repo.update() do Repo.update_all( from(c in Client, where: c.private_key == ^private_key, select: c.id), set: [ public_key: key_pair.public_key, private_key: key_pair.private_key ] ) {:ok, key_pair} end end defp rsa_key(%KeyPair{} = key_pair) do {_type, jwk} = key_pair.public_key |> :jose_jwk.from_pem() |> :jose_jwk.to_map() Map.put(jwk, "kid", Crypto.kid_from_private_key(key_pair.private_key)) end end ================================================ FILE: apps/boruta_auth/lib/boruta_auth/log_rotate.ex ================================================ defmodule BorutaAuth.LogRotate do @moduledoc false def rotate do today = Date.utc_today() max_retention_days = Application.get_env(:boruta_auth, BorutaAuth.LogRotate)[:max_retention_days] log_dates = log_dates( %{today|month: 1, day: 1}, Date.add(today, -1 * max_retention_days) ) Enum.map([:request, :business], fn type -> Enum.map([:boruta_web, :boruta_identity, :boruta_admin, :boruta_gateway], fn application -> _files_deleted? = Enum.map(log_dates, &path(application, type, &1)) |> Enum.filter(&File.exists?/1) |> Enum.map(&File.rm/1) Logger.configure_backend({LoggerFileBackend, :"#{application}_#{type}_logger"}, path: path(application, type, Date.utc_today()) ) end) end) end @spec path(application :: atom(), type :: atom(), date :: Date.t()) :: path :: String.t() def path(application, type, date) do "./log/#{Date.to_string(date)}_#{application}_#{type}.log" end defp log_dates(start_date, end_date) do if Date.compare(start_date, end_date) == :gt do [] else [start_date | log_dates(Date.add(start_date, 1), end_date)] end end end ================================================ FILE: apps/boruta_auth/lib/boruta_auth/repo.ex ================================================ defmodule BorutaAuth.Repo do use Ecto.Repo, otp_app: :boruta_auth, adapter: Ecto.Adapters.Postgres end ================================================ FILE: apps/boruta_auth/lib/boruta_auth/scheduler.ex ================================================ defmodule BorutaAuth.Scheduler do @moduledoc false use Quantum, otp_app: :boruta_auth end ================================================ FILE: apps/boruta_auth/lib/boruta_auth.ex ================================================ defmodule BorutaAuth do @moduledoc """ Documentation for `BorutaAuth`. """ @doc """ Hello world. ## Examples iex> BorutaAuth.hello() :world """ def hello do :world end end ================================================ FILE: apps/boruta_auth/mix.exs ================================================ defmodule BorutaAuth.MixProject do use Mix.Project def project do [ app: :boruta_auth, version: "0.1.0", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", lockfile: "../../mix.lock", elixir: "~> 1.12", start_permanent: Mix.env() == :prod, deps: deps() ] end def application do [ mod: {BorutaAuth.Application, []}, extra_applications: [:logger] ] end defp deps do [ {:boruta, git: "https://github.com/malach-it/boruta_auth.git", branch: "provider-policies-registration"}, {:logger_file_backend, "~> 0.0.13"}, {:quantum, "~> 3.0"} ] end end ================================================ FILE: apps/boruta_auth/priv/repo/boruta.seeds.exs ================================================ BorutaAuth.Repo.insert( %Boruta.Ecto.Scope{ name: "openid", label: "OpenID Connect capabilities", public: true }, on_conflict: :nothing ) BorutaAuth.Repo.insert( %Boruta.Ecto.Scope{ name: "email", label: "Email", public: true }, on_conflict: :nothing ) BorutaAuth.Repo.insert( %Boruta.Ecto.Scope{ name: "profile", label: "Profile", public: true }, on_conflict: :nothing ) BorutaAuth.Repo.insert( %Boruta.Ecto.Scope{ name: "scopes:manage:all", label: "Manage all scopes" }, on_conflict: :nothing ) BorutaAuth.Repo.insert( %Boruta.Ecto.Scope{ name: "roles:manage:all", label: "Manage all roles" }, on_conflict: :nothing ) BorutaAuth.Repo.insert( %Boruta.Ecto.Scope{ name: "clients:manage:all", label: "Manage all clients" }, on_conflict: :nothing ) BorutaAuth.Repo.insert( %Boruta.Ecto.Scope{ name: "upstreams:manage:all", label: "Manage all upstreams" }, on_conflict: :nothing ) BorutaAuth.Repo.insert( %Boruta.Ecto.Scope{ name: "users:manage:all", label: "Manage all users" }, on_conflict: :nothing ) BorutaAuth.Repo.insert( %Boruta.Ecto.Scope{ name: "identity-providers:manage:all", label: "Manage all identity providers" }, on_conflict: :nothing ) BorutaAuth.Repo.insert( %Boruta.Ecto.Scope{ name: "configuration:manage:all", label: "Manage all configuration" }, on_conflict: :nothing ) BorutaAuth.Repo.insert( %Boruta.Ecto.Scope{ name: "logs:read:all", label: "Read all logs" }, on_conflict: :nothing ) client_id = System.get_env("BORUTA_ADMIN_OAUTH_CLIENT_ID", "6a2f41a3-c54c-fce8-32d2-0324e1c32e20") client = case Boruta.Ecto.Admin.create_client(%{ name: "Boruta administration panel", secret: System.get_env("BORUTA_ADMIN_OAUTH_CLIENT_SECRET"), id: client_id, redirect_uris: [ "#{System.get_env("BORUTA_ADMIN_BASE_URL", "http://localhost:4001")}/oauth-callback" ], access_token_ttl: 3600, authorization_code_ttl: 60, public_revoke: true }) do {:ok, client} -> client {:error, _error} -> Boruta.Ecto.Admin.get_client!(client_id) end backend = BorutaIdentity.IdentityProviders.Backend.default!() BorutaIdentity.IdentityProviders.create_identity_provider(%{ name: "Default", registrable: true, backend_id: backend.id }) identity_provider = case BorutaIdentity.IdentityProviders.create_identity_provider(%{ name: "Boruta administration interface", registrable: false, backend_id: backend.id }) do {:ok, identity_provider} -> identity_provider {:error, _error} -> BorutaIdentity.IdentityProviders.list_identity_providers() |> Enum.find(fn %{name: name} -> name == "Boruta administration interface" end) end BorutaIdentity.IdentityProviders.upsert_client_identity_provider(client.id, identity_provider.id) email = System.get_env("BORUTA_ADMIN_EMAIL") user = case BorutaIdentity.Accounts.Internal.User.registration_changeset( %BorutaIdentity.Accounts.Internal.User{}, %{ email: email, password: System.get_env("BORUTA_ADMIN_PASSWORD"), password_confirmation: System.get_env("BORUTA_ADMIN_PASSWORD"), confirmed_at: DateTime.utc_now() }, %{backend: backend} ) |> BorutaIdentity.Repo.insert() do {:ok, user} -> user = BorutaIdentity.Accounts.Internal.domain_user!(user, backend) Boruta.Ecto.Admin.get_scopes_by_names([ "users:manage:all", "clients:manage:all", "scopes:manage:all", "roles:manage:all", "upstreams:manage:all", "identity-providers:manage:all", "configuration:manage:all", "logs:read:all" ]) |> Enum.map(fn %{id: scope_id} -> %BorutaIdentity.Accounts.UserAuthorizedScope{ scope_id: scope_id, user_id: user.id } |> Ecto.Changeset.change() |> BorutaIdentity.Repo.insert(on_conflict: :nothing) end) {:error, _error} -> nil end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20201129024828_create_boruta.exs ================================================ defmodule BorutaWeb.Repo.Migrations.CreateBoruta do use Ecto.Migration def change do create table(:clients, primary_key: false) do add(:id, :uuid, primary_key: true) add(:name, :string) add(:secret, :string) add(:redirect_uris, {:array, :string}) add(:scope, :string) add(:authorize_scope, :boolean, default: false) add(:supported_grant_types, {:array, :string}) add(:authorization_code_ttl, :integer, null: false) add(:access_token_ttl, :integer, null: false) add(:pkce, :boolean, default: false) timestamps() end create table(:tokens, primary_key: false) do add(:id, :uuid, primary_key: true) add(:type, :string) add(:value, :string) add(:refresh_token, :string) add(:expires_at, :integer) add(:redirect_uri, :string) add(:state, :string) add(:scope, :string) add(:revoked_at, :utc_datetime) add(:code_challenge_hash, :string) add(:code_challenge_method, :string) add(:client_id, references(:clients, type: :uuid, on_delete: :nilify_all)) add(:sub, :string) timestamps() end create table(:scopes, primary_key: false) do add :id, :binary_id, primary_key: true add :name, :string add :public, :boolean, default: false, null: false timestamps() end create table(:clients_scopes) do add(:client_id, references(:clients, type: :uuid, on_delete: :delete_all)) add(:scope_id, references(:scopes, type: :uuid, on_delete: :delete_all)) end create unique_index(:clients, [:id, :secret]) create index("tokens", [:value]) create unique_index("tokens", [:client_id, :value]) create unique_index("tokens", [:client_id, :refresh_token]) create unique_index("scopes", [:name]) end end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20210114202055_usec_timestamps.exs ================================================ defmodule Boruta.Repo.Migrations.UsecTimestamps do use Ecto.Migration def change do alter table(:tokens) do modify :inserted_at, :utc_datetime_usec modify :updated_at, :utc_datetime_usec end end end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20210202095024_add_key_pair_to_clients.exs ================================================ defmodule Boruta.Repo.Migrations.AddKeyPairToClients do use Ecto.Migration def change do alter table(:clients) do add :public_key, :text, null: false add :private_key, :text, null: false end end end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20210301123331_add_label_to_scopes.exs ================================================ defmodule Boruta.Repo.Migrations.AddLabelToScopes do use Ecto.Migration def change do alter table(:scopes) do add :label, :string end end end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20210514211510_add_default_redirect_uris_to_clients.exs ================================================ defmodule BorutaWeb.Repo.Migrations.AddDefaultRedirectUrisToClients do use Ecto.Migration import Ecto.Query alias Boruta.Ecto.Client alias BorutaWeb.Repo def change do alter table(:clients) do modify :redirect_uris, {:array, :string}, null: false, default: [], using: "array[redirect_uri]" end end end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20210919174149_openid_connect.exs ================================================ defmodule BorutaWeb.Repo.Migrations.OpenidConnect do use Ecto.Migration use Boruta.Migrations.OpenidConnect end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20210919174150_clients_refresh_tokens.exs ================================================ defmodule BorutaWeb.Repo.Migrations.ClientsRefreshTokens do use Ecto.Migration use Boruta.Migrations.ClientsRefreshTokens end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20211013161324_clients_public_revoke.exs ================================================ defmodule BorutaWeb.Repo.Migrations.ClientsPublicRevoke do use Ecto.Migration use Boruta.Migrations.ClientsPublicRevoke end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20220113221532_store_previous_token.exs ================================================ defmodule BorutaWeb.Repo.Migrations.StorePreviousToken do use Ecto.Migration use Boruta.Migrations.StorePreviousToken end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20220603211852_id_token_signature_alg_configuration.exs ================================================ defmodule BorutaAuth.Repo.Migrations.IdTokenSignatureAlgConfiguration do use Ecto.Migration use Boruta.Migrations.IdTokenSignatureAlgConfiguration end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20220625203958_confidential_clients.exs ================================================ defmodule BorutaAuth.Repo.Migrations.ConfidentialClients do use Ecto.Migration use Boruta.Migrations.ConfidentialClients end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20220824105115_refresh_token_rotation.exs ================================================ defmodule BorutaAuth.Repo.Migrations.RefreshTokenRotation do use Ecto.Migration use Boruta.Migrations.RefreshTokenRotation end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20221025084535_authorization_code_chains.exs ================================================ defmodule BorutaAuth.Repo.Migrations.AuthorizationCodeChains do use Ecto.Migration use Boruta.Migrations.AuthorizationCodeChains end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20221122131429_client_authentication_methods.exs ================================================ defmodule BorutaAuth.Repo.Migrations.ClientAuthenticationMethods do use Ecto.Migration use Boruta.Migrations.ClientAuthenticationMethods end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20221129120152_signed_userinfo_response.exs ================================================ defmodule BorutaAuth.Repo.Migrations.SignedUserinfoResponse do use Ecto.Migration use Boruta.Migrations.SignedUserinfoResponse end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20230506151359_optional_public_key_for_oauth_clients.exs ================================================ defmodule BorutaAuth.Repo.Migrations.OptionalPublicKeyForOauthClients do use Ecto.Migration use Boruta.Migrations.OptionalPublicKeyForOauthClients end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20230514134306_clients_jwks_uri.exs ================================================ defmodule BorutaAuth.Repo.Migrations.ClientsJwksUri do use Ecto.Migration use Boruta.Migrations.ClientsJwksUri end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20230515093131_create_key_pairs.exs ================================================ defmodule BorutaAuth.Repo.Migrations.CreateKeyPairs do use Ecto.Migration def change do create table(:key_pairs, primary_key: false) do add :id, :uuid, primary_key: true add :public_key, :text, null: false add :private_key, :text, null: false add :is_default, :boolean timestamps() end end end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20230515152140_client_id_token_kid.exs ================================================ defmodule BorutaAuth.Repo.Migrations.ClientIdTokenKid do use Ecto.Migration use Boruta.Migrations.ClientIdTokenKid end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20231217091349_add_metadata_to_clients.exs ================================================ defmodule BorutaAuth.Repo.Migrations.AddMetadataToClients do use Ecto.Migration use Boruta.Migrations.AddMetadataToClients end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20231217144905_oid4vci_implementation.exs ================================================ defmodule BorutaAuth.Repo.Migrations.Oid4vciImplementation do use Ecto.Migration use Boruta.Migrations.Oid4vciImplementation end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20240127081327_siopv2_implementation.exs ================================================ defmodule BorutaAuth.Repo.Migrations.Siopv2Implementation do use Ecto.Migration use Boruta.Migrations.Siopv2Implementation end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20240321101558_dpop_implementation.exs ================================================ defmodule BorutaAuth.Repo.Migrations.DpopImplementation do use Ecto.Migration use Boruta.Migrations.DpopImplementation end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20240417052138_par_implementation.exs ================================================ defmodule BorutaAuth.Repo.Migrations.ParImplementation do use Ecto.Migration use Boruta.Migrations.ParImplementation end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20240506083712_c_nonce_implementation.exs ================================================ defmodule BorutaAuth.Repo.Migrations.CNonceImplementation do use Ecto.Migration use Boruta.Migrations.CNonceImplementation end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20240812111902_defered_credentials.exs ================================================ defmodule BorutaAuth.Repo.Migrations.DeferedCredentials do use Ecto.Migration use Boruta.Migrations.DeferedCredentials end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20240824191208_clients_did.exs ================================================ defmodule BorutaAuth.Repo.Migrations.ClientsDid do use Ecto.Migration use Boruta.Migrations.ClientsDid end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20240908082918_verifiable_presentation_definitions.exs ================================================ defmodule BorutaAuth.Repo.Migrations.VerifiablePresentationDefinitions do use Ecto.Migration use Boruta.Migrations.VerifiablePresentationDefinitions end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20240914084657_clients_response_mode.exs ================================================ defmodule BorutaAuth.Repo.Migrations.ClientsResponseMode do use Ecto.Migration use Boruta.Migrations.ClientsResponseMode end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20241021132955_clients_key_pair_types.exs ================================================ defmodule BorutaAuth.Repo.Migrations.ClientsKeyPairTypes do use Ecto.Migration use Boruta.Migrations.ClientsKeyPairTypes end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20241209110846_tokens_tx_code.exs ================================================ defmodule BorutaAuth.Repo.Migrations.TokensTxCode do use Ecto.Migration use Boruta.Migrations.TokensTxCode end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20241220104923_clients_signatures_adapters.exs ================================================ defmodule BorutaAuth.Repo.Migrations.ClientsSignaturesAdapters do use Ecto.Migration use Boruta.Migrations.ClientsSignaturesAdapters end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20250315084213_fix_oauth_clients_did.exs ================================================ defmodule BorutaAuth.Repo.Migrations.FixOauthClientsDid do use Ecto.Migration use Boruta.Migrations.FixOauthClientsDid end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20250413070457_agent_credentials.exs ================================================ defmodule BorutaAuth.Repo.Migrations.AgentCredentials do use Ecto.Migration use Boruta.Migrations.AgentCredentials end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20250524160749_public_client_id.exs ================================================ defmodule BorutaAuth.Repo.Migrations.PublicClientId do use Ecto.Migration use Boruta.Migrations.PublicClientId end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20260324152715_codes_response_type.exs ================================================ defmodule BorutaAuth.Repo.Migrations.CodesResponseType do use Ecto.Migration use Boruta.Migrations.CodesResponseType end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20260324152716_code_metadata_policy.exs ================================================ defmodule BorutaAuth.Repo.Migrations.CodeMetadataPolicy do use Ecto.Migration use Boruta.Migrations.CodeMetadataPolicy end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20260330112657_siopv2_encryption.exs ================================================ defmodule BorutaAuth.Repo.Migrations.Siopv2Encryption do use Ecto.Migration use Boruta.Migrations.Siopv2Encryption end ================================================ FILE: apps/boruta_auth/priv/repo/migrations/20260428121802_requested_scope.exs ================================================ defmodule BorutaAuth.Repo.Migrations.RequestedScope do use Ecto.Migration use Boruta.Migrations.RequestedScope end ================================================ FILE: apps/boruta_auth/test/boruta_auth/key_pairs_test.exs ================================================ defmodule BorutaAuth.KeyPairsTest do use ExUnit.Case @tag :skip test "list_key_pairs/0" @tag :skip test "list_jwks/0" @tag :skip test "get_key_pair!/1" @tag :skip test "create_key_pair/1" @tag :skip test "update_key_pair/2" @tag :skip test "rotate/1" @tag :skip test "delete_key_pair/0" end ================================================ FILE: apps/boruta_auth/test/test_helper.exs ================================================ ExUnit.start() ================================================ FILE: apps/boruta_gateway/.formatter.exs ================================================ [ import_deps: [:ecto], inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], subdirectories: ["priv/*/migrations"] ] ================================================ FILE: apps/boruta_gateway/.gitignore ================================================ # The directory Mix will write compiled artifacts to. /_build/ # If you run "mix test --cover", coverage assets end up here. /cover/ # The directory Mix downloads your dependencies sources to. /deps/ # Where 3rd-party dependencies like ExDoc output generated docs. /doc/ # Ignore .fetch files in case you like to edit your project deps locally. /.fetch # If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump # Also ignore archive artifacts (built via "mix archive.build"). *.ez # Ignore package tarball (built via "mix hex.build"). boruta_gateway-*.tar ================================================ FILE: apps/boruta_gateway/config/config.exs ================================================ import Config config :boruta_gateway, ecto_repos: [BorutaGateway.Repo, BorutaAuth.Repo] import_config "#{Mix.env()}.exs" ================================================ FILE: apps/boruta_gateway/config/dev.exs ================================================ import Config config :boruta_gateway, BorutaGateway.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_dev", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 5 config :boruta_gateway, server: true, sidecar_server: true, port: System.get_env("BORUTA_GATEWAY_PORT", "5000") |> String.to_integer(), sidecar_port: System.get_env("BORUTA_GATEWAY_SIDECAR_PORT", "5001") |> String.to_integer() ================================================ FILE: apps/boruta_gateway/config/prod.exs ================================================ import Config ================================================ FILE: apps/boruta_gateway/config/test.exs ================================================ import Config config :boruta_gateway, BorutaGateway.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_gateway_test", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool: Ecto.Adapters.SQL.Sandbox config :boruta_auth, BorutaAuth.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_auth_test", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool: Ecto.Adapters.SQL.Sandbox config :boruta_gateway, server: true, sidecar_server: true, port: 7777, sidecar_port: 7778 ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/application.ex ================================================ defmodule BorutaGateway.Application do # See https://hexdocs.pm/elixir/Application.html # for more information on OTP Applications @moduledoc false use Application alias BorutaGateway.Logger alias BorutaGateway.Upstreams alias BorutaGateway.Upstreams.ClientSupervisor def start(_type, _args) do children = [ BorutaGateway.Repo, %{ id: Upstreams.Store, start: {Upstreams.Store, :start_link, []} }, {ClientSupervisor, strategy: :one_for_one} ] Logger.start() children = case Application.get_env(:boruta_gateway, :server) do true -> [ %{ start: {BorutaGateway.Server, :start_link, [ [ port: Application.fetch_env!(:boruta_gateway, :port), router: BorutaGateway.Router ] ]}, id: :server } | children ] _ -> children end children = case Application.get_env(:boruta_gateway, :sidecar_server) do true -> [ %{ start: {BorutaGateway.Server, :start_link, [ [ port: Application.fetch_env!(:boruta_gateway, :sidecar_port), router: BorutaGateway.SidecarRouter ] ]}, id: :sidecar_server } | children ] _ -> children end setup_database() Supervisor.start_link(children, strategy: :one_for_one, name: BorutaGateway.Supervisor) end def setup_database do Enum.each([BorutaGateway.Repo], fn repo -> repo.__adapter__.storage_up(repo.config) end) Enum.each([BorutaGateway.Repo], fn repo -> Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) end) :ok end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/configuration_loader.ex ================================================ defmodule BorutaGateway.ConfigurationLoader do @moduledoc false alias BorutaGateway.ConfigurationSchemas.GatewaySchema alias BorutaGateway.Upstreams @spec node_name() :: node_name :: String.t() def node_name do case Application.get_env(__MODULE__, :node_name) do nil -> path = Application.get_env(:boruta_gateway, :configuration_path) %{ "configuration" => %{ "node_name" => node_name } } = YamlElixir.read_from_file!(path) Application.put_env(__MODULE__, :node_name, node_name) node_name node_name -> node_name end rescue _ -> node_name = Atom.to_string(node()) Application.put_env(__MODULE__, :node_name, node_name) node_name end @spec from_file!(configuration_file_path :: String.t()) :: :ok def from_file!(path) do %{"configuration" => configuration} = YamlElixir.read_from_file!(path) load_configuration!(configuration) end defp load_configuration!(%{"gateway" => gateway_configurations} = configuration) do _created_upstreams = Enum.map(gateway_configurations, fn gateway_configuration -> case ExJsonSchema.Validator.validate(GatewaySchema.gateway(), gateway_configuration) do :ok -> {:ok, _upstream} = Upstreams.create_upstream(gateway_configuration) :ok {:error, errors} -> raise inspect(errors) end end) load_configuration!(Map.delete(configuration, "gateway")) end defp load_configuration!(%{"microgateway" => microgateway_configurations} = configuration) do _created_upstreams = Enum.map(microgateway_configurations, fn microgateway_configuration -> microgateway_configuration = Map.put( microgateway_configuration, "node_name", node_name() ) case ExJsonSchema.Validator.validate( GatewaySchema.microgateway(), microgateway_configuration ) do :ok -> {:ok, _upstream} = Upstreams.create_upstream(microgateway_configuration) :ok {:error, errors} -> raise inspect(errors) end end) load_configuration!(Map.delete(configuration, "microgateway")) end defp load_configuration!(%{}), do: :ok end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/configuration_schemas/gateway.ex ================================================ defmodule BorutaGateway.ConfigurationSchemas.GatewaySchema do @moduledoc false alias ExJsonSchema.Schema def gateway do %{ "type" => "object", "properties" => %{ "authorize" => %{"type" => "boolean"}, "error_content_type" => %{"type" => "string"}, "forbidden_response" => %{"type" => "string"}, "unauthorized_response" => %{"type" => "string"}, "forwarded_token_private_key" => %{"type" => "string"}, "forwarded_token_public_key" => %{"type" => "string"}, "forwarded_token_secret" => %{"type" => "string"}, "forwarded_token_signature_alg" => %{"type" => "string"}, "scheme" => %{"type" => "string", "pattern" => "^(http|https)$"}, "host" => %{"type" => "string"}, "port" => %{"type" => "number"}, "uris" => %{ "type" => "array", "items" => %{ "type" => "string" } }, "strip_uri" => %{"type" => "boolean"}, "pool_count" => %{"type" => "number"}, "pool_size" => %{"type" => "number"}, "max_idle_time" => %{"type" => "number"}, "required_scopes" => %{ "type" => "object", "patternProperties" => %{ "(GET|POST|PUT|HEAD|OPTIONS|PATCH|DELETE|\\*)" => %{ "type" => "array", "items" => %{ "type" => "string", "pattern" => ".+" }, "minItems" => 1 } }, "additionalProperties" => false } }, "required" => ["scheme", "host", "port", "uris"] } |> Schema.resolve() end def microgateway do %{ "type" => "object", "properties" => %{ "node_name" => %{"type" => "string"}, "authorize" => %{"type" => "boolean"}, "error_content_type" => %{"type" => "string"}, "forbidden_response" => %{"type" => "string"}, "unauthorized_response" => %{"type" => "string"}, "forwarded_token_private_key" => %{"type" => "string"}, "forwarded_token_public_key" => %{"type" => "string"}, "forwarded_token_secret" => %{"type" => "string"}, "forwarded_token_signature_alg" => %{"type" => "string"}, "scheme" => %{"type" => "string", "pattern" => "^(http|https)$"}, "host" => %{"type" => "string"}, "port" => %{"type" => "number"}, "uris" => %{ "type" => "array", "items" => %{ "type" => "string" } }, "strip_uri" => %{"type" => "boolean"}, "pool_count" => %{"type" => "number"}, "pool_size" => %{"type" => "number"}, "max_idle_time" => %{"type" => "number"}, "required_scopes" => %{ "type" => "object", "patternProperties" => %{ "(GET|POST|PUT|HEAD|OPTIONS|PATCH|DELETE|\\*)" => %{ "type" => "array", "items" => %{ "type" => "string", "pattern" => ".+" }, "minItems" => 1 } }, "additionalProperties" => false } }, "required" => ["node_name", "scheme", "host", "port", "uris"] } |> Schema.resolve() end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/gateway_pipeline.ex ================================================ defmodule BorutaGateway.GatewayPipeline do @moduledoc false use Plug.Router plug(RemoteIp) plug(Plug.RequestId) plug(BorutaGateway.Plug.Metrics) plug(BorutaGateway.Plug.AssignUpstream) plug(Plug.Telemetry, event_prefix: [:boruta_gateway, :endpoint] ) plug(:match) plug(BorutaGateway.Plug.Authorize) plug(:dispatch) match(_, to: BorutaGateway.Plug.Handler, init_opts: []) end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/logger.ex ================================================ defmodule BorutaGateway.Logger do @moduledoc false require Logger def start do handlers = [ { :boruta_gateway_server, [:boruta_gateway, :endpoint, :stop], &__MODULE__.boruta_gateway_server_handler/4 }, { :boruta_gateway_requests, [:boruta_gateway, :request, :done], &__MODULE__.boruta_gateway_request_handler/4 } ] for {handler_id, event_name, fun} <- handlers do :telemetry.attach(handler_id, event_name, fun, :ok) end end def boruta_gateway_server_handler(_, %{duration: duration}, %{conn: conn} = metadata, _) do case log_level(metadata[:options][:log], conn) do false -> :ok level -> Logger.log( level, fn -> %{method: method, request_path: path, status: status, state: state} = conn status = Integer.to_string(status) [ "boruta_gateway", ?\s, method, ?\s, path, " - ", connection_type(state), ?\s, status, " in ", duration(duration) ] end, type: :request ) end end def boruta_gateway_request_handler( _, _measurements, %{request_time: request_time, conn: conn}, _ ) do %{method: method, request_path: path, status: status_code} = conn node_name = conn.assigns[:node_name] status_code = Integer.to_string(status_code) remote_ip = :inet.ntoa(conn.remote_ip) status = business_status(conn) log_line = [ "boruta_gateway", ?\s, "upstream", ?\s, "request", " - ", status, log_attribute("node_name", node_name), log_attribute("remote_ip", remote_ip), log_attribute("method", method), log_attribute("path", path), log_attribute("status_code", status_code), log_attribute("request_time", [to_string(request_time), "µs"]) ] log_line = log_line |> put_access_token(conn) |> put_upstream_attributes(conn, request_time) |> put_upstream_error(conn) Logger.log( :info, fn -> log_line end, type: :business ) end defp business_status(conn) do case conn.assigns[:upstream] do nil -> "failure" _upstream -> "success" end end defp put_access_token(log_line, conn) do case conn.assigns[:token] do nil -> log_line token -> log_line ++ [ log_attribute("access_token", token.value) ] end end defp put_upstream_attributes(log_line, conn, request_time) do case conn.assigns[:upstream] do nil -> log_line upstream -> upstream_time = conn.assigns[:upstream_time] log_line ++ [ log_attribute("upstream_scheme", upstream.scheme), log_attribute("upstream_host", upstream.host), log_attribute("upstream_port", upstream.port), log_attribute("upstream_time", upstream_time && [to_string(upstream_time), "µs"]), log_attribute( "gateway_time", upstream_time && [to_string(request_time - upstream_time), "µs"] ) ] end end defp put_upstream_error(log_line, conn) do case conn.assigns[:upstream_error] do nil -> log_line error -> log_line ++ [log_attribute("upstream_error", ~s["#{inspect(error)}"])] end end defp log_attribute(_key, nil), do: "" defp log_attribute(key, attribute), do: " #{key}=#{attribute}" # From Phoenix.Logger defp log_level(nil, _conn), do: :info defp log_level(level, _conn) when is_atom(level), do: level defp log_level({mod, fun, args}, conn) when is_atom(mod) and is_atom(fun) and is_list(args) do apply(mod, fun, [conn | args]) end defp connection_type(:set_chunked), do: "chunked" defp connection_type(_), do: "sent" defp duration(nil), do: ["0", "µs"] defp duration(duration) do duration = System.convert_time_unit(duration, :native, :microsecond) if duration > 1000 do [duration |> div(1000) |> Integer.to_string(), "ms"] else [Integer.to_string(duration), "µs"] end end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/microgateway_pipeline.ex ================================================ defmodule BorutaGateway.MicrogatewayPipeline do @moduledoc false use Plug.Router plug(RemoteIp) plug(Plug.RequestId) plug(BorutaGateway.Plug.Metrics) plug(BorutaGateway.Plug.AssignSidecarUpstream) plug(Plug.Telemetry, event_prefix: [:boruta_gateway, :endpoint] ) plug(:match) plug(BorutaGateway.Plug.Authorize) plug(:dispatch) match(_, to: BorutaGateway.Plug.Handler, init_opts: []) end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/plugs/assign_sidecar_upstream.ex ================================================ defmodule BorutaGateway.Plug.AssignSidecarUpstream do @moduledoc false import Plug.Conn alias BorutaGateway.ConfigurationLoader alias BorutaGateway.Upstreams alias BorutaGateway.Upstreams.Upstream require Logger def init(options), do: options def call( %Plug.Conn{ path_info: path_info } = conn, _options ) do conn = assign(conn, :node_name, ConfigurationLoader.node_name()) case Upstreams.sidecar_match(path_info) do %Upstream{} = upstream -> assign(conn, :upstream, upstream) nil -> conn |> send_resp(404, "No upstream has been found corresponding to the given request.") |> halt() end end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/plugs/assign_upstream.ex ================================================ defmodule BorutaGateway.Plug.AssignUpstream do @moduledoc false import Plug.Conn alias BorutaGateway.Upstreams alias BorutaGateway.Upstreams.Upstream require Logger def init(options), do: options def call( %Plug.Conn{ path_info: path_info } = conn, _options ) do conn = assign(conn, :node_name, "global") case Upstreams.match(path_info) do %Upstream{} = upstream -> assign(conn, :upstream, upstream) nil -> conn |> send_resp(404, "No upstream has been found corresponding to the given request.") |> halt() end end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/plugs/authorize.ex ================================================ defmodule BorutaGateway.Plug.Authorize do @moduledoc false import Plug.Conn alias Boruta.Oauth.Authorization alias Boruta.Oauth.Scope alias Boruta.Oauth.Token alias BorutaGateway.Upstreams.Upstream @default_error_content_type "application/json" @default_forbidden_response Jason.encode!(%{ error: "FORBIDDEN", message: "You are forbidden to access this resource." }) @default_unauthorized_response Jason.encode!(%{ error: "UNAUTHORIZED", message: "You are unauthorized to access this resource." }) def init(options), do: options def call( %Plug.Conn{ method: method, assigns: %{ upstream: %Upstream{authorize: true, required_scopes: required_scopes} = upstream } } = conn, _options ) do with [authorization_header] <- get_req_header(conn, "authorization"), [_header, value] <- Regex.run(~r/[B|b]earer (.+)/, authorization_header), {:ok, %Token{scope: scope} = token} <- Authorization.AccessToken.authorize(value: value), {:ok, _} <- validate_scopes(scope, required_scopes, method) do assign(conn, :token, token) else {:error, "required scopes are not present."} -> conn |> put_resp_content_type(upstream.error_content_type || @default_error_content_type) |> send_resp(:forbidden, upstream.forbidden_response || @default_forbidden_response) |> halt() _error -> conn |> put_resp_content_type(upstream.error_content_type || @default_error_content_type) |> send_resp( :unauthorized, upstream.unauthorized_response || @default_unauthorized_response ) |> halt() end end def call( %Plug.Conn{ assigns: %{ upstream: %Upstream{authorize: false} } } = conn, _options ), do: conn defp validate_scopes(_scope, required_scopes, _method) when required_scopes == %{}, do: {:ok, []} defp validate_scopes(scope, required_scopes, method) do scopes = Scope.split(scope) default_scopes = Map.get(required_scopes, "*", [:not_authorized]) case Enum.empty?(Map.get(required_scopes, method, default_scopes) -- scopes) do true -> {:ok, scopes} false -> {:error, "required scopes are not present."} end end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/plugs/handler.ex ================================================ defmodule BorutaGateway.Plug.Handler do @moduledoc false import Plug.Conn alias BorutaGateway.Upstreams.Client require Logger def init(options), do: options def call( %Plug.Conn{ assigns: %{upstream: upstream} } = conn, _options ) do start = System.system_time(:microsecond) case Client.request(upstream, conn) do {:ok, %Finch.Response{status: status, headers: headers, body: body}} -> now = System.system_time(:microsecond) request_time = now - start conn = Enum.reduce(headers, conn, fn {"connection", _value}, conn -> conn {"strict-transport-security", _value}, conn -> conn {"host", _value}, conn -> put_resp_header(conn, "host", conn.host) {key, value}, conn -> put_resp_header(conn, String.downcase(key), value) end) conn |> assign(:upstream_time, request_time) |> send_resp(status, body) |> halt() {:error, error} -> now = System.system_time(:microsecond) request_time = now - start conn |> assign(:upstream_time, request_time) |> assign(:upstream_error, error) |> send_resp(500, inspect(error)) |> halt() end end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/plugs/metrics.ex ================================================ defmodule BorutaGateway.Plug.Metrics do @moduledoc false @behaviour Plug import Plug.Conn def init(_options), do: [] def call( %Plug.Conn{} = conn, _options ) do start = System.system_time(:microsecond) register_before_send(conn, fn conn -> now = System.system_time(:microsecond) request_time = now - start :telemetry.execute( [:boruta_gateway, :request, :done], %{}, %{ request_time: request_time, conn: conn } ) conn end) end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/release.ex ================================================ defmodule BorutaGateway.Release do @moduledoc false @apps [:boruta_auth, :boruta_gateway] def migrate do for repo <- repos() do repo.__adapter__.storage_up(repo.config) {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) end end def load_configuration do Application.ensure_all_started(:boruta_gateway) configuration_path = Application.get_env(:boruta_gateway, :configuration_path) BorutaGateway.ConfigurationLoader.from_file!(configuration_path) end def rollback(repo, version) do repo.__adapter__.storage_up(repo.config) {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) end def setup do migrate() end defp repos do Enum.flat_map(@apps, fn app -> Application.load(app) Application.fetch_env!(app, :ecto_repos) end) |> Enum.uniq() end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/repo.ex ================================================ defmodule BorutaGateway.Repo do @moduledoc false use Ecto.Repo, otp_app: :boruta_gateway, adapter: Ecto.Adapters.Postgres def listen(event_name) do with {:ok, pid} <- Postgrex.Notifications.start_link(__MODULE__.config()), {:ok, ref} <- Postgrex.Notifications.listen(pid, event_name) do {:ok, pid, ref} end end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/router.ex ================================================ defmodule BorutaGateway.Router do @moduledoc false use Plug.Router plug :match plug :dispatch forward "/", to: BorutaGateway.GatewayPipeline end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/server.ex ================================================ defmodule BorutaGateway.Server do @moduledoc false use Supervisor def start_link(args) do Supervisor.start_link(__MODULE__, args) end @impl Supervisor def init(args) do children = [ {Plug.Cowboy, scheme: :http, plug: args[:router], options: [ port: args[:port], ip: {0, 0, 0, 0}, transport_options: [ num_acceptors: 64 ] ]} ] Supervisor.init(children, strategy: :one_for_one) end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/sidecar_router.ex ================================================ defmodule BorutaGateway.SidecarRouter do @moduledoc false use Plug.Router plug :match plug :dispatch forward "/", to: BorutaGateway.MicrogatewayPipeline end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/upstreams/client/supervisor.ex ================================================ defmodule BorutaGateway.Upstreams.ClientSupervisor do @moduledoc false use DynamicSupervisor alias BorutaGateway.Upstreams.Client alias BorutaGateway.Upstreams.Upstream def start_link(_init_arg) do DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) end @impl DynamicSupervisor def init([]) do DynamicSupervisor.init( strategy: :one_for_one, extra_arguments: [] ) end def start_child(upstream) do DynamicSupervisor.start_child(__MODULE__, {Client, upstream}) end @spec client_for_upstream(upstream :: Upstream.t()) :: {:ok, pid()} | {:error, reason :: any()} def client_for_upstream(upstream) do client_name = Client.name(upstream) case Process.whereis(client_name) do nil -> start_child(upstream) pid -> {:ok, pid} end end def kill(nil), do: {:error, :not_started} def kill(client) do Process.exit(client, :shutdown) end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/upstreams/client.ex ================================================ defmodule BorutaGateway.Upstreams.Client do @moduledoc """ Upstream scoped HTTP client """ use GenServer import Plug.Conn alias Boruta.Oauth alias BorutaGateway.Upstreams.Upstream defmodule Token do @moduledoc false use Joken.Config def token_config, do: %{} end def child_spec(upstream) do %{ id: __MODULE__, start: {__MODULE__, :start_link, [upstream]}, type: :worker, restart: :transient } end def name(%Upstream{id: upstream_id}) when is_binary(upstream_id) do "gateway_client_#{upstream_id}" |> String.replace("-", "") |> String.to_atom() end def finch_name(%Upstream{id: upstream_id}) when is_binary(upstream_id) do "finch_gateway_client_#{upstream_id}" |> String.replace("-", "") |> String.to_atom() end def start_link(upstream) do GenServer.start_link(__MODULE__, upstream, name: name(upstream)) end @impl GenServer def init(upstream) do name = finch_name(upstream) {:ok, _pid} = Finch.start_link( name: name, pools: %{ :default => [size: upstream.pool_size, count: upstream.pool_count] }, conn_max_idle_time: upstream.max_idle_time ) {:ok, %{upstream: upstream, http_client: name}} end def upstream(client) do GenServer.call(client, {:get, :upstream}) end def http_client(client) do GenServer.call(client, {:get, :http_client}) end def request(%Upstream{http_client: http_client} = upstream, conn) do http_client = http_client(http_client) Finch.build( transform_method(conn), transform_url(upstream, conn), transform_headers(upstream, conn), transform_body(conn) ) |> Finch.request(http_client) end @impl GenServer def handle_call({:get, :upstream}, _from, %{upstream: upstream} = state) do {:reply, upstream, state} end def handle_call({:get, :http_client}, _from, %{http_client: http_client} = state) do {:reply, http_client, state} end defp transform_method(%Plug.Conn{method: method}) do method |> String.downcase() |> String.to_atom() end defp transform_headers( upstream, %Plug.Conn{req_headers: req_headers} = conn ) do token = conn.assigns[:token] || %Oauth.Token{type: "access_token"} payload = %{ "scope" => token.scope, "sub" => token.sub, "value" => token.value, "exp" => token.expires_at, "client_id" => token.client && token.client.id, "iat" => token.inserted_at && DateTime.to_unix(token.inserted_at) } token = with %Joken.Signer{} = signer <- signer(upstream), {:ok, token, _payload} <- Token.encode_and_sign(payload, signer) do token else _ -> nil end req_headers |> Enum.reject(fn {"x-forwarded-authorization", _value} -> true {"connection", _value} -> true {"content-length", _value} -> true {"expect", _value} -> true {"host", _value} -> true {"keep-alive", _value} -> true {"transfer-encoding", _value} -> true {"upgrade", _value} -> true _rest -> false end) |> List.insert_at(0, {"x-forwarded-authorization", "bearer #{token}"}) end def signer( %Upstream{ forwarded_token_signature_alg: signature_alg, forwarded_token_secret: secret, forwarded_token_private_key: private_key } = upstream ) do case signature_alg && signature_type(upstream) do :symmetric -> Joken.Signer.create(signature_alg, secret) :asymmetric -> Joken.Signer.create(signature_alg, %{"pem" => private_key}) nil -> nil end end defp signature_type(%Upstream{forwarded_token_signature_alg: signature_alg}) do case signature_alg && String.match?(signature_alg, ~r/HS/) do true -> :symmetric false -> :asymmetric nil -> nil end end defp transform_body(conn) do case read_body(conn) do {:ok, body, _conn} -> body _ -> "" end end defp transform_url( %Upstream{scheme: scheme, host: host, port: port, uris: uris, strip_uri: strip_uri}, %Plug.Conn{request_path: request_path} = conn ) do path = case strip_uri do true -> matching_uri = Enum.find(uris, fn uri -> String.starts_with?(request_path, uri) end) case matching_uri == "/" do true -> request_path false -> String.replace_prefix(request_path, matching_uri, "") end false -> request_path end conn = fetch_query_params(conn) query = URI.encode_query(conn.query_params) uri = %URI{authority: host, host: host, path: path, port: port, query: query, scheme: scheme} URI.to_string(uri) end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/upstreams/store.ex ================================================ defmodule BorutaGateway.Upstreams.Store do @moduledoc false require Logger use GenServer alias BorutaGateway.ConfigurationLoader alias BorutaGateway.Repo alias BorutaGateway.Upstreams.ClientSupervisor alias BorutaGateway.Upstreams.Upstream def start_link do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @impl GenServer def init(_args) do subscribe() hydrate() {:ok, %{hydrated: false, upstreams: %{}, listener: nil}} end @impl GenServer def terminate(reason, state) do Logger.error(inspect(reason)) listener = state[:listener] if listener do Process.exit(listener, :normal) end :normal end def hydrate do GenServer.cast(__MODULE__, :hydrate) end def subscribe do GenServer.cast(__MODULE__, :subscribe) end @spec match(path_info :: list(String.t())) :: upstream :: Upstream.t() | nil def match(path_info) do GenServer.call(__MODULE__, {:match, path_info}) end @spec sidecar_match(path_info :: list(String.t())) :: upstream :: Upstream.t() | nil def sidecar_match(path_info) do GenServer.call(__MODULE__, {:sidecar_match, path_info}) end def all do GenServer.call(__MODULE__, :all) end @impl GenServer def handle_call(:all, _from, %{upstreams: upstreams} = state) do {:reply, upstreams, state} end def handle_call({:match, path_info}, _from, %{upstreams: upstreams} = state) do upstream = with {_prefix_info, upstream} <- Enum.find(upstreams["global"] || [], fn {prefix_info, _upstream} -> path_info = Enum.take(path_info, length(prefix_info)) Enum.empty?(prefix_info -- path_info) end) do upstream end {:reply, upstream, state} end def handle_call({:sidecar_match, path_info}, _from, %{upstreams: upstreams} = state) do node_name = ConfigurationLoader.node_name() upstream = with {_prefix_info, upstream} <- Enum.find(upstreams[node_name] || [], fn {prefix_info, _upstream} -> path_info = Enum.take(path_info, length(prefix_info)) Enum.empty?(prefix_info -- path_info) end) do upstream end {:reply, upstream, state} end @impl GenServer def handle_cast(:hydrate, state) do upstreams = Repo.all(Upstream) |> Enum.map(fn upstream -> Upstream.with_http_client(upstream) end) |> structure() {:noreply, %{state | hydrated: true, upstreams: upstreams}} rescue error -> Logger.error(inspect(error)) {:stop, error, state} end @impl GenServer def handle_cast(:subscribe, state) do case Repo.listen("upstreams_changed") do {:ok, pid, _ref} -> {:noreply, %{state | listener: pid}} error -> {:stop, error, state} end end @impl GenServer def handle_info( {:notification, _pid, _ref, "upstreams_changed", payload}, %{upstreams: upstreams} = state ) do upstreams = Enum.reduce(upstreams, [], fn {_node_name, upstreams}, acc -> acc ++ (upstreams || []) end) |> update_upstreams(Jason.decode!(payload)) state = %{state | upstreams: upstreams} {:noreply, state} end defp update_upstreams(upstreams, %{"operation" => "INSERT", "record" => record}) do new = struct( Upstream, Enum.map(record, fn {key, value} -> {String.to_atom(key), value} end) ) |> Upstream.with_http_client() upstreams |> Enum.map(fn {_uri, upstream} -> upstream end) |> List.insert_at(0, new) |> structure() end defp update_upstreams(upstreams, %{"operation" => "UPDATE", "record" => record}) do updated = struct( Upstream, Enum.map(record, fn {key, value} -> {String.to_atom(key), value} end) ) updated_id = updated.id upstreams |> Enum.map(fn {_uri, upstream} -> upstream end) |> Enum.map(fn %{id: ^updated_id, http_client: http_client} -> Upstream.with_http_client(%{updated | http_client: http_client}) upstream -> upstream end) |> structure() end defp update_upstreams(upstreams, %{"operation" => "DELETE", "record" => %{"id" => id}}) do upstreams |> Enum.map(fn {_uri, upstream} -> upstream end) |> Enum.reject(fn %{id: ^id, http_client: http_client} -> # TODO manage failure true = ClientSupervisor.kill(http_client) true _ -> false end) |> structure() end defp structure(upstreams) do Enum.reduce(upstreams, [], fn upstream, acc -> (acc ++ Enum.map(upstream.uris, fn prefix -> prefix_info = String.split(prefix, "/", trim: true) {prefix_info, upstream} end)) |> Enum.sort_by(fn {path_info, _upstream} -> length(path_info) end, :desc) end) |> Enum.group_by(fn {_path_info, %Upstream{node_name: node_name}} -> node_name end) end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/upstreams/upstream.ex ================================================ defmodule BorutaGateway.Upstreams.Upstream do @moduledoc false @required_scopes_schema %{ "type" => "object", "patternProperties" => %{ "(GET|POST|PUT|HEAD|OPTIONS|PATCH|DELETE|\\*)" => %{ "type" => "array", "items" => %{ "type" => "string", "pattern" => ".+" }, "minItems" => 1 } }, "additionalProperties" => false } use Ecto.Schema import Ecto.Changeset import Boruta.Config, only: [ token_generator: 0 ] alias BorutaGateway.Upstreams.ClientSupervisor @type t :: %__MODULE__{ node_name: String.t(), scheme: String.t(), host: String.t(), port: integer(), uris: list(String.t()), required_scopes: map(), strip_uri: boolean(), authorize: boolean(), error_content_type: String.t() | nil, forbidden_response: String.t() | nil, unauthorized_response: String.t() | nil, inserted_at: DateTime.t(), updated_at: DateTime.t() } @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "upstreams" do field(:node_name, :string, default: "global") field(:scheme, :string) field(:host, :string) field(:port, :integer) field(:uris, {:array, :string}, default: []) field(:required_scopes, :map, default: %{}) field(:strip_uri, :boolean, default: false) field(:authorize, :boolean, default: false) field(:pool_size, :integer, default: 10) field(:pool_count, :integer, default: 1) field(:max_idle_time, :integer, default: 10) field(:error_content_type, :string) field(:forbidden_response, :string) field(:unauthorized_response, :string) field(:forwarded_token_signature_alg, :string) field(:forwarded_token_secret, :string) field(:forwarded_token_public_key, :string) field(:forwarded_token_private_key, :string) field(:http_client, :any, virtual: true) timestamps() end def with_http_client(%__MODULE__{http_client: nil} = upstream) do # TODO manage failure {:ok, http_client} = ClientSupervisor.client_for_upstream(upstream) %{upstream | http_client: http_client} end def with_http_client(%__MODULE__{http_client: http_client} = upstream) when is_pid(http_client) do ClientSupervisor.kill(http_client) # TODO manage failure {:ok, http_client} = Enum.reduce_while(1..100, http_client, fn _i, http_client -> :timer.sleep(10) case Process.alive?(http_client) do true -> {:cont, http_client} false -> {:halt, ClientSupervisor.client_for_upstream(upstream)} end end) %{upstream | http_client: http_client} end @doc false def changeset(upstream, attrs) do upstream |> cast(attrs, [ :node_name, :scheme, :host, :port, :uris, :strip_uri, :authorize, :required_scopes, :pool_size, :pool_count, :max_idle_time, :error_content_type, :forbidden_response, :unauthorized_response, :forwarded_token_signature_alg, :forwarded_token_secret ]) |> validate_required([:scheme, :host, :port]) |> validate_inclusion(:scheme, ["http", "https"]) |> validate_inclusion(:pool_size, 1..100) |> validate_inclusion(:pool_count, 1..10) |> unique_constraint([:node_name, :host, :port, :uris]) |> maybe_put_forwarded_token_secret() |> maybe_generate_key_pair() |> validate_uris() |> validate_required_scopes_format() end defp validate_uris( %Ecto.Changeset{ changes: %{uris: uris} } = changeset ) do case Enum.any?(uris, fn uri -> is_nil(uri) || uri == "" end) do true -> add_error(changeset, :uris, "may not be blank") false -> changeset end end defp validate_uris(changeset), do: changeset defp validate_required_scopes_format( %Ecto.Changeset{ changes: %{required_scopes: required_scopes} } = changeset ) do case ExJsonSchema.Validator.validate(@required_scopes_schema, required_scopes) do :ok -> changeset {:error, errors} -> Enum.reduce(errors, changeset, fn {message, path}, changeset -> add_error(changeset, :required_scopes, "#{message} at #{path}") end) end end defp validate_required_scopes_format(changeset), do: changeset defp maybe_put_forwarded_token_secret(%Ecto.Changeset{data: data, changes: changes} = changeset) do signature_algorithm = get_field(changeset, :forwarded_token_signature_alg) if signature_algorithm && String.match?(signature_algorithm, ~r/HS/) do case fetch_field(changeset, :forwarded_token_secret) do {_, nil} -> put_change( changeset, :forwarded_token_secret, token_generator().secret(struct(data, changes)) ) {_, _secret} -> changeset :error -> put_change( changeset, :forwarded_token_secret, token_generator().secret(struct(data, changes)) ) end else changeset end end defp maybe_generate_key_pair(changeset) do signature_algorithm = get_field(changeset, :forwarded_token_signature_alg) if signature_algorithm && String.match?(signature_algorithm, ~r/RS/) do case fetch_field(changeset, :forwarded_token_private_key) do {_, "" <> _private_key} -> changeset _ -> private_key = JOSE.JWK.generate_key({:rsa, 1024, 65_537}) public_key = JOSE.JWK.to_public(private_key) {_type, public_pem} = JOSE.JWK.to_pem(public_key) {_type, private_pem} = JOSE.JWK.to_pem(private_key) changeset |> put_change(:forwarded_token_public_key, public_pem) |> put_change(:forwarded_token_private_key, private_pem) end else changeset end end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway/upstreams.ex ================================================ defmodule BorutaGateway.Upstreams do @moduledoc """ The Upstreams context. """ import Ecto.Query, warn: false alias BorutaGateway.Repo alias BorutaGateway.Upstreams.Store alias BorutaGateway.Upstreams.Upstream def match(path) do Store.match(path) end def sidecar_match(path) do Store.sidecar_match(path) end @doc """ Returns the list of upstreams. ## Examples iex> list_upstreams() [%Upstream{}, ...] """ def list_upstreams do Upstream |> Repo.all() |> Enum.group_by(fn %Upstream{node_name: node_name} -> node_name end) end @doc """ Gets a single upstream. Raises `Ecto.NoResultsError` if the Upstream does not exist. ## Examples iex> get_upstream!(123) %Upstream{} iex> get_upstream!(456) ** (Ecto.NoResultsError) """ def get_upstream!(id), do: Repo.get!(Upstream, id) @doc """ Creates a upstream. ## Examples iex> create_upstream(%{field: value}) {:ok, %Upstream{}} iex> create_upstream(%{field: bad_value}) {:error, %Ecto.Changeset{}} """ def create_upstream(attrs \\ %{}) do %Upstream{} |> Upstream.changeset(attrs) |> Repo.insert() end @doc """ Updates a upstream. ## Examples iex> update_upstream(upstream, %{field: new_value}) {:ok, %Upstream{}} iex> update_upstream(upstream, %{field: bad_value}) {:error, %Ecto.Changeset{}} """ def update_upstream(%Upstream{} = upstream, attrs) do upstream |> Upstream.changeset(attrs) |> Repo.update() end @doc """ Deletes a upstream. ## Examples iex> delete_upstream(upstream) {:ok, %Upstream{}} iex> delete_upstream(upstream) {:error, %Ecto.Changeset{}} """ def delete_upstream(%Upstream{} = upstream) do Repo.delete(upstream) end @doc """ Returns an `%Ecto.Changeset{}` for tracking upstream changes. ## Examples iex> change_upstream(upstream) %Ecto.Changeset{source: %Upstream{}} """ def change_upstream(%Upstream{} = upstream) do Upstream.changeset(upstream, %{}) end end ================================================ FILE: apps/boruta_gateway/lib/boruta_gateway.ex ================================================ defmodule BorutaGateway do @moduledoc false end ================================================ FILE: apps/boruta_gateway/lib/mix/tasks/server.ex ================================================ defmodule Mix.Tasks.BorutaGateway.Server do @moduledoc false use Mix.Task def run(_args) do Application.put_env(:boruta_gateway, :server, true, persistent: true) Mix.Tasks.Run.run(["--no-halt"]) end end ================================================ FILE: apps/boruta_gateway/mix.exs ================================================ defmodule BorutaGateway.MixProject do use Mix.Project def project do [ app: :boruta_gateway, version: "0.1.0", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", lockfile: "../../mix.lock", elixir: "~> 1.5", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, aliases: aliases(), deps: deps() ] end # Configuration for the OTP application. # # Type `mix help compile.app` for more information. def application do [ mod: {BorutaGateway.Application, []}, extra_applications: [:logger, :runtime_tools, :syntax_tools] ] end # Specifies which paths to compile per environment. defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] # Specifies your project dependencies. # # Type `mix help deps` for examples and options. defp deps do [ {:boruta_auth, in_umbrella: true}, {:ecto_sql, "~> 3.0"}, {:ex_json_schema, "~> 0.9"}, {:finch, "~> 0.10"}, {:jason, "~> 1.0"}, {:plug_cowboy, "~> 2.0"}, {:postgrex, ">= 0.0.0"}, {:remote_ip, "~> 1.1"}, {:telemetry, "~> 0.4"}, {:yaml_elixir, "~> 2.9"} ] end # Aliases are shortcuts or tasks specific to the current project. # For example, to create, migrate and run the seeds file at once: # # $ mix ecto.setup # # See the documentation for `Mix` for more info on aliases. defp aliases do [ "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate", "test"] ] end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20200219201345_create_upstreams.exs ================================================ defmodule Boruta.Repo.Migrations.CreateUpstreams do use Ecto.Migration def change do create table(:upstreams, primary_key: false) do add :id, :binary_id, primary_key: true add :scheme, :string add :host, :string add :port, :integer add :uris, {:array, :string}, default: [] add :strip_uri, :boolean, default: false add :authorize, :boolean, default: false add :required_scopes, {:array, :string}, default: [] timestamps() end end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20200326185929_upstreams_notify.exs ================================================ defmodule Boruta.Repo.Migrations.UpstreamsNotify do use Ecto.Migration def change do execute """ CREATE OR REPLACE FUNCTION notify_upstreams_changes() RETURNS trigger AS $$ DECLARE rec RECORD; BEGIN CASE TG_OP WHEN 'INSERT', 'UPDATE' THEN rec := NEW; WHEN 'DELETE' THEN rec := OLD; END CASE; PERFORM pg_notify( 'upstreams_changed', json_build_object( 'operation', TG_OP, 'record', row_to_json(rec) )::text ); RETURN NEW; END; $$ LANGUAGE plpgsql; """ execute """ CREATE TRIGGER upstreams_changed AFTER INSERT OR UPDATE OR DELETE ON upstreams FOR EACH ROW EXECUTE PROCEDURE notify_upstreams_changes() """ end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20210111144958_change_upstreams_required_scopes_type.exs ================================================ defmodule BorutaGateway.Repo.Migrations.ChangeUpstreamsRequiredScopesType do use Ecto.Migration def change do alter table(:upstreams) do remove :required_scopes add :required_scopes, :jsonb, default: "{}" end end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20220319220305_add_pool_size_to_upstreams.exs ================================================ defmodule BorutaGateway.Repo.Migrations.AddPoolSizeToUpstreams do use Ecto.Migration def change do alter table(:upstreams) do add(:pool_size, :integer, default: 10) end end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20220728122802_add_max_idle_time_to_upstreams.exs ================================================ defmodule BorutaGateway.Repo.Migrations.AddMaxIdleTimeToUpstreams do use Ecto.Migration def change do alter table(:upstreams) do add :max_idle_time, :integer, default: 10, null: false end end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20220729040405_add_pool_count_to_upstreams.exs ================================================ defmodule BorutaGateway.Repo.Migrations.AddPoolCountToUpstreams do use Ecto.Migration def change do alter table(:upstreams) do add :pool_count, :integer, default: 1, null: false end end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20220810082956_add_forbidden_response_to_upstreams.exs ================================================ defmodule BorutaGateway.Repo.Migrations.AddForbiddenResponseToUpstreams do use Ecto.Migration def change do alter table(:upstreams) do add :forbidden_response, :text end end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20220810084238_add_error_content_type_to_upstreams.exs ================================================ defmodule BorutaGateway.Repo.Migrations.AddErrorContentTypeToUpstreams do use Ecto.Migration def change do alter table(:upstreams) do add :error_content_type, :string end end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20220810084450_add_unauthorized_response_to_upstreams.exs ================================================ defmodule BorutaGateway.Repo.Migrations.AddUnauthorizedResponseToUpstreams do use Ecto.Migration def change do alter table(:upstreams) do add :unauthorized_response, :text end end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20221024100810_add_forwarded_token_signature_algorithm_to_upstreams.exs ================================================ defmodule BorutaGateway.Repo.Migrations.AddForwardedTokenSignatureAlgorithmToUpstreams do use Ecto.Migration def change do alter table(:upstreams) do add :forwarded_token_signature_alg, :string end end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20221024122642_add_forwarded_token_secret_to_upstreams.exs ================================================ defmodule BorutaGateway.Repo.Migrations.AddForwardedTokenSecretToUpstreams do use Ecto.Migration def change do alter table(:upstreams) do add :forwarded_token_secret, :string end end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20221024132312_add_forwarded_token_key_pair_to_upstreams.exs ================================================ defmodule BorutaGateway.Repo.Migrations.AddForwardedTokenKeyPairToUpstreams do use Ecto.Migration def change do alter table(:upstreams) do add :forwarded_token_public_key, :text add :forwarded_token_private_key, :text end end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20230413190522_upstreams_unique_indices.exs ================================================ defmodule BorutaGateway.Repo.Migrations.UpstreamsUniqueIndices do use Ecto.Migration def change do create index(:upstreams, [:host, :port, :uris], unique: true) end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20230421135202_add_node_name_to_upstreams.exs ================================================ defmodule BorutaGateway.Repo.Migrations.AddNodeNameToUpstreams do use Ecto.Migration def change do alter table(:upstreams) do add :node_name, :string, default: "global" end end end ================================================ FILE: apps/boruta_gateway/priv/repo/migrations/20230422083455_update_upstreams_unique_constraint.exs ================================================ defmodule BorutaGateway.Repo.Migrations.UpdateUpstreamsUniqueConstraint do use Ecto.Migration def change do drop index(:upstreams, [:host, :port, :uris], unique: true) create index(:upstreams, [:node_name, :host, :port, :uris], unique: true) end end ================================================ FILE: apps/boruta_gateway/priv/repo/seeds.exs ================================================ ================================================ FILE: apps/boruta_gateway/priv/test/configuration_files/authorized.yml ================================================ --- configuration: gateway: - scheme: "http" host: "httpbin.patatoid.fr" port: 80 uris: ["/httpbin"] strip_uri: true authorize: true required_scopes: GET: ["test"] ================================================ FILE: apps/boruta_gateway/priv/test/configuration_files/authorized_introspect.yml ================================================ --- configuration: gateway: - scheme: "http" host: "httpbin.patatoid.fr" port: 80 uris: ["/httpbin"] strip_uri: true authorize: true required_scopes: GET: ["test"] forwarded_token_signature_alg: "HS256" ================================================ FILE: apps/boruta_gateway/priv/test/configuration_files/bad_configuration.yml ================================================ --- bad_configuration: gateway: - scheme: "http" host: "httpbin.patatoid.fr" port: 80 uris: ["/httpbin"] strip_uri: true authorize: true required_scopes: GET: ["test"] ================================================ FILE: apps/boruta_gateway/priv/test/configuration_files/bad_gateway_configuration.yml ================================================ --- version: "1.0" configuration: gateway: - authorize: true error_content_type: "test" forbidden_response: "test" unauthorized_response: "test" forwarded_token_secret: "test" forwarded_token_signature_alg: "HS384" max_idle_time: 10 pool_count: 1 pool_size: 10 required_scopes: GET: ["test"] strip_uri: true ================================================ FILE: apps/boruta_gateway/priv/test/configuration_files/bad_microgateway_configuration.yml ================================================ --- version: "1.0" configuration: gateway: - authorize: true error_content_type: "test" forbidden_response: "test" unauthorized_response: "test" forwarded_token_secret: "test" forwarded_token_signature_alg: "HS384" host: "httpbin.patatoid.fr" port: 80 uris: ["/httpbin"] max_idle_time: 10 pool_count: 1 pool_size: 10 required_scopes: GET: ["test"] scheme: "http" strip_uri: true microgateway: - authorize: true error_content_type: "test" forbidden_response: "test" unauthorized_response: "test" forwarded_token_secret: "test" forwarded_token_signature_alg: "HS384" max_idle_time: 10 pool_count: 1 pool_size: 10 required_scopes: GET: ["test"] strip_uri: true ================================================ FILE: apps/boruta_gateway/priv/test/configuration_files/forbidden.yml ================================================ --- configuration: gateway: - scheme: "http" host: "should.not.be.called" port: 80 uris: ["/forbidden"] authorize: true required_scopes: GET: ["required"] error_content_type: "text" forbidden_response: "boom" ================================================ FILE: apps/boruta_gateway/priv/test/configuration_files/full_configuration.yml ================================================ --- configuration: node_name: "full-configuration" gateway: - authorize: true error_content_type: "test" forbidden_response: "test" unauthorized_response: "test" forwarded_token_secret: "test" forwarded_token_signature_alg: "HS384" host: "httpbin.patatoid.fr" port: 80 uris: ["/httpbin"] max_idle_time: 10 pool_count: 1 pool_size: 10 required_scopes: GET: ["test"] scheme: "http" strip_uri: true microgateway: - authorize: true error_content_type: "test" forbidden_response: "test" unauthorized_response: "test" forwarded_token_secret: "test" forwarded_token_signature_alg: "HS384" host: "httpbin.patatoid.fr" port: 80 uris: ["/httpbin"] max_idle_time: 10 pool_count: 1 pool_size: 10 required_scopes: GET: ["test"] scheme: "http" strip_uri: true ================================================ FILE: apps/boruta_gateway/priv/test/configuration_files/not_found.yml ================================================ --- configuration: gateway: - scheme: "http" host: "should.not.be.called" port: 80 uris: - "/upstream" ================================================ FILE: apps/boruta_gateway/priv/test/configuration_files/unauthorized.yml ================================================ --- configuration: gateway: - scheme: "http" host: "should.not.be.called" port: 80 uris: ["/unauthorized"] authorize: true error_content_type: "text" unauthorized_response: "boom" ================================================ FILE: apps/boruta_gateway/test/boruta_gateway/configuration_loader_test.exs ================================================ defmodule BorutaGateway.ConfigurationLoaderTest do use BorutaGateway.DataCase alias BorutaGateway.ConfigurationLoader alias BorutaGateway.Repo alias BorutaGateway.Upstreams.Upstream test "returns an error with a bad configuration file" do assert Repo.all(Upstream) |> Enum.empty?() configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/bad_configuration.yml") assert_raise MatchError, fn -> ConfigurationLoader.from_file!(configuration_file_path) end end test "returns an error with a bad gateway configuration file" do assert Repo.all(Upstream) |> Enum.empty?() configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/bad_gateway_configuration.yml") assert_raise RuntimeError, ~s|[{"Required properties scheme, host, port, uris were not present.", "#"}]|, fn -> ConfigurationLoader.from_file!(configuration_file_path) end end test "returns an error with a bad microgateway configuration file" do assert Repo.all(Upstream) |> Enum.empty?() configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/bad_microgateway_configuration.yml") assert_raise RuntimeError, ~s|[{"Required properties scheme, host, port, uris were not present.", "#"}]|, fn -> ConfigurationLoader.from_file!(configuration_file_path) end end test "loads a file" do assert Repo.all(Upstream) |> Enum.empty?() Application.delete_env(ConfigurationLoader, :node_name) configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/full_configuration.yml") ConfigurationLoader.from_file!(configuration_file_path) assert [ %Upstream{ scheme: "http", host: "httpbin.patatoid.fr", port: 80, uris: ["/httpbin"], required_scopes: %{"GET" => ["test"]}, strip_uri: true, authorize: true, pool_size: 10, pool_count: 1, max_idle_time: 10, error_content_type: "test", forbidden_response: "test", unauthorized_response: "test", forwarded_token_signature_alg: "HS384", forwarded_token_secret: "test", forwarded_token_public_key: nil, forwarded_token_private_key: nil }, %Upstream{ node_name: "full-configuration", scheme: "http", host: "httpbin.patatoid.fr", port: 80, uris: ["/httpbin"], required_scopes: %{"GET" => ["test"]}, strip_uri: true, authorize: true, pool_size: 10, pool_count: 1, max_idle_time: 10, error_content_type: "test", forbidden_response: "test", unauthorized_response: "test", forwarded_token_signature_alg: "HS384", forwarded_token_secret: "test", forwarded_token_public_key: nil, forwarded_token_private_key: nil, } ] = Repo.all(Upstream) end end ================================================ FILE: apps/boruta_gateway/test/boruta_gateway/integration/requests_test.exs ================================================ defmodule BorutaGateway.RequestsIntegrationTest do use ExUnit.Case use Plug.Test use BorutaGateway.DataCase alias Boruta.AccessTokensAdapter alias Boruta.ClientsAdapter alias Boruta.Ecto.Admin alias BorutaGateway.ConfigurationLoader alias BorutaGateway.Repo alias BorutaGateway.RequestsIntegrationTest.HttpClient alias BorutaGateway.Upstreams alias BorutaGateway.Upstreams.Client alias BorutaGateway.Upstreams.Upstream alias Ecto.Adapters.SQL.Sandbox setup_all do Finch.start_link(name: HttpClient) :ok end @tag :skip describe "requests" do setup do {:ok, %Boruta.Ecto.Client{id: client_id}} = Admin.create_client(%{}) {:ok, access_token} = AccessTokensAdapter.create( %{ client: ClientsAdapter.get_client(client_id), scope: "test" }, [] ) {:ok, access_token: access_token} end test "returns a 404 when no upstream found" do Sandbox.unboxed_run(Repo, fn -> try do Upstreams.create_upstream(%{ scheme: "http", host: "should.not.be.called", port: 80, uris: ["/upstream"] }) Process.sleep(100) request = Finch.build(:get, "http://localhost:7777/no_upstream", [], "") assert {:ok, %Finch.Response{body: body, status: 404}} = Finch.request(request, HttpClient) assert body == "No upstream has been found corresponding to the given request." after Repo.delete_all(Upstream) end end) end test "returns a 404 when no upstream persisted" do Sandbox.unboxed_run(Repo, fn -> try do request = Finch.build(:get, "http://localhost:7777/no_upstream", [], "") assert {:ok, %Finch.Response{body: body, status: 404}} = Finch.request(request, HttpClient) assert body == "No upstream has been found corresponding to the given request." after Repo.delete_all(Upstream) end end) end test "returns a 401 when unauthorized" do Sandbox.unboxed_run(Repo, fn -> try do {:ok, upstream} = Upstreams.create_upstream(%{ scheme: "http", host: "should.not.be.called", port: 80, uris: ["/unauthorized"], authorize: true, error_content_type: "text", unauthorized_response: "boom" }) Process.sleep(100) request = Finch.build(:get, "http://localhost:7777/unauthorized", [], "") assert {:ok, %Finch.Response{body: body, headers: headers, status: 401}} = Finch.request(request, HttpClient) assert body == upstream.unauthorized_response assert Enum.any?(headers, fn {"content-type", content_type} -> upstream.error_content_type |> Regex.compile!() |> Regex.match?(content_type) _ -> false end) after Repo.delete_all(Upstream) end end) end test "returns a 403 when forbidden", %{access_token: access_token} do Sandbox.unboxed_run(Repo, fn -> try do {:ok, upstream} = Upstreams.create_upstream(%{ scheme: "http", host: "should.not.be.called", port: 80, uris: ["/forbidden"], authorize: true, required_scopes: %{"GET" => ["required"]}, error_content_type: "text", forbidden_response: "boom" }) Process.sleep(100) request = Finch.build( :get, "http://localhost:7777/forbidden", [{"authorization", "Bearer #{access_token.value}"}], "" ) assert {:ok, %Finch.Response{body: body, headers: headers, status: 403}} = Finch.request(request, HttpClient) assert body == upstream.forbidden_response assert Enum.any?(headers, fn {"content-type", content_type} -> upstream.error_content_type |> Regex.compile!() |> Regex.match?(content_type) _ -> false end) after Repo.delete_all(Upstream) end end) end @tag :skip test "returns response when authorized", %{access_token: access_token} do Sandbox.unboxed_run(Repo, fn -> try do Upstreams.create_upstream(%{ scheme: "http", host: "httpbin.patatoid.fr", port: 80, uris: ["/httpbin"], strip_uri: true, authorize: true, required_scopes: %{"GET" => ["test"]} }) Process.sleep(100) request = Finch.build( :get, "http://localhost:7777/httpbin/status/418", [{"authorization", "Bearer #{access_token.value}"}], "" ) assert {:ok, %Finch.Response{body: body, status: 418}} = Finch.request(request, HttpClient) assert body =~ ~r/teapot/ after Repo.delete_all(Upstream) end end) end @tag :skip test "returns response root uri stripped", %{access_token: access_token} do Sandbox.unboxed_run(Repo, fn -> try do Upstreams.create_upstream(%{ scheme: "http", host: "httpbin.patatoid.fr", port: 80, uris: ["/"], strip_uri: true, authorize: true, required_scopes: %{"GET" => ["test"]} }) Process.sleep(100) request = Finch.build( :get, "http://localhost:7777/status/418", [{"authorization", "Bearer #{access_token.value}"}], "" ) assert {:ok, %Finch.Response{body: body, status: 418}} = Finch.request(request, HttpClient) assert body =~ ~r/teapot/ after Repo.delete_all(Upstream) end end) end @tag :skip test "returns authorization header with introspected token when authorized", %{ access_token: access_token } do Sandbox.unboxed_run(Repo, fn -> try do {:ok, upstream} = Upstreams.create_upstream(%{ scheme: "http", host: "httpbin.patatoid.fr", port: 80, uris: ["/httpbin"], strip_uri: true, authorize: true, required_scopes: %{"GET" => ["test"]}, forwarded_token_signature_alg: "HS256" }) Process.sleep(100) request = Finch.build( :get, "http://localhost:7777/httpbin/anything", [{"authorization", "bearer #{access_token.value}"}], "" ) assert {:ok, %Finch.Response{body: body, status: 200}} = Finch.request(request, HttpClient) assert %{ "headers" => %{ "Authorization" => forwarded_authorization, "X-Forwarded-Authorization" => authorization } } = Jason.decode!(body) assert [_authorization_header, token] = Regex.run(~r/bearer (.+)/, authorization) signer = Client.signer(upstream) assert {:ok, claims} = Client.Token.verify(token, signer) assert claims["client_id"] == access_token.client.id assert claims["value"] == access_token.value assert forwarded_authorization == "bearer #{access_token.value}" after Repo.delete_all(Upstream) end end) end end @tag :skip describe "requests (from configuration file)" do setup do {:ok, %Boruta.Ecto.Client{id: client_id}} = Admin.create_client(%{}) {:ok, access_token} = AccessTokensAdapter.create( %{ client: ClientsAdapter.get_client(client_id), scope: "test" }, [] ) {:ok, access_token: access_token} end test "returns a 404 when no upstream found" do Sandbox.unboxed_run(Repo, fn -> try do configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/not_found.yml") ConfigurationLoader.from_file!(configuration_file_path) Process.sleep(100) request = Finch.build(:get, "http://localhost:7777/no_upstream", [], "") assert {:ok, %Finch.Response{body: body, status: 404}} = Finch.request(request, HttpClient) assert body == "No upstream has been found corresponding to the given request." after Repo.delete_all(Upstream) end end) end test "returns a 401 when unauthorized" do Sandbox.unboxed_run(Repo, fn -> try do configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/unauthorized.yml") ConfigurationLoader.from_file!(configuration_file_path) Process.sleep(100) request = Finch.build(:get, "http://localhost:7777/unauthorized", [], "") assert {:ok, %Finch.Response{body: body, headers: headers, status: 401}} = Finch.request(request, HttpClient) assert body == "boom" assert Enum.any?(headers, fn {"content-type", content_type} -> Regex.match?(~r/text/, content_type) _ -> false end) after Repo.delete_all(Upstream) end end) end test "returns a 403 when forbidden", %{access_token: access_token} do Sandbox.unboxed_run(Repo, fn -> try do configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/forbidden.yml") ConfigurationLoader.from_file!(configuration_file_path) Process.sleep(100) request = Finch.build( :get, "http://localhost:7777/forbidden", [{"authorization", "Bearer #{access_token.value}"}], "" ) assert {:ok, %Finch.Response{body: body, headers: headers, status: 403}} = Finch.request(request, HttpClient) assert body == "boom" assert Enum.any?(headers, fn {"content-type", content_type} -> Regex.match?(~r/text/, content_type) _ -> false end) after Repo.delete_all(Upstream) end end) end @tag :skip test "returns response when authorized", %{access_token: access_token} do Sandbox.unboxed_run(Repo, fn -> try do configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/authorized.yml") ConfigurationLoader.from_file!(configuration_file_path) Process.sleep(100) request = Finch.build( :get, "http://localhost:7777/httpbin/status/418", [{"authorization", "Bearer #{access_token.value}"}], "" ) assert {:ok, %Finch.Response{body: body, status: 418}} = Finch.request(request, HttpClient) assert body =~ ~r/teapot/ after Repo.delete_all(Upstream) end end) end @tag :skip test "returns authorization header with introspected token when authorized", %{ access_token: access_token } do Sandbox.unboxed_run(Repo, fn -> try do configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/authorized_introspect.yml") ConfigurationLoader.from_file!(configuration_file_path) Process.sleep(100) request = Finch.build( :get, "http://localhost:7777/httpbin/anything", [{"authorization", "bearer #{access_token.value}"}], "" ) assert {:ok, %Finch.Response{body: body, status: 200}} = Finch.request(request, HttpClient) assert %{ "headers" => %{ "Authorization" => forwarded_authorization, "X-Forwarded-Authorization" => authorization } } = Jason.decode!(body) assert [_authorization_header, token] = Regex.run(~r/bearer (.+)/, authorization) upstream = Repo.all(Upstream) |> List.first() signer = Client.signer(upstream) assert {:ok, claims} = Client.Token.verify(token, signer) assert claims["client_id"] == access_token.client.id assert claims["value"] == access_token.value assert forwarded_authorization == "bearer #{access_token.value}" after Repo.delete_all(Upstream) end end) end end @tag :skip describe "sidecar requests" do setup do {:ok, %Boruta.Ecto.Client{id: client_id}} = Admin.create_client(%{}) {:ok, access_token} = AccessTokensAdapter.create( %{ client: ClientsAdapter.get_client(client_id), scope: "test" }, [] ) {:ok, access_token: access_token} end test "returns a 404 when no upstream found" do Sandbox.unboxed_run(Repo, fn -> try do Upstreams.create_upstream(%{ node_name: Atom.to_string(node()), scheme: "http", host: "should.not.be.called", port: 80, uris: ["/upstream"] }) Process.sleep(100) request = Finch.build(:get, "http://localhost:7778/no_upstream", [], "") assert {:ok, %Finch.Response{body: body, status: 404}} = Finch.request(request, HttpClient) assert body == "No upstream has been found corresponding to the given request." after Repo.delete_all(Upstream) end end) end test "returns a 401 when unauthorized" do Sandbox.unboxed_run(Repo, fn -> try do {:ok, upstream} = Upstreams.create_upstream(%{ node_name: Atom.to_string(node()), scheme: "http", host: "should.not.be.called", port: 80, uris: ["/unauthorized"], authorize: true, error_content_type: "text", unauthorized_response: "boom" }) Process.sleep(100) request = Finch.build(:get, "http://localhost:7778/unauthorized", [], "") assert {:ok, %Finch.Response{body: body, headers: headers, status: 401}} = Finch.request(request, HttpClient) assert body == upstream.unauthorized_response assert Enum.any?(headers, fn {"content-type", content_type} -> upstream.error_content_type |> Regex.compile!() |> Regex.match?(content_type) _ -> false end) after Repo.delete_all(Upstream) end end) end test "returns a 403 when forbidden", %{access_token: access_token} do Sandbox.unboxed_run(Repo, fn -> try do {:ok, upstream} = Upstreams.create_upstream(%{ node_name: Atom.to_string(node()), scheme: "http", host: "should.not.be.called", port: 80, uris: ["/forbidden"], authorize: true, required_scopes: %{"GET" => ["required"]}, error_content_type: "text", forbidden_response: "boom" }) Process.sleep(100) request = Finch.build( :get, "http://localhost:7778/forbidden", [{"authorization", "Bearer #{access_token.value}"}], "" ) assert {:ok, %Finch.Response{body: body, headers: headers, status: 403}} = Finch.request(request, HttpClient) assert body == upstream.forbidden_response assert Enum.any?(headers, fn {"content-type", content_type} -> upstream.error_content_type |> Regex.compile!() |> Regex.match?(content_type) _ -> false end) after Repo.delete_all(Upstream) end end) end @tag :skip test "returns response when authorized", %{access_token: access_token} do Sandbox.unboxed_run(Repo, fn -> try do Upstreams.create_upstream(%{ node_name: Atom.to_string(node()), scheme: "http", host: "httpbin.patatoid.fr", port: 80, uris: ["/httpbin"], strip_uri: true, authorize: true, required_scopes: %{"GET" => ["test"]} }) Process.sleep(100) request = Finch.build( :get, "http://localhost:7778/httpbin/status/418", [{"authorization", "Bearer #{access_token.value}"}], "" ) assert {:ok, %Finch.Response{body: body, status: 418}} = Finch.request(request, HttpClient) assert body =~ ~r/teapot/ after Repo.delete_all(Upstream) end end) end @tag :skip test "returns authorization header with introspected token when authorized", %{ access_token: access_token } do Sandbox.unboxed_run(Repo, fn -> try do {:ok, upstream} = Upstreams.create_upstream(%{ node_name: Atom.to_string(node()), scheme: "http", host: "httpbin.patatoid.fr", port: 80, uris: ["/httpbin"], strip_uri: true, authorize: true, required_scopes: %{"GET" => ["test"]}, forwarded_token_signature_alg: "HS256" }) Process.sleep(100) request = Finch.build( :get, "http://localhost:7778/httpbin/anything", [{"authorization", "bearer #{access_token.value}"}], "" ) assert {:ok, %Finch.Response{body: body, status: 200}} = Finch.request(request, HttpClient) assert %{ "headers" => %{ "Authorization" => forwarded_authorization, "X-Forwarded-Authorization" => authorization } } = Jason.decode!(body) assert [_authorization_header, token] = Regex.run(~r/bearer (.+)/, authorization) signer = Client.signer(upstream) assert {:ok, claims} = Client.Token.verify(token, signer) assert claims["client_id"] == access_token.client.id assert claims["value"] == access_token.value assert forwarded_authorization == "bearer #{access_token.value}" after Repo.delete_all(Upstream) end end) end end end ================================================ FILE: apps/boruta_gateway/test/boruta_gateway/upstreams/client_test.exs ================================================ defmodule BorutaGateway.Upstreams.ClientTest do use ExUnit.Case use Plug.Test alias BorutaGateway.Repo alias BorutaGateway.Upstreams alias BorutaGateway.Upstreams.Client alias BorutaGateway.Upstreams.ClientSupervisor alias BorutaGateway.Upstreams.Upstream alias Ecto.Adapters.SQL.Sandbox describe "ClientSupervisor.start_link/1" do test "application starts supervisor" do assert {:error, {:already_started, _pid}} = ClientSupervisor.start_link([]) end end describe "ClientSupervisor.client_for_upstream/1" do setup do {:ok, upstream: %Upstream{id: SecureRandom.uuid()}} end test "starts a client", %{upstream: upstream} do {:ok, client} = ClientSupervisor.client_for_upstream(upstream) assert Process.alive?(client) end test "starts a client with an upstream", %{upstream: upstream} do {:ok, client} = ClientSupervisor.client_for_upstream(upstream) assert Client.upstream(client) == upstream end test "starts a client with a Finch instance", %{upstream: upstream} do {:ok, client} = ClientSupervisor.client_for_upstream(upstream) assert client |> Client.http_client() |> Process.whereis() |> Process.alive?() end end # TODO change for an internal server describe "external http calls" do @tag :skip test "should request an external url (httpbin.patatoid.fr/status) given a Plug.Conn" do Sandbox.unboxed_run(Repo, fn -> try do {:ok, upstream} = Upstreams.create_upstream(%{scheme: "http", host: "httpbin.patatoid.fr", port: 80}) :timer.sleep(100) conn = conn("GET", "/status/418") {:ok, %{ body: body, status: status }} = Client.request(Upstream.with_http_client(upstream), conn) assert status == 418 assert body =~ ~r/teapot/ after Repo.delete_all(Upstream) end end) end @tag :skip test "should request an external url (httpbin.patatoid.fr/headers) given a Plug.Conn" do Sandbox.unboxed_run(Repo, fn -> try do {:ok, upstream} = Upstreams.create_upstream(%{scheme: "http", host: "httpbin.patatoid.fr", port: 80}) :timer.sleep(100) conn = conn("GET", "/headers") |> put_req_header("authorization", "Bearer test") {:ok, %{ body: body, status: status }} = Client.request(Upstream.with_http_client(upstream), conn) assert status == 200 req_headers = Jason.decode!(body)["headers"] assert Enum.any?(req_headers, fn {"Authorization", "Bearer test"} -> true _ -> false end) assert Enum.any?(req_headers, fn {"Host", "httpbin.patatoid.fr"} -> true _ -> false end) after Repo.delete_all(Upstream) end end) end end end ================================================ FILE: apps/boruta_gateway/test/boruta_gateway/upstreams/store_test.exs ================================================ defmodule BorutaGateway.Upstreams.StoreTest do use ExUnit.Case use BorutaGateway.DataCase alias BorutaGateway.ConfigurationLoader alias BorutaGateway.Upstreams.Client alias BorutaGateway.Upstreams.Store alias BorutaGateway.Upstreams.Upstream alias Ecto.Adapters.SQL.Sandbox @tag :skip test "stores all inserted upstreams from repo" do Sandbox.unboxed_run(Repo, fn -> try do {:ok, a} = Repo.insert(%Upstream{host: "test1.host", port: 1111, uris: ["/path1"]}) {:ok, b} = Repo.insert(%Upstream{host: "test2.host", port: 2222, uris: ["/path2"]}) Store |> Process.whereis() |> Process.exit(:normal) :timer.sleep(100) upstreams = Store.all() assert Enum.any?(upstreams, fn {["path1"], %{id: id, http_client: http_client}} -> id == a.id && Process.alive?(http_client) _ -> false end) assert Enum.any?(upstreams, fn {["path2"], %{id: id, http_client: http_client}} -> id == b.id && Process.alive?(http_client) _ -> false end) after Repo.delete_all(Upstream) end end) end test "stores all updated upstreams from repo" do Sandbox.unboxed_run(Repo, fn -> try do {:ok, a} = Repo.insert(%Upstream{host: "test1.host", port: 1111, uris: ["/path"]}) a = Ecto.Changeset.change(a, host: "updated.host") {:ok, _a} = Repo.update(a) :timer.sleep(200) upstreams = Store.all() assert Enum.any?(upstreams["global"], fn {["path"], %{host: "updated.host", http_client: http_client} = upstream} -> assert Client.upstream(http_client).host == upstream.host _ -> false end) after Repo.delete_all(Upstream) end end) end test "do not stores all deleted upstreams from repo" do Sandbox.unboxed_run(Repo, fn -> try do {:ok, a} = Repo.insert(%Upstream{host: "test1.host", port: 1111, uris: ["/path"]}) :timer.sleep(100) upstreams = Store.all() assert {_path, %Upstream{http_client: http_client}} = Enum.find(upstreams["global"], fn {["path"], %{id: id}} -> id == a.id _ -> false end) assert Process.alive?(http_client) Repo.delete(a) :timer.sleep(100) upstreams = Store.all() assert Enum.all?(upstreams, fn {["path"], %{id: id}} -> id != a.id _ -> false end) refute Process.alive?(http_client) after Repo.delete_all(Upstream) end end) end describe "match/2" do test "return matching upstream" do Sandbox.unboxed_run(Repo, fn -> try do {:ok, a} = Repo.insert(%Upstream{host: "test1.host", port: 1111, uris: ["/matching/uri"]}) :timer.sleep(100) %Upstream{id: id} = Store.match(["matching", "uri"]) assert id == a.id after Repo.delete_all(Upstream) end end) end end describe "sidecar_match/2" do test "return sidecar matching upstream" do Sandbox.unboxed_run(Repo, fn -> try do {:ok, a} = Repo.insert(%Upstream{ node_name: ConfigurationLoader.node_name(), host: "test1.host", port: 1111, uris: ["/matching/uri"] }) :timer.sleep(100) %Upstream{id: id} = Store.sidecar_match(["matching", "uri"]) assert id == a.id after Repo.delete_all(Upstream) end end) end test "return sidecar matching upstream from static configuration" do Application.delete_env(ConfigurationLoader, :node_name) configuration_file_path = :code.priv_dir(:boruta_gateway) |> Path.join("/test/configuration_files/full_configuration.yml") Application.put_env(:boruta_gateway, :configuration_path, configuration_file_path) :timer.sleep(100) Sandbox.unboxed_run(Repo, fn -> try do {:ok, a} = Repo.insert(%Upstream{ node_name: "full-configuration", host: "test1.host", port: 1111, uris: ["/matching/uri"] }) :timer.sleep(100) %Upstream{id: id} = Store.sidecar_match(["matching", "uri"]) assert id == a.id after Repo.delete_all(Upstream) end end) end end end ================================================ FILE: apps/boruta_gateway/test/boruta_gateway/upstreams_test.exs ================================================ defmodule BorutaGateway.UpstreamsTest do use BorutaGateway.DataCase alias BorutaGateway.Upstreams alias Ecto.Adapters.SQL.Sandbox describe "upstreams" do alias BorutaGateway.Upstreams.Upstream @valid_attrs %{ scheme: "https", host: "test.host", port: 777, uris: ["/valid"], required_scopes: %{"GET" => ["scope"]} } @update_attrs %{host: "update.host"} @invalid_attrs %{port: nil, required_scopes: %{"BAD" => "bad_format"}} def upstream_fixture(attrs \\ %{}) do {:ok, upstream} = attrs |> Enum.into(@valid_attrs) |> Upstreams.create_upstream() upstream end test "list_upstreams/0 returns all upstreams" do upstream = upstream_fixture() assert Upstreams.list_upstreams() == %{"global" => [upstream]} end test "get_upstream!/1 returns the upstream with given id" do upstream = upstream_fixture() assert Upstreams.get_upstream!(upstream.id) == upstream end test "match/1 returns the upstream matching given path" do Sandbox.unboxed_run(Repo, fn -> try do upstream = upstream_fixture() :timer.sleep(100) %Upstream{id: id} = Upstreams.match(["valid"]) assert id == upstream.id after Repo.delete_all(Upstream) end end) end test "create_upstream/1 with valid data creates a upstream" do assert {:ok, %Upstream{}} = Upstreams.create_upstream(@valid_attrs) end test "create_upstream/1 generates a secret with HS* algorithms" do assert {:ok, %Upstream{forwarded_token_secret: forwarded_token_secret}} = Upstreams.create_upstream( Map.put( @valid_attrs, :forwarded_token_signature_alg, "HS256" ) ) assert forwarded_token_secret end test "create_upstream/1 generates a secret with RS* algorithms" do assert {:ok, %Upstream{ forwarded_token_private_key: forwarded_token_private_key, forwarded_token_public_key: forwarded_token_public_key }} = Upstreams.create_upstream( Map.put( @valid_attrs, :forwarded_token_signature_alg, "RS256" ) ) assert forwarded_token_private_key assert forwarded_token_public_key end test "create_upstream/1 with invalid data returns error changeset" do assert {:error, %Ecto.Changeset{ errors: [ required_scopes: {"Schema does not allow additional properties. at #/BAD", []}, scheme: {"can't be blank", [validation: :required]}, host: {"can't be blank", [validation: :required]}, port: {"can't be blank", [validation: :required]} ] }} = Upstreams.create_upstream(@invalid_attrs) end test "create_upstream/1 with unique constraint returns error changeset" do Upstreams.create_upstream(@valid_attrs) assert {:error, %Ecto.Changeset{ errors: [ node_name: {"has already been taken", [constraint: :unique, constraint_name: "upstreams_node_name_host_port_uris_index"]} ] }} = Upstreams.create_upstream(@valid_attrs) end test "update_upstream/2 with valid data updates the upstream" do upstream = upstream_fixture() assert {:ok, %Upstream{}} = Upstreams.update_upstream(upstream, @update_attrs) end test "update_upstream/2 with invalid data returns error changeset" do upstream = upstream_fixture() assert {:error, %Ecto.Changeset{}} = Upstreams.update_upstream(upstream, @invalid_attrs) assert upstream == Upstreams.get_upstream!(upstream.id) end test "delete_upstream/1 deletes the upstream" do upstream = upstream_fixture() assert {:ok, %Upstream{}} = Upstreams.delete_upstream(upstream) assert_raise Ecto.NoResultsError, fn -> Upstreams.get_upstream!(upstream.id) end end test "change_upstream/1 returns a upstream changeset" do upstream = upstream_fixture() assert %Ecto.Changeset{} = Upstreams.change_upstream(upstream) end end end ================================================ FILE: apps/boruta_gateway/test/support/data_case.ex ================================================ defmodule BorutaGateway.DataCase do @moduledoc """ This module defines the setup for tests requiring access to the application's data layer. You may define functions here to be used as helpers in your tests. Finally, if the test case interacts with the database, we enable the SQL sandbox, so changes done to the database are reverted at the end of every test. If you are using PostgreSQL, you can even run database tests asynchronously by setting `use nil.DataCase, async: true`, although this option is not recommendded for other databases. """ use ExUnit.CaseTemplate alias Ecto.Adapters.SQL.Sandbox using do quote do alias BorutaGateway.Repo import Ecto import Ecto.Changeset import Ecto.Query import BorutaGateway.DataCase end end setup tags do :ok = Sandbox.checkout(BorutaGateway.Repo) :ok = Sandbox.checkout(BorutaAuth.Repo) unless tags[:async] do Sandbox.mode(BorutaGateway.Repo, {:shared, self()}) Sandbox.mode(BorutaAuth.Repo, {:shared, self()}) end :ok end @doc """ A helper that transforms changeset errors into a map of messages. assert {:error, changeset} = Accounts.create_user(%{password: "short"}) assert "password is too short" in errors_on(changeset).password assert %{password: ["password is too short"]} = errors_on(changeset) """ def errors_on(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> Regex.replace(~r"%{(\w+)}", message, fn _, key -> opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() end) end) end end ================================================ FILE: apps/boruta_gateway/test/test_helper.exs ================================================ ExUnit.start() ================================================ FILE: apps/boruta_identity/.formatter.exs ================================================ [ import_deps: [:ecto, :phoenix], inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], subdirectories: ["priv/*/migrations"] ] ================================================ FILE: apps/boruta_identity/.gitignore ================================================ # The directory Mix will write compiled artifacts to. /_build/ # If you run "mix test --cover", coverage assets end up here. /cover/ # The directory Mix downloads your dependencies sources to. /deps/ # Where 3rd-party dependencies like ExDoc output generated docs. /doc/ # Ignore .fetch files in case you like to edit your project deps locally. /.fetch # If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump # Also ignore archive artifacts (built via "mix archive.build"). *.ez # Ignore package tarball (built via "mix hex.build"). boruta_identity-*.tar # If NPM crashes, it generates a log, let's ignore it too. npm-debug.log # The directory NPM downloads your dependencies sources to. /assets/node_modules/ # Since we are building assets from assets/, # we ignore priv/static. You may want to comment # this depending on your deployment strategy. ================================================ FILE: apps/boruta_identity/assets/wallet/.browserslistrc ================================================ > 1% last 2 versions not dead not ie 11 ================================================ FILE: apps/boruta_identity/assets/wallet/.gitignore ================================================ .DS_Store node_modules /dist # local env files .env.local .env.*.local # Log files npm-debug.log* yarn-debug.log* yarn-error.log* # Editor directories and files .idea .vscode *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: apps/boruta_identity/assets/wallet/Dockerfile ================================================ FROM node:21.4 AS builder COPY . /app WORKDIR /app RUN npm ci RUN npm run build RUN npm install -g serve CMD ["serve", "-s", "--listen", "tcp://0.0.0.0:3000", "dist"] ================================================ FILE: apps/boruta_identity/assets/wallet/README.md ================================================ # boruta-wallet ## Project setup ``` npm install ``` ### Compiles and hot-reloads for development ``` npm run serve ``` ### Compiles and minifies for production ``` npm run build ``` ### Run your unit tests ``` npm run test:unit ``` ### Customize configuration See [Configuration Reference](https://cli.vuejs.org/config/). ================================================ FILE: apps/boruta_identity/assets/wallet/package.json ================================================ { "name": "boruta-wallet", "version": "0.1.0", "private": true, "scripts": { "serve": "vite", "build": "vite build --emptyOutDir", "build:watch": "vite build --watch --emptyOutDir", "deploy": "vite build" }, "dependencies": { "@sd-jwt/decode": "^0.9.2", "axios": "^1.15.0", "boruta-client": "github:malach-it/boruta-client", "qr-scanner": "^1.4.2", "register-service-worker": "^1.7.2", "rollup": "^4.59.0", "vue": "^3.2.13", "vue-router": "^4.0.3", "vuex": "^4.0.0" }, "devDependencies": { "@types/chai": "^4.2.15", "@types/mocha": "^8.2.1", "@vitejs/plugin-vue": "^5.2.3", "@vue/test-utils": "^2.0.0-0", "chai": "^4.2.0", "sass": "^1.32.7", "sass-loader": "^12.0.0", "typescript": "~4.5.5", "vite": "^6.4.2", "vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-pwa": "^0.21.1", "vite-plugin-singlefile": "^2.2.0" } } ================================================ FILE: apps/boruta_identity/assets/wallet/public/index.html ================================================ <%= htmlWebpackPlugin.options.title %>
================================================ FILE: apps/boruta_identity/assets/wallet/public/robots.txt ================================================ User-agent: * Disallow: ================================================ FILE: apps/boruta_identity/assets/wallet/src/App.vue ================================================ ================================================ FILE: apps/boruta_identity/assets/wallet/src/components/Consent.vue ================================================ ================================================ FILE: apps/boruta_identity/assets/wallet/src/components/Credentials.vue ================================================ ================================================ FILE: apps/boruta_identity/assets/wallet/src/components/HelloWorld.vue ================================================ ================================================ FILE: apps/boruta_identity/assets/wallet/src/components/KeySelect.vue ================================================ ================================================ FILE: apps/boruta_identity/assets/wallet/src/main.ts ================================================ import { createApp } from 'vue' import App from './App.vue' import './registerServiceWorker' import router from './router' import store from './store' createApp(App).use(store).use(router).mount('#app') ================================================ FILE: apps/boruta_identity/assets/wallet/src/registerServiceWorker.ts ================================================ /* eslint-disable no-console */ import { register } from 'register-service-worker' register(`${window.env.BORUTA_OAUTH_BASE_URL}/accounts/wallet/sw.js`, { ready () { console.log( 'App is being served from cache by a service worker.\n' + 'For more details, visit https://goo.gl/AFskqB' ) }, registered () { console.log('Service worker has been registered.') }, cached () { console.log('Content has been cached for offline use.') }, updatefound () { console.log('New content is downloading.') }, updated () { console.log('New content is available; please refresh.') }, offline () { console.log('No internet connection found. App is running in offline mode.') }, error (error) { console.error('Error during service worker registration:', error) } }) ================================================ FILE: apps/boruta_identity/assets/wallet/src/router/index.ts ================================================ import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router' import HomeView from '../views/HomeView.vue' import Oid4vcCallbackView from '../views/Oid4vcCallbackView.vue' const routes: Array = [ { path: '/credentials', name: 'home', component: HomeView }, { path: '/', name: 'oid4vc-callback', component: Oid4vcCallbackView }, { path: '/callback', name: 'callback', component: HomeView } ] const router = createRouter({ history: createWebHistory('/accounts/wallet'), routes }) export default router ================================================ FILE: apps/boruta_identity/assets/wallet/src/shims-vue.d.ts ================================================ /* eslint-disable */ declare module '*.vue' { import type { DefineComponent } from 'vue' const component: DefineComponent<{}, {}, any> export default component } ================================================ FILE: apps/boruta_identity/assets/wallet/src/store/index.ts ================================================ import { createStore } from 'vuex' import { CredentialsStore, BrowserStorage, BrowserEventHandler } from 'boruta-client' export const storage = new BrowserStorage(window) const eventHandler = new BrowserEventHandler(window) const credentialsStore = new CredentialsStore(eventHandler, storage) const store = createStore({ state: { credentials: [] }, getters: { credentials ({ credentials }) { return credentials } }, mutations: { async refreshCredentials(state) { state.credentials = await credentialsStore.credentials() }, deleteCredential(state, credential) { credentialsStore.deleteCredential(credential.credential).then(credentials => { state.credentials = credentials }) } }, actions: { }, modules: { } }) store.commit('refreshCredentials') export default store ================================================ FILE: apps/boruta_identity/assets/wallet/src/views/HomeView.vue ================================================ ================================================ FILE: apps/boruta_identity/assets/wallet/src/views/Oid4vcCallbackView.vue ================================================ ================================================ FILE: apps/boruta_identity/assets/wallet/src/views/VerifiableCredentialsIssuanceView.vue ================================================ ================================================ FILE: apps/boruta_identity/assets/wallet/tsconfig.json ================================================ { "compilerOptions": { "target": "es6", "module": "esnext", "strict": true, "jsx": "preserve", "importHelpers": true, "moduleResolution": "node", "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, "useDefineForClassFields": true, "sourceMap": true, "baseUrl": ".", "types": [ "webpack-env", "mocha", "chai" ], "paths": { "@/*": [ "src/*" ] }, "lib": [ "esnext", "dom", "dom.iterable", "scripthost" ] }, "include": [ "src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "tests/**/*.ts", "tests/**/*.tsx" ], "exclude": [ "node_modules" ] } ================================================ FILE: apps/boruta_identity/assets/wallet/vite.config.js ================================================ import path from 'path' import { defineConfig } from 'vite' import { viteSingleFile } from 'vite-plugin-singlefile' import vue from '@vitejs/plugin-vue' import { VitePWA } from 'vite-plugin-pwa' import { nodePolyfills } from 'vite-plugin-node-polyfills' const base_url = new URL(process.env.BORUTA_OAUTH_BASE_URL || 'http://localhost:4000') const manifest = { "name": "boruta wallet", "theme_color": "#f5ba00", "background_color": "#333333", "display": "standalone", "scope": "/accounts/wallet", "start_url": "/accounts/wallet", "intent_filters": { "scope_url_scheme": base_url.protocol.slice(0, -1), "scope_url_host": base_url.host, "scope_url_path": "/accounts/wallet" }, "capture_links": "existing-client-navigate", "url_handlers": [ { "origin": `${base_url.toString()}/accounts/wallet` } ], "icons": [{ "src": "/accounts/wallet/images/icons/logo-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }] } // https://vitejs.dev/config/ export default defineConfig({ plugins: [ nodePolyfills({ // To exclude specific polyfills, add them to this list. exclude: [ 'fs', // Excludes the polyfill for `fs` and `node:fs`. ], // Whether to polyfill specific globals. globals: { Buffer: true, // can also be 'build', 'dev', or false global: true, process: true, }, // Whether to polyfill `node:` protocol imports. protocolImports: true, }), vue(), viteSingleFile(), VitePWA({ injectRegister: 'auto', manifest }) ], publicDir: false, build: { outDir: path.resolve(__dirname, '../../priv/static/wallet'), emptyOutDir: false, lib: { entry: path.resolve(__dirname, './src/main.ts'), name: 'Boruta', fileName: (format) => `app.${format}.js` }, target: 'esnext', assetsInlineLimit: 100000000, chunkSizeWarningLimit: 100000000, cssCodeSplit: false, brotliSize: false } }) ================================================ FILE: apps/boruta_identity/config/config.exs ================================================ import Config config :boruta_identity, ecto_repos: [BorutaAuth.Repo, BorutaIdentity.Repo] config :boruta_identity, BorutaIdentityWeb.Endpoint, url: [host: "localhost"], # url: [host: "localhost", path: "/accounts"], server: false, secret_key_base: "Caq0kwgjLGwxoEVPOxUhEiZ3AG2nADaNYi+ceWh2RuAgKF6vv/FfwqM/P7cDcNrR", render_errors: [view: BorutaIdentityWeb.ErrorView, accepts: ~w(html json), layout: false], pubsub_server: BorutaIdentity.PubSub, live_view: [signing_salt: "9q0RPs/i"] config :logger, :console, format: "$time $metadata[$level] $message\n", metadata: [:request_id] config :phoenix, :json_library, Jason config :boruta, Boruta.Oauth, repo: BorutaAuth.Repo, contexts: [ resource_owners: BorutaIdentity.ResourceOwners ], issuer: System.get_env("BORUTA_OAUTH_BASE_URL", "http://localhost:4000") config :oauth2, adapter: Tesla.Adapter.Mint import_config "#{Mix.env()}.exs" ================================================ FILE: apps/boruta_identity/config/dev.exs ================================================ import Config config :boruta_identity, BorutaIdentity.Repo, username: "postgres", password: "postgres", database: "boruta_dev", hostname: "localhost", show_sensitive_data_on_connection_error: true, pool_size: 5, after_connect: {BorutaIdentity.Repo, :set_limit, []} config :boruta_identity, BorutaIdentityWeb.Endpoint, http: [port: System.get_env("BORUTA_OAUTH_PORT", "4000") |> String.to_integer(), path: "/accounts"], debug_errors: true, code_reloader: true, check_origin: false, server: false config :boruta_identity, BorutaIdentity.SMTP, adapter: Swoosh.Adapters.SMTP config :logger, :console, format: "[$level] $message\n" config :phoenix, :stacktrace_depth, 20 config :phoenix, :plug_init_mode, :runtime ================================================ FILE: apps/boruta_identity/config/prod.exs ================================================ import Config ================================================ FILE: apps/boruta_identity/config/test.exs ================================================ import Config # Configure your database # # The MIX_TEST_PARTITION environment variable can be used # to provide built-in test partitioning in CI environment. # Run `mix help test` for more information. config :boruta_identity, BorutaIdentity.Repo, username: "postgres", password: "postgres", database: "boruta_identity_test#{System.get_env("MIX_TEST_PARTITION")}", hostname: "localhost", pool: Ecto.Adapters.SQL.Sandbox, after_connect: {BorutaIdentity.Repo, :set_limit, []} config :boruta_identity, BorutaIdentityWeb.Endpoint, http: [port: 4002], server: false config :boruta_identity, BorutaIdentity.SMTP, adapter: Swoosh.Adapters.Test config :boruta_identity, BorutaIdentity.LdapRepo, adapter: BorutaIdentity.LdapRepoMock config :logger, level: :warn ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/backends/federated.ex ================================================ defmodule BorutaIdentity.Accounts.Federated do @moduledoc false @behaviour BorutaIdentity.FederatedAccounts import Ecto.Query alias BorutaIdentity.Accounts.User alias BorutaIdentity.Repo @features [ :destroyable ] def features, do: @features @account_type "federated" def account_type, do: @account_type @impl BorutaIdentity.FederatedAccounts def domain_user!(federated_server_name, access_token, backend) do federated_server = Enum.find(backend.federated_servers, fn %{"name" => name} -> name == federated_server_name end) base_url = URI.parse(federated_server["base_url"]) userinfo_uri = case URI.parse(federated_server["userinfo_path"]) do %URI{host: host} = uri when not is_nil(host) -> uri %URI{path: path} -> %{base_url | path: path} end |> URI.to_string() userinfo = get_resource!(userinfo_uri, access_token) federated_metadata = Enum.flat_map(federated_server["metadata_endpoints"] || [], fn endpoint -> response = get_resource!(endpoint["endpoint"], access_token) claims_from_response(endpoint, response) end) |> Enum.into(%{}) impl_user_params = %{ uid: to_string(userinfo["sub"] || userinfo["id"]), username: userinfo["email"] || "#{userinfo["sub"]}@#{federated_server["name"]}", federated_metadata: %{federated_server_name => Map.merge(userinfo, federated_metadata)}, account_type: @account_type, backend_id: backend.id } # TODO store origin federated server changeset = User.implementation_changeset(impl_user_params, backend) new_metadata = Ecto.Changeset.get_field(changeset, :federated_metadata) new_username = Ecto.Changeset.get_field(changeset, :username) Repo.insert!(changeset, on_conflict: from(u in User, update: [ set: [ username: ^new_username, federated_metadata: fragment("? || ?", u.federated_metadata, ^new_metadata) ] ] ), returning: true, conflict_target: [:backend_id, :uid] ) |> Repo.preload([:authorized_scopes, :consents, :backend, :organizations]) end def delete_user(_uid), do: :ok defp get_resource!(url, access_token) do case Finch.build(:get, url, [ {"accept", "application/json"}, {"authorization", "Bearer #{access_token}"} ]) |> Finch.request(BorutaIdentity.Finch) do {:ok, %Finch.Response{status: 200, body: body}} -> Jason.decode!(body) {:ok, %Finch.Response{status: status, body: body}} -> raise "GET #{url} failed with status #{status} - #{inspect(body)}" error -> raise inspect(error) end end defp claims_from_response(endpoint, body) do endpoint["claims"] |> String.split(" ") |> Enum.map(fn claim -> {String.replace(claim, ".", "-"), %{ "value" => get_in( body, String.split(claim, ".") |> Enum.map(fn ":all" -> Access.all() claim -> claim end) ) }} end) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/backends/internal/user.ex ================================================ defmodule BorutaIdentity.Accounts.Internal.User do @moduledoc false use Ecto.Schema import Ecto.Changeset alias BorutaIdentity.IdentityProviders.Backend @type t :: %__MODULE__{ email: String.t(), password: String.t(), hashed_password: String.t(), metadata: map() | nil, inserted_at: DateTime.t(), updated_at: DateTime.t() } @derive {Inspect, except: [:password]} @primary_key {:id, Ecto.UUID, autogenerate: true} @foreign_key_type Ecto.UUID schema "internal_users" do field(:email, :string) field(:password, :string, virtual: true) field(:hashed_password, :string) field(:metadata, :map, virtual: true) field(:group, :string, virtual: true) belongs_to(:backend, Backend) timestamps() end @doc """ A user changeset for registration. It is important to validate the length of both email and password. Otherwise databases may truncate the email without warnings, which could lead to unpredictable or insecure behaviour. Long passwords may also be very expensive to hash for certain algorithms. ## Options * `:hash_password` - Hashes the password so it can be stored securely in the database and ensures the password field is cleared to prevent leaks in the logs. If password hashing is not needed and clearing the password field is not desired (like when using this changeset for validations on a LiveView form), this option can be set to `false`. Defaults to `true`. """ def registration_changeset(user, attrs, %{backend: backend} = opts) do user |> cast(attrs, [:email, :password]) |> validate_required([:email, :password]) |> change(backend_id: backend.id) |> validate_email() |> validate_password(opts) end def raw_registration_changeset(user, attrs, %{backend: backend}) do user |> cast(attrs, [:email, :hashed_password]) |> validate_required([:email, :hashed_password]) |> change(backend_id: backend.id) |> validate_email() end defp validate_email(changeset) do changeset |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") |> validate_length(:email, max: 160) |> unique_constraint([:backend_id, :email], error_key: :email, message: "has already been taken") end defp validate_password(changeset, opts) do changeset |> validate_length(:password, min: 12, max: 80) # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") |> maybe_hash_password(opts) end defp maybe_hash_password(changeset, %{backend: backend} = opts) do hash_password? = Map.get(opts, :hash_password, true) password = get_change(changeset, :password) if hash_password? && password && changeset.valid? do changeset |> put_change( :hashed_password, apply(Backend.password_hashing_module(backend), :hash_pwd_salt, [ password, Backend.password_hashing_opts(backend) ]) ) |> delete_change(:password) else changeset end end def update_changeset(user, attrs, opts) do user |> cast(attrs, [:email, :password, :group]) |> validate_required([:email]) |> validate_email() |> validate_password(opts) end @doc """ A user changeset for changing the password. ## Options * `:hash_password` - Hashes the password so it can be stored securely in the database and ensures the password field is cleared to prevent leaks in the logs. If password hashing is not needed and clearing the password field is not desired (like when using this changeset for validations on a LiveView form), this option can be set to `false`. Defaults to `true`. """ def password_changeset(user, attrs, opts) do user |> cast(attrs, [:password]) |> validate_confirmation(:password, message: "does not match password") |> validate_password(opts) end def valid_password?(backend, %__MODULE__{hashed_password: hashed_password}, password) when is_binary(hashed_password) and byte_size(password) > 0 do apply( Backend.password_hashing_module(backend), :verify_pass, [password, hashed_password] ) rescue _ -> false end def valid_password?(backend, _, _) do apply( Backend.password_hashing_module(backend), :no_user_verify, [] ) false rescue _ -> false end @doc """ Validates the current password otherwise adds an error to the changeset. """ def validate_current_password(backend, changeset, password) do if valid_password?(backend, changeset.data, password) do changeset else add_error(changeset, :current_password, "is not valid") end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/backends/internal.ex ================================================ defmodule BorutaIdentity.Accounts.Internal do @moduledoc """ Internal database `Accounts` implementation. """ @behaviour BorutaIdentity.Admin @behaviour BorutaIdentity.Accounts.Registrations @behaviour BorutaIdentity.Accounts.ResetPasswords @behaviour BorutaIdentity.Accounts.Sessions @behaviour BorutaIdentity.Accounts.Settings import Ecto.Query, only: [from: 2] alias BorutaIdentity.Accounts.Internal alias BorutaIdentity.Accounts.User alias BorutaIdentity.Accounts.UserToken alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.Repo @features [ :authenticable, :totpable, :webauthnable, :registrable, :user_editable, :confirmable, :reset_password, :consentable, :destroyable ] def features, do: @features @account_type "internal" def account_type, do: @account_type @impl BorutaIdentity.Accounts.Registrations def register(backend, registration_params) do with {:ok, user} <- Internal.User.registration_changeset( %Internal.User{ group: registration_params[:group], metadata: registration_params[:metadata] }, registration_params, %{ backend: backend } ) |> Repo.insert() do {:ok, domain_user!(user, backend)} end end @impl BorutaIdentity.Accounts.Sessions def get_user(backend, %{email: email}) when is_binary(email) do user = Repo.get_by!(Internal.User, email: email, backend_id: backend.id) {:ok, user} rescue Ecto.NoResultsError -> {:error, "User not found."} end def get_user(_authentication_params), do: {:error, "Cannot find an user without an email."} @impl BorutaIdentity.Accounts.Sessions def domain_user!( %Internal.User{id: id, email: email, metadata: metadata, group: group}, %Backend{ id: backend_id } = backend, repo \\ Repo ) do impl_user_params = %{ uid: id, username: email, group: group, backend_id: backend_id, account_type: @account_type } {replace, impl_user_params} = case metadata do %{} = metadata -> {[:username, :metadata, :group], Map.put(impl_user_params, :metadata, metadata)} _ -> {[:username, :group], impl_user_params} end User.implementation_changeset(impl_user_params, backend) |> repo.insert!( on_conflict: {:replace, replace}, returning: true, conflict_target: [:backend_id, :uid] ) |> Repo.preload([:authorized_scopes, :consents, :backend, :organizations]) end # BorutaIdentity.Accounts.Sessions, BorutaIdentity.Accounts.Settings @impl true def check_user_against(backend, user, authentication_params) do check_user_password(backend, user, authentication_params[:password]) end defp check_user_password(backend, user, password) do case Internal.User.valid_password?(backend, user, password) do true -> {:ok, user} false -> {:error, "Invalid user password."} end end @impl BorutaIdentity.Accounts.ResetPasswords def reset_password(backend, reset_password_params) do with {:ok, user} <- get_user_by_reset_password_token(reset_password_params.reset_password_token), {:ok, %{user: user}} <- reset_user_password_multi(backend, user, reset_password_params) do {:ok, user} else {:error, :user, changeset, _} -> {:error, changeset} {:error, _reason} = error -> error end end @impl BorutaIdentity.Accounts.Settings def update_user(backend, user, params) do Repo.transaction(fn repo -> case %{user | metadata: params[:metadata], group: params[:group]} |> Internal.User.update_changeset(params, %{backend: backend}) |> repo.update() do {:ok, user} -> domain_user!(user, backend, repo) {:error, error} -> Repo.rollback(error) end end) end @impl BorutaIdentity.Accounts.Settings def delete_user(uid) do case Repo.delete_all(from(u in Internal.User, where: u.id == ^uid)) do {1, nil} -> :ok _ -> {:error, "User could not be deleted."} end end @impl BorutaIdentity.Admin def create_user(backend, params) do Repo.transaction(fn repo -> case Internal.User.registration_changeset( %Internal.User{ group: params[:group], metadata: params[:metadata] }, %{ email: params[:username], password: params[:password] }, %{backend: backend} ) |> repo.insert() do {:ok, user} -> domain_user!(user, backend, repo) {:error, error} -> Repo.rollback(error) end end) end @impl BorutaIdentity.Admin def create_raw_user(backend, params) do # TODO database transaction with {:ok, user} <- Internal.User.raw_registration_changeset( %Internal.User{}, %{ email: params[:username], hashed_password: params[:hashed_password] }, %{backend: backend} ) |> Repo.insert() do {:ok, domain_user!(user, backend)} end end defp get_user_by_reset_password_token(token) do with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), %User{} = user <- Repo.one(query), %Internal.User{} = user <- Repo.get(Internal.User, user.uid) do {:ok, user} else _ -> {:error, "Given reset password token is invalid."} end end defp reset_user_password_multi(backend, user, reset_password_params) do Ecto.Multi.new() |> Ecto.Multi.update( :user, Internal.User.password_changeset(user, reset_password_params, %{backend: backend}) ) |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) |> Repo.transaction() end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/backends/ldap/user.ex ================================================ defmodule BorutaIdentity.Accounts.Ldap.User do @moduledoc false defstruct uid: nil, dn: nil, username: nil, backend: nil, metadata: nil @type t :: %__MODULE__{ uid: String.t() | nil, dn: String.t() | nil, username: String.t() | nil, backend: BorutaIdentity.IdentityProviders.Backend.t() | nil, metadata: map() | nil } end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/backends/ldap.ex ================================================ defmodule BorutaIdentity.Accounts.LdapError do @enforce_keys [:message] defexception [:message] @type t :: %__MODULE__{ message: String.t() } def exception(message) when is_binary(message) do %__MODULE__{message: message} end def message(exception) do exception.message end end defmodule BorutaIdentity.Accounts.Ldap do @moduledoc false @behaviour NimblePool alias BorutaIdentity.Accounts.Ldap alias BorutaIdentity.Accounts.LdapError alias BorutaIdentity.Accounts.User alias BorutaIdentity.Accounts.UserToken alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.LdapRepo alias BorutaIdentity.Repo @behaviour BorutaIdentity.Accounts.ResetPasswords @behaviour BorutaIdentity.Accounts.Sessions @behaviour BorutaIdentity.Accounts.Settings @behaviour BorutaIdentity.Admin @features [ :authenticable, :totpable, :webauthnable, :consentable, :user_editable, :reset_password, :confirmable ] @account_type "ldap" def account_type, do: "ldap" @ldap_timeout 10_000 def features, do: @features @impl BorutaIdentity.Accounts.Sessions def get_user(backend, %{email: email}) do lazy_start(backend) NimblePool.checkout!( pool_name(backend), :checkout, fn _from, ldap -> {fetch_user_from_ldap(ldap, backend, email), ldap} end, @ldap_timeout ) end @impl BorutaIdentity.Accounts.Sessions def domain_user!( %Ldap.User{uid: uid, username: username, metadata: metadata}, %Backend{id: backend_id} = backend ) do impl_user_params = %{ uid: uid, username: username, backend_id: backend_id, account_type: @account_type } {replace, impl_user_params} = case metadata do %{} = metadata -> {[:username, :metadata], Map.put(impl_user_params, :metadata, metadata)} _ -> {[:username], impl_user_params} end User.implementation_changeset(impl_user_params, backend) |> Repo.insert!( on_conflict: {:replace, replace}, returning: true, conflict_target: [:backend_id, :uid] ) |> Repo.preload([:authorized_scopes, :consents, :backend, :organizations]) end @impl BorutaIdentity.Accounts.Sessions def check_user_against(backend, ldap_user, authentication_params) do lazy_start(backend) NimblePool.checkout!( pool_name(backend), :checkout, fn _from, ldap -> case LdapRepo.simple_bind(ldap, ldap_user.dn, authentication_params[:password]) do :ok -> {{:ok, ldap_user}, ldap} _error -> {{:error, "Authentication failure."}, ldap} end end, @ldap_timeout ) end @impl BorutaIdentity.Accounts.Settings def update_user(backend, user, params) do lazy_start(backend) NimblePool.checkout!( pool_name(backend), :checkout, fn _from, ldap -> case update_user_in_ldap(ldap, backend, user, params) do {:ok, user} -> {{:ok, domain_user!(%{user | metadata: params[:metadata]}, backend)}, ldap} {:error, error, user} -> # NOTE keep user synchronized from LDAP domain_user!(user, backend) {{:error, error}, ldap} end end ) end @impl BorutaIdentity.Accounts.Settings def delete_user(_id) do {:error, "LDAP backends does not support user deletion."} end @impl BorutaIdentity.Accounts.ResetPasswords def reset_password(backend, reset_password_params) do lazy_start(backend) NimblePool.checkout!( pool_name(backend), :checkout, fn _from, ldap -> with {:ok, user} <- get_user_by_reset_password_token( ldap, backend, reset_password_params.reset_password_token ), {:ok, _user} <- reset_password_in_ldap(ldap, backend, user, reset_password_params) do {{:ok, user}, ldap} else {:error, error} -> {{:error, error}, ldap} end end ) end @impl BorutaIdentity.Admin def create_user(_backend, _params) do raise LdapError, "LDAP backends does not support user creation." end @impl BorutaIdentity.Admin def create_raw_user(_backend, _params) do raise LdapError, "LDAP backends does not support user creation." end @spec pool_name(backend :: Backend.t()) :: pool_name :: atom() def pool_name(backend) do signature = backend |> Map.from_struct() |> Enum.map_join(fn {key, value} -> case Atom.to_string(key) do "ldap_" <> _rest -> to_string(value) _ -> nil end end) signature = :crypto.hash(:sha256, signature) signature |> Base.encode16() |> String.to_atom() end def start_link(backend) do NimblePool.start_link( pool_size: backend.ldap_pool_size, worker: {__MODULE__, %{backend: backend}}, name: pool_name(backend) ) end @impl NimblePool def init_worker(%{backend: backend}) do # TODO add ldap port and ssl configurations {:ok, ldap} = LdapRepo.open(backend.ldap_host) {:ok, ldap, %{backend: backend}} end @impl NimblePool def handle_checkout(:checkout, _from, ldap, pool_state) do {:ok, ldap, ldap, pool_state} end @impl NimblePool def handle_checkin(ldap, _from, ldap, pool_state) do {:remove, :closed, pool_state} end @impl NimblePool def terminate_worker(_reason, ldap, pool_state) do LdapRepo.close(ldap) {:ok, pool_state} rescue _ -> {:ok, pool_state} end defp fetch_user_from_ldap(ldap, backend, email) do user_rdn_attribute = backend.ldap_user_rdn_attribute with {:ok, {dn, user_properties}} <- LdapRepo.search(ldap, backend, email), uid when is_binary(uid) <- Map.get( user_properties, "uid", {:error, "Could not get uid attribute"} ), username when is_binary(username) <- Map.get( user_properties, user_rdn_attribute, {:error, "Could not get #{user_rdn_attribute} attribute"} ) do {:ok, %Ldap.User{ uid: to_string(uid), dn: to_string(dn), username: to_string(username), backend: backend }} else {:error, error} when is_binary(error) -> {:error, error} {:error, error} -> {:error, inspect(error)} end end defp update_user_in_ldap( ldap, %Backend{ ldap_master_dn: ldap_master_dn, ldap_master_password: ldap_master_password } = backend, user, %{email: email} = params ) do with :ok <- LdapRepo.simple_bind(ldap, ldap_master_dn, ldap_master_password), :ok <- LdapRepo.modify(ldap, backend, user, email) do user = %{user | username: to_string(email)} update_user_in_ldap(ldap, backend, user, Map.delete(params, :email)) else {:error, error} -> {:error, error, user} end end defp update_user_in_ldap( ldap, backend, user, %{password: password, current_password: current_password} = params ) when byte_size(password) > 0 do case LdapRepo.modify_password(ldap, user, password, current_password) do :ok -> update_user_in_ldap(ldap, backend, user, Map.delete(params, :password)) {:error, error} -> {:error, error, user} end end defp update_user_in_ldap(_ldap, _backend, user, _params), do: {:ok, user} defp get_user_by_reset_password_token(ldap, backend, token) do with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), %User{username: username} <- Repo.one(query), {:ok, user} <- fetch_user_from_ldap(ldap, backend, username) do {:ok, user} else _ -> {:error, "Given reset password token is invalid."} end end defp reset_password_in_ldap( ldap, %Backend{ ldap_master_dn: ldap_master_dn, ldap_master_password: ldap_master_password }, user, %{password: password} = reset_password_params ) when byte_size(password) > 0 do with :ok <- check_password_confirmation(reset_password_params), :ok <- LdapRepo.simple_bind(ldap, ldap_master_dn, ldap_master_password), :ok <- LdapRepo.modify_password(ldap, user, reset_password_params.password) do {:ok, user} end end defp reset_password_in_ldap(_ldap, _backend, _user, _reset_password_params), do: {:error, "Password cannot be empty."} defp check_password_confirmation(%{ password: password, password_confirmation: password_confirmation }) when password == password_confirmation do :ok end defp check_password_confirmation(_reset_password_params), do: {:error, "Password and password confirmation do not match."} defp lazy_start(backend) do case start_link(backend) do {:ok, pid} -> {:ok, pid} {:error, {:already_started, pid}} -> {:ok, pid} error -> error end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/choose_sessions.ex ================================================ defmodule BorutaIdentity.Accounts.ChooseSessionApplication do @moduledoc """ TODO ConsentApplication documentation """ @callback choose_session_initialized( context :: any(), template :: BorutaIdentity.IdentityProviders.Template.t() ) :: any() @callback choose_session_not_required(context :: any()) :: any() end defmodule BorutaIdentity.Accounts.ChooseSessions do @moduledoc false import BorutaIdentity.Accounts.Utils, only: [defwithclientidp: 2] alias BorutaIdentity.IdentityProviders @spec initialize_choose_session(context :: any(), client_id :: String.t(), module :: atom()) :: callback_result :: any() defwithclientidp initialize_choose_session(context, client_id, module) do case client_idp.choose_session do true -> module.choose_session_initialized(context, new_choose_session_template(client_idp)) false -> module.choose_session_not_required(context) end end defp new_choose_session_template(identity_provider) do IdentityProviders.get_identity_provider_template!(identity_provider.id, :choose_session) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/confirmations.ex ================================================ defmodule BorutaIdentity.Accounts.ConfirmationError do @enforce_keys [:message] defexception [:message] @type t :: %__MODULE__{ message: String.t() } def exception(message) when is_binary(message) do %__MODULE__{message: message} end def message(exception) do exception.message end end defmodule BorutaIdentity.Accounts.ConfirmationApplication do @moduledoc """ TODO ConfirmationApplication documentation """ @callback confirmation_instructions_initialized( context :: any(), template :: BorutaIdentity.IdentityProviders.Template.t() ) :: any() @callback confirmation_instructions_delivered(context :: any()) :: any() @callback user_confirmed(context :: any(), user :: BorutaIdentity.Accounts.User.t()) :: any() @callback user_confirmation_failure( context :: any(), error :: BorutaIdentity.Accounts.ConfirmationError.t() ) :: any() end defmodule BorutaIdentity.Accounts.Confirmations do @moduledoc false import BorutaIdentity.Accounts.Utils, only: [defwithclientidp: 2] alias BorutaIdentity.Accounts alias BorutaIdentity.Accounts.ConfirmationError alias BorutaIdentity.Accounts.Deliveries alias BorutaIdentity.Accounts.User alias BorutaIdentity.Accounts.UserToken alias BorutaIdentity.IdentityProviders alias BorutaIdentity.Repo @type confirmation_instructions_params :: %{ email: String.t() } @type confirmation_url_fun :: (token :: String.t() -> confirmation_url :: String.t()) @spec initialize_confirmation_instructions( context :: any(), client_id :: String.t(), module :: atom() ) :: callback_result :: any() defwithclientidp initialize_confirmation_instructions(context, client_id, module) do module.confirmation_instructions_initialized( context, new_confirmation_instructions_template(client_idp) ) end @spec send_confirmation_instructions( context :: any(), client_id :: String.t(), confirmation_instructions_params :: confirmation_instructions_params(), confirmation_url_fun :: confirmation_url_fun(), module :: atom() ) :: callback_result :: any() defwithclientidp send_confirmation_instructions( context, client_id, confirmation_instructions_params, confirmation_url_fun, module ) do with %User{} = user <- Accounts.get_user_by_email( client_idp.backend, confirmation_instructions_params[:email] ) do Deliveries.deliver_user_confirmation_instructions(client_idp.backend, user, confirmation_url_fun) end # NOTE return a success either confirmation instructions email sent or not module.confirmation_instructions_delivered(context) end # NOTE If the token matches, the user account is marked as confirmed and the token is deleted. @spec confirm_user( context :: any(), client_id :: String.t(), token :: String.t(), module :: atom() ) :: callback_result :: any() def confirm_user(context, _client_id, token, module) do case confirm_user(token) do {:ok, user} -> module.user_confirmed(context, user) {:error, _reason} -> module.user_confirmation_failure(context, %ConfirmationError{ message: "Account confirmation token is invalid or it has expired." }) end end defp new_confirmation_instructions_template(identity_provider) do IdentityProviders.get_identity_provider_template!( identity_provider.id, :new_confirmation_instructions ) end defp confirm_user(token) do with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), %User{confirmed_at: nil} = user <- Repo.one(query), {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do {:ok, user} else _ -> {:error, "Account confirmation token is invalid or it has expired."} end end defp confirm_user_multi(user) do Ecto.Multi.new() |> Ecto.Multi.update(:user, User.confirm_changeset(user)) |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"])) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/consents.ex ================================================ defmodule BorutaIdentity.Accounts.ConsentApplication do @moduledoc """ TODO ConsentApplication documentation """ @callback consent_initialized( context :: any(), client :: Boruta.Oauth.Client.t(), scopes :: list(Boruta.Oauth.Scope.t()), template :: BorutaIdentity.IdentityProviders.Template.t() ) :: any() @callback consent_not_required(context :: any()) :: any() @callback consented(context :: any(), scope :: String.t()) :: any() @callback consent_failed(context :: any(), changeset :: Ecto.Changeset.t()) :: any() end defmodule BorutaIdentity.Accounts.Consents do @moduledoc false import BorutaIdentity.Accounts.Utils, only: [defwithclientidp: 2] import Ecto.Query, only: [from: 2] alias Boruta.Ecto.Admin alias Boruta.Ecto.Clients alias Boruta.Oauth.Scope alias BorutaIdentity.Accounts.Consent alias BorutaIdentity.Accounts.User alias BorutaIdentity.IdentityProviders alias BorutaIdentity.Repo @spec initialize_consent( context :: any(), client_id :: String.t(), user :: User.t(), scope :: String.t(), module :: atom() ) :: callback_result :: any() defwithclientidp initialize_consent( context, client_id, user, scope, module ) do client = Clients.get_client(client_id) scopes = Scope.split(scope) case {client_idp.consentable, consented?(user, client_id, scopes)} do {true, false} -> scopes = Admin.get_scopes_by_names(scopes) module.consent_initialized(context, client, scopes, new_consent_template(client_idp)) _ -> module.consent_not_required(context) end end @type consent_params :: %{ client_id: String.t(), scopes: list(String.t()) } @spec consent( context :: any(), client_id :: String.t(), user :: User.t(), params :: consent_params(), module :: atom() ) :: callback_result :: any() defwithclientidp consent(context, client_id, user, params, module) do _client_idp = client_idp case user |> User.consent_changeset(%{consents: [params]}) |> Repo.update() do {:ok, _user} -> module.consented(context, params[:scopes]) {:error, changeset} -> module.consent_failed(context, changeset) end end @spec consented?(user :: User.t(), client_id :: String.t(), scopes :: list(String.t())) :: boolean() def consented?(%User{}, _client_id, []), do: true def consented?(%User{id: user_id}, client_id, scopes) do case Repo.one(from c in Consent, where: c.user_id == ^user_id and c.client_id == ^client_id) do nil -> false consent -> Enum.empty?(scopes -- consent.scopes) end end def consented?(_, _, _), do: false defp new_consent_template(identity_provider) do IdentityProviders.get_identity_provider_template!(identity_provider.id, :new_consent) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/deliveries/email_template.ex ================================================ defmodule BorutaIdentity.Accounts.EmailTemplate do @moduledoc false use Ecto.Schema import Ecto.Changeset alias BorutaIdentity.IdentityProviders.Backend @type t :: %__MODULE__{ id: String.t() | nil, type: String.t(), default: boolean(), txt_content: String.t(), html_content: String.t(), inserted_at: DateTime.t() | nil, updated_at: DateTime.t() | nil } @template_types [ :confirmation_instructions, :reset_password_instructions, :tx_code ] @type template_type :: :confirmation_instructions | :reset_password_instructions @default_templates %{ txt_confirmation_instructions: :code.priv_dir(:boruta_identity) |> Path.join("templates/emails/confirmation_instructions.txt.mustache") |> File.read!(), html_confirmation_instructions: :code.priv_dir(:boruta_identity) |> Path.join("templates/emails/confirmation_instructions.html.mustache") |> File.read!(), txt_reset_password_instructions: :code.priv_dir(:boruta_identity) |> Path.join("templates/emails/reset_password_instructions.txt.mustache") |> File.read!(), html_reset_password_instructions: :code.priv_dir(:boruta_identity) |> Path.join("templates/emails/reset_password_instructions.html.mustache") |> File.read!(), txt_tx_code: :code.priv_dir(:boruta_identity) |> Path.join("templates/emails/tx_code.txt.mustache") |> File.read!(), html_tx_code: :code.priv_dir(:boruta_identity) |> Path.join("templates/emails/tx_code.html.mustache") |> File.read!() } @foreign_key_type :binary_id @primary_key {:id, :binary_id, autogenerate: true} schema "email_templates" do field(:txt_content, :string, default: "") field(:html_content, :string, default: "") field(:type, :string) field(:default, :boolean, virtual: true, default: false) belongs_to(:backend, Backend) timestamps() end def template_types, do: @template_types @spec default_txt_content(type :: template_type()) :: template_content :: String.t() def default_txt_content(type) when type in @template_types, do: @default_templates[:"txt_#{type}"] @spec default_html_content(type :: template_type()) :: template_content :: String.t() def default_html_content(type) when type in @template_types, do: @default_templates[:"html_#{type}"] @spec default_template(type :: template_type()) :: %__MODULE__{} | nil def default_template(type) when type in @template_types do %__MODULE__{ default: true, type: Atom.to_string(type), txt_content: default_txt_content(type), html_content: default_html_content(type) } end def default_template(_type), do: nil @doc false def changeset(template, attrs) do template |> cast(attrs, [:type, :txt_content, :html_content, :backend_id]) |> validate_required([:type, :backend_id, :txt_content, :html_content]) |> validate_inclusion(:type, Enum.map(@template_types, &Atom.to_string/1)) |> foreign_key_constraint(:backend_id) |> put_default_txt() |> put_default_html() end @doc false def assoc_changeset(template, attrs) do template |> cast(attrs, [:type, :txt_content, :html_content]) |> validate_required([:type, :txt_content, :html_content]) |> validate_inclusion(:type, Enum.map(@template_types, &Atom.to_string/1)) |> put_default_txt() |> put_default_html() end defp put_default_txt(changeset) do case fetch_change(changeset, :txt_content) do {:ok, content} when not is_nil(content) -> changeset _ -> change( changeset, txt_content: default_txt_content(changeset |> fetch_field!(:type) |> String.to_atom()) ) end end defp put_default_html(changeset) do case fetch_change(changeset, :html_content) do {:ok, content} when not is_nil(content) -> changeset _ -> change( changeset, html_content: default_html_content(changeset |> fetch_field!(:type) |> String.to_atom()) ) end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/deliveries/user_notifier.ex ================================================ defmodule BorutaIdentity.Accounts.UserNotifier do @moduledoc false require Logger import Swoosh.Email alias BorutaIdentity.Accounts.EmailTemplate alias BorutaIdentity.IdentityProviders.Backend def smtp_adapter do Application.get_env(:boruta_identity, BorutaIdentity.SMTP)[:adapter] end # TODO hide Swoosh from the rest of the world @spec deliver(email :: %Swoosh.Email{}, backend :: Backend.t()) :: {:ok, email :: %Swoosh.Email{}} | {:error, reason :: String.t()} def deliver(email, backend) do config = [ relay: backend.smtp_relay, username: backend.smtp_username, password: backend.smtp_password, ssl: backend.smtp_ssl, tls: String.to_atom(backend.smtp_tls), auth: :always, port: backend.smtp_port, # dkim: [ # s: "default", d: "domain.com", # private_key: {:pem_plain, File.read!("priv/keys/domain.private")} # ], retries: 2, no_mx_lookups: false ] with :ok <- smtp_adapter().validate_config(config), {:ok, _} <- smtp_adapter().deliver(email, config) do {:ok, email} else {:error, {_status, %{"Errors" => errors}}} -> reason = errors |> Enum.map_join(", ", fn %{"ErrorMessage" => message} -> message end) {:error, reason} {:error, reason} -> {:error, inspect(reason)} end rescue _error -> {:error, "Bad SMTP configuration."} end @doc """ Deliver instructions to confirm account. """ def deliver_confirmation_instructions(backend, user, url) do template = Enum.find(backend.email_templates, fn %EmailTemplate{type: type} -> type == "confirmation_instructions" end) || EmailTemplate.default_template(:confirmation_instructions) context = %{ user: Map.from_struct(user), url: url } text_body = Mustachex.render(template.txt_content, context) html_body = Mustachex.render(template.html_content, context) new() |> from(user.backend.smtp_from) |> to(user.username) |> subject("Welcome to boruta service beta preview") |> text_body(text_body) |> html_body(html_body) rescue _error -> {:error, "Bad SMTP configuration."} end @doc """ Deliver instructions to reset a user password. """ def deliver_reset_password_instructions(backend, user, url) do template = Enum.find(backend.email_templates, fn %EmailTemplate{type: type} -> type == "reset_password_instructions" end) || EmailTemplate.default_template(:reset_password_instructions) context = %{ user: Map.from_struct(user), url: url } text_body = Mustachex.render(template.txt_content, context) html_body = Mustachex.render(template.html_content, context) new() |> from(user.backend.smtp_from) |> to(user.username) |> subject("Reset your password.") |> text_body(text_body) |> html_body(html_body) rescue _error -> {:error, "Bad SMTP configuration."} end def deliver_tx_code(backend, user, tx_code) do template = Enum.find(backend.email_templates, fn %EmailTemplate{type: type} -> type == "tx_code" end) || EmailTemplate.default_template(:tx_code) context = %{ user: Map.from_struct(user), tx_code: tx_code } text_body = Mustachex.render(template.txt_content, context) html_body = Mustachex.render(template.html_content, context) new() |> from(user.backend.smtp_from) |> to(user.username) |> subject("Wallet transaction code") |> text_body(text_body) |> html_body(html_body) rescue _error -> {:error, "Bad SMTP configuration."} end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/deliveries.ex ================================================ defmodule BorutaIdentity.Accounts.Deliveries do @moduledoc false alias BorutaIdentity.Accounts.User alias BorutaIdentity.Accounts.UserNotifier alias BorutaIdentity.Accounts.UserToken alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.Repo @type callback_function :: (token :: String.t() -> String.t()) @spec deliver_user_confirmation_instructions( backend :: Backend.t(), user :: User.t(), confirmation_url_fun :: callback_function() ) :: {:ok, confirmation_token :: String.t()} | {:error, reason :: String.t() | Ecto.Changeset.t()} def deliver_user_confirmation_instructions(backend, %User{} = user, confirmation_url_fun) when is_function(confirmation_url_fun, 1) do if user.confirmed_at do {:error, "User is already confirmed."} else {encoded_token, confirmation_token} = UserToken.build_email_token(user, "confirm") with {:ok, _confirmation_token} <- Repo.insert(confirmation_token), {:ok, _email} <- UserNotifier.deliver_confirmation_instructions( backend, user, confirmation_url_fun.(encoded_token) ) |> UserNotifier.deliver(backend) do {:ok, encoded_token} end end end @spec deliver_user_reset_password_instructions( backend :: Backend.t(), user :: User.t(), reset_password_url_fun :: callback_function() ) :: {:ok, email :: any()} | {:error, reason :: any()} def deliver_user_reset_password_instructions(backend, %User{} = user, reset_password_url) do UserNotifier.deliver_reset_password_instructions( backend, user, reset_password_url ) |> UserNotifier.deliver(backend) end @spec deliver_tx_code( backend :: Backend.t(), user :: User.t(), tx_code :: String.t() ) :: :ok | {:error, reason :: String.t() | Ecto.Changeset.t()} def deliver_tx_code(backend, %User{} = user, tx_code) do with {:ok, _email} <- UserNotifier.deliver_tx_code( backend, user, tx_code ) |> UserNotifier.deliver(backend) do :ok end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/registrations.ex ================================================ defmodule BorutaIdentity.Accounts.RegistrationError do @enforce_keys [:message] defexception [:user, :message, :changeset, :template] @type t :: %__MODULE__{ user: BorutaIdentity.Accounts.User.t() | nil, message: String.t(), changeset: Ecto.Changeset.t() | nil, template: BorutaIdentity.IdentityProviders.Template.t() } def exception(message) when is_binary(message) do %__MODULE__{message: message} end def message(exception) do exception.message end end defmodule BorutaIdentity.Accounts.RegistrationApplication do @moduledoc """ TODO RegistrationApplication documentation """ @callback registration_initialized( context :: any(), template :: BorutaIdentity.IdentityProviders.Template.t() ) :: any() @callback user_registered( context :: any(), user :: BorutaIdentity.Accounts.User.t(), session_token :: String.t() ) :: any() @callback registration_failure( context :: any(), error :: BorutaIdentity.Accounts.RegistrationError.t() ) :: any() end defmodule BorutaIdentity.Accounts.Registrations do @moduledoc false import BorutaIdentity.Accounts.Utils, only: [defwithclientidp: 2] alias BorutaIdentity.Accounts.Deliveries alias BorutaIdentity.Accounts.RegistrationError alias BorutaIdentity.Accounts.Sessions alias BorutaIdentity.Accounts.User alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.IdentityProvider @type registration_params :: %{ email: String.t(), password: String.t(), metadata: map() } @callback register( backend :: BorutaIdentity.IdentityProviders.Backend.t(), registration_params :: registration_params() ) :: {:ok, user :: User.t()} | {:error, changeset :: Ecto.Changeset.t()} @spec initialize_registration(context :: any(), client_id :: String.t(), module :: atom()) :: callback_result :: any() defwithclientidp initialize_registration(context, client_id, module) do module.registration_initialized(context, new_registration_template(client_idp)) end @spec register( context :: any(), client_id :: String.t(), registration_params :: registration_params(), confirmation_url_fun :: (token :: String.t() -> confirmation_url :: String.t()), module :: atom() ) :: calback_result :: any() defwithclientidp register( context, client_id, registration_params, confirmation_url_fun, module ) do registration_params = case registration_params[:metadata] do %{} = metadata -> Map.put( registration_params, :metadata, User.user_metadata_filter(%User{}, metadata, client_idp.backend) ) nil -> registration_params end with {:ok, user} <- create_user(client_idp, registration_params), :ok <- maybe_deliver_confirmation_email( client_idp.backend, user, confirmation_url_fun, client_idp ), {:ok, user, session_token} <- maybe_create_session(user, client_idp) do module.user_registered(context, user, session_token) else {:error, %Ecto.Changeset{} = changeset} -> module.registration_failure(context, %RegistrationError{ changeset: changeset, message: "Could not create user with given params.", template: new_registration_template(client_idp) }) {:error, reason} -> module.registration_failure(context, %RegistrationError{ message: inspect(reason), template: new_confirmation_instructions_template(client_idp) }) {:user_not_confirmed, user, reason} -> module.registration_failure(context, %RegistrationError{ user: user, message: reason, template: new_confirmation_instructions_template(client_idp) }) end end use BorutaIdentity.PostUserCreationHook @decorate post_user_creation_hook([]) defp create_user(client_idp, registration_params) do client_impl = IdentityProvider.implementation(client_idp) apply(client_impl, :register, [client_idp.backend, registration_params]) end defp maybe_deliver_confirmation_email(_backend, _user, _confirmation_url_fun, %IdentityProvider{ confirmable: false }) do :ok end defp maybe_deliver_confirmation_email(backend, user, confirmation_url_fun, %IdentityProvider{ confirmable: true }) do with {:ok, _confirmation_token} <- Deliveries.deliver_user_confirmation_instructions( backend, user, confirmation_url_fun ) do :ok end end defp maybe_create_session(user, %IdentityProvider{confirmable: true}) do {:user_not_confirmed, user, "Email confirmation is required to authenticate."} end defp maybe_create_session(user, %IdentityProvider{confirmable: false}) do Sessions.create_user_session(user) end defp new_registration_template(identity_provider) do IdentityProviders.get_identity_provider_template!(identity_provider.id, :new_registration) end defp new_confirmation_instructions_template(identity_provider) do IdentityProviders.get_identity_provider_template!( identity_provider.id, :new_confirmation_instructions ) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/reset_passwords.ex ================================================ defmodule BorutaIdentity.Accounts.ResetPasswordError do @enforce_keys [:message] defexception [:message, :changeset, :token, :template] @type t :: %__MODULE__{ message: String.t(), token: String.t(), changeset: Ecto.Changeset.t() | nil, template: template :: BorutaIdentity.IdentityProviders.Template.t() } def exception(message) when is_binary(message) do %__MODULE__{message: message} end def message(exception) do exception.message end end defmodule BorutaIdentity.Accounts.ResetPasswordApplication do @moduledoc """ TODO SessionApplication documentation """ @callback password_instructions_initialized( context :: any(), template :: BorutaIdentity.IdentityProviders.Template.t() ) :: any() @callback reset_password_instructions_delivered(context :: any()) :: any() @callback password_reset_initialized( context :: any(), token :: String.t(), template :: BorutaIdentity.IdentityProviders.Template.t() ) :: any() @callback password_reseted(context :: any(), user :: BorutaIdentity.Accounts.User.t()) :: any() @callback password_reset_failure( context :: any(), error :: BorutaIdentity.Accounts.ResetPasswordError.t() ) :: any() end defmodule BorutaIdentity.Accounts.ResetPasswords do @moduledoc false import BorutaIdentity.Accounts.Utils, only: [defwithclientidp: 2] alias BorutaIdentity.Accounts.Deliveries alias BorutaIdentity.Accounts.ResetPasswordError alias BorutaIdentity.Accounts.User alias BorutaIdentity.Accounts.Users alias BorutaIdentity.Accounts.UserToken alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaIdentity.Repo @type reset_password_url_fun :: (token :: String.t() -> reset_password_url :: String.t()) @type reset_password_instructions_params :: %{ email: String.t() } @type reset_password_params :: %{ reset_password_token: String.t(), password: String.t(), password_confirmation: String.t() } @callback reset_password( backend :: BorutaIdentity.IdentityProviders.Backend.t(), reset_password_params :: reset_password_params() ) :: {:ok, user :: User.t()} | {:error, reason :: String.t() | Ecto.Changeset.t()} @spec initialize_password_instructions( context :: any(), client_id :: String.t(), module :: atom() ) :: callback_result :: any() defwithclientidp initialize_password_instructions(context, client_id, module) do module.password_instructions_initialized( context, new_reset_password_template(client_idp) ) end @spec send_reset_password_instructions( context :: any(), client_id :: String.t(), reset_password_instructions_params :: reset_password_instructions_params(), reset_password_url_fun :: reset_password_url_fun(), module :: atom() ) :: callback_result :: any() defwithclientidp send_reset_password_instructions( context, client_id, reset_password_instructions_params, reset_password_url_fun, module ) do with %User{} = user <- Users.get_user_by_email(client_idp.backend, reset_password_instructions_params[:email]) do send_reset_password_instructions( client_idp.backend, user, reset_password_url_fun ) end # NOTE return a success either reset passowrd instructions email sent or not module.reset_password_instructions_delivered(context) end @spec initialize_password_reset( context :: any(), client_id :: String.t(), token :: String.t(), module :: atom() ) :: callback_result :: any() defwithclientidp initialize_password_reset( context, client_id, token, module ) do case reset_password_changeset(client_idp.backend, token) do {:ok, _changeset} -> module.password_reset_initialized( context, token, edit_reset_password_template(client_idp) ) {:error, reason} -> module.password_reset_failure(context, %ResetPasswordError{ message: reason, token: token, template: edit_reset_password_template(client_idp) }) end end @spec reset_password( context :: any(), client_id :: String.t(), reset_password_params :: reset_password_params(), module :: atom() ) :: callback_result :: any() defwithclientidp reset_password( context, client_id, reset_password_params, module ) do client_impl = IdentityProvider.implementation(client_idp) edit_template = edit_reset_password_template(client_idp) token = reset_password_params.reset_password_token with {:ok, user} <- get_user_by_reset_password_token(token), # TODO wrap password reset and token revocation in a transaction {:ok, _user} <- apply(client_impl, :reset_password, [client_idp.backend, reset_password_params]), {:ok, revoke_query} <- UserToken.revoke_email_token_query(token, "reset_password"), _deleted <- Repo.update_all(revoke_query, set: [revoked_at: DateTime.utc_now()]) do module.password_reseted(context, user) else {:error, %Ecto.Changeset{} = changeset} -> module.password_reset_failure(context, %ResetPasswordError{ token: reset_password_params.reset_password_token, message: "Could not update user password.", changeset: changeset, template: edit_template }) {:error, reason} -> module.password_reset_failure(context, %ResetPasswordError{ template: edit_template, token: reset_password_params.reset_password_token, message: reason }) end end defp send_reset_password_instructions(backend, user, reset_password_url_fun) do {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") with {:ok, _user_token} <- Repo.insert(user_token), {:ok, _user_token} <- Deliveries.deliver_user_reset_password_instructions( backend, user, reset_password_url_fun.(encoded_token) ) do :ok end end defp reset_password_changeset(_backend, token) do with {:ok, user} <- get_user_by_reset_password_token(token) do {:ok, Ecto.Changeset.change(user)} end end defp get_user_by_reset_password_token(token) do with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), %User{} = user <- Repo.one(query) do {:ok, user} else _ -> {:error, "Given reset password token is invalid."} end end defp new_reset_password_template(identity_provider) do IdentityProviders.get_identity_provider_template!(identity_provider.id, :new_reset_password) end defp edit_reset_password_template(identity_provider) do IdentityProviders.get_identity_provider_template!(identity_provider.id, :edit_reset_password) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/schemas/consent.ex ================================================ defmodule BorutaIdentity.Accounts.Consent do @moduledoc false use Ecto.Schema import Ecto.Changeset alias BorutaIdentity.Accounts.Internal.User @type t :: %__MODULE__{ id: String.t(), client_id: String.t(), scopes: list(String.t()), user: Ecto.Association.NotLoaded.t() | User.t(), inserted_at: DateTime.t(), updated_at: DateTime.t() } @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "consents" do field(:client_id, :string) field(:scopes, {:array, :string}, default: []) belongs_to(:user, User) timestamps() end @doc false def changeset(consent, attrs) do consent |> cast(attrs, [:client_id, :scopes]) |> validate_required([:client_id]) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/schemas/role.ex ================================================ defmodule BorutaIdentity.Accounts.Role do @moduledoc false use Ecto.Schema import Ecto.Changeset alias BorutaIdentity.Accounts.RoleScope alias BorutaIdentity.Repo @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "roles" do field :name, :string field :scopes, {:array, :map}, virtual: true has_many :role_scopes, RoleScope, on_replace: :delete timestamps() end @doc false def changeset(role, attrs) do role |> Repo.preload(:role_scopes) |> cast(attrs, [:id, :name]) |> unique_constraint(:id, name: :roles_pkey) |> put_assoc( :role_scopes, (attrs[:scopes] || attrs["scopes"] || []) |> Enum.uniq() |> Enum.map(fn %{id: id} -> %RoleScope{scope_id: id} %{"id" => id} -> %RoleScope{scope_id: id} _ -> nil end) |> Enum.reject(&is_nil/1) ) |> validate_required([:name]) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/schemas/role_scope.ex ================================================ defmodule BorutaIdentity.Accounts.RoleScope do @moduledoc false use Ecto.Schema import Ecto.Changeset @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "roles_scopes" do field :role_id, Ecto.UUID field :scope_id, Ecto.UUID timestamps() end @doc false def changeset(role_scope, attrs) do role_scope |> cast(attrs, [:role_id, :scope_id]) |> validate_required([:role_id, :scope_id]) |> unique_constraint([:role_id, :scope_id], name: "roles_scopes_role_id_scope_id_index", error_key: :scopes, message: "must be unique") end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/schemas/user.ex ================================================ defmodule BorutaIdentity.Accounts.User do @moduledoc false defmodule CoseKey do @moduledoc false @behaviour Ecto.Type def type, do: :binary def cast(bin), do: {:ok, Base.decode64!(bin) |> :erlang.binary_to_term()} def load(bin), do: {:ok, Base.decode64!(bin) |> :erlang.binary_to_term()} def dump(bin), do: {:ok, :erlang.term_to_binary(bin) |> Base.encode64()} def equal?(a, b), do: a == b def embed_as(_a), do: :self end use Ecto.Schema import Ecto.Changeset alias BorutaIdentity.Accounts.Consent alias BorutaIdentity.Accounts.UserAuthorizedScope alias BorutaIdentity.Accounts.UserRole alias BorutaIdentity.Accounts.UserToken alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.Organizations.OrganizationUser alias BorutaIdentity.Repo @type t :: %__MODULE__{ id: String.t() | nil, uid: String.t() | nil, username: String.t() | nil, password: String.t() | nil, metadata: map(), federated_metadata: map(), totp_secret: String.t() | nil, webauthn_challenge: String.t() | nil, confirmed_at: DateTime.t() | nil, authorized_scopes: Ecto.Association.NotLoaded.t() | list(UserAuthorizedScope.t()), consents: Ecto.Association.NotLoaded.t() | list(Consent.t()), backend: Ecto.Association.NotLoaded.t() | Backend.t(), backend_id: String.t() | nil, last_login_at: DateTime.t() | nil, inserted_at: DateTime.t() | nil, updated_at: DateTime.t() | nil } @metadata_schema %{ "type" => "object", "properties" => %{ "value" => %{}, "status" => %{"type" => "string"}, "display" => %{"type" => "array", "items" => %{"type" => "string"}} }, "required" => ["value", "status"] } def account_types, do: [ BorutaIdentity.Accounts.Federated.account_type(), BorutaIdentity.Accounts.Internal.account_type(), BorutaIdentity.Accounts.Ldap.account_type() ] @derive {Inspect, except: [:password]} @primary_key {:id, Ecto.UUID, autogenerate: true} @foreign_key_type Ecto.UUID schema "users" do # TODO add email field field(:username, :string) field(:uid, :string) field(:group, :string) field(:password, :string, virtual: true) field(:confirmed_at, :utc_datetime_usec) field(:last_login_at, :utc_datetime_usec) field(:metadata, :map, default: %{}) field(:federated_metadata, :map, default: %{}) field(:totp_secret, :string) field(:totp_registered_at, :utc_datetime_usec) field(:webauthn_challenge, :string) field(:webauthn_identifier, :string) field(:webauthn_public_key, CoseKey) field(:webauthn_registered_at, :utc_datetime_usec) field(:account_type, :string) has_many(:user_tokens, UserToken) has_many(:authorized_scopes, UserAuthorizedScope) has_many(:roles, UserRole) has_many(:organizations, OrganizationUser) has_many(:consents, Consent, on_replace: :delete) belongs_to(:backend, Backend) timestamps() end def implementation_changeset(attrs, backend) do %__MODULE__{} |> cast(attrs, [ :backend_id, :uid, :username, :group, :metadata, :federated_metadata, :account_type ]) |> metadata_template_filter(backend) |> validate_required([:backend_id, :uid, :username, :account_type]) |> validate_inclusion(:account_type, account_types()) |> validate_metadata() |> validate_group() end def changeset(user, attrs \\ %{}) do user |> cast(attrs, [:metadata, :group]) |> validate_group() |> validate_metadata() end def login_changeset(user) do user |> change(last_login_at: DateTime.utc_now()) |> validate_required([:backend_id]) end def confirm_changeset(user) do now = DateTime.utc_now() change(user, confirmed_at: now) end def unconfirm_changeset(user) do change(user, confirmed_at: nil) end def webauthn_challenge_changeset(user) do change(user, webauthn_challenge: SecureRandom.hex()) end def webauthn_public_key_changeset(user, cose_key, identifier) do change(user, webauthn_public_key: cose_key, webauthn_registered_at: DateTime.utc_now(), webauthn_identifier: identifier ) end def totp_changeset(user, totp_secret) do change(user, totp_secret: totp_secret, totp_registered_at: DateTime.utc_now()) end def consent_changeset(user, attrs) do user |> Repo.preload(:consents) |> cast(attrs, []) |> cast_assoc(:consents, with: &Consent.changeset/2) end def confirmed?(%__MODULE__{confirmed_at: nil}), do: false def confirmed?(%__MODULE__{confirmed_at: _confirmed_at}), do: true @spec metadata_filter(metadata :: map(), backend :: Backend.t()) :: metadata :: map() def metadata_filter(metadata, %Backend{ metadata_fields: metadata_fields }) do Enum.filter(metadata, fn {key, _value} -> attribute_names = Enum.map(metadata_fields, fn %{"attribute_name" => attribute_name} -> attribute_name end) Enum.member?(attribute_names, key) end) |> Enum.into(%{}) end @spec user_metadata_filter(user :: t(), metadata :: map(), backend :: Backend.t()) :: metadata :: map() def user_metadata_filter( %__MODULE__{metadata: user_metadata}, metadata, %Backend{metadata_fields: metadata_fields} = backend ) do metadata = metadata_filter(metadata, backend) metadata_fields |> Enum.map(fn field -> attribute_name = field["attribute_name"] user_editable = field["user_editable"] case Enum.find(metadata, fn {key, _value} -> attribute_name == key end) do {key, _value} = field -> case user_editable do true -> field _ -> {attribute_name, user_metadata[key]} end nil -> {attribute_name, user_metadata[attribute_name]} end end) |> Enum.reject(fn {_key, nil} -> true nil -> true _ -> false end) |> Enum.map(fn {key, value} when is_map(value) -> {key, value} {key, value} -> # TODO default display {key, %{"value" => value, "status" => "valid", "display" => []}} end) |> Enum.into(%{}) end # TODO check metadata schema defp metadata_template_filter( %Ecto.Changeset{changes: %{metadata: %{} = metadata}} = changeset, backend ) when not (map_size(metadata) == 0) do put_change(changeset, :metadata, metadata_filter(metadata, backend)) end defp metadata_template_filter(changeset, _backend), do: changeset defp validate_group(changeset) do case Ecto.Changeset.get_change(changeset, :group) do nil -> changeset group -> groups = String.split(group, " ") case groups == Enum.uniq(groups) do true -> changeset false -> %{ changeset | valid?: false, errors: [{:group, {"must be unique", []}} | changeset.errors] } end end end defp validate_metadata( %Ecto.Changeset{changes: %{metadata: metadata}} = changeset ) do Enum.reduce_while(metadata, changeset, fn {_attribute, value}, changeset -> case ExJsonSchema.Validator.validate(@metadata_schema, value) do :ok -> {:cont, changeset} {:error, errors} -> {:halt, Enum.reduce(errors, changeset, fn {message, path}, changeset -> add_error(changeset, :metadata, "#{message} at #{path}") end)} end end) end defp validate_metadata(changeset), do: changeset end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/schemas/user_authorized_scope.ex ================================================ defmodule BorutaIdentity.Accounts.UserAuthorizedScope do @moduledoc false use Ecto.Schema import Ecto.Changeset alias BorutaIdentity.Accounts.User @type t :: %__MODULE__{ user: Ecto.Association.NotLoaded.t() | User.t(), scope_id: String.t(), inserted_at: DateTime.t(), updated_at: DateTime.t() } @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "users_authorized_scopes" do field(:scope_id, :string) belongs_to(:user, User) timestamps() end @doc false def changeset(scope, attrs) do scope |> cast(attrs, [:scope_id, :user_id]) |> validate_required([:scope_id, :user_id]) |> unique_constraint([:scope_id, :user_id]) |> foreign_key_constraint(:user_id) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/schemas/user_role.ex ================================================ defmodule BorutaIdentity.Accounts.UserRole do @moduledoc false use Ecto.Schema import Ecto.Changeset alias BorutaIdentity.Accounts.Role alias BorutaIdentity.Accounts.User @type t :: %__MODULE__{ id: String.t(), user_id: String.t(), role_id: String.t() } @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "roles_users" do belongs_to(:role, Role) belongs_to(:user, User) timestamps() end @doc false def changeset(user_role, attrs) do user_role |> cast(attrs, [:role_id, :user_id]) |> validate_required([:role_id, :user_id]) |> unique_constraint([:role_id, :user_id], name: "roles_users_role_id_user_id_index", error_key: :users, message: "must be unique") end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/schemas/user_token.ex ================================================ defmodule BorutaIdentity.Accounts.UserToken do @moduledoc false use Ecto.Schema import Ecto.Query alias BorutaIdentity.Accounts.User @hash_algorithm :sha256 @rand_size 32 # It is very important to keep the reset password token expiry short, # since someone with access to the email may take over the account. @reset_password_validity_in_days 1 @confirm_validity_in_days 7 @change_email_validity_in_days 7 @session_validity_in_days 60 @primary_key {:id, Ecto.UUID, autogenerate: true} @foreign_key_type Ecto.UUID schema "users_tokens" do field :token, :binary field :context, :string field :sent_to, :string field :revoked_at, :utc_datetime_usec belongs_to :user, BorutaIdentity.Accounts.User timestamps(updated_at: false) end @doc """ Generates a token that will be stored in a signed place, such as session or cookie. As they are signed, those tokens do not need to be hashed. """ def build_session_token(user) do token = :crypto.strong_rand_bytes(@rand_size) {token, %BorutaIdentity.Accounts.UserToken{token: token, context: "session", user_id: user.id}} end @doc """ Checks if the token is valid and returns its underlying lookup query. The query returns the user found by the token. """ def verify_session_token_query(token) do query = from u in User, join: t in assoc(u, :user_tokens), join: b in assoc(u, :backend), where: t.token == ^token, where: t.context == "session", where: t.inserted_at > ago(@session_validity_in_days, "day"), preload: [backend: b] {:ok, query} end @doc """ Builds a token with a hashed counter part. The non-hashed token is sent to the user email while the hashed part is stored in the database, to avoid reconstruction. The token is valid for a week as long as users don't change their email. """ def build_email_token(user, context) do build_hashed_token(user, context, user.username) end defp build_hashed_token(user, context, sent_to) do token = :crypto.strong_rand_bytes(@rand_size) hashed_token = :crypto.hash(@hash_algorithm, token) {Base.url_encode64(token, padding: false), %BorutaIdentity.Accounts.UserToken{ token: hashed_token, context: context, sent_to: sent_to, user_id: user.id }} end @doc """ Checks if the token is valid and returns its underlying lookup query. The query returns the user found by the token. """ def verify_email_token_query(token, context) do case Base.url_decode64(token, padding: false) do {:ok, decoded_token} -> hashed_token = :crypto.hash(@hash_algorithm, decoded_token) days = days_for_context(context) query = from token in token_and_context_query(hashed_token, context), join: user in assoc(token, :user), where: token.inserted_at > ago(^days, "day") and token.sent_to == user.username and is_nil(token.revoked_at), select: user {:ok, query} :error -> :error end end def revoke_email_token_query(token, context) do case Base.url_decode64(token, padding: false) do {:ok, decoded_token} -> hashed_token = :crypto.hash(@hash_algorithm, decoded_token) {:ok, token_and_context_query(hashed_token, context)} _error -> {:error, "Could not revoke given token."} end end defp days_for_context("confirm"), do: @confirm_validity_in_days defp days_for_context("reset_password"), do: @reset_password_validity_in_days @doc """ Checks if the token is valid and returns its underlying lookup query. The query returns the user token record. """ def verify_confirm_email_token_query(token, context) do case Base.url_decode64(token, padding: false) do {:ok, decoded_token} -> hashed_token = :crypto.hash(@hash_algorithm, decoded_token) query = from token in token_and_context_query(hashed_token, context), where: token.inserted_at > ago(@change_email_validity_in_days, "day") {:ok, query} :error -> :error end end @doc """ Returns the given token with the given context. """ def token_and_context_query(token, context) do from BorutaIdentity.Accounts.UserToken, where: [token: ^token, context: ^context] end @doc """ Gets all tokens for the given user for the given contexts. """ def user_and_contexts_query(user, :all) do from t in BorutaIdentity.Accounts.UserToken, where: t.user_id == ^user.id end def user_and_contexts_query(user, [_ | _] = contexts) do from t in BorutaIdentity.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/sessions.ex ================================================ defmodule BorutaIdentity.Accounts.SessionError do @enforce_keys [:message] defexception [:message, :changeset, :template] @type t :: %__MODULE__{ message: String.t(), changeset: Ecto.Changeset.t() | nil, template: BorutaIdentity.IdentityProviders.Template.t() } def exception(message) when is_binary(message) do %__MODULE__{message: message} end def message(exception) do exception.message end end defmodule BorutaIdentity.Accounts.SessionApplication do @moduledoc """ TODO SessionApplication documentation """ @callback session_initialized( context :: any(), template :: BorutaIdentity.IdentityProviders.Template.t() ) :: any() @callback user_authenticated( context :: any(), user :: BorutaIdentity.Accounts.User.t(), session_token :: String.t() ) :: any() @callback authentication_failure( context :: any(), error :: BorutaIdentity.Accounts.SessionError.t() ) :: any() @callback session_deleted(context :: any()) :: any() end defmodule BorutaIdentity.Accounts.Sessions do @moduledoc false import BorutaIdentity.Accounts.Utils, only: [defwithclientidp: 2] alias BorutaIdentity.Accounts.SessionError alias BorutaIdentity.Accounts.User alias BorutaIdentity.Accounts.UserToken alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaIdentity.Repo @type user_params :: %{ email: String.t() } @type authentication_params :: %{ email: String.t(), password: String.t() } # TODO rename to fetch_user @callback get_user(backend :: Backend.t(), user_params :: user_params()) :: {:ok, impl_user :: any()} | {:error, reason :: String.t()} @callback domain_user!(implementation_user :: any(), backend :: Backend.t()) :: user :: User.t() @callback check_user_against( backend :: Backend.t(), impl_user :: any(), authentication_params :: authentication_params() ) :: {:ok, user :: User.t()} | {:error, reason :: String.t()} # NOTE duplicate of BorutaIdentity.Accounts.Registrations # @callback register( # backend :: BorutaIdentity.IdentityProviders.Backend.t(), # registration_params :: registration_params() # ) :: # {:ok, user :: User.t()} # | {:error, changeset :: Ecto.Changeset.t()} @spec initialize_session( context :: any(), client_id :: String.t(), module :: atom() ) :: callback_result :: any() defwithclientidp initialize_session(context, client_id, module) do module.session_initialized(context, new_session_template(client_idp)) end @spec create_session( context :: any(), client_id :: String.t(), authentication_params :: authentication_params(), module :: atom() ) :: callback_result :: any() defwithclientidp create_session(context, client_id, authentication_params, module) do with {:ok, user} <- get_user(authentication_params, client_idp), {:ok, user} <- maybe_check_password(user, authentication_params, client_idp), :ok <- ensure_user_confirmed(user, client_idp), {:ok, user, session_token} <- create_user_session(user) do module.user_authenticated(context, user, session_token) else {:error, _reason} -> module.authentication_failure(context, %SessionError{ template: new_session_template(client_idp), message: "Invalid email or password." }) {:user_not_confirmed, reason} -> module.authentication_failure(context, %SessionError{ template: new_confirmation_instructions_template(client_idp), message: reason }) end end def get_user(%{email: email}, %IdentityProvider{check_password: false} = client_idp) do client_impl = IdentityProvider.implementation(client_idp) apply(client_impl, :register, [client_idp.backend, %{ email: email, password: SecureRandom.hex(), metadata: %{"check_password" => %{"value" => false, "display" => [], "status" => "valid"}} }]) end def get_user(authentication_params, %IdentityProvider{check_password: true} = client_idp) do client_impl = IdentityProvider.implementation(client_idp) apply(client_impl, :get_user, [client_idp.backend, authentication_params]) end def maybe_check_password(user, _authentication_params, %IdentityProvider{check_password: false}), do: {:ok, user} def maybe_check_password(user, authentication_params, %IdentityProvider{backend: backend, check_password: true} = client_idp) do client_impl = IdentityProvider.implementation(client_idp) with {:ok, user} <- apply(client_impl, :check_user_against, [ backend, user, authentication_params ]) do {:ok, apply(client_impl, :domain_user!, [user, client_idp.backend])} end end @spec delete_session( context :: any(), client_id :: String.t(), session_token :: String.t(), module :: atom() ) :: callback_result :: any() def delete_session(context, _client_id, session_token, module) do case delete_session(session_token) do :ok -> module.session_deleted(context) {:error, "Session not found."} -> module.session_deleted(context) end end @spec create_user_session(user :: User.t()) :: {:ok, user :: User.t(), session_token :: String.t()} | {:error, changeset :: Ecto.Changeset.t()} def create_user_session(%User{} = user) do with {_token, user_token} <- UserToken.build_session_token(user), {:ok, session_token} <- Repo.insert(user_token), {:ok, user} <- User.login_changeset(user) |> Repo.update() do {:ok, user, session_token.token} end end defp ensure_user_confirmed(_user, %IdentityProvider{confirmable: false}), do: :ok defp ensure_user_confirmed(user, %IdentityProvider{confirmable: true}) do case User.confirmed?(user) do true -> :ok false -> {:user_not_confirmed, "Email confirmation is required to authenticate."} end end defp new_session_template(identity_provider) do IdentityProviders.get_identity_provider_template!(identity_provider.id, :new_session) end defp new_confirmation_instructions_template(identity_provider) do IdentityProviders.get_identity_provider_template!( identity_provider.id, :new_confirmation_instructions ) end def delete_session(nil), do: {:error, "Session not found."} def delete_session(session_token) do case Repo.delete_all(UserToken.token_and_context_query(session_token, "session")) do {1, _} -> :ok {_, _} -> {:error, "Session not found."} end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/settings.ex ================================================ defmodule BorutaIdentity.Accounts.SettingsError do @enforce_keys [:message] defexception [:message, :changeset, :template] @type t :: %__MODULE__{ message: String.t(), changeset: Ecto.Changeset.t() | nil, template: BorutaIdentity.IdentityProviders.Template.t() } def exception(message) when is_binary(message) do %__MODULE__{message: message} end def message(exception) do exception.message end end defmodule BorutaIdentity.Accounts.SettingsApplication do @moduledoc false @callback edit_user_initialized( context :: any(), user :: BorutaIdentity.Accounts.User.t(), template :: BorutaIdentity.IdentityProviders.Template.t() ) :: any() @callback user_updated( context :: any(), user :: BorutaIdentity.Accounts.User.t() ) :: any() @callback user_update_failure( context :: any(), error :: BorutaIdentity.Accounts.SettingsError.t() ) :: any() @callback user_destroyed( context :: any(), user :: BorutaIdentity.Accounts.User.t() ) :: any() @callback user_destroy_failure( context :: any(), error :: BorutaIdentity.Accounts.SettingsError.t() ) :: any() end defmodule BorutaIdentity.Accounts.Settings do @moduledoc false import BorutaIdentity.Accounts.Utils, only: [defwithclientidp: 2] alias BorutaIdentity.Accounts.Deliveries alias BorutaIdentity.Accounts.SettingsError alias BorutaIdentity.Accounts.User alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaIdentity.Repo @type user_update_params :: %{ :current_password => String.t(), optional(:email) => String.t(), optional(:password) => String.t(), optional(:metadata) => map() } @type authentication_params :: %{ password: String.t() } @callback update_user( backend :: BorutaIdentity.IdentityProviders.Backend.t(), impl_user :: any(), user_update_params :: user_update_params() ) :: {:ok, user :: User.t()} | {:error, changeset :: Ecto.Changeset.t()} # NOTE emits a compilation warning since callback is already defined in BorutaIdentity.Accounts.Sessions # @callback check_user_against( # backend :: Backend.t(), # user :: User.t(), # authentication_params :: authentication_params(), # identity_provider :: IdentityProvider.t() # ) :: # {:ok, user :: User.t()} | {:error, reason :: String.t()} @callback delete_user(id :: String.t()) :: :ok | {:error, reason :: String.t()} @spec initialize_edit_user( context :: any(), client_id :: String.t(), user :: User.t(), module :: atom() ) :: callback_result :: any() defwithclientidp initialize_edit_user(context, client_id, user, module) do module.edit_user_initialized(context, user, edit_user_template(client_idp)) end @spec update_user( context :: any(), client_id :: String.t(), user :: User.t(), user_update_params :: user_update_params(), confirmation_url_fun :: (token :: String.t() -> confirmation_url :: String.t()), module :: atom() ) :: callback_result :: any() defwithclientidp update_user( context, client_id, user, user_update_params, confirmation_url_fun, module ) do client_impl = IdentityProvider.implementation(client_idp) # TODO remove implementation_user from domain user_update_params = case user_update_params[:metadata] do %{} = metadata -> Map.put( user_update_params, :metadata, User.user_metadata_filter(user, metadata, client_idp.backend) ) nil -> user_update_params end with {:ok, old_user} <- apply(client_impl, :get_user, [client_idp.backend, %{email: user.username}]), {:ok, _user} <- apply(client_impl, :check_user_against, [ client_idp.backend, old_user, %{password: user_update_params[:current_password]} ]), # TODO wrap user update and confirmation email sending in a transaction {:ok, user} <- apply(client_impl, :update_user, [client_idp.backend, old_user, user_update_params]), {:ok, user} <- maybe_unconfirm_user(old_user, user, client_idp), :ok <- maybe_deliver_email_confirmation_instructions( client_idp.backend, old_user, user, confirmation_url_fun, client_idp ) do module.user_updated(context, user) else {:error, %Ecto.Changeset{} = changeset} -> module.user_update_failure(context, %SettingsError{ template: edit_user_template(client_idp), message: "Could not update user with given params.", changeset: changeset }) {:error, reason} -> module.user_update_failure(context, %SettingsError{ template: edit_user_template(client_idp), message: reason }) end end @spec destroy_user( context :: any(), client_id :: String.t(), user :: User.t(), module :: atom() ) :: callback_result :: any() | {:error, reason :: String.t()} | {:error, Ecto.Changeset.t()} defwithclientidp destroy_user(context, client_id, user, module) do client_impl = IdentityProvider.implementation(client_idp) with :ok <- apply(client_impl, :delete_user, [user.uid]), {:ok, user} <- Repo.delete(user) do module.user_destroyed(context, user) else {:error, _error} -> module.user_destroy_failure(context, %SettingsError{ template: edit_user_template(client_idp), message: "User could not be deleted, please contact an administrator.", }) end end defp maybe_unconfirm_user(old_user, user, %IdentityProvider{confirmable: true}) do case email_changed?(old_user, user) do true -> User.unconfirm_changeset(user) |> Repo.update() false -> {:ok, user} end end defp maybe_unconfirm_user(_old_user, user, %IdentityProvider{confirmable: false}) do {:ok, user} end defp maybe_deliver_email_confirmation_instructions( _backend, _old_user, _user, _confirmation_url_fun, %IdentityProvider{confirmable: false} ) do :ok end defp maybe_deliver_email_confirmation_instructions( backend, old_user, user, confirmation_url_fun, %IdentityProvider{confirmable: true} ) do case email_changed?(old_user, user) do true -> with {:ok, _confirmation_token} <- Deliveries.deliver_user_confirmation_instructions( backend, user, confirmation_url_fun ) do :ok end false -> :ok end end defp email_changed?(%{email: email}, %User{username: email}), do: false defp email_changed?(%{email: _email}, %User{username: nil}), do: false defp email_changed?(_user, _user_update_params), do: true defp edit_user_template(identity_provider) do IdentityProviders.get_identity_provider_template!(identity_provider.id, :edit_user) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/users.ex ================================================ defmodule BorutaIdentity.Accounts.Users do @moduledoc false import Ecto.Query alias Boruta.Ecto.Scopes alias Boruta.Oauth.Scope alias BorutaIdentity.Accounts.User alias BorutaIdentity.Accounts.UserAuthorizedScope alias BorutaIdentity.Accounts.UserRole alias BorutaIdentity.Accounts.UserToken alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.IdentityProviders.BackendRole alias BorutaIdentity.Organizations.OrganizationUser alias BorutaIdentity.Repo @spec get_user_by_email(backend :: Backend.t(), email :: String.t()) :: user :: User.t() | nil def get_user_by_email(backend, email) when is_binary(email) do # TODO remove backend_user from domain case apply( Backend.implementation(backend), :get_user, [backend, %{email: email}] ) do {:ok, backend_user} -> apply( Backend.implementation(backend), :domain_user!, [backend_user, backend] ) _ -> nil end end @spec get_user(id :: String.t()) :: user :: User.t() | nil def get_user(id) when is_binary(id) do Repo.one( from(u in User, left_join: as in assoc(u, :authorized_scopes), left_join: b in assoc(u, :backend), preload: [authorized_scopes: as, backend: b], where: u.id == ^id ) ) end def get_user(_), do: nil @doc """ Gets the user with the given signed token. """ @spec get_user_by_session_token(token :: String.t()) :: user :: User.t() | nil def get_user_by_session_token(token) do {:ok, query} = UserToken.verify_session_token_query(token) Repo.one(query) end @spec get_user_scopes(user_id :: String.t()) :: user :: list(UserAuthorizedScope.t()) | nil def get_user_scopes(user_id) do scopes = Scopes.all() Repo.all(from(u in UserAuthorizedScope, where: u.user_id == ^user_id)) |> Enum.map(fn user_scope -> Enum.find(scopes, fn %{id: id} -> id == user_scope.scope_id end) end) |> Enum.flat_map(fn %{id: id, name: name} -> [%Scope{id: id, name: name}] _ -> [] end) end @spec get_user_roles(user_id :: String.t()) :: user :: list(BackendRole.t() | UserRole.t()) | nil def get_user_roles(user_id) do scopes = Scopes.all() (Repo.all( from(ur in UserRole, left_join: r in assoc(ur, :role), left_join: rs in assoc(r, :role_scopes), where: ur.user_id == ^user_id, preload: [role: {r, [role_scopes: rs]}] ) ) ++ Repo.all( from(br in BackendRole, left_join: b in assoc(br, :backend), left_join: r in assoc(br, :role), left_join: u in assoc(b, :users), left_join: rs in assoc(r, :role_scopes), where: u.id == ^user_id, preload: [role: {r, [role_scopes: rs]}] ) )) |> Enum.uniq_by(fn %{role: role} -> role end) |> Enum.map(fn %{role: role} -> %{ role | scopes: role.role_scopes |> Enum.map(fn role_scope -> Enum.find(scopes, fn %{id: id} -> id == role_scope.scope_id end) end) |> Enum.flat_map(fn %{id: id, name: name} -> [%Scope{id: id, name: name}] _ -> [] end) } end) end @spec get_user_organizations(user_id :: String.t()) :: user :: list(OrganizationUser.t()) | nil def get_user_organizations(user_id) do Repo.all( from(ou in OrganizationUser, left_join: o in assoc(ou, :organization), where: ou.user_id == ^user_id, preload: [organization: o] ) ) |> Enum.map(fn %{organization: organization} -> organization end) end @spec put_user_webauthn_challenge(user :: User.t()) :: {:ok, user :: User.t()} | {:error, changset :: Ecto.Changeset.t()} def put_user_webauthn_challenge(user) do user |> User.webauthn_challenge_changeset() |> Repo.update() end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/verifiable_credentials.ex ================================================ defmodule BorutaIdentity.Accounts.VerifiableCredentials do @moduledoc false alias Boruta.Oauth.Client alias Boruta.Oauth.Scope alias BorutaIdentity.Accounts.User alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.Repo @authorization_details [ %{ "type" => "openid_credential", "format" => "jwt_vc_json", "credential_definition" => %{ "type" => [ "VerifiableCredential", "BorutaCredential" ] }, "credential_identifiers" => [ "FederatedAttributes" ] } ] def credentials do Enum.flat_map(@authorization_details, fn detail -> detail["credential_definition"]["type"] end) |> Enum.uniq() end def credentials_supported do Repo.all(Backend) |> Enum.flat_map(fn %Backend{verifiable_credentials: credentials} -> credentials |> Enum.reject(fn credential -> credential["version"] != "11" end) |> Enum.map(fn credential -> %{ "id" => credential["credential_identifier"], "types" => String.split(credential["types"], " "), "display" => [Map.put(credential["display"], "locale", "en-US")], "format" => credential["format"], "claims" => Enum.map(credential["claims"], fn %{"name" => name} -> name end), "cryptographic_binding_methods_supported" => [ "did:example" ] } end) end) end def credential_configurations_supported do Repo.all(Backend) |> Enum.flat_map(fn %Backend{verifiable_credentials: credentials} -> credentials |> Enum.reject(fn credential -> credential["version"] && credential["version"] != "13" end) |> Enum.map(fn %{"format" => "vc+sd-jwt"} = credential -> {credential["credential_identifier"], %{ "format" => credential["format"], "scope" => credential["credential_identifier"], "cryptographic_binding_methods_supported" => [ "did:jwk", "did:key" ], "credential_signing_alg_values_supported" => Client.Crypto.signature_algorithms(), "proof_types_supported" => %{ "jwt" => %{ "proof_signing_alg_values_supported" => [ "ES256", "EdDSA" ] } }, "vct" => credential["credential_identifier"], "display" => [ credential["display"] |> Map.put("locale", "en-US") |> Map.merge( %{"logo" => %{"uri" => credential["display"]["logo"]["url"]}}, fn _k, a, b -> Map.merge(a, b) end ) ], "claims" => Enum.map(credential["claims"], fn claim -> {claim["name"], %{"display" => [%{"name" => claim["label"]}]}} end) |> Enum.into(%{}) }} credential -> {credential["credential_identifier"], %{ "format" => credential["format"], # TODO add scope to backends vc configuration "scope" => credential["credential_identifier"], "cryptographic_binding_methods_supported" => [ "did:jwk", "did:key" ], "credential_signing_alg_values_supported" => Client.Crypto.signature_algorithms(), "credential_definition" => %{ "type" => String.split(credential["types"], " "), "credentialSubject" => Enum.map(credential["claims"], fn claim -> {claim["name"], [%{"name" => claim["label"]}]} end) |> Enum.into(%{}) }, "display" => [Map.put(credential["display"], "locale", "en-US")] }} end) end) |> Enum.into(%{}) end def authorization_details(%User{backend: %Backend{} = backend}, scope) do ((Enum.map(backend.verifiable_credentials, fn credential -> case (credential["scopes"] || []) -- Scope.split(scope) do [] -> case credential["type"] do "11" -> %{ "type" => "openid_credential", "format" => credential["format"], "credential_definition" => %{ "type" => String.split(credential["types"], " ") }, "credential_identifiers" => [credential["credential_identifier"]] } _ -> %{ "type" => "openid_credential", "format" => credential["format"], "credential_configuration_id" => credential["credential_identifier"], "credential_identifiers" => String.split(credential["types"], " ") } end _scopes -> nil end end) |> Enum.reject(&is_nil/1)) ++ default_authorization_details(scope)) |> Enum.uniq() end def authorization_details(_user, scope), do: default_authorization_details(scope) def default_authorization_details(scope) do Enum.map(Backend.default!().verifiable_credentials, fn credential -> case (credential["scopes"] || []) -- Scope.split(scope) do [] -> case credential["type"] do "11" -> %{ "type" => "openid_credential", "format" => credential["format"], "credential_definition" => %{ "type" => String.split(credential["types"], " ") }, "credential_identifiers" => [credential["credential_identifier"]] } _ -> %{ "type" => "openid_credential", "format" => credential["format"], "credential_configuration_id" => credential["credential_identifier"], "credential_identifiers" => String.split(credential["types"], " ") } end _scopes -> nil end end) |> Enum.reject(&is_nil/1) end def public_credential_configuration do backend = Backend.default!() Enum.map(backend.verifiable_credentials, fn credential -> {credential["credential_identifier"], %{ version: credential["version"] || "13", vct: credential["vct"], types: String.split(credential["types"], " "), format: credential["format"], time_to_live: credential["time_to_live"] || 31_536_000, scopes: credential["scopes"], claims: case credential["claims"] do claim when is_binary(claim) -> String.split(claim, " ") claims when is_list(claims) -> claims end }} end) |> Enum.into(%{}) end def credential_configuration(%User{backend: %Backend{} = backend}) do Enum.map(backend.verifiable_credentials, fn credential -> {credential["credential_identifier"], %{ version: credential["version"] || "13", vct: credential["vct"], types: String.split(credential["types"], " "), format: credential["format"], defered: credential["defered"], time_to_live: credential["time_to_live"] || 31_536_000, scopes: credential["scopes"], claims: case credential["claims"] do claim when is_binary(claim) -> String.split(claim, " ") claims when is_list(claims) -> claims end }} end) |> Enum.into(%{}) |> Map.merge(public_credential_configuration()) end def credential_configuration(_user), do: public_credential_configuration() end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts/verifiable_presentations.ex ================================================ defmodule BorutaIdentity.Accounts.VerifiablePresentations do @moduledoc false alias BorutaIdentity.Accounts.User alias BorutaIdentity.IdentityProviders.Backend def presentation_configuration(%User{backend: %Backend{} = backend}) do Enum.map(backend.verifiable_presentations, fn presentation -> {presentation["presentation_identifier"], %{ definition: Jason.decode!(presentation["presentation_definition"]) }} end) |> Enum.into(%{}) |> Map.merge(public_presentation_configuration()) end def presentation_configuration(_user) do public_presentation_configuration() end def public_presentation_configuration do backend = Backend.default!() Enum.map(backend.verifiable_presentations, fn presentation -> {presentation["presentation_identifier"], %{ definition: Jason.decode!(presentation["presentation_definition"]) }} end) |> Enum.into(%{}) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/accounts.ex ================================================ defmodule BorutaIdentity.Accounts.Utils do @moduledoc false alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.IdentityProvider @spec client_identity_provider(client_id :: String.t() | nil) :: {:ok, identity_provider :: IdentityProvider.t()} | {:error, reason :: String.t()} def client_identity_provider(nil), do: {:error, "Client identifier not provided."} def client_identity_provider(client_id) do case IdentityProviders.get_identity_provider_by_client_id(client_id) do %IdentityProvider{} = identity_provider -> {:ok, identity_provider} nil -> {:error, "identity provider not configured for given OAuth client. Please contact your administrator."} end end @doc """ Adds `client_impl` variable in function body context. The function definition must have `context`, `client_id` and `module' as parameters. """ # TODO find a better way to delegate to the given client idp defmacro defwithclientidp(fun, do: block) do fun = Macro.escape(fun, unquote: true) block = Macro.escape(block, unquote: true) quote bind_quoted: [fun: fun, block: block] do {name, params} = Macro.decompose_call(fun) context_param = Enum.find(params, fn {var, _, _} -> var == :context end) || raise "`context` must be part of function parameters" client_id_param = Enum.find(params, fn {var, _, _} -> var == :client_id end) || raise "`client_id` must be part of function parameters" module_param = Enum.find(params, fn {var, _, _} -> var == :module end) || raise "`module` must be part of function parameters" def unquote({name, [line: __ENV__.line], params}) do with {:ok, identity_provider} <- BorutaIdentity.Accounts.Utils.client_identity_provider(unquote(client_id_param)), :ok <- BorutaIdentity.IdentityProviders.IdentityProvider.check_feature( identity_provider, unquote(name) ) do var!(client_idp) = identity_provider unquote(block) else {:error, reason} -> raise BorutaIdentity.Accounts.IdentityProviderError, reason end end end end end defmodule BorutaIdentity.Accounts.IdentityProviderError do @enforce_keys [:message] defexception [:message, plug_status: 400] @type t :: %__MODULE__{ message: String.t() } def exception(message) when is_binary(message) do %__MODULE__{message: message} end def message(exception) do exception.message end end defmodule BorutaIdentity.Accounts do @moduledoc """ The Accounts context. """ alias BorutaIdentity.Accounts.ChooseSessions alias BorutaIdentity.Accounts.Confirmations alias BorutaIdentity.Accounts.Consents alias BorutaIdentity.Accounts.Registrations alias BorutaIdentity.Accounts.ResetPasswords alias BorutaIdentity.Accounts.Sessions alias BorutaIdentity.Accounts.Settings alias BorutaIdentity.Accounts.Users ## Registrations defdelegate initialize_registration(context, client_id, module), to: Registrations defdelegate register(context, client_id, registration_params, confirmation_url_fun, module), to: Registrations ## Sessions defdelegate initialize_session(context, client_id, module), to: Sessions defdelegate create_session(context, client_id, authentication_params, module), to: Sessions defdelegate delete_session(context, client_id, session_token, module), to: Sessions ## Reset passwords defdelegate initialize_password_instructions(context, client_id, module), to: ResetPasswords defdelegate send_reset_password_instructions( context, client_id, reset_password_params, reset_password_url_fun, module ), to: ResetPasswords defdelegate initialize_password_reset(context, client_id, token, module), to: ResetPasswords defdelegate reset_password(context, client_id, reset_password_params, module), to: ResetPasswords ## Confirmation defdelegate initialize_confirmation_instructions(context, client_id, module), to: Confirmations defdelegate send_confirmation_instructions( context, client_id, confirmation_params, confirmation_url_fun, module ), to: Confirmations defdelegate confirm_user(context, client_id, token, module), to: Confirmations ## Consent defdelegate initialize_consent(context, client_id, user, scope, module), to: Consents defdelegate consent(context, client_id, user, params, module), to: Consents ## Choose session defdelegate initialize_choose_session(context, client_id, module), to: ChooseSessions ## User settings defdelegate initialize_edit_user(context, client_id, user, module), to: Settings defdelegate update_user(context, client_id, user, params, confirmation_url_fun, module), to: Settings defdelegate destroy_user(context, client_id, user, module), to: Settings ## Deprecated Database getters defdelegate get_user(id), to: Users defdelegate get_user_by_email(backend, email), to: Users defdelegate get_user_by_session_token(token), to: Users defdelegate get_user_roles(user_id), to: Users defdelegate get_user_scopes(user_id), to: Users defdelegate get_user_organizations(user_id), to: Users defdelegate put_user_webauthn_challenge(user), to: Users end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/admin.ex ================================================ defmodule BorutaIdentity.Admin do @moduledoc """ TODO Admin documentation """ import Ecto.Query alias Boruta.Ecto.Admin alias BorutaIdentity.Accounts.User alias BorutaIdentity.Accounts.UserAuthorizedScope alias BorutaIdentity.Accounts.UserRole alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.Organizations.OrganizationUser alias BorutaIdentity.Repo alias NimbleCSV.RFC4180, as: CSV @type user_params :: %{ optional(:username) => String.t(), optional(:password) => String.t(), optional(:group) => String.t(), optional(:metadata) => map(), optional(:roles) => list(map()), optional(:authorized_scopes) => list(map()), optional(:organizations) => list(map()) } @type raw_user_params :: %{ username: String.t(), hashed_password: String.t() } # NOTE emits a compilation warning since callback is already defined in BorutaIdentity.Accounts.Settings # @callback delete_user(id :: String.t()) :: :ok | {:error, reason :: String.t()} @callback create_user( backend :: Backend.t(), params :: user_params() ) :: {:ok, User.t()} | {:error, changeset :: Ecto.Changeset.t()} @callback create_raw_user( backend :: Backend.t(), params :: user_params() ) :: {:ok, User.t()} | {:error, changeset :: Ecto.Changeset.t()} @spec list_users(params :: map()) :: Scrivener.Page.t() @spec list_users() :: Scrivener.Page.t() def list_users(params \\ %{}) do from(u in User) |> user_preloads() |> Repo.paginate(params) end @spec search_users(query :: String.t(), params :: map()) :: Scrivener.Page.t() @spec search_users(query :: String.t()) :: Scrivener.Page.t() def search_users(query, params \\ %{}) do from(u in User, where: fragment("username % ?", ^query), order_by: fragment("word_similarity(username, ?) DESC", ^query) ) |> user_preloads() |> Repo.paginate(params) end @doc """ Gets a single user. ## Examples iex> get_user(123) %User{} iex> get_user(456) nil """ @spec get_user(id :: Ecto.UUID.t()) :: user :: User.t() | nil def get_user(id) do Repo.one( from(u in User, left_join: as in assoc(u, :authorized_scopes), left_join: r in assoc(u, :roles), left_join: o in assoc(u, :organizations), join: b in assoc(u, :backend), preload: [authorized_scopes: as, roles: r, backend: b, organizations: o], where: u.id == ^id ) ) end use BorutaIdentity.PostUserCreationHook @spec create_user(backend :: Backend.t(), params :: user_params()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} @decorate post_user_creation_hook([]) def create_user(backend, params) do with {:ok, user} <- apply( Backend.implementation(backend), :create_user, [backend, params] ), {:ok, user} <- update_user_authorized_scopes(user, params[:authorized_scopes] || []), {:ok, user} <- update_user_organizations(user, params[:organizations] || []), {:ok, user} <- update_user_roles(user, params[:roles] || []) do {:ok, user} end end @spec create_raw_user(backend :: Backend.t(), params :: raw_user_params()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} @decorate post_user_creation_hook([]) def create_raw_user(backend, params) do # TODO give the ability to provide authorized scopes at user creation apply( Backend.implementation(backend), :create_raw_user, [backend, params] ) end @type import_users_opts :: %{ optional(:username_header) => String.t(), optional(:password_header) => String.t(), optional(:hash_password) => boolean() } @spec import_users(backend :: Backend.t(), csv_path :: String.t(), opts :: import_users_opts()) :: import_result :: map() def import_users(backend, csv_path, opts \\ %{}) do opts = Map.merge( %{ username_header: "username", password_header: "password", hash_password: false }, opts ) headers = File.stream!(csv_path) |> CSV.parse_stream(skip_headers: false) |> Enum.take(1) |> Enum.reduce(%{}, fn headers, _acc -> username_index = Enum.find_index(headers, fn header -> header == opts[:username_header] end) password_index = Enum.find_index(headers, fn header -> header == opts[:password_header] end) metadata_headers = Enum.map(opts[:metadata_headers] || [], fn metadata_header -> [origin, target] = String.split(metadata_header, ">") header_index = Enum.find_index(headers, fn header -> header == origin end) {target, header_index} end) |> Enum.into(%{}) %{ username: {"username", username_index}, password: {"password", password_index}, metadata: metadata_headers } end) File.stream!(csv_path) |> CSV.parse_stream(skip_headers: true) |> Stream.map(fn row -> case opts[:hash_password] do true -> create_params = %{ username: headers[:username] && Enum.at(row, elem(headers[:username], 1)), password: headers[:password] && Enum.at(row, elem(headers[:password], 1)) } |> Map.put(:metadata, Enum.map(headers[:metadata], fn header -> {elem(header, 0), %{ "value" => Enum.at(row, elem(header, 1)), "status" => "valid", "display" => ["status", "origin"], "origin" => "import - #{:os.system_time(:second)}" }} end) |> Enum.into(%{})) create_user(backend, create_params) _ -> create_params = %{ username: headers[:username] && Enum.at(row, elem(headers[:username], 1)), hashed_password: headers[:password] && Enum.at(row, elem(headers[:password], 1)) } create_raw_user(backend, create_params) end end) |> Stream.with_index(1) |> Enum.reduce( %{success_count: 0, error_count: 0, errors: []}, fn {{:ok, _user}, _line}, %{ success_count: success_count, error_count: error_count, errors: errors } -> %{ success_count: success_count + 1, error_count: error_count, errors: errors } {{:error, changeset}, line}, %{ success_count: success_count, error_count: error_count, errors: errors } -> %{ success_count: success_count, error_count: error_count + 1, errors: errors ++ [%{line: line, changeset: changeset}] } end ) |> Enum.into(%{}) end @spec update_user_authorized_scopes(user :: %User{}, scopes :: list(map())) :: {:ok, %User{}} | {:error, Ecto.Changeset.t()} def update_user_authorized_scopes(%User{id: user_id} = user, scopes) do Repo.delete_all(from(s in UserAuthorizedScope, where: s.user_id == ^user_id)) case Enum.reduce(scopes, Ecto.Multi.new(), fn attrs, multi -> changeset = UserAuthorizedScope.changeset( %UserAuthorizedScope{}, %{ "scope_id" => attrs["id"] || attrs[:scope_id], "user_id" => user.id } ) Ecto.Multi.insert(multi, "scope_-#{SecureRandom.uuid()}", changeset) end) |> Repo.transaction() do {:ok, _result} -> {:ok, user |> Repo.reload() |> user_preloads()} {:error, _multi_name, %Ecto.Changeset{} = changeset, _changes} -> {:error, changeset} end end @spec update_user_roles(user :: %User{}, roles :: list(map())) :: {:ok, %User{}} | {:error, Ecto.Changeset.t()} def update_user_roles(%User{id: user_id} = user, roles) do Repo.delete_all(from(s in UserRole, where: s.user_id == ^user_id)) case Enum.reduce(roles, Ecto.Multi.new(), fn attrs, multi -> changeset = UserRole.changeset( %UserRole{}, %{ "role_id" => attrs["id"] || attrs[:role_id], "user_id" => user.id } ) Ecto.Multi.insert(multi, "role_-#{SecureRandom.uuid()}", changeset) end) |> Repo.transaction() do {:ok, _result} -> {:ok, user |> Repo.reload() |> user_preloads()} {:error, _multi_name, %Ecto.Changeset{} = changeset, _changes} -> {:error, changeset} end end @spec update_user_organizations(user :: %User{}, organizations :: list(map())) :: {:ok, %User{}} | {:error, Ecto.Changeset.t()} def update_user_organizations(%User{id: user_id} = user, organizations) do Repo.delete_all(from(o in OrganizationUser, where: o.user_id == ^user_id)) case Enum.reduce(organizations, Ecto.Multi.new(), fn attrs, multi -> changeset = OrganizationUser.changeset( %OrganizationUser{}, %{ "organization_id" => attrs["id"] || attrs[:organization_id], "user_id" => user.id } ) Ecto.Multi.insert(multi, "organization_-#{SecureRandom.uuid()}", changeset) end) |> Repo.transaction() do {:ok, _result} -> {:ok, user |> Repo.reload() |> user_preloads()} {:error, _multi_name, %Ecto.Changeset{} = changeset, _changes} -> {:error, changeset} end end @spec update_user(user :: User.t(), user_params :: user_params()) :: {:ok, user :: User.t()} | {:error, Ecto.Changeset.t()} def update_user(user, user_params) do with {:ok, user} <- user |> User.changeset(user_params) |> Repo.update(), {:ok, user} <- update_user_authorized_scopes( user, user_params[:authorized_scopes] || user.authorized_scopes ), {:ok, user} <- update_user_organizations( user, user_params[:organizations] || user.organizations ) do update_user_roles(user, user_params[:roles] || user.roles) end end @spec delete_user(user_id :: Ecto.UUID.t()) :: {:ok, user :: User.t()} | {:error, atom()} | {:error, Ecto.Changeset.t()} | {:error, reason :: String.t()} def delete_user(user_id) when is_binary(user_id) do case get_user(user_id) do nil -> {:error, :not_found} user -> # TODO delete both provider and domain users in a transaction # TODO manage identity federated users with :ok <- apply(Backend.implementation(user.backend, user.account_type), :delete_user, [user.uid]) do Repo.delete(user) end end end @spec delete_user_authorized_scopes_by_id(scope_id :: String.t()) :: {deleted :: integer(), nil} def delete_user_authorized_scopes_by_id(scope_id) do Repo.delete_all(from(s in UserAuthorizedScope, where: s.scope_id == ^scope_id)) end defp user_preloads(users) when is_list(users) do Repo.preload(users, [:backend, :authorized_scopes, :roles, :organizations]) end defp user_preloads(%User{} = user) do Repo.preload(user, [:backend, :authorized_scopes, :roles, :organizations]) end defp user_preloads(queryable) do preload(queryable, [:backend, :authorized_scopes, :roles, :organizations]) end defdelegate list_organizations, to: BorutaIdentity.Organizations defdelegate list_organizations(params), to: BorutaIdentity.Organizations # defdelegate search_organizations(query), to: BorutaIdentity.Organizations # defdelegate search_organizations(query, params), to: BorutaIdentity.Organizations defdelegate get_organization(organization_id), to: BorutaIdentity.Organizations defdelegate create_organization(organization_params), to: BorutaIdentity.Organizations defdelegate update_organization(organization, organization_params), to: BorutaIdentity.Organizations defdelegate delete_organization(organization_id), to: BorutaIdentity.Organizations # --------- TODO refactor below functions alias BorutaIdentity.Accounts.Role def list_roles do Repo.all( from r in Role, left_join: rs in assoc(r, :role_scopes), preload: [role_scopes: rs] ) |> Enum.map(fn %Role{role_scopes: role_scopes} = role -> scopes = role_scopes |> Enum.map(fn role_scope -> role_scope.scope_id end) |> Admin.get_scopes_by_ids() %{role | scopes: scopes} end) end @doc """ Gets a single role. Raises `Ecto.NoResultsError` if the Role does not exist. ## Examples iex> get_role!(123) %Role{} iex> get_role!(456) ** (Ecto.NoResultsError) """ def get_role!(id) do %Role{role_scopes: role_scopes} = role = Repo.one!( from r in Role, left_join: rs in assoc(r, :role_scopes), where: r.id == ^id, preload: [role_scopes: rs] ) scopes = role_scopes |> Enum.map(fn role_scope -> role_scope.scope_id end) |> Admin.get_scopes_by_ids() %{role | scopes: scopes} end def create_role(attrs \\ %{}) do with {:ok, role} <- %Role{} |> Role.changeset(attrs) |> Repo.insert() do {:ok, get_role!(role.id)} end end def update_role(%Role{} = role, attrs) do with {:ok, role} <- role |> Role.changeset(attrs) |> Repo.update() do {:ok, get_role!(role.id)} end end def delete_role(%Role{} = role) do Repo.delete(role) end def change_role(%Role{} = role, attrs \\ %{}) do Role.changeset(role, attrs) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/application.ex ================================================ defmodule BorutaIdentity.Application do # See https://hexdocs.pm/elixir/Application.html # for more information on OTP Applications @moduledoc false use Application def start(_type, _args) do children = [ BorutaIdentity.Repo, BorutaIdentityWeb.Telemetry, {Phoenix.PubSub, name: BorutaIdentity.PubSub}, BorutaIdentityWeb.Endpoint, {Finch, name: BorutaIdentity.Finch} ] BorutaIdentity.Logger.start() setup_database() opts = [strategy: :one_for_one, name: BorutaIdentity.Supervisor] Supervisor.start_link(children, opts) end # Tell Phoenix to update the endpoint configuration # whenever the application is updated. def config_change(changed, _new, removed) do BorutaIdentityWeb.Endpoint.config_change(changed, removed) :ok end def setup_database do Enum.each([BorutaAuth.Repo, BorutaIdentity.Repo], fn repo -> repo.__adapter__.storage_up(repo.config) end) :ok end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/clients.ex ================================================ defmodule BorutaIdentity.Clients do @moduledoc false alias Boruta.Ecto.Admin alias Boruta.Ecto.Client alias BorutaAuth.KeyPairs alias BorutaAuth.KeyPairs.KeyPair alias BorutaIdentity.IdentityProviders def create_client(client_params) do identity_provider_id = get_in(client_params, ["identity_provider", "id"]) BorutaAuth.Repo.transaction(fn -> with {:ok, client} <- Admin.create_client(client_params), {:ok, client} <- insert_global_key_pair(client, client_params["key_pair_id"]), {:ok, _client_identity_provider} <- IdentityProviders.upsert_client_identity_provider( client.id, identity_provider_id ) do client else {:error, error} -> BorutaAuth.Repo.rollback(error) end end) end def insert_global_key_pair(%Client{} = client, nil), do: {:ok, client} def insert_global_key_pair(%Client{} = client, key_pair_id) do %KeyPair{public_key: public_key, private_key: private_key} = KeyPairs.get_key_pair!(key_pair_id) Admin.regenerate_client_key_pair(client, public_key, private_key) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/configuration/error_template.ex ================================================ defmodule BorutaIdentity.Configuration.ErrorTemplate do @moduledoc false use Ecto.Schema import Ecto.Changeset @type t :: %__MODULE__{ id: String.t() | nil, type: String.t(), default: boolean(), content: String.t(), inserted_at: DateTime.t() | nil, updated_at: DateTime.t() | nil } @template_types [ 400, 401, 403, 404, 500 ] @type template_type :: integer() @default_templates %{ 400 => :code.priv_dir(:boruta_identity) |> Path.join("templates/errors/400.mustache") |> File.read!(), 401 => :code.priv_dir(:boruta_identity) |> Path.join("templates/errors/401.mustache") |> File.read!(), 403 => :code.priv_dir(:boruta_identity) |> Path.join("templates/errors/403.mustache") |> File.read!(), 404 => :code.priv_dir(:boruta_identity) |> Path.join("templates/errors/404.mustache") |> File.read!(), 500 => :code.priv_dir(:boruta_identity) |> Path.join("templates/errors/500.mustache") |> File.read!() } @foreign_key_type :binary_id @primary_key {:id, :binary_id, autogenerate: true} schema "error_templates" do field(:content, :string) field(:type, :string) field(:default, :boolean, virtual: true, default: false) timestamps() end def template_types, do: @template_types @spec default_content(type :: template_type()) :: template_content :: String.t() def default_content(type) when type in @template_types, do: @default_templates[type] @spec default_template(type :: template_type()) :: %__MODULE__{} | nil def default_template(type) when type in @template_types do %__MODULE__{ default: true, type: Integer.to_string(type), content: default_content(type) } end def default_template(_type), do: nil @doc false def changeset(template, attrs) do template |> cast(attrs, [:type, :content]) |> validate_inclusion(:type, Enum.map(@template_types, &Integer.to_string/1)) |> validate_required([:type, :content]) |> put_default() end defp put_default(changeset) do case fetch_change(changeset, :content) do {:ok, content} when not is_nil(content) -> changeset _ -> change( changeset, content: default_template(changeset |> fetch_field!(:type) |> String.to_integer()) ) end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/configuration.ex ================================================ defmodule BorutaIdentity.Configuration do @moduledoc false import Ecto.Query, only: [from: 2] alias BorutaIdentity.Configuration.ErrorTemplate alias BorutaIdentity.Repo def get_error_template!(type) do case Repo.get_by(ErrorTemplate, type: to_string(type)) do nil -> ErrorTemplate.default_template(type) template -> template end || raise Ecto.NoResultsError, queryable: ErrorTemplate end def upsert_error_template(%ErrorTemplate{id: template_id} = template, attrs) do changeset = ErrorTemplate.changeset(template, attrs) case template_id do nil -> Repo.insert(changeset) _ -> Repo.update(changeset) end end def delete_error_template!(type) do template_type = to_string(type) with {1, _results} <- Repo.delete_all( from(t in ErrorTemplate, where: t.type == ^template_type ) ), %ErrorTemplate{} = template <- get_error_template!(type) do template else {0, nil} -> raise Ecto.NoResultsError, queryable: ErrorTemplate end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/federated_accounts.ex ================================================ defmodule BorutaIdentity.Accounts.FederatedSessionApplication do @moduledoc """ TODO FederatedSessionApplication documentation """ @callback user_authenticated( context :: any(), user :: BorutaIdentity.Accounts.User.t(), session_token :: String.t() ) :: any() @callback authentication_failure( context :: any(), error :: BorutaIdentity.Accounts.SessionError.t() ) :: any() end defmodule BorutaIdentity.FederatedAccounts do @moduledoc false import BorutaIdentity.Accounts.Utils, only: [defwithclientidp: 2] require Logger alias BorutaIdentity.Accounts.Federated alias BorutaIdentity.Accounts.IdentityProviderError alias BorutaIdentity.Accounts.SessionError alias BorutaIdentity.Accounts.Sessions alias BorutaIdentity.Accounts.User alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.Backend @callback domain_user!( federated_server_name :: String.t(), access_token :: String.t(), backend :: Backend.t() ) :: user :: User.t() @spec create_federated_session( context :: any(), client_id :: String.t(), federated_server_name :: any(), code :: String.t(), module :: atom() ) :: callback_result :: any() defwithclientidp create_federated_session( context, client_id, federated_server_name, code, module ) do try do case Backend.federated_oauth_client(client_idp.backend, federated_server_name) do nil -> raise IdentityProviderError, "Could not fetch associated federated server." oauth_client -> %OAuth2.Client{token: token} = OAuth2.Client.get_token!(oauth_client, code: code) with %User{} = user <- Federated.domain_user!(federated_server_name, token.access_token, client_idp.backend), {:ok, user, session_token} <- Sessions.create_user_session(user) do module.user_authenticated(context, user, session_token) end end rescue error in OAuth2.Error -> module.authentication_failure(context, %SessionError{ message: error.reason, template: new_session_template(client_idp) }) error in IdentityProviderError -> module.authentication_failure(context, %SessionError{ message: error.message, template: new_session_template(client_idp) }) error -> Logger.error("Federation failed " <> inspect(error)) module.authentication_failure(context, %SessionError{ message: "Could not fetch user information.", template: new_session_template(client_idp) }) end end defp new_session_template(identity_provider) do IdentityProviders.get_identity_provider_template!(identity_provider.id, :new_session) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/hooks/post_user_creation_hook.ex ================================================ defmodule BorutaIdentity.PostUserCreationHook do @moduledoc false use Decorator.Define, post_user_creation_hook: 1 alias BorutaIdentity.Accounts.User alias BorutaIdentity.Admin alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.Organizations def post_user_creation_hook(_options, body, _context) do quote do with {:ok, user} = result <- unquote(body), {:ok, user} <- BorutaIdentity.PostUserCreationHook.maybe_create_organization(user) do {:ok, user} end end end @spec maybe_create_organization(user :: User.t()) :: {:ok, user :: User.t()} | {:error, changeset :: Ecto.Changeset.t()} def maybe_create_organization( %User{backend: %Backend{create_default_organization: true}} = user ) do with {:ok, organization} <- Organizations.create_organization(%{ name: "default_#{user.uid}", label: "Default" }) do organizations = [organization | user.organizations] |> Enum.map(fn %{id: id} -> %{"id" => id} end) Admin.update_user_organizations(user, organizations) end end def maybe_create_organization(user), do: {:ok, user} end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/identity_providers/backend.ex ================================================ defmodule BorutaIdentity.IdentityProviders.Backend do @moduledoc false defmodule AuthCodeStrategy do @moduledoc false use OAuth2.Strategy @impl true def authorize_url(client, params) do client |> put_param(:response_type, "code") |> put_param(:client_id, client.client_id) |> put_param(:redirect_uri, client.redirect_uri) |> merge_params(params) end @impl true def get_token(client, params, headers) do {code, params} = Keyword.pop(params, :code, client.params["code"]) unless code do raise OAuth2.Error, reason: "Missing required key `code` for `#{inspect(__MODULE__)}`" end client |> put_param(:code, code) |> put_param(:grant_type, "authorization_code") |> put_param(:client_id, client.client_id) |> put_param(:client_secret, client.client_secret) |> put_param(:redirect_uri, client.redirect_uri) |> merge_params(params) |> basic_auth() |> put_headers(headers) end end use Ecto.Schema use Nebulex.Caching import Ecto.Changeset alias BorutaIdentity.Accounts.EmailTemplate alias BorutaIdentity.Accounts.Federated alias BorutaIdentity.Accounts.Internal alias BorutaIdentity.Accounts.Ldap alias BorutaIdentity.Accounts.User alias BorutaIdentity.Repo alias BorutaIdentityWeb.Router.Helpers, as: Routes @type t :: %__MODULE__{ type: String.t(), name: String.t(), is_default: boolean(), metadata_fields: map(), password_hashing_alg: String.t(), password_hashing_opts: map(), email_templates: Ecto.Association.NotLoaded.t() | list(EmailTemplate.t()), create_default_organization: boolean(), smtp_from: String.t() | nil, smtp_relay: String.t() | nil, smtp_username: String.t() | nil, smtp_password: String.t() | nil, smtp_tls: String.t() | nil, smtp_port: integer() | nil, ldap_pool_size: integer() | nil, ldap_host: String.t() | nil, ldap_user_rdn_attribute: String.t() | nil, ldap_base_dn: String.t() | nil, ldap_ou: String.t() | nil, ldap_master_dn: String.t() | nil, ldap_master_password: String.t() | nil, inserted_at: DateTime.t() | nil, updated_at: DateTime.t() | nil } @backend_types [Internal, Ldap] def account_implementations do Enum.map([Internal, Federated, Ldap], fn module -> {apply(module, :account_type, []), module} end) |> Enum.into(%{}) end @smtp_tls_types [ :always, :never, :if_available ] @password_hashing_modules %{ "argon2" => Argon2, "bcrypt" => Bcrypt, "pbkdf2" => Pbkdf2 } @password_hashing_opts_schema %{ "argon2" => %{ "type" => "object", "properties" => %{ "salt_len" => %{"type" => "number"}, "t_cost" => %{"type" => "number"}, "m_cost" => %{"type" => "number"}, "parallelism" => %{"type" => "number"}, "format" => %{"type" => "string", "pattern" => "^(encoded|raw_hash|report)$"}, "hashlen" => %{"type" => "number", "minimum" => 1, "maximum" => 128}, "argon2_type" => %{"type" => "number", "minimum" => 0, "maximum" => 2} }, "additionalProperties" => false }, "bcrypt" => %{ "type" => "object", "properties" => %{ "log_rounds" => %{"type" => "number"}, "legacy" => %{"type" => "boolean"} }, "additionalProperties" => false }, "pbkdf2" => %{ "type" => "object", "properties" => %{ "salt_len" => %{"type" => "number"}, "format" => %{"type" => "string", "pattern" => "^(modular|django|hex)$"}, "digest" => %{"type" => "string", "pattern" => "^(sha224|sha256|sha384|sha512)$"}, "length" => %{"type" => "number", "minimum" => 1, "maximum" => 64} }, "additionalProperties" => false } } @federated_server_schema ExJsonSchema.Schema.resolve(%{ "type" => "object", "properties" => %{ "name" => %{"type" => "string", "pattern" => "^[^\s]+$"}, "client_id" => %{"type" => "string"}, "client_secret" => %{"type" => "string"}, "base_url" => %{"type" => "string"}, "discovery_path" => %{"type" => "string"}, "userinfo_path" => %{"type" => "string"}, "authorize_path" => %{"type" => "string"}, "token_path" => %{"type" => "string"}, "scope" => %{"type" => "string"}, "federated_attributes" => %{"type" => "string"}, "metadata_endpoints" => %{ "type" => "array", "items" => %{ "type" => "object", "properties" => %{ "endpoint" => %{"type" => "string"}, "claims" => %{"type" => "string"} }, "required" => ["endpoint", "claims"] } } }, "required" => [ "name", "client_id", "client_secret", "base_url" ], "additionalProperties" => false }) @metadata_fields_schema ExJsonSchema.Schema.resolve(%{ "type" => "array", "items" => %{ "type" => "object", "properties" => %{ "attribute_name" => %{"type" => "string"}, "user_editable" => %{"type" => "boolean"}, "scopes" => %{"type" => "array", "items" => %{"type" => "string"}} }, "required" => ["attribute_name"], "additionalProperties" => false } }) @verifiable_credential_schema ExJsonSchema.Schema.resolve(%{ "type" => "object", "properties" => %{ "version" => %{"type" => "string"}, "credential_identifier" => %{"type" => "string"}, "vct" => %{"type" => "string"}, "time_to_live" => %{"type" => "number"}, "types" => %{"type" => "string"}, "format" => %{"type" => "string", "pattern" => "jwt_vc|jwt_vc_json|vc\\+sd\\-jwt"}, "defered" => %{"type" => "boolean"}, "claims" => %{ "type" => "array", "items" => %{ "type" => "object", "properties" => %{ "type" => %{"type" => "string"}, "name" => %{"type" => "string"}, "label" => %{"type" => "string"}, "pointer" => %{"type" => "string"}, # TODO check claims schema "claims" => %{"type" => "array"}, # TODO check items schema "items" => %{"type" => "array"} }, "required" => ["name"], "additionalProperties" => false } }, "display" => %{ "type" => "object", "properties" => %{ "name" => %{"type" => "string"}, "locale" => %{"type" => "string"}, "background_color" => %{"type" => "string"}, "text_color" => %{"type" => "string"}, "logo" => %{ "type" => "object", "properties" => %{ "url" => %{"type" => "string"}, "alt_text" => %{"type" => "string"} } } }, "required" => ["name", "logo"], "additionalProperties" => false }, "scopes" => %{"type" => "array", "items" => %{"type" => "string"}} }, "required" => [ "version", "credential_identifier", "format", "types", "claims", "display" ], "additionalProperties" => false }) @verifiable_presentation_schema ExJsonSchema.Schema.resolve(%{ "type" => "object", "properties" => %{ "presentation_identifier" => %{"type" => "string"}, "presentation_definition" => %{"type" => "string"} }, "required" => [ "presentation_identifier", "presentation_definition" ], "additionalProperties" => false }) @spec backend_types() :: list(atom) def backend_types, do: @backend_types @primary_key {:id, Ecto.UUID, autogenerate: true} schema "backends" do field(:type, :string) field(:is_default, :boolean, default: false) field(:name, :string) field(:metadata_fields, {:array, :map}, default: []) field(:create_default_organization, :boolean, default: false) # smtp config field(:smtp_from, :string) field(:smtp_relay, :string) field(:smtp_username, :string) field(:smtp_password, :string) field(:smtp_ssl, :boolean) field(:smtp_tls, :string) field(:smtp_port, :integer) # ldap config field(:ldap_pool_size, :integer, default: 5) field(:ldap_host, :string) field(:ldap_user_rdn_attribute, :string) field(:ldap_base_dn, :string) field(:ldap_ou, :string) field(:ldap_master_dn, :string) field(:ldap_master_password, :string) # internal config field(:password_hashing_alg, :string, default: "argon2") field(:password_hashing_opts, :map, default: %{}) # identity federation field(:federated_servers, {:array, :map}, default: []) # verifiable credentials field(:verifiable_credentials, {:array, :map}, default: []) # verifiable presentations field(:verifiable_presentations, {:array, :map}, default: []) has_many(:email_templates, EmailTemplate) has_many(:users, User) timestamps() end @spec default!() :: t() @decorate cacheable(key: {__MODULE__, :default}, cache: Boruta.Cache) def default! do Repo.get_by!(__MODULE__, is_default: true) end @spec implementation(t()) :: atom() @spec implementation(t(), account_type :: String.t()) :: atom() def implementation(%__MODULE__{type: type}, account_type \\ nil) do case account_type do nil -> String.to_atom(type) account_type -> account_implementations()[account_type] end end @spec features(backend :: t()) :: list(atom()) def features(backend) do apply(implementation(backend), :features, []) end @spec password_hashing_module(t()) :: atom() def password_hashing_module(%__MODULE__{password_hashing_alg: password_hashing_alg}) do @password_hashing_modules[password_hashing_alg] end @spec password_hashing_opts(t()) :: Keyword.t() def password_hashing_opts(%__MODULE__{password_hashing_opts: password_hashing_opts}) do Enum.map(password_hashing_opts, fn {key, value} when is_binary(value) -> {String.to_atom(key), String.to_atom(value)} {key, value} -> {String.to_atom(key), value} end) |> Enum.into([]) end @spec email_template(backend :: t(), type :: atom()) :: EmailTemplate.t() | nil def email_template(%__MODULE__{email_templates: email_templates} = backend, type) when is_list(email_templates) do case Enum.find(email_templates, fn %EmailTemplate{type: template_type} -> Atom.to_string(type) == template_type end) do nil -> template = EmailTemplate.default_template(type) template && %{ template | backend_id: backend.id, backend: backend } template -> %{template | backend: backend} end end @spec federated_login_url(backend :: t(), federated_server_name :: String.t()) :: login_url :: String.t() def federated_login_url(%__MODULE__{} = backend, federated_server_name) do case federated_oauth_client(backend, federated_server_name) do nil -> "" client -> OAuth2.Client.authorize_url!(client, scope: Enum.join( [federated_server_scope(backend, federated_server_name)], " " ) ) end end @spec federated_oauth_client(backend :: t(), federated_server_name :: String.t()) :: oauth_client :: OAuth2.Client.t() | nil def federated_oauth_client( %__MODULE__{federated_servers: federated_servers} = backend, federated_server_name ) do case Enum.find(federated_servers, fn federated_server -> federated_server["name"] == federated_server_name end) do nil -> nil federated_server -> base_url = URI.parse(federated_server["base_url"]) endpoints = case federated_server["discovery_path"] do nil -> %{ authorize_url: URI.to_string(%{ base_url | path: federated_server["authorize_path"] }), token_url: URI.to_string(%{ base_url | path: federated_server["token_path"] }) } discovery_path -> discover_federated_server_urls(backend, federated_server, discovery_path) end client = OAuth2.Client.new( strategy: AuthCodeStrategy, token_method: :post, client_id: federated_server["client_id"], client_secret: federated_server["client_secret"], site: base_url, request_opts: [state: "boruta"], authorize_url: endpoints[:authorize_url] || "", token_url: endpoints[:token_url] || "", redirect_uri: federated_redirect_url(backend, federated_server_name) ) OAuth2.Client.put_serializer(client, "application/json", Jason) end end @spec federated_server_scope(backend :: t(), federated_server_name :: String.t()) :: server_scope :: String.t() def federated_server_scope( %__MODULE__{federated_servers: federated_servers}, federated_server_name ) do case Enum.find(federated_servers, fn federated_server -> federated_server["name"] == federated_server_name end) do nil -> "" federated_server -> federated_server["scope"] || "" end end defp discover_federated_server_urls( %__MODULE__{federated_servers: federated_servers} = backend, federated_server, discovery_path ) do base_url = URI.parse(federated_server["base_url"]) case Finch.build( :get, URI.to_string(%{base_url | path: discovery_path}), [] ) |> Finch.request(BorutaIdentity.Finch) do {:ok, %Finch.Response{status: 200, body: body}} -> discovery = Jason.decode!(body) authorize_url = discovery["authorization_endpoint"] token_url = discovery["token_endpoint"] userinfo_url = discovery["userinfo_endpoint"] change(backend, %{ federated_servers: Enum.map(federated_servers, fn %{"name" => name} = current_federated_server -> case name == federated_server["name"] do true -> current_federated_server |> Map.put("authorize_path", authorize_url) |> Map.put("token_path", token_url) |> Map.put("userinfo_path", userinfo_url) false -> current_federated_server end end) }) |> Repo.update() %{ authorize_url: discovery["authorization_endpoint"], token_url: discovery["token_endpoint"], userinfo_url: discovery["userinfo_endpoint"] } _error -> %{} end end @spec federated_redirect_url(backend :: t(), federated_server_name :: String.t()) :: redirect_uri :: String.t() def federated_redirect_url(%__MODULE__{id: backend_id}, federated_server_name) do base_url = URI.parse(BorutaIdentityWeb.Endpoint.url()) base_url = case base_url.port do 80 -> %{base_url | port: nil} _ -> base_url end URI.to_string(%{ base_url | path: Routes.backends_path( BorutaIdentityWeb.Endpoint, :callback, backend_id, federated_server_name ) }) end @doc false def changeset(backend, attrs) do backend |> cast(attrs, [ :id, :type, :name, :is_default, :create_default_organization, :metadata_fields, :password_hashing_alg, :password_hashing_opts, :ldap_pool_size, :ldap_host, :ldap_user_rdn_attribute, :ldap_base_dn, :ldap_ou, :ldap_master_dn, :ldap_master_password, :smtp_from, :smtp_relay, :smtp_username, :smtp_password, :smtp_ssl, :smtp_tls, :smtp_port, :federated_servers, :verifiable_credentials, :verifiable_presentations ]) |> unique_constraint(:id, name: :backends_pkey) |> validate_required([:name, :password_hashing_alg]) |> validate_metadata_fields() |> validate_federated_servers() |> validate_verifiable_credentials() |> validate_verifiable_presentations() |> validate_inclusion(:type, Enum.map(@backend_types, &Atom.to_string/1)) |> validate_inclusion(:smtp_tls, Enum.map(@smtp_tls_types, &Atom.to_string/1)) |> foreign_key_constraint(:identity_provider, name: :identity_providers_backend_id_fkey) |> set_default() |> validate_backend_by_type() end @doc false def delete_changeset(%__MODULE__{id: backend_id} = backend) do case default!().id == backend_id do true -> change(backend) |> add_error(:is_default, "Deleting a default backend is prohibited.") false -> change(backend) end |> foreign_key_constraint(:identity_provider, name: :identity_providers_backend_id_fkey, message: "This backend is linked to an identity provider. Please unlink it before continue." ) |> foreign_key_constraint(:user, name: :users_backend_id_fkey, message: "This backend has existing users. Please delete them before continue" ) end defp validate_metadata_fields( %Ecto.Changeset{changes: %{metadata_fields: metadata_fields}} = changeset ) do case ExJsonSchema.Validator.validate(@metadata_fields_schema, metadata_fields) do :ok -> changeset {:error, errors} -> Enum.reduce(errors, changeset, fn {message, path}, changeset -> add_error(changeset, :metadata_fields, "#{message} at #{path}") end) end end defp validate_metadata_fields(changeset), do: changeset defp validate_federated_servers( %Ecto.Changeset{changes: %{federated_servers: federated_servers}} = changeset ) do Enum.reduce(federated_servers, changeset, fn federated_server, changeset -> case ExJsonSchema.Validator.validate(@federated_server_schema, federated_server) do :ok -> changeset {:error, errors} -> Enum.reduce(errors, changeset, fn {message, path}, changeset -> add_error(changeset, :federated_servers, "#{message} at #{path}") end) end end) end defp validate_federated_servers(changeset), do: changeset defp validate_verifiable_credentials( %Ecto.Changeset{changes: %{verifiable_credentials: verifiable_credentials}} = changeset ) do Enum.reduce(verifiable_credentials, changeset, fn verifiable_credential, changeset -> case ExJsonSchema.Validator.validate(@verifiable_credential_schema, verifiable_credential) do :ok -> changeset {:error, errors} -> Enum.reduce(errors, changeset, fn {message, path}, changeset -> add_error(changeset, :verifiable_credentials, "#{message} at #{path}") end) end end) end defp validate_verifiable_credentials(changeset), do: changeset defp validate_verifiable_presentations( %Ecto.Changeset{changes: %{verifiable_presentations: verifiable_presentations}} = changeset ) do Enum.reduce(verifiable_presentations, changeset, fn verifiable_presentation, changeset -> case ExJsonSchema.Validator.validate(@verifiable_presentation_schema, verifiable_presentation) do :ok -> case Jason.decode(verifiable_presentation["presentation_definition"]) do {:ok, _map} -> changeset {:error, _error} -> add_error(changeset, :verifiable_presentations, "Verifiable presentation definition must be a valid JSON object") end {:error, errors} -> Enum.reduce(errors, changeset, fn {message, path}, changeset -> add_error(changeset, :verifiable_presentations, "#{message} at #{path}") end) end end) end defp validate_verifiable_presentations(changeset), do: changeset defp set_default(%Ecto.Changeset{changes: %{is_default: false}} = changeset) do Ecto.Changeset.add_error( changeset, :is_default, "There must be at least one default backend." ) end defp set_default(%Ecto.Changeset{changes: %{is_default: _is_default}} = changeset) do # TODO use a transaction to change default backend :ok = Boruta.Cache.delete({__MODULE__, :default}) case Ecto.Changeset.change(default!(), %{is_default: false}) |> Repo.update() do {:ok, _backend} -> changeset {:error, changeset} -> Ecto.Changeset.add_error( changeset, :is_default, "Cannot remove value from the existing default backend." ) end rescue Ecto.NoResultsError -> changeset end defp set_default(changeset), do: changeset defp validate_backend_by_type(changeset) do type = get_field(changeset, :type) validate_backend(changeset, String.to_atom(type)) end defp validate_backend(changeset, Internal) do changeset |> validate_inclusion(:password_hashing_alg, Map.keys(@password_hashing_modules)) |> validate_password_hashing_opts() end defp validate_backend(changeset, Ldap) do changeset |> validate_required([ :ldap_pool_size, :ldap_host, :ldap_user_rdn_attribute, :ldap_base_dn ]) |> validate_inclusion(:ldap_pool_size, 1..50) end defp validate_backend(changeset, _implementation), do: changeset defp validate_password_hashing_opts(changeset) do alg = fetch_field!(changeset, :password_hashing_alg) opts = fetch_field!(changeset, :password_hashing_opts) case ExJsonSchema.Validator.validate(@password_hashing_opts_schema[alg], opts) do :ok -> changeset {:error, errors} -> Enum.reduce(errors, changeset, fn {message, path}, changeset -> add_error(changeset, :password_hashing_opts, "#{message} at #{path}") end) end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/identity_providers/backend_role.ex ================================================ defmodule BorutaIdentity.IdentityProviders.BackendRole do @moduledoc false use Ecto.Schema import Ecto.Changeset alias BorutaIdentity.Accounts.Role alias BorutaIdentity.IdentityProviders.Backend @type t :: %__MODULE__{ id: String.t(), backend_id: String.t(), role_id: String.t() } @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "backends_roles" do belongs_to :role, Role belongs_to :backend, Backend timestamps() end @doc false def changeset(role_scope, attrs) do role_scope |> cast(attrs, [:role_id, :backend_id]) |> validate_required([:role_id, :backend_id]) |> unique_constraint([:role_id, :backend_id]) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/identity_providers/client_identity_provider.ex ================================================ defmodule BorutaIdentity.IdentityProviders.ClientIdentityProvider do @moduledoc false use Ecto.Schema import Ecto.Changeset alias BorutaIdentity.IdentityProviders.IdentityProvider @type t :: %__MODULE__{ client_id: String.t(), identity_provider: IdentityProvider.t() | Ecto.Association.NotLoaded.t(), inserted_at: DateTime.t(), updated_at: DateTime.t() } @foreign_key_type :binary_id @primary_key {:id, :binary_id, autogenerate: true} schema "clients_identity_providers" do field(:client_id, :binary_id) belongs_to(:identity_provider, IdentityProvider) timestamps() end @doc false def changeset(client_identity_provider, attrs) do client_identity_provider |> cast(attrs, [:client_id, :identity_provider_id]) |> validate_required([:client_id, :identity_provider_id]) |> validate_format( :identity_provider_id, ~r/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/ ) |> validate_format( :client_id, ~r/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/ ) |> foreign_key_constraint(:identity_provider_id) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/identity_providers/identity_provider.ex ================================================ defmodule BorutaIdentity.IdentityProviders.IdentityProvider do @moduledoc false use Ecto.Schema import Ecto.Changeset alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.IdentityProviders.ClientIdentityProvider alias BorutaIdentity.IdentityProviders.Template alias BorutaIdentity.Repo @type t :: %__MODULE__{ name: String.t(), backend_id: String.t(), backend: Backend.t(), registrable: boolean(), totpable: boolean(), enforce_totp: boolean(), confirmable: boolean(), authenticable: boolean(), check_password: boolean(), reset_password: boolean(), client_identity_providers: list(ClientIdentityProvider.t()) | Ecto.Association.NotLoaded.t(), inserted_at: DateTime.t(), updated_at: DateTime.t() } @features %{ authenticable: [ # BorutaIdentity.Accounts.Sessions :initialize_session, # BorutaIdentity.Accounts.FederatedSessions :create_federated_session, # BorutaIdentity.Totp :initialize_totp, # BorutaIdentity.Accounts.Sessions :create_session, # BorutaIdentity.Accounts.Sessions :delete_session, # BorutaIdentity.Accounts.Consents :initialize_consent, # BorutaIdentity.Accounts.ChooseSessions :initialize_choose_session ], totpable: [ # BorutaIdentity.Totp :initialize_totp_registration, # BorutaIdentity.Totp :register_totp, # BorutaIdentity.Totp :initialize_totp, # BorutaIdentity.Totp :authenticate_totp ], webauthnable: [ # BorutaIdentity.Totp :initialize_webauthn_registration, # BorutaIdentity.Webauthn :register_webauthn, # BorutaIdentity.Webauthn :initialize_webauthn, # BorutaIdentity.Webauthn :authenticate_webauthn ], registrable: [ # BorutaIdentity.Accounts.Registrations :initialize_registration, # BorutaIdentity.Accounts.Registrations :register ], user_editable: [ # BorutaIdentity.Accounts.Settings :initialize_edit_user, # BorutaIdentity.Accounts.Settings :update_user ], confirmable: [ # BorutaIdentity.Accounts.Confirmations :initialize_confirmation_instructions, # BorutaIdentity.Accounts.Confirmations :send_confirmation_instructions, # BorutaIdentity.Accounts.Confirmations :confirm_user ], reset_password: [ # BorutaIdentity.Accounts.ResetPasswords :initialize_password_instructions, # BorutaIdentity.Accounts.ResetPasswords :send_reset_password_instructions, # BorutaIdentity.Accounts.ResetPasswords :initialize_password_reset, # BorutaIdentity.Accounts.ResetPasswords :reset_password ], consentable: [ # BorutaIdentity.Accounts.Consents :consent ], destroyable: [ # BorutaIdentity.Accounts.Settings :destroy_user ] } @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "identity_providers" do field(:name, :string) field(:choose_session, :boolean, default: true) field(:totpable, :boolean, default: false) field(:enforce_totp, :boolean, default: false) field(:webauthnable, :boolean, default: false) field(:enforce_webauthn, :boolean, default: false) field(:registrable, :boolean, default: false) field(:user_editable, :boolean, default: false) field(:confirmable, :boolean, default: false) field(:consentable, :boolean, default: false) field(:authenticable, :boolean, default: true, virtual: true) field(:destroyable, :boolean, default: true, virtual: true) field(:check_password, :boolean, default: true) field(:reset_password, :boolean, default: true, virtual: true) has_many(:client_identity_providers, ClientIdentityProvider) has_many(:templates, Template, on_replace: :delete_if_exists) belongs_to(:backend, Backend) timestamps() end @spec template(identity_provider :: t(), type :: atom()) :: Template.t() | nil def template(%__MODULE__{templates: templates} = identity_provider, type) when is_list(templates) do case Enum.find(templates, fn %Template{type: template_type} -> Atom.to_string(type) == template_type end) do nil -> template = Template.default_template(type) template && %{ template | identity_provider_id: identity_provider.id, identity_provider: identity_provider } template -> %{template | identity_provider: identity_provider} end end # TODO rename to backend @spec implementation(client_identity_provider :: %__MODULE__{}) :: implementation :: atom() def implementation(%__MODULE__{backend: backend}) do Backend.implementation(backend) end @spec check_feature(identity_provider :: t(), action_name :: atom()) :: :ok | {:error, reason :: String.t()} def check_feature(identity_provider, requested_action_name) do backend_features = apply(Backend.implementation(identity_provider.backend), :features, []) with {feature_name, _actions} <- Enum.find(@features, fn {_feature_name, actions} -> Enum.member?(actions, requested_action_name) end), {:ok, true} <- identity_provider |> Map.from_struct() |> Map.fetch(feature_name), true <- Enum.member?(backend_features, feature_name) do :ok else false -> {:error, "Feature is not enabled for identity provider backend implementation."} {:ok, false} -> {:error, "Feature is not enabled for client identity provider."} nil -> {:error, "This provider does not support this feature."} end end @doc false def changeset(identity_provider, attrs) do identity_provider |> Repo.preload(:templates) |> cast(attrs, [ :id, :name, :check_password, :choose_session, :totpable, :enforce_totp, :webauthnable, :enforce_webauthn, :registrable, :user_editable, :consentable, :confirmable, :backend_id ]) |> unique_constraint(:id, name: :relying_parties_pkey) |> validate_required([:name, :backend_id]) |> unique_constraint(:name) |> cast_assoc(:templates, with: &Template.assoc_changeset/2) |> foreign_key_constraint(:backend_id, name: :identity_providers_backend_id_fkey) end @doc false def delete_changeset(identity_provider) do changeset = change(identity_provider) case Repo.preload(identity_provider, :client_identity_providers) do %__MODULE__{client_identity_providers: []} -> changeset %__MODULE__{client_identity_providers: client_identity_providers} -> client_ids = Enum.map(client_identity_providers, fn %ClientIdentityProvider{client_id: client_id} -> client_id end) add_error( changeset, :client_identity_providers, "identity provider is associated with client(s) #{Enum.join(client_ids, ", ")}" ) end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/identity_providers/template.ex ================================================ defmodule BorutaIdentity.IdentityProviders.Template do @moduledoc false use Ecto.Schema import Ecto.Changeset alias BorutaIdentity.IdentityProviders.IdentityProvider @type t :: %__MODULE__{ id: String.t() | nil, type: String.t(), layout: t(), default: boolean(), content: String.t(), inserted_at: DateTime.t() | nil, updated_at: DateTime.t() | nil } @template_types [ :layout, :new_session, :choose_session, :new_totp_registration, :new_totp_authentication, :new_webauthn_registration, :new_webauthn_authentication, :new_registration, :new_consent, :new_reset_password, :edit_reset_password, :new_confirmation_instructions, :edit_user, :credential_offer, :cross_device_presentation ] @type template_type :: :layout | :new_session | :choose_session | :new_consent | :new_totp_registration | :new_totp_authentication | :new_webauthn_registration | :new_webauthn_authentication | :new_registration | :new_reset_password | :edit_reset_password | :new_confirmation_instructions | :edit_user | :credential_offer | :cross_device_presentation @default_templates %{ layout: :code.priv_dir(:boruta_identity) |> Path.join("templates/layouts/app.mustache") |> File.read!(), new_session: :code.priv_dir(:boruta_identity) |> Path.join("templates/sessions/new.mustache") |> File.read!(), choose_session: :code.priv_dir(:boruta_identity) |> Path.join("templates/choose_session/index.mustache") |> File.read!(), new_totp_registration: :code.priv_dir(:boruta_identity) |> Path.join("templates/mfa/totp/registration.mustache") |> File.read!(), new_totp_authentication: :code.priv_dir(:boruta_identity) |> Path.join("templates/mfa/totp/authentication.mustache") |> File.read!(), new_webauthn_registration: :code.priv_dir(:boruta_identity) |> Path.join("templates/mfa/webauthn/registration.mustache") |> File.read!(), new_webauthn_authentication: :code.priv_dir(:boruta_identity) |> Path.join("templates/mfa/webauthn/authentication.mustache") |> File.read!(), new_registration: :code.priv_dir(:boruta_identity) |> Path.join("templates/registrations/new.mustache") |> File.read!(), new_consent: :code.priv_dir(:boruta_identity) |> Path.join("templates/consents/new.mustache") |> File.read!(), new_reset_password: :code.priv_dir(:boruta_identity) |> Path.join("templates/reset_passwords/new.mustache") |> File.read!(), edit_reset_password: :code.priv_dir(:boruta_identity) |> Path.join("templates/reset_passwords/edit.mustache") |> File.read!(), new_confirmation_instructions: :code.priv_dir(:boruta_identity) |> Path.join("templates/confirmations/new.mustache") |> File.read!(), edit_user: :code.priv_dir(:boruta_identity) |> Path.join("templates/settings/edit_user.mustache") |> File.read!(), credential_offer: :code.priv_dir(:boruta_identity) |> Path.join("templates/settings/credential_offer.mustache") |> File.read!(), cross_device_presentation: :code.priv_dir(:boruta_identity) |> Path.join("templates/settings/verifiable_presentation.mustache") |> File.read!() } @foreign_key_type :binary_id @primary_key {:id, :binary_id, autogenerate: true} schema "identity_provider_templates" do field(:content, :string) field(:type, :string) field(:default, :boolean, virtual: true, default: false) field(:layout, :any, virtual: true, default: nil) belongs_to(:identity_provider, IdentityProvider) timestamps() end def template_types, do: @template_types @spec default_content(type :: template_type()) :: template_content :: String.t() def default_content(type) when type in @template_types, do: @default_templates[type] @spec default_template(type :: template_type()) :: %__MODULE__{} | nil def default_template(type) when type in @template_types do %__MODULE__{ default: true, type: Atom.to_string(type), content: default_content(type) } end def default_template(_type), do: nil @doc false def changeset(template, attrs) do template |> cast(attrs, [:type, :content, :identity_provider_id]) |> validate_required([:type, :identity_provider_id, :content]) |> validate_inclusion(:type, Enum.map(@template_types, &Atom.to_string/1)) |> foreign_key_constraint(:identity_provider_id) |> put_default() end @doc false def assoc_changeset(template, attrs) do template |> cast(attrs, [:type, :content]) |> validate_required([:type, :content]) |> validate_inclusion(:type, Enum.map(@template_types, &Atom.to_string/1)) |> put_default() end defp put_default(changeset) do case get_field(changeset, :content) do content when not is_nil(content) -> changeset _ -> change( changeset, content: default_template(changeset |> fetch_field!(:type) |> String.to_atom()).content ) end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/identity_providers.ex ================================================ defmodule BorutaIdentity.IdentityProviders do @moduledoc """ The IdentityProviders context. """ use Nebulex.Caching import Ecto.Query, warn: false alias BorutaIdentity.Repo alias Boruta.Ecto.Scopes alias Boruta.Oauth.Scope alias BorutaIdentity.IdentityProviders.BackendRole alias BorutaIdentity.IdentityProviders.ClientIdentityProvider alias BorutaIdentity.IdentityProviders.IdentityProvider def list_identity_providers do Repo.all( from idp in IdentityProvider, join: b in assoc(idp, :backend), left_join: et in assoc(b, :email_templates), preload: [backend: {b, email_templates: et}] ) end def get_identity_provider!(id) do case Ecto.UUID.cast(id) do {:ok, id} -> Repo.one!( from idp in IdentityProvider, join: b in assoc(idp, :backend), left_join: et in assoc(b, :email_templates), where: idp.id == ^id, preload: [backend: {b, email_templates: et}] ) _ -> raise Ecto.NoResultsError, queryable: IdentityProvider end end def create_identity_provider(attrs \\ %{}) do with {:ok, identity_provider} <- %IdentityProvider{} |> IdentityProvider.changeset(attrs) |> Repo.insert() do {:ok, Repo.preload(identity_provider, :backend)} end end def update_identity_provider(%IdentityProvider{} = identity_provider, attrs) do clear_identity_provider_by_client_id_cache() identity_provider |> IdentityProvider.changeset(attrs) |> Repo.update() end def delete_identity_provider(%IdentityProvider{} = identity_provider) do clear_identity_provider_by_client_id_cache() identity_provider |> IdentityProvider.delete_changeset() |> Repo.delete() end def change_identity_provider(%IdentityProvider{} = identity_provider, attrs \\ %{}) do IdentityProvider.changeset(identity_provider, attrs) end def upsert_client_identity_provider(client_id, identity_provider_id) do clear_identity_provider_by_client_id_cache() %ClientIdentityProvider{} |> ClientIdentityProvider.changeset(%{ client_id: client_id, identity_provider_id: identity_provider_id }) |> Repo.insert( on_conflict: [set: [identity_provider_id: identity_provider_id]], conflict_target: :client_id ) end defp clear_identity_provider_by_client_id_cache do Boruta.Cache.delete_all( [ { {:entry, {BorutaIdentity.IdentityProviders, :identity_provider_by_client_id, :"$1"}, :"$2", :"$3", :"$4"}, [], [true] } ] ) end def remove_client_identity_provider(client_id) do query = from(cr in ClientIdentityProvider, where: cr.client_id == ^client_id, select: cr ) case Repo.delete_all(query) do {1, [client_identity_provider]} -> {:ok, client_identity_provider} {0, []} -> {:ok, nil} end end @decorate cacheable( key: {__MODULE__, :identity_provider_by_client_id, client_id}, cache: Boruta.Cache ) def get_identity_provider_by_client_id(client_id) do case Ecto.UUID.cast(client_id) do {:ok, client_id} -> Repo.one( from(idp in IdentityProvider, join: b in assoc(idp, :backend), left_join: et in assoc(b, :email_templates), join: cidp in assoc(idp, :client_identity_providers), where: cidp.client_id == ^client_id, preload: [backend: {b, email_templates: et}] ) ) :error -> nil end end alias BorutaIdentity.IdentityProviders.Template @decorate cacheable( key: {__MODULE__, :identity_provider_template, identity_provider_id, type}, cache: Boruta.Cache ) def get_identity_provider_template!(identity_provider_id, type) do with %IdentityProvider{} = identity_provider_with_templates <- Repo.one( from(idp in IdentityProvider, left_join: t in assoc(idp, :templates), join: b in assoc(idp, :backend), where: idp.id == ^identity_provider_id, preload: [backend: b, templates: t] ) ), %Template{} = template <- IdentityProvider.template(identity_provider_with_templates, type) do %{template | layout: IdentityProvider.template(identity_provider_with_templates, :layout)} else nil -> raise Ecto.NoResultsError, queryable: Template end end def upsert_template(%Template{id: template_id} = template, attrs) do :ok = Boruta.Cache.delete( {__MODULE__, :identity_provider_template, template.identity_provider_id, String.to_atom(template.type)} ) changeset = Template.changeset(template, attrs) case template_id do nil -> Repo.insert(changeset) _ -> Repo.update(changeset) end end def delete_identity_provider_template!(identity_provider_id, type) do template_type = Atom.to_string(type) with :ok <- Boruta.Cache.delete( {__MODULE__, :identity_provider_template, identity_provider_id, type} ), {1, _results} <- Repo.delete_all( from(t in Template, join: idp in assoc(t, :identity_provider), where: idp.id == ^identity_provider_id and t.type == ^template_type ) ), %Template{} = template <- get_identity_provider_template!(identity_provider_id, type) do template else {0, nil} -> raise Ecto.NoResultsError, queryable: Template nil -> raise Ecto.NoResultsError, queryable: Template end end alias BorutaIdentity.Accounts.EmailTemplate alias BorutaIdentity.Accounts.Ldap alias BorutaIdentity.IdentityProviders.Backend def list_backends do Repo.all(Backend) end @decorate cacheable(key: {__MODULE__, :backend, id}, cache: Boruta.Cache) def get_backend!(id) do case Ecto.UUID.cast(id) do {:ok, id} -> Repo.get!(Backend, id) _ -> raise Ecto.NoResultsError, queryable: Backend end end # TODO client backend association # def get_backend_by_client_id(client_id) do # case Ecto.UUID.cast(client_id) do # {:ok, client_id} -> # Repo.one( # from(b in Backend, # join: idp in assoc(b, :identity_providers), # join: cidp in assoc(idp, :client_identity_providers), # where: cidp.client_id == ^client_id # ) # ) # :error -> # nil # end # end def create_backend(attrs \\ %{}) do with {:ok, backend} <- %Backend{type: "Elixir.BorutaIdentity.Accounts.Internal"} |> Backend.changeset(attrs) |> Repo.insert() do update_backend_roles(backend, attrs["roles"] || []) end end def update_backend(%Backend{} = backend, attrs) do :ok = Boruta.Cache.delete({__MODULE__, :backend, backend.id}) if backend.is_default do :ok = Boruta.Cache.delete({Backend, :default}) end ldap_pool_name = Ldap.pool_name(backend) with {:ok, backend} <- backend |> Backend.changeset(attrs) |> Repo.update(), {:ok, backend} <- update_backend_roles(backend, attrs["roles"] || []) do Process.whereis(ldap_pool_name) && NimblePool.stop(ldap_pool_name) {:ok, backend} end end @spec update_backend_roles(backend :: %Backend{}, roles :: list(map())) :: {:ok, %Backend{}} | {:error, Ecto.Changeset.t()} def update_backend_roles(%Backend{id: backend_id} = backend, roles) do :ok = Boruta.Cache.delete({__MODULE__, :backend, backend_id}) if backend.is_default do :ok = Boruta.Cache.delete({Backend, :default}) end Repo.delete_all(from(s in BackendRole, where: s.backend_id == ^backend_id)) case Enum.reduce(roles, Ecto.Multi.new(), fn attrs, multi -> changeset = BackendRole.changeset( %BackendRole{}, %{ "role_id" => attrs["id"] || attrs[:role_id], "backend_id" => backend.id } ) Ecto.Multi.insert(multi, "role_-#{SecureRandom.uuid()}", changeset) end) |> Repo.transaction() do {:ok, _result} -> {:ok, backend |> Repo.reload()} {:error, _multi_name, %Ecto.Changeset{} = changeset, _changes} -> {:error, changeset} end end @spec get_backend_roles(backend_id :: String.t()) :: backend :: list(BackendRole.t()) | nil def get_backend_roles(backend_id) do scopes = Scopes.all() Repo.all( from(br in BackendRole, left_join: r in assoc(br, :role), left_join: rs in assoc(r, :role_scopes), where: br.backend_id == ^backend_id, preload: [role: {r, [role_scopes: rs]}] ) ) |> Enum.map(fn %{role: role} -> %{ role | scopes: role.role_scopes |> Enum.map(fn role_scope -> Enum.find(scopes, fn %{id: id} -> id == role_scope.scope_id end) end) |> Enum.flat_map(fn %{id: id, name: name} -> [%Scope{id: id, name: name}] _ -> [] end) } end) end def delete_backend(%Backend{} = backend) do :ok = Boruta.Cache.delete({__MODULE__, :backend, backend.id}) if backend.is_default do :ok = Boruta.Cache.delete({Backend, :default}) end ldap_pool_name = Ldap.pool_name(backend) with {:ok, backend} <- backend |> Backend.delete_changeset() |> Repo.delete() do Process.whereis(ldap_pool_name) && NimblePool.stop(ldap_pool_name) {:ok, backend} end end @doc """ Returns an `%Ecto.Changeset{}` for tracking backend changes. ## Examples iex> change_backend(backend) %Ecto.Changeset{data: %Backend{}} """ def change_backend(%Backend{} = backend, attrs \\ %{}) do Backend.changeset(backend, attrs) end def get_backend_email_template!(backend_id, type) do with %Backend{} = backend <- Repo.one( from(b in Backend, left_join: t in assoc(b, :email_templates), where: b.id == ^backend_id, preload: [email_templates: t] ) ), %EmailTemplate{} = template <- Backend.email_template(backend, type) do template else nil -> raise Ecto.NoResultsError, queryable: Template end end def upsert_email_template(%EmailTemplate{id: template_id} = template, attrs) do changeset = EmailTemplate.changeset(template, attrs) case template_id do nil -> Repo.insert(changeset) _ -> Repo.update(changeset) end end @doc """ Deletes an email template. ## Examples iex> delete_email_template!(template, :reset_password) {:ok, %EmailTemplate{}} iex> delete_email_template!(template, :unknown) ** (Ecto.NoResultsError) """ def delete_email_template!(backend_id, type) do template_type = Atom.to_string(type) with {1, _results} <- Repo.delete_all( from(t in EmailTemplate, join: b in assoc(t, :backend), where: b.id == ^backend_id and t.type == ^template_type ) ), %EmailTemplate{} = template <- get_backend_email_template!(backend_id, type) do template else {0, nil} -> raise Ecto.NoResultsError, queryable: Template nil -> raise Ecto.NoResultsError, queryable: Template end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/ldap_repo.ex ================================================ defmodule BorutaIdentity.LdapRepo do @moduledoc false alias BorutaIdentity.Accounts.Ldap alias BorutaIdentity.IdentityProviders.Backend @type user_properties :: %{ String.t() => list(String.t()) } @callback open(host :: String.t()) :: {:ok, pid()} | {:error, reason :: any()} @callback open(host :: String.t(), opts :: Keyword.t()) :: {:ok, pid()} | {:error, reason :: any()} @callback close(handle :: pid()) :: :ok @callback simple_bind(handle :: pid(), dn :: String.t(), password :: String.t()) :: :ok | {:error, any()} @callback search(handle :: pid, backend :: Backend.t(), username :: String.t()) :: {:ok, {dn :: String.t(), user_properties :: user_properties()}} | {:error, any()} @callback modify( handle :: pid, backend :: Backend.t(), user :: Ldap.User.t(), username :: String.t() ) :: :ok | {:error, any()} @callback modify_password( handle :: pid, user :: Ldap.User.t(), new_password :: String.t(), old_password :: String.t() ) :: :ok | {:error, any()} @callback modify_password( handle :: pid, user :: Ldap.User.t(), new_password :: String.t() ) :: :ok | {:error, any()} def open(host, opts \\ []), do: impl().open(host, opts) def close(handle), do: impl().close(handle) def simple_bind(handle, dn, password), do: impl().simple_bind(handle, dn, password) def search(handle, backend, username), do: impl().search(handle, backend, username) def modify(handle, backend, user, username), do: impl().modify(handle, backend, user, username) def modify_password(handle, user, new_password, old_password), do: impl().modify_password(handle, user, new_password, old_password) def modify_password(handle, user, new_password), do: impl().modify_password(handle, user, new_password) defp impl do case Application.get_env(:boruta_identity, BorutaIdentity.LdapRepo) do [adapter: adapter] -> adapter _ -> BorutaIdentity.LdapAdapter end end end defmodule BorutaIdentity.LdapAdapter do @moduledoc false @behaviour BorutaIdentity.LdapRepo alias BorutaIdentity.Accounts.Ldap @impl BorutaIdentity.LdapRepo def open(host, opts \\ []) do :eldap.open([String.to_charlist(host)], opts) end @impl BorutaIdentity.LdapRepo def close(handle) do :eldap.close(handle) end @impl BorutaIdentity.LdapRepo def simple_bind(handle, dn, password) do :eldap.simple_bind(handle, String.to_charlist(dn), password) rescue _ -> {:error, "Authentication failed."} end @impl BorutaIdentity.LdapRepo def search(handle, backend, username) do user_rdn_attribute = String.to_charlist(backend.ldap_user_rdn_attribute) base_dn = [backend.ldap_ou, backend.ldap_base_dn] |> Enum.reject(&is_nil/1) |> Enum.join(",") case :eldap.search(handle, base: base_dn, filter: :eldap.equalityMatch(user_rdn_attribute, String.to_charlist(username)) ) do {:ok, {:eldap_search_result, [ {:eldap_entry, dn, user_properties} ], _, _}} -> user_properties = Enum.map(user_properties, fn {key, values} -> {to_string(key), List.first(values) |> to_string()} end) |> Enum.into(%{}) {:ok, {to_string(dn), user_properties}} {:ok, {:eldap_search_result, _results, _, _}} -> {:error, "Multiple users matched the given #{user_rdn_attribute}."} {:error, error} -> {:error, error} end end @impl BorutaIdentity.LdapRepo def modify(handle, backend, %Ldap.User{dn: dn}, username) do user_rdn_attribute = String.to_charlist(backend.ldap_user_rdn_attribute) username = String.to_charlist(username) :eldap.modify(handle, String.to_charlist(dn), [ :eldap.mod_replace(user_rdn_attribute, [username]) ]) end @impl BorutaIdentity.LdapRepo def modify_password(handle, %Ldap.User{dn: dn}, new_password, old_password) when byte_size(new_password) > 0 do :eldap.modify_password( handle, String.to_charlist(dn), String.to_charlist(new_password), String.to_charlist(old_password) ) end @impl BorutaIdentity.LdapRepo def modify_password(handle, %Ldap.User{dn: dn}, new_password) when byte_size(new_password) > 0 do :eldap.modify_password( handle, String.to_charlist(dn), String.to_charlist(new_password) ) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/logger.ex ================================================ defmodule BorutaIdentity.Logger do @moduledoc false require Logger alias BorutaIdentityWeb.ErrorHelpers def start do handlers = [ { :boruta_identity_requests, [:boruta_identity, :endpoint, :stop], &__MODULE__.boruta_identity_request_handler/4 }, { :authentication_log_in_success, [:authentication, :log_in, :success], &__MODULE__.authentication_log_in_success_handler/4 }, { :authentication_log_in_failure, [:authentication, :log_in, :failure], &__MODULE__.authentication_log_in_failure_handler/4 }, { :authentication_log_out_success, [:authentication, :log_out, :success], &__MODULE__.authentication_log_out_success_handler/4 }, { :registration_create_success, [:registration, :create, :success], &__MODULE__.registration_create_success_handler/4 }, { :registration_create_failure, [:registration, :create, :failure], &__MODULE__.registration_create_failure_handler/4 }, { :registration_confirm_success, [:registration, :confirm, :success], &__MODULE__.registration_confirm_success_handler/4 }, { :registration_confirm_failure, [:registration, :confirm, :failure], &__MODULE__.registration_confirm_failure_handler/4 }, { :registration_update_success, [:registration, :update, :success], &__MODULE__.registration_update_success_handler/4 }, { :registration_update_failure, [:registration, :update, :failure], &__MODULE__.registration_update_failure_handler/4 }, { :authorization_consent_success, [:authorization, :consent, :success], &__MODULE__.authorization_consent_success_handler/4 }, { :authorization_consent_failure, [:authorization, :consent, :failure], &__MODULE__.authorization_consent_failure_handler/4 } ] for {handler_id, event_name, fun} <- handlers do :telemetry.attach(handler_id, event_name, fun, :ok) end end def boruta_identity_request_handler(_, %{duration: duration}, %{conn: conn} = metadata, _) do remote_ip = :inet.ntoa(conn.remote_ip) case log_level(metadata[:options][:log], conn) do false -> :ok level -> Logger.log( level, fn -> %{method: method, request_path: path, status: status, state: state} = conn status = Integer.to_string(status) [ "boruta_identity", ?\s, method, ?\s, path, " - ", connection_type(state), ?\s, status, " from ", remote_ip, " in ", duration(duration) ] end, type: :request ) end end def authentication_log_in_success_handler( _, _measurements, %{sub: sub, backend: backend, client_id: client_id}, _ ) do Logger.log( :info, fn -> [ "boruta_identity", ?\s, "authentication", ?\s, "log_in", " - ", "success", log_attribute("client_id", client_id), log_attribute("sub", sub), log_attribute("backend_id", backend.id), log_attribute("provider", backend.type) ] end, type: :business ) end def authentication_log_in_failure_handler( _, _measurements, %{message: message, client_id: client_id}, _ ) do Logger.log( :info, fn -> [ "boruta_identity", ?\s, "authentication", ?\s, "log_in", " - ", "failure", log_attribute("client_id", client_id), log_attribute("message", ~s{"#{message}"}) ] end, type: :business ) end def authentication_log_out_success_handler( _, _measurements, %{sub: sub, backend: backend, client_id: client_id}, _ ) do Logger.log( :info, fn -> [ "boruta_identity", ?\s, "authentication", ?\s, "log_out", " - ", "success", log_attribute("client_id", client_id), log_attribute("sub", sub), log_attribute("backend_id", backend.id), log_attribute("provider", backend.type) ] end, type: :business ) end def registration_create_success_handler( _, _measurements, %{sub: sub, backend: backend, client_id: client_id}, _ ) do Logger.log( :info, fn -> [ "boruta_identity", ?\s, "registration", ?\s, "create", " - ", "success", log_attribute("client_id", client_id), log_attribute("sub", sub), log_attribute("backend_id", backend.id), log_attribute("provider", backend.type) ] end, type: :business ) end def registration_create_failure_handler( _, _measurements, %{client_id: client_id, error: %Ecto.Changeset{} = changeset}, _ ) do message = ErrorHelpers.error_messages(changeset) |> Enum.join(", ") Logger.log( :info, fn -> [ "boruta_identity", ?\s, "registration", ?\s, "create", " - ", "failure", log_attribute("client_id", client_id), log_attribute("message", ~s{"#{message}"}) ] end, type: :business ) end def registration_confirm_success_handler( _, _measurements, %{sub: sub, backend: backend, client_id: client_id, token: token}, _ ) do Logger.log( :info, fn -> [ "boruta_identity", ?\s, "registration", ?\s, "confirm", " - ", "success", log_attribute("client_id", client_id), log_attribute("sub", sub), log_attribute("backend", backend), log_attribute("token", token) ] end, type: :business ) end def registration_confirm_failure_handler( _, _measurements, %{client_id: client_id, message: message, token: token}, _ ) do Logger.log( :info, fn -> [ "boruta_identity", ?\s, "registration", ?\s, "confirm", " - ", "failure", log_attribute("client_id", client_id), log_attribute("message", ~s{"#{message}"}), log_attribute("token", token) ] end, type: :business ) end def registration_update_success_handler( _, _measurements, %{sub: sub, backend: backend, client_id: client_id}, _ ) do Logger.log( :info, fn -> [ "boruta_identity", ?\s, "registration", ?\s, "update", " - ", "success", log_attribute("client_id", client_id), log_attribute("sub", sub), log_attribute("provider", backend.type), log_attribute("backend_id", backend.id), ] end, type: :business ) end def registration_update_failure_handler( _, _measurements, %{sub: sub, backend: backend, client_id: client_id, error: message}, _ ) when is_binary(message) do Logger.log( :info, fn -> [ "boruta_identity", ?\s, "registration", ?\s, "update", " - ", "failure", log_attribute("client_id", client_id), log_attribute("sub", sub), log_attribute("provider", backend.type), log_attribute("backend_id", backend.id), log_attribute("message", ~s{"#{message}"}) ] end, type: :business ) end def registration_update_failure_handler( _, _measurements, %{ sub: sub, backend: backend, client_id: client_id, error: %Ecto.Changeset{} = changeset }, _ ) do message = ErrorHelpers.error_messages(changeset) |> Enum.join(", ") Logger.log( :info, fn -> [ "boruta_identity", ?\s, "registration", ?\s, "update", " - ", "failure", log_attribute("client_id", client_id), log_attribute("sub", sub), log_attribute("provider", backend.type), log_attribute("backend_id", backend.id), log_attribute("message", ~s{"#{message}"}) ] end, type: :business ) end def authorization_consent_success_handler( _, _measurements, %{sub: sub, backend: backend, client_id: client_id, scopes: scopes}, _ ) do Logger.log( :info, fn -> [ "boruta_identity", ?\s, "authorization", ?\s, "consent", " - ", "success", log_attribute("client_id", client_id), log_attribute("sub", sub), log_attribute("provider", backend.type), log_attribute("backend_id", backend.id), log_attribute("scope", ~s{"#{Enum.join(scopes, " ")}"}) ] end, type: :business ) end def authorization_consent_failure_handler( _, _measurements, %{sub: sub, backend: backend, client_id: client_id, scopes: scopes, message: message}, _ ) do Logger.log( :info, fn -> [ "boruta_identity", ?\s, "authorization", ?\s, "consent", " - ", "success", log_attribute("client_id", client_id), log_attribute("sub", sub), log_attribute("provider", backend.type), log_attribute("backend_id", backend.id), log_attribute("scope", ~s{"#{Enum.join(scopes, " ")}"}), log_attribute("message", ~s{"#{message}"}) ] end, type: :business ) end defp log_attribute(_key, nil), do: "" defp log_attribute(key, attribute), do: " #{key}=#{attribute}" # From Phoenix.Logger defp log_level(nil, _conn), do: :info defp log_level(level, _conn) when is_atom(level), do: level defp log_level({mod, fun, args}, conn) when is_atom(mod) and is_atom(fun) and is_list(args) do apply(mod, fun, [conn | args]) end defp connection_type(:set_chunked), do: "chunked" defp connection_type(_), do: "sent" defp duration(duration) do duration = System.convert_time_unit(duration, :native, :microsecond) if duration > 1000 do [duration |> div(1000) |> Integer.to_string(), "ms"] else [Integer.to_string(duration), "µs"] end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/organizations/organization.ex ================================================ defmodule BorutaIdentity.Organizations.Organization do @moduledoc false use Ecto.Schema import Ecto.Changeset @type t :: %__MODULE__{ id: String.t(), name: String.t(), label: String.t() | nil, inserted_at: DateTime.t(), updated_at: DateTime.t() } @primary_key {:id, Ecto.UUID, autogenerate: true} @foreign_key_type Ecto.UUID schema "organizations" do field(:name, :string) field(:label, :string) timestamps() end @doc false def changeset(organization, attrs) do organization |> cast(attrs, [:name, :label]) |> validate_required([:name]) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/organizations/organization_user.ex ================================================ defmodule BorutaIdentity.Organizations.OrganizationUser do @moduledoc false use Ecto.Schema import Ecto.Changeset alias BorutaIdentity.Accounts.User alias BorutaIdentity.Organizations.Organization @type t :: %__MODULE__{ user_id: String.t(), organization_id: String.t(), inserted_at: DateTime.t(), updated_at: DateTime.t() } @foreign_key_type Ecto.UUID @primary_key {:id, Ecto.UUID, autogenerate: true} schema "organizations_users" do belongs_to :user, User belongs_to :organization, Organization timestamps() end @doc false def changeset(organization_user, attrs) do organization_user |> cast(attrs, [:organization_id, :user_id]) |> validate_required([:organization_id, :user_id]) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/organizations.ex ================================================ defmodule BorutaIdentity.Organizations do @moduledoc false import Ecto.Query alias BorutaIdentity.Organizations.Organization alias BorutaIdentity.Repo @type organization_params :: %{ name: String.t(), label: String.t() | nil } @spec list_organizations() :: Scrivener.Page.t() @spec list_organizations(params :: map()) :: Scrivener.Page.t() def list_organizations(params \\ %{}) do from(o in Organization) |> Repo.paginate(Map.merge(params, %{"page_size" => 500})) end # @spec search_organizations(query :: String.t(), params :: map()) :: Scrivener.Page.t() # @spec search_organizations(query :: String.t()) :: Scrivener.Page.t() # def search_organizations(query, params \\ %{}) do # from(o in Organization, # where: fragment("name % ?", ^query), # order_by: fragment("word_similarity(name, ?) DESC", ^query) # ) # |> Repo.paginate(params) # end @spec create_organization(organization_params :: organization_params()) :: {:ok, organization :: Organization.t()} | {:error, changeset :: Ecto.Changeset.t()} def create_organization(organization_params) do Organization.changeset(%Organization{}, organization_params) |> Repo.insert() end @spec delete_organization(organization_id :: String.t()) :: {:ok, organization :: Organization.t()} | {:error, changeset :: Ecto.Changeset.t()} def delete_organization(organization_id) do case get_organization(organization_id) do nil -> {:error, :not_found} organization -> Repo.delete(organization) end end @spec get_organization(organization_id :: String.t()) :: organization :: Organization.t() | nil def get_organization(organization_id) do case Ecto.UUID.cast(organization_id) do {:ok, _} -> Repo.get(Organization, organization_id) _ -> nil end end @spec update_organization( organization :: Organization.t(), organization_params :: organization_params() ) :: {:ok, organization :: Organization.t()} | {:error, changeset :: Ecto.Changeset.t()} def update_organization(organization, organization_params) do Organization.changeset(organization, organization_params) |> Repo.update() end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/repo.ex ================================================ defmodule BorutaIdentity.Repo do use Ecto.Repo, otp_app: :boruta_identity, adapter: Ecto.Adapters.Postgres use Scrivener, page_size: 12 def set_limit(conn) do Postgrex.query(conn, "SELECT set_limit($1)", [0.15]) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/resource_owners.ex ================================================ defmodule BorutaIdentity.ResourceOwners do @moduledoc false @behaviour Boruta.Oauth.ResourceOwners use BorutaIdentityWeb, :controller alias Boruta.Oauth.ResourceOwner alias Boruta.Oauth.Scope alias BorutaIdentity.Accounts alias BorutaIdentity.Accounts.Role alias BorutaIdentity.Accounts.User alias BorutaIdentity.Accounts.VerifiableCredentials alias BorutaIdentity.Accounts.VerifiablePresentations alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.Organizations.Organization @impl Boruta.Oauth.ResourceOwners def get_by(username: username) do backend = Backend.default!() with {:ok, impl_user} <- apply(Backend.implementation(backend), :get_user, [backend, %{email: username}]), %User{id: id, username: email, last_login_at: last_login_at} <- apply(Backend.implementation(backend), :domain_user!, [impl_user, backend]) do {:ok, %ResourceOwner{ sub: id, username: email, last_login_at: last_login_at, # TODO find out why the impl user in extra_claims extra_claims: %{user: impl_user} }} else _ -> {:error, "Invalid username or password."} end end def get_by(sub: "unknown", scope: _scope), do: %User{} def get_by(sub: "did:" <> _key, scope: _scope), do: %User{} def get_by(sub: sub, scope: scope) when not is_nil(sub) do case Accounts.get_user(sub) do %User{ id: id, username: email, last_login_at: last_login_at, federated_metadata: federated_metadata } = user -> {:ok, %ResourceOwner{ sub: id, username: email, last_login_at: last_login_at, extra_claims: Map.merge(metadata(user, scope), federated_metadata), authorization_details: VerifiableCredentials.authorization_details(user, scope), credential_configuration: VerifiableCredentials.credential_configuration(user), presentation_configuration: VerifiablePresentations.presentation_configuration(user) }} _ -> {:error, "Invalid username or password."} end end def get_by(_), do: {:error, "Invalid username or password."} @impl Boruta.Oauth.ResourceOwners def check_password(%ResourceOwner{extra_claims: extra_claims}, password) do backend = Backend.default!() case apply( Backend.implementation(backend), :check_user_against, [backend, extra_claims[:user], %{password: password}] ) do {:ok, _user} -> :ok _ -> {:error, "Invalid username or password."} end end @impl Boruta.Oauth.ResourceOwners def authorized_scopes(%ResourceOwner{sub: "unknown"}), do: [] def authorized_scopes(%ResourceOwner{sub: "did:" <> _key}), do: [] def authorized_scopes(%ResourceOwner{sub: sub}) when not is_nil(sub) do Accounts.get_user_scopes(sub) ++ Enum.flat_map(Accounts.get_user_roles(sub), fn %{scopes: scopes} -> scopes end) end def authorized_scopes(_), do: [] @impl Boruta.Oauth.ResourceOwners def claims(%ResourceOwner{sub: sub}, scope) do case Accounts.get_user(sub) do %User{} = user -> scope |> Scope.split() |> Enum.reduce(%{}, fn scope, acc -> merge_claims(scope, acc, user, sub) end) |> Map.put("scope", scope) _ -> %{} end end @spec metadata(user :: User.t(), scope :: String.t()) :: metadata :: map() def metadata(%User{username: username, metadata: %{} = metadata}, _scope) when metadata == %{}, do: %{ "email" => username } def metadata(user, scope) do user.metadata |> User.metadata_filter(user.backend) |> metadata_scope_filter(scope, user.backend) |> Enum.into(%{}) |> Map.put("email", user.username) end defp merge_claims( "email", acc, %User{ username: username, confirmed_at: confirmed_at }, _sub ) do Map.merge(acc, %{ "email" => username, "email_verified" => !!confirmed_at }) end defp merge_claims("profile", acc, _user, sub) do roles = Accounts.get_user_roles(sub) organizations = Accounts.get_user_organizations(sub) acc |> Map.put( "organizations", Enum.map(organizations, fn %Organization{} = organization -> Map.from_struct(organization) |> Map.take([:id, :name, :label]) |> Enum.map(fn {key, value} -> {Atom.to_string(key), value} end) |> Enum.into(%{}) end) ) |> Map.put("roles", Enum.map(roles, fn %Role{name: name} -> name end)) end defp merge_claims(_, acc, _user, _sub), do: acc defp metadata_scope_filter(metadata, request_scope, %Backend{metadata_fields: metadata_fields}) do Enum.filter(metadata, fn {key, _value} -> # does backend metadata fields configuration allows current field according to scope ? Enum.reduce(metadata_fields, true, fn %{"attribute_name" => ^key, "scopes" => scopes}, acc -> case scopes do nil -> acc && true scopes -> request_scopes = Scope.split(request_scope) Enum.empty?(scopes -- request_scopes) end _, acc -> acc && true end) end) |> Enum.into(%{}) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/totp.ex ================================================ defmodule BorutaIdentity.TotpError do @moduledoc false @enforce_keys [:message, :totp_secret] defexception [:message, :totp_secret, :changeset, :template, plug_status: 400] @type t :: %__MODULE__{ message: String.t(), totp_secret: String.t(), changeset: Ecto.Changeset.t() | nil, template: BorutaIdentity.IdentityProviders.Template.t() } def exception(message) when is_binary(message) do %__MODULE__{message: message, totp_secret: ""} end def message(exception) do exception.message end end defmodule BorutaIdentity.TotpRegistrationApplication do @moduledoc false @callback totp_registration_initialized( context :: any(), totp_secret :: String.t(), template :: BorutaIdentity.IdentityProviders.Template.t() ) :: any() @callback totp_registration_error( context :: any(), error :: BorutaIdentity.TotpError.t() ) :: any() @callback totp_registration_success( context :: any(), user :: BorutaIdentity.Accounts.User.t() ) :: any() end defmodule BorutaIdentity.TotpAuthenticationApplication do @moduledoc false @callback totp_initialized( context :: any(), template :: BorutaIdentity.IdentityProviders.Template.t() ) :: any() @callback totp_not_required(context :: any()) :: any() @callback totp_registration_missing(context :: any()) :: any() @callback totp_authenticated( context :: any(), current_user :: BorutaIdentity.Accounts.User.t() ) :: any() @callback totp_authentication_failure( context :: any(), error :: BorutaIdentity.TotpError.t() ) :: any() end defmodule BorutaIdentity.Totp do @moduledoc false defmodule Hotp do @moduledoc """ Implements HOTP generation as described in the IETF RFC [HOTP: An HMAC-Based One-Time Password Algorithm](https://www.ietf.org/rfc/rfc4226.txt) > This implementation defaults to 6 digits using the sha1 algorithm as hashing function """ import Bitwise @hmac_algorithm :sha @digits 6 @spec generate_hotp(secret :: String.t(), counter :: integer()) :: hotp :: String.t() def generate_hotp(secret, counter) do # Step 1: Generate an HMAC-SHA-1 value hmac_result = :crypto.mac(:hmac, @hmac_algorithm, secret, <>) # Step 2: Dynamic truncation truncated_hash = truncate_hash(hmac_result) # Step 3: Compute HOTP value (6-digit OTP) hotp = truncated_hash |> rem(10 ** @digits) format_hotp(hotp) end defp truncate_hash(hmac_value) do # NOTE the folowing hard coded values are part of the specification offset = :binary.at(hmac_value, 19) &&& 0xF with <<_::size(1), result::size(31)>> <- :binary.part(hmac_value, offset, 4) do result end end defp format_hotp(hotp) do String.pad_leading(Integer.to_string(hotp), @digits, "0") end end defmodule Admin do @moduledoc false import Boruta.Config, only: [ issuer: 0 ] @interval 30 @spec generate_totp(secret :: String.t()) :: totp :: String.t() | :error def generate_totp(secret) do with {:ok, secret} <- Base.decode32(secret, padding: false) do Hotp.generate_hotp(secret, number_of_time_steps()) end end @spec check_totp(totp :: String.t(), secret :: String.t()) :: totp :: :ok | {:error, reason :: String.t()} def check_totp(totp, secret) when is_binary(secret) do with {:ok, secret} <- Base.decode32(secret, padding: false), true <- Hotp.generate_hotp(secret, number_of_time_steps()) == totp do :ok else _ -> {:error, "Given TOTP is invalid."} end end def check_totp(_totp, _secret), do: {:error, "Given TOTP is invalid."} def generate_secret do SecureRandom.uuid() |> Base.encode32(padding: false) end def url(username, secret) do "otpauth://totp/#{username}?secret=#{secret}&issuer=#{issuer()}" end defp number_of_time_steps do floor(:os.system_time(:seconds) / @interval) end end import BorutaIdentity.Accounts.Utils, only: [defwithclientidp: 2] alias BorutaIdentity.Accounts.User alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaIdentity.Repo alias BorutaIdentity.TotpError defwithclientidp initialize_totp_registration(context, client_id, totp_authenticated, current_user, module) do totp_secret = Admin.generate_secret() case {totp_authenticated, current_user.totp_registered_at} do {true, _} -> module.totp_registration_initialized( context, totp_secret, new_totp_registration_template(client_idp) ) {false, nil} -> module.totp_registration_initialized( context, totp_secret, new_totp_registration_template(client_idp) ) _ -> raise TotpError, "Authenticator registration could not be initialized." end end defwithclientidp register_totp(context, client_id, current_user, totp_params, module) do with :ok <- Admin.check_totp(totp_params[:totp_code], totp_params[:totp_secret]), {:ok, user} <- current_user |> User.totp_changeset(totp_params[:totp_secret]) |> Repo.update() do module.totp_registration_success(context, user) else {:error, %Ecto.Changeset{} = changeset} -> error = %TotpError{ message: "Current user could not be updated.", changeset: changeset, totp_secret: totp_params[:totp_secret], template: new_totp_registration_template(client_idp) } module.totp_registration_error(context, error) {:error, error} -> error = %TotpError{ message: error, totp_secret: totp_params[:totp_secret], template: new_totp_registration_template(client_idp) } module.totp_registration_error(context, error) end end defwithclientidp initialize_totp(context, client_id, current_user, module) do case {client_idp, current_user} do {%IdentityProvider{totpable: true}, %User{totp_registered_at: %DateTime{}}} -> module.totp_initialized(context, new_totp_authentication_template(client_idp)) {%IdentityProvider{enforce_totp: true}, %User{totp_registered_at: nil}} -> module.totp_registration_missing(context) {%IdentityProvider{enforce_totp: true}, _} -> module.totp_initialized(context, new_totp_authentication_template(client_idp)) {%IdentityProvider{enforce_totp: false}, _} -> module.totp_not_required(context) end end defwithclientidp authenticate_totp(context, client_id, %User{totp_registered_at: nil}, _totp_params, module) do case client_idp.enforce_totp do true -> module.totp_registration_missing(context) false -> module.totp_not_required(context) end end defwithclientidp authenticate_totp(context, client_id, user, totp_params, module) do case Admin.check_totp(totp_params[:totp_code], user.totp_secret) do :ok -> module.totp_authenticated(context, user) {:error, error} -> error = %TotpError{ message: error, totp_secret: totp_params[:totp_secret], template: new_totp_authentication_template(client_idp) } module.totp_authentication_failure(context, error) end end defp new_totp_registration_template(identity_provider) do IdentityProviders.get_identity_provider_template!( identity_provider.id, :new_totp_registration ) end defp new_totp_authentication_template(identity_provider) do IdentityProviders.get_identity_provider_template!( identity_provider.id, :new_totp_authentication ) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity/webauthn.ex ================================================ defmodule BorutaIdentity.WebauthnError do @moduledoc false @enforce_keys [:message] defexception [:message, :webauthn_options, :template, plug_status: 400] @type t :: %__MODULE__{ message: String.t(), webauthn_options: BorutaIdentity.Webauthn.Options.t() | nil, template: BorutaIdentity.IdentityProviders.Template.t() } def exception(message) when is_binary(message) do %__MODULE__{message: message} end def message(exception) do exception.message end end defmodule BorutaIdentity.WebauthnRegistrationApplication do @moduledoc false @callback webauthn_registration_initialized( context :: any(), webauthn_options :: BorutaIdentity.Webauthn.Options.t(), template :: BorutaIdentity.IdentityProviders.Template.t() ) :: any() @callback webauthn_registration_error( context :: any(), error :: BorutaIdentity.WebauthnError.t() ) :: any() @callback webauthn_registration_success( context :: any(), user :: BorutaIdentity.Accounts.User.t() ) :: any() end defmodule BorutaIdentity.WebauthnAuthenticationApplication do @moduledoc false @callback webauthn_initialized( context :: any(), webauthn_options :: BorutaIdentity.Webauthn.Options.t(), template :: BorutaIdentity.IdentityProviders.Template.t() ) :: any() @callback webauthn_not_required(context :: any()) :: any() @callback webauthn_registration_missing(context :: any()) :: any() @callback webauthn_authenticated( context :: any(), current_user :: BorutaIdentity.Accounts.User.t() ) :: any() @callback webauthn_authentication_failure( context :: any(), error :: BorutaIdentity.WebauthnError.t() ) :: any() end defmodule BorutaIdentity.Webauthn do @moduledoc false defmodule Options do @moduledoc false alias Boruta.Config @type t :: %__MODULE__{ rp: %{ id: String.t() }, user: %{ id: String.t(), displayName: String.t() }, challenge: String.t(), credential_id: String.t(), publicKeyCredParams: %{ alg: integer(), type: String.t() } } @cose_alg_identifier %{ "ES256" => -7, "ES384" => -35, "ES512" => -36, "EdDSA" => -8 } @enforce_keys [:rp, :user, :challenge] defstruct rp: nil, user: nil, challenge: nil, credential_id: nil, publicKeyCredParams: %{ alg: @cose_alg_identifier["ES256"], type: "public-key" } end import BorutaIdentity.Accounts.Utils, only: [defwithclientidp: 2] alias Boruta.Config alias BorutaIdentity.Accounts alias BorutaIdentity.Accounts.User alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaIdentity.Repo alias BorutaIdentity.WebauthnError def options(user, true) do with {:ok, user} <- Accounts.put_user_webauthn_challenge(user) do options = %Options{ rp: %{ id: Config.issuer() |> URI.parse() |> Map.get(:host), name: "boruta" }, user: %{ id: user.id, name: user.username, displayName: user.username }, challenge: user.webauthn_challenge, credential_id: user.webauthn_identifier } {:ok, options} end end def options(user, false) do options = %Options{ rp: %{ id: Config.issuer() |> URI.parse() |> Map.get(:host), name: "boruta" }, user: %{ id: user.id, name: user.username, displayName: user.username }, challenge: user.webauthn_challenge } {:ok, options} end defwithclientidp initialize_webauthn_registration( context, client_id, webauthn_authenticated, current_user, module ) do case options(current_user, true) do {:ok, webauthn_options} -> case {webauthn_authenticated, current_user.webauthn_registered_at} do {true, _} -> module.webauthn_registration_initialized( context, webauthn_options, new_webauthn_registration_template(client_idp) ) {false, nil} -> module.webauthn_registration_initialized( context, webauthn_options, new_webauthn_registration_template(client_idp) ) _error -> raise WebauthnError, "Authenticator registration could not be initialized." end _error -> raise WebauthnError, "Authenticator registration could not be initialized." end end defwithclientidp initialize_webauthn(context, client_id, current_user, module) do {:ok, webauthn_options} = options(current_user, true) case {client_idp, current_user} do {%IdentityProvider{webauthnable: true}, %User{webauthn_registered_at: %DateTime{}}} -> module.webauthn_initialized( context, webauthn_options, new_webauthn_authentication_template(client_idp) ) {%IdentityProvider{enforce_webauthn: true}, %User{webauthn_registered_at: nil}} -> module.webauthn_registration_missing(context) {%IdentityProvider{enforce_webauthn: true}, _} -> module.webauthn_initialized( context, webauthn_options, new_webauthn_authentication_template(client_idp) ) {%IdentityProvider{enforce_webauthn: false}, _} -> module.webauthn_not_required(context) end end defwithclientidp register_webauthn(context, client_id, current_user, webauthn_params, module) do %{ attestation: attestation, client_data: client_data, identifier: identifier, type: "public-key" } = webauthn_params wax_challenge = Wax.new_registration_challenge( origin: Config.issuer(), attestation: "direct", rp_id: Config.issuer() |> URI.parse() |> Map.get(:host), trusted_attestation_types: [:basic, :uncertain, :attca, :anonca], verify_trust_root: false ) wax_challenge = %{wax_challenge | bytes: current_user.webauthn_challenge} with {:ok, attestation} <- Base.decode64(attestation), {:ok, {authenticator_data, _result}} <- Wax.register(attestation, client_data, wax_challenge), {:ok, user} <- current_user |> User.webauthn_public_key_changeset( authenticator_data.attested_credential_data.credential_public_key, identifier ) |> Repo.update() do module.webauthn_registration_success(context, user) else _ -> # TODO provide more meaningful errors case options(current_user, true) do {:ok, webauthn_options} -> error = %WebauthnError{ message: "Authenticator could not be registered.", webauthn_options: webauthn_options, template: new_webauthn_registration_template(client_idp) } module.webauthn_registration_error(context, error) {:error, %Ecto.Changeset{}} -> raise WebauthnError, "Authenticator registration could not be initialized." end end end @dialyzer {:no_return, {:authenticate_webauthn, 5}} defwithclientidp authenticate_webauthn( context, client_id, current_user, webauthn_params, module ) do %{ signature: signature, authenticator_data: authenticator_data, client_data: client_data, identifier: identifier } = webauthn_params wax_challenge = Wax.new_registration_challenge( origin: Config.issuer(), attestation: "direct", rp_id: Config.issuer() |> URI.parse() |> Map.get(:host), trusted_attestation_types: [:basic, :uncertain, :attca, :anonca], verify_trust_root: false, allow_credentials: [{current_user.webauthn_identifier, current_user.webauthn_public_key}] ) wax_challenge = %{wax_challenge | bytes: current_user.webauthn_challenge} case Wax.authenticate( identifier, Base.decode64!(authenticator_data), Base.decode64!(signature), client_data, wax_challenge, [] ) do {:ok, _auth_data} -> module.webauthn_authenticated(context, current_user) {:error, _error} -> case options(current_user, true) do {:ok, webauthn_options} -> error = %WebauthnError{ message: "Passkey could not be verified.", webauthn_options: webauthn_options, template: new_webauthn_authentication_template(client_idp) } module.webauthn_authentication_failure(context, error) {:error, %Ecto.Changeset{}} -> raise WebauthnError, "Authenticator registration could not be initialized." end end end defp new_webauthn_registration_template(identity_provider) do IdentityProviders.get_identity_provider_template!( identity_provider.id, :new_webauthn_registration ) end defp new_webauthn_authentication_template(identity_provider) do IdentityProviders.get_identity_provider_template!( identity_provider.id, :new_webauthn_authentication ) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity.ex ================================================ defmodule BorutaIdentity do @moduledoc """ BorutaIdentity keeps the contexts that define your domain and business logic. Contexts are also responsible for managing your data, regardless if it comes from the database, an external API or others. """ end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/concerns/authenticable.ex ================================================ defmodule BorutaIdentityWeb.Authenticable do @moduledoc false use BorutaIdentityWeb, :controller alias Boruta.ClientsAdapter alias Boruta.Oauth alias BorutaIdentity.Accounts # Make the remember me cookie valid for 60 days. # If you want bump or reduce this value, also change # the token expiry itself in UserToken. @session_key :user_token @max_age 60 * 60 * 24 * 60 @remember_me_cookie "_boruta_identity_web_user_remember_me" @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] @spec remember_me_cookie() :: String.t() def remember_me_cookie, do: @remember_me_cookie @spec store_user_session(conn :: Plug.Conn.t(), session_token :: String.t()) :: conn :: Plug.Conn.t() def store_user_session(%Plug.Conn{body_params: params} = conn, session_token) do user = session_token && Accounts.get_user_by_session_token(session_token) conn |> assign(:current_user, user) |> put_session(@session_key, session_token) |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(session_token)}") |> maybe_write_remember_me_cookie(session_token, params["user"]) end @spec get_user_session(conn :: Plug.Conn.t()) :: session_token :: String.t() def get_user_session(conn) do get_session(conn, @session_key) end @spec remove_user_session(conn :: Plug.Conn.t()) :: conn :: Plug.Conn.t() def remove_user_session(conn) do conn |> delete_resp_cookie(@remember_me_cookie) |> delete_session(@session_key) end defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => remember_me}) when remember_me in ["true", "on"] do put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) end defp maybe_write_remember_me_cookie(conn, _token, _params) do conn end @spec after_sign_in_path(conn :: Plug.Conn.t()) :: String.t() def after_sign_in_path(conn), do: user_return_to_from_request(conn) || "/" @spec after_registration_path(conn :: Plug.Conn.t()) :: String.t() def after_registration_path(conn), do: user_return_to_from_request(conn) || "/" @spec after_sign_out_path(conn :: Plug.Conn.t()) :: String.t() def after_sign_out_path(%Plug.Conn{query_params: query_params} = conn) do Routes.user_session_path(conn, :new, query_params) end @spec request_param(conn :: Plug.Conn.t()) :: request_param :: String.t() def request_param(conn) do case Oauth.Request.authorize_request(conn, %Oauth.ResourceOwner{sub: ""}) do {:ok, %_{client_id: "did:" <> _key, scope: scope}} -> user_return_to = current_path(conn) |> String.replace(~r/prompt=(login|none)/, "") |> String.replace(~r/max_age=(\d+)/, "") {:ok, jwt, _payload} = Joken.encode_and_sign( %{ "client_id" => ClientsAdapter.public!().id, "scope" => scope, "user_return_to" => user_return_to }, BorutaIdentityWeb.Token.application_signer() ) jwt {:ok, %_{client_id: client_id, scope: scope}} -> # NOTE remove prompt and max_age params affecting redirections user_return_to = current_path(conn) |> String.replace(~r/prompt=(login|none)/, "") |> String.replace(~r/max_age=(\d+)/, "") {:ok, jwt, _payload} = Joken.encode_and_sign( %{ "client_id" => client_id, "scope" => scope, "user_return_to" => user_return_to }, BorutaIdentityWeb.Token.application_signer() ) jwt _ -> "" end end @spec scope_from_request(conn :: Plug.Conn.t()) :: String.t() | nil def scope_from_request(%Plug.Conn{query_params: query_params}) do with {:ok, claims} <- BorutaIdentityWeb.Token.verify( query_params["request"] || "", BorutaIdentityWeb.Token.application_signer() ), {:ok, scope} <- Map.fetch(claims, "scope") do scope else _ -> nil end end @spec client_id_from_request(conn :: Plug.Conn.t()) :: String.t() | nil def client_id_from_request(%Plug.Conn{query_params: query_params}) do with {:ok, claims} <- BorutaIdentityWeb.Token.verify( query_params["request"] || "", BorutaIdentityWeb.Token.application_signer() ), {:ok, client_id} <- Map.fetch(claims, "client_id") do client_id else _ -> nil end end @spec user_return_to_from_request(conn :: Plug.Conn.t()) :: String.t() | nil def user_return_to_from_request(%Plug.Conn{query_params: query_params}) do with {:ok, claims} <- BorutaIdentityWeb.Token.verify( query_params["request"] || "", BorutaIdentityWeb.Token.application_signer() ), {:ok, user_return_to} <- Map.fetch(claims, "user_return_to") do user_return_to else _ -> nil end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/controllers/backends_controller.ex ================================================ defmodule BorutaIdentityWeb.BackendsController do # TODO test identity federation @behaviour BorutaIdentity.Accounts.FederatedSessionApplication use BorutaIdentityWeb, :controller import BorutaIdentityWeb.Authenticable, only: [ store_user_session: 2, after_sign_in_path: 1, client_id_from_request: 1 ] alias BorutaIdentity.Accounts.Federated alias BorutaIdentity.Accounts.IdentityProviderError alias BorutaIdentity.Accounts.SessionError alias BorutaIdentity.FederatedAccounts alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentityWeb.TemplateView def authorize( conn, %{ "id" => backend_id, "federated_server_name" => federated_server_name } = params ) do backend = IdentityProviders.get_backend!(backend_id) conn = case client_id_from_request(conn) do nil -> raise IdentityProviderError, "Client identifier not provided." _client_id -> put_session(conn, :request, params["request"]) end case Backend.federated_oauth_client(backend, federated_server_name) do nil -> raise IdentityProviderError, "Could not fetch associated federated server" _oauth_client -> conn |> redirect(external: Backend.federated_login_url(backend, federated_server_name)) end end def callback(conn, %{"federated_server_name" => federated_server_name} = params) do conn = request_from_session(conn) client_id = client_id_from_request(conn) FederatedAccounts.create_federated_session( conn, client_id, federated_server_name, params["code"] || "", __MODULE__ ) end @impl BorutaIdentity.Accounts.FederatedSessionApplication def user_authenticated(conn, user, session_token) do conn = request_from_session(conn) client_id = client_id_from_request(conn) :telemetry.execute( [:authentication, :log_in, :success], %{}, %{ sub: user.uid, backend: %{user.backend | type: Federated}, client_id: client_id } ) conn |> clear_session() |> store_user_session(session_token) |> put_session(:session_chosen, true) |> redirect(to: after_sign_in_path(conn)) end @impl BorutaIdentity.Accounts.FederatedSessionApplication def authentication_failure(%Plug.Conn{} = conn, %SessionError{ message: message, template: template }) do conn = request_from_session(conn) client_id = client_id_from_request(conn) :telemetry.execute( [:authentication, :log_in, :failure], %{}, %{ message: message, client_id: client_id } ) conn |> put_layout(false) |> put_status(:unauthorized) |> clear_session() |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ errors: [message] } ) end defp request_from_session(conn) do case get_session(conn, :request) do nil -> raise IdentityProviderError, "Could not get request information." request -> %{ conn | query_params: Map.put(conn.query_params, "request", request) } end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/controllers/choose_session_controller.ex ================================================ defmodule BorutaIdentityWeb.ChooseSessionController do @behaviour BorutaIdentity.Accounts.ChooseSessionApplication use BorutaIdentityWeb, :controller import BorutaIdentityWeb.Authenticable, only: [client_id_from_request: 1] alias BorutaIdentity.Accounts alias BorutaIdentityWeb.TemplateView def index(conn, _params) do client_id = client_id_from_request(conn) Accounts.initialize_choose_session(conn, client_id, __MODULE__) end @impl BorutaIdentity.Accounts.ChooseSessionApplication def choose_session_initialized(conn, template) do current_user = conn.assigns[:current_user] conn |> put_session(:session_chosen, true) |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{current_user: current_user}) end @impl BorutaIdentity.Accounts.ChooseSessionApplication def choose_session_not_required(conn) do conn |> put_session(:session_chosen, true) |> redirect(to: Routes.user_session_path(conn, :new, conn.query_params)) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/controllers/fallback_controller.ex ================================================ defmodule BorutaIdentityWeb.FallbackController do @moduledoc """ Translates controller action results into valid `Plug.Conn` responses. See `Phoenix.Controller.action_fallback/1` for more details. """ use BorutaIdentityWeb, :controller alias BorutaIdentityWeb.ErrorView def call(conn, {:error, :not_found}) do conn |> put_status(:not_found) |> put_view(ErrorView) |> render(:"404") end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/controllers/totp_controller.ex ================================================ defmodule BorutaIdentityWeb.TotpController do @behaviour BorutaIdentity.TotpRegistrationApplication use BorutaIdentityWeb, :controller import BorutaIdentityWeb.Authenticable, only: [ get_user_session: 1, client_id_from_request: 1, after_sign_in_path: 1 ] alias BorutaIdentity.Totp alias BorutaIdentity.TotpError alias BorutaIdentityWeb.TemplateView def new(conn, _params) do client_id = client_id_from_request(conn) current_user = conn.assigns[:current_user] totp_authenticated = Map.get( get_session(conn, :totp_authenticated) || %{}, get_user_session(conn), false ) Totp.initialize_totp_registration(conn, client_id, totp_authenticated, current_user, __MODULE__) end def register(conn, %{"totp" => totp_params}) do client_id = client_id_from_request(conn) current_user = conn.assigns.current_user totp_params = %{ totp_code: totp_params["totp_code"], totp_secret: totp_params["totp_secret"] } Totp.register_totp(conn, client_id, current_user, totp_params, __MODULE__) end @impl BorutaIdentity.TotpRegistrationApplication def totp_registration_initialized(conn, totp_secret, template) do current_user = conn.assigns.current_user conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ current_user: current_user, totp_secret: totp_secret } ) end @impl BorutaIdentity.TotpRegistrationApplication def totp_registration_error(conn, %TotpError{ changeset: %Ecto.Changeset{} = changeset, totp_secret: totp_secret, template: template }) do current_user = conn.assigns.current_user conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ changeset: changeset, current_user: current_user, totp_secret: totp_secret } ) end def totp_registration_error(conn, %TotpError{ message: error, totp_secret: totp_secret, template: template }) do current_user = conn.assigns.current_user conn |> put_layout(false) |> put_status(:unprocessable_entity) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ errors: [error], current_user: current_user, totp_secret: totp_secret } ) end @impl BorutaIdentity.TotpRegistrationApplication def totp_registration_success(%Plug.Conn{} = conn, _user) do conn |> put_flash(:info, "TOTP authenticator registered successfully.") |> put_session( :totp_authenticated, (get_session(conn, :totp_authenticated) || %{}) |> Map.put(get_user_session(conn), true) ) |> redirect( to: after_sign_in_path(conn) ) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/controllers/user_confirmation_controller.ex ================================================ defmodule BorutaIdentityWeb.UserConfirmationController do @behaviour BorutaIdentity.Accounts.ConfirmationApplication use BorutaIdentityWeb, :controller import BorutaIdentityWeb.Authenticable, only: [client_id_from_request: 1] alias BorutaIdentity.Accounts alias BorutaIdentity.Accounts.ConfirmationError alias BorutaIdentity.Accounts.User alias BorutaIdentityWeb.TemplateView def new(conn, _params) do client_id = client_id_from_request(conn) Accounts.initialize_confirmation_instructions(conn, client_id, __MODULE__) end def create(%Plug.Conn{query_params: query_params} = conn, %{"user" => %{"email" => email}}) do client_id = client_id_from_request(conn) request = Map.get(query_params, "request") confirmation_params = %{ email: email } Accounts.send_confirmation_instructions( conn, client_id, confirmation_params, &Routes.user_confirmation_url(conn, :confirm, &1, %{request: request}), __MODULE__ ) end # Do not log in the user after confirmation to avoid a # leaked token giving the user access to the account. def confirm(conn, %{"token" => token}) do client_id = client_id_from_request(conn) Accounts.confirm_user(conn, client_id, token, __MODULE__) end @impl BorutaIdentity.Accounts.ConfirmationApplication def user_confirmed(%Plug.Conn{query_params: query_params} = conn, user) do client_id = client_id_from_request(conn) :telemetry.execute( [:registration, :confirm, :success], %{}, %{ client_id: client_id, sub: user.uid, backend: user.backend, token: query_params["token"] } ) conn |> put_flash(:info, "Account confirmed successfully.") |> redirect(to: Routes.user_session_path(conn, :new, %{request: query_params["request"]})) end @impl BorutaIdentity.Accounts.ConfirmationApplication def user_confirmation_failure(%Plug.Conn{query_params: query_params} = conn, %ConfirmationError{message: message}) do client_id = client_id_from_request(conn) :telemetry.execute( [:registration, :confirm, :failure], %{}, %{ client_id: client_id, message: message, token: query_params["token"] } ) case conn.assigns[:current_user] do %User{} -> conn |> put_flash(:error, message) |> redirect(to: Routes.user_session_path(conn, :new, request: query_params["request"])) _ -> conn |> put_flash(:error, message) |> redirect(to: Routes.user_session_path(conn, :new, request: query_params["request"])) end end @impl BorutaIdentity.Accounts.ConfirmationApplication def confirmation_instructions_delivered(%Plug.Conn{query_params: query_params} = conn) do conn |> put_flash( :info, "If your email is in our system and it has not been confirmed yet, " <> "you will receive an email with instructions shortly." ) |> redirect(to: Routes.user_session_path(conn, :new, request: query_params["request"])) end @impl BorutaIdentity.Accounts.ConfirmationApplication def confirmation_instructions_initialized(conn, template) do conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{} ) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/controllers/user_consent_controller.ex ================================================ defmodule BorutaIdentityWeb.UserConsentController do @behaviour BorutaIdentity.Accounts.ConsentApplication use BorutaIdentityWeb, :controller import BorutaIdentityWeb.Authenticable, only: [client_id_from_request: 1, scope_from_request: 1, after_sign_in_path: 1] alias BorutaIdentity.Accounts alias BorutaIdentityWeb.ErrorHelpers alias BorutaIdentityWeb.TemplateView action_fallback(BorutaIdentityWeb.FallbackController) def index(conn, _params) do current_user = conn.assigns[:current_user] client_id = client_id_from_request(conn) scope = scope_from_request(conn) Accounts.initialize_consent(conn, client_id, current_user, scope, __MODULE__) end def consent(conn, params) do client_id = client_id_from_request(conn) current_user = conn.assigns[:current_user] consent_params = %{ client_id: client_id, scopes: params["scopes"] || [] } Accounts.consent(conn, client_id, current_user, consent_params, __MODULE__) end @impl BorutaIdentity.Accounts.ConsentApplication def consent_initialized(conn, client, scopes, template) do conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ scopes: scopes, client: client } ) end @impl BorutaIdentity.Accounts.ConsentApplication def consent_not_required(conn) do redirect(conn, to: after_sign_in_path(conn)) end @impl BorutaIdentity.Accounts.ConsentApplication def consented(conn, scopes) do client_id = client_id_from_request(conn) current_user = conn.assigns[:current_user] :telemetry.execute( [:authorization, :consent, :success], %{}, %{ client_id: client_id, sub: current_user.uid, backend: current_user.backend, scopes: scopes } ) redirect(conn, to: after_sign_in_path(conn)) end @impl BorutaIdentity.Accounts.ConsentApplication def consent_failed(%Plug.Conn{query_params: query_params} = conn, changeset) do message = ErrorHelpers.error_messages(changeset) |> Enum.join(", ") request = query_params["request"] client_id = client_id_from_request(conn) current_user = conn.assigns[:current_user] :telemetry.execute( [:authorization, :consent, :failure], %{}, %{ client_id: client_id, sub: current_user.uid, backend: current_user.backend, scopes: Ecto.Changeset.get_field(changeset, :scopes), message: message } ) conn |> put_flash(:error, message) |> redirect(to: Routes.user_session_path(conn, :new, %{request: request})) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/controllers/user_registration_controller.ex ================================================ defmodule BorutaIdentityWeb.UserRegistrationController do @behaviour BorutaIdentity.Accounts.RegistrationApplication use BorutaIdentityWeb, :controller import BorutaIdentityWeb.Authenticable, only: [store_user_session: 2, after_registration_path: 1, client_id_from_request: 1] alias BorutaIdentity.Accounts alias BorutaIdentity.Accounts.RegistrationError alias BorutaIdentityWeb.TemplateView def new(conn, _params) do client_id = client_id_from_request(conn) Accounts.initialize_registration(conn, client_id, __MODULE__) end def create(%Plug.Conn{query_params: query_params} = conn, %{"user" => user_params}) do client_id = client_id_from_request(conn) request = query_params["request"] registration_params = %{ email: user_params["email"], password: user_params["password"], metadata: user_params["metadata"] } Accounts.register( conn, client_id, registration_params, &Routes.user_confirmation_url(conn, :confirm, &1, %{request: request}), __MODULE__ ) end @impl BorutaIdentity.Accounts.RegistrationApplication def registration_initialized(%Plug.Conn{} = conn, template) do conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{}) end @impl BorutaIdentity.Accounts.RegistrationApplication def registration_failure(%Plug.Conn{} = conn, %RegistrationError{ changeset: %Ecto.Changeset{} = changeset, template: template }) do client_id = client_id_from_request(conn) :telemetry.execute( [:registration, :create, :failure], %{}, %{ client_id: client_id, error: changeset } ) conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ changeset: changeset } ) end @impl BorutaIdentity.Accounts.RegistrationApplication def registration_failure(%Plug.Conn{} = conn, %RegistrationError{ message: message, template: template }) do client_id = client_id_from_request(conn) :telemetry.execute( [:registration, :create, :failure], %{}, %{ client_id: client_id, error: message } ) conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ errors: [message] } ) end def registration_failure(%Plug.Conn{} = conn, %RegistrationError{ user: user, message: message, template: template }) do client_id = client_id_from_request(conn) # NOTE user is registered but his email is not confirmed :telemetry.execute( [:registration, :create, :success], %{}, %{ client_id: client_id, sub: user.uid, backend: user.backend, message: message } ) conn |> put_layout(false) |> put_flash(:info, "Confirmation email has been sent. Please go to your mailbox and follow the provided link.") |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ errors: [message] } ) end @impl BorutaIdentity.Accounts.RegistrationApplication def user_registered(conn, user, session_token) do client_id = client_id_from_request(conn) :telemetry.execute( [:registration, :create, :success], %{}, %{ client_id: client_id, sub: user.uid, backend: user.backend } ) conn |> store_user_session(session_token) |> put_session(:session_chosen, true) |> redirect(to: after_registration_path(conn)) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/controllers/user_reset_password_controller.ex ================================================ defmodule BorutaIdentityWeb.UserResetPasswordController do @behaviour BorutaIdentity.Accounts.ResetPasswordApplication use BorutaIdentityWeb, :controller import BorutaIdentityWeb.Authenticable, only: [client_id_from_request: 1] alias BorutaIdentity.Accounts alias BorutaIdentity.Accounts.ResetPasswordError alias BorutaIdentityWeb.TemplateView def new(conn, _params) do client_id = client_id_from_request(conn) Accounts.initialize_password_instructions(conn, client_id, __MODULE__) end def create(%Plug.Conn{query_params: query_params} = conn, %{"user" => %{"email" => email}}) do request = query_params["request"] client_id = client_id_from_request(conn) user_params = %{ email: email } Accounts.send_reset_password_instructions( conn, client_id, user_params, &Routes.user_reset_password_url(conn, :edit, &1, %{request: request}), __MODULE__ ) end def edit(conn, params) do client_id = client_id_from_request(conn) Accounts.initialize_password_reset(conn, client_id, params["token"], __MODULE__) end def update(conn, params) do client_id = client_id_from_request(conn) user_params = Map.get(params, "user", %{}) reset_password_params = %{ reset_password_token: params["token"], password: user_params["password"], password_confirmation: user_params["password_confirmation"] } Accounts.reset_password(conn, client_id, reset_password_params, __MODULE__) end @impl BorutaIdentity.Accounts.ResetPasswordApplication def password_instructions_initialized( conn, template ) do conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{} ) end @impl BorutaIdentity.Accounts.ResetPasswordApplication def reset_password_instructions_delivered(%Plug.Conn{query_params: query_params} = conn) do request = query_params["request"] conn |> put_flash( :info, "If your email is in our system, you will receive instructions to reset your password shortly." ) |> redirect(to: Routes.user_session_path(conn, :new, %{request: request})) end @impl BorutaIdentity.Accounts.ResetPasswordApplication def password_reset_initialized( conn, token, template ) do conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{token: token} ) end @impl BorutaIdentity.Accounts.ResetPasswordApplication def password_reseted(%Plug.Conn{query_params: query_params} = conn, _user) do request = query_params["request"] # Do not log in the user after reset password to avoid a # leaked token giving the user access to the account. conn |> put_flash(:info, "Password reset successfully.") |> redirect(to: Routes.user_session_path(conn, :new, %{request: request})) end @impl BorutaIdentity.Accounts.ResetPasswordApplication def password_reset_failure(%Plug.Conn{} = conn, %ResetPasswordError{ template: template, changeset: %Ecto.Changeset{} = changeset, token: token }) do conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ changeset: changeset, token: token } ) end @impl BorutaIdentity.Accounts.ResetPasswordApplication def password_reset_failure(conn, %ResetPasswordError{ template: template, token: token, message: message }) do conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ errors: [message], token: token } ) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/controllers/user_session_controller.ex ================================================ defmodule BorutaIdentityWeb.UserSessionController do @behaviour BorutaIdentity.Accounts.SessionApplication @behaviour BorutaIdentity.TotpAuthenticationApplication @behaviour BorutaIdentity.WebauthnAuthenticationApplication use BorutaIdentityWeb, :controller import BorutaIdentityWeb.Authenticable, only: [ store_user_session: 2, get_user_session: 1, remove_user_session: 1, after_sign_in_path: 1, after_sign_out_path: 1, client_id_from_request: 1 ] alias BorutaIdentity.Accounts alias BorutaIdentity.Accounts.SessionError alias BorutaIdentity.Totp alias BorutaIdentity.TotpError alias BorutaIdentity.Webauthn alias BorutaIdentity.WebauthnError alias BorutaIdentityWeb.TemplateView def new(conn, _params) do client_id = client_id_from_request(conn) Accounts.initialize_session(conn, client_id, __MODULE__) end def create(conn, %{"user" => user_params}) do client_id = client_id_from_request(conn) authentication_params = %{ email: user_params["email"], password: user_params["password"] } Accounts.create_session(conn, client_id, authentication_params, __MODULE__) end def delete(conn, _params) do client_id = client_id_from_request(conn) session_token = get_user_session(conn) Accounts.delete_session(conn, client_id, session_token, __MODULE__) end def initialize_totp(conn, _params) do client_id = client_id_from_request(conn) current_user = conn.assigns[:current_user] conn |> Totp.initialize_totp(client_id, current_user, __MODULE__) end def authenticate_totp(conn, %{"totp" => totp_params}) do client_id = client_id_from_request(conn) current_user = conn.assigns[:current_user] totp_params = %{ totp_code: totp_params["totp_code"] } Totp.authenticate_totp(conn, client_id, current_user, totp_params, __MODULE__) end def initialize_webauthn(conn, _params) do client_id = client_id_from_request(conn) current_user = conn.assigns[:current_user] conn |> Webauthn.initialize_webauthn(client_id, current_user, __MODULE__) end @dialyzer {:no_return, {:authenticate_webauthn, 2}} def authenticate_webauthn(conn, params) do client_id = client_id_from_request(conn) current_user = conn.assigns[:current_user] webauthn_params = %{ signature: params["signature"], authenticator_data: params["authenticator_data"], client_data: params["client_data"], identifier: params["identifier"], type: params["type"] } Webauthn.authenticate_webauthn(conn, client_id, current_user, webauthn_params, __MODULE__) end @impl BorutaIdentity.WebauthnAuthenticationApplication def webauthn_registration_missing(%Plug.Conn{query_params: query_params} = conn) do conn |> put_flash(:warning, "You need to register a TOTP authenticator before continue.") |> redirect( to: Routes.webauthn_path(BorutaIdentityWeb.Endpoint, :new, %{request: query_params["request"]}) ) end @impl BorutaIdentity.Accounts.SessionApplication def session_initialized(%Plug.Conn{} = conn, template) do conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{} ) end @impl BorutaIdentity.Accounts.SessionApplication def user_authenticated(conn, user, session_token) do client_id = client_id_from_request(conn) :telemetry.execute( [:authentication, :log_in, :success], %{}, %{ sub: user.uid, backend: user.backend, client_id: client_id } ) conn |> store_user_session(session_token) |> Totp.initialize_totp(client_id, user, __MODULE__) end @impl BorutaIdentity.Accounts.SessionApplication def authentication_failure(%Plug.Conn{} = conn, %SessionError{ message: message, template: template }) do client_id = client_id_from_request(conn) :telemetry.execute( [:authentication, :log_in, :failure], %{}, %{ message: message, client_id: client_id } ) conn |> put_layout(false) |> put_status(:unauthorized) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ errors: [message] } ) end @impl BorutaIdentity.Accounts.SessionApplication def session_deleted(conn) do client_id = client_id_from_request(conn) user = conn.assigns[:current_user] :telemetry.execute( [:authentication, :log_out, :success], %{}, %{ sub: user && user.uid, backend: user && user.backend, client_id: client_id } ) conn |> remove_user_session() |> put_flash(:info, "Logged out successfully.") |> redirect(to: after_sign_out_path(conn)) end @impl BorutaIdentity.TotpAuthenticationApplication def totp_not_required(conn) do conn |> put_session(:session_chosen, true) |> redirect(to: after_sign_in_path(conn)) end @impl BorutaIdentity.TotpAuthenticationApplication def totp_registration_missing(%Plug.Conn{query_params: query_params} = conn) do conn |> put_flash(:warning, "You need to register a TOTP authenticator before continue.") |> redirect( to: Routes.totp_path(BorutaIdentityWeb.Endpoint, :new, %{request: query_params["request"]}) ) end @impl BorutaIdentity.TotpAuthenticationApplication def totp_initialized(%Plug.Conn{} = conn, template) do current_user = conn.assigns[:current_user] conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ current_user: current_user } ) end @impl BorutaIdentity.WebauthnAuthenticationApplication def webauthn_initialized(%Plug.Conn{} = conn, webauthn_options, template) do current_user = conn.assigns[:current_user] conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ current_user: current_user, webauthn_options: webauthn_options } ) end @impl BorutaIdentity.TotpAuthenticationApplication def totp_authenticated(%Plug.Conn{} = conn, _user) do conn |> put_session( :totp_authenticated, (get_session(conn, :totp_authenticated) || %{}) |> Map.put(get_user_session(conn), true) ) |> put_session(:session_chosen, true) |> redirect(to: after_sign_in_path(conn)) end @impl BorutaIdentity.TotpAuthenticationApplication def totp_authentication_failure(%Plug.Conn{} = conn, %TotpError{ message: message, template: template }) do current_user = conn.assigns.current_user conn |> put_layout(false) |> put_status(:unauthorized) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ errors: [message], current_user: current_user } ) end @impl BorutaIdentity.WebauthnAuthenticationApplication def webauthn_not_required(conn) do conn |> put_session(:session_chosen, true) |> redirect(to: after_sign_in_path(conn)) end @impl BorutaIdentity.WebauthnAuthenticationApplication def webauthn_authenticated(%Plug.Conn{} = conn, _user) do conn |> put_session( :webauthn_authenticated, (get_session(conn, :webauthn_authenticated) || %{}) |> Map.put(get_user_session(conn), true) ) |> put_session(:session_chosen, true) |> redirect(to: after_sign_in_path(conn)) end @impl BorutaIdentity.WebauthnAuthenticationApplication def webauthn_authentication_failure(%Plug.Conn{} = conn, %WebauthnError{ message: message, webauthn_options: webauthn_options, template: template }) do current_user = conn.assigns.current_user conn |> put_layout(false) |> put_status(:unauthorized) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ errors: [message], webauthn_options: webauthn_options, current_user: current_user } ) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/controllers/user_settings_controller.ex ================================================ defmodule BorutaIdentityWeb.UserSettingsController do @behaviour BorutaIdentity.Accounts.SettingsApplication use BorutaIdentityWeb, :controller import BorutaIdentityWeb.Authenticable, only: [ client_id_from_request: 1, get_user_session: 1, remove_user_session: 1, after_sign_out_path: 1 ] alias BorutaIdentity.Accounts alias BorutaIdentity.Accounts.SettingsError alias BorutaIdentityWeb.TemplateView def edit(conn, _params) do client_id = client_id_from_request(conn) current_user = conn.assigns[:current_user] Accounts.initialize_edit_user(conn, client_id, current_user, __MODULE__) end def update(%Plug.Conn{query_params: query_params} = conn, %{"user" => user_params}) do request = query_params["request"] client_id = client_id_from_request(conn) current_user = conn.assigns[:current_user] user_update_params = user_params |> Enum.map(fn {key, value} -> {String.to_atom(key), value} end) |> Enum.into(%{}) Accounts.update_user( conn, client_id, current_user, user_update_params, &Routes.user_confirmation_url(conn, :confirm, &1, %{request: request}), __MODULE__ ) end def destroy(conn, _params) do client_id = client_id_from_request(conn) current_user = conn.assigns[:current_user] Accounts.destroy_user(conn, client_id, current_user, __MODULE__) end @impl BorutaIdentity.Accounts.SettingsApplication def edit_user_initialized(conn, user, template) do conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{current_user: user}) end @impl BorutaIdentity.Accounts.SettingsApplication def user_updated(%Plug.Conn{query_params: query_params} = conn, user) do request = Map.get(query_params, "request") client_id = client_id_from_request(conn) :telemetry.execute( [:registration, :update, :success], %{}, %{ client_id: client_id, sub: user.uid, backend: user.backend } ) conn |> put_flash(:info, "Your information has been updated.") |> redirect(to: Routes.user_settings_path(conn, :edit, request: request)) end @impl BorutaIdentity.Accounts.SettingsApplication def user_update_failure(%Plug.Conn{} = conn, %SettingsError{ changeset: %Ecto.Changeset{} = changeset, template: template }) do client_id = client_id_from_request(conn) user = conn.assigns[:current_user] :telemetry.execute( [:registration, :update, :failure], %{}, %{ client_id: client_id, sub: user.uid, backend: user.backend, error: changeset } ) conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ changeset: changeset } ) end def user_update_failure(%Plug.Conn{} = conn, %SettingsError{ message: message, template: template }) do client_id = client_id_from_request(conn) user = conn.assigns[:current_user] :telemetry.execute( [:registration, :update, :failure], %{}, %{ client_id: client_id, sub: user.uid, backend: user.backend, error: message } ) conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ errors: [message] } ) end @impl BorutaIdentity.Accounts.SettingsApplication def user_destroyed(conn, user) do client_id = client_id_from_request(conn) session_token = get_user_session(conn) :telemetry.execute( [:registration, :destroy, :success], %{}, %{ client_id: client_id, uid: user.uid, id: user.id, backend: user.backend } ) conn |> remove_user_session() |> put_flash(:info, "User data destroyed.") Accounts.delete_session(conn, client_id, session_token, __MODULE__) end def session_deleted(conn) do client_id = client_id_from_request(conn) user = conn.assigns[:current_user] :telemetry.execute( [:authentication, :log_out, :success], %{}, %{ sub: user && user.uid, backend: user && user.backend, client_id: client_id } ) conn |> remove_user_session() |> put_flash(:info, "Your data has been deleted.") |> redirect(to: after_sign_out_path(conn)) end @impl BorutaIdentity.Accounts.SettingsApplication def user_destroy_failure(%Plug.Conn{} = conn, %SettingsError{ message: message, template: template }) do client_id = client_id_from_request(conn) user = conn.assigns[:current_user] :telemetry.execute( [:registration, :destroy, :failure], %{}, %{ client_id: client_id, uid: user.uid, id: user.id, backend: user.backend, error: message } ) conn |> put_layout(false) |> put_view(TemplateView) |> put_status(:unprocessable_entity) |> render("template.html", template: template, assigns: %{ errors: [message] } ) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/controllers/wallet_controller.ex ================================================ defmodule BorutaIdentityWeb.WalletController do use BorutaIdentityWeb, :controller def index(conn, _params) do conn |> put_layout(false) |> render("index.html") end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/controllers/webauthn_controller.ex ================================================ defmodule BorutaIdentityWeb.WebauthnController do @behaviour BorutaIdentity.WebauthnRegistrationApplication use BorutaIdentityWeb, :controller import BorutaIdentityWeb.Authenticable, only: [ get_user_session: 1, client_id_from_request: 1, after_sign_in_path: 1 ] alias BorutaIdentity.Webauthn alias BorutaIdentity.WebauthnError alias BorutaIdentityWeb.TemplateView def new(conn, _params) do client_id = client_id_from_request(conn) current_user = conn.assigns[:current_user] webauthn_authenticated = Map.get( get_session(conn, :webauthn_authenticated) || %{}, get_user_session(conn), false ) Webauthn.initialize_webauthn_registration(conn, client_id, webauthn_authenticated, current_user, __MODULE__) end def register(conn, params) do client_id = client_id_from_request(conn) current_user = conn.assigns[:current_user] webauthn_params = %{ attestation: params["attestation"], client_data: params["client_data"], identifier: params["identifier"], type: params["type"] } Webauthn.register_webauthn(conn, client_id, current_user, webauthn_params, __MODULE__) end @impl BorutaIdentity.WebauthnRegistrationApplication def webauthn_registration_initialized(conn, webauthn_options, template) do current_user = conn.assigns[:current_user] conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ current_user: current_user, webauthn_options: webauthn_options } ) end @impl BorutaIdentity.WebauthnRegistrationApplication def webauthn_registration_error(conn, %WebauthnError{ message: message, webauthn_options: webauthn_options, template: template }) do current_user = conn.assigns[:current_user] conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ errors: [message], webauthn_options: webauthn_options, current_user: current_user } ) end @impl BorutaIdentity.WebauthnRegistrationApplication def webauthn_registration_success(%Plug.Conn{} = conn, _user) do conn |> put_flash(:info, "Passkey registered successfully.") |> put_session( :webauthn_authenticated, (get_session(conn, :webauthn_authenticated) || %{}) |> Map.put(get_user_session(conn), true) ) |> redirect(to: after_sign_in_path(conn)) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/endpoint.ex ================================================ defmodule BorutaIdentityWeb.Endpoint do use Phoenix.Endpoint, otp_app: :boruta_identity @session_options [ store: :cookie, key: "_boruta_web_key", signing_salt: "OCKBuS86" ] # Serve at "/" the static files from "priv/static" directory. # # You should set gzip to true if you are running phx.digest # when deploying your static files in production. plug RemoteIp plug Plug.Static, at: "/", from: :boruta_identity, gzip: false, only: ~w(images wallet manifest.json favicon.ico robots.txt semantic-ui.min.css) plug Plug.RequestId plug Plug.Telemetry, event_prefix: [:boruta_identity, :endpoint], log: {__MODULE__, :log_level, []} plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], json_decoder: Phoenix.json_library() plug Plug.MethodOverride plug Plug.Head plug Plug.Session, @session_options plug BorutaIdentityWeb.Router def log_level(%{path_info: ["healthcheck" | _]}), do: false def log_level(_), do: :info end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/gettext.ex ================================================ defmodule BorutaIdentityWeb.Gettext do @moduledoc """ A module providing Internationalization with a gettext-based API. By using [Gettext](https://hexdocs.pm/gettext), your module gains a set of macros for translations, for example: import BorutaIdentityWeb.Gettext # Simple translation gettext("Here is the string to translate") # Plural translation ngettext("Here is the string to translate", "Here are the strings to translate", 3) # Domain-based translation dgettext("errors", "Here is the error message to translate") See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. """ use Gettext.Backend, otp_app: :boruta_identity end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/plugs/sessions.ex ================================================ defmodule BorutaIdentityWeb.Sessions do @moduledoc false use BorutaIdentityWeb, :controller import BorutaIdentityWeb.Authenticable, only: [remember_me_cookie: 0, after_sign_in_path: 1] alias BorutaIdentity.Accounts @doc """ Authenticates the user by looking into the session and remember me token. """ @spec fetch_current_user(conn :: Plug.Conn.t(), list()) :: conn :: Plug.Conn.t() def fetch_current_user(conn, _opts) do {user_token, conn} = ensure_user_token(conn) user = user_token && Accounts.get_user_by_session_token(user_token) assign(conn, :current_user, user) end defp ensure_user_token(conn) do if user_token = get_session(conn, :user_token) do {user_token, conn} else conn = fetch_cookies(conn, signed: [remember_me_cookie()]) if user_token = conn.cookies[remember_me_cookie()] do {user_token, put_session(conn, :user_token, user_token)} else {nil, conn} end end end @doc """ Used for routes that require the user to not be authenticated. """ @spec redirect_if_user_is_authenticated(conn :: Plug.Conn.t(), list()) :: conn :: Plug.Conn.t() def redirect_if_user_is_authenticated(conn, _opts) do if conn.assigns[:current_user] do conn |> redirect(to: after_sign_in_path(conn)) |> halt() else conn end end @doc """ Used for routes that require the user to be authenticated. If you want to enforce the user email is confirmed before they use the application at all, here would be a good place. """ @spec require_authenticated_user(conn :: Plug.Conn.t(), list()) :: conn :: Plug.Conn.t() def require_authenticated_user(conn, _opts) do if conn.assigns[:current_user] do conn else conn |> put_flash(:error, "You must log in to access this page.") |> redirect(to: Routes.user_session_path(conn, :new, conn.query_params)) |> halt() end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/router.ex ================================================ defmodule BorutaIdentityWeb.Router do use BorutaIdentityWeb, :router use Plug.ErrorHandler import BorutaIdentityWeb.Sessions, only: [ fetch_current_user: 2, redirect_if_user_is_authenticated: 2, require_authenticated_user: 2 ] require Logger alias BorutaIdentity.Configuration alias BorutaIdentity.Configuration.ErrorTemplate pipeline :browser do plug(:accepts, ["html"]) plug(:fetch_session) plug(:fetch_flash) plug(:protect_from_forgery) plug(:put_secure_browser_headers) plug(:fetch_current_user) end pipeline :api do plug(:accepts, ["json"]) end # scope "/", BorutaIdentityWeb do # pipe_through :browser # get "/", PageController, :index # end ## Authentication routes scope "/", BorutaIdentityWeb do pipe_through([:browser, :redirect_if_user_is_authenticated]) get("/users/register", UserRegistrationController, :new) post("/users/register", UserRegistrationController, :create) get("/users/log_in", UserSessionController, :new) post("/users/log_in", UserSessionController, :create) get("/users/reset_password", UserResetPasswordController, :new) post("/users/reset_password", UserResetPasswordController, :create) end scope "/", BorutaIdentityWeb do pipe_through([:browser, :require_authenticated_user]) get("/users/totp_registration", TotpController, :new) post("/users/totp_registration", TotpController, :register) get("/users/webauthn_registration", WebauthnController, :new) post("/users/register_webauthn", WebauthnController, :register) get("/users/totp", UserSessionController, :initialize_totp) post("/users/totp_authenticate", UserSessionController, :authenticate_totp) get("/users/webauthn", UserSessionController, :initialize_webauthn) post("/users/webauthn_authenticate", UserSessionController, :authenticate_webauthn) get("/users/choose_session", ChooseSessionController, :index) get("/users/consent", UserConsentController, :index) post("/users/consent", UserConsentController, :consent) get("/users/settings", UserSettingsController, :edit) put("/users/settings", UserSettingsController, :update) post("/users/destroy", UserSettingsController, :destroy) end scope "/", BorutaIdentityWeb do pipe_through([:browser]) get("/backends/:id/:federated_server_name/authorize", BackendsController, :authorize) get("/backends/:id/:federated_server_name/callback", BackendsController, :callback) get("/users/log_out", UserSessionController, :delete) get("/users/confirm", UserConfirmationController, :new) post("/users/confirm", UserConfirmationController, :create) get("/users/confirm/:token", UserConfirmationController, :confirm) get("/users/reset_password/:token", UserResetPasswordController, :edit) put("/users/reset_password/:token", UserResetPasswordController, :update) get("/wallet", WalletController, :index) end scope "/wallet", BorutaIdentityWeb do pipe_through(:browser) match(:get, "/*path", WalletController, :index) end @impl Plug.ErrorHandler def handle_errors(conn, %{reason: %Plug.CSRFProtection.InvalidCSRFTokenError{message: message}}) do with [referer] <- Plug.Conn.get_req_header(conn, "referer"), %URI{path: path, query: query} <- URI.parse(referer) do uri = %URI{path: path, query: query} conn |> Plug.Conn.fetch_session() |> Phoenix.Controller.fetch_flash() |> Phoenix.Controller.put_flash(:error, message) |> Plug.Conn.put_status(:found) |> Phoenix.Controller.redirect(to: URI.to_string(uri)) else _ -> render_error(conn, message) end end def handle_errors(conn, %{reason: reason}) do reason = %{ message: Map.get(reason, :message, inspect(reason)) } render_error(conn, reason) end defp render_error(conn, reason) do Logger.error("conn: #{inspect(conn)} reason: #{inspect(reason)}") %ErrorTemplate{content: template} = Configuration.get_error_template!(conn.status) context = %{ reason: reason, boruta_logo_path: BorutaIdentityWeb.Router.Helpers.static_path( BorutaIdentityWeb.Endpoint, "/images/logo-yellow.png" ) } content = Mustachex.render(template, context) conn |> put_resp_content_type("text/html") |> send_resp(conn.status, content) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/telemetry.ex ================================================ defmodule BorutaIdentityWeb.Telemetry do @moduledoc false use Supervisor import Telemetry.Metrics def start_link(arg) do Supervisor.start_link(__MODULE__, arg, name: __MODULE__) end @impl true def init(_arg) do children = [ # Telemetry poller will execute the given period measurements # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} # Add reporters as children of your supervision tree. # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} ] Supervisor.init(children, strategy: :one_for_one) end def metrics do [ # Phoenix Metrics summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond} ), summary("phoenix.router_dispatch.stop.duration", tags: [:route], unit: {:native, :millisecond} ), # Database Metrics summary("boruta_identity.repo.query.total_time", unit: {:native, :millisecond}), summary("boruta_identity.repo.query.decode_time", unit: {:native, :millisecond}), summary("boruta_identity.repo.query.query_time", unit: {:native, :millisecond}), summary("boruta_identity.repo.query.queue_time", unit: {:native, :millisecond}), summary("boruta_identity.repo.query.idle_time", unit: {:native, :millisecond}), # VM Metrics summary("vm.memory.total", unit: {:byte, :kilobyte}), summary("vm.total_run_queue_lengths.total"), summary("vm.total_run_queue_lengths.cpu"), summary("vm.total_run_queue_lengths.io") ] end defp periodic_measurements do [ # A module, function and arguments to be invoked periodically. # This function must call :telemetry.execute/3 and a metric must be added above. # {BorutaIdentityWeb, :count_users, []} ] end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/templates/error/400.html.eex ================================================ Boruta · Phoenix Framework
<%= @reason.message %>

Request could not be processed
The given request could not be processed. Please retry with valid parameters.

================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/templates/error/403.html.eex ================================================ Boruta · Phoenix Framework
<%= @reason.message %>

Forbidden
You are forbidden to access this resource.

================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/templates/error/404.html.eex ================================================ Boruta · Phoenix Framework

Page not found
The page you requested was not found. Please contact your administrator.

================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/templates/error/500.html.eex ================================================ Boruta · Phoenix Framework

Internal server error
An unexpected error occured. Please contact your administrator.

================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/templates/layout/app.html.eex ================================================ BorutaIdentity · Identity provider "/>
<%= @inner_content %>
================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/templates/wallet/index.html.eex ================================================ Boruta · Administration panel " media="all"/> " media="all"/>
================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/token.ex ================================================ defmodule BorutaIdentityWeb.Token do @moduledoc false use Joken.Config def application_signer do Joken.Signer.create( "HS512", Application.get_env(:boruta_identity, BorutaIdentityWeb.Endpoint)[:secret_key_base] ) end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/views/error_helpers.ex ================================================ defmodule BorutaIdentityWeb.ErrorHelpers do @moduledoc """ Conveniences for translating and building error messages. """ use Phoenix.HTML def error_messages(nil), do: [] def error_messages(%Ecto.Changeset{} = changeset) do Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> Regex.replace(~r"%{(\w+)}", msg, fn _, key -> opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() end) end) |> Enum.map(&error_message/1) end def error_message({field, messages}) do message = Enum.flat_map(messages, fn errors when is_map(errors) -> Enum.map(errors, &error_message/1) message -> [message] end) |> Enum.join(", ") Phoenix.Naming.humanize(field) <> ": " <> message end def errors_tag(errors) do content_tag( :ul, Enum.map(errors, &error_tag/1) ) end def error_tag({field, {_msg, _opts} = error}) do content_tag( :li, [ content_tag( :strong, Phoenix.Naming.humanize(field) <> ":" ), content_tag(:span, " "), content_tag(:span, translate_error(error)) ] ) end def error_tag({field, ["" <> _first | _rest] = messages}) do content_tag( :li, [ content_tag( :strong, Atom.to_string(field) ), content_tag(:span, " "), content_tag(:span, Enum.join(messages, ", ")) ] ) end def error_tag({field, errors}) when is_list(errors) do Enum.map(errors, fn %{} = errors -> [ content_tag( :strong, Atom.to_string(field) ), content_tag(:span, " "), Enum.map(errors, fn error -> error_tag(error) end) ] end) end @doc """ Generates tag for inlined form input errors. """ def error_tag(form, field) do Enum.map(Keyword.get_values(form.errors, field), fn error -> content_tag(:span, translate_error(error)) end) end @doc """ Translates an error message using gettext. """ def translate_error({msg, opts}) do # When using gettext, we typically pass the strings we want # to translate as a static argument: # # # Translate "is invalid" in the "errors" domain # dgettext("errors", "is invalid") # # # Translate the number of files with plural rules # dngettext("errors", "1 file", "%{count} files", count) # # Because the error messages we show in our forms and APIs # are defined inside Ecto, we need to translate them dynamically. # This requires us to call the Gettext module passing our gettext # backend as first argument. # # Note we use the "errors" domain, which means translations # should be written to the errors.po file. The :count option is # set by Ecto and indicates we should also apply plural rules. if count = opts[:count] do Gettext.dngettext(BorutaIdentityWeb.Gettext, "errors", msg, msg, count, opts) else Gettext.dgettext(BorutaIdentityWeb.Gettext, "errors", msg, opts) end end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/views/error_view.ex ================================================ defmodule BorutaIdentityWeb.ErrorView do use BorutaIdentityWeb, :view end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/views/template_view.ex ================================================ defmodule BorutaIdentityWeb.TemplateView do use BorutaIdentityWeb, :view alias BorutaIdentity.IdentityProviders.Template alias BorutaIdentityWeb.ErrorHelpers def render("template.html", %{ conn: conn, template: %Template{ layout: layout, content: content, identity_provider: identity_provider }, assigns: assigns }) do assigns = assigns |> Map.put(:identity_provider, identity_provider) |> Map.put(:conn, conn) context = context(%{}, assigns) |> Map.put(:messages, messages(conn)) |> Map.put(:_csrf_token, Plug.CSRFProtection.get_csrf_token()) |> Map.merge(errors(assigns)) |> Map.merge(paths(conn, assigns)) |> Map.merge(identity_provider_configurations(identity_provider)) {:safe, Mustachex.render(layout.content, context, partials: %{inner_content: content})} end def context(context, %{conn: conn, identity_provider: identity_provider} = assigns) do %Plug.Conn{query_params: query_params} = conn request = Map.get(query_params, "request") backend = identity_provider.backend federated_servers = Enum.map(backend.federated_servers, fn federated_server -> federated_server_name = federated_server["name"] {federated_server_name, %{ login_url: Routes.backends_path( BorutaIdentityWeb.Endpoint, :authorize, backend.id, federated_server_name, %{request: request} ) }} end) |> Enum.into(%{}) %{federated_servers: federated_servers} |> Map.merge(context) |> context(Map.delete(assigns, :identity_provider)) end def context(context, %{current_user: current_user, totp_secret: totp_secret} = assigns) do {:ok, base64_totp_registration_qr_code} = BorutaIdentity.Totp.Admin.url(current_user.username, totp_secret) |> QRCode.create() |> QRCode.render(:svg) |> QRCode.to_base64() %{ totp_secret: totp_secret, base64_totp_registration_qr_code: base64_totp_registration_qr_code } |> Map.merge(context) |> context(Map.delete(assigns, :totp_secret)) end def context(context, %{client: client} = assigns) do client = Map.take(client, [:name]) %{client: client} |> Map.merge(context) |> context(Map.delete(assigns, :client)) end def context(context, %{resource_owner: resource_owner} = assigns) do resource_owner = Map.take(resource_owner, [:sub, :extra_claims, :claims]) %{resource_owner: resource_owner} |> Map.merge(context) |> context(Map.delete(assigns, :resource_owner)) end def context(context, %{credential_offer: credential_offer} = assigns) do {:ok, base64_credential_offer_qr_code} = text_from_credential_offer(credential_offer) |> QRCode.create() |> QRCode.render(:svg) |> QRCode.to_base64() %{ base64_credential_offer_qr_code: base64_credential_offer_qr_code, credential_offer_deeplink: text_from_credential_offer(credential_offer) } |> Map.merge(context) |> context(Map.delete(assigns, :credential_offer)) end def context(context, %{presentation_deeplink: presentation_deeplink} = assigns) do {:ok, base64_presentation_qr_code} = presentation_deeplink |> QRCode.create() |> QRCode.render(:svg) |> QRCode.to_base64() %{ base64_presentation_qr_code: base64_presentation_qr_code, presentation_deeplink: presentation_deeplink } |> Map.merge(context) |> context(Map.delete(assigns, :presentation_deeplink)) end def context(context, %{webauthn_options: webauthn_options} = assigns) do options = Map.from_struct(webauthn_options) %{webauthn_options: options} |> Map.merge(context) |> context(Map.delete(assigns, :webauthn_options)) end def context(context, %{code: code} = assigns) do %{code: code} |> Map.merge(context) |> context(Map.delete(assigns, :code)) end def context(context, %{current_user: current_user} = assigns) do current_user = Map.take(current_user, [:username, :webauthn_registered_at, :totp_registered_at, :metadata]) current_user = %{ current_user | totp_registered_at: current_user.totp_registered_at && current_user.totp_registered_at |> DateTime.truncate(:second) |> DateTime.to_string(), webauthn_registered_at: current_user.webauthn_registered_at && current_user.webauthn_registered_at |> DateTime.truncate(:second) |> DateTime.to_string() } %{current_user: current_user} |> Map.merge(context) |> context(Map.delete(assigns, :current_user)) end def context(context, %{scopes: scopes} = assigns) do scopes = Enum.map(scopes, &Map.from_struct/1) %{scopes: scopes} |> Map.merge(context) |> context(Map.delete(assigns, :scopes)) end def context(context, %{}), do: context defp text_from_credential_offer(credential_offer) do # TODO Jason.Encode implementation for CredentialOfferResponse "#{credential_offer.redirect_uri}?credential_offer=#{credential_offer |> Map.from_struct() |> Map.take([:credential_configuration_ids, :client_id, :credential_issuer, :grants]) |> Jason.encode!() |> URI.encode_www_form()}" end defp paths(conn, assigns) do %Plug.Conn{query_params: query_params} = conn request = Map.get(query_params, "request") %{ boruta_logo_path: Routes.static_path(BorutaIdentityWeb.Endpoint, "/images/logo-yellow.png"), choose_session_path: Routes.choose_session_path(BorutaIdentityWeb.Endpoint, :index, %{request: request}), create_user_reset_password_path: Routes.user_reset_password_path(BorutaIdentityWeb.Endpoint, :create, %{request: request}), create_user_confirmation_path: Routes.user_confirmation_path(BorutaIdentityWeb.Endpoint, :create, %{request: request}), create_user_consent_path: Routes.user_consent_path(conn, :consent, %{request: request}), create_user_registration_path: Routes.user_registration_path(BorutaIdentityWeb.Endpoint, :create, %{request: request}), create_user_session_path: Routes.user_session_path(BorutaIdentityWeb.Endpoint, :create, %{request: request}), create_user_session_totp_authentication_path: Routes.user_session_path(BorutaIdentityWeb.Endpoint, :authenticate_totp, %{ request: request }), create_user_session_webauthn_authentication_path: Routes.user_session_path(BorutaIdentityWeb.Endpoint, :authenticate_webauthn, %{ request: request }), delete_user_session_path: Routes.user_session_path(BorutaIdentityWeb.Endpoint, :delete, %{request: request}), edit_user_path: Routes.user_settings_path(BorutaIdentityWeb.Endpoint, :edit, %{request: request}), destroy_user_path: Routes.user_settings_path(BorutaIdentityWeb.Endpoint, :destroy, %{request: request}), new_user_totp_registration_path: Routes.totp_path(BorutaIdentityWeb.Endpoint, :new, %{request: request}), create_user_totp_registration_path: Routes.totp_path(BorutaIdentityWeb.Endpoint, :register, %{request: request}), new_user_webauthn_registration_path: Routes.webauthn_path(BorutaIdentityWeb.Endpoint, :new, %{request: request}), create_user_webauthn_registration_path: Routes.webauthn_path(BorutaIdentityWeb.Endpoint, :register, %{request: request}), new_user_registration_path: Routes.user_registration_path(BorutaIdentityWeb.Endpoint, :new, %{request: request}), new_user_reset_password_path: Routes.user_reset_password_path(BorutaIdentityWeb.Endpoint, :new, %{request: request}), new_user_session_path: Routes.user_session_path(BorutaIdentityWeb.Endpoint, :new, %{request: request}), update_user_reset_password_path: Routes.user_reset_password_path( BorutaIdentityWeb.Endpoint, :update, Map.get(assigns, :token, ""), %{request: request} ), update_user_path: Routes.user_settings_path(BorutaIdentityWeb.Endpoint, :update, %{request: request}) } end defp errors(%{errors: errors}) do formatted_errors = Enum.map(errors, &%{message: &1}) %{valid?: false, errors: formatted_errors} end defp errors(%{changeset: changeset}) do formatted_errors = changeset |> ErrorHelpers.error_messages() |> Enum.map(fn message -> %{message: message} end) %{valid?: false, errors: formatted_errors} end defp errors(_assigns), do: %{errors: [], valid?: true} defp messages(conn) do get_flash(conn) |> Enum.map(fn {type, value} -> %{ "type" => type, "content" => value } end) end defp identity_provider_configurations(identity_provider) do %{ registrable?: identity_provider.registrable, totpable?: identity_provider.totpable, user_editable?: identity_provider.user_editable } end end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web/views/wallet_view.ex ================================================ defmodule BorutaIdentityWeb.WalletView do use BorutaIdentityWeb, :view end ================================================ FILE: apps/boruta_identity/lib/boruta_identity_web.ex ================================================ defmodule BorutaIdentityWeb do @moduledoc """ The entrypoint for defining your web interface, such as controllers, views, channels and so on. This can be used in your application as: use BorutaIdentityWeb, :controller use BorutaIdentityWeb, :view The definitions below will be executed for every view, controller, etc, so keep them short and clean, focused on imports, uses and aliases. Do NOT define functions inside the quoted expressions below. Instead, define any helper function in modules and import those modules here. """ def controller do quote do use Phoenix.Controller, namespace: BorutaIdentityWeb import Plug.Conn import BorutaIdentityWeb.Gettext alias BorutaIdentityWeb.Router.Helpers, as: Routes end end def view do quote do use Phoenix.View, root: "lib/boruta_identity_web/templates", namespace: BorutaIdentityWeb # Import convenience functions from controllers import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] # Include shared imports and aliases for views unquote(view_helpers()) end end def router do quote do use Phoenix.Router import Plug.Conn import Phoenix.Controller end end def channel do quote do use Phoenix.Channel import BorutaIdentityWeb.Gettext end end defp view_helpers do quote do # Use all HTML functionality (forms, tags, etc) use Phoenix.HTML # Import basic rendering functionality (render, render_layout, etc) import Phoenix.View import BorutaIdentityWeb.ErrorHelpers import BorutaIdentityWeb.Gettext alias BorutaIdentityWeb.Router.Helpers, as: Routes end end @doc """ When used, dispatch to the appropriate controller/view/etc. """ defmacro __using__(which) when is_atom(which) do apply(__MODULE__, which, []) end end ================================================ FILE: apps/boruta_identity/mix.exs ================================================ defmodule BorutaIdentity.MixProject do use Mix.Project def project do [ app: :boruta_identity, version: "0.1.0", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", lockfile: "../../mix.lock", elixir: "~> 1.7", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix] ++ Mix.compilers(), start_permanent: Mix.env() == :prod, aliases: aliases(), deps: deps() ] end # Configuration for the OTP application. # # Type `mix help compile.app` for more information. def application do [ mod: {BorutaIdentity.Application, []}, extra_applications: [:logger, :runtime_tools, :eldap, :boruta] ] end # Specifies which paths to compile per environment. defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] # Specifies your project dependencies. # # Type `mix help deps` for examples and options. defp deps do [ {:argon2_elixir, "~> 2.0"}, {:bcrypt_elixir, "~> 3.0"}, {:boruta_auth, in_umbrella: true}, {:bypass, "~> 2.1.0", only: :test}, {:decorator, "~> 1.2"}, {:ecto_sql, "~> 3.4"}, {:ex_json_schema, "~> 0.9"}, {:ex_machina, "~> 2.4", only: :test}, {:finch, "~> 0.8"}, {:gen_smtp, "~> 1.1"}, {:gettext, "~> 0.11"}, {:jason, "~> 1.0"}, {:mox, "~> 1.0"}, {:mustachex, git: "https://github.com/jui/mustachex.git"}, {:nebulex, "~> 2.0"}, {:shards, "~> 1.0"}, {:nimble_csv, "~> 1.2"}, {:nimble_pool, "~> 0.2"}, {:oauth2, "~> 2.0"}, {:pbkdf2_elixir, "~> 2.0"}, {:phoenix, "~> 1.6.0", override: true}, {:phoenix_ecto, "~> 4.1"}, {:phoenix_html, "~> 3.0"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_pubsub, "~> 2.0"}, {:plug_cowboy, "~> 2.0"}, {:postgrex, ">= 0.0.0"}, {:qr_code, "~> 3.0.0"}, {:remote_ip, "~> 1.1"}, {:scrivener_ecto, "~> 2.7"}, {:secure_random, "~> 0.5"}, {:swoosh, "~> 1.5"}, {:telemetry_metrics, "~> 0.6"}, {:telemetry_poller, "~> 0.5"}, {:wax_, "~> 0.6.0"} ] end # Aliases are shortcuts or tasks specific to the current project. # For example, to install project dependencies and perform other setup tasks, run: # # $ mix setup # # See the documentation for `Mix` for more info on aliases. defp aliases do [ setup: ["deps.get", "ecto.setup", "cmd npm install --prefix assets"], "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] ] end end ================================================ FILE: apps/boruta_identity/priv/gettext/en/LC_MESSAGES/errors.po ================================================ ## `msgid`s in this file come from POT (.pot) files. ## ## Do not add, change, or remove `msgid`s manually here as ## they're tied to the ones in the corresponding POT file ## (with the same domain). ## ## Use `mix gettext.extract --merge` or `mix gettext.merge` ## to merge POT files into PO files. msgid "" msgstr "" "Language: en\n" ## From Ecto.Changeset.cast/4 msgid "can't be blank" msgstr "" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" msgstr "" ## From Ecto.Changeset.put_change/3 msgid "is invalid" msgstr "" ## From Ecto.Changeset.validate_acceptance/3 msgid "must be accepted" msgstr "" ## From Ecto.Changeset.validate_format/3 msgid "has invalid format" msgstr "" ## From Ecto.Changeset.validate_subset/3 msgid "has an invalid entry" msgstr "" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" msgstr "" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" msgstr "" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" msgstr "" msgid "are still associated with this entry" msgstr "" ## From Ecto.Changeset.validate_length/3 msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have %{count} item(s)" msgid_plural "should have %{count} item(s)" msgstr[0] "" msgstr[1] "" msgid "should be at least %{count} character(s)" msgid_plural "should be at least %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have at least %{count} item(s)" msgid_plural "should have at least %{count} item(s)" msgstr[0] "" msgstr[1] "" msgid "should be at most %{count} character(s)" msgid_plural "should be at most %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have at most %{count} item(s)" msgid_plural "should have at most %{count} item(s)" msgstr[0] "" msgstr[1] "" ## From Ecto.Changeset.validate_number/3 msgid "must be less than %{number}" msgstr "" msgid "must be greater than %{number}" msgstr "" msgid "must be less than or equal to %{number}" msgstr "" msgid "must be greater than or equal to %{number}" msgstr "" msgid "must be equal to %{number}" msgstr "" ================================================ FILE: apps/boruta_identity/priv/gettext/errors.pot ================================================ ## This is a PO Template file. ## ## `msgid`s here are often extracted from source code. ## Add new translations manually only if they're dynamic ## translations that can't be statically extracted. ## ## Run `mix gettext.extract` to bring this file up to ## date. Leave `msgstr`s empty as changing them here has no ## effect: edit them in PO (`.po`) files instead. ## From Ecto.Changeset.cast/4 msgid "can't be blank" msgstr "" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" msgstr "" ## From Ecto.Changeset.put_change/3 msgid "is invalid" msgstr "" ## From Ecto.Changeset.validate_acceptance/3 msgid "must be accepted" msgstr "" ## From Ecto.Changeset.validate_format/3 msgid "has invalid format" msgstr "" ## From Ecto.Changeset.validate_subset/3 msgid "has an invalid entry" msgstr "" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" msgstr "" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" msgstr "" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" msgstr "" msgid "are still associated with this entry" msgstr "" ## From Ecto.Changeset.validate_length/3 msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have %{count} item(s)" msgid_plural "should have %{count} item(s)" msgstr[0] "" msgstr[1] "" msgid "should be at least %{count} character(s)" msgid_plural "should be at least %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have at least %{count} item(s)" msgid_plural "should have at least %{count} item(s)" msgstr[0] "" msgstr[1] "" msgid "should be at most %{count} character(s)" msgid_plural "should be at most %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have at most %{count} item(s)" msgid_plural "should have at most %{count} item(s)" msgstr[0] "" msgstr[1] "" ## From Ecto.Changeset.validate_number/3 msgid "must be less than %{number}" msgstr "" msgid "must be greater than %{number}" msgstr "" msgid "must be less than or equal to %{number}" msgstr "" msgid "must be greater than or equal to %{number}" msgstr "" msgid "must be equal to %{number}" msgstr "" ================================================ FILE: apps/boruta_identity/priv/repo/migrations/.formatter.exs ================================================ [ import_deps: [:ecto_sql], inputs: ["*.exs"] ] ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20210127190501_create_users_auth_tables.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateUsersAuthTables do use Ecto.Migration def change do execute "CREATE EXTENSION IF NOT EXISTS citext", "" drop_if_exists table(:users) create table(:users, primary_key: false) do add :id, :binary_id, primary_key: true add :email, :citext, null: false add :hashed_password, :string, null: false add :confirmed_at, :naive_datetime timestamps() end create unique_index(:users, [:email]) create table(:users_tokens, primary_key: false) do add :id, :binary_id, primary_key: true add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false add :token, :binary, null: false add :context, :string, null: false add :sent_to, :string timestamps(updated_at: false) end create index(:users_tokens, [:user_id]) create unique_index(:users_tokens, [:context, :token]) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20210128080043_create_users_authorized_scopes.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateUsersAuthorizedScopes do use Ecto.Migration def change do create(table(:users_authorized_scopes, primary_key: false)) do add :id, :uuid, primary_key: true add :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false add :name, :string, null: false timestamps() end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20210208110903_user_authorized_scopes_unique_index.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.UserAuthorizedScopesUniqueIndex do use Ecto.Migration def change do create unique_index(:users_authorized_scopes, [:name, :user_id]) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20210302213536_create_consents.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateConsents do use Ecto.Migration def change do create table(:consents, primary_key: false) do add :id, :binary_id, primary_key: true add :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false add :client_id, :string, null: false add :scopes, {:array, :string}, default: [] timestamps() end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20210806194842_add_last_login_at_to_users.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddLastLoginAtToUsers do use Ecto.Migration def change do alter table(:users) do add :last_login_at, :utc_datetime_usec end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20211002132445_modify_users_confirmed_at.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.ModifyUsersConfirmedAt do use Ecto.Migration def change do alter table(:users) do modify :confirmed_at, :utc_datetime_usec end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20211129225646_create_relying_parties.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateRelyingParties do use Ecto.Migration def change do create table(:relying_parties, primary_key: false) do add :id, :binary_id, primary_key: true add :name, :string add :type, :string timestamps() end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20211130230927_create_clients_relying_parties.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateClientsRelyingParties do use Ecto.Migration def change do create table(:clients_relying_parties, primary_key: false) do add :id, :binary_id, primary_key: true add :relying_party_id, references(:relying_parties, type: :uuid, on_delete: :delete_all), null: false add :client_id, :uuid, null: false timestamps() end create index("clients_relying_parties", [:client_id], unique: true) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220117220007_add_registrable_to_relying_parties.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddRegistrableToRelyingParties do use Ecto.Migration def change do alter table(:relying_parties) do add :registrable, :boolean, null: false, default: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220118122834_add_unique_name_to_relying_parties.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddUniqueNameToRelyingParties do use Ecto.Migration def change do create index(:relying_parties, [:name], unique: true) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220120214356_create_relying_party_templates.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateRelyingPartyTemplates do use Ecto.Migration def change do create table(:relying_party_templates, primary_key: false) do add :id, :binary_id, primary_key: true add :relying_party_id, references(:relying_parties, type: :uuid, on_delete: :delete_all), null: false add :type, :string, null: false add :content, :text, default: "" timestamps() end create index(:relying_party_templates, [:relying_party_id, :type], unique: true) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220131133951_add_confirmable_to_relying_parties.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddConfirmableToRelyingParties do use Ecto.Migration def change do alter table(:relying_parties) do add :confirmable, :boolean, default: false, null: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220218144931_add_consentable_to_relying_parties.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddConsentableToRelyingParties do use Ecto.Migration def change do alter table(:relying_parties) do add :consentable, :boolean, default: false, null: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220221123627_add_choose_session_to_relying_parties.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddChooseSessionToRelyingParties do use Ecto.Migration def change do alter table(:relying_parties) do add :choose_session, :boolean, default: true, null: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220520212652_add_user_editable_to_relying_parties.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddUserEditableToRelyingParties do use Ecto.Migration def change do alter table(:relying_parties) do add :user_editable, :boolean, default: false, null: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220528155902_create_internal_users.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateInternalUsers do use Ecto.Migration def up do drop constraint(:users_authorized_scopes, "users_authorized_scopes_user_id_fkey") alter table(:users_authorized_scopes) do modify :user_id, :uuid end drop constraint(:users_tokens, "users_tokens_user_id_fkey") alter table(:users_tokens) do modify :user_id, :uuid end drop constraint(:consents, "consents_user_id_fkey") alter table(:consents) do modify :user_id, :uuid end rename table(:users), to: table(:internal_users) execute("CREATE TABLE users as (SELECT * FROM internal_users)") alter table(:users, primary_key: false) do modify :id, :uuid, primary_key: true add :provider, :string add :uid, :string remove :hashed_password end execute(""" ALTER TABLE users ALTER COLUMN provider TYPE varchar(255) USING '#{to_string(BorutaIdentity.Accounts.Internal)}' """) execute(""" ALTER TABLE users ALTER COLUMN provider SET NOT NULL """) execute(""" ALTER TABLE users ALTER COLUMN uid TYPE varchar(255) USING (users.id::varchar) """) execute(""" ALTER TABLE users ALTER COLUMN uid SET NOT NULL """) rename table(:users), :email, to: :username alter table(:internal_users) do remove :last_login_at remove :confirmed_at end create index(:users, [:provider, :uid], unique: true) alter table(:users_authorized_scopes) do modify :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false end alter table(:users_tokens) do modify :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false end alter table(:consents) do modify :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false end end def down do drop constraint(:users_authorized_scopes, "users_authorized_scopes_user_id_fkey") alter table(:users_authorized_scopes) do modify :user_id, :uuid end drop constraint(:users_tokens, "users_tokens_user_id_fkey") alter table(:users_tokens) do modify :user_id, :uuid end drop constraint(:consents, "consents_user_id_fkey") alter table(:consents) do modify :user_id, :uuid end drop index(:users, [:provider, :uid], unique: true) alter table(:users, primary_key: false) do add :hashed_password, :string end execute(""" CREATE OR REPLACE FUNCTION hashed_password(uid varchar(255)) RETURNS varchar(255) AS $$ DECLARE hp varchar(255); BEGIN SELECT hashed_password INTO hp FROM internal_users WHERE id = $1::uuid; RETURN hp; END; $$ LANGUAGE plpgsql """) execute(""" ALTER TABLE users ALTER COLUMN hashed_password TYPE varchar(255) USING hashed_password(users.uid) """) execute(""" ALTER TABLE users ALTER COLUMN hashed_password SET NOT NULL """) alter table(:users, primary_key: false) do remove :provider remove :uid end drop table(:internal_users) rename table(:users), :username, to: :email alter table(:users_authorized_scopes) do modify :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false end alter table(:users_tokens) do modify :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false end alter table(:consents) do modify :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220607201657_add_user_authorized_scopes_scope_id.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddUserAuthorizedScopesScopeId do use Ecto.Migration alias Boruta.Ecto.Admin alias BorutaAuth.Repo def up do Repo.start_link([]) {:ok, %Postgrex.Result{rows: scopes}} = BorutaAuth.Repo.query(""" SELECT id, name FROM oauth_scopes """) json_scopes = Enum.map(scopes, fn [id, name] -> %{id: id, name: name} end) execute(""" CREATE OR REPLACE FUNCTION scope_id_from_name(name varchar(255)) RETURNS uuid AS $$ DECLARE scope_id varchar(255); BEGIN SELECT scopes::jsonb ->> 'id' INTO scope_id FROM unnest(ARRAY['#{json_scopes |> Enum.join("', '")}']) as scopes WHERE scopes::jsonb ->> 'name' = $1; RETURN scope_id::uuid; END; $$ LANGUAGE plpgsql """) alter table(:users_authorized_scopes) do add(:scope_id, :uuid) end create index(:users_authorized_scopes, [:scope_id, :user_id], unique: true) drop index(:users_authorized_scopes, [:name, :user_id]) execute(""" ALTER TABLE users_authorized_scopes ALTER COLUMN scope_id TYPE varchar(255) USING scope_id_from_name(users_authorized_scopes.name) """) execute(""" ALTER TABLE users_authorized_scopes ALTER COLUMN scope_id SET NOT NULL """) alter table(:users_authorized_scopes) do remove(:name) end end def down do Repo.start_link([]) json_scopes = Admin.list_scopes() |> Enum.map(fn %{name: name, id: id} -> %{name: name, id: id} end) |> Enum.map(&Jason.encode!/1) execute(""" CREATE OR REPLACE FUNCTION scope_name_from_id(id varchar(255)) RETURNS varchar(255) AS $$ DECLARE scope_name varchar(255); BEGIN SELECT scopes::jsonb ->> 'name' INTO scope_name FROM unnest(ARRAY['#{json_scopes |> Enum.join("', '")}']) as scopes WHERE scopes::jsonb ->> 'id' = $1; RETURN scope_name; END; $$ LANGUAGE plpgsql """) alter table(:users_authorized_scopes) do add(:name, :string) end drop index(:users_authorized_scopes, [:scope_id, :user_id]) create index(:users_authorized_scopes, [:name, :user_id], unique: true) execute(""" ALTER TABLE users_authorized_scopes ALTER COLUMN name TYPE varchar(255) USING scope_name_from_id(users_authorized_scopes.scope_id) """) execute(""" ALTER TABLE users_authorized_scopes ALTER COLUMN name SET NOT NULL """) alter table(:users_authorized_scopes) do remove(:scope_id) end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220617195827_rename_relying_parties.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.RenameRelyingParties do use Ecto.Migration def change do drop index(:relying_parties, [:name]) drop constraint(:clients_relying_parties, "clients_relying_parties_relying_party_id_fkey") drop constraint(:relying_party_templates, "relying_party_templates_relying_party_id_fkey") rename table(:relying_parties), to: table(:identity_providers) rename table(:relying_party_templates), to: table(:identity_provider_templates) rename table(:clients_relying_parties), to: table(:clients_identity_providers) rename table(:identity_provider_templates), :relying_party_id, to: :identity_provider_id alter table(:identity_provider_templates) do modify :identity_provider_id, references(:identity_providers, type: :binary_id, on_delete: :delete_all) end rename table(:clients_identity_providers), :relying_party_id, to: :identity_provider_id alter table(:clients_identity_providers) do modify :identity_provider_id, references(:identity_providers, type: :binary_id, on_delete: :delete_all) end create index(:identity_providers, [:name], unique: true) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220628073937_create_error_templates.exs ================================================ defmodule BorutaAuth.Repo.Migrations.CreateErrorTemplates do use Ecto.Migration def change do create table(:error_templates, primary_key: false) do add(:id, :uuid, primary_key: true) add(:type, :string, null: false) add(:content, :text, null: false) timestamps() end create index(:error_templates, [:type], unique: true) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220812123254_create_backends.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateBackends do use Ecto.Migration def change do create table(:backends, primary_key: false) do add :id, :binary_id, primary_key: true add :name, :string, null: false add :type, :string, null: false add :password_hashing_alg, :string, null: false add :password_hashing_salt, :string, null: false, default: "" timestamps() end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220815073225_add_backend_id_to_identity_providers.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddBackendIdToIdentityProviders do use Ecto.Migration def up do backend_id = SecureRandom.uuid() now = DateTime.utc_now() execute(""" INSERT INTO backends (id, type, name, password_hashing_alg, inserted_at, updated_at) VALUES ('#{backend_id}', 'Elixir.BorutaIdentity.Accounts.Internal', 'Default', 'argon2', '#{DateTime.to_iso8601(now)}', '#{DateTime.to_iso8601(now)}') """) alter table(:identity_providers) do add :backend_id, references(:backends, type: :binary_id, on_delete: :nothing) end execute(""" UPDATE identity_providers SET backend_id = '#{backend_id}' """) alter table(:identity_providers) do modify :backend_id, :binary_id, null: false end end def down do alter table(:identity_providers) do remove :backend_id, references(:backends, type: :binary_id, on_delete: :nothing), null: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220815091033_remove_type_from_identity_providers.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.RemoveTypeFromIdentityProviders do use Ecto.Migration def change do alter table(:identity_providers) do remove :type, :string, null: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220815115719_add_default_to_backends.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddDefaultToBackends do use Ecto.Migration def change do alter table(:backends) do add :is_default, :boolean, default: false, null: false end execute(""" UPDATE backends SET is_default = true WHERE id = (SELECT id FROM backends LIMIT 1) """) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220816074610_add_password_hashing_opts_to_backends.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddPasswordHashingOptsToBackends do use Ecto.Migration def change do alter table(:backends) do remove :password_hashing_salt, :string add :password_hashing_opts, :map, default: %{} end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220817134821_change_users_provider_to_backend_id.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.ChangeUsersProviderToBackendId do use Ecto.Migration def up do alter table(:users) do remove :provider, :string add :backend_id, references(:backends, type: :uuid, on_delete: :nothing) end execute(""" UPDATE users SET backend_id = ( SELECT id FROM backends WHERE type = 'Elixir.BorutaIdentity.Accounts.Internal' LIMIT 1 ) """) create index(:users, [:backend_id, :uid], unique: true) alter table(:users) do modify :backend_id, :uuid, null: false end end def down do alter table(:users) do add :provider, :string, default: "Elixir.BorutaIdentity.Accounts.Internal" remove :backend_id end create index(:users, [:provider, :uid], unique: true) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220817150643_add_backend_id_to_internal_users.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddBackendIdToInternalUsers do use Ecto.Migration def up do alter table(:internal_users) do add :backend_id, references(:backends, type: :uuid, on_delete: :nothing) end execute(""" UPDATE internal_users SET backend_id = ( SELECT id FROM backends WHERE type = 'Elixir.BorutaIdentity.Accounts.Internal' LIMIT 1 ) """) create index(:internal_users, [:backend_id, :email], unique: true) alter table(:internal_users) do modify :backend_id, :uuid, null: false end end def down do alter table(:internal_users) do remove :backend_id end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220826055043_add_mail_configuration_to_backends.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddMailConfigurationToBackends do use Ecto.Migration def change do alter table(:backends) do add :smtp_from, :string add :smtp_relay, :string add :smtp_username, :string add :smtp_password, :string add :smtp_tls, :string add :smtp_port, :integer end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220904073628_create_pg_trgm_extension.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreatePgTrgmExtension do use Ecto.Migration def up do execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") end def down do execute("DROP EXTENSION pg_trgm") end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220904183116_create_trgm_index.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateTrgmIndex do use Ecto.Migration def up do execute("CREATE INDEX username_trgm_idx ON users USING GIN (username gin_trgm_ops)") end def down do execute("DROP INDEX username_trgm_idx") end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220911195248_add_ldap_configuration_to_backends.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddLdapConfigurationToBackends do use Ecto.Migration def change do alter table(:backends) do add :ldap_pool_size, :integer, default: 5 add :ldap_host, :string add :ldap_password, :string add :ldap_base_dn, :string add :ldap_ou, :string end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20220915191039_add_ldap_ser_rdn_attribute_to_backends.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddLdapSerRdnAttributeToBackends do use Ecto.Migration def change do alter table(:backends) do remove :ldap_password, :string add :ldap_user_rdn_attribute, :string end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20221008202236_add_ldap_master_credentials_to_backends.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddLdapMasterCredentialsToBackends do use Ecto.Migration def change do alter table(:backends) do add :ldap_master_dn, :string add :ldap_master_password, :string end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20221026092004_create_email_templates.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateEmailTemplates do use Ecto.Migration def change do create table(:email_templates, primary_key: false) do add :id, :binary_id, primary_key: true add :type, :string, null: false add :txt_content, :text, default: "" add :html_content, :text, default: "" add :backend_id, references(:backends, type: :uuid, on_delete: :delete_all), null: false timestamps() end create index(:email_templates, [:backend_id, :type], unique: true) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20221026211130_add_smtp_ssl_to_backends.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddSmtpSslToBackends do use Ecto.Migration def change do alter table(:backends) do add :smtp_ssl, :boolean end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20221028083326_add_revoked_at_to_users_tokens.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddRevokedAtToUsersTokens do use Ecto.Migration def change do alter table(:users_tokens) do add :revoked_at, :utc_datetime_usec end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20221108140432_add_metadata_fields_to_backends.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddMetadataFieldsToBackends do use Ecto.Migration def change do alter table(:backends) do add :metadata_fields, {:array, :jsonb}, default: [] end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20221108144651_add_metadata_to_users.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddMetadataToUsers do use Ecto.Migration def change do alter table(:users) do add :metadata, :jsonb, default: "{}", null: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20230303151220_add_group_to_users.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddGroupToUsers do use Ecto.Migration def change do alter table(:users) do add :group, :string end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20230502111802_add_identity_federation_to_backends.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddIdentityFederationToBackends do use Ecto.Migration def change do alter table(:backends) do add :federated_servers, {:array, :jsonb}, default: [] end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20230605073651_create_roles.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateRoles do use Ecto.Migration def change do create table(:roles, primary_key: false) do add :id, :uuid, primary_key: true add :name, :string, null: false timestamps() end create index(:roles, [:name], unique: true) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20230605074117_create_roles_scopes.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateRolesScopes do use Ecto.Migration def change do create table(:roles_scopes, primary_key: false) do add :id, :uuid, primary_key: true add :role_id, references(:roles, type: :uuid, on_delete: :delete_all), null: false add :scope_id, :uuid, null: false timestamps() end create index(:roles_scopes, [:role_id, :scope_id], unique: true) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20230615082520_create_roles_users.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateRolesUsers do use Ecto.Migration def change do create table(:roles_users, primary_key: false) do add :id, :uuid, primary_key: true add :role_id, references(:roles, type: :uuid, on_delete: :delete_all), null: false add :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false timestamps() end create index(:roles_users, [:role_id, :user_id], unique: true) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20230626064411_create_backends_roles.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateBackendsRoles do use Ecto.Migration def change do create table(:backends_roles, primary_key: false) do add :id, :uuid, primary_key: true add :backend_id, references(:backends, type: :uuid, on_delete: :delete_all), null: false add :role_id, references(:roles, type: :uuid, on_delete: :delete_all), null: false timestamps() end create index(:backends_roles, [:backend_id, :role_id], unique: true) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20230805134343_add_totpable_to_identity_providers.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddTotpableToIdentityProviders do use Ecto.Migration def change do alter table(:identity_providers) do add :totpable, :boolean, default: false, null: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20230805160200_add_totp_to_users.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddTotpToUsers do use Ecto.Migration def change do alter table(:users) do add :totp_secret, :string add :totp_registered_at, :utc_datetime_usec end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20230810130509_add_enforce_totp_to_identity_providers.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddEnforceTotpToIdentityProviders do use Ecto.Migration def change do alter table(:identity_providers) do add :enforce_totp, :boolean, default: false, null: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20230903150227_create_organizations.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateOrganizations do use Ecto.Migration def change do create table(:organizations, primary_key: false) do add :id, :uuid, primary_key: true add :name, :string, null: false timestamps() end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20230908094746_add_label_to_organizations.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddLabelToOrganizations do use Ecto.Migration def change do alter table(:organizations) do add :label, :string end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20230908113944_create_organizations_users.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.CreateOrganizationsUsers do use Ecto.Migration def change do create table(:organizations_users, primary_key: false) do add :id, :uuid, primary_key: true add :organization_id, references(:organizations, type: :uuid), null: false add :user_id, references(:users, type: :uuid), null: false timestamps() end create index(:organizations_users, [:organization_id, :user_id], unique: true) end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20230909103013_add_create_default_organization_to_backends.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddCreateDefaultOrganizationToBackends do use Ecto.Migration def change do alter table(:backends) do add :create_default_organization, :boolean, null: false, default: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20240109125818_add_verifiable_credentails_to_backends.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddVerifiableCredentailsToBackends do use Ecto.Migration def change do alter table(:backends) do add :verifiable_credentials, {:array, :jsonb}, default: [] end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20240110094020_add_federated_metadata_to_users.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddFederatedMetadataToUsers do use Ecto.Migration def change do alter table(:users) do add :federated_metadata, :jsonb, default: "{}" end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20240426110841_organizations_users_reference.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.OrganizationsUsersReference do use Ecto.Migration def up do execute "ALTER TABLE organizations_users DROP CONSTRAINT organizations_users_organization_id_fkey" execute "ALTER TABLE organizations_users DROP CONSTRAINT organizations_users_user_id_fkey" alter table(:organizations_users, primary_key: false) do modify :organization_id, references(:organizations, type: :uuid, on_delete: :nothing), null: false modify :user_id, references(:users, type: :uuid, on_delete: :nothing), null: false end end def down do execute "ALTER TABLE organizations_users DROP CONSTRATAINT organizations_users_organization_id_fkey" execute "ALTER TABLE organizations_users DROP CONSTRATAINT organizations_users_user_id_fkey" alter table(:organizations_users, primary_key: false) do modify :organization_id, references(:organizations, type: :uuid), null: false modify :user_id, references(:users, type: :uuid), null: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20240505104631_organizations_users_reference2.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.OrganizationsUsersReference2 do use Ecto.Migration def up do execute "ALTER TABLE organizations_users DROP CONSTRAINT organizations_users_organization_id_fkey" execute "ALTER TABLE organizations_users DROP CONSTRAINT organizations_users_user_id_fkey" alter table(:organizations_users, primary_key: false) do modify :organization_id, references(:organizations, type: :uuid, on_delete: :delete_all), null: false modify :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false end end def down do execute "ALTER TABLE organizations_users DROP CONSTRATAINT organizations_users_organization_id_fkey" execute "ALTER TABLE organizations_users DROP CONSTRATAINT organizations_users_user_id_fkey" alter table(:organizations_users, primary_key: false) do modify :organization_id, references(:organizations, type: :uuid), null: false modify :user_id, references(:users, type: :uuid), null: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20240808195715_add_webauthn_challenge_to_users.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddWebauthnChallengeToUsers do use Ecto.Migration def change do alter table(:users) do add :webauthn_challenge, :string end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20240820140733_add_webauthn_public_key_to_users.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddWebauthnPublicKeyToUsers do use Ecto.Migration def change do alter table(:users) do add :webauthn_public_key, :text add :webauthn_registered_at, :utc_datetime_usec add :webauthn_identifier, :string end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20240820233604_add_webauthn_to_identity_providers.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddWebauthnToIdentityProviders do use Ecto.Migration def change do alter table(:identity_providers) do add :webauthnable, :boolean, default: false, null: false add :enforce_webauthn, :boolean, default: false, null: false end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20240907103609_add_verifiable_presentations_to_backends.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddVerifiablePresentationsToBackends do use Ecto.Migration def change do alter table(:backends) do add :verifiable_presentations, {:array, :jsonb}, default: [] end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20241017153124_add_account_type_to_users.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddAccountTypeToUsers do use Ecto.Migration def change do alter table(:users) do add :account_type, :string end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20241130000259_add_check_password_to_identity_providers.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.AddCheckPasswordToIdentityProviders do use Ecto.Migration def change do alter table(:identity_providers) do add :check_password, :boolean, null: false, default: true end end end ================================================ FILE: apps/boruta_identity/priv/repo/migrations/20250302193825_remove_global_email_unique_constraint.exs ================================================ defmodule BorutaIdentity.Repo.Migrations.RemoveGlobalEmailUniqueConstraint do use Ecto.Migration def change do execute("DROP INDEX users_email_index") end end ================================================ FILE: apps/boruta_identity/priv/repo/seeds.exs ================================================ ================================================ FILE: apps/boruta_identity/priv/templates/choose_session/index.mustache ================================================

Continue ?

Log out{{#user_editable?}} | Edit your information{{/user_editable?}}
================================================ FILE: apps/boruta_identity/priv/templates/confirmations/new.mustache ================================================

Resend confirmation instructions

{{^valid?}}
{{#errors}}
  • {{ message }}
  • {{/errors}}
    {{/valid?}}

    Log in | {{#registrable?}}Register | {{/registrable?}}Forgot your password?

    ================================================ FILE: apps/boruta_identity/priv/templates/consents/new.mustache ================================================

    {{ client.name }} require access to your data

    {{#scopes}}
    {{/scopes}}
    ================================================ FILE: apps/boruta_identity/priv/templates/emails/confirmation_instructions.html.mustache ================================================

    Hi {{ user.username }},

    You can confirm your account by visiting the link below:

    Confirm your account


    If you didn't create an account with us, please ignore this.

    ================================================ FILE: apps/boruta_identity/priv/templates/emails/confirmation_instructions.txt.mustache ================================================ ============================== Hi {{ user.username }}, You can confirm your account by visiting the URL below: {{ url }} If you didn't create an account with us, please ignore this. ============================== ================================================ FILE: apps/boruta_identity/priv/templates/emails/reset_password_instructions.html.mustache ================================================

    Hi {{ user.username }},

    You can reset your password by visiting the link below:

    Reset your password


    If you didn't request this change, please ignore this.

    ================================================ FILE: apps/boruta_identity/priv/templates/emails/reset_password_instructions.txt.mustache ================================================ ============================== Hi {{ user.username }}, You can reset your password by visiting the URL below: {{ url }} If you didn't request this change, please ignore this. ============================== ================================================ FILE: apps/boruta_identity/priv/templates/emails/tx_code.html.mustache ================================================

    Hi {{ user.username }},

    Here is your transaction code to fill in in order to obtain your verifiable credential:

    {{ tx_code }}


    If you didn't requested a verifiable credential, please ignore this.

    ================================================ FILE: apps/boruta_identity/priv/templates/emails/tx_code.txt.mustache ================================================ ============================== Hi {{ user.username }}, Here is your transaction code to fill in in order to obtain your verifiable credential: {{ tx_code }} If you didn't requested a verifiable credential, please ignore this. ============================== ================================================ FILE: apps/boruta_identity/priv/templates/errors/400.mustache ================================================ Boruta · Authorization server

    Request could not be processed
    The given request could not be processed. Please retry with valid parameters.

    {{ reason.message }}
    Powered by
    ================================================ FILE: apps/boruta_identity/priv/templates/errors/401.mustache ================================================ Boruta · Authorization server

    Unauthorized
    You are not authorized to access this resource.

    {{ reason.message }}
    Powered by
    ================================================ FILE: apps/boruta_identity/priv/templates/errors/403.mustache ================================================ Boruta · Authorization server

    Forbidden
    You are forbidden to access this resource.

    {{ reason.message }}
    Powered by
    ================================================ FILE: apps/boruta_identity/priv/templates/errors/404.mustache ================================================ Boruta · Authorization server

    Page not found
    The page you requested was not found. Please contact your administrator.

    Powered by
    ================================================ FILE: apps/boruta_identity/priv/templates/errors/500.mustache ================================================ Boruta · Authorization server

    Internal server error
    An unexpected error occurred. Please contact your administrator.

    Powered by
    ================================================ FILE: apps/boruta_identity/priv/templates/layouts/app.mustache ================================================ Boruta · Identity provider
    No
    Learning
    Paradox
    {{#messages}}
    {{ content }}
    {{/messages}}
    {{> inner_content }}
    Powered by
    ================================================ FILE: apps/boruta_identity/priv/templates/mfa/totp/authentication.mustache ================================================

    TOTP authentication

    {{^valid?}}
    {{#errors}}
  • {{ message }}
  • {{/errors}}
    {{/valid?}}
    Provide the TOTP code from your authenticator

    ================================================ FILE: apps/boruta_identity/priv/templates/mfa/totp/registration.mustache ================================================ {{#user_editable?}}Edit your information{{/user_editable?}}

    Add TOTP authentication from an authenticator

    {{^valid?}}
    {{#errors}}
  • {{ message }}
  • {{/errors}}
    {{/valid?}}

    1. Scan the QR code with your authenticator


    QR code

    ================================================ FILE: apps/boruta_identity/priv/templates/mfa/webauthn/authentication.mustache ================================================

    Authenticate with a passkey

    {{^valid?}}
    {{#errors}}
  • {{ message }}
  • {{/errors}}
    {{/valid?}}
    or Register a passkey ================================================ FILE: apps/boruta_identity/priv/templates/mfa/webauthn/registration.mustache ================================================ {{#user_editable?}}Edit your information{{/user_editable?}}

    Register a passkey

    {{^valid?}}
    {{#errors}}
  • {{ message }}
  • {{/errors}}
    {{/valid?}}
    ================================================ FILE: apps/boruta_identity/priv/templates/registrations/new.mustache ================================================

    Register

    {{^valid?}}
    {{#errors}}
  • {{ message }}
  • {{/errors}}
    {{/valid?}}

    Log in | Forgot your password?

    ================================================ FILE: apps/boruta_identity/priv/templates/reset_passwords/edit.mustache ================================================

    Reset password

    {{^valid?}}
    {{#errors}}
  • {{ message }}
  • {{/errors}}
    {{/valid?}}

    {{#registrable?}}Register | {{/registrable?}}Log in

    ================================================ FILE: apps/boruta_identity/priv/templates/reset_passwords/new.mustache ================================================

    Forgot your password?

    {{#registrable?}}Register | {{/registrable?}}Log in

    ================================================ FILE: apps/boruta_identity/priv/templates/sessions/new.mustache ================================================

    Log in

    {{^valid?}}
    {{#errors}}
  • {{ message }}
  • {{/errors}}
    {{/valid?}}

    {{#registrable?}}Register | {{/registrable?}}Forgot your password?

    ================================================ FILE: apps/boruta_identity/priv/templates/settings/credential_offer.mustache ================================================

    Credential offer

    {{^valid?}}
    {{#errors}}
  • {{ message }}
  • {{/errors}}
    {{/valid?}}

    Scan the QR code with your identity wallet


    QR code

    Reload
    ================================================ FILE: apps/boruta_identity/priv/templates/settings/edit_user.mustache ================================================ Back

    Edit user

    {{^valid?}}
    {{#errors}}
  • {{ message }}
  • {{/errors}}
    {{/valid?}} {{#current_user.totp_registered_at}}
    TOTP registered at {{ . }}
    {{/current_user.totp_registered_at}} {{#current_user.webauthn_registered_at}}
    Passkey registered at {{ . }}
    {{/current_user.webauthn_registered_at}} Register a TOTP authenticator
    Register a Passkey
    ================================================ FILE: apps/boruta_identity/priv/templates/settings/verifiable_presentation.mustache ================================================

    Credential presentation

    {{^valid?}}
    {{#errors}}
  • {{ message }}
  • {{/errors}}
    {{/valid?}}

    Scan the QR code with your identity wallet


    QR code

    Reload
    ================================================ FILE: apps/boruta_identity/test/boruta_identity/accounts/deliveries_test.exs ================================================ defmodule BorutaIdentity.Accounts.DeliveriesTest do use BorutaIdentity.DataCase import BorutaIdentity.AccountsFixtures import BorutaIdentity.Factory alias BorutaIdentity.Accounts.Deliveries describe "deliver_user_reset_password_instructions/2" do setup do {:ok, user: user_fixture(%{backend: insert(:smtp_backend)})} end test "sends token through notification", %{user: user} do reset_password_url_fun = fn _ -> "http://test.host" end assert {:ok, _email} = Deliveries.deliver_user_reset_password_instructions( user.backend, user, reset_password_url_fun ) end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity/accounts_test.exs ================================================ defmodule BorutaIdentity.AccountsTest do use BorutaIdentity.DataCase import BorutaIdentity.AccountsFixtures import BorutaIdentity.Factory import Mox alias BorutaIdentity.Accounts alias BorutaIdentity.Accounts.IdentityProviderError alias BorutaIdentity.Accounts.Internal alias BorutaIdentity.Accounts.RegistrationError alias BorutaIdentity.Accounts.ResetPasswordError alias BorutaIdentity.Accounts.SessionError alias BorutaIdentity.Accounts.SettingsError alias BorutaIdentity.Accounts.{User, UserToken} alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.IdentityProviders.ClientIdentityProvider alias BorutaIdentity.IdentityProviders.Template alias BorutaIdentity.Repo setup :set_mox_from_context defmodule DummyRegistration do @behaviour Accounts.RegistrationApplication @impl Accounts.RegistrationApplication def registration_initialized(context, template) do {:registration_initialized, context, template} end @impl Accounts.RegistrationApplication def user_registered(context, user, session_token) do {:user_registered, context, user, session_token} end @impl Accounts.RegistrationApplication def registration_failure(context, error) do {:registration_failure, context, error} end end defmodule DummySession do @behaviour Accounts.SessionApplication @impl Accounts.SessionApplication def session_initialized(context, template) do {:session_initialized, context, template} end @impl Accounts.SessionApplication def user_authenticated(context, user, session_token) do {:user_authenticated, context, user, session_token} end @impl Accounts.SessionApplication def authentication_failure(context, error) do {:authentication_failure, context, error} end @impl Accounts.SessionApplication def session_deleted(context) do {:session_deleted, context} end end defmodule DummyConfirmation do @behaviour Accounts.ConfirmationApplication @impl Accounts.ConfirmationApplication def confirmation_instructions_initialized(context, template) do {:confirmation_instructions_initialized, context, template} end @impl Accounts.ConfirmationApplication def confirmation_instructions_delivered(context) do {:confirmation_instructions_delivered, context} end @impl Accounts.ConfirmationApplication def user_confirmed(context, user) do {:user_confirmed, context, user} end @impl Accounts.ConfirmationApplication def user_confirmation_failure(context, error) do {:user_confirmation_failure, context, error} end end defmodule DummySettings do @behaviour Accounts.SettingsApplication @impl Accounts.SettingsApplication def edit_user_initialized(context, user, template) do {:edit_user_initialized, context, user, template} end @impl Accounts.SettingsApplication def user_updated(context, user) do {:user_updated, context, user} end @impl Accounts.SettingsApplication def user_destroy_failure(context, error) do {:user_destroy_failure, context, error} end @impl Accounts.SettingsApplication def user_destroyed(context, user) do {:user_destroyed, context, user} end @impl Accounts.SettingsApplication def user_update_failure(context, error) do {:user_update_failure, context, error} end end defmodule DummyResetPasswords do @behaviour Accounts.ResetPasswordApplication @impl Accounts.ResetPasswordApplication def password_instructions_initialized(context, template) do {:password_instructions_initialized, context, template} end @impl Accounts.ResetPasswordApplication def reset_password_instructions_delivered(context) do {:reset_password_instructions_delivered, context} end @impl Accounts.ResetPasswordApplication def password_reset_initialized(context, token, template) do {:passsword_reseet_initialized, context, token, template} end @impl Accounts.ResetPasswordApplication def password_reseted(context, user) do {:password_reseted, context, user} end @impl Accounts.ResetPasswordApplication def password_reset_failure(context, error) do {:password_reset_failure, context, error} end end describe "Utils.client_identity_provider/1" do test "returns an error when client_id is nil" do client_id = nil assert Accounts.Utils.client_identity_provider(client_id) == {:error, "Client identifier not provided."} end test "returns an error when client_id is unknown" do client_id = SecureRandom.uuid() assert Accounts.Utils.client_identity_provider(client_id) == {:error, "identity provider not configured for given OAuth client. " <> "Please contact your administrator."} end test "returns client identity_provider" do identity_provider = BorutaIdentity.Factory.insert(:identity_provider) %ClientIdentityProvider{client_id: client_id} = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: identity_provider ) identity_provider = Repo.preload(identity_provider, backend: :email_templates) assert Accounts.Utils.client_identity_provider(client_id) == {:ok, identity_provider} end end describe "initialize_registration/3" do setup do client_identity_provider = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: build( :identity_provider, registrable: true ) ) {:ok, client_id: client_identity_provider.client_id} end test "returns an error with nil client_id" do client_id = nil context = :context assert_raise IdentityProviderError, "Client identifier not provided.", fn -> Accounts.initialize_registration(context, client_id, DummyRegistration) end end test "returns an error with unknown client_id" do client_id = SecureRandom.uuid() context = :context assert_raise IdentityProviderError, "identity provider not configured for given OAuth client. Please contact your administrator.", fn -> Accounts.initialize_registration(context, client_id, DummyRegistration) end end test "returns an error if registration is not enabled for client identity provider" do %ClientIdentityProvider{client_id: client_id} = insert(:client_identity_provider) context = :context assert_raise IdentityProviderError, "Feature is not enabled for client identity provider.", fn -> Accounts.initialize_registration(context, client_id, DummyRegistration) end end test "returns a template", %{client_id: client_id} do context = :context assert {:registration_initialized, ^context, %Template{}} = Accounts.initialize_registration(context, client_id, DummyRegistration) end end describe "register/3" do setup do identity_provider = insert(:identity_provider, registrable: true) client_identity_provider = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: identity_provider ) {:ok, client_id: client_identity_provider.client_id, backend: identity_provider.backend} end test "returns an error with nil client_id" do context = :context client_id = nil user_params = %{} confirmation_callback_fun = & &1 assert_raise IdentityProviderError, "Client identifier not provided.", fn -> Accounts.register( context, client_id, user_params, confirmation_callback_fun, DummyRegistration ) end end test "returns an error with unknown client_id" do context = :context client_id = SecureRandom.uuid() user_params = %{} confirmation_callback_fun = & &1 assert_raise IdentityProviderError, "identity provider not configured for given OAuth client. Please contact your administrator.", fn -> Accounts.register( context, client_id, user_params, confirmation_callback_fun, DummyRegistration ) end end test "returns an error if registrations is disabled for client identity provider" do %ClientIdentityProvider{client_id: client_id} = insert(:client_identity_provider) context = :context user_params = %{} confirmation_callback_fun = & &1 assert_raise IdentityProviderError, "Feature is not enabled for client identity provider.", fn -> Accounts.register( context, client_id, user_params, confirmation_callback_fun, DummyRegistration ) end end test "returns a template on error", %{client_id: client_id} do context = :context user_params = %{} confirmation_callback_fun = & &1 assert {:registration_failure, ^context, %RegistrationError{template: %Template{}}} = Accounts.register( context, client_id, user_params, confirmation_callback_fun, DummyRegistration ) end test "requires email and password to be set", %{client_id: client_id} do context = :context user_params = %{} confirmation_callback_fun = & &1 assert {:registration_failure, ^context, %RegistrationError{changeset: changeset}} = Accounts.register( context, client_id, user_params, confirmation_callback_fun, DummyRegistration ) assert %{ password: ["can't be blank"], email: ["can't be blank"] } = errors_on(changeset) end test "validates email and password when given", %{client_id: client_id} do context = :context user_params = %{email: "not valid", password: "not valid"} confirmation_callback_fun = & &1 assert {:registration_failure, ^context, %RegistrationError{changeset: changeset}} = Accounts.register( context, client_id, user_params, confirmation_callback_fun, DummyRegistration ) assert %{ email: ["must have the @ sign and no spaces"], password: ["should be at least 12 character(s)"] } = errors_on(changeset) end test "validates maximum values for email and password for security", %{client_id: client_id} do too_long = String.duplicate("too_long", 100) context = :context user_params = %{email: too_long, password: too_long} confirmation_callback_fun = & &1 assert {:registration_failure, ^context, %RegistrationError{changeset: changeset}} = Accounts.register( context, client_id, user_params, confirmation_callback_fun, DummyRegistration ) assert "should be at most 160 character(s)" in errors_on(changeset).email assert "should be at most 80 character(s)" in errors_on(changeset).password end test "validates email uniqueness", %{client_id: client_id} do email = "test@test.test" context = :context user_params = %{email: email, password: "imaynotknowthat"} confirmation_callback_fun = & &1 Accounts.register( context, client_id, user_params, confirmation_callback_fun, DummyRegistration ) assert {:registration_failure, ^context, %RegistrationError{changeset: changeset}} = Accounts.register( context, client_id, user_params, confirmation_callback_fun, DummyRegistration ) assert "has already been taken" in errors_on(changeset).email # Now try with the upper cased email too, to check that email case is ignored. user_params = %{email: String.upcase(email), password: "imaynotknowthat"} confirmation_callback_fun = & &1 assert {:registration_failure, ^context, %RegistrationError{changeset: changeset}} = Accounts.register( context, client_id, user_params, confirmation_callback_fun, DummyRegistration ) assert "has already been taken" in errors_on(changeset).email end test "registers users with a hashed password", %{client_id: client_id} do email = unique_user_email() context = :context user_params = %{email: email, password: valid_user_password()} confirmation_callback_fun = & &1 assert {:user_registered, ^context, user, session_token} = Accounts.register( context, client_id, user_params, confirmation_callback_fun, DummyRegistration ) assert session_token assert user.username == email assert is_nil(user.confirmed_at) assert is_nil(user.password) end test "registers users with metadata", %{client_id: client_id, backend: backend} do {:ok, _backend} = Ecto.Changeset.change(backend, %{ metadata_fields: [ %{"attribute_name" => "test", "user_editable" => true}, %{"attribute_name" => "restricted_field", "user_editable" => false} ] }) |> Repo.update() metadata = %{"test" => "test value"} email = unique_user_email() context = :context user_params = %{ email: email, password: valid_user_password(), metadata: Map.put(metadata, "restricted_field", "restricted") } confirmation_callback_fun = & &1 assert {:user_registered, ^context, user, _session_token} = Accounts.register( context, client_id, user_params, confirmation_callback_fun, DummyRegistration ) assert user.metadata == %{ "test" => %{"value" => "test value", "status" => "valid", "display" => []} } end test "registers users with default organization", %{client_id: client_id, backend: backend} do {:ok, _backend} = Ecto.Changeset.change(backend, %{create_default_organization: true}) |> Repo.update() email = unique_user_email() context = :context user_params = %{ email: email, password: valid_user_password() } confirmation_callback_fun = & &1 assert {:user_registered, ^context, %User{organizations: [organization], uid: uid}, _session_token} = Accounts.register( context, client_id, user_params, confirmation_callback_fun, DummyRegistration ) new_organization_name = "default_#{uid}" assert %{organization: %{name: ^new_organization_name}} = Repo.preload(organization, :organization) end @tag :skip test "delivers a confirmation mail when identity provider confirmable" @tag :skip test "does not deliver a confirmation mail when identity provider not confirmable" end describe "initialize_session/3" do setup do client_identity_provider = BorutaIdentity.Factory.insert(:client_identity_provider) {:ok, client_id: client_identity_provider.client_id} end test "returns an error with nil client_id" do context = :context client_id = nil assert_raise IdentityProviderError, "Client identifier not provided.", fn -> Accounts.initialize_session( context, client_id, DummySession ) end end test "returns an error with unknown client_id" do context = :context client_id = SecureRandom.uuid() assert_raise IdentityProviderError, "identity provider not configured for given OAuth client. Please contact your administrator.", fn -> Accounts.initialize_session( context, client_id, DummySession ) end end test "returns identity provider and a template", %{client_id: client_id} do context = :context assert {:session_initialized, ^context, %Template{type: "new_session"}} = Accounts.initialize_session( context, client_id, DummySession ) end end describe "create_session/4 with an internal backend" do setup do confirmable_client_identity_provider = BorutaIdentity.Factory.insert( :client_identity_provider, identity_provider: insert(:identity_provider, confirmable: true) ) no_password_client_identity_provider = BorutaIdentity.Factory.insert( :client_identity_provider, identity_provider: insert(:identity_provider, check_password: false) ) client_identity_provider = BorutaIdentity.Factory.insert(:client_identity_provider) {:ok, backend: client_identity_provider.identity_provider.backend, client_id: client_identity_provider.client_id, confirmable_backend: confirmable_client_identity_provider.identity_provider.backend, confirmable_client_id: confirmable_client_identity_provider.client_id, no_password_backend: no_password_client_identity_provider.identity_provider.backend, no_password_client_id: no_password_client_identity_provider.client_id} end test "returns an error with nil client_id" do context = :context client_id = nil authentication_params = %{} assert_raise IdentityProviderError, "Client identifier not provided.", fn -> Accounts.create_session( context, client_id, authentication_params, DummySession ) end end test "returns an error with unknown client_id" do context = :context client_id = SecureRandom.uuid() authentication_params = %{} assert_raise IdentityProviderError, "identity provider not configured for given OAuth client. Please contact your administrator.", fn -> Accounts.create_session( context, client_id, authentication_params, DummySession ) end end test "returns an error with empty email", %{client_id: client_id} do context = :context authentication_params = %{email: ""} assert {:authentication_failure, ^context, %SessionError{template: %Template{type: "new_session"}} = error} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert error.message == "Invalid email or password." end test "returns an error with a wrong email", %{client_id: client_id} do context = :context authentication_params = %{email: "does_not_exist"} assert {:authentication_failure, ^context, %SessionError{template: %Template{type: "new_session"}} = error} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert error.message == "Invalid email or password." end test "returns an error without password", %{client_id: client_id} do %Internal.User{email: email} = insert(:internal_user) context = :context authentication_params = %{email: email} assert {:authentication_failure, ^context, %SessionError{template: %Template{type: "new_session"}} = error} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert error.message == "Invalid email or password." end test "returns an error with a wrong password", %{client_id: client_id} do %Internal.User{email: email} = insert(:internal_user) context = :context authentication_params = %{email: email, password: "wrong password"} assert {:authentication_failure, ^context, %SessionError{template: %Template{type: "new_session"}} = error} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert error.message == "Invalid email or password." end test "returns an error with a wrong password (confirmable)", %{ confirmable_client_id: client_id } do %Internal.User{email: email} = insert(:internal_user) context = :context authentication_params = %{email: email, password: "wrong password"} assert {:authentication_failure, ^context, %SessionError{template: %Template{type: "new_session"}} = error} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert error.message == "Invalid email or password." end test "returns an error if not confirmed", %{ confirmable_backend: backend, confirmable_client_id: client_id } do %Internal.User{email: email} = insert(:internal_user, backend: backend) context = :context authentication_params = %{email: email, password: valid_user_password()} assert {:authentication_failure, ^context, %SessionError{template: %Template{type: "new_confirmation_instructions"}} = error} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert error.message == "Email confirmation is required to authenticate." end test "authenticates the user", %{client_id: client_id, backend: backend} do %Internal.User{id: uid, email: username} = insert(:internal_user, backend: backend) context = :context authentication_params = %{email: username, password: valid_user_password()} assert {:user_authenticated, ^context, %User{ username: ^username, backend: ^backend, uid: ^uid, last_login_at: last_login_at }, session_token} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert last_login_at assert session_token end test "authenticates the user with no password", %{ no_password_client_id: client_id, no_password_backend: backend } do context = :context username = "no_password@test.test" authentication_params = %{email: username} assert {:user_authenticated, ^context, %User{ username: ^username, backend: ^backend, last_login_at: last_login_at }, session_token} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert last_login_at assert session_token end test "does not create multiple users accross multiple authentications", %{ client_id: client_id, backend: backend } do %Internal.User{id: uid, email: username} = insert(:internal_user, backend: backend) context = :context authentication_params = %{email: username, password: valid_user_password()} assert {:user_authenticated, ^context, %User{id: user_id, username: ^username, backend: ^backend, uid: ^uid}, session_token} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert session_token assert {:user_authenticated, ^context, %User{id: new_user_id, username: ^username, backend: ^backend, uid: ^uid}, session_token} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert session_token assert user_id == new_user_id end @tag :skip test "returns a valid session token" end describe "create_session/4 with a ldap backend" do setup do backend = insert(:ldap_backend) confirmable_client_identity_provider = BorutaIdentity.Factory.insert( :client_identity_provider, identity_provider: insert(:identity_provider, confirmable: true, backend: backend) ) client_identity_provider = BorutaIdentity.Factory.insert( :client_identity_provider, identity_provider: insert(:identity_provider, backend: backend) ) BorutaIdentity.LdapRepoMock |> stub(:open, fn host, _opts -> assert host == backend.ldap_host {:ok, :ldap_pid} end) |> stub(:close, fn _handle -> :ok end) {:ok, backend: backend, client_id: client_identity_provider.client_id, confirmable_backend: confirmable_client_identity_provider.identity_provider.backend, confirmable_client_id: confirmable_client_identity_provider.client_id} end test "returns an error with nil client_id" do context = :context client_id = nil authentication_params = %{} assert_raise IdentityProviderError, "Client identifier not provided.", fn -> Accounts.create_session( context, client_id, authentication_params, DummySession ) end end test "returns an error with unknown client_id" do context = :context client_id = SecureRandom.uuid() authentication_params = %{} assert_raise IdentityProviderError, "identity provider not configured for given OAuth client. Please contact your administrator.", fn -> Accounts.create_session( context, client_id, authentication_params, DummySession ) end end test "returns an error with empty email", %{client_id: client_id} do BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, email -> assert email == "" {:error, "user not found"} end) context = :context authentication_params = %{email: ""} assert {:authentication_failure, ^context, %SessionError{template: %Template{type: "new_session"}} = error} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert error.message == "Invalid email or password." end test "returns an error with a wrong email", %{client_id: client_id} do BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, email -> assert email == "does_not_exist" {:error, "user not found"} end) context = :context authentication_params = %{email: "does_not_exist"} assert {:authentication_failure, ^context, %SessionError{template: %Template{type: "new_session"}} = error} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert error.message == "Invalid email or password." end test "returns an error without password", %{client_id: client_id} do uid = "ldap_uid" username = "ldap_username" BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, email -> {:ok, {"user_dn", %{"uid" => uid, "sn" => email}}} end) |> expect(:simple_bind, fn _handle, dn, password -> assert dn == "user_dn" assert password == nil {:error, :boom} end) context = :context authentication_params = %{email: username} assert {:authentication_failure, ^context, %SessionError{template: %Template{type: "new_session"}} = error} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert error.message == "Invalid email or password." end test "returns an error with a wrong password", %{client_id: client_id} do uid = "ldap_uid" username = "ldap_username" BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, email -> {:ok, {"user_dn", %{"uid" => uid, "sn" => email}}} end) |> expect(:simple_bind, fn _handle, dn, password -> assert dn == "user_dn" assert password == "wrong password" {:error, :boom} end) context = :context authentication_params = %{email: username, password: "wrong password"} assert {:authentication_failure, ^context, %SessionError{template: %Template{type: "new_session"}} = error} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert error.message == "Invalid email or password." end test "returns an error with a wrong password (confirmable)", %{ confirmable_client_id: client_id } do uid = "ldap_uid" username = "ldap_username" BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, email -> {:ok, {"user_dn", %{"uid" => uid, "sn" => email}}} end) |> expect(:simple_bind, fn _handle, dn, password -> assert dn == "user_dn" assert password == "wrong password" {:error, :boom} end) context = :context authentication_params = %{email: username, password: "wrong password"} assert {:authentication_failure, ^context, %SessionError{template: %Template{type: "new_session"}} = error} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert error.message == "Invalid email or password." end test "returns an error if not confirmed", %{ confirmable_client_id: client_id } do uid = "ldap_uid" username = "ldap_username" BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, email -> {:ok, {"user_dn", %{"uid" => uid, "sn" => email}}} end) |> expect(:simple_bind, fn _handle, dn, password -> assert dn == "user_dn" assert password == valid_user_password() :ok end) context = :context authentication_params = %{email: username, password: valid_user_password()} assert {:authentication_failure, ^context, %SessionError{template: %Template{type: "new_confirmation_instructions"}} = error} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert error.message == "Email confirmation is required to authenticate." end test "authenticates the user", %{client_id: client_id, backend: backend} do uid = "ldap_uid" username = "ldap_username" BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, email -> {:ok, {"user_dn", %{"uid" => uid, "sn" => email}}} end) |> expect(:simple_bind, fn _handle, dn, password -> assert dn == "user_dn" assert password == valid_user_password() :ok end) context = :context authentication_params = %{email: username, password: valid_user_password()} assert {:user_authenticated, ^context, %User{ username: ^username, backend: ^backend, uid: ^uid, last_login_at: last_login_at }, session_token} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert last_login_at assert session_token end test "does not create multiple users accross multiple authentications", %{ client_id: client_id, backend: backend } do uid = "ldap_uid" username = "ldap_username" BorutaIdentity.LdapRepoMock |> expect(:search, 2, fn _handle, _backend, email -> {:ok, {"user_dn", %{"uid" => uid, "sn" => email}}} end) |> expect(:simple_bind, 2, fn _handle, dn, password -> assert dn == "user_dn" assert password == valid_user_password() :ok end) context = :context authentication_params = %{email: username, password: valid_user_password()} assert {:user_authenticated, ^context, %User{id: user_id, username: ^username, backend: ^backend, uid: ^uid}, session_token} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert session_token assert {:user_authenticated, ^context, %User{id: new_user_id, username: ^username, backend: ^backend, uid: ^uid}, session_token} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert session_token assert user_id == new_user_id end @tag :skip test "returns a valid session token" end describe "delete_session/4" do setup do client_identity_provider = BorutaIdentity.Factory.insert(:client_identity_provider) {:ok, backend: client_identity_provider.identity_provider.backend, client_id: client_identity_provider.client_id} end test "return a success when session does not exist", %{client_id: client_id} do context = :context session_token = "unexisting sessino" assert {:session_deleted, ^context} = Accounts.delete_session( context, client_id, session_token, DummySession ) end test "deletes session", %{client_id: client_id, backend: backend} do context = :context %User{id: user_id, username: email} = user_fixture(%{backend: backend}) authentication_params = %{email: email, password: valid_user_password()} assert {:user_authenticated, ^context, %User{id: ^user_id}, session_token} = Accounts.create_session( context, client_id, authentication_params, DummySession ) assert session_token assert Repo.get_by(UserToken, token: session_token) assert {:session_deleted, ^context} = Accounts.delete_session( context, client_id, session_token, DummySession ) refute Repo.get_by(UserToken, token: session_token) end end @tag :skip test "initialize_password_instructions/3" @tag :skip test "send_reset_password_instructions/5" @tag :skip test "initialize_password_reset/3" describe "reset_password/4 with an internal backend" do setup do client_identity_provider = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: build( :identity_provider, user_editable: true ) ) {:ok, client_id: client_identity_provider.client_id} end test "returns an error when password token is invalid", %{client_id: client_id} do user_token = insert(:reset_password_user_token) reset_password_params = %{ reset_password_token: user_token.token } assert {:password_reset_failure, :context, %ResetPasswordError{ message: "Given reset password token is invalid.", template: %Template{type: "edit_reset_password"} }} = Accounts.reset_password( :context, client_id, reset_password_params, DummyResetPasswords ) end test "returns an error when password params are invalid", %{client_id: client_id} do user = user_fixture() {token, user_token} = UserToken.build_email_token(user, "reset_password") {:ok, _user_token} = Repo.insert(user_token) reset_password_params = %{ reset_password_token: token, password: "password", password_confirmation: "bad password confirmation" } assert {:password_reset_failure, :context, %ResetPasswordError{ message: "Could not update user password.", changeset: %Ecto.Changeset{}, template: %Template{type: "edit_reset_password"} }} = Accounts.reset_password( :context, client_id, reset_password_params, DummyResetPasswords ) end test "returns an error with an already used token", %{client_id: client_id} do user = user_fixture() {token, user_token} = UserToken.build_email_token(user, "reset_password") {:ok, _user_token} = Repo.insert(%{user_token | revoked_at: DateTime.utc_now()}) password = "a good password" reset_password_params = %{ reset_password_token: token, password: password, password_confirmation: password } assert {:password_reset_failure, :context, %ResetPasswordError{ message: "Given reset password token is invalid.", template: %Template{type: "edit_reset_password"} }} = Accounts.reset_password( :context, client_id, reset_password_params, DummyResetPasswords ) end test "resets user password", %{client_id: client_id} do %User{id: user_id} = user = user_fixture() {token, user_token} = UserToken.build_email_token(user, "reset_password") {:ok, _user_token} = Repo.insert(user_token) password = "a good password" reset_password_params = %{ reset_password_token: token, password: password, password_confirmation: password } assert {:password_reseted, :context, %User{id: ^user_id}} = Accounts.reset_password( :context, client_id, reset_password_params, DummyResetPasswords ) end test "invalidates reset password token", %{client_id: client_id} do %User{id: user_id} = user = user_fixture() {token, user_token} = UserToken.build_email_token(user, "reset_password") {:ok, user_token} = Repo.insert(user_token) password = "a good password" reset_password_params = %{ reset_password_token: token, password: password, password_confirmation: password } assert {:password_reseted, :context, %User{id: ^user_id}} = Accounts.reset_password( :context, client_id, reset_password_params, DummyResetPasswords ) assert %UserToken{revoked_at: revoked_at} = Repo.reload(user_token) assert revoked_at end end describe "reset_password/4 with an ldap backend" do setup do backend = insert(:ldap_backend) client_identity_provider = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: build( :identity_provider, user_editable: true, backend: backend ) ) BorutaIdentity.LdapRepoMock |> stub(:open, fn host, _opts -> assert host == backend.ldap_host {:ok, :ldap_pid} end) |> stub(:close, fn _handle -> :ok end) {:ok, client_id: client_identity_provider.client_id, backend: backend} end test "returns an error when password token is invalid", %{client_id: client_id} do user_token = insert(:reset_password_user_token) reset_password_params = %{ reset_password_token: user_token.token } assert {:password_reset_failure, :context, %ResetPasswordError{ message: "Given reset password token is invalid.", template: %Template{type: "edit_reset_password"} }} = Accounts.reset_password( :context, client_id, reset_password_params, DummyResetPasswords ) end test "returns an error when password params are invalid (no ldap user)", %{ client_id: client_id, backend: backend } do user = user_fixture(%{backend: backend}) {token, user_token} = UserToken.build_email_token(user, "reset_password") {:ok, _user_token} = Repo.insert(user_token) reset_password_params = %{ reset_password_token: token, password: "password", password_confirmation: "bad password confirmation" } BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, _email -> {:ok, {"dn", %{"uid" => "uid", backend.ldap_user_rdn_attribute => "username"}}} end) assert {:password_reset_failure, :context, %ResetPasswordError{ message: "Password and password confirmation do not match.", changeset: nil, template: %Template{type: "edit_reset_password"} }} = Accounts.reset_password( :context, client_id, reset_password_params, DummyResetPasswords ) end test "returns an error when password params are invalid (ldap password error)", %{ client_id: client_id, backend: backend } do user = user_fixture(%{backend: backend}) {token, user_token} = UserToken.build_email_token(user, "reset_password") {:ok, _user_token} = Repo.insert(user_token) reset_password_params = %{ reset_password_token: token, password: "password that fails on ldap", password_confirmation: "password that fails on ldap" } BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, _email -> {:ok, {"dn", %{"uid" => "uid", backend.ldap_user_rdn_attribute => "username"}}} end) |> expect(:simple_bind, fn _handle, _master_dn, _master_password -> :ok end) |> expect(:modify_password, fn _handle, _ldap_user, _password -> {:error, "ldap error"} end) assert {:password_reset_failure, :context, %ResetPasswordError{ message: "ldap error", changeset: nil, template: %Template{type: "edit_reset_password"} }} = Accounts.reset_password( :context, client_id, reset_password_params, DummyResetPasswords ) end test "returns an error with an already used token", %{client_id: client_id, backend: backend} do user = user_fixture(%{backend: backend}) {token, user_token} = UserToken.build_email_token(user, "reset_password") {:ok, _user_token} = Repo.insert(%{user_token | revoked_at: DateTime.utc_now()}) password = "a good password" reset_password_params = %{ reset_password_token: token, password: password, password_confirmation: password } assert {:password_reset_failure, :context, %ResetPasswordError{ message: "Given reset password token is invalid.", template: %Template{type: "edit_reset_password"} }} = Accounts.reset_password( :context, client_id, reset_password_params, DummyResetPasswords ) end test "resets user password", %{client_id: client_id, backend: backend} do %User{id: user_id} = user = user_fixture(%{backend: backend}) {token, user_token} = UserToken.build_email_token(user, "reset_password") {:ok, _user_token} = Repo.insert(user_token) password = "a good password" reset_password_params = %{ reset_password_token: token, password: password, password_confirmation: password } BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, _email -> {:ok, {"dn", %{"uid" => "uid", backend.ldap_user_rdn_attribute => "username"}}} end) |> expect(:simple_bind, fn _handle, _master_dn, _master_password -> :ok end) |> expect(:modify_password, fn _handle, _ldap_user, _password -> :ok end) assert {:password_reseted, :context, %User{id: ^user_id}} = Accounts.reset_password( :context, client_id, reset_password_params, DummyResetPasswords ) end test "invalidates reset password token", %{client_id: client_id, backend: backend} do %User{id: user_id} = user = user_fixture(%{backend: backend}) {token, user_token} = UserToken.build_email_token(user, "reset_password") {:ok, user_token} = Repo.insert(user_token) password = "a good password" BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, _email -> {:ok, {"dn", %{"uid" => "uid", backend.ldap_user_rdn_attribute => "username"}}} end) |> expect(:simple_bind, fn _handle, _master_dn, _master_password -> :ok end) |> expect(:modify_password, fn _handle, _ldap_user, _password -> :ok end) reset_password_params = %{ reset_password_token: token, password: password, password_confirmation: password } assert {:password_reseted, :context, %User{id: ^user_id}} = Accounts.reset_password( :context, client_id, reset_password_params, DummyResetPasswords ) assert %UserToken{revoked_at: revoked_at} = Repo.reload(user_token) assert revoked_at end end describe "initialize_edit_user/4" do setup do client_identity_provider = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: build( :identity_provider, user_editable: true ) ) user = insert(:user) {:ok, client_id: client_identity_provider.client_id, user: user} end test "returns an error with nil client_id", %{user: user} do client_id = nil context = :context assert_raise IdentityProviderError, "Client identifier not provided.", fn -> Accounts.initialize_edit_user(context, client_id, user, DummySettings) end end test "returns an error with unknown client_id", %{user: user} do client_id = SecureRandom.uuid() context = :context assert_raise IdentityProviderError, "identity provider not configured for given OAuth client. Please contact your administrator.", fn -> Accounts.initialize_edit_user(context, client_id, user, DummySettings) end end test "returns an error if registration is not enabled for client identity provider", %{ user: user } do %ClientIdentityProvider{client_id: client_id} = insert(:client_identity_provider) context = :context assert_raise IdentityProviderError, "Feature is not enabled for client identity provider.", fn -> Accounts.initialize_edit_user(context, client_id, user, DummySettings) end end test "returns a template", %{client_id: client_id, user: user} do context = :context assert {:edit_user_initialized, ^context, ^user, %Template{}} = Accounts.initialize_edit_user(context, client_id, user, DummySettings) end end describe "update_user/5 with internal backend" do setup do backend = insert(:backend) identity_provider = build( :identity_provider, user_editable: true, backend: backend ) client_identity_provider = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: identity_provider ) user = user_fixture(%{backend: backend}) {:ok, client_id: client_identity_provider.client_id, user: user, backend: backend} end test "returns an error with unexisting user", %{client_id: client_id} do user = %User{username: "unexisting"} confirmation_url_fun = fn -> "" end assert {:user_update_failure, :context, %SettingsError{message: "User not found.", template: %Template{type: "edit_user"}}} = Accounts.update_user( :context, client_id, user, %{}, confirmation_url_fun, DummySettings ) end test "returns an error with a bad current password", %{client_id: client_id, user: user} do confirmation_url_fun = fn -> "" end assert {:user_update_failure, :context, %SettingsError{ message: "Invalid user password.", template: %Template{type: "edit_user"} }} = Accounts.update_user( :context, client_id, user, %{current_password: "bad password"}, confirmation_url_fun, DummySettings ) end test "returns an error with bad update parameters", %{client_id: client_id, user: user} do confirmation_url_fun = fn -> "" end assert {:user_update_failure, :context, %SettingsError{ message: "Could not update user with given params.", changeset: %Ecto.Changeset{}, template: %Template{type: "edit_user"} }} = Accounts.update_user( :context, client_id, user, %{current_password: valid_user_password(), email: ""}, confirmation_url_fun, DummySettings ) end test "updates user", %{client_id: client_id, user: user} do updated_email = "updated@email.test" confirmation_url_fun = fn -> "" end assert {:user_updated, :context, %User{username: ^updated_email}} = Accounts.update_user( :context, client_id, user, %{current_password: valid_user_password(), email: updated_email}, confirmation_url_fun, DummySettings ) end test "updates user with metadata", %{client_id: client_id, backend: backend} do user = user_fixture(%{ backend: backend, metadata: %{"other" => %{"value" => "other", "status" => "valid", "display" => []}} }) {:ok, _backend} = Ecto.Changeset.change(backend, %{ metadata_fields: [ %{"attribute_name" => "other", "user_editable" => false}, %{"attribute_name" => "test", "user_editable" => true} ] }) |> Repo.update() metadata = %{"test" => "test value"} updated_email = "updated@email.test" confirmation_url_fun = fn -> "" end assert {:user_updated, :context, %User{ username: ^updated_email, metadata: %{ "test" => %{"value" => "test value", "status" => "valid", "display" => []}, "other" => %{"value" => "other", "status" => "valid", "display" => []} } }} = Accounts.update_user( :context, client_id, user, %{ current_password: valid_user_password(), email: updated_email, metadata: metadata }, confirmation_url_fun, DummySettings ) end test "updates user with filtered metadata", %{ client_id: client_id, user: user, backend: backend } do {:ok, _backend} = Ecto.Changeset.change(backend, %{ metadata_fields: [ %{"attribute_name" => "test", "user_editable" => true}, %{"attribute_name" => "restricted_field", "user_editable" => false} ] }) |> Repo.update() {:ok, user} = Ecto.Changeset.change(user, %{metadata: %{"restricted_field" => "restricted"}}) |> Repo.update() metadata = %{"test" => "test value"} updated_email = "updated@email.test" confirmation_url_fun = fn -> "" end assert {:user_updated, :context, %User{username: ^updated_email}} = Accounts.update_user( :context, client_id, user, %{ current_password: valid_user_password(), email: updated_email, metadata: metadata |> Map.put("filtered", true) |> Map.put("restricted_field", "update restricted") }, confirmation_url_fun, DummySettings ) assert %User{ metadata: %{ "restricted_field" => %{"status" => "valid", "value" => "restricted"}, "test" => %{"status" => "valid", "value" => "test value"} } } = Repo.reload(user) end @tag :skip test "unconfirms user" end @tag :skip describe "update_user/5 with ldap backend" do setup do backend = insert(:ldap_backend) identity_provider = build( :identity_provider, user_editable: true, backend: backend ) client_identity_provider = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: identity_provider ) user = user_fixture(%{backend: backend}) BorutaIdentity.LdapRepoMock |> stub(:open, fn host, _opts -> assert host == backend.ldap_host {:ok, :ldap_pid} end) |> stub(:close, fn _handle -> :ok end) {:ok, client_id: client_identity_provider.client_id, backend: backend, user: user} end test "returns an error with unexisting user", %{client_id: client_id} do user = %User{username: "unexisting"} confirmation_url_fun = fn -> "" end assert {:user_update_failure, :context, %SettingsError{message: "User not found.", template: %Template{type: "edit_user"}}} = Accounts.update_user( :context, client_id, user, %{}, confirmation_url_fun, DummySettings ) end test "returns an error with a bad current password (no ldap user)", %{ client_id: client_id, user: user } do confirmation_url_fun = fn -> "" end BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, _email -> {:error, "ldap error"} end) assert {:user_update_failure, :context, %SettingsError{ message: "ldap error", template: %Template{type: "edit_user"} }} = Accounts.update_user( :context, client_id, user, %{current_password: "bad password"}, confirmation_url_fun, DummySettings ) end test "returns an error with a bad current password (ldap password error)", %{ client_id: client_id, backend: backend, user: user } do confirmation_url_fun = fn -> "" end BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, _email -> {:ok, {"dn", %{"uid" => "uid", backend.ldap_user_rdn_attribute => "username"}}} end) |> expect(:simple_bind, fn _handle, _dn, _password -> {:error, "ldap error"} end) assert {:user_update_failure, :context, %SettingsError{ message: "Authentication failure.", template: %Template{type: "edit_user"} }} = Accounts.update_user( :context, client_id, user, %{current_password: "bad password"}, confirmation_url_fun, DummySettings ) end test "returns an error with bad update parameters", %{ client_id: client_id, user: user, backend: backend } do confirmation_url_fun = fn -> "" end BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, _email -> {:ok, {"dn", %{"uid" => "uid", backend.ldap_user_rdn_attribute => "username"}}} end) |> expect(:simple_bind, fn _handle, _dn, _password -> :ok end) |> expect(:simple_bind, fn _handle, _master_dn, _master_password -> :ok end) |> expect(:modify, fn _handle, _backend, _user, "" -> {:error, "ldap error"} end) assert {:user_update_failure, :context, %SettingsError{ message: "ldap error", template: %Template{type: "edit_user"} }} = Accounts.update_user( :context, client_id, user, %{current_password: valid_user_password(), email: ""}, confirmation_url_fun, DummySettings ) end test "updates user", %{client_id: client_id, user: user, backend: backend} do updated_email = "updated@email.test" confirmation_url_fun = fn -> "" end BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, _email -> {:ok, {"dn", %{"uid" => "uid", backend.ldap_user_rdn_attribute => "username"}}} end) |> expect(:simple_bind, fn _handle, _dn, _password -> :ok end) |> expect(:simple_bind, fn _handle, _master_dn, _master_password -> :ok end) |> expect(:modify, fn _handle, _backend, _user, ^updated_email -> :ok end) assert {:user_updated, :context, %User{username: ^updated_email}} = Accounts.update_user( :context, client_id, user, %{current_password: valid_user_password(), email: updated_email}, confirmation_url_fun, DummySettings ) end test "updates user with metadata", %{client_id: client_id, user: user, backend: backend} do {:ok, _backend} = Ecto.Changeset.change(backend, %{ metadata_fields: [%{"attribute_name" => "test", "user_editable" => true}] }) |> Repo.update() metadata = %{"test" => "test value"} updated_email = "updated@email.test" confirmation_url_fun = fn -> "" end BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, _email -> {:ok, {"dn", %{"uid" => "uid", backend.ldap_user_rdn_attribute => "username"}}} end) |> expect(:simple_bind, fn _handle, _dn, _password -> :ok end) |> expect(:simple_bind, fn _handle, _master_dn, _master_password -> :ok end) |> expect(:modify, fn _handle, _backend, _user, ^updated_email -> :ok end) assert {:user_updated, :context, %User{ username: ^updated_email, metadata: %{"test" => %{"value" => "test value", "status" => "valid"}} }} = Accounts.update_user( :context, client_id, user, %{ current_password: valid_user_password(), email: updated_email, metadata: metadata }, confirmation_url_fun, DummySettings ) end test "updates user with filtered metadata", %{ client_id: client_id, user: user, backend: backend } do Ecto.Changeset.change(backend, %{ metadata_fields: [ %{"attribute_name" => "test", "user_editable" => true}, %{"attribute_name" => "restricted_field", "user_editable" => false} ] }) |> Repo.update() metadata = %{"test" => "test value"} updated_email = "updated@email.test" confirmation_url_fun = fn -> "" end BorutaIdentity.LdapRepoMock |> expect(:search, fn _handle, _backend, _email -> {:ok, {"dn", %{"uid" => "uid", backend.ldap_user_rdn_attribute => "username"}}} end) |> expect(:simple_bind, fn _handle, _dn, _password -> :ok end) |> expect(:simple_bind, fn _handle, _master_dn, _master_password -> :ok end) |> expect(:modify, fn _handle, _backend, _user, ^updated_email -> :ok end) assert {:user_updated, :context, %User{ username: ^updated_email, metadata: %{"test" => %{"value" => "test value", "status" => "valid"}} }} = Accounts.update_user( :context, client_id, user, %{ current_password: valid_user_password(), email: updated_email, metadata: metadata |> Map.put("filtered", true) |> Map.put("restricted_field", "restricted") }, confirmation_url_fun, DummySettings ) end end describe "get_user_by_email/1" do test "does not return the user if the email does not exist" do refute Accounts.get_user_by_email(Backend.default!(), "unknown@example.com") end test "returns the user if the email exists" do %{id: id} = user = user_fixture() assert %User{id: ^id} = Accounts.get_user_by_email(user.backend, user.username) end end describe "get_user_by_session_token/1" do setup do user = user_fixture() token = BorutaIdentityWeb.ConnCase.generate_user_session_token(user) %{user: user, token: token} end test "returns user by token", %{user: user, token: token} do assert session_user = Accounts.get_user_by_session_token(token) assert session_user.id == user.id end test "does not return user for invalid token" do refute Accounts.get_user_by_session_token("oops") end test "does not return user for expired token", %{token: token} do {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) refute Accounts.get_user_by_session_token(token) end end describe "send_confirmation_instructions/5" do test "returns a success" do identity_provider = BorutaIdentity.Factory.insert(:identity_provider, confirmable: true) %ClientIdentityProvider{client_id: client_id} = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: identity_provider ) context = :context confirmation_params = %{ email: "unknown@test.test" } confirmation_url_fun = & &1 assert Accounts.send_confirmation_instructions( context, client_id, confirmation_params, confirmation_url_fun, DummyConfirmation ) == { :confirmation_instructions_delivered, context } end @tag :skip test "delivers confirmation instructions when user is known" end @tag :skip test "confirm_user/4" @tag :skip test "initialize_consent/4" describe "inspect/2" do test "does not include password" do refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" end end describe "get_user_scopes/1" do test "returns an empty list" do user = user_fixture() assert Accounts.get_user_scopes(user.id) == [] end test "returns authorized scopes" do user = user_fixture() user_scope = user_scopes_fixture(user) scope_id = user_scope.scope_id assert [%Boruta.Oauth.Scope{id: ^scope_id, name: "name"}] = Accounts.get_user_scopes(user.id) end end @tag :skip test "consent/5" describe "destroy_user/4" do setup do backend = insert(:backend) identity_provider = build( :identity_provider, user_editable: true, backend: backend ) client_identity_provider = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: identity_provider ) user = user_fixture(%{backend: backend}) {:ok, client_id: client_identity_provider.client_id, user: user, backend: backend} end test "returns an error", %{client_id: client_id} do assert {:user_destroy_failure, :context, %SettingsError{ message: "User could not be deleted, please contact an administrator." }} = Accounts.destroy_user( :context, client_id, %User{uid: SecureRandom.uuid()}, DummySettings ) end test "destroys user", %{client_id: client_id, user: user} do assert {:user_destroyed, :context, %User{}} = Accounts.destroy_user( :context, client_id, user, DummySettings ) end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity/admin_test.exs ================================================ defmodule BorutaIdentity.AdminTest do use BorutaIdentity.DataCase import BorutaIdentity.AccountsFixtures import BorutaIdentity.Factory alias BorutaIdentity.Accounts.Internal alias BorutaIdentity.Accounts.LdapError alias BorutaIdentity.Accounts.User alias BorutaIdentity.Accounts.UserAuthorizedScope alias BorutaIdentity.Admin alias BorutaIdentity.Repo describe "update_user_authorized_scopes/2" do test "returns an error on duplicates" do user = insert(:user) assert {:error, %Ecto.Changeset{}} = Admin.update_user_authorized_scopes(user, [ %{"name" => "test"}, %{"name" => "test"} ]) end test "stores user scopes" do scope = Boruta.Factory.insert(:scope, name: "test") user = insert(:user) user_id = user.id scope_id = scope.id {:ok, %User{ authorized_scopes: [ %UserAuthorizedScope{ scope_id: ^scope_id, user_id: ^user_id } ] }} = Admin.update_user_authorized_scopes(user, [%{"id" => scope.id}]) assert [%{scope_id: ^scope_id, user_id: ^user_id}] = Repo.all(UserAuthorizedScope) end end describe "list_users/0" do test "returns an empty list" do assert Admin.list_users() == %Scrivener.Page{ entries: [], page_number: 1, page_size: 12, total_entries: 0, total_pages: 1 } end test "returns paginated users" do user = insert(:user) |> Repo.preload([:authorized_scopes, :roles, :organizations]) assert Admin.list_users() == %Scrivener.Page{ entries: [user], page_number: 1, page_size: 12, total_entries: 1, total_pages: 1 } end end describe "search_users/0" do test "returns an empty search" do assert Admin.search_users("query") == %Scrivener.Page{ entries: [], page_number: 1, page_size: 12, total_entries: 0, total_pages: 1 } end test "returns user search" do _other = insert(:user) |> Repo.preload(:authorized_scopes) user = insert(:user, username: "match") |> Repo.preload([:authorized_scopes, :roles, :organizations]) assert Admin.search_users("match") == %Scrivener.Page{ entries: [user], page_number: 1, page_size: 12, total_entries: 1, total_pages: 1 } end end describe "delete_user/1 with internal backend" do test "returns an error" do assert Admin.delete_user(Ecto.UUID.generate()) == {:error, :not_found} end test "returns deleted user" do %User{id: user_id, uid: user_uid} = user_fixture(%{}, "internal") assert {:ok, %User{id: ^user_id}} = Admin.delete_user(user_id) refute Repo.get(User, user_id) refute Repo.get(Internal.User, user_uid) end end describe "delete_user/1 with federated backend" do test "returns an error" do assert Admin.delete_user(Ecto.UUID.generate()) == {:error, :not_found} end test "returns deleted user" do %User{id: user_id} = user_fixture(%{}, "federated") assert {:ok, %User{id: ^user_id}} = Admin.delete_user(user_id) refute Repo.get(User, user_id) end end @tag :skip test "delete_user/1 with ldap backend" describe "create_user/2 with an internal backend" do setup do backend = insert(:backend) {:ok, backend: backend} end test "returns an error when data is invalid", %{backend: backend} do params = %{} assert {:error, %Ecto.Changeset{}} = Admin.create_user(backend, params) end test "creates a user", %{backend: backend} do params = %{username: "test@created.email", password: "a valid password"} assert {:ok, %User{}} = Admin.create_user(backend, params) end test "creates a user with metadata", %{backend: backend} do metadata_field = %{ "attribute_name" => "attribute_test" } {:ok, backend} = Ecto.Changeset.change(backend, %{ metadata_fields: [ metadata_field ] }) |> Repo.update() params = %{ username: "test@created.email", password: "a valid password", metadata: %{ "attribute_test" => %{ "value" => "attribute_test value", "status" => "valid" } } } assert {:ok, %User{ metadata: %{ "attribute_test" => %{ "value" => "attribute_test value", "status" => "valid" } } }} = Admin.create_user(backend, params) end test "returns an error with invalid metadata", %{backend: backend} do metadata_field = %{ "attribute_name" => "attribute_test" } {:ok, backend} = Ecto.Changeset.change(backend, %{ metadata_fields: [ metadata_field ] }) |> Repo.update() params = %{ username: "test@created.email", password: "a valid password", metadata: %{ "attribute_test" => %{ "value" => "attribute_test value" } } } assert Enum.empty?(Repo.all(Internal.User)) assert_raise Ecto.InvalidChangesetError, fn -> Admin.create_user(backend, params) == {:error, %Ecto.Changeset{}} end end test "creates a user with a group", %{backend: backend} do params = %{ username: "test@created.email", password: "a valid password", group: "group" } assert {:ok, %User{ group: "group" }} = Admin.create_user(backend, params) end test "creates a user with a default organization", %{backend: backend} do {:ok, backend} = Ecto.Changeset.change(backend, %{create_default_organization: true}) |> Repo.update() params = %{ username: "test@created.email", password: "a valid password" } assert {:ok, %User{ uid: uid, organizations: [organization] }} = Admin.create_user(backend, params) new_organization_name = "default_#{uid}" assert %{organization: %{name: ^new_organization_name}} = Repo.preload(organization, :organization) end @tag :skip test "creates a user with roles" end describe "create_user/2 with a ldap backend" do setup do backend = insert(:ldap_backend) {:ok, backend: backend} end test "raises an error", %{backend: backend} do params = %{} assert_raise LdapError, fn -> Admin.create_user(backend, params) end end @tag :skip test "creates a user" @tag :skip test "creates a user with roles" end describe "update_user/2 with an internal backend" do setup do user = user_fixture() {:ok, user: user} end test "updates user with metadata", %{user: user} do {:ok, _backend} = Ecto.Changeset.change(user.backend, %{metadata_fields: [%{attribute_name: "test"}]}) |> Repo.update() metadata = %{ "attribute_test" => %{ "value" => "attribute_test value", "status" => "valid" } } user_params = %{metadata: metadata} assert {:ok, %User{metadata: ^metadata}} = Admin.update_user(user, user_params) end test "returns an error with invalid metadata", %{user: user} do {:ok, _backend} = Ecto.Changeset.change(user.backend, %{metadata_fields: [%{attribute_name: "test"}]}) |> Repo.update() metadata = %{ "attribute_test" => %{ "value" => "attribute_test value" } } user_params = %{metadata: metadata} assert {:error, %Ecto.Changeset{ errors: [metadata: {"Required property status was not present. at #", []}] }} = Admin.update_user(user, user_params) end test "returns an error if group is not unique", %{user: user} do {:ok, _backend} = Ecto.Changeset.change(user.backend, %{metadata_fields: [%{attribute_name: "test"}]}) |> Repo.update() user_params = %{group: "group group"} assert {:error, %Ecto.Changeset{errors: [group: {"must be unique", []}]}} = Admin.update_user(user, user_params) end test "updates user with a group", %{user: user} do {:ok, _backend} = Ecto.Changeset.change(user.backend, %{metadata_fields: [%{attribute_name: "test"}]}) |> Repo.update() user_params = %{group: "group"} assert {:ok, %User{group: "group"}} = Admin.update_user(user, user_params) end @tag :skip test "updates user with roles" end @tag :skip test "update_user_roles/2" @tag :skip test "update_user_authorized_scopes/2" @tag :skip test "create_raw_user/2" @tag :skip test "import_users/3" @tag :skip test "delete_user_authorized_scopes_by_id/1" describe "roles" do alias BorutaIdentity.Accounts.Role import BorutaIdentity.AdminFixtures @invalid_attrs %{name: nil} test "list_roles/0 returns all roles" do role = role_fixture() assert Admin.list_roles() == [role] end test "get_role!/1 returns the role with given id" do role = role_fixture() assert Admin.get_role!(role.id) == role end test "create_role/1 with valid data creates a role" do valid_attrs = %{name: "some name"} assert {:ok, %Role{} = role} = Admin.create_role(valid_attrs) assert role.name == "some name" end test "create_role/1 with scopes creates a role" do scope = Boruta.Factory.insert(:scope) valid_attrs = %{name: "some name", scopes: [%{id: scope.id, name: scope.name}]} assert {:ok, %Role{} = role} = Admin.create_role(valid_attrs) assert role.name == "some name" scope_id = scope.id scope_name = scope.name assert [%{id: ^scope_id, name: ^scope_name}] = role.scopes end test "create_role/1 with invalid data returns error changeset" do assert {:error, %Ecto.Changeset{}} = Admin.create_role(@invalid_attrs) end test "update_role/2 with valid data updates the role" do role = role_fixture() update_attrs = %{name: "some updated name"} assert {:ok, %Role{} = role} = Admin.update_role(role, update_attrs) assert role.name == "some updated name" end test "update_role/2 with scopes updates a role" do role = role_fixture() scope = Boruta.Factory.insert(:scope) update_attrs = %{name: "some updated name", scopes: [%{id: scope.id, name: scope.name}]} assert {:ok, %Role{} = role} = Admin.update_role(role, update_attrs) assert role.name == "some updated name" scope_id = scope.id scope_name = scope.name assert [%{id: ^scope_id, name: ^scope_name}] = role.scopes end test "update_role/2 with invalid data returns error changeset" do role = role_fixture() assert {:error, %Ecto.Changeset{}} = Admin.update_role(role, @invalid_attrs) assert role == Admin.get_role!(role.id) end test "delete_role/1 deletes the role" do role = role_fixture() assert {:ok, %Role{}} = Admin.delete_role(role) assert_raise Ecto.NoResultsError, fn -> Admin.get_role!(role.id) end end test "change_role/1 returns a role changeset" do role = role_fixture() assert %Ecto.Changeset{} = Admin.change_role(role) end end defmodule OrganizationsTest do use BorutaIdentity.DataCase, async: true alias BorutaIdentity.Organizations.Organization describe "list_organizations/0" do test "returns an empty set" do assert Enum.empty?(Admin.list_organizations()) end test "returns paginated organizations" do organization = insert(:organization) assert Admin.list_organizations() == %Scrivener.Page{ page_number: 1, page_size: 500, total_entries: 1, total_pages: 1, entries: [organization] } end end describe "get_organization/1" do test "returns nil" do assert Admin.get_organization("bad id") == nil end test "returns nil with an unkown uuid" do organization_id = SecureRandom.uuid() assert Admin.get_organization(organization_id) == nil end test "returns an organization" do %Organization{id: organization_id} = organization = insert(:organization) assert Admin.get_organization(organization_id) == organization end end describe "create_organization/1" do test "returns an error with invalid params" do organization_params = %{name: nil} assert {:error, %Ecto.Changeset{}} = Admin.create_organization(organization_params) end test "creates and organization" do organization_params = %{name: "Organization"} assert {:ok, %Organization{name: "Organization"}} = Admin.create_organization(organization_params) end test "creates and organization with label" do organization_params = %{name: "Organization", label: "label"} assert {:ok, %Organization{name: "Organization", label: "label"}} = Admin.create_organization(organization_params) end end describe "update_organization/2" do test "returns an error with unkown organization" do organization = build(:organization) organization_params = %{name: nil} assert {:error, %Ecto.Changeset{}} = Admin.update_organization(organization, organization_params) end test "returns an error with invalid params" do organization = insert(:organization) organization_params = %{name: nil} assert {:error, %Ecto.Changeset{}} = Admin.update_organization(organization, organization_params) end test "updates an organization" do organization = insert(:organization) organization_params = %{name: "updated"} assert {:ok, %Organization{name: "updated"}} = Admin.update_organization(organization, organization_params) assert %Organization{name: "updated"} = Repo.reload(organization) end test "updates an organization with label" do organization = insert(:organization) organization_params = %{name: "updated", label: "updated"} assert {:ok, %Organization{name: "updated", label: "updated"}} = Admin.update_organization(organization, organization_params) assert %Organization{name: "updated"} = Repo.reload(organization) end end describe "delete_organization/1" do test "returns an error with unkown organization" do organization_id = SecureRandom.uuid() assert {:error, :not_found} = Admin.delete_organization(organization_id) end test "deletes an organization" do %Organization{id: organization_id} = insert(:organization) assert {:ok, %Organization{}} = Admin.delete_organization(organization_id) assert Repo.get(Organization, organization_id) == nil end end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity/configuration_test.exs ================================================ defmodule BorutaIdentity.ConfigurationTest do use BorutaIdentity.DataCase import BorutaIdentity.Factory alias BorutaIdentity.Configuration alias BorutaIdentity.Configuration.ErrorTemplate alias BorutaIdentity.Repo describe "get_error_template!/1" do test "returns nil with unexisting template" do assert_raise Ecto.NoResultsError, fn -> Configuration.get_error_template!(:unexisting) == nil end end test "returns default template" do template = Configuration.get_error_template!(400) assert template == ErrorTemplate.default_template(400) end test "returns error template" do template = insert(:error_template, content: "custom registration template" ) assert Configuration.get_error_template!(400) == template end end describe "upsert_error_template/1" do test "inserts with a default template" do template = Configuration.get_error_template!(400) assert {:ok, template} = Configuration.upsert_error_template(template, %{content: "new content"}) assert Repo.reload(template) end test "updates with an existing template" do template = insert(:error_template) assert {:ok, template} = Configuration.upsert_error_template(template, %{content: "new content"}) assert Repo.reload(template) end end describe "delete_error_template!/2" do test "raises an error with unexisting template" do assert_raise Ecto.NoResultsError, fn -> Configuration.delete_error_template!(:unexisting) end end test "returns an error if template is default" do assert_raise Ecto.NoResultsError, fn -> Configuration.delete_error_template!(400) end end test "deletes and returns error template" do template = insert(:error_template, content: "custom registration template" ) default_template = ErrorTemplate.default_template(400) reseted_template = Configuration.delete_error_template!(400) assert reseted_template.default == true assert reseted_template.type == "400" assert reseted_template.content == default_template.content assert Repo.get_by(ErrorTemplate, id: template.id) == nil end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity/federated_accounts_test.exs ================================================ defmodule BorutaIdentity.FederatedAccountsTest do use BorutaIdentity.DataCase alias BorutaIdentity.Accounts.IdentityProviderError alias BorutaIdentity.Accounts.SessionError alias BorutaIdentity.Accounts.User alias BorutaIdentity.FederatedAccounts defmodule DummyFederatedSessions do @behaviour BorutaIdentity.Accounts.FederatedSessionApplication @impl BorutaIdentity.Accounts.FederatedSessionApplication def user_authenticated(context, user, session_token) do {:user_authenticated, context, user, session_token} end @impl BorutaIdentity.Accounts.FederatedSessionApplication def authentication_failure(context, error) do {:authentication_failure, context, error} end end describe "create_federated_session/4" do setup do federated_backend = BorutaIdentity.Factory.insert(:federated_backend) identity_provider = BorutaIdentity.Factory.insert(:identity_provider, backend: federated_backend) client_identity_provider = BorutaIdentity.Factory.insert( :client_identity_provider, identity_provider: identity_provider ) bypass = Bypass.open(port: 7878) Bypass.up(bypass) {:ok, bypass: bypass, client_id: client_identity_provider.client_id, backend: federated_backend} end test "returns an error if client id is unknown" do context = :context access_token = "access_token" assert_raise IdentityProviderError, fn -> FederatedAccounts.create_federated_session( context, "unknown", "unknown", access_token, DummyFederatedSessions ) end end test "returns an error if federated server is unknown", %{client_id: client_id} do context = :context access_token = "access_token" assert {:authentication_failure, ^context, %SessionError{message: "Could not fetch associated federated server."}} = FederatedAccounts.create_federated_session( context, client_id, "unknown", access_token, DummyFederatedSessions ) end test "returns an error if code fails", %{ client_id: client_id, backend: backend, bypass: bypass } do federated_server = List.first(backend.federated_servers) context = :context code = "code" error = "error" Bypass.stub(bypass, "POST", federated_server["token_path"], fn conn -> Plug.Conn.resp(conn, 400, Jason.encode!(%{error: error})) end) assert {:authentication_failure, ^context, %SessionError{message: message}} = FederatedAccounts.create_federated_session( context, client_id, federated_server["name"], code, DummyFederatedSessions ) assert message =~ ~r/#{error}/ end test "returns an error if userinfo fails", %{ client_id: client_id, backend: backend, bypass: bypass } do federated_server = List.first(backend.federated_servers) context = :context code = "code" Bypass.stub(bypass, "POST", federated_server["token_path"], fn conn -> Plug.Conn.resp(conn, 200, Jason.encode!(%{access_token: "access_token"})) end) Bypass.stub(bypass, "GET", federated_server["userinfo_path"], fn conn -> Plug.Conn.resp(conn, 401, "") end) assert {:authentication_failure, ^context, %SessionError{message: "Could not fetch user information."}} = FederatedAccounts.create_federated_session( context, client_id, federated_server["name"], code, DummyFederatedSessions ) end test "creates user session", %{client_id: client_id, backend: backend, bypass: bypass} do federated_server = List.first(backend.federated_servers) context = :context code = "code" sub = "sub" Bypass.stub(bypass, "POST", federated_server["token_path"], fn conn -> Plug.Conn.resp(conn, 200, Jason.encode!(%{access_token: "access_token"})) end) Bypass.stub(bypass, "GET", federated_server["userinfo_path"], fn conn -> Plug.Conn.resp(conn, 200, Jason.encode!(%{sub: sub})) end) assert {:user_authenticated, ^context, %User{uid: ^sub}, _session_token} = FederatedAccounts.create_federated_session( context, client_id, federated_server["name"], code, DummyFederatedSessions ) end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity/identity_providers/backend_test.exs ================================================ defmodule BorutaIdentity.IdentityProviders.BackendTest do use BorutaIdentity.DataCase import BorutaIdentity.Factory alias BorutaIdentity.IdentityProviders.Backend describe "federated_login_url/2" do test "returns an empty string" do backend = insert(:backend) assert Backend.federated_login_url(backend, "inexistant") == "" end test "returns login url" do federated_servers = [ %{ "name" => "name", "client_id" => "client_id", "client_secret" => "client_secret", "base_url" => "https://host.test", "authorize_path" => "/authorize", "token_path" => "/token", "scope" => "openid email" } ] backend = insert(:backend, federated_servers: federated_servers) assert Backend.federated_login_url(backend, "name") == "https://host.test/authorize?client_id=client_id&redirect_uri=http%3A%2F%2Flocalhost%3A4003%2Fbackends%2F#{backend.id}%2Fname%2Fcallback&response_type=code&scope=openid+email" end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity/identity_providers/identity_provider_test.exs ================================================ defmodule BorutaIdentity.IdentityProviders.IdentityProviderTest do use BorutaIdentity.DataCase import BorutaIdentity.Factory alias BorutaIdentity.Accounts.Ldap alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaIdentity.IdentityProviders.Template alias BorutaIdentity.Repo describe "template/2" do setup do identity_provider = insert(:identity_provider, templates: []) identity_provider_with_template = insert(:identity_provider, templates: [ build(:new_registration_template, content: "custom new registration template") ] ) {:ok, identity_provider: identity_provider, identity_provider_with_template: identity_provider_with_template} end test "returns nil", %{identity_provider: identity_provider} do assert IdentityProvider.template(identity_provider, :unexisting) == nil end test "returns default template", %{identity_provider: identity_provider} do assert IdentityProvider.template(identity_provider, :new_registration) == %{ Template.default_template(:new_registration) | identity_provider_id: identity_provider.id, identity_provider: identity_provider } end test "returns identity provider template", %{ identity_provider_with_template: identity_provider } do assert IdentityProvider.template(identity_provider, :new_registration) == List.first(identity_provider.templates) |> Repo.preload(identity_provider: [:backend, :templates]) end end describe "check_feature/2" do setup do identity_provider = insert(:identity_provider) {:ok, identity_provider: identity_provider} end test "returns an error if feature is not supported", %{identity_provider: identity_provider} do assert IdentityProvider.check_feature(identity_provider, :not_supported) == {:error, "This provider does not support this feature."} end test "returns an error if identity provider disabled the feature", %{ identity_provider: identity_provider } do identity_provider = %{identity_provider | authenticable: false} assert IdentityProvider.check_feature(identity_provider, :initialize_session) == {:error, "Feature is not enabled for client identity provider."} end test "returns an error if identity provider backend does not support the feature", %{ identity_provider: identity_provider } do identity_provider = %{ identity_provider | registrable: true, backend: insert(:backend, type: Atom.to_string(Ldap)) } assert IdentityProvider.check_feature(identity_provider, :register) == {:error, "Feature is not enabled for identity provider backend implementation."} end test "returns ok if feature is supported", %{identity_provider: identity_provider} do assert IdentityProvider.check_feature(identity_provider, :initialize_session) == :ok end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity/identity_providers_test.exs ================================================ defmodule BorutaIdentity.IdentityProvidersTest do use BorutaIdentity.DataCase import BorutaIdentity.Factory import Mox alias BorutaIdentity.Accounts.EmailTemplate alias BorutaIdentity.Accounts.Ldap alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.IdentityProviders.ClientIdentityProvider alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaIdentity.IdentityProviders.Template alias BorutaIdentity.Repo setup :set_mox_global describe "identity_providers" do setup do backend = insert(:backend) {:ok, backend: backend} end @valid_attrs %{name: "some name", backend_id: nil} @update_attrs %{name: "some updated name"} @invalid_attrs %{name: nil} def identity_provider_fixture(attrs \\ %{}) do insert(:identity_provider, Map.merge(@valid_attrs, attrs)) |> Repo.preload(backend: :email_templates) end test "list_identity_providers/0 returns all identity_providers" do identity_provider = identity_provider_fixture() assert IdentityProviders.list_identity_providers() == [identity_provider] end test "get_identity_provider!/1 returns the identity_provider with given id" do identity_provider = identity_provider_fixture() assert IdentityProviders.get_identity_provider!(identity_provider.id) == identity_provider end test "create_identity_provider/1 with valid data creates a identity_provider", %{ backend: backend } do assert {:ok, %IdentityProvider{} = identity_provider} = IdentityProviders.create_identity_provider(%{@valid_attrs | backend_id: backend.id}) assert identity_provider.name == "some name" end test "create_identity_provider/1 with valid data (with a new template) creates a identity_provider", %{backend: backend} do templates_attrs = %{templates: [%{type: "new_registration", content: "test content"}]} assert {:ok, %IdentityProvider{} = identity_provider} = IdentityProviders.create_identity_provider( Map.merge(%{@valid_attrs | backend_id: backend.id}, templates_attrs) ) assert [%Template{type: "new_registration", content: "test content"}] = identity_provider.templates end test "create_identity_provider/1 with invalid data returns error changeset" do assert {:error, %Ecto.Changeset{ errors: [ name: {"can't be blank", [validation: :required]}, backend_id: {"can't be blank", [validation: :required]} ] }} = IdentityProviders.create_identity_provider(@invalid_attrs) end test "create_identity_provider/1 with invalid data (unique name) returns error changeset", %{ backend: backend } do identity_provider_fixture() assert {:error, %Ecto.Changeset{ errors: [ name: {"has already been taken", [constraint: :unique, constraint_name: "identity_providers_name_index"]} ] }} = IdentityProviders.create_identity_provider(%{@valid_attrs | backend_id: backend.id}) end test "update_identity_provider/2 with valid data updates the identity_provider" do identity_provider = identity_provider_fixture() assert {:ok, %IdentityProvider{} = identity_provider} = IdentityProviders.update_identity_provider(identity_provider, @update_attrs) assert identity_provider.name == "some updated name" end test "update_identity_provider/1 with valid data (with an existing template) creates a identity_provider" do identity_provider = identity_provider_fixture() template = insert(:template, identity_provider: identity_provider) templates_attrs = %{ templates: [%{id: template.id, type: "new_registration", content: "test content"}] } assert {:ok, %IdentityProvider{} = identity_provider} = IdentityProviders.update_identity_provider(identity_provider, templates_attrs) template_id = template.id assert [ %Template{ id: ^template_id, type: "new_registration", content: "test content" } ] = identity_provider.templates end test "update_identity_provider/1 with valid data (with an existing template, delete_if_exists) creates a identity_provider" do identity_provider = identity_provider_fixture() insert(:template, identity_provider: identity_provider) templates_attrs = %{ templates: [%{type: "new_registration", content: "test content"}] } assert {:ok, %IdentityProvider{} = identity_provider} = IdentityProviders.update_identity_provider(identity_provider, templates_attrs) assert [ %Template{ type: "new_registration", content: "test content" } ] = identity_provider.templates end test "update_identity_provider/2 with invalid data returns error changeset" do identity_provider = identity_provider_fixture() assert {:error, %Ecto.Changeset{}} = IdentityProviders.update_identity_provider(identity_provider, @invalid_attrs) assert identity_provider == IdentityProviders.get_identity_provider!(identity_provider.id) end test "update_identity_provider/2 with invalid data (unique name) returns error changeset", %{ backend: backend } do identity_provider_fixture() identity_provider = identity_provider_fixture(%{name: "other"}) assert {:error, %Ecto.Changeset{ errors: [ name: {"has already been taken", [constraint: :unique, constraint_name: "identity_providers_name_index"]} ] }} = IdentityProviders.update_identity_provider(identity_provider, %{ @valid_attrs | backend_id: backend.id }) assert identity_provider == IdentityProviders.get_identity_provider!(identity_provider.id) end test "delete_identity_provider/1 deletes the identity_provider" do identity_provider = identity_provider_fixture() assert {:ok, %IdentityProvider{}} = IdentityProviders.delete_identity_provider(identity_provider) assert_raise Ecto.NoResultsError, fn -> IdentityProviders.get_identity_provider!(identity_provider.id) end end test "delete_identity_provider/1 returns an error when associated to a client" do identity_provider = identity_provider_fixture() insert(:client_identity_provider, identity_provider: identity_provider) assert {:error, %Ecto.Changeset{errors: [client_identity_providers: {_message, []}]}} = IdentityProviders.delete_identity_provider(identity_provider) end test "change_identity_provider/1 returns a identity_provider changeset" do identity_provider = identity_provider_fixture() assert %Ecto.Changeset{} = IdentityProviders.change_identity_provider(identity_provider) end end describe "upsert_client_identity_provider/2" do test "inserts client identity provider" do %IdentityProvider{id: identity_provider_id} = insert(:identity_provider) client_id = SecureRandom.uuid() assert {:ok, %ClientIdentityProvider{ client_id: ^client_id, identity_provider_id: ^identity_provider_id }} = IdentityProviders.upsert_client_identity_provider(client_id, identity_provider_id) end test "updates client identity provider" do %ClientIdentityProvider{client_id: client_id} = insert(:client_identity_provider) %IdentityProvider{id: new_identity_provider_id} = insert(:identity_provider) assert {:ok, %ClientIdentityProvider{ client_id: ^client_id, identity_provider_id: ^new_identity_provider_id }} = IdentityProviders.upsert_client_identity_provider( client_id, new_identity_provider_id ) end end describe "remove_client_identity_provider/2" do test "remove client identity provider" do client_id = SecureRandom.uuid() client_identity_provider = insert(:client_identity_provider, client_id: client_id) |> Repo.reload() assert {:ok, ^client_identity_provider} = IdentityProviders.remove_client_identity_provider(client_id) assert_raise Ecto.NoResultsError, fn -> Repo.get!(ClientIdentityProvider, client_identity_provider.id) end end test "returns nil when not exists" do client_id = SecureRandom.uuid() assert IdentityProviders.remove_client_identity_provider(client_id) == {:ok, nil} end end describe "get_identity_provider_by_client_id/1" do test "returns nil with nil" do assert IdentityProviders.get_identity_provider_by_client_id(nil) == nil end test "returns nil with a raw string" do assert IdentityProviders.get_identity_provider_by_client_id("bad_id") == nil end test "returns nil with a random uuid" do assert IdentityProviders.get_identity_provider_by_client_id(SecureRandom.uuid()) == nil end test "returns client's identity provider" do %ClientIdentityProvider{client_id: client_id, identity_provider: identity_provider} = insert(:client_identity_provider) identity_provider = Repo.preload(identity_provider, backend: :email_templates) assert IdentityProviders.get_identity_provider_by_client_id(client_id) == identity_provider end end describe "get_identity_provider_template!/2" do test "raises an error with unexisting identity provider" do identity_provider_id = SecureRandom.uuid() assert_raise Ecto.NoResultsError, fn -> IdentityProviders.get_identity_provider_template!(identity_provider_id, :unexisting) end end test "raises an error with unexisting template" do identity_provider_id = insert(:identity_provider).id assert_raise Ecto.NoResultsError, fn -> IdentityProviders.get_identity_provider_template!(identity_provider_id, :unexisting) end end test "returns default template" do identity_provider = insert(:identity_provider, templates: []) template = IdentityProviders.get_identity_provider_template!(identity_provider.id, :new_registration) assert template == %{ Template.default_template(:new_registration) | identity_provider_id: identity_provider.id, identity_provider: identity_provider, layout: IdentityProvider.template(identity_provider, :layout) } end test "returns identity provider template with a layout" do template = build(:new_registration_template, content: "custom registration template" ) %IdentityProvider{templates: [template]} = identity_provider = insert(:identity_provider, templates: [template]) assert IdentityProviders.get_identity_provider_template!( identity_provider.id, :new_registration ) == %{ template | layout: IdentityProvider.template(identity_provider, :layout), identity_provider: identity_provider } end end describe "upsert_template/2" do test "inserts with a default template" do identity_provider = insert(:identity_provider) template = IdentityProviders.get_identity_provider_template!(identity_provider.id, :new_registration) assert {:ok, template} = IdentityProviders.upsert_template(template, %{content: "new content"}) assert Repo.reload(template) end test "updates with an existing template" do identity_provider = insert(:identity_provider) template = insert(:new_registration_template, identity_provider: identity_provider) assert {:ok, template} = IdentityProviders.upsert_template(template, %{content: "new content"}) assert Repo.reload(template) end end describe "delete_identity_provider_template!/2" do test "raises an error with unexisting identity provider" do identity_provider_id = SecureRandom.uuid() assert_raise Ecto.NoResultsError, fn -> IdentityProviders.delete_identity_provider_template!(identity_provider_id, :unexisting) end end test "raises an error with unexisting template" do identity_provider_id = insert(:identity_provider).id assert_raise Ecto.NoResultsError, fn -> IdentityProviders.delete_identity_provider_template!(identity_provider_id, :unexisting) end end test "returns an error if template is default" do identity_provider = insert(:identity_provider, templates: []) assert_raise Ecto.NoResultsError, fn -> IdentityProviders.delete_identity_provider_template!( identity_provider.id, :new_registration ) end end test "returns identity provider template with a layout" do template = build(:new_registration_template, content: "custom registration template" ) %IdentityProvider{templates: [template]} = identity_provider = insert(:identity_provider, templates: [template]) default_template = %{ Template.default_template(:new_registration) | identity_provider_id: identity_provider.id } reseted_template = IdentityProviders.delete_identity_provider_template!( identity_provider.id, :new_registration ) assert reseted_template.default == true assert reseted_template.type == "new_registration" assert reseted_template.content == default_template.content assert Repo.get_by(Template, id: template.id) == nil end end describe "backends" do import BorutaIdentity.IdentityProvidersFixtures @invalid_attrs %{name: nil, type: "bad type"} test "list_backends/0 returns all backends" do backend = backend_fixture() assert IdentityProviders.list_backends() |> Enum.member?(backend) end test "get_backend!/1 returns the backend with given id" do backend = backend_fixture() assert IdentityProviders.get_backend!(backend.id) == backend end @tag :skip test "get_backend_roles/1" test "create_backend/1 with valid data creates a backend" do valid_attrs = %{name: "some name", type: "Elixir.BorutaIdentity.Accounts.Internal"} assert {:ok, %Backend{} = backend} = IdentityProviders.create_backend(valid_attrs) assert backend.name == "some name" assert backend.type == "Elixir.BorutaIdentity.Accounts.Internal" end test "create_backend/1 with valid argon2 password hashing opts creates a backend" do valid_attrs = %{ name: "some name", type: "Elixir.BorutaIdentity.Accounts.Internal", password_hashing_alg: "argon2", password_hashing_opts: %{ "salt_len" => 16, "t_cost" => 8, "m_cost" => 16, "parallelism" => 2, "format" => "encoded", "hashlen" => 32, "argon2_type" => 2 } } assert {:ok, %Backend{} = backend} = IdentityProviders.create_backend(valid_attrs) assert backend.name == "some name" assert backend.type == "Elixir.BorutaIdentity.Accounts.Internal" assert backend.password_hashing_alg == "argon2" assert backend.password_hashing_opts == %{ "argon2_type" => 2, "format" => "encoded", "hashlen" => 32, "m_cost" => 16, "parallelism" => 2, "salt_len" => 16, "t_cost" => 8 } end test "create_backend/1 with valid bcrypt password hashing opts creates a backend" do valid_attrs = %{ name: "some name", type: "Elixir.BorutaIdentity.Accounts.Internal", password_hashing_alg: "bcrypt", password_hashing_opts: %{ "log_rounds" => 12, "legacy" => false } } assert {:ok, %Backend{} = backend} = IdentityProviders.create_backend(valid_attrs) assert backend.name == "some name" assert backend.type == "Elixir.BorutaIdentity.Accounts.Internal" assert backend.password_hashing_alg == "bcrypt" assert backend.password_hashing_opts == %{"legacy" => false, "log_rounds" => 12} end test "create_backend/1 set as default will override other backends default attribute" do other_backend = backend_fixture(%{is_default: true}) valid_attrs = %{ name: "some name", type: "Elixir.BorutaIdentity.Accounts.Internal", is_default: true } assert {:ok, %Backend{} = backend} = IdentityProviders.create_backend(valid_attrs) assert backend.is_default refute Repo.reload!(other_backend).is_default end test "create_backend/1 with invalid argon2 password hashing opts returns an error changeset" do valid_attrs = %{ name: "some name", type: "Elixir.BorutaIdentity.Accounts.Internal", password_hashing_alg: "argon2", password_hashing_opts: %{ "salt_len" => true, "t_cost" => true, "m_cost" => true, "parallelism" => true, "format" => true, "hashlen" => true, "argon2_type" => true } } assert {:error, %Ecto.Changeset{ errors: [ password_hashing_opts: {"Type mismatch. Expected Number but got Boolean. at #/t_cost", []}, password_hashing_opts: {"Type mismatch. Expected Number but got Boolean. at #/salt_len", []}, password_hashing_opts: {"Type mismatch. Expected Number but got Boolean. at #/parallelism", []}, password_hashing_opts: {"Type mismatch. Expected Number but got Boolean. at #/m_cost", []}, password_hashing_opts: {"Type mismatch. Expected Number but got Boolean. at #/hashlen", []}, password_hashing_opts: {"Type mismatch. Expected String but got Boolean. at #/format", []}, password_hashing_opts: {"Type mismatch. Expected Number but got Boolean. at #/argon2_type", []} ] }} = IdentityProviders.create_backend(valid_attrs) end test "create_backend/1 with invalid pbkdf2 password hashing opts returns an error changeset" do valid_attrs = %{ name: "some name", type: "Elixir.BorutaIdentity.Accounts.Internal", password_hashing_alg: "pbkdf2", password_hashing_opts: %{ "salt_len" => true, "format" => true, "digest" => true, "length" => true } } assert {:error, %Ecto.Changeset{ errors: [ password_hashing_opts: {"Type mismatch. Expected Number but got Boolean. at #/salt_len", []}, password_hashing_opts: {"Type mismatch. Expected Number but got Boolean. at #/length", []}, password_hashing_opts: {"Type mismatch. Expected String but got Boolean. at #/format", []}, password_hashing_opts: {"Type mismatch. Expected String but got Boolean. at #/digest", []} ] }} = IdentityProviders.create_backend(valid_attrs) end test "create_backend/1 with invalid bcrypt password hashing opts returns an error changeset" do valid_attrs = %{ name: "some name", type: "Elixir.BorutaIdentity.Accounts.Internal", password_hashing_alg: "bcrypt", password_hashing_opts: %{ "log_rounds" => true, "legacy" => "invalid" } } assert {:error, %Ecto.Changeset{ errors: [ password_hashing_opts: {"Type mismatch. Expected Number but got Boolean. at #/log_rounds", []}, password_hashing_opts: {"Type mismatch. Expected Boolean but got String. at #/legacy", []} ] }} = IdentityProviders.create_backend(valid_attrs) end test "create_backend/1 with invalid data returns error changeset" do assert {:error, %Ecto.Changeset{}} = IdentityProviders.create_backend(@invalid_attrs) end test "create_backend/1 with invalid metadata_fields returns an error changeset" do attrs = Map.put(@valid_attrs, :metadata_fields, [%{"valid" => false}]) assert {:error, %Ecto.Changeset{errors: errors}} = IdentityProviders.create_backend(attrs) assert errors[:metadata_fields] attrs = Map.put(@valid_attrs, :metadata_fields, %{"valid" => false}) assert {:error, %Ecto.Changeset{errors: errors}} = IdentityProviders.create_backend(attrs) assert errors[:metadata_fields] end test "create_backend/1 with valid metadata_fields creates a backend" do metadata_fields = [%{"attribute_name" => "attribute value"}] attrs = Map.put(@valid_attrs, :metadata_fields, metadata_fields) assert {:ok, backend} = IdentityProviders.create_backend(attrs) assert backend.metadata_fields == metadata_fields end test "create_backend/1 with invalid federated_servers returns an error changeset" do federated_servers = [%{}] attrs = Map.put(@valid_attrs, :federated_servers, federated_servers) assert {:error, %Ecto.Changeset{ errors: [ federated_servers: {"Required properties name, client_id, client_secret, base_url were not present. at #", []} ] }} = IdentityProviders.create_backend(attrs) end test "create_backend/1 with valid federated_servers creates a backend" do federated_servers = [ %{ "name" => "name", "client_id" => "client_id", "client_secret" => "client_secret", "base_url" => "https://host.test", "userinfo_path" => "/userinfo", "authorize_path" => "/authorize", "token_path" => "/token" } ] attrs = Map.put(@valid_attrs, :federated_servers, federated_servers) assert {:ok, backend} = IdentityProviders.create_backend(attrs) assert backend.federated_servers == federated_servers end test "update_backend/2 with valid data updates the backend" do backend = backend_fixture() update_attrs = %{name: "some updated name"} assert {:ok, %Backend{} = backend} = IdentityProviders.update_backend(backend, update_attrs) assert backend.name == "some updated name" end test "update_backend/2 stop associated ldap connection pool" do backend = insert(:ldap_backend) update_attrs = %{ldap_pool_size: 3} BorutaIdentity.LdapRepoMock |> stub(:open, fn host, _opts -> assert host == backend.ldap_host {:ok, SecureRandom.uuid()} end) {:ok, ldap_pool_pid} = Ldap.start_link(backend) assert {:ok, %Backend{}} = IdentityProviders.update_backend(backend, update_attrs) refute Process.alive?(ldap_pool_pid) end test "update_backend/2 cannot remove default" do backend = backend_fixture(%{is_default: true}) update_attrs = %{name: "some updated name", is_default: false} assert {:error, %Ecto.Changeset{ errors: [is_default: {"There must be at least one default backend.", []}] }} = IdentityProviders.update_backend(backend, update_attrs) end test "update_backend/2 other backends default attribute" do other_backend = backend_fixture(%{is_default: true}) backend = backend_fixture() update_attrs = %{name: "some updated name", is_default: true} assert {:ok, %Backend{} = backend} = IdentityProviders.update_backend(backend, update_attrs) assert backend.is_default refute Repo.reload!(other_backend).is_default end test "update_backend/2 with invalid data returns error changeset" do backend = backend_fixture() assert {:error, %Ecto.Changeset{}} = IdentityProviders.update_backend(backend, @invalid_attrs) assert backend == IdentityProviders.get_backend!(backend.id) end test "delete_backend/1 deletes the backend" do backend = backend_fixture() assert {:ok, %Backend{}} = IdentityProviders.delete_backend(backend) assert_raise Ecto.NoResultsError, fn -> IdentityProviders.get_backend!(backend.id) end end test "delete_backend/1 can't delete a default backend" do assert {:error, %Ecto.Changeset{ errors: [is_default: {"Deleting a default backend is prohibited.", []}] }} = IdentityProviders.delete_backend(Backend.default!()) assert Backend.default!() end test "delete_backend/1 stop the associated ldap connection pool" do backend = insert(:ldap_backend) BorutaIdentity.LdapRepoMock |> stub(:open, fn host, _opts -> assert host == backend.ldap_host {:ok, SecureRandom.uuid()} end) {:ok, ldap_pool_pid} = Ldap.start_link(backend) assert {:ok, %Backend{}} = IdentityProviders.delete_backend(backend) refute Process.alive?(ldap_pool_pid) assert_raise Ecto.NoResultsError, fn -> IdentityProviders.get_backend!(backend.id) end end test "change_backend/1 returns a backend changeset" do backend = backend_fixture() assert %Ecto.Changeset{} = IdentityProviders.change_backend(backend) end end describe "get_backend_email_template!/2" do test "raises an error with unexisting identity provider" do backend_id = SecureRandom.uuid() assert_raise Ecto.NoResultsError, fn -> IdentityProviders.get_backend_email_template!(backend_id, :unexisting) end end test "raises an error with unexisting template" do backend_id = insert(:backend).id assert_raise Ecto.NoResultsError, fn -> IdentityProviders.get_backend_email_template!(backend_id, :unexisting) end end test "returns default template" do backend = insert(:backend, email_templates: []) template = IdentityProviders.get_backend_email_template!(backend.id, :reset_password_instructions) assert template == %{ EmailTemplate.default_template(:reset_password_instructions) | backend_id: backend.id, backend: backend } end test "returns backend email template with a layout" do template = build(:reset_password_instructions_email_template, txt_content: "custom reset password instructions template" ) %Backend{email_templates: [template]} = backend = insert(:backend, email_templates: [template]) assert IdentityProviders.get_backend_email_template!( backend.id, :reset_password_instructions ) == %{template | backend: backend} end end describe "upsert_email_template/2" do test "inserts with a default template" do backend = insert(:backend) template = IdentityProviders.get_backend_email_template!(backend.id, :reset_password_instructions) assert {:ok, template} = IdentityProviders.upsert_email_template(template, %{txt_content: "new txt content"}) assert Repo.reload(template) end test "updates with an existing template" do backend = insert(:backend) template = insert(:reset_password_instructions_email_template, backend: backend) assert {:ok, template} = IdentityProviders.upsert_email_template(template, %{txt_content: "new content"}) assert Repo.reload(template) end end describe "delete_email_template!/2" do test "raises an error with unexisting identity provider" do backend_id = SecureRandom.uuid() assert_raise Ecto.NoResultsError, fn -> IdentityProviders.delete_email_template!(backend_id, :unexisting) end end test "raises an error with unexisting template" do backend_id = insert(:backend).id assert_raise Ecto.NoResultsError, fn -> IdentityProviders.delete_email_template!(backend_id, :unexisting) end end test "returns an error if template is default" do backend = insert(:backend, email_templates: []) assert_raise Ecto.NoResultsError, fn -> IdentityProviders.delete_email_template!( backend.id, :reset_password_instructions ) end end test "returns identity provider template with a layout" do template = build(:reset_password_instructions_email_template, txt_content: "custom registration template" ) %Backend{email_templates: [template]} = backend = insert(:backend, email_templates: [template]) default_template = %{ EmailTemplate.default_template(:reset_password_instructions) | backend_id: backend.id } reseted_template = IdentityProviders.delete_email_template!( backend.id, :reset_password_instructions ) assert reseted_template.default == true assert reseted_template.type == "reset_password_instructions" assert reseted_template.txt_content == default_template.txt_content assert Repo.get_by(EmailTemplate, id: template.id) == nil end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity/resource_owners_test.exs ================================================ defmodule BorutaIdentity.ResourceOwnersTest do use BorutaIdentity.DataCase import BorutaIdentity.AccountsFixtures alias Boruta.Ecto.Admin alias Boruta.Oauth.ResourceOwner alias BorutaIdentity.Accounts.UserRole alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.Organizations.OrganizationUser alias BorutaIdentity.Repo alias BorutaIdentity.ResourceOwners doctest BorutaIdentity @valid_username unique_user_email() @valid_password valid_user_password() describe "get_by/1" do test "returns an user by username" do username = @valid_username user = user_fixture(%{ email: username, password: @valid_password, backend: Backend.default!() }) {:ok, result} = ResourceOwners.get_by(username: username) user_id = user.id assert %ResourceOwner{sub: ^user_id, username: ^username, extra_claims: %{user: _user}} = result end test "returns an user by sub" do user = user_fixture(%{ email: @valid_username, password: @valid_password, backend: Backend.default!() }) {:ok, result} = ResourceOwners.get_by(sub: user.id, scope: "") user_id = user.id user_username = user.username assert %ResourceOwner{sub: ^user_id, username: ^user_username} = result end test "returns nil when username do not exists" do user_fixture(%{ email: @valid_username, password: @valid_password, backend: Backend.default!() }) assert ResourceOwners.get_by(username: "other") == {:error, "Invalid username or password."} end end describe "#check_password/2" do test "returns ok if password match" do username = @valid_username backend = Backend.default!() user = user_fixture(%{ email: username, password: @valid_password, backend: backend }) {:ok, impl_user} = apply(Backend.implementation(backend), :get_user, [backend, %{email: username}]) resource_owner = %ResourceOwner{ sub: user.id, username: user.username, extra_claims: %{user: impl_user} } assert ResourceOwners.check_password(resource_owner, @valid_password) == :ok end test "returns an error if password do not match" do user = user_fixture(%{ email: @valid_username, password: @valid_password, backend: Backend.default!() }) resource_owner = %ResourceOwner{sub: user.id} assert ResourceOwners.check_password(resource_owner, "wrong password") == {:error, "Invalid username or password."} end end describe "authorized_scopes/1" do test "returns an empty array" do user = user_fixture(%{backend: Backend.default!()}) resource_owner = %ResourceOwner{sub: user.id} assert ResourceOwners.authorized_scopes(resource_owner) == [] end test "return user associated scopes with authorized scopes" do %{id: id} = user = user_fixture(%{backend: Backend.default!()}) {:ok, scope} = Admin.create_scope(%{name: "scope:scope"}) insert(:user_authorized_scope, user_id: id, scope_id: scope.id) resource_owner = %ResourceOwner{sub: user.id} name = scope.name assert [%Boruta.Oauth.Scope{name: ^name}] = ResourceOwners.authorized_scopes(resource_owner) end test "return user associated scopes with roles" do %{id: id} = user = user_fixture(%{backend: Backend.default!()}) {:ok, scope} = Admin.create_scope(%{name: "scope:scope"}) role = insert(:role) insert(:role_scope, role_id: role.id, scope_id: scope.id) insert(:user_role, user_id: id, role_id: role.id) resource_owner = %ResourceOwner{sub: user.id} name = scope.name assert [%Boruta.Oauth.Scope{name: ^name}] = ResourceOwners.authorized_scopes(resource_owner) end end describe "claims/2" do test "returns user roles with profile in scope" do user = user_fixture() role = BorutaIdentity.Factory.insert(:role) Repo.insert(%UserRole{user_id: user.id, role_id: role.id}) role_name = role.name assert %{"roles" => [^role_name]} = ResourceOwners.claims(%ResourceOwner{sub: user.id}, "profile") end test "returns user organizations with profile in scope" do user = user_fixture() organization = BorutaIdentity.Factory.insert(:organization) Repo.insert(%OrganizationUser{user_id: user.id, organization_id: organization.id}) organization_id = organization.id organization_name = organization.name organization_label = organization.label assert %{ "organizations" => [ %{ "id" => ^organization_id, "name" => ^organization_name, "label" => ^organization_label } ] } = ResourceOwners.claims(%ResourceOwner{sub: user.id}, "profile") end end describe "metadata/2" do test "returns user metadata" do user = user_fixture() {:ok, backend} = Ecto.Changeset.change(user.backend, %{ metadata_fields: [%{"attribute_name" => "metadata"}] }) |> Repo.update() user = %{user | backend: backend} {:ok, user} = Ecto.Changeset.change(user, %{metadata: %{"metadata" => "true"}}) |> Repo.update() assert %{"metadata" => "true"} = ResourceOwners.metadata(user, "") end test "filters user metadata" do user = user_fixture() {:ok, backend} = Ecto.Changeset.change(user.backend, %{ metadata_fields: [%{"attribute_name" => "metadata"}] }) |> Repo.update() user = %{user | backend: backend} {:ok, user} = Ecto.Changeset.change(user, %{metadata: %{"filtered" => "true", "metadata" => "true"}}) |> Repo.update() assert %{"metadata" => "true"} = ResourceOwners.metadata(user, "") end test "filters user metadata according to scopes" do user = user_fixture() {:ok, backend} = Ecto.Changeset.change(user.backend, %{ metadata_fields: [ %{"attribute_name" => "without_scopes"}, %{"attribute_name" => "test_scope", "scopes" => ["test"]}, %{"attribute_name" => "other_scope", "scopes" => ["other"]} ] }) |> Repo.update() user = %{user | backend: backend} {:ok, user} = Ecto.Changeset.change(user, %{ metadata: %{"without_scopes" => "true", "test_scope" => "true", "other_scope" => "true"} }) |> Repo.update() assert %{"without_scopes" => "true", "test_scope" => "true"} = ResourceOwners.metadata(user, "test") end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity/totp_test.exs ================================================ defmodule BorutaIdentity.TotpTest do defmodule DummyTotpRegistrationApplication do @behaviour BorutaIdentity.TotpRegistrationApplication @impl BorutaIdentity.TotpRegistrationApplication def totp_registration_initialized(context, totp_secret, template) do {:totp_registration_initialized, context, totp_secret, template} end @impl BorutaIdentity.TotpRegistrationApplication def totp_registration_error(context, error) do {:totp_registration_error, context, error} end @impl BorutaIdentity.TotpRegistrationApplication def totp_registration_success(context, user) do {:totp_registration_success, context, user} end end defmodule DummyTotpAuthenticationApplication do @behaviour BorutaIdentity.TotpAuthenticationApplication @impl BorutaIdentity.TotpAuthenticationApplication def totp_initialized(context, template) do {:totp_initialized, context, template} end @impl BorutaIdentity.TotpAuthenticationApplication def totp_not_required(context) do {:totp_not_required, context} end @impl BorutaIdentity.TotpAuthenticationApplication def totp_registration_missing(context) do {:totp_registration_missing, context} end @impl BorutaIdentity.TotpAuthenticationApplication def totp_authenticated(context, current_user) do {:totp_authenticated, context, current_user} end @impl BorutaIdentity.TotpAuthenticationApplication def totp_authentication_failure(context, error) do {:totp_authentication_failure, context, error} end end defmodule HotpTest do use ExUnit.Case alias BorutaIdentity.Totp.Hotp test "returns an htop given empty params" do assert Hotp.generate_hotp("", 0) == "328482" end test "returns an hotp with RFC examples" do assert Hotp.generate_hotp("12345678901234567890", 0) == "755224" assert Hotp.generate_hotp("12345678901234567890", 1) == "287082" assert Hotp.generate_hotp("12345678901234567890", 2) == "359152" assert Hotp.generate_hotp("12345678901234567890", 3) == "969429" assert Hotp.generate_hotp("12345678901234567890", 4) == "338314" assert Hotp.generate_hotp("12345678901234567890", 5) == "254676" assert Hotp.generate_hotp("12345678901234567890", 6) == "287922" assert Hotp.generate_hotp("12345678901234567890", 7) == "162583" assert Hotp.generate_hotp("12345678901234567890", 8) == "399871" assert Hotp.generate_hotp("12345678901234567890", 9) == "520489" end end defmodule AdminTest do use ExUnit.Case alias BorutaIdentity.Totp.Admin describe "generate_totp/1" do test "returns an error with a non base32 encoded secret" do assert :error = Admin.generate_totp("not base64 encoded secret") end test "returns a totp" do secret = Base.encode32("secret", padding: false) # TODO test only the presence until we have a timestamp provider assert Admin.generate_totp(secret) |> String.length() == 6 end end describe "check_totp/2" do test "returns an error when totp invalid" do secret = Base.encode32("secret", padding: false) assert {:error, "Given TOTP is invalid."} = Admin.check_totp("invalid", secret) end test "returns an error when secret is bad encoded invalid" do secret = "bad encoding" assert {:error, "Given TOTP is invalid."} = Admin.check_totp("whatever", secret) end end describe "generate_secret/0" do test "return a random secret" do # secret is hashed from an uuid assert {:ok, _} = Admin.generate_secret() |> Base.decode32!(padding: false) |> Ecto.UUID.cast() end end end use BorutaIdentity.DataCase alias BorutaIdentity.Accounts.IdentityProviderError alias BorutaIdentity.Accounts.User alias BorutaIdentity.Totp describe "initialize_totp_registration/3" do setup do client_identity_provider = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: build( :identity_provider, totpable: true ) ) {:ok, client_id: client_identity_provider.client_id} end test "raises an error", %{client_id: client_id} do assert_raise BorutaIdentity.TotpError, fn -> Totp.initialize_totp_registration( :context, client_id, false, %User{totp_registered_at: DateTime.utc_now()}, DummyTotpRegistrationApplication ) end end test "returns a secret and the registration template when not registered", %{client_id: client_id} do assert {:totp_registration_initialized, :context, totp_secret, template} = Totp.initialize_totp_registration( :context, client_id, false, %User{}, DummyTotpRegistrationApplication ) # secret is hashed from an uuid assert {:ok, _} = totp_secret |> Base.decode32!(padding: false) |> Ecto.UUID.cast() assert Regex.match?( ~r/Add TOTP authentication from an authenticator/, template.content ) end test "returns a secret and the registration template when totp authenticated", %{client_id: client_id} do assert {:totp_registration_initialized, :context, totp_secret, template} = Totp.initialize_totp_registration( :context, client_id, true, %User{}, DummyTotpRegistrationApplication ) # secret is hashed from an uuid assert {:ok, _} = totp_secret |> Base.decode32!(padding: false) |> Ecto.UUID.cast() assert Regex.match?( ~r/Add TOTP authentication from an authenticator/, template.content ) end end describe "register_totp/4" do setup do client_identity_provider = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: build( :identity_provider, totpable: true ) ) user = BorutaIdentity.Factory.insert(:user) {:ok, client_id: client_identity_provider.client_id, user: user} end test "returns an error with registration template if bad secret format", %{ client_id: client_id, user: current_user } do totp_params = %{ totp_code: "totp_code", totp_secret: "bad_totp_secret" } assert {:totp_registration_error, :context, error} = Totp.register_totp( :context, client_id, current_user, totp_params, DummyTotpRegistrationApplication ) assert error.message == "Given TOTP is invalid." assert Regex.match?( ~r/Add TOTP authentication from an authenticator/, error.template.content ) end test "returns an error with registration template if totp is invalid (bad code)", %{ client_id: client_id, user: current_user } do totp_params = %{ totp_code: "totp_code", totp_secret: Totp.Admin.generate_secret() } assert {:totp_registration_error, :context, error} = Totp.register_totp( :context, client_id, current_user, totp_params, DummyTotpRegistrationApplication ) assert error.message == "Given TOTP is invalid." assert Regex.match?( ~r/Add TOTP authentication from an authenticator/, error.template.content ) end # NOTE can be a flaky test, mind about time provider test "successes when TOTP is valid", %{ client_id: client_id, user: current_user } do secret = Totp.Admin.generate_secret() totp_params = %{ totp_code: Totp.Admin.generate_totp(secret), totp_secret: secret } assert {:totp_registration_success, :context, user} = Totp.register_totp( :context, client_id, current_user, totp_params, DummyTotpRegistrationApplication ) assert user.totp_registered_at assert user.totp_secret == secret end end describe "initialize_totp/4" do setup do client_id = for totpable <- [true, false], enforce_totp <- [true, false] do current_client_id = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: build( :identity_provider, totpable: totpable, enforce_totp: enforce_totp ) ).client_id label = case {totpable, enforce_totp} do {true, true} -> :totpable_enforce_totp {true, false} -> :totpable {false, true} -> :enforce_totp {false, false} -> :basic end {label, current_client_id} end |> Enum.into(%{}) user = BorutaIdentity.Factory.insert(:user) registered_user = BorutaIdentity.Factory.insert(:user, totp_registered_at: DateTime.utc_now()) {:ok, client_id: client_id, user: user, registered_user: registered_user} end test "returns not required if identity provider is basic", %{ client_id: %{basic: client_id}, user: current_user } do assert {:totp_not_required, :context} = Totp.initialize_totp( :context, client_id, current_user, DummyTotpAuthenticationApplication ) end test "returns registration missing if identity provider enforces totp", %{ client_id: %{enforce_totp: client_id}, user: current_user } do assert {:totp_registration_missing, :context} = Totp.initialize_totp( :context, client_id, current_user, DummyTotpAuthenticationApplication ) end test "returns authentication template if identity provider enforces totp and user registred", %{ client_id: %{enforce_totp: client_id}, registered_user: current_user } do assert {:totp_initialized, :context, template} = Totp.initialize_totp( :context, client_id, current_user, DummyTotpAuthenticationApplication ) assert Regex.match?( ~r/Provide the TOTP code from your authenticator/, template.content ) end test "returns not required if identity provider is totpable", %{ client_id: %{totpable: client_id}, user: current_user } do assert {:totp_not_required, :context} = Totp.initialize_totp( :context, client_id, current_user, DummyTotpAuthenticationApplication ) end test "returns authentication template if identity provider is totpable and user registered", %{ client_id: %{totpable: client_id}, registered_user: current_user } do assert {:totp_initialized, :context, template} = Totp.initialize_totp( :context, client_id, current_user, DummyTotpAuthenticationApplication ) assert Regex.match?( ~r/Provide the TOTP code from your authenticator/, template.content ) end test "returns registration missing if identity provider totpable and enforces totp", %{ client_id: %{totpable_enforce_totp: client_id}, user: current_user } do assert {:totp_registration_missing, :context} = Totp.initialize_totp( :context, client_id, current_user, DummyTotpAuthenticationApplication ) end test "returns authentication template if identity provider is totpable, enforces totp and user registered", %{ client_id: %{totpable_enforce_totp: client_id}, registered_user: current_user } do assert {:totp_initialized, :context, template} = Totp.initialize_totp( :context, client_id, current_user, DummyTotpAuthenticationApplication ) assert Regex.match?( ~r/Provide the TOTP code from your authenticator/, template.content ) end end describe "authenticate_totp/5" do setup do client_id = for totpable <- [true, false], enforce_totp <- [true, false] do current_client_id = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: build( :identity_provider, totpable: totpable, enforce_totp: enforce_totp ) ).client_id label = case {totpable, enforce_totp} do {true, true} -> :totpable_enforce_totp {true, false} -> :totpable {false, true} -> :enforce_totp {false, false} -> :basic end {label, current_client_id} end |> Enum.into(%{}) user = BorutaIdentity.Factory.insert(:user) registered_user = BorutaIdentity.Factory.insert(:user, totp_registered_at: DateTime.utc_now(), totp_secret: Totp.Admin.generate_secret() ) {:ok, client_id: client_id, user: user, registered_user: registered_user} end test "raises an error if identity provider is basic", %{ client_id: %{basic: client_id}, user: current_user } do totp_params = %{} assert_raise IdentityProviderError, fn -> Totp.authenticate_totp( :context, client_id, current_user, totp_params, DummyTotpAuthenticationApplication ) end end test "raises an error if identity provider enforces totp", %{ client_id: %{enforce_totp: client_id}, user: current_user } do totp_params = %{} assert_raise IdentityProviderError, fn -> Totp.authenticate_totp( :context, client_id, current_user, totp_params, DummyTotpAuthenticationApplication ) end end test "returns not required if identity provider is totpable", %{ client_id: %{totpable: client_id}, user: current_user } do totp_params = %{} assert {:totp_not_required, :context} = Totp.authenticate_totp( :context, client_id, current_user, totp_params, DummyTotpAuthenticationApplication ) end test "returns authentication failure if identity provider is totpable, and user registered", %{ client_id: %{totpable: client_id}, registered_user: current_user } do totp_params = %{ totp_code: "bad code" } assert {:totp_authentication_failure, :context, error} = Totp.authenticate_totp( :context, client_id, current_user, totp_params, DummyTotpAuthenticationApplication ) assert error.message == "Given TOTP is invalid." assert Regex.match?( ~r/Provide the TOTP code from your authenticator/, error.template.content ) end test "authenticates if identity provider is totpable, user registered and valid totp", %{ client_id: %{totpable: client_id}, registered_user: current_user } do totp_params = %{ totp_code: Totp.Admin.generate_totp(current_user.totp_secret) } assert {:totp_authenticated, :context, %User{}} = Totp.authenticate_totp( :context, client_id, current_user, totp_params, DummyTotpAuthenticationApplication ) end test "returns registration missing if identity provider totpable and enforces totp", %{ client_id: %{totpable_enforce_totp: client_id}, user: current_user } do totp_params = %{} assert {:totp_registration_missing, :context} = Totp.authenticate_totp( :context, client_id, current_user, totp_params, DummyTotpAuthenticationApplication ) end test "returns an error if identity provider is totpable, enforces totp and user registered", %{ client_id: %{totpable_enforce_totp: client_id}, registered_user: current_user } do totp_params = %{} assert {:totp_authentication_failure, :context, error} = Totp.authenticate_totp( :context, client_id, current_user, totp_params, DummyTotpAuthenticationApplication ) assert error.message == "Given TOTP is invalid." assert Regex.match?( ~r/Provide the TOTP code from your authenticator/, error.template.content ) end test "authenticates if identity provider is totpable, enforces totp, user registered and totp valid", %{ client_id: %{totpable_enforce_totp: client_id}, registered_user: current_user } do totp_params = %{ totp_code: Totp.Admin.generate_totp(current_user.totp_secret) } assert {:totp_authenticated, :context, %User{}} = Totp.authenticate_totp( :context, client_id, current_user, totp_params, DummyTotpAuthenticationApplication ) end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity/webauthn_test.exs ================================================ defmodule BorutaIdentity.WebauthnTest do use BorutaIdentity.DataCase import BorutaIdentity.Factory alias BorutaIdentity.Webauthn describe "options/2" do test "returns webauthn options" do user = insert(:user) assert {:ok, %Webauthn.Options{ user: webauthn_user, challenge: challenge, publicKeyCredParams: %{alg: -7, type: "public-key"}, rp: %{id: "localhost"} }} = Webauthn.options(user, true) assert challenge assert webauthn_user[:id] == user.id assert webauthn_user[:displayName] == user.username end end @tag :skip test "registration" @tag :skip test "authentication" end ================================================ FILE: apps/boruta_identity/test/boruta_identity_web/concerns/authenticable_test.exs ================================================ defmodule BorutaIdentityWeb.AuthenticableTest do use BorutaIdentityWeb.ConnCase, async: true import BorutaIdentity.AccountsFixtures alias BorutaIdentityWeb.Authenticable setup %{conn: conn} do conn = conn |> Map.replace!(:secret_key_base, BorutaIdentityWeb.Endpoint.config(:secret_key_base)) |> init_test_session(%{}) %{user: user_fixture(), conn: conn} end describe "after_sign_in_path" do setup :with_a_request test "returns root path", %{conn: conn} do conn = Plug.Conn.fetch_query_params(conn) assert Authenticable.after_sign_in_path(conn) == "/" end test "returns session stored path if provided", %{conn: conn, request: request} do conn = %{conn|query_params: %{"request" => request}} assert Authenticable.after_sign_in_path(conn) == "/user_return_to" end end @tag :skip test "store_user_session/2" @tag :skip test "after_sign_in_path/2" @tag :skip test "after_registration_path/2" @tag :skip test "after_sign_out_path/2" end ================================================ FILE: apps/boruta_identity/test/boruta_identity_web/controllers/choose_session_controller_test.exs ================================================ defmodule BorutaIdentityWeb.ChooseSessionControllerTest do use BorutaIdentityWeb.ConnCase import BorutaIdentity.AccountsFixtures alias BorutaIdentity.Repo describe "GET /choose_session" do setup :with_a_request test "renders choose session template", %{conn: conn, request: request} do conn = conn |> log_in(user_fixture()) |> get(Routes.choose_session_path(conn, :index, %{request: request})) assert html_response(conn, 200) =~ "Continue ?" end test "redirect to log in if identity provider disabled choose_session", %{ conn: conn, identity_provider: identity_provider, request: request } do identity_provider |> Ecto.Changeset.change(choose_session: false) |> Repo.update() conn = conn |> log_in(user_fixture()) |> get(Routes.choose_session_path(conn, :index, %{request: request})) assert redirected_to(conn) =~ Routes.user_session_path(conn, :new, request: request) end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity_web/controllers/page_controller_test.exs ================================================ defmodule BorutaIdentityWeb.PageControllerTest do use BorutaIdentityWeb.ConnCase # test "GET /", %{conn: conn} do # conn = get(conn, "/") # assert html_response(conn, 200) =~ "Welcome to Phoenix!" # end end ================================================ FILE: apps/boruta_identity/test/boruta_identity_web/controllers/totp_controller_test.exs ================================================ defmodule BorutaIdentityWeb.TotpControllerTest do use BorutaIdentityWeb.ConnCase, async: true import BorutaIdentity.AccountsFixtures import BorutaIdentityWeb.Authenticable, only: [ get_user_session: 1 ] alias BorutaIdentity.Accounts.IdentityProviderError alias BorutaIdentity.Repo alias BorutaIdentity.Totp setup :with_a_request setup %{identity_provider: identity_provider} do {:ok, user} = user_fixture(%{backend: identity_provider.backend}) |> Ecto.Changeset.change(confirmed_at: DateTime.utc_now()) |> Repo.update() %{user: user} end describe "GET /users/totp_registration" do test "redirects to log in", %{ conn: conn, request: request } do conn = conn |> get(Routes.totp_path(conn, :new, request: request)) assert redirected_to(conn) =~ Routes.user_session_path(conn, :new) end test "raises if identity provider not totpable", %{ conn: conn, request: request, user: user } do assert_raise IdentityProviderError, fn -> conn |> log_in(user) |> get(Routes.totp_path(conn, :new, request: request)) end end test "renders totp registration template", %{ identity_provider: identity_provider, conn: conn, request: request, user: user } do Ecto.Changeset.change(identity_provider, %{totpable: true}) |> Repo.update!() conn = log_in(conn, user) conn = conn |> put_session( :totp_authenticated, %{conn |> fetch_session() |> get_user_session() => true} ) |> get(Routes.totp_path(conn, :new, request: request)) assert html_response(conn, 200) =~ "Add TOTP authentication from an authenticator" end end describe "POST /users/totp_registration" do test "redirects to log in", %{ conn: conn, request: request } do conn = conn |> post(Routes.totp_path(conn, :register, request: request)) assert redirected_to(conn) =~ Routes.user_session_path(conn, :new) end test "raises if identity provider not totpable", %{ conn: conn, request: request, user: user } do assert_raise IdentityProviderError, fn -> conn |> log_in(user) |> post(Routes.totp_path(conn, :register, request: request), %{"totp" => %{}}) end end test "renders registration template with error with invalid code", %{ identity_provider: identity_provider, conn: conn, request: request, user: user } do Ecto.Changeset.change(identity_provider, %{totpable: true}) |> Repo.update!() totp_params = %{ "totp_code" => "bad code", "totp_secret" => "bad secret" } conn = conn |> log_in(user) |> post(Routes.totp_path(conn, :register, request: request), %{"totp" => totp_params}) assert html_response(conn, 422) =~ "Add TOTP authentication from an authenticator" assert html_response(conn, 422) =~ "Given TOTP is invalid." end test "redirects to chosse session with valid code", %{ identity_provider: identity_provider, conn: conn, request: request, user: user } do Ecto.Changeset.change(identity_provider, %{totpable: true}) |> Repo.update!() secret = Totp.Admin.generate_secret() totp_params = %{ "totp_code" => Totp.Admin.generate_totp(secret), "totp_secret" => secret } conn = conn |> log_in(user) |> post(Routes.totp_path(conn, :register, request: request), %{"totp" => totp_params}) assert redirected_to(conn) =~ "/user_return_to" end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity_web/controllers/user_confirmation_controller_test.exs ================================================ defmodule BorutaIdentityWeb.UserConfirmationControllerTest do use BorutaIdentityWeb.ConnCase, async: true alias BorutaIdentity.Accounts alias BorutaIdentity.Accounts.Deliveries alias BorutaIdentity.Repo import BorutaIdentity.AccountsFixtures setup :with_a_request setup %{identity_provider: identity_provider}do %{user: user_fixture(%{backend: identity_provider.backend})} end describe "GET /users/confirm" do test "renders the confirmation page", %{conn: conn, request: request} do conn = get(conn, Routes.user_confirmation_path(conn, :new, request: request)) response = html_response(conn, 200) assert response =~ "

    Resend confirmation instructions

    " end end describe "POST /users/confirm" do @tag :capture_log test "sends a new confirmation token", %{conn: conn, user: user, request: request} do conn = post(conn, Routes.user_confirmation_path(conn, :create, request: request), %{ "user" => %{"email" => user.username} }) assert redirected_to(conn) == Routes.user_session_path(conn, :new, request: request) assert get_flash(conn, :info) =~ "If your email is in our system" assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" end test "does not send confirmation token if account is confirmed", %{ conn: conn, request: request } do {:ok, user} = user_fixture() |> Ecto.Changeset.change(confirmed_at: DateTime.utc_now()) |> Repo.update() conn = post(conn, Routes.user_confirmation_path(conn, :create, request: request), %{ "user" => %{"email" => user.username} }) assert redirected_to(conn) == Routes.user_session_path(conn, :new, request: request) assert get_flash(conn, :info) =~ "If your email is in our system" refute Repo.get_by(Accounts.UserToken, user_id: user.id) end test "does not send confirmation token if email is invalid", %{conn: conn, request: request} do conn = post(conn, Routes.user_confirmation_path(conn, :create, request: request), %{ "user" => %{"email" => "unknown@example.com"} }) assert redirected_to(conn) == Routes.user_session_path(conn, :new, request: request) assert get_flash(conn, :info) =~ "If your email is in our system" assert Repo.all(Accounts.UserToken) == [] end end describe "GET /users/confirm/:token" do test "confirms the given token once", %{conn: conn, user: user, request: request} do confirmation_url_fun = fn _ -> "http://test.host" end {:ok, token} = Deliveries.deliver_user_confirmation_instructions(user.backend, user, confirmation_url_fun) confirm_conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token, request: request)) assert redirected_to(confirm_conn) == Routes.user_session_path(conn, :new, request: request) assert get_flash(confirm_conn, :info) =~ "Account confirmed successfully" assert Accounts.get_user(user.id).confirmed_at refute get_session(confirm_conn, :user_token) # When not logged in signed_out_conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token, request: request)) assert redirected_to(signed_out_conn) == Routes.user_session_path(signed_out_conn, :new, request: request) assert get_flash(signed_out_conn, :error) =~ "Account confirmation token is invalid or it has expired" end test "redirects if user is already confirmed", %{conn: conn, request: request} do user_fixture() |> Ecto.Changeset.change(confirmed_at: DateTime.utc_now()) |> Repo.update() signed_in_conn = conn |> get(Routes.user_confirmation_path(conn, :confirm, "unused_token", request: request)) assert redirected_to(signed_in_conn) == Routes.user_session_path(conn, :new, request: request) assert get_flash(signed_in_conn, :error) =~ "Account confirmation token is invalid or it has expired" end test "does not confirm email with invalid token", %{conn: conn, user: user, request: request} do conn = get(conn, Routes.user_confirmation_path(conn, :confirm, "oops", request: request)) assert redirected_to(conn) == Routes.user_session_path(conn, :new, request: request) assert get_flash(conn, :error) =~ "Account confirmation token is invalid or it has expired" refute Accounts.get_user(user.id).confirmed_at end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity_web/controllers/user_consent_controller_test.exs ================================================ defmodule BorutaIdentityWeb.UserConsentControllerTest do use BorutaIdentityWeb.ConnCase import BorutaIdentity.AccountsFixtures describe "GET /consent" do setup :with_a_request test "renders consent form", %{ conn: conn, request: request, requested_scope: scope } do conn = conn |> log_in(user_fixture()) |> get(Routes.user_consent_path(conn, :index, %{request: request})) assert html_response(conn, 200) =~ "Scope from request" assert html_response(conn, 200) =~ scope.name end end describe "POST /consent" do setup :with_a_request test "redirects to after sign in path with valid params", %{conn: conn, request: request} do conn = conn |> log_in(user_fixture()) |> post(Routes.user_consent_path(conn, :consent, %{request: request}), %{}) assert redirected_to(conn) == "/user_return_to" end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity_web/controllers/user_registration_controller_test.exs ================================================ defmodule BorutaIdentityWeb.UserRegistrationControllerTest do use BorutaIdentityWeb.ConnCase, async: true import BorutaIdentity.AccountsFixtures alias BorutaIdentity.Repo describe "GET /users/register" do setup :with_a_request test "renders registration page", %{conn: conn, request: request} do conn = get(conn, Routes.user_registration_path(conn, :new, request: request)) response = html_response(conn, 200) assert response =~ "

    Register

    " end test "redirects if already logged in", %{conn: conn, request: request} do conn = conn |> log_in(user_fixture()) |> get(Routes.user_registration_path(conn, :new, request: request)) assert redirected_to(conn) == "/user_return_to" end end describe "POST /users/register" do setup :with_a_request @tag :capture_log test "creates account and ask for confirmation in if confirmable", %{conn: conn, request: request} do email = unique_user_email() conn = post(conn, Routes.user_registration_path(conn, :create, request: request), %{ "user" => %{"email" => email, "password" => valid_user_password()} }) response = html_response(conn, 200) assert response =~ "

    Resend confirmation instructions

    " assert response =~ "Email confirmation is required to authenticate." end @tag :capture_log test "creates account and logs the user in if not confirmable", %{identity_provider: identity_provider, conn: conn, request: request} do Ecto.Changeset.change(identity_provider, confirmable: false) |> Repo.update() email = unique_user_email() conn = post(conn, Routes.user_registration_path(conn, :create, request: request), %{ "user" => %{"email" => email, "password" => valid_user_password()} }) assert get_session(conn, :user_token) assert redirected_to(conn) =~ "/user_return_to" end test "render errors for invalid data", %{conn: conn, request: request} do conn = post(conn, Routes.user_registration_path(conn, :create, request: request), %{ "user" => %{"email" => "with spaces", "password" => "too short"} }) response = html_response(conn, 200) assert response =~ "

    Register

    " assert response =~ "must have the @ sign and no spaces" assert response =~ "should be at least 12 character" end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity_web/controllers/user_reset_password_controller_test.exs ================================================ defmodule BorutaIdentityWeb.UserResetPasswordControllerTest do use BorutaIdentityWeb.ConnCase, async: false import BorutaIdentity.AccountsFixtures import Swoosh.TestAssertions alias BorutaIdentity.Accounts alias BorutaIdentity.Accounts.UserToken alias BorutaIdentity.Repo setup :set_swoosh_global setup :with_a_request setup %{identity_provider: identity_provider} do {:ok, user: user_fixture(%{backend: identity_provider.backend})} end describe "GET /users/reset_password" do test "renders the reset password page", %{conn: conn, request: request} do conn = get(conn, Routes.user_reset_password_path(conn, :new, request: request)) response = html_response(conn, 200) assert response =~ "

    Forgot your password?

    " end end describe "POST /users/reset_password" do @tag :capture_log test "sends a new reset password token", %{conn: conn, user: user, request: request} do conn = post(conn, Routes.user_reset_password_path(conn, :create, request: request), %{ "user" => %{"email" => user.username} }) assert redirected_to(conn) == Routes.user_session_path(conn, :new, request: request) assert get_flash(conn, :info) =~ "If your email is in our system" user_token = Repo.get_by!(Accounts.UserToken, user_id: user.id) assert user_token.context == "reset_password" assert_email_sent(text_body: ~r/reset your password/) end test "does not send reset password token if email is invalid", %{conn: conn, request: request} do conn = post(conn, Routes.user_reset_password_path(conn, :create, request: request), %{ "user" => %{"email" => "unknown@example.com"} }) assert redirected_to(conn) == Routes.user_session_path(conn, :new, request: request) assert get_flash(conn, :info) =~ "If your email is in our system" assert Repo.all(Accounts.UserToken) == [] refute_email_sent() end end describe "GET /users/reset_password/:token" do setup %{user: user} do {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") {:ok, _user_token} = Repo.insert(user_token) {:ok, token: encoded_token} end test "renders reset password", %{conn: conn, token: token, request: request} do conn = get(conn, Routes.user_reset_password_path(conn, :edit, token, request: request)) assert html_response(conn, 200) =~ "

    Reset password

    " end test "does not render reset password with invalid token", %{conn: conn, request: request} do conn = get(conn, Routes.user_reset_password_path(conn, :edit, "oops", request: request)) response = html_response(conn, 200) assert response =~ "

    Reset password

    " assert response =~ "Given reset password token is invalid." end end describe "PUT /users/reset_password/:token" do setup %{user: user} do {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") {:ok, _user_token} = Repo.insert(user_token) {:ok, token: encoded_token} end test "resets password once", %{conn: conn, user: user, token: token, request: request, identity_provider: identity_provider} do conn = put(conn, Routes.user_reset_password_path(conn, :update, token, request: request), %{ "user" => %{ "password" => "new valid password", "password_confirmation" => "new valid password" } }) assert redirected_to(conn) == Routes.user_session_path(conn, :new, request: request) refute get_session(conn, :user_token) assert get_flash(conn, :info) =~ "Password reset successfully" assert {:ok, user} = Accounts.Internal.get_user(identity_provider.backend, %{email: user.username}) assert {:ok, _user} = Accounts.Internal.check_user_against( identity_provider.backend, user, %{password: "new valid password"} ) end test "does not reset password on invalid data", %{conn: conn, token: token, request: request} do conn = put(conn, Routes.user_reset_password_path(conn, :update, token, request: request), %{ "user" => %{ "password" => "too short", "password_confirmation" => "does not match" } }) response = html_response(conn, 200) assert response =~ "

    Reset password

    " assert response =~ "should be at least 12 character(s)" assert response =~ "does not match password" end test "does not reset password with invalid token", %{conn: conn, request: request} do conn = put(conn, Routes.user_reset_password_path(conn, :update, "oops", request: request)) response = html_response(conn, 200) assert response =~ "

    Reset password

    " assert response =~ "Given reset password token is invalid." end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity_web/controllers/user_session_controller_test.exs ================================================ defmodule BorutaIdentityWeb.UserSessionControllerTest do use BorutaIdentityWeb.ConnCase, async: true import BorutaIdentity.AccountsFixtures alias BorutaIdentity.Repo setup :with_a_request setup %{identity_provider: identity_provider} do {:ok, user} = user_fixture(%{backend: identity_provider.backend}) |> Ecto.Changeset.change(confirmed_at: DateTime.utc_now()) |> Repo.update() %{user: user} end describe "GET /users/log_in" do test "renders log in page", %{conn: conn, request: request} do conn = get(conn, Routes.user_session_path(conn, :new, request: request)) response = html_response(conn, 200) assert response =~ "

    Log in

    " end test "redirects if already logged in", %{conn: conn, user: user, request: request} do conn = conn |> log_in(user) |> get(Routes.user_session_path(conn, :new, request: request)) assert redirected_to(conn) == "/user_return_to" end end describe "POST /users/log_in" do test "logs the user in with remember me", %{conn: conn, user: user, request: request} do conn = post(conn, Routes.user_session_path(conn, :create, request: request), %{ "user" => %{ "email" => user.username, "password" => valid_user_password(), "remember_me" => "true" } }) assert conn.resp_cookies["_boruta_identity_web_user_remember_me"] assert redirected_to(conn) =~ "/" end test "returns unauthorized when not confirmed", %{ conn: conn, request: request, identity_provider: identity_provider } do user = user_fixture(%{backend: identity_provider.backend}) conn = post(conn, Routes.user_session_path(conn, :create, request: request), %{ "user" => %{"email" => user.username, "password" => valid_user_password()} }) response = html_response(conn, 401) assert response =~ "

    Resend confirmation instructions

    " assert response =~ "Email confirmation is required to authenticate." end test "returns unauthorized with invalid credentials", %{ conn: conn, user: user, request: request } do conn = post(conn, Routes.user_session_path(conn, :create, request: request), %{ "user" => %{"email" => user.username, "password" => "invalid_password"} }) response = html_response(conn, 401) assert response =~ "

    Log in

    " assert response =~ "Invalid email or password" end test "logs the user in", %{conn: conn, user: user, request: request} do conn = post(conn, Routes.user_session_path(conn, :create, request: request), %{ "user" => %{"email" => user.username, "password" => valid_user_password()} }) assert get_session(conn, :user_token) assert redirected_to(conn) == "/user_return_to" end test "logs the user in with totp identity provider", %{ conn: conn, user: user, request: request, identity_provider: identity_provider } do Ecto.Changeset.change(identity_provider, %{totpable: true}) |> Repo.update() conn = post(conn, Routes.user_session_path(conn, :create, request: request), %{ "user" => %{"email" => user.username, "password" => valid_user_password()} }) assert redirected_to(conn) == "/user_return_to" end test "returns totp template with totp identity provider and totp registered user", %{ conn: conn, user: user, request: request, identity_provider: identity_provider } do Ecto.Changeset.change(identity_provider, %{totpable: true}) |> Repo.update() Ecto.Changeset.change(user, %{ totp_registered_at: DateTime.utc_now() }) |> Repo.update() conn = post(conn, Routes.user_session_path(conn, :create, request: request), %{ "user" => %{"email" => user.username, "password" => valid_user_password()} }) assert html_response(conn, 200) =~ ~r/TOTP authentication/ end test "returns totp template with totp enforced identity provider and totp registered user", %{ conn: conn, user: user, request: request, identity_provider: identity_provider } do Ecto.Changeset.change(identity_provider, %{ enforce_totp: true, totpable: true }) |> Repo.update() Ecto.Changeset.change(user, %{ totp_registered_at: DateTime.utc_now() }) |> Repo.update() conn = post(conn, Routes.user_session_path(conn, :create, request: request), %{ "user" => %{"email" => user.username, "password" => valid_user_password()} }) assert html_response(conn, 200) =~ ~r/TOTP authentication/ end test "redirects to totp registration with totp enforced identity provider", %{ conn: conn, user: user, request: request, identity_provider: identity_provider } do Ecto.Changeset.change(identity_provider, %{ enforce_totp: true, totpable: true }) |> Repo.update() conn = post(conn, Routes.user_session_path(conn, :create, request: request), %{ "user" => %{"email" => user.username, "password" => valid_user_password()} }) assert redirected_to(conn) =~ Routes.totp_path(conn, :new) end end describe "GET /users/totp_authenticate" do test "redirects to login", %{ conn: conn, request: request } do conn = conn |> get(Routes.user_session_path(conn, :initialize_totp, request: request)) assert redirected_to(conn) =~ Routes.user_session_path(conn, :new) end test "returns totp template with totp enforced identity provider and logged in user", %{ conn: conn, user: user, request: request, identity_provider: identity_provider } do Ecto.Changeset.change(identity_provider, %{totpable: true, enforce_totp: true}) |> Repo.update() conn = conn |> log_in(user) |> get(Routes.user_session_path(conn, :initialize_totp, request: request)) assert redirected_to(conn) =~ Routes.totp_path(conn, :new) end test "returns totp template with totp enforced identity provider and totp logged in registered user", %{ conn: conn, user: user, request: request, identity_provider: identity_provider } do Ecto.Changeset.change(identity_provider, %{ totpable: true, enforce_totp: true }) |> Repo.update() Ecto.Changeset.change(user, %{ totp_registered_at: DateTime.utc_now() }) |> Repo.update() conn = conn |> log_in(user) |> get(Routes.user_session_path(conn, :initialize_totp, request: request)) assert html_response(conn, 200) =~ ~r/TOTP authentication/ end test "returns totp template with totp identity provider and totp logged in registered user", %{ conn: conn, user: user, request: request, identity_provider: identity_provider } do Ecto.Changeset.change(identity_provider, %{totpable: true}) |> Repo.update() Ecto.Changeset.change(user, %{ totp_registered_at: DateTime.utc_now() }) |> Repo.update() conn = conn |> log_in(user) |> get(Routes.user_session_path(conn, :initialize_totp, request: request)) assert html_response(conn, 200) =~ ~r/TOTP authentication/ end end describe "GET /users/log_out" do test "logs the user out", %{conn: conn, user: user, request: request} do conn = conn |> log_in(user) |> get(Routes.user_session_path(conn, :delete, request: request)) assert redirected_to(conn) == Routes.user_session_path(conn, :new, request: request) assert get_flash(conn, :info) =~ "Logged out successfully" end test "succeeds even if the user is not logged in", %{conn: conn, request: request} do conn = get(conn, Routes.user_session_path(conn, :delete, request: request)) assert redirected_to(conn) == Routes.user_session_path(conn, :new, request: request) assert get_flash(conn, :info) =~ "Logged out successfully" end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity_web/controllers/user_settings_controller_test.exs ================================================ defmodule BorutaIdentityWeb.UserSettingsControllerTest do use BorutaIdentityWeb.ConnCase import BorutaIdentity.AccountsFixtures alias BorutaIdentity.Accounts.User alias BorutaIdentity.Repo setup :register_and_log_in describe "GET /users/settings" do setup :with_a_request test "renders settings page", %{conn: conn, request: request} do conn = get(conn, Routes.user_settings_path(conn, :edit, request: request)) response = html_response(conn, 200) assert response =~ "

    Edit user

    " end test "redirects if user is not logged in", %{request: request} do conn = build_conn() conn = get(conn, Routes.user_settings_path(conn, :edit, request: request)) assert redirected_to(conn) == Routes.user_session_path(conn, :new, request: request) end end describe "PUT /users/settings" do setup :with_a_request setup %{identity_provider: identity_provider, user: user} do {:ok, _identity_provider} = identity_provider |> Ecto.Changeset.change(%{backend_id: user.backend.id}) |> Repo.update() :ok end @tag :skip test "render errors when data is invalid" test "updates an user with metadata", %{conn: conn, request: request, user: user} do {:ok, _backend} = Ecto.Changeset.change(user.backend, %{metadata_fields: [%{"attribute_name" => "test", "user_editable" => true}]}) |> Repo.update() conn = put(conn, Routes.user_settings_path(conn, :update, request: request), %{ "user" => %{ "current_password" => valid_user_password(), "metadata" => %{"test" => "test value"} } }) assert redirected_to(conn, 302) == Routes.user_settings_path(conn, :edit, request: request) assert %User{metadata: %{"test" => %{"value" => "test value", "status" => "valid"}}} = Repo.reload(user) end test "updates an user without metadata (do not override)", %{ conn: conn, request: request, user: user } do {:ok, _user} = Ecto.Changeset.change(user, %{metadata: %{"test" => "test value"}}) |> Repo.update() conn = put(conn, Routes.user_settings_path(conn, :update, request: request), %{ "user" => %{ "current_password" => valid_user_password() } }) assert redirected_to(conn, 302) == Routes.user_settings_path(conn, :edit, request: request) assert %User{metadata: %{"test" => "test value"}} = Repo.reload(user) end end describe "POST /users/destroy" do setup :with_a_request test "redirects to log in", %{conn: conn, request: request} do conn = post(conn, Routes.user_settings_path(conn, :destroy, request: request)) assert redirected_to(conn) =~ ~r"/users/log_in" end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity_web/plugs/sessions_test.exs ================================================ defmodule BorutaIdentityWeb.SessionsTest do use BorutaIdentityWeb.ConnCase, async: true import BorutaIdentity.AccountsFixtures alias BorutaIdentityWeb.Sessions @remember_me_cookie "_boruta_identity_web_user_remember_me" setup %{conn: conn} do conn = conn |> Map.replace!(:secret_key_base, BorutaIdentityWeb.Endpoint.config(:secret_key_base)) |> init_test_session(%{}) |> Plug.Conn.fetch_query_params() %{user: user_fixture(), conn: conn} end describe "fetch_current_user/2" do test "authenticates user from session", %{conn: conn, user: user} do user_token = generate_user_session_token(user) conn = conn |> put_session(:user_token, user_token) |> Sessions.fetch_current_user([]) assert conn.assigns.current_user.id == user.id end test "does not authenticate if data is missing", %{conn: conn, user: user} do _ = generate_user_session_token(user) conn = Sessions.fetch_current_user(conn, []) refute get_session(conn, :user_token) refute conn.assigns.current_user end test "authenticates user from cookies", %{conn: conn, user: user} do logged_in_conn = conn |> fetch_cookies() |> log_in(user, %{"user" => %{"remember_me" => "true"}}) user_token = logged_in_conn.cookies[@remember_me_cookie] %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] conn = conn |> put_req_cookie(@remember_me_cookie, signed_token) |> Sessions.fetch_current_user([]) assert get_session(conn, :user_token) == user_token assert conn.assigns.current_user.id == user.id end end describe "redirect_if_user_is_authenticated/2" do test "redirects if user is authenticated", %{conn: conn, user: user} do conn = conn |> assign(:current_user, user) |> Sessions.redirect_if_user_is_authenticated([]) assert conn.halted assert redirected_to(conn) == "/" end test "does not redirect if user is not authenticated", %{conn: conn} do conn = Sessions.redirect_if_user_is_authenticated(conn, []) refute conn.halted refute conn.status end end describe "require_authenticated_user/2" do test "redirects if user is not authenticated", %{conn: conn} do conn = conn |> fetch_flash() |> Sessions.require_authenticated_user([]) assert conn.halted assert redirected_to(conn) == Routes.user_session_path(conn, :new) assert get_flash(conn, :error) == "You must log in to access this page." end test "does not redirect if user is authenticated", %{conn: conn, user: user} do conn = conn |> assign(:current_user, user) |> Sessions.require_authenticated_user([]) refute conn.halted refute conn.status end end end ================================================ FILE: apps/boruta_identity/test/boruta_identity_web/views/error_view_test.exs ================================================ defmodule BorutaIdentityWeb.ErrorViewTest do use BorutaIdentityWeb.ConnCase, async: true # Bring render/3 and render_to_string/3 for testing custom views import Phoenix.View test "renders 404.html" do assert render_to_string(BorutaIdentityWeb.ErrorView, "404.html", []) =~ "Page not found" end test "renders 500.html" do assert render_to_string(BorutaIdentityWeb.ErrorView, "500.html", []) =~ "Internal server error" end end ================================================ FILE: apps/boruta_identity/test/boruta_identity_web/views/layout_view_test.exs ================================================ defmodule BorutaIdentityWeb.LayoutViewTest do use BorutaIdentityWeb.ConnCase, async: true # When testing helpers, you may want to import Phoenix.HTML and # use functions such as safe_to_string() to convert the helper # result into an HTML string. # import Phoenix.HTML end ================================================ FILE: apps/boruta_identity/test/boruta_identity_web/views/page_view_test.exs ================================================ defmodule BorutaIdentityWeb.PageViewTest do use BorutaIdentityWeb.ConnCase, async: true end ================================================ FILE: apps/boruta_identity/test/support/boruta_factory.ex ================================================ defmodule Boruta.Factory do @moduledoc false use ExMachina.Ecto, repo: BorutaAuth.Repo alias Boruta.Ecto def client_factory do %Ecto.Client{ secret: SecureRandom.urlsafe_base64(), redirect_uris: ["https://redirect.uri/oauth2-redirect-path"], access_token_ttl: 3600, authorization_code_ttl: 60, private_key: "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVO\nf8cU8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa\n9QyHsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8Wd\nSq3dGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/\nU8xDZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2t\npyQ0AEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQABAoIBAG0dg/upL8k1IWiv\n8BNphrXIYLYQmiiBQTPJWZGvWIC2sl7i40yvCXjDjiRnZNK9HwgL94XtALCXYRFR\nJD41bRA3MO5A0HSPIWwJXwS10/cU56HVCNHjwKa6Rz/QiG2kNASMZEMzlvHtrjna\ndx36/sjI3HH8gh1BaTZyiuDE72SMkPbL838jfL1YY9uJ0u6hWFDbdn3sqPfJ6Cnz\n1cu0piT35nkilnIGCNYA0i3lyMeo4XrdXaAJdN9nnqbCi5ewQWqaHbrIIY5LTgzJ\nYlOr3IiecyokFxHCbULXle60u0KqXYgBHmlQJJr1Dj4c9AkQmefjC2jRMlhOrIzo\nIkIUeMECgYEA+MNLB+w6vv1ogqzM3M1OLt6bziWJCn+XkziuMrCiY9KeDD+S70+E\nhfbhM5RjCE3wxC/k59039laT973BmdMHxrDd2zSjOFmCIORv5yrD5oBHMaMZcwuQ\n45Xisi4aoQoOhyznSnjo/RjeQB7qEDzXFznLLNT79HzqyAtCWD3UIu8CgYEA2yik\n9FKl7HJEY94D2K6vNh1AHGnkwIQC72pXzlUrVuwQYngj6/Gkhw8ayFBApHfwVCXj\no9rDYPdNrrAs0Zz0JsiJp6bOCEKCrMYE16UiejUUAg/OZ5eg6+3m3/iWatkzLUuK\n1LIkVBJlEyY0uPuAaBF0V0VleNvfCGhVYOn46+ECgYAUD4OsduNh5YOZDiBTKgdF\nBlSgMiyz+QgbKjX6Bn6B+EkgibvqqonwV7FffHbkA40H9SjLfe52YhL6poXHRtpY\nroillcAX2jgBOQrBJJS5sNyM5y81NNiRUdP/NHKXS/1R71ATlF6NkoTRvOx5NL7P\ns6xryB0tYSl5ylamUQ4bZwKBgHF6FB9mA//wErVbKcayfIqajq2nrwh30kVBXQG7\nW9uAE+PIrWDoF/bOvWFnHHGMoOYRUFNxXKUCqDiBhFNs34aNY6lpV1kzhxIK3ksC\neF2qyhdfM9Kz0mEXJ+pkfw4INNWJPfNv4hueArPtnnMB1rUMBJ+DkU0JG+zwiPTL\ncVZBAoGBAM6kOsh5KGn3aI83g9ZO0TrKLXXFotxJt31Wu11ydj9K33/Qj3UXcxd4\nJPXr600F0DkLeUKBob6BALeHFWcrSz5FGLGRqdRxdv+L6g18WH5m2xEs7o6M6e5I\nIhyUC60ZewJ2M8rV4KgCJJdZE2kENlSgjU92IDVPT9Oetrc7hQJd\n-----END RSA PRIVATE KEY-----\n\n", public_key: "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVOf8cU\n8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa9QyH\nsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8WdSq3d\nGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/U8xD\nZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2tpyQ0\nAEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQAB\n-----END RSA PUBLIC KEY-----\n\n" } end def scope_factory do %Ecto.Scope{ name: SecureRandom.hex(10), public: false } end def token_factory do %Ecto.Token{ client: build(:client), type: "access_token", value: Boruta.TokenGenerator.generate(), expires_at: :os.system_time(:seconds) + 10 } end end ================================================ FILE: apps/boruta_identity/test/support/boruta_identity_factory.ex ================================================ defmodule BorutaIdentity.Factory do @moduledoc false use ExMachina.Ecto, repo: BorutaIdentity.Repo alias BorutaIdentity.Accounts.Consent alias BorutaIdentity.Accounts.EmailTemplate alias BorutaIdentity.Accounts.Internal alias BorutaIdentity.Accounts.Role alias BorutaIdentity.Accounts.RoleScope alias BorutaIdentity.Accounts.User alias BorutaIdentity.Accounts.UserAuthorizedScope alias BorutaIdentity.Accounts.UserRole alias BorutaIdentity.Accounts.UserToken alias BorutaIdentity.Configuration.ErrorTemplate alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.IdentityProviders.ClientIdentityProvider alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaIdentity.IdentityProviders.Template alias BorutaIdentity.Organizations.Organization # @password "hello world!" @hashed_password "$argon2id$v=19$m=131072,t=8,p=4$9lPv7KsJogno0FlnhaRQXA$TeTY9FYjR1HJtZzg+N1z0oDC+0Mn7buPpOMhDP+M2Ik" def user_factory do %User{ username: "user#{System.unique_integer()}@example.com", uid: SecureRandom.hex(), backend: insert(:backend) } end def user_authorized_scope_factory do %UserAuthorizedScope{} end def user_role_factory do %UserRole{} end def reset_password_user_token_factory do user = build(:user) %UserToken{ token: SecureRandom.hex(64), context: "reset_password", sent_to: user.username, user: user } end def internal_user_factory do %Internal.User{ email: "user#{System.unique_integer()}@example.com", hashed_password: @hashed_password, backend: build(:backend) } end def consent_factory do %Consent{ client_id: SecureRandom.uuid(), scopes: [] } end def client_identity_provider_factory do %ClientIdentityProvider{ client_id: SecureRandom.uuid(), identity_provider: build(:identity_provider) } end def identity_provider_factory do %IdentityProvider{ name: sequence(:name, &"identity provider #{&1}"), backend: build(:backend) } end def backend_factory do %Backend{ name: "backend name", type: "Elixir.BorutaIdentity.Accounts.Internal" } end def federated_backend_factory do %Backend{ name: "backend name", type: "Elixir.BorutaIdentity.Accounts.Internal", federated_servers: [%{ "name" => "federated", "client_id" => "client_id", "client_secret" => "client_secret", "base_url" => "http://localhost:7878", "token_path" => "/token_path", "authorize_path" => "/authorize_path", "userinfo_path" => "/userinfo_path", }] } end def ldap_backend_factory do %Backend{ name: "backend name", type: "Elixir.BorutaIdentity.Accounts.Ldap", ldap_pool_size: 2, ldap_host: "ldpa.test", ldap_user_rdn_attribute: "sn", ldap_base_dn: "dc=ldap,dc=test", ldap_ou: "" } end def smtp_backend_factory do %Backend{ name: "backend name", type: "Elixir.BorutaIdentity.Accounts.Internal", smtp_from: "from@test.factory", smtp_relay: "test.smtp.factory", smtp_ssl: false, smtp_tls: "never", smtp_username: "factory_smtp_username", smtp_password: "factory_smtp_password", smtp_port: 25 } end def template_factory do %Template{ type: "template_type", content: "template content" } end def new_registration_template_factory do %Template{ type: "new_registration", content: Template.default_content(:new_registration) } end def email_template_factory do %EmailTemplate{ type: "template_type", txt_content: "template content", html_content: "template content" } end def reset_password_instructions_email_template_factory do %EmailTemplate{ type: "reset_password_instructions", txt_content: EmailTemplate.default_txt_content(:reset_password_instructions), html_content: EmailTemplate.default_html_content(:reset_password_instructions) } end def error_template_factory do %ErrorTemplate{ type: "400", content: "error template content" } end def role_factory do %Role{ name: SecureRandom.hex(32) } end def role_scope_factory do %RoleScope{} end def organization_factory do %Organization{ name: "Organization " <> SecureRandom.hex() } end end ================================================ FILE: apps/boruta_identity/test/support/conn_case.ex ================================================ defmodule BorutaIdentityWeb.ConnCase do @moduledoc """ This module defines the test case to be used by tests that require setting up a connection. Such tests rely on `Phoenix.ConnTest` and also import other functionality to make it easier to build common data structures and query the data layer. Finally, if the test case interacts with the database, we enable the SQL sandbox, so changes done to the database are reverted at the end of every test. If you are using PostgreSQL, you can even run database tests asynchronously by setting `use BorutaIdentityWeb.ConnCase, async: true`, although this option is not recommended for other databases. """ use ExUnit.CaseTemplate alias BorutaIdentity.Accounts.User alias BorutaIdentity.Accounts.UserToken alias BorutaIdentity.Repo alias BorutaIdentityWeb.Authenticable alias Ecto.Adapters.SQL.Sandbox using do quote do # Import conveniences for testing with connections import Plug.Conn import Phoenix.ConnTest import BorutaIdentityWeb.ConnCase alias BorutaIdentityWeb.Router.Helpers, as: Routes # The default endpoint for testing @endpoint BorutaIdentityWeb.Endpoint end end setup tags do :ok = Sandbox.checkout(BorutaIdentity.Repo) :ok = Sandbox.checkout(BorutaAuth.Repo) unless tags[:async] do Sandbox.mode(BorutaIdentity.Repo, {:shared, self()}) end {:ok, conn: Phoenix.ConnTest.build_conn()} end @doc """ Setup helper that registers and logs in users. setup :register_and_log_in It stores an updated connection and a registered user in the test context. """ def register_and_log_in(%{conn: conn}) do user = BorutaIdentity.AccountsFixtures.user_fixture() %{conn: log_in(conn, user), user: user} end @doc """ Generates a session token. """ @spec generate_user_session_token(user :: User.t()) :: token :: String.t() def generate_user_session_token(%User{id: user_id}) do user = Repo.get!(User, user_id) User.login_changeset(user) |> Repo.update() {token, user_token} = UserToken.build_session_token(user) Repo.insert!(user_token) token end @doc """ Logs the given `user` into the `conn`. It returns an updated `conn`. """ def log_in(conn, user, params \\ %{}) do token = generate_user_session_token(user) conn |> Phoenix.ConnTest.init_test_session(%{}) |> Map.put(:body_params, params) |> Authenticable.store_user_session(token) end def with_a_request(_params) do identity_provider = BorutaIdentity.Factory.insert(:identity_provider, registrable: true, consentable: true, confirmable: true, user_editable: true, backend: BorutaIdentity.Factory.build(:smtp_backend) ) client = Boruta.Factory.insert(:client) scope = Boruta.Factory.insert(:scope, name: "request:scope", label: "Scope from request") client_identity_provider = BorutaIdentity.Factory.insert(:client_identity_provider, identity_provider: identity_provider, client_id: client.id ) {:ok, jwt, _payload} = Joken.encode_and_sign( %{ "client_id" => client_identity_provider.client_id, "scope" => scope.name, "user_return_to" => "/user_return_to" }, BorutaIdentityWeb.Token.application_signer() ) %{request: jwt, identity_provider: identity_provider, client: client, requested_scope: scope} end end ================================================ FILE: apps/boruta_identity/test/support/data_case.ex ================================================ defmodule BorutaIdentity.DataCase do @moduledoc """ This module defines the setup for tests requiring access to the application's data layer. You may define functions here to be used as helpers in your tests. Finally, if the test case interacts with the database, we enable the SQL sandbox, so changes done to the database are reverted at the end of every test. If you are using PostgreSQL, you can even run database tests asynchronously by setting `use BorutaIdentity.DataCase, async: true`, although this option is not recommended for other databases. """ use ExUnit.CaseTemplate alias Ecto.Adapters.SQL.Sandbox using do quote do alias BorutaIdentity.Repo import Ecto import Ecto.Changeset import Ecto.Query import BorutaIdentity.DataCase import BorutaIdentity.Factory end end setup tags do :ok = Sandbox.checkout(BorutaIdentity.Repo) :ok = Sandbox.checkout(BorutaAuth.Repo) unless tags[:async] do Sandbox.mode(BorutaIdentity.Repo, {:shared, self()}) Sandbox.mode(BorutaAuth.Repo, {:shared, self()}) end :ok end @doc """ A helper that transforms changeset errors into a map of messages. assert {:error, changeset} = Accounts.create_user(%{password: "short"}) assert "password is too short" in errors_on(changeset).password assert %{password: ["password is too short"]} = errors_on(changeset) """ def errors_on(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> Regex.replace(~r"%{(\w+)}", message, fn _, key -> opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() end) end) end end ================================================ FILE: apps/boruta_identity/test/support/fixtures/accounts_fixtures.ex ================================================ defmodule BorutaIdentity.AccountsFixtures do @moduledoc """ This module defines test helpers for creating entities via the `BorutaIdentity.Accounts` context. """ import BorutaIdentity.Factory alias Boruta.Ecto.Admin alias BorutaIdentity.Accounts.UserAuthorizedScope alias BorutaIdentity.Repo # From BorutaIdentity.Factory @password "hello world!" def unique_user_email, do: "user#{System.unique_integer()}@example.com" def valid_user_password, do: @password def user_fixture(attrs \\ %{}, account_type \\ "internal") do backend = attrs[:backend] || insert(:backend) user = insert(:internal_user, Map.merge(%{backend: backend}, attrs)) insert(:user, username: user.email, uid: user.id, backend: backend, account_type: account_type, metadata: attrs[:metadata] || %{} ) |> Repo.preload([:backend, :authorized_scopes, :roles, :organizations]) end def user_scopes_fixture(user, attrs \\ %{}) do {:ok, scope} = Admin.create_scope(%{name: "name"}) {:ok, scope} = Repo.insert( %UserAuthorizedScope{ user_id: user.id, scope_id: scope.id } |> Ecto.Changeset.change(attrs) ) scope end def extract_user_token(fun) do {:ok, captured} = fun.(&"[TOKEN]#{&1}[TOKEN]") [_, token, _] = String.split(captured.body, "[TOKEN]") token end end ================================================ FILE: apps/boruta_identity/test/support/fixtures/admin_fixtures.ex ================================================ defmodule BorutaIdentity.AdminFixtures do @moduledoc """ This module defines test helpers for creating entities via the `BorutaIdentity.Admin` context. """ def role_fixture(attrs \\ %{}) do {:ok, role} = attrs |> Enum.into(%{ name: "some name" }) |> BorutaIdentity.Admin.create_role() role end end ================================================ FILE: apps/boruta_identity/test/support/fixtures/identity_providers_fixtures.ex ================================================ defmodule BorutaIdentity.IdentityProvidersFixtures do @moduledoc """ This module defines test helpers for creating entities via the `BorutaIdentity.IdentityProviders` context. """ @doc """ Generate a backend. """ def backend_fixture(attrs \\ %{}) do {:ok, backend} = attrs |> Enum.into(%{ type: "Elixir.BorutaIdentity.Accounts.Internal", name: "some name" }) |> BorutaIdentity.IdentityProviders.create_backend() backend end end ================================================ FILE: apps/boruta_identity/test/test_helper.exs ================================================ ExUnit.start() Mox.defmock(BorutaIdentity.LdapRepoMock, for: BorutaIdentity.LdapRepo) Ecto.Adapters.SQL.Sandbox.mode(BorutaIdentity.Repo, :manual) Ecto.Adapters.SQL.Sandbox.mode(BorutaAuth.Repo, :manual) Logger.remove_backend(:console) ================================================ FILE: apps/boruta_web/.formatter.exs ================================================ [ import_deps: [:phoenix], inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] ] ================================================ FILE: apps/boruta_web/.gitignore ================================================ # The directory Mix will write compiled artifacts to. /_build/ # If you run "mix test --cover", coverage assets end up here. /cover/ # The directory Mix downloads your dependencies sources to. /deps/ # Where 3rd-party dependencies like ExDoc output generated docs. /doc/ # Ignore .fetch files in case you like to edit your project deps locally. /.fetch # If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump # Also ignore archive artifacts (built via "mix archive.build"). *.ez # Ignore package tarball (built via "mix hex.build"). boruta_web-*.tar # Files matching config/*.secret.exs pattern contain sensitive # data and you should not commit them into version control. # # Alternatively, you may comment the line below and commit the # secrets files as long as you replace their contents by environment # variables. /config/*.secret.exs /tmp/ /priv/static/admin ================================================ FILE: apps/boruta_web/config/config.exs ================================================ import Config config :boruta_web, ecto_repos: [BorutaAuth.Repo, BorutaWeb.Repo] config :boruta_web, BorutaWeb.Endpoint, url: [host: "localhost"], secret_key_base: "Caq0kwgjLGwxoEVPOxUhEiZ3AG2nADaNYi+ceWh2RuAgKF6vv/FfwqM/P7cDcNrR", render_errors: [view: BorutaWeb.ErrorView, accepts: ~w(html json)], pubsub_server: BorutaWeb.PubSub config :mime, :types, %{ "text/event-stream" => ["event-stream"], "application/jwt" => ["jwt"] } config :phoenix, :json_library, Jason config :swoosh, :api_client, Swoosh.ApiClient.Finch config :boruta, Boruta.Oauth, repo: BorutaAuth.Repo, contexts: [ resource_owners: BorutaIdentity.ResourceOwners ], max_ttl: [ authorization_code: 600 ], issuer: System.get_env("BORUTA_OAUTH_BASE_URL", "http://localhost:4000") config :boruta_auth, BorutaAuth.LogRotate, max_retention_days: String.to_integer(System.get_env("MAX_LOG_RETENTION_DAYS", "60")) config :hammer, backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, cleanup_interval_ms: 60_000 * 10]} import_config "#{Mix.env()}.exs" ================================================ FILE: apps/boruta_web/config/dev.exs ================================================ import Config config :boruta_web, BorutaWeb.Endpoint, http: [port: System.get_env("BORUTA_OAUTH_PORT", "4000") |> String.to_integer()], debug_errors: true, code_reloader: true, watchers: [ npm: [ "run", "build:watch", cd: Path.expand("../../boruta_identity/assets/wallet", __DIR__) ] ] config :boruta_web, BorutaWeb.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_dev", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 5 config :boruta_auth, BorutaAuth.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_dev", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 5 config :boruta_identity, BorutaIdentity.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_dev", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 10 config :boruta_admin, BorutaAdmin.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_dev", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 10 config :boruta_identity, Boruta.Accounts, secret_key_base: "secret" config :libcluster, topologies: [ example: [ strategy: Cluster.Strategy.Epmd, config: [hosts: []], connect: {:net_kernel, :connect_node, []}, disconnect: {:erlang, :disconnect_node, []}, list_nodes: {:erlang, :nodes, [:connected]}, ] ] ================================================ FILE: apps/boruta_web/config/prod.exs ================================================ import Config ================================================ FILE: apps/boruta_web/config/test.exs ================================================ import Config config :boruta_web, BorutaWeb.Endpoint, http: [port: 4002], server: false, secret_key_base: "averysecretkeybaseaverysecretkeybaseaverysecretkeybaseaverysecretkeybase" config :boruta_identity, BorutaIdentityWeb.Endpoint, http: [port: 4003], server: false, secret_key_base: "averysecretkeybaseaverysecretkeybaseaverysecretkeybaseaverysecretkeybase" config :boruta_web, BorutaWeb.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_web_test", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool: Ecto.Adapters.SQL.Sandbox config :boruta_identity, BorutaIdentity.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_identity_test", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool: Ecto.Adapters.SQL.Sandbox config :boruta_auth, BorutaAuth.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_web_test", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool: Ecto.Adapters.SQL.Sandbox config :boruta_identity, Boruta.Accounts, secret_key_base: "secret" config :logger, level: :warn config :libcluster, topologies: [ example: [ strategy: Cluster.Strategy.Epmd, config: [hosts: []], connect: {:net_kernel, :connect_node, []}, disconnect: {:erlang, :disconnect_node, []}, list_nodes: {:erlang, :nodes, [:connected]}, ] ] config :boruta, Boruta.Oauth, did_resolver_base_url: "https://universalresolver.boruta.patatoid.fr/1.0" ================================================ FILE: apps/boruta_web/lib/boruta/status_resolver.ex ================================================ defmodule Boruta.Did.StatusResolver do @moduledoc false def resolve(_status) do end end ================================================ FILE: apps/boruta_web/lib/boruta_web/application.ex ================================================ defmodule BorutaWeb.Application do @moduledoc false require Logger use Application def start(_type, _args) do children = [ BorutaWeb.Endpoint, BorutaWeb.Repo, %{ id: BorutaWeb.PresentationServer, start: {BorutaWeb.PresentationServer, :start_link, []} }, BorutaWeb.Plugs.RateLimit.Counter, {Finch, name: FinchHttp}, {Cluster.Supervisor, [Application.get_env(:libcluster, :topologies), [name: BorutaWeb.ClusterSupervisor]]}, {Phoenix.PubSub, name: BorutaWeb.PubSub} ] BorutaWeb.Logger.start() setup_database() opts = [strategy: :one_for_one, name: BorutaWeb.Supervisor] Supervisor.start_link(children, opts) end def config_change(changed, _new, removed) do BorutaWeb.Endpoint.config_change(changed, removed) :ok end def setup_database do Enum.each([BorutaAuth.Repo, BorutaIdentity.Repo], fn repo -> repo.__adapter__.storage_up(repo.config) end) need_seeding? = not (Ecto.Migrator.migrated_versions(BorutaAuth.Repo) # First BorutaAuth migration |> Enum.member?(20_201_129_024_828)) Enum.each([BorutaAuth.Repo, BorutaIdentity.Repo], fn repo -> Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) end) if need_seeding? do seed() end :ok end defp seed do Code.eval_file(Path.join(:code.priv_dir(:boruta_auth), "/repo/boruta.seeds.exs")) end end ================================================ FILE: apps/boruta_web/lib/boruta_web/controllers/did_controller.ex ================================================ defmodule BorutaWeb.DidController do use BorutaWeb, :controller alias Boruta.Openid.VerifiableCredentials def resolve_status(conn, %{"status" => salt}) do clients = Boruta.Ecto.Admin.list_clients() status = Enum.reduce_while(clients, :invaild, fn client, _acc -> case VerifiableCredentials.Status.verify_status_token(client.private_key, salt) do :expired -> {:cont, :expired} :invalid -> {:cont, :invalid} status -> {:halt, status} end end) send_resp(conn, 200, Atom.to_string(status)) end end ================================================ FILE: apps/boruta_web/lib/boruta_web/controllers/fallback_controller.ex ================================================ defmodule BorutaWeb.FallbackController do @moduledoc """ Translates controller action results into valid `Plug.Conn` responses. See `Phoenix.Controller.action_fallback/1` for more details. """ use BorutaWeb, :controller def call(conn, {:error, %Ecto.Changeset{} = changeset}) do conn |> put_status(:unprocessable_entity) |> put_view(BorutaWeb.ChangesetView) |> render("error.json", changeset: changeset) end def call(conn, {:error, :not_found}) do conn |> put_status(:not_found) |> put_view(BorutaWeb.ErrorView) |> render(:"404") end end ================================================ FILE: apps/boruta_web/lib/boruta_web/controllers/monitoring_controller.ex ================================================ defmodule BorutaWeb.MonitoringController do use BorutaWeb, :controller plug Phoenix.Ecto.CheckRepoStatus, [otp_app: :boruta_web] when action in [:healthcheck] def healthcheck(conn, _params) do send_resp(conn, 204, "") end end ================================================ FILE: apps/boruta_web/lib/boruta_web/controllers/oauth/authorize_controller.ex ================================================ defmodule BorutaWeb.AuthorizeError do @enforce_keys [:message] defexception [:message, :plug_status] end defmodule BorutaWeb.Oauth.AuthorizeController do @dialyzer :no_match @behaviour Boruta.Oauth.AuthorizeApplication use BorutaWeb, :controller import BorutaIdentityWeb.Authenticable, only: [request_param: 1, get_user_session: 1] alias Boruta.ClientsAdapter alias Boruta.Oauth alias Boruta.Oauth.AuthorizationSuccess alias Boruta.Oauth.AuthorizeResponse alias Boruta.Oauth.Error alias Boruta.Oauth.ResourceOwner alias Boruta.Openid.CredentialOfferResponse alias Boruta.Openid.SiopV2Response alias Boruta.Openid.VerifiablePresentationResponse alias BorutaIdentity.Accounts alias BorutaIdentity.Accounts.Deliveries alias BorutaIdentity.Accounts.User alias BorutaIdentity.Accounts.VerifiableCredentials alias BorutaIdentity.Accounts.VerifiablePresentations alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaIdentity.ResourceOwners alias BorutaIdentityWeb.Router.Helpers, as: IdentityRoutes alias BorutaIdentityWeb.TemplateView alias BorutaWeb.PresentationServer @public_response_types ["id_token", "code", "vp_token"] def authorize(%Plug.Conn{} = conn, _params) do current_user = conn.assigns[:current_user] conn = put_unsigned_request(conn) with {:unchanged, conn} <- public_client?(conn), {:unchanged, conn} <- prompt_redirection(conn, current_user), {:unchanged, conn} <- max_age_redirection(conn, current_user), {:unchanged, conn} <- check_preauthorized(conn), {:unchanged, conn} <- redirect_if_mfa_required(conn, current_user), {:unchanged, conn} <- preauthorize(conn, current_user) do redirect(conn, to: IdentityRoutes.user_session_path(BorutaIdentityWeb.Endpoint, :new, %{ request: request_param(conn) }) ) else {:preauthorized, conn} -> do_authorize(conn, current_user) {:preauthorize, conn} -> conn {:authorize, conn} -> conn {:redirected, conn} -> conn end end def authenticated?(conn, %{"code" => code}) do PresentationServer.start_presentation(code) conn = conn |> put_resp_header("content-type", "text/event-stream") |> send_chunked(200) receive do {:authenticated, redirect_uri} -> chunk(conn, "event: authenticated\ndata: #{redirect_uri}\n\n") {:message, message} -> chunk(conn, "event: message\ndata: #{message}\n\n") end conn end def public_client?( %Plug.Conn{ query_params: %{"response_type" => "code" <> _rest, "client_metadata" => _client_metadata} } = conn ), do: {:preauthorized, conn} def public_client?( %Plug.Conn{ query_params: %{"response_type" => "id_token" <> _rest, "client_metadata" => _client_metadata} } = conn ), do: {:preauthorized, conn} def public_client?( %Plug.Conn{ query_params: %{"response_type" => "vp_token" <> _rest, "client_metadata" => _client_metadata} } = conn ), do: {:preauthorized, conn} def public_client?(conn), do: {:unchanged, conn} defp redirect_if_mfa_required(conn, current_user) do case ensure_mfa(conn, current_user) do :ok -> {:unchanged, conn} {:error, action, reason} -> case get_session(conn, :session_chosen) do true -> conn = conn |> put_flash(:warning, reason) |> redirect( to: IdentityRoutes.user_session_path( BorutaIdentityWeb.Endpoint, action, %{ request: request_param(conn) } ) ) {:redirected, conn} _ -> conn = conn |> put_flash(:warning, reason) |> redirect( to: IdentityRoutes.choose_session_path(BorutaIdentityWeb.Endpoint, :index, %{ request: request_param(conn) }) ) {:redirected, conn} end end end defp ensure_mfa(%Plug.Conn{query_params: query_params} = conn, current_user) do identity_provider = IdentityProviders.get_identity_provider_by_client_id(query_params["client_id"]) totp_authenticated = (get_session(conn, :totp_authenticated) || %{})[get_user_session(conn)] webauthn_authenticated = (get_session(conn, :webauthn_authenticated) || %{})[get_user_session(conn)] do_enforce_mfa(identity_provider, current_user, totp_authenticated, webauthn_authenticated) end defp do_enforce_mfa( %IdentityProvider{enforce_totp: false, enforce_webauthn: false}, %User{totp_registered_at: nil}, _totp_authenticated, _webauthn_authenticated ) do :ok end defp do_enforce_mfa( %IdentityProvider{webauthnable: true}, %User{webauthn_registered_at: %DateTime{}}, _totp_authenticated, webauthn_authenticated ) do case webauthn_authenticated do true -> :ok _ -> {:error, :initialize_webauthn, "Multi factor authentication required."} end end defp do_enforce_mfa( %IdentityProvider{totpable: true}, %User{totp_registered_at: %DateTime{}}, totp_authenticated, _webauthn_authenticated ) do case totp_authenticated do true -> :ok _ -> {:error, :initialize_totp, "Multi factor authentication required."} end end defp do_enforce_mfa( %IdentityProvider{enforce_webauthn: true}, %User{webauthn_registered_at: nil}, _totp_authenticated, webauthn_authenticated ) do case webauthn_authenticated do true -> :ok _ -> {:error, :initialize_webauthn, "Multi factor authentication required."} end end defp do_enforce_mfa( %IdentityProvider{enforce_totp: true}, %User{totp_registered_at: nil}, totp_authenticated, _webauthn_authenticated ) do case totp_authenticated do true -> :ok _ -> {:error, :initialize_totp, "Multi factor authentication required."} end end defp do_enforce_mfa(_identity_provider, _user, _totp_authenticated, _webauthn_authenticated) do :ok end defp check_preauthorized(conn) do case get_session(conn, :preauthorizations) do nil -> {:unchanged, conn} preauthorizations -> case Map.get(preauthorizations, request_param(conn), false) do false -> {:unchanged, conn} true -> preauthorizations = get_session(conn, :preauthorizations) || %{} {:preauthorized, conn |> put_session( :preauthorizations, Map.delete(preauthorizations, request_param(conn)) )} end end end defp max_age_redirection( %Plug.Conn{query_params: %{"max_age" => max_age}} = conn, %User{} = current_user ) do case login_expired?(current_user, max_age) do true -> conn = redirect(conn, to: IdentityRoutes.user_session_path(BorutaIdentityWeb.Endpoint, :delete, %{ request: request_param(conn) }) ) {:redirected, conn} false -> {:unchanged, conn} end end defp max_age_redirection(conn, _current_user), do: {:unchanged, conn} defp prompt_redirection( %Plug.Conn{query_params: %{"prompt" => "none"} = query_params} = conn, current_user ) do case ensure_mfa(conn, current_user) do :ok -> {:authorize, do_authorize(conn, current_user)} {:error, _action, reason} -> {:redirected, authorize_error(conn, %Error{ status: :unauthorized, format: :fragment, redirect_uri: query_params["redirect_uri"], error: :login_required, error_description: reason })} end end defp prompt_redirection(%Plug.Conn{query_params: %{"prompt" => "login"}} = conn, _current_user) do conn = redirect(conn, to: IdentityRoutes.user_session_path(BorutaIdentityWeb.Endpoint, :delete, %{ request: request_param(conn) }) ) {:redirected, conn} end defp prompt_redirection(conn, _current_user), do: {:unchanged, conn} defp preauthorize(conn, nil), do: {:unchanged, conn} defp preauthorize(conn, current_user) do conn = conn |> Oauth.preauthorize( resource_owner(conn, current_user), __MODULE__ ) {:preauthorize, conn} end defp do_authorize(conn, current_user) do conn |> delete_session(:preauthorizations) |> Oauth.authorize( resource_owner(conn, current_user), __MODULE__ ) end @impl Boruta.Oauth.AuthorizeApplication def preauthorize_success(conn, %AuthorizationSuccess{ sub: "did:" <> _key = sub, response_types: response_types }) do case Enum.map(@public_response_types, &String.split(&1, " ")) |> List.first() |> Enum.member?(Enum.split(response_types, " ") |> List.first()) do true -> Oauth.authorize( conn, %ResourceOwner{ sub: sub, presentation_configuration: VerifiablePresentations.public_presentation_configuration() }, __MODULE__ ) false -> preauthorize_success(conn, :preauthorized) end end def preauthorize_success(conn, _authorization) do session_chosen? = get_session(conn, :session_chosen) || false preauthorizations = get_session(conn, :preauthorizations) || %{} case session_chosen? do true -> conn |> put_session( :preauthorizations, Map.merge(preauthorizations, %{request_param(conn) => true}) ) |> redirect( to: IdentityRoutes.user_consent_path(BorutaIdentityWeb.Endpoint, :index, %{ request: request_param(conn) }) ) false -> conn |> redirect( to: IdentityRoutes.choose_session_path(BorutaIdentityWeb.Endpoint, :index, %{ request: request_param(conn) }) ) end end @impl Boruta.Oauth.AuthorizeApplication def preauthorize_error(conn, error) do session_chosen? = get_session(conn, :session_chosen) || false case {session_chosen?, conn.assigns[:current_user]} do {true, _current_user} -> authorize_error(conn, error) {false, _current_user} -> case request_param(conn) do "" -> authorize_error(conn, error) request -> conn |> redirect( to: IdentityRoutes.choose_session_path(BorutaIdentityWeb.Endpoint, :index, %{ request: request }) ) end end end @impl Boruta.Oauth.AuthorizeApplication def authorize_success( %Plug.Conn{query_params: query_params} = conn, %AuthorizeResponse{} = response ) do # TODO get client_id, grant_type and resource_owner from response client_id = query_params["client_id"] current_user = conn.assigns[:current_user] :telemetry.execute( [:authorization, :authorize, :success], %{}, %{ access_token: response.access_token && response.access_token.value, code: response.code && response.code.value, type: response.type, response_mode: response.response_mode, expires_in: response.expires_in, client_id: client_id, current_user: current_user } ) conn |> delete_session(:session_chosen) |> redirect(external: AuthorizeResponse.redirect_to_url(response)) end def authorize_success( %Plug.Conn{} = conn, %SiopV2Response{response_mode: "post"} = response ) do {:ok, idp} = Accounts.Utils.client_identity_provider(response.client.id) template = IdentityProviders.get_identity_provider_template!(idp.id, :cross_device_presentation) conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ resource_owner: response.code.resource_owner, presentation_deeplink: SiopV2Response.redirect_to_deeplink(response, fn code -> uri = URI.parse(Boruta.Config.issuer()) %{uri | path: Routes.token_path(conn, :direct_post, code)} |> URI.to_string() end), code: response.code.value } ) end def authorize_success( %Plug.Conn{} = conn, %SiopV2Response{response_mode: "direct_post"} = response ) do # TODO log business event conn |> redirect( external: SiopV2Response.redirect_to_deeplink(response, fn code -> uri = URI.parse(Boruta.Config.issuer()) %{uri | path: Routes.token_path(conn, :direct_post, code)} |> URI.to_string() end) ) end def authorize_success( %Plug.Conn{} = conn, %VerifiablePresentationResponse{response_mode: "direct_post"} = response ) do # TODO log business event conn |> redirect( external: VerifiablePresentationResponse.redirect_to_deeplink(response, fn code -> uri = URI.parse(Boruta.Config.issuer()) %{uri | path: Routes.token_path(conn, :direct_post, code)} |> URI.to_string() end) ) end def authorize_success( %Plug.Conn{} = conn, %VerifiablePresentationResponse{response_mode: "post"} = response ) do {:ok, idp} = Accounts.Utils.client_identity_provider(response.client.id) template = IdentityProviders.get_identity_provider_template!(idp.id, :cross_device_presentation) conn |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ resource_owner: response.code.resource_owner, presentation_deeplink: VerifiablePresentationResponse.redirect_to_deeplink(response, fn code -> uri = URI.parse(Boruta.Config.issuer()) %{uri | path: Routes.token_path(conn, :direct_post, code)} |> URI.to_string() end), code: response.code.value } ) end def authorize_success( %Plug.Conn{query_params: query_params} = conn, %CredentialOfferResponse{tx_code: tx_code, tx_code_required: true} = response ) do current_user = conn.assigns[:current_user] case IdentityProviders.get_identity_provider_by_client_id(query_params["client_id"]) do %IdentityProvider{} = identity_provider -> template = IdentityProviders.get_identity_provider_template!( identity_provider.id, :credential_offer ) case Deliveries.deliver_tx_code(identity_provider.backend, current_user, tx_code) do :ok -> conn |> delete_session(:session_chosen) |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ resource_owner: response.code.resource_owner, credential_offer: response, code: response.code.value } ) {:error, _error} -> {:error, :bad_request} end nil -> raise BorutaIdentity.Accounts.IdentityProviderError, "identity provider not configured for given OAuth client. Please contact your administrator." end end def authorize_success( %Plug.Conn{query_params: query_params} = conn, %CredentialOfferResponse{} = response ) do client_id = case query_params["client_id"] do "did" <> _key -> ClientsAdapter.public!().id client_id -> client_id end case IdentityProviders.get_identity_provider_by_client_id(client_id) do %IdentityProvider{} = identity_provider -> template = IdentityProviders.get_identity_provider_template!( identity_provider.id, :credential_offer ) conn |> delete_session(:session_chosen) |> put_layout(false) |> put_view(TemplateView) |> render("template.html", template: template, assigns: %{ resource_owner: response.code.resource_owner, credential_offer: response, code: response.code.value } ) nil -> raise BorutaIdentity.Accounts.IdentityProviderError, "identity provider not configured for given OAuth client. Please contact your administrator." end end @impl Boruta.Oauth.AuthorizeApplication def authorize_error( %Plug.Conn{} = conn, %Error{status: :unauthorized, error: :login_required} = error ) do redirect(conn, external: Error.redirect_to_url(error)) end def authorize_error( %Plug.Conn{} = conn, %Error{status: :unauthorized, error: :invalid_resource_owner} = error ) do emit_authorize_error_event(conn, error) conn |> delete_session(:session_chosen) |> delete_session(:totp_authenticated) |> redirect( to: IdentityRoutes.user_session_path(BorutaIdentityWeb.Endpoint, :new, %{ request: request_param(conn) }) ) end def authorize_error(conn, %Error{format: format} = error) when not is_nil(format) do emit_authorize_error_event(conn, error) conn |> delete_session(:session_chosen) |> delete_session(:totp_authenticated) |> redirect(external: Error.redirect_to_url(error)) end def authorize_error( conn, %Error{status: status, error_description: error_description} = error ) do emit_authorize_error_event(conn, error) conn |> delete_session(:session_chosen) |> delete_session(:totp_authenticated) |> put_status(status) raise %BorutaWeb.AuthorizeError{message: error_description, plug_status: status} end defp emit_authorize_error_event(%Plug.Conn{query_params: query_params} = conn, error) do # TODO get client_id and grant_type from error client_id = query_params["client_id"] current_user = conn.assigns[:current_user] :telemetry.execute( [:authorization, :authorize, :failure], %{}, %{ status: error.status, error: error.error, error_description: error.error_description, client_id: client_id, current_user: current_user } ) end defp login_expired?(current_user, max_age) do now = DateTime.utc_now() |> DateTime.to_unix() with "" <> max_age <- max_age, {max_age, _} <- Integer.parse(max_age), true <- now - DateTime.to_unix(current_user.last_login_at) >= max_age do true else _ -> false end end defp put_unsigned_request(%Plug.Conn{query_params: query_params} = conn) do unsigned_request = with request <- Map.get(query_params, "request", ""), {:ok, params} <- Joken.peek_claims(request) do params else _ -> %{} end query_params = Map.merge(query_params, unsigned_request) %{conn | query_params: query_params} end defp resource_owner(conn, current_user) do current_user = current_user || %User{} anonymous_sub = case conn.query_params["client_id"] do "did:" <> _key = did -> did _ -> case conn.query_params["client_metadata"] do nil -> nil _ -> "unknown" end end scope = case conn.query_params["scope"] do nil -> "" scope -> scope end %ResourceOwner{ sub: current_user.id || anonymous_sub, username: current_user.username, last_login_at: current_user.last_login_at, extra_claims: Map.merge(ResourceOwners.metadata(current_user, scope), current_user.federated_metadata), authorization_details: VerifiableCredentials.authorization_details(current_user, scope), presentation_configuration: VerifiablePresentations.presentation_configuration(current_user) } end end ================================================ FILE: apps/boruta_web/lib/boruta_web/controllers/oauth/introspect_controller.ex ================================================ defmodule BorutaWeb.Oauth.IntrospectController do @behaviour Boruta.Oauth.IntrospectApplication use BorutaWeb, :controller alias Boruta.Oauth alias Boruta.Oauth.Error alias Boruta.Oauth.IntrospectResponse alias BorutaWeb.OauthView def introspect(%Plug.Conn{} = conn, _params) do conn |> Oauth.introspect(__MODULE__) end @impl Boruta.Oauth.IntrospectApplication def introspect_success(%Plug.Conn{body_params: body_params} = conn, %IntrospectResponse{} = response) do # TODO get token from response token = body_params["token"] :telemetry.execute( [:authorization, :introspect, :success], %{}, %{ active: response.active, client_id: response.client_id, sub: response.sub, token: token } ) conn |> put_view(OauthView) |> render("introspect.#{get_format(conn)}", response: response) end @impl Boruta.Oauth.IntrospectApplication def introspect_error(%Plug.Conn{body_params: body_params} = conn, %Error{ status: status, error: error, error_description: error_description }) do # TODO get client_id and token from error token = body_params["token"] :telemetry.execute( [:authorization, :introspect, :failure], %{}, %{ status: status, error: error, error_description: error_description, token: token } ) conn |> put_status(status) |> put_view(OauthView) |> render("error.json", error: error, error_description: error_description) end end ================================================ FILE: apps/boruta_web/lib/boruta_web/controllers/oauth/pushed_authorization_request_controller.ex ================================================ defmodule BorutaWeb.Oauth.PushedAuthorizationRequestController do @behaviour Boruta.Oauth.PushedAuthorizationRequestApplication use BorutaWeb, :controller alias Boruta.Oauth alias Boruta.Oauth.Error alias BorutaWeb.OauthView def pushed_authorization_request(%Plug.Conn{} = conn, _params) do conn |> Oauth.pushed_authorization_request(__MODULE__) end @impl Boruta.Oauth.PushedAuthorizationRequestApplication def request_stored(conn, response) do conn |> put_view(OauthView) |> put_status(:created) |> render("pushed_authorization_request.json", response: response) end @impl Boruta.Oauth.PushedAuthorizationRequestApplication def pushed_authorization_error(conn, %Error{ status: status, error: error, error_description: error_description }) do conn |> put_view(OauthView) |> put_status(status) |> render("error.json", error: error, error_description: error_description) end end ================================================ FILE: apps/boruta_web/lib/boruta_web/controllers/oauth/revoke_controller.ex ================================================ defmodule BorutaWeb.Oauth.RevokeController do @behaviour Boruta.Oauth.RevokeApplication use BorutaWeb, :controller alias Boruta.Oauth alias Boruta.Oauth.Error alias BorutaWeb.OauthView def revoke(%Plug.Conn{} = conn, _params) do conn |> Oauth.revoke(__MODULE__) end @impl Boruta.Oauth.RevokeApplication def revoke_success(%Plug.Conn{body_params: body_params} = conn) do # TODO get client_id and token from response token = body_params["token"] :telemetry.execute( [:authorization, :revoke, :success], %{}, %{ token: token } ) send_resp(conn, 200, "") end @impl Boruta.Oauth.RevokeApplication def revoke_error(%Plug.Conn{body_params: body_params} = conn, %Error{ status: status, error: error, error_description: error_description }) do # TODO get client_id and token from error token = body_params["token"] :telemetry.execute( [:authorization, :revoke, :failure], %{}, %{ status: status, error: error, error_description: error_description, token: token } ) conn |> put_status(status) |> put_view(OauthView) |> render("error.json", error: error, error_description: error_description) end end ================================================ FILE: apps/boruta_web/lib/boruta_web/controllers/oauth/token_controller.ex ================================================ defmodule BorutaWeb.Oauth.TokenController do @behaviour Boruta.Oauth.TokenApplication @behaviour Boruta.Openid.DirectPostApplication use BorutaWeb, :controller import Boruta.Config, only: [issuer: 0] alias Boruta.Oauth alias Boruta.Oauth.Error alias Boruta.Oauth.TokenResponse alias Boruta.Openid alias Boruta.Openid.DirectPostResponse alias BorutaWeb.OauthView alias BorutaWeb.PresentationServer def token(%Plug.Conn{} = conn, _params) do conn |> Oauth.token(__MODULE__) end @impl Boruta.Oauth.TokenApplication def token_success(conn, %TokenResponse{} = response) do # TODO get grant_type from response :telemetry.execute( [:authorization, :token, :success], %{}, %{ client_id: response.token.client.id, sub: response.token.sub, access_token: response.access_token, agent_token: response.agent_token, token_type: response.token_type, expires_in: response.expires_in, refresh_token: response.refresh_token } ) conn |> put_view(OauthView) |> put_resp_header("pragma", "no-cache") |> put_resp_header("cache-control", "no-store") |> render("token.json", response: response) end @impl Boruta.Oauth.TokenApplication def token_error(conn, %Error{status: status, error: error, error_description: error_description}) do # TODO get client_id and grant_type from error :telemetry.execute( [:authorization, :token, :failure], %{}, %{ status: status, error: error, error_description: error_description } ) conn |> put_status(status) |> put_view(OauthView) |> render("error.json", error: error, error_description: error_description) end def direct_post(conn, %{"code_id" => code_id} = params) do direct_post_params = %{ code_id: code_id, metadata_policy: params["metadata_policy"] } direct_post_params = case params do %{"id_token" => id_token} -> Map.put(direct_post_params, :id_token, id_token) %{"vp_token" => vp_token} -> Map.put(direct_post_params, :vp_token, vp_token) %{} -> direct_post_params end direct_post_params = case params do %{"presentation_submission" => presentation_submission} -> Map.put(direct_post_params, :presentation_submission, presentation_submission) %{} -> direct_post_params end Openid.direct_post(conn, direct_post_params, __MODULE__) end @impl Boruta.Openid.DirectPostApplication def code_not_found(conn) do send_resp(conn, 404, "") end @impl Boruta.Openid.DirectPostApplication def authentication_failure(conn, %Error{ redirect_uri: nil, status: status, error: error, error_description: error_description }) do conn |> put_status(status) |> put_view(OauthView) |> render("error.json", error: error, error_description: error_description) end def authentication_failure(conn, %Error{} = error) do redirect(conn, external: Error.redirect_to_url(error)) end @impl Boruta.Openid.DirectPostApplication def direct_post_success(conn, %DirectPostResponse{vp_token: vp_token} = response) when not is_nil(vp_token) do {:ok, %{"kid" => kid}} = Joken.peek_header(vp_token) case tl(String.split(response.code.response_type, " ")) do [] -> query = %{ code: response.code.value, state: response.state } |> URI.encode_query() callback_uri = URI.parse(response.redirect_uri) callback_uri = %{callback_uri | host: callback_uri.host || "", query: query} |> URI.to_string() redirect(conn, external: callback_uri) response_types -> params = %{ "client_id" => kid, "response_type" => Enum.join(response_types, " "), "client_metadata" => "{}", "scope" => response.code.requested_scope, "state" => response.code.state, "code" => response.code.value, "redirect_uri" => response.redirect_uri } redirect_uri = issuer() <> Routes.authorize_path(conn, :authorize, params) PresentationServer.authenticated(response.code.value, redirect_uri) redirect(conn, external: redirect_uri) end end def direct_post_success( conn, %DirectPostResponse{id_token: id_token, error: %Error{}} = response ) when not is_nil(id_token) do {:ok, %{"kid" => kid}} = Joken.peek_header(id_token) params = %{ "client_id" => kid, "response_type" => response.code.response_type, "client_metadata" => "{}", "scope" => response.code.requested_scope, "state" => response.code.state, "code" => response.code.value, "redirect_uri" => response.redirect_uri } redirect_uri = issuer() <> Routes.authorize_path(conn, :authorize, params) redirect(conn, external: redirect_uri) end def direct_post_success(conn, %DirectPostResponse{id_token: id_token, code: code} = response) when not is_nil(id_token) do {:ok, %{"kid" => kid}} = Joken.peek_header(id_token) case tl(String.split(response.code.response_type, " ")) do [] -> query = %{ code: response.code.value, state: response.state } |> URI.encode_query() callback_uri = URI.parse(response.redirect_uri) callback_uri = %{callback_uri | host: callback_uri.host || "", query: query} |> URI.to_string() redirect(conn, external: callback_uri) response_types -> params = %{ "client_id" => kid, "response_type" => Enum.join(response_types, " "), "client_metadata" => "{}", "scope" => response.code.requested_scope, "state" => response.code.state, "code" => response.code.value, "redirect_uri" => response.redirect_uri } redirect_uri = issuer() <> Routes.authorize_path(conn, :authorize, params) PresentationServer.authenticated(code.value, redirect_uri) redirect(conn, external: redirect_uri) end end end ================================================ FILE: apps/boruta_web/lib/boruta_web/controllers/openid/credential_controller.ex ================================================ defmodule BorutaWeb.Openid.CredentialController do @behaviour Boruta.Openid.CredentialApplication use BorutaWeb, :controller alias Boruta.Oauth.Error alias Boruta.Openid alias Boruta.Openid.CredentialResponse alias Boruta.Openid.DeferedCredentialResponse alias BorutaIdentity.Accounts.VerifiableCredentials alias BorutaWeb.OauthView alias BorutaWeb.PresentationServer def credential(conn, params) do Openid.credential( conn, params, VerifiableCredentials.public_credential_configuration(), __MODULE__ ) end def defered_credential(conn, _params) do Openid.defered_credential(conn, __MODULE__) end @impl Boruta.Openid.CredentialApplication def credential_created(conn, %CredentialResponse{} = response) do PresentationServer.message(response.token.previous_code, "Credential issued") conn |> put_view(OauthView) |> render("credential.json", credential_response: response) end def credential_created(conn, %DeferedCredentialResponse{} = response) do conn |> put_view(OauthView) |> render("defered_credential.json", credential_response: response) end @impl Boruta.Openid.CredentialApplication def credential_failure(conn, %Error{ status: status, error: error, error_description: error_description }) do conn |> put_status(status) |> put_view(OauthView) |> render("error.json", error: error, error_description: error_description) end end ================================================ FILE: apps/boruta_web/lib/boruta_web/controllers/openid/dynamic_registration_controller.ex ================================================ defmodule BorutaWeb.Openid.DynamicRegistrationController do @behaviour Boruta.Openid.DynamicRegistrationApplication alias Boruta.Ecto alias Boruta.Oauth alias Boruta.Openid alias BorutaAuth.KeyPairs.KeyPair alias BorutaIdentity.IdentityProviders alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaWeb.OpenidView use BorutaWeb, :controller def register_client(conn, params) do registration_params = Enum.map(params, fn {key, value} -> {String.to_atom(key), value} end) |> Enum.into(%{}) |> Map.put(:id_token_signature_alg, "RS256") Openid.register_client(conn, registration_params, __MODULE__) end @impl Boruta.Openid.DynamicRegistrationApplication def client_registered(conn, %Oauth.Client{id: client_id} = client) do with %Backend{id: backend_id} <- Backend.default!(), {:ok, %IdentityProvider{id: identity_provider_id}} <- IdentityProviders.create_identity_provider(%{ name: "Created with dynamic registration for client #{client_id}", backend_id: backend_id }), {:ok, client} <- insert_global_key_pair(client), {:ok, _client_identity_provider} <- IdentityProviders.upsert_client_identity_provider(client_id, identity_provider_id) do conn |> put_view(OpenidView) |> put_status(:created) |> render("client.json", client: client) else {:error, changeset} -> registration_failure(conn, changeset) end end @impl Boruta.Openid.DynamicRegistrationApplication def registration_failure(conn, changeset) do conn |> put_view(OpenidView) |> put_status(:bad_request) |> render("registration_error.json", changeset: changeset) end defp insert_global_key_pair(%Oauth.Client{id: client_id}) do %KeyPair{public_key: public_key, private_key: private_key} = KeyPair.default!() client = BorutaAuth.Repo.get!(Ecto.Client, client_id) Ecto.Admin.regenerate_client_key_pair(client, public_key, private_key) end end ================================================ FILE: apps/boruta_web/lib/boruta_web/controllers/openid/jwks_controller.ex ================================================ defmodule BorutaWeb.Openid.JwksController do @behaviour Boruta.Openid.JwksApplication alias Boruta.Openid alias BorutaAuth.KeyPairs alias BorutaWeb.OpenidView use BorutaWeb, :controller def jwks_index(conn, _params) do Openid.jwks(conn, __MODULE__) end @impl Boruta.Openid.JwksApplication def jwk_list(conn, jwk_keys) do global_keys = KeyPairs.list_jwks() keys = Enum.uniq_by( global_keys ++ jwk_keys, fn %{"kid" => kid} -> kid end ) conn |> put_view(OpenidView) |> render("jwks.json", keys: keys) end end ================================================ FILE: apps/boruta_web/lib/boruta_web/controllers/openid/userinfo_controller.ex ================================================ defmodule BorutaWeb.Openid.UserinfoController do @behaviour Boruta.Openid.UserinfoApplication use BorutaWeb, :controller alias Boruta.Openid alias Boruta.Openid.UserinfoResponse alias BorutaWeb.OpenidView def userinfo(conn, _params) do Openid.userinfo(conn, __MODULE__) end @impl Boruta.Openid.UserinfoApplication def userinfo_fetched(conn, response) do conn |> put_view(OpenidView) |> put_resp_header("content-type", UserinfoResponse.content_type(response)) |> render("userinfo.#{response.format}", response: response) end @impl Boruta.Openid.UserinfoApplication def unauthorized(conn, error) do conn |> put_resp_header( "www-authenticate", "error=\"#{error.error}\", error_description=\"#{error.error_description}\"" ) |> send_resp(:unauthorized, "") end end ================================================ FILE: apps/boruta_web/lib/boruta_web/controllers/openid_controller.ex ================================================ defmodule BorutaWeb.OpenidController do use BorutaWeb, :controller alias BorutaWeb.OauthView def well_known(conn, _params) do scopes = Boruta.Ecto.Admin.list_scopes() conn |> put_view(OauthView) |> render("well_known.json", routes: Routes, scopes: scopes) end def openid_credential_issuer(conn, _params) do conn |> put_view(OauthView) |> render("openid_credential_issuer.json", routes: Routes) end end ================================================ FILE: apps/boruta_web/lib/boruta_web/endpoint.ex ================================================ defmodule BorutaWeb.Endpoint do use Phoenix.Endpoint, otp_app: :boruta_web @session_options [ store: :cookie, key: "_boruta_web_key", signing_salt: "OCKBuS86" ] plug RemoteIp # unless Mix.env() == :test, do: plug BorutaWeb.Plugs.RateLimit plug Plug.Static, at: "/", from: :boruta_web, gzip: false, only: ~w(admin accounts css fonts images js favicon.ico robots.txt) if code_reloading? do socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket plug Phoenix.LiveReloader plug Phoenix.CodeReloader plug Phoenix.Ecto.CheckRepoStatus, otp_app: :boruta_web end plug Plug.RequestId plug Plug.Telemetry, event_prefix: [:boruta_web, :endpoint], log: {__MODULE__, :log_level, []} plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], json_decoder: Phoenix.json_library() plug :put_secret_key_base def put_secret_key_base(conn, _) do put_in conn.secret_key_base, Application.get_env(:boruta_web, BorutaWeb.Endpoint)[:secret_key_base] end plug Plug.MethodOverride plug Plug.Head plug Plug.Session, @session_options plug CORSPlug plug BorutaWeb.Router def log_level(%{private: %{BorutaIdentityWeb.Router => {["accounts"], _}}}), do: false # logs are handled by boruta_identity def log_level(%{path_info: ["healthcheck" | _]}), do: false def log_level(%{path_info: path_info}) do case Enum.member?(path_info, "images") do true -> false false -> :info end end end ================================================ FILE: apps/boruta_web/lib/boruta_web/gettext.ex ================================================ defmodule BorutaWeb.Gettext do @moduledoc """ A module providing Internationalization with a gettext-based API. By using [Gettext](https://hexdocs.pm/gettext), your module gains a set of macros for translations, for example: import BorutaWeb.Gettext # Simple translation gettext("Here is the string to translate") # Plural translation ngettext("Here is the string to translate", "Here are the strings to translate", 3) # Domain-based translation dgettext("errors", "Here is the error message to translate") See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. """ use Gettext.Backend, otp_app: :boruta_web end ================================================ FILE: apps/boruta_web/lib/boruta_web/logger.ex ================================================ defmodule BorutaWeb.Logger do @moduledoc false require Logger def start do handlers = [ { :boruta_web_requests, [:boruta_web, :endpoint, :stop], &__MODULE__.boruta_web_request_handler/4 }, { :authorization_authorize_success, [:authorization, :authorize, :success], &__MODULE__.authorization_authorize_success_handler/4 }, { :authorization_authorize_failure, [:authorization, :authorize, :failure], &__MODULE__.authorization_authorize_failure_handler/4 }, { :authorization_token_success, [:authorization, :token, :success], &__MODULE__.authorization_token_success_handler/4 }, { :authorization_token_failure, [:authorization, :token, :failure], &__MODULE__.authorization_token_failure_handler/4 }, { :authorization_introspect_success, [:authorization, :introspect, :success], &__MODULE__.authorization_introspect_success_handler/4 }, { :authorization_introspect_failure, [:authorization, :introspect, :failure], &__MODULE__.authorization_introspect_failure_handler/4 }, { :authorization_revoke_success, [:authorization, :revoke, :success], &__MODULE__.authorization_revoke_success_handler/4 }, { :authorization_revoke_failure, [:authorization, :revoke, :failure], &__MODULE__.authorization_revoke_failure_handler/4 } ] for {handler_id, event_name, fun} <- handlers do :telemetry.attach(handler_id, event_name, fun, :ok) end end def boruta_web_request_handler(_, %{duration: duration}, %{conn: conn} = metadata, _) do remote_ip = :inet.ntoa(conn.remote_ip) case log_level(metadata[:options][:log], conn) do false -> :ok level -> Logger.log( level, fn -> %{method: method, request_path: path, status: status, state: state} = conn status = Integer.to_string(status) [ "boruta_web", ?\s, method, ?\s, path, " - ", connection_type(state), ?\s, status, " from ", remote_ip, " in ", duration(duration) ] end, type: :request ) end end def authorization_authorize_success_handler( _, _measurements, %{ access_token: access_token, code: code, type: type, response_mode: response_mode, expires_in: expires_in, client_id: client_id, current_user: current_user }, _ ) do log_line = [ "boruta_web", ?\s, "authorization", ?\s, "authorize", " - ", "success", log_attribute("client_id", client_id), log_attribute("sub", current_user && current_user.uid), log_attribute("type", type), log_attribute("response_mode", response_mode), log_attribute("access_token", access_token), log_attribute("code", code), log_attribute("expires_in", expires_in) ] Logger.log(:info, fn -> log_line end, type: :business) end def authorization_authorize_failure_handler( _, _measurements, %{ status: status, error: error, error_description: error_description, client_id: client_id, current_user: current_user }, _ ) do log_line = [ "boruta_web", ?\s, "authorization", ?\s, "authorize", " - ", "failure", log_attribute("client_id", client_id), log_attribute("sub", current_user && current_user.uid), log_attribute("status", status), log_attribute("error", error), log_attribute("error_description", ~s{"#{error_description}"}) ] Logger.log(:info, fn -> log_line end, type: :business) end def authorization_token_success_handler( _, _measurements, %{ client_id: client_id, sub: sub, access_token: access_token, agent_token: agent_token, token_type: token_type, expires_in: expires_in, refresh_token: refresh_token }, _ ) do log_line = [ "boruta_web", ?\s, "authorization", ?\s, "token", " - ", "success", log_attribute("client_id", client_id), log_attribute("sub", sub), log_attribute("access_token", access_token), log_attribute("agent_token", agent_token), log_attribute("token_type", token_type), log_attribute("expires_in", expires_in), log_attribute("refresh_token", refresh_token) ] Logger.log(:info, fn -> log_line end, type: :business) end def authorization_token_failure_handler( _, _measurements, %{ status: status, error: error, error_description: error_description }, _ ) do log_line = [ "boruta_web", ?\s, "authorization", ?\s, "token", " - ", "failure", log_attribute("status", status), log_attribute("error", error), log_attribute("error_description", ~s{"#{error_description}"}) ] Logger.log(:info, fn -> log_line end, type: :business) end def authorization_introspect_success_handler( _, _measurements, %{ active: active, client_id: client_id, sub: sub, token: token }, _ ) do log_line = [ "boruta_web", ?\s, "authorization", ?\s, "introspect", " - ", "success", log_attribute("client_id", client_id), log_attribute("sub", sub), log_attribute("access_token", token), log_attribute("active", active) ] Logger.log(:info, fn -> log_line end, type: :business) end def authorization_introspect_failure_handler( _, _measurements, %{ status: status, error: error, error_description: error_description, token: token }, _ ) do log_line = [ "boruta_web", ?\s, "authorization", ?\s, "introspect", " - ", "failure", log_attribute("access_token", token), log_attribute("status", status), log_attribute("error", error), log_attribute("error_description", ~s{"#{error_description}"}) ] Logger.log(:info, fn -> log_line end, type: :business) end def authorization_revoke_success_handler( _, _measurements, %{ token: token }, _ ) do log_line = [ "boruta_web", ?\s, "authorization", ?\s, "revoke", " - ", "success", log_attribute("access_token", token) ] Logger.log(:info, fn -> log_line end, type: :business) end def authorization_revoke_failure_handler( _, _measurements, %{ status: status, error: error, error_description: error_description, token: token }, _ ) do log_line = [ "boruta_web", ?\s, "authorization", ?\s, "revoke", " - ", "failure", log_attribute("access_token", token), log_attribute("status", status), log_attribute("error", error), log_attribute("error_description", ~s{"#{error_description}"}) ] Logger.log(:info, fn -> log_line end, type: :business) end defp log_attribute(_key, nil), do: "" defp log_attribute(key, attribute), do: " #{key}=#{attribute}" # From Phoenix.Logger defp log_level(nil, _conn), do: :info defp log_level(level, _conn) when is_atom(level), do: level defp log_level({mod, fun, args}, conn) when is_atom(mod) and is_atom(fun) and is_list(args) do apply(mod, fun, [conn | args]) end defp connection_type(:set_chunked), do: "chunked" defp connection_type(_), do: "sent" defp duration(duration) do duration = System.convert_time_unit(duration, :native, :microsecond) if duration > 1000 do [duration |> div(1000) |> Integer.to_string(), "ms"] else [Integer.to_string(duration), "µs"] end end end ================================================ FILE: apps/boruta_web/lib/boruta_web/plugs/rate_limit.ex ================================================ defmodule BorutaWeb.Plugs.RateLimit do @moduledoc false defmodule Counter do @moduledoc false use Agent @base_unit :millisecond @memory_length 50 @time_unit_stamps [ millisecond: 1, second: 1_000, minute: 60 * 1_000 ] def start_link(_args) do Agent.start_link(fn -> %{} end, name: __MODULE__) end def get(ip, time_unit) do Agent.get(__MODULE__, fn counter -> Map.get(counter, ip, []) end) |> Enum.count(fn timestamp -> timestamp > :os.system_time(@base_unit) - @time_unit_stamps[time_unit] end) end def throttling_timeout(ip, count, time_unit, penality) do now = :os.system_time(@base_unit) request_rates = Agent.get(__MODULE__, fn counter -> Map.get(counter, ip, []) |> Enum.filter(fn timestamp -> timestamp > now - @memory_length * @time_unit_stamps[time_unit] end) end) |> Enum.group_by(fn timestamp -> div(timestamp, @time_unit_stamps[time_unit]) end) timeout = Enum.map(0..@memory_length - 1, fn i -> current = floor(now - (i * @time_unit_stamps[time_unit])) Map.get(request_rates, div(current, @time_unit_stamps[time_unit]), []) end) |> Enum.reverse() |> Enum.map(fn [] -> count / @time_unit_stamps[time_unit] timestamps -> Enum.count(timestamps) / @time_unit_stamps[time_unit] end) |> Enum.reduce(1, fn factor, acc -> acc * factor * (@time_unit_stamps[time_unit] / count) end) case timeout <= 1 do true -> 0 false -> floor(timeout * penality) end end def increment(ip, time_unit) do Agent.update(__MODULE__, fn counter -> timestamps = Map.get(counter, ip, []) |> Enum.filter(fn timestamp -> timestamp > :os.system_time(@base_unit) - @memory_length * @time_unit_stamps[time_unit] end) Map.put( counter, ip, [:os.system_time(@base_unit) | timestamps] ) end) end end use BorutaWeb, :controller def init(options), do: options def call(conn, options) do remote_ip = :inet.ntoa(conn.remote_ip) max_timeout = options[:timeout] case Counter.throttling_timeout( remote_ip, options[:count], options[:time_unit], options[:penality] ) do timeout when timeout < max_timeout -> :timer.sleep(timeout) Counter.increment(remote_ip, options[:time_unit]) conn _ -> send_resp(conn, 429, "") |> halt() end end end ================================================ FILE: apps/boruta_web/lib/boruta_web/presentation_server.ex ================================================ defmodule BorutaWeb.PresentationServer do @moduledoc false use GenServer def start_link do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_args) do {:ok, %{presentations: %{}}} end def start_presentation(code) do GenServer.call(__MODULE__, {:start_presentation, code}) end def authenticated(code, redirect_uri) do GenServer.cast(__MODULE__, {:authenticated, code, redirect_uri}) end def message(code, message) do GenServer.cast(__MODULE__, {:message, code, message}) end def handle_call({:start_presentation, code}, {pid, _}, state) do presentations = Map.put( state.presentations, code, %{ start: :os.system_time(:microsecond), pid: pid } ) {:reply, :ok, %{state | presentations: presentations}} end def handle_cast({:authenticated, code, redirect_uri}, state) do case state.presentations[code] do nil -> :ok presentation -> send(presentation[:pid], {:authenticated, redirect_uri}) end {:noreply, Map.delete(state, code)} end def handle_cast({:message, code, message}, state) do case state.presentations[code] do nil -> :ok presentation -> send(presentation[:pid], {:message, message}) end {:noreply, Map.delete(state, code)} end end ================================================ FILE: apps/boruta_web/lib/boruta_web/release.ex ================================================ defmodule BorutaWeb.Release do @moduledoc false @apps [:boruta_auth, :boruta_identity, :boruta_web] def migrate do for repo <- repos() do repo.__adapter__.storage_up(repo.config) {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) end end def rollback(repo, version) do repo.__adapter__.storage_up(repo.config) {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) end def seed do _started = Enum.map(@apps, fn app -> Application.ensure_all_started(app) end) Code.eval_file(Path.join(:code.priv_dir(:boruta_auth), "/repo/boruta.seeds.exs")) end def setup do migrate() seed() end defp repos do Enum.flat_map(@apps, fn app -> Application.load(app) Application.fetch_env!(app, :ecto_repos) end) |> Enum.uniq() end end ================================================ FILE: apps/boruta_web/lib/boruta_web/repo.ex ================================================ defmodule BorutaWeb.Repo do use Ecto.Repo, otp_app: :boruta_web, adapter: Ecto.Adapters.Postgres end ================================================ FILE: apps/boruta_web/lib/boruta_web/router.ex ================================================ defmodule BorutaWeb.Router do use BorutaWeb, :router use Plug.ErrorHandler alias BorutaWeb.Plugs.RateLimit import BorutaIdentityWeb.Sessions, only: [ fetch_current_user: 2 ] pipeline :browser do plug(:accepts, ["html"]) plug(:fetch_session) plug(:fetch_flash) plug(:protect_from_forgery) plug(:put_secure_browser_headers) end pipeline :protected do plug(:accepts, ["html"]) plug(:fetch_session) plug(:fetch_flash) plug(:protect_from_forgery) plug(:put_secure_browser_headers) end pipeline :api do plug(:accepts, ["json", "jwt", "event-stream"]) plug RateLimit, count: 10, time_unit: :second, penality: 500, timeout: 5_000 end scope "/", BorutaWeb do pipe_through(:api) get("/.well-known/oauth-authorization-server", OpenidController, :well_known) get("/.well-known/openid-configuration", OpenidController, :well_known) get("/.well-known/openid-credential-issuer", OpenidController, :openid_credential_issuer) end get("/healthcheck", BorutaWeb.MonitoringController, :healthcheck, log: false) forward("/accounts", BorutaIdentityWeb.Endpoint) scope "/openid", BorutaWeb do pipe_through(:api) options("/credential", Openid.CredentialController, :options) post("/credential", Openid.CredentialController, :credential) post("/defered-credential", Openid.CredentialController, :defered_credential) get("/jwks", Openid.JwksController, :jwks_index) get("/jwks/:client_id", Openid.JwksController, :jwks_show) # post("/register", Openid.DynamicRegistrationController, :register_client) end scope "/oauth", BorutaWeb.Oauth do pipe_through(:api) post("/token", TokenController, :token) post("/introspect", IntrospectController, :introspect) post("/pushed_authorization_request", PushedAuthorizationRequestController, :pushed_authorization_request) post("/revoke", RevokeController, :revoke) options("/token", TokenController, :options) options("/introspect", IntrospectController, :options) options("/revoke", RevokeController, :options) end scope "/oauth", BorutaWeb do pipe_through(:api) get("/userinfo", Openid.UserinfoController, :userinfo) post("/userinfo", Openid.UserinfoController, :userinfo) end scope "/oauth", BorutaWeb.Oauth do pipe_through([:browser, :fetch_current_user]) get("/authorize", AuthorizeController, :authorize) end scope "/did", BorutaWeb do pipe_through([:api]) get("/resolve_status/:status", DidController, :resolve_status) end scope "/openid", BorutaWeb.Oauth do pipe_through([:api]) post("/direct_post/:code_id", TokenController, :direct_post) options("/presentation_sse", AuthorizeController, :options) get("/presentation_sse", AuthorizeController, :authenticated?) end @impl Plug.ErrorHandler def handle_errors(conn, error) do BorutaIdentityWeb.Router.handle_errors(conn, error) end end ================================================ FILE: apps/boruta_web/lib/boruta_web/templates/error/404.html.eex ================================================ Boruta · Phoenix Framework

    Page not found
    The page you requested was not found. Please contact your administrator.

    ================================================ FILE: apps/boruta_web/lib/boruta_web/templates/error/500.html.eex ================================================ Boruta · Phoenix Framework

    Internal server error
    An unexpected error occured. Please contact your administrator.

    ================================================ FILE: apps/boruta_web/lib/boruta_web/token.ex ================================================ defmodule BorutaWeb.Token do @moduledoc false use Joken.Config def application_signer do Joken.Signer.create( "HS512", Application.get_env(:boruta_web, BorutaWeb.Endpoint)[:secret_key_base] ) end end ================================================ FILE: apps/boruta_web/lib/boruta_web/views/error_helpers.ex ================================================ defmodule BorutaWeb.ErrorHelpers do @moduledoc """ Conveniences for translating and building error messages. """ use Phoenix.HTML @doc """ Generates tag for inlined form input errors. """ def error_tag(form, field) do Enum.map(Keyword.get_values(form.errors, field), fn error -> content_tag(:span, translate_error(error), class: "help-block") end) end @doc """ Translates an error message using gettext. """ def translate_error({msg, opts}) do # When using gettext, we typically pass the strings we want # to translate as a static argument: # # # Translate "is invalid" in the "errors" domain # dgettext("errors", "is invalid") # # # Translate the number of files with plural rules # dngettext("errors", "1 file", "%{count} files", count) # # Because the error messages we show in our forms and APIs # are defined inside Ecto, we need to translate them dynamically. # This requires us to call the Gettext module passing our gettext # backend as first argument. # # Note we use the "errors" domain, which means translations # should be written to the errors.po file. The :count option is # set by Ecto and indicates we should also apply plural rules. if count = opts[:count] do Gettext.dngettext(BorutaWeb.Gettext, "errors", msg, msg, count, opts) else Gettext.dgettext(BorutaWeb.Gettext, "errors", msg, opts) end end end ================================================ FILE: apps/boruta_web/lib/boruta_web/views/error_view.ex ================================================ defmodule BorutaWeb.ErrorView do use BorutaWeb, :view # If you want to customize a particular status code # for a certain format, you may uncomment below. # def render("500.html", _assigns) do # "Internal Server Error" # end # By default, Phoenix returns the status message from # the template name. For example, "404.html" becomes # "Not Found". def template_not_found(template, _assigns) do Phoenix.Controller.status_message_from_template(template) end def render("error.json", %{error: error, message: message}) do %{error: error, message: message} end end ================================================ FILE: apps/boruta_web/lib/boruta_web/views/oauth_view.ex ================================================ defmodule BorutaWeb.OauthView do use BorutaWeb, :view alias Boruta.Oauth.Client alias BorutaIdentity.Accounts.VerifiableCredentials alias BorutaWeb.Token def render("token.json", %{response: %Boruta.Oauth.TokenResponse{} = response}) do response end def render("introspect.json", %{response: %Boruta.Oauth.IntrospectResponse{active: false}}) do %{"active" => false} end def render("introspect.json", %{response: %Boruta.Oauth.IntrospectResponse{} = response}) do response end def render("introspect.jwt", %{response: %Boruta.Oauth.IntrospectResponse{active: false}}) do payload = %{"active" => false} {:ok, token, _payload} = Joken.encode_and_sign(payload, Token.application_signer()) token end def render("introspect.jwt", %{ response: %Boruta.Oauth.IntrospectResponse{private_key: private_key} = response }) do payload = response |> Map.delete(:private_key) |> Map.from_struct() signer = Joken.Signer.create("RS512", %{"pem" => private_key}) {:ok, token, _payload} = Joken.encode_and_sign(payload, signer) token end def render("error.json", %{error: error, error_description: error_description}) do %{ error: error, error_description: error_description } end def render("well_known.json", %{routes: routes, scopes: scopes}) do issuer = Boruta.Config.issuer() %{ "issuer" => issuer, "authorization_endpoint" => issuer <> routes.authorize_path(BorutaWeb.Endpoint, :authorize), "token_endpoint" => issuer <> routes.token_path(BorutaWeb.Endpoint, :token), "userinfo_endpoint" => issuer <> routes.userinfo_path(BorutaWeb.Endpoint, :userinfo), "jwks_uri" => issuer <> routes.jwks_path(BorutaWeb.Endpoint, :jwks_index), # "registration_endpoint" => # issuer <> routes.dynamic_registration_path(BorutaWeb.Endpoint, :register_client), "pushed_authorization_request_endpoint" => issuer <> routes.pushed_authorization_request_path(BorutaWeb.Endpoint, :pushed_authorization_request), "grant_types_supported" => [ "client_credentials", "password", "implicit", "authorization_code", "refresh_token" ], "response_types_supported" => [ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code id_token token" ], "scopes_supported" => Enum.map(scopes, fn scope -> scope.name end), "response_modes_supported" => ["query", "fragment"], "subject_types_supported" => ["public"], "token_endpoint_auth_methods_supported" => [ "client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt" ], "request_object_signing_alg_values_supported" => Client.Crypto.signature_algorithms(), "id_token_signing_alg_values_supported" => Client.Crypto.signature_algorithms(), "userinfo_signing_alg_values_supported" => Client.Crypto.signature_algorithms(), "credential_issuer" => issuer, "credential_endpoint" => issuer <> routes.credential_path(BorutaWeb.Endpoint, :credential), "defered_credential_endpoint" => issuer <> routes.credential_path(BorutaWeb.Endpoint, :defered_credential), "credential_configurations_supported" => VerifiableCredentials.credential_configurations_supported(), "credentials_supported" => VerifiableCredentials.credentials_supported() } end def render("openid_credential_issuer.json", %{routes: routes}) do issuer = Boruta.Config.issuer() %{ "issuer" => issuer, "token_endpoint" => issuer <> routes.token_path(BorutaWeb.Endpoint, :token), "credential_issuer" => issuer, "credential_endpoint" => issuer <> routes.credential_path(BorutaWeb.Endpoint, :credential), "credential_configurations_supported" => VerifiableCredentials.credential_configurations_supported(), "credentials_supported" => VerifiableCredentials.credentials_supported() } end def render("credential.json", %{credential_response: credential_response}) do %{ format: credential_response.format, credential: credential_response.credential } # TODO use associated access token c_nonce |> Map.put(:c_nonce, "boruta") |> Map.put(:c_nonce_expires_in, 3600) end def render("defered_credential.json", %{credential_response: credential_response}) do %{ acceptance_token: credential_response.acceptance_token, c_nonce: credential_response.c_nonce, c_nonce_expires_in: credential_response.c_nonce_expires_in } end def render("pushed_authorization_request.json", %{ response: %Boruta.Oauth.PushedAuthorizationResponse{} = response }) do response end defimpl Jason.Encoder, for: Boruta.Oauth.TokenResponse do def encode( %Boruta.Oauth.TokenResponse{ token_type: token_type, access_token: access_token, agent_token: agent_token, id_token: id_token, c_nonce: c_nonce, expires_in: expires_in, refresh_token: refresh_token, authorization_details: authorization_details }, options ) do response = %{ token_type: token_type, expires_in: expires_in, refresh_token: refresh_token, c_nonce: c_nonce } response = case id_token do nil -> response id_token -> Map.put(response, :id_token, id_token) end response = case access_token do nil -> response access_token -> Map.put(response, :access_token, access_token) end response = case agent_token do nil -> response agent_token -> Map.put(response, :agent_token, agent_token) end response = case authorization_details do nil -> response _authorization_details -> response |> Map.put(:authorization_details, authorization_details) |> Map.put(:c_nonce_expires_in, 3600) end Jason.Encode.map( response, options ) end end defimpl Jason.Encoder, for: Boruta.Oauth.IntrospectResponse do def encode( %Boruta.Oauth.IntrospectResponse{ active: false }, options ) do Jason.Encode.map(%{active: false}, options) end def encode( %Boruta.Oauth.IntrospectResponse{ active: true, client_id: client_id, username: username, scope: scope, sub: sub, iss: iss, exp: exp, iat: iat }, options ) do Jason.Encode.map( %{ active: true, client_id: client_id, username: username, scope: scope, sub: sub, iss: iss, exp: exp, iat: iat }, options ) end end defimpl Jason.Encoder, for: Boruta.Oauth.PushedAuthorizationResponse do def encode(%Boruta.Oauth.PushedAuthorizationResponse{} = response, options) do Jason.Encode.map( %{ request_uri: response.request_uri, expires_in: response.expires_in }, options ) end end end ================================================ FILE: apps/boruta_web/lib/boruta_web/views/openid_view.ex ================================================ defmodule BorutaWeb.OpenidView do use BorutaWeb, :view alias Boruta.Openid.UserinfoResponse def render("userinfo.json", %{response: response}) do UserinfoResponse.payload(response) end def render("jwks.json", %{keys: keys}) do %{ keys: keys } end def render("jwk.json", %{client: %Boruta.Ecto.Client{id: client_id, public_key: public_key}}) do {_type, jwk} = public_key |> :jose_jwk.from_pem() |> :jose_jwk.to_map() %{ keys: [Map.put(jwk, :kid, client_id)] } end def render("userinfo.jwt", %{response: response}) do UserinfoResponse.payload(response) end def render("show.json", %{client: client}) do %{data: render_one(client, __MODULE__, "client.json")} end def render("client.json", %{client: client}) do %{ client_id: client.id, client_secret: client.secret, client_secret_expires_at: 0 } end def render("registration_error.json", %{changeset: changeset}) do %{ error: "invalid_client_metadata", error_description: errors_full_message(changeset) } end defp errors_full_message(changeset) do Ecto.Changeset.traverse_errors(changeset, &translate_error/1) |> Enum.map_join(", ", fn {attribute, messages} -> "#{attribute} : #{Enum.join(messages, ", ")}" end) end end ================================================ FILE: apps/boruta_web/lib/boruta_web.ex ================================================ defmodule BorutaWeb do @moduledoc """ The entrypoint for defining your web interface, such as controllers, views, channels and so on. This can be used in your application as: use BorutaWeb, :controller use BorutaWeb, :view The definitions below will be executed for every view, controller, etc, so keep them short and clean, focused on imports, uses and aliases. Do NOT define functions inside the quoted expressions below. Instead, define any helper function in modules and import those modules here. """ def controller do quote do use Phoenix.Controller, namespace: BorutaWeb import Plug.Conn import BorutaWeb.Gettext alias BorutaWeb.Router.Helpers, as: Routes end end def view do quote do use Phoenix.View, root: "lib/boruta_web/templates", namespace: BorutaWeb # Import convenience functions from controllers import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1] # Use all HTML functionality (forms, tags, etc) use Phoenix.HTML import BorutaWeb.ErrorHelpers import BorutaWeb.Gettext alias BorutaWeb.Router.Helpers, as: Routes end end def router do quote do use Phoenix.Router import Plug.Conn import Phoenix.Controller end end def channel do quote do use Phoenix.Channel import BorutaWeb.Gettext end end @doc """ When used, dispatch to the appropriate controller/view/etc. """ defmacro __using__(which) when is_atom(which) do apply(__MODULE__, which, []) end end ================================================ FILE: apps/boruta_web/lib/mix/tasks/server.ex ================================================ defmodule Mix.Tasks.Boruta.Server do @moduledoc false use Mix.Task def run(args) do Application.put_env(:boruta_gateway, :server, true, persistent: true) Mix.Tasks.Phx.Server.run(args) end end ================================================ FILE: apps/boruta_web/mix.exs ================================================ defmodule BorutaWeb.MixProject do use Mix.Project def project do [ app: :boruta_web, version: "0.1.0", build_path: "../../_build", deps_path: "../../deps", lockfile: "../../mix.lock", elixir: "~> 1.5", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix] ++ Mix.compilers(), start_permanent: Mix.env() == :prod, aliases: aliases(), deps: deps() ] end # Configuration for the OTP application. # # Type `mix help compile.app` for more information. def application do [ mod: {BorutaWeb.Application, []}, extra_applications: [:logger, :runtime_tools] ] end # Specifies which paths to compile per environment. defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] # Specifies your project dependencies. # # Type `mix help deps` for examples and options. defp deps do [ {:boruta_auth, in_umbrella: true}, {:boruta_identity, in_umbrella: true}, {:bypass, "~> 2.1.0", only: :test}, {:cors_plug, "~> 3.0"}, {:ex_machina, "~> 2.4", only: :test}, {:finch, "~> 0.8"}, {:gettext, "~> 0.11"}, {:hammer, "~> 6.1"}, {:jason, "~> 1.0"}, {:joken, "~> 2.3"}, {:libcluster, "~> 3.2.1"}, {:owl, "~> 0.8.0"}, {:phoenix, "~> 1.6.0", override: true}, {:phoenix_ecto, "~> 4.1"}, {:phoenix_html, "~> 3.0"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_pubsub, "~> 2.0"}, {:plug_cowboy, "~> 2.0"}, {:remote_ip, "~> 1.1"}, {:telemetry_metrics, "~> 0.6"}, {:telemetry_poller, "~> 0.5"} ] end # Aliases are shortcuts or tasks specific to the current project. # For example, we extend the test task to create and migrate the database. # # See the documentation for `Mix` for more info on aliases. defp aliases do [ "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/boruta.seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate", "test"] ] end end ================================================ FILE: apps/boruta_web/priv/gettext/en/LC_MESSAGES/errors.po ================================================ ## `msgid`s in this file come from POT (.pot) files. ## ## Do not add, change, or remove `msgid`s manually here as ## they're tied to the ones in the corresponding POT file ## (with the same domain). ## ## Use `mix gettext.extract --merge` or `mix gettext.merge` ## to merge POT files into PO files. msgid "" msgstr "" "Language: en\n" ## From Ecto.Changeset.cast/4 msgid "can't be blank" msgstr "" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" msgstr "" ## From Ecto.Changeset.put_change/3 msgid "is invalid" msgstr "" ## From Ecto.Changeset.validate_acceptance/3 msgid "must be accepted" msgstr "" ## From Ecto.Changeset.validate_format/3 msgid "has invalid format" msgstr "" ## From Ecto.Changeset.validate_subset/3 msgid "has an invalid entry" msgstr "" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" msgstr "" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" msgstr "" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" msgstr "" msgid "are still associated with this entry" msgstr "" ## From Ecto.Changeset.validate_length/3 msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have %{count} item(s)" msgid_plural "should have %{count} item(s)" msgstr[0] "" msgstr[1] "" msgid "should be at least %{count} character(s)" msgid_plural "should be at least %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have at least %{count} item(s)" msgid_plural "should have at least %{count} item(s)" msgstr[0] "" msgstr[1] "" msgid "should be at most %{count} character(s)" msgid_plural "should be at most %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have at most %{count} item(s)" msgid_plural "should have at most %{count} item(s)" msgstr[0] "" msgstr[1] "" ## From Ecto.Changeset.validate_number/3 msgid "must be less than %{number}" msgstr "" msgid "must be greater than %{number}" msgstr "" msgid "must be less than or equal to %{number}" msgstr "" msgid "must be greater than or equal to %{number}" msgstr "" msgid "must be equal to %{number}" msgstr "" ================================================ FILE: apps/boruta_web/priv/gettext/errors.pot ================================================ ## This is a PO Template file. ## ## `msgid`s here are often extracted from source code. ## Add new translations manually only if they're dynamic ## translations that can't be statically extracted. ## ## Run `mix gettext.extract` to bring this file up to ## date. Leave `msgstr`s empty as changing them here has no ## effect: edit them in PO (`.po`) files instead. ## From Ecto.Changeset.cast/4 msgid "can't be blank" msgstr "" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" msgstr "" ## From Ecto.Changeset.put_change/3 msgid "is invalid" msgstr "" ## From Ecto.Changeset.validate_acceptance/3 msgid "must be accepted" msgstr "" ## From Ecto.Changeset.validate_format/3 msgid "has invalid format" msgstr "" ## From Ecto.Changeset.validate_subset/3 msgid "has an invalid entry" msgstr "" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" msgstr "" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" msgstr "" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" msgstr "" msgid "are still associated with this entry" msgstr "" ## From Ecto.Changeset.validate_length/3 msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have %{count} item(s)" msgid_plural "should have %{count} item(s)" msgstr[0] "" msgstr[1] "" msgid "should be at least %{count} character(s)" msgid_plural "should be at least %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have at least %{count} item(s)" msgid_plural "should have at least %{count} item(s)" msgstr[0] "" msgstr[1] "" msgid "should be at most %{count} character(s)" msgid_plural "should be at most %{count} character(s)" msgstr[0] "" msgstr[1] "" msgid "should have at most %{count} item(s)" msgid_plural "should have at most %{count} item(s)" msgstr[0] "" msgstr[1] "" ## From Ecto.Changeset.validate_number/3 msgid "must be less than %{number}" msgstr "" msgid "must be greater than %{number}" msgstr "" msgid "must be less than or equal to %{number}" msgstr "" msgid "must be greater than or equal to %{number}" msgstr "" msgid "must be equal to %{number}" msgstr "" ================================================ FILE: apps/boruta_web/priv/repo/migrations/.keep ================================================ ================================================ FILE: apps/boruta_web/test/boruta_web/controllers/credential_controller_test.exs ================================================ defmodule BorutaWeb.CredentialControllerTest do use BorutaWeb.ConnCase import Boruta.Factory import BorutaIdentity.AccountsFixtures alias Boruta.Config alias Boruta.Ecto.Token alias Boruta.Internal.Signatures alias BorutaIdentity.Accounts.User setup %{conn: conn} do {:ok, conn: conn} end @tag :skip test "returns a credential with a valid credential type", %{conn: conn} do {_, public_jwk} = public_key_fixture() |> JOSE.JWK.from_pem() |> JOSE.JWK.to_map() signer = Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ "jwk" => public_jwk, "typ" => "openid4vci-proof+jwt" }) {:ok, token, _claims} = Signatures.Token.generate_and_sign( %{ "aud" => Config.issuer(), "iat" => :os.system_time(:seconds) }, signer ) proof = %{ "proof_type" => "jwt", "jwt" => token } credential_params = %{ "credential_identifier" => "VerifiableCredential", "format" => "jwt_vc", "proof" => proof } backend = BorutaIdentity.Factory.insert(:backend, verifiable_credentials: [ %{ "display" => %{ "background_color" => "#53b29f", "logo" => %{ "alt_text" => "Boruta PoC logo", "url" => "https://io.malach.it/assets/images/logo.png" }, "name" => "Federation credential PoC", "text_color" => "#FFFFFF" }, "credential_identifier" => "FederatedAttributes", "types" => "VerifiableCredential BorutaCredential", "format" => "jwt_vc", "claims" => "family_name" } ] ) %User{id: sub} = user_fixture(%{backend: backend, metadata: %{"family_name" => "family_name"}}) %Token{value: access_token} = insert(:token, sub: sub, authorization_details: [%{"credential_identifiers" => ["VerifiableCredential"]}] ) conn = conn |> put_req_header("authorization", "Bearer #{access_token}") conn = post( conn, Routes.credential_path(conn, :credential), credential_params ) assert %{"credential" => credential} = json_response(conn, 200) assert credential end @tag :skip test "returns a defered credential with a valid credential type", %{conn: conn} do {_, public_jwk} = public_key_fixture() |> JOSE.JWK.from_pem() |> JOSE.JWK.to_map() signer = Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ "jwk" => public_jwk, "typ" => "openid4vci-proof+jwt" }) {:ok, token, _claims} = Signatures.Token.generate_and_sign( %{ "aud" => Config.issuer(), "iat" => :os.system_time(:seconds) }, signer ) proof = %{ "proof_type" => "jwt", "jwt" => token } credential_params = %{ "credential_identifier" => "VerifiableCredential", "format" => "jwt_vc", "proof" => proof } backend = BorutaIdentity.Factory.insert(:backend, verifiable_credentials: [ %{ "display" => %{ "background_color" => "#53b29f", "logo" => %{ "alt_text" => "Boruta PoC logo", "url" => "https://io.malach.it/assets/images/logo.png" }, "name" => "Federation credential PoC", "text_color" => "#FFFFFF" }, "credential_identifier" => "FederatedAttributes", "types" => "VerifiableCredential BorutaCredential", "format" => "jwt_vc", "defered" => true, "claims" => "family_name" } ] ) %User{id: sub} = user_fixture(%{backend: backend, metadata: %{"family_name" => "family_name"}}) %Token{value: access_token} = insert(:token, sub: sub, authorization_details: [%{"credential_identifiers" => ["VerifiableCredential"]}] ) conn = conn |> put_req_header("authorization", "Bearer #{access_token}") defered_conn = post( conn, Routes.credential_path(conn, :credential), credential_params ) assert %{"acceptance_token" => acceptance_token} = json_response(defered_conn, 200) assert acceptance_token conn = post( conn, Routes.credential_path(conn, :defered_credential), credential_params ) assert %{"credential" => credential} = json_response(conn, 200) assert credential end def public_key_fixture do "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVOf8cU\n8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa9QyH\nsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8WdSq3d\nGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/U8xD\nZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2tpyQ0\nAEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQAB\n-----END RSA PUBLIC KEY-----\n\n" end def private_key_fixture do "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVO\nf8cU8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa\n9QyHsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8Wd\nSq3dGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/\nU8xDZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2t\npyQ0AEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQABAoIBAG0dg/upL8k1IWiv\n8BNphrXIYLYQmiiBQTPJWZGvWIC2sl7i40yvCXjDjiRnZNK9HwgL94XtALCXYRFR\nJD41bRA3MO5A0HSPIWwJXwS10/cU56HVCNHjwKa6Rz/QiG2kNASMZEMzlvHtrjna\ndx36/sjI3HH8gh1BaTZyiuDE72SMkPbL838jfL1YY9uJ0u6hWFDbdn3sqPfJ6Cnz\n1cu0piT35nkilnIGCNYA0i3lyMeo4XrdXaAJdN9nnqbCi5ewQWqaHbrIIY5LTgzJ\nYlOr3IiecyokFxHCbULXle60u0KqXYgBHmlQJJr1Dj4c9AkQmefjC2jRMlhOrIzo\nIkIUeMECgYEA+MNLB+w6vv1ogqzM3M1OLt6bziWJCn+XkziuMrCiY9KeDD+S70+E\nhfbhM5RjCE3wxC/k59039laT973BmdMHxrDd2zSjOFmCIORv5yrD5oBHMaMZcwuQ\n45Xisi4aoQoOhyznSnjo/RjeQB7qEDzXFznLLNT79HzqyAtCWD3UIu8CgYEA2yik\n9FKl7HJEY94D2K6vNh1AHGnkwIQC72pXzlUrVuwQYngj6/Gkhw8ayFBApHfwVCXj\no9rDYPdNrrAs0Zz0JsiJp6bOCEKCrMYE16UiejUUAg/OZ5eg6+3m3/iWatkzLUuK\n1LIkVBJlEyY0uPuAaBF0V0VleNvfCGhVYOn46+ECgYAUD4OsduNh5YOZDiBTKgdF\nBlSgMiyz+QgbKjX6Bn6B+EkgibvqqonwV7FffHbkA40H9SjLfe52YhL6poXHRtpY\nroillcAX2jgBOQrBJJS5sNyM5y81NNiRUdP/NHKXS/1R71ATlF6NkoTRvOx5NL7P\ns6xryB0tYSl5ylamUQ4bZwKBgHF6FB9mA//wErVbKcayfIqajq2nrwh30kVBXQG7\nW9uAE+PIrWDoF/bOvWFnHHGMoOYRUFNxXKUCqDiBhFNs34aNY6lpV1kzhxIK3ksC\neF2qyhdfM9Kz0mEXJ+pkfw4INNWJPfNv4hueArPtnnMB1rUMBJ+DkU0JG+zwiPTL\ncVZBAoGBAM6kOsh5KGn3aI83g9ZO0TrKLXXFotxJt31Wu11ydj9K33/Qj3UXcxd4\nJPXr600F0DkLeUKBob6BALeHFWcrSz5FGLGRqdRxdv+L6g18WH5m2xEs7o6M6e5I\nIhyUC60ZewJ2M8rV4KgCJJdZE2kENlSgjU92IDVPT9Oetrc7hQJd\n-----END RSA PRIVATE KEY-----\n\n" end end ================================================ FILE: apps/boruta_web/test/boruta_web/controllers/oauth/authorization_code_test.exs ================================================ defmodule BorutaWeb.Oauth.AuthorizationCodeTest do use BorutaWeb.ConnCase import Boruta.Factory import BorutaIdentity.AccountsFixtures alias BorutaIdentityWeb.Authenticable describe "#authorize" do setup %{conn: conn} do resource_owner = user_fixture() redirect_uri = "http://redirect.uri" client = insert(:client, redirect_uris: [redirect_uri]) {:ok, conn: conn, client: client, redirect_uri: redirect_uri, resource_owner: resource_owner} end test "redirects to choose session if session not chosen", %{ conn: conn, client: client, redirect_uri: redirect_uri, resource_owner: resource_owner } do conn = conn |> log_in(resource_owner) conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "token", client_id: client.id, redirect_uri: redirect_uri, state: "state" }) ) assert redirected_to(conn) =~ IdentityRoutes.choose_session_path(conn, :index) end end describe "authorization code grant" do setup %{conn: conn} do resource_owner = user_fixture() client = insert(:client) identity_provider = BorutaIdentity.Factory.insert(:identity_provider, consentable: true) BorutaIdentity.Factory.insert(:client_identity_provider, client_id: client.id, identity_provider: identity_provider ) scope = insert(:scope, public: true) {:ok, conn: put_req_header(conn, "content-type", "application/x-www-form-urlencoded"), client: client, resource_owner: resource_owner, scope: scope} end test "renders preauthorize with scope", %{ conn: conn, client: client, resource_owner: resource_owner, scope: scope } do redirect_uri = List.first(client.redirect_uris) request_param = Authenticable.request_param( get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "code", client_id: client.id, redirect_uri: redirect_uri, scope: scope.name }) ) ) conn = conn |> log_in(resource_owner) |> init_test_session(session_chosen: true) conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "code", client_id: client.id, redirect_uri: redirect_uri, scope: scope.name }) ) assert redirected_to(conn) == IdentityRoutes.user_consent_path(conn, :index, request: request_param) end test "redirects to redirect_uri with errors in query if redirect_uri is invalid", %{ conn: conn, client: client, resource_owner: resource_owner } do conn = conn |> log_in(resource_owner) |> init_test_session(session_chosen: true) assert_raise BorutaWeb.AuthorizeError, "Invalid client_id or redirect_uri.", fn -> get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "code", client_id: client.id, redirect_uri: "http://bad.redirect.uri", state: "state" }) ) end end test "redirects to redirect_uri with token if current_user is set", %{ conn: conn, client: client, resource_owner: resource_owner } do redirect_uri = List.first(client.redirect_uris) request_param = Authenticable.request_param( get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "code", client_id: client.id, redirect_uri: redirect_uri }) ) ) conn = conn |> log_in(resource_owner) |> init_test_session(session_chosen: true, preauthorizations: %{request_param => true}) conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "code", client_id: client.id, redirect_uri: redirect_uri }) ) [_, code] = Regex.run( ~r/#{redirect_uri}\?code=(.+)/, redirected_to(conn) ) assert code end test "redirects to redirect_uri with state when session chosen", %{ conn: conn, client: client, resource_owner: resource_owner } do given_state = "state" redirect_uri = List.first(client.redirect_uris) request_param = Authenticable.request_param( get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "code", client_id: client.id, redirect_uri: redirect_uri, state: given_state }) ) ) conn = conn |> log_in(resource_owner) |> init_test_session(session_chosen: true, preauthorizations: %{request_param => true}) conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "code", client_id: client.id, redirect_uri: redirect_uri, state: given_state }) ) assert [_, _redirect_uri] = Regex.run( ~r/(#{redirect_uri})\?/, redirected_to(conn) ) [_, code] = Regex.run( ~r/code=([^&]+)/, redirected_to(conn) ) [_, state] = Regex.run( ~r/state=([^&]+)/, redirected_to(conn) ) assert code assert state == given_state end test "redirects to redirect_uri with consented scope", %{ conn: conn, client: client, resource_owner: resource_owner, scope: scope } do redirect_uri = List.first(client.redirect_uris) request_param = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "code", client_id: client.id, redirect_uri: redirect_uri, scope: scope.name }) ) |> Authenticable.request_param() conn = conn |> log_in(resource_owner) |> init_test_session(session_chosen: true, preauthorizations: %{request_param => true}) BorutaIdentity.Factory.insert(:consent, user_id: resource_owner.id, client_id: client.id, scopes: [scope.name] ) conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "code", client_id: client.id, redirect_uri: redirect_uri, scope: scope.name }) ) [_, code] = Regex.run( ~r/#{redirect_uri}\?code=(.+)/, redirected_to(conn) ) assert code end @tag :skip test "delivers a token inexchange of a code" @tag :skip test "preauthorize error case" end end ================================================ FILE: apps/boruta_web/test/boruta_web/controllers/oauth/authorize_controller_test.exs ================================================ defmodule BorutaWeb.AuthorizeControllerTest do use BorutaWeb.ConnCase import Boruta.Factory import BorutaIdentity.AccountsFixtures setup %{conn: conn} do {:ok, conn: conn} end describe "#authorize" do setup %{conn: conn} do resource_owner = user_fixture() redirect_uri = "http://redirect.uri" client = insert(:client, redirect_uris: [redirect_uri]) {:ok, conn: conn, client: client, redirect_uri: redirect_uri, resource_owner: resource_owner} end test "redirects to choose session if session not chosen", %{ conn: conn, client: client, redirect_uri: redirect_uri, resource_owner: resource_owner } do conn = conn |> log_in(resource_owner) conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "token", client_id: client.id, redirect_uri: redirect_uri, state: "state" }) ) assert redirected_to(conn) =~ IdentityRoutes.choose_session_path(conn, :index) end @tag :skip test "error renders and redirections" end end ================================================ FILE: apps/boruta_web/test/boruta_web/controllers/oauth/client_credentials_test.exs ================================================ defmodule BorutaWeb.Oauth.ClientCredentialsTest do use BorutaWeb.ConnCase import Boruta.Factory setup %{conn: conn} do {:ok, conn: conn} end describe "client_credentials grant" do setup %{conn: conn} do client = insert(:client) {:ok, conn: put_req_header(conn, "content-type", "application/x-www-form-urlencoded"), client: client} end test "returns an error with invalid query parameters", %{conn: conn} do conn = post(conn, "/oauth/token") assert json_response(conn, 400) == %{ "error" => "invalid_request", "error_description" => "Request is not a valid OAuth request. Need a grant_type param." } end test "returns an error with an invalid grant type", %{conn: conn} do conn = post(conn, "/oauth/token", "grant_type=bad_grant_type") assert json_response(conn, 400) == %{ "error" => "invalid_request", "error_description" => "Request body validation failed. #/grant_type do match required pattern /^(client_credentials|agent_credentials|password|agent_code|authorization_code|refresh_token)$/." } end test "returns an error with invalid body parameters", %{conn: conn} do conn = post(conn, "/oauth/token", "grant_type=client_credentials") assert json_response(conn, 400) == %{ "error" => "invalid_request", "error_description" => "Request body validation failed. Required property client_id is missing at #." } end test "returns an error with invalid client_id", %{conn: conn} do conn = post( conn, "/oauth/token", "grant_type=client_credentials&client_id=666&client_secret=666" ) assert json_response(conn, 400) == %{ "error" => "invalid_request", "error_description" => "Request body validation failed. #/client_id do match required pattern /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/." } end test "returns an error with invalid client_id/secret couple", %{conn: conn} do conn = post( conn, "/oauth/token", "grant_type=client_credentials&client_id=6a2f41a3-c54c-fce8-32d2-0324e1c32e22&client_secret=666" ) assert json_response(conn, 401) == %{ "error" => "invalid_client", "error_description" => "Invalid client_id or client_secret." } end test "returns an error with invalid client_secret", %{conn: conn, client: client} do conn = post( conn, "/oauth/token", "grant_type=client_credentials&client_id=#{client.id}&client_secret=666" ) assert json_response(conn, 401) == %{ "error" => "invalid_client", "error_description" => "Invalid client_id or client_secret." } end test "returns a token response with valid client_id/client_secret", %{ conn: conn, client: client } do conn = post( conn, "/oauth/token", "grant_type=client_credentials&client_id=#{client.id}&client_secret=#{client.secret}" ) %{ "access_token" => access_token, "token_type" => token_type, "expires_in" => expires_in, "refresh_token" => refresh_token } = json_response(conn, 200) assert access_token assert token_type == "bearer" assert expires_in assert refresh_token end test "returns a token response with valid agent token request", %{ conn: conn, client: client } do conn = post( conn, "/oauth/token", "grant_type=agent_credentials&client_id=#{client.id}&client_secret=#{client.secret}&bind_data={}&bind_configuration={}" ) %{ "agent_token" => agent_token, "token_type" => token_type, "expires_in" => expires_in, "refresh_token" => refresh_token } = json_response(conn, 200) assert agent_token assert token_type == "bearer" assert expires_in assert refresh_token end end end ================================================ FILE: apps/boruta_web/test/boruta_web/controllers/oauth/direct_post_test.exs ================================================ defmodule BorutaWeb.Integration.DirectPostTest do use BorutaWeb.ConnCase, async: false alias Boruta.Internal.Signatures setup %{conn: conn} do client = Boruta.Factory.insert(:client, id_token_signature_alg: "RS512") code = Boruta.Factory.insert(:token, type: "code", redirect_uri: "http://redirect.uri", response_type: "id_token", state: "state", sub: "did:jwk:eyJlIjoiQVFBQiIsImt0eSI6IlJTQSIsIm4iOiIxUGFQX2diWGl4NWl0alJDYWVndklfQjNhRk9lb3hsd1BQTHZmTEhHQTRRZkRtVk9mOGNVOE91WkZBWXpMQXJXM1BubndXV3kzOW5WSk94NDJRUlZHQ0dkVUNtVjdzaERIUnNyODYtMkRsTDdwd1VhOVF5SHNUajg0ZkFKbjJGdjloOW1xckl2VXpBdEVZUmxHRnZqVlRHQ3d6RXVsbHBzQjBHSmFmb3BVVEZieThXZFNxM2RHTEpCQjFyLVE4UXRabkF4eHZvbGh3T21Za0Jra2lkZWZtbTQ4WDdoRlhMMmNTSm0yRzd3UXlpbk9leV9VOHhEWjY4bWdUYWtpcVMyUnRqbkZEMGRucEJsNUNZVGU0czZvWktFeUZpRk5pVzRLa1IxR1Zqc0t3WTlvQzJ0cHlRMEFFVU12azlUOVZkSWx0U0lpQXZPS2x3RnpMNDljZ3daRHcifQ" ) signer = Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ "kid" => "did:jwk:eyJlIjoiQVFBQiIsImt0eSI6IlJTQSIsIm4iOiIxUGFQX2diWGl4NWl0alJDYWVndklfQjNhRk9lb3hsd1BQTHZmTEhHQTRRZkRtVk9mOGNVOE91WkZBWXpMQXJXM1BubndXV3kzOW5WSk94NDJRUlZHQ0dkVUNtVjdzaERIUnNyODYtMkRsTDdwd1VhOVF5SHNUajg0ZkFKbjJGdjloOW1xckl2VXpBdEVZUmxHRnZqVlRHQ3d6RXVsbHBzQjBHSmFmb3BVVEZieThXZFNxM2RHTEpCQjFyLVE4UXRabkF4eHZvbGh3T21Za0Jra2lkZWZtbTQ4WDdoRlhMMmNTSm0yRzd3UXlpbk9leV9VOHhEWjY4bWdUYWtpcVMyUnRqbkZEMGRucEJsNUNZVGU0czZvWktFeUZpRk5pVzRLa1IxR1Zqc0t3WTlvQzJ0cHlRMEFFVU12azlUOVZkSWx0U0lpQXZPS2x3RnpMNDljZ3daRHcifQ", "typ" => "openid4vci-proof+jwt" }) {:ok, id_token, _claims} = Signatures.Token.generate_and_sign( %{ "iss" => "did:jwk:eyJlIjoiQVFBQiIsImt0eSI6IlJTQSIsIm4iOiIxUGFQX2diWGl4NWl0alJDYWVndklfQjNhRk9lb3hsd1BQTHZmTEhHQTRRZkRtVk9mOGNVOE91WkZBWXpMQXJXM1BubndXV3kzOW5WSk94NDJRUlZHQ0dkVUNtVjdzaERIUnNyODYtMkRsTDdwd1VhOVF5SHNUajg0ZkFKbjJGdjloOW1xckl2VXpBdEVZUmxHRnZqVlRHQ3d6RXVsbHBzQjBHSmFmb3BVVEZieThXZFNxM2RHTEpCQjFyLVE4UXRabkF4eHZvbGh3T21Za0Jra2lkZWZtbTQ4WDdoRlhMMmNTSm0yRzd3UXlpbk9leV9VOHhEWjY4bWdUYWtpcVMyUnRqbkZEMGRucEJsNUNZVGU0czZvWktFeUZpRk5pVzRLa1IxR1Zqc0t3WTlvQzJ0cHlRMEFFVU12azlUOVZkSWx0U0lpQXZPS2x3RnpMNDljZ3daRHcifQ" }, signer ) {:ok, client: client, id_token: id_token, code: code, conn: put_req_header(conn, "content-type", "application/x-www-form-urlencoded")} end describe "SIOPV2 direct post" do test "unauthorized with a bad id_token", %{conn: conn} do conn = post( conn, "/openid/direct_post/bad_code", "id_token=bad_id_token" ) assert json_response(conn, 401) == %{ "error" => "unauthorized", "error_description" => "{:error, :token_malformed}" } end @tag :skip test "not found with a bad code", %{id_token: id_token, conn: conn} do conn = post( conn, "/openid/direct_post/bad_code", "id_token=#{id_token}" ) assert response(conn, 404) end @tag :skip test "authenticates", %{id_token: id_token, code: code, conn: conn} do conn = post( conn, "/openid/direct_post/#{code.id}", "id_token=#{id_token}" ) assert redirected_to(conn) =~ ~r/#{code.redirect_uri}/ assert redirected_to(conn) =~ ~r/code=#{code.value}/ assert redirected_to(conn) =~ ~r/state=#{code.state}/ end end def public_key_fixture do "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVOf8cU\n8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa9QyH\nsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8WdSq3d\nGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/U8xD\nZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2tpyQ0\nAEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQAB\n-----END RSA PUBLIC KEY-----\n\n" end def private_key_fixture do "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVO\nf8cU8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa\n9QyHsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8Wd\nSq3dGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/\nU8xDZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2t\npyQ0AEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQABAoIBAG0dg/upL8k1IWiv\n8BNphrXIYLYQmiiBQTPJWZGvWIC2sl7i40yvCXjDjiRnZNK9HwgL94XtALCXYRFR\nJD41bRA3MO5A0HSPIWwJXwS10/cU56HVCNHjwKa6Rz/QiG2kNASMZEMzlvHtrjna\ndx36/sjI3HH8gh1BaTZyiuDE72SMkPbL838jfL1YY9uJ0u6hWFDbdn3sqPfJ6Cnz\n1cu0piT35nkilnIGCNYA0i3lyMeo4XrdXaAJdN9nnqbCi5ewQWqaHbrIIY5LTgzJ\nYlOr3IiecyokFxHCbULXle60u0KqXYgBHmlQJJr1Dj4c9AkQmefjC2jRMlhOrIzo\nIkIUeMECgYEA+MNLB+w6vv1ogqzM3M1OLt6bziWJCn+XkziuMrCiY9KeDD+S70+E\nhfbhM5RjCE3wxC/k59039laT973BmdMHxrDd2zSjOFmCIORv5yrD5oBHMaMZcwuQ\n45Xisi4aoQoOhyznSnjo/RjeQB7qEDzXFznLLNT79HzqyAtCWD3UIu8CgYEA2yik\n9FKl7HJEY94D2K6vNh1AHGnkwIQC72pXzlUrVuwQYngj6/Gkhw8ayFBApHfwVCXj\no9rDYPdNrrAs0Zz0JsiJp6bOCEKCrMYE16UiejUUAg/OZ5eg6+3m3/iWatkzLUuK\n1LIkVBJlEyY0uPuAaBF0V0VleNvfCGhVYOn46+ECgYAUD4OsduNh5YOZDiBTKgdF\nBlSgMiyz+QgbKjX6Bn6B+EkgibvqqonwV7FffHbkA40H9SjLfe52YhL6poXHRtpY\nroillcAX2jgBOQrBJJS5sNyM5y81NNiRUdP/NHKXS/1R71ATlF6NkoTRvOx5NL7P\ns6xryB0tYSl5ylamUQ4bZwKBgHF6FB9mA//wErVbKcayfIqajq2nrwh30kVBXQG7\nW9uAE+PIrWDoF/bOvWFnHHGMoOYRUFNxXKUCqDiBhFNs34aNY6lpV1kzhxIK3ksC\neF2qyhdfM9Kz0mEXJ+pkfw4INNWJPfNv4hueArPtnnMB1rUMBJ+DkU0JG+zwiPTL\ncVZBAoGBAM6kOsh5KGn3aI83g9ZO0TrKLXXFotxJt31Wu11ydj9K33/Qj3UXcxd4\nJPXr600F0DkLeUKBob6BALeHFWcrSz5FGLGRqdRxdv+L6g18WH5m2xEs7o6M6e5I\nIhyUC60ZewJ2M8rV4KgCJJdZE2kENlSgjU92IDVPT9Oetrc7hQJd\n-----END RSA PRIVATE KEY-----\n\n" end end ================================================ FILE: apps/boruta_web/test/boruta_web/controllers/oauth/implicit_test.exs ================================================ defmodule BorutaWeb.Oauth.ImplicitTest do use BorutaWeb.ConnCase import Boruta.Factory import BorutaIdentity.AccountsFixtures alias BorutaIdentityWeb.Authenticable setup %{conn: conn} do {:ok, conn: conn} end describe "implicit grant" do setup %{conn: conn} do resource_owner = user_fixture() redirect_uri = "http://redirect.uri" client = insert(:client, redirect_uris: [redirect_uri]) identity_provider = BorutaIdentity.Factory.insert(:identity_provider, consentable: true) BorutaIdentity.Factory.insert(:client_identity_provider, client_id: client.id, identity_provider: identity_provider ) scope = insert(:scope, public: true) {:ok, conn: conn, client: client, redirect_uri: redirect_uri, resource_owner: resource_owner, scope: scope} end # TODO test different validation cases test "validates request params", %{ conn: conn, resource_owner: resource_owner } do conn = conn |> log_in(resource_owner) |> init_test_session(session_chosen: true) assert_raise BorutaWeb.AuthorizeError, "Request is not a valid OAuth request. Need a response_type param.", fn -> get(conn, "/oauth/authorize") end end test "returns an error if client_id is invalid", %{ conn: conn, redirect_uri: redirect_uri, resource_owner: resource_owner } do conn = conn |> log_in(resource_owner) |> init_test_session(session_chosen: true) assert_raise BorutaWeb.AuthorizeError, "Invalid client_id or redirect_uri.", fn -> get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "token", client_id: "6a2f41a3-c54c-fce8-32d2-0324e1c32e22", redirect_uri: redirect_uri, state: "state" }) ) end end test "redirect to user authentication page", %{ conn: conn, client: client, redirect_uri: redirect_uri } do conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "token", client_id: client.id, redirect_uri: redirect_uri }) ) # NOTE Path will be scoped in production with configuration and be forwarded to assert redirected_to(conn) =~ "/users/choose_session" end test "redirects to redirect_uri with token if current_user is set", %{ conn: conn, client: client, redirect_uri: redirect_uri, resource_owner: resource_owner } do request_param = Authenticable.request_param( get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "token", client_id: client.id, redirect_uri: redirect_uri }) ) ) conn = conn |> log_in(resource_owner) |> init_test_session(session_chosen: true, preauthorizations: %{request_param => true}) conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "token", client_id: client.id, redirect_uri: redirect_uri }) ) [_, access_token, expires_in] = Regex.run( ~r/#{redirect_uri}#access_token=(.+)&expires_in=(.+)/, redirected_to(conn) ) assert access_token assert expires_in end test "redirects to redirect_uri with state", %{ conn: conn, client: client, redirect_uri: redirect_uri, resource_owner: resource_owner } do given_state = "state" request_param = Authenticable.request_param( get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "token", client_id: client.id, redirect_uri: redirect_uri, state: given_state }) ) ) conn = conn |> log_in(resource_owner) |> init_test_session(session_chosen: true, preauthorizations: %{request_param => true}) conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "token", client_id: client.id, redirect_uri: redirect_uri, state: given_state }) ) assert [_, "bearer"] = Regex.run( ~r/token_type=([^&]+)/, redirected_to(conn) ) assert [_, _redirect_uri] = Regex.run( ~r/(#{redirect_uri})#/, redirected_to(conn) ) [_, access_token] = Regex.run( ~r/access_token=([^&]+)/, redirected_to(conn) ) [_, expires_in] = Regex.run( ~r/expires_in=([^&]+)/, redirected_to(conn) ) [_, state] = Regex.run( ~r/state=([^&]+)/, redirected_to(conn) ) assert access_token assert expires_in assert state == given_state end test "renders preauthorize with scope", %{ conn: conn, client: client, redirect_uri: redirect_uri, resource_owner: resource_owner, scope: scope } do conn = conn |> log_in(resource_owner) |> init_test_session(session_chosen: true) conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "token", client_id: client.id, redirect_uri: redirect_uri, scope: scope.name }) ) # TODO test request query param assert redirected_to(conn) =~ IdentityRoutes.user_consent_path(conn, :index) end test "redirects to redirect_uri with consented scope", %{ conn: conn, client: client, redirect_uri: redirect_uri, resource_owner: resource_owner, scope: scope } do request_param = Authenticable.request_param( get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "token", client_id: client.id, redirect_uri: redirect_uri, scope: scope.name }) ) ) conn = conn |> log_in(resource_owner) |> init_test_session(session_chosen: true, preauthorizations: %{request_param => true}) BorutaIdentity.Factory.insert(:consent, user_id: resource_owner.id, client_id: client.id, scopes: [scope.name] ) conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "token", client_id: client.id, redirect_uri: redirect_uri, scope: scope.name }) ) [_, access_token, expires_in] = Regex.run( ~r/#{redirect_uri}#access_token=(.+)&expires_in=(.+)/, redirected_to(conn) ) assert access_token assert expires_in end @tag :skip test "preauthorize error case" end end ================================================ FILE: apps/boruta_web/test/boruta_web/controllers/oauth/introspect_test.exs ================================================ defmodule BorutaWeb.Oauth.IntrospectTest do use BorutaWeb.ConnCase import Boruta.Factory import BorutaIdentity.AccountsFixtures setup %{conn: conn} do {:ok, conn: conn} end describe "introspect" do setup %{conn: conn} do client = insert(:client) client_token = insert(:token, type: "access_token", client: client, scope: "") resource_owner = user_fixture() resource_owner_token = insert(:token, type: "access_token", client: client, sub: resource_owner.id, scope: "" ) {:ok, conn: put_req_header(conn, "content-type", "application/x-www-form-urlencoded"), client: client, client_token: client_token, resource_owner_token: resource_owner_token, resource_owner: resource_owner} end test "returns an error if request is invalid", %{conn: conn} do conn = post( conn, "/oauth/introspect" ) assert json_response(conn, 400) == %{ "error" => "invalid_request", "error_description" => "Request validation failed. Required properties client_id, token are missing at #." } end test "returns an error if client is invalid", %{conn: conn, client: client} do conn = post( conn, "/oauth/introspect", "client_id=#{client.id}&client_secret=bad_secret&token=token" ) assert json_response(conn, 401) == %{ "error" => "invalid_client", "error_description" => "Invalid client_id or client_secret." } end test "returns an inactive token response if token is invalid", %{conn: conn, client: client} do conn = post( conn, "/oauth/introspect", "client_id=#{client.id}&client_secret=#{client.secret}&token=bad_token" ) assert json_response(conn, 200) == %{"active" => false} end test "returns an introspect token response if client, token are valid", %{ conn: conn, client: client, client_token: token } do conn = post( conn, "/oauth/introspect", "client_id=#{client.id}&client_secret=#{client.secret}&token=#{token.value}" ) assert json_response(conn, 200) == %{ "active" => true, "client_id" => client.id, "exp" => token.expires_at, "iat" => DateTime.to_unix(token.inserted_at), "iss" => "http://localhost:4000", "scope" => token.scope, "sub" => nil, "username" => nil } end test "returns an introspect token response if resource owner token is valid", %{ conn: conn, client: client, resource_owner_token: token, resource_owner: resource_owner } do conn = post( conn, "/oauth/introspect", "client_id=#{client.id}&client_secret=#{client.secret}&token=#{token.value}" ) assert json_response(conn, 200) == %{ "active" => true, "client_id" => client.id, "exp" => token.expires_at, "iat" => DateTime.to_unix(token.inserted_at), "iss" => "http://localhost:4000", "scope" => token.scope, "sub" => resource_owner.id, "username" => resource_owner.username } end test "returns a jwt token when accept header set", %{ conn: conn, client: client, client_token: token } do signer = Joken.Signer.create("RS512", %{"pem" => client.public_key}) conn = put_req_header(conn, "accept", "application/jwt") conn = post( conn, "/oauth/introspect", "client_id=#{client.id}&client_secret=#{client.secret}&token=#{token.value}" ) case Joken.Signer.verify(response(conn, 200), signer) do {:ok, payload} -> assert payload == %{ "active" => true, "client_id" => client.id, "exp" => token.expires_at, "iat" => DateTime.to_unix(token.inserted_at), "iss" => "http://localhost:4000", "scope" => token.scope, "sub" => nil, "username" => nil } _ -> assert false end end end end ================================================ FILE: apps/boruta_web/test/boruta_web/controllers/oauth/openid_connect_test.exs ================================================ defmodule BorutaWeb.Integration.OpenidConnectTest do use BorutaWeb.ConnCase, async: false import Boruta.Factory import BorutaIdentity.AccountsFixtures alias Boruta.ClientsAdapter alias Boruta.Ecto.Admin alias Boruta.Ecto.Client alias Boruta.Ecto.ClientStore alias Boruta.Oauth alias BorutaIdentity.IdentityProviders.ClientIdentityProvider alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaIdentityWeb.Authenticable describe "OpenID Connect flows" do setup %{conn: conn} do public_client = Admin.get_client!(ClientsAdapter.public!().id) {:ok, _client} = Admin.update_client(public_client, %{supported_grant_types: Oauth.Client.grant_types()}) ClientStore.invalidate_public() resource_owner = user_fixture() redirect_uri = "http://redirect.uri" client = insert(:client, redirect_uris: [redirect_uri]) scope = insert(:scope, public: true) {:ok, conn: conn, client: client, redirect_uri: redirect_uri, resource_owner: resource_owner, scope: scope} end test "redirect to login with prompt=login", %{conn: conn} do conn = get( conn, Routes.authorize_path(conn, :authorize, %{ prompt: "login" }) ) assert redirected_to(conn) =~ "/users/log_out" end test "returns an error with prompt=none without any current_user", %{ conn: conn, client: client, redirect_uri: redirect_uri } do conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "id_token", client_id: client.id, redirect_uri: redirect_uri, prompt: "none", scope: "openid", nonce: "nonce" }) ) assert redirected_to(conn) =~ ~r/error=login_required/ end test "authorizes with prompt=none with anonymous client (verifiable presentation - wallet)", %{ conn: conn, redirect_uri: redirect_uri } do conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "vp_token", client_id: "did:key:test", redirect_uri: redirect_uri, client_metadata: "{}", prompt: "none", scope: "openid", nonce: "nonce" }) ) assert redirected_to(conn) =~ ~r/request=/ assert redirected_to(conn) =~ ~r/redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fopenid%2Fdirect_post%2F/ assert redirected_to(conn) =~ ~r/#{redirect_uri}/ end test "authorizes with prompt=none with anonymous client (siopv2 - wallet)", %{ conn: conn, redirect_uri: redirect_uri } do conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "code", client_id: "did:key:test", redirect_uri: redirect_uri, client_metadata: "{}", prompt: "none", scope: "openid", nonce: "nonce" }) ) assert redirected_to(conn) =~ ~r/request=/ assert redirected_to(conn) =~ ~r/redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fopenid%2Fdirect_post%2F/ assert redirected_to(conn) =~ ~r/#{redirect_uri}/ end test "returns an error with prompt=none without any current_user (preauthorized)", %{ conn: conn, client: client, redirect_uri: redirect_uri } do request_param = Authenticable.request_param( get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "id_token", client_id: client.id, redirect_uri: redirect_uri, prompt: "none", scope: "openid", nonce: "nonce" }) ) ) conn = init_test_session(conn, session_chosen: true, preauthorizations: %{request_param => true}) conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "id_token", client_id: client.id, redirect_uri: redirect_uri, prompt: "none", scope: "openid", nonce: "nonce" }) ) assert redirected_to(conn) =~ ~r/error=login_required/ end test "authorize with prompt='none' and a current_user", %{ conn: conn, client: client, resource_owner: resource_owner, redirect_uri: redirect_uri } do request_param = Authenticable.request_param( get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "id_token", client_id: client.id, redirect_uri: redirect_uri, prompt: "none", scope: "openid", nonce: "nonce" }) ) ) conn = conn |> log_in(resource_owner) |> init_test_session(session_chosen: true, preauthorizations: %{request_param => true}) conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "id_token", client_id: client.id, redirect_uri: redirect_uri, prompt: "none", scope: "openid", nonce: "nonce" }) ) assert url = redirected_to(conn) assert [_, _id_token] = Regex.run( ~r/#{redirect_uri}#id_token=(.+)/, url ) end test "logs in with an expired max_age and current_user", %{ conn: conn, client: client, resource_owner: resource_owner, redirect_uri: redirect_uri } do conn = conn |> log_in(resource_owner) conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "id_token", client_id: client.id, redirect_uri: redirect_uri, scope: "openid", nonce: "nonce", max_age: 0 }) ) assert redirected_to(conn) =~ "/users/log_out" end test "redirects to redirect_uri session with a non expired max_age and current_user", %{ conn: conn, client: client, resource_owner: resource_owner, redirect_uri: redirect_uri } do request_param = Authenticable.request_param( get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "id_token", client_id: client.id, redirect_uri: redirect_uri, scope: "openid", nonce: "nonce", max_age: 10 }) ) ) conn = conn |> log_in(resource_owner) |> init_test_session(preauthorizations: %{request_param => true}) conn = get( conn, Routes.authorize_path(conn, :authorize, %{ response_type: "id_token", client_id: client.id, redirect_uri: redirect_uri, scope: "openid", nonce: "nonce", max_age: 10 }) ) assert url = redirected_to(conn) assert [_, _id_token] = Regex.run( ~r/#{redirect_uri}#id_token=(.+)/, url ) end end describe "jwks endpoints" do test "returns public client key", %{conn: conn} do conn = get(conn, Routes.jwks_path(conn, :jwks_index)) assert %{"keys" => keys} = json_response(conn, 200) assert Enum.count(keys) == 1 end test "returns all clients keys", %{conn: conn} do %Client{} = insert(:client) conn = get(conn, Routes.jwks_path(conn, :jwks_index)) assert keys = json_response(conn, 200)["keys"] assert Enum.find(keys, fn %{"kid" => kid} -> kid == "Ac9ufCpgwReXGJ6LI" end) end end describe "userinfo" do test "returns userinfo", %{conn: conn} do sub = user_fixture().id token = insert(:token, sub: sub) conn = conn |> put_req_header("authorization", "bearer #{token.value}") |> post(Routes.userinfo_path(conn, :userinfo)) assert json_response(conn, 200) end test "returns userinfo as jwt", %{conn: conn} do sub = user_fixture().id token = insert(:token, sub: sub) {:ok, _client} = Ecto.Changeset.change(token.client, %{userinfo_signed_response_alg: "HS512"}) |> BorutaAuth.Repo.update() conn = conn |> put_req_header("authorization", "bearer #{token.value}") |> post(Routes.userinfo_path(conn, :userinfo)) assert String.starts_with?(response(conn, 200), "ey") end end describe "discovery 1.0" do test "returns required keys", %{conn: conn} do BorutaIdentity.Factory.insert(:backend, verifiable_credentials: [ %{ "display" => %{ "background_color" => "#53b29f", "logo" => %{ "alt_text" => "Boruta PoC logo", "url" => "https://io.malach.it/assets/images/logo.png" }, "name" => "Federation credential PoC", "text_color" => "#FFFFFF" }, "claims" => [%{"name" => "claim", "label" => "label"}], "credential_identifier" => "FederatedAttributes", "format" => "jwt_vc", "types" => "VerifiableCredential BorutaCredential" } ] ) Boruta.Factory.insert(:scope, name: "well_known") conn = get(conn, Routes.openid_path(conn, :well_known)) assert json_response(conn, 200) == %{ "authorization_endpoint" => "http://localhost:4000/oauth/authorize", "credential_endpoint" => "http://localhost:4000/openid/credential", "defered_credential_endpoint" => "http://localhost:4000/openid/defered-credential", "pushed_authorization_request_endpoint" => "http://localhost:4000/oauth/pushed_authorization_request", "credential_issuer" => "http://localhost:4000", "credentials_supported" => [], "credential_configurations_supported" => %{ "FederatedAttributes" => %{ "credential_definition" => %{ "credentialSubject" => %{"claim" => [%{"name" => "label"}]}, "type" => ["VerifiableCredential", "BorutaCredential"] }, "credential_signing_alg_values_supported" => [ "ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "HS256", "HS384", "HS512", "EdDSA" ], "cryptographic_binding_methods_supported" => ["did:jwk", "did:key"], "display" => [ %{ "background_color" => "#53b29f", "locale" => "en-US", "logo" => %{ "alt_text" => "Boruta PoC logo", "url" => "https://io.malach.it/assets/images/logo.png" }, "name" => "Federation credential PoC", "text_color" => "#FFFFFF" } ], "format" => "jwt_vc", "scope" => "FederatedAttributes" } }, "grant_types_supported" => [ "client_credentials", "password", "implicit", "authorization_code", "refresh_token" ], "id_token_signing_alg_values_supported" => [ "ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "HS256", "HS384", "HS512", "EdDSA" ], "issuer" => "http://localhost:4000", "jwks_uri" => "http://localhost:4000/openid/jwks", # "registration_endpoint" => "http://localhost:4000/openid/register", "request_object_signing_alg_values_supported" => [ "ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "HS256", "HS384", "HS512", "EdDSA" ], "response_modes_supported" => ["query", "fragment"], "response_types_supported" => [ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code id_token token" ], "scopes_supported" => ["well_known"], "subject_types_supported" => ["public"], "token_endpoint" => "http://localhost:4000/oauth/token", "token_endpoint_auth_methods_supported" => [ "client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt" ], "userinfo_endpoint" => "http://localhost:4000/oauth/userinfo", "userinfo_signing_alg_values_supported" => [ "ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "HS256", "HS384", "HS512", "EdDSA" ] } end end # describe "dynamic registration" do # test "returns an error when data is invalid", %{conn: conn} do # conn = # post(conn, Routes.dynamic_registration_path(conn, :register_client), %{redirect_uris: nil}) # assert json_response(conn, 400) == %{ # "error" => "invalid_client_metadata", # "error_description" => "redirect_uris : can't be blank" # } # end # test "registers client", %{conn: conn} do # conn = # post(conn, Routes.dynamic_registration_path(conn, :register_client), %{ # redirect_uris: ["https://test.uri"] # }) # assert %{ # "client_id" => client_id, # "client_secret" => client_secret, # "client_secret_expires_at" => 0 # } = json_response(conn, 201) # assert client_id # assert client_secret # end # test "creates associated identity provider", %{conn: conn} do # conn = # post(conn, Routes.dynamic_registration_path(conn, :register_client), %{ # redirect_uris: ["https://test.uri"] # }) # assert %{ # "client_id" => client_id # } = json_response(conn, 201) # assert %ClientIdentityProvider{identity_provider_id: identity_provider_id} = # BorutaIdentity.Repo.get_by(ClientIdentityProvider, client_id: client_id) # assert BorutaIdentity.Repo.get!(IdentityProvider, identity_provider_id) # end # end end ================================================ FILE: apps/boruta_web/test/boruta_web/controllers/oauth/password_test.exs ================================================ defmodule BorutaWeb.Oauth.PasswordTest do use BorutaWeb.ConnCase import Boruta.Factory import BorutaIdentity.AccountsFixtures alias BorutaIdentity.IdentityProviders.Backend setup %{conn: conn} do {:ok, conn: conn} end describe "password grant" do setup %{conn: conn} do password = valid_user_password() resource_owner = user_fixture(%{password: password, backend: Backend.default!()}) client = insert(:client) {:ok, conn: put_req_header(conn, "content-type", "application/x-www-form-urlencoded"), client: client, resource_owner: resource_owner, password: password} end test "returns a token response with valid client_id/client_secret", %{ conn: conn, client: client, resource_owner: resource_owner, password: password } do conn = post( conn, "/oauth/token", "grant_type=password&username=#{resource_owner.username}&password=#{password}&client_id=#{ client.id }&client_secret=#{client.secret}" ) %{ "access_token" => access_token, "token_type" => token_type, "expires_in" => expires_in, "refresh_token" => refresh_token } = json_response(conn, 200) assert access_token assert token_type == "bearer" assert expires_in assert refresh_token end end end ================================================ FILE: apps/boruta_web/test/boruta_web/controllers/oauth/revoke_test.exs ================================================ defmodule BorutaWeb.Oauth.RevokeTest do use BorutaWeb.ConnCase import Boruta.Factory import BorutaIdentity.AccountsFixtures alias Boruta.Ecto.Token setup %{conn: conn} do {:ok, conn: conn} end describe "revoke" do setup %{conn: conn} do client = insert(:client) client_token = insert(:token, type: "access_token", value: SecureRandom.uuid(), client: client) resource_owner = user_fixture() resource_owner_token = insert(:token, type: "access_token", value: "888", client: client, sub: resource_owner.id) {:ok, conn: put_req_header(conn, "content-type", "application/x-www-form-urlencoded"), client: client, client_token: client_token, resource_owner_token: resource_owner_token, resource_owner: resource_owner} end test "returns an error if request is invalid", %{conn: conn} do conn = post( conn, "/oauth/revoke" ) assert json_response(conn, 400) == %{ "error" => "invalid_request", "error_description" => "Request validation failed. Required properties client_id, token are missing at #." } end test "returns an error if client is invalid", %{conn: conn, client: client} do conn = post( conn, "/oauth/revoke", "client_id=#{client.id}&client_secret=bad_secret&token=token" ) assert json_response(conn, 401) == %{ "error" => "invalid_client", "error_description" => "Invalid client_id or client_secret." } end test "returns a success if token is invalid", %{conn: conn, client: client} do conn = post( conn, "/oauth/revoke", "client_id=#{client.id}&client_secret=#{client.secret}&token=bad_token" ) assert response(conn, 200) end test "return a success if client, token are valid", %{ conn: conn, client: client, client_token: token } do conn = post( conn, "/oauth/revoke", "client_id=#{client.id}&client_secret=#{client.secret}&token=#{token.value}" ) assert response(conn, 200) end test "revoke token if client, token are valid", %{ conn: conn, client: client, client_token: token } do post( conn, "/oauth/revoke", "client_id=#{client.id}&client_secret=#{client.secret}&token=#{token.value}" ) assert BorutaAuth.Repo.get_by(Token, value: token.value).revoked_at end end end ================================================ FILE: apps/boruta_web/test/boruta_web/controllers/pushed_authorization_request_controller_test.exs ================================================ defmodule BorutaWeb.Oauth.PushedAuthorizationRequestControllerTest do use BorutaWeb.ConnCase import Boruta.Factory setup %{conn: conn} do {:ok, conn: conn} end describe "pushed authorization request" do test "respond with an error", %{conn: conn} do request_params = %{} conn = post( conn, Routes.pushed_authorization_request_path(conn, :pushed_authorization_request), request_params ) assert json_response(conn, 400) == %{ "error" => "invalid_request", "error_description" => "Request is not a valid OAuth request. Need a response_type param." } end test "stores the request", %{conn: conn} do client = insert(:client, redirect_uris: ["http://redirect_uri"]) request_params = %{ "response_type" => "code", "client_id" => client.id, "redirect_uri" => List.first(client.redirect_uris) } conn = post( conn, Routes.pushed_authorization_request_path(conn, :pushed_authorization_request), request_params ) assert %{ "request_uri" => request_uri, "expires_in" => expires_in } = json_response(conn, 201) assert request_uri assert expires_in end end end ================================================ FILE: apps/boruta_web/test/boruta_web/plugs/rate_limit_test.exs ================================================ defmodule BorutaWeb.Plugs.RateLimitTest do use ExUnit.Case alias BorutaWeb.Plugs.RateLimit setup do {:ok, conn: Phoenix.ConnTest.build_conn()} end describe "rate limiting" do test "request passes", %{conn: conn} do :timer.sleep(1) options = [time_unit: :millisecond, count: 1] assert RateLimit.call(conn, options) == conn end test "request is throttled", %{conn: conn} do :timer.sleep(1000) b_conn = %{conn | remote_ip: {127, 0, 0, 2}} options = [time_unit: :second, count: 5, penality: 500] assert RateLimit.call(conn, options) == conn assert RateLimit.call(b_conn, options) == b_conn end end describe "Counter.get" do test "gives the count within the time unit range" do :timer.sleep(1000) ip = :ip time_unit = :second assert RateLimit.Counter.get(ip, time_unit) == 0 Agent.update(RateLimit.Counter, fn _counter -> %{ip => [:os.system_time(:millisecond)]} end) assert RateLimit.Counter.get(ip, time_unit) == 1 Agent.update(RateLimit.Counter, fn _counter -> %{ip => [:os.system_time(:millisecond), :os.system_time(:millisecond)]} end) assert RateLimit.Counter.get(ip, time_unit) == 2 Agent.update(RateLimit.Counter, fn _counter -> %{ip => [:os.system_time(:millisecond), :os.system_time(:millisecond) - 1000]} end) assert RateLimit.Counter.get(ip, time_unit) == 1 end end describe "Counter.throttling_timeout" do test "gives the timeout within the time unit range" do :timer.sleep(1000) ip = :ip time_unit = :second penality = 100 count = 1 Agent.update(RateLimit.Counter, fn _counter -> %{} end) assert RateLimit.Counter.throttling_timeout(ip, count, time_unit, penality) == 0 Agent.update(RateLimit.Counter, fn _counter -> %{ip => [:os.system_time(:millisecond)]} end) assert RateLimit.Counter.throttling_timeout(ip, count, time_unit, penality) == 0 Agent.update(RateLimit.Counter, fn _counter -> %{ ip => [ :os.system_time(:millisecond), :os.system_time(:millisecond), :os.system_time(:millisecond), :os.system_time(:millisecond), :os.system_time(:millisecond), :os.system_time(:millisecond), :os.system_time(:millisecond) ] } end) assert RateLimit.Counter.throttling_timeout(ip, count, time_unit, penality) == 700 Agent.update(RateLimit.Counter, fn _counter -> %{ ip => [ :os.system_time(:millisecond) - 1000, :os.system_time(:millisecond) - 800, :os.system_time(:millisecond) ] } end) assert RateLimit.Counter.throttling_timeout(ip, count, time_unit, penality) == 200 Agent.update(RateLimit.Counter, fn _counter -> %{ip => [:os.system_time(:millisecond), :os.system_time(:millisecond) - 1000]} end) assert RateLimit.Counter.throttling_timeout(ip, count, time_unit, penality) == 0 end end describe "Counter.increment" do test "updates the counter" do :timer.sleep(1000) ip = :ip time_unit = :second assert Agent.get(RateLimit.Counter, fn counter -> Map.get(counter, ip, []) |> Enum.count(fn timestamp -> timestamp > :os.system_time(:millisecond) - 1000 end) end) == 0 RateLimit.Counter.increment(ip, time_unit) assert Agent.get(RateLimit.Counter, fn %{^ip => timestamps} -> timestamps |> Enum.count(fn timestamp -> timestamp > :os.system_time(:millisecond) - 1000 end) end) == 1 RateLimit.Counter.increment(ip, time_unit) assert Agent.get(RateLimit.Counter, fn %{^ip => timestamps} -> timestamps |> Enum.count(fn timestamp -> timestamp > :os.system_time(:millisecond) - 1000 end) end) == 2 :timer.sleep(1000) RateLimit.Counter.increment(ip, time_unit) assert Agent.get(RateLimit.Counter, fn %{^ip => timestamps} -> timestamps |> Enum.count(fn timestamp -> timestamp > :os.system_time(:millisecond) - 1000 end) end) == 1 end end end ================================================ FILE: apps/boruta_web/test/boruta_web/views/error_view_test.exs ================================================ defmodule BorutaWeb.ErrorViewTest do use BorutaWeb.ConnCase, async: true # Bring render/3 and render_to_string/3 for testing custom views import Phoenix.View test "renders 404.html" do assert render_to_string(BorutaWeb.ErrorView, "404.html", []) =~ "Page not found" end test "renders 500.html" do assert render_to_string(BorutaWeb.ErrorView, "500.html", []) =~ "Internal server error" end end ================================================ FILE: apps/boruta_web/test/boruta_web/views/layout_view_test.exs ================================================ defmodule BorutaWeb.LayoutViewTest do use BorutaWeb.ConnCase, async: true end ================================================ FILE: apps/boruta_web/test/boruta_web/views/page_view_test.exs ================================================ defmodule BorutaWeb.PageViewTest do use BorutaWeb.ConnCase, async: true end ================================================ FILE: apps/boruta_web/test/support/boruta_factory.ex ================================================ defmodule Boruta.Factory do @moduledoc false use ExMachina.Ecto, repo: BorutaAuth.Repo alias Boruta.Ecto def client_factory do %Ecto.Client{ secret: SecureRandom.urlsafe_base64(), redirect_uris: ["https://redirect.uri/oauth2-redirect-path"], access_token_ttl: 3600, authorization_code_ttl: 60, private_key: "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVO\nf8cU8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa\n9QyHsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8Wd\nSq3dGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/\nU8xDZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2t\npyQ0AEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQABAoIBAG0dg/upL8k1IWiv\n8BNphrXIYLYQmiiBQTPJWZGvWIC2sl7i40yvCXjDjiRnZNK9HwgL94XtALCXYRFR\nJD41bRA3MO5A0HSPIWwJXwS10/cU56HVCNHjwKa6Rz/QiG2kNASMZEMzlvHtrjna\ndx36/sjI3HH8gh1BaTZyiuDE72SMkPbL838jfL1YY9uJ0u6hWFDbdn3sqPfJ6Cnz\n1cu0piT35nkilnIGCNYA0i3lyMeo4XrdXaAJdN9nnqbCi5ewQWqaHbrIIY5LTgzJ\nYlOr3IiecyokFxHCbULXle60u0KqXYgBHmlQJJr1Dj4c9AkQmefjC2jRMlhOrIzo\nIkIUeMECgYEA+MNLB+w6vv1ogqzM3M1OLt6bziWJCn+XkziuMrCiY9KeDD+S70+E\nhfbhM5RjCE3wxC/k59039laT973BmdMHxrDd2zSjOFmCIORv5yrD5oBHMaMZcwuQ\n45Xisi4aoQoOhyznSnjo/RjeQB7qEDzXFznLLNT79HzqyAtCWD3UIu8CgYEA2yik\n9FKl7HJEY94D2K6vNh1AHGnkwIQC72pXzlUrVuwQYngj6/Gkhw8ayFBApHfwVCXj\no9rDYPdNrrAs0Zz0JsiJp6bOCEKCrMYE16UiejUUAg/OZ5eg6+3m3/iWatkzLUuK\n1LIkVBJlEyY0uPuAaBF0V0VleNvfCGhVYOn46+ECgYAUD4OsduNh5YOZDiBTKgdF\nBlSgMiyz+QgbKjX6Bn6B+EkgibvqqonwV7FffHbkA40H9SjLfe52YhL6poXHRtpY\nroillcAX2jgBOQrBJJS5sNyM5y81NNiRUdP/NHKXS/1R71ATlF6NkoTRvOx5NL7P\ns6xryB0tYSl5ylamUQ4bZwKBgHF6FB9mA//wErVbKcayfIqajq2nrwh30kVBXQG7\nW9uAE+PIrWDoF/bOvWFnHHGMoOYRUFNxXKUCqDiBhFNs34aNY6lpV1kzhxIK3ksC\neF2qyhdfM9Kz0mEXJ+pkfw4INNWJPfNv4hueArPtnnMB1rUMBJ+DkU0JG+zwiPTL\ncVZBAoGBAM6kOsh5KGn3aI83g9ZO0TrKLXXFotxJt31Wu11ydj9K33/Qj3UXcxd4\nJPXr600F0DkLeUKBob6BALeHFWcrSz5FGLGRqdRxdv+L6g18WH5m2xEs7o6M6e5I\nIhyUC60ZewJ2M8rV4KgCJJdZE2kENlSgjU92IDVPT9Oetrc7hQJd\n-----END RSA PRIVATE KEY-----\n\n", public_key: "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVOf8cU\n8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa9QyH\nsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8WdSq3d\nGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/U8xD\nZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2tpyQ0\nAEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQAB\n-----END RSA PUBLIC KEY-----\n\n" } end def scope_factory do %Ecto.Scope{ name: SecureRandom.hex(10), public: false } end def token_factory do %Ecto.Token{ client: build(:client), type: "access_token", value: Boruta.TokenGenerator.generate(), expires_at: :os.system_time(:seconds) + 10 } end end ================================================ FILE: apps/boruta_web/test/support/boruta_identity_factory.ex ================================================ defmodule BorutaIdentity.Factory do @moduledoc false use ExMachina.Ecto, repo: BorutaIdentity.Repo alias BorutaIdentity.Accounts.Consent alias BorutaIdentity.Accounts.Internal alias BorutaIdentity.Accounts.Role alias BorutaIdentity.Accounts.RoleScope alias BorutaIdentity.Accounts.User alias BorutaIdentity.Configuration.ErrorTemplate alias BorutaIdentity.IdentityProviders.Backend alias BorutaIdentity.IdentityProviders.ClientIdentityProvider alias BorutaIdentity.IdentityProviders.IdentityProvider alias BorutaIdentity.IdentityProviders.Template # @password "hello world!" @hashed_password "$argon2id$v=19$m=131072,t=8,p=4$9lPv7KsJogno0FlnhaRQXA$TeTY9FYjR1HJtZzg+N1z0oDC+0Mn7buPpOMhDP+M2Ik" def user_factory do %User{ username: "user#{System.unique_integer()}@example.com", uid: SecureRandom.hex(), backend: build(:backend) } end def internal_user_factory do %Internal.User{ email: "user#{System.unique_integer()}@example.com", hashed_password: @hashed_password, backend: build(:backend) } end def consent_factory do %Consent{ client_id: SecureRandom.uuid(), scopes: [] } end def client_identity_provider_factory do %ClientIdentityProvider{ client_id: SecureRandom.uuid(), identity_provider: build(:identity_provider) } end def identity_provider_factory do %IdentityProvider{ name: sequence(:name, &"identity provider #{&1}"), backend: build(:backend) } end def backend_factory do %Backend{ name: "backend name", type: "Elixir.BorutaIdentity.Accounts.Internal" } end def template_factory do %Template{ type: "template_type", content: "template content" } end def new_registration_template_factory do %Template{ type: "new_registration", content: Template.default_content(:new_registration) } end def error_template_factory do %ErrorTemplate{ type: "400", content: "error template content" } end def role_factory do %Role{ name: SecureRandom.hex(32) } end def role_scope_factory do %RoleScope{} end end ================================================ FILE: apps/boruta_web/test/support/conn_case.ex ================================================ defmodule BorutaWeb.ConnCase do @moduledoc """ This module defines the test case to be used by tests that require setting up a connection. Such tests rely on `Phoenix.ConnTest` and also import other functionality to make it easier to build common data structures and query the data layer. Finally, if the test case interacts with the database, it cannot be async. For this reason, every test runs inside a transaction which is reset at the beginning of the test unless the test case is marked as async. """ use ExUnit.CaseTemplate alias Boruta.Ecto.Scopes alias Ecto.Adapters.SQL.Sandbox using do quote do # Import conveniences for testing with connections import Plug.Conn import Phoenix.ConnTest import BorutaIdentityWeb.ConnCase import BorutaWeb.ConnCase alias BorutaIdentityWeb.Router.Helpers, as: IdentityRoutes alias BorutaWeb.Router.Helpers, as: Routes # The default endpoint for testing @endpoint BorutaWeb.Endpoint end end setup tags do :ok = Sandbox.checkout(BorutaIdentity.Repo) :ok = Sandbox.checkout(BorutaWeb.Repo) :ok = Sandbox.checkout(BorutaAuth.Repo) :ok = Scopes.invalidate(:public) unless tags[:async] do Sandbox.mode(BorutaIdentity.Repo, {:shared, self()}) Sandbox.mode(BorutaWeb.Repo, {:shared, self()}) end {:ok, conn: Phoenix.ConnTest.build_conn()} end end ================================================ FILE: apps/boruta_web/test/test_helper.exs ================================================ ExUnit.start() Ecto.Adapters.SQL.Sandbox.mode(BorutaIdentity.Repo, :manual) Ecto.Adapters.SQL.Sandbox.mode(BorutaWeb.Repo, :manual) Ecto.Adapters.SQL.Sandbox.mode(BorutaAuth.Repo, :manual) Application.ensure_all_started(:bypass) Logger.remove_backend(:console) ================================================ FILE: boruta-admin.openapi.json ================================================ { "openapi": "3.0.0", "info": { "title": "Boruta Administration", "contact": {}, "version": "1.0" }, "servers": [ { "url": "http://example.com", "variables": {} } ], "paths": { "/api/clients": { "get": { "tags": [ "Clients" ], "summary": "list clients", "operationId": "listclients", "parameters": [], "responses": { "200": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/Client" } } } } } } }, "401": { "description": "The client is unauthorized to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Unauthorized" } } } }, "403": { "description": "The client is forbidden to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Forbidden" } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] }, "post": { "tags": [ "Clients" ], "summary": "create a client", "operationId": "createaclient", "parameters": [], "requestBody": { "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/createaclientrequest" }, "example": { "client": { "access_token_ttl": 86400, "authorization_code_ttl": 60, "authorize_scope": false, "authorized_scopes": [], "id": "b70e3293-d661-4eee-a709-d5161cb27d9d", "id_token_ttl": 86400, "name": "Created from API", "pkce": false, "public_refresh_token": false, "public_revoke": false, "redirect_uris": [], "refresh_token_ttl": 2592000, "identity_provider": { "id": "f74e3d72-b41c-4fad-aa38-635c45831a1e" }, "secret": "GMx4LfhqIkLea1rzYGcr5cEmZ9ugif5EgKHQ3qgdvJ72Nyfyk9wC6YU7YICedaDEd8ZVNrOkoAV77dQPzBWLgm", "supported_grant_types": [ "client_credentials", "password", "authorization_code", "refresh_token", "implicit", "revoke", "introspect" ] } } } }, "required": true }, "responses": { "201": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Client" } } } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] } }, "/api/clients/{client_id}": { "get": { "tags": [ "Clients" ], "summary": "show a client", "operationId": "showaclient", "parameters": [ { "name": "client_id", "in": "path", "description": "", "required": true, "style": "simple", "schema": { "type": "string" } } ], "responses": { "200": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Client" } } } } } }, "401": { "description": "The client is unauthorized to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Unauthorized" } } } }, "403": { "description": "The client is forbidden to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Forbidden" } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] }, "patch": { "tags": [ "Clients" ], "summary": "update a client", "operationId": "updateaclient", "parameters": [ { "name": "client_id", "in": "path", "description": "", "required": true, "style": "simple", "schema": { "type": "string" } } ], "requestBody": { "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/updateaclientrequest" }, "example": { "client": { "access_token_ttl": 86400, "authorization_code_ttl": 60, "authorize_scope": false, "authorized_scopes": [], "id": "b70e3293-d661-4eee-a709-d5161cb27d9d", "id_token_ttl": 86400, "name": "Updated from API", "pkce": false, "public_refresh_token": false, "public_revoke": false, "redirect_uris": [], "refresh_token_ttl": 2592000, "identity_provider": { "id": "f74e3d72-b41c-4fad-aa38-635c45831a1e" }, "secret": "GMx4LfhqIkLea1rzYGcr5cEmZ9ugif5EgKHQ3qgdvJ72Nyfyk9wC6YU7YICedaDEd8ZVNrOkoAV77dQPzBWLgm", "supported_grant_types": [ "client_credentials", "password", "authorization_code", "refresh_token", "implicit", "revoke", "introspect" ] } } } }, "required": true }, "responses": { "200": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Client" } } } } } }, "401": { "description": "The client is unauthorized to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Unauthorized" } } } }, "403": { "description": "The client is forbidden to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Forbidden" } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] }, "delete": { "tags": [ "Clients" ], "summary": "delete a client", "operationId": "deleteaclient", "parameters": [ { "name": "client_id", "in": "path", "description": "", "required": true, "style": "simple", "schema": { "type": "string" } } ], "responses": { "204": { "description": "", "headers": {} } }, "deprecated": false, "security": [ { "bearer": [] } ] } }, "/api/scopes": { "get": { "tags": [ "Scopes" ], "summary": "list scopes", "operationId": "listscopes", "parameters": [], "responses": { "200": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Scope" } } } } }, "401": { "description": "The client is unauthorized to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Unauthorized" } } } }, "403": { "description": "The client is forbidden to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Forbidden" } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] }, "post": { "tags": [ "Scopes" ], "summary": "create a scope", "operationId": "createascope", "parameters": [], "requestBody": { "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/createascoperequest" }, "example": { "scope": { "name": "from:api", "label": "Created from API" } } } }, "required": true }, "responses": { "201": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Scope" } } } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] } }, "/api/scopes/{scope_id}": { "get": { "tags": [ "Scopes" ], "summary": "show a scope", "operationId": "showascope", "parameters": [ { "name": "scope_id", "in": "path", "description": "", "required": true, "style": "simple", "schema": { "type": "string" } } ], "responses": { "200": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Scope" } } } } } }, "401": { "description": "The client is unauthorized to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Unauthorized" } } } }, "403": { "description": "The client is forbidden to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Forbidden" } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] }, "patch": { "tags": [ "Scopes" ], "summary": "update a scope", "operationId": "updateascope", "parameters": [ { "name": "scope_id", "in": "path", "description": "", "required": true, "style": "simple", "schema": { "type": "string" } } ], "requestBody": { "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/updateascoperequest" }, "example": { "scope": { "label": "Updated from API" } } } }, "required": true }, "responses": { "200": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Scope" } } } } } }, "401": { "description": "The client is unauthorized to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Unauthorized" } } } }, "403": { "description": "The client is forbidden to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Forbidden" } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] }, "delete": { "tags": [ "Scopes" ], "summary": "delete a scope", "operationId": "deleteascope", "parameters": [ { "name": "scope_id", "in": "path", "description": "", "required": true, "style": "simple", "schema": { "type": "string" } } ], "responses": { "204": { "description": "", "headers": {} } }, "deprecated": false, "security": [ { "bearer": [] } ] } }, "/api/upstreams": { "get": { "tags": [ "Upstreams" ], "summary": "list upstreams", "operationId": "listupstreams", "parameters": [], "responses": { "200": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Upstream" } } } } }, "401": { "description": "The client is unauthorized to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Unauthorized" } } } }, "403": { "description": "The client is forbidden to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Forbidden" } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] }, "post": { "tags": [ "Upstreams" ], "summary": "create an upstream", "operationId": "createanupstream", "parameters": [], "requestBody": { "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/createanupstreamrequest" }, "example": { "upstream": { "authorize": true, "host": "from.api", "pool_size": 10, "port": "80", "required_scopes": { "GET": [ "test" ] }, "scheme": "http", "strip_uri": true, "uris": [ "/from-api" ] } } } }, "required": true }, "responses": { "201": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Upstream" } } } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] } }, "/api/upstreams/{upstream_id}": { "get": { "tags": [ "Upstreams" ], "summary": "show an upstream", "operationId": "showanupstream", "parameters": [ { "name": "upstream_id", "in": "path", "description": "", "required": true, "style": "simple", "schema": { "type": "string" } } ], "responses": { "200": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Upstream" } } } } } }, "401": { "description": "The client is unauthorized to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Unauthorized" } } } }, "403": { "description": "The client is forbidden to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Forbidden" } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] }, "patch": { "tags": [ "Upstreams" ], "summary": "update an upstream", "operationId": "updateanupstream", "parameters": [ { "name": "upstream_id", "in": "path", "description": "", "required": true, "style": "simple", "schema": { "type": "string" } } ], "requestBody": { "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/updateanupstreamrequest" }, "example": { "upstream": { "authorize": true, "host": "from.api.updated", "pool_size": 10, "port": "80", "required_scopes": { "GET": [ "test" ] }, "scheme": "http", "strip_uri": true, "uris": [ "/from-api" ] } } } }, "required": true }, "responses": { "200": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Upstream" } } } } } }, "401": { "description": "The client is unauthorized to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Unauthorized" } } } }, "403": { "description": "The client is forbidden to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Forbidden" } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] }, "delete": { "tags": [ "Upstreams" ], "summary": "delete an upstream", "operationId": "deleteanupstream", "parameters": [ { "name": "upstream_id", "in": "path", "description": "", "required": true, "style": "simple", "schema": { "type": "string" } } ], "responses": { "204": { "description": "", "headers": {} } }, "deprecated": false, "security": [ { "bearer": [] } ] } }, "/api/identity-providers": { "get": { "tags": [ "identity providers" ], "summary": "list identity providers", "operationId": "listidentityProviders", "parameters": [], "responses": { "200": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/IdentityProvider" } } } } }, "401": { "description": "The client is unauthorized to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Unauthorized" } } } }, "403": { "description": "The client is forbidden to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Forbidden" } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] }, "post": { "tags": [ "identity providers" ], "summary": "create a identity provider", "operationId": "createaidentityProvider", "parameters": [], "requestBody": { "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/createaidentityProviderrequest" }, "example": { "identity_provider": { "choose_session": true, "confirmable": false, "consentable": false, "name": "Created fom API", "registrable": false, "type": "internal" } } } }, "required": true }, "responses": { "201": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/IdentityProvider" } } } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] } }, "/api/identity-providers/{identity_provider_id}": { "get": { "tags": [ "identity providers" ], "summary": "show a identity provider", "operationId": "showaidentityProvider", "parameters": [ { "name": "identity_provider_id", "in": "path", "description": "", "required": true, "style": "simple", "schema": { "type": "string" } } ], "responses": { "200": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/IdentityProvider" } } } } } }, "401": { "description": "The client is unauthorized to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Unauthorized" } } } }, "403": { "description": "The client is forbidden to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Forbidden" } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] }, "patch": { "tags": [ "identity providers" ], "summary": "update a identity provider", "operationId": "updateaidentityProvider", "parameters": [ { "name": "identity_provider_id", "in": "path", "description": "", "required": true, "style": "simple", "schema": { "type": "string" } } ], "requestBody": { "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/updateaidentityProviderrequest" }, "example": { "identity_provider": { "choose_session": true, "confirmable": false, "consentable": false, "name": "Updated fom API", "registrable": false, "type": "internal" } } } }, "required": true }, "responses": { "200": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/IdentityProvider" } } } } } }, "401": { "description": "The client is unauthorized to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Unauthorized" } } } }, "403": { "description": "The client is forbidden to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Forbidden" } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] }, "delete": { "tags": [ "identity providers" ], "summary": "delete a identity provider", "operationId": "deleteaidentityProvider", "parameters": [ { "name": "identity_provider_id", "in": "path", "description": "", "required": true, "style": "simple", "schema": { "type": "string" } } ], "responses": { "204": { "description": "", "headers": {} } }, "deprecated": false, "security": [ { "bearer": [] } ] } }, "/api/identity-providers/{identity_provider_id}/templates/{template_name}": { "get": { "tags": [ "identity providers" ], "summary": "show a identity provider template", "operationId": "showaidentityProvidertemplate", "parameters": [ { "name": "identity_provider_id", "in": "path", "description": "", "required": true, "style": "simple", "schema": { "type": "string" } }, { "name": "template_name", "in": "path", "description": "", "required": true, "style": "simple", "schema": { "type": "string" } } ], "responses": { "200": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Template" } } } } } }, "401": { "description": "The client is unauthorized to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Unauthorized" } } } }, "403": { "description": "The client is forbidden to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Forbidden" } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] } }, "/api/users": { "get": { "tags": [ "identity providers" ], "summary": "list users", "operationId": "listusers", "parameters": [], "responses": { "200": { "description": "", "headers": {}, "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/User" } } } } } } }, "401": { "description": "The client is unauthorized to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Unauthorized" } } } }, "403": { "description": "The client is forbidden to access this resource.", "headers": {}, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Forbidden" } } } } }, "deprecated": false, "security": [ { "bearer": [] } ] } } }, "components": { "schemas": { "createaclientrequest": { "title": "createaclientrequest", "required": [ "client" ], "type": "object", "properties": { "client": { "$ref": "#/components/schemas/Client" } }, "example": { "client": { "access_token_ttl": 86400, "authorization_code_ttl": 60, "authorize_scope": false, "authorized_scopes": [], "id": "b70e3293-d661-4eee-a709-d5161cb27d9d", "id_token_ttl": 86400, "name": "Created from API", "pkce": false, "public_refresh_token": false, "public_revoke": false, "redirect_uris": [], "refresh_token_ttl": 2592000, "identity_provider": { "id": "f74e3d72-b41c-4fad-aa38-635c45831a1e" }, "secret": "GMx4LfhqIkLea1rzYGcr5cEmZ9ugif5EgKHQ3qgdvJ72Nyfyk9wC6YU7YICedaDEd8ZVNrOkoAV77dQPzBWLgm", "supported_grant_types": [ "client_credentials", "password", "authorization_code", "refresh_token", "implicit", "revoke", "introspect" ] } } }, "Client": { "title": "Client", "type": "object", "properties": { "access_token_ttl": { "type": "integer", "format": "int32" }, "authorization_code_ttl": { "type": "integer", "format": "int32" }, "authorize_scope": { "type": "boolean" }, "authorized_scopes": { "type": "array", "items": { "type": "string" }, "description": "" }, "id": { "type": "string" }, "id_token_ttl": { "type": "integer", "format": "int32" }, "name": { "type": "string" }, "pkce": { "type": "boolean" }, "public_refresh_token": { "type": "boolean" }, "public_revoke": { "type": "boolean" }, "redirect_uris": { "type": "array", "items": { "type": "string" }, "description": "" }, "refresh_token_ttl": { "type": "integer", "format": "int32" }, "identity_provider": { "$ref": "#/components/schemas/IdentityProvider" }, "secret": { "type": "string" }, "supported_grant_types": { "type": "array", "items": { "type": "string" }, "description": "" } }, "example": { "access_token_ttl": 86400, "authorization_code_ttl": 60, "authorize_scope": false, "authorized_scopes": [], "id": "b70e3293-d661-4eee-a709-d5161cb27d9d", "id_token_ttl": 86400, "name": "Created from API", "pkce": false, "public_refresh_token": false, "public_revoke": false, "redirect_uris": [], "refresh_token_ttl": 2592000, "identity_provider": { "id": "f74e3d72-b41c-4fad-aa38-635c45831a1e" }, "secret": "GMx4LfhqIkLea1rzYGcr5cEmZ9ugif5EgKHQ3qgdvJ72Nyfyk9wC6YU7YICedaDEd8ZVNrOkoAV77dQPzBWLgm", "supported_grant_types": [ "client_credentials", "password", "authorization_code", "refresh_token", "implicit", "revoke", "introspect" ] } }, "updateaclientrequest": { "title": "updateaclientrequest", "required": [ "client" ], "type": "object", "properties": { "client": { "$ref": "#/components/schemas/Client" } }, "example": { "client": { "access_token_ttl": 86400, "authorization_code_ttl": 60, "authorize_scope": false, "authorized_scopes": [], "id": "b70e3293-d661-4eee-a709-d5161cb27d9d", "id_token_ttl": 86400, "name": "Updated from API", "pkce": false, "public_refresh_token": false, "public_revoke": false, "redirect_uris": [], "refresh_token_ttl": 2592000, "identity_provider": { "id": "f74e3d72-b41c-4fad-aa38-635c45831a1e" }, "secret": "GMx4LfhqIkLea1rzYGcr5cEmZ9ugif5EgKHQ3qgdvJ72Nyfyk9wC6YU7YICedaDEd8ZVNrOkoAV77dQPzBWLgm", "supported_grant_types": [ "client_credentials", "password", "authorization_code", "refresh_token", "implicit", "revoke", "introspect" ] } } }, "createascoperequest": { "title": "createascoperequest", "required": [ "scope" ], "type": "object", "properties": { "scope": { "$ref": "#/components/schemas/Scope" } }, "example": { "scope": { "name": "from:api", "label": "Created from API" } } }, "Scope": { "title": "Scope", "required": [ "name", "label" ], "type": "object", "properties": { "name": { "type": "string" }, "label": { "type": "string" } }, "example": { "name": "from:api", "label": "Created from API" } }, "updateascoperequest": { "title": "updateascoperequest", "required": [ "scope" ], "type": "object", "properties": { "scope": { "$ref": "#/components/schemas/Scope1" } }, "example": { "scope": { "label": "Updated from API" } } }, "Scope1": { "title": "Scope1", "required": [ "label" ], "type": "object", "properties": { "label": { "type": "string" } }, "example": { "label": "Updated from API" } }, "deleteascoperequest": { "title": "deleteascoperequest", "required": [ "scope" ], "type": "object", "properties": { "scope": { "$ref": "#/components/schemas/Scope1" } }, "example": { "scope": { "label": "Updated from API" } } }, "createanupstreamrequest": { "title": "createanupstreamrequest", "required": [ "upstream" ], "type": "object", "properties": { "upstream": { "$ref": "#/components/schemas/Upstream" } }, "example": { "upstream": { "authorize": true, "host": "from.api", "pool_size": 10, "port": "80", "required_scopes": { "GET": [ "test" ] }, "scheme": "http", "strip_uri": true, "uris": [ "/from-api" ] } } }, "Upstream": { "title": "Upstream", "required": [ "authorize", "host", "pool_size", "port", "required_scopes", "scheme", "strip_uri", "uris" ], "type": "object", "properties": { "authorize": { "type": "boolean" }, "host": { "type": "string" }, "pool_size": { "type": "integer", "format": "int32" }, "port": { "type": "string" }, "required_scopes": { "$ref": "#/components/schemas/RequiredScopes" }, "scheme": { "type": "string" }, "strip_uri": { "type": "boolean" }, "uris": { "type": "array", "items": { "type": "string" }, "description": "" } }, "example": { "authorize": true, "host": "from.api", "pool_size": 10, "port": "80", "required_scopes": { "GET": [ "test" ] }, "scheme": "http", "strip_uri": true, "uris": [ "/from-api" ] } }, "RequiredScopes": { "title": "RequiredScopes", "required": [ "GET" ], "type": "object", "properties": { "GET": { "type": "array", "items": { "type": "string" }, "description": "" } }, "example": { "GET": [ "test" ] } }, "updateanupstreamrequest": { "title": "updateanupstreamrequest", "required": [ "upstream" ], "type": "object", "properties": { "upstream": { "$ref": "#/components/schemas/Upstream" } }, "example": { "upstream": { "authorize": true, "host": "from.api.updated", "pool_size": 10, "port": "80", "required_scopes": { "GET": [ "test" ] }, "scheme": "http", "strip_uri": true, "uris": [ "/from-api" ] } } }, "deleteanupstreamrequest": { "title": "deleteanupstreamrequest", "required": [ "scope" ], "type": "object", "properties": { "scope": { "$ref": "#/components/schemas/Scope1" } }, "example": { "scope": { "label": "Updated from API" } } }, "createaidentityProviderrequest": { "title": "createaidentityProviderrequest", "required": [ "identity_provider" ], "type": "object", "properties": { "identity_provider": { "$ref": "#/components/schemas/IdentityProvider" } }, "example": { "identity_provider": { "choose_session": true, "confirmable": false, "consentable": false, "name": "Created fom API", "registrable": false, "type": "internal" } } }, "IdentityProvider": { "title": "IdentityProvider", "required": [ "choose_session", "confirmable", "consentable", "name", "registrable", "type" ], "type": "object", "properties": { "choose_session": { "type": "boolean" }, "confirmable": { "type": "boolean" }, "consentable": { "type": "boolean" }, "name": { "type": "string" }, "registrable": { "type": "boolean" }, "type": { "type": "string" } }, "example": { "choose_session": true, "confirmable": false, "consentable": false, "name": "Created fom API", "registrable": false, "type": "internal" } }, "Template": { "title": "Template", "type": "object", "properties": { "id": { "type": "string" }, "content": { "type": "string" }, "identity_provider_id": { "type": "string" }, "type": { "type": "string" } }, "example": { "content": "\n\n \n \n \n \n \n BorutaIdentity · Phoenix Framework\n \n \n \n
    \n
    \n
    \n
    \n {{#messages}}\n
    \n {{ content }}\n
    \n {{/messages}}\n {{> inner_content }}\n
    \n
    \n
    \n \n\n", "id": null, "identity_provider_id": "75da9775-2f6f-4418-aff5-b98e63830079", "type": "layout" } }, "User": { "title": "User", "type": "object", "properties": { "id": { "type": "string" }, "email": { "type": "string" }, "authorized_scopes": { "type": "array", "items": { "$ref": "#/components/schemas/Scope" } }, "type": { "type": "string" } }, "example": { "content": "\n\n \n \n \n \n \n BorutaIdentity · Phoenix Framework\n \n \n \n
    \n
    \n
    \n
    \n {{#messages}}\n
    \n {{ content }}\n
    \n {{/messages}}\n {{> inner_content }}\n
    \n
    \n
    \n \n\n", "id": null, "identity_provider_id": "75da9775-2f6f-4418-aff5-b98e63830079", "type": "layout" } }, "updateaidentityProviderrequest": { "title": "updateaidentityProviderrequest", "required": [ "identity_provider" ], "type": "object", "properties": { "identity_provider": { "$ref": "#/components/schemas/IdentityProvider" } }, "example": { "identity_provider": { "choose_session": true, "confirmable": false, "consentable": false, "name": "Updated fom API", "registrable": false, "type": "internal" } } }, "deleteaidentityProviderrequest": { "title": "deleteaidentityProviderrequest", "required": [ "scope" ], "type": "object", "properties": { "scope": { "$ref": "#/components/schemas/Scope1" } }, "example": { "scope": { "label": "Updated from API" } } }, "Unauthorized": { "title": "Unauthorized", "required": [ "code", "message" ], "type": "object", "properties": { "code": { "type": "string" }, "message": { "type": "string" } }, "example": { "code": "UNAUTHORIZED", "message": "The client is unauthorized to access this resource." } }, "Forbidden": { "title": "Forbidden", "required": [ "code", "message" ], "type": "object", "properties": { "code": { "type": "string" }, "message": { "type": "string" } }, "example": { "code": "FORBIDDEN", "message": "The client is forbidden to access this resource." } } }, "securitySchemes": { "bearer": { "type": "http", "scheme": "bearer" } } }, "security": [], "tags": [ { "name": "Clients" }, { "name": "Scopes" }, { "name": "Upstreams" }, { "name": "identity providers" }, { "name": "Misc", "description": "" } ] } ================================================ FILE: config/config.exs ================================================ import Config for config <- "../apps/*/config/config.exs" |> Path.expand(__DIR__) |> Path.wildcard() do import_config config end config :logger, utc_log: true, backends: [ {LoggerFileBackend, :boruta_web_business_logger}, {LoggerFileBackend, :boruta_web_request_logger}, {LoggerFileBackend, :boruta_identity_business_logger}, {LoggerFileBackend, :boruta_identity_request_logger}, {LoggerFileBackend, :boruta_admin_request_logger}, {LoggerFileBackend, :boruta_gateway_business_logger}, {LoggerFileBackend, :boruta_gateway_request_logger}, :console ] Enum.map([:request, :business], fn type -> Enum.map([:boruta_web, :boruta_identity, :boruta_admin, :boruta_gateway], fn application -> config :logger, :"#{application}_#{type}_logger", format: "$dateT$timeZ $metadata[$level] $message\n", path: "./log/test", metadata: [:request_id], metadata_filter: [application: application, type: type], level: :info end) end) config :logger, :console, format: "$dateT$timeZ $metadata[$level] $message\n", metadata: [:request_id], level: :info config :phoenix, :json_library, Jason config :mime, :types, %{ "text/event-stream" => ["event-stream"], "application/jwt" => ["jwt"] } import_config "#{Mix.env()}.exs" ================================================ FILE: config/dev.exs ================================================ import Config # Do not include metadata nor timestamps in development logs config :logger, :console, level: :debug # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime # Set a higher stacktrace during development. Avoid configuring such # in production as building large stacktraces may be expensive. config :phoenix, :stacktrace_depth, 20 ================================================ FILE: config/prod.exs ================================================ import Config config :logger, level: :info config :phoenix, :filter_parameters, ["password", "client_secret"] config :swoosh, local: false config :boruta_web, BorutaWeb.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_web_dev", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 10 config :boruta_identity, BorutaIdentity.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_identity_dev", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 10 config :boruta_gateway, BorutaGateway.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_gateway_dev", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 10 config :boruta_admin, BorutaAdmin.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_gateway_dev", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 10 config :boruta_gateway, server: true, sidecar_server: true config :boruta_web, BorutaWeb.Endpoint, server: true, cache_static_manifest: "priv/static/cache_manifest.json" config :boruta_admin, BorutaAdminWeb.Endpoint, server: true, cache_static_manifest: "priv/static/cache_manifest.json" config :boruta_identity, BorutaIdentity.Endpoint, server: false, cache_static_manifest: "priv/static/cache_manifest.json" ================================================ FILE: config/releases.exs ================================================ import Config config :boruta_auth, BorutaAuth.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_web", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: String.to_integer(System.get_env("POOL_SIZE", "5")) config :boruta_web, BorutaWeb.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_web", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 1 config :boruta_identity, BorutaIdentity.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_identity", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: String.to_integer(System.get_env("POOL_SIZE", "5")), after_connect: {BorutaIdentity.Repo, :set_limit, []} config :boruta_gateway, BorutaGateway.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_gateway", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 1 config :boruta_admin, BorutaAdmin.Repo, username: System.get_env("POSTGRES_USER") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres", database: System.get_env("POSTGRES_DATABASE") || "boruta_admin", hostname: System.get_env("POSTGRES_HOST") || "localhost", pool_size: 1 config :boruta_identity, Boruta.Accounts, secret_key_base: System.get_env("SECRET_KEY_BASE") config :boruta_identity, BorutaIdentity.SMTP, adapter: Swoosh.Adapters.SMTP config :boruta_gateway, port: System.get_env("BORUTA_GATEWAY_PORT") |> String.to_integer(), sidecar_port: System.get_env("BORUTA_GATEWAY_SIDECAR_PORT") |> String.to_integer(), configuration_path: System.get_env("BORUTA_GATEWAY_CONFIGURATION_PATH", "config/example-configuration.yml"), server: true config :boruta_web, BorutaWeb.Endpoint, http: [ port: System.get_env("BORUTA_OAUTH_PORT") |> String.to_integer(), ip: System.get_env("BORUTA_OAUTH_BIND", "::") |> String.to_charlist() |> :inet.parse_address() |> elem(1), transport_options: [ num_acceptors: String.to_integer(System.get_env("WEB_ACCEPTORS", "64")) ] ], url: [scheme: System.get_env("BORUTA_OAUTH_SCHEME", "https"), host: System.get_env("BORUTA_OAUTH_HOST")], secret_key_base: System.get_env("SECRET_KEY_BASE") config :boruta_identity, BorutaIdentityWeb.Endpoint, url: [scheme: System.get_env("BORUTA_OAUTH_SCHEME", "https"), host: System.get_env("BORUTA_OAUTH_HOST"), path: "/accounts", port: System.get_env("BORUTA_OAUTH_PORT")], secret_key_base: System.get_env("SECRET_KEY_BASE") config :boruta_admin, BorutaAdminWeb.Endpoint, http: [ port: System.get_env("BORUTA_ADMIN_PORT") |> String.to_integer(), ip: System.get_env("BORUTA_ADMIN_BIND", "::") |> String.to_charlist() |> :inet.parse_address() |> elem(1), protocol_options: [idle_timeout: 3_600_000, inactivity_timeout: 3_600_000] ], url: [scheme: "https", host: System.get_env("BORUTA_ADMIN_HOST")], secret_key_base: System.get_env("SECRET_KEY_BASE") config :boruta_admin, configuration_path: System.get_env("BORUTA_CONFIGURATION_PATH") config :boruta_web, BorutaAdminWeb.Authorization, oauth2: [ site: System.get_env("BORUTA_ADMIN_OAUTH_BASE_URL") ], sub_restricted: System.get_env("BORUTA_SUB_RESTRICTED", nil), organization_restricted: System.get_env("BORUTA_ORGANIZATION_RESTRICTED", nil) config :boruta, Boruta.Oauth, repo: BorutaAuth.Repo, contexts: [ resource_owners: BorutaIdentity.ResourceOwners ], max_ttl: [ authorization_code: 600 ], issuer: System.get_env("BORUTA_OAUTH_BASE_URL"), did_resolver_base_url: System.get_env("DID_RESOLVER_BASE_URL", "https://api.godiddy.com/1.0.0/universal-resolver"), did_registrar_base_url: System.get_env("DID_REGISTRAR_BASE_URL", "https://api.godiddy.com/1.0.0/universal-registrar"), universal_did_auth: %{ type: "bearer", token: System.get_env("DID_SERVICES_API_KEY") } config :boruta_auth, BorutaAuth.LogRotate, max_retention_days: String.to_integer(System.get_env("MAX_LOG_RETENTION_DAYS", "60")) if System.get_env("K8S_NAMESPACE") && System.get_env("K8S_SELECTOR") do config :libcluster, topologies: [ k8s: [ strategy: Cluster.Strategy.Kubernetes, config: [ mode: :ip, kubernetes_ip_lookup_mode: :pods, kubernetes_node_basename: "boruta", kubernetes_selector: System.get_env("K8S_SELECTOR"), kubernetes_namespace: System.get_env("K8S_NAMESPACE"), polling_interval: 10_000 ] ] ] else config :libcluster, topologies: [ example: [ strategy: Cluster.Strategy.Epmd, config: [hosts: []], connect: {:net_kernel, :connect_node, []}, disconnect: {:erlang, :disconnect_node, []}, list_nodes: {:erlang, :nodes, [:connected]} ] ] end ================================================ FILE: config/test.exs ================================================ import Config # Print only warnings and errors during test config :logger, level: :warn ================================================ FILE: docker-compose.yml ================================================ version: "3" volumes: boruta-logs: services: postgres: image: postgres:14 environment: POSTGRES_USER: "postgres" POSTGRES_PASSWORD: "postgres" POSTGRES_DATABASE: "boruta_release" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 5 boruta: stdin_open: true tty: true build: context: . dockerfile: Dockerfile.full args: BORUTA_OAUTH_BASE_URL: "http://localhost:8080" ports: - "8080:8080" - "8081:8081" - "8082:8082" - "8083:8083" volumes: - "boruta-logs:/app/log" env_file: "./.env.example" environment: MIX_ENV: "prod" POSTGRES_USER: "postgres" POSTGRES_PASSWORD: "postgres" POSTGRES_DATABASE: "boruta_release" POSTGRES_HOST: "postgres" depends_on: postgres: condition: service_healthy ================================================ FILE: examples/README.md ================================================ # example configurations ## Sidecar authorization gateways located at `examples/sidecar-authorization-gateways` 1. run database migrations ```bash docker-compose run boruta-server ./bin/boruta eval "BorutaWeb.Release.setup()" docker-compose run boruta-server ./bin/boruta eval "BorutaGateway.Release.setup()" ``` 2. load (micro)gateways configuration ``` docker-compose run boruta-server ./bin/boruta eval "BorutaGateway.Release.load_configuration()" docker-compose run httpbin-sidecar ./bin/boruta_gateway eval "BorutaGateway.Release.load_configuration()" docker-compose run protected-httpbin-sidecar ./bin/boruta_gateway eval "BorutaGateway.Release.load_configuration()" ``` Once done, you can run the docker images as follow: ```bash docker-compose up ``` The applications will be available on different ports (depending on the docker compose environment configuration): - http://localhost:8080 for the authorization server - http://localhost:8081 for the admin interface - http://localhost:8082 for the gateway The gateway will have two endpoints: - `http://localhost:8082/httpbin` which expose the httpbin service publicly - `http://localhost:8082/protected-httpbin` which expose the httpbin and restrict traffic to test scope granted users ================================================ FILE: examples/sidecar-authorization-gateways/config/example-gateway-configuration.yml ================================================ --- configuration: gateway: - authorize: false error_content_type: "text/plain" forbidden_response: "forbidden" unauthorized_response: "unauthorized" forwarded_token_secret: "this is a secret" forwarded_token_signature_alg: "HS512" pool_count: 1 pool_size: 10 max_idle_time: 10 scheme: "http" host: "httpbin-sidecar" port: 8083 uris: ["/httpbin"] strip_uri: true - authorize: false error_content_type: "text/plain" forbidden_response: "forbidden" unauthorized_response: "unauthorized" forwarded_token_secret: "this is a secret" forwarded_token_signature_alg: "HS512" pool_count: 1 pool_size: 10 max_idle_time: 10 scheme: "http" host: "protected-httpbin-sidecar" port: 8083 uris: ["/protected-httpbin"] strip_uri: true ================================================ FILE: examples/sidecar-authorization-gateways/config/example-httpbin-configuration.yml ================================================ --- configuration: node_name: "httpbin" microgateway: - authorize: false error_content_type: "text" forbidden_response: "forbidden" unauthorized_response: "unauthorized" forwarded_token_secret: "this is a secret" forwarded_token_signature_alg: "HS512" pool_count: 1 pool_size: 10 max_idle_time: 10 scheme: "http" host: "httpbin" port: 80 uris: ["/"] strip_uri: false ================================================ FILE: examples/sidecar-authorization-gateways/config/example-protected-httpbin-configuration.yml ================================================ --- configuration: node_name: "protected-httpbin" microgateway: - authorize: true error_content_type: "text/plain" forbidden_response: "forbidden" unauthorized_response: "unauthorized" forwarded_token_secret: "this is a secret" forwarded_token_signature_alg: "HS512" pool_count: 1 pool_size: 10 max_idle_time: 10 required_scopes: GET: ["test"] scheme: "http" host: "httpbin" port: 80 uris: ["/"] strip_uri: false ================================================ FILE: examples/sidecar-authorization-gateways/docker-compose.yml ================================================ version: "3" volumes: boruta-logs: services: postgres: image: postgres:14 environment: POSTGRES_USER: "postgres" POSTGRES_PASSWORD: "postgres" POSTGRES_DATABASE: "boruta_release" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 5 boruta-server: image: ghcr.io/malach-it/boruta-server:master ports: - "8080:8080" - "8081:8081" - "8082:8082" volumes: - "boruta-logs:/app/log" - "./config:/app/config" env_file: "../../.env.example" environment: MIX_ENV: "prod" POSTGRES_USER: "postgres" POSTGRES_PASSWORD: "postgres" POSTGRES_DATABASE: "boruta_release" POSTGRES_HOST: "postgres" BORUTA_GATEWAY_CONFIGURATION_PATH: "config/example-gateway-configuration.yml" depends_on: postgres: condition: service_healthy httpbin-sidecar: image: ghcr.io/malach-it/boruta-gateway:master volumes: - "boruta-logs:/app/log" - "./config:/app/config" env_file: "../../.env.example" environment: MIX_ENV: "prod" POSTGRES_USER: "postgres" POSTGRES_PASSWORD: "postgres" POSTGRES_DATABASE: "boruta_release" POSTGRES_HOST: "postgres" BORUTA_GATEWAY_CONFIGURATION_PATH: "config/example-httpbin-configuration.yml" depends_on: postgres: condition: service_healthy protected-httpbin-sidecar: image: ghcr.io/malach-it/boruta-gateway:master volumes: - "boruta-logs:/app/log" - "./config:/app/config" env_file: "../../.env.example" environment: MIX_ENV: "prod" POSTGRES_USER: "postgres" POSTGRES_PASSWORD: "postgres" POSTGRES_DATABASE: "boruta_release" POSTGRES_HOST: "postgres" BORUTA_GATEWAY_CONFIGURATION_PATH: "config/example-protected-httpbin-configuration.yml" depends_on: postgres: condition: service_healthy httpbin: image: kennethreitz/httpbin ================================================ FILE: mix.exs ================================================ defmodule Boruta.Umbrella.MixProject do use Mix.Project def project do [ version: "0.8.0", apps_path: "apps", start_permanent: Mix.env() == :prod, deps: deps(), dialyzer: [ plt_add_apps: [:mix] ], aliases: aliases(), releases: [ boruta: [ include_executables_for: [:unix], applications: [ boruta_web: :permanent, boruta_admin: :permanent, boruta_gateway: :permanent ] ], boruta_gateway: [ include_executables_for: [:unix], applications: [ boruta_gateway: :permanent ] ], boruta_auth: [ include_executables_for: [:unix], applications: [ boruta_web: :permanent ] ], boruta_admin: [ include_executables_for: [:unix], applications: [ boruta_admin: :permanent ] ] ] ] end # Dependencies can be Hex packages: # # {:mydep, "~> 0.3.0"} # # Or git/path repositories: # # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} # # Type "mix help deps" for more examples and options. # # Dependencies listed here are available only for this project # and cannot be accessed from applications inside the apps folder defp deps do [ {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, {:credo, "~> 1.4", only: [:dev, :test], runtime: false} ] end defp aliases do [ test: "cmd mix test" ] end end ================================================ FILE: rel/env.bat.eex ================================================ @echo off rem Set the release to work across nodes. If using the long name format like rem the one below (my_app@127.0.0.1), you need to also uncomment the rem RELEASE_DISTRIBUTION variable below. Must be "sname", "name" or "none". rem set RELEASE_DISTRIBUTION=name rem set RELEASE_NODE=<%= @release.name %>@127.0.0.1 ================================================ FILE: rel/env.sh.eex ================================================ #!/bin/sh # Sets and enables heart (recommended only in daemon mode) # case $RELEASE_COMMAND in # daemon*) # HEART_COMMAND="$RELEASE_ROOT/bin/$RELEASE_NAME $RELEASE_COMMAND" # export HEART_COMMAND # export ELIXIR_ERL_OPTIONS="-heart" # ;; # *) # ;; # esac # Set the release to work across nodes. If using the long name format like # the one below (my_app@127.0.0.1), you need to also uncomment the # RELEASE_DISTRIBUTION variable below. Must be "sname", "name" or "none". # NOTE node name in accordance with libcluster export RELEASE_DISTRIBUTION=name export RELEASE_NODE=<%= @release.name %>@$(hostname -i) ================================================ FILE: rel/vm.args.eex ================================================ ## Customize flags given to the VM: http://erlang.org/doc/man/erl.html ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here ## Number of dirty schedulers doing IO work (file, sockets, and others) ##+SDio 5 ## Increase number of concurrent ports/sockets ##+Q 65536 ## Tweak GC to run more often ##-env ERL_FULLSWEEP_AFTER 10 ================================================ FILE: scripts/prepare_assets.sh ================================================ set -e cd apps/boruta_admin/assets npm ci npm run build cd ../ mix phx.digest cd ../boruta_identity mix phx.digest cd ../boruta_web mix phx.digest cd ../.. ================================================ FILE: scripts/setup.debian.sh ================================================ #!/bin/sh ## # Dependencies # - systemd echo '# Boruta server setup' echo '## install dependencies' apt-get -q update apt-get -q install -y libssl-dev wget vim postgresql postgresql-client echo '## install boruta' cd /opt wget -O boruta.tar.gz https://github.com/malach-it/boruta-server/releases/download/0.4.0/boruta.tar.gz rm -rf ./boruta/ tar xf boruta.tar.gz wget -q -O /opt/boruta/.env.production https://raw.githubusercontent.com/malach-it/boruta-server/0.4.0/.env.example vim /opt/boruta/.env.production cat > /etc/systemd/system/boruta.service <<- EOF [Unit] Description=Boruta server After=network.target [Service] Type=simple User=root WorkingDirectory=/opt/boruta EnvironmentFile=/opt/boruta/.env.production ExecStartPre=-su -w POSTGRES_USER,POSTGRES_PASSWORD - postgres -c "psql -c \"CREATE USER \${POSTGRES_USER} WITH CREATEDB PASSWORD '\${POSTGRES_PASSWORD}'\"" ExecStart=/opt/boruta/bin/boruta start Restart=on-failure [Install] WantedBy=multi-user.target EOF chmod +x /etc/systemd/system/boruta.service echo '## Enable boruta service' systemctl daemon-reload systemctl enable boruta systemctl restart boruta exit 0 ================================================ FILE: static_config/example-gateway-configuration.yml ================================================ --- configuration: gateway: - authorize: false error_content_type: "text/plain" forbidden_response: "forbidden" unauthorized_response: "unauthorized" forwarded_token_secret: "this is a secret" forwarded_token_signature_alg: "HS512" pool_count: 1 pool_size: 10 max_idle_time: 10 scheme: "https" host: "httpbin.boruta.patatoid.fr" port: 443 uris: ["/httpbin"] strip_uri: true - authorize: false error_content_type: "text/plain" forbidden_response: "forbidden" unauthorized_response: "unauthorized" forwarded_token_secret: "this is a secret" forwarded_token_signature_alg: "HS512" pool_count: 1 pool_size: 10 max_idle_time: 10 scheme: "https" host: "protected-httpbin.boruta.patatoid.fr" port: 443 uris: ["/protected-httpbin"] strip_uri: true ================================================ FILE: static_config/example-httpbin-configuration.yml ================================================ --- configuration: node_name: "httpbin" microgateway: - authorize: false error_content_type: "text/plain" forbidden_response: "forbidden" unauthorized_response: "unauthorized" forwarded_token_secret: "this is a secret" forwarded_token_signature_alg: "HS512" pool_count: 1 pool_size: 10 max_idle_time: 10 scheme: "http" host: "httpbin.patatoid.fr" port: 80 uris: ["/"] strip_uri: false ================================================ FILE: static_config/example-protected-httpbin-configuration.yml ================================================ --- configuration: node_name: "protected-httpbin" microgateway: - authorize: true error_content_type: "text/plain" forbidden_response: "forbidden" unauthorized_response: "unauthorized" forwarded_token_secret: "this is a secret" forwarded_token_signature_alg: "HS512" pool_count: 1 pool_size: 10 max_idle_time: 10 required_scopes: GET: ["test"] scheme: "http" host: "httpbin.patatoid.fr" port: 80 uris: ["/"] strip_uri: false ================================================ FILE: vetur.config.js ================================================ module.exports = { projects: [ './app/boruta_admin/assets' ] }